@takodotid/circuit 0.21.0 → 0.23.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.
@@ -151,6 +151,7 @@ async function studio(options) {
151
151
  `);
152
152
  };
153
153
  const server = createServer((request, response) => {
154
+ response.setHeader("x-robots-tag", "noindex, nofollow, noarchive");
154
155
  if (!hostAllowed(request.headers.host, port, options.allowHosts)) {
155
156
  response.writeHead(403).end();
156
157
  return;
@@ -186,6 +187,12 @@ async function studio(options) {
186
187
  });
187
188
  return;
188
189
  }
190
+ if (path === "/robots.txt") {
191
+ response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end(`User-agent: *
192
+ Disallow: /
193
+ `);
194
+ return;
195
+ }
189
196
  if (path === "/data") {
190
197
  response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
191
198
  return;
package/dist/cli/index.js CHANGED
@@ -4801,7 +4801,7 @@ async function studioCommand() {
4801
4801
  const port = Number(flagValue("port") ?? 4600);
4802
4802
  if (!Number.isInteger(port) || port < 1 || port > 65535)
4803
4803
  fail("--port takes a number from 1 to 65535");
4804
- const { isLoopback, studio } = await import("../chunks/server-zapfzhv4.js");
4804
+ const { isLoopback, studio } = await import("../chunks/server-ckdy25w2.js");
4805
4805
  const host = flagValue("host") ?? "127.0.0.1";
4806
4806
  const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
4807
4807
  if (!isLoopback(host) && !allowHosts.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",
@@ -2,7 +2,7 @@
2
2
  /// <reference lib="dom" />
3
3
  // Studio's page: loads what the server gathered from the config, shows the place the address names, and loads again whenever the server says the config changed.
4
4
 
5
- import { byId, go } from "./dom.js";
5
+ import { byId, enter, go } from "./dom.js";
6
6
  import { currentLayer, isLayer, setGraph, showLayer } from "./graph.js";
7
7
  import { problemCount, showTables } from "./tables.js";
8
8
  import { showTools } from "./tools.js";
@@ -99,6 +99,9 @@ function route() {
99
99
  else if (view === "compare") showCompare(parts, tables, studio);
100
100
  else showTables(parts, tables, studio);
101
101
  tables.scrollTop = moved ? 0 : scrolled;
102
+
103
+ // A new place comes in; the same place drawn again, because the config changed, stays still.
104
+ if (moved) enter(tables);
102
105
  }
103
106
 
104
107
  window.addEventListener("hashchange", route);
@@ -159,7 +162,14 @@ events.addEventListener("error", () => {
159
162
  live.classList.remove("on");
160
163
  });
161
164
 
162
- // The config changed: load it again. The page itself changed, while working on Circuit: load the page again.
163
- events.addEventListener("message", (event) => (event.data === "page" ? location.reload() : void load()));
165
+ // The config changed: load it again, and the live mark beats once to say so. The page itself changed, while working on Circuit: load the page again.
166
+ events.addEventListener("message", (event) => {
167
+ if (event.data === "page") return location.reload();
168
+
169
+ live.classList.remove("beat");
170
+ void live.offsetWidth;
171
+ live.classList.add("beat");
172
+ void load();
173
+ });
164
174
 
165
175
  void load();
@@ -71,6 +71,26 @@ export function svgById(id) {
71
71
  return element;
72
72
  }
73
73
 
74
+ /** The reader's system asks for less motion: nothing moves for show, and changes happen at once. */
75
+ export const still = matchMedia("(prefers-reduced-motion: reduce)");
76
+
77
+ /**
78
+ * Bring a part of the page in as it changes: it fades in and rises a little.
79
+ *
80
+ * @param {Element} element
81
+ */
82
+ export function enter(element) {
83
+ if (still.matches) return;
84
+
85
+ element.animate(
86
+ [
87
+ { opacity: 0, transform: "translateY(6px)" },
88
+ { opacity: 1, transform: "none" },
89
+ ],
90
+ { duration: 240, easing: "cubic-bezier(0.16, 1, 0.3, 1)" }
91
+ );
92
+ }
93
+
74
94
  /** Browser storage can be missing or refuse, in a private window for example. The page works without it. */
75
95
  export const store = {
76
96
  /**
@@ -64,15 +64,21 @@ export function svgOf(svg, viewport) {
64
64
  const originals = [svg, ...svg.querySelectorAll("*")];
65
65
  const copies = [copy, ...copy.querySelectorAll("*")];
66
66
 
67
+ // The dots travelling the cables are left out: a still image would show each one stopped at the drawing's corner.
68
+ const moving = [];
69
+
67
70
  for (const [index, original] of originals.entries()) {
68
71
  const target = copies[index];
69
72
  if (!(target instanceof SVGElement)) continue;
73
+ if (original.classList.contains("signal")) moving.push(target);
70
74
 
71
75
  const look = getComputedStyle(original);
72
76
  target.setAttribute("style", DRAWN.map((name) => `${name}:${look.getPropertyValue(name)}`).join(";"));
73
77
  target.removeAttribute("class");
74
78
  }
75
79
 
80
+ for (const dot of moving) dot.remove();
81
+
76
82
  // The drawing's own place on screen is left out: the file is framed around what is drawn.
77
83
  copy.querySelector("#viewport")?.removeAttribute("transform");
78
84
  copy.setAttribute("xmlns", "http://www.w3.org/2000/svg");
@@ -2,7 +2,7 @@
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, go, h, link, s, store, svgById } from "./dom.js";
5
+ import { byId, enter, go, h, link, s, still, store, svgById } from "./dom.js";
6
6
  import { changeKey } from "./evaluate.js";
7
7
  import { pngOf, save, svgOf } from "./export.js";
8
8
 
@@ -51,6 +51,8 @@ const viewport = svgById("viewport");
51
51
  const edgeLayer = svgById("edges");
52
52
  const nodeLayer = svgById("nodes");
53
53
  const panel = byId("panel");
54
+ const presentButton = byId("present");
55
+ const caption = byId("present-caption");
54
56
 
55
57
  // State.
56
58
  /** @type {Annotated | undefined} */
@@ -73,14 +75,38 @@ let pendingPath;
73
75
  let pathFocus;
74
76
  /** A comparison with another commit: its name, the edges it adds, and those gone. @type {{ name: string, added: Set<string>, removed: GraphEdge[] } | undefined} */
75
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;
76
84
 
77
85
  /** @param {GraphEdge} edge */
78
86
  const edgeKey = (edge) => `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
79
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();
80
90
 
81
- const saved = /** @type {Record<string, [number, number]>} */ (store.get("positions") ?? {});
82
- /** @type {Map<string, Point>} */
83
- 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);
84
110
 
85
111
  /** @type {Map<string, SVGGElement>} */
86
112
  const nodeElements = new Map();
@@ -88,7 +114,9 @@ const nodeElements = new Map();
88
114
  const edgeElements = [];
89
115
 
90
116
  function savePositions() {
91
- 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)])));
92
120
  }
93
121
 
94
122
  /**
@@ -103,17 +131,23 @@ function hash(text) {
103
131
  }
104
132
 
105
133
  /**
106
- * 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.
107
- * 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.
108
136
  *
109
137
  * @param {GraphNode[]} nodes
110
138
  * @param {GraphEdge[]} edges
111
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
112
142
  */
113
- function layout(nodes, edges, fresh) {
114
- if (fresh) positions.clear();
143
+ function layout(nodes, edges, fresh, places, seed) {
144
+ if (fresh) places.clear();
115
145
 
116
- 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));
117
151
  /** @type {Map<string, Set<string>>} */
118
152
  const neighbors = new Map();
119
153
 
@@ -125,29 +159,64 @@ function layout(nodes, edges, fresh) {
125
159
  neighbors.get(edge.to)?.add(edge.from);
126
160
  }
127
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
+
128
191
  // A new node starts beside a neighbor already placed, or on a circle.
129
192
  const radius = 60 * Math.sqrt(nodes.length + 1);
130
193
 
131
194
  for (const node of nodes) {
132
- 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
+ }
133
202
 
134
203
  const angle = hash(node.id) * Math.PI * 2;
135
- 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);
136
205
  const distance = placed ? 90 : radius;
137
206
  const origin = placed ?? { x: 0, y: 0 };
138
207
 
139
- 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 });
140
209
  }
141
210
 
142
211
  const moving = nodes.filter((node) => !fixed.has(node.id));
143
- if (!moving.length) return;
212
+ if (!moving.length) return park();
144
213
 
145
214
  const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
146
215
  const ideal = 170;
147
216
  const rounds = fixed.size ? 200 : 500;
148
217
 
149
218
  /** @param {string} id */
150
- const at = (id) => /** @type {Point} */ (positions.get(id));
219
+ const at = (id) => /** @type {Point} */ (places.get(id));
151
220
 
152
221
  for (let round = 0; round < rounds; round++) {
153
222
  const heat = 40 * (1 - round / rounds) + 1;
@@ -179,7 +248,7 @@ function layout(nodes, edges, fresh) {
179
248
  }
180
249
 
181
250
  for (const [from, to] of pairs) {
182
- if (!from || !to || !positions.has(from) || !positions.has(to)) continue;
251
+ if (!from || !to || !places.has(from) || !places.has(to)) continue;
183
252
 
184
253
  const dx = at(from).x - at(to).x;
185
254
  const dy = at(from).y - at(to).y;
@@ -207,9 +276,6 @@ function layout(nodes, edges, fresh) {
207
276
  }
208
277
 
209
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.
210
- /** @param {GraphNode} node */
211
- const halfWidth = (node) =>
212
- node.kind === "device" ? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60 : node.label.length * 4 + 30;
213
279
 
214
280
  for (let round = 0; round < 80; round++) {
215
281
  let overlapping = false;
@@ -240,27 +306,99 @@ function layout(nodes, edges, fresh) {
240
306
  if (!overlapping) break;
241
307
  }
242
308
 
309
+ park();
243
310
  savePositions();
244
311
  }
245
312
 
246
313
  // What is drawn.
247
- /** @returns {DrawnEdge[]} */
248
- const visibleEdges = () => {
249
- 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) ?? [];
250
323
  if (!comparison) return current;
251
324
 
252
325
  // An edge the comparison says is gone is drawn when both its ends are still drawn.
253
326
  const nodes = new Set(data?.nodes.map((node) => node.id));
254
- 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));
255
328
  return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
256
329
  };
257
330
 
258
- /** @returns {GraphNode[]} */
259
- function visibleNodes() {
260
- 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]));
261
339
  return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
262
340
  }
263
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
+
264
402
  /** @param {string} id */
265
403
  const nodeById = (id) => data?.nodes.find((node) => node.id === id);
266
404
 
@@ -325,6 +463,9 @@ const MOST_APART = 3;
325
463
  /** @type {Record<Layer, string>} */
326
464
  const MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
327
465
 
466
+ /** @type {Record<Layer, string>} */
467
+ const ONE = { l1: "cable", l2: "cable", l3: "shared prefix", bgp: "session", ospf: "adjacency", tunnels: "tunnel" };
468
+
328
469
  /**
329
470
  * The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
330
471
  *
@@ -363,7 +504,104 @@ function bundled(edges) {
363
504
  });
364
505
  }
365
506
 
366
- function draw() {
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
+ });
530
+
531
+ motion.append(s("mpath", { href: `#${id}` }));
532
+ dot.append(motion);
533
+ return dot;
534
+ });
535
+ }
536
+
537
+ /** A confident arrival: fast at first, settling at the end. */
538
+ const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
539
+
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;
547
+
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);
561
+ }
562
+ }
563
+
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;
569
+
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" });
573
+ }
574
+
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"));
589
+ }
590
+
591
+ for (const part of [label, ...group.querySelectorAll(".signal")]) {
592
+ part.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: delay + 420, fill: "backwards" });
593
+ }
594
+ }
595
+ }
596
+
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
+
367
605
  edgeLayer.replaceChildren();
368
606
  nodeLayer.replaceChildren();
369
607
  nodeElements.clear();
@@ -380,7 +618,7 @@ function draw() {
380
618
  /** @type {Map<string, number>} */
381
619
  const seen = new Map();
382
620
 
383
- for (const edge of edges) {
621
+ for (const [number, edge] of edges.entries()) {
384
622
  const pair = pairOf(edge);
385
623
  const index = seen.get(pair) ?? 0;
386
624
  seen.set(pair, index + 1);
@@ -393,11 +631,11 @@ function draw() {
393
631
  const change = edge.gone ? " gone" : added ? " added" : "";
394
632
  const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
395
633
  const hit = s("path", { class: "hit" });
396
- const path = s("path");
634
+ const path = s("path", { id: `cable-${number}` });
397
635
  const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
398
636
  label.textContent = edge.label;
399
637
 
400
- group.append(path, hit, label);
638
+ group.append(path, hit, ...signals(edge, `cable-${number}`), label);
401
639
 
402
640
  // Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
403
641
  group.addEventListener("pointerdown", (event) => {
@@ -435,6 +673,11 @@ function draw() {
435
673
  rect.setAttribute("width", String(width));
436
674
  rect.setAttribute("height", String(height));
437
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
+
438
681
  if (device) {
439
682
  for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
440
683
 
@@ -466,11 +709,13 @@ function draw() {
466
709
 
467
710
  place();
468
711
  emphasize();
712
+
713
+ if (arrival) arrive(new Set([...nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
469
714
  }
470
715
 
471
- /** Light up what the pointer is on, or what is selected, and dim the rest. */
716
+ /** Light up what the pointer is on, the device lit while presenting, or what is selected, and dim the rest. */
472
717
  function emphasize() {
473
- const focus = hovered ?? (selection && "node" in selection ? selection.node : undefined);
718
+ const focus = hovered ?? touring ?? (selection && "node" in selection ? selection.node : undefined);
474
719
  const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
475
720
 
476
721
  /** @param {GraphEdge} edge */
@@ -502,6 +747,7 @@ function emphasize() {
502
747
 
503
748
  for (const [id, group] of nodeElements) {
504
749
  group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
750
+ group.classList.toggle("touring", presenting && id === touring && !hovered);
505
751
  group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
506
752
  }
507
753
  }
@@ -525,6 +771,14 @@ const toWorld = (event) => {
525
771
  */
526
772
  function startDrag(event, id) {
527
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();
781
+ }
528
782
  event.stopPropagation();
529
783
 
530
784
  svg.setPointerCapture(event.pointerId);
@@ -568,6 +822,8 @@ svg.addEventListener(
568
822
  "wheel",
569
823
  (event) => {
570
824
  event.preventDefault();
825
+ if (gliding !== undefined) cancelAnimationFrame(gliding);
826
+ gliding = undefined;
571
827
 
572
828
  const point = toWorld(event);
573
829
  const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
@@ -582,17 +838,77 @@ svg.addEventListener(
582
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. */
583
839
  const READABLE = 0.6;
584
840
 
841
+ /** The view moving to a new place, which a press or a scroll stops. @type {number | undefined} */
842
+ let gliding;
843
+
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;
852
+
853
+ if (still.matches) {
854
+ view = target;
855
+ place();
856
+ return;
857
+ }
858
+
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
+ };
878
+
879
+ gliding = requestAnimationFrame(step);
880
+ }
881
+
585
882
  /**
586
- * Show the whole drawing, or, when that would make it unreadable and `readable` is asked, as much of it as stays readable.
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.
587
884
  *
588
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
+ }
896
+
897
+ /**
898
+ * The view that frames these nodes.
899
+ *
900
+ * @param {GraphNode[]} nodes
901
+ * @param {boolean} readable
902
+ * @returns {typeof view | undefined}
589
903
  */
590
- function fit(readable = false) {
591
- const points = visibleNodes().flatMap((node) => {
592
- const point = positions.get(node.id);
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);
593
909
  return point ? [point] : [];
594
910
  });
595
- if (!points.length) return;
911
+ if (!points.length) return undefined;
596
912
 
597
913
  const box = svg.getBoundingClientRect();
598
914
  const xs = points.map((point) => point.x);
@@ -605,13 +921,32 @@ function fit(readable = false) {
605
921
  // When the whole drawing does not fit readably, the view starts on the device with the most lines, the heart of the network.
606
922
  const edges = visibleEdges();
607
923
  const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
608
- const hub = [...visibleNodes()].sort((a, b) => degree(b.id) - degree(a.id))[0];
924
+ const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
609
925
  const centre =
610
- k > whole && hub && positions.has(hub.id)
611
- ? /** @type {Point} */ (positions.get(hub.id))
926
+ k > whole && hub && places.has(hub.id)
927
+ ? /** @type {Point} */ (places.get(hub.id))
612
928
  : { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
613
929
 
614
- view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
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
+ }
935
+
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);
940
+
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;
615
950
  place();
616
951
  }
617
952
 
@@ -619,12 +954,126 @@ byId("fit").addEventListener("click", () => fit());
619
954
 
620
955
  byId("lay-out").addEventListener("click", () => {
621
956
  if (!data) return;
622
- layout(data.nodes, data.edges, true);
957
+ layOut(layer, true);
958
+ settle();
623
959
  draw();
624
960
  fit();
625
961
  });
626
962
 
627
- window.addEventListener("resize", () => place());
963
+ // Presenting keeps the whole network in view as the screen changes size.
964
+ window.addEventListener("resize", () => (presenting ? frameTour(false) : place()));
965
+
966
+ // Presenting.
967
+
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;
972
+
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]}`;
976
+
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}`)
982
+ );
983
+
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;
989
+
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;
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
+
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
+ }
1001
+
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
+ }
1023
+
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);
1045
+ }
1046
+
1047
+ /** @param {boolean} on */
1048
+ function setPresenting(on) {
1049
+ presenting = on;
1050
+ store.set("present", on);
1051
+
1052
+ document.body.classList.toggle("presenting", on);
1053
+ presentButton.setAttribute("aria-pressed", String(on));
1054
+
1055
+ touring = undefined;
1056
+ if (on) selection = undefined;
1057
+ restartTour();
1058
+
1059
+ if (graphView.hidden || !data) return;
1060
+
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
+ }
1067
+
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
+ });
628
1077
 
629
1078
  // Saving the layer shown, as it looks.
630
1079
  byId("save-svg").addEventListener("click", () => {
@@ -640,10 +1089,16 @@ byId("save-png").addEventListener("click", async () => {
640
1089
  // The side panel.
641
1090
  /** @param {Selection} next */
642
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
+
643
1096
  selection = next;
644
1097
  pathFocus = undefined;
645
1098
  emphasize();
646
1099
  showPanel();
1100
+
1101
+ if (changed) enter(panel);
647
1102
  }
648
1103
 
649
1104
  /**
@@ -882,7 +1337,12 @@ function showLayerButtons() {
882
1337
  document.addEventListener("keydown", (event) => {
883
1338
  if (event.target instanceof HTMLInputElement || graphView.hidden) return;
884
1339
 
885
- if (event.key === "Escape") select(undefined);
1340
+ if (event.key === "Escape") {
1341
+ if (presenting) setPresenting(false);
1342
+ select(undefined);
1343
+ }
1344
+
1345
+ if (event.key === "p" && !event.ctrlKey && !event.metaKey && !event.altKey) setPresenting(!presenting);
886
1346
 
887
1347
  // Keys 1 to 6 pick a layer.
888
1348
  const chosen = LAYERS[Number(event.key) - 1];
@@ -907,7 +1367,7 @@ export function setGraph(next) {
907
1367
  selection = same ? { edge: same } : undefined;
908
1368
  }
909
1369
 
910
- layout(data.nodes, data.edges, false);
1370
+ layOut(layer, false);
911
1371
  }
912
1372
 
913
1373
  /**
@@ -920,6 +1380,9 @@ export function showLayer(next) {
920
1380
  if (selection && "edge" in selection) selection = undefined;
921
1381
  vlanFocus = undefined;
922
1382
  pathFocus = undefined;
1383
+ touring = undefined;
1384
+ tourStep = 0;
1385
+ restartTour();
923
1386
  }
924
1387
 
925
1388
  if (pendingVlan !== undefined) {
@@ -932,14 +1395,32 @@ export function showLayer(next) {
932
1395
  pendingPath = undefined;
933
1396
  }
934
1397
 
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
+
935
1403
  layer = next;
936
1404
  store.set("layer", layer);
1405
+ layOut(layer, false);
1406
+
1407
+ if (arrival === "cables") glideNodes(from);
1408
+ else settle();
937
1409
 
938
1410
  showLayerButtons();
939
- draw();
1411
+ draw(arrival);
940
1412
  showPanel();
941
-
942
- if (!drawn && data) fit(true);
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
+ }
943
1424
  drawn = data !== undefined;
944
1425
  }
945
1426
 
@@ -5,6 +5,8 @@
5
5
  <meta charset="utf-8" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
7
  <title>Circuit Studio</title>
8
+ <!-- Studio shows one team's network, so search engines are asked to leave it out. -->
9
+ <meta name="robots" content="noindex, nofollow, noarchive" />
8
10
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
9
11
  <!-- Special Gothic is shared with the documentation site, and served from beside this page so Studio works offline. -->
10
12
  <link rel="preload" href="special-gothic-latin.woff2" as="font" type="font/woff2" crossorigin />
@@ -42,7 +44,16 @@
42
44
  <nav id="layers" aria-label="Layers"></nav>
43
45
  <div class="actions">
44
46
  <button id="fit" title="Fit the drawing to the window">Fit</button>
45
- <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>
50
+ <button
51
+ id="present"
52
+ aria-pressed="false"
53
+ title="Show the network on a big screen: the panel hides, and the drawing goes through every layer, lighting its busiest devices in turn. Key P."
54
+ >
55
+ Present
56
+ </button>
46
57
  <details class="menu">
47
58
  <summary>Save image</summary>
48
59
  <div class="menu-items">
@@ -59,6 +70,7 @@
59
70
  <g id="nodes"></g>
60
71
  </g>
61
72
  </svg>
73
+ <p id="present-caption" class="present-caption" aria-live="polite" hidden></p>
62
74
  <aside id="panel"></aside>
63
75
  </div>
64
76
  </section>
@@ -39,6 +39,8 @@
39
39
  --surface: #ffffff;
40
40
  --surface-raised: #f4f6fb;
41
41
  --line: #d3d9e7;
42
+ --head: #e1e7f3;
43
+ --hover: rgba(10, 71, 184, 0.045);
42
44
  --text: #11141b;
43
45
  --muted: #49536a;
44
46
  --edge: #7b86a0;
@@ -67,6 +69,8 @@
67
69
  --surface: #121a30;
68
70
  --surface-raised: #182241;
69
71
  --line: #26314d;
72
+ --head: #26325a;
73
+ --hover: rgba(156, 185, 255, 0.05);
70
74
  --text: #f1f4fb;
71
75
  --muted: #a9b4cc;
72
76
  --edge: #7c89a8;
@@ -258,6 +262,14 @@ header > nav button[aria-pressed="true"]::after {
258
262
  left: 9px;
259
263
  height: 4px;
260
264
  background: var(--yellow);
265
+ animation: bar 0.28s cubic-bezier(0.16, 1, 0.3, 1);
266
+ }
267
+
268
+ /* The bar under the view or tab just chosen grows out from its middle. */
269
+ @keyframes bar {
270
+ from {
271
+ transform: scaleX(0);
272
+ }
261
273
  }
262
274
 
263
275
  .count {
@@ -393,6 +405,18 @@ header input[type="search"]:focus {
393
405
  background: var(--ok);
394
406
  }
395
407
 
408
+ /* When a changed config arrives, the live mark beats once in yellow. */
409
+ .status-rail .live.beat::before {
410
+ animation: beat 0.7s cubic-bezier(0.16, 1, 0.3, 1);
411
+ }
412
+
413
+ @keyframes beat {
414
+ 30% {
415
+ background: var(--yellow);
416
+ transform: scale(1.9);
417
+ }
418
+ }
419
+
396
420
  .banner {
397
421
  margin: 0;
398
422
  padding: 10px 18px;
@@ -537,9 +561,11 @@ main {
537
561
  /* The drawing board. */
538
562
 
539
563
  .stage {
564
+ position: relative;
540
565
  flex: 1;
541
566
  display: flex;
542
567
  min-height: 0;
568
+ overflow: hidden;
543
569
  background-color: var(--bg);
544
570
  background-image: radial-gradient(circle, var(--dots) 1.2px, transparent 1.6px);
545
571
  background-size: 24px 24px;
@@ -562,6 +588,33 @@ svg.canvas.panning {
562
588
  fill: none;
563
589
  stroke: var(--edge);
564
590
  stroke-width: 2;
591
+ transition:
592
+ stroke 0.2s,
593
+ stroke-width 0.2s;
594
+ }
595
+
596
+ /* Lighting one part dims the rest gently, so the eye follows what changed. */
597
+ .edge,
598
+ .node {
599
+ transition: opacity 0.25s ease-out;
600
+ }
601
+
602
+ /* Dots travelling a cable both ends declare: traffic where the link works. On a lit cable they turn to the highlighter. */
603
+ .edge .signal {
604
+ fill: var(--accent);
605
+ pointer-events: none;
606
+ }
607
+
608
+ .edge.lit .signal,
609
+ .edge.selected .signal {
610
+ r: 4.5px;
611
+ fill: var(--yellow);
612
+ stroke: var(--navy);
613
+ stroke-width: 1.5;
614
+ }
615
+
616
+ .edge.added .signal {
617
+ fill: var(--ok);
565
618
  }
566
619
 
567
620
  .edge text {
@@ -659,6 +712,66 @@ svg.canvas.panning {
659
712
  fill: var(--yellow);
660
713
  }
661
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
+
662
775
  .node text {
663
776
  fill: var(--text);
664
777
  font-size: 14px;
@@ -695,6 +808,78 @@ svg.far .edge text {
695
808
  display: none;
696
809
  }
697
810
 
811
+ /* Presenting: the network for a big screen. The panel steps aside, lines and dots are heavier, and the drawing drifts slowly. */
812
+
813
+ .presenting #panel {
814
+ display: none;
815
+ }
816
+
817
+ .presenting .edge path:not(.hit) {
818
+ stroke-width: 3;
819
+ }
820
+
821
+ .presenting .edge.lit path:not(.hit) {
822
+ stroke-width: 5;
823
+ }
824
+
825
+ .presenting .edge .signal {
826
+ r: 4.5px;
827
+ }
828
+
829
+ .presenting .edge.lit .signal {
830
+ r: 6px;
831
+ }
832
+
833
+ .present-caption {
834
+ position: absolute;
835
+ bottom: 22px;
836
+ left: 22px;
837
+ display: grid;
838
+ gap: 2px;
839
+ margin: 0;
840
+ padding: 12px 18px 14px;
841
+ border: 2px solid var(--text);
842
+ border-radius: 2px;
843
+ background: var(--surface);
844
+ pointer-events: none;
845
+ }
846
+
847
+ .present-caption[hidden] {
848
+ display: none;
849
+ }
850
+
851
+ .present-caption strong {
852
+ font-size: 26px;
853
+ letter-spacing: -0.015em;
854
+ line-height: 1.15;
855
+ }
856
+
857
+ .present-caption span {
858
+ color: var(--muted);
859
+ font-size: 14px;
860
+ }
861
+
862
+ @media (prefers-reduced-motion: no-preference) {
863
+ .presenting svg.canvas {
864
+ animation: drift 48s ease-in-out infinite alternate;
865
+ }
866
+
867
+ .presenting svg.canvas:hover,
868
+ .presenting svg.canvas.panning {
869
+ animation-play-state: paused;
870
+ }
871
+ }
872
+
873
+ @keyframes drift {
874
+ from {
875
+ transform: scale(1) translate(-1%, -0.6%);
876
+ }
877
+
878
+ to {
879
+ transform: scale(1.05) translate(1%, 0.6%);
880
+ }
881
+ }
882
+
698
883
  /* The side panel: what is selected, or the layer's legend. */
699
884
 
700
885
  aside {
@@ -898,6 +1083,7 @@ aside pre {
898
1083
  left: 8px;
899
1084
  height: 4px;
900
1085
  background: var(--cobalt);
1086
+ animation: bar 0.28s cubic-bezier(0.16, 1, 0.3, 1);
901
1087
  }
902
1088
 
903
1089
  .table-block {
@@ -934,9 +1120,10 @@ table.data td {
934
1120
  vertical-align: top;
935
1121
  }
936
1122
 
1123
+ /* The head is a tinted band rather than a rule, so it is never read as a divider between rows. */
937
1124
  table.data thead th {
938
- border-bottom: 2px solid var(--text);
939
- background: var(--surface);
1125
+ border-bottom: 0;
1126
+ background: var(--head);
940
1127
  color: var(--text);
941
1128
  font-size: 11px;
942
1129
  font-weight: 700;
@@ -952,9 +1139,10 @@ table.data th[title] {
952
1139
  text-underline-offset: 3px;
953
1140
  }
954
1141
 
955
- table.data tbody tr:hover > td,
956
- table.data tbody tr:hover > th {
957
- background: var(--surface-raised);
1142
+ /* Only the row under the pointer is washed, not every row that holds it, and the wash is a faint link tint well apart from the head's band. */
1143
+ table.data tbody tr:hover:not(:has(tr:hover)) > td,
1144
+ table.data tbody tr:hover:not(:has(tr:hover)) > th {
1145
+ background: var(--hover);
958
1146
  }
959
1147
 
960
1148
  table.data tr.muted td {
@@ -110,6 +110,9 @@ export async function studio(options: StudioOptions): Promise<void> {
110
110
  };
111
111
 
112
112
  const server = createServer((request, response) => {
113
+ // Studio shows one team's network: every answer asks search engines to leave it out, and robots.txt says so for the whole site.
114
+ response.setHeader("x-robots-tag", "noindex, nofollow, noarchive");
115
+
113
116
  if (!hostAllowed(request.headers.host, port, options.allowHosts)) {
114
117
  response.writeHead(403).end();
115
118
  return;
@@ -154,6 +157,11 @@ export async function studio(options: StudioOptions): Promise<void> {
154
157
  return;
155
158
  }
156
159
 
160
+ if (path === "/robots.txt") {
161
+ response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("User-agent: *\nDisallow: /\n");
162
+ return;
163
+ }
164
+
157
165
  if (path === "/data") {
158
166
  response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
159
167
  return;