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-node": "^0.222.0",
"@opentelemetry/semantic-conventions": "^1.43.0",
"konva": "^10.6.0",
"matrix-js-sdk": "^42.4.0",
"ws": "^8.18.0"
},
@@ -3940,6 +3941,38 @@
"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": {
"version": "1.33.0",
"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-node": "^0.222.0",
"@opentelemetry/semantic-conventions": "^1.43.0",
"konva": "^10.6.0",
"matrix-js-sdk": "^42.4.0",
"ws": "^8.18.0"
},
+117 -108
View File
@@ -1,4 +1,5 @@
import * as matrixcs from 'matrix-js-sdk';
import Konva from 'konva';
// ---------------------------------------------------------------------------
// 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');
let dragging = null;
const pad = 30;
let sx = 1, sy = 1; // px per world unit, recomputed on resize
// Camera is always centered on the listener (me), matching how the audio
// panning already works (peer positions are relative to S.me). While
// dragging, the camera freezes at its pre-drag position so the world
// doesn't slide under the pointer as S.me moves.
function spatialMetrics(camX = S.me.x, camY = S.me.y) {
const r = els.spatial.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
if (els.spatial.width !== r.width * dpr) {
els.spatial.width = r.width * dpr;
els.spatial.height = r.height * dpr;
const spatialStage = new Konva.Stage({ container: 'spatial', width: 1, height: 1 });
const worldLayer = new Konva.Layer();
spatialStage.add(worldLayer);
// grid lives inside worldLayer so it pans with the camera along with everyone else
const gridGroup = new Konva.Group({ listening: false });
worldLayer.add(gridGroup);
const rangeCircle = new Konva.Circle({
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);
const pad = 30;
const sx = (r.width - 2 * pad) / ROOM_UNITS;
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,
toPx: (x, y) => [cx + (x - camX) * sx, cy + (y - camY) * sy],
fromPx: (px, py) => [camX + (px - cx) / sx, camY + (py - cy) / sy] };
node = { group, circle, initial, nameText };
avatarNodes.set(id, node);
return node;
}
function recenterCamera() {
const cx = spatialStage.width() / 2, cy = spatialStage.height() / 2;
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() {
const camX = dragging ? dragging.camX : S.me.x;
const camY = dragging ? dragging.camY : S.me.y;
const m = spatialMetrics(camX, camY);
ctx2d.clearRect(0, 0, m.w, m.h);
// range circle stays centered on "me", which the camera keeps at center
rangeCircle.position({ x: S.me.x * sx, y: S.me.y * sy });
rangeCircle.radius(Math.max(0, Math.min(56 * sx, spatialStage.height() / 2 - pad)));
// grid (world bounds only — clamped so it doesn't imply an infinite plane)
ctx2d.strokeStyle = 'rgba(255,255,255,.05)';
ctx2d.lineWidth = 1;
for (let i = 0; i <= ROOM_UNITS; i += 10) {
const [gx] = m.toPx(i, 0);
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();
ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(ROOM_UNITS, 0)[0], gy); ctx2d.stroke();
const seen = new Set(['me']);
const meId = S.myId || 'me';
const meNode = avatarNode('me', true);
updateAvatarNode(meNode, meId, S.name || 'me', S.muted, els.meAvatar.classList.contains('speaking'), true, S.me.x, S.me.y);
meNode.group.moveToTop();
for (const peer of S.peers.values()) {
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)
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);
worldLayer.batchDraw();
}
requestAnimationFrame(drawSpatial);
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
});
requestAnimationFrame(function loop() { drawSpatial(); requestAnimationFrame(loop); });
// ---------------------------------------------------------------------------
// Matrix text chat (experimental) — native OAuth2/OIDC auth against the
+1 -1
View File
@@ -72,7 +72,7 @@
<span>SPATIAL AUDIO</span>
<span class="hint">drag <b>your</b> avatar &mdash; others hear you shift; you hear them relative to position</span>
</div>
<canvas id="spatial"></canvas>
<div id="spatial"></div>
</div>
</main>
</div>