diff --git a/viz/index.html b/viz/index.html
index fab35d3..298857d 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -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