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:
1 parent
5ecf415f64
commit
099d637815
4 files changed
+152
-109
No files matched your search
Generated
+33
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -72,7 +72,7 @@
|
||||
<span>SPATIAL AUDIO</span>
|
||||
<span class="hint">drag <b>your</b> avatar — others hear you shift; you hear them relative to position</span>
|
||||
</div>
|
||||
<canvas id="spatial"></canvas>
|
||||
<div id="spatial"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user