@takodotid/circuit 0.43.1 → 1.0.0-rc.2

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.
Files changed (256) hide show
  1. package/README.md +115 -37
  2. package/package.json +21 -58
  3. package/src/checks/across.ts +402 -0
  4. package/src/checks/built-in.ts +256 -0
  5. package/src/checks/device.ts +436 -0
  6. package/src/checks/index.ts +53 -0
  7. package/src/cli/advisories.ts +108 -0
  8. package/src/cli/app.ts +71 -0
  9. package/src/cli/editor-types.generated.ts +30 -0
  10. package/src/cli/editor-types.ts +69 -0
  11. package/src/cli/format.ts +124 -0
  12. package/src/cli/git-hooks.ts +79 -0
  13. package/src/cli/index.ts +350 -1170
  14. package/src/cli/migrate.ts +554 -0
  15. package/src/cli/project-commands.ts +656 -0
  16. package/src/cli/releases.ts +374 -0
  17. package/src/cli/remember.ts +339 -0
  18. package/src/cli/rename.ts +27 -33
  19. package/src/cli/secrets-password.ts +60 -0
  20. package/src/cli/secrets.ts +258 -0
  21. package/src/cli/setup.ts +300 -0
  22. package/src/cli/signing.ts +23 -0
  23. package/src/cli/text-editor.ts +268 -0
  24. package/src/cli/ui.ts +298 -0
  25. package/src/cli/version.ts +2 -0
  26. package/src/cli/versions.ts +158 -0
  27. package/src/config/address.ts +102 -0
  28. package/src/config/circuit.d.ts +30 -0
  29. package/src/config/common.ts +65 -0
  30. package/src/config/communities.ts +479 -0
  31. package/src/config/define.ts +684 -0
  32. package/src/config/device.ts +164 -0
  33. package/src/config/firewall.ts +150 -0
  34. package/src/config/index.ts +22 -0
  35. package/src/{schema → config}/interfaces.ts +121 -85
  36. package/src/config/keys.ts +434 -0
  37. package/src/config/management.ts +61 -0
  38. package/src/config/merge.ts +335 -0
  39. package/src/config/models.ts +70 -0
  40. package/src/config/monitoring.ts +64 -0
  41. package/src/config/network.ts +180 -0
  42. package/src/config/presets.ts +149 -0
  43. package/src/config/routing.ts +401 -0
  44. package/src/config/services.ts +51 -0
  45. package/src/config/switching.ts +84 -0
  46. package/src/config/system.ts +103 -0
  47. package/src/{schema → config}/time-zones.ts +1 -1
  48. package/src/core/address.ts +1 -1
  49. package/src/core/cabling.ts +8 -43
  50. package/src/core/graph.ts +40 -0
  51. package/src/{integrations → core}/registries.ts +5 -3
  52. package/src/core/secrets-file.ts +539 -0
  53. package/src/core/secrets.ts +83 -64
  54. package/src/core/topology.ts +103 -146
  55. package/src/core/utils.ts +12 -1
  56. package/src/model.ts +83 -0
  57. package/src/platforms/derive.ts +64 -37
  58. package/src/platforms/finding.ts +9 -0
  59. package/src/platforms/fortios/firewall.ts +76 -64
  60. package/src/platforms/fortios/index.ts +189 -125
  61. package/src/platforms/fortios/names.ts +51 -18
  62. package/src/platforms/fortios/render.ts +69 -62
  63. package/src/platforms/fortios/routing.ts +89 -58
  64. package/src/platforms/fortios/vdoms.ts +47 -16
  65. package/src/platforms/junos/TRAPS.md +2 -2
  66. package/src/platforms/junos/bgp.ts +63 -42
  67. package/src/platforms/junos/index.ts +122 -89
  68. package/src/platforms/junos/render.ts +139 -104
  69. package/src/platforms/platform.ts +10 -8
  70. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  71. package/src/platforms/raisecom-ros/index.ts +89 -70
  72. package/src/platforms/raisecom-ros/render.ts +49 -45
  73. package/src/platforms/routeros/TRAPS.md +3 -3
  74. package/src/platforms/routeros/index.ts +121 -60
  75. package/src/platforms/routeros/render.ts +214 -195
  76. package/src/platforms/vrp/TRAPS.md +1 -1
  77. package/src/platforms/vrp/index.ts +74 -50
  78. package/src/platforms/vrp/render.ts +67 -56
  79. package/src/project/project.ts +725 -0
  80. package/src/studio/app/env.d.ts +9 -0
  81. package/src/studio/app/index.html +18 -0
  82. package/src/studio/app/src/App.vue +95 -0
  83. package/src/studio/app/src/components/AddressesView.vue +38 -0
  84. package/src/studio/app/src/components/CablesView.vue +29 -0
  85. package/src/studio/app/src/components/CompareView.vue +160 -0
  86. package/src/studio/app/src/components/DataTable.vue +49 -0
  87. package/src/studio/app/src/components/DeviceView.vue +237 -0
  88. package/src/studio/app/src/components/DevicesView.vue +32 -0
  89. package/src/studio/app/src/components/Faceplate.vue +75 -0
  90. package/src/studio/app/src/components/GraphView.vue +282 -0
  91. package/src/studio/app/src/components/LineDiff.vue +24 -0
  92. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  93. package/src/studio/app/src/components/SearchView.vue +72 -0
  94. package/src/studio/app/src/components/SecretsView.vue +112 -0
  95. package/src/studio/app/src/components/SessionsView.vue +35 -0
  96. package/src/studio/app/src/components/Settings.vue +95 -0
  97. package/src/studio/app/src/components/ToolsView.vue +118 -0
  98. package/src/studio/app/src/components/VlansView.vue +50 -0
  99. package/src/studio/app/src/lib/csv.ts +19 -0
  100. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  101. package/src/studio/app/src/lib/format.ts +38 -0
  102. package/src/studio/app/src/lib/net.ts +68 -0
  103. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  104. package/src/studio/app/src/lib/store.ts +71 -0
  105. package/src/studio/app/src/lib/tools.ts +217 -0
  106. package/src/studio/app/src/main.ts +9 -0
  107. package/src/studio/{public → app/src}/style.css +14 -4
  108. package/src/studio/app/tsconfig.json +17 -0
  109. package/src/studio/app/vite.config.ts +16 -0
  110. package/src/studio/assets.generated.ts +3 -0
  111. package/src/studio/data.ts +96 -188
  112. package/src/studio/git.ts +27 -43
  113. package/src/studio/server.ts +196 -107
  114. package/src/studio/types.ts +141 -0
  115. package/src/transport/console.ts +3 -3
  116. package/src/transport/ssh.ts +1 -1
  117. package/dist/chunks/index-3b343xva.js +0 -177
  118. package/dist/chunks/index-6wb41q67.js +0 -62
  119. package/dist/chunks/index-n7w3tg5y.js +0 -149
  120. package/dist/chunks/index-yzw86rc8.js +0 -921
  121. package/dist/chunks/new-w3zys5y4.js +0 -172
  122. package/dist/chunks/server-cxr9ynxc.js +0 -259
  123. package/dist/cli/index.js +0 -12922
  124. package/dist/helpers/index.js +0 -881
  125. package/dist/index.js +0 -28
  126. package/dist/types/core/address-space.d.ts +0 -62
  127. package/dist/types/core/address.d.ts +0 -44
  128. package/dist/types/core/cabling.d.ts +0 -41
  129. package/dist/types/core/cross-checks.d.ts +0 -28
  130. package/dist/types/core/define.d.ts +0 -79
  131. package/dist/types/core/project-files.d.ts +0 -12
  132. package/dist/types/core/schema-docs.d.ts +0 -25
  133. package/dist/types/core/secrets.d.ts +0 -62
  134. package/dist/types/core/topology.d.ts +0 -96
  135. package/dist/types/core/utils.d.ts +0 -10
  136. package/dist/types/core/validator.d.ts +0 -23
  137. package/dist/types/helpers/bgp.d.ts +0 -195
  138. package/dist/types/helpers/cables.d.ts +0 -69
  139. package/dist/types/helpers/checks.d.ts +0 -36
  140. package/dist/types/helpers/exchange.d.ts +0 -55
  141. package/dist/types/helpers/filters.d.ts +0 -51
  142. package/dist/types/helpers/index.d.ts +0 -14
  143. package/dist/types/helpers/route-server.d.ts +0 -52
  144. package/dist/types/helpers/upstreams.d.ts +0 -192
  145. package/dist/types/index.d.ts +0 -10
  146. package/dist/types/integrations/librenms.d.ts +0 -60
  147. package/dist/types/integrations/netbox.d.ts +0 -89
  148. package/dist/types/integrations/peeringdb.d.ts +0 -24
  149. package/dist/types/integrations/prometheus.d.ts +0 -24
  150. package/dist/types/integrations/registries.d.ts +0 -8
  151. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  152. package/dist/types/platforms/catalog.d.ts +0 -109
  153. package/dist/types/platforms/derive.d.ts +0 -17
  154. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  155. package/dist/types/platforms/fortios/index.d.ts +0 -140
  156. package/dist/types/platforms/fortios/models.d.ts +0 -14
  157. package/dist/types/platforms/fortios/names.d.ts +0 -52
  158. package/dist/types/platforms/fortios/plan.d.ts +0 -114
  159. package/dist/types/platforms/fortios/render.d.ts +0 -77
  160. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  161. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  162. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  163. package/dist/types/platforms/index.d.ts +0 -13
  164. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  165. package/dist/types/platforms/junos/index.d.ts +0 -114
  166. package/dist/types/platforms/junos/models.d.ts +0 -14
  167. package/dist/types/platforms/junos/render.d.ts +0 -154
  168. package/dist/types/platforms/line-diff.d.ts +0 -67
  169. package/dist/types/platforms/platform.d.ts +0 -135
  170. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  171. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  172. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  173. package/dist/types/platforms/routeros/export.d.ts +0 -59
  174. package/dist/types/platforms/routeros/index.d.ts +0 -116
  175. package/dist/types/platforms/routeros/models.d.ts +0 -18
  176. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  177. package/dist/types/platforms/routeros/render.d.ts +0 -250
  178. package/dist/types/platforms/vrp/index.d.ts +0 -83
  179. package/dist/types/platforms/vrp/models.d.ts +0 -12
  180. package/dist/types/platforms/vrp/render.d.ts +0 -96
  181. package/dist/types/schema/common.d.ts +0 -30
  182. package/dist/types/schema/device.d.ts +0 -126
  183. package/dist/types/schema/firewall.d.ts +0 -131
  184. package/dist/types/schema/index.d.ts +0 -8
  185. package/dist/types/schema/interfaces.d.ts +0 -205
  186. package/dist/types/schema/policy.d.ts +0 -107
  187. package/dist/types/schema/routing.d.ts +0 -161
  188. package/dist/types/schema/system.d.ts +0 -198
  189. package/dist/types/schema/time-zones.d.ts +0 -2
  190. package/dist/types/transport/console.d.ts +0 -115
  191. package/dist/types/transport/path-check.d.ts +0 -32
  192. package/dist/types/transport/ssh.d.ts +0 -27
  193. package/src/cli/graph-format.ts +0 -91
  194. package/src/cli/new.ts +0 -234
  195. package/src/core/address-space.ts +0 -320
  196. package/src/core/cross-checks.ts +0 -369
  197. package/src/core/define.ts +0 -124
  198. package/src/core/project-files.ts +0 -35
  199. package/src/core/schema-docs.ts +0 -174
  200. package/src/core/validator.ts +0 -508
  201. package/src/helpers/bgp.ts +0 -473
  202. package/src/helpers/cables.ts +0 -74
  203. package/src/helpers/checks.ts +0 -201
  204. package/src/helpers/exchange.ts +0 -123
  205. package/src/helpers/filters.ts +0 -107
  206. package/src/helpers/index.ts +0 -25
  207. package/src/helpers/route-server.ts +0 -212
  208. package/src/helpers/upstreams.ts +0 -369
  209. package/src/index.ts +0 -13
  210. package/src/integrations/librenms.ts +0 -291
  211. package/src/integrations/netbox.ts +0 -620
  212. package/src/integrations/peeringdb.ts +0 -173
  213. package/src/integrations/prometheus.ts +0 -90
  214. package/src/schema/common.ts +0 -41
  215. package/src/schema/device.ts +0 -145
  216. package/src/schema/firewall.ts +0 -140
  217. package/src/schema/index.ts +0 -8
  218. package/src/schema/policy.ts +0 -115
  219. package/src/schema/routing.ts +0 -173
  220. package/src/schema/system.ts +0 -203
  221. package/src/studio/public/app.js +0 -186
  222. package/src/studio/public/compare.js +0 -250
  223. package/src/studio/public/dom.js +0 -138
  224. package/src/studio/public/evaluate.js +0 -687
  225. package/src/studio/public/export.js +0 -130
  226. package/src/studio/public/graph.js +0 -1541
  227. package/src/studio/public/index.html +0 -83
  228. package/src/studio/public/net.js +0 -134
  229. package/src/studio/public/tables.js +0 -1289
  230. package/src/studio/public/tools.js +0 -610
  231. package/templates/colocation/checks.ts +0 -53
  232. package/templates/colocation/circuit.config.ts +0 -9
  233. package/templates/colocation/edge-01.ts +0 -133
  234. package/templates/colocation/routing.ts +0 -13
  235. package/templates/colocation/tenancy.ts +0 -76
  236. package/templates/colocation/tenants/acme.ts +0 -19
  237. package/templates/colocation/tenants/index.ts +0 -6
  238. package/templates/colocation/tor-01.ts +0 -33
  239. package/templates/edge-router/checks.ts +0 -20
  240. package/templates/edge-router/circuit.config.ts +0 -8
  241. package/templates/edge-router/edge-01.ts +0 -110
  242. package/templates/edge-router/routing.ts +0 -13
  243. package/templates/project/AGENTS.md +0 -51
  244. package/templates/project/CLAUDE.md +0 -1
  245. package/templates/project/README.md +0 -16
  246. package/templates/project/gitignore +0 -5
  247. package/templates/project/tsconfig.json +0 -13
  248. package/templates/single-site/checks.ts +0 -26
  249. package/templates/single-site/circuit.config.ts +0 -8
  250. package/templates/single-site/router.ts +0 -86
  251. package/templates/single-site/site.ts +0 -18
  252. package/templates/single-site/switch.ts +0 -34
  253. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  254. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  256. /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
@@ -1,1541 +0,0 @@
1
- // @ts-check
2
- /// <reference lib="dom" />
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
-
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
- /** @typedef {{ x: number, y: number }} Point */
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
- /** @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
- };
29
-
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
- };
39
-
40
- static LAYERS = /** @type {Layer[]} */ (Object.keys(Graph.LAYER_NAMES));
41
-
42
- /**
43
- * @param {string | undefined} name
44
- * @returns {name is Layer}
45
- */
46
- static isLayer(name) {
47
- return Graph.LAYERS.includes(/** @type {Layer} */ (name));
48
- }
49
-
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;
88
-
89
- /** @param {GraphEdge} edge */
90
- static #edgeKey(edge) {
91
- return `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
92
- }
93
-
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();
97
-
98
- /** @typedef {Record<string, [number, number]>} SavedPlaces */
99
-
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"));
103
-
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));
115
- }
116
-
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);
119
-
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 = [];
124
-
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
- }
141
-
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);
171
- }
172
-
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));
176
-
177
- function park() {
178
- if (!loose.length) return;
179
-
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;
185
-
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
- }
192
-
193
- places.set(node.id, { x: x + half, y });
194
- x += half * 2 + 24;
195
- }
196
-
197
- Graph.#savePositions();
198
- }
199
-
200
- nodes = linked;
201
-
202
- // A new node starts beside a neighbor already placed, or on a circle.
203
- const radius = 60 * Math.sqrt(nodes.length + 1);
204
-
205
- for (const node of nodes) {
206
- if (places.has(node.id)) continue;
207
-
208
- const start = seed?.get(node.id);
209
- if (start) {
210
- places.set(node.id, { ...start });
211
- continue;
212
- }
213
-
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 });
220
- }
221
-
222
- const moving = nodes.filter((node) => !fixed.has(node.id));
223
- if (!moving.length) return park();
224
-
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;
228
-
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
- }
287
- }
288
-
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.
290
-
291
- for (let round = 0; round < 80; round++) {
292
- let overlapping = false;
293
-
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;
299
-
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;
305
-
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;
311
-
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
- }
315
- }
316
-
317
- if (!overlapping) break;
318
- }
319
-
320
- park();
321
- Graph.#savePositions();
322
- }
323
-
324
- // What is drawn.
325
-
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
-
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
- }
352
-
353
- static #visibleEdges() {
354
- return Graph.#edgesOn(Graph.#layer);
355
- }
356
-
357
- static #visibleNodes() {
358
- return Graph.#nodesOn(Graph.#layer);
359
- }
360
-
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
- }
379
-
380
- /** The node glide between two layers' places, which a press stops. @type {number | undefined} */
381
- static #regrouping;
382
-
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
- }
389
-
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
-
398
- const to = Graph.#layoutOf(Graph.#layer);
399
- if (Dom.still.matches || from === to) return;
400
-
401
- /** @type {Map<string, Point>} */
402
- const shown = new Map();
403
- const began = performance.now();
404
-
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;
409
-
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
-
415
- Graph.#positions = t < 1 ? shown : to;
416
- Graph.#place();
417
- Graph.#regrouping = t < 1 ? requestAnimationFrame(step) : undefined;
418
- };
419
-
420
- Graph.#positions = shown;
421
- step(began);
422
- }
423
-
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;
435
-
436
- const findings = Graph.#data.findings.filter((finding) => finding.device === node.label);
437
- if (findings.some((finding) => finding.level === "error")) return "error";
438
-
439
- const drift = Graph.#data.drift[node.label]?.status;
440
- if (findings.length || drift === "differs" || drift === "cannot plan") return "warning";
441
-
442
- return undefined;
443
- }
444
-
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
- };
463
- }
464
-
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);
469
-
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
- }
474
-
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
- }
483
-
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;
486
-
487
- /** @type {Record<Layer, string>} */
488
- static #MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
489
-
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]);
505
- }
506
-
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
- }
530
-
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
- });
559
- }
560
-
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
- }
586
- }
587
-
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
- });
602
- }
603
-
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
- }
619
-
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
- }
624
- }
625
-
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
- }
680
-
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
- }
725
- }
726
- group.insertBefore(chassis, texts[0] ?? null);
727
- }
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);
741
- }
742
-
743
- Graph.#place();
744
- Graph.#emphasize();
745
-
746
- if (arrival) Graph.#arrive(new Set([...Graph.#nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
747
- }
748
-
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
- }
781
-
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
- }
788
-
789
- // Panning, zooming and dragging.
790
- /** @type {{ id?: string, start: Point, origin: Point, moved: boolean } | undefined} */
791
- static #drag;
792
-
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
- };
803
- }
804
-
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();
820
-
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");
826
- }
827
-
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;
830
-
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
- }
848
-
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
- }
871
-
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;
881
-
882
- if (glide) return Graph.#glideTo(target);
883
- Graph.#view = target;
884
- Graph.#place();
885
- }
886
-
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
- }
925
-
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;
940
-
941
- if (glide) return Graph.#glideTo(target);
942
- Graph.#view = target;
943
- Graph.#place();
944
- }
945
-
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
- }
970
-
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;
973
-
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;
976
-
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;
979
-
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
- }
988
-
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
- }
1010
-
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();
1016
-
1017
- const next = Graph.#nextLayer();
1018
- Graph.#leaving = setTimeout(() => {
1019
- Graph.#leaving = undefined;
1020
-
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);
1024
- }
1025
-
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
- }
1033
-
1034
- /** @param {boolean} on */
1035
- static #setPresenting(on) {
1036
- Graph.#presenting = on;
1037
- Dom.store.set("present", on);
1038
-
1039
- document.body.classList.toggle("presenting", on);
1040
- Graph.#presentButton.setAttribute("aria-pressed", String(on));
1041
-
1042
- Graph.#touring = undefined;
1043
- if (on) Graph.#selection = undefined;
1044
- Graph.#restartTour();
1045
-
1046
- if (Graph.#graphView.hidden || !Graph.#data) return;
1047
-
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
- }
1054
-
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);
1061
-
1062
- Graph.#selection = next;
1063
- Graph.#pathFocus = undefined;
1064
- Graph.#emphasize();
1065
- Graph.#showPanel();
1066
-
1067
- if (changed) Dom.enter(Graph.#panel);
1068
- }
1069
-
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
- }
1092
-
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
- }
1107
-
1108
- return chips;
1109
- }
1110
-
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")
1139
- );
1140
- }
1141
-
1142
- static #showPanel() {
1143
- Graph.#panel.replaceChildren();
1144
- if (!Graph.#data) return;
1145
-
1146
- if (Graph.#selection && "node" in Graph.#selection) {
1147
- const node = Graph.#nodeById(Graph.#selection.node);
1148
- if (node) return Graph.#showNode(node);
1149
- }
1150
-
1151
- if (Graph.#selection && "edge" in Graph.#selection) return Graph.#showEdge(Graph.#selection.edge);
1152
-
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");
1157
-
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
- );
1162
-
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
- }
1181
-
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);
1186
-
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
- }
1190
-
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
- }
1213
-
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
- }
1220
-
1221
- Graph.#panel.append(Dom.h("h3", {}, "Legend"), Graph.#legend());
1222
- }
1223
-
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
- );
1246
-
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
- }
1260
-
1261
- const edges = Graph.#visibleEdges().filter((edge) => edge.from === node.id || edge.to === node.id);
1262
-
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."));
1265
-
1266
- Graph.#panel.append(
1267
- Dom.h(
1268
- "ul",
1269
- {},
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
- })
1279
- )
1280
- );
1281
- }
1282
-
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
- );
1299
-
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
- }
1305
-
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
- }
1321
-
1322
- /** The layer shown last, or the first. */
1323
- static currentLayer() {
1324
- return Graph.#layer;
1325
- }
1326
-
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
- }
1343
-
1344
- Graph.#layOut(Graph.#layer, false);
1345
- }
1346
-
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
- }
1361
-
1362
- if (Graph.#pendingVlan !== undefined) {
1363
- Graph.#vlanFocus = Graph.#pendingVlan;
1364
- Graph.#pendingVlan = undefined;
1365
- }
1366
-
1367
- if (Graph.#pendingPath) {
1368
- Graph.#pathFocus = new Set(Graph.#pendingPath.map(Graph.#edgeKey));
1369
- Graph.#pendingPath = undefined;
1370
- }
1371
-
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;
1399
- }
1400
-
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}` };
1408
- }
1409
-
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
- }
1419
-
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;
1428
- }
1429
-
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;
1438
- }
1439
-
1440
- static {
1441
- Graph.#svg.addEventListener("pointerdown", (event) => Graph.#startDrag(event));
1442
-
1443
- Graph.#svg.addEventListener("pointermove", (event) => {
1444
- if (!Graph.#drag) return;
1445
-
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;
1450
-
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 };
1457
-
1458
- Graph.#place();
1459
- });
1460
-
1461
- Graph.#svg.addEventListener("pointerup", () => {
1462
- if (!Graph.#drag) return;
1463
-
1464
- const ended = Graph.#drag;
1465
- Graph.#drag = undefined;
1466
- Graph.#svg.classList.remove("panning");
1467
-
1468
- if (ended.moved && ended.id) Graph.#savePositions();
1469
- if (ended.moved) return;
1470
-
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
- });
1474
-
1475
- Graph.#svg.addEventListener(
1476
- "wheel",
1477
- (event) => {
1478
- event.preventDefault();
1479
- if (Graph.#gliding !== undefined) cancelAnimationFrame(Graph.#gliding);
1480
- Graph.#gliding = undefined;
1481
-
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();
1485
-
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
- );
1491
-
1492
- Dom.byId("fit").addEventListener("click", () => Graph.#fit());
1493
-
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
- });
1501
-
1502
- // Presenting keeps the whole network in view as the screen changes size.
1503
- window.addEventListener("resize", () => (Graph.#presenting ? Graph.#frameTour(false) : Graph.#place()));
1504
-
1505
- Graph.#presentButton.addEventListener("click", () => Graph.#setPresenting(!Graph.#presenting));
1506
- Graph.#setPresenting(Graph.#presenting);
1507
-
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
- });
1514
-
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
- });
1520
-
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
- });
1525
-
1526
- document.addEventListener("keydown", (event) => {
1527
- if (event.target instanceof HTMLInputElement || Graph.#graphView.hidden) return;
1528
-
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);
1535
-
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
- }
1541
- }