@takodotid/circuit 0.22.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/package.json
CHANGED
|
@@ -85,10 +85,28 @@ let tour;
|
|
|
85
85
|
/** @param {GraphEdge} edge */
|
|
86
86
|
const edgeKey = (edge) => `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
|
|
87
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();
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
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);
|
|
92
110
|
|
|
93
111
|
/** @type {Map<string, SVGGElement>} */
|
|
94
112
|
const nodeElements = new Map();
|
|
@@ -96,7 +114,9 @@ const nodeElements = new Map();
|
|
|
96
114
|
const edgeElements = [];
|
|
97
115
|
|
|
98
116
|
function savePositions() {
|
|
99
|
-
|
|
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)])));
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
/**
|
|
@@ -111,17 +131,23 @@ function hash(text) {
|
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
/**
|
|
114
|
-
* Place nodes with a force layout: every node pushes every other away, and each
|
|
115
|
-
*
|
|
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.
|
|
116
136
|
*
|
|
117
137
|
* @param {GraphNode[]} nodes
|
|
118
138
|
* @param {GraphEdge[]} edges
|
|
119
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
|
|
120
142
|
*/
|
|
121
|
-
function layout(nodes, edges, fresh) {
|
|
122
|
-
if (fresh)
|
|
143
|
+
function layout(nodes, edges, fresh, places, seed) {
|
|
144
|
+
if (fresh) places.clear();
|
|
123
145
|
|
|
124
|
-
|
|
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));
|
|
125
151
|
/** @type {Map<string, Set<string>>} */
|
|
126
152
|
const neighbors = new Map();
|
|
127
153
|
|
|
@@ -133,29 +159,64 @@ function layout(nodes, edges, fresh) {
|
|
|
133
159
|
neighbors.get(edge.to)?.add(edge.from);
|
|
134
160
|
}
|
|
135
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
|
+
|
|
136
191
|
// A new node starts beside a neighbor already placed, or on a circle.
|
|
137
192
|
const radius = 60 * Math.sqrt(nodes.length + 1);
|
|
138
193
|
|
|
139
194
|
for (const node of nodes) {
|
|
140
|
-
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
|
+
}
|
|
141
202
|
|
|
142
203
|
const angle = hash(node.id) * Math.PI * 2;
|
|
143
|
-
const placed = [...(neighbors.get(node.id) ?? [])].map((id) =>
|
|
204
|
+
const placed = [...(neighbors.get(node.id) ?? [])].map((id) => places.get(id)).find(Boolean);
|
|
144
205
|
const distance = placed ? 90 : radius;
|
|
145
206
|
const origin = placed ?? { x: 0, y: 0 };
|
|
146
207
|
|
|
147
|
-
|
|
208
|
+
places.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
|
|
148
209
|
}
|
|
149
210
|
|
|
150
211
|
const moving = nodes.filter((node) => !fixed.has(node.id));
|
|
151
|
-
if (!moving.length) return;
|
|
212
|
+
if (!moving.length) return park();
|
|
152
213
|
|
|
153
214
|
const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
|
|
154
215
|
const ideal = 170;
|
|
155
216
|
const rounds = fixed.size ? 200 : 500;
|
|
156
217
|
|
|
157
218
|
/** @param {string} id */
|
|
158
|
-
const at = (id) => /** @type {Point} */ (
|
|
219
|
+
const at = (id) => /** @type {Point} */ (places.get(id));
|
|
159
220
|
|
|
160
221
|
for (let round = 0; round < rounds; round++) {
|
|
161
222
|
const heat = 40 * (1 - round / rounds) + 1;
|
|
@@ -187,7 +248,7 @@ function layout(nodes, edges, fresh) {
|
|
|
187
248
|
}
|
|
188
249
|
|
|
189
250
|
for (const [from, to] of pairs) {
|
|
190
|
-
if (!from || !to || !
|
|
251
|
+
if (!from || !to || !places.has(from) || !places.has(to)) continue;
|
|
191
252
|
|
|
192
253
|
const dx = at(from).x - at(to).x;
|
|
193
254
|
const dy = at(from).y - at(to).y;
|
|
@@ -215,9 +276,6 @@ function layout(nodes, edges, fresh) {
|
|
|
215
276
|
}
|
|
216
277
|
|
|
217
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.
|
|
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
279
|
|
|
222
280
|
for (let round = 0; round < 80; round++) {
|
|
223
281
|
let overlapping = false;
|
|
@@ -248,27 +306,99 @@ function layout(nodes, edges, fresh) {
|
|
|
248
306
|
if (!overlapping) break;
|
|
249
307
|
}
|
|
250
308
|
|
|
309
|
+
park();
|
|
251
310
|
savePositions();
|
|
252
311
|
}
|
|
253
312
|
|
|
254
313
|
// What is drawn.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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) ?? [];
|
|
258
323
|
if (!comparison) return current;
|
|
259
324
|
|
|
260
325
|
// An edge the comparison says is gone is drawn when both its ends are still drawn.
|
|
261
326
|
const nodes = new Set(data?.nodes.map((node) => node.id));
|
|
262
|
-
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));
|
|
263
328
|
return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
|
|
264
329
|
};
|
|
265
330
|
|
|
266
|
-
/**
|
|
267
|
-
|
|
268
|
-
|
|
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]));
|
|
269
339
|
return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
|
|
270
340
|
}
|
|
271
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
|
+
|
|
272
402
|
/** @param {string} id */
|
|
273
403
|
const nodeById = (id) => data?.nodes.find((node) => node.id === id);
|
|
274
404
|
|
|
@@ -449,10 +579,13 @@ function arrive(fresh) {
|
|
|
449
579
|
if (edge.oneSided || edge.gone) {
|
|
450
580
|
path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: EASE, fill: "backwards" });
|
|
451
581
|
} else {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
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"));
|
|
456
589
|
}
|
|
457
590
|
|
|
458
591
|
for (const part of [label, ...group.querySelectorAll(".signal")]) {
|
|
@@ -540,6 +673,11 @@ function draw(arrival) {
|
|
|
540
673
|
rect.setAttribute("width", String(width));
|
|
541
674
|
rect.setAttribute("height", String(height));
|
|
542
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
|
+
|
|
543
681
|
if (device) {
|
|
544
682
|
for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
|
|
545
683
|
|
|
@@ -609,6 +747,7 @@ function emphasize() {
|
|
|
609
747
|
|
|
610
748
|
for (const [id, group] of nodeElements) {
|
|
611
749
|
group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
|
|
750
|
+
group.classList.toggle("touring", presenting && id === touring && !hovered);
|
|
612
751
|
group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
|
|
613
752
|
}
|
|
614
753
|
}
|
|
@@ -634,6 +773,12 @@ function startDrag(event, id) {
|
|
|
634
773
|
if (event.button !== 0) return;
|
|
635
774
|
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
636
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
|
+
}
|
|
637
782
|
event.stopPropagation();
|
|
638
783
|
|
|
639
784
|
svg.setPointerCapture(event.pointerId);
|
|
@@ -757,8 +902,10 @@ function fit(readable = false, glide = false) {
|
|
|
757
902
|
* @returns {typeof view | undefined}
|
|
758
903
|
*/
|
|
759
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);
|
|
760
907
|
const points = nodes.flatMap((node) => {
|
|
761
|
-
const point =
|
|
908
|
+
const point = places.get(node.id);
|
|
762
909
|
return point ? [point] : [];
|
|
763
910
|
});
|
|
764
911
|
if (!points.length) return undefined;
|
|
@@ -776,8 +923,8 @@ function framing(nodes, readable) {
|
|
|
776
923
|
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
777
924
|
const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
|
|
778
925
|
const centre =
|
|
779
|
-
k > whole && hub &&
|
|
780
|
-
? /** @type {Point} */ (
|
|
926
|
+
k > whole && hub && places.has(hub.id)
|
|
927
|
+
? /** @type {Point} */ (places.get(hub.id))
|
|
781
928
|
: { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
782
929
|
|
|
783
930
|
// Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
|
|
@@ -807,7 +954,8 @@ byId("fit").addEventListener("click", () => fit());
|
|
|
807
954
|
|
|
808
955
|
byId("lay-out").addEventListener("click", () => {
|
|
809
956
|
if (!data) return;
|
|
810
|
-
|
|
957
|
+
layOut(layer, true);
|
|
958
|
+
settle();
|
|
811
959
|
draw();
|
|
812
960
|
fit();
|
|
813
961
|
});
|
|
@@ -842,6 +990,9 @@ const LIT_PER_LAYER = 4;
|
|
|
842
990
|
/** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
|
|
843
991
|
let tourStep = 0;
|
|
844
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
|
+
|
|
845
996
|
/** The next layer that has lines to show, after this one; this one when no other has any. */
|
|
846
997
|
function nextLayer() {
|
|
847
998
|
const after = [...LAYERS.slice(LAYERS.indexOf(layer) + 1), ...LAYERS.slice(0, LAYERS.indexOf(layer) + 1)];
|
|
@@ -850,7 +1001,7 @@ function nextLayer() {
|
|
|
850
1001
|
|
|
851
1002
|
/** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
|
|
852
1003
|
function tourNext() {
|
|
853
|
-
if (graphView.hidden || document.hidden || hovered || drag) return;
|
|
1004
|
+
if (graphView.hidden || document.hidden || hovered || drag || leaving) return;
|
|
854
1005
|
|
|
855
1006
|
const edges = visibleEdges();
|
|
856
1007
|
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
@@ -870,8 +1021,27 @@ function tourNext() {
|
|
|
870
1021
|
return;
|
|
871
1022
|
}
|
|
872
1023
|
|
|
873
|
-
// Every lit device has had its turn
|
|
874
|
-
|
|
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);
|
|
875
1045
|
}
|
|
876
1046
|
|
|
877
1047
|
/** @param {boolean} on */
|
|
@@ -882,12 +1052,9 @@ function setPresenting(on) {
|
|
|
882
1052
|
document.body.classList.toggle("presenting", on);
|
|
883
1053
|
presentButton.setAttribute("aria-pressed", String(on));
|
|
884
1054
|
|
|
885
|
-
clearInterval(tour);
|
|
886
1055
|
touring = undefined;
|
|
887
|
-
if (on)
|
|
888
|
-
|
|
889
|
-
tour = setInterval(tourNext, 6000);
|
|
890
|
-
}
|
|
1056
|
+
if (on) selection = undefined;
|
|
1057
|
+
restartTour();
|
|
891
1058
|
|
|
892
1059
|
if (graphView.hidden || !data) return;
|
|
893
1060
|
|
|
@@ -1200,7 +1367,7 @@ export function setGraph(next) {
|
|
|
1200
1367
|
selection = same ? { edge: same } : undefined;
|
|
1201
1368
|
}
|
|
1202
1369
|
|
|
1203
|
-
|
|
1370
|
+
layOut(layer, false);
|
|
1204
1371
|
}
|
|
1205
1372
|
|
|
1206
1373
|
/**
|
|
@@ -1215,6 +1382,7 @@ export function showLayer(next) {
|
|
|
1215
1382
|
pathFocus = undefined;
|
|
1216
1383
|
touring = undefined;
|
|
1217
1384
|
tourStep = 0;
|
|
1385
|
+
restartTour();
|
|
1218
1386
|
}
|
|
1219
1387
|
|
|
1220
1388
|
if (pendingVlan !== undefined) {
|
|
@@ -1227,20 +1395,32 @@ export function showLayer(next) {
|
|
|
1227
1395
|
pendingPath = undefined;
|
|
1228
1396
|
}
|
|
1229
1397
|
|
|
1230
|
-
// The first view draws the whole network in; a new layer draws in its own lines.
|
|
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.
|
|
1231
1399
|
const arrival = !drawn && data ? "network" : next !== layer ? "cables" : undefined;
|
|
1400
|
+
const from = positions;
|
|
1401
|
+
if (drawn) views.set(layer, { ...view });
|
|
1232
1402
|
|
|
1233
1403
|
layer = next;
|
|
1234
1404
|
store.set("layer", layer);
|
|
1405
|
+
layOut(layer, false);
|
|
1406
|
+
|
|
1407
|
+
if (arrival === "cables") glideNodes(from);
|
|
1408
|
+
else settle();
|
|
1235
1409
|
|
|
1236
1410
|
showLayerButtons();
|
|
1237
1411
|
draw(arrival);
|
|
1238
1412
|
showPanel();
|
|
1239
1413
|
showCaption();
|
|
1240
1414
|
|
|
1241
|
-
// Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read.
|
|
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.
|
|
1242
1417
|
if (presenting && arrival) fit();
|
|
1243
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
|
+
}
|
|
1244
1424
|
drawn = data !== undefined;
|
|
1245
1425
|
}
|
|
1246
1426
|
|
|
@@ -44,7 +44,9 @@
|
|
|
44
44
|
<nav id="layers" aria-label="Layers"></nav>
|
|
45
45
|
<div class="actions">
|
|
46
46
|
<button id="fit" title="Fit the drawing to the window">Fit</button>
|
|
47
|
-
<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>
|
|
48
50
|
<button
|
|
49
51
|
id="present"
|
|
50
52
|
aria-pressed="false"
|
|
@@ -712,6 +712,66 @@ svg.canvas.panning {
|
|
|
712
712
|
fill: var(--yellow);
|
|
713
713
|
}
|
|
714
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
|
+
|
|
715
775
|
.node text {
|
|
716
776
|
fill: var(--text);
|
|
717
777
|
font-size: 14px;
|