@takodotid/circuit 0.21.0 → 0.22.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.22.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,6 +75,12 @@ 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}`;
@@ -325,6 +333,9 @@ const MOST_APART = 3;
325
333
  /** @type {Record<Layer, string>} */
326
334
  const MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
327
335
 
336
+ /** @type {Record<Layer, string>} */
337
+ const ONE = { l1: "cable", l2: "cable", l3: "shared prefix", bgp: "session", ospf: "adjacency", tunnels: "tunnel" };
338
+
328
339
  /**
329
340
  * The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
330
341
  *
@@ -363,7 +374,101 @@ function bundled(edges) {
363
374
  });
364
375
  }
365
376
 
366
- function draw() {
377
+ /**
378
+ * 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.
379
+ *
380
+ * @param {DrawnEdge} edge
381
+ * @param {string} id the id of the cable's drawn path, which the dots follow as it moves
382
+ */
383
+ function signals(edge, id) {
384
+ if (still.matches || edge.oneSided || edge.gone) return [];
385
+
386
+ const a = positions.get(edge.from) ?? { x: 0, y: 0 };
387
+ const b = positions.get(edge.to) ?? { x: 0, y: 0 };
388
+ const seconds = Math.min(Math.max(Math.hypot(b.x - a.x, b.y - a.y) / (presenting ? 110 : 45), 1.6), 12);
389
+ const phase = hash(`${edge.from}${edge.to}${edge.label}`) * seconds;
390
+ const backwards = presenting ? [false, true] : [hash(edge.label) < 0.5];
391
+
392
+ return backwards.map((back, index) => {
393
+ const dot = s("circle", { class: "signal", r: 3.5 });
394
+ const motion = s("animateMotion", {
395
+ dur: `${seconds.toFixed(2)}s`,
396
+ begin: `-${(phase + (index * seconds) / 2).toFixed(2)}s`,
397
+ repeatCount: "indefinite",
398
+ ...(back ? { keyPoints: "1;0", keyTimes: "0;1", calcMode: "linear" } : {}),
399
+ });
400
+
401
+ motion.append(s("mpath", { href: `#${id}` }));
402
+ dot.append(motion);
403
+ return dot;
404
+ });
405
+ }
406
+
407
+ /** A confident arrival: fast at first, settling at the end. */
408
+ const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
409
+
410
+ /**
411
+ * Draw the network in from its busiest device outward, a ring of neighbours at a time, each cable drawn from its nearer end.
412
+ *
413
+ * @param {Set<string>} fresh the nodes this drawing adds, which fade in; the others are already on screen
414
+ */
415
+ function arrive(fresh) {
416
+ if (still.matches || !edgeElements.length) return;
417
+
418
+ /** @param {string} id */
419
+ const degree = (id) => edgeElements.filter(({ edge }) => edge.from === id || edge.to === id).length;
420
+ const hub = [...nodeElements.keys()].sort((a, b) => degree(b) - degree(a))[0];
421
+
422
+ /** @type {Map<string, number>} */
423
+ const ring = new Map(hub ? [[hub, 0]] : []);
424
+ for (let queue = hub ? [hub] : []; queue.length;) {
425
+ const id = /** @type {string} */ (queue.shift());
426
+ for (const { edge } of edgeElements) {
427
+ const other = edge.from === id ? edge.to : edge.to === id ? edge.from : undefined;
428
+ if (other === undefined || ring.has(other)) continue;
429
+ ring.set(other, (ring.get(id) ?? 0) + 1);
430
+ queue.push(other);
431
+ }
432
+ }
433
+
434
+ // The whole drawing arrives within about a second, however many rings it has.
435
+ const farthest = Math.max(0, ...ring.values()) + 1;
436
+ const step = Math.min(110, 800 / farthest);
437
+ /** @param {string} id */
438
+ const ringOf = (id) => ring.get(id) ?? farthest;
439
+
440
+ for (const [id, group] of nodeElements) {
441
+ if (!fresh.has(id)) continue;
442
+ group.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 360, delay: ringOf(id) * step, easing: EASE, fill: "backwards" });
443
+ }
444
+
445
+ for (const { edge, group, path, label } of edgeElements) {
446
+ const forward = ringOf(edge.from) <= ringOf(edge.to);
447
+ const delay = (Math.min(ringOf(edge.from), ringOf(edge.to)) + 0.5) * step;
448
+
449
+ if (edge.oneSided || edge.gone) {
450
+ path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: EASE, fill: "backwards" });
451
+ } 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" });
456
+ }
457
+
458
+ for (const part of [label, ...group.querySelectorAll(".signal")]) {
459
+ part.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: delay + 420, fill: "backwards" });
460
+ }
461
+ }
462
+ }
463
+
464
+ /**
465
+ * 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.
466
+ *
467
+ * @param {"network" | "cables"} [arrival]
468
+ */
469
+ function draw(arrival) {
470
+ const before = new Set(nodeElements.keys());
471
+
367
472
  edgeLayer.replaceChildren();
368
473
  nodeLayer.replaceChildren();
369
474
  nodeElements.clear();
@@ -380,7 +485,7 @@ function draw() {
380
485
  /** @type {Map<string, number>} */
381
486
  const seen = new Map();
382
487
 
383
- for (const edge of edges) {
488
+ for (const [number, edge] of edges.entries()) {
384
489
  const pair = pairOf(edge);
385
490
  const index = seen.get(pair) ?? 0;
386
491
  seen.set(pair, index + 1);
@@ -393,11 +498,11 @@ function draw() {
393
498
  const change = edge.gone ? " gone" : added ? " added" : "";
394
499
  const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
395
500
  const hit = s("path", { class: "hit" });
396
- const path = s("path");
501
+ const path = s("path", { id: `cable-${number}` });
397
502
  const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
398
503
  label.textContent = edge.label;
399
504
 
400
- group.append(path, hit, label);
505
+ group.append(path, hit, ...signals(edge, `cable-${number}`), label);
401
506
 
402
507
  // Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
403
508
  group.addEventListener("pointerdown", (event) => {
@@ -466,11 +571,13 @@ function draw() {
466
571
 
467
572
  place();
468
573
  emphasize();
574
+
575
+ if (arrival) arrive(new Set([...nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
469
576
  }
470
577
 
471
- /** Light up what the pointer is on, or what is selected, and dim the rest. */
578
+ /** Light up what the pointer is on, the device lit while presenting, or what is selected, and dim the rest. */
472
579
  function emphasize() {
473
- const focus = hovered ?? (selection && "node" in selection ? selection.node : undefined);
580
+ const focus = hovered ?? touring ?? (selection && "node" in selection ? selection.node : undefined);
474
581
  const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
475
582
 
476
583
  /** @param {GraphEdge} edge */
@@ -525,6 +632,8 @@ const toWorld = (event) => {
525
632
  */
526
633
  function startDrag(event, id) {
527
634
  if (event.button !== 0) return;
635
+ if (gliding !== undefined) cancelAnimationFrame(gliding);
636
+ gliding = undefined;
528
637
  event.stopPropagation();
529
638
 
530
639
  svg.setPointerCapture(event.pointerId);
@@ -568,6 +677,8 @@ svg.addEventListener(
568
677
  "wheel",
569
678
  (event) => {
570
679
  event.preventDefault();
680
+ if (gliding !== undefined) cancelAnimationFrame(gliding);
681
+ gliding = undefined;
571
682
 
572
683
  const point = toWorld(event);
573
684
  const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
@@ -582,17 +693,75 @@ svg.addEventListener(
582
693
  /** 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
694
  const READABLE = 0.6;
584
695
 
696
+ /** The view moving to a new place, which a press or a scroll stops. @type {number | undefined} */
697
+ let gliding;
698
+
585
699
  /**
586
- * Show the whole drawing, or, when that would make it unreadable and `readable` is asked, as much of it as stays readable.
700
+ * 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.
701
+ *
702
+ * @param {typeof view} target
703
+ */
704
+ function glideTo(target) {
705
+ if (gliding !== undefined) cancelAnimationFrame(gliding);
706
+ gliding = undefined;
707
+
708
+ if (still.matches) {
709
+ view = target;
710
+ place();
711
+ return;
712
+ }
713
+
714
+ const box = svg.getBoundingClientRect();
715
+ const from = { ...view };
716
+ /** @param {typeof view} one */
717
+ const centreOf = (one) => ({ x: (box.width / 2 - one.x) / one.k, y: (box.height / 2 - one.y) / one.k });
718
+ const start = centreOf(from);
719
+ const end = centreOf(target);
720
+ const began = performance.now();
721
+
722
+ /** @param {number} now */
723
+ const step = (now) => {
724
+ const t = Math.min((now - began) / 1100, 1);
725
+ const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
726
+ const k = from.k * (target.k / from.k) ** eased;
727
+ const centre = { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased };
728
+
729
+ view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
730
+ place();
731
+ gliding = t < 1 ? requestAnimationFrame(step) : undefined;
732
+ };
733
+
734
+ gliding = requestAnimationFrame(step);
735
+ }
736
+
737
+ /**
738
+ * 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
739
  *
588
740
  * @param {boolean} [readable]
741
+ * @param {boolean} [glide]
589
742
  */
590
- function fit(readable = false) {
591
- const points = visibleNodes().flatMap((node) => {
743
+ function fit(readable = false, glide = false) {
744
+ const target = framing(visibleNodes(), readable);
745
+ if (!target) return;
746
+
747
+ if (glide) return glideTo(target);
748
+ view = target;
749
+ place();
750
+ }
751
+
752
+ /**
753
+ * The view that frames these nodes.
754
+ *
755
+ * @param {GraphNode[]} nodes
756
+ * @param {boolean} readable
757
+ * @returns {typeof view | undefined}
758
+ */
759
+ function framing(nodes, readable) {
760
+ const points = nodes.flatMap((node) => {
592
761
  const point = positions.get(node.id);
593
762
  return point ? [point] : [];
594
763
  });
595
- if (!points.length) return;
764
+ if (!points.length) return undefined;
596
765
 
597
766
  const box = svg.getBoundingClientRect();
598
767
  const xs = points.map((point) => point.x);
@@ -605,13 +774,32 @@ function fit(readable = false) {
605
774
  // When the whole drawing does not fit readably, the view starts on the device with the most lines, the heart of the network.
606
775
  const edges = visibleEdges();
607
776
  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];
777
+ const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
609
778
  const centre =
610
779
  k > whole && hub && positions.has(hub.id)
611
780
  ? /** @type {Point} */ (positions.get(hub.id))
612
781
  : { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
613
782
 
614
- view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
783
+ // Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
784
+ const shown = presenting ? k * 0.86 : k;
785
+
786
+ return { k: shown, x: box.width / 2 - centre.x * shown, y: box.height / 2 - centre.y * shown };
787
+ }
788
+
789
+ /** While presenting, frame what is shown: the lit device with everything it has a line to, or the whole layer. */
790
+ function frameTour(glide = true) {
791
+ const lit = touring;
792
+ if (!lit) return fit(false, glide);
793
+
794
+ const near = new Set([lit, ...visibleEdges().flatMap((edge) => (edge.from === lit ? [edge.to] : edge.to === lit ? [edge.from] : []))]);
795
+ const target = framing(
796
+ visibleNodes().filter((node) => near.has(node.id)),
797
+ false
798
+ );
799
+ if (!target) return;
800
+
801
+ if (glide) return glideTo(target);
802
+ view = target;
615
803
  place();
616
804
  }
617
805
 
@@ -624,7 +812,101 @@ byId("lay-out").addEventListener("click", () => {
624
812
  fit();
625
813
  });
626
814
 
627
- window.addEventListener("resize", () => place());
815
+ // Presenting keeps the whole network in view as the screen changes size.
816
+ window.addEventListener("resize", () => (presenting ? frameTour(false) : place()));
817
+
818
+ // Presenting.
819
+
820
+ /** Say what is shown while presenting: the device lit, or the whole layer. */
821
+ function showCaption() {
822
+ caption.hidden = !presenting;
823
+ if (!presenting) return;
824
+
825
+ const node = touring ? nodeById(touring) : undefined;
826
+ const edges = visibleEdges().filter((edge) => !node || edge.from === node.id || edge.to === node.id);
827
+ const count = `${edges.length} ${edges.length === 1 ? ONE[layer] : MANY[layer]}`;
828
+
829
+ if (node) caption.replaceChildren(h("strong", {}, node.label), h("span", {}, [node.detail[0], count].filter(Boolean).join(", ")));
830
+ else
831
+ caption.replaceChildren(
832
+ h("strong", {}, LAYER_NAMES[layer]),
833
+ h("span", {}, `${visibleNodes().filter((one) => one.kind === "device").length} devices, ${count}`)
834
+ );
835
+
836
+ enter(caption);
837
+ }
838
+
839
+ /** How many devices are lit on each layer while presenting, busiest first, before it moves on to the next layer. */
840
+ const LIT_PER_LAYER = 4;
841
+
842
+ /** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
843
+ let tourStep = 0;
844
+
845
+ /** The next layer that has lines to show, after this one; this one when no other has any. */
846
+ function nextLayer() {
847
+ const after = [...LAYERS.slice(LAYERS.indexOf(layer) + 1), ...LAYERS.slice(0, LAYERS.indexOf(layer) + 1)];
848
+ return after.find((name) => data?.edges.some((edge) => edge.layer === name)) ?? layer;
849
+ }
850
+
851
+ /** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
852
+ function tourNext() {
853
+ if (graphView.hidden || document.hidden || hovered || drag) return;
854
+
855
+ const edges = visibleEdges();
856
+ const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
857
+ const devices = visibleNodes()
858
+ .filter((node) => node.kind === "device" && degree(node.id))
859
+ .sort((a, b) => degree(b.id) - degree(a.id) || a.label.localeCompare(b.label))
860
+ .slice(0, LIT_PER_LAYER);
861
+
862
+ tourStep += 1;
863
+ touring = devices[tourStep - 1]?.id;
864
+
865
+ if (touring || nextLayer() === layer) {
866
+ if (!touring) tourStep = 0;
867
+ emphasize();
868
+ showCaption();
869
+ frameTour();
870
+ return;
871
+ }
872
+
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()}`);
875
+ }
876
+
877
+ /** @param {boolean} on */
878
+ function setPresenting(on) {
879
+ presenting = on;
880
+ store.set("present", on);
881
+
882
+ document.body.classList.toggle("presenting", on);
883
+ presentButton.setAttribute("aria-pressed", String(on));
884
+
885
+ clearInterval(tour);
886
+ touring = undefined;
887
+ if (on) {
888
+ selection = undefined;
889
+ tour = setInterval(tourNext, 6000);
890
+ }
891
+
892
+ if (graphView.hidden || !data) return;
893
+
894
+ // The panel hides or returns, so the drawing is fitted once the page has its new size.
895
+ draw();
896
+ showPanel();
897
+ showCaption();
898
+ requestAnimationFrame(() => (presenting ? frameTour(false) : fit()));
899
+ }
900
+
901
+ presentButton.addEventListener("click", () => setPresenting(!presenting));
902
+ setPresenting(presenting);
903
+
904
+ // A hidden page's dots stop moving.
905
+ document.addEventListener("visibilitychange", () => {
906
+ if (!(svg instanceof SVGSVGElement)) return;
907
+ if (document.hidden) svg.pauseAnimations();
908
+ else svg.unpauseAnimations();
909
+ });
628
910
 
629
911
  // Saving the layer shown, as it looks.
630
912
  byId("save-svg").addEventListener("click", () => {
@@ -640,10 +922,16 @@ byId("save-png").addEventListener("click", async () => {
640
922
  // The side panel.
641
923
  /** @param {Selection} next */
642
924
  function select(next) {
925
+ /** @param {Selection} one */
926
+ const keyOf = (one) => (one && "node" in one ? one.node : one ? edgeKey(one.edge) : "");
927
+ const changed = keyOf(next) !== keyOf(selection);
928
+
643
929
  selection = next;
644
930
  pathFocus = undefined;
645
931
  emphasize();
646
932
  showPanel();
933
+
934
+ if (changed) enter(panel);
647
935
  }
648
936
 
649
937
  /**
@@ -882,7 +1170,12 @@ function showLayerButtons() {
882
1170
  document.addEventListener("keydown", (event) => {
883
1171
  if (event.target instanceof HTMLInputElement || graphView.hidden) return;
884
1172
 
885
- if (event.key === "Escape") select(undefined);
1173
+ if (event.key === "Escape") {
1174
+ if (presenting) setPresenting(false);
1175
+ select(undefined);
1176
+ }
1177
+
1178
+ if (event.key === "p" && !event.ctrlKey && !event.metaKey && !event.altKey) setPresenting(!presenting);
886
1179
 
887
1180
  // Keys 1 to 6 pick a layer.
888
1181
  const chosen = LAYERS[Number(event.key) - 1];
@@ -920,6 +1213,8 @@ export function showLayer(next) {
920
1213
  if (selection && "edge" in selection) selection = undefined;
921
1214
  vlanFocus = undefined;
922
1215
  pathFocus = undefined;
1216
+ touring = undefined;
1217
+ tourStep = 0;
923
1218
  }
924
1219
 
925
1220
  if (pendingVlan !== undefined) {
@@ -932,14 +1227,20 @@ export function showLayer(next) {
932
1227
  pendingPath = undefined;
933
1228
  }
934
1229
 
1230
+ // The first view draws the whole network in; a new layer draws in its own lines.
1231
+ const arrival = !drawn && data ? "network" : next !== layer ? "cables" : undefined;
1232
+
935
1233
  layer = next;
936
1234
  store.set("layer", layer);
937
1235
 
938
1236
  showLayerButtons();
939
- draw();
1237
+ draw(arrival);
940
1238
  showPanel();
1239
+ showCaption();
941
1240
 
942
- if (!drawn && data) fit(true);
1241
+ // Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read.
1242
+ if (presenting && arrival) fit();
1243
+ else if (!drawn && data) fit(true);
943
1244
  drawn = data !== undefined;
944
1245
  }
945
1246
 
@@ -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 />
@@ -43,6 +45,13 @@
43
45
  <div class="actions">
44
46
  <button id="fit" title="Fit the drawing to the window">Fit</button>
45
47
  <button id="lay-out" title="Forget where nodes were moved, and lay them out again">Lay out again</button>
48
+ <button
49
+ id="present"
50
+ aria-pressed="false"
51
+ 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."
52
+ >
53
+ Present
54
+ </button>
46
55
  <details class="menu">
47
56
  <summary>Save image</summary>
48
57
  <div class="menu-items">
@@ -59,6 +68,7 @@
59
68
  <g id="nodes"></g>
60
69
  </g>
61
70
  </svg>
71
+ <p id="present-caption" class="present-caption" aria-live="polite" hidden></p>
62
72
  <aside id="panel"></aside>
63
73
  </div>
64
74
  </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 {
@@ -695,6 +748,78 @@ svg.far .edge text {
695
748
  display: none;
696
749
  }
697
750
 
751
+ /* Presenting: the network for a big screen. The panel steps aside, lines and dots are heavier, and the drawing drifts slowly. */
752
+
753
+ .presenting #panel {
754
+ display: none;
755
+ }
756
+
757
+ .presenting .edge path:not(.hit) {
758
+ stroke-width: 3;
759
+ }
760
+
761
+ .presenting .edge.lit path:not(.hit) {
762
+ stroke-width: 5;
763
+ }
764
+
765
+ .presenting .edge .signal {
766
+ r: 4.5px;
767
+ }
768
+
769
+ .presenting .edge.lit .signal {
770
+ r: 6px;
771
+ }
772
+
773
+ .present-caption {
774
+ position: absolute;
775
+ bottom: 22px;
776
+ left: 22px;
777
+ display: grid;
778
+ gap: 2px;
779
+ margin: 0;
780
+ padding: 12px 18px 14px;
781
+ border: 2px solid var(--text);
782
+ border-radius: 2px;
783
+ background: var(--surface);
784
+ pointer-events: none;
785
+ }
786
+
787
+ .present-caption[hidden] {
788
+ display: none;
789
+ }
790
+
791
+ .present-caption strong {
792
+ font-size: 26px;
793
+ letter-spacing: -0.015em;
794
+ line-height: 1.15;
795
+ }
796
+
797
+ .present-caption span {
798
+ color: var(--muted);
799
+ font-size: 14px;
800
+ }
801
+
802
+ @media (prefers-reduced-motion: no-preference) {
803
+ .presenting svg.canvas {
804
+ animation: drift 48s ease-in-out infinite alternate;
805
+ }
806
+
807
+ .presenting svg.canvas:hover,
808
+ .presenting svg.canvas.panning {
809
+ animation-play-state: paused;
810
+ }
811
+ }
812
+
813
+ @keyframes drift {
814
+ from {
815
+ transform: scale(1) translate(-1%, -0.6%);
816
+ }
817
+
818
+ to {
819
+ transform: scale(1.05) translate(1%, 0.6%);
820
+ }
821
+ }
822
+
698
823
  /* The side panel: what is selected, or the layer's legend. */
699
824
 
700
825
  aside {
@@ -898,6 +1023,7 @@ aside pre {
898
1023
  left: 8px;
899
1024
  height: 4px;
900
1025
  background: var(--cobalt);
1026
+ animation: bar 0.28s cubic-bezier(0.16, 1, 0.3, 1);
901
1027
  }
902
1028
 
903
1029
  .table-block {
@@ -934,9 +1060,10 @@ table.data td {
934
1060
  vertical-align: top;
935
1061
  }
936
1062
 
1063
+ /* The head is a tinted band rather than a rule, so it is never read as a divider between rows. */
937
1064
  table.data thead th {
938
- border-bottom: 2px solid var(--text);
939
- background: var(--surface);
1065
+ border-bottom: 0;
1066
+ background: var(--head);
940
1067
  color: var(--text);
941
1068
  font-size: 11px;
942
1069
  font-weight: 700;
@@ -952,9 +1079,10 @@ table.data th[title] {
952
1079
  text-underline-offset: 3px;
953
1080
  }
954
1081
 
955
- table.data tbody tr:hover > td,
956
- table.data tbody tr:hover > th {
957
- background: var(--surface-raised);
1082
+ /* 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. */
1083
+ table.data tbody tr:hover:not(:has(tr:hover)) > td,
1084
+ table.data tbody tr:hover:not(:has(tr:hover)) > th {
1085
+ background: var(--hover);
958
1086
  }
959
1087
 
960
1088
  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;