diff --git a/viz/index.html b/viz/index.html
index b6dc335..d531b78 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -885,6 +885,7 @@
- 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.
- Drag a source onto a voice — lay a new cable. This is composition by hand. Drag quake.depth onto lead.note and deep earthquakes start choosing the melody; drag weather.wind onto drone.voices and Tokyo's gusts thicken the choir. The cable is born live at a default amount, ready to bend.
+- Hover a crowded column — the zoom finds the one you want. The left column can hold dozens of sources packed a few pixels apart, too tight to click just one. Rest the cursor near it and a magnified strip unfolds beside it — the nine feeds nearest your cursor, spaced out and fully named with their live values, the one under you highlighted. Every row is the real thing: click to mute, ⇧-click for its tab, ⌘-click to group, or drag it straight onto a voice to patch. Reach across into the strip and it holds still for you. (Right-click the sky → hover zoom to turn it off.)
- Shift-click any node — open its tab. The inspector tells you what the thing is (its real feed and label — solar wind speed, PM2.5 over Delhi, the dealgod market's average price), its vibe (which groups claim it), its live value ticking in real time, and every cable in and out — each with an amount slider to lean harder or lighter and a × to unpatch it entirely. This is where a rough idea becomes a mix.
- ⌘/Ctrl-click several sources, then group them — mint your own vibe. Multi-select spheres by feeling, not category — a shadow of debt and inflation and quakes and the death rate, a bright of births and moonlight and fertility — and group them into a new named macro you can then mute, freeze, or gain as one hand-sized thing. The factory ships fourteen such vibes (planet, cosmos, human, market, heavens, dark, light, and the seven elemental + seasonal blends); yours join them.
- ♪ (top-right) — wake the sound. Browsers hold audio until a human asks. This is the ask. One click and the in-browser synth spins up, and the planet — already flowing as data — becomes audible.
@@ -2070,6 +2071,101 @@
placeColumn(srcList, leftX, topPad, usableH);
placeColumn(dstList, rightX, topPad, usableH);
+ columnInfo = {
+ src: { x: leftX, gap: usableH / Math.max(1, srcList.length), list: srcList },
+ dst: { x: rightX, gap: usableH / Math.max(1, dstList.length), list: dstList },
+ };
+ }
+
+ // ---------------------------------------------------------------------------
+ // Hover zoom (the loupe) — a dense column (dozens of sources ~8px apart) is
+ // impossible to target one node in. On hover, magnify the ~9 nodes nearest
+ // the cursor into a readable, comfortably-spaced strip; each row is a live
+ // hit target (click to mute, ⇧-click for its tab, ⌘-click to group, drag onto
+ // a voice to patch) so precision is restored without moving the base layout.
+ // ---------------------------------------------------------------------------
+ let columnInfo = null;
+ const loupe = { on: true, active: false, side: null, y: 0, rows: [], frozen: false, panel: null };
+ try { loupe.on = localStorage.getItem("gs_loupe") !== "0"; } catch (e) {}
+
+ function updateLoupe(mx, my) {
+ if (!loupe.on || chakraMode || performMode || !columnInfo
+ || inspectEl.classList.contains("open") || arr.open) { loupe.active = false; return; }
+ if (loupe.frozen) return; // held still through a press
+ // once open, keep it open while the cursor is over the magnified panel, so
+ // you can reach across to a row without it vanishing under you
+ if (loupe.active && loupe.panel &&
+ mx >= loupe.panel.x0 - 10 && mx <= loupe.panel.x1 + 10 &&
+ my >= loupe.panel.y0 - 14 && my <= loupe.panel.y1 + 14) return;
+ let side = null;
+ for (const s of ["src", "dst"]) {
+ const c = columnInfo[s];
+ if (c && c.gap < 15 && c.list.length > 8 && Math.abs(mx - c.x) < 48) side = s;
+ }
+ if (!side) { loupe.active = false; return; }
+ loupe.side = side; loupe.active = true; loupe.y = my; // side is stable across relayouts
+ }
+
+ function loupeNodeAt(mx, my) {
+ if (!loupe.active) return null;
+ for (const row of loupe.rows)
+ if (mx >= row.x0 && mx <= row.x1 && my >= row.y0 && my <= row.y1) return row.node;
+ return null;
+ }
+
+ function drawLoupe(t) { // a draw helper must never be able to kill the render loop
+ try { drawLoupeInner(t); } catch (e) { loupe.active = false; loupe.rows = []; }
+ }
+ function drawLoupeInner(t) {
+ const col = loupe.active && columnInfo ? columnInfo[loupe.side] : null;
+ if (!loupe.active || !col || !col.list.length) { loupe.rows = []; return; }
+ const list = col.list;
+ let ci = 0, best = Infinity;
+ for (let i = 0; i < list.length; i++) {
+ const d = Math.abs((list[i].targetY != null ? list[i].targetY : list[i].y) - loupe.y);
+ if (d < best) { best = d; ci = i; }
+ }
+ const ROWH = 24, SHOW = Math.min(9, list.length), half = (SHOW - 1) / 2;
+ const i0 = clamp(ci - Math.round(half), 0, Math.max(0, list.length - SHOW));
+ const isSrc = loupe.side === "src";
+ const panelW = 208;
+ const panelX = isSrc ? col.x + 32 : col.x - 32 - panelW;
+ const cy = clamp(loupe.y, HEADER_H + SHOW * ROWH / 2 + 10, H - FOOTER_H - SHOW * ROWH / 2 - 10);
+ const topY = cy - SHOW * ROWH / 2;
+ ctx.save();
+ ctx.fillStyle = "rgba(9,14,24,0.95)";
+ ctx.fillRect(panelX, topY - 6, panelW, SHOW * ROWH + 12);
+ ctx.strokeStyle = "rgba(120,150,200,0.4)"; ctx.lineWidth = 1;
+ ctx.strokeRect(panelX, topY - 6, panelW, SHOW * ROWH + 12);
+ const rows = [];
+ for (let k = 0; k < SHOW; k++) {
+ const n = list[i0 + k]; if (!n) continue;
+ const ry = topY + k * ROWH, centered = (i0 + k) === ci;
+ const col2 = (n.kind === "source" ? n.col : (FAMILIES[n.family] || FAMILIES.other).col) || [150, 170, 200];
+ if (centered) { ctx.fillStyle = "rgba(90,130,230,0.22)"; ctx.fillRect(panelX + 3, ry + 2, panelW - 6, ROWH - 4); }
+ ctx.beginPath(); ctx.arc(panelX + 16, ry + ROWH / 2, 6 + (n.normDisp || 0) * 3, 0, Math.PI * 2);
+ ctx.fillStyle = rgba(rgbMix(col2, 0.4), 0.9); ctx.fill();
+ if (n.muted) {
+ ctx.strokeStyle = "rgba(255,95,95,0.9)"; ctx.lineWidth = 1.5;
+ ctx.beginPath(); ctx.moveTo(panelX + 11, ry + ROWH / 2 - 5); ctx.lineTo(panelX + 21, ry + ROWH / 2 + 5); ctx.stroke();
+ }
+ ctx.textAlign = "left"; ctx.textBaseline = "middle";
+ ctx.font = (centered ? "600 " : "500 ") + "12px 'Helvetica Neue', Arial, sans-serif";
+ ctx.fillStyle = rgba(rgbMix(col2, centered ? 0.55 : 0.4), centered ? 0.98 : 0.82);
+ ctx.fillText(truncate(n.label, 22), panelX + 30, ry + ROWH / 2);
+ ctx.textAlign = "right"; ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
+ ctx.fillStyle = "rgba(150,170,200,0.7)";
+ ctx.fillText(n.isNote ? midiToName(n.raw) : fmtRaw(n.rawDisp != null ? n.rawDisp : n.raw), panelX + panelW - 8, ry + ROWH / 2);
+ rows.push({ node: n, x0: panelX, y0: ry, x1: panelX + panelW, y1: ry + ROWH });
+ }
+ ctx.strokeStyle = "rgba(150,180,230,0.3)"; ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(col.x, loupe.y);
+ ctx.lineTo(panelX + (isSrc ? 0 : panelW), topY + (ci - i0) * ROWH + ROWH / 2);
+ ctx.stroke();
+ ctx.restore();
+ loupe.rows = rows;
+ loupe.panel = { x0: panelX, y0: topY - 6, x1: panelX + panelW, y1: topY + SHOW * ROWH + 6 };
}
function placeColumn(list, x, top, usableH) {
@@ -2420,6 +2516,7 @@
else for (const n of sources.values()) drawSourceNode(n, t);
drawAspectCables(t); // the sky's geometry, drawn between its spheres
for (const n of dests.values()) drawDestNode(n, t);
+ if (!chakraMode) drawLoupe(t); // the hover zoom over a dense column
drawEditing(t);
if (performMode) drawPerform(t, dt);
drawHeader(t);
@@ -4190,7 +4287,9 @@
const r = canvas.getBoundingClientRect();
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
if (inGodBand(mx, my)) { godDrag = { x0: mx, bpm0: godtime.bpm, moved: false }; return; }
- const s = nodeAt(mx, my) || destAt(mx, my); // sources AND dests are clickable
+ const lp = loupeNodeAt(mx, my); // a magnified row in the hover zoom
+ if (lp) loupe.frozen = true; // hold the panel still through the press
+ const s = lp || nodeAt(mx, my) || destAt(mx, my);// sources AND dests are clickable
if (s && ev.shiftKey) { // ⇧-drag = ride this node's level
gainDrag = { node: s, y0: my, gain0: tw(s.key).gain, moved: false };
} else if (s) {
@@ -4237,12 +4336,14 @@
if (Math.hypot(mx - drag.x0, my - drag.y0) > 6) drag.moved = true;
hoverDest = drag.moved ? destAt(mx, my) : null;
} else {
+ updateLoupe(mx, my);
canvas.style.cursor = inGodBand(mx, my) ? "ew-resize"
- : (nodeAt(mx, my) || destAt(mx, my)) ? "pointer"
+ : (loupeNodeAt(mx, my) || nodeAt(mx, my) || destAt(mx, my)) ? "pointer"
: chakraMode ? "grab" : "default";
}
});
window.addEventListener("mouseup", (ev) => {
+ loupe.frozen = false; // the hover zoom follows the cursor again
if (chakraDrag) { chakraDrag = null; return; } // let go — momentum carries it
if (gainDrag) { // release the level ride
const gd = gainDrag; gainDrag = null;
@@ -4462,6 +4563,8 @@
ctxItem("graphics: " + fxLabel,
() => setFxMode(fxMode === "auto" ? "full" : fxMode === "full" ? "ultra"
: fxMode === "ultra" ? "lite" : "auto"));
+ ctxItem("hover zoom: " + (loupe.on ? "on" : "off"),
+ () => { loupe.on = !loupe.on; try { localStorage.setItem("gs_loupe", loupe.on ? "1" : "0"); } catch (e) {} });
if (soloKey) ctxItem("clear solo (" + soloKey + ")", () => { soloKey = null; });
ctxSep();
if (!hrDevice) {