- world_debt.py + world_econ.py: US national debt + inflation (the dark half) - concept groups: "the shadow" / "the bright" — group sources by feeling - named templates: hub save_patch/load_patch + console TEMPLATES panel - editing console: runtime add/remove/set route + make_group; drag-to-patch, shift-click inspector, cmd-select grouping in the viz - planetary orbital LFOs per group; almanac + ephemeris workers - Web MIDI (out + learn) in the console; strudel/godstrument.md starter kit Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1417 lines
54 KiB
HTML
1417 lines
54 KiB
HTML
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||
<title>GODSTRUMENT</title>
|
||
<style>
|
||
html, body {
|
||
margin: 0; padding: 0; height: 100%; width: 100%;
|
||
background: #04060b; overflow: hidden;
|
||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||
-webkit-font-smoothing: antialiased;
|
||
cursor: default; user-select: none;
|
||
}
|
||
#stage { display: block; position: fixed; inset: 0; }
|
||
#gear {
|
||
position: fixed; top: 12px; right: 14px; z-index: 10;
|
||
width: 30px; height: 30px; border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: rgba(20,28,44,0.7); color: #9fb4d8; cursor: pointer;
|
||
font-size: 16px; border: 1px solid rgba(120,150,200,0.25);
|
||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
|
||
}
|
||
#gear:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.5); }
|
||
#panel {
|
||
position: fixed; top: 50px; right: 14px; z-index: 10;
|
||
width: 244px; max-height: 76vh; overflow-y: auto;
|
||
background: rgba(10,15,26,0.88); border: 1px solid rgba(120,150,200,0.22);
|
||
border-radius: 12px; padding: 12px 14px; display: none;
|
||
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
color: #c7d4ea; font-size: 12px;
|
||
}
|
||
#panel.open { display: block; }
|
||
#panel h3 { margin: 2px 0 8px; font-size: 12px; font-weight: 700; color: #9fd0ff; letter-spacing: .5px; }
|
||
#panel .hint { color: rgba(150,170,200,0.7); font-size: 11px; margin-bottom: 12px; line-height: 1.5; }
|
||
.grp { margin-bottom: 11px; padding-bottom: 9px; border-bottom: 1px solid rgba(120,150,200,0.12); }
|
||
.grp .row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||
.grp .nm { font-weight: 600; color: #dbe8ff; }
|
||
.grp .btns button {
|
||
background: rgba(40,52,74,0.8); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.2);
|
||
border-radius: 6px; padding: 2px 8px; font-size: 10px; cursor: pointer; margin-left: 4px;
|
||
}
|
||
.grp .btns button.on { background: rgba(255,90,90,0.25); color: #ffb3b3; border-color: rgba(255,120,120,0.5); }
|
||
.grp .btns button.frz.on { background: rgba(120,180,255,0.25); color: #bcd8ff; border-color: rgba(140,190,255,0.55); }
|
||
.grp input[type=range] { width: 100%; accent-color: #78a0ff; }
|
||
#panel .midibtn {
|
||
background: rgba(40,52,74,0.8); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer; margin-top: 2px;
|
||
}
|
||
#panel .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||
#panel .midisel {
|
||
background: rgba(20,28,44,0.9); color: #c7d4ea; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 6px; font-size: 11px; max-width: 152px; padding: 2px;
|
||
}
|
||
#panel .tplname {
|
||
background: rgba(20,28,44,0.9); color: #c7d4ea; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 6px; font-size: 11px; padding: 3px 6px; flex: 1; max-width: 120px; margin-right: 6px;
|
||
}
|
||
#inspect {
|
||
position: fixed; top: 96px; left: 14px; z-index: 11;
|
||
width: 262px; max-height: 74vh; overflow-y: auto;
|
||
background: rgba(10,15,26,0.92); border: 1px solid rgba(120,150,200,0.3);
|
||
border-radius: 12px; padding: 12px 14px; display: none;
|
||
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
color: #c7d4ea; font-size: 12px;
|
||
}
|
||
#inspect.open { display: block; }
|
||
#inspect h3 { margin: 0 24px 2px 0; font-size: 13px; color: #9fd0ff; }
|
||
#inspect .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0; }
|
||
#inspect .rt { display: flex; align-items: center; gap: 6px; margin: 5px 0; }
|
||
#inspect .rt .lbl { flex: 1; font-size: 11px; color: #cfe0f5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
#inspect .rt input[type=range] { width: 68px; accent-color: #78a0ff; }
|
||
#inspect .rt .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||
#inspect .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
|
||
#inspect .midibtn {
|
||
background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer;
|
||
}
|
||
#inspect .midibtn:disabled { opacity: 0.4; cursor: default; }
|
||
</style>
|
||
<canvas id="stage"></canvas>
|
||
<div id="gear" title="controls">⚙</div>
|
||
<div id="panel"></div>
|
||
<div id="inspect"></div>
|
||
<script>
|
||
"use strict";
|
||
(function () {
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Canvas / DPR setup
|
||
// ---------------------------------------------------------------------------
|
||
const canvas = document.getElementById("stage");
|
||
const ctx = canvas.getContext("2d");
|
||
let W = 0, H = 0, DPR = 1;
|
||
|
||
function resize() {
|
||
DPR = Math.min(window.devicePixelRatio || 1, 2.5);
|
||
W = window.innerWidth;
|
||
H = window.innerHeight;
|
||
canvas.width = Math.floor(W * DPR);
|
||
canvas.height = Math.floor(H * DPR);
|
||
canvas.style.width = W + "px";
|
||
canvas.style.height = H + "px";
|
||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||
layoutDirty = true;
|
||
}
|
||
window.addEventListener("resize", resize);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utility
|
||
// ---------------------------------------------------------------------------
|
||
const clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
|
||
const lerp = (a, b, t) => a + (b - a) * t;
|
||
function approach(cur, target, rate, dt) {
|
||
// frame-rate independent lerp
|
||
const t = 1 - Math.pow(1 - rate, dt * 60);
|
||
return cur + (target - cur) * t;
|
||
}
|
||
const easeOut = t => 1 - Math.pow(1 - t, 3);
|
||
const now = () => performance.now() / 1000;
|
||
|
||
// MIDI note number -> name
|
||
const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
|
||
function midiToName(n) {
|
||
n = Math.round(n);
|
||
if (!isFinite(n)) return "--";
|
||
const octave = Math.floor(n / 12) - 1;
|
||
return NOTE_NAMES[((n % 12) + 12) % 12] + octave;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Families & palette
|
||
// ---------------------------------------------------------------------------
|
||
// body/room = warm amber; planet/weather = teal/green; sky/space = blue/violet;
|
||
// markets = gold; human-net = magenta.
|
||
const FAMILIES = {
|
||
body: { name: "body & room", hue: 34, sat: 90, col: [255, 176, 74] },
|
||
planet: { name: "planet", hue: 165, sat: 75, col: [64, 224, 178] },
|
||
sky: { name: "sky & space", hue: 226, sat: 82, col: [120, 150, 255] },
|
||
markets:{ name: "markets", hue: 45, sat: 92, col: [255, 214, 92] },
|
||
human: { name: "human net", hue: 312, sat: 82, col: [246, 108, 236] },
|
||
other: { name: "signals", hue: 200, sat: 40, col: [150, 180, 210] }
|
||
};
|
||
|
||
function familyOf(key) {
|
||
const p = (key || "").split(".")[0].toLowerCase();
|
||
if (["tof","light","hand","audio","mic","touch","room","cam"].includes(p)) return "body";
|
||
if (["weather","air","quake","seismic","tide","temp"].includes(p)) return "planet";
|
||
if (["sun","iss","planes","plane","sky","aurora","moon","space"].includes(p)) return "sky";
|
||
if (["crypto","market","stock","btc","eth","fx"].includes(p)) return "markets";
|
||
if (["wiki","net","news","social","edit"].includes(p)) return "human";
|
||
return "other";
|
||
}
|
||
|
||
function rgba(col, a) {
|
||
return "rgba(" + col[0] + "," + col[1] + "," + col[2] + "," + a + ")";
|
||
}
|
||
function rgbMix(c, amt) {
|
||
// mix toward white by amt
|
||
return [
|
||
Math.round(lerp(c[0], 255, amt)),
|
||
Math.round(lerp(c[1], 255, amt)),
|
||
Math.round(lerp(c[2], 255, amt))
|
||
];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Model
|
||
// ---------------------------------------------------------------------------
|
||
const sources = new Map(); // key -> node
|
||
const dests = new Map(); // key -> node
|
||
const routesArr = []; // route objects
|
||
const routeIndex = new Map();// "src|dest" -> route
|
||
let helloReceived = false;
|
||
let layoutDirty = true;
|
||
let lastServerT = 0;
|
||
let groupsInfo = {};
|
||
|
||
const eventTicker = []; // {text, tAdded, src, mag}
|
||
const shockwaves = []; // active bursts
|
||
const particles = [];
|
||
|
||
function ensureSource(key, info) {
|
||
let n = sources.get(key);
|
||
if (!n) {
|
||
const fam = familyOf(key);
|
||
n = {
|
||
key, kind: "source", family: fam,
|
||
col: FAMILIES[fam].col,
|
||
label: (info && info.label) || key,
|
||
raw: 0, rawDisp: 0,
|
||
norm: 0, normDisp: 0,
|
||
isEvent: !!(info && info.event),
|
||
firePulse: 0, muted: false,
|
||
x: 0, y: 0, seed: Math.random() * 1000
|
||
};
|
||
sources.set(key, n);
|
||
layoutDirty = true;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
function ensureDest(key) {
|
||
let n = dests.get(key);
|
||
if (!n) {
|
||
const isNote = /\.note$/.test(key) || /note/.test(key.split(".").pop() || "");
|
||
n = {
|
||
key, kind: "dest",
|
||
label: prettyDest(key),
|
||
isNote,
|
||
raw: 0, rawDisp: 0, // for notes this is midi number
|
||
norm: 0, normDisp: 0,
|
||
x: 0, y: 0, seed: Math.random() * 1000,
|
||
family: destFamily(key)
|
||
};
|
||
dests.set(key, n);
|
||
layoutDirty = true;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
function destFamily(key) {
|
||
const p = key.split(".")[0].toLowerCase();
|
||
if (p.includes("lead") || p.includes("bass") || p.includes("pad") || p.includes("arp")) return "sky";
|
||
if (p.includes("filter") || p.includes("cutoff")) return "planet";
|
||
if (p.includes("drum") || p.includes("perc") || p.includes("kick")) return "body";
|
||
return "other";
|
||
}
|
||
|
||
function prettyDest(key) {
|
||
return key.replace(/\./g, " ").replace(/_/g, " ");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Layout — sources on left column, dests on right column
|
||
// ---------------------------------------------------------------------------
|
||
const HEADER_H = 96;
|
||
const FOOTER_H = 46;
|
||
|
||
function doLayout() {
|
||
layoutDirty = false;
|
||
const topPad = HEADER_H + 24;
|
||
const botPad = FOOTER_H + 20;
|
||
const usableH = Math.max(120, H - topPad - botPad);
|
||
|
||
// group sources by family for nicer grouping order
|
||
const srcList = Array.from(sources.values());
|
||
const famOrder = ["body","planet","sky","markets","human","other"];
|
||
srcList.sort((a, b) => {
|
||
const fa = famOrder.indexOf(a.family), fb = famOrder.indexOf(b.family);
|
||
if (fa !== fb) return fa - fb;
|
||
return a.key < b.key ? -1 : 1;
|
||
});
|
||
|
||
const dstList = Array.from(dests.values());
|
||
dstList.sort((a, b) => a.key < b.key ? -1 : 1);
|
||
|
||
const leftX = clamp(W * 0.20, 150, 320);
|
||
const rightX = W - clamp(W * 0.20, 150, 320);
|
||
|
||
placeColumn(srcList, leftX, topPad, usableH);
|
||
placeColumn(dstList, rightX, topPad, usableH);
|
||
}
|
||
|
||
function placeColumn(list, x, top, usableH) {
|
||
const n = list.length;
|
||
if (n === 0) return;
|
||
const gap = usableH / n;
|
||
for (let i = 0; i < n; i++) {
|
||
list[i].x = x;
|
||
list[i].targetY = top + gap * (i + 0.5);
|
||
if (list[i].y === 0) list[i].y = list[i].targetY;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// WebSocket
|
||
// ---------------------------------------------------------------------------
|
||
let ws = null;
|
||
let connected = false;
|
||
let reconnectTimer = null;
|
||
let connectingSince = now();
|
||
|
||
function connect() {
|
||
try {
|
||
ws = new WebSocket("ws://localhost:8765");
|
||
} catch (e) {
|
||
scheduleReconnect();
|
||
return;
|
||
}
|
||
ws.onopen = () => {
|
||
connected = true;
|
||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||
};
|
||
ws.onclose = () => { connected = false; scheduleReconnect(); };
|
||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||
ws.onmessage = (ev) => {
|
||
let msg;
|
||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||
handleMessage(msg);
|
||
};
|
||
}
|
||
|
||
function scheduleReconnect() {
|
||
connected = false;
|
||
connectingSince = now();
|
||
if (reconnectTimer) return;
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectTimer = null;
|
||
connect();
|
||
}, 1000);
|
||
}
|
||
|
||
function handleMessage(msg) {
|
||
if (msg.hello) {
|
||
helloReceived = true;
|
||
if (Array.isArray(msg.routes)) {
|
||
preLayoutRoutes(msg.routes);
|
||
}
|
||
if (Array.isArray(msg.patches)) patchList = msg.patches;
|
||
if (msg.groups) { groupsInfo = msg.groups; buildPanel(); }
|
||
if (msg.midi) midiMap = msg.midi;
|
||
return;
|
||
}
|
||
if (typeof msg.t === "number") lastServerT = msg.t;
|
||
|
||
if (msg.sources) {
|
||
for (const k in msg.sources) {
|
||
const s = msg.sources[k];
|
||
const n = ensureSource(k, s);
|
||
if (s.label) n.label = s.label;
|
||
if (typeof s.raw === "number") n.raw = s.raw;
|
||
if (typeof s.norm === "number") n.norm = clamp(s.norm, 0, 1);
|
||
if (s.event) n.isEvent = true;
|
||
n.muted = !!s.muted;
|
||
}
|
||
}
|
||
if (msg.dests) {
|
||
for (const k in msg.dests) {
|
||
const n = ensureDest(k);
|
||
const v = msg.dests[k];
|
||
n.raw = v;
|
||
if (n.isNote) {
|
||
// normalize midi-ish 0..127 into 0..1 for meter fill (centered on 36..96)
|
||
n.norm = clamp((v - 24) / (96 - 24), 0, 1);
|
||
} else {
|
||
n.norm = clamp(v, 0, 1);
|
||
}
|
||
}
|
||
sendDestsToMidi(msg.dests);
|
||
}
|
||
if (msg.routes) {
|
||
const present = new Set();
|
||
for (const r of msg.routes) { registerRoute(r); present.add(r.source + "|" + r.dest); }
|
||
// drop cables the hub no longer has (live unpatch)
|
||
for (let i = routesArr.length - 1; i >= 0; i--) {
|
||
const id = routesArr[i].source + "|" + routesArr[i].dest;
|
||
if (!present.has(id)) { routeIndex.delete(id); routesArr.splice(i, 1); }
|
||
}
|
||
}
|
||
if (msg.macros) {
|
||
let fresh = false;
|
||
for (const g in msg.macros) if (!(g in groupsInfo)) { groupsInfo[g] = { label: g }; fresh = true; }
|
||
if (fresh) { buildPanel(); if (typeof midiAccess !== "undefined" && midiAccess) populatePorts(); }
|
||
}
|
||
if (msg.patches) { patchList = msg.patches; refreshTemplates(); }
|
||
if (msg.events && Array.isArray(msg.events)) {
|
||
for (const e of msg.events) {
|
||
ingestEvent(e);
|
||
}
|
||
}
|
||
}
|
||
|
||
function preLayoutRoutes(rlist) {
|
||
for (const r of rlist) {
|
||
if (r.source) ensureSource(r.source, null);
|
||
if (r.dest) ensureDest(r.dest);
|
||
registerRoute(r, true);
|
||
}
|
||
layoutDirty = true;
|
||
}
|
||
|
||
function registerRoute(r, silent) {
|
||
if (!r.source || !r.dest) return;
|
||
ensureSource(r.source, null);
|
||
ensureDest(r.dest);
|
||
const id = r.source + "|" + r.dest;
|
||
let route = routeIndex.get(id);
|
||
if (!route) {
|
||
route = {
|
||
source: r.source, dest: r.dest,
|
||
active: 0, activeDisp: 0,
|
||
amount: (typeof r.amount === "number" ? r.amount : 0.5),
|
||
label: r.label || "",
|
||
dashPhase: Math.random() * 40
|
||
};
|
||
routeIndex.set(id, route);
|
||
routesArr.push(route);
|
||
layoutDirty = true;
|
||
}
|
||
if (typeof r.active === "number") route.active = clamp(r.active, 0, 1);
|
||
if (typeof r.amount === "number") route.amount = r.amount;
|
||
if (r.label) route.label = r.label;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Events -> ticker + shockwaves
|
||
// ---------------------------------------------------------------------------
|
||
const seenEventKeys = new Set();
|
||
|
||
function ingestEvent(e) {
|
||
const key = (e.src || "?") + "@" + (e.t != null ? e.t : Math.random());
|
||
if (seenEventKeys.has(key)) return;
|
||
seenEventKeys.add(key);
|
||
if (seenEventKeys.size > 400) {
|
||
// trim
|
||
const arr = Array.from(seenEventKeys);
|
||
for (let i = 0; i < 200; i++) seenEventKeys.delete(arr[i]);
|
||
}
|
||
|
||
const text = eventText(e);
|
||
eventTicker.unshift({ text, tAdded: now(), src: e.src, mag: e.mag, serverT: e.t });
|
||
if (eventTicker.length > 40) eventTicker.pop();
|
||
|
||
// trigger a fire pulse + shockwave at the matching source node
|
||
const node = findSourceForEvent(e.src);
|
||
if (node) {
|
||
node.firePulse = 1;
|
||
spawnShockwave(node, e.mag);
|
||
}
|
||
}
|
||
|
||
function eventText(e) {
|
||
const src = (e.src || "event");
|
||
if (/quake/i.test(src)) {
|
||
return "M" + (e.mag != null ? (+e.mag).toFixed(1) : "?") + " earthquake";
|
||
}
|
||
if (/wiki/i.test(src)) {
|
||
return "wiki edit " + (e.mag != null ? "+" + Math.round(e.mag) + "b" : "");
|
||
}
|
||
if (/plane|iss/i.test(src)) {
|
||
return src + " " + (e.mag != null ? "· " + e.mag : "");
|
||
}
|
||
return src + (e.mag != null ? " · " + e.mag : "");
|
||
}
|
||
|
||
function findSourceForEvent(src) {
|
||
if (!src) return null;
|
||
// exact key
|
||
if (sources.has(src)) return sources.get(src);
|
||
// prefix match: event src "quake" -> source "quake.event"
|
||
for (const [k, n] of sources) {
|
||
if (k.split(".")[0] === src) return n;
|
||
if (k.startsWith(src)) return n;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function spawnShockwave(node, mag) {
|
||
const strength = clamp((mag != null ? Math.log10(Math.abs(mag) + 1) / 3 : 0.5), 0.25, 1.2);
|
||
shockwaves.push({
|
||
x: node.x, y: node.y, r: 8, maxR: 60 + strength * 90,
|
||
life: 1, col: node.col, born: now()
|
||
});
|
||
const count = Math.floor(10 + strength * 26);
|
||
for (let i = 0; i < count; i++) {
|
||
const ang = Math.random() * Math.PI * 2;
|
||
const spd = 40 + Math.random() * (80 + strength * 160);
|
||
particles.push({
|
||
x: node.x, y: node.y,
|
||
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
|
||
life: 1, decay: 0.6 + Math.random() * 0.9,
|
||
size: 1 + Math.random() * 2.4, col: node.col
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Starfield
|
||
// ---------------------------------------------------------------------------
|
||
const stars = [];
|
||
function initStars() {
|
||
stars.length = 0;
|
||
const count = 170;
|
||
for (let i = 0; i < count; i++) {
|
||
stars.push({
|
||
x: Math.random(), y: Math.random(),
|
||
z: Math.random() * 0.9 + 0.1,
|
||
tw: Math.random() * Math.PI * 2,
|
||
drift: (Math.random() - 0.5) * 0.004
|
||
});
|
||
}
|
||
}
|
||
initStars();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Render loop
|
||
// ---------------------------------------------------------------------------
|
||
let lastFrame = now();
|
||
let pulseClock = 0;
|
||
|
||
function frame() {
|
||
const t = now();
|
||
let dt = t - lastFrame;
|
||
if (dt > 0.1) dt = 0.1;
|
||
lastFrame = t;
|
||
pulseClock += dt;
|
||
|
||
if (layoutDirty) doLayout();
|
||
|
||
// advance smoothing
|
||
for (const n of sources.values()) {
|
||
n.normDisp = approach(n.normDisp, n.norm, 0.16, dt);
|
||
n.rawDisp = approach(n.rawDisp, n.raw, 0.16, dt);
|
||
if (n.y !== n.targetY && n.targetY != null) n.y = approach(n.y, n.targetY, 0.12, dt);
|
||
if (n.firePulse > 0) n.firePulse = Math.max(0, n.firePulse - dt * 1.8);
|
||
}
|
||
for (const n of dests.values()) {
|
||
n.normDisp = approach(n.normDisp, n.norm, 0.16, dt);
|
||
n.rawDisp = approach(n.rawDisp, n.raw, 0.22, dt);
|
||
if (n.y !== n.targetY && n.targetY != null) n.y = approach(n.y, n.targetY, 0.12, dt);
|
||
}
|
||
for (const r of routesArr) {
|
||
r.activeDisp = approach(r.activeDisp, r.active, 0.14, dt);
|
||
r.dashPhase -= dt * (18 + r.activeDisp * 90);
|
||
}
|
||
|
||
drawBackground(t);
|
||
drawCables(t);
|
||
drawShockwaves(t, dt);
|
||
drawParticles(dt);
|
||
for (const n of sources.values()) drawSourceNode(n, t);
|
||
for (const n of dests.values()) drawDestNode(n, t);
|
||
drawEditing(t);
|
||
drawHeader(t);
|
||
drawFooter(t);
|
||
if (!connected) drawConnecting(t);
|
||
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Background
|
||
// ---------------------------------------------------------------------------
|
||
function drawBackground(t) {
|
||
// base gradient
|
||
const g = ctx.createRadialGradient(W * 0.5, H * 0.42, 40, W * 0.5, H * 0.5, Math.max(W, H) * 0.75);
|
||
g.addColorStop(0, "#0a1120");
|
||
g.addColorStop(0.55, "#060a13");
|
||
g.addColorStop(1, "#020306");
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// starfield
|
||
for (const s of stars) {
|
||
s.y += s.drift * 0.06;
|
||
if (s.y > 1.02) s.y -= 1.04; else if (s.y < -0.02) s.y += 1.04;
|
||
const px = s.x * W;
|
||
const py = s.y * H;
|
||
const tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * (0.6 + s.z) + s.tw));
|
||
const a = tw * s.z * 0.55;
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, s.z * 1.3, 0, Math.PI * 2);
|
||
ctx.fillStyle = "rgba(190,205,255," + a.toFixed(3) + ")";
|
||
ctx.fill();
|
||
}
|
||
|
||
// vignette
|
||
const vg = ctx.createRadialGradient(W * 0.5, H * 0.5, Math.min(W, H) * 0.35, W * 0.5, H * 0.5, Math.max(W, H) * 0.72);
|
||
vg.addColorStop(0, "rgba(0,0,0,0)");
|
||
vg.addColorStop(1, "rgba(0,0,0,0.72)");
|
||
ctx.fillStyle = vg;
|
||
ctx.fillRect(0, 0, W, H);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cables
|
||
// ---------------------------------------------------------------------------
|
||
function drawCables(t) {
|
||
ctx.lineCap = "round";
|
||
for (const r of routesArr) {
|
||
const s = sources.get(r.source);
|
||
const d = dests.get(r.dest);
|
||
if (!s || !d) continue;
|
||
const a = r.activeDisp;
|
||
|
||
const x1 = s.x + NODE_R + 6, y1 = s.y;
|
||
const x2 = d.x - NODE_R - 6, y2 = d.y;
|
||
const midx = (x1 + x2) / 2;
|
||
const cx1 = lerp(x1, midx, 0.85) + 40;
|
||
const cx2 = lerp(midx, x2, 0.15) - 40;
|
||
|
||
const col = s.col;
|
||
const glow = rgbMix(col, 0.25);
|
||
|
||
// idle base line
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.bezierCurveTo(cx1, y1, cx2, y2, x2, y2);
|
||
ctx.lineWidth = 1 + a * 3.5;
|
||
ctx.strokeStyle = rgba(col, 0.08 + a * 0.30);
|
||
ctx.setLineDash([]);
|
||
ctx.stroke();
|
||
|
||
if (a > 0.02) {
|
||
// glowing flowing dashes
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.bezierCurveTo(cx1, y1, cx2, y2, x2, y2);
|
||
ctx.lineWidth = 1.4 + a * 4.5;
|
||
ctx.strokeStyle = rgba(glow, 0.35 + a * 0.5);
|
||
ctx.setLineDash([2 + a * 8, 10 - a * 4]);
|
||
ctx.lineDashOffset = r.dashPhase;
|
||
ctx.shadowColor = rgba(col, 0.6);
|
||
ctx.shadowBlur = 6 + a * 14;
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// travelling pulse dot
|
||
const pt = ((t * (0.15 + a * 0.6) + r.dashPhase * 0.002) % 1 + 1) % 1;
|
||
const bp = bezierPoint(x1, y1, cx1, y1, cx2, y2, x2, y2, pt);
|
||
ctx.beginPath();
|
||
ctx.arc(bp.x, bp.y, 1.5 + a * 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = rgba(rgbMix(col, 0.5), 0.6 + a * 0.4);
|
||
ctx.shadowColor = rgba(col, 0.9);
|
||
ctx.shadowBlur = 8 + a * 10;
|
||
ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
function bezierPoint(x1,y1,cx1,cy1,cx2,cy2,x2,y2,t) {
|
||
const mt = 1 - t;
|
||
const a = mt*mt*mt, b = 3*mt*mt*t, c = 3*mt*t*t, d = t*t*t;
|
||
return {
|
||
x: a*x1 + b*cx1 + c*cx2 + d*x2,
|
||
y: a*y1 + b*cy1 + c*cy2 + d*y2
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Nodes
|
||
// ---------------------------------------------------------------------------
|
||
const NODE_R = 16;
|
||
|
||
function drawSourceNode(n, t) {
|
||
const col = n.col;
|
||
const x = n.x, y = n.y;
|
||
const pulse = n.firePulse;
|
||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.1 + n.seed);
|
||
|
||
// outer halo
|
||
const halo = 0.10 + n.normDisp * 0.28 + pulse * 0.5;
|
||
const hg = ctx.createRadialGradient(x, y, 2, x, y, NODE_R + 26 + pulse * 20);
|
||
hg.addColorStop(0, rgba(col, halo));
|
||
hg.addColorStop(1, rgba(col, 0));
|
||
ctx.fillStyle = hg;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 26 + pulse * 20, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// radial meter arc (norm)
|
||
const startA = Math.PI * 0.75;
|
||
const endA = Math.PI * 0.75 + Math.PI * 1.5;
|
||
// track
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 5, startA, endA);
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = rgba(col, 0.14);
|
||
ctx.stroke();
|
||
// fill
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 5, startA, startA + (endA - startA) * n.normDisp);
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.25), 0.85);
|
||
ctx.shadowColor = rgba(col, 0.7);
|
||
ctx.shadowBlur = 8;
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// core disc
|
||
const cg = ctx.createRadialGradient(x - 4, y - 4, 1, x, y, NODE_R);
|
||
cg.addColorStop(0, rgba(rgbMix(col, 0.55 + pulse * 0.4), 0.95));
|
||
cg.addColorStop(1, rgba(col, 0.35 + breathe * 0.12));
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R, 0, Math.PI * 2);
|
||
ctx.fillStyle = cg;
|
||
ctx.fill();
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.4), 0.5);
|
||
ctx.stroke();
|
||
|
||
// event indicator ring
|
||
if (n.isEvent) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R - 5, 0, Math.PI * 2);
|
||
ctx.setLineDash([2, 3]);
|
||
ctx.lineDashOffset = -t * 8;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.4), 0.6 + pulse * 0.4);
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
// label (to the LEFT of node)
|
||
ctx.textAlign = "right";
|
||
ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 13px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = rgba(rgbMix(col, 0.55), 0.95);
|
||
ctx.fillText(truncate(n.label, 22), x - NODE_R - 14, y - 1);
|
||
ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,165,190,0.7)";
|
||
ctx.fillText(fmtRaw(n.rawDisp), x - NODE_R - 14, y + 13);
|
||
|
||
// multi-select highlight ring
|
||
if (selection.has(n.key)) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 9, 0, Math.PI * 2);
|
||
ctx.strokeStyle = "rgba(255,238,150,0.9)";
|
||
ctx.lineWidth = 2;
|
||
ctx.setLineDash([3, 3]); ctx.lineDashOffset = -t * 6;
|
||
ctx.stroke(); ctx.setLineDash([]);
|
||
}
|
||
|
||
// muted: dark veil + red slash
|
||
if (n.muted) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = "rgba(4,6,11,0.62)";
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.moveTo(x - NODE_R + 2, y - NODE_R + 2);
|
||
ctx.lineTo(x + NODE_R - 2, y + NODE_R - 2);
|
||
ctx.lineWidth = 2.5;
|
||
ctx.strokeStyle = "rgba(255,95,95,0.85)";
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawDestNode(n, t) {
|
||
const fam = FAMILIES[n.family] || FAMILIES.other;
|
||
const col = fam.col;
|
||
const x = n.x, y = n.y;
|
||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.3 + n.seed);
|
||
|
||
const halo = 0.08 + n.normDisp * 0.22;
|
||
const hg = ctx.createRadialGradient(x, y, 2, x, y, NODE_R + 22);
|
||
hg.addColorStop(0, rgba(col, halo));
|
||
hg.addColorStop(1, rgba(col, 0));
|
||
ctx.fillStyle = hg;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 22, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// meter arc
|
||
const startA = Math.PI * 0.75;
|
||
const endA = Math.PI * 0.75 + Math.PI * 1.5;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 5, startA, endA);
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = rgba(col, 0.14);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 5, startA, startA + (endA - startA) * n.normDisp);
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.3), 0.85);
|
||
ctx.shadowColor = rgba(col, 0.6);
|
||
ctx.shadowBlur = 7;
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// core
|
||
const cg = ctx.createRadialGradient(x - 4, y - 4, 1, x, y, NODE_R);
|
||
cg.addColorStop(0, rgba(rgbMix(col, 0.5), 0.92));
|
||
cg.addColorStop(1, rgba(col, 0.3 + breathe * 0.1));
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R, 0, Math.PI * 2);
|
||
ctx.fillStyle = cg;
|
||
ctx.fill();
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.35), 0.5);
|
||
ctx.stroke();
|
||
|
||
// center value for notes
|
||
if (n.isNote) {
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.font = "700 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(10,14,22,0.9)";
|
||
ctx.fillText(midiToName(n.raw), x, y + 0.5);
|
||
}
|
||
|
||
// label (to the RIGHT of node)
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 13px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = rgba(rgbMix(col, 0.5), 0.95);
|
||
ctx.fillText(truncate(n.label, 22), x + NODE_R + 14, y - 1);
|
||
ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,165,190,0.7)";
|
||
const sub = n.isNote ? ("note " + Math.round(n.raw)) : fmtRaw(n.rawDisp);
|
||
ctx.fillText(sub, x + NODE_R + 14, y + 13);
|
||
}
|
||
|
||
function fmtRaw(v) {
|
||
if (!isFinite(v)) return "--";
|
||
const a = Math.abs(v);
|
||
if (a >= 10000) return (v / 1000).toFixed(1) + "k";
|
||
if (a >= 100) return v.toFixed(0);
|
||
if (a >= 10) return v.toFixed(1);
|
||
return v.toFixed(2);
|
||
}
|
||
function truncate(s, n) {
|
||
s = String(s || "");
|
||
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shockwaves & particles
|
||
// ---------------------------------------------------------------------------
|
||
function drawShockwaves(t, dt) {
|
||
for (let i = shockwaves.length - 1; i >= 0; i--) {
|
||
const s = shockwaves[i];
|
||
const age = t - s.born;
|
||
const p = clamp(age / 1.1, 0, 1);
|
||
s.r = lerp(8, s.maxR, easeOut(p));
|
||
const a = (1 - p) * 0.6;
|
||
if (p >= 1) { shockwaves.splice(i, 1); continue; }
|
||
ctx.beginPath();
|
||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||
ctx.lineWidth = 2 * (1 - p) + 0.5;
|
||
ctx.strokeStyle = rgba(rgbMix(s.col, 0.3), a);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawParticles(dt) {
|
||
for (let i = particles.length - 1; i >= 0; i--) {
|
||
const p = particles[i];
|
||
p.life -= p.decay * dt;
|
||
if (p.life <= 0) { particles.splice(i, 1); continue; }
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
p.vx *= (1 - 2.2 * dt);
|
||
p.vy *= (1 - 2.2 * dt);
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
|
||
ctx.fillStyle = rgba(rgbMix(p.col, 0.4), clamp(p.life, 0, 1) * 0.8);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Editing overlay — rubber-band cable while dragging a patch
|
||
// ---------------------------------------------------------------------------
|
||
function drawEditing(t) {
|
||
if (!drag || !drag.moved) return;
|
||
const s = drag.node;
|
||
ctx.beginPath();
|
||
ctx.moveTo(s.x, s.y);
|
||
ctx.lineTo(drag.cx, drag.cy);
|
||
ctx.setLineDash([4, 5]); ctx.lineDashOffset = -t * 22;
|
||
ctx.strokeStyle = "rgba(180,212,255,0.85)"; ctx.lineWidth = 2;
|
||
ctx.stroke(); ctx.setLineDash([]);
|
||
ctx.beginPath(); ctx.arc(drag.cx, drag.cy, 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = "rgba(205,228,255,0.95)"; ctx.fill();
|
||
if (hoverDest) {
|
||
ctx.beginPath();
|
||
ctx.arc(hoverDest.x, hoverDest.y, NODE_R + 8, 0, Math.PI * 2);
|
||
ctx.strokeStyle = "rgba(140,255,180,0.95)"; ctx.lineWidth = 2.5; ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Header
|
||
// ---------------------------------------------------------------------------
|
||
function drawHeader(t) {
|
||
// wordmark
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "alphabetic";
|
||
const wm = "GODSTRUMENT";
|
||
ctx.font = "800 30px 'Helvetica Neue', Arial, sans-serif";
|
||
const grad = ctx.createLinearGradient(34, 0, 34 + 360, 0);
|
||
grad.addColorStop(0, "#9fd0ff");
|
||
grad.addColorStop(0.5, "#f6c8ff");
|
||
grad.addColorStop(1, "#ffd98a");
|
||
ctx.fillStyle = grad;
|
||
// subtle letter spacing
|
||
drawSpacedText(wm, 34, 50, 4.5, ctx.font);
|
||
|
||
ctx.font = "500 12px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,170,200,0.65)";
|
||
ctx.fillText("an instrument played by the world", 36, 70);
|
||
|
||
// status line — placed in the clear zone RIGHT of the wordmark, LEFT of the
|
||
// counts/⚙, so nothing overlaps at any width
|
||
const wmRight = 34 + measureSpaced(wm, "800 30px 'Helvetica Neue', Arial, sans-serif") + wm.length * 4.5;
|
||
const status = connected ? statusPhrase() : "connecting to the hub…";
|
||
ctx.font = "600 14px 'Helvetica Neue', Arial, sans-serif";
|
||
const statusW = ctx.measureText(status).width;
|
||
const zoneL = wmRight + 46, zoneR = W - 210;
|
||
const cx = (zoneR - zoneL > statusW) ? (zoneL + zoneR) / 2
|
||
: Math.max(zoneL + statusW / 2 + 8, wmRight + 60);
|
||
const pulse = 0.6 + 0.4 * Math.sin(t * 2);
|
||
ctx.textAlign = "center";
|
||
ctx.fillStyle = connected
|
||
? "rgba(120,230,190," + pulse.toFixed(3) + ")"
|
||
: "rgba(255,180,120," + pulse.toFixed(3) + ")";
|
||
ctx.fillText(status, cx, 40);
|
||
ctx.beginPath();
|
||
ctx.arc(cx - statusW / 2 - 12, 35, 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = connected ? "rgba(90,240,170," + pulse + ")" : "rgba(255,150,90," + pulse + ")";
|
||
ctx.fill();
|
||
|
||
// counts (right, kept clear of the ⚙ gear button)
|
||
ctx.textAlign = "right";
|
||
ctx.font = "500 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(130,150,180,0.6)";
|
||
ctx.fillText(sources.size + " sources · " + routesArr.length + " cables · " + dests.size + " voices", W - 58, 34);
|
||
if (lastServerT) {
|
||
ctx.fillStyle = "rgba(110,130,160,0.5)";
|
||
ctx.fillText("t+" + lastServerT.toFixed(1) + "s", W - 58, 50);
|
||
}
|
||
|
||
// header underline glow
|
||
const lg = ctx.createLinearGradient(0, 0, W, 0);
|
||
lg.addColorStop(0, "rgba(90,140,255,0)");
|
||
lg.addColorStop(0.5, "rgba(140,180,255,0.25)");
|
||
lg.addColorStop(1, "rgba(255,180,120,0)");
|
||
ctx.fillStyle = lg;
|
||
ctx.fillRect(0, HEADER_H - 1, W, 1);
|
||
}
|
||
|
||
function statusPhrase() {
|
||
// pick liveliest source family
|
||
let best = null, bestV = -1;
|
||
for (const n of sources.values()) {
|
||
if (n.normDisp > bestV) { bestV = n.normDisp; best = n; }
|
||
}
|
||
if (best && bestV > 0.5) {
|
||
const fam = FAMILIES[best.family];
|
||
return "the " + fam.name + " is playing";
|
||
}
|
||
return "the planet is playing";
|
||
}
|
||
|
||
function drawSpacedText(str, x, y, spacing, font) {
|
||
ctx.save();
|
||
ctx.font = font;
|
||
let cx = x;
|
||
for (const ch of str) {
|
||
ctx.fillText(ch, cx, y);
|
||
cx += ctx.measureText(ch).width + spacing;
|
||
}
|
||
ctx.restore();
|
||
}
|
||
function measureSpaced(str, font) {
|
||
ctx.save();
|
||
ctx.font = font;
|
||
const w = ctx.measureText(str).width;
|
||
ctx.restore();
|
||
return w;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Footer ticker
|
||
// ---------------------------------------------------------------------------
|
||
let tickerOffset = 0;
|
||
function drawFooter(t) {
|
||
const y = H - FOOTER_H / 2;
|
||
// bar
|
||
ctx.fillStyle = "rgba(6,10,18,0.72)";
|
||
ctx.fillRect(0, H - FOOTER_H, W, FOOTER_H);
|
||
const tg = ctx.createLinearGradient(0, H - FOOTER_H, 0, H);
|
||
tg.addColorStop(0, "rgba(120,160,255,0.12)");
|
||
tg.addColorStop(1, "rgba(120,160,255,0)");
|
||
ctx.fillStyle = tg;
|
||
ctx.fillRect(0, H - FOOTER_H, W, 2);
|
||
|
||
ctx.textBaseline = "middle";
|
||
ctx.textAlign = "left";
|
||
ctx.font = "600 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(140,160,190,0.55)";
|
||
ctx.fillText("RECENT EVENTS", 20, y);
|
||
|
||
const startX = 150;
|
||
// build scrolling string entries
|
||
ctx.font = "500 12px 'Helvetica Neue', Arial, sans-serif";
|
||
let x = startX - tickerOffset;
|
||
const spacing = 46;
|
||
let totalW = 0;
|
||
const segs = [];
|
||
for (const e of eventTicker) {
|
||
const ago = Math.max(0, Math.floor(now() - e.tAdded));
|
||
const label = e.text + " · " + ago + "s ago";
|
||
const w = ctx.measureText(label).width;
|
||
segs.push({ label, w, col: colForEventSrc(e.src) });
|
||
totalW += w + spacing;
|
||
}
|
||
if (totalW < 10) totalW = W; // avoid div issues
|
||
|
||
for (const seg of segs) {
|
||
if (x + seg.w > startX - 40 && x < W + 40) {
|
||
// fade near left edge
|
||
let alpha = 1;
|
||
if (x < startX + 30) alpha = clamp((x - startX + 10) / 40, 0, 1);
|
||
ctx.fillStyle = rgba(seg.col, 0.85 * alpha);
|
||
ctx.beginPath();
|
||
ctx.arc(x - 8, y, 2.2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.fillStyle = "rgba(200,215,240," + (0.85 * alpha).toFixed(3) + ")";
|
||
ctx.fillText(seg.label, x, y);
|
||
}
|
||
x += seg.w + spacing;
|
||
}
|
||
|
||
// advance scroll; loop
|
||
tickerOffset += 0.7;
|
||
const loopW = totalW;
|
||
if (tickerOffset > loopW && segs.length) tickerOffset -= loopW;
|
||
if (!segs.length) {
|
||
tickerOffset = 0;
|
||
ctx.fillStyle = "rgba(120,140,170,0.4)";
|
||
ctx.fillText("listening for the world…", startX, y);
|
||
}
|
||
|
||
// clip mask left of label
|
||
ctx.fillStyle = "rgba(6,10,18,0.9)";
|
||
ctx.fillRect(0, H - FOOTER_H, startX - 6, FOOTER_H);
|
||
ctx.fillStyle = "rgba(140,160,190,0.55)";
|
||
ctx.font = "600 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillText("RECENT EVENTS", 20, y);
|
||
}
|
||
|
||
function colForEventSrc(src) {
|
||
const node = findSourceForEvent(src);
|
||
if (node) return node.col;
|
||
const fam = familyOf(src || "");
|
||
return FAMILIES[fam].col;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Connecting overlay
|
||
// ---------------------------------------------------------------------------
|
||
function drawConnecting(t) {
|
||
if (sources.size > 0) return; // only full overlay before any data
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.font = "500 15px 'Helvetica Neue', Arial, sans-serif";
|
||
const a = 0.4 + 0.4 * Math.sin(t * 2.2);
|
||
ctx.fillStyle = "rgba(160,190,230," + a.toFixed(3) + ")";
|
||
ctx.fillText("connecting to the hub…", W / 2, H / 2);
|
||
// spinner
|
||
const r = 22;
|
||
ctx.beginPath();
|
||
ctx.arc(W / 2, H / 2 - 44, r, t * 2, t * 2 + Math.PI * 1.4);
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeStyle = "rgba(150,200,255,0.7)";
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Interaction + Web MIDI — click-to-mute, ⚙ panel, MIDI out + learn
|
||
// ---------------------------------------------------------------------------
|
||
const panelEl = document.getElementById("panel");
|
||
const gearEl = document.getElementById("gear");
|
||
gearEl.addEventListener("click", () => panelEl.classList.toggle("open"));
|
||
|
||
function sendCmd(obj) {
|
||
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
|
||
}
|
||
|
||
function nodeAt(mx, my) {
|
||
let hit = null, best = (NODE_R + 12) * (NODE_R + 12);
|
||
for (const n of sources.values()) {
|
||
const dx = mx - n.x, dy = my - n.y, d2 = dx * dx + dy * dy;
|
||
if (d2 < best) { best = d2; hit = n; }
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
// ---- Web MIDI state ----
|
||
let midiAccess = null, midiOut = null, midiIn = null;
|
||
let learnMode = false, learnTarget = null;
|
||
let patchList = [], templateSelect = null;
|
||
const midiBindings = loadBindings(); // "cc:17" / "note:36" -> {target,param,mode}
|
||
const lastCCSent = {}, lastNoteFor = {};
|
||
let outSel = null, inSel = null, midiStatusEl = null, learnBtn = null, bindCountEl = null;
|
||
|
||
function loadBindings() {
|
||
try { return JSON.parse(localStorage.getItem("gs_midi_bindings") || "{}"); }
|
||
catch (e) { return {}; }
|
||
}
|
||
function saveBindings() {
|
||
try { localStorage.setItem("gs_midi_bindings", JSON.stringify(midiBindings)); } catch (e) {}
|
||
if (bindCountEl) bindCountEl.textContent = Object.keys(midiBindings).length + " mapped";
|
||
}
|
||
function armLearn(target) {
|
||
if (!learnMode) return false;
|
||
learnTarget = target;
|
||
if (midiStatusEl) midiStatusEl.textContent = "learning " + target.target + " · " + target.param + " — now move a knob…";
|
||
return true;
|
||
}
|
||
|
||
// ---- editing: drag-to-patch, shift-inspect, cmd-select, click-mute ----
|
||
const inspectEl = document.getElementById("inspect");
|
||
const selection = new Set();
|
||
let drag = null; // {node, x0,y0, cx,cy, moved}
|
||
let hoverDest = null;
|
||
|
||
function destAt(mx, my) {
|
||
let hit = null, best = (NODE_R + 12) * (NODE_R + 12);
|
||
for (const n of dests.values()) {
|
||
const dx = mx - n.x, dy = my - n.y, d2 = dx * dx + dy * dy;
|
||
if (d2 < best) { best = d2; hit = n; }
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
canvas.addEventListener("mousedown", (ev) => {
|
||
const r = canvas.getBoundingClientRect();
|
||
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
||
const s = nodeAt(mx, my);
|
||
if (s) drag = { node: s, x0: mx, y0: my, cx: mx, cy: my, moved: false };
|
||
});
|
||
window.addEventListener("mousemove", (ev) => {
|
||
const r = canvas.getBoundingClientRect();
|
||
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
||
if (drag) {
|
||
drag.cx = mx; drag.cy = my;
|
||
if (Math.hypot(mx - drag.x0, my - drag.y0) > 6) drag.moved = true;
|
||
hoverDest = drag.moved ? destAt(mx, my) : null;
|
||
} else {
|
||
canvas.style.cursor = (nodeAt(mx, my) || destAt(mx, my)) ? "pointer" : "default";
|
||
}
|
||
});
|
||
window.addEventListener("mouseup", (ev) => {
|
||
if (!drag) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
||
const d = drag; drag = null; hoverDest = null;
|
||
if (d.moved) { // drag-to-patch
|
||
const dest = destAt(mx, my);
|
||
if (dest) sendCmd({ cmd: "add_route", source: d.node.key, dest: dest.key, amount: 0.5 });
|
||
} else if (ev.shiftKey) { // shift-click -> inspector
|
||
openInspector(d.node);
|
||
} else if (ev.metaKey || ev.ctrlKey) { // cmd/ctrl-click -> multi-select
|
||
toggleSelect(d.node.key);
|
||
} else if (armLearn({ target: d.node.key, param: "mute", mode: "toggle" })) {
|
||
/* armed for MIDI learn */
|
||
} else { // plain click -> mute
|
||
d.node.muted = !d.node.muted;
|
||
sendCmd({ cmd: "mute_toggle", target: d.node.key });
|
||
}
|
||
});
|
||
|
||
function toggleSelect(key) {
|
||
if (selection.has(key)) selection.delete(key); else selection.add(key);
|
||
updateGroupBtn();
|
||
}
|
||
|
||
function openInspector(node) {
|
||
inspectEl.innerHTML = "";
|
||
const close = document.createElement("span");
|
||
close.className = "close"; close.textContent = "×";
|
||
close.onclick = () => inspectEl.classList.remove("open");
|
||
inspectEl.appendChild(close);
|
||
const h = document.createElement("h3"); h.textContent = node.label || node.key;
|
||
inspectEl.appendChild(h);
|
||
const fam = FAMILIES[node.family] ? FAMILIES[node.family].name : "";
|
||
const sub = document.createElement("div"); sub.className = "sub";
|
||
sub.textContent = node.key + (fam ? " · " + fam : "");
|
||
inspectEl.appendChild(sub);
|
||
|
||
const isSource = node.kind === "source";
|
||
const rel = routesArr.filter(rt => isSource ? rt.source === node.key : rt.dest === node.key);
|
||
const title = document.createElement("div"); title.className = "sub";
|
||
title.textContent = rel.length ? (isSource ? "connects to:" : "driven by:") : "no connections yet";
|
||
inspectEl.appendChild(title);
|
||
|
||
for (const rt of rel) {
|
||
const row = document.createElement("div"); row.className = "rt";
|
||
const lbl = document.createElement("span"); lbl.className = "lbl";
|
||
lbl.textContent = isSource ? ("→ " + prettyDest(rt.dest)) : ("← " + rt.source);
|
||
const sl = document.createElement("input");
|
||
sl.type = "range"; sl.min = "0"; sl.max = "1"; sl.step = "0.01";
|
||
sl.value = String(rt.amount != null ? rt.amount : 0.5); sl.title = "amount";
|
||
sl.oninput = () => sendCmd({ cmd: "set_route", source: rt.source, dest: rt.dest, amount: parseFloat(sl.value) });
|
||
const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "unpatch";
|
||
x.onclick = () => { sendCmd({ cmd: "remove_route", source: rt.source, dest: rt.dest }); setTimeout(() => openInspector(node), 140); };
|
||
row.appendChild(lbl); row.appendChild(sl); row.appendChild(x);
|
||
inspectEl.appendChild(row);
|
||
}
|
||
if (isSource) {
|
||
const tip = document.createElement("div"); tip.className = "sub"; tip.style.marginTop = "8px";
|
||
tip.textContent = "drag this sphere onto a destination to add an output.";
|
||
inspectEl.appendChild(tip);
|
||
}
|
||
const grp = document.createElement("div"); grp.id = "grpbtn"; grp.style.marginTop = "8px";
|
||
inspectEl.appendChild(grp);
|
||
updateGroupBtn();
|
||
inspectEl.classList.add("open");
|
||
}
|
||
|
||
function updateGroupBtn() {
|
||
const grp = document.getElementById("grpbtn");
|
||
if (!grp) return;
|
||
grp.innerHTML = "";
|
||
const btn = document.createElement("button"); btn.className = "midibtn";
|
||
btn.textContent = "group selected (" + selection.size + ")";
|
||
btn.disabled = selection.size < 2;
|
||
btn.onclick = () => {
|
||
if (selection.size < 2) return;
|
||
const name = "group" + (Object.keys(groupsInfo).length + 1);
|
||
sendCmd({ cmd: "make_group", name: name, members: Array.from(selection) });
|
||
selection.clear(); updateGroupBtn();
|
||
};
|
||
grp.appendChild(btn);
|
||
const hint = document.createElement("div"); hint.className = "sub"; hint.style.marginTop = "4px";
|
||
hint.textContent = "⌘/ctrl-click spheres to select, then group.";
|
||
grp.appendChild(hint);
|
||
}
|
||
|
||
// ---- Web MIDI ----
|
||
async function enableMidi() {
|
||
if (!navigator.requestMIDIAccess) {
|
||
if (midiStatusEl) midiStatusEl.textContent = "Web MIDI not supported — use Chrome/Edge.";
|
||
return;
|
||
}
|
||
try {
|
||
midiAccess = await navigator.requestMIDIAccess({ sysex: false });
|
||
} catch (e) {
|
||
if (midiStatusEl) midiStatusEl.textContent = "MIDI blocked (needs https or localhost, and permission).";
|
||
return;
|
||
}
|
||
midiAccess.onstatechange = populatePorts;
|
||
populatePorts();
|
||
if (midiStatusEl) midiStatusEl.textContent = "MIDI ready — pick your ports.";
|
||
}
|
||
|
||
function populatePorts() {
|
||
if (!midiAccess) return;
|
||
fillSelect(outSel, midiAccess.outputs, midiOut, (p) => { midiOut = p; });
|
||
fillSelect(inSel, midiAccess.inputs, midiIn, (p) => {
|
||
if (midiIn) midiIn.onmidimessage = null;
|
||
midiIn = p;
|
||
if (midiIn) midiIn.onmidimessage = onMidiMessage;
|
||
});
|
||
}
|
||
|
||
function fillSelect(sel, portMap, current, setter) {
|
||
if (!sel) return;
|
||
const ports = Array.from(portMap.values());
|
||
sel.innerHTML = "";
|
||
const none = document.createElement("option");
|
||
none.value = ""; none.textContent = "— none —"; sel.appendChild(none);
|
||
let chosen = null;
|
||
for (const p of ports) {
|
||
const o = document.createElement("option");
|
||
o.value = p.id; o.textContent = p.name; sel.appendChild(o);
|
||
if (current && p.id === current.id) { o.selected = true; chosen = p; }
|
||
}
|
||
if (!chosen && ports.length) { chosen = ports[0]; sel.options[1].selected = true; }
|
||
sel.onchange = () => setter(ports.find((q) => q.id === sel.value) || null);
|
||
setter(chosen);
|
||
}
|
||
|
||
function onMidiMessage(ev) {
|
||
const status = ev.data[0], d1 = ev.data[1], d2 = ev.data[2];
|
||
const type = status & 0xf0;
|
||
let key = null, value = 0;
|
||
if (type === 0xB0) { key = "cc:" + d1; value = d2 / 127; }
|
||
else if (type === 0x90 && d2 > 0) { key = "note:" + d1; value = 1; }
|
||
else if (type === 0x80 || (type === 0x90 && d2 === 0)) { key = "note:" + d1; value = 0; }
|
||
else return;
|
||
|
||
if (learnMode && learnTarget) {
|
||
midiBindings[key] = { target: learnTarget.target, param: learnTarget.param, mode: learnTarget.mode };
|
||
saveBindings();
|
||
learnTarget = null; learnMode = false;
|
||
if (learnBtn) learnBtn.classList.remove("on");
|
||
if (midiStatusEl) midiStatusEl.textContent = "bound " + key + " ✓";
|
||
return;
|
||
}
|
||
const b = midiBindings[key];
|
||
if (!b) return;
|
||
if (b.mode === "toggle") {
|
||
if (value >= 0.5) sendCmd({ cmd: "toggle", target: b.target, param: b.param });
|
||
} else {
|
||
const max = b.param === "gain" ? 1.5 : 1.0;
|
||
sendCmd({ cmd: "set", target: b.target, param: b.param, value: value * max });
|
||
}
|
||
}
|
||
|
||
function sendDestsToMidi(destObj) {
|
||
if (!midiOut) return;
|
||
for (const dest in destObj) {
|
||
const m = midiMap[dest];
|
||
if (!m) continue;
|
||
const ch = (m.channel || 0) & 0x0f;
|
||
const v = destObj[dest];
|
||
if (m.note) {
|
||
const note = Math.round(v);
|
||
if (note >= 24 && lastNoteFor[dest] !== note) {
|
||
if (lastNoteFor[dest] != null) midiOut.send([0x80 | ch, lastNoteFor[dest], 0]);
|
||
midiOut.send([0x90 | ch, note, 100]);
|
||
lastNoteFor[dest] = note;
|
||
}
|
||
} else if (m.cc != null) {
|
||
const cc = clamp(Math.round(v * 127), 0, 127);
|
||
if (lastCCSent[dest] !== cc) { midiOut.send([0xB0 | ch, m.cc, cc]); lastCCSent[dest] = cc; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- panel ----
|
||
function mkSelect(labelText) {
|
||
const row = document.createElement("div"); row.className = "row"; row.style.margin = "6px 0";
|
||
const lab = document.createElement("span"); lab.className = "hint"; lab.textContent = labelText;
|
||
const sel = document.createElement("select"); sel.className = "midisel";
|
||
row.appendChild(lab); row.appendChild(sel); panelEl.appendChild(row);
|
||
return sel;
|
||
}
|
||
|
||
function buildPanel() {
|
||
panelEl.innerHTML = "";
|
||
const h = document.createElement("h3"); h.textContent = "CONTROLS";
|
||
panelEl.appendChild(h);
|
||
const hint = document.createElement("div"); hint.className = "hint";
|
||
hint.innerHTML = "Click any <b>sphere</b> to mute / unmute it.<br>Group macros:";
|
||
panelEl.appendChild(hint);
|
||
|
||
for (const gname in groupsInfo) {
|
||
const g = groupsInfo[gname] || {};
|
||
const wrap = document.createElement("div"); wrap.className = "grp";
|
||
const row = document.createElement("div"); row.className = "row";
|
||
const nm = document.createElement("span"); nm.className = "nm"; nm.textContent = g.label || gname;
|
||
const btns = document.createElement("span"); btns.className = "btns";
|
||
const mute = document.createElement("button"); mute.textContent = "mute";
|
||
mute.onclick = () => { if (armLearn({ target: "@" + gname, param: "mute", mode: "toggle" })) return; sendCmd({ cmd: "toggle", target: "@" + gname, param: "mute" }); mute.classList.toggle("on"); };
|
||
const frz = document.createElement("button"); frz.textContent = "freeze"; frz.className = "frz";
|
||
frz.onclick = () => { if (armLearn({ target: "@" + gname, param: "freeze", mode: "toggle" })) return; sendCmd({ cmd: "toggle", target: "@" + gname, param: "freeze" }); frz.classList.toggle("on"); };
|
||
btns.appendChild(mute); btns.appendChild(frz);
|
||
row.appendChild(nm); row.appendChild(btns); wrap.appendChild(row);
|
||
const slider = document.createElement("input");
|
||
slider.type = "range"; slider.min = "0"; slider.max = "1.5"; slider.step = "0.01"; slider.value = "1"; slider.title = "gain";
|
||
slider.addEventListener("pointerdown", () => armLearn({ target: "@" + gname, param: "gain", mode: "cc" }));
|
||
slider.oninput = () => { if (!learnMode) sendCmd({ cmd: "set", target: "@" + gname, param: "gain", value: parseFloat(slider.value) }); };
|
||
wrap.appendChild(slider);
|
||
panelEl.appendChild(wrap);
|
||
}
|
||
|
||
// Web MIDI section
|
||
const mh = document.createElement("h3"); mh.textContent = "WEB MIDI"; mh.style.marginTop = "4px";
|
||
panelEl.appendChild(mh);
|
||
const enable = document.createElement("button"); enable.textContent = "enable MIDI"; enable.className = "midibtn";
|
||
enable.onclick = enableMidi; panelEl.appendChild(enable);
|
||
midiStatusEl = document.createElement("div"); midiStatusEl.className = "hint"; midiStatusEl.style.margin = "6px 0";
|
||
midiStatusEl.textContent = "off — enable to drive your DAW (Chrome/Edge; https or localhost).";
|
||
panelEl.appendChild(midiStatusEl);
|
||
outSel = mkSelect("out →");
|
||
inSel = mkSelect("in ←");
|
||
learnBtn = document.createElement("button"); learnBtn.textContent = "MIDI learn"; learnBtn.className = "midibtn";
|
||
learnBtn.onclick = () => {
|
||
learnMode = !learnMode; learnTarget = null;
|
||
learnBtn.classList.toggle("on", learnMode);
|
||
midiStatusEl.textContent = learnMode
|
||
? "LEARN on — click a slider / mute / sphere, then move a knob."
|
||
: "learn off.";
|
||
};
|
||
panelEl.appendChild(learnBtn);
|
||
bindCountEl = document.createElement("span"); bindCountEl.className = "hint"; bindCountEl.style.marginLeft = "8px";
|
||
bindCountEl.textContent = Object.keys(midiBindings).length + " mapped";
|
||
panelEl.appendChild(bindCountEl);
|
||
const clr = document.createElement("button"); clr.textContent = "clear"; clr.className = "midibtn"; clr.style.marginLeft = "8px";
|
||
clr.onclick = () => { for (const k in midiBindings) delete midiBindings[k]; saveBindings(); midiStatusEl.textContent = "mappings cleared."; };
|
||
panelEl.appendChild(clr);
|
||
|
||
// Templates — save / load named patches
|
||
const th = document.createElement("h3"); th.textContent = "TEMPLATES"; th.style.marginTop = "8px";
|
||
panelEl.appendChild(th);
|
||
const nameRow = document.createElement("div"); nameRow.className = "row"; nameRow.style.margin = "4px 0";
|
||
const nameIn = document.createElement("input"); nameIn.type = "text"; nameIn.placeholder = "name…"; nameIn.className = "tplname";
|
||
const saveBtn = document.createElement("button"); saveBtn.textContent = "save"; saveBtn.className = "midibtn";
|
||
saveBtn.onclick = () => { const n = nameIn.value.trim(); if (n) { sendCmd({ cmd: "save_patch", name: n }); nameIn.value = ""; } };
|
||
nameRow.appendChild(nameIn); nameRow.appendChild(saveBtn); panelEl.appendChild(nameRow);
|
||
const loadRow = document.createElement("div"); loadRow.className = "row"; loadRow.style.margin = "4px 0";
|
||
templateSelect = document.createElement("select"); templateSelect.className = "midisel";
|
||
const loadBtn = document.createElement("button"); loadBtn.textContent = "load"; loadBtn.className = "midibtn"; loadBtn.style.marginLeft = "6px";
|
||
loadBtn.onclick = () => { if (templateSelect.value) sendCmd({ cmd: "load_patch", name: templateSelect.value }); };
|
||
loadRow.appendChild(templateSelect); loadRow.appendChild(loadBtn); panelEl.appendChild(loadRow);
|
||
refreshTemplates();
|
||
}
|
||
|
||
function refreshTemplates() {
|
||
if (!templateSelect) return;
|
||
templateSelect.innerHTML = "";
|
||
if (!patchList.length) {
|
||
const o = document.createElement("option"); o.value = ""; o.textContent = "— none saved —";
|
||
templateSelect.appendChild(o);
|
||
} else {
|
||
for (const p of patchList) {
|
||
const o = document.createElement("option"); o.value = p; o.textContent = p;
|
||
templateSelect.appendChild(o);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Boot
|
||
// ---------------------------------------------------------------------------
|
||
resize();
|
||
connect();
|
||
requestAnimationFrame(frame);
|
||
|
||
})();
|
||
</script>
|