From f68e77472e962b71eb0792f8d14198a14b3976eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 11:51:05 +0200 Subject: [PATCH] Clamp spatial-map dragging to the room and shrink initial spawn spread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging (yourself or a peer, for local-only listening position) never clamped the node's actual on-screen position — only the *derived* room coordinate got clamped to [0,100]. Cytoscape let the dot follow the pointer arbitrarily far past the edge while the stored position stayed pinned at the wall, so it visually flew off, then snapped back the instant you released. Now every drag frame writes the clamped position back onto the node itself, so it can't separate from its real, bounded position. Given refDistance=4/rolloffFactor=1.4, staying inside the room already keeps everyone's audio gain above ~18%, so this is the actual "never drag out of hearing range" bound. Also replaced the ':grabbed' re-query in the 100ms sync loop (which decided whether to skip re-snapping the actively-dragged node) with an explicitly tracked node id — strictly more correct regardless of whether it contributed to the glitch above. Dragging a peer is local-only (repositions how loud/where only you hear them, never sent to anyone else) but had no feedback saying so; added a one-time toast when you grab a peer's dot. Also shrink the initial spawn-scatter radius from 20 to 6 room units — at radius 20, two people could spawn diametrically opposite (40 units apart), which was already down to ~7% audio gain before anyone said a word. Co-Authored-By: Claude Sonnet 5 --- public/app.js | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/public/app.js b/public/app.js index 8019b31..8b0d292 100644 --- a/public/app.js +++ b/public/app.js @@ -1023,7 +1023,7 @@ const cy = cytoscape({ ], }); -let isDraggingNode = false; +let draggedNodeId = null; // set explicitly in grab/free — not re-derived via ':grabbed' each tick // Convert spatial room units (0..100) to Cytoscape viewport pixels (scaled around center) const SPATIAL_PX_SCALE = 6; @@ -1033,6 +1033,7 @@ function roomToCyPos(x, y) { function cyToRoomPos(pos) { const x = Math.min(ROOM_UNITS, Math.max(0, pos.x / SPATIAL_PX_SCALE + 50)); const y = Math.min(ROOM_UNITS, Math.max(0, pos.y / SPATIAL_PX_SCALE + 50)); + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; // never let NaN/Infinity leak into S.me/peer.x/y return { x, y }; } @@ -1054,19 +1055,33 @@ function zoomToBounds(animate = true) { // Handle dragging nodes cy.on('grab', 'node', (evt) => { - isDraggingNode = true; + cy.stop(); // cancel any in-flight fit animation so it can't fight the drag's pointer->model mapping + draggedNodeId = evt.target.id(); + if (!evt.target.data('isMe')) { + toast(`Repositioning ${evt.target.data('label')} — this only changes how loud/where YOU hear them, not their real position`, 'warn'); + } els.spatial.classList.add('dragging'); }); cy.on('drag', 'node', (evt) => { const node = evt.target; const pos = cyToRoomPos(node.position()); + if (!pos) return; // guard: never propagate a bad (NaN/Infinity) position into shared state + // cyToRoomPos already clamped pos to the room — snap the node's actual on-screen + // position back to that clamp too. Without this, Cytoscape lets the dot follow the + // pointer arbitrarily far past the edge (nothing was clamping node.position() itself, + // only the derived room coordinate), so it visually flew off while the stored + // position silently stayed pinned at the wall — then snapped back the instant you + // let go. This keeps everyone within the room, which given refDistance=4 / + // rolloffFactor=1.4 (see updatePanner) always keeps audio gain above ~18%, i.e. + // nobody can drag themselves or a peer out of hearing range. + node.position(roomToCyPos(pos.x, pos.y)); if (node.data('isMe')) { S.me.x = pos.x; S.me.y = pos.y; sendMyPos(); } else { - // Peer dragged: update local audio position relative to me + // Peer dragged: update local audio position relative to me — never sent to anyone else const peer = S.peers.get(node.id()); if (peer) { peer.x = pos.x; @@ -1078,7 +1093,7 @@ cy.on('drag', 'node', (evt) => { }); cy.on('free', 'node', (evt) => { - isDraggingNode = false; + draggedNodeId = null; els.spatial.classList.remove('dragging'); const node = evt.target; if (node.data('isMe')) { @@ -1143,7 +1158,7 @@ function syncSpatialGraph() { } else { meNode.data('label', `${S.name || 'me'} (you)`); meNode.data('color', colorFor(meId)); - if (!isDraggingNode) { + if (draggedNodeId !== 'me') { meNode.position(roomToCyPos(S.me.x, S.me.y)); } } @@ -1169,7 +1184,7 @@ function syncSpatialGraph() { } else { peerNode.data('label', peer.name); peerNode.data('color', colorFor(peer.id)); - if (!isDraggingNode || cy.$(':grabbed').id() !== peer.id) { + if (draggedNodeId !== peer.id) { peerNode.position(roomToCyPos(peer.x, peer.y)); } } @@ -1302,6 +1317,7 @@ async function mxBoot(session) { refreshToken: session.refreshToken || undefined, userId: session.userId, deviceId: session.deviceId, + fallbackICEServerAllowed: false, }); Mx.client.on(matrixcs.ClientEvent.Sync, (state) => { if (state === 'PREPARED') mxOnReady(); @@ -1508,13 +1524,17 @@ setInterval(() => { } }, 5000); -// spawn me somewhere spread out, not dead-center on top of others +// Spawn me slightly off-center so two people don't land exactly on top of +// each other — small radius on purpose: at refDistance=4/rolloffFactor=1.4, +// the old radius-20 ring put name-hash-unlucky pairs up to 40 units apart +// (diametrically opposite), which is already down to ~7% audio gain before +// anyone's said a word. (() => { let h = 0; for (const c of S.myId || S.name) h = (h * 33 + c.charCodeAt(0)) >>> 0; const a = (h % 360) * (Math.PI / 180); - S.me.x = 50 + 20 * Math.cos(a); - S.me.y = 50 + 20 * Math.sin(a); + S.me.x = 50 + 6 * Math.cos(a); + S.me.y = 50 + 6 * Math.sin(a); })(); window.addEventListener('keydown', (ev) => {