diff --git a/GODSTRUMENT_MANUAL_SOURCE.md b/GODSTRUMENT_MANUAL_SOURCE.md
index 6b3b477..ac64963 100644
--- a/GODSTRUMENT_MANUAL_SOURCE.md
+++ b/GODSTRUMENT_MANUAL_SOURCE.md
@@ -518,6 +518,8 @@ These four — element, waxing, declination, fertility — gather in the **spiri
The console is not a picture of the instrument. It *is* the instrument. Every sphere, every cable, every dial answers to the hand. Nothing here writes to `config.json` — the edits ride the same websocket the data rides (*add_route · remove_route · set_route · make_group*), so the modulation matrix rewires beneath your fingers in real time, live, on stage, mid-note.
+**Zoom in.** Scroll (or pinch on a trackpad) to zoom the whole sky toward your cursor — get right up into the orbits, the cables, the chakra wheels. Once you're in, **drag empty space to pan** around, and **double-click** to snap back out to the full view.
+
Learn these seven gestures and the whole planet is under your hands:
- **Click any node — toggle it on or off.** A source sphere (*the sun, an earthquake, Delhi's air, bitcoin's volatility*) or a voice (*lead, bass, pad, drone, perc*): one click mutes it, and the node takes a red slash. Silence the sun and the master filter stops breathing with the solar wind; silence *perc* and BTC's chaos stops driving the hats. Click again — it returns.
@@ -1001,3 +1003,5 @@ Every feed the world sends is one of eight temperaments; the shape dictates how
- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
- **⬗ Parameter locks (p-locks)** — Pocket-Operator / Elektron style: a texture voice (filter, glitch, reverb, drive) gets a per-step `lock` lane that pins its value to a fixed level on that step (blocky, disjointed), overriding the world; drag a step below the lane to unlock it and hand that step back to the data. Applied in `computeDests` via `applyGroove`; travels with the patch.
- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
+- **⬗ Parameter locks (p-locks)** — Pocket-Operator / Elektron style: a texture voice (filter, glitch, reverb, drive) gets a per-step `lock` lane that pins its value to a fixed level on that step (blocky, disjointed), overriding the world; drag a step below the lane to unlock it and hand that step back to the data. Applied in `computeDests` via `applyGroove`; travels with the patch.
+- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
diff --git a/viz/index.html b/viz/index.html
index 22e5d25..eafee92 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -971,6 +971,8 @@
The console is not a picture of the instrument. It is the instrument. Every sphere, every cable, every dial answers to the hand. Nothing here writes to config.json — the edits ride the same websocket the data rides (add_route · remove_route · set_route · make_group), so the modulation matrix rewires beneath your fingers in real time, live, on stage, mid-note.
+Zoom in. Scroll (or pinch on a trackpad) to zoom the whole sky toward your cursor — get right up into the orbits, the cables, the chakra wheels. Once you're in, drag empty space to pan around, and double-click to snap back out to the full view.
+
Learn these seven gestures and the whole planet is under your hands:
@@ -1062,6 +1064,21 @@
const ctx = canvas.getContext("2d");
let W = 0, H = 0, DPR = 1;
+ // ---- pinch / scroll zoom of the whole visual (CSS transform on the canvas) ----
+ const view = { z: 1, x: 0, y: 0 }; // zoom + pan (screen px); #stage is fixed at 0,0
+ let panDrag = null;
+ canvas.style.transformOrigin = "0 0";
+ function clampView() {
+ view.x = clamp(view.x, window.innerWidth * (1 - view.z), 0); // keep the canvas covering the viewport
+ view.y = clamp(view.y, window.innerHeight * (1 - view.z), 0);
+ }
+ function applyView() {
+ if (view.z <= 1.002) { view.z = 1; view.x = 0; view.y = 0; } // snap home when fully out
+ clampView();
+ canvas.style.transform = (view.z === 1 && !view.x && !view.y) ? "none"
+ : "translate(" + view.x.toFixed(1) + "px," + view.y.toFixed(1) + "px) scale(" + view.z.toFixed(4) + ")";
+ }
+
// ---- graphics level ----------------------------------------------------
// "auto" starts full and drops to lite if the frame rate drowns (ultrawides,
// older boxes). lite = no shadow blur, flat node cores, DPR capped at 1 —
@@ -5132,10 +5149,18 @@
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;
+ applyView();
+ }, { 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
const r = canvas.getBoundingClientRect();
- const mx = ev.clientX - r.left, my = ev.clientY - r.top;
+ const mx = (ev.clientX - r.left) / view.z, my = (ev.clientY - r.top) / view.z; // screen → zoomed content coords
if (inGodBand(mx, my)) { godDrag = { x0: mx, bpm0: godtime.bpm, moved: false }; return; }
const lp = loupeNodeAt(mx, my); // a magnified row in the hover zoom
if (lp) loupe.frozen = true; // hold the panel still through the press
@@ -5160,12 +5185,18 @@
}
chakraSpin.vel = 0; // empty space = grab / look around
chakraDrag = { x0: mx, y0: my, rot0: chakraSpin.rot, tilt0: chakraSpin.tilt, lastX: mx };
+ } else if (view.z > 1) { // zoomed in, empty space → pan the view
+ panDrag = { sx: ev.clientX, sy: ev.clientY, x0: view.x, y0: view.y };
}
});
window.addEventListener("mousemove", (ev) => {
const r = canvas.getBoundingClientRect();
- const mx = ev.clientX - r.left, my = ev.clientY - r.top;
- if (chakraDrag) { // spin / tilt the subtle body
+ const mx = (ev.clientX - r.left) / view.z, my = (ev.clientY - r.top) / view.z;
+ if (panDrag) { // drag the zoomed view around
+ view.x = panDrag.x0 + (ev.clientX - panDrag.sx);
+ view.y = panDrag.y0 + (ev.clientY - panDrag.sy);
+ applyView(); canvas.style.cursor = "grabbing";
+ } else if (chakraDrag) { // spin / tilt the subtle body
chakraSpin.rot = chakraDrag.rot0 + (mx - chakraDrag.x0) * 0.008;
chakraSpin.tilt = clamp(chakraDrag.tilt0 + (my - chakraDrag.y0) * 0.004, 0.06, 0.95);
chakraSpin.vel = (mx - chakraDrag.lastX) * 0.48; // flick momentum, rad/s-ish
@@ -5194,6 +5225,7 @@
});
window.addEventListener("mouseup", (ev) => {
loupe.frozen = false; // the hover zoom follows the cursor again
+ if (panDrag) { panDrag = null; canvas.style.cursor = "default"; return; }
if (chakraDrag) { chakraDrag = null; return; } // let go — momentum carries it
if (gainDrag) { // release the level ride
const gd = gainDrag; gainDrag = null;
@@ -5210,7 +5242,7 @@
}
if (!drag) return;
const r = canvas.getBoundingClientRect();
- const mx = ev.clientX - r.left, my = ev.clientY - r.top;
+ const mx = (ev.clientX - r.left) / view.z, my = (ev.clientY - r.top) / view.z;
const d = drag; drag = null; hoverDest = null;
const isSrc = d.node.kind === "source";
if (d.moved) { // drag source -> dest = new cable
@@ -5520,7 +5552,7 @@
canvas.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
const r = canvas.getBoundingClientRect();
- const mx = ev.clientX - r.left, my = ev.clientY - r.top;
+ const mx = (ev.clientX - r.left) / view.z, my = (ev.clientY - r.top) / view.z; // zoomed content coords
openCtxMenu(nodeAt(mx, my) || destAt(mx, my), ev.clientX, ev.clientY);
});