Files
colony/colony/core/roles/webview/static/app.js
T
2026-09-21 19:36:04 +02:00

883 lines
36 KiB
JavaScript

"use strict";
/* The page: fetches the cluster, draws the map once into persistent SVG elements and
then only changes attributes, so what has focus or is being dragged is never rebuilt.
All decisions live in model.js, layout.js and viewport.js. */
(function () {
const C = globalThis.Colony;
const SVG_NS = "http://www.w3.org/2000/svg";
const REFRESH_MS = 1000;
const STORAGE_KEY = "colony.map.v1";
const PAD = 48;
const FIT_MS = 260;
const COMPACT_MAX_WIDTH = 600;
const stage = document.getElementById("stage");
const svgRoot = document.getElementById("map");
const world = document.getElementById("world");
const [viewpointBox, summaryBox, alertsBox, ondemandBox, readerList, liveRegion, errorBanner, updatedLabel] = ["viewpoint", "summary", "alerts", "ondemand", "reader", "live", "error", "updated"].map((id) => document.getElementById(id));
const layers = {
spokes: document.getElementById("layer-spokes"),
regions: document.getElementById("layer-regions"),
ports: document.getElementById("layer-ports"),
ends: document.getElementById("layer-ends"),
legs: document.getElementById("layer-legs"),
nodes: document.getElementById("layer-nodes"),
};
const svg = (name, attributes, text) => {
const element = document.createElementNS(SVG_NS, name);
Object.keys(attributes || {}).forEach((key) => element.setAttribute(key, attributes[key]));
if (text !== undefined) element.textContent = text;
return element;
};
const html = (name, text, className) => {
const element = document.createElement(name);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
};
/* Write an attribute only when it changed. */
const put = (element, name, value) => {
const seen = element.__seen || (element.__seen = {});
if (seen[name] === value) return;
seen[name] = value;
element.setAttribute(name, value);
};
const putText = (element, value) => {
if (element.__text === value) return;
element.__text = value;
element.textContent = value;
};
const putClass = (element, value) => put(element, "class", value);
const reducedMotion = () => window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ------------------------------------------------------------------ storage */
const store = {
load() {
try {
return C.parseOverrides(window.localStorage.getItem(STORAGE_KEY) || "");
} catch (error) {
return C.newOverrides();
}
},
save(value) {
try {
window.localStorage.setItem(STORAGE_KEY, C.serializeOverrides(value));
} catch (error) {
/* private mode or full: the map still works, it just forgets */
}
},
};
let saveTimer = null;
const saveSoon = () => {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => store.save(overrides), 300);
};
/* -------------------------------------------------------------------- state */
let overrides = store.load();
let layoutState = C.newState();
let model = null;
let map = null;
let routes = null;
let trace = null;
let mapSignature = "";
let lastCompute = 0;
let view = { k: 1, tx: 0, ty: 0 };
let size = { w: stage.clientWidth || 800, h: stage.clientHeight || 600 };
let autoFit = true;
let lod;
let selection = null;
let hovered = null;
let focusKey = null;
let gesture = C.initialGesture();
let drag = null;
let lastPressed = null;
let tween = null;
const nodeRecords = new Map();
const regionRecords = new Map();
let spokeRecords = [];
/* A pinned node keeps the trace; hovering another node only previews while nothing is pinned. */
const selectionLocks = () => Boolean(selection && selection.kind === "node");
const selectedNet = () => (selection && selection.kind === "network" ? selection.id : null);
const isPinned = (key) => Boolean(selection && selection.kind === "node" && selection.id === key);
const viewKey = () => C.resolveView(model, hovered || (selectionLocks() ? selection.id : null));
/* --------------------------------------------------------------------- view */
function applyView() {
world.style.transform = "translate(" + view.tx + "px," + view.ty + "px) scale(" + view.k + ")";
const level = C.lodOf(view.k, lod);
if (level !== lod) {
lod = level;
svgRoot.classList.remove("lod0", "lod1", "lod2");
svgRoot.classList.add("lod" + level);
}
}
function setView(next, byUser) {
const bounds = map ? map.bounds : C.EMPTY_BOUNDS;
view = C.clampView(next, bounds, size);
if (byUser) autoFit = false;
applyView();
updateControls();
}
function animateTo(target, byUser) {
if (tween) cancelAnimationFrame(tween.frame);
tween = null;
if (reducedMotion()) return setView(target, byUser);
const from = view;
const start = performance.now();
const step = (now) => {
const t = Math.min(1, (now - start) / FIT_MS);
setView(C.tween(from, target, t), byUser);
tween = t < 1 ? { frame: requestAnimationFrame(step) } : null;
};
tween = { frame: requestAnimationFrame(step) };
}
function fitAll(animate) {
if (!map) return;
const target = C.fitView(map.bounds, size, PAD);
if (animate) animateTo(target, false);
else setView(target, false);
autoFit = true;
updateControls();
}
function regionView(region) {
return C.viewFor({ x: region.x - 40, y: region.y - 40, w: region.w + 80, h: region.h + 80 }, size, 24);
}
function fitRegion(net) {
const region = map && map.regionAt.get(net);
if (!region) return;
animateTo(regionView(region), true);
}
// On a phone, fitting the whole map shrinks it below the zoom where names and stats are drawn,
// so the first view is the region of this node instead. COMPACT_MAX_WIDTH matches the phone
// breakpoint in style.css. This only sets `view` and `autoFit`; the caller applies the view afterwards.
let startedCompact = false;
function startCompact() {
if (startedCompact) return;
startedCompact = true; // decided once, on the first map: later changes to the window or the topology must not move the view
if (size.w > COMPACT_MAX_WIDTH) return;
const self = model.nodes.find((node) => node.self);
const region = self && map.regionAt.get(self.net);
if (!region) return;
autoFit = false;
view = regionView(region);
}
function resize() {
const next = { w: stage.clientWidth || size.w, h: stage.clientHeight || size.h };
if (next.w === size.w && next.h === size.h) return;
const bounds = map ? map.bounds : C.EMPTY_BOUNDS;
svgRoot.setAttribute("viewBox", "0 0 " + next.w + " " + next.h);
view = C.clampView(C.resizeView(view, size, next, autoFit, bounds, PAD), bounds, next);
size = next;
applyView();
}
/* ------------------------------------------------------------ drawing: map */
/* One bar of hardware: "CPU 9% · 6C/12T" over a track with a fill. Nodes and networks both use it. */
function statBar(parent, width) {
const label = svg("text", { class: "st-text" });
const name = svg("tspan", { class: "st-name" });
const value = svg("tspan", { class: "st-value" });
label.appendChild(name);
label.appendChild(value);
const track = svg("rect", { class: "st-track", height: 5, rx: 2.5 });
const fill = svg("rect", { class: "st-fill", height: 5, rx: 2.5 });
if (width) put(track, "width", width);
[label, track, fill].forEach((part) => parent.appendChild(part));
return { label: label, name: name, value: value, track: track, fill: fill };
}
function paintBar(bar, name, usage, width) {
putText(bar.name, name + " ");
putText(bar.value, usage ? usage.text : "n/a");
put(bar.fill, "width", usage && usage.pct ? Math.max((width * usage.pct) / 100, 4) : 0);
putClass(bar.fill, "st-fill " + (usage && usage.pct !== null ? C.barLevel(usage.pct) : "ok"));
}
const FOOT_PAD = 14;
const FOOT_GAP = 8;
const footColumn = (region) => (region.w - 2 * FOOT_PAD - (C.STAT_COUNT - 1) * FOOT_GAP) / C.STAT_COUNT;
function regionRecord(region) {
let record = regionRecords.get(region.id);
if (record) return record;
const group = svg("g", { class: "region" });
const box = svg("rect", { class: "region-box", rx: 16 });
const grab = svg("rect", { class: "region-grab", height: 38, rx: 16 });
const dot = svg("circle", { class: "region-dot", cx: 21, cy: 22, r: 5 });
const title = svg("text", { class: "region-title", x: 33, y: 27 });
const count = svg("tspan", { class: "region-count", dx: 10 });
title.appendChild(document.createTextNode(""));
title.appendChild(count);
[box, grab, dot, title].forEach((part) => group.appendChild(part));
[box, grab].forEach((part) => (part.dataset.net = region.id));
/* The network's hardware added up: the same three bars a node has, across the bottom. */
const foot = svg("g", { class: "stats foot" });
const rule = svg("line", { class: "foot-rule" });
const columns = Array.from({ length: C.STAT_COUNT }, () => statBar(foot));
const note = svg("text", { class: "st-foot" });
const hint = svg("title");
[rule, note, hint].forEach((part) => foot.appendChild(part));
group.appendChild(foot);
layers.regions.appendChild(group);
record = { group: group, box: box, grab: grab, title: title, count: count, rule: rule, columns: columns, note: note, hint: hint };
regionRecords.set(region.id, record);
return record;
}
function prune(records, present) {
records.forEach((record, key) => {
if (present.has(key)) return;
record.group.remove();
records.delete(key);
});
}
/* Three columns across the bottom band of a network. */
function placeFooter(record, region) {
const top = region.footer.y - region.y;
const column = footColumn(region);
put(record.rule, "x1", FOOT_PAD);
put(record.rule, "x2", region.w - FOOT_PAD);
put(record.rule, "y1", top + 2);
put(record.rule, "y2", top + 2);
record.columns.forEach((col, index) => {
const x = FOOT_PAD + index * (column + FOOT_GAP);
put(col.label, "x", x);
put(col.label, "y", top + 20);
put(col.track, "x", x);
put(col.track, "y", top + 26);
put(col.track, "width", column);
put(col.fill, "x", x);
put(col.fill, "y", top + 26);
});
put(record.note, "x", FOOT_PAD);
put(record.note, "y", top + 50);
}
function drawRegions() {
const present = new Set();
map.regions.forEach((region) => {
present.add(region.id);
const record = regionRecord(region);
record.group.style.transform = "translate(" + region.x + "px," + region.y + "px)";
put(record.box, "width", region.w);
put(record.box, "height", region.h);
put(record.grab, "width", region.w);
placeFooter(record, region);
const pinnedNet = selectedNet();
const state = pinnedNet ? (pinnedNet === region.id ? " selected" : " dim") : "";
putClass(record.group, "region n" + region.color + state + (region.moved ? " moved" : ""));
record.title.firstChild.textContent = region.id;
putText(record.count, region.alive === region.count ? region.count + (region.count === 1 ? " node" : " nodes") : region.alive + "/" + region.count + " alive");
});
prune(regionRecords, present);
}
function makeNodeRecord(node) {
const group = svg("g", { class: "node", role: "button", tabindex: "-1" });
group.dataset.key = node.key;
const title = svg("title");
const halo = svg("circle", { class: "halo" });
const shape = node.gateway ? svg("rect", { class: "disc", rx: 9 }) : svg("circle", { class: "disc" });
const dot = svg("circle", { class: "dot" });
const texts = { name: svg("text", { class: "l-name" }), roles: [svg("text", { class: "l-roles" }), svg("text", { class: "l-roles" })], caption: svg("text", { class: "l-caption" }) };
[title, halo, shape, dot, texts.name, texts.roles[0], texts.roles[1], texts.caption].forEach((part) => group.appendChild(part));
/* Hardware: three bars with their numbers, and a footer. */
const stats = svg("g", { class: "stats" });
const rows = Array.from({ length: C.STAT_COUNT }, () => statBar(stats, C.STAT_W));
const footer = svg("text", { class: "st-foot" });
stats.appendChild(footer);
group.appendChild(stats);
layers.nodes.appendChild(group);
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, gateway: node.gateway };
}
function drawNodes() {
const present = new Set();
map.nodes.forEach((node) => {
present.add(node.key);
let record = nodeRecords.get(node.key);
if (record && record.gateway !== node.gateway) {
record.group.remove();
record = null;
}
if (!record) {
record = makeNodeRecord(node);
nodeRecords.set(node.key, record);
}
record.node = node;
if (drag && drag.key === node.key) return;
const info = model.byKey.get(node.key);
record.group.style.transform = "translate(" + node.x + "px," + node.y + "px)";
const r = node.r;
if (node.gateway) {
put(record.shape, "x", -r);
put(record.shape, "y", -r);
put(record.shape, "width", 2 * r);
put(record.shape, "height", 2 * r);
} else {
put(record.shape, "r", r);
}
put(record.halo, "r", r + 6);
put(record.dot, "r", node.gateway ? 5.5 : 4.5);
/* Every node, gateway or not, is the same stack: name, roles, caption, then its stats. */
const lines = C.wrapLabels((node.gateway ? ["gateway"] : []).concat(C.specialRoles(model, info)), 17, 2);
const first = node.box.y - node.y + 11;
const place = (element, index, text) => {
put(element, "x", 0);
put(element, "y", first + index * 13);
put(element, "text-anchor", "middle");
putText(element, text);
};
place(record.texts.name, 0, C.shorten(info.hostname, 15));
record.texts.roles.forEach((element, index) => place(element, 1 + index, lines[index] || ""));
put(record.texts.caption, "x", 0);
put(record.texts.caption, "y", first + (1 + lines.length) * 13);
put(record.texts.caption, "text-anchor", "middle");
put(record.stats, "transform", "translate(" + -C.STAT_W / 2 + "," + (node.box.y - node.y + C.STAT_TOP) + ")");
record.rows.forEach((row, index) => {
put(row.label, "y", index * C.STAT_ROW + 10);
put(row.track, "y", index * C.STAT_ROW + 14);
put(row.fill, "y", index * C.STAT_ROW + 14);
});
put(record.footer, "y", C.STAT_COUNT * C.STAT_ROW + 10);
});
prune(nodeRecords, present);
}
/* The tooltip and the screen reader's text carry the numbers too, so they follow them. */
function describe(record, info) {
const text = C.describeNode(model, info, routes);
putText(record.title, text);
put(record.group, "aria-label", text.replace(/\n/g, ". ") + ". Show routes from this node.");
}
/* The numbers move every second and never change the layout, so they are written on their own. */
function drawStats() {
nodeRecords.forEach((record, key) => {
const info = model.byKey.get(key);
if (!info) return;
if (routes) describe(record, info);
const stats = C.describeStats(info.stats);
stats.rows.forEach(([name, usage], index) => paintBar(record.rows[index], name, usage, C.STAT_W));
putText(record.footer, stats.footer);
});
map.regions.forEach((region) => {
const record = regionRecords.get(region.id);
const totals = C.networkStats(model, region.id);
totals.rows.forEach(([name, usage], index) => paintBar(record.columns[index], name, usage, footColumn(region)));
putText(record.note, totals.nodes ? "total of " + C.plural(totals.nodes, "node") + " on " + C.plural(totals.machines, "machine") : totals.footer);
putText(record.hint, "Cumulative hardware of " + region.id + ": " + C.describeRows(totals.rows).join(", ") + ". Nodes that report the same totals are counted as one machine.");
});
}
/* A gateway's door on the edge of its network, joined to the gateway node inside. */
function drawPorts() {
layers.ports.replaceChildren();
map.nodes.filter((node) => node.gateway).forEach((node) => {
const cls = "n" + node.color;
layers.ports.appendChild(svg("line", { class: "port-link " + cls, x1: node.port.x, y1: node.port.y, x2: node.x, y2: node.y }));
layers.ports.appendChild(svg("rect", { class: "port " + cls, x: node.port.x - 9, y: node.port.y - 9, width: 18, height: 18, rx: 5 }));
});
}
function drawSpokes() {
layers.spokes.replaceChildren();
spokeRecords = map.spokes.map((spoke) => {
const path = svg("path", { class: "spoke n" + spoke.color, d: spoke.d });
layers.spokes.appendChild(path);
return { spoke: spoke, path: path };
});
}
/* ------------------------------------------------------- drawing: the trace */
function drawTrace() {
routes = C.routesFrom(model, viewKey());
trace = C.traceGeometry(map, model, routes);
layers.legs.replaceChildren();
layers.ends.replaceChildren();
trace.legs.forEach((leg) => {
layers.legs.appendChild(svg("path", { class: "flow n" + leg.color, d: leg.d, "marker-end": "url(#arrow-" + leg.color + ")" }));
});
const shown = viewKey();
const pinnedNet = selectedNet();
spokeRecords.forEach(({ spoke, path }) => {
const used = trace.used.has(spoke.gateway + ">" + spoke.to);
layers.ends.appendChild(svg("circle", { class: "spoke-end n" + spoke.color + (used || isPinned(spoke.gateway) ? " used" : ""), cx: spoke.x2, cy: spoke.y2, r: used ? 5.5 : 4 }));
if (used) layers.ends.appendChild(svg("path", { class: "flow n" + spoke.color, d: spoke.d, "marker-end": "url(#arrow-" + spoke.color + ")" }));
const lit = pinnedNet && (spoke.to === pinnedNet || (model.byKey.get(spoke.gateway) || {}).net === pinnedNet);
putClass(path, "spoke n" + spoke.color + (used ? " hidden" : "") + (lit ? " lit" : ""));
});
nodeRecords.forEach((record, key) => {
const node = record.node;
const info = model.byKey.get(key);
const kind = trace.kinds.get(key);
const classes = ["node", "n" + node.color, info.alive ? "alive" : "down"];
if (node.gateway) classes.push("gateway");
if (key === shown) classes.push("origin");
if (isPinned(key)) classes.push("selected");
if (kind === "none" || kind === "unknown") classes.push(kind);
if (kind === "via") classes.push("reached");
if (key === focusKey || key === hovered || key === shown) classes.push("full");
if (pinnedNet && pinnedNet !== node.net) classes.push("dim");
putClass(record.group, classes.join(" "));
putText(record.texts.caption, trace.captions.get(key) || "");
describe(record, info);
put(record.group, "aria-pressed", isPinned(key) ? "true" : "false");
put(record.group, "tabindex", key === focusKey ? "0" : "-1");
});
drawViewpoint();
}
function drawViewpoint() {
const box = viewpointBox;
box.replaceChildren();
const viewer = model.byKey.get(routes.viewKey);
if (!viewer || model.nodes.filter((node) => node.alive).length < 2) return;
box.appendChild(html("span", "Routes from"));
box.appendChild(html("strong", viewer.hostname));
C.routeSummary(model, routes).forEach((part) => {
const region = part.network === undefined ? null : map.regionAt.get(part.network);
box.appendChild(html("span", part.text, "route " + part.kind + (region ? " n" + region.color : "")));
});
box.appendChild(html("span", selectionLocks() ? "Pinned. Select it again to release." : "Hover a node to trace it, select to pin.", "muted hint"));
}
function drawHeader() {
summaryBox.textContent = C.summarize(model);
const box = alertsBox;
box.replaceChildren();
C.alerts(model).forEach((alert) => box.appendChild(html("span", alert.text, "alert " + alert.level)));
ondemandBox.textContent = C.onDemandHint(model);
}
/* A plain list of the same facts, for screen readers. */
function drawReaderList() {
const list = readerList;
list.replaceChildren();
model.networks.forEach((net) => {
const names = model.nodes.filter((node) => node.net === net).map((node) => node.hostname + (model.bridgeAt.has(node.key) ? " (gateway)" : "") + (node.alive ? "" : " (down)"));
list.appendChild(html("li", net + ": " + names.join(", ")));
});
}
const announce = (text) => {
liveRegion.textContent = text;
};
/* --------------------------------------------------------------- the update */
function recompute(now) {
map = C.computeMap(model, layoutState, overrides, now);
lastCompute = now;
drawRegions();
drawNodes();
drawSpokes();
drawPorts();
startCompact();
if (autoFit) fitAll(false);
else setView(view, false);
drawTrace();
drawStats();
updateControls();
mapSignature = model.signature;
}
function update(payload) {
model = C.analyze(payload);
const now = Date.now();
if (focusKey && !model.byKey.has(focusKey)) focusKey = null;
if (!focusKey) focusKey = model.selfKey || (model.nodes[0] || {}).key || null;
if (selection && !(selection.kind === "network" ? model.networks.includes(selection.id) : model.byKey.has(selection.id))) selection = null;
if (hovered && !model.byKey.has(hovered)) hovered = null;
drawHeader();
C.noteGateways(model, layoutState, now);
const ghosts = layoutState.gateways.size > model.bridges.length;
if (model.signature !== mapSignature || (ghosts && now - lastCompute > 5000)) {
recompute(now);
drawReaderList();
} else {
drawStats();
}
}
/* ----------------------------------------------------------------- controls */
const legend = document.getElementById("legend");
const legendButton = document.getElementById("legend-open");
const resetButton = document.getElementById("reset");
function updateControls() {
resetButton.hidden = !C.hasOverrides(overrides);
legend.hidden = !overrides.legend;
legendButton.hidden = overrides.legend;
document.getElementById("fit").classList.toggle("on", autoFit);
}
function zoomStep(factor) {
cancelTween();
setView(C.zoomAt(view, size.w / 2, size.h / 2, factor, map && map.bounds, size), true);
}
document.getElementById("zoom-in").addEventListener("click", () => zoomStep(1.25));
document.getElementById("zoom-out").addEventListener("click", () => zoomStep(0.8));
document.getElementById("fit").addEventListener("click", () => fitAll(true));
resetButton.addEventListener("click", () => {
const legendWas = overrides.legend;
overrides = C.newOverrides();
overrides.legend = legendWas;
layoutState = C.newState();
store.save(overrides);
recompute(Date.now());
fitAll(true);
announce("Layout reset");
});
document.getElementById("legend-close").addEventListener("click", () => {
overrides.legend = false;
saveSoon();
updateControls();
});
legendButton.addEventListener("click", () => {
overrides.legend = true;
saveSoon();
updateControls();
});
/* ------------------------------------------------------------ pointer input */
const local = (event) => {
const box = svgRoot.getBoundingClientRect();
return { x: event.clientX - box.left, y: event.clientY - box.top };
};
function targetOf(element) {
const node = element.closest && element.closest(".node");
if (node) return { kind: node.classList.contains("gateway") ? "gateway" : "node", key: node.dataset.key };
const grab = element.closest && element.closest(".region-grab");
if (grab) return { kind: "region-title", key: grab.dataset.net };
const box = element.closest && element.closest(".region-box");
if (box) return { kind: "region", key: box.dataset.net };
return { kind: "background", key: null };
}
function select(next) {
selection = next;
hovered = null;
if (next && next.kind === "node") announce("Tracing routes from " + model.byKey.get(next.id).hostname);
else if (next && next.kind === "network") announce("Network " + next.id + " selected");
else announce("Selection cleared");
drawRegions();
drawTrace();
}
const same = (a, b) => Boolean(a && b && a.kind === b.kind && a.id === b.id);
const toggle = (next) => select(same(selection, next) ? null : next);
function cancelTween() {
if (tween) cancelAnimationFrame(tween.frame);
tween = null;
}
function perform(action) {
if (["pan", "zoom", "drag-start"].includes(action.type)) cancelTween();
if (action.type === "pan") setView(C.panBy(view, action.dx, action.dy), true);
else if (action.type === "zoom") setView(C.zoomAt(view, action.x, action.y, action.factor, map && map.bounds, size), true);
else if (action.type === "fit") {
if (action.target && action.target.kind.startsWith("region")) fitRegion(action.target.key);
else fitAll(true);
} else if (action.type === "click") {
const target = action.target;
if (target.kind === "node" || target.kind === "gateway") {
focusKey = target.key;
toggle({ kind: "node", id: target.key });
} else if (target.kind === "region" || target.kind === "region-title") toggle({ kind: "network", id: target.key });
else if (selection) select(null);
} else if (action.type === "drag-start") startDrag(action.target);
else if (action.type === "drag-move") moveDrag(action);
else if (action.type === "drag-end") endDrag(true);
else if (action.type === "drag-cancel") endDrag(false);
}
function startDrag(target) {
if (target.kind === "region-title") {
const region = map.regionAt.get(target.key);
drag = { kind: "region", net: target.key, x: region.x, y: region.y, before: overrides.regions[target.key] };
autoFit = false;
updateControls();
return;
}
const node = map.nodeAt.get(target.key);
if (!node) return;
drag = { kind: target.kind, key: target.key, net: node.net, x: node.x, y: node.y, from: node.cell };
nodeRecords.get(target.key).group.classList.add("dragging");
}
let dragFrame = null;
function moveDrag(action) {
if (!drag) return;
drag.x += action.dx / view.k;
drag.y += action.dy / view.k;
if (drag.kind === "region") {
C.setRegion(overrides, drag.net, { x: drag.x, y: drag.y, cols: (overrides.regions[drag.net] || {}).cols });
if (dragFrame) return;
dragFrame = requestAnimationFrame(() => {
dragFrame = null;
if (drag) recompute(Date.now());
});
} else if (nodeRecords.has(drag.key)) {
nodeRecords.get(drag.key).group.style.transform = "translate(" + drag.x + "px," + drag.y + "px)";
}
}
function endDrag(keep) {
if (!drag) return;
const done = drag;
drag = null;
if (dragFrame) cancelAnimationFrame(dragFrame);
dragFrame = null;
if (done.kind !== "region" && nodeRecords.has(done.key)) nodeRecords.get(done.key).group.classList.remove("dragging");
if (done.kind !== "region" && !map.nodeAt.has(done.key)) return;
if (!keep) {
if (done.kind === "region") {
if (done.before) overrides.regions[done.net] = done.before;
else delete overrides.regions[done.net];
}
} else if (done.kind === "node") {
const cell = C.cellAt(map, done.net, done.x, done.y);
if (cell !== null && cell !== done.from) {
const other = map.nodes.find((node) => node.net === done.net && node.cell === cell && node.key !== done.key);
C.setCell(overrides, done.net, done.key, cell);
if (other) C.setCell(overrides, done.net, other.key, done.from);
}
} else if (done.kind === "gateway") {
C.setDock(overrides, done.key, C.dockAt(map, done.net, done.x, done.y, map.nodeAt.get(done.key).dock.side));
}
saveSoon();
recompute(Date.now());
}
svgRoot.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
const point = local(event);
try {
svgRoot.setPointerCapture(event.pointerId);
} catch (error) {
/* a synthetic event has no capturable pointer */
}
lastPressed = targetOf(event.target);
const result = C.reduceGesture(gesture, { type: "down", id: event.pointerId, x: point.x, y: point.y, pointerType: event.pointerType, target: lastPressed, selected: selection ? selection.id : null });
gesture = result.state;
result.actions.forEach(perform);
});
svgRoot.addEventListener("pointermove", (event) => {
const point = local(event);
if (gesture.mode === "idle") {
if (event.pointerType !== "mouse") return;
const target = targetOf(event.target);
const key = target.kind === "node" || target.kind === "gateway" ? target.key : null;
if (key !== hovered && !selectionLocks()) {
hovered = key;
drawTrace();
}
return;
}
const result = C.reduceGesture(gesture, { type: "move", id: event.pointerId, x: point.x, y: point.y });
gesture = result.state;
result.actions.forEach(perform);
});
["pointerup", "pointercancel"].forEach((type) => {
svgRoot.addEventListener(type, (event) => {
const point = local(event);
const result = C.reduceGesture(gesture, { type: type === "pointerup" ? "up" : "cancel", id: event.pointerId, x: point.x, y: point.y });
gesture = result.state;
result.actions.forEach(perform);
});
});
svgRoot.addEventListener("pointerleave", (event) => {
const focused = nodeFromElement(document.activeElement);
if (event.pointerType === "mouse" && hovered && gesture.mode === "idle" && model && !(focused && focused.dataset.key === hovered)) {
hovered = null;
drawTrace();
}
});
svgRoot.addEventListener("wheel", (event) => {
event.preventDefault();
const point = local(event);
const result = C.reduceGesture(gesture, { type: "wheel", x: point.x, y: point.y, deltaY: event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY, ctrl: event.ctrlKey });
result.actions.forEach(perform);
}, { passive: false });
svgRoot.addEventListener("dblclick", (event) => {
/* With the pointer captured, the event's own target is the svg: use what was pressed. */
C.reduceGesture(gesture, { type: "dblclick", target: lastPressed || targetOf(event.target) }).actions.forEach(perform);
});
/* ----------------------------------------------------------------- keyboard */
const nodeFromElement = (element) => (element && element.closest ? element.closest(".node") : null);
function focusNode(key) {
focusKey = key;
const record = nodeRecords.get(key);
if (!record) return;
nodeRecords.forEach((other, id) => put(other.group, "tabindex", id === key ? "0" : "-1"));
record.group.focus({ preventScroll: true });
const node = map.nodeAt.get(key);
const x = node.x * view.k + view.tx;
const y = node.y * view.k + view.ty;
const shift = (at, limit) => (at < 40 ? 40 - at : at > limit - 40 ? limit - 40 - at : 0);
if (shift(x, size.w) || shift(y, size.h)) setView(C.panBy(view, shift(x, size.w), shift(y, size.h)), true);
}
function moveFocus(dx, dy) {
const from = map.nodeAt.get(focusKey);
if (!from) return;
let best = null;
map.nodes.forEach((node) => {
const ahead = (node.x - from.x) * dx + (node.y - from.y) * dy;
if (node === from || ahead <= 0) return;
const score = ahead + (Math.abs((node.x - from.x) * dy) + Math.abs((node.y - from.y) * dx)) * 2;
if (!best || score < best.score) best = { node: node, score: score };
});
if (best) focusNode(best.node.key);
}
/* Alt+arrows: the keyboard's way of dragging. */
function nudge(dx, dy) {
const node = map.nodeAt.get(focusKey);
if (selectedNet() && !nodeFromElement(document.activeElement)) {
const region = map.regionAt.get(selectedNet());
C.setRegion(overrides, region.id, { x: region.x + dx * C.CELL_W, y: region.y + dy * C.CELL_H, cols: (overrides.regions[region.id] || {}).cols });
} else if (node && node.gateway) {
const side = node.dock.side;
const along = side === "left" || side === "right" ? dy : dx;
const line = map.nodes.filter((other) => other.gateway && other.net === node.net && other.dock.side === side).sort((a, b) => a.dock.index - b.dock.index);
const target = line.indexOf(node) + along;
if (!along || target < 0 || target >= line.length) return;
line.splice(line.indexOf(node), 1);
line.splice(target, 0, node);
line.forEach((other, index) => C.setDock(overrides, other.key, { side: side, t: index * 10 }));
} else if (node) {
const region = map.regionAt.get(node.net);
const col = node.col + dx;
const row = node.row + dy;
if (col < 0 || col >= region.cols || row < 0 || row > region.rows) return;
const cell = row * region.cols + col;
const other = map.nodes.find((entry) => entry.net === node.net && entry.cell === cell);
C.setCell(overrides, node.net, node.key, cell);
if (other) C.setCell(overrides, node.net, other.key, node.cell);
} else {
return;
}
saveSoon();
recompute(Date.now());
const record = focusKey && nodeRecords.get(focusKey);
if (record && document.activeElement !== record.group && nodeFromElement(document.activeElement)) record.group.focus({ preventScroll: true });
}
function nextNetwork(step) {
const nets = model.networks;
const at = selectedNet() ? nets.indexOf(selectedNet()) : -1;
const net = nets[(at + step + nets.length) % nets.length];
select({ kind: "network", id: net });
const first = map.nodes.find((node) => node.net === net);
if (first) focusNode(first.key);
fitRegion(net);
}
svgRoot.addEventListener("keydown", (event) => {
if (!map) return;
const target = nodeFromElement(event.target);
const action = C.keyAction(event, { onNode: Boolean(target) });
if (!action) return;
event.preventDefault();
if (action.type === "move-focus") moveFocus(action.dx, action.dy);
else if (action.type === "nudge") nudge(action.dx, action.dy);
else if (action.type === "zoom-step") zoomStep(action.factor);
else if (action.type === "fit") fitAll(true);
else if (action.type === "fit-selected") {
const net = selectedNet() || (map.nodeAt.get(focusKey) || {}).net;
if (net) fitRegion(net);
} else if (action.type === "next-network") nextNetwork(action.step);
else if (action.type === "clear") select(null);
else if (action.type === "activate") toggle({ kind: "node", id: target.dataset.key });
else if (action.type === "pan") setView(C.panBy(view, action.dx, action.dy), true);
});
svgRoot.addEventListener("focusin", (event) => {
const node = nodeFromElement(event.target);
if (!node || !map) return;
focusKey = node.dataset.key;
if (!selectionLocks() && hovered !== focusKey) {
hovered = focusKey;
drawTrace();
}
});
svgRoot.addEventListener("focusout", (event) => {
if (nodeFromElement(event.relatedTarget) || !map || !hovered) return;
hovered = null;
drawTrace();
});
/* --------------------------------------------------------------------- data */
if (typeof ResizeObserver !== "undefined") new ResizeObserver(resize).observe(stage);
else window.addEventListener("resize", resize);
let busy = false;
async function refresh() {
if (busy || document.hidden) return;
busy = true;
try {
const response = await fetch("/api/status?compact=1", { cache: "no-store" });
if (!response.ok) throw new Error("the node answered with HTTP " + response.status);
update(await response.json());
errorBanner.hidden = true;
updatedLabel.textContent = "Updated " + new Date().toLocaleTimeString();
} catch (error) {
const banner = errorBanner;
banner.textContent = "Cannot reach the node: " + error.message + ". Retrying...";
banner.hidden = false;
} finally {
busy = false;
}
}
document.addEventListener("visibilitychange", () => {
if (!document.hidden) refresh();
});
svgRoot.setAttribute("viewBox", "0 0 " + size.w + " " + size.h);
updateControls();
refresh();
setInterval(refresh, REFRESH_MS);
})();