883 lines
36 KiB
JavaScript
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);
|
|
})();
|