This commit is contained in:
ciomek committed 2026-09-20 13:20:26 +02:00
1 parent cb735d4fa2
commit 1484483100
6 files changed
+376 -102

No files matched your search

+149 -22
View File
@@ -18,6 +18,7 @@
const layers = { const layers = {
spokes: document.getElementById("layer-spokes"), spokes: document.getElementById("layer-spokes"),
regions: document.getElementById("layer-regions"), regions: document.getElementById("layer-regions"),
ports: document.getElementById("layer-ports"),
ends: document.getElementById("layer-ends"), ends: document.getElementById("layer-ends"),
legs: document.getElementById("layer-legs"), legs: document.getElementById("layer-legs"),
nodes: document.getElementById("layer-nodes"), nodes: document.getElementById("layer-nodes"),
@@ -93,6 +94,7 @@
let focusKey = null; let focusKey = null;
let gesture = C.initialGesture(); let gesture = C.initialGesture();
let drag = null; let drag = null;
let lastPressed = null;
let tween = null; let tween = null;
const nodeRecords = new Map(); const nodeRecords = new Map();
const regionRecords = new Map(); const regionRecords = new Map();
@@ -165,6 +167,31 @@
/* ------------------------------------------------------------ drawing: map */ /* ------------------------------------------------------------ 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) { function regionRecord(region) {
let record = regionRecords.get(region.id); let record = regionRecords.get(region.id);
if (record) return record; if (record) return record;
@@ -178,8 +205,17 @@
title.appendChild(count); title.appendChild(count);
[box, grab, dot, title].forEach((part) => group.appendChild(part)); [box, grab, dot, title].forEach((part) => group.appendChild(part));
[box, grab].forEach((part) => (part.dataset.net = region.id)); [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); layers.regions.appendChild(group);
record = { group: group, box: box, grab: grab, title: title, count: count }; record = { group: group, box: box, grab: grab, title: title, count: count, rule: rule, columns: columns, note: note, hint: hint };
regionRecords.set(region.id, record); regionRecords.set(region.id, record);
return record; return record;
} }
@@ -192,6 +228,28 @@
}); });
} }
/* 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() { function drawRegions() {
const present = new Set(); const present = new Set();
map.regions.forEach((region) => { map.regions.forEach((region) => {
@@ -201,6 +259,7 @@
put(record.box, "width", region.w); put(record.box, "width", region.w);
put(record.box, "height", region.h); put(record.box, "height", region.h);
put(record.grab, "width", region.w); put(record.grab, "width", region.w);
placeFooter(record, region);
const pinnedNet = selectedNet(); const pinnedNet = selectedNet();
const state = pinnedNet ? (pinnedNet === region.id ? " selected" : " dim") : ""; const state = pinnedNet ? (pinnedNet === region.id ? " selected" : " dim") : "";
putClass(record.group, "region n" + region.color + state + (region.moved ? " moved" : "")); putClass(record.group, "region n" + region.color + state + (region.moved ? " moved" : ""));
@@ -219,8 +278,16 @@
const dot = svg("circle", { class: "dot" }); 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" }) }; 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)); [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); layers.nodes.appendChild(group);
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, gateway: node.gateway }; return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, gateway: node.gateway };
} }
function drawNodes() { function drawNodes() {
@@ -253,27 +320,69 @@
put(record.halo, "r", r + 6); put(record.halo, "r", r + 6);
put(record.dot, "r", node.gateway ? 5.5 : 4.5); put(record.dot, "r", node.gateway ? 5.5 : 4.5);
/* Labels sit in the box the layout reserved for them. */ /* 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 lines = C.wrapLabels((node.gateway ? ["gateway"] : []).concat(C.specialRoles(model, info)), 17, 2);
const side = node.dock ? node.dock.side : null;
const anchor = side === "right" ? "end" : side ? "start" : "middle";
const x = side === "right" ? node.box.x + node.box.w - node.x : side ? node.box.x - node.x : 0;
const first = node.box.y - node.y + 11; const first = node.box.y - node.y + 11;
const place = (element, index, text) => { const place = (element, index, text) => {
put(element, "x", x); put(element, "x", 0);
put(element, "y", first + index * 13); put(element, "y", first + index * 13);
put(element, "text-anchor", anchor); put(element, "text-anchor", "middle");
putText(element, text); putText(element, text);
}; };
place(record.texts.name, 0, C.shorten(info.hostname, 15)); place(record.texts.name, 0, C.shorten(info.hostname, 15));
record.texts.roles.forEach((element, index) => place(element, 1 + index, lines[index] || "")); record.texts.roles.forEach((element, index) => place(element, 1 + index, lines[index] || ""));
put(record.texts.caption, "x", x); put(record.texts.caption, "x", 0);
put(record.texts.caption, "y", first + (1 + lines.length) * 13); put(record.texts.caption, "y", first + (1 + lines.length) * 13);
put(record.texts.caption, "text-anchor", anchor); 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); 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() { function drawSpokes() {
layers.spokes.replaceChildren(); layers.spokes.replaceChildren();
spokeRecords = map.spokes.map((spoke) => { spokeRecords = map.spokes.map((spoke) => {
@@ -319,9 +428,7 @@
putClass(record.group, classes.join(" ")); putClass(record.group, classes.join(" "));
putText(record.texts.caption, trace.captions.get(key) || ""); putText(record.texts.caption, trace.captions.get(key) || "");
const text = C.describeNode(model, info, routes); describe(record, info);
putText(record.title, text);
put(record.group, "aria-label", text.replace(/\n/g, ". ") + ". Show routes from this node.");
put(record.group, "aria-pressed", isPinned(key) ? "true" : "false"); put(record.group, "aria-pressed", isPinned(key) ? "true" : "false");
put(record.group, "tabindex", key === focusKey ? "0" : "-1"); put(record.group, "tabindex", key === focusKey ? "0" : "-1");
}); });
@@ -371,14 +478,16 @@
function recompute(now) { function recompute(now) {
map = C.computeMap(model, layoutState, overrides, now); map = C.computeMap(model, layoutState, overrides, now);
lastCompute = now; lastCompute = now;
mapSignature = model.signature;
drawRegions(); drawRegions();
drawNodes(); drawNodes();
drawSpokes(); drawSpokes();
drawPorts();
if (autoFit) fitAll(false); if (autoFit) fitAll(false);
else setView(view, false); else setView(view, false);
drawTrace(); drawTrace();
drawStats();
updateControls(); updateControls();
mapSignature = model.signature;
} }
function update(payload) { function update(payload) {
@@ -390,10 +499,13 @@
if (hovered && !model.byKey.has(hovered)) hovered = null; if (hovered && !model.byKey.has(hovered)) hovered = null;
drawHeader(); drawHeader();
C.noteGateways(model, layoutState, now);
const ghosts = layoutState.gateways.size > model.bridges.length; const ghosts = layoutState.gateways.size > model.bridges.length;
if (model.signature !== mapSignature || (ghosts && now - lastCompute > 5000)) { if (model.signature !== mapSignature || (ghosts && now - lastCompute > 5000)) {
recompute(now); recompute(now);
drawReaderList(); drawReaderList();
} else {
drawStats();
} }
} }
@@ -411,6 +523,7 @@
} }
function zoomStep(factor) { function zoomStep(factor) {
cancelTween();
setView(C.zoomAt(view, size.w / 2, size.h / 2, factor, map && map.bounds, size), true); setView(C.zoomAt(view, size.w / 2, size.h / 2, factor, map && map.bounds, size), true);
} }
@@ -418,7 +531,9 @@
document.getElementById("zoom-out").addEventListener("click", () => zoomStep(0.8)); document.getElementById("zoom-out").addEventListener("click", () => zoomStep(0.8));
document.getElementById("fit").addEventListener("click", () => fitAll(true)); document.getElementById("fit").addEventListener("click", () => fitAll(true));
resetButton.addEventListener("click", () => { resetButton.addEventListener("click", () => {
const legendWas = overrides.legend;
overrides = C.newOverrides(); overrides = C.newOverrides();
overrides.legend = legendWas;
layoutState = C.newState(); layoutState = C.newState();
store.save(overrides); store.save(overrides);
recompute(Date.now()); recompute(Date.now());
@@ -466,7 +581,13 @@
const same = (a, b) => Boolean(a && b && a.kind === b.kind && a.id === b.id); const same = (a, b) => Boolean(a && b && a.kind === b.kind && a.id === b.id);
const toggle = (next) => select(same(selection, next) ? null : next); const toggle = (next) => select(same(selection, next) ? null : next);
function cancelTween() {
if (tween) cancelAnimationFrame(tween.frame);
tween = null;
}
function perform(action) { 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); 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 === "zoom") setView(C.zoomAt(view, action.x, action.y, action.factor, map && map.bounds, size), true);
else if (action.type === "fit") { else if (action.type === "fit") {
@@ -489,9 +610,12 @@
if (target.kind === "region-title") { if (target.kind === "region-title") {
const region = map.regionAt.get(target.key); const region = map.regionAt.get(target.key);
drag = { kind: "region", net: target.key, x: region.x, y: region.y, before: overrides.regions[target.key] }; drag = { kind: "region", net: target.key, x: region.x, y: region.y, before: overrides.regions[target.key] };
autoFit = false;
updateControls();
return; return;
} }
const node = map.nodeAt.get(target.key); 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 }; 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"); nodeRecords.get(target.key).group.classList.add("dragging");
} }
@@ -508,7 +632,7 @@
dragFrame = null; dragFrame = null;
if (drag) recompute(Date.now()); if (drag) recompute(Date.now());
}); });
} else { } else if (nodeRecords.has(drag.key)) {
nodeRecords.get(drag.key).group.style.transform = "translate(" + drag.x + "px," + drag.y + "px)"; nodeRecords.get(drag.key).group.style.transform = "translate(" + drag.x + "px," + drag.y + "px)";
} }
} }
@@ -519,7 +643,8 @@
drag = null; drag = null;
if (dragFrame) cancelAnimationFrame(dragFrame); if (dragFrame) cancelAnimationFrame(dragFrame);
dragFrame = null; dragFrame = null;
if (done.kind !== "region") nodeRecords.get(done.key).group.classList.remove("dragging"); 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 (!keep) {
if (done.kind === "region") { if (done.kind === "region") {
@@ -534,7 +659,7 @@
if (other) C.setCell(overrides, done.net, other.key, done.from); if (other) C.setCell(overrides, done.net, other.key, done.from);
} }
} else if (done.kind === "gateway") { } else if (done.kind === "gateway") {
C.setDock(overrides, done.key, C.dockAt(map, done.net, done.x, done.y)); C.setDock(overrides, done.key, C.dockAt(map, done.net, done.x, done.y, map.nodeAt.get(done.key).dock.side));
} }
saveSoon(); saveSoon();
recompute(Date.now()); recompute(Date.now());
@@ -548,8 +673,8 @@
} catch (error) { } catch (error) {
/* a synthetic event has no capturable pointer */ /* a synthetic event has no capturable pointer */
} }
const selected = selection && selection.kind === "node" ? selection.id : null; lastPressed = targetOf(event.target);
const result = C.reduceGesture(gesture, { type: "down", id: event.pointerId, x: point.x, y: point.y, pointerType: event.pointerType, target: targetOf(event.target), selected: selected }); 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; gesture = result.state;
result.actions.forEach(perform); result.actions.forEach(perform);
}); });
@@ -578,7 +703,8 @@
}); });
}); });
svgRoot.addEventListener("pointerleave", (event) => { svgRoot.addEventListener("pointerleave", (event) => {
if (event.pointerType === "mouse" && hovered && gesture.mode === "idle" && model) { const focused = nodeFromElement(document.activeElement);
if (event.pointerType === "mouse" && hovered && gesture.mode === "idle" && model && !(focused && focused.dataset.key === hovered)) {
hovered = null; hovered = null;
drawTrace(); drawTrace();
} }
@@ -590,7 +716,8 @@
result.actions.forEach(perform); result.actions.forEach(perform);
}, { passive: false }); }, { passive: false });
svgRoot.addEventListener("dblclick", (event) => { svgRoot.addEventListener("dblclick", (event) => {
C.reduceGesture(gesture, { type: "dblclick", target: targetOf(event.target) }).actions.forEach(perform); /* 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 */ /* ----------------------------------------------------------------- keyboard */
@@ -642,7 +769,7 @@
const region = map.regionAt.get(node.net); const region = map.regionAt.get(node.net);
const col = node.col + dx; const col = node.col + dx;
const row = node.row + dy; const row = node.row + dy;
if (col < 0 || col >= region.cols || row < 0) return; if (col < 0 || col >= region.cols || row < 0 || row > region.rows) return;
const cell = row * region.cols + col; const cell = row * region.cols + col;
const other = map.nodes.find((entry) => entry.net === node.net && entry.cell === cell); const other = map.nodes.find((entry) => entry.net === node.net && entry.cell === cell);
C.setCell(overrides, node.net, node.key, cell); C.setCell(overrides, node.net, node.key, cell);
@@ -33,6 +33,7 @@
<g id="world"> <g id="world">
<g id="layer-spokes"></g> <g id="layer-spokes"></g>
<g id="layer-regions"></g> <g id="layer-regions"></g>
<g id="layer-ports"></g>
<g id="layer-ends"></g> <g id="layer-ends"></g>
<g id="layer-legs"></g> <g id="layer-legs"></g>
<g id="layer-nodes"></g> <g id="layer-nodes"></g>
+103 -77
View File
@@ -7,30 +7,34 @@
(function () { (function () {
const M = typeof require !== "undefined" ? require("./model.js") : globalThis.Colony; const M = typeof require !== "undefined" ? require("./model.js") : globalThis.Colony;
const CELL_W = 120; const CELL_W = 168;
const CELL_H = 104; const CELL_H = 196;
const GRID_INSET = 30; const GRID_INSET = 30;
const HEAD = 38; const HEAD = 38;
const REGION_PAD = 14; const REGION_PAD = 14;
const GAP = 56; const GAP = 56;
const MIN_WIDTH = 240; const MIN_WIDTH = 420;
const NODE_R = 14; const NODE_R = 14;
const GATEWAY_R = 19; const GATEWAY_R = 19;
const DOCK_LANE = 136; /* The band at the bottom of every network for its cumulative hardware. */
const DOCK_ROW = 64; const FOOTER = 58;
const DOCK_PITCH = 152; const RING_GAP = 4;
const RESERVE_TOP = 32;
const RESERVE_BOTTOM = 70;
const NOMINAL = { w: 320, h: 240 }; const NOMINAL = { w: 320, h: 240 };
const GATEWAY_HOLD_MS = 60000; const GATEWAY_HOLD_MS = 60000;
const MAX_OVERRIDES = 300; const MAX_OVERRIDES = 300;
const LABEL_W = 104; /* The box every node owns for its text: name, roles and route caption (four lines), then its stats. */
const LABEL_H = 56; const LINE = 13;
const BOX_W = 160;
const STAT_W = 148;
const STAT_COUNT = 3;
const STAT_ROW = 24;
const STAT_TOP = 4 * LINE + 4;
const BOX_H = STAT_TOP + STAT_COUNT * STAT_ROW + 14;
/* ---------------------------------------------------------------- state */ /* ---------------------------------------------------------------- state */
function newState() { function newState() {
return { regions: new Map(), sizes: new Map(), cols: new Map(), cells: new Map(), sides: new Map(), colors: new Map(), gateways: new Map(), nextColor: 0, placeLater: new Set() }; return { regions: new Map(), sizes: new Map(), cols: new Map(), cells: new Map(), sides: new Map(), colors: new Map(), age: new Map(), colsFor: new Map(), gateways: new Map(), nextColor: 0, nextAge: 0, placeLater: new Set() };
} }
/* Only what the user changed by hand is ever stored. */ /* Only what the user changed by hand is ever stored. */
@@ -65,7 +69,7 @@
if (Object.keys(kept).length) clean.slots[net] = kept; if (Object.keys(kept).length) clean.slots[net] = kept;
}); });
Object.entries(table(data.docks)).forEach(([key, dock]) => { Object.entries(table(data.docks)).forEach(([key, dock]) => {
if (dock && ["left", "right", "top", "bottom"].includes(dock.side) && finite(dock.t)) clean.docks[key] = { side: dock.side, t: dock.t }; if (dock && ["left", "right", "top"].includes(dock.side) && finite(dock.t)) clean.docks[key] = { side: dock.side, t: dock.t };
}); });
if (typeof data.legend === "boolean") clean.legend = data.legend; if (typeof data.legend === "boolean") clean.legend = data.legend;
return limitOverrides(clean); return limitOverrides(clean);
@@ -172,7 +176,7 @@
/* Push apart regions that overlap or sit closer than the gap: the one the user /* Push apart regions that overlap or sit closer than the gap: the one the user
did not place, along the axis with the smaller overlap. */ did not place, along the axis with the smaller overlap. */
function resolveOverlaps(rects, fixed, gap) { function resolveOverlaps(rects, fixed, gap, age) {
const ids = Object.keys(rects).sort(); const ids = Object.keys(rects).sort();
for (let pass = 0; pass < 40; pass++) { for (let pass = 0; pass < 40; pass++) {
let moved = false; let moved = false;
@@ -182,8 +186,10 @@
const b = rects[ids[j]]; const b = rects[ids[j]];
const o = overlapAmount(a, b, gap); const o = overlapAmount(a, b, gap);
if (o.x <= 0 || o.y <= 0) continue; if (o.x <= 0 || o.y <= 0) continue;
const mover = fixed.has(ids[j]) ? (fixed.has(ids[i]) ? null : ids[i]) : ids[j]; /* A network the user placed stays and the other gives way. When both were placed by hand, or neither was, the newer one moves: two hand-placed networks are never left overlapping, so its drawn spot can differ from the saved one. */
if (!mover) continue; let mover;
if (fixed.has(ids[i]) !== fixed.has(ids[j])) mover = fixed.has(ids[i]) ? ids[j] : ids[i];
else mover = age.get(ids[i]) > age.get(ids[j]) ? ids[i] : ids[j];
const other = mover === ids[j] ? a : b; const other = mover === ids[j] ? a : b;
const self = rects[mover]; const self = rects[mover];
if (o.x <= o.y) self.x += self.x + self.w / 2 >= other.x + other.w / 2 ? o.x : -o.x; if (o.x <= o.y) self.x += self.x + self.w / 2 >= other.x + other.w / 2 ? o.x : -o.x;
@@ -203,8 +209,9 @@
function sideToward(from, to) { function sideToward(from, to) {
const dx = to.x - from.x; const dx = to.x - from.x;
const dy = to.y - from.y; const dy = to.y - from.y;
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? "right" : "left"; /* Only a neighbour above and more up than across is on top; there is no bottom edge to dock on. */
return dy > 0 ? "bottom" : "top"; if (dy <= 0 && Math.abs(dy) >= Math.abs(dx)) return "top";
return dx >= 0 ? "right" : "left";
} }
const normals = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] }; const normals = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] };
@@ -261,9 +268,16 @@
const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
/* Remember, on every payload and not only when the map is laid out, who is relaying: that is
what the minute of grace for a gateway that went quiet is measured from. */
function noteGateways(model, state, now) {
model.bridges.forEach((bridge) => state.gateways.set(bridge.key, { time: now, bridge: bridge }));
}
/* The page also calls noteGateways on every payload; repeating it here covers a fresh state (reset, drag). */
function withGhosts(model, state, now) { function withGhosts(model, state, now) {
const bridges = model.bridges.slice(); const bridges = model.bridges.slice();
model.bridges.forEach((bridge) => state.gateways.set(bridge.key, { time: now, bridge: bridge })); noteGateways(model, state, now);
state.gateways.forEach((entry, key) => { state.gateways.forEach((entry, key) => {
if (now - entry.time > GATEWAY_HOLD_MS || !model.byKey.has(key)) state.gateways.delete(key); if (now - entry.time > GATEWAY_HOLD_MS || !model.byKey.has(key)) state.gateways.delete(key);
else if (!model.bridgeAt.has(key)) bridges.push(entry.bridge); else if (!model.bridgeAt.has(key)) bridges.push(entry.bridge);
@@ -311,14 +325,19 @@
nets.forEach((net) => { nets.forEach((net) => {
if (!state.colors.has(net)) state.colors.set(net, state.nextColor++ % M.COLORS); if (!state.colors.has(net)) state.colors.set(net, state.nextColor++ % M.COLORS);
if (!state.age.has(net)) state.age.set(net, state.nextAge++);
}); });
const groups = nets.map((id) => { const groups = nets.map((id) => {
const members = model.nodes.filter((node) => node.net === id).sort(M.compareAddresses); const members = model.nodes.filter((node) => node.net === id).sort(M.compareAddresses);
const plain = members.filter((node) => !bridgeAt.has(node.key)); const plain = members.filter((node) => !bridgeAt.has(node.key));
const region = overrides.regions[id] || {}; const region = overrides.regions[id] || {};
/* Columns are chosen once so nothing reflows, and again only when the network has more than doubled. */
let cols = region.cols || state.cols.get(id); let cols = region.cols || state.cols.get(id);
if (!cols) cols = clamp(Math.ceil(Math.sqrt(1.6 * Math.max(1, plain.length))), 1, 6); if (!cols || (!region.cols && plain.length > 2 * (state.colsFor.get(id) || 1))) {
cols = clamp(Math.ceil(Math.sqrt(1.6 * Math.max(1, plain.length))), 1, 6);
state.colsFor.set(id, Math.max(1, plain.length));
}
state.cols.set(id, cols); state.cols.set(id, cols);
const cells = assignCells(state, id, plain, overrides.slots[id] || {}); const cells = assignCells(state, id, plain, overrides.slots[id] || {});
const rows = Math.max(1, Math.ceil((Math.max(-1, ...cells.values()) + 1) / cols)); const rows = Math.max(1, Math.ceil((Math.max(-1, ...cells.values()) + 1) / cols));
@@ -347,7 +366,7 @@
/* Sides, then sizes. */ /* Sides, then sizes. */
groups.forEach((group) => { groups.forEach((group) => {
group.sides = { left: [], right: [], top: [], bottom: [] }; group.sides = { left: [], right: [], top: [] };
group.gateways.forEach((node) => { group.gateways.forEach((node) => {
const bridge = bridgeAt.get(node.key); const bridge = bridgeAt.get(node.key);
const others = Array.from(bridge.touches).filter((id) => id !== group.id); const others = Array.from(bridge.touches).filter((id) => id !== group.id);
@@ -367,16 +386,16 @@
}); });
const count = (side) => group.sides[side].length; const count = (side) => group.sides[side].length;
const reserve = { left: count("left") ? DOCK_LANE : 0, right: count("right") ? DOCK_LANE : 0, top: count("top") ? RESERVE_TOP : 0, bottom: count("bottom") ? RESERVE_BOTTOM : 0 }; /* A gateway is a node like any other, in a cell of a strip along the edge it docks on. */
const reserve = { left: count("left") ? CELL_W : 0, right: count("right") ? CELL_W : 0, top: count("top") ? CELL_H + 8 : 0 };
const header = 33 + 8.4 * group.id.length + 78; const header = 33 + 8.4 * group.id.length + 78;
const width = Math.max( const width = Math.max(
MIN_WIDTH, MIN_WIDTH,
2 * REGION_PAD + reserve.left + reserve.right + group.cols * CELL_W, 2 * REGION_PAD + reserve.left + reserve.right + group.cols * CELL_W,
count("top") ? Math.max(header, reserve.left) + count("top") * DOCK_PITCH + 10 + reserve.right : 0, count("top") ? Math.max(header + 20, reserve.left) + count("top") * CELL_W + reserve.right : 0
count("bottom") ? reserve.left + count("bottom") * DOCK_PITCH + 25 + reserve.right : 0
); );
const lane = Math.max(count("left"), count("right")); const lane = Math.max(count("left"), count("right"));
const height = Math.max(HEAD + reserve.top + group.rows * CELL_H + reserve.bottom, lane ? HEAD + 30 + (lane - 1) * DOCK_ROW + LABEL_H + 10 : 0, HEAD + CELL_H); const height = Math.max(HEAD + reserve.top + group.rows * CELL_H, HEAD + lane * CELL_H, HEAD + CELL_H) + FOOTER;
Object.assign(group, { reserve: reserve, header: header, w: width, h: height }); Object.assign(group, { reserve: reserve, header: header, w: width, h: height });
state.sizes.set(group.id, { w: width, h: height }); state.sizes.set(group.id, { w: width, h: height });
}); });
@@ -433,7 +452,7 @@
} }
rects[group.id] = { x: at.x, y: at.y, w: group.w, h: group.h }; rects[group.id] = { x: at.x, y: at.y, w: group.w, h: group.h };
}); });
resolveOverlaps(rects, fixed, GAP); resolveOverlaps(rects, fixed, GAP, state.age);
groups.forEach((group) => { groups.forEach((group) => {
state.regions.set(group.id, { x: rects[group.id].x, y: rects[group.id].y }); state.regions.set(group.id, { x: rects[group.id].x, y: rects[group.id].y });
}); });
@@ -447,6 +466,9 @@
const inner = group.w - group.reserve.left - group.reserve.right; const inner = group.w - group.reserve.left - group.reserve.right;
const gridX = at.x + group.reserve.left + (inner - group.cols * CELL_W) / 2; const gridX = at.x + group.reserve.left + (inner - group.cols * CELL_W) / 2;
const gridY = at.y + HEAD + group.reserve.top; const gridY = at.y + HEAD + group.reserve.top;
/* The lines the legs run along: inside the side strips and just outside the grid, where no label reaches. */
const ringLeft = group.reserve.left ? at.x + group.reserve.left : gridX;
const ringRight = group.reserve.right ? at.x + group.w - group.reserve.right : gridX + group.cols * CELL_W;
const region = { const region = {
id: group.id, id: group.id,
color: state.colors.get(group.id), color: state.colors.get(group.id),
@@ -456,7 +478,9 @@
h: group.h, h: group.h,
cols: group.cols, cols: group.cols,
rows: group.rows, rows: group.rows,
grid: { x: gridX, y: gridY, bottom: gridY + group.rows * CELL_H }, grid: { x: gridX, y: gridY },
ring: { x0: ringLeft, x1: ringRight, y0: gridY - RING_GAP, y1: gridY + group.rows * CELL_H + RING_GAP },
footer: { y: at.y + group.h - FOOTER, h: FOOTER },
header: group.header, header: group.header,
count: group.members.length, count: group.members.length,
alive: group.members.filter((node) => node.alive).length, alive: group.members.filter((node) => node.alive).length,
@@ -470,7 +494,7 @@
const row = Math.floor(cell / group.cols); const row = Math.floor(cell / group.cols);
const x = gridX + col * CELL_W + CELL_W / 2; const x = gridX + col * CELL_W + CELL_W / 2;
const y = gridY + row * CELL_H + GRID_INSET; const y = gridY + row * CELL_H + GRID_INSET;
const item = { key: node.key, net: group.id, color: region.color, x: x, y: y, r: NODE_R, cell: cell, col: col, row: row, gateway: false, dock: null, box: { x: x - LABEL_W / 2, y: y + 18, w: LABEL_W, h: LABEL_H - 4 } }; const item = { key: node.key, net: group.id, color: region.color, x: x, y: y, r: NODE_R, cell: cell, col: col, row: row, gateway: false, dock: null, port: null, box: { x: x - BOX_W / 2, y: y + NODE_R + 4, w: BOX_W, h: BOX_H } };
nodes.push(item); nodes.push(item);
nodeAt.set(node.key, item); nodeAt.set(node.key, item);
}); });
@@ -480,21 +504,16 @@
const others = Array.from(bridge.touches).filter((id) => id !== group.id && state.regions.has(id)); const others = Array.from(bridge.touches).filter((id) => id !== group.id && state.regions.has(id));
return others.length ? meanPoint(others.map((id) => netCentre(state, id))) : centreOf(region); return others.length ? meanPoint(others.map((id) => netCentre(state, id))) : centreOf(region);
}; };
["left", "right", "top", "bottom"].forEach((side) => { ["left", "right", "top"].forEach((side) => {
const horizontal = side === "top" || side === "bottom";
const ordered = group.sides[side] const ordered = group.sides[side]
.map((node) => ({ node: node, key: own(overrides.docks, node.key) ? overrides.docks[node.key].t : horizontal ? spot(node).x : spot(node).y })) .map((node) => ({ node: node, key: own(overrides.docks, node.key) ? overrides.docks[node.key].t : side === "top" ? spot(node).x : spot(node).y }))
.sort((a, b) => a.key - b.key || a.node.key.localeCompare(b.node.key)) .sort((a, b) => a.key - b.key || a.node.key.localeCompare(b.node.key))
.map((entry) => entry.node); .map((entry) => entry.node);
ordered.forEach((node, index) => { ordered.forEach((node, index) => {
const [x, y] = side === "left" || side === "right" const x = side === "top" ? at.x + Math.max(group.header + 20, group.reserve.left) + index * CELL_W + CELL_W / 2 : side === "left" ? at.x + CELL_W / 2 : at.x + group.w - CELL_W / 2;
? [side === "left" ? at.x : at.x + group.w, at.y + HEAD + 30 + index * DOCK_ROW] const y = at.y + HEAD + (side === "top" ? 0 : index * CELL_H) + GRID_INSET;
: [side === "top" ? at.x + Math.max(group.header, group.reserve.left) + 30 + index * DOCK_PITCH : at.x + group.reserve.left + 40 + index * DOCK_PITCH, side === "top" ? at.y : at.y + group.h]; const port = side === "left" ? { x: at.x, y: y } : side === "right" ? { x: at.x + group.w, y: y } : { x: x, y: at.y };
const box = side === "left" ? { x: x + 27, y: y + 6, w: LABEL_W, h: LABEL_H } const item = { key: node.key, net: group.id, color: region.color, x: x, y: y, r: GATEWAY_R, cell: null, gateway: true, dock: { side: side, index: index }, port: port, box: { x: x - BOX_W / 2, y: y + GATEWAY_R + 4, w: BOX_W, h: BOX_H } };
: side === "right" ? { x: x - 27 - LABEL_W, y: y + 6, w: LABEL_W, h: LABEL_H }
: side === "top" ? { x: x + 27, y: y + 6, w: LABEL_W, h: LABEL_H }
: { x: x + 27, y: y - 6 - LABEL_H, w: LABEL_W, h: LABEL_H };
const item = { key: node.key, net: group.id, color: region.color, x: x, y: y, r: GATEWAY_R, cell: null, gateway: true, dock: { side: side, index: index }, box: box };
nodes.push(item); nodes.push(item);
nodeAt.set(node.key, item); nodeAt.set(node.key, item);
}); });
@@ -509,8 +528,8 @@
bridge.touches.forEach((id) => { bridge.touches.forEach((id) => {
if (id === gateway.net || !regionAt.has(id)) return; if (id === gateway.net || !regionAt.has(id)) return;
const target = regionAt.get(id); const target = regionAt.get(id);
const end = entryPoint(gateway, target); const end = entryPoint(gateway.port, target);
spokes.push({ gateway: bridge.key, to: id, color: target.color, x1: gateway.x, y1: gateway.y, x2: end.x, y2: end.y, d: spokePath(gateway, gateway.dock.side, end, edgeSide(end, target)) }); spokes.push({ gateway: bridge.key, to: id, color: target.color, x1: gateway.port.x, y1: gateway.port.y, x2: end.x, y2: end.y, d: spokePath(gateway.port, gateway.dock.side, end, edgeSide(end, target)) });
}); });
}); });
@@ -526,39 +545,43 @@
/* ------------------------------------------------------------------ routes */ /* ------------------------------------------------------------------ routes */
/* From a gateway's disc out to the ring of gaps around the grid: sideways for a strip on the
left or right, and along the gap beside its cell for a strip on top. */
function lead(region, dock) {
const disc = { x: dock.x, y: dock.y };
const side = dock.dock.side;
if (side === "left") return [disc, { x: region.ring.x0, y: dock.y }];
if (side === "right") return [disc, { x: region.ring.x1, y: dock.y }];
const column = dock.x - CELL_W / 2;
return [disc, { x: column, y: dock.y }, { x: column, y: region.ring.y0 }];
}
/* From a member of a network to a gateway docked on it (or back), along the gaps between /* From a member of a network to a gateway docked on it (or back), along the gaps between
cells and the lanes beside them, so the line never crosses a node or a label. It leaves the cells, so the line never crosses a node or a label. It leaves the node upwards (its label
node upwards (its label is below), runs along the gap above its row, then along the edge is below), runs along the gap above its row, then along the ring to the gateway. */
of the grid to the gateway. */
function streetPoints(map, plain, dock) { function streetPoints(map, plain, dock) {
const region = map.regionAt.get(plain.net); const region = map.regionAt.get(plain.net);
const gap = region.grid.y + plain.row * CELL_H; const gap = region.grid.y + plain.row * CELL_H;
const start = { x: plain.x, y: plain.y }; const start = { x: plain.x, y: plain.y };
const up = { x: plain.x, y: gap }; const up = { x: plain.x, y: gap };
const end = { x: dock.x, y: dock.y }; const way = lead(region, dock).reverse();
const side = dock.dock.side; const ring = way[0];
if (side === "left" || side === "right") { if (dock.dock.side !== "top") return [start, up, { x: ring.x, y: gap }].concat(way);
const edge = side === "left" ? region.grid.x : region.grid.x + region.cols * CELL_W;
return [start, up, { x: edge, y: gap }, { x: edge, y: end.y }, end];
}
const left = region.grid.x + plain.col * CELL_W; const left = region.grid.x + plain.col * CELL_W;
const column = Math.abs(left - end.x) <= Math.abs(left + CELL_W - end.x) ? left : left + CELL_W; const column = Math.abs(left - ring.x) <= Math.abs(left + CELL_W - ring.x) ? left : left + CELL_W;
const lane = side === "top" ? region.grid.y - 4 : region.grid.bottom + 4; return [start, up, { x: column, y: gap }, { x: column, y: ring.y }].concat(way);
return [start, up, { x: column, y: gap }, { x: column, y: lane }, { x: end.x, y: lane }, end];
} }
/* Between two gateways of one network: out of the first onto the ring of gaps around the /* Between two gateways of one network: out of the first onto the ring of gaps around the
grid, along it, and into the second. */ grid, along it, and into the second. */
function ringPoints(map, a, b) { function ringPoints(map, a, b) {
const region = map.regionAt.get(a.net); const region = map.regionAt.get(a.net);
const x0 = region.grid.x; const { x0, x1, y0, y1 } = region.ring;
const x1 = region.grid.x + region.cols * CELL_W; const outA = lead(region, a);
const y0 = region.grid.y - 4; const outB = lead(region, b);
const y1 = region.grid.bottom + 4; const ea = outA[outA.length - 1];
const onRing = (dock) => ({ left: { x: x0, y: dock.y }, right: { x: x1, y: dock.y }, top: { x: dock.x, y: y0 }, bottom: { x: dock.x, y: y1 } })[dock.dock.side]; const eb = outB[outB.length - 1];
const ea = onRing(a);
const eb = onRing(b);
const sa = a.dock.side; const sa = a.dock.side;
const sb = b.dock.side; const sb = b.dock.side;
const length = (points) => points.reduce((sum, point, i) => (i ? sum + Math.hypot(point.x - points[i - 1].x, point.y - points[i - 1].y) : 0), 0); const length = (points) => points.reduce((sum, point, i) => (i ? sum + Math.hypot(point.x - points[i - 1].x, point.y - points[i - 1].y) : 0), 0);
@@ -570,18 +593,11 @@
const over = [{ x: xa, y: y0 }, { x: xb, y: y0 }]; const over = [{ x: xa, y: y0 }, { x: xb, y: y0 }];
const under = [{ x: xa, y: y1 }, { x: xb, y: y1 }]; const under = [{ x: xa, y: y1 }, { x: xb, y: y1 }];
middle = length([ea].concat(over, [eb])) <= length([ea].concat(under, [eb])) ? over : under; middle = length([ea].concat(over, [eb])) <= length([ea].concat(under, [eb])) ? over : under;
} else if ((sa === "top" && sb === "bottom") || (sa === "bottom" && sb === "top")) {
const ya = sa === "top" ? y0 : y1;
const yb = sa === "top" ? y1 : y0;
const west = [{ x: x0, y: ya }, { x: x0, y: yb }];
const east = [{ x: x1, y: ya }, { x: x1, y: yb }];
middle = length([ea].concat(west, [eb])) <= length([ea].concat(east, [eb])) ? west : east;
} else if (sa !== sb) { } else if (sa !== sb) {
const horizontal = sa === "left" || sa === "right" ? sa : sb; const side = sa === "top" ? sb : sa;
const vertical = sa === "top" || sa === "bottom" ? sa : sb; middle = [{ x: side === "left" ? x0 : x1, y: y0 }];
middle = [{ x: horizontal === "left" ? x0 : x1, y: vertical === "top" ? y0 : y1 }];
} }
return [{ x: a.x, y: a.y }, ea].concat(middle, [eb, { x: b.x, y: b.y }]); return outA.concat(middle, outB.reverse());
} }
function legPoints(map, from, to) { function legPoints(map, from, to) {
@@ -620,7 +636,7 @@
const gateway = map.nodeAt.get(route.via); const gateway = map.nodeAt.get(route.via);
const name = model.byKey.get(route.via); const name = model.byKey.get(route.via);
captions.set(key, "via " + (name ? name.hostname : route.via)); captions.set(key, "via " + M.shorten(name ? name.hostname : route.via, 20));
if (!gateway) return; if (!gateway) return;
if (gateway !== origin) leg(origin, gateway, origin.color); if (gateway !== origin) leg(origin, gateway, origin.color);
if (gateway.net === target.net) leg(gateway, target, target.color); if (gateway.net === target.net) leg(gateway, target, target.color);
@@ -638,21 +654,31 @@
const region = map.regionAt.get(net); const region = map.regionAt.get(net);
if (!region) return null; if (!region) return null;
const col = clamp(Math.floor((x - region.grid.x) / CELL_W), 0, region.cols - 1); const col = clamp(Math.floor((x - region.grid.x) / CELL_W), 0, region.cols - 1);
const row = Math.max(0, Math.floor((y - region.grid.y) / CELL_H)); const row = clamp(Math.floor((y - region.grid.y) / CELL_H), 0, region.rows);
return row * region.cols + col; return row * region.cols + col;
} }
/* Which edge of a network a dropped gateway belongs to, and the position that orders it along that edge. */ /* Which edge of a network a dropped gateway belongs to, and the position that orders it along that
function dockAt(map, net, x, y) { edge. The edge is the one whose strip is nearest; where two strips meet (a corner) the gateway's
current edge wins, so a gateway put back where it was stays put. */
function dockAt(map, net, x, y, current) {
const region = map.regionAt.get(net); const region = map.regionAt.get(net);
const side = edgeSide({ x: clamp(x, region.x, region.x + region.w), y: clamp(y, region.y, region.y + region.h) }, region); const top = region.y + HEAD + GRID_INSET;
return { side: side, t: side === "top" || side === "bottom" ? x : y }; const bottom = region.footer.y;
const away = (x0, y0, x1, y1) => Math.hypot(x - clamp(x, x0, x1), y - clamp(y, y0, y1));
const strips = [
[away(region.x + CELL_W / 2, top, region.x + CELL_W / 2, bottom), "left"],
[away(region.x + region.w - CELL_W / 2, top, region.x + region.w - CELL_W / 2, bottom), "right"],
[away(region.x + region.header + 20, top, region.x + region.w - CELL_W / 2, top), "top"],
];
const side = strips.sort((p, q) => Math.round(p[0]) - Math.round(q[0]) || (q[1] === current) - (p[1] === current))[0][1];
return { side: side, t: side === "top" ? x : y };
} }
const api = { const api = {
CELL_W, CELL_H, GATEWAY_R, NODE_R, GAP, EMPTY_BOUNDS, CELL_W, CELL_H, GATEWAY_R, NODE_R, GAP, EMPTY_BOUNDS, STAT_W, STAT_COUNT, STAT_TOP, STAT_ROW,
newState, newOverrides, parseOverrides, serializeOverrides, hasOverrides, setRegion, setCell, setDock, newState, newOverrides, parseOverrides, serializeOverrides, hasOverrides, setRegion, setCell, setDock,
gridSlots, orderNetworks, resolveOverlaps, entryPoint, roundedPath, computeMap, traceGeometry, cellAt, dockAt, gridSlots, orderNetworks, resolveOverlaps, entryPoint, roundedPath, computeMap, traceGeometry, cellAt, dockAt, noteGateways,
}; };
if (typeof module !== "undefined") module.exports = api; if (typeof module !== "undefined") module.exports = api;
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api); else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
+101 -2
View File
@@ -107,6 +107,7 @@
self: Boolean(member.self), self: Boolean(member.self),
roles: member.roles, roles: member.roles,
declined: member.declined || [], declined: member.declined || [],
stats: member.stats || {},
})); }));
const byKey = new Map(nodes.map((node) => [node.key, node])); const byKey = new Map(nodes.map((node) => [node.key, node]));
const states = roleStates(nodes, payload.roles); const states = roleStates(nodes, payload.roles);
@@ -245,12 +246,110 @@
return text + " · " + held.length + "/" + assigned.length + " roles held"; return text + " · " + held.length + "/" + assigned.length + " roles held";
} }
/* ------------------------------------------------------------------ hardware */
function formatSize(gb) {
if (gb >= 1000) return (gb / 1024).toFixed(1) + " TB";
if (gb >= 100) return Math.round(gb) + " GB";
return gb.toFixed(1) + " GB";
}
function formatUptime(hours) {
if (hours < 1) return Math.round(hours * 60) + "m";
if (hours < 48) return Math.round(hours) + "h";
return Math.floor(hours / 24) + "d " + Math.round(hours % 24) + "h";
}
const percent = (used, total) => (total > 0 ? Math.max(0, Math.min(100, Math.round((100 * used) / total))) : null);
const barLevel = (pct) => (pct >= 90 ? "crit" : pct >= 75 ? "warn" : "ok");
/* What one node reports about itself, ready to draw: three bars and a footer. */
function describeStats(stats) {
stats = stats || {};
const num = (key) => (typeof stats[key] === "number" ? stats[key] : null);
const usage = (usedKey, totalKey) => {
const used = num(usedKey);
const total = num(totalKey);
return used === null || total === null ? null : { pct: percent(used, total), text: formatSize(used) + " / " + formatSize(total) };
};
const load = num("cpu_percent");
const cores = num("cpu_cores");
const threads = num("cpu_threads");
let cpu = null;
if (load !== null) {
let text = Math.round(load) + "%";
if (cores && threads) text += " · " + cores + "C/" + threads + "T";
else if (threads) text += " · " + threads + "T";
cpu = { pct: Math.round(load), text: text };
}
const gpuCount = num("gpu_count");
const gpuUsage = usage("gpu_mem_used_gb", "gpu_mem_total_gb");
const uptime = num("uptime_hours");
const ram = usage("mem_used_gb", "mem_total_gb");
const disk = usage("disk_used_gb", "disk_total_gb");
const gpu = gpuCount && gpuUsage ? (gpuCount > 1 ? gpuCount + "× " : "") + "GPU " + gpuUsage.text : null;
const footer = [uptime === null ? null : "up " + formatUptime(uptime), gpu].filter(Boolean).join(" · ");
return { rows: [["CPU", cpu], ["RAM", ram], ["DISK", disk]], footer: cpu || ram || disk ? footer : "no stats reported" };
}
/* Per machine these are the same for every node on it; the "used" ones move and are averaged. */
const TOTAL_KEYS = ["cpu_cores", "cpu_threads", "mem_total_gb", "disk_total_gb", "gpu_count", "gpu_mem_total_gb"];
const USED_KEYS = ["mem_used_gb", "disk_used_gb", "gpu_mem_used_gb"];
const plural = (count, word) => count + " " + word + (count === 1 ? "" : "s");
/* "CPU 9% · 6C/12T, RAM ..." for the rows that have numbers. */
const describeRows = (rows) => rows.filter(([, usage]) => usage).map(([name, usage]) => name + " " + usage.text);
/* The hardware of one network added up. Nodes that report the same complete totals are one
machine (containers on a host all see the host), so a machine is counted once and its
numbers are the average of what its nodes report. Load is weighted by threads. */
function networkStats(model, net) {
const reporting = model.nodes.filter((node) => node.net === net && node.alive && Object.keys(node.stats).length);
const machines = new Map();
reporting.forEach((node) => {
const s = node.stats;
const complete = typeof s.mem_total_gb === "number" && typeof s.disk_total_gb === "number";
const fingerprint = complete ? TOTAL_KEYS.map((key) => s[key]).join("|") : node.key;
if (!machines.has(fingerprint)) machines.set(fingerprint, []);
machines.get(fingerprint).push(s);
});
const numbers = (list, key) => list.map((s) => s[key]).filter((value) => typeof value === "number");
const mean = (list, key) => {
const values = numbers(list, key);
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null;
};
const stats = {};
const add = (key, value) => {
if (typeof value === "number") stats[key] = (stats[key] || 0) + value;
};
let weighted = 0;
let weight = 0;
machines.forEach((list) => {
TOTAL_KEYS.forEach((key) => add(key, numbers(list, key)[0]));
USED_KEYS.forEach((key) => add(key, mean(list, key)));
const load = mean(list, "cpu_percent");
if (load !== null) {
const threads = numbers(list, "cpu_threads")[0] || 1;
weighted += load * threads;
weight += threads;
}
});
if (weight) stats.cpu_percent = weighted / weight;
return Object.assign(describeStats(stats), { machines: machines.size, nodes: reporting.length });
}
/* The full text of a node: tooltip and screen reader. */ /* The full text of a node: tooltip and screen reader. */
function describeNode(model, node, routes) { function describeNode(model, node, routes) {
const lines = [node.hostname + " " + node.key + (node.self ? " (this node)" : "") + (node.alive ? "" : " (down)")]; const lines = [node.hostname + " " + node.key + (node.self ? " (this node)" : "") + (node.alive ? "" : " (down)")];
const bridge = model.bridgeAt.get(node.key); const bridge = model.bridgeAt.get(node.key);
if (bridge) lines.push("gateway: relays " + bridge.pairs + " node pair" + (bridge.pairs === 1 ? "" : "s")); if (bridge) lines.push("gateway: relays " + plural(bridge.pairs, "node pair"));
lines.push("roles: " + (node.roles.join(", ") || "none")); lines.push("roles: " + (node.roles.join(", ") || "none"));
const hardware = describeStats(node.stats);
lines.push(describeRows(hardware.rows).concat(hardware.footer || []).join(" · "));
if (node.declined.length) lines.push("declined: " + node.declined.join(", ")); if (node.declined.length) lines.push("declined: " + node.declined.join(", "));
const route = routes && node.alive && node.key !== routes.viewKey ? routes.byKey.get(node.key) : null; const route = routes && node.alive && node.key !== routes.viewKey ? routes.byKey.get(node.key) : null;
@@ -267,7 +366,7 @@
return lines.join("\n"); return lines.join("\n");
} }
const api = { OTHER_NETWORK, COLORS, address, keyOf, networkOf, compareNetworks, compareAddresses, shorten, wrapLabels, analyze, bridgesOf, resolveView, routesFrom, routeSummary, specialRoles, alerts, onDemandHint, summarize, describeNode }; const api = { describeStats, describeRows, networkStats, plural, barLevel, OTHER_NETWORK, COLORS, address, keyOf, networkOf, compareNetworks, compareAddresses, shorten, wrapLabels, analyze, bridgesOf, resolveView, routesFrom, routeSummary, specialRoles, alerts, onDemandHint, summarize, describeNode };
if (typeof module !== "undefined") module.exports = api; if (typeof module !== "undefined") module.exports = api;
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api); else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
})(); })();
@@ -5,6 +5,7 @@
--muted: #6b7686; --muted: #6b7686;
--line: #d3d9e1; --line: #d3d9e1;
--down: #98a2b3; --down: #98a2b3;
--track: #dde2ea;
--panel: rgba(255, 255, 255, 0.92); --panel: rgba(255, 255, 255, 0.92);
--warn: #c67a08; --warn: #c67a08;
--crit: #d64545; --crit: #d64545;
@@ -24,6 +25,7 @@
--muted: #93a0b3; --muted: #93a0b3;
--line: #313a47; --line: #313a47;
--down: #6b7686; --down: #6b7686;
--track: #2a3340;
--panel: rgba(27, 33, 41, 0.92); --panel: rgba(27, 33, 41, 0.92);
--warn: #ffb04a; --warn: #ffb04a;
--crit: #ff6b6b; --crit: #ff6b6b;
@@ -119,6 +121,12 @@ p { margin: 4px 0; }
.node.down .disc { stroke-dasharray: 4 3; } .node.down .disc { stroke-dasharray: 4 3; }
.node.down .dot { fill: var(--down); } .node.down .dot { fill: var(--down); }
.node.gateway .disc { stroke-width: 4; } .node.gateway .disc { stroke-width: 4; }
/* The door on the network's edge and its link to the gateway node inside. */
.port { fill: var(--card); stroke: var(--net); stroke-width: 3.5; vector-effect: non-scaling-stroke; pointer-events: none; }
.port-link { stroke: var(--net); stroke-width: 3; stroke-linecap: round; vector-effect: non-scaling-stroke; pointer-events: none; }
.foot-rule { stroke: color-mix(in srgb, var(--net) 40%, transparent); stroke-width: 1; vector-effect: non-scaling-stroke; }
.foot .st-text { font-weight: 600; }
.node.none .halo, .node.unknown .halo { stroke: var(--muted); stroke-width: 2; stroke-dasharray: 4 3; } .node.none .halo, .node.unknown .halo { stroke: var(--muted); stroke-width: 2; stroke-dasharray: 4 3; }
.node.reached .halo { stroke: var(--net); stroke-width: 2; } .node.reached .halo { stroke: var(--net); stroke-width: 2; }
.node:hover .halo { stroke: var(--muted); stroke-dasharray: none; } .node:hover .halo { stroke: var(--muted); stroke-dasharray: none; }
@@ -131,6 +139,15 @@ p { margin: 4px 0; }
.l-name { fill: var(--text); font-size: 13px; font-weight: 600; } .l-name { fill: var(--text); font-size: 13px; font-weight: 600; }
.l-roles { fill: var(--muted); font-size: 11px; } .l-roles { fill: var(--muted); font-size: 11px; }
.l-caption { fill: var(--net); font-size: 11px; font-weight: 600; } .l-caption { fill: var(--net); font-size: 11px; font-weight: 600; }
.st-text, .st-foot { paint-order: stroke; stroke: color-mix(in srgb, var(--net) 9%, var(--bg)); stroke-width: 3px; stroke-linejoin: round; pointer-events: none; font-size: 11px; font-variant-numeric: tabular-nums; }
.st-name { fill: var(--muted); }
.st-value { fill: var(--text); }
.st-foot { fill: var(--muted); }
.st-track { fill: color-mix(in srgb, var(--text) 13%, transparent); }
.st-fill { fill: var(--net); }
.st-fill.warn { fill: var(--warn); }
.st-fill.crit { fill: var(--crit); }
.node.none .l-caption, .node.unknown .l-caption { fill: var(--muted); } .node.none .l-caption, .node.unknown .l-caption { fill: var(--muted); }
/* Level of detail: one class on the root, decided by the zoom. */ /* Level of detail: one class on the root, decided by the zoom. */
@@ -139,7 +156,11 @@ p { margin: 4px 0; }
.lod0 .region-count { font-size: 24px; } .lod0 .region-count { font-size: 24px; }
.lod0 .region-dot { r: 12px; } .lod0 .region-dot { r: 12px; }
.lod1 .l-roles { display: none; } .lod1 .l-roles { display: none; }
.lod0 .stats { display: none; }
.lod1 .st-text, .lod1 .st-foot { display: none; }
.lod0 .node.full .l-name, .lod0 .node.full .l-roles, .lod0 .node.full .l-caption, .lod1 .node.full .l-roles { display: inline; } .lod0 .node.full .l-name, .lod0 .node.full .l-roles, .lod0 .node.full .l-caption, .lod1 .node.full .l-roles { display: inline; }
.lod0 .node.full .stats { display: inline; }
.lod1 .node.full .st-text, .lod1 .node.full .st-foot { display: inline; }
.lod0 .l-caption { display: none; } .lod0 .l-caption { display: none; }
.flow { stroke-dasharray: none; } .flow { stroke-dasharray: none; }
+1 -1
View File
@@ -7,7 +7,7 @@
const K_MAX = 4; const K_MAX = 4;
const K_START_MAX = 1.2; const K_START_MAX = 1.2;
const KEEP = 80; const KEEP = 80;
const LOD_AT = [0.4, 0.9]; const LOD_AT = [0.35, 0.7];
const HYSTERESIS = 0.1; const HYSTERESIS = 0.1;
const THRESHOLD = { mouse: 4, pen: 4, touch: 8 }; const THRESHOLD = { mouse: 4, pen: 4, touch: 8 };