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