@takodotid/circuit 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{server-zapfzhv4.js → server-ckdy25w2.js} +7 -0
- package/dist/cli/index.js +1 -1
- package/package.json +1 -1
- package/src/studio/public/app.js +13 -3
- package/src/studio/public/dom.js +20 -0
- package/src/studio/public/export.js +6 -0
- package/src/studio/public/graph.js +529 -48
- package/src/studio/public/index.html +13 -1
- package/src/studio/public/style.css +193 -5
- package/src/studio/server.ts +8 -0
|
@@ -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-
|
|
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
package/src/studio/public/app.js
CHANGED
|
@@ -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) =>
|
|
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();
|
package/src/studio/public/dom.js
CHANGED
|
@@ -71,6 +71,26 @@ export function svgById(id) {
|
|
|
71
71
|
return element;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** The reader's system asks for less motion: nothing moves for show, and changes happen at once. */
|
|
75
|
+
export const still = matchMedia("(prefers-reduced-motion: reduce)");
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bring a part of the page in as it changes: it fades in and rises a little.
|
|
79
|
+
*
|
|
80
|
+
* @param {Element} element
|
|
81
|
+
*/
|
|
82
|
+
export function enter(element) {
|
|
83
|
+
if (still.matches) return;
|
|
84
|
+
|
|
85
|
+
element.animate(
|
|
86
|
+
[
|
|
87
|
+
{ opacity: 0, transform: "translateY(6px)" },
|
|
88
|
+
{ opacity: 1, transform: "none" },
|
|
89
|
+
],
|
|
90
|
+
{ duration: 240, easing: "cubic-bezier(0.16, 1, 0.3, 1)" }
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
/** Browser storage can be missing or refuse, in a private window for example. The page works without it. */
|
|
75
95
|
export const store = {
|
|
76
96
|
/**
|
|
@@ -64,15 +64,21 @@ export function svgOf(svg, viewport) {
|
|
|
64
64
|
const originals = [svg, ...svg.querySelectorAll("*")];
|
|
65
65
|
const copies = [copy, ...copy.querySelectorAll("*")];
|
|
66
66
|
|
|
67
|
+
// The dots travelling the cables are left out: a still image would show each one stopped at the drawing's corner.
|
|
68
|
+
const moving = [];
|
|
69
|
+
|
|
67
70
|
for (const [index, original] of originals.entries()) {
|
|
68
71
|
const target = copies[index];
|
|
69
72
|
if (!(target instanceof SVGElement)) continue;
|
|
73
|
+
if (original.classList.contains("signal")) moving.push(target);
|
|
70
74
|
|
|
71
75
|
const look = getComputedStyle(original);
|
|
72
76
|
target.setAttribute("style", DRAWN.map((name) => `${name}:${look.getPropertyValue(name)}`).join(";"));
|
|
73
77
|
target.removeAttribute("class");
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
for (const dot of moving) dot.remove();
|
|
81
|
+
|
|
76
82
|
// The drawing's own place on screen is left out: the file is framed around what is drawn.
|
|
77
83
|
copy.querySelector("#viewport")?.removeAttribute("transform");
|
|
78
84
|
copy.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/// <reference lib="dom" />
|
|
3
3
|
// The graph view: a force layout of every node, drawn one layer at a time, with panning, zooming, dragging, and a side panel for what is selected.
|
|
4
4
|
|
|
5
|
-
import { byId, go, h, link, s, store, svgById } from "./dom.js";
|
|
5
|
+
import { byId, enter, go, h, link, s, still, store, svgById } from "./dom.js";
|
|
6
6
|
import { changeKey } from "./evaluate.js";
|
|
7
7
|
import { pngOf, save, svgOf } from "./export.js";
|
|
8
8
|
|
|
@@ -51,6 +51,8 @@ const viewport = svgById("viewport");
|
|
|
51
51
|
const edgeLayer = svgById("edges");
|
|
52
52
|
const nodeLayer = svgById("nodes");
|
|
53
53
|
const panel = byId("panel");
|
|
54
|
+
const presentButton = byId("present");
|
|
55
|
+
const caption = byId("present-caption");
|
|
54
56
|
|
|
55
57
|
// State.
|
|
56
58
|
/** @type {Annotated | undefined} */
|
|
@@ -73,14 +75,38 @@ let pendingPath;
|
|
|
73
75
|
let pathFocus;
|
|
74
76
|
/** A comparison with another commit: its name, the edges it adds, and those gone. @type {{ name: string, added: Set<string>, removed: GraphEdge[] } | undefined} */
|
|
75
77
|
let comparison;
|
|
78
|
+
/** Presenting: the network on a big screen, going through every layer and lighting its busiest devices in turn. Kept, so a screen that reloads stays on it. */
|
|
79
|
+
let presenting = store.get("present") === true;
|
|
80
|
+
/** The device lit while presenting; none while the whole layer is shown. @type {string | undefined} */
|
|
81
|
+
let touring;
|
|
82
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
83
|
+
let tour;
|
|
76
84
|
|
|
77
85
|
/** @param {GraphEdge} edge */
|
|
78
86
|
const edgeKey = (edge) => `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
|
|
79
87
|
let view = { x: 0, y: 0, k: 1 };
|
|
88
|
+
/** Where the reader left each layer's view, as each layer is a drawing of its own. @type {Map<Layer, typeof view>} */
|
|
89
|
+
const views = new Map();
|
|
80
90
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
91
|
+
/** @typedef {Record<string, [number, number]>} SavedPlaces */
|
|
92
|
+
|
|
93
|
+
const saved = /** @type {Partial<Record<Layer, SavedPlaces>>} */ (store.get("layouts") ?? {});
|
|
94
|
+
// The one layout an earlier Studio kept for every layer carries on as the cables layer's.
|
|
95
|
+
const earlier = /** @type {SavedPlaces | undefined} */ (store.get("positions"));
|
|
96
|
+
|
|
97
|
+
/** Where each node is on each layer: every layer is laid out for its own lines, and keeps where its devices were moved. @type {Map<Layer, Map<string, Point>>} */
|
|
98
|
+
const layouts = new Map(
|
|
99
|
+
LAYERS.map((name) => {
|
|
100
|
+
const places = saved[name] ?? (name === "l1" ? earlier : undefined) ?? {};
|
|
101
|
+
return [name, new Map(Object.entries(places).map(([id, [x, y]]) => [id, { x, y }]))];
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/** @param {Layer} name */
|
|
106
|
+
const layoutOf = (name) => /** @type {Map<string, Point>} */ (layouts.get(name));
|
|
107
|
+
|
|
108
|
+
/** Where nodes are drawn now: the layer's own places, or, while the layer changes, the places on the way there. */
|
|
109
|
+
let positions = layoutOf(layer);
|
|
84
110
|
|
|
85
111
|
/** @type {Map<string, SVGGElement>} */
|
|
86
112
|
const nodeElements = new Map();
|
|
@@ -88,7 +114,9 @@ const nodeElements = new Map();
|
|
|
88
114
|
const edgeElements = [];
|
|
89
115
|
|
|
90
116
|
function savePositions() {
|
|
91
|
-
|
|
117
|
+
/** @param {Map<string, Point>} places */
|
|
118
|
+
const rounded = (places) => Object.fromEntries([...places].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]]));
|
|
119
|
+
store.set("layouts", Object.fromEntries([...layouts].map(([name, places]) => [name, rounded(places)])));
|
|
92
120
|
}
|
|
93
121
|
|
|
94
122
|
/**
|
|
@@ -103,17 +131,23 @@ function hash(text) {
|
|
|
103
131
|
}
|
|
104
132
|
|
|
105
133
|
/**
|
|
106
|
-
* Place nodes with a force layout: every node pushes every other away, and each
|
|
107
|
-
*
|
|
134
|
+
* Place one layer's nodes with a force layout: every node pushes every other away, and each of the layer's edges pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
|
|
135
|
+
* A node new to a layer starts where it is on the cables layer, so each layer is laid out for its own lines yet still looks like the same network.
|
|
108
136
|
*
|
|
109
137
|
* @param {GraphNode[]} nodes
|
|
110
138
|
* @param {GraphEdge[]} edges
|
|
111
139
|
* @param {boolean} fresh
|
|
140
|
+
* @param {Map<string, Point>} places the layer's places, filled in
|
|
141
|
+
* @param {Map<string, Point>} [seed] places to start new nodes from
|
|
112
142
|
*/
|
|
113
|
-
function layout(nodes, edges, fresh) {
|
|
114
|
-
if (fresh)
|
|
143
|
+
function layout(nodes, edges, fresh, places, seed) {
|
|
144
|
+
if (fresh) places.clear();
|
|
115
145
|
|
|
116
|
-
|
|
146
|
+
/** @param {GraphNode} node */
|
|
147
|
+
const halfWidth = (node) =>
|
|
148
|
+
node.kind === "device" ? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60 : node.label.length * 4 + 30;
|
|
149
|
+
|
|
150
|
+
const fixed = new Set(nodes.filter((node) => places.has(node.id)).map((node) => node.id));
|
|
117
151
|
/** @type {Map<string, Set<string>>} */
|
|
118
152
|
const neighbors = new Map();
|
|
119
153
|
|
|
@@ -125,29 +159,64 @@ function layout(nodes, edges, fresh) {
|
|
|
125
159
|
neighbors.get(edge.to)?.add(edge.from);
|
|
126
160
|
}
|
|
127
161
|
|
|
162
|
+
// A device with no line on this layer is set in a row under the drawing, rather than pushed out to its edges by the rest.
|
|
163
|
+
const loose = edges.length ? nodes.filter((node) => !neighbors.has(node.id) && !places.has(node.id)) : [];
|
|
164
|
+
const linked = nodes.filter((node) => !loose.includes(node));
|
|
165
|
+
|
|
166
|
+
function park() {
|
|
167
|
+
if (!loose.length) return;
|
|
168
|
+
|
|
169
|
+
const placed = linked.flatMap((node) => places.get(node.id) ?? []);
|
|
170
|
+
const left = placed.length ? Math.min(...placed.map((point) => point.x)) : 0;
|
|
171
|
+
const right = left + Math.max(placed.length ? Math.max(...placed.map((point) => point.x)) - left : 0, 900);
|
|
172
|
+
let x = left;
|
|
173
|
+
let y = (placed.length ? Math.max(...placed.map((point) => point.y)) : 0) + 130;
|
|
174
|
+
|
|
175
|
+
for (const node of [...loose].sort((a, b) => a.label.localeCompare(b.label))) {
|
|
176
|
+
const half = halfWidth(node);
|
|
177
|
+
if (x > left && x + half * 2 > right) {
|
|
178
|
+
x = left;
|
|
179
|
+
y += 96;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
places.set(node.id, { x: x + half, y });
|
|
183
|
+
x += half * 2 + 24;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
savePositions();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
nodes = linked;
|
|
190
|
+
|
|
128
191
|
// A new node starts beside a neighbor already placed, or on a circle.
|
|
129
192
|
const radius = 60 * Math.sqrt(nodes.length + 1);
|
|
130
193
|
|
|
131
194
|
for (const node of nodes) {
|
|
132
|
-
if (
|
|
195
|
+
if (places.has(node.id)) continue;
|
|
196
|
+
|
|
197
|
+
const start = seed?.get(node.id);
|
|
198
|
+
if (start) {
|
|
199
|
+
places.set(node.id, { ...start });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
133
202
|
|
|
134
203
|
const angle = hash(node.id) * Math.PI * 2;
|
|
135
|
-
const placed = [...(neighbors.get(node.id) ?? [])].map((id) =>
|
|
204
|
+
const placed = [...(neighbors.get(node.id) ?? [])].map((id) => places.get(id)).find(Boolean);
|
|
136
205
|
const distance = placed ? 90 : radius;
|
|
137
206
|
const origin = placed ?? { x: 0, y: 0 };
|
|
138
207
|
|
|
139
|
-
|
|
208
|
+
places.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
|
|
140
209
|
}
|
|
141
210
|
|
|
142
211
|
const moving = nodes.filter((node) => !fixed.has(node.id));
|
|
143
|
-
if (!moving.length) return;
|
|
212
|
+
if (!moving.length) return park();
|
|
144
213
|
|
|
145
214
|
const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
|
|
146
215
|
const ideal = 170;
|
|
147
216
|
const rounds = fixed.size ? 200 : 500;
|
|
148
217
|
|
|
149
218
|
/** @param {string} id */
|
|
150
|
-
const at = (id) => /** @type {Point} */ (
|
|
219
|
+
const at = (id) => /** @type {Point} */ (places.get(id));
|
|
151
220
|
|
|
152
221
|
for (let round = 0; round < rounds; round++) {
|
|
153
222
|
const heat = 40 * (1 - round / rounds) + 1;
|
|
@@ -179,7 +248,7 @@ function layout(nodes, edges, fresh) {
|
|
|
179
248
|
}
|
|
180
249
|
|
|
181
250
|
for (const [from, to] of pairs) {
|
|
182
|
-
if (!from || !to || !
|
|
251
|
+
if (!from || !to || !places.has(from) || !places.has(to)) continue;
|
|
183
252
|
|
|
184
253
|
const dx = at(from).x - at(to).x;
|
|
185
254
|
const dy = at(from).y - at(to).y;
|
|
@@ -207,9 +276,6 @@ function layout(nodes, edges, fresh) {
|
|
|
207
276
|
}
|
|
208
277
|
|
|
209
278
|
// No box may cover another: two that overlap are pushed apart the shorter way out, with room for the label of the line between them.
|
|
210
|
-
/** @param {GraphNode} node */
|
|
211
|
-
const halfWidth = (node) =>
|
|
212
|
-
node.kind === "device" ? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60 : node.label.length * 4 + 30;
|
|
213
279
|
|
|
214
280
|
for (let round = 0; round < 80; round++) {
|
|
215
281
|
let overlapping = false;
|
|
@@ -240,27 +306,99 @@ function layout(nodes, edges, fresh) {
|
|
|
240
306
|
if (!overlapping) break;
|
|
241
307
|
}
|
|
242
308
|
|
|
309
|
+
park();
|
|
243
310
|
savePositions();
|
|
244
311
|
}
|
|
245
312
|
|
|
246
313
|
// What is drawn.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A layer's edges, with those a comparison says are gone.
|
|
317
|
+
*
|
|
318
|
+
* @param {Layer} name
|
|
319
|
+
* @returns {DrawnEdge[]}
|
|
320
|
+
*/
|
|
321
|
+
const edgesOn = (name) => {
|
|
322
|
+
const current = data?.edges.filter((edge) => edge.layer === name) ?? [];
|
|
250
323
|
if (!comparison) return current;
|
|
251
324
|
|
|
252
325
|
// An edge the comparison says is gone is drawn when both its ends are still drawn.
|
|
253
326
|
const nodes = new Set(data?.nodes.map((node) => node.id));
|
|
254
|
-
const gone = comparison.removed.filter((edge) => edge.layer ===
|
|
327
|
+
const gone = comparison.removed.filter((edge) => edge.layer === name && nodes.has(edge.from) && nodes.has(edge.to));
|
|
255
328
|
return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
|
|
256
329
|
};
|
|
257
330
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
260
|
-
|
|
331
|
+
/**
|
|
332
|
+
* A layer's nodes: every device, and whatever else its edges end at.
|
|
333
|
+
*
|
|
334
|
+
* @param {Layer} name
|
|
335
|
+
* @returns {GraphNode[]}
|
|
336
|
+
*/
|
|
337
|
+
function nodesOn(name) {
|
|
338
|
+
const ends = new Set(edgesOn(name).flatMap((edge) => [edge.from, edge.to]));
|
|
261
339
|
return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
|
|
262
340
|
}
|
|
263
341
|
|
|
342
|
+
const visibleEdges = () => edgesOn(layer);
|
|
343
|
+
const visibleNodes = () => nodesOn(layer);
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Lay out a layer for its own lines. The cables layer comes first, as every other layer's new nodes start from it.
|
|
347
|
+
*
|
|
348
|
+
* @param {Layer} name
|
|
349
|
+
* @param {boolean} fresh forget where the layer's nodes were, and lay it out again
|
|
350
|
+
*/
|
|
351
|
+
function layOut(name, fresh) {
|
|
352
|
+
if (!data) return;
|
|
353
|
+
if (name !== "l1") layOut("l1", false);
|
|
354
|
+
|
|
355
|
+
layout(nodesOn(name), edgesOn(name), fresh, layoutOf(name), name === "l1" ? undefined : layoutOf("l1"));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The node glide between two layers' places, which a press stops. @type {number | undefined} */
|
|
359
|
+
let regrouping;
|
|
360
|
+
|
|
361
|
+
/** Put the nodes at their own layer's places now, ending a glide between layers. */
|
|
362
|
+
function settle() {
|
|
363
|
+
if (regrouping !== undefined) cancelAnimationFrame(regrouping);
|
|
364
|
+
regrouping = undefined;
|
|
365
|
+
positions = layoutOf(layer);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Move the nodes from where they are drawn to their places on the layer shown, so the eye can follow each device to where it is on this layer.
|
|
370
|
+
*
|
|
371
|
+
* @param {Map<string, Point>} from
|
|
372
|
+
*/
|
|
373
|
+
function glideNodes(from) {
|
|
374
|
+
settle();
|
|
375
|
+
|
|
376
|
+
const to = layoutOf(layer);
|
|
377
|
+
if (still.matches || from === to) return;
|
|
378
|
+
|
|
379
|
+
/** @type {Map<string, Point>} */
|
|
380
|
+
const shown = new Map();
|
|
381
|
+
const began = performance.now();
|
|
382
|
+
|
|
383
|
+
/** @param {number} now */
|
|
384
|
+
const step = (now) => {
|
|
385
|
+
const t = Math.min((now - began) / 700, 1);
|
|
386
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
387
|
+
|
|
388
|
+
for (const [id, end] of to) {
|
|
389
|
+
const start = from.get(id) ?? end;
|
|
390
|
+
shown.set(id, { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
positions = t < 1 ? shown : to;
|
|
394
|
+
place();
|
|
395
|
+
regrouping = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
positions = shown;
|
|
399
|
+
step(began);
|
|
400
|
+
}
|
|
401
|
+
|
|
264
402
|
/** @param {string} id */
|
|
265
403
|
const nodeById = (id) => data?.nodes.find((node) => node.id === id);
|
|
266
404
|
|
|
@@ -325,6 +463,9 @@ const MOST_APART = 3;
|
|
|
325
463
|
/** @type {Record<Layer, string>} */
|
|
326
464
|
const MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
|
|
327
465
|
|
|
466
|
+
/** @type {Record<Layer, string>} */
|
|
467
|
+
const ONE = { l1: "cable", l2: "cable", l3: "shared prefix", bgp: "session", ospf: "adjacency", tunnels: "tunnel" };
|
|
468
|
+
|
|
328
469
|
/**
|
|
329
470
|
* The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
|
|
330
471
|
*
|
|
@@ -363,7 +504,104 @@ function bundled(edges) {
|
|
|
363
504
|
});
|
|
364
505
|
}
|
|
365
506
|
|
|
366
|
-
|
|
507
|
+
/**
|
|
508
|
+
* Dots that travel a cable both ends declare, so a link that works reads as one that carries traffic: one dot, or one each way while presenting. A cable only one end declares, or one a comparison shows is gone, carries none.
|
|
509
|
+
*
|
|
510
|
+
* @param {DrawnEdge} edge
|
|
511
|
+
* @param {string} id the id of the cable's drawn path, which the dots follow as it moves
|
|
512
|
+
*/
|
|
513
|
+
function signals(edge, id) {
|
|
514
|
+
if (still.matches || edge.oneSided || edge.gone) return [];
|
|
515
|
+
|
|
516
|
+
const a = positions.get(edge.from) ?? { x: 0, y: 0 };
|
|
517
|
+
const b = positions.get(edge.to) ?? { x: 0, y: 0 };
|
|
518
|
+
const seconds = Math.min(Math.max(Math.hypot(b.x - a.x, b.y - a.y) / (presenting ? 110 : 45), 1.6), 12);
|
|
519
|
+
const phase = hash(`${edge.from}${edge.to}${edge.label}`) * seconds;
|
|
520
|
+
const backwards = presenting ? [false, true] : [hash(edge.label) < 0.5];
|
|
521
|
+
|
|
522
|
+
return backwards.map((back, index) => {
|
|
523
|
+
const dot = s("circle", { class: "signal", r: 3.5 });
|
|
524
|
+
const motion = s("animateMotion", {
|
|
525
|
+
dur: `${seconds.toFixed(2)}s`,
|
|
526
|
+
begin: `-${(phase + (index * seconds) / 2).toFixed(2)}s`,
|
|
527
|
+
repeatCount: "indefinite",
|
|
528
|
+
...(back ? { keyPoints: "1;0", keyTimes: "0;1", calcMode: "linear" } : {}),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
motion.append(s("mpath", { href: `#${id}` }));
|
|
532
|
+
dot.append(motion);
|
|
533
|
+
return dot;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** A confident arrival: fast at first, settling at the end. */
|
|
538
|
+
const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Draw the network in from its busiest device outward, a ring of neighbours at a time, each cable drawn from its nearer end.
|
|
542
|
+
*
|
|
543
|
+
* @param {Set<string>} fresh the nodes this drawing adds, which fade in; the others are already on screen
|
|
544
|
+
*/
|
|
545
|
+
function arrive(fresh) {
|
|
546
|
+
if (still.matches || !edgeElements.length) return;
|
|
547
|
+
|
|
548
|
+
/** @param {string} id */
|
|
549
|
+
const degree = (id) => edgeElements.filter(({ edge }) => edge.from === id || edge.to === id).length;
|
|
550
|
+
const hub = [...nodeElements.keys()].sort((a, b) => degree(b) - degree(a))[0];
|
|
551
|
+
|
|
552
|
+
/** @type {Map<string, number>} */
|
|
553
|
+
const ring = new Map(hub ? [[hub, 0]] : []);
|
|
554
|
+
for (let queue = hub ? [hub] : []; queue.length;) {
|
|
555
|
+
const id = /** @type {string} */ (queue.shift());
|
|
556
|
+
for (const { edge } of edgeElements) {
|
|
557
|
+
const other = edge.from === id ? edge.to : edge.to === id ? edge.from : undefined;
|
|
558
|
+
if (other === undefined || ring.has(other)) continue;
|
|
559
|
+
ring.set(other, (ring.get(id) ?? 0) + 1);
|
|
560
|
+
queue.push(other);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// The whole drawing arrives within about a second, however many rings it has.
|
|
565
|
+
const farthest = Math.max(0, ...ring.values()) + 1;
|
|
566
|
+
const step = Math.min(110, 800 / farthest);
|
|
567
|
+
/** @param {string} id */
|
|
568
|
+
const ringOf = (id) => ring.get(id) ?? farthest;
|
|
569
|
+
|
|
570
|
+
for (const [id, group] of nodeElements) {
|
|
571
|
+
if (!fresh.has(id)) continue;
|
|
572
|
+
group.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 360, delay: ringOf(id) * step, easing: EASE, fill: "backwards" });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const { edge, group, path, label } of edgeElements) {
|
|
576
|
+
const forward = ringOf(edge.from) <= ringOf(edge.to);
|
|
577
|
+
const delay = (Math.min(ringOf(edge.from), ringOf(edge.to)) + 0.5) * step;
|
|
578
|
+
|
|
579
|
+
if (edge.oneSided || edge.gone) {
|
|
580
|
+
path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: EASE, fill: "backwards" });
|
|
581
|
+
} else {
|
|
582
|
+
// Measured as one whole length, so the line draws in right even while its ends glide to a new layer's places.
|
|
583
|
+
path.setAttribute("pathLength", "1");
|
|
584
|
+
const start = { strokeDasharray: "1 1", strokeDashoffset: forward ? 1 : -1 };
|
|
585
|
+
const end = { strokeDasharray: "1 1", strokeDashoffset: 0 };
|
|
586
|
+
const drawing = path.animate([start, end], { duration: 560, delay, easing: EASE, fill: "backwards" });
|
|
587
|
+
drawing.addEventListener("finish", () => path.removeAttribute("pathLength"));
|
|
588
|
+
drawing.addEventListener("cancel", () => path.removeAttribute("pathLength"));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
for (const part of [label, ...group.querySelectorAll(".signal")]) {
|
|
592
|
+
part.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: delay + 420, fill: "backwards" });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Draw the layer. `arrival` says what is new to the eye: the whole network the first time it is shown, its cables when the layer changes, and nothing when the config is read again.
|
|
599
|
+
*
|
|
600
|
+
* @param {"network" | "cables"} [arrival]
|
|
601
|
+
*/
|
|
602
|
+
function draw(arrival) {
|
|
603
|
+
const before = new Set(nodeElements.keys());
|
|
604
|
+
|
|
367
605
|
edgeLayer.replaceChildren();
|
|
368
606
|
nodeLayer.replaceChildren();
|
|
369
607
|
nodeElements.clear();
|
|
@@ -380,7 +618,7 @@ function draw() {
|
|
|
380
618
|
/** @type {Map<string, number>} */
|
|
381
619
|
const seen = new Map();
|
|
382
620
|
|
|
383
|
-
for (const edge of edges) {
|
|
621
|
+
for (const [number, edge] of edges.entries()) {
|
|
384
622
|
const pair = pairOf(edge);
|
|
385
623
|
const index = seen.get(pair) ?? 0;
|
|
386
624
|
seen.set(pair, index + 1);
|
|
@@ -393,11 +631,11 @@ function draw() {
|
|
|
393
631
|
const change = edge.gone ? " gone" : added ? " added" : "";
|
|
394
632
|
const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
|
|
395
633
|
const hit = s("path", { class: "hit" });
|
|
396
|
-
const path = s("path");
|
|
634
|
+
const path = s("path", { id: `cable-${number}` });
|
|
397
635
|
const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
|
|
398
636
|
label.textContent = edge.label;
|
|
399
637
|
|
|
400
|
-
group.append(path, hit, label);
|
|
638
|
+
group.append(path, hit, ...signals(edge, `cable-${number}`), label);
|
|
401
639
|
|
|
402
640
|
// Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
|
|
403
641
|
group.addEventListener("pointerdown", (event) => {
|
|
@@ -435,6 +673,11 @@ function draw() {
|
|
|
435
673
|
rect.setAttribute("width", String(width));
|
|
436
674
|
rect.setAttribute("height", String(height));
|
|
437
675
|
|
|
676
|
+
// The mark of the device presenting lights now: a ring around its frame, shown only then.
|
|
677
|
+
const beacon = s("g", { class: "beacon", "aria-hidden": "true" });
|
|
678
|
+
beacon.append(s("rect", { x: -width / 2 - 10, y: -height / 2 - 10, width: width + 20, height: height + 20, rx: 16 }));
|
|
679
|
+
group.insertBefore(beacon, rect);
|
|
680
|
+
|
|
438
681
|
if (device) {
|
|
439
682
|
for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
|
|
440
683
|
|
|
@@ -466,11 +709,13 @@ function draw() {
|
|
|
466
709
|
|
|
467
710
|
place();
|
|
468
711
|
emphasize();
|
|
712
|
+
|
|
713
|
+
if (arrival) arrive(new Set([...nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
|
|
469
714
|
}
|
|
470
715
|
|
|
471
|
-
/** Light up what the pointer is on, or what is selected, and dim the rest. */
|
|
716
|
+
/** Light up what the pointer is on, the device lit while presenting, or what is selected, and dim the rest. */
|
|
472
717
|
function emphasize() {
|
|
473
|
-
const focus = hovered ?? (selection && "node" in selection ? selection.node : undefined);
|
|
718
|
+
const focus = hovered ?? touring ?? (selection && "node" in selection ? selection.node : undefined);
|
|
474
719
|
const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
|
|
475
720
|
|
|
476
721
|
/** @param {GraphEdge} edge */
|
|
@@ -502,6 +747,7 @@ function emphasize() {
|
|
|
502
747
|
|
|
503
748
|
for (const [id, group] of nodeElements) {
|
|
504
749
|
group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
|
|
750
|
+
group.classList.toggle("touring", presenting && id === touring && !hovered);
|
|
505
751
|
group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
|
|
506
752
|
}
|
|
507
753
|
}
|
|
@@ -525,6 +771,14 @@ const toWorld = (event) => {
|
|
|
525
771
|
*/
|
|
526
772
|
function startDrag(event, id) {
|
|
527
773
|
if (event.button !== 0) return;
|
|
774
|
+
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
775
|
+
gliding = undefined;
|
|
776
|
+
|
|
777
|
+
// A press ends a glide between layers, so a device is moved from its own place on this layer.
|
|
778
|
+
if (regrouping !== undefined) {
|
|
779
|
+
settle();
|
|
780
|
+
place();
|
|
781
|
+
}
|
|
528
782
|
event.stopPropagation();
|
|
529
783
|
|
|
530
784
|
svg.setPointerCapture(event.pointerId);
|
|
@@ -568,6 +822,8 @@ svg.addEventListener(
|
|
|
568
822
|
"wheel",
|
|
569
823
|
(event) => {
|
|
570
824
|
event.preventDefault();
|
|
825
|
+
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
826
|
+
gliding = undefined;
|
|
571
827
|
|
|
572
828
|
const point = toWorld(event);
|
|
573
829
|
const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
|
|
@@ -582,17 +838,77 @@ svg.addEventListener(
|
|
|
582
838
|
/** The scale below which a device's name is too small to read: a first view stops there, and the reader pans for the rest. */
|
|
583
839
|
const READABLE = 0.6;
|
|
584
840
|
|
|
841
|
+
/** The view moving to a new place, which a press or a scroll stops. @type {number | undefined} */
|
|
842
|
+
let gliding;
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Move the view to a new place over a second, zooming evenly and keeping the eye on the point between them. At once when the reader asked for less motion.
|
|
846
|
+
*
|
|
847
|
+
* @param {typeof view} target
|
|
848
|
+
*/
|
|
849
|
+
function glideTo(target) {
|
|
850
|
+
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
851
|
+
gliding = undefined;
|
|
852
|
+
|
|
853
|
+
if (still.matches) {
|
|
854
|
+
view = target;
|
|
855
|
+
place();
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const box = svg.getBoundingClientRect();
|
|
860
|
+
const from = { ...view };
|
|
861
|
+
/** @param {typeof view} one */
|
|
862
|
+
const centreOf = (one) => ({ x: (box.width / 2 - one.x) / one.k, y: (box.height / 2 - one.y) / one.k });
|
|
863
|
+
const start = centreOf(from);
|
|
864
|
+
const end = centreOf(target);
|
|
865
|
+
const began = performance.now();
|
|
866
|
+
|
|
867
|
+
/** @param {number} now */
|
|
868
|
+
const step = (now) => {
|
|
869
|
+
const t = Math.min((now - began) / 1100, 1);
|
|
870
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
871
|
+
const k = from.k * (target.k / from.k) ** eased;
|
|
872
|
+
const centre = { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased };
|
|
873
|
+
|
|
874
|
+
view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
|
|
875
|
+
place();
|
|
876
|
+
gliding = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
gliding = requestAnimationFrame(step);
|
|
880
|
+
}
|
|
881
|
+
|
|
585
882
|
/**
|
|
586
|
-
* Show the whole drawing, or, when that would make it unreadable and `readable` is asked, as much of it as stays readable.
|
|
883
|
+
* Show the whole drawing, or, when that would make it unreadable and `readable` is asked, as much of it as stays readable. `glide` moves there instead of jumping.
|
|
587
884
|
*
|
|
588
885
|
* @param {boolean} [readable]
|
|
886
|
+
* @param {boolean} [glide]
|
|
887
|
+
*/
|
|
888
|
+
function fit(readable = false, glide = false) {
|
|
889
|
+
const target = framing(visibleNodes(), readable);
|
|
890
|
+
if (!target) return;
|
|
891
|
+
|
|
892
|
+
if (glide) return glideTo(target);
|
|
893
|
+
view = target;
|
|
894
|
+
place();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* The view that frames these nodes.
|
|
899
|
+
*
|
|
900
|
+
* @param {GraphNode[]} nodes
|
|
901
|
+
* @param {boolean} readable
|
|
902
|
+
* @returns {typeof view | undefined}
|
|
589
903
|
*/
|
|
590
|
-
function
|
|
591
|
-
|
|
592
|
-
|
|
904
|
+
function framing(nodes, readable) {
|
|
905
|
+
// Framed where the nodes are going, not where a glide between layers has them now.
|
|
906
|
+
const places = layoutOf(layer);
|
|
907
|
+
const points = nodes.flatMap((node) => {
|
|
908
|
+
const point = places.get(node.id);
|
|
593
909
|
return point ? [point] : [];
|
|
594
910
|
});
|
|
595
|
-
if (!points.length) return;
|
|
911
|
+
if (!points.length) return undefined;
|
|
596
912
|
|
|
597
913
|
const box = svg.getBoundingClientRect();
|
|
598
914
|
const xs = points.map((point) => point.x);
|
|
@@ -605,13 +921,32 @@ function fit(readable = false) {
|
|
|
605
921
|
// When the whole drawing does not fit readably, the view starts on the device with the most lines, the heart of the network.
|
|
606
922
|
const edges = visibleEdges();
|
|
607
923
|
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
608
|
-
const hub = [...
|
|
924
|
+
const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
|
|
609
925
|
const centre =
|
|
610
|
-
k > whole && hub &&
|
|
611
|
-
? /** @type {Point} */ (
|
|
926
|
+
k > whole && hub && places.has(hub.id)
|
|
927
|
+
? /** @type {Point} */ (places.get(hub.id))
|
|
612
928
|
: { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
613
929
|
|
|
614
|
-
|
|
930
|
+
// Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
|
|
931
|
+
const shown = presenting ? k * 0.86 : k;
|
|
932
|
+
|
|
933
|
+
return { k: shown, x: box.width / 2 - centre.x * shown, y: box.height / 2 - centre.y * shown };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** While presenting, frame what is shown: the lit device with everything it has a line to, or the whole layer. */
|
|
937
|
+
function frameTour(glide = true) {
|
|
938
|
+
const lit = touring;
|
|
939
|
+
if (!lit) return fit(false, glide);
|
|
940
|
+
|
|
941
|
+
const near = new Set([lit, ...visibleEdges().flatMap((edge) => (edge.from === lit ? [edge.to] : edge.to === lit ? [edge.from] : []))]);
|
|
942
|
+
const target = framing(
|
|
943
|
+
visibleNodes().filter((node) => near.has(node.id)),
|
|
944
|
+
false
|
|
945
|
+
);
|
|
946
|
+
if (!target) return;
|
|
947
|
+
|
|
948
|
+
if (glide) return glideTo(target);
|
|
949
|
+
view = target;
|
|
615
950
|
place();
|
|
616
951
|
}
|
|
617
952
|
|
|
@@ -619,12 +954,126 @@ byId("fit").addEventListener("click", () => fit());
|
|
|
619
954
|
|
|
620
955
|
byId("lay-out").addEventListener("click", () => {
|
|
621
956
|
if (!data) return;
|
|
622
|
-
|
|
957
|
+
layOut(layer, true);
|
|
958
|
+
settle();
|
|
623
959
|
draw();
|
|
624
960
|
fit();
|
|
625
961
|
});
|
|
626
962
|
|
|
627
|
-
|
|
963
|
+
// Presenting keeps the whole network in view as the screen changes size.
|
|
964
|
+
window.addEventListener("resize", () => (presenting ? frameTour(false) : place()));
|
|
965
|
+
|
|
966
|
+
// Presenting.
|
|
967
|
+
|
|
968
|
+
/** Say what is shown while presenting: the device lit, or the whole layer. */
|
|
969
|
+
function showCaption() {
|
|
970
|
+
caption.hidden = !presenting;
|
|
971
|
+
if (!presenting) return;
|
|
972
|
+
|
|
973
|
+
const node = touring ? nodeById(touring) : undefined;
|
|
974
|
+
const edges = visibleEdges().filter((edge) => !node || edge.from === node.id || edge.to === node.id);
|
|
975
|
+
const count = `${edges.length} ${edges.length === 1 ? ONE[layer] : MANY[layer]}`;
|
|
976
|
+
|
|
977
|
+
if (node) caption.replaceChildren(h("strong", {}, node.label), h("span", {}, [node.detail[0], count].filter(Boolean).join(", ")));
|
|
978
|
+
else
|
|
979
|
+
caption.replaceChildren(
|
|
980
|
+
h("strong", {}, LAYER_NAMES[layer]),
|
|
981
|
+
h("span", {}, `${visibleNodes().filter((one) => one.kind === "device").length} devices, ${count}`)
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
enter(caption);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** How many devices are lit on each layer while presenting, busiest first, before it moves on to the next layer. */
|
|
988
|
+
const LIT_PER_LAYER = 4;
|
|
989
|
+
|
|
990
|
+
/** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
|
|
991
|
+
let tourStep = 0;
|
|
992
|
+
|
|
993
|
+
/** The move to the next layer, waiting while the view zooms out to the whole of this one. @type {ReturnType<typeof setTimeout> | undefined} */
|
|
994
|
+
let leaving;
|
|
995
|
+
|
|
996
|
+
/** The next layer that has lines to show, after this one; this one when no other has any. */
|
|
997
|
+
function nextLayer() {
|
|
998
|
+
const after = [...LAYERS.slice(LAYERS.indexOf(layer) + 1), ...LAYERS.slice(0, LAYERS.indexOf(layer) + 1)];
|
|
999
|
+
return after.find((name) => data?.edges.some((edge) => edge.layer === name)) ?? layer;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
|
|
1003
|
+
function tourNext() {
|
|
1004
|
+
if (graphView.hidden || document.hidden || hovered || drag || leaving) return;
|
|
1005
|
+
|
|
1006
|
+
const edges = visibleEdges();
|
|
1007
|
+
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
1008
|
+
const devices = visibleNodes()
|
|
1009
|
+
.filter((node) => node.kind === "device" && degree(node.id))
|
|
1010
|
+
.sort((a, b) => degree(b.id) - degree(a.id) || a.label.localeCompare(b.label))
|
|
1011
|
+
.slice(0, LIT_PER_LAYER);
|
|
1012
|
+
|
|
1013
|
+
tourStep += 1;
|
|
1014
|
+
touring = devices[tourStep - 1]?.id;
|
|
1015
|
+
|
|
1016
|
+
if (touring || nextLayer() === layer) {
|
|
1017
|
+
if (!touring) tourStep = 0;
|
|
1018
|
+
emphasize();
|
|
1019
|
+
showCaption();
|
|
1020
|
+
frameTour();
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Every lit device has had its turn: the view zooms out to the whole layer first, so the next layer is reached from the whole drawing.
|
|
1025
|
+
tourStep = 0;
|
|
1026
|
+
emphasize();
|
|
1027
|
+
showCaption();
|
|
1028
|
+
frameTour();
|
|
1029
|
+
|
|
1030
|
+
const next = nextLayer();
|
|
1031
|
+
leaving = setTimeout(() => {
|
|
1032
|
+
leaving = undefined;
|
|
1033
|
+
|
|
1034
|
+
// Moving on replaces the address rather than adding to the history, so Back still leaves Studio's last place.
|
|
1035
|
+
if (presenting && !graphView.hidden) location.replace(`#graph/${next}`);
|
|
1036
|
+
}, 2600);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Start the turns over, so the layer shown now has its full time. */
|
|
1040
|
+
function restartTour() {
|
|
1041
|
+
clearInterval(tour);
|
|
1042
|
+
clearTimeout(leaving);
|
|
1043
|
+
leaving = undefined;
|
|
1044
|
+
if (presenting) tour = setInterval(tourNext, 6000);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** @param {boolean} on */
|
|
1048
|
+
function setPresenting(on) {
|
|
1049
|
+
presenting = on;
|
|
1050
|
+
store.set("present", on);
|
|
1051
|
+
|
|
1052
|
+
document.body.classList.toggle("presenting", on);
|
|
1053
|
+
presentButton.setAttribute("aria-pressed", String(on));
|
|
1054
|
+
|
|
1055
|
+
touring = undefined;
|
|
1056
|
+
if (on) selection = undefined;
|
|
1057
|
+
restartTour();
|
|
1058
|
+
|
|
1059
|
+
if (graphView.hidden || !data) return;
|
|
1060
|
+
|
|
1061
|
+
// The panel hides or returns, so the drawing is fitted once the page has its new size.
|
|
1062
|
+
draw();
|
|
1063
|
+
showPanel();
|
|
1064
|
+
showCaption();
|
|
1065
|
+
requestAnimationFrame(() => (presenting ? frameTour(false) : fit()));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
presentButton.addEventListener("click", () => setPresenting(!presenting));
|
|
1069
|
+
setPresenting(presenting);
|
|
1070
|
+
|
|
1071
|
+
// A hidden page's dots stop moving.
|
|
1072
|
+
document.addEventListener("visibilitychange", () => {
|
|
1073
|
+
if (!(svg instanceof SVGSVGElement)) return;
|
|
1074
|
+
if (document.hidden) svg.pauseAnimations();
|
|
1075
|
+
else svg.unpauseAnimations();
|
|
1076
|
+
});
|
|
628
1077
|
|
|
629
1078
|
// Saving the layer shown, as it looks.
|
|
630
1079
|
byId("save-svg").addEventListener("click", () => {
|
|
@@ -640,10 +1089,16 @@ byId("save-png").addEventListener("click", async () => {
|
|
|
640
1089
|
// The side panel.
|
|
641
1090
|
/** @param {Selection} next */
|
|
642
1091
|
function select(next) {
|
|
1092
|
+
/** @param {Selection} one */
|
|
1093
|
+
const keyOf = (one) => (one && "node" in one ? one.node : one ? edgeKey(one.edge) : "");
|
|
1094
|
+
const changed = keyOf(next) !== keyOf(selection);
|
|
1095
|
+
|
|
643
1096
|
selection = next;
|
|
644
1097
|
pathFocus = undefined;
|
|
645
1098
|
emphasize();
|
|
646
1099
|
showPanel();
|
|
1100
|
+
|
|
1101
|
+
if (changed) enter(panel);
|
|
647
1102
|
}
|
|
648
1103
|
|
|
649
1104
|
/**
|
|
@@ -882,7 +1337,12 @@ function showLayerButtons() {
|
|
|
882
1337
|
document.addEventListener("keydown", (event) => {
|
|
883
1338
|
if (event.target instanceof HTMLInputElement || graphView.hidden) return;
|
|
884
1339
|
|
|
885
|
-
if (event.key === "Escape")
|
|
1340
|
+
if (event.key === "Escape") {
|
|
1341
|
+
if (presenting) setPresenting(false);
|
|
1342
|
+
select(undefined);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (event.key === "p" && !event.ctrlKey && !event.metaKey && !event.altKey) setPresenting(!presenting);
|
|
886
1346
|
|
|
887
1347
|
// Keys 1 to 6 pick a layer.
|
|
888
1348
|
const chosen = LAYERS[Number(event.key) - 1];
|
|
@@ -907,7 +1367,7 @@ export function setGraph(next) {
|
|
|
907
1367
|
selection = same ? { edge: same } : undefined;
|
|
908
1368
|
}
|
|
909
1369
|
|
|
910
|
-
|
|
1370
|
+
layOut(layer, false);
|
|
911
1371
|
}
|
|
912
1372
|
|
|
913
1373
|
/**
|
|
@@ -920,6 +1380,9 @@ export function showLayer(next) {
|
|
|
920
1380
|
if (selection && "edge" in selection) selection = undefined;
|
|
921
1381
|
vlanFocus = undefined;
|
|
922
1382
|
pathFocus = undefined;
|
|
1383
|
+
touring = undefined;
|
|
1384
|
+
tourStep = 0;
|
|
1385
|
+
restartTour();
|
|
923
1386
|
}
|
|
924
1387
|
|
|
925
1388
|
if (pendingVlan !== undefined) {
|
|
@@ -932,14 +1395,32 @@ export function showLayer(next) {
|
|
|
932
1395
|
pendingPath = undefined;
|
|
933
1396
|
}
|
|
934
1397
|
|
|
1398
|
+
// The first view draws the whole network in; a new layer draws in its own lines while its devices glide to their places on it.
|
|
1399
|
+
const arrival = !drawn && data ? "network" : next !== layer ? "cables" : undefined;
|
|
1400
|
+
const from = positions;
|
|
1401
|
+
if (drawn) views.set(layer, { ...view });
|
|
1402
|
+
|
|
935
1403
|
layer = next;
|
|
936
1404
|
store.set("layer", layer);
|
|
1405
|
+
layOut(layer, false);
|
|
1406
|
+
|
|
1407
|
+
if (arrival === "cables") glideNodes(from);
|
|
1408
|
+
else settle();
|
|
937
1409
|
|
|
938
1410
|
showLayerButtons();
|
|
939
|
-
draw();
|
|
1411
|
+
draw(arrival);
|
|
940
1412
|
showPanel();
|
|
941
|
-
|
|
942
|
-
|
|
1413
|
+
showCaption();
|
|
1414
|
+
|
|
1415
|
+
// Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read. A layer shown before
|
|
1416
|
+
// comes back as the reader left it, and one shown for the first time glides into view.
|
|
1417
|
+
if (presenting && arrival) fit();
|
|
1418
|
+
else if (!drawn && data) fit(true);
|
|
1419
|
+
else if (arrival === "cables") {
|
|
1420
|
+
const left = views.get(layer);
|
|
1421
|
+
if (left) glideTo(left);
|
|
1422
|
+
else fit(true, true);
|
|
1423
|
+
}
|
|
943
1424
|
drawn = data !== undefined;
|
|
944
1425
|
}
|
|
945
1426
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<meta charset="utf-8" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
7
|
<title>Circuit Studio</title>
|
|
8
|
+
<!-- Studio shows one team's network, so search engines are asked to leave it out. -->
|
|
9
|
+
<meta name="robots" content="noindex, nofollow, noarchive" />
|
|
8
10
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
9
11
|
<!-- Special Gothic is shared with the documentation site, and served from beside this page so Studio works offline. -->
|
|
10
12
|
<link rel="preload" href="special-gothic-latin.woff2" as="font" type="font/woff2" crossorigin />
|
|
@@ -42,7 +44,16 @@
|
|
|
42
44
|
<nav id="layers" aria-label="Layers"></nav>
|
|
43
45
|
<div class="actions">
|
|
44
46
|
<button id="fit" title="Fit the drawing to the window">Fit</button>
|
|
45
|
-
<button id="lay-out" title="Forget where nodes were moved, and lay
|
|
47
|
+
<button id="lay-out" title="Forget where nodes were moved on this layer, and lay it out again">
|
|
48
|
+
Lay out again
|
|
49
|
+
</button>
|
|
50
|
+
<button
|
|
51
|
+
id="present"
|
|
52
|
+
aria-pressed="false"
|
|
53
|
+
title="Show the network on a big screen: the panel hides, and the drawing goes through every layer, lighting its busiest devices in turn. Key P."
|
|
54
|
+
>
|
|
55
|
+
Present
|
|
56
|
+
</button>
|
|
46
57
|
<details class="menu">
|
|
47
58
|
<summary>Save image</summary>
|
|
48
59
|
<div class="menu-items">
|
|
@@ -59,6 +70,7 @@
|
|
|
59
70
|
<g id="nodes"></g>
|
|
60
71
|
</g>
|
|
61
72
|
</svg>
|
|
73
|
+
<p id="present-caption" class="present-caption" aria-live="polite" hidden></p>
|
|
62
74
|
<aside id="panel"></aside>
|
|
63
75
|
</div>
|
|
64
76
|
</section>
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
--surface: #ffffff;
|
|
40
40
|
--surface-raised: #f4f6fb;
|
|
41
41
|
--line: #d3d9e7;
|
|
42
|
+
--head: #e1e7f3;
|
|
43
|
+
--hover: rgba(10, 71, 184, 0.045);
|
|
42
44
|
--text: #11141b;
|
|
43
45
|
--muted: #49536a;
|
|
44
46
|
--edge: #7b86a0;
|
|
@@ -67,6 +69,8 @@
|
|
|
67
69
|
--surface: #121a30;
|
|
68
70
|
--surface-raised: #182241;
|
|
69
71
|
--line: #26314d;
|
|
72
|
+
--head: #26325a;
|
|
73
|
+
--hover: rgba(156, 185, 255, 0.05);
|
|
70
74
|
--text: #f1f4fb;
|
|
71
75
|
--muted: #a9b4cc;
|
|
72
76
|
--edge: #7c89a8;
|
|
@@ -258,6 +262,14 @@ header > nav button[aria-pressed="true"]::after {
|
|
|
258
262
|
left: 9px;
|
|
259
263
|
height: 4px;
|
|
260
264
|
background: var(--yellow);
|
|
265
|
+
animation: bar 0.28s cubic-bezier(0.16, 1, 0.3, 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* The bar under the view or tab just chosen grows out from its middle. */
|
|
269
|
+
@keyframes bar {
|
|
270
|
+
from {
|
|
271
|
+
transform: scaleX(0);
|
|
272
|
+
}
|
|
261
273
|
}
|
|
262
274
|
|
|
263
275
|
.count {
|
|
@@ -393,6 +405,18 @@ header input[type="search"]:focus {
|
|
|
393
405
|
background: var(--ok);
|
|
394
406
|
}
|
|
395
407
|
|
|
408
|
+
/* When a changed config arrives, the live mark beats once in yellow. */
|
|
409
|
+
.status-rail .live.beat::before {
|
|
410
|
+
animation: beat 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
@keyframes beat {
|
|
414
|
+
30% {
|
|
415
|
+
background: var(--yellow);
|
|
416
|
+
transform: scale(1.9);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
396
420
|
.banner {
|
|
397
421
|
margin: 0;
|
|
398
422
|
padding: 10px 18px;
|
|
@@ -537,9 +561,11 @@ main {
|
|
|
537
561
|
/* The drawing board. */
|
|
538
562
|
|
|
539
563
|
.stage {
|
|
564
|
+
position: relative;
|
|
540
565
|
flex: 1;
|
|
541
566
|
display: flex;
|
|
542
567
|
min-height: 0;
|
|
568
|
+
overflow: hidden;
|
|
543
569
|
background-color: var(--bg);
|
|
544
570
|
background-image: radial-gradient(circle, var(--dots) 1.2px, transparent 1.6px);
|
|
545
571
|
background-size: 24px 24px;
|
|
@@ -562,6 +588,33 @@ svg.canvas.panning {
|
|
|
562
588
|
fill: none;
|
|
563
589
|
stroke: var(--edge);
|
|
564
590
|
stroke-width: 2;
|
|
591
|
+
transition:
|
|
592
|
+
stroke 0.2s,
|
|
593
|
+
stroke-width 0.2s;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/* Lighting one part dims the rest gently, so the eye follows what changed. */
|
|
597
|
+
.edge,
|
|
598
|
+
.node {
|
|
599
|
+
transition: opacity 0.25s ease-out;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/* Dots travelling a cable both ends declare: traffic where the link works. On a lit cable they turn to the highlighter. */
|
|
603
|
+
.edge .signal {
|
|
604
|
+
fill: var(--accent);
|
|
605
|
+
pointer-events: none;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.edge.lit .signal,
|
|
609
|
+
.edge.selected .signal {
|
|
610
|
+
r: 4.5px;
|
|
611
|
+
fill: var(--yellow);
|
|
612
|
+
stroke: var(--navy);
|
|
613
|
+
stroke-width: 1.5;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.edge.added .signal {
|
|
617
|
+
fill: var(--ok);
|
|
565
618
|
}
|
|
566
619
|
|
|
567
620
|
.edge text {
|
|
@@ -659,6 +712,66 @@ svg.canvas.panning {
|
|
|
659
712
|
fill: var(--yellow);
|
|
660
713
|
}
|
|
661
714
|
|
|
715
|
+
/* The device presenting lights now: a highlighter fill, lit sockets, and a ring around it that keeps leaving it, the way the docs drawing marks the changed device. A warning or error keeps its own frame. */
|
|
716
|
+
.node .beacon {
|
|
717
|
+
display: none;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.node.touring .beacon {
|
|
721
|
+
display: inline;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.node .beacon rect {
|
|
725
|
+
fill: none;
|
|
726
|
+
stroke: var(--yellow-deep);
|
|
727
|
+
stroke-width: 3;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.node.touring > rect {
|
|
731
|
+
fill: var(--yellow);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.node.touring:not(.error):not(.warning) > rect {
|
|
735
|
+
stroke: #0a1a36;
|
|
736
|
+
stroke-width: 4;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.node.touring text.name {
|
|
740
|
+
fill: #11141b;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.node.touring text.sub {
|
|
744
|
+
fill: #2a3550;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.node.touring .chassis > rect:first-child {
|
|
748
|
+
fill: #0a1a36;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.node.touring .chassis .socket {
|
|
752
|
+
fill: var(--yellow);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
756
|
+
.node.touring .beacon rect {
|
|
757
|
+
transform-box: fill-box;
|
|
758
|
+
transform-origin: center;
|
|
759
|
+
animation: beacon 1.8s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
@keyframes beacon {
|
|
764
|
+
from {
|
|
765
|
+
opacity: 1;
|
|
766
|
+
transform: scale(0.96);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
to {
|
|
770
|
+
opacity: 0;
|
|
771
|
+
transform: scale(1.18);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
662
775
|
.node text {
|
|
663
776
|
fill: var(--text);
|
|
664
777
|
font-size: 14px;
|
|
@@ -695,6 +808,78 @@ svg.far .edge text {
|
|
|
695
808
|
display: none;
|
|
696
809
|
}
|
|
697
810
|
|
|
811
|
+
/* Presenting: the network for a big screen. The panel steps aside, lines and dots are heavier, and the drawing drifts slowly. */
|
|
812
|
+
|
|
813
|
+
.presenting #panel {
|
|
814
|
+
display: none;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
.presenting .edge path:not(.hit) {
|
|
818
|
+
stroke-width: 3;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
.presenting .edge.lit path:not(.hit) {
|
|
822
|
+
stroke-width: 5;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
.presenting .edge .signal {
|
|
826
|
+
r: 4.5px;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
.presenting .edge.lit .signal {
|
|
830
|
+
r: 6px;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
.present-caption {
|
|
834
|
+
position: absolute;
|
|
835
|
+
bottom: 22px;
|
|
836
|
+
left: 22px;
|
|
837
|
+
display: grid;
|
|
838
|
+
gap: 2px;
|
|
839
|
+
margin: 0;
|
|
840
|
+
padding: 12px 18px 14px;
|
|
841
|
+
border: 2px solid var(--text);
|
|
842
|
+
border-radius: 2px;
|
|
843
|
+
background: var(--surface);
|
|
844
|
+
pointer-events: none;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.present-caption[hidden] {
|
|
848
|
+
display: none;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
.present-caption strong {
|
|
852
|
+
font-size: 26px;
|
|
853
|
+
letter-spacing: -0.015em;
|
|
854
|
+
line-height: 1.15;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
.present-caption span {
|
|
858
|
+
color: var(--muted);
|
|
859
|
+
font-size: 14px;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
863
|
+
.presenting svg.canvas {
|
|
864
|
+
animation: drift 48s ease-in-out infinite alternate;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
.presenting svg.canvas:hover,
|
|
868
|
+
.presenting svg.canvas.panning {
|
|
869
|
+
animation-play-state: paused;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
@keyframes drift {
|
|
874
|
+
from {
|
|
875
|
+
transform: scale(1) translate(-1%, -0.6%);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
to {
|
|
879
|
+
transform: scale(1.05) translate(1%, 0.6%);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
698
883
|
/* The side panel: what is selected, or the layer's legend. */
|
|
699
884
|
|
|
700
885
|
aside {
|
|
@@ -898,6 +1083,7 @@ aside pre {
|
|
|
898
1083
|
left: 8px;
|
|
899
1084
|
height: 4px;
|
|
900
1085
|
background: var(--cobalt);
|
|
1086
|
+
animation: bar 0.28s cubic-bezier(0.16, 1, 0.3, 1);
|
|
901
1087
|
}
|
|
902
1088
|
|
|
903
1089
|
.table-block {
|
|
@@ -934,9 +1120,10 @@ table.data td {
|
|
|
934
1120
|
vertical-align: top;
|
|
935
1121
|
}
|
|
936
1122
|
|
|
1123
|
+
/* The head is a tinted band rather than a rule, so it is never read as a divider between rows. */
|
|
937
1124
|
table.data thead th {
|
|
938
|
-
border-bottom:
|
|
939
|
-
background: var(--
|
|
1125
|
+
border-bottom: 0;
|
|
1126
|
+
background: var(--head);
|
|
940
1127
|
color: var(--text);
|
|
941
1128
|
font-size: 11px;
|
|
942
1129
|
font-weight: 700;
|
|
@@ -952,9 +1139,10 @@ table.data th[title] {
|
|
|
952
1139
|
text-underline-offset: 3px;
|
|
953
1140
|
}
|
|
954
1141
|
|
|
955
|
-
|
|
956
|
-
table.data tbody tr:hover >
|
|
957
|
-
|
|
1142
|
+
/* Only the row under the pointer is washed, not every row that holds it, and the wash is a faint link tint well apart from the head's band. */
|
|
1143
|
+
table.data tbody tr:hover:not(:has(tr:hover)) > td,
|
|
1144
|
+
table.data tbody tr:hover:not(:has(tr:hover)) > th {
|
|
1145
|
+
background: var(--hover);
|
|
958
1146
|
}
|
|
959
1147
|
|
|
960
1148
|
table.data tr.muted td {
|
package/src/studio/server.ts
CHANGED
|
@@ -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;
|