This commit is contained in:
1 parent
4793b8dbf8
commit
75b96c986d
8 files changed
+2029
-500
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ def create_app(get_status):
|
|||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def status():
|
def status():
|
||||||
try:
|
try:
|
||||||
payload = get_status()
|
payload = get_status(compact=request.args.get("compact") == "1")
|
||||||
except Exception:
|
except Exception:
|
||||||
app.logger.exception("Could not build the status")
|
app.logger.exception("Could not build the status")
|
||||||
return jsonify(error="status unavailable"), 500
|
return jsonify(error="status unavailable"), 500
|
||||||
|
|||||||
@@ -39,16 +39,49 @@ class WebView(Role):
|
|||||||
raise RuntimeError("The Unit role is not available")
|
raise RuntimeError("The Unit role is not available")
|
||||||
return unit
|
return unit
|
||||||
|
|
||||||
def _status(self):
|
def _status(self, compact=False):
|
||||||
payload = self._unit().show_cluster()
|
payload = self._unit().show_cluster()
|
||||||
payload["links"] = self._links(payload["members"])
|
own_key = next((member["ip"], member["port"]) for member in payload["members"] if member["self"])
|
||||||
|
views = self._fresh_views()
|
||||||
|
views[own_key] = payload["members"]
|
||||||
|
if compact:
|
||||||
|
known = {(member["ip"], member["port"]) for member in payload["members"]}
|
||||||
|
views = {key: rows for key, rows in views.items() if key in known}
|
||||||
|
payload["views"] = [list(key) for key in sorted(views)]
|
||||||
|
if compact:
|
||||||
|
payload["routes"] = self._routes(views, payload["members"])
|
||||||
|
else:
|
||||||
|
payload["links"] = self._links(views)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _links(self, own_rows):
|
@staticmethod
|
||||||
own_key = next((member["ip"], member["port"]) for member in own_rows if member["self"])
|
def _routes(views, members):
|
||||||
views = self._fresh_views()
|
"""Per reporting node, only what differs from "reached directly": the relayed and the unreached members."""
|
||||||
views[own_key] = own_rows
|
index = {(member["ip"], member["port"]): i for i, member in enumerate(members)}
|
||||||
|
routes = []
|
||||||
|
for source in sorted(views):
|
||||||
|
if source not in index:
|
||||||
|
continue
|
||||||
|
rows = {(row["ip"], row["port"]): row for row in views[source] if "direct" in row and not row.get("self")}
|
||||||
|
via, none = {}, []
|
||||||
|
for target, i in index.items():
|
||||||
|
if target == source or not members[i]["alive"]:
|
||||||
|
continue
|
||||||
|
row = rows.get(target)
|
||||||
|
if row is None or not row.get("alive") or not (row["direct"] or row.get("via")):
|
||||||
|
none.append(i)
|
||||||
|
elif not row["direct"]:
|
||||||
|
gateway = index.get(tuple(row["via"]))
|
||||||
|
if gateway is None:
|
||||||
|
none.append(i)
|
||||||
|
else:
|
||||||
|
via.setdefault(str(gateway), []).append(i)
|
||||||
|
if via or none:
|
||||||
|
routes.append({"from": index[source], "via": via, "none": none})
|
||||||
|
return routes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _links(views):
|
||||||
links = []
|
links = []
|
||||||
for source, rows in views.items():
|
for source, rows in views.items():
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -8,29 +8,64 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
<div class="headline">
|
||||||
<h1>Colony</h1>
|
<h1>Colony</h1>
|
||||||
<p id="summary" class="summary">Loading...</p>
|
<p id="summary" class="summary">Loading...</p>
|
||||||
|
</div>
|
||||||
<div id="alerts" class="alerts"></div>
|
<div id="alerts" class="alerts"></div>
|
||||||
<p id="error" class="error" hidden></p>
|
<p id="error" class="error" hidden></p>
|
||||||
|
<p id="viewpoint" class="route-line"></p>
|
||||||
|
<p id="ondemand" class="muted"></p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main id="stage">
|
||||||
<svg id="graph" viewBox="0 0 788 556" role="img" aria-label="Nodes with their hardware and the roles they hold"></svg>
|
<svg id="map" class="lod2" role="group" aria-labelledby="map-title" aria-describedby="map-help">
|
||||||
<ul class="legend">
|
<title id="map-title">Map of the networks, the nodes in them, and the gateways that connect them</title>
|
||||||
<li><span class="swatch alive"></span>node alive</li>
|
<desc id="map-help">Tab to a node, arrow keys move between nodes, Enter pins it. Plus and minus zoom, 0 fits everything, n cycles networks, Alt with arrows moves the focused item, Escape releases.</desc>
|
||||||
<li><span class="swatch down"></span>node down</li>
|
<defs>
|
||||||
<li><span class="swatch link"></span>direct link</li>
|
<marker id="arrow-0" class="arrow n0" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
|
||||||
<li><span class="swatch oneway"></span>one-way reach</li>
|
<marker id="arrow-1" class="arrow n1" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
|
||||||
<li><span class="swatch bridge"></span>relays for others</li>
|
<marker id="arrow-2" class="arrow n2" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
|
||||||
<li><span class="swatch held"></span>role held</li>
|
<marker id="arrow-3" class="arrow n3" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
|
||||||
<li><span class="swatch unmet"></span>role unmet</li>
|
<marker id="arrow-4" class="arrow n4" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
|
||||||
<li><span class="swatch duplicate"></span>held twice</li>
|
<marker id="arrow-5" class="arrow n5" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
|
||||||
<li><span class="swatch general"></span>runs on every node</li>
|
</defs>
|
||||||
|
<g id="world">
|
||||||
|
<g id="layer-spokes"></g>
|
||||||
|
<g id="layer-regions"></g>
|
||||||
|
<g id="layer-ends"></g>
|
||||||
|
<g id="layer-legs"></g>
|
||||||
|
<g id="layer-nodes"></g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="controls" role="group" aria-label="Map view">
|
||||||
|
<button id="reset" type="button" class="text" hidden title="Forget the arrangement you made and lay the map out again">Reset layout</button>
|
||||||
|
<button id="zoom-in" type="button" aria-label="Zoom in" title="Zoom in (+)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4v12M4 10h12"/></svg></button>
|
||||||
|
<button id="zoom-out" type="button" aria-label="Zoom out" title="Zoom out (-)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h12"/></svg></button>
|
||||||
|
<button id="fit" type="button" aria-label="Fit everything" title="Fit everything (0)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 7V3h4M13 3h4v4M17 13v4h-4M7 17H3v-4"/></svg></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="legend" class="legend">
|
||||||
|
<button id="legend-close" type="button" aria-label="Hide the legend" title="Hide the legend"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15"/></svg></button>
|
||||||
|
<ul>
|
||||||
|
<li><span class="swatch network"></span>network, each in its own colour</li>
|
||||||
|
<li><span class="swatch node"></span>node</li>
|
||||||
|
<li><span class="swatch gateway"></span>gateway, on the edge of its network</li>
|
||||||
|
<li><span class="swatch flow"></span>route through a gateway</li>
|
||||||
|
<li><span class="swatch none"></span>no recent contact (not the same as unreachable)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p id="ondemand" class="muted"></p>
|
<p>Drag the background to move, wheel or pinch to zoom. Drag a node, a gateway or a network's title to rearrange.</p>
|
||||||
|
</div>
|
||||||
|
<button id="legend-open" type="button" class="legend-open" aria-label="Show the legend" title="Show the legend" hidden>?</button>
|
||||||
|
<p id="updated" class="muted stamp"></p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="muted"><span id="updated"></span></footer>
|
<div id="live" class="sr-only" role="status" aria-live="polite"></div>
|
||||||
|
<ul id="reader" class="sr-only" aria-label="Networks and their nodes"></ul>
|
||||||
|
<script src="/static/model.js"></script>
|
||||||
|
<script src="/static/layout.js"></script>
|
||||||
|
<script src="/static/viewport.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* Where things go. World units, independent of the window: the page maps the world
|
||||||
|
onto the screen with a pan and zoom transform. Pure and deterministic; the only
|
||||||
|
memory is the `state` object the caller keeps between calls, which is what keeps
|
||||||
|
the map from jumping when the cluster changes. */
|
||||||
|
(function () {
|
||||||
|
const M = typeof require !== "undefined" ? require("./model.js") : globalThis.Colony;
|
||||||
|
|
||||||
|
const CELL_W = 120;
|
||||||
|
const CELL_H = 104;
|
||||||
|
const GRID_INSET = 30;
|
||||||
|
const HEAD = 38;
|
||||||
|
const REGION_PAD = 14;
|
||||||
|
const GAP = 56;
|
||||||
|
const MIN_WIDTH = 240;
|
||||||
|
const NODE_R = 14;
|
||||||
|
const GATEWAY_R = 19;
|
||||||
|
const DOCK_LANE = 136;
|
||||||
|
const DOCK_ROW = 64;
|
||||||
|
const DOCK_PITCH = 152;
|
||||||
|
const RESERVE_TOP = 32;
|
||||||
|
const RESERVE_BOTTOM = 70;
|
||||||
|
const NOMINAL = { w: 320, h: 240 };
|
||||||
|
const GATEWAY_HOLD_MS = 60000;
|
||||||
|
const MAX_OVERRIDES = 300;
|
||||||
|
const LABEL_W = 104;
|
||||||
|
const LABEL_H = 56;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- state */
|
||||||
|
|
||||||
|
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() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only what the user changed by hand is ever stored. */
|
||||||
|
function newOverrides() {
|
||||||
|
return { regions: {}, slots: {}, docks: {}, legend: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
||||||
|
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
||||||
|
|
||||||
|
function parseOverrides(text) {
|
||||||
|
const clean = newOverrides();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== "object") return clean;
|
||||||
|
|
||||||
|
const table = (value) => (value && typeof value === "object" && !Array.isArray(value) ? value : {});
|
||||||
|
Object.entries(table(data.regions)).forEach(([net, region]) => {
|
||||||
|
if (!region || !finite(region.x) || !finite(region.y)) return;
|
||||||
|
clean.regions[net] = { x: Math.max(-1e5, Math.min(1e5, region.x)), y: Math.max(-1e5, Math.min(1e5, region.y)) };
|
||||||
|
if (Number.isInteger(region.cols) && region.cols >= 1 && region.cols <= 6) clean.regions[net].cols = region.cols;
|
||||||
|
});
|
||||||
|
Object.entries(table(data.slots)).forEach(([net, cells]) => {
|
||||||
|
const kept = {};
|
||||||
|
Object.entries(table(cells)).forEach(([key, cell]) => {
|
||||||
|
if (Number.isInteger(cell) && cell >= 0 && cell < 400) kept[key] = cell;
|
||||||
|
});
|
||||||
|
if (Object.keys(kept).length) clean.slots[net] = kept;
|
||||||
|
});
|
||||||
|
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 (typeof data.legend === "boolean") clean.legend = data.legend;
|
||||||
|
return limitOverrides(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countOverrides(overrides) {
|
||||||
|
return Object.keys(overrides.regions).length + Object.values(overrides.slots).reduce((sum, cells) => sum + Object.keys(cells).length, 0) + Object.keys(overrides.docks).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the newest entries; objects remember insertion order. */
|
||||||
|
function limitOverrides(overrides) {
|
||||||
|
while (countOverrides(overrides) > MAX_OVERRIDES) {
|
||||||
|
const nets = Object.keys(overrides.slots);
|
||||||
|
if (nets.length) {
|
||||||
|
const cells = overrides.slots[nets[0]];
|
||||||
|
delete cells[Object.keys(cells)[0]];
|
||||||
|
if (!Object.keys(cells).length) delete overrides.slots[nets[0]];
|
||||||
|
} else if (Object.keys(overrides.docks).length) {
|
||||||
|
delete overrides.docks[Object.keys(overrides.docks)[0]];
|
||||||
|
} else {
|
||||||
|
delete overrides.regions[Object.keys(overrides.regions)[0]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeOverrides = (overrides) => JSON.stringify(overrides);
|
||||||
|
|
||||||
|
function setRegion(overrides, net, region) {
|
||||||
|
delete overrides.regions[net];
|
||||||
|
overrides.regions[net] = region;
|
||||||
|
limitOverrides(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCell(overrides, net, key, cell) {
|
||||||
|
const cells = overrides.slots[net] || {};
|
||||||
|
delete cells[key];
|
||||||
|
cells[key] = cell;
|
||||||
|
delete overrides.slots[net];
|
||||||
|
overrides.slots[net] = cells;
|
||||||
|
limitOverrides(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDock(overrides, key, dock) {
|
||||||
|
delete overrides.docks[key];
|
||||||
|
overrides.docks[key] = dock;
|
||||||
|
limitOverrides(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOverrides = (overrides) => countOverrides(overrides) > 0;
|
||||||
|
|
||||||
|
/* ------------------------------------------------- arrangement of networks */
|
||||||
|
|
||||||
|
/* Two networks per row, a lone last one centred under them, so with three
|
||||||
|
networks each is next to the other two. */
|
||||||
|
function gridSlots(count, columnLimit) {
|
||||||
|
const per = Math.max(1, Math.min(columnLimit, count <= 4 ? 2 : 3));
|
||||||
|
const rows = [];
|
||||||
|
for (let left = count; left > 0; left -= per) rows.push(Math.min(per, left));
|
||||||
|
const columns = Math.max.apply(null, rows.concat(1));
|
||||||
|
const slots = [];
|
||||||
|
rows.forEach((size, row) => {
|
||||||
|
for (let col = 0; col < size; col++) slots.push({ x: col + (columns - size) / 2, y: row, row: row });
|
||||||
|
});
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Put networks that share a gateway close together. */
|
||||||
|
function orderNetworks(ids, bridges, first, slots) {
|
||||||
|
const lead = ids.includes(first) ? [first] : [];
|
||||||
|
const rest = ids.filter((id) => id !== first);
|
||||||
|
if (rest.length > 6) return lead.concat(rest);
|
||||||
|
|
||||||
|
const cost = (order) => {
|
||||||
|
const at = new Map(order.map((id, index) => [id, slots[index]]));
|
||||||
|
return bridges.reduce((sum, bridge) => {
|
||||||
|
const spots = Array.from(bridge.touches).map((id) => at.get(id)).filter(Boolean);
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < spots.length; i++) {
|
||||||
|
for (let j = i + 1; j < spots.length; j++) total += Math.hypot(spots[i].x - spots[j].x, spots[i].y - spots[j].y);
|
||||||
|
}
|
||||||
|
return sum + total;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const orders = [];
|
||||||
|
const permute = (left, chosen) => {
|
||||||
|
if (!left.length) {
|
||||||
|
orders.push(lead.concat(chosen));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
left.forEach((id, index) => permute(left.slice(0, index).concat(left.slice(index + 1)), chosen.concat(id)));
|
||||||
|
};
|
||||||
|
permute(rest, []);
|
||||||
|
return orders.reduce((best, order) => (cost(order) < cost(best) - 1e-9 ? order : best), orders[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- geometry */
|
||||||
|
|
||||||
|
const overlapAmount = (a, b, gap) => ({
|
||||||
|
x: Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) + gap,
|
||||||
|
y: Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) + gap,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 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. */
|
||||||
|
function resolveOverlaps(rects, fixed, gap) {
|
||||||
|
const ids = Object.keys(rects).sort();
|
||||||
|
for (let pass = 0; pass < 40; pass++) {
|
||||||
|
let moved = false;
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
for (let j = i + 1; j < ids.length; j++) {
|
||||||
|
const a = rects[ids[i]];
|
||||||
|
const b = rects[ids[j]];
|
||||||
|
const o = overlapAmount(a, b, gap);
|
||||||
|
if (o.x <= 0 || o.y <= 0) continue;
|
||||||
|
const mover = fixed.has(ids[j]) ? (fixed.has(ids[i]) ? null : ids[i]) : ids[j];
|
||||||
|
if (!mover) continue;
|
||||||
|
const other = mover === ids[j] ? a : b;
|
||||||
|
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;
|
||||||
|
else self.y += self.y + self.h / 2 >= other.y + other.h / 2 ? o.y : -o.y;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!moved) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const centreOf = (rect) => ({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
|
||||||
|
const meanPoint = (points) => points.reduce((sum, point) => ({ x: sum.x + point.x / points.length, y: sum.y + point.y / points.length }), { x: 0, y: 0 });
|
||||||
|
const netCentre = (state, id) => centreOf({ ...state.regions.get(id), ...state.sizes.get(id) });
|
||||||
|
const EMPTY_BOUNDS = { x: 0, y: 0, w: 400, h: 300 };
|
||||||
|
|
||||||
|
function sideToward(from, to) {
|
||||||
|
const dx = to.x - from.x;
|
||||||
|
const dy = to.y - from.y;
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? "right" : "left";
|
||||||
|
return dy > 0 ? "bottom" : "top";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normals = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] };
|
||||||
|
|
||||||
|
/* Where a line from an outside point towards the centre of the rect enters it. */
|
||||||
|
function entryPoint(point, rect) {
|
||||||
|
const dx = rect.x + rect.w / 2 - point.x;
|
||||||
|
const dy = rect.y + rect.h / 2 - point.y;
|
||||||
|
const slab = (from, delta, low, high) => (delta === 0 ? -Infinity : Math.min((low - from) / delta, (high - from) / delta));
|
||||||
|
const t = Math.max(0, Math.min(1, Math.max(slab(point.x, dx, rect.x, rect.x + rect.w), slab(point.y, dy, rect.y, rect.y + rect.h))));
|
||||||
|
return { x: point.x + dx * t, y: point.y + dy * t };
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeSide(point, rect) {
|
||||||
|
const gaps = [
|
||||||
|
[Math.abs(point.x - rect.x), "left"],
|
||||||
|
[Math.abs(point.x - (rect.x + rect.w)), "right"],
|
||||||
|
[Math.abs(point.y - rect.y), "top"],
|
||||||
|
[Math.abs(point.y - (rect.y + rect.h)), "bottom"],
|
||||||
|
];
|
||||||
|
return gaps.sort((a, b) => a[0] - b[0])[0][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A curve that leaves the dock square to its edge and arrives square to the border it meets. */
|
||||||
|
function spokePath(start, startSide, end, endSide) {
|
||||||
|
const reach = Math.max(30, Math.min(140, Math.hypot(end.x - start.x, end.y - start.y) / 2.4));
|
||||||
|
const a = normals[startSide];
|
||||||
|
const b = normals[endSide];
|
||||||
|
const c1 = { x: start.x + a[0] * reach, y: start.y + a[1] * reach };
|
||||||
|
const c2 = { x: end.x + b[0] * reach, y: end.y + b[1] * reach };
|
||||||
|
return "M" + start.x + " " + start.y + " C" + c1.x + " " + c1.y + " " + c2.x + " " + c2.y + " " + end.x + " " + end.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Straight runs with rounded corners. */
|
||||||
|
function roundedPath(points, radius) {
|
||||||
|
const pts = points.filter((point, index) => index === 0 || Math.hypot(point.x - points[index - 1].x, point.y - points[index - 1].y) > 0.01);
|
||||||
|
if (pts.length < 2) return "";
|
||||||
|
let d = "M" + pts[0].x + " " + pts[0].y;
|
||||||
|
for (let i = 1; i < pts.length - 1; i++) {
|
||||||
|
const prev = pts[i - 1];
|
||||||
|
const at = pts[i];
|
||||||
|
const next = pts[i + 1];
|
||||||
|
const before = Math.hypot(at.x - prev.x, at.y - prev.y);
|
||||||
|
const after = Math.hypot(next.x - at.x, next.y - at.y);
|
||||||
|
const r = Math.min(radius, before / 2, after / 2);
|
||||||
|
const a = { x: at.x + ((prev.x - at.x) / before) * r, y: at.y + ((prev.y - at.y) / before) * r };
|
||||||
|
const b = { x: at.x + ((next.x - at.x) / after) * r, y: at.y + ((next.y - at.y) / after) * r };
|
||||||
|
d += " L" + a.x + " " + a.y + " Q" + at.x + " " + at.y + " " + b.x + " " + b.y;
|
||||||
|
}
|
||||||
|
return d + " L" + pts[pts.length - 1].x + " " + pts[pts.length - 1].y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ layout */
|
||||||
|
|
||||||
|
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
||||||
|
|
||||||
|
function withGhosts(model, state, now) {
|
||||||
|
const bridges = model.bridges.slice();
|
||||||
|
model.bridges.forEach((bridge) => state.gateways.set(bridge.key, { time: now, bridge: bridge }));
|
||||||
|
state.gateways.forEach((entry, 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);
|
||||||
|
});
|
||||||
|
return bridges.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cells of one network's grid: the user's choice wins, then where a node already was, then the first free one. */
|
||||||
|
function assignCells(state, net, plain, chosen) {
|
||||||
|
const previous = state.cells.get(net) || new Map();
|
||||||
|
const taken = new Map();
|
||||||
|
const placed = new Map();
|
||||||
|
const keys = plain.map((node) => node.key);
|
||||||
|
|
||||||
|
keys.slice().sort().forEach((key) => {
|
||||||
|
const cell = chosen[key];
|
||||||
|
if (cell !== undefined && !taken.has(cell)) {
|
||||||
|
taken.set(cell, key);
|
||||||
|
placed.set(key, cell);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
keys.forEach((key) => {
|
||||||
|
const cell = previous.get(key);
|
||||||
|
if (!placed.has(key) && cell !== undefined && !taken.has(cell)) {
|
||||||
|
taken.set(cell, key);
|
||||||
|
placed.set(key, cell);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let free = 0;
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (placed.has(key)) return;
|
||||||
|
while (taken.has(free)) free += 1;
|
||||||
|
taken.set(free, key);
|
||||||
|
placed.set(key, free);
|
||||||
|
});
|
||||||
|
state.cells.set(net, placed);
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMap(model, state, overrides, now) {
|
||||||
|
const bridges = withGhosts(model, state, now);
|
||||||
|
const bridgeAt = new Map(bridges.map((bridge) => [bridge.key, bridge]));
|
||||||
|
const nets = model.networks;
|
||||||
|
const selfNet = model.selfKey ? model.byKey.get(model.selfKey).net : null;
|
||||||
|
|
||||||
|
nets.forEach((net) => {
|
||||||
|
if (!state.colors.has(net)) state.colors.set(net, state.nextColor++ % M.COLORS);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = nets.map((id) => {
|
||||||
|
const members = model.nodes.filter((node) => node.net === id).sort(M.compareAddresses);
|
||||||
|
const plain = members.filter((node) => !bridgeAt.has(node.key));
|
||||||
|
const region = overrides.regions[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);
|
||||||
|
state.cols.set(id, cols);
|
||||||
|
const cells = assignCells(state, id, plain, overrides.slots[id] || {});
|
||||||
|
const rows = Math.max(1, Math.ceil((Math.max(-1, ...cells.values()) + 1) / cols));
|
||||||
|
return { id: id, members: members, plain: plain, gateways: members.filter((node) => bridgeAt.has(node.key)), cols: cols, cells: cells, rows: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Where the networks are, or will be, for choosing the side a gateway docks on. */
|
||||||
|
const first = state.regions.size === 0;
|
||||||
|
const approx = new Map();
|
||||||
|
const slots = first ? gridSlots(nets.length, 3) : null;
|
||||||
|
const arrangement = first ? orderNetworks(nets, bridges, selfNet, slots) : null;
|
||||||
|
if (first) {
|
||||||
|
arrangement.forEach((id, index) => approx.set(id, { x: slots[index].x * 1000, y: slots[index].y * 1000 }));
|
||||||
|
} else {
|
||||||
|
groups.forEach((group) => {
|
||||||
|
if (state.regions.has(group.id)) return;
|
||||||
|
const near = bridges.filter((bridge) => bridge.touches.has(group.id)).flatMap((bridge) => Array.from(bridge.touches)).filter((id) => id !== group.id && state.regions.has(id));
|
||||||
|
const spots = (near.length ? near : Array.from(state.regions.keys())).map((id) => netCentre(state, id));
|
||||||
|
const centre = meanPoint(spots);
|
||||||
|
state.regions.set(group.id, { x: centre.x - NOMINAL.w / 2, y: centre.y - NOMINAL.h / 2 });
|
||||||
|
state.sizes.set(group.id, { w: NOMINAL.w, h: NOMINAL.h });
|
||||||
|
state.placeLater.add(group.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const centreFor = (id) => (approx.has(id) ? approx.get(id) : netCentre(state, id));
|
||||||
|
|
||||||
|
/* Sides, then sizes. */
|
||||||
|
groups.forEach((group) => {
|
||||||
|
group.sides = { left: [], right: [], top: [], bottom: [] };
|
||||||
|
group.gateways.forEach((node) => {
|
||||||
|
const bridge = bridgeAt.get(node.key);
|
||||||
|
const others = Array.from(bridge.touches).filter((id) => id !== group.id);
|
||||||
|
const signature = others.slice().sort().join("|");
|
||||||
|
let side = own(overrides.docks, node.key) ? overrides.docks[node.key].side : null;
|
||||||
|
if (!side) {
|
||||||
|
const kept = state.sides.get(node.key);
|
||||||
|
if (kept && kept.signature === signature) side = kept.side;
|
||||||
|
else {
|
||||||
|
const spots = others.filter((id) => approx.has(id) || state.regions.has(id)).map(centreFor);
|
||||||
|
const centre = spots.length ? meanPoint(spots) : null;
|
||||||
|
side = centre ? sideToward(centreFor(group.id), centre) : "right";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.sides.set(node.key, { side: side, signature: signature });
|
||||||
|
group.sides[side].push(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 };
|
||||||
|
const header = 33 + 8.4 * group.id.length + 78;
|
||||||
|
const width = Math.max(
|
||||||
|
MIN_WIDTH,
|
||||||
|
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("bottom") ? reserve.left + count("bottom") * DOCK_PITCH + 25 + reserve.right : 0
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
Object.assign(group, { reserve: reserve, header: header, w: width, h: height });
|
||||||
|
state.sizes.set(group.id, { w: width, h: height });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Origins: the user's, then where a network already was, then a free spot. */
|
||||||
|
const fixed = new Set();
|
||||||
|
if (first) {
|
||||||
|
const byId = new Map(groups.map((group) => [group.id, group]));
|
||||||
|
const rows = new Map();
|
||||||
|
arrangement.forEach((id, index) => rows.set(slots[index].row, (rows.get(slots[index].row) || []).concat(id)));
|
||||||
|
const widths = Array.from(rows.values()).map((ids) => ids.reduce((sum, id) => sum + byId.get(id).w, 0) + GAP * (ids.length - 1));
|
||||||
|
const widest = Math.max.apply(null, widths);
|
||||||
|
let y = 0;
|
||||||
|
Array.from(rows.keys()).sort((a, b) => a - b).forEach((row, index) => {
|
||||||
|
const ids = rows.get(row);
|
||||||
|
let x = (widest - widths[index]) / 2;
|
||||||
|
ids.forEach((id) => {
|
||||||
|
state.regions.set(id, { x: x, y: y });
|
||||||
|
x += byId.get(id).w + GAP;
|
||||||
|
});
|
||||||
|
y += Math.max.apply(null, ids.map((id) => byId.get(id).h)) + GAP;
|
||||||
|
});
|
||||||
|
} else if (state.placeLater.size) {
|
||||||
|
state.placeLater.forEach((id) => {
|
||||||
|
const group = groups.find((entry) => entry.id === id);
|
||||||
|
if (!group) return;
|
||||||
|
const placed = groups.filter((entry) => entry.id !== id && !state.placeLater.has(entry.id));
|
||||||
|
const target = centreOf({ ...state.regions.get(id), ...NOMINAL });
|
||||||
|
const candidates = [];
|
||||||
|
placed.forEach((other) => {
|
||||||
|
const at = state.regions.get(other.id);
|
||||||
|
candidates.push({ x: at.x + other.w + GAP, y: at.y }, { x: at.x, y: at.y + other.h + GAP }, { x: at.x - group.w - GAP, y: at.y }, { x: at.x, y: at.y - group.h - GAP });
|
||||||
|
});
|
||||||
|
const free = candidates.filter((spot) => placed.every((other) => {
|
||||||
|
const at = state.regions.get(other.id);
|
||||||
|
const o = overlapAmount({ x: spot.x, y: spot.y, w: group.w, h: group.h }, { x: at.x, y: at.y, w: other.w, h: other.h }, GAP - 1);
|
||||||
|
return o.x <= 0 || o.y <= 0;
|
||||||
|
}));
|
||||||
|
const pool = free.length ? free : candidates;
|
||||||
|
const best = pool.length ? pool.reduce((a, b) => (Math.hypot(a.x + group.w / 2 - target.x, a.y + group.h / 2 - target.y) <= Math.hypot(b.x + group.w / 2 - target.x, b.y + group.h / 2 - target.y) ? a : b)) : { x: 0, y: 0 };
|
||||||
|
state.regions.set(id, { x: best.x, y: best.y });
|
||||||
|
});
|
||||||
|
state.placeLater.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rects = {};
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const at = state.regions.get(group.id);
|
||||||
|
const chosen = overrides.regions[group.id];
|
||||||
|
if (chosen) {
|
||||||
|
at.x = chosen.x;
|
||||||
|
at.y = chosen.y;
|
||||||
|
fixed.add(group.id);
|
||||||
|
}
|
||||||
|
rects[group.id] = { x: at.x, y: at.y, w: group.w, h: group.h };
|
||||||
|
});
|
||||||
|
resolveOverlaps(rects, fixed, GAP);
|
||||||
|
groups.forEach((group) => {
|
||||||
|
state.regions.set(group.id, { x: rects[group.id].x, y: rects[group.id].y });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Nodes and docks. */
|
||||||
|
const regions = [];
|
||||||
|
const nodes = [];
|
||||||
|
const nodeAt = new Map();
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const at = state.regions.get(group.id);
|
||||||
|
const inner = group.w - group.reserve.left - group.reserve.right;
|
||||||
|
const gridX = at.x + group.reserve.left + (inner - group.cols * CELL_W) / 2;
|
||||||
|
const gridY = at.y + HEAD + group.reserve.top;
|
||||||
|
const region = {
|
||||||
|
id: group.id,
|
||||||
|
color: state.colors.get(group.id),
|
||||||
|
x: at.x,
|
||||||
|
y: at.y,
|
||||||
|
w: group.w,
|
||||||
|
h: group.h,
|
||||||
|
cols: group.cols,
|
||||||
|
rows: group.rows,
|
||||||
|
grid: { x: gridX, y: gridY, bottom: gridY + group.rows * CELL_H },
|
||||||
|
header: group.header,
|
||||||
|
count: group.members.length,
|
||||||
|
alive: group.members.filter((node) => node.alive).length,
|
||||||
|
moved: own(overrides.regions, group.id),
|
||||||
|
};
|
||||||
|
regions.push(region);
|
||||||
|
|
||||||
|
group.plain.forEach((node) => {
|
||||||
|
const cell = group.cells.get(node.key);
|
||||||
|
const col = cell % group.cols;
|
||||||
|
const row = Math.floor(cell / group.cols);
|
||||||
|
const x = gridX + col * CELL_W + CELL_W / 2;
|
||||||
|
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 } };
|
||||||
|
nodes.push(item);
|
||||||
|
nodeAt.set(node.key, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const spot = (node) => {
|
||||||
|
const bridge = bridgeAt.get(node.key);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
["left", "right", "top", "bottom"].forEach((side) => {
|
||||||
|
const horizontal = side === "top" || side === "bottom";
|
||||||
|
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 }))
|
||||||
|
.sort((a, b) => a.key - b.key || a.node.key.localeCompare(b.node.key))
|
||||||
|
.map((entry) => entry.node);
|
||||||
|
ordered.forEach((node, index) => {
|
||||||
|
const [x, y] = side === "left" || side === "right"
|
||||||
|
? [side === "left" ? at.x : at.x + group.w, at.y + HEAD + 30 + index * DOCK_ROW]
|
||||||
|
: [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 box = side === "left" ? { x: x + 27, y: y + 6, w: LABEL_W, h: LABEL_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);
|
||||||
|
nodeAt.set(node.key, item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const regionAt = new Map(regions.map((region) => [region.id, region]));
|
||||||
|
const spokes = [];
|
||||||
|
bridges.forEach((bridge) => {
|
||||||
|
const gateway = nodeAt.get(bridge.key);
|
||||||
|
if (!gateway) return;
|
||||||
|
bridge.touches.forEach((id) => {
|
||||||
|
if (id === gateway.net || !regionAt.has(id)) return;
|
||||||
|
const target = regionAt.get(id);
|
||||||
|
const end = entryPoint(gateway, 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)) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pad = 40;
|
||||||
|
const left = Math.min.apply(null, regions.map((r) => r.x)) - pad;
|
||||||
|
const top = Math.min.apply(null, regions.map((r) => r.y)) - pad;
|
||||||
|
const bounds = regions.length
|
||||||
|
? { x: left, y: top, w: Math.max.apply(null, regions.map((r) => r.x + r.w)) + pad - left, h: Math.max.apply(null, regions.map((r) => r.y + r.h)) + pad - top }
|
||||||
|
: EMPTY_BOUNDS;
|
||||||
|
|
||||||
|
return { bounds: bounds, regions: regions, regionAt: regionAt, nodes: nodes, nodeAt: nodeAt, spokes: spokes, bridges: bridges, signature: model.signature };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ routes */
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
node upwards (its label is below), runs along the gap above its row, then along the edge
|
||||||
|
of the grid to the gateway. */
|
||||||
|
function streetPoints(map, plain, dock) {
|
||||||
|
const region = map.regionAt.get(plain.net);
|
||||||
|
const gap = region.grid.y + plain.row * CELL_H;
|
||||||
|
const start = { x: plain.x, y: plain.y };
|
||||||
|
const up = { x: plain.x, y: gap };
|
||||||
|
const end = { x: dock.x, y: dock.y };
|
||||||
|
const side = dock.dock.side;
|
||||||
|
|
||||||
|
if (side === "left" || side === "right") {
|
||||||
|
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 column = Math.abs(left - end.x) <= Math.abs(left + CELL_W - end.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: lane }, { x: end.x, y: lane }, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Between two gateways of one network: out of the first onto the ring of gaps around the
|
||||||
|
grid, along it, and into the second. */
|
||||||
|
function ringPoints(map, a, b) {
|
||||||
|
const region = map.regionAt.get(a.net);
|
||||||
|
const x0 = region.grid.x;
|
||||||
|
const x1 = region.grid.x + region.cols * CELL_W;
|
||||||
|
const y0 = region.grid.y - 4;
|
||||||
|
const y1 = region.grid.bottom + 4;
|
||||||
|
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 ea = onRing(a);
|
||||||
|
const eb = onRing(b);
|
||||||
|
const sa = a.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);
|
||||||
|
let middle = [];
|
||||||
|
|
||||||
|
if ((sa === "left" && sb === "right") || (sa === "right" && sb === "left")) {
|
||||||
|
const xa = sa === "left" ? x0 : x1;
|
||||||
|
const xb = sa === "left" ? x1 : x0;
|
||||||
|
const over = [{ x: xa, y: y0 }, { x: xb, y: y0 }];
|
||||||
|
const under = [{ x: xa, y: y1 }, { x: xb, y: y1 }];
|
||||||
|
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) {
|
||||||
|
const horizontal = sa === "left" || sa === "right" ? sa : sb;
|
||||||
|
const vertical = sa === "top" || sa === "bottom" ? sa : sb;
|
||||||
|
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 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function legPoints(map, from, to) {
|
||||||
|
if (from.net === to.net) {
|
||||||
|
if (from.gateway && to.gateway) return ringPoints(map, from, to);
|
||||||
|
return from.gateway ? streetPoints(map, to, from).reverse() : streetPoints(map, from, to);
|
||||||
|
}
|
||||||
|
return [{ x: from.x, y: from.y }, { x: to.x, y: to.y }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What a trace from one node lights up: legs inside a network, the spokes used, captions. */
|
||||||
|
function traceGeometry(map, model, routes) {
|
||||||
|
const legs = [];
|
||||||
|
const used = new Set();
|
||||||
|
const captions = new Map();
|
||||||
|
const kinds = new Map();
|
||||||
|
const seen = new Set();
|
||||||
|
const origin = routes ? map.nodeAt.get(routes.viewKey) : null;
|
||||||
|
if (!origin) return { legs: legs, used: used, captions: captions, kinds: kinds, origin: null };
|
||||||
|
|
||||||
|
const leg = (from, to, color) => {
|
||||||
|
const id = from.key + ">" + to.key;
|
||||||
|
if (seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
const points = legPoints(map, from, to);
|
||||||
|
legs.push({ from: from.key, to: to.key, color: color, points: points, d: from.net === to.net ? roundedPath(points, 9) : "M" + from.x + " " + from.y + " L" + to.x + " " + to.y });
|
||||||
|
};
|
||||||
|
|
||||||
|
routes.byKey.forEach((route, key) => {
|
||||||
|
const target = map.nodeAt.get(key);
|
||||||
|
if (!target) return;
|
||||||
|
kinds.set(key, route.kind);
|
||||||
|
if (route.kind === "none") captions.set(key, "no recent contact");
|
||||||
|
if (route.kind === "unknown") captions.set(key, "unknown");
|
||||||
|
if (route.kind !== "via") return;
|
||||||
|
|
||||||
|
const gateway = map.nodeAt.get(route.via);
|
||||||
|
const name = model.byKey.get(route.via);
|
||||||
|
captions.set(key, "via " + (name ? name.hostname : route.via));
|
||||||
|
if (!gateway) return;
|
||||||
|
if (gateway !== origin) leg(origin, gateway, origin.color);
|
||||||
|
if (gateway.net === target.net) leg(gateway, target, target.color);
|
||||||
|
else used.add(gateway.key + ">" + target.net);
|
||||||
|
});
|
||||||
|
if (origin.gateway) map.spokes.filter((spoke) => spoke.gateway === origin.key).forEach((spoke) => used.add(spoke.gateway + ">" + spoke.to));
|
||||||
|
return { legs: legs, used: used, captions: captions, kinds: kinds, origin: origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- user moves */
|
||||||
|
|
||||||
|
/* The cell of a network's grid nearest to a point (world units), or null when the
|
||||||
|
network is not on the map. */
|
||||||
|
function cellAt(map, net, x, y) {
|
||||||
|
const region = map.regionAt.get(net);
|
||||||
|
if (!region) return null;
|
||||||
|
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));
|
||||||
|
return row * region.cols + col;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Which edge of a network a dropped gateway belongs to, and the position that orders it along that edge. */
|
||||||
|
function dockAt(map, net, x, y) {
|
||||||
|
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);
|
||||||
|
return { side: side, t: side === "top" || side === "bottom" ? x : y };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
CELL_W, CELL_H, GATEWAY_R, NODE_R, GAP, EMPTY_BOUNDS,
|
||||||
|
newState, newOverrides, parseOverrides, serializeOverrides, hasOverrides, setRegion, setCell, setDock,
|
||||||
|
gridSlots, orderNetworks, resolveOverlaps, entryPoint, roundedPath, computeMap, traceGeometry, cellAt, dockAt,
|
||||||
|
};
|
||||||
|
if (typeof module !== "undefined") module.exports = api;
|
||||||
|
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* Pure facts about the cluster: who is in which network, who relays for whom, how
|
||||||
|
one node reaches the others. No DOM, no geometry. Loaded by the page as a classic
|
||||||
|
script (functions land on globalThis.Colony) and by the tests through require. */
|
||||||
|
(function () {
|
||||||
|
const OTHER_NETWORK = "other addresses";
|
||||||
|
const COLORS = 6;
|
||||||
|
|
||||||
|
const address = (member) => member.ip + ":" + member.port;
|
||||||
|
const keyOf = (pair) => pair[0] + ":" + pair[1];
|
||||||
|
|
||||||
|
function networkOf(ip) {
|
||||||
|
const parts = String(ip).split(".");
|
||||||
|
const octets = parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
||||||
|
return parts.length === 4 && octets ? parts.slice(0, 3).join(".") + ".0/24" : OTHER_NETWORK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNetworks(a, b) {
|
||||||
|
if (a === OTHER_NETWORK || b === OTHER_NETWORK) return (a === OTHER_NETWORK) - (b === OTHER_NETWORK);
|
||||||
|
return a.localeCompare(b, undefined, { numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareAddresses(a, b) {
|
||||||
|
return a.ip.localeCompare(b.ip, undefined, { numeric: true }) || a.port - b.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shorten(text, limit) {
|
||||||
|
return text.length > limit ? text.slice(0, limit - 1) + "…" : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fill up to maxLines lines of at most limit characters with the items, then cut. */
|
||||||
|
function wrapLabels(items, limit, maxLines) {
|
||||||
|
const lines = [];
|
||||||
|
let line = "";
|
||||||
|
items.forEach((item) => {
|
||||||
|
const next = line ? line + " · " + item : item;
|
||||||
|
if (line && next.length > limit) {
|
||||||
|
lines.push(line);
|
||||||
|
line = item;
|
||||||
|
} else {
|
||||||
|
line = next;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (line) lines.push(line);
|
||||||
|
const shown = lines.slice(0, maxLines);
|
||||||
|
if (lines.length > maxLines) shown[maxLines - 1] = shorten(shown[maxLines - 1] + " · …", limit);
|
||||||
|
return shown.map((text) => shorten(text, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleStates(members, roles) {
|
||||||
|
return roles.map((role) => {
|
||||||
|
const holders = members.filter((node) => node.alive && node.roles.includes(role.name)).length;
|
||||||
|
let state = "held";
|
||||||
|
if (role.on_demand) state = holders ? "held" : "off";
|
||||||
|
else if (role.required === null) state = "general";
|
||||||
|
else if (holders < role.required) state = "unmet";
|
||||||
|
else if (holders > role.required) state = "duplicate";
|
||||||
|
return { name: role.name, state: state };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What one node reported about how it reaches the others.
|
||||||
|
Full payloads list every reached pair in `links`; compact payloads (`routes`)
|
||||||
|
list only the relayed and the unreached members, everything else is direct. */
|
||||||
|
function readRoutes(payload, nodes) {
|
||||||
|
const reporters = new Map((payload.views || []).map((pair) => [keyOf(pair), { via: new Map(), direct: null, none: null }]));
|
||||||
|
|
||||||
|
if (Array.isArray(payload.routes)) {
|
||||||
|
reporters.forEach((entry) => (entry.none = new Set()));
|
||||||
|
payload.routes.forEach((route) => {
|
||||||
|
const from = nodes[route.from];
|
||||||
|
const entry = from && reporters.get(from.key);
|
||||||
|
if (!entry) return;
|
||||||
|
(route.none || []).forEach((index) => nodes[index] && entry.none.add(nodes[index].key));
|
||||||
|
Object.keys(route.via || {}).forEach((gateway) => {
|
||||||
|
if (!nodes[gateway]) return;
|
||||||
|
route.via[gateway].forEach((index) => nodes[index] && entry.via.set(nodes[index].key, nodes[gateway].key));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reporters.forEach((entry) => (entry.direct = new Set()));
|
||||||
|
(payload.links || []).forEach((link) => {
|
||||||
|
const entry = reporters.get(keyOf(link.from));
|
||||||
|
if (!entry) return;
|
||||||
|
if (link.via) entry.via.set(keyOf(link.to), keyOf(link.via));
|
||||||
|
else entry.direct.add(keyOf(link.to));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reporters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memo = new WeakMap();
|
||||||
|
|
||||||
|
/* Everything the page needs from a payload, computed once per payload. */
|
||||||
|
function analyze(payload) {
|
||||||
|
if (memo.has(payload)) return memo.get(payload);
|
||||||
|
|
||||||
|
const nodes = payload.members.map((member, index) => ({
|
||||||
|
index: index,
|
||||||
|
key: address(member),
|
||||||
|
net: networkOf(member.ip),
|
||||||
|
hostname: member.hostname,
|
||||||
|
ip: member.ip,
|
||||||
|
port: member.port,
|
||||||
|
alive: Boolean(member.alive),
|
||||||
|
self: Boolean(member.self),
|
||||||
|
roles: member.roles,
|
||||||
|
declined: member.declined || [],
|
||||||
|
}));
|
||||||
|
const byKey = new Map(nodes.map((node) => [node.key, node]));
|
||||||
|
const states = roleStates(nodes, payload.roles);
|
||||||
|
const reporters = readRoutes(payload, nodes);
|
||||||
|
|
||||||
|
const route = (from, to) => {
|
||||||
|
const target = byKey.get(to);
|
||||||
|
if (!target || !target.alive) return null;
|
||||||
|
const entry = reporters.get(from);
|
||||||
|
if (!entry) return { kind: "unknown", via: null };
|
||||||
|
const gateway = entry.via.get(to);
|
||||||
|
if (gateway !== undefined) return byKey.has(gateway) && byKey.get(gateway).alive ? { kind: "via", via: gateway } : { kind: "none", via: null };
|
||||||
|
const direct = entry.direct ? entry.direct.has(to) : !entry.none.has(to);
|
||||||
|
return { kind: direct ? "direct" : "none", via: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
payload: payload,
|
||||||
|
nodes: nodes,
|
||||||
|
byKey: byKey,
|
||||||
|
selfKey: (nodes.find((node) => node.self) || {}).key || null,
|
||||||
|
states: states,
|
||||||
|
general: new Set(states.filter((role) => role.state === "general").map((role) => role.name)),
|
||||||
|
reporters: reporters,
|
||||||
|
route: route,
|
||||||
|
networks: Array.from(new Set(nodes.map((node) => node.net))).sort(compareNetworks),
|
||||||
|
bridges: [],
|
||||||
|
bridgeAt: new Map(),
|
||||||
|
signature: JSON.stringify([
|
||||||
|
nodes.map((node) => [node.key, node.hostname, node.alive, node.self, node.roles, node.declined]),
|
||||||
|
payload.roles,
|
||||||
|
payload.views,
|
||||||
|
payload.routes || payload.links,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
model.bridges = bridgesOf(model);
|
||||||
|
model.bridgeAt = new Map(model.bridges.map((bridge) => [bridge.key, bridge]));
|
||||||
|
memo.set(payload, model);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A gateway is a node that really relays something for somebody, never one that only
|
||||||
|
holds the role: every node holds it. */
|
||||||
|
function bridgesOf(model) {
|
||||||
|
const found = new Map();
|
||||||
|
const counted = new Set();
|
||||||
|
|
||||||
|
model.reporters.forEach((entry, from) => {
|
||||||
|
entry.via.forEach((gateway, to) => {
|
||||||
|
const via = model.byKey.get(gateway);
|
||||||
|
const target = model.byKey.get(to);
|
||||||
|
const source = model.byKey.get(from);
|
||||||
|
if (!via || !target || !source || !via.alive || !target.alive) return;
|
||||||
|
|
||||||
|
const pair = gateway + "|" + [from, to].sort().join(">");
|
||||||
|
if (counted.has(pair)) return;
|
||||||
|
counted.add(pair);
|
||||||
|
|
||||||
|
if (!found.has(gateway)) found.set(gateway, { key: gateway, hostname: via.hostname, home: via.net, pairs: 0, touches: new Set([via.net]) });
|
||||||
|
const bridge = found.get(gateway);
|
||||||
|
bridge.pairs += 1;
|
||||||
|
bridge.touches.add(source.net);
|
||||||
|
bridge.touches.add(target.net);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(found.values()).sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveView(model, wanted) {
|
||||||
|
const alive = model.nodes.filter((node) => node.alive);
|
||||||
|
if (wanted && alive.some((node) => node.key === wanted)) return wanted;
|
||||||
|
return model.selfKey || (alive.length ? alive[0].key : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* How one node reaches every other alive node. */
|
||||||
|
function routesFrom(model, viewKey) {
|
||||||
|
const byKey = new Map();
|
||||||
|
const counts = { direct: 0, none: 0 };
|
||||||
|
const relayed = new Map();
|
||||||
|
const reported = model.reporters.has(viewKey);
|
||||||
|
|
||||||
|
model.nodes.forEach((node) => {
|
||||||
|
if (node.key === viewKey || !node.alive) return;
|
||||||
|
const route = model.route(viewKey, node.key);
|
||||||
|
byKey.set(node.key, route);
|
||||||
|
if (route.kind === "direct") counts.direct += 1;
|
||||||
|
else if (route.kind === "none") counts.none += 1;
|
||||||
|
else if (route.kind === "via") relayed.set(route.via, (relayed.get(route.via) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { viewKey: viewKey, reported: reported, byKey: byKey, counts: counts, relayed: relayed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeSummary(model, routes) {
|
||||||
|
const viewer = model.byKey.get(routes.viewKey);
|
||||||
|
if (!routes.reported) return [{ kind: "unknown", text: "no report from " + (viewer ? viewer.hostname : "it") + " yet" }];
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (routes.counts.direct) parts.push({ kind: "direct", text: routes.counts.direct + " direct" });
|
||||||
|
model.bridges.forEach((bridge) => {
|
||||||
|
if (routes.relayed.has(bridge.key)) parts.push({ kind: "via", network: bridge.home, text: routes.relayed.get(bridge.key) + " via " + bridge.hostname });
|
||||||
|
});
|
||||||
|
if (routes.counts.none) parts.push({ kind: "none", text: routes.counts.none + " no recent contact" });
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The roles worth naming on the map: everything except the ones every node runs. */
|
||||||
|
function specialRoles(model, node) {
|
||||||
|
return node.roles.filter((name) => !model.general.has(name)).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function alerts(model) {
|
||||||
|
const named = (state) => model.states.filter((role) => role.state === state).map((role) => role.name);
|
||||||
|
const down = model.nodes.filter((node) => !node.alive).map((node) => node.hostname);
|
||||||
|
const out = [];
|
||||||
|
if (named("unmet").length) out.push({ level: "bad", text: "Unmet: " + named("unmet").join(", ") });
|
||||||
|
if (named("duplicate").length) out.push({ level: "warn", text: "Held twice: " + named("duplicate").join(", ") });
|
||||||
|
if (down.length) out.push({ level: "info", text: "Down: " + down.join(", ") });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDemandHint(model) {
|
||||||
|
const off = model.states.filter((role) => role.state === "off").map((role) => role.name);
|
||||||
|
return off.length ? "Not running (on demand): " + off.join(", ") + " · start one with: colonyctl role add " + off[0] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(model) {
|
||||||
|
const alive = model.nodes.filter((node) => node.alive).length;
|
||||||
|
const down = model.nodes.length - alive;
|
||||||
|
const assigned = model.states.filter((role) => role.state !== "general" && role.state !== "off");
|
||||||
|
const held = assigned.filter((role) => role.state !== "unmet");
|
||||||
|
let text = alive + " node" + (alive === 1 ? "" : "s") + " alive";
|
||||||
|
if (model.networks.length > 1) text += " · " + model.networks.length + " networks";
|
||||||
|
if (down) text += " · " + down + " down";
|
||||||
|
return text + " · " + held.length + "/" + assigned.length + " roles held";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The full text of a node: tooltip and screen reader. */
|
||||||
|
function describeNode(model, node, routes) {
|
||||||
|
const lines = [node.hostname + " " + node.key + (node.self ? " (this node)" : "") + (node.alive ? "" : " (down)")];
|
||||||
|
const bridge = model.bridgeAt.get(node.key);
|
||||||
|
if (bridge) lines.push("gateway: relays " + bridge.pairs + " node pair" + (bridge.pairs === 1 ? "" : "s"));
|
||||||
|
lines.push("roles: " + (node.roles.join(", ") || "none"));
|
||||||
|
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;
|
||||||
|
if (route) {
|
||||||
|
const from = (model.byKey.get(routes.viewKey) || {}).hostname || "the viewpoint";
|
||||||
|
const via = route.via && model.byKey.get(route.via);
|
||||||
|
lines.push("from " + from + ": " + {
|
||||||
|
direct: "reached directly",
|
||||||
|
via: "reached through " + (via ? via.hostname : route.via),
|
||||||
|
none: "no contact in the last 40 s (not necessarily unreachable)",
|
||||||
|
unknown: "no report from " + from + " yet",
|
||||||
|
}[route.kind]);
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
if (typeof module !== "undefined") module.exports = api;
|
||||||
|
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
|
||||||
|
})();
|
||||||
@@ -4,15 +4,16 @@
|
|||||||
--text: #1c2430;
|
--text: #1c2430;
|
||||||
--muted: #6b7686;
|
--muted: #6b7686;
|
||||||
--line: #d3d9e1;
|
--line: #d3d9e1;
|
||||||
--track: #e6eaf0;
|
|
||||||
--alive: #2f9e62;
|
|
||||||
--down: #98a2b3;
|
--down: #98a2b3;
|
||||||
--ok: #3b6fd4;
|
--panel: rgba(255, 255, 255, 0.92);
|
||||||
--warn: #e0961e;
|
--warn: #c67a08;
|
||||||
--crit: #d64545;
|
--crit: #d64545;
|
||||||
--held: #3b6fd4;
|
--n0: #2f6fdb;
|
||||||
--unmet: #d64545;
|
--n1: #0e8a8a;
|
||||||
--duplicate: #e0961e;
|
--n2: #7a4fd0;
|
||||||
|
--n3: #c03a8e;
|
||||||
|
--n4: #9a6510;
|
||||||
|
--n5: #4b8a2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -22,31 +23,42 @@
|
|||||||
--text: #e6eaf0;
|
--text: #e6eaf0;
|
||||||
--muted: #93a0b3;
|
--muted: #93a0b3;
|
||||||
--line: #313a47;
|
--line: #313a47;
|
||||||
--track: #2a3340;
|
|
||||||
--alive: #4cc38a;
|
|
||||||
--down: #6b7686;
|
--down: #6b7686;
|
||||||
--ok: #6c9cff;
|
--panel: rgba(27, 33, 41, 0.92);
|
||||||
--warn: #ffb04a;
|
--warn: #ffb04a;
|
||||||
--crit: #ff6b6b;
|
--crit: #ff6b6b;
|
||||||
--held: #6c9cff;
|
--n0: #6c9cff;
|
||||||
--unmet: #ff6b6b;
|
--n1: #3cc7c7;
|
||||||
--duplicate: #ffb04a;
|
--n2: #ab8cff;
|
||||||
|
--n3: #ff7cc8;
|
||||||
|
--n4: #e0b04a;
|
||||||
|
--n5: #8fd06a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 16px 28px;
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100dvh;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 15px/1.45 system-ui, sans-serif;
|
font: 15px/1.45 system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
header, main, footer { max-width: 900px; margin: 0 auto; }
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
h1 { margin: 22px 0 2px; font-size: 24px; }
|
::selection { background: color-mix(in srgb, var(--n0) 35%, transparent); }
|
||||||
|
|
||||||
|
header { padding: 0 16px 6px; }
|
||||||
|
|
||||||
|
h1 { margin: 10px 0 0; font-size: 22px; }
|
||||||
|
.headline { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px; }
|
||||||
p { margin: 4px 0; }
|
p { margin: 4px 0; }
|
||||||
.summary { color: var(--muted); }
|
.summary { color: var(--muted); }
|
||||||
.muted { color: var(--muted); font-size: 13px; }
|
.muted { color: var(--muted); font-size: 13px; }
|
||||||
@@ -59,61 +71,107 @@ p { margin: 4px 0; }
|
|||||||
.alert.warn { color: var(--warn); border-color: var(--warn); }
|
.alert.warn { color: var(--warn); border-color: var(--warn); }
|
||||||
.alert.info { color: var(--muted); border-color: var(--line); }
|
.alert.info { color: var(--muted); border-color: var(--line); }
|
||||||
|
|
||||||
main { margin-top: 14px; }
|
.route-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 10px 0 0; font-size: 14px; }
|
||||||
#graph { width: 100%; height: auto; display: block; margin: 0 auto; }
|
.route-line:empty { display: none; }
|
||||||
|
.route-line .hint { font-size: 12.5px; }
|
||||||
|
.route { padding: 1px 9px; border-radius: 999px; border: 1.5px solid var(--line); font-size: 13px; font-weight: 600; }
|
||||||
|
.route.direct { border-color: var(--muted); }
|
||||||
|
.route.via { border-color: var(--net); background: color-mix(in srgb, var(--net) 16%, transparent); }
|
||||||
|
.route.none { border-color: var(--muted); border-style: dashed; color: var(--muted); }
|
||||||
|
.route.unknown { color: var(--muted); border-style: dashed; }
|
||||||
|
|
||||||
.link { stroke: var(--line); stroke-width: 2.5; }
|
|
||||||
.link.down { stroke-dasharray: 6 6; opacity: 0.7; }
|
|
||||||
.link.oneway { stroke: var(--muted); }
|
|
||||||
.arrowhead { fill: var(--muted); }
|
|
||||||
.node.bridge .card { stroke: var(--held); stroke-width: 2; }
|
|
||||||
|
|
||||||
.card { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
|
#stage { position: relative; flex: 1; min-height: 260px; overflow: hidden; border-top: 1px solid var(--line); background: var(--bg); }
|
||||||
.node.self .card { stroke: var(--alive); stroke-width: 2; }
|
#map { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; user-select: none; -webkit-user-select: none; cursor: grab; }
|
||||||
.node.down > :not(.card) { opacity: 0.6; }
|
#map:active { cursor: grabbing; }
|
||||||
.node.down .card { stroke-dasharray: 6 4; }
|
#map:focus { outline: none; }
|
||||||
.dot.alive { fill: var(--alive); }
|
#world { transform-origin: 0 0; }
|
||||||
.dot.down { fill: var(--down); }
|
|
||||||
|
|
||||||
.t-title { fill: var(--text); font-size: 14px; font-weight: 600; }
|
.n0 { --net: var(--n0); }
|
||||||
.t-tag { fill: var(--muted); font-size: 11px; text-anchor: end; }
|
.n1 { --net: var(--n1); }
|
||||||
.t-sub { fill: var(--muted); font-size: 11px; }
|
.n2 { --net: var(--n2); }
|
||||||
.t-label { fill: var(--muted); font-size: 10.5px; letter-spacing: 0.04em; }
|
.n3 { --net: var(--n3); }
|
||||||
.t-value { fill: var(--text); font-size: 11px; text-anchor: end; }
|
.n4 { --net: var(--n4); }
|
||||||
.t-value.na { fill: var(--muted); }
|
.n5 { --net: var(--n5); }
|
||||||
.t-foot { fill: var(--muted); font-size: 10.5px; }
|
|
||||||
|
|
||||||
.track { fill: var(--track); }
|
.region { transition: opacity 0.2s; }
|
||||||
.fill.ok { fill: var(--ok); }
|
.region-box { fill: color-mix(in srgb, var(--net) 9%, var(--bg)); stroke: var(--net); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||||
.fill.warn { fill: var(--warn); }
|
.region.selected .region-box { stroke-width: 3.5; }
|
||||||
.fill.crit { fill: var(--crit); }
|
.region.dim { opacity: 0.4; }
|
||||||
|
.region-grab { fill: transparent; cursor: move; }
|
||||||
|
.region-dot { fill: var(--net); pointer-events: none; }
|
||||||
|
.region-title { fill: var(--text); font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; pointer-events: none; }
|
||||||
|
.region-count { fill: var(--muted); font-size: 11.5px; font-weight: 400; }
|
||||||
|
|
||||||
.chip rect { fill: transparent; stroke: var(--line); stroke-width: 1.5; }
|
.spoke { fill: none; stroke: var(--net); stroke-width: 2.5; opacity: 0.45; vector-effect: non-scaling-stroke; }
|
||||||
.chip text { fill: var(--muted); font-size: 10.5px; text-anchor: middle; }
|
.spoke.hidden { opacity: 0; }
|
||||||
.chip.held rect { stroke: var(--held); fill: color-mix(in srgb, var(--held) 14%, transparent); }
|
.spoke.lit { opacity: 0.95; stroke-width: 3.5; }
|
||||||
.chip.held text { fill: var(--text); font-weight: 600; }
|
.spoke-end { fill: var(--net); opacity: 0.6; }
|
||||||
.chip.duplicate rect { stroke: var(--duplicate); fill: color-mix(in srgb, var(--duplicate) 16%, transparent); }
|
.spoke-end.used { opacity: 1; }
|
||||||
.chip.duplicate text { fill: var(--text); font-weight: 600; }
|
.flow { fill: none; stroke: var(--net); stroke-width: 3.5; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; pointer-events: none; }
|
||||||
.chip.unmet rect { stroke: var(--unmet); stroke-dasharray: 5 3; fill: color-mix(in srgb, var(--unmet) 12%, transparent); }
|
.arrow path { fill: var(--net); }
|
||||||
.chip.unmet text { fill: var(--unmet); font-weight: 600; }
|
|
||||||
.chip.unknown rect, .chip.off rect { stroke-dasharray: 2 3; }
|
|
||||||
.chip.more text { fill: var(--muted); }
|
|
||||||
|
|
||||||
.center-box { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
|
.node { cursor: pointer; outline: none; }
|
||||||
.center-box.bad { stroke: var(--unmet); }
|
.disc { fill: var(--card); stroke: var(--net); stroke-width: 2.5; vector-effect: non-scaling-stroke; }
|
||||||
.center-title { text-anchor: middle; }
|
.dot { fill: var(--net); }
|
||||||
|
.halo { fill: none; stroke: none; }
|
||||||
|
.node.down { opacity: 0.65; }
|
||||||
|
.node.down .disc { stroke-dasharray: 4 3; }
|
||||||
|
.node.down .dot { fill: var(--down); }
|
||||||
|
.node.gateway .disc { stroke-width: 4; }
|
||||||
|
.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:hover .halo { stroke: var(--muted); stroke-dasharray: none; }
|
||||||
|
.node.origin .halo, .node.selected .halo { stroke: var(--text); stroke-width: 2.5; stroke-dasharray: none; }
|
||||||
|
.node:focus-visible .halo { stroke: var(--text); stroke-width: 3.5; stroke-dasharray: none; }
|
||||||
|
.node.dragging { cursor: grabbing; opacity: 0.85; }
|
||||||
|
.node.dim { opacity: 0.35; }
|
||||||
|
|
||||||
.legend { display: flex; flex-wrap: wrap; gap: 4px 16px; list-style: none; margin: 10px 0 0; padding: 0; font-size: 12.5px; color: var(--muted); }
|
.l-name, .l-roles, .l-caption { paint-order: stroke; stroke: color-mix(in srgb, var(--net) 9%, var(--bg)); stroke-width: 3px; stroke-linejoin: round; pointer-events: none; }
|
||||||
.swatch { display: inline-block; width: 11px; height: 11px; margin-right: 6px; border-radius: 50%; vertical-align: -1px; }
|
.l-name { fill: var(--text); font-size: 13px; font-weight: 600; }
|
||||||
.swatch.alive { background: var(--alive); }
|
.l-roles { fill: var(--muted); font-size: 11px; }
|
||||||
.swatch.down { background: var(--down); }
|
.l-caption { fill: var(--net); font-size: 11px; font-weight: 600; }
|
||||||
.swatch.held, .swatch.unmet, .swatch.duplicate, .swatch.general { border-radius: 6px; background: transparent; border: 2px solid; }
|
.node.none .l-caption, .node.unknown .l-caption { fill: var(--muted); }
|
||||||
.swatch.general { border-color: var(--line); }
|
|
||||||
.swatch.oneway { width: 18px; height: 0; border-radius: 0; border: 0; border-top: 2.5px solid var(--muted); vertical-align: 3px; }
|
|
||||||
.swatch.bridge { border-radius: 4px; background: transparent; border: 2px solid var(--held); }
|
|
||||||
.swatch.link { width: 18px; height: 0; border-radius: 0; border: 0; border-top: 2.5px solid var(--line); vertical-align: 3px; }
|
|
||||||
.swatch.held { border-color: var(--held); }
|
|
||||||
.swatch.unmet { border-color: var(--unmet); border-style: dashed; }
|
|
||||||
.swatch.duplicate { border-color: var(--duplicate); }
|
|
||||||
|
|
||||||
footer { margin-top: 10px; }
|
/* Level of detail: one class on the root, decided by the zoom. */
|
||||||
|
.lod0 .l-name, .lod0 .l-roles, .lod0 .l-caption { display: none; }
|
||||||
|
.lod0 .region-title { font-size: 38px; }
|
||||||
|
.lod0 .region-count { font-size: 24px; }
|
||||||
|
.lod0 .region-dot { r: 12px; }
|
||||||
|
.lod1 .l-roles { 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 .l-caption { display: none; }
|
||||||
|
|
||||||
|
.flow { stroke-dasharray: none; }
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.flow { stroke-dasharray: 9 7; animation: flow 0.9s linear infinite; }
|
||||||
|
.node { transition: transform 0.25s ease-out; }
|
||||||
|
.node.dragging { transition: none; }
|
||||||
|
.region { transition: opacity 0.2s, transform 0.25s ease-out; }
|
||||||
|
}
|
||||||
|
@keyframes flow { to { stroke-dashoffset: -16; } }
|
||||||
|
|
||||||
|
.controls { position: absolute; right: 12px; bottom: 12px; display: flex; gap: 6px; align-items: center; }
|
||||||
|
.controls button, .legend-open, .legend > button { display: inline-flex; align-items: center; justify-content: center; min-width: 36px; height: 36px; padding: 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); font: inherit; cursor: pointer; }
|
||||||
|
.controls button.text { padding: 0 12px; font-size: 13px; }
|
||||||
|
.controls button:hover, .legend-open:hover, .legend > button:hover { border-color: var(--muted); }
|
||||||
|
.controls button:focus-visible, .legend-open:focus-visible, .legend > button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||||
|
.controls button.on svg { stroke: var(--n0); }
|
||||||
|
.controls svg, .legend > button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
@media (pointer: coarse) { .controls button, .legend-open, .legend > button { min-width: 44px; height: 44px; } }
|
||||||
|
|
||||||
|
.legend { position: absolute; left: 12px; bottom: 12px; max-width: min(360px, calc(100% - 24px)); padding: 10px 12px 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); font-size: 12.5px; color: var(--muted); }
|
||||||
|
.legend > button { position: absolute; top: 4px; right: 4px; min-width: 28px; height: 28px; border: 0; background: transparent; }
|
||||||
|
.legend ul { list-style: none; margin: 0 24px 6px 0; padding: 0; display: grid; gap: 3px; }
|
||||||
|
.legend p { margin: 0; }
|
||||||
|
.legend-open { position: absolute; left: 12px; bottom: 12px; font-weight: 700; }
|
||||||
|
.stamp { position: absolute; left: 14px; top: 8px; margin: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.swatch { display: inline-block; width: 11px; height: 11px; margin-right: 8px; border-radius: 50%; vertical-align: -1px; }
|
||||||
|
.swatch.network { border-radius: 4px; background: color-mix(in srgb, var(--n0) 14%, transparent); border: 2px solid var(--n0); }
|
||||||
|
.swatch.node { background: var(--card); border: 2.5px solid var(--n1); }
|
||||||
|
.swatch.gateway { width: 14px; height: 14px; border-radius: 4px; background: var(--card); border: 4px solid var(--n2); vertical-align: -2px; }
|
||||||
|
.swatch.flow { width: 20px; height: 0; border-radius: 0; border: 0; border-top: 3.5px dashed var(--text); vertical-align: 3px; }
|
||||||
|
.swatch.none { background: transparent; border: 2px dashed var(--muted); }
|
||||||
|
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* The window onto the map: pan and zoom maths, level of detail, and the pure state
|
||||||
|
machine that turns pointer events into actions (pan, zoom, click, drag). Nothing
|
||||||
|
here touches the DOM, so it can be tested by feeding it events. */
|
||||||
|
(function () {
|
||||||
|
const K_MAX = 4;
|
||||||
|
const K_START_MAX = 1.2;
|
||||||
|
const KEEP = 80;
|
||||||
|
const LOD_AT = [0.4, 0.9];
|
||||||
|
const HYSTERESIS = 0.1;
|
||||||
|
const THRESHOLD = { mouse: 4, pen: 4, touch: 8 };
|
||||||
|
|
||||||
|
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
||||||
|
|
||||||
|
function fitK(bounds, size, pad) {
|
||||||
|
return Math.min(Math.max(1, size.w - 2 * pad) / bounds.w, Math.max(1, size.h - 2 * pad) / bounds.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function minK(bounds, size) {
|
||||||
|
return Math.min(fitK(bounds, size, 0) * 0.5, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The view that shows all of the bounds, centred. */
|
||||||
|
function fitView(bounds, size, pad, maxK) {
|
||||||
|
const k = clamp(fitK(bounds, size, pad), 0.02, maxK === undefined ? K_START_MAX : maxK);
|
||||||
|
return { k: k, tx: (size.w - bounds.w * k) / 2 - bounds.x * k, ty: (size.h - bounds.h * k) / 2 - bounds.y * k };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom keeping the world point under (sx, sy) where it is. */
|
||||||
|
function zoomAt(view, sx, sy, factor, bounds, size) {
|
||||||
|
const k = clamp(view.k * factor, bounds ? minK(bounds, size) : 0.05, K_MAX);
|
||||||
|
return { k: k, tx: sx - ((sx - view.tx) * k) / view.k, ty: sy - ((sy - view.ty) * k) / view.k };
|
||||||
|
}
|
||||||
|
|
||||||
|
function panBy(view, dx, dy) {
|
||||||
|
return { k: view.k, tx: view.tx + dx, ty: view.ty + dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* At least KEEP pixels of the content stay on screen. */
|
||||||
|
function clampView(view, bounds, size) {
|
||||||
|
const keep = Math.min(KEEP, size.w / 2, size.h / 2);
|
||||||
|
const x0 = bounds.x * view.k + view.tx;
|
||||||
|
const x1 = (bounds.x + bounds.w) * view.k + view.tx;
|
||||||
|
const y0 = bounds.y * view.k + view.ty;
|
||||||
|
const y1 = (bounds.y + bounds.h) * view.k + view.ty;
|
||||||
|
let tx = view.tx;
|
||||||
|
let ty = view.ty;
|
||||||
|
if (x1 < keep) tx += keep - x1;
|
||||||
|
else if (x0 > size.w - keep) tx -= x0 - (size.w - keep);
|
||||||
|
if (y1 < keep) ty += keep - y1;
|
||||||
|
else if (y0 > size.h - keep) ty -= y0 - (size.h - keep);
|
||||||
|
return { k: view.k, tx: tx, ty: ty };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The window changed size: refit if the user never moved the view, else keep the centre. */
|
||||||
|
function resizeView(view, from, to, autoFit, bounds, pad) {
|
||||||
|
if (autoFit) return fitView(bounds, to, pad);
|
||||||
|
const cx = (from.w / 2 - view.tx) / view.k;
|
||||||
|
const cy = (from.h / 2 - view.ty) / view.k;
|
||||||
|
return { k: view.k, tx: to.w / 2 - cx * view.k, ty: to.h / 2 - cy * view.k };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Level of detail 0 (shapes), 1 (names), 2 (everything), with hysteresis so it does not flap. */
|
||||||
|
function lodOf(k, previous) {
|
||||||
|
let level = previous === undefined ? (k < LOD_AT[0] ? 0 : k < LOD_AT[1] ? 1 : 2) : previous;
|
||||||
|
for (let guard = 0; guard < 3; guard++) {
|
||||||
|
if (level < 2 && k >= LOD_AT[level] * (1 + HYSTERESIS)) level += 1;
|
||||||
|
else if (level > 0 && k < LOD_AT[level - 1] * (1 - HYSTERESIS)) level -= 1;
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A view that keeps the given world box in sight (for "fit this network"). */
|
||||||
|
function viewFor(box, size, pad) {
|
||||||
|
return fitView(box, size, pad, 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
const easeOut = (t) => 1 - Math.pow(1 - clamp(t, 0, 1), 3);
|
||||||
|
|
||||||
|
function tween(from, to, t) {
|
||||||
|
const e = easeOut(t);
|
||||||
|
const k = Math.exp(Math.log(from.k) + (Math.log(to.k) - Math.log(from.k)) * e);
|
||||||
|
return { k: k, tx: from.tx + (to.tx - from.tx) * e, ty: from.ty + (to.ty - from.ty) * e };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wheelFactor(deltaY, ctrl) {
|
||||||
|
return Math.exp(-deltaY * (ctrl ? 0.01 : 0.0015));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- gestures */
|
||||||
|
|
||||||
|
function initialGesture() {
|
||||||
|
return { mode: "idle", pointers: {}, start: null, last: null, target: null, selected: null, pinch: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinchOf(pointers) {
|
||||||
|
const [a, b] = Object.values(pointers);
|
||||||
|
if (!a || !b) return null;
|
||||||
|
return { d: Math.hypot(a.x - b.x, a.y - b.y), mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One event in, the new state and the actions to perform out. Targets are
|
||||||
|
{kind: "node" | "gateway" | "region-title" | "region" | "background", key}. */
|
||||||
|
function reduceGesture(state, event) {
|
||||||
|
const next = Object.assign({}, state, { pointers: Object.assign({}, state.pointers) });
|
||||||
|
const actions = [];
|
||||||
|
const count = () => Object.keys(next.pointers).length;
|
||||||
|
|
||||||
|
if (event.type === "wheel") {
|
||||||
|
actions.push({ type: "zoom", x: event.x, y: event.y, factor: wheelFactor(event.deltaY, event.ctrl) });
|
||||||
|
} else if (event.type === "dblclick") {
|
||||||
|
actions.push({ type: "fit", target: event.target && event.target.kind === "background" ? null : event.target });
|
||||||
|
} else if (event.type === "down") {
|
||||||
|
next.pointers[event.id] = { x: event.x, y: event.y };
|
||||||
|
if (count() >= 2) {
|
||||||
|
if (next.mode === "drag") actions.push({ type: "drag-cancel", target: next.target });
|
||||||
|
next.mode = "pinch";
|
||||||
|
next.pinch = pinchOf(next.pointers);
|
||||||
|
next.target = null;
|
||||||
|
} else {
|
||||||
|
Object.assign(next, { mode: "pending", start: { x: event.x, y: event.y, type: event.pointerType }, last: { x: event.x, y: event.y }, target: event.target, selected: event.selected === undefined ? null : event.selected });
|
||||||
|
}
|
||||||
|
} else if (event.type === "move" && next.pointers[event.id]) {
|
||||||
|
next.pointers[event.id] = { x: event.x, y: event.y };
|
||||||
|
if (next.mode === "pinch") {
|
||||||
|
const now = pinchOf(next.pointers);
|
||||||
|
if (now && next.pinch) {
|
||||||
|
actions.push({ type: "pan", dx: now.mid.x - next.pinch.mid.x, dy: now.mid.y - next.pinch.mid.y });
|
||||||
|
if (next.pinch.d > 0 && now.d > 0) actions.push({ type: "zoom", x: now.mid.x, y: now.mid.y, factor: now.d / next.pinch.d });
|
||||||
|
}
|
||||||
|
next.pinch = now;
|
||||||
|
} else if (next.mode === "pending") {
|
||||||
|
const limit = THRESHOLD[next.start.type] || THRESHOLD.mouse;
|
||||||
|
if (Math.hypot(event.x - next.start.x, event.y - next.start.y) >= limit) {
|
||||||
|
const target = next.target;
|
||||||
|
const movable = target && (target.kind === "node" || target.kind === "gateway" || target.kind === "region-title");
|
||||||
|
const touch = next.start.type === "touch";
|
||||||
|
if (movable && !(touch && target.key !== next.selected)) {
|
||||||
|
next.mode = "drag";
|
||||||
|
actions.push({ type: "drag-start", target: target, x: next.start.x, y: next.start.y });
|
||||||
|
actions.push({ type: "drag-move", target: target, dx: event.x - next.start.x, dy: event.y - next.start.y, x: event.x, y: event.y });
|
||||||
|
} else {
|
||||||
|
next.mode = "pan";
|
||||||
|
actions.push({ type: "pan", dx: event.x - next.start.x, dy: event.y - next.start.y });
|
||||||
|
}
|
||||||
|
next.last = { x: event.x, y: event.y };
|
||||||
|
}
|
||||||
|
} else if (next.mode === "pan") {
|
||||||
|
actions.push({ type: "pan", dx: event.x - next.last.x, dy: event.y - next.last.y });
|
||||||
|
next.last = { x: event.x, y: event.y };
|
||||||
|
} else if (next.mode === "drag") {
|
||||||
|
actions.push({ type: "drag-move", target: next.target, dx: event.x - next.last.x, dy: event.y - next.last.y, x: event.x, y: event.y });
|
||||||
|
next.last = { x: event.x, y: event.y };
|
||||||
|
}
|
||||||
|
} else if (event.type === "up" || event.type === "cancel") {
|
||||||
|
delete next.pointers[event.id];
|
||||||
|
const cancelled = event.type === "cancel";
|
||||||
|
if (next.mode === "pending") {
|
||||||
|
if (!cancelled) actions.push({ type: "click", target: next.target, x: event.x, y: event.y });
|
||||||
|
next.mode = "idle";
|
||||||
|
} else if (next.mode === "drag") {
|
||||||
|
actions.push({ type: cancelled ? "drag-cancel" : "drag-end", target: next.target, x: event.x, y: event.y });
|
||||||
|
next.mode = "idle";
|
||||||
|
} else if (next.mode === "pan") {
|
||||||
|
next.mode = "idle";
|
||||||
|
} else if (next.mode === "pinch") {
|
||||||
|
next.mode = count() === 0 ? "idle" : "settle";
|
||||||
|
next.pinch = null;
|
||||||
|
} else if (next.mode === "settle" && count() === 0) {
|
||||||
|
next.mode = "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { state: next, actions: actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||||
|
const KEY_ACTIONS = {
|
||||||
|
"+": { type: "zoom-step", factor: 1.25 },
|
||||||
|
"=": { type: "zoom-step", factor: 1.25 },
|
||||||
|
"-": { type: "zoom-step", factor: 0.8 },
|
||||||
|
_: { type: "zoom-step", factor: 0.8 },
|
||||||
|
"0": { type: "fit" },
|
||||||
|
f: { type: "fit-selected" },
|
||||||
|
N: { type: "next-network", step: -1 },
|
||||||
|
Escape: { type: "clear" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Keyboard: what a key press asks for. Arrows with Alt nudge the focused thing. */
|
||||||
|
function keyAction(event, context) {
|
||||||
|
if (event.ctrlKey || event.metaKey) return null;
|
||||||
|
if (ARROWS[event.key]) {
|
||||||
|
const [dx, dy] = ARROWS[event.key];
|
||||||
|
if (event.altKey) return { type: "nudge", dx: dx, dy: dy };
|
||||||
|
return context && context.onNode ? { type: "move-focus", dx: dx, dy: dy } : { type: "pan", dx: -dx * 80, dy: -dy * 80 };
|
||||||
|
}
|
||||||
|
if (event.key === "n") return { type: "next-network", step: event.shiftKey ? -1 : 1 };
|
||||||
|
if (event.key === "Enter" || event.key === " ") return context && context.onNode ? { type: "activate" } : null;
|
||||||
|
return KEY_ACTIONS[event.key] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { K_MAX, fitView, zoomAt, panBy, clampView, resizeView, lodOf, viewFor, tween, easeOut, wheelFactor, minK, initialGesture, reduceGesture, keyAction };
|
||||||
|
if (typeof module !== "undefined") module.exports = api;
|
||||||
|
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user