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
+153
-110
No files matched your search
Generated
+33
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
+118
-109
@@ -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');
|
|
||||||
let dragging = null;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
const pad = 30;
|
const pad = 30;
|
||||||
const sx = (r.width - 2 * pad) / ROOM_UNITS;
|
let sx = 1, sy = 1; // px per world unit, recomputed on resize
|
||||||
const sy = (r.height - 2 * pad) / ROOM_UNITS;
|
|
||||||
const cx = r.width / 2, cy = r.height / 2;
|
const spatialStage = new Konva.Stage({ container: 'spatial', width: 1, height: 1 });
|
||||||
return { w: r.width, h: r.height, pad, sx, sy,
|
const worldLayer = new Konva.Layer();
|
||||||
toPx: (x, y) => [cx + (x - camX) * sx, cy + (y - camY) * sy],
|
spatialStage.add(worldLayer);
|
||||||
fromPx: (px, py) => [camX + (px - cx) / sx, camY + (py - cy) / sy] };
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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() {
|
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
@@ -72,7 +72,7 @@
|
|||||||
<span>SPATIAL AUDIO</span>
|
<span>SPATIAL AUDIO</span>
|
||||||
<span class="hint">drag <b>your</b> avatar — others hear you shift; you hear them relative to position</span>
|
<span class="hint">drag <b>your</b> avatar — 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>
|
||||||
|
|||||||
Reference in new issue
Block a user