Replace hand-rolled spatial canvas drag with Konva

The custom pointer-capture + frozen-camera drag math was fragile and
felt worse than before. Konva handles the drag gesture itself; the
"me" node's screen position stays pinned to the stage center via
dragBoundFunc + a worldLayer offset that recenters on every dragmove,
so the world pans under the avatar instead of the avatar drifting off
center. Grid, peers, and the audible-range circle all live in the same
world-space layer so they pan together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5 committed 2026-09-22 11:14:32 +02:00
1 parent 5ecf415f64
commit 099d637815
4 files changed
+152 -109

No files matched your search

+33
View File
@@ -16,6 +16,7 @@
"@opentelemetry/sdk-logs": "^0.222.0", "@opentelemetry/sdk-logs": "^0.222.0",
"@opentelemetry/sdk-node": "^0.222.0", "@opentelemetry/sdk-node": "^0.222.0",
"@opentelemetry/semantic-conventions": "^1.43.0", "@opentelemetry/semantic-conventions": "^1.43.0",
"konva": "^10.6.0",
"matrix-js-sdk": "^42.4.0", "matrix-js-sdk": "^42.4.0",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
@@ -3940,6 +3941,38 @@
"bignumber.js": "^9.0.0" "bignumber.js": "^9.0.0"
} }
}, },
"node_modules/konva": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/konva/-/konva-10.6.0.tgz",
"integrity": "sha512-6dyI91lZTx2mDu9DRo5zrT2Pg+Q3TFeyLfGQSAPJMhfvKjelbjPnTJimYQISam/EW+g0+pjLk/yztJKpSuxFPQ==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"peerDependencies": {
"canvas": "^3.0.0",
"skia-canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
},
"skia-canvas": {
"optional": true
}
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@opentelemetry/sdk-logs": "^0.222.0", "@opentelemetry/sdk-logs": "^0.222.0",
"@opentelemetry/sdk-node": "^0.222.0", "@opentelemetry/sdk-node": "^0.222.0",
"@opentelemetry/semantic-conventions": "^1.43.0", "@opentelemetry/semantic-conventions": "^1.43.0",
"konva": "^10.6.0",
"matrix-js-sdk": "^42.4.0", "matrix-js-sdk": "^42.4.0",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
+117 -108
View File
@@ -1,4 +1,5 @@
import * as matrixcs from 'matrix-js-sdk'; import * as matrixcs from 'matrix-js-sdk';
import Konva from 'konva';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// state // state
@@ -904,124 +905,132 @@ function closePopout(key) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// spatial canvas // spatial canvas (Konva) — the "me" node is draggable and stays visually
// pinned to the stage center: every world-unit it moves, the layer is
// re-centered by the same amount in the opposite direction, so the world
// pans underneath it instead of the dot sliding away from center.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ctx2d = els.spatial.getContext('2d'); const pad = 30;
let dragging = null; let sx = 1, sy = 1; // px per world unit, recomputed on resize
// Camera is always centered on the listener (me), matching how the audio const spatialStage = new Konva.Stage({ container: 'spatial', width: 1, height: 1 });
// panning already works (peer positions are relative to S.me). While const worldLayer = new Konva.Layer();
// dragging, the camera freezes at its pre-drag position so the world spatialStage.add(worldLayer);
// doesn't slide under the pointer as S.me moves.
function spatialMetrics(camX = S.me.x, camY = S.me.y) { // grid lives inside worldLayer so it pans with the camera along with everyone else
const r = els.spatial.getBoundingClientRect(); const gridGroup = new Konva.Group({ listening: false });
const dpr = window.devicePixelRatio || 1; worldLayer.add(gridGroup);
if (els.spatial.width !== r.width * dpr) {
els.spatial.width = r.width * dpr; const rangeCircle = new Konva.Circle({
els.spatial.height = r.height * dpr; radius: 0, stroke: 'rgba(88,101,242,.35)', strokeWidth: 1, dash: [5, 5], listening: false,
});
worldLayer.add(rangeCircle);
const avatarNodes = new Map(); // id -> {group, circle, initial, nameText}
function avatarNode(id, isMe) {
let node = avatarNodes.get(id);
if (node) return node;
const r = isMe ? 16 : 13;
const group = new Konva.Group({ draggable: isMe });
const circle = new Konva.Circle({ radius: r, fill: '#5865f2' });
const initial = new Konva.Text({
text: '', fontStyle: 'bold', fontSize: Math.round(r), fill: '#fff',
width: r * 2, height: r * 2, offsetX: r, offsetY: r, align: 'center', verticalAlign: 'middle',
});
const nameText = new Konva.Text({
text: '', fontSize: 11, fill: isMe ? '#b5bac1' : '#80848e', y: r + 4, align: 'center',
});
group.add(circle, initial, nameText);
worldLayer.add(group);
if (isMe) {
group.dragBoundFunc(function (pos) {
// pos is in worldLayer's parent (stage) coordinates; clamp the
// resulting world position to the room bounds, not the screen.
const worldX = Math.min(ROOM_UNITS, Math.max(0, (pos.x - worldLayer.x()) / sx));
const worldY = Math.min(ROOM_UNITS, Math.max(0, (pos.y - worldLayer.y()) / sy));
return { x: worldLayer.x() + worldX * sx, y: worldLayer.y() + worldY * sy };
});
group.on('dragmove', () => {
S.me.x = (group.x() - worldLayer.x()) / sx;
S.me.y = (group.y() - worldLayer.y()) / sy;
recenterCamera();
sendMyPos();
});
group.on('dragstart', () => els.spatial.classList.add('dragging'));
group.on('dragend', () => {
els.spatial.classList.remove('dragging');
sendMyPos(true);
});
} }
ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0); node = { group, circle, initial, nameText };
const pad = 30; avatarNodes.set(id, node);
const sx = (r.width - 2 * pad) / ROOM_UNITS; return node;
const sy = (r.height - 2 * pad) / ROOM_UNITS; }
const cx = r.width / 2, cy = r.height / 2;
return { w: r.width, h: r.height, pad, sx, sy, function recenterCamera() {
toPx: (x, y) => [cx + (x - camX) * sx, cy + (y - camY) * sy], const cx = spatialStage.width() / 2, cy = spatialStage.height() / 2;
fromPx: (px, py) => [camX + (px - cx) / sx, camY + (py - cy) / sy] }; worldLayer.position({ x: cx - S.me.x * sx, y: cy - S.me.y * sy });
}
function layoutSpatialGrid() {
gridGroup.destroyChildren();
for (let i = 0; i <= ROOM_UNITS; i += 10) {
gridGroup.add(new Konva.Line({
points: [i * sx, 0, i * sx, ROOM_UNITS * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1,
}));
gridGroup.add(new Konva.Line({
points: [0, i * sy, ROOM_UNITS * sx, i * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1,
}));
}
}
function resizeSpatial() {
const r = els.spatial.getBoundingClientRect();
spatialStage.width(r.width);
spatialStage.height(r.height);
sx = (r.width - 2 * pad) / ROOM_UNITS;
sy = (r.height - 2 * pad) / ROOM_UNITS;
layoutSpatialGrid();
recenterCamera();
}
new ResizeObserver(resizeSpatial).observe(els.spatial);
resizeSpatial();
function updateAvatarNode(node, id, name, muted, speaking, isMe, worldX, worldY) {
// Safe to set even for "me" while dragging: dragmove already derived
// S.me from the node's own current position, so this is a no-op then.
node.group.position({ x: worldX * sx, y: worldY * sy });
node.circle.stroke(muted ? '#f23f43' : speaking ? '#23a55a' : '');
node.circle.strokeWidth(3);
node.circle.fill(colorFor(id));
node.initial.text((name || '?').slice(0, 1).toUpperCase());
node.nameText.text(name + (isMe ? ' (you)' : ''));
node.nameText.offsetX(node.nameText.width() / 2);
} }
function drawSpatial() { function drawSpatial() {
const camX = dragging ? dragging.camX : S.me.x; // range circle stays centered on "me", which the camera keeps at center
const camY = dragging ? dragging.camY : S.me.y; rangeCircle.position({ x: S.me.x * sx, y: S.me.y * sy });
const m = spatialMetrics(camX, camY); rangeCircle.radius(Math.max(0, Math.min(56 * sx, spatialStage.height() / 2 - pad)));
ctx2d.clearRect(0, 0, m.w, m.h);
// grid (world bounds only — clamped so it doesn't imply an infinite plane) const seen = new Set(['me']);
ctx2d.strokeStyle = 'rgba(255,255,255,.05)'; const meId = S.myId || 'me';
ctx2d.lineWidth = 1; const meNode = avatarNode('me', true);
for (let i = 0; i <= ROOM_UNITS; i += 10) { updateAvatarNode(meNode, meId, S.name || 'me', S.muted, els.meAvatar.classList.contains('speaking'), true, S.me.x, S.me.y);
const [gx] = m.toPx(i, 0); meNode.group.moveToTop();
const [, gy] = m.toPx(0, i);
ctx2d.beginPath(); ctx2d.moveTo(gx, m.toPx(0, 0)[1]); ctx2d.lineTo(gx, m.toPx(0, ROOM_UNITS)[1]); ctx2d.stroke(); for (const peer of S.peers.values()) {
ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(ROOM_UNITS, 0)[0], gy); ctx2d.stroke(); seen.add(peer.id);
const node = avatarNode(peer.id, false);
updateAvatarNode(node, peer.id, peer.name, peer.state.muted, peer.speaking, false, peer.x, peer.y);
}
for (const [id, node] of avatarNodes) {
if (!seen.has(id)) { node.group.destroy(); avatarNodes.delete(id); }
} }
// audible range around me (~gain 0.05 at 56 units with ref=4, rolloff=1.4) worldLayer.batchDraw();
const [mx, my] = m.toPx(S.me.x, S.me.y);
const rangeR = 56 * m.sx;
ctx2d.strokeStyle = 'rgba(88,101,242,.35)';
ctx2d.setLineDash([5, 5]);
ctx2d.beginPath(); ctx2d.arc(mx, my, Math.max(0, Math.min(rangeR, m.h / 2 - m.pad)), 0, Math.PI * 2); ctx2d.stroke();
ctx2d.setLineDash([]);
// peers
for (const peer of S.peers.values()) drawAvatarDot(peer.name, peer.id, peer.x, peer.y, peer.speaking, peer.state.muted, false, m);
// me (draggable listener, always drawn at the camera's center)
drawAvatarDot(S.name || 'me', S.myId || 'me', S.me.x, S.me.y, els.meAvatar.classList.contains('speaking'), S.muted, true, m);
if (!dragging) requestAnimationFrame(drawSpatial);
} }
requestAnimationFrame(drawSpatial); requestAnimationFrame(function loop() { drawSpatial(); requestAnimationFrame(loop); });
function drawAvatarDot(name, id, x, y, speaking, muted, isMe, m) {
const [px, py] = m.toPx(x, y);
const r = isMe ? 16 : 13;
ctx2d.beginPath();
ctx2d.arc(px, py, r, 0, Math.PI * 2);
ctx2d.fillStyle = colorFor(id);
ctx2d.fill();
if (speaking) {
ctx2d.strokeStyle = '#23a55a';
ctx2d.lineWidth = 3;
ctx2d.stroke();
}
if (muted) {
ctx2d.strokeStyle = '#f23f43';
ctx2d.lineWidth = 3;
ctx2d.beginPath();
ctx2d.moveTo(px - r, py + r);
ctx2d.lineTo(px + r, py - r);
ctx2d.stroke();
}
ctx2d.fillStyle = '#fff';
ctx2d.font = `bold ${Math.round(r)}px system-ui`;
ctx2d.textAlign = 'center';
ctx2d.textBaseline = 'middle';
ctx2d.fillText(name.slice(0, 1).toUpperCase(), px, py + 1);
ctx2d.font = '11px system-ui';
ctx2d.fillStyle = isMe ? '#b5bac1' : '#80848e';
ctx2d.fillText(name + (isMe ? ' (you)' : ''), px, py + r + 12);
}
els.spatial.addEventListener('pointerdown', (ev) => {
const m = spatialMetrics();
const rect = els.spatial.getBoundingClientRect();
const px = ev.clientX - rect.left, py = ev.clientY - rect.top;
const [ux, uy] = m.fromPx(px, py);
const d = Math.hypot(ux - S.me.x, uy - S.me.y);
if (d < 10) {
dragging = { dx: S.me.x - ux, dy: S.me.y - uy, camX: S.me.x, camY: S.me.y };
els.spatial.classList.add('dragging');
els.spatial.setPointerCapture(ev.pointerId);
}
});
els.spatial.addEventListener('pointermove', (ev) => {
if (!dragging) return;
const m = spatialMetrics(dragging.camX, dragging.camY);
const rect = els.spatial.getBoundingClientRect();
const [ux, uy] = m.fromPx(ev.clientX - rect.left, ev.clientY - rect.top);
S.me.x = Math.min(ROOM_UNITS, Math.max(0, ux + dragging.dx));
S.me.y = Math.min(ROOM_UNITS, Math.max(0, uy + dragging.dy));
drawSpatial();
sendMyPos();
});
els.spatial.addEventListener('pointerup', () => {
if (!dragging) return;
dragging = null;
els.spatial.classList.remove('dragging');
sendMyPos(true);
requestAnimationFrame(drawSpatial); // rAF loop stopped while dragging
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Matrix text chat (experimental) — native OAuth2/OIDC auth against the // Matrix text chat (experimental) — native OAuth2/OIDC auth against the
+1 -1
View File
@@ -72,7 +72,7 @@
<span>SPATIAL AUDIO</span> <span>SPATIAL AUDIO</span>
<span class="hint">drag <b>your</b> avatar &mdash; others hear you shift; you hear them relative to position</span> <span class="hint">drag <b>your</b> avatar &mdash; others hear you shift; you hear them relative to position</span>
</div> </div>
<canvas id="spatial"></canvas> <div id="spatial"></div>
</div> </div>
</main> </main>
</div> </div>