Clamp spatial-map dragging to the room and shrink initial spawn spread
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s

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 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5 committed 2026-09-22 11:51:05 +02:00
1 parent 7795cf84a3
commit f68e77472e
1 file changed
+29 -9
+29 -9
View File
@@ -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) // Convert spatial room units (0..100) to Cytoscape viewport pixels (scaled around center)
const SPATIAL_PX_SCALE = 6; const SPATIAL_PX_SCALE = 6;
@@ -1033,6 +1033,7 @@ function roomToCyPos(x, y) {
function cyToRoomPos(pos) { function cyToRoomPos(pos) {
const x = Math.min(ROOM_UNITS, Math.max(0, pos.x / SPATIAL_PX_SCALE + 50)); 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)); 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 }; return { x, y };
} }
@@ -1054,19 +1055,33 @@ function zoomToBounds(animate = true) {
// Handle dragging nodes // Handle dragging nodes
cy.on('grab', 'node', (evt) => { 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'); els.spatial.classList.add('dragging');
}); });
cy.on('drag', 'node', (evt) => { cy.on('drag', 'node', (evt) => {
const node = evt.target; const node = evt.target;
const pos = cyToRoomPos(node.position()); 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')) { if (node.data('isMe')) {
S.me.x = pos.x; S.me.x = pos.x;
S.me.y = pos.y; S.me.y = pos.y;
sendMyPos(); sendMyPos();
} else { } 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()); const peer = S.peers.get(node.id());
if (peer) { if (peer) {
peer.x = pos.x; peer.x = pos.x;
@@ -1078,7 +1093,7 @@ cy.on('drag', 'node', (evt) => {
}); });
cy.on('free', 'node', (evt) => { cy.on('free', 'node', (evt) => {
isDraggingNode = false; draggedNodeId = null;
els.spatial.classList.remove('dragging'); els.spatial.classList.remove('dragging');
const node = evt.target; const node = evt.target;
if (node.data('isMe')) { if (node.data('isMe')) {
@@ -1143,7 +1158,7 @@ function syncSpatialGraph() {
} else { } else {
meNode.data('label', `${S.name || 'me'} (you)`); meNode.data('label', `${S.name || 'me'} (you)`);
meNode.data('color', colorFor(meId)); meNode.data('color', colorFor(meId));
if (!isDraggingNode) { if (draggedNodeId !== 'me') {
meNode.position(roomToCyPos(S.me.x, S.me.y)); meNode.position(roomToCyPos(S.me.x, S.me.y));
} }
} }
@@ -1169,7 +1184,7 @@ function syncSpatialGraph() {
} else { } else {
peerNode.data('label', peer.name); peerNode.data('label', peer.name);
peerNode.data('color', colorFor(peer.id)); peerNode.data('color', colorFor(peer.id));
if (!isDraggingNode || cy.$(':grabbed').id() !== peer.id) { if (draggedNodeId !== peer.id) {
peerNode.position(roomToCyPos(peer.x, peer.y)); peerNode.position(roomToCyPos(peer.x, peer.y));
} }
} }
@@ -1302,6 +1317,7 @@ async function mxBoot(session) {
refreshToken: session.refreshToken || undefined, refreshToken: session.refreshToken || undefined,
userId: session.userId, userId: session.userId,
deviceId: session.deviceId, deviceId: session.deviceId,
fallbackICEServerAllowed: false,
}); });
Mx.client.on(matrixcs.ClientEvent.Sync, (state) => { Mx.client.on(matrixcs.ClientEvent.Sync, (state) => {
if (state === 'PREPARED') mxOnReady(); if (state === 'PREPARED') mxOnReady();
@@ -1508,13 +1524,17 @@ setInterval(() => {
} }
}, 5000); }, 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; let h = 0;
for (const c of S.myId || S.name) h = (h * 33 + c.charCodeAt(0)) >>> 0; for (const c of S.myId || S.name) h = (h * 33 + c.charCodeAt(0)) >>> 0;
const a = (h % 360) * (Math.PI / 180); const a = (h % 360) * (Math.PI / 180);
S.me.x = 50 + 20 * Math.cos(a); S.me.x = 50 + 6 * Math.cos(a);
S.me.y = 50 + 20 * Math.sin(a); S.me.y = 50 + 6 * Math.sin(a);
})(); })();
window.addEventListener('keydown', (ev) => { window.addEventListener('keydown', (ev) => {