@takodotid/circuit 0.20.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.
@@ -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}`;
@@ -143,7 +151,7 @@ function layout(nodes, edges, fresh) {
143
151
  if (!moving.length) return;
144
152
 
145
153
  const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
146
- const ideal = 230;
154
+ const ideal = 170;
147
155
  const rounds = fixed.size ? 200 : 500;
148
156
 
149
157
  /** @param {string} id */
@@ -206,6 +214,40 @@ function layout(nodes, edges, fresh) {
206
214
  }
207
215
  }
208
216
 
217
+ // 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
+
222
+ for (let round = 0; round < 80; round++) {
223
+ let overlapping = false;
224
+
225
+ for (const [index, a] of nodes.entries()) {
226
+ for (const b of nodes.slice(index + 1)) {
227
+ const aMoves = !fixed.has(a.id);
228
+ const bMoves = !fixed.has(b.id);
229
+ if (!aMoves && !bMoves) continue;
230
+
231
+ const pa = at(a.id);
232
+ const pb = at(b.id);
233
+ const overlapX = halfWidth(a) + halfWidth(b) + 24 - Math.abs(pa.x - pb.x);
234
+ const overlapY = 96 - Math.abs(pa.y - pb.y);
235
+ if (overlapX <= 0 || overlapY <= 0) continue;
236
+
237
+ overlapping = true;
238
+ const share = aMoves && bMoves ? 0.5 : 1;
239
+ const alongX = overlapX < overlapY * 2;
240
+ const sign = alongX ? Math.sign(pa.x - pb.x) || 1 : Math.sign(pa.y - pb.y) || 1;
241
+ const distance = (alongX ? overlapX : overlapY) * share;
242
+
243
+ if (aMoves) alongX ? (pa.x += sign * distance) : (pa.y += sign * distance);
244
+ if (bMoves) alongX ? (pb.x -= sign * distance) : (pb.y -= sign * distance);
245
+ }
246
+ }
247
+
248
+ if (!overlapping) break;
249
+ }
250
+
209
251
  savePositions();
210
252
  }
211
253
 
@@ -291,6 +333,9 @@ const MOST_APART = 3;
291
333
  /** @type {Record<Layer, string>} */
292
334
  const MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
293
335
 
336
+ /** @type {Record<Layer, string>} */
337
+ const ONE = { l1: "cable", l2: "cable", l3: "shared prefix", bgp: "session", ospf: "adjacency", tunnels: "tunnel" };
338
+
294
339
  /**
295
340
  * The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
296
341
  *
@@ -329,7 +374,101 @@ function bundled(edges) {
329
374
  });
330
375
  }
331
376
 
332
- 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
+
333
472
  edgeLayer.replaceChildren();
334
473
  nodeLayer.replaceChildren();
335
474
  nodeElements.clear();
@@ -346,7 +485,7 @@ function draw() {
346
485
  /** @type {Map<string, number>} */
347
486
  const seen = new Map();
348
487
 
349
- for (const edge of edges) {
488
+ for (const [number, edge] of edges.entries()) {
350
489
  const pair = pairOf(edge);
351
490
  const index = seen.get(pair) ?? 0;
352
491
  seen.set(pair, index + 1);
@@ -359,11 +498,11 @@ function draw() {
359
498
  const change = edge.gone ? " gone" : added ? " added" : "";
360
499
  const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
361
500
  const hit = s("path", { class: "hit" });
362
- const path = s("path");
501
+ const path = s("path", { id: `cable-${number}` });
363
502
  const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
364
503
  label.textContent = edge.label;
365
504
 
366
- group.append(path, hit, label);
505
+ group.append(path, hit, ...signals(edge, `cable-${number}`), label);
367
506
 
368
507
  // Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
369
508
  group.addEventListener("pointerdown", (event) => {
@@ -380,9 +519,11 @@ function draw() {
380
519
  const rect = s("rect", { rx: node.kind === "segment" ? 14 : 8 });
381
520
  group.append(rect);
382
521
 
383
- const lines = node.kind === "device" ? [node.label, node.detail[0] ?? ""] : [node.label];
522
+ const device = node.kind === "device";
523
+ const lines = device ? [node.label, node.detail[0] ?? ""] : [node.label];
384
524
  const texts = lines.map((line, index) => {
385
- const text = s("text", { class: index === 0 ? "name" : "sub", y: lines.length === 1 ? 0 : index === 0 ? -8 : 9 });
525
+ const y = device ? (index === 0 ? -9 : 13) : 0;
526
+ const text = s("text", { class: index === 0 ? "name" : "sub", y });
386
527
  text.textContent = line;
387
528
  group.append(text);
388
529
  return text;
@@ -390,14 +531,30 @@ function draw() {
390
531
 
391
532
  nodeLayer.append(group);
392
533
 
393
- // Each box fits its text, so it is measured once drawn.
394
- const width = Math.max(...texts.map((text) => text.getComputedTextLength())) + 24;
395
- const height = lines.length === 1 ? 28 : 44;
534
+ // Each box fits its text, so it is measured once drawn. A device's text starts after its chassis mark.
535
+ const mark = device ? 46 : 0;
536
+ const width = Math.max(...texts.map((text) => text.getComputedTextLength()), device ? 110 : 0) + 32 + mark;
537
+ const height = device ? 62 : 34;
396
538
  rect.setAttribute("x", String(-width / 2));
397
539
  rect.setAttribute("y", String(-height / 2));
398
540
  rect.setAttribute("width", String(width));
399
541
  rect.setAttribute("height", String(height));
400
542
 
543
+ if (device) {
544
+ for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
545
+
546
+ // A small front panel: a chassis with two rows of ports, drawn rather than pictured.
547
+ const left = -width / 2 + 16;
548
+ const chassis = s("g", { class: "chassis", "aria-hidden": "true" });
549
+ chassis.append(s("rect", { x: left, y: -15, width: 34, height: 30, rx: 4 }));
550
+ for (const row of [0, 1]) {
551
+ for (const column of [0, 1, 2]) {
552
+ chassis.append(s("rect", { class: "socket", x: left + 6 + column * 8, y: -8 + row * 9, width: 6, height: 6, rx: 1 }));
553
+ }
554
+ }
555
+ group.insertBefore(chassis, texts[0] ?? null);
556
+ }
557
+
401
558
  group.addEventListener("pointerdown", (event) => startDrag(event, node.id));
402
559
  group.addEventListener("pointerenter", () => {
403
560
  hovered = node.id;
@@ -414,11 +571,13 @@ function draw() {
414
571
 
415
572
  place();
416
573
  emphasize();
574
+
575
+ if (arrival) arrive(new Set([...nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
417
576
  }
418
577
 
419
- /** 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. */
420
579
  function emphasize() {
421
- const focus = hovered ?? (selection && "node" in selection ? selection.node : undefined);
580
+ const focus = hovered ?? touring ?? (selection && "node" in selection ? selection.node : undefined);
422
581
  const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
423
582
 
424
583
  /** @param {GraphEdge} edge */
@@ -473,6 +632,8 @@ const toWorld = (event) => {
473
632
  */
474
633
  function startDrag(event, id) {
475
634
  if (event.button !== 0) return;
635
+ if (gliding !== undefined) cancelAnimationFrame(gliding);
636
+ gliding = undefined;
476
637
  event.stopPropagation();
477
638
 
478
639
  svg.setPointerCapture(event.pointerId);
@@ -516,6 +677,8 @@ svg.addEventListener(
516
677
  "wheel",
517
678
  (event) => {
518
679
  event.preventDefault();
680
+ if (gliding !== undefined) cancelAnimationFrame(gliding);
681
+ gliding = undefined;
519
682
 
520
683
  const point = toWorld(event);
521
684
  const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
@@ -527,26 +690,120 @@ svg.addEventListener(
527
690
  { passive: false }
528
691
  );
529
692
 
530
- function fit() {
531
- const points = visibleNodes().flatMap((node) => {
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. */
694
+ const READABLE = 0.6;
695
+
696
+ /** The view moving to a new place, which a press or a scroll stops. @type {number | undefined} */
697
+ let gliding;
698
+
699
+ /**
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.
739
+ *
740
+ * @param {boolean} [readable]
741
+ * @param {boolean} [glide]
742
+ */
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) => {
532
761
  const point = positions.get(node.id);
533
762
  return point ? [point] : [];
534
763
  });
535
- if (!points.length) return;
764
+ if (!points.length) return undefined;
536
765
 
537
766
  const box = svg.getBoundingClientRect();
538
767
  const xs = points.map((point) => point.x);
539
768
  const ys = points.map((point) => point.y);
540
- const width = Math.max(...xs) - Math.min(...xs) + 240;
541
- const height = Math.max(...ys) - Math.min(...ys) + 160;
542
- const k = Math.min(Math.max(Math.min(box.width / width, box.height / height), 0.15), 1.5);
769
+ const width = Math.max(...xs) - Math.min(...xs) + 190;
770
+ const height = Math.max(...ys) - Math.min(...ys) + 130;
771
+ const whole = Math.min(Math.max(Math.min(box.width / width, box.height / height), 0.15), 1.8);
772
+ const k = readable ? Math.max(whole, READABLE) : whole;
773
+
774
+ // When the whole drawing does not fit readably, the view starts on the device with the most lines, the heart of the network.
775
+ const edges = visibleEdges();
776
+ const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
777
+ const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
778
+ const centre =
779
+ k > whole && hub && positions.has(hub.id)
780
+ ? /** @type {Point} */ (positions.get(hub.id))
781
+ : { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
782
+
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
+ }
543
788
 
544
- const centre = { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
545
- view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
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;
546
803
  place();
547
804
  }
548
805
 
549
- byId("fit").addEventListener("click", fit);
806
+ byId("fit").addEventListener("click", () => fit());
550
807
 
551
808
  byId("lay-out").addEventListener("click", () => {
552
809
  if (!data) return;
@@ -555,7 +812,101 @@ byId("lay-out").addEventListener("click", () => {
555
812
  fit();
556
813
  });
557
814
 
558
- 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
+ });
559
910
 
560
911
  // Saving the layer shown, as it looks.
561
912
  byId("save-svg").addEventListener("click", () => {
@@ -571,10 +922,16 @@ byId("save-png").addEventListener("click", async () => {
571
922
  // The side panel.
572
923
  /** @param {Selection} next */
573
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
+
574
929
  selection = next;
575
930
  pathFocus = undefined;
576
931
  emphasize();
577
932
  showPanel();
933
+
934
+ if (changed) enter(panel);
578
935
  }
579
936
 
580
937
  /**
@@ -813,7 +1170,12 @@ function showLayerButtons() {
813
1170
  document.addEventListener("keydown", (event) => {
814
1171
  if (event.target instanceof HTMLInputElement || graphView.hidden) return;
815
1172
 
816
- 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);
817
1179
 
818
1180
  // Keys 1 to 6 pick a layer.
819
1181
  const chosen = LAYERS[Number(event.key) - 1];
@@ -851,6 +1213,8 @@ export function showLayer(next) {
851
1213
  if (selection && "edge" in selection) selection = undefined;
852
1214
  vlanFocus = undefined;
853
1215
  pathFocus = undefined;
1216
+ touring = undefined;
1217
+ tourStep = 0;
854
1218
  }
855
1219
 
856
1220
  if (pendingVlan !== undefined) {
@@ -863,14 +1227,20 @@ export function showLayer(next) {
863
1227
  pendingPath = undefined;
864
1228
  }
865
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
+
866
1233
  layer = next;
867
1234
  store.set("layer", layer);
868
1235
 
869
1236
  showLayerButtons();
870
- draw();
1237
+ draw(arrival);
871
1238
  showPanel();
1239
+ showCaption();
872
1240
 
873
- if (!drawn && data) fit();
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);
874
1244
  drawn = data !== undefined;
875
1245
  }
876
1246
 
@@ -5,20 +5,19 @@
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
- <!-- The documentation site's fonts. Offline, the page falls back to the system's. -->
10
- <link rel="preconnect" href="https://fonts.googleapis.com" />
11
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
- <link
13
- rel="stylesheet"
14
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Newsreader:ital,opsz,wght@0,6..72,400;1,6..72,400&display=swap"
15
- />
11
+ <!-- Special Gothic is shared with the documentation site, and served from beside this page so Studio works offline. -->
12
+ <link rel="preload" href="special-gothic-latin.woff2" as="font" type="font/woff2" crossorigin />
16
13
  <link rel="stylesheet" href="style.css" />
17
14
  <script type="module" src="app.js"></script>
18
15
  </head>
19
16
  <body>
20
17
  <header>
21
- <span class="brand" aria-label="Circuit Studio"><span class="wordmark">Circuit</span><span class="product">Studio</span></span>
18
+ <a class="brand" href="#graph" aria-label="Circuit Studio, back to the graph"
19
+ ><span class="wordmark">Circuit</span><span class="product">Studio</span></a
20
+ >
22
21
  <nav id="views" aria-label="Views">
23
22
  <button data-view="graph">Graph</button>
24
23
  <button data-view="devices">Devices</button>
@@ -31,8 +30,10 @@
31
30
  <button data-view="problems">Problems<span id="problem-count" class="count"></span></button>
32
31
  </nav>
33
32
  <input id="search" type="search" placeholder="Search the config" aria-label="Search the config" />
34
- <span id="summary" class="summary"></span>
35
- <span id="live" class="live">connecting</span>
33
+ <div class="status-rail" aria-label="Network status">
34
+ <span id="summary" class="summary"></span>
35
+ <span id="live" class="live">connecting</span>
36
+ </div>
36
37
  </header>
37
38
 
38
39
  <pre id="banner" class="banner" hidden></pre>
@@ -44,8 +45,20 @@
44
45
  <div class="actions">
45
46
  <button id="fit" title="Fit the drawing to the window">Fit</button>
46
47
  <button id="lay-out" title="Forget where nodes were moved, and lay them out again">Lay out again</button>
47
- <button id="save-svg" title="Save this layer as an SVG image">Save SVG</button>
48
- <button id="save-png" title="Save this layer as a PNG image">Save PNG</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>
55
+ <details class="menu">
56
+ <summary>Save image</summary>
57
+ <div class="menu-items">
58
+ <button id="save-svg" title="Save this layer as an SVG image">SVG</button>
59
+ <button id="save-png" title="Save this layer as a PNG image">PNG</button>
60
+ </div>
61
+ </details>
49
62
  </div>
50
63
  </div>
51
64
  <div class="stage">
@@ -55,6 +68,7 @@
55
68
  <g id="nodes"></g>
56
69
  </g>
57
70
  </svg>
71
+ <p id="present-caption" class="present-caption" aria-live="polite" hidden></p>
58
72
  <aside id="panel"></aside>
59
73
  </div>
60
74
  </section>