@takodotid/circuit 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,15 @@ Documentation: [circuit.tako.id](https://circuit.tako.id)
25
25
  <img src="docs/public/screenshots/studio-light.png" alt="Studio's VLAN graph with devices that differ from their snapshots marked by orange dashed borders, beside the layer details and legend.">
26
26
  </picture>
27
27
 
28
+ ## Circuit Academy
29
+
30
+ New to networking? [Circuit Academy](https://circuit.tako.id/academy/) teaches it from the very start, in lessons you try in the browser rather than only read: packets, addresses, switches and routers first, then VLANs, DHCP, NAT and firewalls written down in Circuit, up to BGP and an edge router of your own. A certification is coming.
31
+
32
+ <picture>
33
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/screenshots/academy-dark.png">
34
+ <img src="docs/public/screenshots/academy-light.png" alt="A Circuit Academy lesson on switches: a frame from a laptop to a printer has just been flooded out of every port, and the switch has learned which port the laptop is behind.">
35
+ </picture>
36
+
28
37
  ## Start
29
38
 
30
39
  You need [Node.js](https://nodejs.org) 20.12 or newer, or [Bun](https://bun.sh). Then:
package/dist/cli/index.js CHANGED
@@ -3256,6 +3256,8 @@ function unsupported3(device) {
3256
3256
  found.push(`interfaces.${name}.addresses, LAGs are switched`);
3257
3257
  if (iface.mtu !== undefined)
3258
3258
  found.push(`interfaces.${name}.mtu`);
3259
+ if (iface.ipv6_ra)
3260
+ found.push(`interfaces.${name}.ipv6_ra`);
3259
3261
  for (const group of iface.vrrp ?? [])
3260
3262
  if (familyOf2(group.address) === "ipv6")
3261
3263
  found.push(`interfaces.${name}.vrrp for IPv6`);
@@ -3271,6 +3273,10 @@ function unsupported3(device) {
3271
3273
  found.push("routing.rpki");
3272
3274
  if (routing?.ospf?.families?.includes("ipv6"))
3273
3275
  found.push("routing.ospf for IPv6");
3276
+ for (const [index, route] of (routing?.static ?? []).entries()) {
3277
+ if (route.description && familyOf2(split(route.prefix).addr) === "ipv6")
3278
+ found.push(`routing.static[${index}].description for IPv6`);
3279
+ }
3274
3280
  for (const field of ["firewall", "dhcp", "acls", "policies", "prefix_sets", "hardware_offload", "certificates"]) {
3275
3281
  if (device[field] !== undefined)
3276
3282
  found.push(field);
@@ -3629,14 +3635,25 @@ function checkAcross(devices) {
3629
3635
  ...checkTunnels(devices, owners)
3630
3636
  ];
3631
3637
  }
3632
- function carried(device, port) {
3638
+ function carried(device, settings) {
3633
3639
  const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
3634
3640
  return {
3635
- access: id(port.access_vlan),
3636
- native: id(port.native_vlan),
3637
- trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
3641
+ access: id(settings.access_vlan),
3642
+ native: id(settings.native_vlan),
3643
+ trunk: (settings.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
3638
3644
  };
3639
3645
  }
3646
+ function describeCarried({ access, native, trunk }) {
3647
+ const parts = [];
3648
+ const tagged = trunk.filter((id) => id !== undefined);
3649
+ if (access !== undefined)
3650
+ parts.push(`VLAN ${access} untagged`);
3651
+ if (tagged.length)
3652
+ parts.push(`${tagged.length === 1 ? "VLAN" : "VLANs"} ${tagged.join(", ")} tagged`);
3653
+ if (native !== undefined)
3654
+ parts.push(`VLAN ${native} untagged as native`);
3655
+ return parts.length ? parts.join(" and ") : "no VLANs";
3656
+ }
3640
3657
  function vlanNames(device, settings) {
3641
3658
  const names = [settings.access_vlan, settings.native_vlan, ...settings.trunk_vlans ?? []].filter((name) => name !== undefined);
3642
3659
  return new Map(names.flatMap((name) => device.vlans?.[name] ? [[device.vlans[name].id, name]] : []));
@@ -3695,15 +3712,21 @@ function checkLinks(devices) {
3695
3712
  report(`${link.device} ${link.port} does not link back to it`);
3696
3713
  continue;
3697
3714
  }
3698
- const here = JSON.stringify(carried(device, port));
3699
- const there = JSON.stringify(carried(far, farPort));
3700
- if (here !== there)
3701
- report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
3715
+ const near = carrierOf(device, portName, port);
3716
+ const away = carrierOf(far, link.port, farPort);
3717
+ const inLag = near.name !== portName;
3718
+ const farInLag = away.name !== link.port;
3719
+ if (inLag !== farInLag) {
3720
+ report(inLag ? `${portName} is in ${near.name}, and ${link.device} ${link.port} is in no LAG` : `${portName} is in no LAG, and ${link.device} ${link.port} is in ${away.name}`);
3721
+ }
3722
+ const here = carried(device, near.settings);
3723
+ const there = carried(far, away.settings);
3724
+ if (JSON.stringify(here) !== JSON.stringify(there)) {
3725
+ report(`${near.name} carries ${describeCarried(here)}, and ${link.device} ${away.name} carries ${describeCarried(there)}`);
3726
+ }
3702
3727
  if (port.speed && farPort.speed && port.speed !== farPort.speed) {
3703
3728
  report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
3704
3729
  }
3705
- const near = carrierOf(device, portName, port);
3706
- const away = carrierOf(far, link.port, farPort);
3707
3730
  const mtu = near.settings.mtu ?? 1500;
3708
3731
  const farMtu = away.settings.mtu ?? 1500;
3709
3732
  if (mtu !== farMtu)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -609,6 +609,7 @@ export function unsupported(device: Device): string[] {
609
609
  if (iface.type !== "vlan" && iface.type !== "lag") found.push(`interfaces.${name}, a ${iface.type}`);
610
610
  if (iface.type === "lag" && iface.addresses?.length) found.push(`interfaces.${name}.addresses, LAGs are switched`);
611
611
  if (iface.mtu !== undefined) found.push(`interfaces.${name}.mtu`);
612
+ if (iface.ipv6_ra) found.push(`interfaces.${name}.ipv6_ra`);
612
613
  for (const group of iface.vrrp ?? []) if (familyOf(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
613
614
  }
614
615
 
@@ -621,6 +622,10 @@ export function unsupported(device: Device): string[] {
621
622
  if (routing?.rpki) found.push("routing.rpki");
622
623
  if (routing?.ospf?.families?.includes("ipv6")) found.push("routing.ospf for IPv6");
623
624
 
625
+ for (const [index, route] of (routing?.static ?? []).entries()) {
626
+ if (route.description && familyOf(split(route.prefix).addr) === "ipv6") found.push(`routing.static[${index}].description for IPv6`);
627
+ }
628
+
624
629
  for (const field of ["firewall", "dhcp", "acls", "policies", "prefix_sets", "hardware_offload", "certificates"] as const) {
625
630
  if (device[field] !== undefined) found.push(field);
626
631
  }
@@ -25,16 +25,28 @@ export function checkAcross(devices: readonly Device[]): Finding[] {
25
25
  ];
26
26
  }
27
27
 
28
- /** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
29
- function carried(device: Device, port: Port<string, string, string, string>) {
28
+ /** The VLAN ids a port or LAG carries, by how it carries them, so two ends can be compared across devices. */
29
+ function carried(device: Device, settings: Switched<string>) {
30
30
  const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
31
31
  return {
32
- access: id(port.access_vlan),
33
- native: id(port.native_vlan),
34
- trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
32
+ access: id(settings.access_vlan),
33
+ native: id(settings.native_vlan),
34
+ trunk: (settings.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
35
35
  };
36
36
  }
37
37
 
38
+ /** How a port carries its VLANs, in words: "VLANs 10, 20 tagged and VLAN 30 untagged as native". */
39
+ function describeCarried({ access, native, trunk }: ReturnType<typeof carried>): string {
40
+ const parts: string[] = [];
41
+ const tagged = trunk.filter((id) => id !== undefined);
42
+
43
+ if (access !== undefined) parts.push(`VLAN ${access} untagged`);
44
+ if (tagged.length) parts.push(`${tagged.length === 1 ? "VLAN" : "VLANs"} ${tagged.join(", ")} tagged`);
45
+ if (native !== undefined) parts.push(`VLAN ${native} untagged as native`);
46
+
47
+ return parts.length ? parts.join(" and ") : "no VLANs";
48
+ }
49
+
38
50
  /** VLAN names by id, for the VLANs a port or LAG carries. */
39
51
  function vlanNames(device: Device, settings: Switched<string>): Map<number, string> {
40
52
  const names = [settings.access_vlan, settings.native_vlan, ...(settings.trunk_vlans ?? [])].filter((name) => name !== undefined);
@@ -114,17 +126,30 @@ function checkLinks(devices: readonly Device[]): Finding[] {
114
126
  continue;
115
127
  }
116
128
 
117
- const here = JSON.stringify(carried(device, port));
118
- const there = JSON.stringify(carried(far, farPort));
119
- if (here !== there) report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
129
+ const near = carrierOf(device, portName, port);
130
+ const away = carrierOf(far, link.port, farPort);
131
+
132
+ // A LAG's VLANs are on the LAG, so its member ports are compared by what their LAGs carry.
133
+ const inLag = near.name !== portName;
134
+ const farInLag = away.name !== link.port;
135
+ if (inLag !== farInLag) {
136
+ report(
137
+ inLag
138
+ ? `${portName} is in ${near.name}, and ${link.device} ${link.port} is in no LAG`
139
+ : `${portName} is in no LAG, and ${link.device} ${link.port} is in ${away.name}`
140
+ );
141
+ }
142
+
143
+ const here = carried(device, near.settings);
144
+ const there = carried(far, away.settings);
145
+ if (JSON.stringify(here) !== JSON.stringify(there)) {
146
+ report(`${near.name} carries ${describeCarried(here)}, and ${link.device} ${away.name} carries ${describeCarried(there)}`);
147
+ }
120
148
 
121
149
  if (port.speed && farPort.speed && port.speed !== farPort.speed) {
122
150
  report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
123
151
  }
124
152
 
125
- const near = carrierOf(device, portName, port);
126
- const away = carrierOf(far, link.port, farPort);
127
-
128
153
  const mtu = near.settings.mtu ?? 1500;
129
154
  const farMtu = away.settings.mtu ?? 1500;
130
155
  if (mtu !== farMtu) report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
@@ -85,10 +85,28 @@ let tour;
85
85
  /** @param {GraphEdge} edge */
86
86
  const edgeKey = (edge) => `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
87
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();
88
90
 
89
- const saved = /** @type {Record<string, [number, number]>} */ (store.get("positions") ?? {});
90
- /** @type {Map<string, Point>} */
91
- const positions = new Map(Object.entries(saved).map(([id, [x, y]]) => [id, { x, y }]));
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);
92
110
 
93
111
  /** @type {Map<string, SVGGElement>} */
94
112
  const nodeElements = new Map();
@@ -96,7 +114,9 @@ const nodeElements = new Map();
96
114
  const edgeElements = [];
97
115
 
98
116
  function savePositions() {
99
- store.set("positions", Object.fromEntries([...positions].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]])));
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)])));
100
120
  }
101
121
 
102
122
  /**
@@ -111,17 +131,23 @@ function hash(text) {
111
131
  }
112
132
 
113
133
  /**
114
- * Place nodes with a force layout: every node pushes every other away, and each edge pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
115
- * Every layer is laid out together, so a device stays where it is when the layer changes.
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.
116
136
  *
117
137
  * @param {GraphNode[]} nodes
118
138
  * @param {GraphEdge[]} edges
119
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
120
142
  */
121
- function layout(nodes, edges, fresh) {
122
- if (fresh) positions.clear();
143
+ function layout(nodes, edges, fresh, places, seed) {
144
+ if (fresh) places.clear();
123
145
 
124
- const fixed = new Set(nodes.filter((node) => positions.has(node.id)).map((node) => node.id));
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));
125
151
  /** @type {Map<string, Set<string>>} */
126
152
  const neighbors = new Map();
127
153
 
@@ -133,29 +159,64 @@ function layout(nodes, edges, fresh) {
133
159
  neighbors.get(edge.to)?.add(edge.from);
134
160
  }
135
161
 
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));
165
+
166
+ function park() {
167
+ if (!loose.length) return;
168
+
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;
174
+
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
+ }
181
+
182
+ places.set(node.id, { x: x + half, y });
183
+ x += half * 2 + 24;
184
+ }
185
+
186
+ savePositions();
187
+ }
188
+
189
+ nodes = linked;
190
+
136
191
  // A new node starts beside a neighbor already placed, or on a circle.
137
192
  const radius = 60 * Math.sqrt(nodes.length + 1);
138
193
 
139
194
  for (const node of nodes) {
140
- if (positions.has(node.id)) continue;
195
+ if (places.has(node.id)) continue;
196
+
197
+ const start = seed?.get(node.id);
198
+ if (start) {
199
+ places.set(node.id, { ...start });
200
+ continue;
201
+ }
141
202
 
142
203
  const angle = hash(node.id) * Math.PI * 2;
143
- const placed = [...(neighbors.get(node.id) ?? [])].map((id) => positions.get(id)).find(Boolean);
204
+ const placed = [...(neighbors.get(node.id) ?? [])].map((id) => places.get(id)).find(Boolean);
144
205
  const distance = placed ? 90 : radius;
145
206
  const origin = placed ?? { x: 0, y: 0 };
146
207
 
147
- positions.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
208
+ places.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
148
209
  }
149
210
 
150
211
  const moving = nodes.filter((node) => !fixed.has(node.id));
151
- if (!moving.length) return;
212
+ if (!moving.length) return park();
152
213
 
153
214
  const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
154
215
  const ideal = 170;
155
216
  const rounds = fixed.size ? 200 : 500;
156
217
 
157
218
  /** @param {string} id */
158
- const at = (id) => /** @type {Point} */ (positions.get(id));
219
+ const at = (id) => /** @type {Point} */ (places.get(id));
159
220
 
160
221
  for (let round = 0; round < rounds; round++) {
161
222
  const heat = 40 * (1 - round / rounds) + 1;
@@ -187,7 +248,7 @@ function layout(nodes, edges, fresh) {
187
248
  }
188
249
 
189
250
  for (const [from, to] of pairs) {
190
- if (!from || !to || !positions.has(from) || !positions.has(to)) continue;
251
+ if (!from || !to || !places.has(from) || !places.has(to)) continue;
191
252
 
192
253
  const dx = at(from).x - at(to).x;
193
254
  const dy = at(from).y - at(to).y;
@@ -215,9 +276,6 @@ function layout(nodes, edges, fresh) {
215
276
  }
216
277
 
217
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.
218
- /** @param {GraphNode} node */
219
- const halfWidth = (node) =>
220
- node.kind === "device" ? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60 : node.label.length * 4 + 30;
221
279
 
222
280
  for (let round = 0; round < 80; round++) {
223
281
  let overlapping = false;
@@ -248,27 +306,99 @@ function layout(nodes, edges, fresh) {
248
306
  if (!overlapping) break;
249
307
  }
250
308
 
309
+ park();
251
310
  savePositions();
252
311
  }
253
312
 
254
313
  // What is drawn.
255
- /** @returns {DrawnEdge[]} */
256
- const visibleEdges = () => {
257
- const current = data?.edges.filter((edge) => edge.layer === layer) ?? [];
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) ?? [];
258
323
  if (!comparison) return current;
259
324
 
260
325
  // An edge the comparison says is gone is drawn when both its ends are still drawn.
261
326
  const nodes = new Set(data?.nodes.map((node) => node.id));
262
- const gone = comparison.removed.filter((edge) => edge.layer === layer && nodes.has(edge.from) && nodes.has(edge.to));
327
+ const gone = comparison.removed.filter((edge) => edge.layer === name && nodes.has(edge.from) && nodes.has(edge.to));
263
328
  return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
264
329
  };
265
330
 
266
- /** @returns {GraphNode[]} */
267
- function visibleNodes() {
268
- const ends = new Set(visibleEdges().flatMap((edge) => [edge.from, edge.to]));
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]));
269
339
  return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
270
340
  }
271
341
 
342
+ const visibleEdges = () => edgesOn(layer);
343
+ const visibleNodes = () => nodesOn(layer);
344
+
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);
354
+
355
+ layout(nodesOn(name), edgesOn(name), fresh, layoutOf(name), name === "l1" ? undefined : layoutOf("l1"));
356
+ }
357
+
358
+ /** The node glide between two layers' places, which a press stops. @type {number | undefined} */
359
+ let regrouping;
360
+
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
+ }
367
+
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
+ }
392
+
393
+ positions = t < 1 ? shown : to;
394
+ place();
395
+ regrouping = t < 1 ? requestAnimationFrame(step) : undefined;
396
+ };
397
+
398
+ positions = shown;
399
+ step(began);
400
+ }
401
+
272
402
  /** @param {string} id */
273
403
  const nodeById = (id) => data?.nodes.find((node) => node.id === id);
274
404
 
@@ -449,10 +579,13 @@ function arrive(fresh) {
449
579
  if (edge.oneSided || edge.gone) {
450
580
  path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: EASE, fill: "backwards" });
451
581
  } else {
452
- const length = path.getTotalLength();
453
- const start = { strokeDasharray: `${length} ${length}`, strokeDashoffset: forward ? length : -length };
454
- const end = { strokeDasharray: `${length} ${length}`, strokeDashoffset: 0 };
455
- path.animate([start, end], { duration: 560, delay, easing: EASE, fill: "backwards" });
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"));
456
589
  }
457
590
 
458
591
  for (const part of [label, ...group.querySelectorAll(".signal")]) {
@@ -540,6 +673,11 @@ function draw(arrival) {
540
673
  rect.setAttribute("width", String(width));
541
674
  rect.setAttribute("height", String(height));
542
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
+
543
681
  if (device) {
544
682
  for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
545
683
 
@@ -609,6 +747,7 @@ function emphasize() {
609
747
 
610
748
  for (const [id, group] of nodeElements) {
611
749
  group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
750
+ group.classList.toggle("touring", presenting && id === touring && !hovered);
612
751
  group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
613
752
  }
614
753
  }
@@ -634,6 +773,12 @@ function startDrag(event, id) {
634
773
  if (event.button !== 0) return;
635
774
  if (gliding !== undefined) cancelAnimationFrame(gliding);
636
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();
781
+ }
637
782
  event.stopPropagation();
638
783
 
639
784
  svg.setPointerCapture(event.pointerId);
@@ -757,8 +902,10 @@ function fit(readable = false, glide = false) {
757
902
  * @returns {typeof view | undefined}
758
903
  */
759
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);
760
907
  const points = nodes.flatMap((node) => {
761
- const point = positions.get(node.id);
908
+ const point = places.get(node.id);
762
909
  return point ? [point] : [];
763
910
  });
764
911
  if (!points.length) return undefined;
@@ -776,8 +923,8 @@ function framing(nodes, readable) {
776
923
  const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
777
924
  const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
778
925
  const centre =
779
- k > whole && hub && positions.has(hub.id)
780
- ? /** @type {Point} */ (positions.get(hub.id))
926
+ k > whole && hub && places.has(hub.id)
927
+ ? /** @type {Point} */ (places.get(hub.id))
781
928
  : { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
782
929
 
783
930
  // Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
@@ -807,7 +954,8 @@ byId("fit").addEventListener("click", () => fit());
807
954
 
808
955
  byId("lay-out").addEventListener("click", () => {
809
956
  if (!data) return;
810
- layout(data.nodes, data.edges, true);
957
+ layOut(layer, true);
958
+ settle();
811
959
  draw();
812
960
  fit();
813
961
  });
@@ -842,6 +990,9 @@ const LIT_PER_LAYER = 4;
842
990
  /** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
843
991
  let tourStep = 0;
844
992
 
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;
995
+
845
996
  /** The next layer that has lines to show, after this one; this one when no other has any. */
846
997
  function nextLayer() {
847
998
  const after = [...LAYERS.slice(LAYERS.indexOf(layer) + 1), ...LAYERS.slice(0, LAYERS.indexOf(layer) + 1)];
@@ -850,7 +1001,7 @@ function nextLayer() {
850
1001
 
851
1002
  /** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
852
1003
  function tourNext() {
853
- if (graphView.hidden || document.hidden || hovered || drag) return;
1004
+ if (graphView.hidden || document.hidden || hovered || drag || leaving) return;
854
1005
 
855
1006
  const edges = visibleEdges();
856
1007
  const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
@@ -870,8 +1021,27 @@ function tourNext() {
870
1021
  return;
871
1022
  }
872
1023
 
873
- // Every lit device has had its turn. Moving on replaces the address rather than adding to the history, so Back still leaves Studio's last place.
874
- location.replace(`#graph/${nextLayer()}`);
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();
1029
+
1030
+ const next = nextLayer();
1031
+ leaving = setTimeout(() => {
1032
+ leaving = undefined;
1033
+
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
+ }
1038
+
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);
875
1045
  }
876
1046
 
877
1047
  /** @param {boolean} on */
@@ -882,12 +1052,9 @@ function setPresenting(on) {
882
1052
  document.body.classList.toggle("presenting", on);
883
1053
  presentButton.setAttribute("aria-pressed", String(on));
884
1054
 
885
- clearInterval(tour);
886
1055
  touring = undefined;
887
- if (on) {
888
- selection = undefined;
889
- tour = setInterval(tourNext, 6000);
890
- }
1056
+ if (on) selection = undefined;
1057
+ restartTour();
891
1058
 
892
1059
  if (graphView.hidden || !data) return;
893
1060
 
@@ -1200,7 +1367,7 @@ export function setGraph(next) {
1200
1367
  selection = same ? { edge: same } : undefined;
1201
1368
  }
1202
1369
 
1203
- layout(data.nodes, data.edges, false);
1370
+ layOut(layer, false);
1204
1371
  }
1205
1372
 
1206
1373
  /**
@@ -1215,6 +1382,7 @@ export function showLayer(next) {
1215
1382
  pathFocus = undefined;
1216
1383
  touring = undefined;
1217
1384
  tourStep = 0;
1385
+ restartTour();
1218
1386
  }
1219
1387
 
1220
1388
  if (pendingVlan !== undefined) {
@@ -1227,20 +1395,32 @@ export function showLayer(next) {
1227
1395
  pendingPath = undefined;
1228
1396
  }
1229
1397
 
1230
- // The first view draws the whole network in; a new layer draws in its own lines.
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.
1231
1399
  const arrival = !drawn && data ? "network" : next !== layer ? "cables" : undefined;
1400
+ const from = positions;
1401
+ if (drawn) views.set(layer, { ...view });
1232
1402
 
1233
1403
  layer = next;
1234
1404
  store.set("layer", layer);
1405
+ layOut(layer, false);
1406
+
1407
+ if (arrival === "cables") glideNodes(from);
1408
+ else settle();
1235
1409
 
1236
1410
  showLayerButtons();
1237
1411
  draw(arrival);
1238
1412
  showPanel();
1239
1413
  showCaption();
1240
1414
 
1241
- // Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read.
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.
1242
1417
  if (presenting && arrival) fit();
1243
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
+ }
1244
1424
  drawn = data !== undefined;
1245
1425
  }
1246
1426
 
@@ -44,7 +44,9 @@
44
44
  <nav id="layers" aria-label="Layers"></nav>
45
45
  <div class="actions">
46
46
  <button id="fit" title="Fit the drawing to the window">Fit</button>
47
- <button id="lay-out" title="Forget where nodes were moved, and lay them out again">Lay out again</button>
47
+ <button id="lay-out" title="Forget where nodes were moved on this layer, and lay it out again">
48
+ Lay out again
49
+ </button>
48
50
  <button
49
51
  id="present"
50
52
  aria-pressed="false"
@@ -712,6 +712,66 @@ svg.canvas.panning {
712
712
  fill: var(--yellow);
713
713
  }
714
714
 
715
+ /* The device presenting lights now: a highlighter fill, lit sockets, and a ring around it that keeps leaving it, the way the docs drawing marks the changed device. A warning or error keeps its own frame. */
716
+ .node .beacon {
717
+ display: none;
718
+ }
719
+
720
+ .node.touring .beacon {
721
+ display: inline;
722
+ }
723
+
724
+ .node .beacon rect {
725
+ fill: none;
726
+ stroke: var(--yellow-deep);
727
+ stroke-width: 3;
728
+ }
729
+
730
+ .node.touring > rect {
731
+ fill: var(--yellow);
732
+ }
733
+
734
+ .node.touring:not(.error):not(.warning) > rect {
735
+ stroke: #0a1a36;
736
+ stroke-width: 4;
737
+ }
738
+
739
+ .node.touring text.name {
740
+ fill: #11141b;
741
+ }
742
+
743
+ .node.touring text.sub {
744
+ fill: #2a3550;
745
+ }
746
+
747
+ .node.touring .chassis > rect:first-child {
748
+ fill: #0a1a36;
749
+ }
750
+
751
+ .node.touring .chassis .socket {
752
+ fill: var(--yellow);
753
+ }
754
+
755
+ @media (prefers-reduced-motion: no-preference) {
756
+ .node.touring .beacon rect {
757
+ transform-box: fill-box;
758
+ transform-origin: center;
759
+ animation: beacon 1.8s cubic-bezier(0.16, 1, 0.3, 1) infinite;
760
+ }
761
+ }
762
+
763
+ @keyframes beacon {
764
+ from {
765
+ opacity: 1;
766
+ transform: scale(0.96);
767
+ }
768
+
769
+ to {
770
+ opacity: 0;
771
+ transform: scale(1.18);
772
+ }
773
+ }
774
+
715
775
  .node text {
716
776
  fill: var(--text);
717
777
  font-size: 14px;
@@ -69,16 +69,13 @@ export default defineDevice({
69
69
  { description: "DNS from guests", match: { in_interface: "guests", protocol: "udp", dst_port: 53 }, action: "accept" },
70
70
  ],
71
71
  },
72
- // Traffic passing through the router.
72
+ // Traffic passing through the router: home and guests reach the internet, and nothing else gets through.
73
73
  forward: {
74
+ default: "drop",
74
75
  rules: [
75
76
  { description: "Replies", match: { state: ["established", "related"] }, action: "accept", offload: true },
76
- {
77
- description: "Guests stay off the home network",
78
- match: { in_interface: "guests", out_interface: "home" },
79
- action: "drop",
80
- },
81
- { description: "Guests stay off management", match: { in_interface: "guests", out_interface: "mgmt" }, action: "drop" },
77
+ { description: "Home to the internet", match: { in_interface: "home", out_interface: "1g-1" }, action: "accept" },
78
+ { description: "Guests to the internet", match: { in_interface: "guests", out_interface: "1g-1" }, action: "accept" },
82
79
  ],
83
80
  },
84
81
  },