@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.
Files changed (240) hide show
  1. package/README.md +13 -14
  2. package/dist/chunks/index-20kcjxjg.js +174 -0
  3. package/dist/chunks/index-2r4bbhac.js +977 -0
  4. package/dist/chunks/index-n7w3tg5y.js +149 -0
  5. package/dist/chunks/new-w3zys5y4.js +172 -0
  6. package/dist/chunks/server-gzsf5me5.js +259 -0
  7. package/dist/cli/index.js +11347 -9018
  8. package/dist/helpers/index.js +594 -0
  9. package/dist/index.js +9 -28
  10. package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
  11. package/dist/types/core/address.d.ts +44 -0
  12. package/dist/types/core/cabling.d.ts +41 -0
  13. package/dist/types/core/cross-checks.d.ts +28 -0
  14. package/dist/types/core/define.d.ts +25 -3
  15. package/dist/types/core/project-files.d.ts +12 -0
  16. package/dist/types/core/schema-docs.d.ts +13 -4
  17. package/dist/types/core/secrets.d.ts +62 -21
  18. package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
  19. package/dist/types/core/utils.d.ts +8 -0
  20. package/dist/types/core/validator.d.ts +19 -0
  21. package/dist/types/helpers/bgp.d.ts +97 -0
  22. package/dist/types/helpers/checks.d.ts +32 -0
  23. package/dist/types/helpers/exchange.d.ts +49 -0
  24. package/dist/types/helpers/filters.d.ts +51 -0
  25. package/dist/types/helpers/index.d.ts +10 -0
  26. package/dist/types/helpers/route-server.d.ts +52 -0
  27. package/dist/types/index.d.ts +5 -3
  28. package/dist/types/integrations/librenms.d.ts +60 -0
  29. package/dist/types/integrations/netbox.d.ts +89 -0
  30. package/dist/types/integrations/peeringdb.d.ts +24 -0
  31. package/dist/types/integrations/prometheus.d.ts +24 -0
  32. package/dist/types/integrations/registries.d.ts +8 -0
  33. package/dist/types/platforms/bgp-wait.d.ts +18 -0
  34. package/dist/types/platforms/catalog.d.ts +109 -0
  35. package/dist/types/platforms/derive.d.ts +17 -0
  36. package/dist/types/platforms/fortios/firewall.d.ts +45 -0
  37. package/dist/types/platforms/fortios/index.d.ts +134 -0
  38. package/dist/types/platforms/fortios/names.d.ts +52 -0
  39. package/dist/types/platforms/fortios/plan.d.ts +104 -0
  40. package/dist/types/platforms/fortios/render.d.ts +75 -0
  41. package/dist/types/platforms/fortios/routing.d.ts +56 -0
  42. package/dist/types/platforms/fortios/tree.d.ts +37 -0
  43. package/dist/types/platforms/index.d.ts +13 -0
  44. package/dist/types/platforms/junos/bgp.d.ts +44 -0
  45. package/dist/types/platforms/junos/index.d.ts +114 -0
  46. package/dist/types/platforms/junos/render.d.ts +154 -0
  47. package/dist/types/platforms/line-diff.d.ts +67 -0
  48. package/dist/types/platforms/platform.d.ts +120 -0
  49. package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
  50. package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
  51. package/dist/types/platforms/routeros/export.d.ts +59 -0
  52. package/dist/types/platforms/routeros/index.d.ts +96 -0
  53. package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
  54. package/dist/types/platforms/routeros/plan.d.ts +54 -0
  55. package/dist/types/platforms/routeros/render.d.ts +240 -0
  56. package/dist/types/platforms/vrp/index.d.ts +83 -0
  57. package/dist/types/platforms/vrp/render.d.ts +96 -0
  58. package/dist/types/schema/common.d.ts +12 -6
  59. package/dist/types/schema/index.d.ts +1 -0
  60. package/dist/types/schema/interfaces.d.ts +14 -6
  61. package/dist/types/schema/policy.d.ts +18 -10
  62. package/dist/types/schema/routing.d.ts +14 -1
  63. package/dist/types/schema/system.d.ts +16 -6
  64. package/dist/types/schema/time-zones.d.ts +2 -0
  65. package/dist/types/transport/console.d.ts +79 -23
  66. package/dist/types/transport/path-check.d.ts +32 -0
  67. package/dist/types/transport/ssh.d.ts +17 -15
  68. package/package.json +17 -11
  69. package/src/cli/graph-format.ts +91 -0
  70. package/src/cli/index.ts +1074 -484
  71. package/src/cli/new.ts +208 -175
  72. package/src/cli/rename.ts +97 -91
  73. package/src/core/address-space.ts +320 -0
  74. package/src/core/address.ts +202 -0
  75. package/src/core/cabling.ts +154 -0
  76. package/src/core/cross-checks.ts +364 -0
  77. package/src/core/define.ts +26 -3
  78. package/src/core/project-files.ts +35 -0
  79. package/src/core/schema-docs.ts +138 -130
  80. package/src/core/secrets.ts +170 -76
  81. package/src/core/topology.ts +656 -0
  82. package/src/core/utils.ts +37 -0
  83. package/src/core/validator.ts +407 -0
  84. package/src/helpers/bgp.ts +301 -0
  85. package/src/helpers/checks.ts +178 -0
  86. package/src/helpers/exchange.ts +117 -0
  87. package/src/helpers/filters.ts +107 -0
  88. package/src/helpers/index.ts +12 -0
  89. package/src/helpers/route-server.ts +212 -0
  90. package/src/index.ts +6 -3
  91. package/src/integrations/librenms.ts +291 -0
  92. package/src/integrations/netbox.ts +620 -0
  93. package/src/integrations/peeringdb.ts +173 -0
  94. package/src/integrations/prometheus.ts +90 -0
  95. package/src/integrations/registries.ts +25 -0
  96. package/src/platforms/bgp-wait.ts +41 -0
  97. package/src/platforms/catalog.ts +69 -0
  98. package/src/platforms/derive.ts +117 -0
  99. package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
  100. package/src/platforms/fortios/firewall.ts +377 -0
  101. package/src/platforms/fortios/index.ts +1060 -0
  102. package/src/platforms/fortios/names.ts +130 -0
  103. package/src/platforms/fortios/plan.ts +929 -0
  104. package/src/platforms/fortios/render.ts +779 -0
  105. package/src/platforms/fortios/routing.ts +476 -0
  106. package/src/platforms/fortios/tree.ts +129 -0
  107. package/src/platforms/index.ts +36 -0
  108. package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
  109. package/src/platforms/junos/bgp.ts +293 -0
  110. package/src/platforms/junos/index.ts +1006 -0
  111. package/src/platforms/junos/render.ts +967 -0
  112. package/src/platforms/line-diff.ts +244 -0
  113. package/src/platforms/platform.ts +122 -0
  114. package/src/platforms/raisecom-ros/TRAPS.md +40 -0
  115. package/src/platforms/raisecom-ros/index.ts +731 -0
  116. package/src/platforms/raisecom-ros/render.ts +328 -0
  117. package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
  118. package/src/platforms/routeros/export.ts +342 -0
  119. package/src/platforms/routeros/index.ts +636 -0
  120. package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
  121. package/src/platforms/routeros/plan.ts +444 -0
  122. package/src/platforms/routeros/render.ts +1483 -0
  123. package/src/platforms/vrp/TRAPS.md +45 -0
  124. package/src/platforms/vrp/index.ts +652 -0
  125. package/src/platforms/vrp/render.ts +615 -0
  126. package/src/schema/common.ts +15 -6
  127. package/src/schema/index.ts +1 -0
  128. package/src/schema/interfaces.ts +15 -5
  129. package/src/schema/policy.ts +22 -10
  130. package/src/schema/routing.ts +16 -1
  131. package/src/schema/system.ts +16 -6
  132. package/src/schema/time-zones.ts +482 -0
  133. package/src/studio/data.ts +172 -168
  134. package/src/studio/git.ts +95 -90
  135. package/src/studio/public/app.js +161 -150
  136. package/src/studio/public/compare.js +219 -212
  137. package/src/studio/public/diff.js +77 -75
  138. package/src/studio/public/dom.js +118 -108
  139. package/src/studio/public/evaluate.js +584 -545
  140. package/src/studio/public/export.js +120 -118
  141. package/src/studio/public/graph.js +1343 -1266
  142. package/src/studio/public/net.js +115 -109
  143. package/src/studio/public/tables.js +1135 -1083
  144. package/src/studio/public/tools.js +561 -529
  145. package/src/studio/server.ts +202 -196
  146. package/src/transport/console.ts +363 -271
  147. package/src/transport/path-check.ts +91 -0
  148. package/src/transport/ssh.ts +98 -94
  149. package/templates/colocation/checks.ts +11 -6
  150. package/templates/colocation/edge-01.ts +9 -9
  151. package/templates/colocation/routing.ts +2 -2
  152. package/templates/colocation/tenancy.ts +3 -3
  153. package/templates/colocation/tor-01.ts +3 -3
  154. package/templates/edge-router/checks.ts +4 -4
  155. package/templates/edge-router/edge-01.ts +5 -5
  156. package/templates/edge-router/routing.ts +2 -2
  157. package/templates/project/AGENTS.md +5 -1
  158. package/templates/single-site/checks.ts +2 -2
  159. package/dist/chunks/index-47debt16.js +0 -956
  160. package/dist/chunks/index-v8zgr7wm.js +0 -128
  161. package/dist/chunks/new-ny89s7y7.js +0 -166
  162. package/dist/chunks/server-tjbb3rqc.js +0 -251
  163. package/dist/presets/index.js +0 -394
  164. package/dist/types/adapters/devices/catalog.d.ts +0 -86
  165. package/dist/types/adapters/devices/derive.d.ts +0 -7
  166. package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
  167. package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
  168. package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
  169. package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
  170. package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
  171. package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
  172. package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
  173. package/dist/types/adapters/devices/index.d.ts +0 -3
  174. package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
  175. package/dist/types/adapters/devices/junos/index.d.ts +0 -42
  176. package/dist/types/adapters/devices/junos/render.d.ts +0 -52
  177. package/dist/types/adapters/devices/lines.d.ts +0 -56
  178. package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
  179. package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
  180. package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
  181. package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
  182. package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
  183. package/dist/types/adapters/devices/types.d.ts +0 -40
  184. package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
  185. package/dist/types/adapters/registries/index.d.ts +0 -3
  186. package/dist/types/adapters/registries/librenms.d.ts +0 -15
  187. package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
  188. package/dist/types/adapters/registries/prometheus.d.ts +0 -16
  189. package/dist/types/core/across.d.ts +0 -4
  190. package/dist/types/core/addr.d.ts +0 -34
  191. package/dist/types/core/files.d.ts +0 -6
  192. package/dist/types/core/merge.d.ts +0 -4
  193. package/dist/types/core/time.d.ts +0 -2
  194. package/dist/types/core/validate.d.ts +0 -2
  195. package/dist/types/presets/index.d.ts +0 -183
  196. package/src/adapters/devices/catalog.ts +0 -41
  197. package/src/adapters/devices/derive.ts +0 -59
  198. package/src/adapters/devices/fortios/firewall.ts +0 -362
  199. package/src/adapters/devices/fortios/index.ts +0 -923
  200. package/src/adapters/devices/fortios/names.ts +0 -115
  201. package/src/adapters/devices/fortios/plan.ts +0 -888
  202. package/src/adapters/devices/fortios/render.ts +0 -768
  203. package/src/adapters/devices/fortios/routing.ts +0 -444
  204. package/src/adapters/devices/fortios/tree.ts +0 -121
  205. package/src/adapters/devices/index.ts +0 -9
  206. package/src/adapters/devices/junos/bgp.ts +0 -269
  207. package/src/adapters/devices/junos/index.ts +0 -862
  208. package/src/adapters/devices/junos/render.ts +0 -872
  209. package/src/adapters/devices/lines.ts +0 -212
  210. package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -25
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -693
  212. package/src/adapters/devices/routeros/export.ts +0 -321
  213. package/src/adapters/devices/routeros/index.ts +0 -346
  214. package/src/adapters/devices/routeros/plan.ts +0 -398
  215. package/src/adapters/devices/routeros/render.ts +0 -1490
  216. package/src/adapters/devices/types.ts +0 -38
  217. package/src/adapters/devices/vrp/TRAPS.md +0 -26
  218. package/src/adapters/devices/vrp/index.ts +0 -865
  219. package/src/adapters/registries/index.ts +0 -19
  220. package/src/adapters/registries/librenms.ts +0 -92
  221. package/src/adapters/registries/peeringdb.ts +0 -169
  222. package/src/adapters/registries/prometheus.ts +0 -71
  223. package/src/cli/graph.ts +0 -89
  224. package/src/core/across.ts +0 -350
  225. package/src/core/addr.ts +0 -174
  226. package/src/core/addresses.ts +0 -316
  227. package/src/core/files.ts +0 -32
  228. package/src/core/graph.ts +0 -640
  229. package/src/core/merge.ts +0 -19
  230. package/src/core/time.ts +0 -15
  231. package/src/core/validate.ts +0 -348
  232. package/src/presets/index.ts +0 -579
  233. /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
  234. /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
  235. /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
  236. /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
  237. /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
  238. /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
  239. /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
  240. /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 { byId, enter, go, h, link, s, still, store, svgById } from "./dom.js";
6
- import { changeKey } from "./evaluate.js";
7
- import { pngOf, save, svgOf } from "./export.js";
8
-
9
- /** @typedef {import("../../core/graph").GraphNode} GraphNode */
10
- /** @typedef {import("../../core/graph").GraphEdge} GraphEdge */
11
- /** @typedef {import("../../core/graph").Layer} Layer */
12
- /** @typedef {import("../../core/graph").CarriedVlan} CarriedVlan */
13
- /** @typedef {import("../../core/graph").Annotated} Annotated */
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
- export const LAYER_NAMES = {
20
- l1: "Cables",
21
- l2: "VLANs",
22
- l3: "Addresses",
23
- bgp: "BGP",
24
- ospf: "OSPF",
25
- tunnels: "Tunnels",
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
- /** @param {GraphNode} node */
147
- const halfWidth = (node) =>
148
- node.kind === "device" ? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60 : node.label.length * 4 + 30;
149
-
150
- const fixed = new Set(nodes.filter((node) => places.has(node.id)).map((node) => node.id));
151
- /** @type {Map<string, Set<string>>} */
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
- // 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.
163
- const loose = edges.length ? nodes.filter((node) => !neighbors.has(node.id) && !places.has(node.id)) : [];
164
- const linked = nodes.filter((node) => !loose.includes(node));
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
- function park() {
167
- if (!loose.length) return;
89
+ /** @param {GraphEdge} edge */
90
+ static #edgeKey(edge) {
91
+ return `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
92
+ }
168
93
 
169
- const placed = linked.flatMap((node) => places.get(node.id) ?? []);
170
- const left = placed.length ? Math.min(...placed.map((point) => point.x)) : 0;
171
- const right = left + Math.max(placed.length ? Math.max(...placed.map((point) => point.x)) - left : 0, 900);
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
- for (const node of [...loose].sort((a, b) => a.label.localeCompare(b.label))) {
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
- places.set(node.id, { x: x + half, y });
183
- x += half * 2 + 24;
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
- savePositions();
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 = linked;
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
- // A new node starts beside a neighbor already placed, or on a circle.
192
- const radius = 60 * Math.sqrt(nodes.length + 1);
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
- for (const node of nodes) {
195
- if (places.has(node.id)) continue;
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
- const start = seed?.get(node.id);
198
- if (start) {
199
- places.set(node.id, { ...start });
200
- continue;
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
- const angle = hash(node.id) * Math.PI * 2;
204
- const placed = [...(neighbors.get(node.id) ?? [])].map((id) => places.get(id)).find(Boolean);
205
- const distance = placed ? 90 : radius;
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
- places.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
209
- }
177
+ function park() {
178
+ if (!loose.length) return;
210
179
 
211
- const moving = nodes.filter((node) => !fixed.has(node.id));
212
- if (!moving.length) return park();
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
- const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
215
- const ideal = 170;
216
- const rounds = fixed.size ? 200 : 500;
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
- /** @param {string} id */
219
- const at = (id) => /** @type {Point} */ (places.get(id));
193
+ places.set(node.id, { x: x + half, y });
194
+ x += half * 2 + 24;
195
+ }
220
196
 
221
- for (let round = 0; round < rounds; round++) {
222
- const heat = 40 * (1 - round / rounds) + 1;
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
- for (const [index, a] of nodes.entries()) {
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
- // Two nodes in one place are pulled apart in a direction of their own.
233
- if (dx === 0 && dy === 0) {
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
- // Nodes far apart no longer push, so parts that are not connected stay near each other.
239
- const distance = Math.max(Math.hypot(dx, dy), 1);
240
- if (distance > ideal * 3) continue;
205
+ for (const node of nodes) {
206
+ if (places.has(node.id)) continue;
241
207
 
242
- const force = (ideal * ideal) / distance;
243
- pushOf(a.id).x += (dx / distance) * force;
244
- pushOf(a.id).y += (dy / distance) * force;
245
- pushOf(b.id).x -= (dx / distance) * force;
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
- for (const [from, to] of pairs) {
251
- if (!from || !to || !places.has(from) || !places.has(to)) continue;
252
-
253
- const dx = at(from).x - at(to).x;
254
- const dy = at(from).y - at(to).y;
255
- const distance = Math.max(Math.hypot(dx, dy), 1);
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
- for (const node of moving) {
264
- const point = at(node.id);
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
- // A pull to the middle keeps parts that are not connected from drifting away.
268
- force.x -= point.x * 0.15;
269
- force.y -= point.y * 0.15;
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
- const length = Math.max(Math.hypot(force.x, force.y), 1);
272
- const step = Math.min(length, heat);
273
- point.x += (force.x / length) * step;
274
- point.y += (force.y / length) * step;
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
- // 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.
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
- for (let round = 0; round < 80; round++) {
281
- let overlapping = false;
291
+ for (let round = 0; round < 80; round++) {
292
+ let overlapping = false;
282
293
 
283
- for (const [index, a] of nodes.entries()) {
284
- for (const b of nodes.slice(index + 1)) {
285
- const aMoves = !fixed.has(a.id);
286
- const bMoves = !fixed.has(b.id);
287
- if (!aMoves && !bMoves) continue;
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
- const pa = at(a.id);
290
- const pb = at(b.id);
291
- const overlapX = halfWidth(a) + halfWidth(b) + 24 - Math.abs(pa.x - pb.x);
292
- const overlapY = 96 - Math.abs(pa.y - pb.y);
293
- if (overlapX <= 0 || overlapY <= 0) continue;
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
- overlapping = true;
296
- const share = aMoves && bMoves ? 0.5 : 1;
297
- const alongX = overlapX < overlapY * 2;
298
- const sign = alongX ? Math.sign(pa.x - pb.x) || 1 : Math.sign(pa.y - pb.y) || 1;
299
- const distance = (alongX ? overlapX : overlapY) * share;
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
- if (aMoves) alongX ? (pa.x += sign * distance) : (pa.y += sign * distance);
302
- if (bMoves) alongX ? (pb.x -= sign * distance) : (pb.y -= sign * distance);
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
- if (!overlapping) break;
320
+ park();
321
+ Graph.#savePositions();
307
322
  }
308
323
 
309
- park();
310
- savePositions();
311
- }
324
+ // What is drawn.
312
325
 
313
- // What is drawn.
314
-
315
- /**
316
- * A layer's edges, with those a comparison says are gone.
317
- *
318
- * @param {Layer} name
319
- * @returns {DrawnEdge[]}
320
- */
321
- const edgesOn = (name) => {
322
- const current = data?.edges.filter((edge) => edge.layer === name) ?? [];
323
- if (!comparison) return current;
324
-
325
- // An edge the comparison says is gone is drawn when both its ends are still drawn.
326
- const nodes = new Set(data?.nodes.map((node) => node.id));
327
- const gone = comparison.removed.filter((edge) => edge.layer === name && nodes.has(edge.from) && nodes.has(edge.to));
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
- const visibleEdges = () => edgesOn(layer);
343
- const visibleNodes = () => nodesOn(layer);
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
- * Lay out a layer for its own lines. The cables layer comes first, as every other layer's new nodes start from it.
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
- layout(nodesOn(name), edgesOn(name), fresh, layoutOf(name), name === "l1" ? undefined : layoutOf("l1"));
356
- }
357
+ static #visibleNodes() {
358
+ return Graph.#nodesOn(Graph.#layer);
359
+ }
357
360
 
358
- /** The node glide between two layers' places, which a press stops. @type {number | undefined} */
359
- let regrouping;
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
- /** Put the nodes at their own layer's places now, ending a glide between layers. */
362
- function settle() {
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
- * 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.
370
- *
371
- * @param {Map<string, Point>} from
372
- */
373
- function glideNodes(from) {
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
- positions = t < 1 ? shown : to;
394
- place();
395
- regrouping = t < 1 ? requestAnimationFrame(step) : undefined;
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
- positions = shown;
399
- step(began);
400
- }
398
+ const to = Graph.#layoutOf(Graph.#layer);
399
+ if (Dom.still.matches || from === to) return;
401
400
 
402
- /** @param {string} id */
403
- const nodeById = (id) => data?.nodes.find((node) => node.id === id);
401
+ /** @type {Map<string, Point>} */
402
+ const shown = new Map();
403
+ const began = performance.now();
404
404
 
405
- /**
406
- * @param {GraphNode} node
407
- * @returns {"error" | "warning" | undefined}
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
- const findings = data.findings.filter((finding) => finding.device === node.label);
413
- if (findings.some((finding) => finding.level === "error")) return "error";
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
- const drift = data.drift[node.label]?.status;
416
- if (findings.length || drift === "differs" || drift === "cannot plan") return "warning";
415
+ Graph.#positions = t < 1 ? shown : to;
416
+ Graph.#place();
417
+ Graph.#regrouping = t < 1 ? requestAnimationFrame(step) : undefined;
418
+ };
417
419
 
418
- return undefined;
419
- }
420
+ Graph.#positions = shown;
421
+ step(began);
422
+ }
420
423
 
421
- /**
422
- * @param {GraphEdge} edge
423
- * @param {number} offset
424
- * @returns {{ d: string, label: Point }}
425
- */
426
- function curve(edge, offset) {
427
- const a = positions.get(edge.from) ?? { x: 0, y: 0 };
428
- const b = positions.get(edge.to) ?? { x: 0, y: 0 };
429
- const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
430
-
431
- const length = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1);
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
- /** Move what is drawn to where the nodes are now, without drawing it again. */
442
- function place() {
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
- for (const [id, group] of nodeElements) {
447
- const point = positions.get(id) ?? { x: 0, y: 0 };
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
- for (const { edge, path, hit, label, offset } of edgeElements) {
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
- /** Past this many edges between the same two nodes, they are drawn as one, which lists them all when selected. */
461
- const MOST_APART = 3;
462
-
463
- /** @type {Record<Layer, string>} */
464
- const MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
465
-
466
- /** @type {Record<Layer, string>} */
467
- const ONE = { l1: "cable", l2: "cable", l3: "shared prefix", bgp: "session", ospf: "adjacency", tunnels: "tunnel" };
468
-
469
- /**
470
- * The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
471
- *
472
- * @param {DrawnEdge[]} edges
473
- * @returns {DrawnEdge[]}
474
- */
475
- function bundled(edges) {
476
- /** @type {Map<string, DrawnEdge[]>} */
477
- const byPair = new Map();
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
- return [...byPair.values()].flatMap((members) => {
484
- const [first] = members;
485
- if (!first || members.length <= MOST_APART) return members;
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
- * 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.
509
- *
510
- * @param {DrawnEdge} edge
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
- motion.append(s("mpath", { href: `#${id}` }));
532
- dot.append(motion);
533
- return dot;
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
- /** A confident arrival: fast at first, settling at the end. */
538
- const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
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
- * Draw the network in from its busiest device outward, a ring of neighbours at a time, each cable drawn from its nearer end.
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
- /** @param {string} id */
549
- const degree = (id) => edgeElements.filter(({ edge }) => edge.from === id || edge.to === id).length;
550
- const hub = [...nodeElements.keys()].sort((a, b) => degree(b) - degree(a))[0];
551
-
552
- /** @type {Map<string, number>} */
553
- const ring = new Map(hub ? [[hub, 0]] : []);
554
- for (let queue = hub ? [hub] : []; queue.length;) {
555
- const id = /** @type {string} */ (queue.shift());
556
- for (const { edge } of edgeElements) {
557
- const other = edge.from === id ? edge.to : edge.to === id ? edge.from : undefined;
558
- if (other === undefined || ring.has(other)) continue;
559
- ring.set(other, (ring.get(id) ?? 0) + 1);
560
- queue.push(other);
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
- // The whole drawing arrives within about a second, however many rings it has.
565
- const farthest = Math.max(0, ...ring.values()) + 1;
566
- const step = Math.min(110, 800 / farthest);
567
- /** @param {string} id */
568
- const ringOf = (id) => ring.get(id) ?? farthest;
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
- for (const [id, group] of nodeElements) {
571
- if (!fresh.has(id)) continue;
572
- group.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 360, delay: ringOf(id) * step, easing: EASE, fill: "backwards" });
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
- for (const { edge, group, path, label } of edgeElements) {
576
- const forward = ringOf(edge.from) <= ringOf(edge.to);
577
- const delay = (Math.min(ringOf(edge.from), ringOf(edge.to)) + 0.5) * step;
578
-
579
- if (edge.oneSided || edge.gone) {
580
- path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: EASE, fill: "backwards" });
581
- } else {
582
- // Measured as one whole length, so the line draws in right even while its ends glide to a new layer's places.
583
- path.setAttribute("pathLength", "1");
584
- const start = { strokeDasharray: "1 1", strokeDashoffset: forward ? 1 : -1 };
585
- const end = { strokeDasharray: "1 1", strokeDashoffset: 0 };
586
- const drawing = path.animate([start, end], { duration: 560, delay, easing: EASE, fill: "backwards" });
587
- drawing.addEventListener("finish", () => path.removeAttribute("pathLength"));
588
- drawing.addEventListener("cancel", () => path.removeAttribute("pathLength"));
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
- for (const part of [label, ...group.querySelectorAll(".signal")]) {
592
- part.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: delay + 420, fill: "backwards" });
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
- * 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.
599
- *
600
- * @param {"network" | "cables"} [arrival]
601
- */
602
- function draw(arrival) {
603
- const before = new Set(nodeElements.keys());
604
-
605
- edgeLayer.replaceChildren();
606
- nodeLayer.replaceChildren();
607
- nodeElements.clear();
608
- edgeElements.length = 0;
609
-
610
- const edges = bundled(visibleEdges());
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
- edgeLayer.append(group);
647
- edgeElements.push({ edge, group, path, hit, label, offset });
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
- for (const node of visibleNodes()) {
651
- const group = s("g", { class: `node ${node.kind} ${markOf(node) ?? ""}`, tabindex: 0 });
652
- const rect = s("rect", { rx: node.kind === "segment" ? 14 : 8 });
653
- group.append(rect);
654
-
655
- const device = node.kind === "device";
656
- const lines = device ? [node.label, node.detail[0] ?? ""] : [node.label];
657
- const texts = lines.map((line, index) => {
658
- const y = device ? (index === 0 ? -9 : 13) : 0;
659
- const text = s("text", { class: index === 0 ? "name" : "sub", y });
660
- text.textContent = line;
661
- group.append(text);
662
- return text;
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
- nodeLayer.append(group);
666
-
667
- // Each box fits its text, so it is measured once drawn. A device's text starts after its chassis mark.
668
- const mark = device ? 46 : 0;
669
- const width = Math.max(...texts.map((text) => text.getComputedTextLength()), device ? 110 : 0) + 32 + mark;
670
- const height = device ? 62 : 34;
671
- rect.setAttribute("x", String(-width / 2));
672
- rect.setAttribute("y", String(-height / 2));
673
- rect.setAttribute("width", String(width));
674
- rect.setAttribute("height", String(height));
675
-
676
- // The mark of the device presenting lights now: a ring around its frame, shown only then.
677
- const beacon = s("g", { class: "beacon", "aria-hidden": "true" });
678
- beacon.append(s("rect", { x: -width / 2 - 10, y: -height / 2 - 10, width: width + 20, height: height + 20, rx: 16 }));
679
- group.insertBefore(beacon, rect);
680
-
681
- if (device) {
682
- for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
683
-
684
- // A small front panel: a chassis with two rows of ports, drawn rather than pictured.
685
- const left = -width / 2 + 16;
686
- const chassis = s("g", { class: "chassis", "aria-hidden": "true" });
687
- chassis.append(s("rect", { x: left, y: -15, width: 34, height: 30, rx: 4 }));
688
- for (const row of [0, 1]) {
689
- for (const column of [0, 1, 2]) {
690
- chassis.append(s("rect", { class: "socket", x: left + 6 + column * 8, y: -8 + row * 9, width: 6, height: 6, rx: 1 }));
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
- group.insertBefore(chassis, texts[0] ?? null);
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
- group.addEventListener("pointerdown", (event) => startDrag(event, node.id));
697
- group.addEventListener("pointerenter", () => {
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.set(node.id, group);
746
+ if (arrival) Graph.#arrive(new Set([...Graph.#nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
708
747
  }
709
748
 
710
- place();
711
- emphasize();
712
-
713
- if (arrival) arrive(new Set([...nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
714
- }
715
-
716
- /** Light up what the pointer is on, the device lit while presenting, or what is selected, and dim the rest. */
717
- function emphasize() {
718
- const focus = hovered ?? touring ?? (selection && "node" in selection ? selection.node : undefined);
719
- const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
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
- /** @param {GraphEdge} edge */
722
- const touching = (edge) => edge.from === focus || edge.to === focus;
723
- /** @param {GraphEdge} edge */
724
- const carries = (edge) => edge.vlans?.some((vlan) => vlan.id === vlanFocus) ?? false;
725
-
726
- /** @type {Set<GraphEdge> | undefined} */
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
- const litNodes = litEdges ? new Set([...litEdges].flatMap((edge) => [edge.from, edge.to])) : undefined;
740
- if (focus) litNodes?.add(focus);
789
+ // Panning, zooming and dragging.
790
+ /** @type {{ id?: string, start: Point, origin: Point, moved: boolean } | undefined} */
791
+ static #drag;
741
792
 
742
- for (const { edge, group } of edgeElements) {
743
- group.classList.toggle("lit", Boolean(litEdges?.has(edge)) && !selectedEdge);
744
- group.classList.toggle("selected", edge === selectedEdge);
745
- group.classList.toggle("dim", litEdges !== undefined && !litEdges.has(edge));
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
- for (const [id, group] of nodeElements) {
749
- group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
750
- group.classList.toggle("touring", presenting && id === touring && !hovered);
751
- group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
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
- // Panning, zooming and dragging.
756
- /** @type {{ id?: string, start: Point, origin: Point, moved: boolean } | undefined} */
757
- let drag;
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
- svg.setPointerCapture(event.pointerId);
785
- const point = id ? positions.get(id) : undefined;
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
- svg.addEventListener("pointerdown", (event) => startDrag(event));
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.addEventListener("pointermove", (event) => {
794
- if (!drag) return;
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
- const dx = event.clientX - drag.start.x;
797
- const dy = event.clientY - drag.start.y;
798
- if (Math.hypot(dx, dy) > 3) drag.moved = true;
799
- if (!drag.moved) return;
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
- if (drag.id) positions.set(drag.id, { x: drag.origin.x + dx / view.k, y: drag.origin.y + dy / view.k });
802
- else view = { ...view, x: drag.origin.x + dx, y: drag.origin.y + dy };
882
+ if (glide) return Graph.#glideTo(target);
883
+ Graph.#view = target;
884
+ Graph.#place();
885
+ }
803
886
 
804
- place();
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
- svg.addEventListener("pointerup", () => {
808
- if (!drag) return;
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
- const ended = drag;
811
- drag = undefined;
812
- svg.classList.remove("panning");
941
+ if (glide) return Graph.#glideTo(target);
942
+ Graph.#view = target;
943
+ Graph.#place();
944
+ }
813
945
 
814
- if (ended.moved && ended.id) savePositions();
815
- if (ended.moved) return;
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
- // A press without a move is a click: on a node it selects the node, and on an empty spot it clears the selection.
818
- select(ended.id ? { node: ended.id } : undefined);
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
- svg.addEventListener(
822
- "wheel",
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
- const point = toWorld(event);
829
- const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
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
- view = { k, x: event.clientX - box.left - point.x * k, y: event.clientY - box.top - point.y * k };
833
- place();
834
- },
835
- { passive: false }
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
- /** 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. */
839
- const READABLE = 0.6;
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
- /** The view moving to a new place, which a press or a scroll stops. @type {number | undefined} */
842
- let gliding;
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
- * 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.
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
- if (still.matches) {
854
- view = target;
855
- place();
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
- const box = svg.getBoundingClientRect();
860
- const from = { ...view };
861
- /** @param {typeof view} one */
862
- const centreOf = (one) => ({ x: (box.width / 2 - one.x) / one.k, y: (box.height / 2 - one.y) / one.k });
863
- const start = centreOf(from);
864
- const end = centreOf(target);
865
- const began = performance.now();
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
- gliding = requestAnimationFrame(step);
880
- }
1034
+ /** @param {boolean} on */
1035
+ static #setPresenting(on) {
1036
+ Graph.#presenting = on;
1037
+ Dom.store.set("present", on);
881
1038
 
882
- /**
883
- * 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.
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
- * The view that frames these nodes.
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
- /** While presenting, frame what is shown: the lit device with everything it has a line to, or the whole layer. */
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
- const near = new Set([lit, ...visibleEdges().flatMap((edge) => (edge.from === lit ? [edge.to] : edge.to === lit ? [edge.from] : []))]);
942
- const target = framing(
943
- visibleNodes().filter((node) => near.has(node.id)),
944
- false
945
- );
946
- if (!target) return;
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
- byId("fit").addEventListener("click", () => fit());
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
- byId("lay-out").addEventListener("click", () => {
956
- if (!data) return;
957
- layOut(layer, true);
958
- settle();
959
- draw();
960
- fit();
961
- });
1062
+ Graph.#selection = next;
1063
+ Graph.#pathFocus = undefined;
1064
+ Graph.#emphasize();
1065
+ Graph.#showPanel();
962
1066
 
963
- // Presenting keeps the whole network in view as the screen changes size.
964
- window.addEventListener("resize", () => (presenting ? frameTour(false) : place()));
1067
+ if (changed) Dom.enter(Graph.#panel);
1068
+ }
965
1069
 
966
- // Presenting.
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
- /** Say what is shown while presenting: the device lit, or the whole layer. */
969
- function showCaption() {
970
- caption.hidden = !presenting;
971
- if (!presenting) return;
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
- const node = touring ? nodeById(touring) : undefined;
974
- const edges = visibleEdges().filter((edge) => !node || edge.from === node.id || edge.to === node.id);
975
- const count = `${edges.length} ${edges.length === 1 ? ONE[layer] : MANY[layer]}`;
1108
+ return chips;
1109
+ }
976
1110
 
977
- if (node) caption.replaceChildren(h("strong", {}, node.label), h("span", {}, [node.detail[0], count].filter(Boolean).join(", ")));
978
- else
979
- caption.replaceChildren(
980
- h("strong", {}, LAYER_NAMES[layer]),
981
- h("span", {}, `${visibleNodes().filter((one) => one.kind === "device").length} devices, ${count}`)
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
- enter(caption);
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
- /** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
991
- let tourStep = 0;
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
- /** The move to the next layer, waiting while the view zooms out to the whole of this one. @type {ReturnType<typeof setTimeout> | undefined} */
994
- let leaving;
1151
+ if (Graph.#selection && "edge" in Graph.#selection) return Graph.#showEdge(Graph.#selection.edge);
995
1152
 
996
- /** The next layer that has lines to show, after this one; this one when no other has any. */
997
- function nextLayer() {
998
- const after = [...LAYERS.slice(LAYERS.indexOf(layer) + 1), ...LAYERS.slice(0, LAYERS.indexOf(layer) + 1)];
999
- return after.find((name) => data?.edges.some((edge) => edge.layer === name)) ?? layer;
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
- /** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
1003
- function tourNext() {
1004
- if (graphView.hidden || document.hidden || hovered || drag || leaving) return;
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
- // 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.
1025
- tourStep = 0;
1026
- emphasize();
1027
- showCaption();
1028
- frameTour();
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
- const next = nextLayer();
1031
- leaving = setTimeout(() => {
1032
- leaving = undefined;
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
- // Moving on replaces the address rather than adding to the history, so Back still leaves Studio's last place.
1035
- if (presenting && !graphView.hidden) location.replace(`#graph/${next}`);
1036
- }, 2600);
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
- /** Start the turns over, so the layer shown now has its full time. */
1040
- function restartTour() {
1041
- clearInterval(tour);
1042
- clearTimeout(leaving);
1043
- leaving = undefined;
1044
- if (presenting) tour = setInterval(tourNext, 6000);
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
- /** @param {boolean} on */
1048
- function setPresenting(on) {
1049
- presenting = on;
1050
- store.set("present", on);
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
- document.body.classList.toggle("presenting", on);
1053
- presentButton.setAttribute("aria-pressed", String(on));
1221
+ Graph.#panel.append(Dom.h("h3", {}, "Legend"), Graph.#legend());
1222
+ }
1054
1223
 
1055
- touring = undefined;
1056
- if (on) selection = undefined;
1057
- restartTour();
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
- if (graphView.hidden || !data) return;
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
- // The panel hides or returns, so the drawing is fitted once the page has its new size.
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
- presentButton.addEventListener("click", () => setPresenting(!presenting));
1069
- setPresenting(presenting);
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
- * @param {Annotated["findings"]} findings
1106
- * @param {boolean} withDevice
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
- h("span", { class: `badge ${finding.level}` }, finding.level),
1117
- withDevice && finding.device ? link(finding.device, () => select({ node: `device:${finding.device}` })) : undefined,
1118
- withDevice && finding.device ? ": " : undefined,
1119
- finding.message
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
- return chips;
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
- function legend() {
1144
- /**
1145
- * @param {boolean} dashed
1146
- * @param {string} color
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
- /** @param {string} style */
1155
- const box = (style) => {
1156
- const icon = s("svg", { width: 34, height: 16 });
1157
- icon.append(s("rect", { x: 2, y: 2, width: 30, height: 12, rx: 3, fill: "none", "stroke-width": 2, style }));
1158
- return icon;
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
- return h(
1162
- "ul",
1163
- { class: "legend" },
1164
- h("li", {}, sample(false, "var(--edge)"), "both ends declare it"),
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
- function showPanel() {
1173
- panel.replaceChildren();
1174
- if (!data) return;
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
- if (selection && "node" in selection) {
1177
- const node = nodeById(selection.node);
1178
- if (node) return showNode(node);
1344
+ Graph.#layOut(Graph.#layer, false);
1179
1345
  }
1180
1346
 
1181
- if (selection && "edge" in selection) return showEdge(selection.edge);
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
- const drift = data.drift;
1184
- const devices = data.nodes.filter((node) => node.kind === "device");
1185
- const drifted = devices.filter((node) => drift[node.label]?.status === "differs");
1186
- const unsnapped = devices.filter((node) => drift[node.label]?.status === "no snapshot");
1362
+ if (Graph.#pendingVlan !== undefined) {
1363
+ Graph.#vlanFocus = Graph.#pendingVlan;
1364
+ Graph.#pendingVlan = undefined;
1365
+ }
1187
1366
 
1188
- panel.append(h("h2", {}, LAYER_NAMES[layer]), h("p", { class: "muted" }, LAYER_HELP[layer]));
1367
+ if (Graph.#pendingPath) {
1368
+ Graph.#pathFocus = new Set(Graph.#pendingPath.map(Graph.#edgeKey));
1369
+ Graph.#pendingPath = undefined;
1370
+ }
1189
1371
 
1190
- if (comparison) {
1191
- panel.append(
1192
- h("h3", {}, "Compared with ", comparison.name),
1193
- h(
1194
- "p",
1195
- {},
1196
- h("span", { class: "added" }, "Green lines"),
1197
- " are new, ",
1198
- h("span", { class: "removed" }, "red dashed lines"),
1199
- " are gone. ",
1200
- link("Stop comparing", () => {
1201
- comparison = undefined;
1202
- draw();
1203
- showPanel();
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
- if (layer === "l2") {
1210
- /** @type {Map<number, CarriedVlan>} */
1211
- const vlans = new Map();
1212
- for (const edge of visibleEdges()) for (const vlan of edge.vlans ?? []) vlans.set(vlan.id, vlan);
1213
-
1214
- const sorted = [...vlans.values()].sort((a, b) => a.id - b.id);
1215
- if (sorted.length) panel.append(h("h3", {}, "VLANs"), vlanChips(sorted));
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
- if (data.findings.length) panel.append(h("h3", {}, "Found by ", h("code", {}, "circuit validate")), findingList(data.findings, true));
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
- if (drifted.length) {
1221
- panel.append(
1222
- h("h3", {}, "Unlike their last snapshot"),
1223
- h(
1224
- "ul",
1225
- {},
1226
- ...drifted.map((node) =>
1227
- h(
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
- if (unsnapped.length) {
1238
- panel.append(h("h3", {}, "No snapshot yet"), h("p", { class: "muted" }, unsnapped.map((node) => node.label).join(", ")));
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
- panel.append(h("h3", {}, "Legend"), legend());
1242
- }
1440
+ static {
1441
+ Graph.#svg.addEventListener("pointerdown", (event) => Graph.#startDrag(event));
1243
1442
 
1244
- /** @param {GraphNode} node */
1245
- function showNode(node) {
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
- if (node.kind === "device")
1250
- panel.append(
1251
- h(
1252
- "p",
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
- const edges = visibleEdges().filter((edge) => edge.from === node.id || edge.to === node.id);
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
- panel.append(h("h3", {}, `${LAYER_NAMES[layer]} here`));
1280
- if (!edges.length) panel.append(h("p", { class: "muted" }, "None on this layer."));
1458
+ Graph.#place();
1459
+ });
1281
1460
 
1282
- panel.append(
1283
- h(
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
- /** @param {GraphEdge} edge */
1300
- function showEdge(edge) {
1301
- const from = nodeById(edge.from);
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
- if (edge.oneSided) panel.append(h("p", {}, h("span", { class: "badge warning" }, "one end"), "Only one end declares it."));
1317
- if (edge.detail.length) panel.append(h("h3", {}, "Details"), h("pre", {}, edge.detail.join("\n")));
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
- function showLayerButtons() {
1322
- layersNav.replaceChildren();
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
- for (const [index, name] of LAYERS.entries()) {
1325
- const count = data?.edges.filter((edge) => edge.layer === name).length ?? 0;
1326
- const button = h(
1327
- "button",
1328
- { "aria-pressed": String(name === layer), title: `${LAYER_HELP[name]} Key ${index + 1}.` },
1329
- LAYER_NAMES[name],
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
- document.addEventListener("keydown", (event) => {
1338
- if (event.target instanceof HTMLInputElement || graphView.hidden) return;
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
- if (event.key === "Escape") {
1341
- if (presenting) setPresenting(false);
1342
- select(undefined);
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
- if (event.key === "p" && !event.ctrlKey && !event.metaKey && !event.altKey) setPresenting(!presenting);
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
- layOut(layer, false);
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
- * Draw a layer, now that the graph is visible: its labels are measured as they are drawn.
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
- if (pendingVlan !== undefined) {
1389
- vlanFocus = pendingVlan;
1390
- pendingVlan = undefined;
1391
- }
1505
+ Graph.#presentButton.addEventListener("click", () => Graph.#setPresenting(!Graph.#presenting));
1506
+ Graph.#setPresenting(Graph.#presenting);
1392
1507
 
1393
- if (pendingPath) {
1394
- pathFocus = new Set(pendingPath.map(edgeKey));
1395
- pendingPath = undefined;
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
- // 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.
1399
- const arrival = !drawn && data ? "network" : next !== layer ? "cables" : undefined;
1400
- const from = positions;
1401
- if (drawn) views.set(layer, { ...view });
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
- * Select a device, for when the graph is shown next.
1429
- *
1430
- * @param {string} name
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
- * Show a comparison with another commit on the graph: new edges lit, edges gone drawn dashed.
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
- * Light up these cables as one path, for when their layer is shown next.
1448
- *
1449
- * @param {GraphEdge[]} path
1450
- */
1451
- export function lightPath(path) {
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
- * Light up every cable carrying a VLAN, for when the VLAN layer is shown next.
1458
- *
1459
- * @param {number} id
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
  }