usability: hover-zoom loupe for dense columns

The source column packs ~68 feeds ~8px apart while each hit-target is 28px
wide, so the whole column is one continuous blur — you cannot aim one node,
and labels overlap unreadably. The loupe fixes it: hovering a dense column
unfolds a magnified strip of the ~9 nearest feeds, comfortably spaced and
fully labelled with live values, the one under the cursor highlighted. Each
row is a live hit target (click=mute, shift=tab, cmd=group, drag=patch);
the panel holds still while you reach across it. Right-click sky -> hover
zoom to toggle (localStorage gs_loupe).

Verified: activation on hover, correct render + position, hit-test lands the
right node, shift-clicking a magnified row opens that source's tab. Two bugs
found & fixed during review: the panel's activation band excluded the panel
itself (vanished as you reached for it -> panel-hold), and column identity was
by object reference which goes stale when doLayout rebuilds columnInfo (wrong
side/position -> track by stable "src"/"dst" side key). drawLoupe wrapped so a
draw error can never kill the render loop.

Also verified this session (no code change needed): drag source->voice creates
a cable; cmd-click multi-selects for grouping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-06 12:06:09 +10:00
parent b8908504d3
commit 8318366d1c

View File

@ -885,6 +885,7 @@
<ul>
<li><b>Click any node — toggle it on or off.</b> A source sphere (<i>the sun, an earthquake, Delhi's air, bitcoin's volatility</i>) or a voice (<i>lead, bass, pad, drone, perc</i>): 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 <i>perc</i> and BTC's chaos stops driving the hats. Click again — it returns.</li>
<li><b>Drag a source onto a voice — lay a new cable.</b> This is composition by hand. Drag <i>quake.depth</i> onto <i>lead.note</i> and deep earthquakes start choosing the melody; drag <i>weather.wind</i> onto <i>drone.voices</i> and Tokyo's gusts thicken the choir. The cable is born live at a default amount, ready to bend.</li>
<li><b>Hover a crowded column — the zoom finds the one you want.</b> 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 <b>magnified strip</b> 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 → <i>hover zoom</i> to turn it off.)</li>
<li><b>Shift-click any node — open its tab.</b> The inspector tells you what the thing <i>is</i> (its real feed and label — <i>solar wind speed</i>, <i>PM2.5 over Delhi</i>, <i>the dealgod market's average price</i>), its <i>vibe</i> (which groups claim it), its <b>live value</b> ticking in real time, and every cable in and out — each with an <b>amount slider</b> to lean harder or lighter and a <b>×</b> to unpatch it entirely. This is where a rough idea becomes a mix.</li>
<li><b>⌘/Ctrl-click several sources, then group them — mint your own vibe.</b> Multi-select spheres by feeling, not category — a <i>shadow</i> of debt and inflation and quakes and the death rate, a <i>bright</i> 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 (<i>planet, cosmos, human, market, heavens, dark, light, and the seven elemental + seasonal blends</i>); yours join them.</li>
<li><b>♪ (top-right) — wake the sound.</b> 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.</li>
@ -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) {