@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -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
- const saved = /** @type {Record<string, [number, number]>} */ (store.get("positions") ?? {});
90
- /** @type {Map<string, Point>} */
91
- const positions = new Map(Object.entries(saved).map(([id, [x, y]]) => [id, { x, y }]));
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
- store.set("positions", Object.fromEntries([...positions].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]])));
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 edge pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
115
- * Every layer is laid out together, so a device stays where it is when the layer changes.
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) positions.clear();
143
+ function layout(nodes, edges, fresh, places, seed) {
144
+ if (fresh) places.clear();
123
145
 
124
- const fixed = new Set(nodes.filter((node) => positions.has(node.id)).map((node) => node.id));
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 (positions.has(node.id)) continue;
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) => positions.get(id)).find(Boolean);
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
- positions.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
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} */ (positions.get(id));
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 || !positions.has(from) || !positions.has(to)) continue;
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
- /** @returns {DrawnEdge[]} */
256
- const visibleEdges = () => {
257
- const current = data?.edges.filter((edge) => edge.layer === layer) ?? [];
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 === layer && nodes.has(edge.from) && nodes.has(edge.to));
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
- /** @returns {GraphNode[]} */
267
- function visibleNodes() {
268
- const ends = new Set(visibleEdges().flatMap((edge) => [edge.from, edge.to]));
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
- 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" });
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 = positions.get(node.id);
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 && positions.has(hub.id)
780
- ? /** @type {Point} */ (positions.get(hub.id))
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
- layout(data.nodes, data.edges, true);
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. Moving on replaces the address rather than adding to the history, so Back still leaves Studio's last place.
874
- location.replace(`#graph/${nextLayer()}`);
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
- selection = undefined;
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
- layout(data.nodes, data.edges, false);
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 them out again">Lay out again</button>
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;