Wiring: layouts (Hub, Columns, Groups, Free), All/None, Dante in its own box, gentle trackpad zoom; fix the Keys pages (a second wireLayout) and Computers > Setup (a second setupHtml)

RossTalk and OSC: the watchdog reads Companion's RossTalk port (ROSSTALK OFF, PROP ROSSTALK LOST at NA/WS/SC),
Bible TV and Ableton must be listening for Companion's OSC (BIBLE OSC OFF, ABLETON OSC OFF), and both are their own
connection types on the Wiring screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:45:09 -04:00
co-authored by Claude Opus 5.5
parent 26804e4ce2
commit 5d0263a9b9
11 changed files with 490 additions and 165 deletions
+256 -155
View File
@@ -633,230 +633,331 @@
// ---- Wiring ------------------------------------------------------------------
// Every computer and device the panel knows and what connects them, like a wiring diagram (2026-10-27). Drag a
// machine to arrange it; click one to see its scripts and connections; hide connection types or single
// connections. Positions and what is hidden are kept in this browser only.
const W_W = 2000, W_H = 1300;
const wire = { data: null, pos: null, hidden: null, types: null, sel: null, focus: false, problems: false, drag: null, view: null };
const wireRead = (k, d) => { try { return JSON.parse(store.get(k) || "null") || d; } catch (e) { return d; } };
const wireSave = () => { store.set("sp-wire-pos", JSON.stringify(wire.pos)); store.set("sp-wire-hidden", JSON.stringify([...wire.hidden])); store.set("sp-wire-types", JSON.stringify([...wire.types])); };
const wireType = (id) => (wire.data.types.find((t) => t.id === id) || { label: id, color: "#888" });
const wireNode = (id) => wire.data.nodes.find((n) => n.id === id);
const wireName = (id) => (wireNode(id) || { label: id }).label;
// connections; pick a layout. Positions (per layout) and what is hidden are kept in this browser only.
// Everything here is named wd... (a wireLayout already belongs to the Keys screen).
const WD_W = 2100, WD_H = 1300, WD_DANTE_X = 1660;
const WD_LAYOUTS = [["hub", "Hub", "This Mac in the middle, the computers around it, each device beside its machine"],
["columns", "Columns", "Left to right: what Companion drives, Companion's Mac, the computers, what they talk to"],
["groups", "Groups", "Machines grouped by kind"], ["free", "Free", "Pulled together by their connections"]];
const wd = { data: null, pos: null, hidden: null, types: null, layout: "hub", zones: [], sel: null, focus: false, problems: false, drag: null, view: null };
const wdRead = (k, d) => { try { return JSON.parse(store.get(k) || "null") || d; } catch (e) { return d; } };
const wdSave = () => { store.set("sp-wd-pos", JSON.stringify(wd.pos)); store.set("sp-wd-hidden", JSON.stringify([...wd.hidden])); store.set("sp-wd-types", JSON.stringify([...wd.types])); store.set("sp-wd-layout", wd.layout); };
const wdType = (id) => (wd.data.types.find((t) => t.id === id) || { label: id, color: "#888" });
const wdNode = (id) => wd.data.nodes.find((n) => n.id === id);
const wdName = (id) => (wdNode(id) || { label: id }).label;
const wdP = () => (wd.pos[wd.layout] = wd.pos[wd.layout] || {});
const wdSize = (n) => n.kind === "computer" ? { w: 168, h: 46 } : { w: Math.min(190, 26 + 6.6 * String(n.label).length), h: 26 };
const wdViewBox = (svg) => svg && svg.setAttribute("viewBox", `${wd.view.x} ${wd.view.y} ${wd.view.w} ${wd.view.h}`);
async function wiring(rest, quiet) {
if (quiet && (wire.drag || document.getElementById("dialog").hidden === false)) return;
wire.data = await api("/api/wiring");
if (!wire.pos) { wire.pos = wireRead("sp-wire-pos", {}); wire.hidden = new Set(wireRead("sp-wire-hidden", [])); wire.types = new Set(wireRead("sp-wire-types", ["watch"])); } // the watchdog's own lines: hidden at first
if (wire.sel && !(wire.sel.edge ? wire.data.edges.some((e) => e.id === wire.sel.id) : wireNode(wire.sel.id))) wire.sel = null;
wireLayout();
if (!(quiet && document.getElementById("wiresvg"))) {
main.innerHTML = `<h1>Wiring${hint("What is connected to what. Drag a machine to move it, click it for its scripts and connections. Colours: the kind of connection; red = that connection has a problem; dashed = can't see it from here.")}</h1>
<div class="tabs" id="wiretools"></div><div class="wire"><div class="wirecanvas"><div class="wzoom"><button class="small" id="wzin" aria-label="zoom in">+</button><button class="small" id="wzout" aria-label="zoom out">−</button><button class="small" id="wfit" data-hint="Show everything that is shown">Fit</button></div><svg id="wiresvg" class="canvas" viewBox="0 0 ${W_W} ${W_H}" role="img" aria-label="wiring diagram"></svg></div><aside class="card wireside" id="wireside"></aside></div>`;
wireEvents();
if (quiet && (wd.drag || document.getElementById("dialog").hidden === false)) return;
wd.data = await api("/api/wiring");
if (!wd.pos) {
wd.pos = wdRead("sp-wd-pos", {}); wd.hidden = new Set(wdRead("sp-wd-hidden", [])); wd.types = new Set(wdRead("sp-wd-types", ["watch"]));
wd.layout = WD_LAYOUTS.some((l) => l[0] === store.get("sp-wd-layout")) ? store.get("sp-wd-layout") : "hub";
}
wireTools(); wireDraw(); wireSide();
if (wd.sel && !(wd.sel.edge ? wd.data.edges.some((e) => e.id === wd.sel.id) : wdNode(wd.sel.id))) wd.sel = null;
wdLayout();
if (!(quiet && document.getElementById("wdsvg"))) {
wd.view = null; // a fresh visit starts with everything in view
main.innerHTML = `<h1>Wiring${hint("What is connected to what. Drag a machine to move it, click it for its scripts and connections. Colours: the kind of connection; red = that connection has a problem; dashed = can't see it from here. Two-finger scroll or pinch zooms, drag the background to move around.")}</h1>
<div class="tabs" id="wdtools"></div><div class="wire"><div class="wirecanvas"><div class="wzoom"><button class="small" id="wdzin" aria-label="zoom in">+</button><button class="small" id="wdzout" aria-label="zoom out">−</button><button class="small" id="wdfit" data-hint="Show everything that is shown">Fit</button></div><svg id="wdsvg" class="canvas" viewBox="0 0 ${WD_W} ${WD_H}" role="img" aria-label="wiring diagram"></svg></div><aside class="card wireside" id="wdside"></aside></div>`;
wdEvents();
}
wdTools(); wdDraw(); wdSide();
}
function wireLayout(all) {
const d = wire.data, cx = W_W / 2, cy = W_H / 2;
const fresh = d.nodes.filter((n) => all || !wire.pos[n.id]).map((n) => n.id);
if (!fresh.length) return;
const nb = {};
for (const e of d.edges) if (e.type !== "watch") { (nb[e.from] = nb[e.from] || []).push(e.to); (nb[e.to] = nb[e.to] || []).push(e.from); }
const move = new Set(fresh), hubs = d.nodes.filter((n) => (n.kind === "computer" || n.kind === "switch") && n.id !== d.me);
hubs.sort((a, b) => (a.kind === "switch") - (b.kind === "switch") || a.label.localeCompare(b.label));
if (d.me && move.has(d.me)) wire.pos[d.me] = { x: cx, y: cy };
const angle = {};
hubs.forEach((n, i) => { angle[n.id] = 2 * Math.PI * i / hubs.length - Math.PI / 2; if (move.has(n.id)) wire.pos[n.id] = { x: cx + W_W * 0.24 * Math.cos(angle[n.id]), y: cy + W_H * 0.26 * Math.sin(angle[n.id]) }; });
// Each device beside the machine it belongs to: its switch or computer with the most links, else Companion's Mac.
const anchor = {}, groups = {};
for (const n of d.nodes) {
if (n.kind === "computer" || n.kind === "switch") continue;
const count = {};
for (const o of nb[n.id] || []) if (angle[o] !== undefined) count[o] = (count[o] || 0) + 1;
const best = Object.keys(count).sort((a, b) => count[b] - count[a])[0] || d.me;
anchor[n.id] = best; (groups[best] = groups[best] || []).push(n);
// ---- layouts: each returns {pos: {id: {x, y}}, zones: [{x, y, w, h, label}]} for every node --------------
function wdGraph() {
const d = wd.data, nb = {}, kinds = {};
for (const e of d.edges) {
if (e.type !== "watch") { (nb[e.from] = nb[e.from] || []).push(e.to); (nb[e.to] = nb[e.to] || []).push(e.from); }
for (const end of [e.from, e.to]) (kinds[end] = kinds[end] || new Set()).add(e.type);
}
// Dante is its own network: its switches and the devices only on it sit apart, on the right.
const dante = new Set(d.nodes.filter((n) => n.kind === "switch" || (n.kind !== "computer" && kinds[n.id] && [...kinds[n.id]].every((t) => t === "dante"))).map((n) => n.id));
return { nb, dante };
}
function wdDanteZone(g, pos) {
const d = wd.data, switches = d.nodes.filter((n) => n.kind === "switch").sort((a, b) => a.label.localeCompare(b.label));
let y = 70;
for (const s of switches) {
pos[s.id] = { x: WD_DANTE_X + 215, y };
const devs = (g.nb[s.id] || []).filter((id) => g.dante.has(id) && id !== s.id).sort((a, b) => wdName(a).localeCompare(wdName(b)));
devs.forEach((id, i) => { pos[id] = { x: WD_DANTE_X + 110 + (i % 2) * 210, y: y + 44 + Math.floor(i / 2) * 34 }; });
y += 44 + Math.ceil(devs.length / 2) * 34 + 40;
}
for (const id of g.dante) if (!pos[id]) { pos[id] = { x: WD_DANTE_X + 215, y }; y += 34; }
return switches.length ? [{ x: WD_DANTE_X - 10, y: 20, w: WD_W - WD_DANTE_X, h: Math.max(200, y - 10), label: "Dante network" }] : [];
}
function wdAnchor(g, n, hubs) {
const count = {};
for (const o of g.nb[n.id] || []) if (hubs.has(o)) count[o] = (count[o] || 0) + 1;
return Object.keys(count).sort((a, b) => count[b] - count[a])[0] || wd.data.me;
}
function wdHub(g) {
const d = wd.data, pos = {}, cx = WD_DANTE_X / 2 - 20, cy = WD_H / 2, rx = WD_DANTE_X * 0.27, ry = WD_H * 0.27;
const hubs = d.nodes.filter((n) => n.kind === "computer" && n.id !== d.me).sort((a, b) => a.label.localeCompare(b.label));
if (d.me) pos[d.me] = { x: cx, y: cy };
const angle = {};
hubs.forEach((n, i) => { angle[n.id] = 2 * Math.PI * i / hubs.length - Math.PI / 2; pos[n.id] = { x: cx + rx * Math.cos(angle[n.id]), y: cy + ry * Math.sin(angle[n.id]) }; });
const groups = {}, hubIds = new Set(hubs.map((n) => n.id));
for (const n of d.nodes) if (n.kind !== "computer" && !g.dante.has(n.id)) { const a = wdAnchor(g, n, hubIds); (groups[a] = groups[a] || []).push(n); }
for (const [hub, list] of Object.entries(groups)) {
list.sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.label).localeCompare(String(b.label)));
list.forEach((n, i) => {
if (!move.has(n.id)) return;
if (hub === d.me || angle[hub] === undefined) { // the outer ring, kinds together
const a = 2 * Math.PI * i / list.length - Math.PI / 2 + 0.2;
wire.pos[n.id] = { x: cx + W_W * 0.43 * Math.cos(a), y: cy + W_H * 0.44 * Math.sin(a) };
} else { // a fan outward from its machine
const a = angle[hub] + (i - (list.length - 1) / 2) * 0.12, r = 1.0 + 0.16 * (1 + (i % 2));
wire.pos[n.id] = { x: cx + W_W * 0.24 * r * Math.cos(a), y: cy + W_H * 0.26 * r * Math.sin(a) };
}
if (angle[hub] === undefined) { const a = 2 * Math.PI * i / list.length - Math.PI / 2 + 0.2; pos[n.id] = { x: cx + rx * 1.75 * Math.cos(a), y: cy + ry * 1.7 * Math.sin(a) }; }
else { const a = angle[hub] + (i - (list.length - 1) / 2) * 0.12, r = 1.16 + 0.16 * (i % 2); pos[n.id] = { x: cx + rx * r * Math.cos(a), y: cy + ry * r * Math.sin(a) }; }
});
}
// Push apart whatever overlaps (boxes, with a margin), each device held near its machine.
return { pos, zones: wdDanteZone(g, pos) };
}
function wdColumns(g) {
const d = wd.data, pos = {};
const comps = d.nodes.filter((n) => n.kind === "computer" && n.id !== d.me).sort((a, b) => a.label.localeCompare(b.label));
const hubIds = new Set(comps.map((n) => n.id)), mine = [], theirs = {};
for (const n of d.nodes) {
if (n.kind === "computer" || g.dante.has(n.id)) continue;
const a = wdAnchor(g, n, hubIds);
if (a === d.me || !hubIds.has(a)) mine.push(n); else (theirs[a] = theirs[a] || []).push(n);
}
mine.sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.label).localeCompare(String(b.label)));
const per = Math.ceil(mine.length / Math.max(1, Math.ceil(mine.length / 34)));
mine.forEach((n, i) => { pos[n.id] = { x: 130 + Math.floor(i / per) * 215, y: 60 + (i % per) * 35 }; });
const colX = 130 + Math.ceil(mine.length / per) * 215 + 140;
if (d.me) pos[d.me] = { x: colX, y: WD_H / 2 };
let y = 60;
for (const c of comps) {
pos[c.id] = { x: colX + 330, y };
const list = (theirs[c.id] || []).sort((a, b) => String(a.label).localeCompare(String(b.label)));
list.forEach((n, i) => { pos[n.id] = { x: colX + 600 + (i % 2) * 210, y: y + Math.floor(i / 2) * 34 }; });
y += Math.max(80, Math.ceil(list.length / 2) * 34 + 30);
}
return { pos, zones: [{ x: 20, y: 20, w: colX - 150, h: WD_H - 40, label: "Companion drives" }].concat(wdDanteZone(g, pos)) };
}
function wdGroups(g) {
const d = wd.data, pos = {}, zones = [], word = { computer: "Computers", service: "Services and cloud", cloud: "Services and cloud", MIDI: "MIDI devices", device: "Devices" };
const by = {};
for (const n of d.nodes) if (!g.dante.has(n.id)) { const k = word[n.kind] || (n.kind.charAt(0).toUpperCase() + n.kind.slice(1) + "s"); (by[k] = by[k] || []).push(n); }
const order = Object.keys(by).sort((a, b) => (a === "Computers" ? -1 : b === "Computers" ? 1 : by[b].length - by[a].length));
let x = 30, y = 30, rowH = 0;
for (const k of order) {
const list = by[k].sort((a, b) => String(a.label).localeCompare(String(b.label)));
const isComp = k === "Computers", cols = isComp ? 2 : Math.min(3, Math.ceil(list.length / 8)), cw = isComp ? 190 : 205, rh = isComp ? 62 : 34;
const w = cols * cw + 30, h = Math.ceil(list.length / cols) * rh + 50;
if (x + w > WD_DANTE_X - 20) { x = 30; y += rowH + 30; rowH = 0; }
list.forEach((n, i) => { pos[n.id] = { x: x + 15 + cw / 2 + (i % cols) * cw, y: y + 45 + Math.floor(i / cols) * rh }; });
zones.push({ x, y, w, h, label: k });
x += w + 30; rowH = Math.max(rowH, h);
}
return { pos, zones: zones.concat(wdDanteZone(g, pos)) };
}
function wdFree(g) {
const d = wd.data, base = wdHub(g).pos, pos = {};
for (const id in base) pos[id] = { x: base[id].x + (Math.random() - 0.5) * 40, y: base[id].y + (Math.random() - 0.5) * 40 };
const ids = d.nodes.filter((n) => !g.dante.has(n.id)).map((n) => n.id);
for (let it = 0; it < 240; it++) {
const f = {};
for (const a of ids) f[a] = { x: 0, y: 0 };
for (const a of ids) for (const b of ids) {
if (a === b) continue;
const dx = pos[a].x - pos[b].x, dy = pos[a].y - pos[b].y, dd = Math.hypot(dx, dy) || 1;
if (dd < 220) { const k = (220 - dd) / dd * 0.06; f[a].x += dx * k; f[a].y += dy * k; }
}
for (const e of d.edges) if (pos[e.from] && pos[e.to] && f[e.from] && f[e.to]) {
const dx = pos[e.to].x - pos[e.from].x, dy = pos[e.to].y - pos[e.from].y, dd = Math.hypot(dx, dy) || 1, k = (dd - 170) / dd * 0.02;
f[e.from].x += dx * k; f[e.from].y += dy * k; f[e.to].x -= dx * k; f[e.to].y -= dy * k;
}
for (const a of ids) { pos[a].x = Math.max(110, Math.min(WD_DANTE_X - 110, pos[a].x + Math.max(-20, Math.min(20, f[a].x)))); pos[a].y = Math.max(30, Math.min(WD_H - 30, pos[a].y + Math.max(-20, Math.min(20, f[a].y)))); }
}
return { pos, zones: wdDanteZone(g, pos) };
}
// Places the nodes that have no position in this layout yet (all of them with all=true), then pushes apart what overlaps.
function wdLayout(all) {
const d = wd.data, P = wdP(), g = wdGraph();
const lay = ({ hub: wdHub, columns: wdColumns, groups: wdGroups, free: wdFree }[wd.layout] || wdHub)(g);
wd.zones = lay.zones;
const move = new Set(d.nodes.filter((n) => all || !P[n.id]).map((n) => n.id));
if (!move.size) return;
for (const id of move) P[id] = Object.assign({}, lay.pos[id] || { x: WD_DANTE_X / 2, y: WD_H / 2 });
const ids = d.nodes.map((n) => n.id), size = {};
for (const n of d.nodes) size[n.id] = nodeSize(n);
for (let it = 0; it < 260; it++) {
for (const n of d.nodes) size[n.id] = wdSize(n);
for (let it = 0; it < (wd.layout === "hub" || wd.layout === "free" ? 200 : 40); it++) {
let moved = false;
for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
const a = ids[i], b = ids[j];
if (!move.has(a) && !move.has(b)) continue;
const pa = wire.pos[a], pb = wire.pos[b];
const ox = (size[a].w + size[b].w) / 2 + 14 - Math.abs(pa.x - pb.x), oy = (size[a].h + size[b].h) / 2 + 12 - Math.abs(pa.y - pb.y);
const pa = P[a], pb = P[b];
const ox = (size[a].w + size[b].w) / 2 + 12 - Math.abs(pa.x - pb.x), oy = (size[a].h + size[b].h) / 2 + 8 - Math.abs(pa.y - pb.y);
if (ox <= 0 || oy <= 0) continue;
moved = true;
const ma = move.has(a) ? (move.has(b) ? 0.5 : 1) : 0, mb = 1 - ma;
if (ox < oy) { const sx = (pa.x < pb.x ? -1 : 1) * ox; pa.x += sx * ma; pb.x -= sx * mb; }
else { const sy = (pa.y < pb.y ? -1 : 1) * oy; pa.y += sy * ma; pb.y -= sy * mb; }
}
for (const id of move) {
const h = anchor[id], p = wire.pos[id];
if (h && h !== d.me && wire.pos[h]) { const q = wire.pos[h], dx = q.x - p.x, dy = q.y - p.y, dd = Math.hypot(dx, dy) || 1; if (dd > 230) { p.x += dx * 0.02; p.y += dy * 0.02; } }
p.x = Math.max(size[id].w / 2 + 6, Math.min(W_W - size[id].w / 2 - 6, p.x)); p.y = Math.max(20, Math.min(W_H - 20, p.y));
}
if (!moved) break;
}
wireSave();
wdSave();
}
function wireFit() {
const { nodes } = wireShown();
if (!nodes.length) { wire.view = { x: 0, y: 0, w: W_W, h: W_H }; return; }
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of nodes) { const p = wire.pos[n.id], { w, h } = nodeSize(n); x0 = Math.min(x0, p.x - w / 2); x1 = Math.max(x1, p.x + w / 2); y0 = Math.min(y0, p.y - h / 2); y1 = Math.max(y1, p.y + h / 2); }
const svg = document.getElementById("wiresvg"), box = svg && svg.getBoundingClientRect();
const pad = 30, w = x1 - x0 + 2 * pad, h = y1 - y0 + 2 * pad, ratio = box && box.height ? box.width / box.height : W_W / W_H;
const vw = Math.max(w, h * ratio), vh = vw / ratio;
wire.view = { x: (x0 + x1) / 2 - vw / 2, y: (y0 + y1) / 2 - vh / 2, w: vw, h: vh };
}
function wireZoom(f, px, py) {
const v = wire.view, nw = Math.max(250, Math.min(W_W * 2.5, v.w * f)), k = nw / v.w;
px = px == null ? v.x + v.w / 2 : px; py = py == null ? v.y + v.h / 2 : py;
wire.view = { x: px - (px - v.x) * k, y: py - (py - v.y) * k, w: nw, h: v.h * k };
const svg = document.getElementById("wiresvg"); if (svg) svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`);
}
function wireShown() {
const d = wire.data, sel = wire.sel && !wire.sel.edge ? wire.sel.id : null;
const edges = d.edges.filter((e) => !wire.types.has(e.type) && !wire.hidden.has(e.id) && (!wire.problems || e.light === "problem")
&& (!wire.focus || !sel || e.from === sel || e.to === sel));
function wdShown() {
const d = wd.data, sel = wd.sel && !wd.sel.edge ? wd.sel.id : null;
const edges = d.edges.filter((e) => !wd.types.has(e.type) && !wd.hidden.has(e.id) && (!wd.problems || e.light === "problem")
&& (!wd.focus || !sel || e.from === sel || e.to === sel));
const ends = new Set(); for (const e of edges) { ends.add(e.from); ends.add(e.to); }
const nodes = d.nodes.filter((n) => ends.has(n.id) || (n.kind === "computer" && !wire.focus && !wire.problems) || n.id === sel);
const nodes = d.nodes.filter((n) => ends.has(n.id) || (n.kind === "computer" && !wd.focus && !wd.problems) || n.id === sel);
return { edges, nodes };
}
const nodeSize = (n) => n.kind === "computer" ? { w: 168, h: 46 } : { w: Math.min(190, 26 + 6.6 * String(n.label).length), h: 26 };
function wdFit() {
const { nodes } = wdShown(), P = wdP();
if (!nodes.length) { wd.view = { x: 0, y: 0, w: WD_W, h: WD_H }; return; }
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of nodes) { const p = P[n.id], { w, h } = wdSize(n); if (!p) continue; x0 = Math.min(x0, p.x - w / 2); x1 = Math.max(x1, p.x + w / 2); y0 = Math.min(y0, p.y - h / 2); y1 = Math.max(y1, p.y + h / 2); }
const svg = document.getElementById("wdsvg"), box = svg && svg.getBoundingClientRect();
const pad = 40, w = x1 - x0 + 2 * pad, h = y1 - y0 + 2 * pad, ratio = box && box.height ? box.width / box.height : WD_W / WD_H;
const vw = Math.max(w, h * ratio), vh = vw / ratio;
wd.view = { x: (x0 + x1) / 2 - vw / 2, y: (y0 + y1) / 2 - vh / 2, w: vw, h: vh };
}
function wdZoom(f, px, py) {
const v = wd.view, nw = Math.max(250, Math.min(WD_W * 3, v.w * f)), k = nw / v.w;
px = px == null ? v.x + v.w / 2 : px; py = py == null ? v.y + v.h / 2 : py;
wd.view = { x: px - (px - v.x) * k, y: py - (py - v.y) * k, w: nw, h: v.h * k };
wdViewBox(document.getElementById("wdsvg"));
}
function wireDraw() {
const svg = document.getElementById("wiresvg");
function wdDraw() {
const svg = document.getElementById("wdsvg");
if (!svg) return;
if (!wire.view) wireFit();
svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`);
const { edges, nodes } = wireShown(), sel = wire.sel;
const near = new Set();
if (!wd.view) wdFit();
wdViewBox(svg);
const P = wdP(), { edges, nodes } = wdShown(), sel = wd.sel, near = new Set();
if (sel && !sel.edge) { near.add(sel.id); for (const e of edges) if (e.from === sel.id || e.to === sel.id) { near.add(e.from); near.add(e.to); } }
if (sel && sel.edge) { const e = wire.data.edges.find((x) => x.id === sel.id); if (e) { near.add(e.from); near.add(e.to); } }
if (sel && sel.edge) { const e = wd.data.edges.find((x) => x.id === sel.id); if (e) { near.add(e.from); near.add(e.to); } }
const shownIds = new Set(nodes.map((n) => n.id));
let out = wd.zones.filter((z) => z.label !== "Dante network" || nodes.some((n) => n.kind === "switch"))
.map((z) => `<g class="wzone"><rect x="${z.x}" y="${z.y}" width="${z.w}" height="${z.h}" rx="14"/><text x="${z.x + 14}" y="${z.y + 22}">${esc(z.label)}</text></g>`).join("");
const pairs = {};
for (const e of edges) { const k = [e.from, e.to].sort().join("|"); (pairs[k] = pairs[k] || []).push(e); }
let out = "";
for (const list of Object.values(pairs)) list.forEach((e, i) => {
const a = wire.pos[e.from], b = wire.pos[e.to];
if (!a || !b) return;
const a = P[e.from], b = P[e.to];
if (!a || !b || !shownIds.has(e.from) || !shownIds.has(e.to)) return;
const off = (i - (list.length - 1) / 2) * 16, dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
const mx = (a.x + b.x) / 2 - dy / len * off, my = (a.y + b.y) / 2 + dx / len * off;
const t = wireType(e.type), on = sel ? (sel.edge ? sel.id === e.id : e.from === sel.id || e.to === sel.id) : true;
out += `<path class="wedge ${esc(e.light)}${on ? "" : " dim"}${sel && sel.edge && sel.id === e.id ? " sel" : ""}" data-edge="${esc(e.id)}" d="M${a.x},${a.y} Q${mx},${my} ${b.x},${b.y}" style="stroke:${e.light === "problem" ? "var(--problem)" : t.color}"><title>${esc(t.label)}: ${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))} · ${esc(e.label)} · ${esc(lightWord[e.light] || e.light)}</title></path>`;
const t = wdType(e.type), on = sel ? (sel.edge ? sel.id === e.id : e.from === sel.id || e.to === sel.id) : true;
out += `<path class="wedge ${esc(e.light)}${on ? "" : " dim"}${sel && sel.edge && sel.id === e.id ? " sel" : ""}" data-edge="${esc(e.id)}" d="M${a.x},${a.y} Q${mx},${my} ${b.x},${b.y}" style="stroke:${e.light === "problem" ? "var(--problem)" : t.color}"><title>${esc(t.label)}: ${esc(wdName(e.from))} ↔ ${esc(wdName(e.to))} · ${esc(e.label)} · ${esc(lightWord[e.light] || e.light)}</title></path>`;
});
for (const n of nodes) {
const p = wire.pos[n.id], { w, h } = nodeSize(n), dim = sel && !near.has(n.id);
const p = P[n.id]; if (!p) continue;
const { w, h } = wdSize(n), dim = sel && !near.has(n.id);
const scripts = n.scripts ? n.scripts.filter((x) => x.running).length : null;
const shape = n.kind === "computer" ? `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="9"/>`
: n.kind === "switch" ? `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="2"/>`
: `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="${h / 2}"/>`;
const name = String(n.label).length > 26 ? String(n.label).slice(0, 25) + "…" : n.label;
out += `<g class="wnode ${esc(n.kind)} ${esc(n.light)}${dim ? " dim" : ""}${sel && !sel.edge && sel.id === n.id ? " sel" : ""}" data-node="${esc(n.id)}" transform="translate(${p.x},${p.y})"><title>${esc(n.label)}${n.address ? " · " + esc(n.address) : ""}${n.reason ? " · " + esc(n.reason) : ""}</title>${shape}
out += `<g class="wnode ${esc(n.kind)} ${esc(n.light)}${dim ? " dim" : ""}${sel && !sel.edge && sel.id === n.id ? " sel" : ""}" data-node="${esc(n.id)}" transform="translate(${p.x},${p.y})"><title>${esc(n.label)}${n.address ? " · " + esc(n.address) : ""}${n.reason ? " · " + esc(n.reason) : ""}</title>
<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="${n.kind === "computer" ? 9 : n.kind === "switch" ? 2 : h / 2}"/>
<circle class="wdot" cx="${-w / 2 + 12}" cy="0" r="5"/><text x="${-w / 2 + 22}" y="${n.kind === "computer" ? -3 : 4}">${esc(name)}</text>
${n.kind === "computer" ? `<text class="wsub" x="${-w / 2 + 22}" y="13">${scripts == null ? "scripts: not looked yet" : scripts + " script" + (scripts === 1 ? "" : "s") + " running"}${n.self ? " · this Mac" : ""}</text>` : ""}</g>`;
}
svg.innerHTML = out;
}
function wireTools() {
const el = document.getElementById("wiretools");
function wdTools() {
const el = document.getElementById("wdtools");
if (!el) return;
const d = wire.data, count = {};
const d = wd.data, count = {};
for (const e of d.edges) count[e.type] = (count[e.type] || 0) + 1;
const bad = d.edges.filter((e) => e.light === "problem").length;
el.innerHTML = d.types.filter((t) => count[t.id]).map((t) => `<button class="small wtype${wire.types.has(t.id) ? " off" : " on"}" data-wtype="${esc(t.id)}" data-hint="${esc(t.hint)} · click to ${wire.types.has(t.id) ? "show" : "hide"}"><i style="background:${t.color}"></i>${esc(t.label)} <span class="muted">${count[t.id]}</span></button>`).join("")
+ `<button class="small${wire.problems ? " on" : ""}" id="wprob" data-hint="Only the connections with a problem">Problems only <span class="muted">${bad}</span></button>`
+ `<button class="small" id="wall" data-hint="Show every connection again">Show all</button>`
+ `<button class="small" id="wre" data-hint="Forget where machines were dragged and lay them out again">Re-arrange</button>`
+ `<button class="small" id="wprobe" style="margin-left:auto" ${d.probing ? "disabled" : ""} data-hint="Log in to every computer now and list its scripts (read-only)">${d.probing ? "Looking…" : "Check scripts now"}</button>`;
for (const b of el.querySelectorAll("[data-wtype]")) b.onclick = () => { const t = b.dataset.wtype; wire.types.has(t) ? wire.types.delete(t) : wire.types.add(t); wireSave(); wireTools(); wireDraw(); wireSide(); };
el.querySelector("#wprob").onclick = () => { wire.problems = !wire.problems; wireTools(); wireDraw(); wireSide(); };
el.querySelector("#wall").onclick = () => { wire.types.clear(); wire.hidden.clear(); wire.focus = false; wire.problems = false; wireSave(); wireTools(); wireDraw(); wireSide(); };
el.querySelector("#wre").onclick = () => { wireLayout(true); wireFit(); wireDraw(); };
el.querySelector("#wprobe").onclick = async (ev) => { ev.target.disabled = true; ev.target.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => wiring([], true), 6000); };
const present = d.types.filter((t) => count[t.id]), bad = d.edges.filter((e) => e.light === "problem").length;
el.innerHTML = `<span class="seg">${WD_LAYOUTS.map(([id, label, h]) => `<button class="small${wd.layout === id ? " on" : ""}" data-wdlayout="${id}" data-hint="${esc(h)}">${label}</button>`).join("")}</span>`
+ present.map((t) => `<button class="small wtype${wd.types.has(t.id) ? " off" : " on"}" data-wdtype="${esc(t.id)}" data-hint="${esc(t.hint)} · click to ${wd.types.has(t.id) ? "show" : "hide"}"><i style="background:${t.color}"></i>${esc(t.label)} <span class="muted">${count[t.id]}</span></button>`).join("")
+ `<button class="small" id="wdall" data-hint="Show every kind of connection (and any you hid one by one)">All</button><button class="small" id="wdnone" data-hint="Hide every kind: then click the one you want">None</button>`
+ `<button class="small${wd.problems ? " on" : ""}" id="wdprob" data-hint="Only the connections with a problem">Problems only <span class="muted">${bad}</span></button>`
+ `<button class="small" id="wdre" data-hint="Forget where machines were dragged in this layout and lay them out again">Re-arrange</button>`
+ `<button class="small" id="wdprobe" style="margin-left:auto" ${d.probing ? "disabled" : ""} data-hint="Log in to every computer now and list its scripts (read-only)">${d.probing ? "Looking…" : "Check scripts now"}</button>`;
const redraw = () => { wdSave(); wdTools(); wdDraw(); wdSide(); };
for (const b of el.querySelectorAll("[data-wdlayout]")) b.onclick = () => { wd.layout = b.dataset.wdlayout; wdLayout(); wdFit(); redraw(); };
for (const b of el.querySelectorAll("[data-wdtype]")) b.onclick = () => { const t = b.dataset.wdtype; wd.types.has(t) ? wd.types.delete(t) : wd.types.add(t); redraw(); };
el.querySelector("#wdall").onclick = () => { wd.types.clear(); wd.hidden.clear(); wd.focus = false; wd.problems = false; redraw(); };
el.querySelector("#wdnone").onclick = () => { wd.types = new Set(present.map((t) => t.id)); wd.focus = false; redraw(); };
el.querySelector("#wdprob").onclick = () => { wd.problems = !wd.problems; redraw(); };
el.querySelector("#wdre").onclick = () => { wdLayout(true); wdFit(); redraw(); };
el.querySelector("#wdprobe").onclick = async (ev) => { ev.target.disabled = true; ev.target.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => wiring([], true), 6000); };
}
function wireSide() {
const el = document.getElementById("wireside");
function wdSide() {
const el = document.getElementById("wdside");
if (!el) return;
const d = wire.data, sel = wire.sel;
const row = (e, other) => `<li><label class="wrow"><input type="checkbox" data-whide="${esc(e.id)}" ${wire.hidden.has(e.id) ? "" : "checked"}>${lightDot(e.light, lightWord[e.light])}<span><b>${esc(wireName(other))}</b> <span class="muted small">${esc(e.label)}</span></span></label></li>`;
const d = wd.data, sel = wd.sel;
const row = (e, other) => `<li><label class="wrow"><input type="checkbox" data-wdhide="${esc(e.id)}" ${wd.hidden.has(e.id) ? "" : "checked"}>${lightDot(e.light, lightWord[e.light])}<span><b>${esc(wdName(other))}</b> <span class="muted small">${esc(e.label)}</span></span></label></li>`;
let html;
if (sel && sel.edge) {
const e = d.edges.find((x) => x.id === sel.id), t = wireType(e.type);
html = `<h2><i class="wsw" style="background:${t.color}"></i>${esc(t.label)}</h2><p>${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</p><p class="small">${esc(e.label)} · ${lightDot(e.light)} ${esc(lightWord[e.light] || e.light)}</p>
<p><button class="small" data-whide-one="${esc(e.id)}">${wire.hidden.has(e.id) ? "Show" : "Hide"} this connection</button> <button class="small" data-wsel="${esc(e.from)}">${esc(wireName(e.from))}</button> <button class="small" data-wsel="${esc(e.to)}">${esc(wireName(e.to))}</button></p>`;
const e = d.edges.find((x) => x.id === sel.id), t = wdType(e.type);
html = `<h2><i class="wsw" style="background:${t.color}"></i>${esc(t.label)}</h2><p>${esc(wdName(e.from))} ↔ ${esc(wdName(e.to))}</p><p class="small">${esc(e.label)} · ${lightDot(e.light)} ${esc(lightWord[e.light] || e.light)}</p>${e.detail ? `<p class="small muted">${esc(e.detail)}</p>` : ""}
<p><button class="small" data-wdhide-one="${esc(e.id)}">${wd.hidden.has(e.id) ? "Show" : "Hide"} this connection</button> <button class="small" data-wdsel="${esc(e.from)}">${esc(wdName(e.from))}</button> <button class="small" data-wdsel="${esc(e.to)}">${esc(wdName(e.to))}</button></p>`;
} else if (sel) {
const n = wireNode(sel.id), mine = d.edges.filter((e) => e.from === n.id || e.to === n.id);
const byType = {};
const n = wdNode(sel.id), mine = d.edges.filter((e) => e.from === n.id || e.to === n.id), byType = {};
for (const e of mine) (byType[e.type] = byType[e.type] || []).push(e);
const scripts = n.kind !== "computer" ? "" : n.scripts == null
? `<h3>Scripts</h3><p class="small muted">Not looked yet: press Check scripts now.</p>`
: `<h3>Scripts <span class="muted small">${n.scripts.filter((x) => x.running).length} running${n.scripts.some((x) => !x.running) ? `, ${n.scripts.filter((x) => !x.running).length} stopped` : ""}</span></h3>${n.scripts.length ? `<ul class="wlist">${n.scripts.map((x) => `<li>${lightDot(x.running ? "ok" : "not_needed", x.running ? "running" : (x.state || "not running"))}<span class="mono small">${esc(x.name)}</span>${x.by && x.by !== x.name ? ` <span class="muted small">${esc(x.by.replace(/^org\.waterschurch\./, ""))}</span>` : ""}</li>`).join("")}</ul>` : `<p class="small muted">None.</p>`}${n.looked ? `<p class="small muted">looked ${esc(ago(Date.now() / 1000 - n.looked))}</p>` : ""}`;
html = `<h2>${lightDot(n.light, lightWord[n.light])} ${esc(n.label)}</h2><p class="small muted">${esc(n.kind === "computer" ? (n.os === "windows" ? "Windows PC" : "Mac") + (n.self ? " · this Mac (the panel)" : "") : n.kind)}${n.address ? " · " + esc(n.address) : ""}${n.role ? " · " + esc(n.role) : ""}</p>
: `<h3>Scripts <span class="muted small">${n.scripts.filter((x) => x.running).length} running${n.scripts.some((x) => !x.running) ? `, ${n.scripts.filter((x) => !x.running).length} stopped` : ""}</span></h3>${n.scripts.length ? `<ul class="wlist">${n.scripts.map((x) => `<li>${lightDot(x.running ? "ok" : "not_needed", x.running ? "running" : (x.state || "not running"))}<span class="mono small">${esc(x.name)}</span>${x.by && x.by.replace(/^org\.waterschurch\./, "") !== x.name ? ` <span class="muted small">${esc(x.by.replace(/^org\.waterschurch\./, ""))}</span>` : ""}</li>`).join("")}</ul>` : `<p class="small muted">None.</p>`}${n.looked ? `<p class="small muted">looked ${esc(ago(Date.now() / 1000 - n.looked))}</p>` : ""}`;
html = `<h2>${lightDot(n.light, lightWord[n.light])} ${esc(n.label)}</h2><p class="small muted">${esc(n.kind === "computer" ? (n.os === "windows" ? "Windows PC" : "Mac") + (n.self ? " · runs the panel" : "") : n.kind)}${n.address ? " · " + esc(n.address) : ""}${n.role ? " · " + esc(n.role) : ""}</p>
${n.reason ? `<p class="small">${esc(n.reason)}</p>` : ""}${n.names && n.names.length > 1 ? `<p class="small muted">also called ${esc(n.names.filter((x) => x !== n.label).join(", "))}</p>` : ""}
<p><button class="small${wire.focus ? " on" : ""}" id="wfocus">${wire.focus ? "Show everything" : "Only this one's connections"}</button></p>${scripts}
<h3>Connections <span class="muted small">${mine.length}</span></h3>${Object.entries(byType).map(([t, list]) => `<p class="small wtyp"><i class="wsw" style="background:${wireType(t).color}"></i>${esc(wireType(t).label)}</p><ul class="wlist">${list.map((e) => row(e, e.from === n.id ? e.to : e.from)).join("")}</ul>`).join("") || `<p class="small muted">None.</p>`}
<p><button class="small${wd.focus ? " on" : ""}" id="wdfocus">${wd.focus ? "Show everything" : "Only this one's connections"}</button></p>${scripts}
<h3>Connections <span class="muted small">${mine.length}</span></h3>${Object.entries(byType).map(([t, list]) => `<p class="small wtyp"><i class="wsw" style="background:${wdType(t).color}"></i>${esc(wdType(t).label)}</p><ul class="wlist">${list.map((e) => row(e, e.from === n.id ? e.to : e.from)).join("")}</ul>`).join("") || `<p class="small muted">None.</p>`}
${(n.local || []).length ? `<p class="small muted">On this machine itself: ${esc(n.local.map((x) => x.label).join(", "))}</p>` : ""}`;
} else {
const comps = d.nodes.filter((n) => n.kind === "computer");
const bad = d.edges.filter((e) => e.light === "problem");
const comps = d.nodes.filter((n) => n.kind === "computer"), bad = d.edges.filter((e) => e.light === "problem");
html = `<h2>${comps.length} computers, ${d.nodes.length - comps.length} devices</h2><p class="small">${d.edges.length} connections. Click a machine for its scripts and connections; untick a connection to hide it.</p>
<h3>Scripts per computer</h3><ul class="wlist">${comps.map((n) => `<li><a href="#" data-wsel="${esc(n.id)}">${esc(n.label)}</a> <span class="muted small">${n.scripts == null ? "not looked yet" : n.scripts.filter((x) => x.running).length + " running"}</span></li>`).join("")}</ul>
${bad.length ? `<h3>Problems <span class="muted small">${bad.length}</span></h3><ul class="wlist">${bad.map((e) => `<li><a href="#" data-wedge="${esc(e.id)}">${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</a> <span class="muted small">${esc(e.label)}</span></li>`).join("")}</ul>` : ""}
${wire.hidden.size ? `<h3>Hidden <span class="muted small">${wire.hidden.size}</span></h3><ul class="wlist">${[...wire.hidden].map((id) => d.edges.find((e) => e.id === id)).filter(Boolean).map((e) => `<li><label class="wrow"><input type="checkbox" data-whide="${esc(e.id)}"><span>${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))} <span class="muted small">${esc(e.label)}</span></span></label></li>`).join("")}</ul>` : ""}`;
<h3>Scripts per computer</h3><ul class="wlist">${comps.map((n) => `<li><a href="#" data-wdsel="${esc(n.id)}">${esc(n.label)}</a> <span class="muted small">${n.scripts == null ? "not looked yet" : n.scripts.filter((x) => x.running).length + " running"}</span></li>`).join("")}</ul>
${bad.length ? `<h3>Problems <span class="muted small">${bad.length}</span></h3><ul class="wlist">${bad.map((e) => `<li><a href="#" data-wdedge="${esc(e.id)}">${esc(wdName(e.from))} ↔ ${esc(wdName(e.to))}</a> <span class="muted small">${esc(e.label)}</span></li>`).join("")}</ul>` : ""}
${wd.hidden.size ? `<h3>Hidden <span class="muted small">${wd.hidden.size}</span></h3><ul class="wlist">${[...wd.hidden].map((id) => d.edges.find((e) => e.id === id)).filter(Boolean).map((e) => `<li><label class="wrow"><input type="checkbox" data-wdhide="${esc(e.id)}"><span>${esc(wdName(e.from))} ↔ ${esc(wdName(e.to))} <span class="muted small">${esc(e.label)}</span></span></label></li>`).join("")}</ul>` : ""}`;
}
el.innerHTML = html;
for (const c of el.querySelectorAll("[data-whide]")) c.onchange = () => { c.checked ? wire.hidden.delete(c.dataset.whide) : wire.hidden.add(c.dataset.whide); wireSave(); wireDraw(); wireSide(); };
for (const b of el.querySelectorAll("[data-whide-one]")) b.onclick = () => { const id = b.dataset.whideOne; wire.hidden.has(id) ? wire.hidden.delete(id) : wire.hidden.add(id); wireSave(); wireDraw(); wireSide(); };
for (const a of el.querySelectorAll("[data-wsel]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wsel }; wireDraw(); wireSide(); };
for (const a of el.querySelectorAll("[data-wedge]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wedge, edge: true }; wireDraw(); wireSide(); };
const f = el.querySelector("#wfocus"); if (f) f.onclick = () => { wire.focus = !wire.focus; wireDraw(); wireSide(); };
const again = () => { wdSave(); wdDraw(); wdSide(); };
for (const c of el.querySelectorAll("[data-wdhide]")) c.onchange = () => { c.checked ? wd.hidden.delete(c.dataset.wdhide) : wd.hidden.add(c.dataset.wdhide); again(); };
for (const b of el.querySelectorAll("[data-wdhide-one]")) b.onclick = () => { const id = b.dataset.wdhideOne; wd.hidden.has(id) ? wd.hidden.delete(id) : wd.hidden.add(id); again(); };
for (const a of el.querySelectorAll("[data-wdsel]")) a.onclick = (ev) => { ev.preventDefault(); wd.sel = { id: a.dataset.wdsel }; wdDraw(); wdSide(); };
for (const a of el.querySelectorAll("[data-wdedge]")) a.onclick = (ev) => { ev.preventDefault(); wd.sel = { id: a.dataset.wdedge, edge: true }; wdDraw(); wdSide(); };
const f = el.querySelector("#wdfocus"); if (f) f.onclick = () => { wd.focus = !wd.focus; wdDraw(); wdSide(); };
}
function wireEvents() {
const svg = document.getElementById("wiresvg");
function wdEvents() {
const svg = document.getElementById("wdsvg");
const at = (ev) => { const pt = svg.createSVGPoint(); pt.x = ev.clientX; pt.y = ev.clientY; return pt.matrixTransform(svg.getScreenCTM().inverse()); };
document.getElementById("wzin").onclick = () => wireZoom(0.8);
document.getElementById("wzout").onclick = () => wireZoom(1.25);
document.getElementById("wfit").onclick = () => { wireFit(); wireDraw(); };
svg.addEventListener("wheel", (ev) => { ev.preventDefault(); const p = at(ev); wireZoom(ev.deltaY > 0 ? 1.12 : 1 / 1.12, p.x, p.y); }, { passive: false });
svg.addEventListener("pointerdown", (ev) => {
const g = ev.target.closest("[data-node]"), path = ev.target.closest("[data-edge]");
document.getElementById("wdzin").onclick = () => wdZoom(0.8);
document.getElementById("wdzout").onclick = () => wdZoom(1.25);
document.getElementById("wdfit").onclick = () => { wdFit(); wdDraw(); };
// Trackpads send many small wheel steps (pinch: with ctrlKey): each zooms a little, in proportion; a mouse
// wheel's big steps are capped. Sideways two-finger scroll moves the view.
svg.addEventListener("wheel", (ev) => {
ev.preventDefault();
const unit = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? 400 : 1, dx = ev.deltaX * unit, dy = ev.deltaY * unit;
if (!ev.ctrlKey && Math.abs(dx) > Math.abs(dy)) {
const k = wd.view.w / svg.getBoundingClientRect().width;
wd.view.x += dx * k; wdViewBox(svg); return;
}
const p = at(ev);
if (g) { const id = g.dataset.node; wire.drag = { id, dx: wire.pos[id].x - p.x, dy: wire.pos[id].y - p.y, x0: ev.clientX, y0: ev.clientY, moved: false }; }
else if (path) { wire.sel = { id: path.dataset.edge, edge: true }; wireDraw(); wireSide(); return; }
else wire.drag = { pan: true, x0: ev.clientX, y0: ev.clientY, vx: wire.view.x, vy: wire.view.y, k: wire.view.w / svg.getBoundingClientRect().width, moved: false };
svg.setPointerCapture(ev.pointerId); ev.preventDefault();
wdZoom(Math.exp(Math.max(-0.2, Math.min(0.2, dy * (ev.ctrlKey ? 0.008 : 0.0018)))), p.x, p.y);
}, { passive: false });
svg.addEventListener("pointerdown", (ev) => {
const g = ev.target.closest("[data-node]"), path = ev.target.closest("[data-edge]"), p = at(ev), P = wdP();
if (g) { const id = g.dataset.node; wd.drag = { id, dx: P[id].x - p.x, dy: P[id].y - p.y, x0: ev.clientX, y0: ev.clientY, moved: false }; }
else if (path) { wd.sel = { id: path.dataset.edge, edge: true }; wdDraw(); wdSide(); return; }
else wd.drag = { pan: true, x0: ev.clientX, y0: ev.clientY, vx: wd.view.x, vy: wd.view.y, k: wd.view.w / svg.getBoundingClientRect().width, moved: false };
try { svg.setPointerCapture(ev.pointerId); } catch (e) { /* a synthetic pointer */ }
ev.preventDefault();
});
svg.addEventListener("pointermove", (ev) => {
const dr = wire.drag; if (!dr) return;
const dr = wd.drag; if (!dr) return;
if (!dr.moved && Math.hypot(ev.clientX - dr.x0, ev.clientY - dr.y0) < 4) return;
dr.moved = true;
if (dr.pan) { wire.view.x = dr.vx - (ev.clientX - dr.x0) * dr.k; wire.view.y = dr.vy - (ev.clientY - dr.y0) * dr.k; svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`); return; }
if (dr.pan) { wd.view.x = dr.vx - (ev.clientX - dr.x0) * dr.k; wd.view.y = dr.vy - (ev.clientY - dr.y0) * dr.k; wdViewBox(svg); return; }
const p = at(ev);
wire.pos[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy };
wireDraw();
wdP()[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy };
wdDraw();
});
const end = () => {
const dr = wire.drag; wire.drag = null; if (!dr) return;
if (dr.pan) { if (!dr.moved) { wire.sel = null; wire.focus = false; wireDraw(); wireSide(); } return; }
if (dr.moved) wireSave(); else { wire.sel = wire.sel && !wire.sel.edge && wire.sel.id === dr.id ? null : { id: dr.id }; if (!wire.sel) wire.focus = false; }
wireDraw(); wireSide();
const dr = wd.drag; wd.drag = null; if (!dr) return;
if (dr.pan) { if (!dr.moved) { wd.sel = null; wd.focus = false; wdDraw(); wdSide(); } return; }
if (dr.moved) wdSave(); else { wd.sel = wd.sel && !wd.sel.edge && wd.sel.id === dr.id ? null : { id: dr.id }; if (!wd.sel) wd.focus = false; }
wdDraw(); wdSide();
};
svg.addEventListener("pointerup", end); svg.addEventListener("pointercancel", end);
}
@@ -1148,7 +1249,7 @@
else if (which === "outlets") body = await outletsHtml();
else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
else if (which === "network") body = fieldsHtml("Network & Dante");
else if (which === "setup") body = await setupHtml(c);
else if (which === "setup") body = await siteSetupHtml(c);
else if (which === "site") body = siteHtml(c);
else if (which === "program") body = await programHtml();
else if (which === "advanced") body = advancedHtml();
@@ -1176,7 +1277,7 @@
return screens.map((s) => { const h = fieldsHtml(s, true); return h ? `<h3>${esc(s)}</h3>${h}` : ""; }).join("") || "<p class='muted'>Nothing.</p>";
}
// Auto-setup: what was found and added, the join lines, and the install line for another location.
async function setupHtml(c) {
async function siteSetupHtml(c) {
const d = await api("/api/discover").catch(() => null);
if (!d) return "<p class='muted'>Auto-setup is not answering.</p>";
const last = d.last || {}, inv = last.inventory || {};
+3
View File
@@ -185,3 +185,6 @@ tr.off td, .trig.off, .key.off { opacity: .55; }
.wlist { list-style: none; margin: 0; padding: 0; } .wlist li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.wrow { display: flex; gap: 6px; align-items: center; cursor: pointer; } .wtyp { margin: 8px 0 2px; }
main:has(.wire) { max-width: none; }
.wzone rect { fill: var(--bg); stroke: var(--line); stroke-dasharray: 6 5; } .wzone text { fill: var(--muted); font: 600 15px var(--font); stroke: none; letter-spacing: .04em; }
.seg { display: inline-flex; gap: 0; margin-right: 6px; } .seg button { border-radius: 0; margin-left: -1px; } .seg button:first-child { border-radius: 8px 0 0 8px; } .seg button:last-child { border-radius: 0 8px 8px 0; }
.seg button.on { background: var(--accent-soft); border-color: var(--accent); position: relative; }