diff --git a/viz/index.html b/viz/index.html
index 65fce5b..5e6e45a 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -2709,7 +2709,9 @@
+ "@keyframes zpulse{0%,100%{transform:scale(1);opacity:.9}50%{transform:scale(1.12);opacity:1}}"
+ ".zrack{position:absolute;left:210px;right:210px;bottom:20px;height:120px;pointer-events:auto;overflow:auto}"
+ ".zrack .grp{font-size:10px;letter-spacing:1px;color:rgba(150,170,205,.55);text-transform:uppercase;margin:2px 0 4px}"
- + ".zsock{display:inline-block;margin:0 7px 7px 0;padding:6px 10px;border-radius:9px;background:rgba(18,26,42,.7);border:1px solid rgba(120,150,200,.2);font-size:11.5px;color:#aab8d4}"
+ + ".zsock{display:inline-block;margin:0 7px 7px 0;padding:6px 10px;border-radius:9px;background:rgba(18,26,42,.7);border:1px solid rgba(120,150,200,.2);font-size:11.5px;color:#aab8d4;transition:border-color .12s,background .12s}"
+ + ".zsock.lit{border-color:rgba(120,255,178,.6);color:#dff5e8;background:rgba(28,56,46,.55)}"
+ + ".zsock.hot{border-color:rgba(150,200,255,.95);background:rgba(44,64,96,.7);color:#eaf1ff}"
+ ".zdrawer{position:absolute;top:76px;bottom:20px;left:0;width:288px;background:rgba(12,18,32,.95);border-radius:0 16px 16px 0;border:1px solid rgba(120,150,200,.25);box-shadow:0 8px 40px rgba(0,0,0,.5);opacity:0;transform:translateX(-16px);transition:opacity .18s,transform .18s;pointer-events:none;overflow:auto;padding:16px}"
+ ".zdrawer.right{left:auto;right:0;border-radius:16px 0 0 16px;transform:translateX(16px)}"
+ ".zdrawer.open{opacity:1;transform:translateX(0);pointer-events:auto}"
@@ -2738,13 +2740,17 @@
this.rings[key] = { ring, dot, lbl };
}
const stage = document.createElement("div"); stage.className = "zstage"; this.stage = stage;
- const rack = document.createElement("div"); rack.className = "zrack";
+ const rack = document.createElement("div"); rack.className = "zrack"; this.socks = {};
for (const sect of ZERO_RACK) {
const g = document.createElement("div"); g.className = "grp"; g.textContent = sect.grp; rack.appendChild(g);
const box = document.createElement("div");
for (const [key, label] of sect.items) {
const s = document.createElement("span"); s.className = "zsock"; s.textContent = label; s.dataset.dest = key;
- box.appendChild(s);
+ s.ondragover = (e) => { e.preventDefault(); s.classList.add("hot"); };
+ s.ondragleave = () => s.classList.remove("hot");
+ s.ondrop = (e) => { e.preventDefault(); s.classList.remove("hot"); this.routeGroup(e.dataTransfer.getData("text/plain"), key); };
+ s.oncontextmenu = (e) => { e.preventDefault(); this.assignMenu(e, key); };
+ this.socks[key] = s; box.appendChild(s);
}
rack.appendChild(box);
}
@@ -2753,7 +2759,7 @@
document.body.appendChild(w);
this.renderStage();
},
- open() { this.build(); this.root.classList.add("on"); this.renderStage(); },
+ open() { this.build(); this.root.classList.add("on"); this.renderStage(); this.refreshSockets(); },
close() { if (this.root) { this.root.classList.remove("on"); this.closeDrawer(); } },
openDrawer(key) {
this.build();
@@ -2806,7 +2812,9 @@
}
for (const gk of keys) { // each built group → a spinning orb; they accumulate
const meta = GROUP_META[gk], mem = [...this.nodes[gk].members];
- const orb = document.createElement("div"); orb.className = "zorb"; orb.title = mem.map(k => (sources.get(k) || {}).label || k).join(" · ");
+ const orb = document.createElement("div"); orb.className = "zorb"; orb.title = "drag me onto a voice → · " + mem.map(k => (sources.get(k) || {}).label || k).join(" · ");
+ orb.draggable = true;
+ orb.ondragstart = (e) => { e.dataTransfer.setData("text/plain", gk); e.dataTransfer.effectAllowed = "copy"; };
const orbit = document.createElement("div"); orbit.className = "orbit";
const R = 44 + Math.min(mem.length, 8) * 1.5; // more feeds → a slightly wider ring
mem.forEach((k, i) => {
@@ -2824,6 +2832,66 @@
this.stage.appendChild(orb); this.orbs.push(orb);
}
},
+ // ---- routing (Z2): an orb's feeds → an output socket ------------------
+ routeGroup(gk, destKey) {
+ const node = this.nodes[gk]; if (!node || !destKey) return;
+ const isNote = /note/.test(destKey.split(".").pop());
+ let n = 0;
+ for (const src of node.members) {
+ const r = { source: src, dest: destKey, amount: 0.5, curve: "lin" };
+ if (isNote) r.quantize = { scale: gScale.name || "minor_pent", root: /bass/.test(destKey) ? 36 : 60, octaves: 2 };
+ registerRoute(r); n++;
+ }
+ this.refreshSockets();
+ eventTicker.unshift({ text: "🌱 patched " + GROUP_META[gk].emoji + " " + gk + " → " + prettyDest(destKey)
+ + " (" + n + " cable" + (n > 1 ? "s" : "") + "). press ♪ to hear it" + (isNote ? " · right-click the voice to pick a scale" : ""), tAdded: now(), src: "sys" });
+ },
+ refreshSockets() {
+ if (!this.socks) return;
+ for (const dest in this.socks) {
+ const s = this.socks[dest], into = routesArr.filter(r => r.dest === dest);
+ s.classList.toggle("lit", into.length > 0);
+ s.dataset.n = into.length || "";
+ }
+ },
+ assignMenu(e, destKey) { // right-click a socket → assign the "synth roll"
+ const old = document.getElementById("zassign"); if (old) old.remove();
+ const into = routesArr.filter(r => r.dest === destKey);
+ if (!into.length) { eventTicker.unshift({ text: "🌱 nothing patched to " + prettyDest(destKey) + " yet — drag an orb onto it first", tAdded: now(), src: "sys" }); return; }
+ const isNote = /note/.test(destKey.split(".").pop());
+ const m = document.createElement("div"); m.id = "zassign";
+ Object.assign(m.style, { position: "fixed", left: Math.min(e.clientX, innerWidth - 230) + "px", top: Math.min(e.clientY, innerHeight - 200) + "px",
+ zIndex: "80", background: "rgba(14,20,34,.97)", border: "1px solid rgba(120,150,200,.35)", borderRadius: "12px",
+ padding: "12px 14px", minWidth: "210px", color: "#dbe6f8", fontSize: "12px", boxShadow: "0 8px 30px rgba(0,0,0,.5)" });
+ const title = document.createElement("div"); title.textContent = prettyDest(destKey); title.style.cssText = "font-weight:700;color:#eaf1ff;margin-bottom:8px";
+ m.appendChild(title);
+ const row = (label) => { const r = document.createElement("div"); r.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;margin:6px 0";
+ const l = document.createElement("span"); l.textContent = label; l.style.color = "rgba(180,195,225,.8)"; r.appendChild(l); return r; };
+ if (isNote) {
+ const q = into.find(r => r.quantize) && into.find(r => r.quantize).quantize || { scale: "minor_pent", root: 60, octaves: 2 };
+ const scRow = row("scale"); const sc = document.createElement("select");
+ for (const name in SCALES) { const o = document.createElement("option"); o.value = name; o.textContent = name; if (name === q.scale) o.selected = true; sc.appendChild(o); }
+ scRow.appendChild(sc); m.appendChild(scRow);
+ const rtRow = row("root"); const rt = document.createElement("input"); rt.type = "number"; rt.min = "24"; rt.max = "96"; rt.value = q.root; rt.style.width = "56px"; rtRow.appendChild(rt); m.appendChild(rtRow);
+ const ocRow = row("octaves"); const oc = document.createElement("input"); oc.type = "number"; oc.min = "1"; oc.max = "3"; oc.value = q.octaves; oc.style.width = "56px"; ocRow.appendChild(oc); m.appendChild(ocRow);
+ const apply = () => { const nq = { scale: sc.value, root: parseInt(rt.value) || 60, octaves: parseInt(oc.value) || 2 };
+ for (const r of into) r.quantize = nq; };
+ sc.onchange = apply; rt.onchange = apply; oc.onchange = apply;
+ } else {
+ const amRow = row("amount"); const am = document.createElement("input"); am.type = "range"; am.min = "0"; am.max = "1"; am.step = "0.01"; am.value = into[0].amount != null ? into[0].amount : 0.5; amRow.appendChild(am); m.appendChild(amRow);
+ const cvRow = row("curve"); const cv = document.createElement("select");
+ for (const c of ["lin", "exp", "exp3", "log", "scurve", "inv"]) { const o = document.createElement("option"); o.value = c; o.textContent = c; if (c === (into[0].curve || "lin")) o.selected = true; cv.appendChild(o); }
+ cvRow.appendChild(cv); m.appendChild(cvRow);
+ am.oninput = () => { for (const r of into) r.amount = parseFloat(am.value); };
+ cv.onchange = () => { for (const r of into) r.curve = cv.value; };
+ }
+ const unp = document.createElement("div"); unp.textContent = "× unpatch all"; unp.style.cssText = "margin-top:10px;color:#ff9a9a;cursor:pointer;font-size:11px";
+ unp.onclick = () => { for (const r of [...into]) removeRouteLocal(r.source, r.dest); this.refreshSockets(); m.remove(); };
+ m.appendChild(unp);
+ document.body.appendChild(m);
+ const off = (ev) => { if (!m.contains(ev.target)) { m.remove(); document.removeEventListener("mousedown", off, true); } };
+ setTimeout(() => document.addEventListener("mousedown", off, true), 0);
+ },
update() {
if (!this.root || !this.root.classList.contains("on")) return;
for (const key in this.rings) { // breathing glow = the group's live average