Files
colony/colony/core/roles/webview/static/app.js
T
2026-10-03 10:04:45 +02:00

1256 lines
50 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));
const badge = svg("g", { class: "badge" });
const badgeText = svg("text", { "text-anchor": "middle", y: 3.5 });
badge.appendChild(svg("circle", { r: 9 }));
badge.appendChild(badgeText);
/* 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);
group.appendChild(badge);
layers.nodes.appendChild(group);
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, badge: badge, badgeText: badgeText, 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);
drawUnread();
}
function drawUnread() {
let total = 0;
nodeRecords.forEach((record, key) => {
const info = model.byKey.get(key);
const count = info ? unreadOf(info.id) : 0;
total += count;
const corner = record.node.r * 0.8;
put(record.badge, "transform", "translate(" + corner + "," + -corner + ")");
put(record.badge, "display", count ? "inline" : "none");
putText(record.badgeText, count > 9 ? "9+" : String(count));
});
document.title = total ? "(" + total + ") Colony" : "Colony";
drawBell(total);
}
/* The tooltip and the screen reader's text carry the numbers too, so they follow them. */
function describe(record, info) {
const unread = unreadOf(info.id);
const text = C.describeNode(model, info, routes) + (unread ? "\n" + C.plural(unread, "unread message") : "");
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();
}
updateChat();
}
/* ----------------------------------------------------------------- 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();
updateChat();
}
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();
});
const [chatPanel, chatTitle, chatStatus, chatLog, chatEmpty, chatError, chatForm, chatInput, chatSend, chatClose, chatLive] = ["chat", "chat-title", "chat-status", "chat-log", "chat-empty", "chat-error", "chat-form", "chat-input", "chat-send", "chat-close", "chat-live"].map((id) => document.getElementById(id));
let chat = { peer: null, after: 0, loaded: false, loading: false };
const chatTarget = () => (model && selectionLocks() && selection.id !== model.selfKey ? model.byKey.get(selection.id) : null);
const chatTime = (at) => {
const date = new Date(at);
return isNaN(date) ? "" : date.toLocaleString([], { dateStyle: "short", timeStyle: "short" });
};
function updateChat() {
const peer = chatTarget();
chatPanel.hidden = !peer;
stage.classList.toggle("chatting", Boolean(peer));
if (!peer) {
chat = { peer: null, after: 0, loaded: false, loading: false };
return;
}
if (chat.peer !== peer.id) {
chat = { peer: peer.id, hostname: peer.hostname, after: 0, loaded: false, loading: false };
chatTitle.textContent = "Chat with " + peer.hostname;
chatLog.replaceChildren();
chatEmpty.hidden = true;
chatError.hidden = true;
chatInput.value = "";
}
const state = C.chatState(model, peer.key);
const ignored = isIgnored(peer.id);
const status = [ignored ? "You are ignoring " + peer.hostname + " in this browser: no notifications or unread counts for them." : "", state.text].filter(Boolean).join(" ");
chatStatus.textContent = status;
chatStatus.hidden = !status;
chatForm.hidden = !state.send;
chatIgnore.hidden = !inbox || !state.read;
chatIgnore.textContent = ignored ? "Unignore" : "Ignore";
chatIgnore.title = ignored ? "Get notified about " + peer.hostname + " again" : "Stop notifications and unread counts for " + peer.hostname;
chatIgnore.setAttribute("aria-pressed", ignored ? "true" : "false");
if (state.read) loadChat();
}
function paintChatMessages(messages, fresh) {
const atBottom = chatLog.scrollTop + chatLog.clientHeight >= chatLog.scrollHeight - 8;
messages.forEach((message) => {
const item = html("li", undefined, "msg " + (message.direction === "out" ? "out" : "in"));
item.appendChild(html("span", message.direction === "out" ? "You" : chat.hostname, "msg-from sr-only"));
item.appendChild(html("p", message.text, "msg-text"));
const time = html("time", chatTime(message.at), "msg-time");
time.dateTime = message.at;
item.appendChild(time);
chatLog.appendChild(item);
});
if (atBottom || !fresh) chatLog.scrollTop = chatLog.scrollHeight;
const incoming = messages.filter((message) => message.direction === "in");
if (fresh && incoming.length) chatLive.textContent = chat.hostname + " says: " + incoming[incoming.length - 1].text;
}
async function loadChat() {
const current = chat;
if (current.loading || !current.peer) return;
current.loading = true;
try {
const response = await fetch("/api/chat?peer=" + encodeURIComponent(current.peer) + "&after=" + current.after, { cache: "no-store" });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
if (chat !== current) return;
paintChatMessages(body.messages, current.loaded);
if (body.messages.length) current.after = body.messages[body.messages.length - 1].seq;
current.loaded = true;
if (!document.hidden) markSeen(current.peer);
chatEmpty.hidden = chatLog.children.length > 0;
if (chatError.dataset.from === "load") chatError.hidden = true;
} catch (error) {
if (chat === current) {
chatError.textContent = "Cannot load the messages: " + error.message;
chatError.dataset.from = "load";
chatError.hidden = false;
}
} finally {
current.loading = false;
}
}
chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const text = chatInput.value.trim();
const current = chat;
if (!text || !current.peer || chatSend.disabled) return;
chatInput.disabled = chatSend.disabled = true;
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ peer: current.peer, text: text }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
if (chat === current) {
chatInput.value = "";
chatError.hidden = true;
loadChat();
}
} catch (error) {
if (chat === current) {
chatError.textContent = "Not sent: " + error.message;
chatError.dataset.from = "send";
chatError.hidden = false;
}
} finally {
chatInput.disabled = chatSend.disabled = false;
if (!chatPanel.hidden) chatInput.focus();
}
});
/* ------------------------------------------------------------ notifications */
const SEEN_KEY = "colony.chat.seen.v1";
const IGNORED_KEY = "colony.chat.ignored.v1";
const TOAST_MS = 8000;
const TOAST_LIMIT = 3;
const [notifyButton, toastBox, chatIgnore, bellWrap, bell, bellCount, inboxPanel, inboxList, inboxEmpty, inboxRead] = ["notify", "toasts", "chat-ignore", "bell-wrap", "bell", "bell-count", "inbox-panel", "inbox-list", "inbox-empty", "inbox-read"].map((id) => document.getElementById(id));
let inbox = null;
let inboxText = "";
let inboxBusy = false;
let inboxSignature = "";
let seen = loadStored(SEEN_KEY);
const ignoredPeers = new Set(Object.keys(loadStored(IGNORED_KEY) || {}));
function loadStored(key) {
try {
const value = JSON.parse(window.localStorage.getItem(key));
return value && typeof value === "object" ? value : null;
} catch (error) {
return null;
}
}
function saveStored(key, value) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
/* private mode: this is forgotten with the page */
}
}
const saveSeen = () => saveStored(SEEN_KEY, seen);
const isIgnored = (peerId) => ignoredPeers.has(peerId);
const unreadOf = (peerId) => {
const entry = inbox && !isIgnored(peerId) ? inbox[peerId] : null;
return entry ? Math.max(0, entry.incoming - ((seen && seen[peerId]) || 0)) : 0;
};
const peerName = (peerId) => {
const node = model && model.nodes.find((entry) => entry.id === peerId);
return node ? node.hostname : peerId.split("@")[0];
};
function markSeen(peerId) {
const entry = inbox && inbox[peerId];
if (!entry || !seen || seen[peerId] === entry.incoming) return;
seen[peerId] = entry.incoming;
saveSeen();
if (model) drawUnread();
}
function drawBell(total) {
bellWrap.hidden = !inbox;
if (!inbox) toggleInbox(false);
bellCount.hidden = !total;
bellCount.textContent = total > 99 ? "99+" : String(total);
bell.classList.toggle("unread", total > 0);
bell.setAttribute("aria-label", total ? "Messages, " + C.plural(total, "unread message") : "Messages");
drawInbox();
}
function drawInbox() {
if (inboxPanel.hidden || !inbox) return;
const conversations = Object.keys(inbox)
.filter((peerId) => inbox[peerId].last && !isIgnored(peerId))
.sort((a, b) => (inbox[b].last.at || "").localeCompare(inbox[a].last.at || ""));
const signature = JSON.stringify(conversations.map((peerId) => [peerId, peerName(peerId), unreadOf(peerId), inbox[peerId].last]));
if (signature === inboxSignature) return;
inboxSignature = signature;
inboxList.replaceChildren();
conversations.forEach((peerId) => {
const unread = unreadOf(peerId);
const last = inbox[peerId].last;
const row = html("button", undefined, "inbox-row" + (unread ? " unread" : ""));
row.type = "button";
row.appendChild(html("span", peerName(peerId), "inbox-name"));
const time = html("time", chatTime(last.at), "inbox-time");
time.dateTime = last.at;
row.appendChild(time);
row.appendChild(html("span", last.text, "inbox-text"));
if (unread) row.appendChild(html("span", unread > 99 ? "99+" : String(unread), "inbox-count"));
row.setAttribute("aria-label", peerName(peerId) + (unread ? ", " + C.plural(unread, "unread message") : "") + ": " + last.text);
row.addEventListener("click", () => openChat(peerId));
const item = html("li");
item.appendChild(row);
inboxList.appendChild(item);
});
inboxEmpty.hidden = conversations.length > 0;
inboxRead.disabled = !conversations.some((peerId) => unreadOf(peerId));
}
function toggleInbox(open) {
if (open === !inboxPanel.hidden) return;
inboxPanel.hidden = !open;
inboxSignature = "";
bell.setAttribute("aria-expanded", open ? "true" : "false");
if (open) {
toastBox.replaceChildren();
updateNotifyButton();
drawInbox();
}
}
function markAllRead() {
Object.keys(inbox).forEach((peerId) => (seen[peerId] = inbox[peerId].incoming));
saveSeen();
drawUnread();
drawStats();
}
function openChat(peerId) {
const node = model && model.nodes.find((entry) => entry.id === peerId);
toggleInbox(false);
if (!node) return;
focusKey = node.key;
if (!isPinned(node.key)) select({ kind: "node", id: node.key });
chatInput.focus();
}
function showToast(peerId, name, text) {
const toast = html("button", undefined, "toast");
toast.type = "button";
toast.appendChild(html("strong", name));
toast.appendChild(html("span", text));
toast.addEventListener("click", () => {
toast.remove();
openChat(peerId);
});
toastBox.appendChild(toast);
while (toastBox.children.length > TOAST_LIMIT) toastBox.firstChild.remove();
setTimeout(() => toast.remove(), TOAST_MS);
}
function notifyMessage(peerId, message, count) {
const name = peerName(peerId);
const text = count > 1 ? C.plural(count, "new message") + ", last: " + message.text : message.text;
if (inboxPanel.hidden) showToast(peerId, name, text);
if (!("Notification" in window) || Notification.permission !== "granted" || (!document.hidden && document.hasFocus())) return;
const notification = new Notification("Message from " + name, { body: text, tag: "colony-chat-" + peerId });
notification.addEventListener("click", () => {
window.focus();
openChat(peerId);
notification.close();
});
}
function handleInbox(next) {
const previous = inbox;
inbox = next;
if (!seen) {
seen = {};
Object.keys(next).forEach((peerId) => (seen[peerId] = next[peerId].incoming));
saveSeen();
}
Object.keys(next).forEach((peerId) => {
const entry = next[peerId];
const before = previous ? (previous[peerId] || { incoming: 0 }).incoming : entry.incoming;
if (entry.incoming <= before || isIgnored(peerId) || !entry.last) return;
if (chat.peer === peerId && !document.hidden) return;
notifyMessage(peerId, entry.last, entry.incoming - before);
});
if (model) {
drawUnread();
drawStats();
updateChat();
}
}
async function pollInbox() {
if (inboxBusy) return;
inboxBusy = true;
try {
const response = await fetch("/api/chat/inbox", { cache: "no-store" });
if (response.status === 409 && inbox) {
inboxText = "";
handleInbox(null);
}
if (!response.ok) return;
const text = await response.text();
if (text === inboxText) return;
inboxText = text;
handleInbox(JSON.parse(text));
} catch (error) {
/* the status refresh already shows when the node cannot be reached */
} finally {
inboxBusy = false;
}
}
function updateNotifyButton() {
notifyButton.hidden = !("Notification" in window) || !window.isSecureContext || Notification.permission !== "default";
}
bell.addEventListener("click", () => toggleInbox(inboxPanel.hidden));
inboxRead.addEventListener("click", markAllRead);
bellWrap.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || inboxPanel.hidden) return;
event.preventDefault();
toggleInbox(false);
bell.focus();
});
document.addEventListener("pointerdown", (event) => {
if (!inboxPanel.hidden && !bellWrap.contains(event.target)) toggleInbox(false);
});
notifyButton.addEventListener("click", async () => {
await Notification.requestPermission();
updateNotifyButton();
});
chatIgnore.addEventListener("click", () => {
const peerId = chat.peer;
if (!peerId) return;
const ignored = !isIgnored(peerId);
if (ignored) ignoredPeers.add(peerId);
else ignoredPeers.delete(peerId);
saveStored(IGNORED_KEY, Object.fromEntries(Array.from(ignoredPeers, (id) => [id, true])));
if (!ignored) markSeen(peerId);
announce((ignored ? "Ignoring " : "No longer ignoring ") + chat.hostname);
drawUnread();
drawStats();
updateChat();
});
chatClose.addEventListener("click", () => select(null));
chatPanel.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
const key = selection && selection.id;
select(null);
if (key && model.byKey.has(key)) focusNode(key);
});
/* --------------------------------------------------------------------- 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) return;
refresh();
if (chat.peer && chat.loaded) markSeen(chat.peer);
});
svgRoot.setAttribute("viewBox", "0 0 " + size.w + " " + size.h);
updateControls();
updateNotifyButton();
refresh();
setInterval(refresh, REFRESH_MS);
pollInbox();
setInterval(pollInbox, REFRESH_MS);
})();