🔧 fix: pinch-zoom trap over menus + cable blips ride the data's own rate

Two live-play fixes:

- Zoom: trackpad pinch (ctrl+wheel) over a DOM overlay (right-click menu,
  panels) used to hit the BROWSER's page zoom, which the canvas then made
  un-undoable because it preventDefaults every wheel — zoomed-in with no way
  out except re-opening a menu. Now a capture-phase window listener catches
  pinch everywhere and drives the app's own view zoom (shared zoomAt());
  Safari's gesture* events are blocked too. Plain scrolling in menus and
  panels is untouched; canvas behavior unchanged (verified single-fire).

- Cables: dash/blip speed was uniform (activity-only). Each source now
  carries flowDisp — an EMA of |Δnorm|/s with fast attack + slow release —
  and every cable's dashPhase advances at 6 + flow·240 + activity·40, with
  the pulse dot riding the same phase. A ticking feed streams, a glacial
  feed crawls (venus.dist ~0 flow vs mercury.phase saturated, measured);
  event sources surge on fire via firePulse.

Verified: pinch in/out over overlay drives view.z and is defaultPrevented,
plain scroll not hijacked, canvas single-fire, flows measured per-source
via temp hook (removed, grep-verified 0), zero console errors, T O B G S
+ ⌘Z regression green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-25 10:32:28 +10:00
parent 9fbba05426
commit 7c6d44b010

View File

@ -5458,6 +5458,14 @@
for (const n of sources.values()) {
n.normDisp = approach(n.normDisp, n.norm, 0.16, dt);
n.rawDisp = approach(n.rawDisp, n.raw, 0.16, dt);
// the data's own pulse: |Δnorm|/s, fast attack + slow release, so a ticking
// feed holds a flow level ∝ how often (and how hard) it actually moves —
// this is what the cables' blip speed rides (a glacial feed crawls, BTC streams)
const pv = n._flowPrev == null ? n.norm : n._flowPrev;
const fv = clamp(Math.sqrt(Math.abs(n.norm - pv) / Math.max(dt, 1e-3) * 8), 0, 1);
n._flowPrev = n.norm;
const ft = Math.max(fv, n.firePulse || 0); // an event firing IS data arriving
n.flowDisp = approach(n.flowDisp || 0, ft, ft > (n.flowDisp || 0) ? 0.5 : 0.05, dt);
if (n.y !== n.targetY && n.targetY != null) n.y = approach(n.y, n.targetY, 0.12, dt);
if (n.firePulse > 0) n.firePulse = Math.max(0, n.firePulse - dt * 1.8);
}
@ -5468,7 +5476,9 @@
}
for (const r of routesArr) {
r.activeDisp = approach(r.activeDisp, r.active, 0.14, dt);
r.dashPhase -= dt * (18 + r.activeDisp * 90);
const sn = sources.get(r.source); // the cable flows at the DATA's rate:
const flow = sn && sn.flowDisp ? sn.flowDisp : 0; // lively source = streaming blips,
r.dashPhase -= dt * (6 + flow * 240 + r.activeDisp * 40); // glacial source = a slow crawl
}
if (chakraMode) updateChakra(t, dt); // positions sources on the subtle body
@ -5560,8 +5570,8 @@
ctx.stroke();
ctx.shadowBlur = 0;
// travelling pulse dot
const pt = ((t * (0.15 + a * 0.6) + r.dashPhase * 0.002) % 1 + 1) % 1;
// travelling pulse dot — rides dashPhase, so its speed IS the data's rate
const pt = ((-r.dashPhase * 0.004) % 1 + 1) % 1;
const bp = bezierPoint(x1, y1, cx1, y1, cx2, y2, x2, y2, pt);
ctx.beginPath();
ctx.arc(bp.x, bp.y, 1.5 + a * 3, 0, Math.PI * 2);
@ -7762,13 +7772,27 @@
return hit;
}
canvas.addEventListener("wheel", (ev) => { // scroll or trackpad-pinch → zoom toward the cursor
ev.preventDefault();
const cx = (ev.clientX - view.x) / view.z, cy = (ev.clientY - view.y) / view.z; // content point under cursor
view.z = clamp(view.z * Math.exp(-ev.deltaY * 0.0016), 1, 6);
view.x = ev.clientX - cx * view.z; view.y = ev.clientY - cy * view.z;
function zoomAt(clientX, clientY, deltaY) { // zoom toward the cursor (shared by canvas + overlay pinch)
const cx = (clientX - view.x) / view.z, cy = (clientY - view.y) / view.z; // content point under cursor
view.z = clamp(view.z * Math.exp(-deltaY * 0.0016), 1, 6);
view.x = clientX - cx * view.z; view.y = clientY - cy * view.z;
applyView();
}
canvas.addEventListener("wheel", (ev) => { // scroll or trackpad-pinch → zoom
ev.preventDefault();
zoomAt(ev.clientX, ev.clientY, ev.deltaY);
}, { passive: false });
// Trackpad pinch arrives as ctrl+wheel. Over a DOM overlay (the right-click menu,
// a panel) it used to hit the BROWSER's page zoom — which the canvas then made
// un-undoable, because it preventDefaults every wheel. Catch pinch everywhere:
// it always drives OUR zoom, never the page's. Plain scrolling in panels is untouched.
window.addEventListener("wheel", (ev) => {
if (!ev.ctrlKey || ev.target === canvas) return; // not a pinch / canvas already handles it
ev.preventDefault();
zoomAt(ev.clientX, ev.clientY, ev.deltaY);
}, { passive: false, capture: true });
for (const ge of ["gesturestart", "gesturechange", "gestureend"]) // Safari's proprietary pinch path
window.addEventListener(ge, (ev) => ev.preventDefault(), { passive: false });
canvas.addEventListener("dblclick", () => { if (view.z !== 1) { view.z = 1; view.x = 0; view.y = 0; applyView(); } });
canvas.addEventListener("mousedown", (ev) => {
if (ev.button !== 0) return; // right button belongs to the menu