@takodotid/circuit 0.34.2 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -14
- package/dist/chunks/index-20kcjxjg.js +174 -0
- package/dist/chunks/index-2r4bbhac.js +977 -0
- package/dist/chunks/index-n7w3tg5y.js +149 -0
- package/dist/chunks/new-w3zys5y4.js +172 -0
- package/dist/chunks/server-gzsf5me5.js +259 -0
- package/dist/cli/index.js +11347 -9018
- package/dist/helpers/index.js +594 -0
- package/dist/index.js +9 -28
- package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
- package/dist/types/core/address.d.ts +44 -0
- package/dist/types/core/cabling.d.ts +41 -0
- package/dist/types/core/cross-checks.d.ts +28 -0
- package/dist/types/core/define.d.ts +25 -3
- package/dist/types/core/project-files.d.ts +12 -0
- package/dist/types/core/schema-docs.d.ts +13 -4
- package/dist/types/core/secrets.d.ts +62 -21
- package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
- package/dist/types/core/utils.d.ts +8 -0
- package/dist/types/core/validator.d.ts +19 -0
- package/dist/types/helpers/bgp.d.ts +97 -0
- package/dist/types/helpers/checks.d.ts +32 -0
- package/dist/types/helpers/exchange.d.ts +49 -0
- package/dist/types/helpers/filters.d.ts +51 -0
- package/dist/types/helpers/index.d.ts +10 -0
- package/dist/types/helpers/route-server.d.ts +52 -0
- package/dist/types/index.d.ts +5 -3
- package/dist/types/integrations/librenms.d.ts +60 -0
- package/dist/types/integrations/netbox.d.ts +89 -0
- package/dist/types/integrations/peeringdb.d.ts +24 -0
- package/dist/types/integrations/prometheus.d.ts +24 -0
- package/dist/types/integrations/registries.d.ts +8 -0
- package/dist/types/platforms/bgp-wait.d.ts +18 -0
- package/dist/types/platforms/catalog.d.ts +109 -0
- package/dist/types/platforms/derive.d.ts +17 -0
- package/dist/types/platforms/fortios/firewall.d.ts +45 -0
- package/dist/types/platforms/fortios/index.d.ts +134 -0
- package/dist/types/platforms/fortios/names.d.ts +52 -0
- package/dist/types/platforms/fortios/plan.d.ts +104 -0
- package/dist/types/platforms/fortios/render.d.ts +75 -0
- package/dist/types/platforms/fortios/routing.d.ts +56 -0
- package/dist/types/platforms/fortios/tree.d.ts +37 -0
- package/dist/types/platforms/index.d.ts +13 -0
- package/dist/types/platforms/junos/bgp.d.ts +44 -0
- package/dist/types/platforms/junos/index.d.ts +114 -0
- package/dist/types/platforms/junos/render.d.ts +154 -0
- package/dist/types/platforms/line-diff.d.ts +67 -0
- package/dist/types/platforms/platform.d.ts +120 -0
- package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
- package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
- package/dist/types/platforms/routeros/export.d.ts +59 -0
- package/dist/types/platforms/routeros/index.d.ts +96 -0
- package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
- package/dist/types/platforms/routeros/plan.d.ts +54 -0
- package/dist/types/platforms/routeros/render.d.ts +240 -0
- package/dist/types/platforms/vrp/index.d.ts +83 -0
- package/dist/types/platforms/vrp/render.d.ts +96 -0
- package/dist/types/schema/common.d.ts +12 -6
- package/dist/types/schema/index.d.ts +1 -0
- package/dist/types/schema/interfaces.d.ts +14 -6
- package/dist/types/schema/policy.d.ts +18 -10
- package/dist/types/schema/routing.d.ts +14 -1
- package/dist/types/schema/system.d.ts +16 -6
- package/dist/types/schema/time-zones.d.ts +2 -0
- package/dist/types/transport/console.d.ts +79 -23
- package/dist/types/transport/path-check.d.ts +32 -0
- package/dist/types/transport/ssh.d.ts +17 -15
- package/package.json +17 -11
- package/src/cli/graph-format.ts +91 -0
- package/src/cli/index.ts +1074 -484
- package/src/cli/new.ts +208 -175
- package/src/cli/rename.ts +97 -91
- package/src/core/address-space.ts +320 -0
- package/src/core/address.ts +202 -0
- package/src/core/cabling.ts +154 -0
- package/src/core/cross-checks.ts +364 -0
- package/src/core/define.ts +26 -3
- package/src/core/project-files.ts +35 -0
- package/src/core/schema-docs.ts +138 -130
- package/src/core/secrets.ts +170 -76
- package/src/core/topology.ts +656 -0
- package/src/core/utils.ts +37 -0
- package/src/core/validator.ts +407 -0
- package/src/helpers/bgp.ts +301 -0
- package/src/helpers/checks.ts +178 -0
- package/src/helpers/exchange.ts +117 -0
- package/src/helpers/filters.ts +107 -0
- package/src/helpers/index.ts +12 -0
- package/src/helpers/route-server.ts +212 -0
- package/src/index.ts +6 -3
- package/src/integrations/librenms.ts +291 -0
- package/src/integrations/netbox.ts +620 -0
- package/src/integrations/peeringdb.ts +173 -0
- package/src/integrations/prometheus.ts +90 -0
- package/src/integrations/registries.ts +25 -0
- package/src/platforms/bgp-wait.ts +41 -0
- package/src/platforms/catalog.ts +69 -0
- package/src/platforms/derive.ts +117 -0
- package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
- package/src/platforms/fortios/firewall.ts +377 -0
- package/src/platforms/fortios/index.ts +1060 -0
- package/src/platforms/fortios/names.ts +130 -0
- package/src/platforms/fortios/plan.ts +929 -0
- package/src/platforms/fortios/render.ts +779 -0
- package/src/platforms/fortios/routing.ts +476 -0
- package/src/platforms/fortios/tree.ts +129 -0
- package/src/platforms/index.ts +36 -0
- package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
- package/src/platforms/junos/bgp.ts +293 -0
- package/src/platforms/junos/index.ts +1006 -0
- package/src/platforms/junos/render.ts +967 -0
- package/src/platforms/line-diff.ts +244 -0
- package/src/platforms/platform.ts +122 -0
- package/src/platforms/raisecom-ros/TRAPS.md +40 -0
- package/src/platforms/raisecom-ros/index.ts +731 -0
- package/src/platforms/raisecom-ros/render.ts +328 -0
- package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
- package/src/platforms/routeros/export.ts +342 -0
- package/src/platforms/routeros/index.ts +636 -0
- package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
- package/src/platforms/routeros/plan.ts +444 -0
- package/src/platforms/routeros/render.ts +1483 -0
- package/src/platforms/vrp/TRAPS.md +45 -0
- package/src/platforms/vrp/index.ts +652 -0
- package/src/platforms/vrp/render.ts +615 -0
- package/src/schema/common.ts +15 -6
- package/src/schema/index.ts +1 -0
- package/src/schema/interfaces.ts +15 -5
- package/src/schema/policy.ts +22 -10
- package/src/schema/routing.ts +16 -1
- package/src/schema/system.ts +16 -6
- package/src/schema/time-zones.ts +482 -0
- package/src/studio/data.ts +172 -168
- package/src/studio/git.ts +95 -90
- package/src/studio/public/app.js +161 -150
- package/src/studio/public/compare.js +219 -212
- package/src/studio/public/diff.js +77 -75
- package/src/studio/public/dom.js +118 -108
- package/src/studio/public/evaluate.js +584 -545
- package/src/studio/public/export.js +120 -118
- package/src/studio/public/graph.js +1343 -1266
- package/src/studio/public/net.js +115 -109
- package/src/studio/public/tables.js +1135 -1083
- package/src/studio/public/tools.js +561 -529
- package/src/studio/server.ts +202 -196
- package/src/transport/console.ts +363 -271
- package/src/transport/path-check.ts +91 -0
- package/src/transport/ssh.ts +98 -94
- package/templates/colocation/checks.ts +11 -6
- package/templates/colocation/edge-01.ts +9 -9
- package/templates/colocation/routing.ts +2 -2
- package/templates/colocation/tenancy.ts +3 -3
- package/templates/colocation/tor-01.ts +3 -3
- package/templates/edge-router/checks.ts +4 -4
- package/templates/edge-router/edge-01.ts +5 -5
- package/templates/edge-router/routing.ts +2 -2
- package/templates/project/AGENTS.md +5 -1
- package/templates/single-site/checks.ts +2 -2
- package/dist/chunks/index-47debt16.js +0 -956
- package/dist/chunks/index-v8zgr7wm.js +0 -128
- package/dist/chunks/new-ny89s7y7.js +0 -166
- package/dist/chunks/server-tjbb3rqc.js +0 -251
- package/dist/presets/index.js +0 -394
- package/dist/types/adapters/devices/catalog.d.ts +0 -86
- package/dist/types/adapters/devices/derive.d.ts +0 -7
- package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
- package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
- package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
- package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
- package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
- package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
- package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
- package/dist/types/adapters/devices/index.d.ts +0 -3
- package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
- package/dist/types/adapters/devices/junos/index.d.ts +0 -42
- package/dist/types/adapters/devices/junos/render.d.ts +0 -52
- package/dist/types/adapters/devices/lines.d.ts +0 -56
- package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
- package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
- package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
- package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
- package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
- package/dist/types/adapters/devices/types.d.ts +0 -40
- package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
- package/dist/types/adapters/registries/index.d.ts +0 -3
- package/dist/types/adapters/registries/librenms.d.ts +0 -15
- package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
- package/dist/types/adapters/registries/prometheus.d.ts +0 -16
- package/dist/types/core/across.d.ts +0 -4
- package/dist/types/core/addr.d.ts +0 -34
- package/dist/types/core/files.d.ts +0 -6
- package/dist/types/core/merge.d.ts +0 -4
- package/dist/types/core/time.d.ts +0 -2
- package/dist/types/core/validate.d.ts +0 -2
- package/dist/types/presets/index.d.ts +0 -183
- package/src/adapters/devices/catalog.ts +0 -41
- package/src/adapters/devices/derive.ts +0 -59
- package/src/adapters/devices/fortios/firewall.ts +0 -362
- package/src/adapters/devices/fortios/index.ts +0 -923
- package/src/adapters/devices/fortios/names.ts +0 -115
- package/src/adapters/devices/fortios/plan.ts +0 -888
- package/src/adapters/devices/fortios/render.ts +0 -768
- package/src/adapters/devices/fortios/routing.ts +0 -444
- package/src/adapters/devices/fortios/tree.ts +0 -121
- package/src/adapters/devices/index.ts +0 -9
- package/src/adapters/devices/junos/bgp.ts +0 -269
- package/src/adapters/devices/junos/index.ts +0 -862
- package/src/adapters/devices/junos/render.ts +0 -872
- package/src/adapters/devices/lines.ts +0 -212
- package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -25
- package/src/adapters/devices/raisecom-ros/index.ts +0 -693
- package/src/adapters/devices/routeros/export.ts +0 -321
- package/src/adapters/devices/routeros/index.ts +0 -346
- package/src/adapters/devices/routeros/plan.ts +0 -398
- package/src/adapters/devices/routeros/render.ts +0 -1490
- package/src/adapters/devices/types.ts +0 -38
- package/src/adapters/devices/vrp/TRAPS.md +0 -26
- package/src/adapters/devices/vrp/index.ts +0 -865
- package/src/adapters/registries/index.ts +0 -19
- package/src/adapters/registries/librenms.ts +0 -92
- package/src/adapters/registries/peeringdb.ts +0 -169
- package/src/adapters/registries/prometheus.ts +0 -71
- package/src/cli/graph.ts +0 -89
- package/src/core/across.ts +0 -350
- package/src/core/addr.ts +0 -174
- package/src/core/addresses.ts +0 -316
- package/src/core/files.ts +0 -32
- package/src/core/graph.ts +0 -640
- package/src/core/merge.ts +0 -19
- package/src/core/time.ts +0 -15
- package/src/core/validate.ts +0 -348
- package/src/presets/index.ts +0 -579
- /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
- /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
|
@@ -2,1463 +2,1540 @@
|
|
|
2
2
|
/// <reference lib="dom" />
|
|
3
3
|
// The graph view: a force layout of every node, drawn one layer at a time, with panning, zooming, dragging, and a side panel for what is selected.
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
/** @typedef {import("../../core/
|
|
10
|
-
/** @typedef {import("../../core/
|
|
11
|
-
/** @typedef {import("../../core/
|
|
12
|
-
/** @typedef {import("../../core/
|
|
13
|
-
/** @typedef {import("../../core/
|
|
5
|
+
import { Dom } from "./dom.js";
|
|
6
|
+
import { Evaluate } from "./evaluate.js";
|
|
7
|
+
import { Export } from "./export.js";
|
|
8
|
+
|
|
9
|
+
/** @typedef {import("../../core/topology").GraphNode} GraphNode */
|
|
10
|
+
/** @typedef {import("../../core/topology").GraphEdge} GraphEdge */
|
|
11
|
+
/** @typedef {import("../../core/topology").Layer} Layer */
|
|
12
|
+
/** @typedef {import("../../core/topology").CarriedVlan} CarriedVlan */
|
|
13
|
+
/** @typedef {import("../../core/topology").Annotated} Annotated */
|
|
14
14
|
/** @typedef {{ x: number, y: number }} Point */
|
|
15
15
|
/** An edge as drawn: one of the graph's, or several between the same two nodes drawn as one, or one a comparison shows is gone. @typedef {GraphEdge & { members?: DrawnEdge[], gone?: true }} DrawnEdge */
|
|
16
16
|
/** @typedef {{ node: string } | { edge: DrawnEdge } | undefined} Selection */
|
|
17
|
+
/** @typedef {{ x: number, y: number, k: number }} View */
|
|
18
|
+
|
|
19
|
+
export class Graph {
|
|
20
|
+
/** @type {Record<Layer, string>} */
|
|
21
|
+
static LAYER_NAMES = {
|
|
22
|
+
l1: "Cables",
|
|
23
|
+
l2: "VLANs",
|
|
24
|
+
l3: "Addresses",
|
|
25
|
+
bgp: "BGP",
|
|
26
|
+
ospf: "OSPF",
|
|
27
|
+
tunnels: "Tunnels",
|
|
28
|
+
};
|
|
17
29
|
|
|
18
|
-
/** @type {Record<Layer, string>} */
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/** @type {Record<Layer, string>} */
|
|
29
|
-
const LAYER_HELP = {
|
|
30
|
-
l1: "Each cable a port's link names. A LAG's cables are one line.",
|
|
31
|
-
l2: "The VLANs each cable carries. Pick a VLAN to light up every cable that carries it.",
|
|
32
|
-
l3: "Prefixes two or more devices hold an address in, and point-to-point addresses. Loopbacks and prefixes only one device holds are left out.",
|
|
33
|
-
bgp: "BGP sessions: between our devices, matched by the address each names, and to every AS outside.",
|
|
34
|
-
ospf: "OSPF adjacencies: devices with OSPF in one area on one prefix, neither side passive.",
|
|
35
|
-
tunnels: "GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers.",
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export const LAYERS = /** @type {Layer[]} */ (Object.keys(LAYER_NAMES));
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @param {string | undefined} name
|
|
42
|
-
* @returns {name is Layer}
|
|
43
|
-
*/
|
|
44
|
-
export const isLayer = (name) => LAYERS.includes(/** @type {Layer} */ (name));
|
|
45
|
-
|
|
46
|
-
// The parts of index.html this view draws into.
|
|
47
|
-
const graphView = byId("graph-view");
|
|
48
|
-
const layersNav = byId("layers");
|
|
49
|
-
const svg = svgById("canvas");
|
|
50
|
-
const viewport = svgById("viewport");
|
|
51
|
-
const edgeLayer = svgById("edges");
|
|
52
|
-
const nodeLayer = svgById("nodes");
|
|
53
|
-
const panel = byId("panel");
|
|
54
|
-
const presentButton = byId("present");
|
|
55
|
-
const caption = byId("present-caption");
|
|
56
|
-
|
|
57
|
-
// State.
|
|
58
|
-
/** @type {Annotated | undefined} */
|
|
59
|
-
let data;
|
|
60
|
-
/** @type {Layer} */
|
|
61
|
-
let layer = LAYERS.find((name) => name === store.get("layer")) ?? "l1";
|
|
62
|
-
/** The graph has been drawn while visible at least once; until then it is fitted when shown. */
|
|
63
|
-
let drawn = false;
|
|
64
|
-
/** @type {Selection} */
|
|
65
|
-
let selection;
|
|
66
|
-
/** @type {string | undefined} */
|
|
67
|
-
let hovered;
|
|
68
|
-
/** @type {number | undefined} */
|
|
69
|
-
let vlanFocus;
|
|
70
|
-
/** A VLAN to light up once the VLAN layer is shown, asked for from the tables. @type {number | undefined} */
|
|
71
|
-
let pendingVlan;
|
|
72
|
-
/** Cables to light up as one path, asked for from the tools, and those lit now. @type {GraphEdge[] | undefined} */
|
|
73
|
-
let pendingPath;
|
|
74
|
-
/** @type {Set<string> | undefined} */
|
|
75
|
-
let pathFocus;
|
|
76
|
-
/** A comparison with another commit: its name, the edges it adds, and those gone. @type {{ name: string, added: Set<string>, removed: GraphEdge[] } | undefined} */
|
|
77
|
-
let comparison;
|
|
78
|
-
/** Presenting: the network on a big screen, going through every layer and lighting its busiest devices in turn. Kept, so a screen that reloads stays on it. */
|
|
79
|
-
let presenting = store.get("present") === true;
|
|
80
|
-
/** The device lit while presenting; none while the whole layer is shown. @type {string | undefined} */
|
|
81
|
-
let touring;
|
|
82
|
-
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
83
|
-
let tour;
|
|
84
|
-
|
|
85
|
-
/** @param {GraphEdge} edge */
|
|
86
|
-
const edgeKey = (edge) => `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
|
|
87
|
-
let view = { x: 0, y: 0, k: 1 };
|
|
88
|
-
/** Where the reader left each layer's view, as each layer is a drawing of its own. @type {Map<Layer, typeof view>} */
|
|
89
|
-
const views = new Map();
|
|
90
|
-
|
|
91
|
-
/** @typedef {Record<string, [number, number]>} SavedPlaces */
|
|
92
|
-
|
|
93
|
-
const saved = /** @type {Partial<Record<Layer, SavedPlaces>>} */ (store.get("layouts") ?? {});
|
|
94
|
-
// The one layout an earlier Studio kept for every layer carries on as the cables layer's.
|
|
95
|
-
const earlier = /** @type {SavedPlaces | undefined} */ (store.get("positions"));
|
|
96
|
-
|
|
97
|
-
/** Where each node is on each layer: every layer is laid out for its own lines, and keeps where its devices were moved. @type {Map<Layer, Map<string, Point>>} */
|
|
98
|
-
const layouts = new Map(
|
|
99
|
-
LAYERS.map((name) => {
|
|
100
|
-
const places = saved[name] ?? (name === "l1" ? earlier : undefined) ?? {};
|
|
101
|
-
return [name, new Map(Object.entries(places).map(([id, [x, y]]) => [id, { x, y }]))];
|
|
102
|
-
})
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
/** @param {Layer} name */
|
|
106
|
-
const layoutOf = (name) => /** @type {Map<string, Point>} */ (layouts.get(name));
|
|
107
|
-
|
|
108
|
-
/** Where nodes are drawn now: the layer's own places, or, while the layer changes, the places on the way there. */
|
|
109
|
-
let positions = layoutOf(layer);
|
|
110
|
-
|
|
111
|
-
/** @type {Map<string, SVGGElement>} */
|
|
112
|
-
const nodeElements = new Map();
|
|
113
|
-
/** @type {{ edge: DrawnEdge, group: SVGGElement, path: SVGPathElement, hit: SVGPathElement, label: SVGTextElement, offset: number }[]} */
|
|
114
|
-
const edgeElements = [];
|
|
115
|
-
|
|
116
|
-
function savePositions() {
|
|
117
|
-
/** @param {Map<string, Point>} places */
|
|
118
|
-
const rounded = (places) => Object.fromEntries([...places].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]]));
|
|
119
|
-
store.set("layouts", Object.fromEntries([...layouts].map(([name, places]) => [name, rounded(places)])));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* A number from 0 to 1 that is always the same for the same text, so a layout comes out the same each time.
|
|
124
|
-
*
|
|
125
|
-
* @param {string} text
|
|
126
|
-
*/
|
|
127
|
-
function hash(text) {
|
|
128
|
-
let value = 2166136261;
|
|
129
|
-
for (const char of text) value = Math.imul(value ^ char.charCodeAt(0), 16777619);
|
|
130
|
-
return (value >>> 0) / 4294967296;
|
|
131
|
-
}
|
|
30
|
+
/** @type {Record<Layer, string>} */
|
|
31
|
+
static #LAYER_HELP = {
|
|
32
|
+
l1: "Each cable a port's link names. A LAG's cables are one line.",
|
|
33
|
+
l2: "The VLANs each cable carries. Pick a VLAN to light up every cable that carries it.",
|
|
34
|
+
l3: "Prefixes two or more devices hold an address in, and point-to-point addresses. Loopbacks and prefixes only one device holds are left out.",
|
|
35
|
+
bgp: "BGP sessions: between our devices, matched by the address each names, and to every AS outside.",
|
|
36
|
+
ospf: "OSPF adjacencies: devices with OSPF in one area on one prefix, neither side passive.",
|
|
37
|
+
tunnels: "GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers.",
|
|
38
|
+
};
|
|
132
39
|
|
|
133
|
-
/**
|
|
134
|
-
* Place one layer's nodes with a force layout: every node pushes every other away, and each of the layer's edges pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
|
|
135
|
-
* A node new to a layer starts where it is on the cables layer, so each layer is laid out for its own lines yet still looks like the same network.
|
|
136
|
-
*
|
|
137
|
-
* @param {GraphNode[]} nodes
|
|
138
|
-
* @param {GraphEdge[]} edges
|
|
139
|
-
* @param {boolean} fresh
|
|
140
|
-
* @param {Map<string, Point>} places the layer's places, filled in
|
|
141
|
-
* @param {Map<string, Point>} [seed] places to start new nodes from
|
|
142
|
-
*/
|
|
143
|
-
function layout(nodes, edges, fresh, places, seed) {
|
|
144
|
-
if (fresh) places.clear();
|
|
40
|
+
static LAYERS = /** @type {Layer[]} */ (Object.keys(Graph.LAYER_NAMES));
|
|
145
41
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const neighbors = new Map();
|
|
153
|
-
|
|
154
|
-
for (const edge of edges) {
|
|
155
|
-
if (edge.from === edge.to) continue;
|
|
156
|
-
if (!neighbors.has(edge.from)) neighbors.set(edge.from, new Set());
|
|
157
|
-
if (!neighbors.has(edge.to)) neighbors.set(edge.to, new Set());
|
|
158
|
-
neighbors.get(edge.from)?.add(edge.to);
|
|
159
|
-
neighbors.get(edge.to)?.add(edge.from);
|
|
42
|
+
/**
|
|
43
|
+
* @param {string | undefined} name
|
|
44
|
+
* @returns {name is Layer}
|
|
45
|
+
*/
|
|
46
|
+
static isLayer(name) {
|
|
47
|
+
return Graph.LAYERS.includes(/** @type {Layer} */ (name));
|
|
160
48
|
}
|
|
161
49
|
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
50
|
+
// The parts of index.html this view draws into.
|
|
51
|
+
static #graphView = Dom.byId("graph-view");
|
|
52
|
+
static #layersNav = Dom.byId("layers");
|
|
53
|
+
static #svg = Dom.svgById("canvas");
|
|
54
|
+
static #viewport = Dom.svgById("viewport");
|
|
55
|
+
static #edgeLayer = Dom.svgById("edges");
|
|
56
|
+
static #nodeLayer = Dom.svgById("nodes");
|
|
57
|
+
static #panel = Dom.byId("panel");
|
|
58
|
+
static #presentButton = Dom.byId("present");
|
|
59
|
+
static #caption = Dom.byId("present-caption");
|
|
60
|
+
|
|
61
|
+
// State.
|
|
62
|
+
/** @type {Annotated | undefined} */
|
|
63
|
+
static #data;
|
|
64
|
+
/** @type {Layer} */
|
|
65
|
+
static #layer = Graph.LAYERS.find((name) => name === Dom.store.get("layer")) ?? "l1";
|
|
66
|
+
/** The graph has been drawn while visible at least once; until then it is fitted when shown. */
|
|
67
|
+
static #drawn = false;
|
|
68
|
+
/** @type {Selection} */
|
|
69
|
+
static #selection;
|
|
70
|
+
/** @type {string | undefined} */
|
|
71
|
+
static #hovered;
|
|
72
|
+
/** @type {number | undefined} */
|
|
73
|
+
static #vlanFocus;
|
|
74
|
+
/** A VLAN to light up once the VLAN layer is shown, asked for from the tables. @type {number | undefined} */
|
|
75
|
+
static #pendingVlan;
|
|
76
|
+
/** Cables to light up as one path, asked for from the tools, and those lit now. @type {GraphEdge[] | undefined} */
|
|
77
|
+
static #pendingPath;
|
|
78
|
+
/** @type {Set<string> | undefined} */
|
|
79
|
+
static #pathFocus;
|
|
80
|
+
/** A comparison with another commit: its name, the edges it adds, and those gone. @type {{ name: string, added: Set<string>, removed: GraphEdge[] } | undefined} */
|
|
81
|
+
static #comparison;
|
|
82
|
+
/** Presenting: the network on a big screen, going through every layer and lighting its busiest devices in turn. Kept, so a screen that reloads stays on it. */
|
|
83
|
+
static #presenting = Dom.store.get("present") === true;
|
|
84
|
+
/** The device lit while presenting; none while the whole layer is shown. @type {string | undefined} */
|
|
85
|
+
static #touring;
|
|
86
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
87
|
+
static #tour;
|
|
165
88
|
|
|
166
|
-
|
|
167
|
-
|
|
89
|
+
/** @param {GraphEdge} edge */
|
|
90
|
+
static #edgeKey(edge) {
|
|
91
|
+
return `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
|
|
92
|
+
}
|
|
168
93
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
let x = left;
|
|
173
|
-
let y = (placed.length ? Math.max(...placed.map((point) => point.y)) : 0) + 130;
|
|
94
|
+
static #view = { x: 0, y: 0, k: 1 };
|
|
95
|
+
/** Where the reader left each layer's view, as each layer is a drawing of its own. @type {Map<Layer, View>} */
|
|
96
|
+
static #views = new Map();
|
|
174
97
|
|
|
175
|
-
|
|
176
|
-
const half = halfWidth(node);
|
|
177
|
-
if (x > left && x + half * 2 > right) {
|
|
178
|
-
x = left;
|
|
179
|
-
y += 96;
|
|
180
|
-
}
|
|
98
|
+
/** @typedef {Record<string, [number, number]>} SavedPlaces */
|
|
181
99
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
100
|
+
static #saved = /** @type {Partial<Record<Layer, SavedPlaces>>} */ (Dom.store.get("layouts") ?? {});
|
|
101
|
+
// The one layout an earlier Studio kept for every layer carries on as the cables layer's.
|
|
102
|
+
static #earlier = /** @type {SavedPlaces | undefined} */ (Dom.store.get("positions"));
|
|
185
103
|
|
|
186
|
-
|
|
104
|
+
/** Where each node is on each layer: every layer is laid out for its own lines, and keeps where its devices were moved. @type {Map<Layer, Map<string, Point>>} */
|
|
105
|
+
static #layouts = new Map(
|
|
106
|
+
Graph.LAYERS.map((name) => {
|
|
107
|
+
const places = Graph.#saved[name] ?? (name === "l1" ? Graph.#earlier : undefined) ?? {};
|
|
108
|
+
return [name, new Map(Object.entries(places).map(([id, [x, y]]) => [id, { x, y }]))];
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/** @param {Layer} name */
|
|
113
|
+
static #layoutOf(name) {
|
|
114
|
+
return /** @type {Map<string, Point>} */ (Graph.#layouts.get(name));
|
|
187
115
|
}
|
|
188
116
|
|
|
189
|
-
nodes
|
|
117
|
+
/** Where nodes are drawn now: the layer's own places, or, while the layer changes, the places on the way there. */
|
|
118
|
+
static #positions = Graph.#layoutOf(Graph.#layer);
|
|
190
119
|
|
|
191
|
-
|
|
192
|
-
|
|
120
|
+
/** @type {Map<string, SVGGElement>} */
|
|
121
|
+
static #nodeElements = new Map();
|
|
122
|
+
/** @type {{ edge: DrawnEdge, group: SVGGElement, path: SVGPathElement, hit: SVGPathElement, label: SVGTextElement, offset: number }[]} */
|
|
123
|
+
static #edgeElements = [];
|
|
193
124
|
|
|
194
|
-
|
|
195
|
-
|
|
125
|
+
static #savePositions() {
|
|
126
|
+
/** @param {Map<string, Point>} places */
|
|
127
|
+
const rounded = (places) => Object.fromEntries([...places].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]]));
|
|
128
|
+
Dom.store.set("layouts", Object.fromEntries([...Graph.#layouts].map(([name, places]) => [name, rounded(places)])));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A number from 0 to 1 that is always the same for the same text, so a layout comes out the same each time.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text
|
|
135
|
+
*/
|
|
136
|
+
static #hash(text) {
|
|
137
|
+
let value = 2166136261;
|
|
138
|
+
for (const char of text) value = Math.imul(value ^ char.charCodeAt(0), 16777619);
|
|
139
|
+
return (value >>> 0) / 4294967296;
|
|
140
|
+
}
|
|
196
141
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Place one layer's nodes with a force layout: every node pushes every other away, and each of the layer's edges pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
|
|
144
|
+
* A node new to a layer starts where it is on the cables layer, so each layer is laid out for its own lines yet still looks like the same network.
|
|
145
|
+
*
|
|
146
|
+
* @param {GraphNode[]} nodes
|
|
147
|
+
* @param {GraphEdge[]} edges
|
|
148
|
+
* @param {boolean} fresh
|
|
149
|
+
* @param {Map<string, Point>} places the layer's places, filled in
|
|
150
|
+
* @param {Map<string, Point>} [seed] places to start new nodes from
|
|
151
|
+
*/
|
|
152
|
+
static #layout(nodes, edges, fresh, places, seed) {
|
|
153
|
+
if (fresh) places.clear();
|
|
154
|
+
|
|
155
|
+
/** @param {GraphNode} node */
|
|
156
|
+
const halfWidth = (node) =>
|
|
157
|
+
node.kind === "device"
|
|
158
|
+
? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60
|
|
159
|
+
: node.label.length * 4 + 30;
|
|
160
|
+
|
|
161
|
+
const fixed = new Set(nodes.filter((node) => places.has(node.id)).map((node) => node.id));
|
|
162
|
+
/** @type {Map<string, Set<string>>} */
|
|
163
|
+
const neighbors = new Map();
|
|
164
|
+
|
|
165
|
+
for (const edge of edges) {
|
|
166
|
+
if (edge.from === edge.to) continue;
|
|
167
|
+
if (!neighbors.has(edge.from)) neighbors.set(edge.from, new Set());
|
|
168
|
+
if (!neighbors.has(edge.to)) neighbors.set(edge.to, new Set());
|
|
169
|
+
neighbors.get(edge.from)?.add(edge.to);
|
|
170
|
+
neighbors.get(edge.to)?.add(edge.from);
|
|
201
171
|
}
|
|
202
172
|
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
const origin = placed ?? { x: 0, y: 0 };
|
|
173
|
+
// A device with no line on this layer is set in a row under the drawing, rather than pushed out to its edges by the rest.
|
|
174
|
+
const loose = edges.length ? nodes.filter((node) => !neighbors.has(node.id) && !places.has(node.id)) : [];
|
|
175
|
+
const linked = nodes.filter((node) => !loose.includes(node));
|
|
207
176
|
|
|
208
|
-
|
|
209
|
-
|
|
177
|
+
function park() {
|
|
178
|
+
if (!loose.length) return;
|
|
210
179
|
|
|
211
|
-
|
|
212
|
-
|
|
180
|
+
const placed = linked.flatMap((node) => places.get(node.id) ?? []);
|
|
181
|
+
const left = placed.length ? Math.min(...placed.map((point) => point.x)) : 0;
|
|
182
|
+
const right = left + Math.max(placed.length ? Math.max(...placed.map((point) => point.x)) - left : 0, 900);
|
|
183
|
+
let x = left;
|
|
184
|
+
let y = (placed.length ? Math.max(...placed.map((point) => point.y)) : 0) + 130;
|
|
213
185
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
186
|
+
for (const node of [...loose].sort((a, b) => a.label.localeCompare(b.label))) {
|
|
187
|
+
const half = halfWidth(node);
|
|
188
|
+
if (x > left && x + half * 2 > right) {
|
|
189
|
+
x = left;
|
|
190
|
+
y += 96;
|
|
191
|
+
}
|
|
217
192
|
|
|
218
|
-
|
|
219
|
-
|
|
193
|
+
places.set(node.id, { x: x + half, y });
|
|
194
|
+
x += half * 2 + 24;
|
|
195
|
+
}
|
|
220
196
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const push = new Map(nodes.map((node) => [node.id, { x: 0, y: 0 }]));
|
|
224
|
-
/** @param {string} id */
|
|
225
|
-
const pushOf = (id) => /** @type {Point} */ (push.get(id));
|
|
197
|
+
Graph.#savePositions();
|
|
198
|
+
}
|
|
226
199
|
|
|
227
|
-
|
|
228
|
-
for (const b of nodes.slice(index + 1)) {
|
|
229
|
-
let dx = at(a.id).x - at(b.id).x;
|
|
230
|
-
let dy = at(a.id).y - at(b.id).y;
|
|
200
|
+
nodes = linked;
|
|
231
201
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
dx = hash(a.id + b.id) - 0.5;
|
|
235
|
-
dy = hash(b.id + a.id) - 0.5;
|
|
236
|
-
}
|
|
202
|
+
// A new node starts beside a neighbor already placed, or on a circle.
|
|
203
|
+
const radius = 60 * Math.sqrt(nodes.length + 1);
|
|
237
204
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (distance > ideal * 3) continue;
|
|
205
|
+
for (const node of nodes) {
|
|
206
|
+
if (places.has(node.id)) continue;
|
|
241
207
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
pushOf(b.id).y -= (dy / distance) * force;
|
|
208
|
+
const start = seed?.get(node.id);
|
|
209
|
+
if (start) {
|
|
210
|
+
places.set(node.id, { ...start });
|
|
211
|
+
continue;
|
|
247
212
|
}
|
|
248
|
-
}
|
|
249
213
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const force = (distance * distance) / ideal;
|
|
257
|
-
pushOf(from).x -= (dx / distance) * force;
|
|
258
|
-
pushOf(from).y -= (dy / distance) * force;
|
|
259
|
-
pushOf(to).x += (dx / distance) * force;
|
|
260
|
-
pushOf(to).y += (dy / distance) * force;
|
|
214
|
+
const angle = Graph.#hash(node.id) * Math.PI * 2;
|
|
215
|
+
const placed = [...(neighbors.get(node.id) ?? [])].map((id) => places.get(id)).find(Boolean);
|
|
216
|
+
const distance = placed ? 90 : radius;
|
|
217
|
+
const origin = placed ?? { x: 0, y: 0 };
|
|
218
|
+
|
|
219
|
+
places.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
|
|
261
220
|
}
|
|
262
221
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const force = pushOf(node.id);
|
|
222
|
+
const moving = nodes.filter((node) => !fixed.has(node.id));
|
|
223
|
+
if (!moving.length) return park();
|
|
266
224
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
225
|
+
const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
|
|
226
|
+
const ideal = 170;
|
|
227
|
+
const rounds = fixed.size ? 200 : 500;
|
|
270
228
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
229
|
+
/** @param {string} id */
|
|
230
|
+
const at = (id) => /** @type {Point} */ (places.get(id));
|
|
231
|
+
|
|
232
|
+
for (let round = 0; round < rounds; round++) {
|
|
233
|
+
const heat = 40 * (1 - round / rounds) + 1;
|
|
234
|
+
const push = new Map(nodes.map((node) => [node.id, { x: 0, y: 0 }]));
|
|
235
|
+
/** @param {string} id */
|
|
236
|
+
const pushOf = (id) => /** @type {Point} */ (push.get(id));
|
|
237
|
+
|
|
238
|
+
for (const [index, a] of nodes.entries()) {
|
|
239
|
+
for (const b of nodes.slice(index + 1)) {
|
|
240
|
+
let dx = at(a.id).x - at(b.id).x;
|
|
241
|
+
let dy = at(a.id).y - at(b.id).y;
|
|
242
|
+
|
|
243
|
+
// Two nodes in one place are pulled apart in a direction of their own.
|
|
244
|
+
if (dx === 0 && dy === 0) {
|
|
245
|
+
dx = Graph.#hash(a.id + b.id) - 0.5;
|
|
246
|
+
dy = Graph.#hash(b.id + a.id) - 0.5;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Nodes far apart no longer push, so parts that are not connected stay near each other.
|
|
250
|
+
const distance = Math.max(Math.hypot(dx, dy), 1);
|
|
251
|
+
if (distance > ideal * 3) continue;
|
|
252
|
+
|
|
253
|
+
const force = (ideal * ideal) / distance;
|
|
254
|
+
pushOf(a.id).x += (dx / distance) * force;
|
|
255
|
+
pushOf(a.id).y += (dy / distance) * force;
|
|
256
|
+
pushOf(b.id).x -= (dx / distance) * force;
|
|
257
|
+
pushOf(b.id).y -= (dy / distance) * force;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const [from, to] of pairs) {
|
|
262
|
+
if (!from || !to || !places.has(from) || !places.has(to)) continue;
|
|
263
|
+
|
|
264
|
+
const dx = at(from).x - at(to).x;
|
|
265
|
+
const dy = at(from).y - at(to).y;
|
|
266
|
+
const distance = Math.max(Math.hypot(dx, dy), 1);
|
|
267
|
+
const force = (distance * distance) / ideal;
|
|
268
|
+
pushOf(from).x -= (dx / distance) * force;
|
|
269
|
+
pushOf(from).y -= (dy / distance) * force;
|
|
270
|
+
pushOf(to).x += (dx / distance) * force;
|
|
271
|
+
pushOf(to).y += (dy / distance) * force;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const node of moving) {
|
|
275
|
+
const point = at(node.id);
|
|
276
|
+
const force = pushOf(node.id);
|
|
277
|
+
|
|
278
|
+
// A pull to the middle keeps parts that are not connected from drifting away.
|
|
279
|
+
force.x -= point.x * 0.15;
|
|
280
|
+
force.y -= point.y * 0.15;
|
|
281
|
+
|
|
282
|
+
const length = Math.max(Math.hypot(force.x, force.y), 1);
|
|
283
|
+
const step = Math.min(length, heat);
|
|
284
|
+
point.x += (force.x / length) * step;
|
|
285
|
+
point.y += (force.y / length) * step;
|
|
286
|
+
}
|
|
275
287
|
}
|
|
276
|
-
}
|
|
277
288
|
|
|
278
|
-
|
|
289
|
+
// No box may cover another: two that overlap are pushed apart the shorter way out, with room for the label of the line between them.
|
|
279
290
|
|
|
280
|
-
|
|
281
|
-
|
|
291
|
+
for (let round = 0; round < 80; round++) {
|
|
292
|
+
let overlapping = false;
|
|
282
293
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
294
|
+
for (const [index, a] of nodes.entries()) {
|
|
295
|
+
for (const b of nodes.slice(index + 1)) {
|
|
296
|
+
const aMoves = !fixed.has(a.id);
|
|
297
|
+
const bMoves = !fixed.has(b.id);
|
|
298
|
+
if (!aMoves && !bMoves) continue;
|
|
288
299
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
300
|
+
const pa = at(a.id);
|
|
301
|
+
const pb = at(b.id);
|
|
302
|
+
const overlapX = halfWidth(a) + halfWidth(b) + 24 - Math.abs(pa.x - pb.x);
|
|
303
|
+
const overlapY = 96 - Math.abs(pa.y - pb.y);
|
|
304
|
+
if (overlapX <= 0 || overlapY <= 0) continue;
|
|
294
305
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
306
|
+
overlapping = true;
|
|
307
|
+
const share = aMoves && bMoves ? 0.5 : 1;
|
|
308
|
+
const alongX = overlapX < overlapY * 2;
|
|
309
|
+
const sign = alongX ? Math.sign(pa.x - pb.x) || 1 : Math.sign(pa.y - pb.y) || 1;
|
|
310
|
+
const distance = (alongX ? overlapX : overlapY) * share;
|
|
300
311
|
|
|
301
|
-
|
|
302
|
-
|
|
312
|
+
if (aMoves) alongX ? (pa.x += sign * distance) : (pa.y += sign * distance);
|
|
313
|
+
if (bMoves) alongX ? (pb.x -= sign * distance) : (pb.y -= sign * distance);
|
|
314
|
+
}
|
|
303
315
|
}
|
|
316
|
+
|
|
317
|
+
if (!overlapping) break;
|
|
304
318
|
}
|
|
305
319
|
|
|
306
|
-
|
|
320
|
+
park();
|
|
321
|
+
Graph.#savePositions();
|
|
307
322
|
}
|
|
308
323
|
|
|
309
|
-
|
|
310
|
-
savePositions();
|
|
311
|
-
}
|
|
324
|
+
// What is drawn.
|
|
312
325
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* A layer's nodes: every device, and whatever else its edges end at.
|
|
333
|
-
*
|
|
334
|
-
* @param {Layer} name
|
|
335
|
-
* @returns {GraphNode[]}
|
|
336
|
-
*/
|
|
337
|
-
function nodesOn(name) {
|
|
338
|
-
const ends = new Set(edgesOn(name).flatMap((edge) => [edge.from, edge.to]));
|
|
339
|
-
return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
|
|
340
|
-
}
|
|
326
|
+
/**
|
|
327
|
+
* A layer's edges, with those a comparison says are gone.
|
|
328
|
+
*
|
|
329
|
+
* @param {Layer} name
|
|
330
|
+
* @returns {DrawnEdge[]}
|
|
331
|
+
*/
|
|
332
|
+
static #edgesOn(name) {
|
|
333
|
+
const current = Graph.#data?.edges.filter((edge) => edge.layer === name) ?? [];
|
|
334
|
+
if (!Graph.#comparison) return current;
|
|
335
|
+
|
|
336
|
+
// An edge the comparison says is gone is drawn when both its ends are still drawn.
|
|
337
|
+
const nodes = new Set(Graph.#data?.nodes.map((node) => node.id));
|
|
338
|
+
const gone = Graph.#comparison.removed.filter((edge) => edge.layer === name && nodes.has(edge.from) && nodes.has(edge.to));
|
|
339
|
+
return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
|
|
340
|
+
}
|
|
341
341
|
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
/**
|
|
343
|
+
* A layer's nodes: every device, and whatever else its edges end at.
|
|
344
|
+
*
|
|
345
|
+
* @param {Layer} name
|
|
346
|
+
* @returns {GraphNode[]}
|
|
347
|
+
*/
|
|
348
|
+
static #nodesOn(name) {
|
|
349
|
+
const ends = new Set(Graph.#edgesOn(name).flatMap((edge) => [edge.from, edge.to]));
|
|
350
|
+
return Graph.#data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
|
|
351
|
+
}
|
|
344
352
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
* @param {Layer} name
|
|
349
|
-
* @param {boolean} fresh forget where the layer's nodes were, and lay it out again
|
|
350
|
-
*/
|
|
351
|
-
function layOut(name, fresh) {
|
|
352
|
-
if (!data) return;
|
|
353
|
-
if (name !== "l1") layOut("l1", false);
|
|
353
|
+
static #visibleEdges() {
|
|
354
|
+
return Graph.#edgesOn(Graph.#layer);
|
|
355
|
+
}
|
|
354
356
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
+
static #visibleNodes() {
|
|
358
|
+
return Graph.#nodesOn(Graph.#layer);
|
|
359
|
+
}
|
|
357
360
|
|
|
358
|
-
/**
|
|
359
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Lay out a layer for its own lines. The cables layer comes first, as every other layer's new nodes start from it.
|
|
363
|
+
*
|
|
364
|
+
* @param {Layer} name
|
|
365
|
+
* @param {boolean} fresh forget where the layer's nodes were, and lay it out again
|
|
366
|
+
*/
|
|
367
|
+
static #layOut(name, fresh) {
|
|
368
|
+
if (!Graph.#data) return;
|
|
369
|
+
if (name !== "l1") Graph.#layOut("l1", false);
|
|
370
|
+
|
|
371
|
+
Graph.#layout(
|
|
372
|
+
Graph.#nodesOn(name),
|
|
373
|
+
Graph.#edgesOn(name),
|
|
374
|
+
fresh,
|
|
375
|
+
Graph.#layoutOf(name),
|
|
376
|
+
name === "l1" ? undefined : Graph.#layoutOf("l1")
|
|
377
|
+
);
|
|
378
|
+
}
|
|
360
379
|
|
|
361
|
-
/**
|
|
362
|
-
|
|
363
|
-
if (regrouping !== undefined) cancelAnimationFrame(regrouping);
|
|
364
|
-
regrouping = undefined;
|
|
365
|
-
positions = layoutOf(layer);
|
|
366
|
-
}
|
|
380
|
+
/** The node glide between two layers' places, which a press stops. @type {number | undefined} */
|
|
381
|
+
static #regrouping;
|
|
367
382
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
settle();
|
|
375
|
-
|
|
376
|
-
const to = layoutOf(layer);
|
|
377
|
-
if (still.matches || from === to) return;
|
|
378
|
-
|
|
379
|
-
/** @type {Map<string, Point>} */
|
|
380
|
-
const shown = new Map();
|
|
381
|
-
const began = performance.now();
|
|
382
|
-
|
|
383
|
-
/** @param {number} now */
|
|
384
|
-
const step = (now) => {
|
|
385
|
-
const t = Math.min((now - began) / 700, 1);
|
|
386
|
-
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
387
|
-
|
|
388
|
-
for (const [id, end] of to) {
|
|
389
|
-
const start = from.get(id) ?? end;
|
|
390
|
-
shown.set(id, { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased });
|
|
391
|
-
}
|
|
383
|
+
/** Put the nodes at their own layer's places now, ending a glide between layers. */
|
|
384
|
+
static #settle() {
|
|
385
|
+
if (Graph.#regrouping !== undefined) cancelAnimationFrame(Graph.#regrouping);
|
|
386
|
+
Graph.#regrouping = undefined;
|
|
387
|
+
Graph.#positions = Graph.#layoutOf(Graph.#layer);
|
|
388
|
+
}
|
|
392
389
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Move the nodes from where they are drawn to their places on the layer shown, so the eye can follow each device to where it is on this layer.
|
|
392
|
+
*
|
|
393
|
+
* @param {Map<string, Point>} from
|
|
394
|
+
*/
|
|
395
|
+
static #glideNodes(from) {
|
|
396
|
+
Graph.#settle();
|
|
397
397
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
398
|
+
const to = Graph.#layoutOf(Graph.#layer);
|
|
399
|
+
if (Dom.still.matches || from === to) return;
|
|
401
400
|
|
|
402
|
-
/** @
|
|
403
|
-
const
|
|
401
|
+
/** @type {Map<string, Point>} */
|
|
402
|
+
const shown = new Map();
|
|
403
|
+
const began = performance.now();
|
|
404
404
|
|
|
405
|
-
/**
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
function markOf(node) {
|
|
410
|
-
if (!data || node.kind !== "device") return undefined;
|
|
405
|
+
/** @param {number} now */
|
|
406
|
+
const step = (now) => {
|
|
407
|
+
const t = Math.min((now - began) / 700, 1);
|
|
408
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
411
409
|
|
|
412
|
-
|
|
413
|
-
|
|
410
|
+
for (const [id, end] of to) {
|
|
411
|
+
const start = from.get(id) ?? end;
|
|
412
|
+
shown.set(id, { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased });
|
|
413
|
+
}
|
|
414
414
|
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
Graph.#positions = t < 1 ? shown : to;
|
|
416
|
+
Graph.#place();
|
|
417
|
+
Graph.#regrouping = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
418
|
+
};
|
|
417
419
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
+
Graph.#positions = shown;
|
|
421
|
+
step(began);
|
|
422
|
+
}
|
|
420
423
|
|
|
421
|
-
/**
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const normal = { x: -(b.y - a.y) / length, y: (b.x - a.x) / length };
|
|
433
|
-
const control = { x: mid.x + normal.x * offset * 2, y: mid.y + normal.y * offset * 2 };
|
|
434
|
-
|
|
435
|
-
return {
|
|
436
|
-
d: `M${a.x},${a.y} Q${control.x},${control.y} ${b.x},${b.y}`,
|
|
437
|
-
label: { x: mid.x + normal.x * offset, y: mid.y + normal.y * offset },
|
|
438
|
-
};
|
|
439
|
-
}
|
|
424
|
+
/** @param {string} id */
|
|
425
|
+
static #nodeById(id) {
|
|
426
|
+
return Graph.#data?.nodes.find((node) => node.id === id);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {GraphNode} node
|
|
431
|
+
* @returns {"error" | "warning" | undefined}
|
|
432
|
+
*/
|
|
433
|
+
static #markOf(node) {
|
|
434
|
+
if (!Graph.#data || node.kind !== "device") return undefined;
|
|
440
435
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
viewport.setAttribute("transform", `translate(${view.x},${view.y}) scale(${view.k})`);
|
|
444
|
-
svg.classList.toggle("far", view.k < 0.4);
|
|
436
|
+
const findings = Graph.#data.findings.filter((finding) => finding.device === node.label);
|
|
437
|
+
if (findings.some((finding) => finding.level === "error")) return "error";
|
|
445
438
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
group.setAttribute("transform", `translate(${point.x},${point.y})`);
|
|
449
|
-
}
|
|
439
|
+
const drift = Graph.#data.drift[node.label]?.status;
|
|
440
|
+
if (findings.length || drift === "differs" || drift === "cannot plan") return "warning";
|
|
450
441
|
|
|
451
|
-
|
|
452
|
-
const { d, label: middle } = curve(edge, offset);
|
|
453
|
-
path.setAttribute("d", d);
|
|
454
|
-
hit.setAttribute("d", d);
|
|
455
|
-
label.setAttribute("x", String(middle.x));
|
|
456
|
-
label.setAttribute("y", String(middle.y));
|
|
442
|
+
return undefined;
|
|
457
443
|
}
|
|
458
|
-
}
|
|
459
444
|
|
|
460
|
-
/**
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
*
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
for (const edge of edges) {
|
|
479
|
-
const pair = `${[edge.from, edge.to].sort().join("|")}${edge.gone ? " gone" : ""}`;
|
|
480
|
-
byPair.set(pair, [...(byPair.get(pair) ?? []), edge]);
|
|
445
|
+
/**
|
|
446
|
+
* @param {GraphEdge} edge
|
|
447
|
+
* @param {number} offset
|
|
448
|
+
* @returns {{ d: string, label: Point }}
|
|
449
|
+
*/
|
|
450
|
+
static #curve(edge, offset) {
|
|
451
|
+
const a = Graph.#positions.get(edge.from) ?? { x: 0, y: 0 };
|
|
452
|
+
const b = Graph.#positions.get(edge.to) ?? { x: 0, y: 0 };
|
|
453
|
+
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
454
|
+
|
|
455
|
+
const length = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1);
|
|
456
|
+
const normal = { x: -(b.y - a.y) / length, y: (b.x - a.x) / length };
|
|
457
|
+
const control = { x: mid.x + normal.x * offset * 2, y: mid.y + normal.y * offset * 2 };
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
d: `M${a.x},${a.y} Q${control.x},${control.y} ${b.x},${b.y}`,
|
|
461
|
+
label: { x: mid.x + normal.x * offset, y: mid.y + normal.y * offset },
|
|
462
|
+
};
|
|
481
463
|
}
|
|
482
464
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
/** @type {Map<number, import("../../core/graph").CarriedVlan>} */
|
|
488
|
-
const vlans = new Map();
|
|
489
|
-
for (const member of members) for (const vlan of member.vlans ?? []) vlans.set(vlan.id, vlan);
|
|
490
|
-
|
|
491
|
-
return [
|
|
492
|
-
{
|
|
493
|
-
layer: first.layer,
|
|
494
|
-
from: first.from,
|
|
495
|
-
to: first.to,
|
|
496
|
-
label: `${members.length} ${MANY[first.layer]}`,
|
|
497
|
-
detail: members.flatMap((member) => [member.label, ...member.detail.map((line) => ` ${line}`)]),
|
|
498
|
-
...(members.some((member) => member.oneSided) ? { oneSided: /** @type {const} */ (true) } : {}),
|
|
499
|
-
...(vlans.size ? { vlans: [...vlans.values()].sort((a, b) => a.id - b.id) } : {}),
|
|
500
|
-
...(first.gone ? { gone: /** @type {const} */ (true) } : {}),
|
|
501
|
-
members,
|
|
502
|
-
},
|
|
503
|
-
];
|
|
504
|
-
});
|
|
505
|
-
}
|
|
465
|
+
/** Move what is drawn to where the nodes are now, without drawing it again. */
|
|
466
|
+
static #place() {
|
|
467
|
+
Graph.#viewport.setAttribute("transform", `translate(${Graph.#view.x},${Graph.#view.y}) scale(${Graph.#view.k})`);
|
|
468
|
+
Graph.#svg.classList.toggle("far", Graph.#view.k < 0.4);
|
|
506
469
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
* @param {string} id the id of the cable's drawn path, which the dots follow as it moves
|
|
512
|
-
*/
|
|
513
|
-
function signals(edge, id) {
|
|
514
|
-
if (still.matches || edge.oneSided || edge.gone) return [];
|
|
515
|
-
|
|
516
|
-
const a = positions.get(edge.from) ?? { x: 0, y: 0 };
|
|
517
|
-
const b = positions.get(edge.to) ?? { x: 0, y: 0 };
|
|
518
|
-
const seconds = Math.min(Math.max(Math.hypot(b.x - a.x, b.y - a.y) / (presenting ? 110 : 45), 1.6), 12);
|
|
519
|
-
const phase = hash(`${edge.from}${edge.to}${edge.label}`) * seconds;
|
|
520
|
-
const backwards = presenting ? [false, true] : [hash(edge.label) < 0.5];
|
|
521
|
-
|
|
522
|
-
return backwards.map((back, index) => {
|
|
523
|
-
const dot = s("circle", { class: "signal", r: 3.5 });
|
|
524
|
-
const motion = s("animateMotion", {
|
|
525
|
-
dur: `${seconds.toFixed(2)}s`,
|
|
526
|
-
begin: `-${(phase + (index * seconds) / 2).toFixed(2)}s`,
|
|
527
|
-
repeatCount: "indefinite",
|
|
528
|
-
...(back ? { keyPoints: "1;0", keyTimes: "0;1", calcMode: "linear" } : {}),
|
|
529
|
-
});
|
|
470
|
+
for (const [id, group] of Graph.#nodeElements) {
|
|
471
|
+
const point = Graph.#positions.get(id) ?? { x: 0, y: 0 };
|
|
472
|
+
group.setAttribute("transform", `translate(${point.x},${point.y})`);
|
|
473
|
+
}
|
|
530
474
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
475
|
+
for (const { edge, path, hit, label, offset } of Graph.#edgeElements) {
|
|
476
|
+
const { d, label: middle } = Graph.#curve(edge, offset);
|
|
477
|
+
path.setAttribute("d", d);
|
|
478
|
+
hit.setAttribute("d", d);
|
|
479
|
+
label.setAttribute("x", String(middle.x));
|
|
480
|
+
label.setAttribute("y", String(middle.y));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
536
483
|
|
|
537
|
-
/**
|
|
538
|
-
|
|
484
|
+
/** Past this many edges between the same two nodes, they are drawn as one, which lists them all when selected. */
|
|
485
|
+
static #MOST_APART = 3;
|
|
539
486
|
|
|
540
|
-
/**
|
|
541
|
-
|
|
542
|
-
*
|
|
543
|
-
* @param {Set<string>} fresh the nodes this drawing adds, which fade in; the others are already on screen
|
|
544
|
-
*/
|
|
545
|
-
function arrive(fresh) {
|
|
546
|
-
if (still.matches || !edgeElements.length) return;
|
|
487
|
+
/** @type {Record<Layer, string>} */
|
|
488
|
+
static #MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
|
|
547
489
|
|
|
548
|
-
/** @
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
490
|
+
/** @type {Record<Layer, string>} */
|
|
491
|
+
static #ONE = { l1: "cable", l2: "cable", l3: "shared prefix", bgp: "session", ospf: "adjacency", tunnels: "tunnel" };
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
|
|
495
|
+
*
|
|
496
|
+
* @param {DrawnEdge[]} edges
|
|
497
|
+
* @returns {DrawnEdge[]}
|
|
498
|
+
*/
|
|
499
|
+
static #bundled(edges) {
|
|
500
|
+
/** @type {Map<string, DrawnEdge[]>} */
|
|
501
|
+
const byPair = new Map();
|
|
502
|
+
for (const edge of edges) {
|
|
503
|
+
const pair = `${[edge.from, edge.to].sort().join("|")}${edge.gone ? " gone" : ""}`;
|
|
504
|
+
byPair.set(pair, [...(byPair.get(pair) ?? []), edge]);
|
|
561
505
|
}
|
|
562
|
-
}
|
|
563
506
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
507
|
+
return [...byPair.values()].flatMap((members) => {
|
|
508
|
+
const [first] = members;
|
|
509
|
+
if (!first || members.length <= Graph.#MOST_APART) return members;
|
|
510
|
+
|
|
511
|
+
/** @type {Map<number, import("../../core/topology").CarriedVlan>} */
|
|
512
|
+
const vlans = new Map();
|
|
513
|
+
for (const member of members) for (const vlan of member.vlans ?? []) vlans.set(vlan.id, vlan);
|
|
514
|
+
|
|
515
|
+
return [
|
|
516
|
+
{
|
|
517
|
+
layer: first.layer,
|
|
518
|
+
from: first.from,
|
|
519
|
+
to: first.to,
|
|
520
|
+
label: `${members.length} ${Graph.#MANY[first.layer]}`,
|
|
521
|
+
detail: members.flatMap((member) => [member.label, ...member.detail.map((line) => ` ${line}`)]),
|
|
522
|
+
...(members.some((member) => member.oneSided) ? { oneSided: /** @type {const} */ (true) } : {}),
|
|
523
|
+
...(vlans.size ? { vlans: [...vlans.values()].sort((a, b) => a.id - b.id) } : {}),
|
|
524
|
+
...(first.gone ? { gone: /** @type {const} */ (true) } : {}),
|
|
525
|
+
members,
|
|
526
|
+
},
|
|
527
|
+
];
|
|
528
|
+
});
|
|
529
|
+
}
|
|
569
530
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
531
|
+
/**
|
|
532
|
+
* Dots that travel a cable both ends declare, so a link that works reads as one that carries traffic: one dot, or one each way while presenting. A cable only one end declares, or one a comparison shows is gone, carries none.
|
|
533
|
+
*
|
|
534
|
+
* @param {DrawnEdge} edge
|
|
535
|
+
* @param {string} id the id of the cable's drawn path, which the dots follow as it moves
|
|
536
|
+
*/
|
|
537
|
+
static #signals(edge, id) {
|
|
538
|
+
if (Dom.still.matches || edge.oneSided || edge.gone) return [];
|
|
539
|
+
|
|
540
|
+
const a = Graph.#positions.get(edge.from) ?? { x: 0, y: 0 };
|
|
541
|
+
const b = Graph.#positions.get(edge.to) ?? { x: 0, y: 0 };
|
|
542
|
+
const seconds = Math.min(Math.max(Math.hypot(b.x - a.x, b.y - a.y) / (Graph.#presenting ? 110 : 45), 1.6), 12);
|
|
543
|
+
const phase = Graph.#hash(`${edge.from}${edge.to}${edge.label}`) * seconds;
|
|
544
|
+
const backwards = Graph.#presenting ? [false, true] : [Graph.#hash(edge.label) < 0.5];
|
|
545
|
+
|
|
546
|
+
return backwards.map((back, index) => {
|
|
547
|
+
const dot = Dom.s("circle", { class: "signal", r: 3.5 });
|
|
548
|
+
const motion = Dom.s("animateMotion", {
|
|
549
|
+
dur: `${seconds.toFixed(2)}s`,
|
|
550
|
+
begin: `-${(phase + (index * seconds) / 2).toFixed(2)}s`,
|
|
551
|
+
repeatCount: "indefinite",
|
|
552
|
+
...(back ? { keyPoints: "1;0", keyTimes: "0;1", calcMode: "linear" } : {}),
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
motion.append(Dom.s("mpath", { href: `#${id}` }));
|
|
556
|
+
dot.append(motion);
|
|
557
|
+
return dot;
|
|
558
|
+
});
|
|
573
559
|
}
|
|
574
560
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
561
|
+
/** A confident arrival: fast at first, settling at the end. */
|
|
562
|
+
static #EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Draw the network in from its busiest device outward, a ring of neighbours at a time, each cable drawn from its nearer end.
|
|
566
|
+
*
|
|
567
|
+
* @param {Set<string>} fresh the nodes this drawing adds, which fade in; the others are already on screen
|
|
568
|
+
*/
|
|
569
|
+
static #arrive(fresh) {
|
|
570
|
+
if (Dom.still.matches || !Graph.#edgeElements.length) return;
|
|
571
|
+
|
|
572
|
+
/** @param {string} id */
|
|
573
|
+
const degree = (id) => Graph.#edgeElements.filter(({ edge }) => edge.from === id || edge.to === id).length;
|
|
574
|
+
const hub = [...Graph.#nodeElements.keys()].sort((a, b) => degree(b) - degree(a))[0];
|
|
575
|
+
|
|
576
|
+
/** @type {Map<string, number>} */
|
|
577
|
+
const ring = new Map(hub ? [[hub, 0]] : []);
|
|
578
|
+
for (let queue = hub ? [hub] : []; queue.length;) {
|
|
579
|
+
const id = /** @type {string} */ (queue.shift());
|
|
580
|
+
for (const { edge } of Graph.#edgeElements) {
|
|
581
|
+
const other = edge.from === id ? edge.to : edge.to === id ? edge.from : undefined;
|
|
582
|
+
if (other === undefined || ring.has(other)) continue;
|
|
583
|
+
ring.set(other, (ring.get(id) ?? 0) + 1);
|
|
584
|
+
queue.push(other);
|
|
585
|
+
}
|
|
589
586
|
}
|
|
590
587
|
|
|
591
|
-
|
|
592
|
-
|
|
588
|
+
// The whole drawing arrives within about a second, however many rings it has.
|
|
589
|
+
const farthest = Math.max(0, ...ring.values()) + 1;
|
|
590
|
+
const step = Math.min(110, 800 / farthest);
|
|
591
|
+
/** @param {string} id */
|
|
592
|
+
const ringOf = (id) => ring.get(id) ?? farthest;
|
|
593
|
+
|
|
594
|
+
for (const [id, group] of Graph.#nodeElements) {
|
|
595
|
+
if (!fresh.has(id)) continue;
|
|
596
|
+
group.animate([{ opacity: 0 }, { opacity: 1 }], {
|
|
597
|
+
duration: 360,
|
|
598
|
+
delay: ringOf(id) * step,
|
|
599
|
+
easing: Graph.#EASE,
|
|
600
|
+
fill: "backwards",
|
|
601
|
+
});
|
|
593
602
|
}
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
603
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
*
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
// Edges between the same two nodes bend apart, so each stays readable.
|
|
613
|
-
/** @type {Map<string, number>} */
|
|
614
|
-
const between = new Map();
|
|
615
|
-
/** @param {DrawnEdge} edge */
|
|
616
|
-
const pairOf = (edge) => [edge.from, edge.to].sort().join("|");
|
|
617
|
-
for (const edge of edges) between.set(pairOf(edge), (between.get(pairOf(edge)) ?? 0) + 1);
|
|
618
|
-
/** @type {Map<string, number>} */
|
|
619
|
-
const seen = new Map();
|
|
620
|
-
|
|
621
|
-
for (const [number, edge] of edges.entries()) {
|
|
622
|
-
const pair = pairOf(edge);
|
|
623
|
-
const index = seen.get(pair) ?? 0;
|
|
624
|
-
seen.set(pair, index + 1);
|
|
625
|
-
|
|
626
|
-
// Bend in one direction whichever way round the edge was written.
|
|
627
|
-
const sign = edge.from < edge.to ? 1 : -1;
|
|
628
|
-
const offset = (index - ((between.get(pair) ?? 1) - 1) / 2) * 34 * sign;
|
|
629
|
-
|
|
630
|
-
const added = comparison && [edge, ...(edge.members ?? [])].some((one) => !one.gone && comparison?.added.has(changeKey(one)));
|
|
631
|
-
const change = edge.gone ? " gone" : added ? " added" : "";
|
|
632
|
-
const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
|
|
633
|
-
const hit = s("path", { class: "hit" });
|
|
634
|
-
const path = s("path", { id: `cable-${number}` });
|
|
635
|
-
const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
|
|
636
|
-
label.textContent = edge.label;
|
|
637
|
-
|
|
638
|
-
group.append(path, hit, ...signals(edge, `cable-${number}`), label);
|
|
639
|
-
|
|
640
|
-
// Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
|
|
641
|
-
group.addEventListener("pointerdown", (event) => {
|
|
642
|
-
event.stopPropagation();
|
|
643
|
-
select({ edge });
|
|
644
|
-
});
|
|
604
|
+
for (const { edge, group, path, label } of Graph.#edgeElements) {
|
|
605
|
+
const forward = ringOf(edge.from) <= ringOf(edge.to);
|
|
606
|
+
const delay = (Math.min(ringOf(edge.from), ringOf(edge.to)) + 0.5) * step;
|
|
607
|
+
|
|
608
|
+
if (edge.oneSided || edge.gone) {
|
|
609
|
+
path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: Graph.#EASE, fill: "backwards" });
|
|
610
|
+
} else {
|
|
611
|
+
// Measured as one whole length, so the line draws in right even while its ends glide to a new layer's places.
|
|
612
|
+
path.setAttribute("pathLength", "1");
|
|
613
|
+
const start = { strokeDasharray: "1 1", strokeDashoffset: forward ? 1 : -1 };
|
|
614
|
+
const end = { strokeDasharray: "1 1", strokeDashoffset: 0 };
|
|
615
|
+
const drawing = path.animate([start, end], { duration: 560, delay, easing: Graph.#EASE, fill: "backwards" });
|
|
616
|
+
drawing.addEventListener("finish", () => path.removeAttribute("pathLength"));
|
|
617
|
+
drawing.addEventListener("cancel", () => path.removeAttribute("pathLength"));
|
|
618
|
+
}
|
|
645
619
|
|
|
646
|
-
|
|
647
|
-
|
|
620
|
+
for (const part of [label, ...group.querySelectorAll(".signal")]) {
|
|
621
|
+
part.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: delay + 420, fill: "backwards" });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
648
624
|
}
|
|
649
625
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
626
|
+
/**
|
|
627
|
+
* Draw the layer. `arrival` says what is new to the eye: the whole network the first time it is shown, its cables when the layer changes, and nothing when the config is read again.
|
|
628
|
+
*
|
|
629
|
+
* @param {"network" | "cables"} [arrival]
|
|
630
|
+
*/
|
|
631
|
+
static #draw(arrival) {
|
|
632
|
+
const before = new Set(Graph.#nodeElements.keys());
|
|
633
|
+
|
|
634
|
+
Graph.#edgeLayer.replaceChildren();
|
|
635
|
+
Graph.#nodeLayer.replaceChildren();
|
|
636
|
+
Graph.#nodeElements.clear();
|
|
637
|
+
Graph.#edgeElements.length = 0;
|
|
638
|
+
|
|
639
|
+
const edges = Graph.#bundled(Graph.#visibleEdges());
|
|
640
|
+
|
|
641
|
+
// Edges between the same two nodes bend apart, so each stays readable.
|
|
642
|
+
/** @type {Map<string, number>} */
|
|
643
|
+
const between = new Map();
|
|
644
|
+
/** @param {DrawnEdge} edge */
|
|
645
|
+
const pairOf = (edge) => [edge.from, edge.to].sort().join("|");
|
|
646
|
+
for (const edge of edges) between.set(pairOf(edge), (between.get(pairOf(edge)) ?? 0) + 1);
|
|
647
|
+
/** @type {Map<string, number>} */
|
|
648
|
+
const seen = new Map();
|
|
649
|
+
|
|
650
|
+
for (const [number, edge] of edges.entries()) {
|
|
651
|
+
const pair = pairOf(edge);
|
|
652
|
+
const index = seen.get(pair) ?? 0;
|
|
653
|
+
seen.set(pair, index + 1);
|
|
654
|
+
|
|
655
|
+
// Bend in one direction whichever way round the edge was written.
|
|
656
|
+
const sign = edge.from < edge.to ? 1 : -1;
|
|
657
|
+
const offset = (index - ((between.get(pair) ?? 1) - 1) / 2) * 34 * sign;
|
|
658
|
+
|
|
659
|
+
const added =
|
|
660
|
+
Graph.#comparison &&
|
|
661
|
+
[edge, ...(edge.members ?? [])].some((one) => !one.gone && Graph.#comparison?.added.has(Evaluate.changeKey(one)));
|
|
662
|
+
const change = edge.gone ? " gone" : added ? " added" : "";
|
|
663
|
+
const group = Dom.s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
|
|
664
|
+
const hit = Dom.s("path", { class: "hit" });
|
|
665
|
+
const path = Dom.s("path", { id: `cable-${number}` });
|
|
666
|
+
const label = Dom.s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
|
|
667
|
+
label.textContent = edge.label;
|
|
668
|
+
|
|
669
|
+
group.append(path, hit, ...Graph.#signals(edge, `cable-${number}`), label);
|
|
670
|
+
|
|
671
|
+
// Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
|
|
672
|
+
group.addEventListener("pointerdown", (event) => {
|
|
673
|
+
event.stopPropagation();
|
|
674
|
+
Graph.#select({ edge });
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
Graph.#edgeLayer.append(group);
|
|
678
|
+
Graph.#edgeElements.push({ edge, group, path, hit, label, offset });
|
|
679
|
+
}
|
|
664
680
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
681
|
+
for (const node of Graph.#visibleNodes()) {
|
|
682
|
+
const group = Dom.s("g", { class: `node ${node.kind} ${Graph.#markOf(node) ?? ""}`, tabindex: 0 });
|
|
683
|
+
const rect = Dom.s("rect", { rx: node.kind === "segment" ? 14 : 8 });
|
|
684
|
+
group.append(rect);
|
|
685
|
+
|
|
686
|
+
const device = node.kind === "device";
|
|
687
|
+
const lines = device ? [node.label, node.detail[0] ?? ""] : [node.label];
|
|
688
|
+
const texts = lines.map((line, index) => {
|
|
689
|
+
const y = device ? (index === 0 ? -9 : 13) : 0;
|
|
690
|
+
const text = Dom.s("text", { class: index === 0 ? "name" : "sub", y });
|
|
691
|
+
text.textContent = line;
|
|
692
|
+
group.append(text);
|
|
693
|
+
return text;
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
Graph.#nodeLayer.append(group);
|
|
697
|
+
|
|
698
|
+
// Each box fits its text, so it is measured once drawn. A device's text starts after its chassis mark.
|
|
699
|
+
const mark = device ? 46 : 0;
|
|
700
|
+
const width = Math.max(...texts.map((text) => text.getComputedTextLength()), device ? 110 : 0) + 32 + mark;
|
|
701
|
+
const height = device ? 62 : 34;
|
|
702
|
+
rect.setAttribute("x", String(-width / 2));
|
|
703
|
+
rect.setAttribute("y", String(-height / 2));
|
|
704
|
+
rect.setAttribute("width", String(width));
|
|
705
|
+
rect.setAttribute("height", String(height));
|
|
706
|
+
|
|
707
|
+
// The mark of the device presenting lights now: a ring around its frame, shown only then.
|
|
708
|
+
const beacon = Dom.s("g", { class: "beacon", "aria-hidden": "true" });
|
|
709
|
+
beacon.append(Dom.s("rect", { x: -width / 2 - 10, y: -height / 2 - 10, width: width + 20, height: height + 20, rx: 16 }));
|
|
710
|
+
group.insertBefore(beacon, rect);
|
|
711
|
+
|
|
712
|
+
if (device) {
|
|
713
|
+
for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
|
|
714
|
+
|
|
715
|
+
// A small front panel: a chassis with two rows of ports, drawn rather than pictured.
|
|
716
|
+
const left = -width / 2 + 16;
|
|
717
|
+
const chassis = Dom.s("g", { class: "chassis", "aria-hidden": "true" });
|
|
718
|
+
chassis.append(Dom.s("rect", { x: left, y: -15, width: 34, height: 30, rx: 4 }));
|
|
719
|
+
for (const row of [0, 1]) {
|
|
720
|
+
for (const column of [0, 1, 2]) {
|
|
721
|
+
chassis.append(
|
|
722
|
+
Dom.s("rect", { class: "socket", x: left + 6 + column * 8, y: -8 + row * 9, width: 6, height: 6, rx: 1 })
|
|
723
|
+
);
|
|
724
|
+
}
|
|
691
725
|
}
|
|
726
|
+
group.insertBefore(chassis, texts[0] ?? null);
|
|
692
727
|
}
|
|
693
|
-
|
|
728
|
+
|
|
729
|
+
group.addEventListener("pointerdown", (event) => Graph.#startDrag(event, node.id));
|
|
730
|
+
group.addEventListener("pointerenter", () => {
|
|
731
|
+
Graph.#hovered = node.id;
|
|
732
|
+
Graph.#emphasize();
|
|
733
|
+
});
|
|
734
|
+
group.addEventListener("pointerleave", () => {
|
|
735
|
+
Graph.#hovered = undefined;
|
|
736
|
+
Graph.#emphasize();
|
|
737
|
+
});
|
|
738
|
+
group.addEventListener("keydown", (event) => event.key === "Enter" && Graph.#select({ node: node.id }));
|
|
739
|
+
|
|
740
|
+
Graph.#nodeElements.set(node.id, group);
|
|
694
741
|
}
|
|
695
742
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
hovered = node.id;
|
|
699
|
-
emphasize();
|
|
700
|
-
});
|
|
701
|
-
group.addEventListener("pointerleave", () => {
|
|
702
|
-
hovered = undefined;
|
|
703
|
-
emphasize();
|
|
704
|
-
});
|
|
705
|
-
group.addEventListener("keydown", (event) => event.key === "Enter" && select({ node: node.id }));
|
|
743
|
+
Graph.#place();
|
|
744
|
+
Graph.#emphasize();
|
|
706
745
|
|
|
707
|
-
nodeElements.
|
|
746
|
+
if (arrival) Graph.#arrive(new Set([...Graph.#nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
|
|
708
747
|
}
|
|
709
748
|
|
|
710
|
-
|
|
711
|
-
emphasize()
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
/**
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
749
|
+
/** Light up what the pointer is on, the device lit while presenting, or what is selected, and dim the rest. */
|
|
750
|
+
static #emphasize() {
|
|
751
|
+
const focus =
|
|
752
|
+
Graph.#hovered ?? Graph.#touring ?? (Graph.#selection && "node" in Graph.#selection ? Graph.#selection.node : undefined);
|
|
753
|
+
const selectedEdge = Graph.#selection && "edge" in Graph.#selection ? Graph.#selection.edge : undefined;
|
|
754
|
+
|
|
755
|
+
/** @param {GraphEdge} edge */
|
|
756
|
+
const touching = (edge) => edge.from === focus || edge.to === focus;
|
|
757
|
+
/** @param {GraphEdge} edge */
|
|
758
|
+
const carries = (edge) => edge.vlans?.some((vlan) => vlan.id === Graph.#vlanFocus) ?? false;
|
|
759
|
+
|
|
760
|
+
/** @type {Set<GraphEdge> | undefined} */
|
|
761
|
+
let litEdges;
|
|
762
|
+
if (focus) litEdges = new Set(Graph.#edgeElements.filter(({ edge }) => touching(edge)).map(({ edge }) => edge));
|
|
763
|
+
else if (Graph.#pathFocus)
|
|
764
|
+
litEdges = new Set(
|
|
765
|
+
Graph.#edgeElements
|
|
766
|
+
.filter(({ edge }) => [edge, ...(edge.members ?? [])].some((one) => Graph.#pathFocus?.has(Graph.#edgeKey(one))))
|
|
767
|
+
.map(({ edge }) => edge)
|
|
768
|
+
);
|
|
769
|
+
else if (Graph.#vlanFocus !== undefined && Graph.#layer === "l2")
|
|
770
|
+
litEdges = new Set(Graph.#edgeElements.filter(({ edge }) => carries(edge)).map(({ edge }) => edge));
|
|
771
|
+
else if (selectedEdge) litEdges = new Set([selectedEdge]);
|
|
772
|
+
|
|
773
|
+
const litNodes = litEdges ? new Set([...litEdges].flatMap((edge) => [edge.from, edge.to])) : undefined;
|
|
774
|
+
if (focus) litNodes?.add(focus);
|
|
775
|
+
|
|
776
|
+
for (const { edge, group } of Graph.#edgeElements) {
|
|
777
|
+
group.classList.toggle("lit", Boolean(litEdges?.has(edge)) && !selectedEdge);
|
|
778
|
+
group.classList.toggle("selected", edge === selectedEdge);
|
|
779
|
+
group.classList.toggle("dim", litEdges !== undefined && !litEdges.has(edge));
|
|
780
|
+
}
|
|
720
781
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
let litEdges;
|
|
728
|
-
if (focus) litEdges = new Set(edgeElements.filter(({ edge }) => touching(edge)).map(({ edge }) => edge));
|
|
729
|
-
else if (pathFocus)
|
|
730
|
-
litEdges = new Set(
|
|
731
|
-
edgeElements
|
|
732
|
-
.filter(({ edge }) => [edge, ...(edge.members ?? [])].some((one) => pathFocus?.has(edgeKey(one))))
|
|
733
|
-
.map(({ edge }) => edge)
|
|
734
|
-
);
|
|
735
|
-
else if (vlanFocus !== undefined && layer === "l2")
|
|
736
|
-
litEdges = new Set(edgeElements.filter(({ edge }) => carries(edge)).map(({ edge }) => edge));
|
|
737
|
-
else if (selectedEdge) litEdges = new Set([selectedEdge]);
|
|
782
|
+
for (const [id, group] of Graph.#nodeElements) {
|
|
783
|
+
group.classList.toggle("selected", Boolean(Graph.#selection && "node" in Graph.#selection && Graph.#selection.node === id));
|
|
784
|
+
group.classList.toggle("touring", Graph.#presenting && id === Graph.#touring && !Graph.#hovered);
|
|
785
|
+
group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
738
788
|
|
|
739
|
-
|
|
740
|
-
|
|
789
|
+
// Panning, zooming and dragging.
|
|
790
|
+
/** @type {{ id?: string, start: Point, origin: Point, moved: boolean } | undefined} */
|
|
791
|
+
static #drag;
|
|
741
792
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
793
|
+
/**
|
|
794
|
+
* @param {{ clientX: number, clientY: number }} event
|
|
795
|
+
* @returns {Point}
|
|
796
|
+
*/
|
|
797
|
+
static #toWorld(event) {
|
|
798
|
+
const box = Graph.#svg.getBoundingClientRect();
|
|
799
|
+
return {
|
|
800
|
+
x: (event.clientX - box.left - Graph.#view.x) / Graph.#view.k,
|
|
801
|
+
y: (event.clientY - box.top - Graph.#view.y) / Graph.#view.k,
|
|
802
|
+
};
|
|
746
803
|
}
|
|
747
804
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
805
|
+
/**
|
|
806
|
+
* @param {PointerEvent} event
|
|
807
|
+
* @param {string} [id]
|
|
808
|
+
*/
|
|
809
|
+
static #startDrag(event, id) {
|
|
810
|
+
if (event.button !== 0) return;
|
|
811
|
+
if (Graph.#gliding !== undefined) cancelAnimationFrame(Graph.#gliding);
|
|
812
|
+
Graph.#gliding = undefined;
|
|
813
|
+
|
|
814
|
+
// A press ends a glide between layers, so a device is moved from its own place on this layer.
|
|
815
|
+
if (Graph.#regrouping !== undefined) {
|
|
816
|
+
Graph.#settle();
|
|
817
|
+
Graph.#place();
|
|
818
|
+
}
|
|
819
|
+
event.stopPropagation();
|
|
754
820
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
* @param {{ clientX: number, clientY: number }} event
|
|
761
|
-
* @returns {Point}
|
|
762
|
-
*/
|
|
763
|
-
const toWorld = (event) => {
|
|
764
|
-
const box = svg.getBoundingClientRect();
|
|
765
|
-
return { x: (event.clientX - box.left - view.x) / view.k, y: (event.clientY - box.top - view.y) / view.k };
|
|
766
|
-
};
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* @param {PointerEvent} event
|
|
770
|
-
* @param {string} [id]
|
|
771
|
-
*/
|
|
772
|
-
function startDrag(event, id) {
|
|
773
|
-
if (event.button !== 0) return;
|
|
774
|
-
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
775
|
-
gliding = undefined;
|
|
776
|
-
|
|
777
|
-
// A press ends a glide between layers, so a device is moved from its own place on this layer.
|
|
778
|
-
if (regrouping !== undefined) {
|
|
779
|
-
settle();
|
|
780
|
-
place();
|
|
821
|
+
Graph.#svg.setPointerCapture(event.pointerId);
|
|
822
|
+
const point = id ? Graph.#positions.get(id) : undefined;
|
|
823
|
+
const origin = point ? { ...point } : { x: Graph.#view.x, y: Graph.#view.y };
|
|
824
|
+
Graph.#drag = { id, start: { x: event.clientX, y: event.clientY }, origin, moved: false };
|
|
825
|
+
if (!id) Graph.#svg.classList.add("panning");
|
|
781
826
|
}
|
|
782
|
-
event.stopPropagation();
|
|
783
827
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const origin = point ? { ...point } : { x: view.x, y: view.y };
|
|
787
|
-
drag = { id, start: { x: event.clientX, y: event.clientY }, origin, moved: false };
|
|
788
|
-
if (!id) svg.classList.add("panning");
|
|
789
|
-
}
|
|
828
|
+
/** The scale below which a device's name is too small to read: a first view stops there, and the reader pans for the rest. */
|
|
829
|
+
static #READABLE = 0.6;
|
|
790
830
|
|
|
791
|
-
|
|
831
|
+
/** The view moving to a new place, which a press or a scroll stops. @type {number | undefined} */
|
|
832
|
+
static #gliding;
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Move the view to a new place over a second, zooming evenly and keeping the eye on the point between them. At once when the reader asked for less motion.
|
|
836
|
+
*
|
|
837
|
+
* @param {View} target
|
|
838
|
+
*/
|
|
839
|
+
static #glideTo(target) {
|
|
840
|
+
if (Graph.#gliding !== undefined) cancelAnimationFrame(Graph.#gliding);
|
|
841
|
+
Graph.#gliding = undefined;
|
|
842
|
+
|
|
843
|
+
if (Dom.still.matches) {
|
|
844
|
+
Graph.#view = target;
|
|
845
|
+
Graph.#place();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
792
848
|
|
|
793
|
-
svg.
|
|
794
|
-
|
|
849
|
+
const box = Graph.#svg.getBoundingClientRect();
|
|
850
|
+
const from = { ...Graph.#view };
|
|
851
|
+
/** @param {View} one */
|
|
852
|
+
const centreOf = (one) => ({ x: (box.width / 2 - one.x) / one.k, y: (box.height / 2 - one.y) / one.k });
|
|
853
|
+
const start = centreOf(from);
|
|
854
|
+
const end = centreOf(target);
|
|
855
|
+
const began = performance.now();
|
|
856
|
+
|
|
857
|
+
/** @param {number} now */
|
|
858
|
+
const step = (now) => {
|
|
859
|
+
const t = Math.min((now - began) / 1100, 1);
|
|
860
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
861
|
+
const k = from.k * (target.k / from.k) ** eased;
|
|
862
|
+
const centre = { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased };
|
|
863
|
+
|
|
864
|
+
Graph.#view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
|
|
865
|
+
Graph.#place();
|
|
866
|
+
Graph.#gliding = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
Graph.#gliding = requestAnimationFrame(step);
|
|
870
|
+
}
|
|
795
871
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
872
|
+
/**
|
|
873
|
+
* Show the whole drawing, or, when that would make it unreadable and `readable` is asked, as much of it as stays readable. `glide` moves there instead of jumping.
|
|
874
|
+
*
|
|
875
|
+
* @param {boolean} [readable]
|
|
876
|
+
* @param {boolean} [glide]
|
|
877
|
+
*/
|
|
878
|
+
static #fit(readable = false, glide = false) {
|
|
879
|
+
const target = Graph.#framing(Graph.#visibleNodes(), readable);
|
|
880
|
+
if (!target) return;
|
|
800
881
|
|
|
801
|
-
|
|
802
|
-
|
|
882
|
+
if (glide) return Graph.#glideTo(target);
|
|
883
|
+
Graph.#view = target;
|
|
884
|
+
Graph.#place();
|
|
885
|
+
}
|
|
803
886
|
|
|
804
|
-
|
|
805
|
-
|
|
887
|
+
/**
|
|
888
|
+
* The view that frames these nodes.
|
|
889
|
+
*
|
|
890
|
+
* @param {GraphNode[]} nodes
|
|
891
|
+
* @param {boolean} readable
|
|
892
|
+
* @returns {View | undefined}
|
|
893
|
+
*/
|
|
894
|
+
static #framing(nodes, readable) {
|
|
895
|
+
// Framed where the nodes are going, not where a glide between layers has them now.
|
|
896
|
+
const places = Graph.#layoutOf(Graph.#layer);
|
|
897
|
+
const points = nodes.flatMap((node) => {
|
|
898
|
+
const point = places.get(node.id);
|
|
899
|
+
return point ? [point] : [];
|
|
900
|
+
});
|
|
901
|
+
if (!points.length) return undefined;
|
|
902
|
+
|
|
903
|
+
const box = Graph.#svg.getBoundingClientRect();
|
|
904
|
+
const xs = points.map((point) => point.x);
|
|
905
|
+
const ys = points.map((point) => point.y);
|
|
906
|
+
const width = Math.max(...xs) - Math.min(...xs) + 190;
|
|
907
|
+
const height = Math.max(...ys) - Math.min(...ys) + 130;
|
|
908
|
+
const whole = Math.min(Math.max(Math.min(box.width / width, box.height / height), 0.15), 1.8);
|
|
909
|
+
const k = readable ? Math.max(whole, Graph.#READABLE) : whole;
|
|
910
|
+
|
|
911
|
+
// When the whole drawing does not fit readably, the view starts on the device with the most lines, the heart of the network.
|
|
912
|
+
const edges = Graph.#visibleEdges();
|
|
913
|
+
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
914
|
+
const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
|
|
915
|
+
const centre =
|
|
916
|
+
k > whole && hub && places.has(hub.id)
|
|
917
|
+
? /** @type {Point} */ (places.get(hub.id))
|
|
918
|
+
: { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
919
|
+
|
|
920
|
+
// Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
|
|
921
|
+
const shown = Graph.#presenting ? k * 0.86 : k;
|
|
922
|
+
|
|
923
|
+
return { k: shown, x: box.width / 2 - centre.x * shown, y: box.height / 2 - centre.y * shown };
|
|
924
|
+
}
|
|
806
925
|
|
|
807
|
-
|
|
808
|
-
|
|
926
|
+
/** While presenting, frame what is shown: the lit device with everything it has a line to, or the whole layer. */
|
|
927
|
+
static #frameTour(glide = true) {
|
|
928
|
+
const lit = Graph.#touring;
|
|
929
|
+
if (!lit) return Graph.#fit(false, glide);
|
|
930
|
+
|
|
931
|
+
const near = new Set([
|
|
932
|
+
lit,
|
|
933
|
+
...Graph.#visibleEdges().flatMap((edge) => (edge.from === lit ? [edge.to] : edge.to === lit ? [edge.from] : [])),
|
|
934
|
+
]);
|
|
935
|
+
const target = Graph.#framing(
|
|
936
|
+
Graph.#visibleNodes().filter((node) => near.has(node.id)),
|
|
937
|
+
false
|
|
938
|
+
);
|
|
939
|
+
if (!target) return;
|
|
809
940
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
941
|
+
if (glide) return Graph.#glideTo(target);
|
|
942
|
+
Graph.#view = target;
|
|
943
|
+
Graph.#place();
|
|
944
|
+
}
|
|
813
945
|
|
|
814
|
-
|
|
815
|
-
|
|
946
|
+
// Presenting.
|
|
947
|
+
|
|
948
|
+
/** Say what is shown while presenting: the device lit, or the whole layer. */
|
|
949
|
+
static #showCaption() {
|
|
950
|
+
Graph.#caption.hidden = !Graph.#presenting;
|
|
951
|
+
if (!Graph.#presenting) return;
|
|
952
|
+
|
|
953
|
+
const node = Graph.#touring ? Graph.#nodeById(Graph.#touring) : undefined;
|
|
954
|
+
const edges = Graph.#visibleEdges().filter((edge) => !node || edge.from === node.id || edge.to === node.id);
|
|
955
|
+
const count = `${edges.length} ${edges.length === 1 ? Graph.#ONE[Graph.#layer] : Graph.#MANY[Graph.#layer]}`;
|
|
956
|
+
|
|
957
|
+
if (node)
|
|
958
|
+
Graph.#caption.replaceChildren(
|
|
959
|
+
Dom.h("strong", {}, node.label),
|
|
960
|
+
Dom.h("span", {}, [node.detail[0], count].filter(Boolean).join(", "))
|
|
961
|
+
);
|
|
962
|
+
else
|
|
963
|
+
Graph.#caption.replaceChildren(
|
|
964
|
+
Dom.h("strong", {}, Graph.LAYER_NAMES[Graph.#layer]),
|
|
965
|
+
Dom.h("span", {}, `${Graph.#visibleNodes().filter((one) => one.kind === "device").length} devices, ${count}`)
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
Dom.enter(Graph.#caption);
|
|
969
|
+
}
|
|
816
970
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
});
|
|
971
|
+
/** How many devices are lit on each layer while presenting, busiest first, before it moves on to the next layer. */
|
|
972
|
+
static #LIT_PER_LAYER = 4;
|
|
820
973
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
(event) => {
|
|
824
|
-
event.preventDefault();
|
|
825
|
-
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
826
|
-
gliding = undefined;
|
|
974
|
+
/** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
|
|
975
|
+
static #tourStep = 0;
|
|
827
976
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const box = svg.getBoundingClientRect();
|
|
977
|
+
/** The move to the next layer, waiting while the view zooms out to the whole of this one. @type {ReturnType<typeof setTimeout> | undefined} */
|
|
978
|
+
static #leaving;
|
|
831
979
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
)
|
|
980
|
+
/** The next layer that has lines to show, after this one; this one when no other has any. */
|
|
981
|
+
static #nextLayer() {
|
|
982
|
+
const after = [
|
|
983
|
+
...Graph.LAYERS.slice(Graph.LAYERS.indexOf(Graph.#layer) + 1),
|
|
984
|
+
...Graph.LAYERS.slice(0, Graph.LAYERS.indexOf(Graph.#layer) + 1),
|
|
985
|
+
];
|
|
986
|
+
return after.find((name) => Graph.#data?.edges.some((edge) => edge.layer === name)) ?? Graph.#layer;
|
|
987
|
+
}
|
|
837
988
|
|
|
838
|
-
/**
|
|
839
|
-
|
|
989
|
+
/** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
|
|
990
|
+
static #tourNext() {
|
|
991
|
+
if (Graph.#graphView.hidden || document.hidden || Graph.#hovered || Graph.#drag || Graph.#leaving) return;
|
|
992
|
+
|
|
993
|
+
const edges = Graph.#visibleEdges();
|
|
994
|
+
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
995
|
+
const devices = Graph.#visibleNodes()
|
|
996
|
+
.filter((node) => node.kind === "device" && degree(node.id))
|
|
997
|
+
.sort((a, b) => degree(b.id) - degree(a.id) || a.label.localeCompare(b.label))
|
|
998
|
+
.slice(0, Graph.#LIT_PER_LAYER);
|
|
999
|
+
|
|
1000
|
+
Graph.#tourStep += 1;
|
|
1001
|
+
Graph.#touring = devices[Graph.#tourStep - 1]?.id;
|
|
1002
|
+
|
|
1003
|
+
if (Graph.#touring || Graph.#nextLayer() === Graph.#layer) {
|
|
1004
|
+
if (!Graph.#touring) Graph.#tourStep = 0;
|
|
1005
|
+
Graph.#emphasize();
|
|
1006
|
+
Graph.#showCaption();
|
|
1007
|
+
Graph.#frameTour();
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
840
1010
|
|
|
841
|
-
|
|
842
|
-
|
|
1011
|
+
// Every lit device has had its turn: the view zooms out to the whole layer first, so the next layer is reached from the whole drawing.
|
|
1012
|
+
Graph.#tourStep = 0;
|
|
1013
|
+
Graph.#emphasize();
|
|
1014
|
+
Graph.#showCaption();
|
|
1015
|
+
Graph.#frameTour();
|
|
843
1016
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
* @param {typeof view} target
|
|
848
|
-
*/
|
|
849
|
-
function glideTo(target) {
|
|
850
|
-
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
851
|
-
gliding = undefined;
|
|
1017
|
+
const next = Graph.#nextLayer();
|
|
1018
|
+
Graph.#leaving = setTimeout(() => {
|
|
1019
|
+
Graph.#leaving = undefined;
|
|
852
1020
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
return;
|
|
1021
|
+
// Moving on replaces the address rather than adding to the history, so Back still leaves Studio's last place.
|
|
1022
|
+
if (Graph.#presenting && !Graph.#graphView.hidden) location.replace(`#graph/${next}`);
|
|
1023
|
+
}, 2600);
|
|
857
1024
|
}
|
|
858
1025
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
/** @param {number} now */
|
|
868
|
-
const step = (now) => {
|
|
869
|
-
const t = Math.min((now - began) / 1100, 1);
|
|
870
|
-
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
871
|
-
const k = from.k * (target.k / from.k) ** eased;
|
|
872
|
-
const centre = { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased };
|
|
873
|
-
|
|
874
|
-
view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
|
|
875
|
-
place();
|
|
876
|
-
gliding = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
877
|
-
};
|
|
1026
|
+
/** Start the turns over, so the layer shown now has its full time. */
|
|
1027
|
+
static #restartTour() {
|
|
1028
|
+
clearInterval(Graph.#tour);
|
|
1029
|
+
clearTimeout(Graph.#leaving);
|
|
1030
|
+
Graph.#leaving = undefined;
|
|
1031
|
+
if (Graph.#presenting) Graph.#tour = setInterval(Graph.#tourNext, 6000);
|
|
1032
|
+
}
|
|
878
1033
|
|
|
879
|
-
|
|
880
|
-
|
|
1034
|
+
/** @param {boolean} on */
|
|
1035
|
+
static #setPresenting(on) {
|
|
1036
|
+
Graph.#presenting = on;
|
|
1037
|
+
Dom.store.set("present", on);
|
|
881
1038
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
*
|
|
885
|
-
* @param {boolean} [readable]
|
|
886
|
-
* @param {boolean} [glide]
|
|
887
|
-
*/
|
|
888
|
-
function fit(readable = false, glide = false) {
|
|
889
|
-
const target = framing(visibleNodes(), readable);
|
|
890
|
-
if (!target) return;
|
|
891
|
-
|
|
892
|
-
if (glide) return glideTo(target);
|
|
893
|
-
view = target;
|
|
894
|
-
place();
|
|
895
|
-
}
|
|
1039
|
+
document.body.classList.toggle("presenting", on);
|
|
1040
|
+
Graph.#presentButton.setAttribute("aria-pressed", String(on));
|
|
896
1041
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
* @param {GraphNode[]} nodes
|
|
901
|
-
* @param {boolean} readable
|
|
902
|
-
* @returns {typeof view | undefined}
|
|
903
|
-
*/
|
|
904
|
-
function framing(nodes, readable) {
|
|
905
|
-
// Framed where the nodes are going, not where a glide between layers has them now.
|
|
906
|
-
const places = layoutOf(layer);
|
|
907
|
-
const points = nodes.flatMap((node) => {
|
|
908
|
-
const point = places.get(node.id);
|
|
909
|
-
return point ? [point] : [];
|
|
910
|
-
});
|
|
911
|
-
if (!points.length) return undefined;
|
|
912
|
-
|
|
913
|
-
const box = svg.getBoundingClientRect();
|
|
914
|
-
const xs = points.map((point) => point.x);
|
|
915
|
-
const ys = points.map((point) => point.y);
|
|
916
|
-
const width = Math.max(...xs) - Math.min(...xs) + 190;
|
|
917
|
-
const height = Math.max(...ys) - Math.min(...ys) + 130;
|
|
918
|
-
const whole = Math.min(Math.max(Math.min(box.width / width, box.height / height), 0.15), 1.8);
|
|
919
|
-
const k = readable ? Math.max(whole, READABLE) : whole;
|
|
920
|
-
|
|
921
|
-
// When the whole drawing does not fit readably, the view starts on the device with the most lines, the heart of the network.
|
|
922
|
-
const edges = visibleEdges();
|
|
923
|
-
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
924
|
-
const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
|
|
925
|
-
const centre =
|
|
926
|
-
k > whole && hub && places.has(hub.id)
|
|
927
|
-
? /** @type {Point} */ (places.get(hub.id))
|
|
928
|
-
: { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
929
|
-
|
|
930
|
-
// Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
|
|
931
|
-
const shown = presenting ? k * 0.86 : k;
|
|
932
|
-
|
|
933
|
-
return { k: shown, x: box.width / 2 - centre.x * shown, y: box.height / 2 - centre.y * shown };
|
|
934
|
-
}
|
|
1042
|
+
Graph.#touring = undefined;
|
|
1043
|
+
if (on) Graph.#selection = undefined;
|
|
1044
|
+
Graph.#restartTour();
|
|
935
1045
|
|
|
936
|
-
|
|
937
|
-
function frameTour(glide = true) {
|
|
938
|
-
const lit = touring;
|
|
939
|
-
if (!lit) return fit(false, glide);
|
|
1046
|
+
if (Graph.#graphView.hidden || !Graph.#data) return;
|
|
940
1047
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
if (glide) return glideTo(target);
|
|
949
|
-
view = target;
|
|
950
|
-
place();
|
|
951
|
-
}
|
|
1048
|
+
// The panel hides or returns, so the drawing is fitted once the page has its new size.
|
|
1049
|
+
Graph.#draw();
|
|
1050
|
+
Graph.#showPanel();
|
|
1051
|
+
Graph.#showCaption();
|
|
1052
|
+
requestAnimationFrame(() => (Graph.#presenting ? Graph.#frameTour(false) : Graph.#fit()));
|
|
1053
|
+
}
|
|
952
1054
|
|
|
953
|
-
|
|
1055
|
+
// The side panel.
|
|
1056
|
+
/** @param {Selection} next */
|
|
1057
|
+
static #select(next) {
|
|
1058
|
+
/** @param {Selection} one */
|
|
1059
|
+
const keyOf = (one) => (one && "node" in one ? one.node : one ? Graph.#edgeKey(one.edge) : "");
|
|
1060
|
+
const changed = keyOf(next) !== keyOf(Graph.#selection);
|
|
954
1061
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
draw();
|
|
960
|
-
fit();
|
|
961
|
-
});
|
|
1062
|
+
Graph.#selection = next;
|
|
1063
|
+
Graph.#pathFocus = undefined;
|
|
1064
|
+
Graph.#emphasize();
|
|
1065
|
+
Graph.#showPanel();
|
|
962
1066
|
|
|
963
|
-
|
|
964
|
-
|
|
1067
|
+
if (changed) Dom.enter(Graph.#panel);
|
|
1068
|
+
}
|
|
965
1069
|
|
|
966
|
-
|
|
1070
|
+
/**
|
|
1071
|
+
* @param {Annotated["findings"]} findings
|
|
1072
|
+
* @param {boolean} withDevice
|
|
1073
|
+
*/
|
|
1074
|
+
static #findingList(findings, withDevice) {
|
|
1075
|
+
return Dom.h(
|
|
1076
|
+
"ul",
|
|
1077
|
+
{},
|
|
1078
|
+
...findings.map((finding) =>
|
|
1079
|
+
Dom.h(
|
|
1080
|
+
"li",
|
|
1081
|
+
{},
|
|
1082
|
+
Dom.h("span", { class: `badge ${finding.level}` }, finding.level),
|
|
1083
|
+
withDevice && finding.device
|
|
1084
|
+
? Dom.link(finding.device, () => Graph.#select({ node: `device:${finding.device}` }))
|
|
1085
|
+
: undefined,
|
|
1086
|
+
withDevice && finding.device ? ": " : undefined,
|
|
1087
|
+
finding.message
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
967
1092
|
|
|
968
|
-
/**
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1093
|
+
/** @param {CarriedVlan[]} vlans */
|
|
1094
|
+
static #vlanChips(vlans) {
|
|
1095
|
+
const chips = Dom.h("div", { class: "chips" });
|
|
1096
|
+
|
|
1097
|
+
for (const vlan of vlans) {
|
|
1098
|
+
const button = Dom.h("button", { "aria-pressed": String(vlan.id === Graph.#vlanFocus) }, `${vlan.name} ${vlan.id}`);
|
|
1099
|
+
button.addEventListener("click", () => {
|
|
1100
|
+
Graph.#vlanFocus = Graph.#vlanFocus === vlan.id ? undefined : vlan.id;
|
|
1101
|
+
Graph.#selection = undefined;
|
|
1102
|
+
Graph.#emphasize();
|
|
1103
|
+
Graph.#showPanel();
|
|
1104
|
+
});
|
|
1105
|
+
chips.append(button);
|
|
1106
|
+
}
|
|
972
1107
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const count = `${edges.length} ${edges.length === 1 ? ONE[layer] : MANY[layer]}`;
|
|
1108
|
+
return chips;
|
|
1109
|
+
}
|
|
976
1110
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1111
|
+
static #legend() {
|
|
1112
|
+
/**
|
|
1113
|
+
* @param {boolean} dashed
|
|
1114
|
+
* @param {string} color
|
|
1115
|
+
*/
|
|
1116
|
+
const sample = (dashed, color) => {
|
|
1117
|
+
const icon = Dom.s("svg", { width: 34, height: 10 });
|
|
1118
|
+
icon.append(
|
|
1119
|
+
Dom.s("line", { x1: 2, y1: 5, x2: 32, y2: 5, stroke: color, "stroke-width": 2, "stroke-dasharray": dashed ? "6 4" : "" })
|
|
1120
|
+
);
|
|
1121
|
+
return icon;
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
/** @param {string} style */
|
|
1125
|
+
const box = (style) => {
|
|
1126
|
+
const icon = Dom.s("svg", { width: 34, height: 16 });
|
|
1127
|
+
icon.append(Dom.s("rect", { x: 2, y: 2, width: 30, height: 12, rx: 3, fill: "none", "stroke-width": 2, style }));
|
|
1128
|
+
return icon;
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
return Dom.h(
|
|
1132
|
+
"ul",
|
|
1133
|
+
{ class: "legend" },
|
|
1134
|
+
Dom.h("li", {}, sample(false, "var(--edge)"), "both ends declare it"),
|
|
1135
|
+
Dom.h("li", {}, sample(true, "var(--warning)"), "only one end declares it"),
|
|
1136
|
+
Dom.h("li", {}, box("stroke: var(--error)"), "a device with an error"),
|
|
1137
|
+
Dom.h("li", {}, box("stroke: var(--warning)"), "a warning, or unlike its snapshot"),
|
|
1138
|
+
Dom.h("li", {}, box("stroke: var(--line); stroke-dasharray: 5 3"), "outside the network")
|
|
982
1139
|
);
|
|
1140
|
+
}
|
|
983
1141
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
/** How many devices are lit on each layer while presenting, busiest first, before it moves on to the next layer. */
|
|
988
|
-
const LIT_PER_LAYER = 4;
|
|
1142
|
+
static #showPanel() {
|
|
1143
|
+
Graph.#panel.replaceChildren();
|
|
1144
|
+
if (!Graph.#data) return;
|
|
989
1145
|
|
|
990
|
-
|
|
991
|
-
|
|
1146
|
+
if (Graph.#selection && "node" in Graph.#selection) {
|
|
1147
|
+
const node = Graph.#nodeById(Graph.#selection.node);
|
|
1148
|
+
if (node) return Graph.#showNode(node);
|
|
1149
|
+
}
|
|
992
1150
|
|
|
993
|
-
|
|
994
|
-
let leaving;
|
|
1151
|
+
if (Graph.#selection && "edge" in Graph.#selection) return Graph.#showEdge(Graph.#selection.edge);
|
|
995
1152
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
}
|
|
1153
|
+
const drift = Graph.#data.drift;
|
|
1154
|
+
const devices = Graph.#data.nodes.filter((node) => node.kind === "device");
|
|
1155
|
+
const drifted = devices.filter((node) => drift[node.label]?.status === "differs");
|
|
1156
|
+
const unsnapped = devices.filter((node) => drift[node.label]?.status === "no snapshot");
|
|
1001
1157
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
const edges = visibleEdges();
|
|
1007
|
-
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
1008
|
-
const devices = visibleNodes()
|
|
1009
|
-
.filter((node) => node.kind === "device" && degree(node.id))
|
|
1010
|
-
.sort((a, b) => degree(b.id) - degree(a.id) || a.label.localeCompare(b.label))
|
|
1011
|
-
.slice(0, LIT_PER_LAYER);
|
|
1012
|
-
|
|
1013
|
-
tourStep += 1;
|
|
1014
|
-
touring = devices[tourStep - 1]?.id;
|
|
1015
|
-
|
|
1016
|
-
if (touring || nextLayer() === layer) {
|
|
1017
|
-
if (!touring) tourStep = 0;
|
|
1018
|
-
emphasize();
|
|
1019
|
-
showCaption();
|
|
1020
|
-
frameTour();
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1158
|
+
Graph.#panel.append(
|
|
1159
|
+
Dom.h("h2", {}, Graph.LAYER_NAMES[Graph.#layer]),
|
|
1160
|
+
Dom.h("p", { class: "muted" }, Graph.#LAYER_HELP[Graph.#layer])
|
|
1161
|
+
);
|
|
1023
1162
|
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1163
|
+
if (Graph.#comparison) {
|
|
1164
|
+
Graph.#panel.append(
|
|
1165
|
+
Dom.h("h3", {}, "Compared with ", Graph.#comparison.name),
|
|
1166
|
+
Dom.h(
|
|
1167
|
+
"p",
|
|
1168
|
+
{},
|
|
1169
|
+
Dom.h("span", { class: "added" }, "Green lines"),
|
|
1170
|
+
" are new, ",
|
|
1171
|
+
Dom.h("span", { class: "removed" }, "red dashed lines"),
|
|
1172
|
+
" are gone. ",
|
|
1173
|
+
Dom.link("Stop comparing", () => {
|
|
1174
|
+
Graph.#comparison = undefined;
|
|
1175
|
+
Graph.#draw();
|
|
1176
|
+
Graph.#showPanel();
|
|
1177
|
+
})
|
|
1178
|
+
)
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1029
1181
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1182
|
+
if (Graph.#layer === "l2") {
|
|
1183
|
+
/** @type {Map<number, CarriedVlan>} */
|
|
1184
|
+
const vlans = new Map();
|
|
1185
|
+
for (const edge of Graph.#visibleEdges()) for (const vlan of edge.vlans ?? []) vlans.set(vlan.id, vlan);
|
|
1033
1186
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1187
|
+
const sorted = [...vlans.values()].sort((a, b) => a.id - b.id);
|
|
1188
|
+
if (sorted.length) Graph.#panel.append(Dom.h("h3", {}, "VLANs"), Graph.#vlanChips(sorted));
|
|
1189
|
+
}
|
|
1038
1190
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1191
|
+
if (Graph.#data.findings.length)
|
|
1192
|
+
Graph.#panel.append(
|
|
1193
|
+
Dom.h("h3", {}, "Found by ", Dom.h("code", {}, "circuit validate")),
|
|
1194
|
+
Graph.#findingList(Graph.#data.findings, true)
|
|
1195
|
+
);
|
|
1196
|
+
|
|
1197
|
+
if (drifted.length) {
|
|
1198
|
+
Graph.#panel.append(
|
|
1199
|
+
Dom.h("h3", {}, "Unlike their last snapshot"),
|
|
1200
|
+
Dom.h(
|
|
1201
|
+
"ul",
|
|
1202
|
+
{},
|
|
1203
|
+
...drifted.map((node) =>
|
|
1204
|
+
Dom.h(
|
|
1205
|
+
"li",
|
|
1206
|
+
{},
|
|
1207
|
+
Dom.link(node.label, () => Graph.#select({ node: node.id }))
|
|
1208
|
+
)
|
|
1209
|
+
)
|
|
1210
|
+
)
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1046
1213
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1214
|
+
if (unsnapped.length) {
|
|
1215
|
+
Graph.#panel.append(
|
|
1216
|
+
Dom.h("h3", {}, "No snapshot yet"),
|
|
1217
|
+
Dom.h("p", { class: "muted" }, unsnapped.map((node) => node.label).join(", "))
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1051
1220
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1221
|
+
Graph.#panel.append(Dom.h("h3", {}, "Legend"), Graph.#legend());
|
|
1222
|
+
}
|
|
1054
1223
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1224
|
+
/** @param {GraphNode} node */
|
|
1225
|
+
static #showNode(node) {
|
|
1226
|
+
const kinds = { device: "device", segment: "shared segment", outside: "outside the network" };
|
|
1227
|
+
Graph.#panel.append(Dom.h("h2", {}, node.label), Dom.h("div", { class: "kind" }, kinds[node.kind]));
|
|
1228
|
+
|
|
1229
|
+
if (node.kind === "device")
|
|
1230
|
+
Graph.#panel.append(
|
|
1231
|
+
Dom.h(
|
|
1232
|
+
"p",
|
|
1233
|
+
{},
|
|
1234
|
+
Dom.link("Everything it holds", () => Dom.go(`devices/${encodeURIComponent(node.label)}`))
|
|
1235
|
+
)
|
|
1236
|
+
);
|
|
1237
|
+
if (node.detail.length) Graph.#panel.append(Dom.h("h3", {}, "Details"), Dom.h("pre", {}, node.detail.join("\n")));
|
|
1238
|
+
|
|
1239
|
+
if (node.kind === "device" && Graph.#data) {
|
|
1240
|
+
const findings = Graph.#data.findings.filter((finding) => finding.device === node.label);
|
|
1241
|
+
if (findings.length)
|
|
1242
|
+
Graph.#panel.append(
|
|
1243
|
+
Dom.h("h3", {}, "Found by ", Dom.h("code", {}, "circuit validate")),
|
|
1244
|
+
Graph.#findingList(findings, false)
|
|
1245
|
+
);
|
|
1058
1246
|
|
|
1059
|
-
|
|
1247
|
+
const drift = Graph.#data.drift[node.label];
|
|
1248
|
+
if (drift) {
|
|
1249
|
+
const notes = {
|
|
1250
|
+
matches: ["Matches its last snapshot."],
|
|
1251
|
+
differs: ["Differs from its last snapshot. This is what ", Dom.h("code", {}, "circuit diff"), " would send:"],
|
|
1252
|
+
"no snapshot": ["No snapshot yet: run ", Dom.h("code", {}, "circuit snapshot"), "."],
|
|
1253
|
+
"cannot plan": ["Could not be planned against its snapshot:"],
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
Graph.#panel.append(Dom.h("h3", {}, "Against its snapshot"), Dom.h("p", {}, ...notes[drift.status]));
|
|
1257
|
+
if (drift.lines.length) Graph.#panel.append(Dom.h("pre", {}, drift.lines.join("\n")));
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1060
1260
|
|
|
1061
|
-
|
|
1062
|
-
draw();
|
|
1063
|
-
showPanel();
|
|
1064
|
-
showCaption();
|
|
1065
|
-
requestAnimationFrame(() => (presenting ? frameTour(false) : fit()));
|
|
1066
|
-
}
|
|
1261
|
+
const edges = Graph.#visibleEdges().filter((edge) => edge.from === node.id || edge.to === node.id);
|
|
1067
1262
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
// A hidden page's dots stop moving.
|
|
1072
|
-
document.addEventListener("visibilitychange", () => {
|
|
1073
|
-
if (!(svg instanceof SVGSVGElement)) return;
|
|
1074
|
-
if (document.hidden) svg.pauseAnimations();
|
|
1075
|
-
else svg.unpauseAnimations();
|
|
1076
|
-
});
|
|
1077
|
-
|
|
1078
|
-
// Saving the layer shown, as it looks.
|
|
1079
|
-
byId("save-svg").addEventListener("click", () => {
|
|
1080
|
-
if (!(viewport instanceof SVGGraphicsElement)) return;
|
|
1081
|
-
save(`circuit-${layer}.svg`, new Blob([svgOf(svg, viewport).text], { type: "image/svg+xml" }));
|
|
1082
|
-
});
|
|
1083
|
-
|
|
1084
|
-
byId("save-png").addEventListener("click", async () => {
|
|
1085
|
-
if (!(viewport instanceof SVGGraphicsElement)) return;
|
|
1086
|
-
save(`circuit-${layer}.png`, await pngOf(svgOf(svg, viewport)));
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
// The side panel.
|
|
1090
|
-
/** @param {Selection} next */
|
|
1091
|
-
function select(next) {
|
|
1092
|
-
/** @param {Selection} one */
|
|
1093
|
-
const keyOf = (one) => (one && "node" in one ? one.node : one ? edgeKey(one.edge) : "");
|
|
1094
|
-
const changed = keyOf(next) !== keyOf(selection);
|
|
1095
|
-
|
|
1096
|
-
selection = next;
|
|
1097
|
-
pathFocus = undefined;
|
|
1098
|
-
emphasize();
|
|
1099
|
-
showPanel();
|
|
1100
|
-
|
|
1101
|
-
if (changed) enter(panel);
|
|
1102
|
-
}
|
|
1263
|
+
Graph.#panel.append(Dom.h("h3", {}, `${Graph.LAYER_NAMES[Graph.#layer]} here`));
|
|
1264
|
+
if (!edges.length) Graph.#panel.append(Dom.h("p", { class: "muted" }, "None on this layer."));
|
|
1103
1265
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
*/
|
|
1108
|
-
function findingList(findings, withDevice) {
|
|
1109
|
-
return h(
|
|
1110
|
-
"ul",
|
|
1111
|
-
{},
|
|
1112
|
-
...findings.map((finding) =>
|
|
1113
|
-
h(
|
|
1114
|
-
"li",
|
|
1266
|
+
Graph.#panel.append(
|
|
1267
|
+
Dom.h(
|
|
1268
|
+
"ul",
|
|
1115
1269
|
{},
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1270
|
+
...edges.map((edge) => {
|
|
1271
|
+
const other = Graph.#nodeById(edge.from === node.id ? edge.to : edge.from);
|
|
1272
|
+
return Dom.h(
|
|
1273
|
+
"li",
|
|
1274
|
+
{},
|
|
1275
|
+
Dom.link(edge.label, () => Graph.#select({ edge })),
|
|
1276
|
+
Dom.h("span", { class: "muted" }, ` with ${other?.label ?? "?"}`)
|
|
1277
|
+
);
|
|
1278
|
+
})
|
|
1120
1279
|
)
|
|
1121
|
-
)
|
|
1122
|
-
);
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
/** @param {CarriedVlan[]} vlans */
|
|
1126
|
-
function vlanChips(vlans) {
|
|
1127
|
-
const chips = h("div", { class: "chips" });
|
|
1128
|
-
|
|
1129
|
-
for (const vlan of vlans) {
|
|
1130
|
-
const button = h("button", { "aria-pressed": String(vlan.id === vlanFocus) }, `${vlan.name} ${vlan.id}`);
|
|
1131
|
-
button.addEventListener("click", () => {
|
|
1132
|
-
vlanFocus = vlanFocus === vlan.id ? undefined : vlan.id;
|
|
1133
|
-
selection = undefined;
|
|
1134
|
-
emphasize();
|
|
1135
|
-
showPanel();
|
|
1136
|
-
});
|
|
1137
|
-
chips.append(button);
|
|
1280
|
+
);
|
|
1138
1281
|
}
|
|
1139
1282
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1283
|
+
/** @param {GraphEdge} edge */
|
|
1284
|
+
static #showEdge(edge) {
|
|
1285
|
+
const from = Graph.#nodeById(edge.from);
|
|
1286
|
+
const to = Graph.#nodeById(edge.to);
|
|
1287
|
+
|
|
1288
|
+
Graph.#panel.append(
|
|
1289
|
+
Dom.h("h2", {}, edge.label),
|
|
1290
|
+
Dom.h(
|
|
1291
|
+
"div",
|
|
1292
|
+
{ class: "kind" },
|
|
1293
|
+
`${Graph.LAYER_NAMES[edge.layer]}, between `,
|
|
1294
|
+
Dom.link(from?.label ?? edge.from, () => Graph.#select({ node: edge.from })),
|
|
1295
|
+
" and ",
|
|
1296
|
+
Dom.link(to?.label ?? edge.to, () => Graph.#select({ node: edge.to }))
|
|
1297
|
+
)
|
|
1298
|
+
);
|
|
1142
1299
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
const sample = (dashed, color) => {
|
|
1149
|
-
const icon = s("svg", { width: 34, height: 10 });
|
|
1150
|
-
icon.append(s("line", { x1: 2, y1: 5, x2: 32, y2: 5, stroke: color, "stroke-width": 2, "stroke-dasharray": dashed ? "6 4" : "" }));
|
|
1151
|
-
return icon;
|
|
1152
|
-
};
|
|
1300
|
+
if (edge.oneSided)
|
|
1301
|
+
Graph.#panel.append(Dom.h("p", {}, Dom.h("span", { class: "badge warning" }, "one end"), "Only one end declares it."));
|
|
1302
|
+
if (edge.detail.length) Graph.#panel.append(Dom.h("h3", {}, "Details"), Dom.h("pre", {}, edge.detail.join("\n")));
|
|
1303
|
+
if (edge.vlans?.length) Graph.#panel.append(Dom.h("h3", {}, "VLANs"), Graph.#vlanChips(edge.vlans));
|
|
1304
|
+
}
|
|
1153
1305
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1306
|
+
static #showLayerButtons() {
|
|
1307
|
+
Graph.#layersNav.replaceChildren();
|
|
1308
|
+
|
|
1309
|
+
for (const [index, name] of Graph.LAYERS.entries()) {
|
|
1310
|
+
const count = Graph.#data?.edges.filter((edge) => edge.layer === name).length ?? 0;
|
|
1311
|
+
const button = Dom.h(
|
|
1312
|
+
"button",
|
|
1313
|
+
{ "aria-pressed": String(name === Graph.#layer), title: `${Graph.#LAYER_HELP[name]} Key ${index + 1}.` },
|
|
1314
|
+
Graph.LAYER_NAMES[name],
|
|
1315
|
+
Dom.h("span", { class: "count" }, String(count))
|
|
1316
|
+
);
|
|
1317
|
+
button.addEventListener("click", () => Dom.go(`graph/${name}`));
|
|
1318
|
+
Graph.#layersNav.append(button);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1160
1321
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
h("li", {}, sample(true, "var(--warning)"), "only one end declares it"),
|
|
1166
|
-
h("li", {}, box("stroke: var(--error)"), "a device with an error"),
|
|
1167
|
-
h("li", {}, box("stroke: var(--warning)"), "a warning, or unlike its snapshot"),
|
|
1168
|
-
h("li", {}, box("stroke: var(--line); stroke-dasharray: 5 3"), "outside the network")
|
|
1169
|
-
);
|
|
1170
|
-
}
|
|
1322
|
+
/** The layer shown last, or the first. */
|
|
1323
|
+
static currentLayer() {
|
|
1324
|
+
return Graph.#layer;
|
|
1325
|
+
}
|
|
1171
1326
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1327
|
+
/**
|
|
1328
|
+
* Take a new graph, when the config changed. Nodes keep their places, and a selection whose node or edge is gone is dropped.
|
|
1329
|
+
*
|
|
1330
|
+
* @param {Annotated} next
|
|
1331
|
+
*/
|
|
1332
|
+
static set(next) {
|
|
1333
|
+
Graph.#data = next;
|
|
1334
|
+
|
|
1335
|
+
if (Graph.#selection && "node" in Graph.#selection && !Graph.#nodeById(Graph.#selection.node)) Graph.#selection = undefined;
|
|
1336
|
+
if (Graph.#selection && "edge" in Graph.#selection) {
|
|
1337
|
+
const { layer: was, from, to, label } = Graph.#selection.edge;
|
|
1338
|
+
const same = Graph.#data.edges.find(
|
|
1339
|
+
(edge) => edge.layer === was && edge.from === from && edge.to === to && edge.label === label
|
|
1340
|
+
);
|
|
1341
|
+
Graph.#selection = same ? { edge: same } : undefined;
|
|
1342
|
+
}
|
|
1175
1343
|
|
|
1176
|
-
|
|
1177
|
-
const node = nodeById(selection.node);
|
|
1178
|
-
if (node) return showNode(node);
|
|
1344
|
+
Graph.#layOut(Graph.#layer, false);
|
|
1179
1345
|
}
|
|
1180
1346
|
|
|
1181
|
-
|
|
1347
|
+
/**
|
|
1348
|
+
* Draw a layer, now that the graph is visible: its labels are measured as they are drawn.
|
|
1349
|
+
*
|
|
1350
|
+
* @param {Layer} next
|
|
1351
|
+
*/
|
|
1352
|
+
static show(next) {
|
|
1353
|
+
if (next !== Graph.#layer) {
|
|
1354
|
+
if (Graph.#selection && "edge" in Graph.#selection) Graph.#selection = undefined;
|
|
1355
|
+
Graph.#vlanFocus = undefined;
|
|
1356
|
+
Graph.#pathFocus = undefined;
|
|
1357
|
+
Graph.#touring = undefined;
|
|
1358
|
+
Graph.#tourStep = 0;
|
|
1359
|
+
Graph.#restartTour();
|
|
1360
|
+
}
|
|
1182
1361
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1362
|
+
if (Graph.#pendingVlan !== undefined) {
|
|
1363
|
+
Graph.#vlanFocus = Graph.#pendingVlan;
|
|
1364
|
+
Graph.#pendingVlan = undefined;
|
|
1365
|
+
}
|
|
1187
1366
|
|
|
1188
|
-
|
|
1367
|
+
if (Graph.#pendingPath) {
|
|
1368
|
+
Graph.#pathFocus = new Set(Graph.#pendingPath.map(Graph.#edgeKey));
|
|
1369
|
+
Graph.#pendingPath = undefined;
|
|
1370
|
+
}
|
|
1189
1371
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1372
|
+
// The first view draws the whole network in; a new layer draws in its own lines while its devices glide to their places on it.
|
|
1373
|
+
const arrival = !Graph.#drawn && Graph.#data ? "network" : next !== Graph.#layer ? "cables" : undefined;
|
|
1374
|
+
const from = Graph.#positions;
|
|
1375
|
+
if (Graph.#drawn) Graph.#views.set(Graph.#layer, { ...Graph.#view });
|
|
1376
|
+
|
|
1377
|
+
Graph.#layer = next;
|
|
1378
|
+
Dom.store.set("layer", Graph.#layer);
|
|
1379
|
+
Graph.#layOut(Graph.#layer, false);
|
|
1380
|
+
|
|
1381
|
+
if (arrival === "cables") Graph.#glideNodes(from);
|
|
1382
|
+
else Graph.#settle();
|
|
1383
|
+
|
|
1384
|
+
Graph.#showLayerButtons();
|
|
1385
|
+
Graph.#draw(arrival);
|
|
1386
|
+
Graph.#showPanel();
|
|
1387
|
+
Graph.#showCaption();
|
|
1388
|
+
|
|
1389
|
+
// Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read. A layer shown before
|
|
1390
|
+
// comes back as the reader left it, and one shown for the first time glides into view.
|
|
1391
|
+
if (Graph.#presenting && arrival) Graph.#fit();
|
|
1392
|
+
else if (!Graph.#drawn && Graph.#data) Graph.#fit(true);
|
|
1393
|
+
else if (arrival === "cables") {
|
|
1394
|
+
const left = Graph.#views.get(Graph.#layer);
|
|
1395
|
+
if (left) Graph.#glideTo(left);
|
|
1396
|
+
else Graph.#fit(true, true);
|
|
1397
|
+
}
|
|
1398
|
+
Graph.#drawn = Graph.#data !== undefined;
|
|
1207
1399
|
}
|
|
1208
1400
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1401
|
+
/**
|
|
1402
|
+
* Select a device, for when the graph is shown next.
|
|
1403
|
+
*
|
|
1404
|
+
* @param {string} name
|
|
1405
|
+
*/
|
|
1406
|
+
static selectDevice(name) {
|
|
1407
|
+
Graph.#selection = { node: `device:${name}` };
|
|
1216
1408
|
}
|
|
1217
1409
|
|
|
1218
|
-
|
|
1410
|
+
/**
|
|
1411
|
+
* Show a comparison with another commit on the graph: new edges lit, edges gone drawn dashed.
|
|
1412
|
+
*
|
|
1413
|
+
* @param {{ name: string, added: GraphEdge[], removed: GraphEdge[] }} next
|
|
1414
|
+
*/
|
|
1415
|
+
static setComparison(next) {
|
|
1416
|
+
Graph.#comparison = { name: next.name, added: new Set(next.added.map(Evaluate.changeKey)), removed: next.removed };
|
|
1417
|
+
Graph.#selection = undefined;
|
|
1418
|
+
}
|
|
1219
1419
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
"li",
|
|
1229
|
-
{},
|
|
1230
|
-
link(node.label, () => select({ node: node.id }))
|
|
1231
|
-
)
|
|
1232
|
-
)
|
|
1233
|
-
)
|
|
1234
|
-
);
|
|
1420
|
+
/**
|
|
1421
|
+
* Light up these cables as one path, for when their layer is shown next.
|
|
1422
|
+
*
|
|
1423
|
+
* @param {GraphEdge[]} path
|
|
1424
|
+
*/
|
|
1425
|
+
static lightPath(path) {
|
|
1426
|
+
Graph.#pendingPath = path;
|
|
1427
|
+
Graph.#selection = undefined;
|
|
1235
1428
|
}
|
|
1236
1429
|
|
|
1237
|
-
|
|
1238
|
-
|
|
1430
|
+
/**
|
|
1431
|
+
* Light up every cable carrying a VLAN, for when the VLAN layer is shown next.
|
|
1432
|
+
*
|
|
1433
|
+
* @param {number} id
|
|
1434
|
+
*/
|
|
1435
|
+
static lightVlan(id) {
|
|
1436
|
+
Graph.#pendingVlan = id;
|
|
1437
|
+
Graph.#selection = undefined;
|
|
1239
1438
|
}
|
|
1240
1439
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1440
|
+
static {
|
|
1441
|
+
Graph.#svg.addEventListener("pointerdown", (event) => Graph.#startDrag(event));
|
|
1243
1442
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
const kinds = { device: "device", segment: "shared segment", outside: "outside the network" };
|
|
1247
|
-
panel.append(h("h2", {}, node.label), h("div", { class: "kind" }, kinds[node.kind]));
|
|
1443
|
+
Graph.#svg.addEventListener("pointermove", (event) => {
|
|
1444
|
+
if (!Graph.#drag) return;
|
|
1248
1445
|
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
{},
|
|
1254
|
-
link("Everything it holds", () => go(`devices/${encodeURIComponent(node.label)}`))
|
|
1255
|
-
)
|
|
1256
|
-
);
|
|
1257
|
-
if (node.detail.length) panel.append(h("h3", {}, "Details"), h("pre", {}, node.detail.join("\n")));
|
|
1258
|
-
|
|
1259
|
-
if (node.kind === "device" && data) {
|
|
1260
|
-
const findings = data.findings.filter((finding) => finding.device === node.label);
|
|
1261
|
-
if (findings.length) panel.append(h("h3", {}, "Found by ", h("code", {}, "circuit validate")), findingList(findings, false));
|
|
1262
|
-
|
|
1263
|
-
const drift = data.drift[node.label];
|
|
1264
|
-
if (drift) {
|
|
1265
|
-
const notes = {
|
|
1266
|
-
matches: ["Matches its last snapshot."],
|
|
1267
|
-
differs: ["Differs from its last snapshot. This is what ", h("code", {}, "circuit diff"), " would send:"],
|
|
1268
|
-
"no snapshot": ["No snapshot yet: run ", h("code", {}, "circuit snapshot"), "."],
|
|
1269
|
-
"cannot plan": ["Could not be planned against its snapshot:"],
|
|
1270
|
-
};
|
|
1271
|
-
|
|
1272
|
-
panel.append(h("h3", {}, "Against its snapshot"), h("p", {}, ...notes[drift.status]));
|
|
1273
|
-
if (drift.lines.length) panel.append(h("pre", {}, drift.lines.join("\n")));
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1446
|
+
const dx = event.clientX - Graph.#drag.start.x;
|
|
1447
|
+
const dy = event.clientY - Graph.#drag.start.y;
|
|
1448
|
+
if (Math.hypot(dx, dy) > 3) Graph.#drag.moved = true;
|
|
1449
|
+
if (!Graph.#drag.moved) return;
|
|
1276
1450
|
|
|
1277
|
-
|
|
1451
|
+
if (Graph.#drag.id)
|
|
1452
|
+
Graph.#positions.set(Graph.#drag.id, {
|
|
1453
|
+
x: Graph.#drag.origin.x + dx / Graph.#view.k,
|
|
1454
|
+
y: Graph.#drag.origin.y + dy / Graph.#view.k,
|
|
1455
|
+
});
|
|
1456
|
+
else Graph.#view = { ...Graph.#view, x: Graph.#drag.origin.x + dx, y: Graph.#drag.origin.y + dy };
|
|
1278
1457
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1458
|
+
Graph.#place();
|
|
1459
|
+
});
|
|
1281
1460
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
"ul",
|
|
1285
|
-
{},
|
|
1286
|
-
...edges.map((edge) => {
|
|
1287
|
-
const other = nodeById(edge.from === node.id ? edge.to : edge.from);
|
|
1288
|
-
return h(
|
|
1289
|
-
"li",
|
|
1290
|
-
{},
|
|
1291
|
-
link(edge.label, () => select({ edge })),
|
|
1292
|
-
h("span", { class: "muted" }, ` with ${other?.label ?? "?"}`)
|
|
1293
|
-
);
|
|
1294
|
-
})
|
|
1295
|
-
)
|
|
1296
|
-
);
|
|
1297
|
-
}
|
|
1461
|
+
Graph.#svg.addEventListener("pointerup", () => {
|
|
1462
|
+
if (!Graph.#drag) return;
|
|
1298
1463
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
const to = nodeById(edge.to);
|
|
1303
|
-
|
|
1304
|
-
panel.append(
|
|
1305
|
-
h("h2", {}, edge.label),
|
|
1306
|
-
h(
|
|
1307
|
-
"div",
|
|
1308
|
-
{ class: "kind" },
|
|
1309
|
-
`${LAYER_NAMES[edge.layer]}, between `,
|
|
1310
|
-
link(from?.label ?? edge.from, () => select({ node: edge.from })),
|
|
1311
|
-
" and ",
|
|
1312
|
-
link(to?.label ?? edge.to, () => select({ node: edge.to }))
|
|
1313
|
-
)
|
|
1314
|
-
);
|
|
1464
|
+
const ended = Graph.#drag;
|
|
1465
|
+
Graph.#drag = undefined;
|
|
1466
|
+
Graph.#svg.classList.remove("panning");
|
|
1315
1467
|
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (edge.vlans?.length) panel.append(h("h3", {}, "VLANs"), vlanChips(edge.vlans));
|
|
1319
|
-
}
|
|
1468
|
+
if (ended.moved && ended.id) Graph.#savePositions();
|
|
1469
|
+
if (ended.moved) return;
|
|
1320
1470
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1471
|
+
// A press without a move is a click: on a node it selects the node, and on an empty spot it clears the selection.
|
|
1472
|
+
Graph.#select(ended.id ? { node: ended.id } : undefined);
|
|
1473
|
+
});
|
|
1323
1474
|
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
h("span", { class: "count" }, String(count))
|
|
1331
|
-
);
|
|
1332
|
-
button.addEventListener("click", () => go(`graph/${name}`));
|
|
1333
|
-
layersNav.append(button);
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1475
|
+
Graph.#svg.addEventListener(
|
|
1476
|
+
"wheel",
|
|
1477
|
+
(event) => {
|
|
1478
|
+
event.preventDefault();
|
|
1479
|
+
if (Graph.#gliding !== undefined) cancelAnimationFrame(Graph.#gliding);
|
|
1480
|
+
Graph.#gliding = undefined;
|
|
1336
1481
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1482
|
+
const point = Graph.#toWorld(event);
|
|
1483
|
+
const k = Math.min(Math.max(Graph.#view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
|
|
1484
|
+
const box = Graph.#svg.getBoundingClientRect();
|
|
1339
1485
|
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1486
|
+
Graph.#view = { k, x: event.clientX - box.left - point.x * k, y: event.clientY - box.top - point.y * k };
|
|
1487
|
+
Graph.#place();
|
|
1488
|
+
},
|
|
1489
|
+
{ passive: false }
|
|
1490
|
+
);
|
|
1344
1491
|
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
// Keys 1 to 6 pick a layer.
|
|
1348
|
-
const chosen = LAYERS[Number(event.key) - 1];
|
|
1349
|
-
if (chosen) go(`graph/${chosen}`);
|
|
1350
|
-
});
|
|
1351
|
-
|
|
1352
|
-
/** The layer shown last, or the first. */
|
|
1353
|
-
export const currentLayer = () => layer;
|
|
1354
|
-
|
|
1355
|
-
/**
|
|
1356
|
-
* Take a new graph, when the config changed. Nodes keep their places, and a selection whose node or edge is gone is dropped.
|
|
1357
|
-
*
|
|
1358
|
-
* @param {Annotated} next
|
|
1359
|
-
*/
|
|
1360
|
-
export function setGraph(next) {
|
|
1361
|
-
data = next;
|
|
1362
|
-
|
|
1363
|
-
if (selection && "node" in selection && !nodeById(selection.node)) selection = undefined;
|
|
1364
|
-
if (selection && "edge" in selection) {
|
|
1365
|
-
const { layer: was, from, to, label } = selection.edge;
|
|
1366
|
-
const same = data.edges.find((edge) => edge.layer === was && edge.from === from && edge.to === to && edge.label === label);
|
|
1367
|
-
selection = same ? { edge: same } : undefined;
|
|
1368
|
-
}
|
|
1492
|
+
Dom.byId("fit").addEventListener("click", () => Graph.#fit());
|
|
1369
1493
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1494
|
+
Dom.byId("lay-out").addEventListener("click", () => {
|
|
1495
|
+
if (!Graph.#data) return;
|
|
1496
|
+
Graph.#layOut(Graph.#layer, true);
|
|
1497
|
+
Graph.#settle();
|
|
1498
|
+
Graph.#draw();
|
|
1499
|
+
Graph.#fit();
|
|
1500
|
+
});
|
|
1372
1501
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
*
|
|
1376
|
-
* @param {Layer} next
|
|
1377
|
-
*/
|
|
1378
|
-
export function showLayer(next) {
|
|
1379
|
-
if (next !== layer) {
|
|
1380
|
-
if (selection && "edge" in selection) selection = undefined;
|
|
1381
|
-
vlanFocus = undefined;
|
|
1382
|
-
pathFocus = undefined;
|
|
1383
|
-
touring = undefined;
|
|
1384
|
-
tourStep = 0;
|
|
1385
|
-
restartTour();
|
|
1386
|
-
}
|
|
1502
|
+
// Presenting keeps the whole network in view as the screen changes size.
|
|
1503
|
+
window.addEventListener("resize", () => (Graph.#presenting ? Graph.#frameTour(false) : Graph.#place()));
|
|
1387
1504
|
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
pendingVlan = undefined;
|
|
1391
|
-
}
|
|
1505
|
+
Graph.#presentButton.addEventListener("click", () => Graph.#setPresenting(!Graph.#presenting));
|
|
1506
|
+
Graph.#setPresenting(Graph.#presenting);
|
|
1392
1507
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1508
|
+
// A hidden page's dots stop moving.
|
|
1509
|
+
document.addEventListener("visibilitychange", () => {
|
|
1510
|
+
if (!(Graph.#svg instanceof SVGSVGElement)) return;
|
|
1511
|
+
if (document.hidden) Graph.#svg.pauseAnimations();
|
|
1512
|
+
else Graph.#svg.unpauseAnimations();
|
|
1513
|
+
});
|
|
1397
1514
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
layer = next;
|
|
1404
|
-
store.set("layer", layer);
|
|
1405
|
-
layOut(layer, false);
|
|
1406
|
-
|
|
1407
|
-
if (arrival === "cables") glideNodes(from);
|
|
1408
|
-
else settle();
|
|
1409
|
-
|
|
1410
|
-
showLayerButtons();
|
|
1411
|
-
draw(arrival);
|
|
1412
|
-
showPanel();
|
|
1413
|
-
showCaption();
|
|
1414
|
-
|
|
1415
|
-
// Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read. A layer shown before
|
|
1416
|
-
// comes back as the reader left it, and one shown for the first time glides into view.
|
|
1417
|
-
if (presenting && arrival) fit();
|
|
1418
|
-
else if (!drawn && data) fit(true);
|
|
1419
|
-
else if (arrival === "cables") {
|
|
1420
|
-
const left = views.get(layer);
|
|
1421
|
-
if (left) glideTo(left);
|
|
1422
|
-
else fit(true, true);
|
|
1423
|
-
}
|
|
1424
|
-
drawn = data !== undefined;
|
|
1425
|
-
}
|
|
1515
|
+
// Saving the layer shown, as it looks.
|
|
1516
|
+
Dom.byId("save-svg").addEventListener("click", () => {
|
|
1517
|
+
if (!(Graph.#viewport instanceof SVGGraphicsElement)) return;
|
|
1518
|
+
Export.save(`circuit-${Graph.#layer}.svg`, new Blob([Export.svg(Graph.#svg, Graph.#viewport).text], { type: "image/svg+xml" }));
|
|
1519
|
+
});
|
|
1426
1520
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
*/
|
|
1432
|
-
export function selectDevice(name) {
|
|
1433
|
-
selection = { node: `device:${name}` };
|
|
1434
|
-
}
|
|
1521
|
+
Dom.byId("save-png").addEventListener("click", async () => {
|
|
1522
|
+
if (!(Graph.#viewport instanceof SVGGraphicsElement)) return;
|
|
1523
|
+
Export.save(`circuit-${Graph.#layer}.png`, await Export.png(Export.svg(Graph.#svg, Graph.#viewport)));
|
|
1524
|
+
});
|
|
1435
1525
|
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
*
|
|
1439
|
-
* @param {{ name: string, added: GraphEdge[], removed: GraphEdge[] }} next
|
|
1440
|
-
*/
|
|
1441
|
-
export function setComparison(next) {
|
|
1442
|
-
comparison = { name: next.name, added: new Set(next.added.map(changeKey)), removed: next.removed };
|
|
1443
|
-
selection = undefined;
|
|
1444
|
-
}
|
|
1526
|
+
document.addEventListener("keydown", (event) => {
|
|
1527
|
+
if (event.target instanceof HTMLInputElement || Graph.#graphView.hidden) return;
|
|
1445
1528
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
pendingPath = path;
|
|
1453
|
-
selection = undefined;
|
|
1454
|
-
}
|
|
1529
|
+
if (event.key === "Escape") {
|
|
1530
|
+
if (Graph.#presenting) Graph.#setPresenting(false);
|
|
1531
|
+
Graph.#select(undefined);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (event.key === "p" && !event.ctrlKey && !event.metaKey && !event.altKey) Graph.#setPresenting(!Graph.#presenting);
|
|
1455
1535
|
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
export function lightVlan(id) {
|
|
1462
|
-
pendingVlan = id;
|
|
1463
|
-
selection = undefined;
|
|
1536
|
+
// Keys 1 to 6 pick a layer.
|
|
1537
|
+
const chosen = Graph.LAYERS[Number(event.key) - 1];
|
|
1538
|
+
if (chosen) Dom.go(`graph/${chosen}`);
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1464
1541
|
}
|