From 19f8fbaf03d6e127e78145c8e6c5e4d2c0633fbb Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 1 Sep 2026 15:47:09 +0200 Subject: [PATCH] Convergence: ink converges onto the decoded key (WebGL glow) On ack (and on picking a place) a glowing copy of the stroke peels off and converges point-by-point onto the key's decoded path - a staggered wave from stroke start to end, comet trails, additive on dark / ink on light, ghost deposited where the light settles. WebGL point sprites on an overlay canvas; no WebGL or reduced-motion means exactly the previous behavior. ?relay= query override points widgets at a local relay for dev. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ --- gesture.js | 234 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 232 insertions(+), 2 deletions(-) diff --git a/gesture.js b/gesture.js index f25d141..d3ad2a4 100644 --- a/gesture.js +++ b/gesture.js @@ -43,6 +43,77 @@ const MAX_ECHOES = 8; const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30000; +// ── Convergence animation (ink → what the network heard) ────────── +// +// When the ack lands, a glowing copy of the stroke peels off and +// converges point-by-point onto the key's decoded path: a wave of +// "being understood" travels from the stroke's start to its end, +// and the ghost materializes where the light settles. WebGL point +// sprites with additive glow on a dedicated overlay canvas; the 2d +// layer underneath never changes its final render, so losing WebGL +// (or prefers-reduced-motion) degrades to exactly today's behavior. +const CONVERGE_MS = 1500; +const CONVERGE_FADE_MS = 450; +const CONVERGE_POINTS = 128; +const CONVERGE_STAGGER = 0.6; // fraction of the timeline spent on the travelling wave + +const GLOW_VERT = ` +attribute vec2 aFrom; +attribute vec2 aTo; +attribute float aIndex; +uniform float uT; +uniform vec2 uResolution; +uniform float uDpr; +varying float vLife; +void main() { + float lead = aIndex * ${CONVERGE_STAGGER}; + float p = clamp((uT * ${1.0 + CONVERGE_STAGGER} - lead), 0.0, 1.0); + float e = p < 0.5 ? 4.0 * p * p * p : 1.0 - pow(-2.0 * p + 2.0, 3.0) / 2.0; + vec2 pos = mix(aFrom, aTo, e); + vec2 clip = (pos / uResolution) * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + float pulse = 1.0 + 1.4 * sin(3.14159 * p); + gl_PointSize = 9.0 * uDpr * pulse; + vLife = p; +}`; + +const GLOW_FRAG = ` +precision mediump float; +uniform vec3 uColor; +uniform float uAlpha; +varying float vLife; +void main() { + vec2 d = gl_PointCoord - 0.5; + float fall = exp(-dot(d, d) * 18.0); + float breathe = 0.55 + 0.45 * sin(3.14159 * vLife); + gl_FragColor = vec4(uColor, 1.0) * (fall * uAlpha * breathe); +}`; + +// Uniform arc-length resample of a pixel-space polyline to n points. +function resamplePx(points, n) { + if (points.length === 1) return Array(n).fill(points[0]); + const dists = [0]; + for (let i = 1; i < points.length; i++) { + const dx = points[i][0] - points[i - 1][0]; + const dy = points[i][1] - points[i - 1][1]; + dists.push(dists[i - 1] + Math.hypot(dx, dy)); + } + const total = dists[dists.length - 1] || 1; + const out = []; + let seg = 0; + for (let i = 0; i < n; i++) { + const target = (i / (n - 1)) * total; + while (seg < points.length - 2 && dists[seg + 1] < target) seg++; + const span = dists[seg + 1] - dists[seg] || 1; + const t = (target - dists[seg]) / span; + out.push([ + points[seg][0] + (points[seg + 1][0] - points[seg][0]) * t, + points[seg][1] + (points[seg + 1][1] - points[seg][1]) * t, + ]); + } + return out; +} + class GestureWidget { constructor(container, hidden, relayUrl) { this.container = container; @@ -63,6 +134,17 @@ class GestureWidget { this.canvas = document.createElement('canvas'); this.canvas.className = 'gesture-canvas'; container.appendChild(this.canvas); + // Overlay for the convergence glow; sized with the main canvas, + // inert to pointers, empty until an ack arrives. + this.glow = document.createElement('canvas'); + this.glow.className = 'gesture-glow'; + this.glow.style.position = 'absolute'; + this.glow.style.pointerEvents = 'none'; + if (!container.style.position) container.style.position = 'relative'; + container.appendChild(this.glow); + this.gl = null; + this.animFrame = null; + this.converging = false; this.echoes = document.createElement('div'); this.echoes.className = 'gesture-echoes'; this.echoes.setAttribute('role', 'listbox'); @@ -110,6 +192,13 @@ class GestureWidget { this.ctx.scale(dpr, dpr); this.cssWidth = rect.width; this.cssHeight = rect.height; + this.glow.style.left = `${this.canvas.offsetLeft}px`; + this.glow.style.top = `${this.canvas.offsetTop}px`; + this.glow.style.width = `${rect.width}px`; + this.glow.style.height = `${rect.height}px`; + this.glow.width = this.canvas.width; + this.glow.height = this.canvas.height; + this.cancelConvergence(); // buffers are in old pixels } pos(e) { @@ -123,6 +212,7 @@ class GestureWidget { // One stroke only - a new pointerdown replaces the old drawing // (and any ghost from the previous round). this.drawing = true; + this.cancelConvergence(); this.ghost = null; this.ownKey = null; this.selected = null; @@ -254,6 +344,7 @@ class GestureWidget { applySelection() { if (this.points.length < 2) return; const points = this.normalized(); + const before = this.ghost; if (this.selected) { this.ghost = this.selected.path; this.setValue({ points, key: this.selected.key, own_key: this.ownKey, selected_from: this.ownKey, selected_distance: this.selected.distance }); @@ -261,6 +352,7 @@ class GestureWidget { this.ghost = this.ownGhost || null; this.setValue({ points, key: this.ownKey }); } + if (this.ghost && this.ghost !== before) this.startConvergence(this.ghost); for (const el of this.echoes.children) { el.classList.toggle('selected', !!this.selected && el.dataset.key === this.selected.key); el.setAttribute('aria-selected', String(!!this.selected && el.dataset.key === this.selected.key)); @@ -317,6 +409,140 @@ class GestureWidget { setTimeout(() => thumb.classList.add('shown'), 30); } + // ── Convergence ──────────────────────────────────────────────── + + initGlow() { + if (this.gl) return this.gl; + const gl = this.glow.getContext('webgl', { alpha: true, premultipliedAlpha: true }); + if (!gl) return null; + const compile = (type, src) => { + const s = gl.createShader(type); + gl.shaderSource(s, src); + gl.compileShader(s); + return s; + }; + const prog = gl.createProgram(); + gl.attachShader(prog, compile(gl.VERTEX_SHADER, GLOW_VERT)); + gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, GLOW_FRAG)); + gl.linkProgram(prog); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null; + gl.useProgram(prog); + this.gl = gl; + this.glProg = prog; + this.glLoc = { + aFrom: gl.getAttribLocation(prog, 'aFrom'), + aTo: gl.getAttribLocation(prog, 'aTo'), + aIndex: gl.getAttribLocation(prog, 'aIndex'), + uT: gl.getUniformLocation(prog, 'uT'), + uResolution: gl.getUniformLocation(prog, 'uResolution'), + uDpr: gl.getUniformLocation(prog, 'uDpr'), + uColor: gl.getUniformLocation(prog, 'uColor'), + uAlpha: gl.getUniformLocation(prog, 'uAlpha'), + }; + this.glBufFrom = gl.createBuffer(); + this.glBufTo = gl.createBuffer(); + this.glBufIndex = gl.createBuffer(); + return gl; + } + + // The ghost's pixel frame - same fit drawPath uses. + ghostToPx(path) { + const scale = (Math.min(this.cssWidth, this.cssHeight) / 2) * 0.8; + const cx = this.cssWidth / 2, cy = this.cssHeight / 2; + return path.map(([x, y]) => [cx + x * scale, cy + y * scale]); + } + + startConvergence(targetPath) { + this.cancelConvergence(); + if (!targetPath || this.points.length < 2) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + const gl = this.initGlow(); + if (!gl) return; + + const dpr = window.devicePixelRatio || 1; + const from = resamplePx(this.points.map((p) => [p.x, p.y]), CONVERGE_POINTS); + const to = resamplePx(this.ghostToPx(targetPath), CONVERGE_POINTS); + const f = new Float32Array(CONVERGE_POINTS * 2); + const t = new Float32Array(CONVERGE_POINTS * 2); + const idx = new Float32Array(CONVERGE_POINTS); + for (let i = 0; i < CONVERGE_POINTS; i++) { + f[i * 2] = from[i][0] * dpr; + f[i * 2 + 1] = from[i][1] * dpr; + t[i * 2] = to[i][0] * dpr; + t[i * 2 + 1] = to[i][1] * dpr; + idx[i] = i / (CONVERGE_POINTS - 1); + } + const bind = (buf, data, loc, size) => { + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0); + }; + gl.useProgram(this.glProg); + bind(this.glBufFrom, f, this.glLoc.aFrom, 2); + bind(this.glBufTo, t, this.glLoc.aTo, 2); + bind(this.glBufIndex, idx, this.glLoc.aIndex, 1); + gl.viewport(0, 0, this.glow.width, this.glow.height); + gl.uniform2f(this.glLoc.uResolution, this.glow.width, this.glow.height); + gl.uniform1f(this.glLoc.uDpr, dpr); + + const light = this._themeQuery.matches; + const color = light ? [0x47 / 255, 0x80 / 255, 0x7e / 255] : [0x8e / 255, 0xc2 / 255, 0xc0 / 255]; + gl.uniform3f(this.glLoc.uColor, color[0], color[1], color[2]); + // Additive light on the dark ground; normal ink on the light one. + gl.enable(gl.BLEND); + if (light) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + else gl.blendFunc(gl.ONE, gl.ONE); + + this.converging = true; + this.render(); + const started = performance.now(); + const step = (now) => { + const raw = (now - started) / CONVERGE_MS; + if (raw >= 1) { + // Deposit the ghost, then let the glow breathe out. + if (this.converging) { + this.converging = false; + this.render(); + } + const fade = (now - started - CONVERGE_MS) / CONVERGE_FADE_MS; + if (fade >= 1) { + this.cancelConvergence(); + return; + } + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.uniform1f(this.glLoc.uT, 1); + gl.uniform1f(this.glLoc.uAlpha, 0.32 * (1 - fade)); + gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS); + } else { + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + // Trailing passes give the wave a comet tail. + for (const [lag, alpha] of [[0, 0.32], [0.035, 0.16], [0.07, 0.08], [0.105, 0.04]]) { + gl.uniform1f(this.glLoc.uT, Math.max(0, raw - lag)); + gl.uniform1f(this.glLoc.uAlpha, alpha); + gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS); + } + } + this.animFrame = requestAnimationFrame(step); + }; + this.animFrame = requestAnimationFrame(step); + } + + cancelConvergence() { + if (this.animFrame) cancelAnimationFrame(this.animFrame); + this.animFrame = null; + if (this.converging) { + this.converging = false; + this.render(); + } + if (this.gl) { + this.gl.clearColor(0, 0, 0, 0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + } + } + // ── Rendering ────────────────────────────────────────────────── // Draw a normalized (-1..1) path fitted into w×h with padding. @@ -341,7 +567,7 @@ class GestureWidget { if (!this.ctx) return; const t = this.theme(); this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight); - if (this.ghost) { + if (this.ghost && !this.converging) { this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2); } if (this.points.length >= 1) { @@ -360,6 +586,7 @@ class GestureWidget { stop() { this.stopped = true; + if (this.animFrame) cancelAnimationFrame(this.animFrame); clearTimeout(this.emptyTimer); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.ws) { @@ -378,5 +605,8 @@ class GestureWidget { } export function mountGesture(container, hidden, relayUrl) { - return new GestureWidget(container, hidden, relayUrl); + // Dev convenience: `?relay=ws://127.0.0.1:9040` points every widget + // on the page at a local relay (prod relays reject foreign Origins). + const override = new URLSearchParams(window.location.search).get('relay'); + return new GestureWidget(container, hidden, override || relayUrl); }