Files
portal/gesture.js
T
Bendik Aagaard LynghaugandClaude Fable 5 19f8fbaf03 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:47:09 +02:00

613 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The `type: gesture` requirement widget: draw one stroke on a canvas,
// and the stroke becomes the field's value. Mirrors the two house
// patterns at once:
//
// - prosekit-editor.js's hidden-input bridge: the widget writes JSON
// into a paired hidden <input> and fires a real bubbling `input`
// event, so the Rust side reuses the exact same RwSignal/on:input
// wiring every other field kind uses - no bespoke value channel.
// - yes.js's lifecycle: mountGesture returns a handle whose stop()
// tears everything down (socket, listeners, rAF), because SPA
// navigation never unloads the document.
//
// When a relay URL is given, the widget also speaks the redoal-relay
// protocol (ADR-0013 in the redoal repo): announce the stroke, receive
// an ack carrying the stroke's gesture key plus the key's own decoded
// path - "what the network heard", drawn as a ghost under the stroke
// when the key is v2 or later (ADR-0014's ordered turning chain; a v1
// key's histogram decode is a blob and stays hidden) - and receive
// echoes of similar strokes other visitors drew, shown as thumbnails.
// Everything network is best-effort: no relay, refused connection, or
// a dropped socket all degrade to a plain offline drawing input.
//
// Canvas colors can't come from CSS custom properties (same
// constraint yes.js documents), so the widget carries its own copy of
// the palette for both themes - any palette change must land here AND
// in style/main.css.
const THEMES = {
dark: {
stroke: '#8ec2c0',
echo: '#8ec2c0',
ghost: 'rgba(255, 255, 255, 0.28)',
},
light: {
stroke: '#47807e',
echo: '#47807e',
ghost: 'rgba(29, 29, 27, 0.30)',
},
};
const MIN_POINT_DISTANCE = 3; // css px between recorded points
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;
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.ghost = null;
this.ownKey = null;
this.selected = null; // {key, path, distance} - a place the visitor picked
this.seenKeys = new Set();
this.points = [];
this.drawing = false;
this.stopped = false;
this.ws = null;
this.reconnectDelay = RECONNECT_BASE_MS;
this.reconnectTimer = null;
this.pendingAnnounce = null;
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');
this.echoes.setAttribute('aria-label', 'places near your shape');
container.appendChild(this.echoes);
this.empty = document.createElement('p');
this.empty.className = 'gesture-empty';
container.appendChild(this.empty);
if (this.relayUrl) {
this.status = document.createElement('span');
this.status.className = 'gesture-status offline';
this.status.setAttribute('aria-label', 'relay connection');
container.appendChild(this.status);
}
this._themeQuery = window.matchMedia('(prefers-color-scheme: light)');
this._onTheme = () => this.render();
this._themeQuery.addEventListener('change', this._onTheme);
this._onResize = () => { this.sizeCanvas(); this.render(); };
window.addEventListener('resize', this._onResize);
this.sizeCanvas();
this._onDown = (e) => this.beginStroke(e);
this._onMove = (e) => this.extendStroke(e);
this._onUp = (e) => this.endStroke(e);
this.canvas.addEventListener('pointerdown', this._onDown);
this.canvas.addEventListener('pointermove', this._onMove);
this.canvas.addEventListener('pointerup', this._onUp);
this.canvas.addEventListener('pointercancel', this._onUp);
if (this.relayUrl) this.connect();
}
theme() {
return this._themeQuery.matches ? THEMES.light : THEMES.dark;
}
sizeCanvas() {
const rect = this.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
this.ctx = this.canvas.getContext('2d');
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) {
const rect = this.canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
beginStroke(e) {
e.preventDefault();
this.canvas.setPointerCapture(e.pointerId);
// 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;
this.seenKeys.clear();
this.echoes.replaceChildren();
this.empty.textContent = '';
this.points = [this.pos(e)];
this.render();
}
extendStroke(e) {
if (!this.drawing) return;
const p = this.pos(e);
const last = this.points[this.points.length - 1];
const dx = p.x - last.x, dy = p.y - last.y;
if (Math.sqrt(dx * dx + dy * dy) < MIN_POINT_DISTANCE) return;
this.points.push(p);
this.render();
}
endStroke() {
if (!this.drawing) return;
this.drawing = false;
if (this.points.length < 2) return;
const normalized = this.normalized();
this.setValue({ points: normalized, key: null });
this.announce(normalized);
}
// Canvas-frame normalization into the -1..1 square (the relay
// re-normalizes around the stroke itself before keying - this
// just bounds the payload and keeps the stored value
// resolution-independent).
normalized() {
const half = Math.max(this.cssWidth, this.cssHeight) / 2;
const cx = this.cssWidth / 2, cy = this.cssHeight / 2;
return this.points.map((p) => [
Math.round(((p.x - cx) / half) * 10000) / 10000,
Math.round(((p.y - cy) / half) * 10000) / 10000,
]);
}
setValue(value) {
if (!this.hidden) return;
this.hidden.value = JSON.stringify(value);
this.hidden.dispatchEvent(new Event('input', { bubbles: true }));
}
// ── Relay ──────────────────────────────────────────────────────
connect() {
if (this.stopped) return;
let ws;
try {
ws = new WebSocket(this.relayUrl);
} catch {
this.scheduleReconnect();
return;
}
this.ws = ws;
ws.addEventListener('open', () => {
if (this.stopped) return;
this.reconnectDelay = RECONNECT_BASE_MS;
this.status?.classList.replace('offline', 'live');
if (this.pendingAnnounce) {
ws.send(JSON.stringify({ type: 'announce', points: this.pendingAnnounce }));
this.pendingAnnounce = null;
}
});
ws.addEventListener('message', (e) => this.onMessage(e));
ws.addEventListener('close', () => {
this.status?.classList.replace('live', 'offline');
this.ws = null;
this.scheduleReconnect();
});
// 'error' is always followed by 'close'; nothing to do here.
}
scheduleReconnect() {
if (this.stopped || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
}
announce(points) {
if (!this.relayUrl) return;
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'announce', points }));
} else {
this.pendingAnnounce = points;
}
}
onMessage(e) {
let msg;
try {
msg = JSON.parse(e.data);
} catch {
return;
}
if (msg.type === 'ack') {
this.ownKey = msg.key;
// Version nibble is the key's first hex digit.
const version = parseInt((msg.key || '0')[0], 16);
this.ownGhost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
this.applySelection();
// Places arrive right after the ack; say so if none do.
clearTimeout(this.emptyTimer);
this.emptyTimer = setTimeout(() => {
if (!this.echoes.children.length) this.empty.textContent = msg.empty || 'Nothing kept at this shape yet.';
}, 1500);
} else if (msg.type === 'echo' || msg.type === 'place') {
if (!msg.key || this.seenKeys.has(msg.key)) return;
this.seenKeys.add(msg.key);
this.empty.textContent = '';
this.addEchoThumbnail(msg);
}
// 'error' frames are intentionally silent: the widget is a
// form field first, and a rate-limited announce shouldn't
// alarm anyone mid-form.
}
// The key the input carries: the visitor's own, or the place they
// picked (selection re-derives the key - the ghost under the
// stroke becomes that place's decode, and the answer records both).
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 });
} else {
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));
}
this.render();
}
select(thumb) {
const key = thumb.dataset.key;
if (this.selected && this.selected.key === key) {
this.selected = null;
} else {
this.selected = { key, path: JSON.parse(thumb.dataset.path), distance: Number(thumb.dataset.distance) };
// The relay counts picks per place for ranking.
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ownKey) {
this.ws.send(JSON.stringify({ type: 'select', key, from: this.ownKey, distance: this.selected.distance }));
}
}
this.applySelection();
}
addEchoThumbnail(msg) {
const thumb = document.createElement('canvas');
thumb.className = 'gesture-echo' + (msg.type === 'place' ? ' place' : '');
thumb.dataset.key = msg.key;
thumb.dataset.path = JSON.stringify(msg.path || []);
thumb.dataset.distance = String(msg.distance || 0);
thumb.setAttribute('role', 'option');
thumb.setAttribute('tabindex', '0');
thumb.setAttribute('aria-selected', 'false');
thumb.title = msg.type === 'place'
? `A place with ${msg.recordings || 0} recording${msg.recordings === 1 ? '' : 's'} - tap to make it your address`
: 'Someone at this shape right now';
thumb.addEventListener('click', () => this.select(thumb));
thumb.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.select(thumb); } });
// Closer strokes render stronger; 500 is comfortably past the
// relay's default threshold, so everything stays visible.
const strength = Math.max(0.35, Math.min(1, 1 - msg.distance / 500));
thumb.style.setProperty('--echo-strength', String(strength));
this.echoes.prepend(thumb);
while (this.echoes.children.length > MAX_ECHOES) {
this.echoes.lastChild.remove();
}
const dpr = window.devicePixelRatio || 1;
const rect = thumb.getBoundingClientRect();
thumb.width = Math.max(1, Math.round(rect.width * dpr));
thumb.height = Math.max(1, Math.round(rect.height * dpr));
const ctx = thumb.getContext('2d');
ctx.scale(dpr, dpr);
this.drawPath(ctx, msg.path, rect.width, rect.height, this.theme().echo, 1.5, 0.15);
// A timeout, not requestAnimationFrame: the class must land on
// a later tick for the opacity transition to run, and rAF
// doesn't tick at all in headless engines.
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.
drawPath(ctx, path, w, h, color, width, pad) {
if (!path || path.length < 2) return;
const scale = (Math.min(w, h) / 2) * (1 - pad);
const cx = w / 2, cy = h / 2;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
path.forEach(([x, y], i) => {
const px = cx + x * scale, py = cy + y * scale;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}
render() {
if (!this.ctx) return;
const t = this.theme();
this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight);
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) {
this.ctx.strokeStyle = t.stroke;
this.ctx.lineWidth = 2;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.beginPath();
this.points.forEach((p, i) => {
if (i === 0) this.ctx.moveTo(p.x, p.y);
else this.ctx.lineTo(p.x, p.y);
});
this.ctx.stroke();
}
}
stop() {
this.stopped = true;
if (this.animFrame) cancelAnimationFrame(this.animFrame);
clearTimeout(this.emptyTimer);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) {
// The close event still fires, but scheduleReconnect
// no-ops once stopped is set.
try { this.ws.close(); } catch { /* already dead */ }
this.ws = null;
}
window.removeEventListener('resize', this._onResize);
this._themeQuery.removeEventListener('change', this._onTheme);
this.canvas.removeEventListener('pointerdown', this._onDown);
this.canvas.removeEventListener('pointermove', this._onMove);
this.canvas.removeEventListener('pointerup', this._onUp);
this.canvas.removeEventListener('pointercancel', this._onUp);
}
}
export function mountGesture(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);
}