@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.
- package/README.md +1 -1
- package/dist/chunks/{index-jtzfw679.js → index-n4zpd25n.js} +92 -92
- package/dist/chunks/{index-xm9nma2t.js → index-v8zgr7wm.js} +14 -14
- package/dist/chunks/{new-7n8qvtjd.js → new-ny89s7y7.js} +4 -6
- package/dist/chunks/{server-5shmsmse.js → server-ckdy25w2.js} +16 -11
- package/dist/cli/index.js +406 -411
- package/dist/index.js +20 -21
- package/dist/presets/index.js +20 -21
- package/package.json +7 -7
- package/src/core/files.ts +5 -3
- package/src/studio/public/app.js +37 -9
- package/src/studio/public/dom.js +20 -0
- package/src/studio/public/export.js +6 -0
- package/src/studio/public/favicon.svg +10 -4
- package/src/studio/public/graph.js +396 -26
- package/src/studio/public/index.html +26 -12
- package/src/studio/public/special-gothic-latin-ext.woff2 +0 -0
- package/src/studio/public/special-gothic-latin.woff2 +0 -0
- package/src/studio/public/special-gothic-license.txt +93 -0
- package/src/studio/public/style.css +1125 -356
- package/src/studio/server.ts +10 -1
- package/dist/chunks/index-fq57hya4.js +0 -20
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
395
|
-
const
|
|
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
|
-
|
|
531
|
-
|
|
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) +
|
|
541
|
-
const height = Math.max(...ys) - Math.min(...ys) +
|
|
542
|
-
const
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
-
<!--
|
|
10
|
-
<link rel="
|
|
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
|
-
<
|
|
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
|
-
<
|
|
35
|
-
|
|
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
|
|
48
|
-
|
|
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>
|
|
Binary file
|
|
Binary file
|