@fluixi/devtools 0.2.0-alpha.1 → 0.2.0-alpha.3

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.
Files changed (96) hide show
  1. package/README.md +80 -2
  2. package/dist/callsite.d.ts +22 -0
  3. package/dist/callsite.d.ts.map +1 -0
  4. package/dist/callsite.js +82 -0
  5. package/dist/change-view.d.ts +10 -0
  6. package/dist/change-view.d.ts.map +1 -0
  7. package/dist/change-view.js +102 -0
  8. package/dist/copy.d.ts +9 -0
  9. package/dist/copy.d.ts.map +1 -0
  10. package/dist/copy.js +52 -0
  11. package/dist/describe.d.ts +3 -0
  12. package/dist/describe.d.ts.map +1 -0
  13. package/dist/describe.js +47 -0
  14. package/dist/detail-panel.d.ts +17 -0
  15. package/dist/detail-panel.d.ts.map +1 -0
  16. package/dist/detail-panel.js +217 -0
  17. package/dist/diff.d.ts +31 -0
  18. package/dist/diff.d.ts.map +1 -0
  19. package/dist/diff.js +132 -0
  20. package/dist/dom.d.ts +81 -0
  21. package/dist/dom.d.ts.map +1 -0
  22. package/dist/dom.js +269 -0
  23. package/dist/export.d.ts +55 -0
  24. package/dist/export.d.ts.map +1 -0
  25. package/dist/export.js +90 -0
  26. package/dist/float.d.ts +54 -0
  27. package/dist/float.d.ts.map +1 -0
  28. package/dist/float.js +256 -0
  29. package/dist/format.d.ts +16 -0
  30. package/dist/format.d.ts.map +1 -0
  31. package/dist/format.js +188 -0
  32. package/dist/graph-view.d.ts +79 -2
  33. package/dist/graph-view.d.ts.map +1 -1
  34. package/dist/graph-view.js +866 -50
  35. package/dist/highlight.d.ts +56 -0
  36. package/dist/highlight.d.ts.map +1 -0
  37. package/dist/highlight.js +154 -0
  38. package/dist/history.d.ts +59 -0
  39. package/dist/history.d.ts.map +1 -0
  40. package/dist/history.js +85 -0
  41. package/dist/hook.d.ts +60 -0
  42. package/dist/hook.d.ts.map +1 -0
  43. package/dist/hook.js +142 -0
  44. package/dist/index.d.ts +44 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +25 -1
  47. package/dist/inspector.d.ts +60 -0
  48. package/dist/inspector.d.ts.map +1 -0
  49. package/dist/inspector.js +415 -0
  50. package/dist/instrument.d.ts +147 -5
  51. package/dist/instrument.d.ts.map +1 -1
  52. package/dist/instrument.js +164 -44
  53. package/dist/kind-filter.d.ts +39 -0
  54. package/dist/kind-filter.d.ts.map +1 -0
  55. package/dist/kind-filter.js +151 -0
  56. package/dist/menu.d.ts +37 -0
  57. package/dist/menu.d.ts.map +1 -0
  58. package/dist/menu.js +125 -0
  59. package/dist/observe.d.ts +80 -0
  60. package/dist/observe.d.ts.map +1 -0
  61. package/dist/observe.js +893 -0
  62. package/dist/palette.d.ts +11 -0
  63. package/dist/palette.d.ts.map +1 -0
  64. package/dist/palette.js +27 -0
  65. package/dist/path.d.ts +19 -0
  66. package/dist/path.d.ts.map +1 -0
  67. package/dist/path.js +49 -0
  68. package/dist/plugin.d.ts +121 -0
  69. package/dist/plugin.d.ts.map +1 -0
  70. package/dist/plugin.js +74 -0
  71. package/dist/plugins/context.d.ts +10 -0
  72. package/dist/plugins/context.d.ts.map +1 -0
  73. package/dist/plugins/context.js +80 -0
  74. package/dist/plugins/directives.d.ts +25 -0
  75. package/dist/plugins/directives.d.ts.map +1 -0
  76. package/dist/plugins/directives.js +78 -0
  77. package/dist/plugins/index.d.ts +7 -0
  78. package/dist/plugins/index.d.ts.map +1 -0
  79. package/dist/plugins/index.js +9 -0
  80. package/dist/source.d.ts +72 -0
  81. package/dist/source.d.ts.map +1 -0
  82. package/dist/source.js +153 -0
  83. package/dist/timeline.d.ts +30 -0
  84. package/dist/timeline.d.ts.map +1 -0
  85. package/dist/timeline.js +271 -0
  86. package/dist/tree.d.ts +128 -0
  87. package/dist/tree.d.ts.map +1 -0
  88. package/dist/tree.js +645 -0
  89. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  90. package/dist/value-pane.d.ts +10 -0
  91. package/dist/value-pane.d.ts.map +1 -0
  92. package/dist/value-pane.js +61 -0
  93. package/dist/wire.d.ts +100 -0
  94. package/dist/wire.d.ts.map +1 -0
  95. package/dist/wire.js +93 -0
  96. package/package.json +10 -3
@@ -1,11 +1,82 @@
1
+ import { shortenPath } from './path.js';
2
+ import { pluginBadge } from './plugin.js';
3
+ import { kindRules } from './palette.js';
4
+ import { inPageOrder } from './tree.js';
1
5
  const SVGNS = 'http://www.w3.org/2000/svg';
2
6
  const COL = 200;
3
7
  const ROW = 92;
4
8
  const NW = 132;
5
- const NH = 56;
9
+ /** file.ts:12, the path's tail only. The full one is for an inspector with room for it. */
10
+ /**
11
+ * The location a card shows.
12
+ *
13
+ * `source` is where the compiler marked it. A node the compiler never saw, meaning anything
14
+ * the framework made on the application's behalf, carries `usedAt` instead, and a component
15
+ * carries where it was declared. The inspector already falls back this way; the card read
16
+ * only the first of the three, so those nodes showed a location in one place and not the
17
+ * other.
18
+ */
19
+ const placeOf = (node) => node.source ?? node.usedAt ?? node.declaredAt;
20
+ const shortSource = (s) => {
21
+ const file = (s.file ?? '').split('/').pop() ?? '';
22
+ return s.line == null ? file : `${file}:${s.line}`;
23
+ };
24
+ /** What the value column holds at 12px monospace, ellipsis included. */
25
+ const VALUE_CHARS = 7;
26
+ const fit = (v) => {
27
+ const text = v ?? '';
28
+ return text.length > VALUE_CHARS ? text.slice(0, VALUE_CHARS - 1) + '…' : text;
29
+ };
30
+ const NH = 62;
6
31
  const PAD = 48;
7
- const KIND_CLASS = { state: 'n-state', memo: 'n-memo', effect: 'n-effect', store: 'n-store', resource: 'n-resource' };
8
- const KIND_GLYPH = { state: '◆ state', memo: 'ƒ memo', effect: '⚡ effect', store: '▤ store', resource: '⟳ resource' };
32
+ /** How far apart two steps of a propagation land, and how long each glow lasts. */
33
+ const STEP = 110;
34
+ const FLASH = 560;
35
+ /** An edge lights while the value is crossing it: from its source's step to the next. */
36
+ const EDGE_FLASH = 260;
37
+ const MIN_ZOOM = 0.2;
38
+ /**
39
+ * How far past natural size the zoom goes.
40
+ *
41
+ * The ceiling cannot be a constant. The viewBox is the whole graph, so a big one is already
42
+ * shrunk to fit before `scale` applies. A five-hundred-node graph is shrunk enormously, and
43
+ * any fixed limit leaves its cards unreadable. The limit is worked out from how much the
44
+ * viewBox is shrinking, so it grows with the graph.
45
+ */
46
+ const OVER_NATURAL = 12;
47
+ /** For a graph smaller than the panel, where the viewBox is not shrinking anything. */
48
+ const MIN_MAX_ZOOM = 8;
49
+ /** A transform this deep is a bug somewhere, not a reader looking closely. */
50
+ const ABSOLUTE_MAX_ZOOM = 4000;
51
+ /** Past this the pointer was panning, not clicking. */
52
+ const DRAG_SLOP = 4;
53
+ const KIND_CLASS = { state: 'n-state', memo: 'n-memo', effect: 'n-effect', store: 'n-store', resource: 'n-resource', component: 'n-component', control: 'n-control', router: 'n-router', handler: 'n-handler', group: 'n-group', binding: 'n-binding', setter: 'n-setter', directive: 'n-directive', context: 'n-context', provider: 'n-provider' };
54
+ const KIND_GLYPH = { state: '◆ state', memo: 'ƒ memo', effect: '⚡ effect', store: '▤ store', resource: '⟳ resource', component: '◧ component', control: '⑂ control flow', router: '⤳ router', handler: '⇄ route', group: '▤ framework', binding: '⇥ dom', setter: '✎ sets', directive: '⌘ directive', context: '◈ context', provider: '◈ provider' };
55
+ /** The card that stands for everything a dependency made and the application never touched. */
56
+ const GROUP_ID = -1;
57
+ /**
58
+ * Ids for the cards that stand for something outside the graph.
59
+ *
60
+ * Counted down from below the group card, and derived from the node they hang off so they
61
+ * stay the same across renders. An id that moved would make the view rebuild every frame.
62
+ */
63
+ /**
64
+ * One card per place in the document, not per reader.
65
+ *
66
+ * Several values can drive the same binding, as a resource's three signals all write the same
67
+ * heading, and a card each made them look like duplicates of one another. Ids are handed
68
+ * out per place and kept, so a card kept its identity between frames.
69
+ */
70
+ const bindingIds = new Map();
71
+ const bindingId = (place) => {
72
+ const held = bindingIds.get(place);
73
+ if (held !== undefined)
74
+ return held;
75
+ const id = -100 - bindingIds.size;
76
+ bindingIds.set(place, id);
77
+ return id;
78
+ };
79
+ const setterId = (owner, index) => -100000 - owner * 32 - index;
9
80
  function el(tag, attrs = {}) {
10
81
  const n = document.createElementNS(SVGNS, tag);
11
82
  for (const k in attrs)
@@ -13,7 +84,7 @@ function el(tag, attrs = {}) {
13
84
  return n;
14
85
  }
15
86
  // Longest-path depth so every read edge points strictly rightward (the read graph is a DAG).
16
- function computeDepths(graph) {
87
+ function computeDepths(graph, deps) {
17
88
  const memo = new Map();
18
89
  const depth = (id, seen) => {
19
90
  if (memo.has(id))
@@ -24,7 +95,7 @@ function computeDepths(graph) {
24
95
  const node = graph.get(id);
25
96
  let d = 0;
26
97
  if (node)
27
- for (const dep of node.deps)
98
+ for (const dep of deps(node))
28
99
  d = Math.max(d, depth(dep, seen) + 1);
29
100
  seen.delete(id);
30
101
  memo.set(id, d);
@@ -36,37 +107,499 @@ function computeDepths(graph) {
36
107
  export function createGraphView(svg, options = {}) {
37
108
  let signature = '';
38
109
  let selected = null;
110
+ /** A set held lit until something clears it, being one commit from the timeline. */
111
+ let focused = null;
39
112
  const nodeEls = new Map();
113
+ /** Edges as ids, so hovering a node can pick out the ones that touch it. */
114
+ const edgeEls = [];
115
+ /**
116
+ * When each node and edge should glow.
117
+ *
118
+ * A write reaches every node that depends on it in one flush, so the graph knows about all
119
+ * of them in the same frame. Flashing them together says "these changed"; flashing them a
120
+ * step apart, along the edges between them, says which one caused which, and that is the
121
+ * thing the graph is for.
122
+ */
123
+ const staged = new Map();
124
+ const edgeStaged = new Map();
125
+ /** The pulse each node was last staged for, so one change is staged once. */
126
+ const stagedFor = new Map();
127
+ /** Where a folded-away node is drawn: itself, unless a store it belongs to is folded. */
128
+ let standIn = (id) => id;
129
+ /** Stores the viewer opened, and ones it closed again. Nothing else is remembered. */
130
+ const opened = new Set();
131
+ const closed = new Set();
132
+ const foldStores = options.collapseStores !== false;
133
+ let direction = options.direction ?? 'horizontal';
134
+ let groupExternal = options.groupExternal !== false;
135
+ let showBindings = options.showBindings === true;
136
+ /** Which kinds to draw. Null is every kind, which is not the same as a set holding them all. */
137
+ let kinds = options.kinds ? new Set(options.kinds) : null;
138
+ // A clip region per text, not one for the box: the label grows right from the left edge
139
+ // and the value grows left from the right, so clipping both to the same box still let a
140
+ // long label run under the value. Each gets its own half, with a gap between.
141
+ const uid = Math.random().toString(36).slice(2, 8);
142
+ const labelClip = `fx-label-clip-${uid}`;
143
+ const valueClip = `fx-value-clip-${uid}`;
144
+ const LABEL_W = 66;
145
+ const defs = el('defs');
146
+ const forLabel = el('clipPath', { id: labelClip });
147
+ forLabel.appendChild(el('rect', { x: 8, y: 0, width: LABEL_W, height: NH }));
148
+ const sourceClip = `fx-source-clip-${uid}`;
149
+ const forSource = el('clipPath', { id: sourceClip });
150
+ forSource.appendChild(el('rect', { x: 8, y: 0, width: NW - 16, height: NH }));
151
+ const forValue = el('clipPath', { id: valueClip });
152
+ // A little past the box on the right, so the ellipsis itself is not the thing clipped.
153
+ forValue.appendChild(el('rect', { x: 8 + LABEL_W + 6, y: 0, width: NW - LABEL_W - 10, height: NH }));
154
+ defs.append(forLabel, forValue, forSource);
155
+ // Everything drawn hangs off one group, which is what pan and zoom move. The viewBox still
156
+ // sizes the drawing to the graph; this rides on top of it.
157
+ const viewport = el('g', { class: 'fx-viewport' });
40
158
  let edgeLayer = el('g', { class: 'fx-edges' });
41
159
  let nodeLayer = el('g', { class: 'fx-nodes' });
42
- svg.append(edgeLayer, nodeLayer);
160
+ viewport.append(edgeLayer, nodeLayer);
161
+ svg.append(defs, viewport);
162
+ /**
163
+ * The furthest in this graph is worth zooming.
164
+ *
165
+ * `meet` fits by whichever axis is tighter, so the scale at which a card is its natural size
166
+ * is the *larger* of the two ratios. Measuring width alone left a tall graph clamped while
167
+ * its nodes were unreadable.
168
+ */
169
+ const maxZoom = () => {
170
+ const box = svg.viewBox?.baseVal;
171
+ const rect = svg.getBoundingClientRect?.();
172
+ if (!box?.width || !rect?.width || !rect.height)
173
+ return MIN_MAX_ZOOM;
174
+ const natural = Math.max(box.width / rect.width, (box.height || 0) / rect.height);
175
+ return Math.min(ABSOLUTE_MAX_ZOOM, Math.max(MIN_MAX_ZOOM, natural * OVER_NATURAL));
176
+ };
177
+ let scale = 1;
178
+ let panX = 0;
179
+ let panY = 0;
180
+ const applyView = () => viewport.setAttribute('transform', `translate(${panX} ${panY}) scale(${scale})`);
181
+ /** Client pixels → the coordinates the drawing is in, before pan and zoom. */
182
+ const toUserSpace = (clientX, clientY) => {
183
+ const ctm = svg.getScreenCTM?.();
184
+ if (!ctm)
185
+ return { x: 0, y: 0 };
186
+ const point = Object.assign(svg.createSVGPoint(), { x: clientX, y: clientY }).matrixTransform(ctm.inverse());
187
+ return { x: point.x, y: point.y };
188
+ };
189
+ const zoomAt = (factor, at) => {
190
+ const next = Math.min(maxZoom(), Math.max(MIN_ZOOM, scale * factor));
191
+ if (next === scale)
192
+ return;
193
+ // Keep whatever is under the cursor under the cursor.
194
+ panX = at.x - ((at.x - panX) * next) / scale;
195
+ panY = at.y - ((at.y - panY) * next) / scale;
196
+ scale = next;
197
+ applyView();
198
+ };
199
+ svg.addEventListener('wheel', (event) => {
200
+ event.preventDefault();
201
+ zoomAt(Math.exp(-event.deltaY * 0.0015), toUserSpace(event.clientX, event.clientY));
202
+ }, { passive: false });
203
+ let dragging = null;
204
+ svg.addEventListener('pointerdown', (event) => {
205
+ if (event.button !== 0)
206
+ return;
207
+ // No capture yet. Capturing here retargets every later event at the svg, so the click
208
+ // that follows never reaches the node it was aimed at and nothing gets selected.
209
+ dragging = { x: event.clientX, y: event.clientY, panX, panY, moved: false };
210
+ });
211
+ svg.addEventListener('pointermove', (event) => {
212
+ if (!dragging)
213
+ return;
214
+ const dx = event.clientX - dragging.x;
215
+ const dy = event.clientY - dragging.y;
216
+ if (!dragging.moved && Math.abs(dx) + Math.abs(dy) < DRAG_SLOP)
217
+ return;
218
+ if (!dragging.moved)
219
+ svg.setPointerCapture?.(event.pointerId);
220
+ dragging.moved = true;
221
+ // Screen pixels are not user units once the viewBox has scaled the drawing.
222
+ const ratio = svg.getScreenCTM?.()?.a || 1;
223
+ panX = dragging.panX + dx / ratio;
224
+ panY = dragging.panY + dy / ratio;
225
+ applyView();
226
+ });
227
+ const endDrag = (event) => {
228
+ if (dragging?.moved)
229
+ svg.releasePointerCapture?.(event.pointerId);
230
+ // Cleared after the click event, so a pan does not also select what it started on.
231
+ setTimeout(() => {
232
+ dragging = null;
233
+ }, 0);
234
+ };
235
+ svg.addEventListener('pointerup', endDrag);
236
+ svg.addEventListener('pointercancel', endDrag);
237
+ svg.addEventListener('dblclick', () => {
238
+ scale = 1;
239
+ panX = 0;
240
+ panY = 0;
241
+ applyView();
242
+ });
243
+ /** Order this frame's changes into steps, following the edges that are actually drawn. */
244
+ function stageWave(graph, now) {
245
+ const fresh = [];
246
+ for (const node of graph.values()) {
247
+ if (!node.lastPulse || stagedFor.get(node.id) === node.lastPulse)
248
+ continue;
249
+ stagedFor.set(node.id, node.lastPulse);
250
+ if (nodeEls.has(node.id))
251
+ fresh.push(node.id);
252
+ }
253
+ if (!fresh.length)
254
+ return;
255
+ const changed = new Set(fresh);
256
+ // Only the edges between things that changed together: the rest of the graph is not
257
+ // part of this propagation.
258
+ const after = new Map();
259
+ for (const { a, b } of edgeEls) {
260
+ if (changed.has(a) && changed.has(b))
261
+ (after.get(a) ?? after.set(a, []).get(a)).push(b);
262
+ }
263
+ const fed = new Set([...after.values()].flat());
264
+ // Breadth-first from what nothing else in this change fed: those are the writes.
265
+ const step = new Map();
266
+ const queue = fresh
267
+ .filter((id) => !fed.has(id))
268
+ .map((id) => ({ id, depth: 0 }));
269
+ // A cycle, or a change with no clear source, still has to be shown.
270
+ if (!queue.length)
271
+ for (const id of fresh)
272
+ queue.push({ id, depth: 0 });
273
+ while (queue.length) {
274
+ const { id, depth } = queue.shift();
275
+ if (step.has(id))
276
+ continue;
277
+ step.set(id, depth);
278
+ for (const next of after.get(id) ?? [])
279
+ if (!step.has(next))
280
+ queue.push({ id: next, depth: depth + 1 });
281
+ }
282
+ for (const id of fresh)
283
+ staged.set(id, now + (step.get(id) ?? 0) * STEP);
284
+ for (const { a, b, el } of edgeEls) {
285
+ const from = step.get(a);
286
+ const to = step.get(b);
287
+ if (from === undefined || to === undefined || to <= from)
288
+ continue;
289
+ // Lit while the value is between the two, which is the gap the nodes leave.
290
+ edgeStaged.set(el, now + from * STEP + STEP * 0.35);
291
+ }
292
+ }
43
293
  function applySelected() {
44
294
  for (const [id, { g }] of nodeEls)
45
295
  g.classList.toggle('selected', id === selected);
46
296
  }
47
- function rebuild(graph) {
48
- computeDepths(graph);
297
+ /**
298
+ * What this node is part of: everything upstream of it and everything downstream, along
299
+ * the direction values actually travel.
300
+ *
301
+ * Ownership is one hop only. A component owns most of a page, and following it would
302
+ * light the whole graph up, which is the same as highlighting nothing.
303
+ */
304
+ function related(graph, from) {
305
+ const feeds = new Map();
306
+ const fedBy = new Map();
307
+ const link = (source, target) => {
308
+ (feeds.get(source) ?? feeds.set(source, new Set()).get(source)).add(target);
309
+ (fedBy.get(target) ?? fedBy.set(target, new Set()).get(target)).add(source);
310
+ };
311
+ for (const node of graph.values()) {
312
+ for (const dep of node.deps)
313
+ link(dep, node.id);
314
+ for (const written of node.writes)
315
+ link(node.id, written);
316
+ }
317
+ const out = new Set([from]);
318
+ const walk = (edges) => {
319
+ const queue = [from];
320
+ while (queue.length) {
321
+ for (const next of edges.get(queue.pop()) ?? []) {
322
+ if (!out.has(next)) {
323
+ out.add(next);
324
+ queue.push(next);
325
+ }
326
+ }
327
+ }
328
+ };
329
+ walk(feeds);
330
+ walk(fedBy);
331
+ const node = graph.get(from);
332
+ if (node?.createdIn !== undefined)
333
+ out.add(node.createdIn);
334
+ for (const other of graph.values())
335
+ if (other.createdIn === from)
336
+ out.add(other.id);
337
+ return out;
338
+ }
339
+ /** Light this set and dim the rest; no set puts everything back. */
340
+ function light(set) {
341
+ if (!set) {
342
+ svg.classList.remove('fx-hovering');
343
+ for (const { g } of nodeEls.values())
344
+ g.classList.remove('hot');
345
+ for (const { el: path } of edgeEls)
346
+ path.classList.remove('hot');
347
+ return;
348
+ }
349
+ svg.classList.add('fx-hovering');
350
+ for (const [nodeId, { g }] of nodeEls)
351
+ g.classList.toggle('hot', set.has(nodeId));
352
+ for (const { a, b, el: path } of edgeEls)
353
+ path.classList.toggle('hot', set.has(a) && set.has(b));
354
+ }
355
+ function hover(graph, id) {
356
+ // Back to the focus, not to nothing: leaving a node must not undo a picked commit.
357
+ if (id === null)
358
+ return light(focused);
359
+ // Through the fold: a memo reading `cart.items` is reading the card marked `cart`.
360
+ const set = new Set([...related(graph, id)].map(standIn));
361
+ light(set);
362
+ // Hovering a node that is folded away is hovering the card it is drawn as.
363
+ nodeEls.get(standIn(id))?.g.classList.add('hot');
364
+ }
365
+ /**
366
+ * The graph as it is drawn: the nodes the application made, plus one card standing for
367
+ * everything a dependency made on its own account.
368
+ *
369
+ * A copy, not the host's map. What is drawn is a view of the graph, and the graph belongs
370
+ * to whoever is filling it.
371
+ */
372
+ /**
373
+ * The document and the handlers, as cards.
374
+ *
375
+ * A binding is not a node, which is why they are plumbing. But "where does this end up" is
376
+ * the one thing the graph cannot otherwise answer, so they can be turned on.
377
+ */
378
+ function withEdges(graph) {
379
+ if (!showBindings)
380
+ return graph;
381
+ const shown = new Map(graph);
382
+ let added = false;
383
+ for (const node of graph.values()) {
384
+ node.targets?.forEach((target) => {
385
+ const at = target.source;
386
+ const place = `${target.name ?? ''}|${target.element}|${at?.file ?? ''}:${at?.line ?? ''}:${at?.column ?? ''}`;
387
+ const id = bindingId(place);
388
+ const card = shown.get(id) ?? {
389
+ id,
390
+ label: target.name ? `${target.name} of ${target.element}` : target.element,
391
+ kind: 'binding',
392
+ // The card reads the signal, so the arrow runs the way the value does.
393
+ deps: new Set(),
394
+ writes: new Set(),
395
+ runs: 0,
396
+ lastPulse: 0,
397
+ ...(at ? { source: at } : {}),
398
+ };
399
+ card.deps.add(node.id);
400
+ // Whichever of its readers ran last is when this place last changed.
401
+ card.runs = Math.max(card.runs, node.runs);
402
+ card.lastPulse = Math.max(card.lastPulse, node.lastPulse);
403
+ shown.set(id, card);
404
+ added = true;
405
+ });
406
+ node.setAt?.forEach((at, i) => {
407
+ const id = setterId(node.id, i);
408
+ shown.set(id, {
409
+ id,
410
+ // Two handlers in one component can share an original position; the compiled line
411
+ // is what separates the cards.
412
+ label: `set at ${(at.file ?? '').split('/').pop() ?? '?'}:${at.line ?? ''}${at.compiled && at.compiled.line !== at.line ? `→${at.compiled.line}` : ''}`,
413
+ kind: 'setter',
414
+ deps: new Set(),
415
+ // A handler writes the signal, so it gets the write edge a computation would.
416
+ writes: new Set([node.id]),
417
+ runs: node.runs,
418
+ // The write edge runs setter → signal, so the wave starts here.
419
+ lastPulse: node.lastPulse,
420
+ source: at,
421
+ });
422
+ added = true;
423
+ });
424
+ }
425
+ return added ? shown : graph;
426
+ }
427
+ /**
428
+ * A node the author did not write: the memo the compiler wraps a component in, the
429
+ * signals a resource keeps, `Show.when`, `mapArray`, `row index`. All of it is real and
430
+ * none of it is anything the author can go and edit.
431
+ *
432
+ * Control flow is excluded on purpose. `Show` and `Switch` are tags in the source, so
433
+ * they stay drawn as themselves.
434
+ *
435
+ * A tag is harder: `LazyWrapper` is made by `lazy()` and is nobody's to edit, while the
436
+ * routed component it loads is created in the same file and is entirely the author's. The
437
+ * difference is whether the project holds a line for it, not where it was instantiated.
438
+ */
439
+ const inProject = (at) => !!at?.file && at.external !== true;
440
+ const machinery = (n) => {
441
+ if (n.authored !== false)
442
+ return false;
443
+ if (n.kind === 'state' || n.kind === 'memo' || n.kind === 'effect' || n.kind === 'context') {
444
+ return true;
445
+ }
446
+ if (n.kind === 'component' || n.kind === 'provider') {
447
+ return !inProject(n.declaredAt) && !inProject(n.source) && !inProject(n.usedAt);
448
+ }
449
+ return false;
450
+ };
451
+ /**
452
+ * Cards a view makes rather than nodes the application has. They follow their own toggles
453
+ * and their owner's visibility, so a kind filter does not reach them.
454
+ */
455
+ const VIEW_CARD = new Set(['group', 'binding', 'setter']);
456
+ const kindKey = () => (kinds ? [...kinds].sort().join('+') : '*');
457
+ /**
458
+ * Keep only the kinds asked for. `createdIn` is re-pointed at the nearest ancestor that
459
+ * survived, so hiding the memos around a component does not orphan what they made.
460
+ */
461
+ function onlyKinds(graph) {
462
+ if (!kinds)
463
+ return graph;
464
+ const keep = (n) => VIEW_CARD.has(n.kind) || kinds.has(n.kind);
465
+ const out = new Map();
466
+ for (const [id, node] of graph) {
467
+ if (!keep(node))
468
+ continue;
469
+ let owner = node.createdIn;
470
+ const seen = new Set();
471
+ while (owner !== undefined && !seen.has(owner)) {
472
+ seen.add(owner);
473
+ const up = graph.get(owner);
474
+ if (up && keep(up))
475
+ break;
476
+ owner = up?.createdIn;
477
+ }
478
+ out.set(id, owner === node.createdIn ? node : { ...node, createdIn: owner });
479
+ }
480
+ return out;
481
+ }
482
+ function grouped(graph) {
483
+ if (!groupExternal)
484
+ return graph;
485
+ const outsiders = [...graph.values()].filter(
486
+ // A card carries the source of whatever made it, often a package. It already hangs off
487
+ // its signal and must not be moved under the group.
488
+ (n) => (n.source?.external && n.createdIn === undefined && n.kind !== 'binding' && n.kind !== 'setter') ||
489
+ machinery(n));
490
+ if (!outsiders.length)
491
+ return graph;
492
+ const shown = new Map(graph);
493
+ shown.set(GROUP_ID, {
494
+ id: GROUP_ID,
495
+ label: 'framework',
496
+ kind: 'group',
497
+ deps: new Set(),
498
+ writes: new Set(),
499
+ runs: 0,
500
+ lastPulse: 0,
501
+ });
502
+ // Hung off the card, which is what the existing folding already knows how to draw.
503
+ const moved = new Set(outsiders.map((n) => n.id));
504
+ for (const node of outsiders)
505
+ shown.set(node.id, { ...node, createdIn: GROUP_ID });
506
+ // What the machinery made is not machinery. A `Show` created inside the memo the
507
+ // compiler wrapped it in would otherwise fold away with it, and the tag the author
508
+ // wrote would be missing from the graph.
509
+ for (const [id, node] of graph) {
510
+ if (moved.has(id) || node.createdIn === undefined)
511
+ continue;
512
+ let owner = node.createdIn;
513
+ const seen = new Set();
514
+ while (owner !== undefined && moved.has(owner) && !seen.has(owner)) {
515
+ seen.add(owner);
516
+ owner = graph.get(owner)?.createdIn;
517
+ }
518
+ if (owner !== node.createdIn)
519
+ shown.set(id, { ...node, createdIn: owner });
520
+ }
521
+ return shown;
522
+ }
523
+ function rebuild(graph, folded) {
49
524
  edgeLayer.remove();
50
525
  nodeLayer.remove();
51
526
  nodeEls.clear();
527
+ edgeEls.length = 0;
528
+ edgeStaged.clear();
52
529
  edgeLayer = el('g', { class: 'fx-edges' });
53
530
  nodeLayer = el('g', { class: 'fx-nodes' });
531
+ // A node inside a folded store is drawn as the store: its edges become the store's, so
532
+ // a memo reading `cart.total` still visibly reads something.
533
+ standIn = (id) => {
534
+ let at = id;
535
+ let outermost = id;
536
+ const seen = new Set();
537
+ while (at !== undefined && !seen.has(at)) {
538
+ seen.add(at);
539
+ if (folded.has(at) && at !== id)
540
+ outermost = at;
541
+ at = graph.get(at)?.createdIn;
542
+ }
543
+ return outermost;
544
+ };
545
+ const visible = new Map();
546
+ const hiddenCount = new Map();
547
+ for (const node of graph.values()) {
548
+ const shown = standIn(node.id);
549
+ if (shown === node.id)
550
+ visible.set(node.id, node);
551
+ else
552
+ hiddenCount.set(shown, (hiddenCount.get(shown) ?? 0) + 1);
553
+ }
554
+ /** Read edges after folding, deduplicated: several keys of one store are one edge. */
555
+ const readsOf = (node) => {
556
+ const out = new Set();
557
+ for (const dep of node.deps) {
558
+ const from = standIn(dep);
559
+ if (from !== node.id && visible.has(from))
560
+ out.add(from);
561
+ }
562
+ if (node.createdIn !== undefined) {
563
+ const owner = standIn(node.createdIn);
564
+ if (owner !== node.id && visible.has(owner))
565
+ out.add(owner);
566
+ }
567
+ return out;
568
+ };
569
+ computeDepths(visible, readsOf);
54
570
  const byDepth = new Map();
55
571
  let maxDepth = 0;
56
- for (const node of graph.values()) {
572
+ for (const node of visible.values()) {
57
573
  const d = node.depth ?? 0;
58
574
  (byDepth.get(d) ?? byDepth.set(d, []).get(d)).push(node);
59
575
  maxDepth = Math.max(maxDepth, d);
60
576
  }
577
+ // Page order inside a band, for the same reason the tree uses it.
578
+ for (const [d, nodes] of byDepth)
579
+ byDepth.set(d, inPageOrder(nodes));
61
580
  let maxRows = 0;
62
581
  for (const nodes of byDepth.values())
63
582
  maxRows = Math.max(maxRows, nodes.length);
583
+ // Depth runs along one axis and the nodes at a depth along the other; which is which is
584
+ // the only difference between the two layouts.
585
+ const down = direction === 'vertical';
586
+ const STEP_ACROSS = down ? COL + 24 : ROW;
64
587
  const pos = new Map();
65
588
  for (const [d, nodes] of byDepth) {
66
- const top = PAD + (maxRows * ROW - nodes.length * ROW) / 2;
67
- nodes.forEach((node, i) => pos.set(node.id, { x: PAD + d * COL, y: top + i * ROW }));
589
+ const offset = PAD + (maxRows * STEP_ACROSS - nodes.length * STEP_ACROSS) / 2;
590
+ nodes.forEach((node, i) => pos.set(node.id, down
591
+ ? { x: offset + i * STEP_ACROSS, y: PAD + d * (NH + 46) }
592
+ : { x: PAD + d * COL, y: offset + i * STEP_ACROSS }));
68
593
  }
69
594
  const curve = (from, to) => {
595
+ if (down) {
596
+ const x1 = from.x + NW / 2;
597
+ const y1 = from.y + NH;
598
+ const x2 = to.x + NW / 2;
599
+ const y2 = to.y;
600
+ const my = (y1 + y2) / 2;
601
+ return `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`;
602
+ }
70
603
  const x1 = from.x + NW;
71
604
  const y1 = from.y + NH / 2;
72
605
  const x2 = to.x;
@@ -74,92 +607,375 @@ export function createGraphView(svg, options = {}) {
74
607
  const mx = (x1 + x2) / 2;
75
608
  return `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`;
76
609
  };
77
- for (const node of graph.values()) {
610
+ const addEdge = (a, b, kind, d) => {
611
+ const path = el('path', { class: kind, d });
612
+ edgeLayer.appendChild(path);
613
+ edgeEls.push({ a, b, el: path });
614
+ };
615
+ for (const node of visible.values()) {
78
616
  const to = pos.get(node.id);
79
- for (const dep of node.deps) {
617
+ const owner = node.createdIn !== undefined ? standIn(node.createdIn) : undefined;
618
+ for (const dep of readsOf(node)) {
80
619
  const from = pos.get(dep);
81
- if (from)
82
- edgeLayer.appendChild(el('path', { class: 'fx-edge', d: curve(from, to) }));
620
+ // The one that created it is drawn as what it is, unless it is also read.
621
+ const own = dep === owner && !node.deps.has(dep);
622
+ addEdge(dep, node.id, own ? 'fx-oedge' : 'fx-edge', curve(from, to));
83
623
  }
84
624
  // write edges: this node → the signal it sets (reverse direction, dashed)
85
625
  for (const w of node.writes) {
86
- const target = pos.get(w);
87
- if (target)
88
- edgeLayer.appendChild(el('path', { class: 'fx-wedge', d: curve(to, target) }));
626
+ const target = pos.get(standIn(w));
627
+ if (target && standIn(w) !== node.id)
628
+ addEdge(node.id, standIn(w), 'fx-wedge', curve(to, target));
89
629
  }
90
630
  }
91
- for (const node of graph.values()) {
631
+ for (const node of visible.values()) {
92
632
  const p = pos.get(node.id);
93
- const g = el('g', { class: `fx-node ${KIND_CLASS[node.kind]}`, transform: `translate(${p.x} ${p.y})`, 'data-id': node.id });
633
+ // A node a dependency made is drawn back, so what the application itself created reads
634
+ // first. Only ever set when the compiler was told to mark dependencies.
635
+ const from = node.source?.external ? ' n-external' : '';
636
+ const g = el('g', { class: `fx-node ${KIND_CLASS[node.kind]}${from}`, transform: `translate(${p.x} ${p.y})`, 'data-id': node.id });
94
637
  g.appendChild(el('rect', { class: 'fx-box', width: NW, height: NH, rx: 12 }));
95
638
  const kind = el('text', { class: 'fx-kind', x: 12, y: 20 });
96
639
  kind.textContent = KIND_GLYPH[node.kind];
97
640
  const label = el('text', { class: 'fx-label', x: 12, y: 38 });
641
+ label.setAttribute('clip-path', `url(#${labelClip})`);
98
642
  label.textContent = node.label;
99
- const value = el('text', { class: 'fx-value', x: NW - 12, y: 38, 'text-anchor': 'end' });
100
- value.textContent = node.value ?? '';
643
+ // Left-anchored and truncated rather than clipped from the right: an end-anchored
644
+ // string is cut at its start, so a store's value showed the tail of a json blob,
645
+ // `unter...` or `"App"}`, instead of what it is.
646
+ const value = el('text', { class: 'fx-value', x: 8 + LABEL_W + 6, y: 38 });
647
+ value.setAttribute('clip-path', `url(#${valueClip})`);
648
+ value.textContent = fit(node.value);
649
+ // The card has room for a dozen characters of a value that can be an object. The rest
650
+ // is here, on hover, rather than nowhere.
651
+ const full = node.detail ?? node.value;
652
+ if (full)
653
+ value.appendChild(el('title')).textContent = full;
101
654
  g.append(kind, label, value);
655
+ // A routed component arrives with its chunk, so it is missing from the graph until the
656
+ // navigation that fetches it. Saying so beats looking like it was never there.
657
+ if (node.lazy) {
658
+ const chunk = el('text', { class: 'fx-lazy', x: 12, y: 52 });
659
+ chunk.textContent = 'lazy';
660
+ chunk.appendChild(el('title')).textContent = 'loaded as its own chunk';
661
+ g.appendChild(chunk);
662
+ }
663
+ // What a plugin wants said about this node. Right-aligned so it does not collide with
664
+ // the lazy mark on the same line.
665
+ const mark = pluginBadge(node);
666
+ if (mark) {
667
+ const badge = el('text', { class: 'fx-plugin', x: NW - 12, y: 52, 'text-anchor': 'end' });
668
+ badge.textContent = fit(mark);
669
+ badge.appendChild(el('title')).textContent = mark;
670
+ g.appendChild(badge);
671
+ }
672
+ const summary = el('title');
673
+ summary.textContent = `${node.kind} ${node.label}${node.lazy ? ' (lazy)' : ''}${full ? ` = ${full}` : ''}${placeOf(node)
674
+ ? `\n${placeOf(node).file ?? ''}:${placeOf(node).line ?? ''}:${placeOf(node).column ?? ''}`
675
+ : ''}`;
676
+ g.appendChild(summary);
677
+ // Always made, filled per frame. A source can arrive after the card does, and the layout
678
+ // signature does not depend on it, so waiting for a rebuild meant it never showed.
679
+ // Empty text draws nothing, so an unmarked node still reads as unmarked.
680
+ const source = el('text', { class: 'fx-source', x: 12, y: 52 });
681
+ source.setAttribute('clip-path', `url(#${sourceClip})`);
682
+ const sourceTitle = el('title');
683
+ source.appendChild(sourceTitle);
684
+ g.appendChild(source);
685
+ // Anything that made other nodes can hide them. A store makes one per key it is read
686
+ // by, which is most of what fills a graph up.
687
+ const inside = hiddenCount.get(node.id) ?? 0;
688
+ const owns = inside > 0 || [...graph.values()].some((n) => n.createdIn === node.id);
689
+ if (owns) {
690
+ const badge = el('text', { class: 'fx-fold', x: NW - 12, y: 20, 'text-anchor': 'end' });
691
+ badge.textContent = inside > 0 ? `▸ ${inside}` : '▾';
692
+ badge.appendChild(el('title')).textContent = inside > 0 ? `show ${inside} inside` : 'fold what is inside';
693
+ badge.addEventListener('click', (event) => {
694
+ event.stopPropagation();
695
+ toggleFold(node.id);
696
+ });
697
+ g.appendChild(badge);
698
+ }
102
699
  g.addEventListener('click', () => {
700
+ // A pan that ended on a node is not a click on it.
701
+ if (dragging?.moved)
702
+ return;
103
703
  selected = node.id;
104
704
  applySelected();
105
705
  options.onSelect?.(node);
106
706
  });
707
+ g.addEventListener('dblclick', (event) => {
708
+ if (!options.onReveal)
709
+ return;
710
+ // The svg's own dblclick resets the zoom, which is not what a double-click on a
711
+ // card is asking for.
712
+ event.stopPropagation();
713
+ options.onReveal(node);
714
+ });
715
+ g.addEventListener('mouseenter', () => hover(graph, node.id));
716
+ g.addEventListener('mouseleave', () => hover(graph, null));
107
717
  g.style.cursor = 'pointer';
108
718
  nodeLayer.appendChild(g);
109
- nodeEls.set(node.id, { g, value });
719
+ nodeEls.set(node.id, { g, value, source, sourceTitle });
110
720
  }
111
- svg.append(edgeLayer, nodeLayer);
112
- svg.setAttribute('viewBox', `0 0 ${Math.max(PAD * 2 + maxDepth * COL + NW, 400)} ${Math.max(PAD * 2 + maxRows * ROW, 240)}`);
721
+ viewport.append(edgeLayer, nodeLayer);
722
+ const width = down ? PAD * 2 + maxRows * STEP_ACROSS + NW : PAD * 2 + maxDepth * COL + NW;
723
+ const height = down ? PAD * 2 + (maxDepth + 1) * (NH + 46) : PAD * 2 + maxRows * ROW;
724
+ svg.setAttribute('viewBox', `0 0 ${Math.max(width, 400)} ${Math.max(height, 240)}`);
113
725
  applySelected();
114
726
  }
727
+ let lastGraph = new Map();
728
+ /**
729
+ * The graph as drawn, including the cards the view invents.
730
+ *
731
+ * A host holds the real graph and cannot resolve a binding card by id, so selecting one
732
+ * left the inspector empty. This is what it should look the selection up in.
733
+ */
734
+ let lastShown = new Map();
735
+ /**
736
+ * What is folded right now: a store and anything inside it, minus what the viewer opened.
737
+ *
738
+ * Nesting matters: a cart holds items, an item holds its properties. Opening the store
739
+ * shows the items as their own cards, each still folded over its own keys, rather than
740
+ * every signal in the tree at once.
741
+ */
742
+ function foldedIn(graph) {
743
+ const hasChildren = new Set();
744
+ for (const node of graph.values())
745
+ if (node.createdIn !== undefined)
746
+ hasChildren.add(node.createdIn);
747
+ const inStore = (id) => {
748
+ const seen = new Set();
749
+ let at = graph.get(id)?.createdIn;
750
+ while (at !== undefined && !seen.has(at)) {
751
+ seen.add(at);
752
+ const up = graph.get(at);
753
+ if (up?.kind === 'store')
754
+ return true;
755
+ at = up?.createdIn;
756
+ }
757
+ return false;
758
+ };
759
+ const folded = new Set();
760
+ for (const node of graph.values()) {
761
+ if (!hasChildren.has(node.id))
762
+ continue;
763
+ const byDefault = (node.kind === 'group' || (foldStores && (node.kind === 'store' || inStore(node.id)))) &&
764
+ !opened.has(node.id);
765
+ if (byDefault || closed.has(node.id))
766
+ folded.add(node.id);
767
+ }
768
+ return folded;
769
+ }
770
+ function toggleFold(id) {
771
+ // The grouped view, not the host's graph: the card standing for the packages only exists
772
+ // in the former, so asking the latter says it is not folded and the toggle does nothing.
773
+ const folded = foldedIn(grouped(lastGraph)).has(id);
774
+ if (folded) {
775
+ opened.add(id);
776
+ closed.delete(id);
777
+ }
778
+ else {
779
+ closed.add(id);
780
+ opened.delete(id);
781
+ }
782
+ signature = '';
783
+ // What is on screen changed shape, so whatever the view was zoomed into is gone. Back to
784
+ // the whole graph in frame rather than a viewport pointing at nothing.
785
+ scale = 1;
786
+ panX = 0;
787
+ panY = 0;
788
+ applyView();
789
+ return !folded;
790
+ }
115
791
  return {
116
- update(graph, now = performance.now()) {
117
- let sig = '';
118
- for (const n of graph.values())
119
- sig += `${n.id}:${n.kind}:${[...n.deps].sort().join(',')}>${[...n.writes].sort().join(',')};`;
792
+ // Date.now, matching what the reactor stamps on a node. See instrument.ts.
793
+ update(graph, now = Date.now()) {
794
+ lastGraph = graph;
795
+ // Bindings first: a card hung off a signal is not an outsider.
796
+ const shown = grouped(withEdges(onlyKinds(graph)));
797
+ lastShown = shown;
798
+ const folded = foldedIn(shown);
799
+ let sig = `${[...folded].sort().join(',')}|${groupExternal}|${direction}|${kindKey()}|`;
800
+ for (const n of shown.values())
801
+ sig += `${n.id}:${n.kind}:${[...n.deps].sort().join(',')}>${[...n.writes].sort().join(',')}^${n.createdIn ?? ''};`;
120
802
  if (sig !== signature) {
121
803
  signature = sig;
122
- rebuild(graph);
804
+ rebuild(shown, folded);
805
+ // The elements are new, so what was lit is not.
806
+ if (focused)
807
+ light(focused);
123
808
  }
124
- for (const node of graph.values()) {
809
+ stageWave(shown, now);
810
+ for (const node of shown.values()) {
125
811
  const eles = nodeEls.get(node.id);
126
812
  if (!eles)
127
813
  continue;
128
- if (node.value !== undefined && eles.value.textContent !== node.value)
129
- eles.value.textContent = node.value;
130
- const age = now - node.lastPulse;
131
- eles.g.style.setProperty('--pulse', (age < 600 ? 1 - age / 600 : 0).toFixed(3));
814
+ const next = fit(node.value);
815
+ if (eles.value.textContent !== next)
816
+ eles.value.textContent = next;
817
+ const place = placeOf(node);
818
+ const where = place ? shortSource(place) : '';
819
+ if (eles.source.textContent !== where) {
820
+ eles.source.textContent = where;
821
+ // The card shows the file name; the tooltip has room for where it sits, which for
822
+ // a node the framework made is a path on the machine that ran it.
823
+ eles.sourceTitle.textContent = place
824
+ ? `${place.file ? shortenPath(place.file, options.root) : ''}:${place.line ?? ''}:${place.column ?? ''}`
825
+ : '';
826
+ }
827
+ // Nothing before its turn: a node three steps down the chain sits dark until the
828
+ // change reaches it.
829
+ const age = now - (staged.get(node.id) ?? node.lastPulse);
830
+ eles.g.style.setProperty('--pulse', (age >= 0 && age < FLASH ? 1 - age / FLASH : 0).toFixed(3));
831
+ }
832
+ for (const { el } of edgeEls) {
833
+ const at = edgeStaged.get(el);
834
+ const age = at === undefined ? Infinity : now - at;
835
+ el.style.setProperty('--pulse', (age >= 0 && age < EDGE_FLASH ? 1 - age / EDGE_FLASH : 0).toFixed(3));
132
836
  }
133
837
  },
134
838
  clear() {
135
839
  signature = '';
136
840
  selected = null;
841
+ focused = null;
842
+ svg.classList.remove('fx-hovering');
137
843
  edgeLayer.replaceChildren();
138
844
  nodeLayer.replaceChildren();
139
845
  nodeEls.clear();
846
+ edgeEls.length = 0;
847
+ staged.clear();
848
+ stagedFor.clear();
849
+ edgeStaged.clear();
140
850
  options.onSelect?.(null);
141
851
  },
142
852
  select(id) {
143
853
  selected = id;
144
854
  applySelected();
145
855
  },
856
+ zoomBy(factor) {
857
+ const box = svg.viewBox?.baseVal;
858
+ zoomAt(factor, { x: (box?.width ?? 400) / 2, y: (box?.height ?? 240) / 2 });
859
+ },
860
+ resetView() {
861
+ scale = 1;
862
+ panX = 0;
863
+ panY = 0;
864
+ applyView();
865
+ },
866
+ toggle: toggleFold,
867
+ setGroupExternal(next) {
868
+ if (groupExternal === next)
869
+ return;
870
+ groupExternal = next;
871
+ signature = '';
872
+ scale = 1;
873
+ panX = 0;
874
+ panY = 0;
875
+ applyView();
876
+ },
877
+ get groupExternal() {
878
+ return groupExternal;
879
+ },
880
+ setShowBindings(next) {
881
+ if (showBindings === next)
882
+ return;
883
+ showBindings = next;
884
+ // The topology changed, so the layout is worked out again.
885
+ signature = '';
886
+ },
887
+ get showBindings() {
888
+ return showBindings;
889
+ },
890
+ setKinds(next) {
891
+ const key = next ? [...next].sort().join('+') : '*';
892
+ if (key === kindKey())
893
+ return;
894
+ kinds = next ? new Set(next) : null;
895
+ // Fewer nodes is a different graph: it is laid out again rather than hidden in place.
896
+ signature = '';
897
+ },
898
+ get kinds() {
899
+ return kinds;
900
+ },
901
+ get shown() {
902
+ return lastShown;
903
+ },
904
+ focus(ids) {
905
+ // Through the fold, like hovering.
906
+ focused = ids?.length ? new Set(ids.map(standIn)) : null;
907
+ light(focused);
908
+ },
909
+ setDirection(next) {
910
+ if (direction === next)
911
+ return;
912
+ direction = next;
913
+ signature = '';
914
+ // The drawing is a different shape now, so the viewport starts over.
915
+ scale = 1;
916
+ panX = 0;
917
+ panY = 0;
918
+ applyView();
919
+ },
920
+ get direction() {
921
+ return direction;
922
+ },
146
923
  };
147
924
  }
148
- // default theme; override the fx-* classes to restyle
925
+ /**
926
+ * Default theme.
927
+ *
928
+ * Every colour reads a custom property first, so a host that defines them, a page with a
929
+ * light mode say, restyles the graph without replacing this. The fallbacks are the dark
930
+ * values it always had. The four node hues stay as they are: here colour is the distinction
931
+ * being drawn, not decoration.
932
+ */
149
933
  export const DEVTOOLS_CSS = `
150
- .fx-edge { fill: none; stroke: #38415a; stroke-width: 1.6; opacity: .75; }
151
- .fx-wedge { fill: none; stroke: #fb7185; stroke-width: 1.4; stroke-dasharray: 3 4; opacity: .65; }
152
- .fx-node { color: #5eead4; filter: drop-shadow(0 0 calc(var(--pulse,0) * 13px) var(--glow, #5eead4)); }
153
- .fx-node.n-state { --glow: #34d399; }
154
- .fx-node.n-memo { --glow: #a78bfa; }
155
- .fx-node.n-effect { --glow: #fbbf24; }
156
- .fx-node.n-store { --glow: #38bdf8; }
157
- .fx-node.n-resource { --glow: #fb7185; }
158
- .fx-box { fill: #131722; stroke: var(--glow, #5eead4); stroke-width: 1.4; stroke-opacity: calc(.5 + var(--pulse,0) * .5); }
159
- .fx-node.selected .fx-box { fill: #1a2230; stroke-width: 2.4; }
934
+ /* An edge carries a pulse while the change is crossing it, so a propagation reads as one
935
+ thing moving rather than everything lighting at once. */
936
+ .fx-edge {
937
+ fill: none;
938
+ stroke: var(--fx-edge, #38415a);
939
+ stroke-width: calc(1.6px + var(--pulse, 0) * 1.7px);
940
+ opacity: calc(.75 + var(--pulse, 0) * .25);
941
+ filter: drop-shadow(0 0 calc(var(--pulse, 0) * 5px) var(--fx-accent, #5eead4));
942
+ }
943
+ .fx-oedge { fill: none; stroke: var(--fx-own, #64748b); stroke-width: 1.2; stroke-dasharray: 1 4; stroke-linecap: round; opacity: .55; }
944
+ .fx-wedge {
945
+ fill: none;
946
+ stroke: var(--fx-write, #fb7185);
947
+ stroke-width: calc(1.4px + var(--pulse, 0) * 1.6px);
948
+ stroke-dasharray: 3 4;
949
+ opacity: calc(.65 + var(--pulse, 0) * .35);
950
+ }
951
+ .fx-node { color: var(--fx-accent, #5eead4); filter: drop-shadow(0 0 calc(var(--pulse,0) * 13px) var(--glow, #5eead4)); }
952
+ ${kindRules((k) => `.fx-node.n-${k}`, '--glow')}
953
+ .fx-node.n-group { opacity: .8; }
954
+ .fx-node.n-group .fx-box { stroke-dasharray: 4 3; }
955
+ /* Neither is a node: dashed and dimmer, so the reactive shape reads first. */
956
+ .fx-node.n-binding { opacity: .72; }
957
+ .fx-node.n-setter { opacity: .72; }
958
+ .fx-node.n-binding .fx-box, .fx-node.n-setter .fx-box { stroke-dasharray: 3 3; }
959
+ .fx-node.n-external { opacity: .55; }
960
+ .fx-node.n-external .fx-box { stroke-dasharray: 3 2; }
961
+ .fx-box { fill: var(--fx-node-bg, #131722); stroke: var(--glow, #5eead4); stroke-width: 1.4; stroke-opacity: calc(.5 + var(--pulse,0) * .5); }
962
+ .fx-node.selected .fx-box { fill: var(--fx-node-bg-selected, #1a2230); stroke-width: 2.4; }
160
963
  .fx-kind { fill: var(--glow, #5eead4); font-family: ui-monospace, monospace; font-size: 9.5px; opacity: .85; }
161
- .fx-label { fill: #e7e9ee; font-family: ui-sans-serif, system-ui, sans-serif; font-size: 13px; font-weight: 600; }
162
- .fx-value { fill: #8a90a2; font-family: ui-monospace, monospace; font-size: 12px; }
163
- .fx-node.n-effect .fx-value { fill: #fbbf24; }
164
- .fx-node.n-resource .fx-value { fill: #fb7185; }
964
+ .fx-label { fill: var(--fx-label, #e7e9ee); font-family: ui-sans-serif, system-ui, sans-serif; font-size: 13px; font-weight: 600; }
965
+ .fx-value { fill: var(--fx-muted, #8a90a2); font-family: ui-monospace, monospace; font-size: 12px; }
966
+ .fx-source { fill: var(--fx-muted, #8a90a2); font-family: ui-monospace, monospace; font-size: 9.5px; opacity: .75; }
967
+ .fx-plugin { fill: var(--fx-accent, #5eead4); font-family: ui-monospace, monospace; font-size: 9px; opacity: .9; }
968
+ .fx-lazy { fill: var(--fx-router, #7dd3fc); font-family: ui-monospace, monospace; font-size: 9px; letter-spacing: .04em; opacity: .9; }
969
+ .fx-fold { fill: var(--glow, #5eead4); font-family: ui-monospace, monospace; font-size: 10px; cursor: pointer; opacity: .8; }
970
+ .fx-fold:hover { opacity: 1; }
971
+ .fx-node.n-effect .fx-value { fill: var(--effect, #fbbf24); }
972
+ .fx-node.n-resource .fx-value { fill: var(--fx-write, #fb7185); }
973
+ /* Hovering one node is a question about that node: everything it cannot reach steps back. */
974
+ .fx-hovering .fx-node { opacity: .2; transition: opacity .12s ease; }
975
+ .fx-hovering .fx-node.hot { opacity: 1; }
976
+ .fx-hovering .fx-node.n-external.hot { opacity: .7; }
977
+ .fx-hovering .fx-edge, .fx-hovering .fx-oedge, .fx-hovering .fx-wedge { opacity: .08; }
978
+ .fx-hovering .fx-edge.hot { opacity: 1; stroke: var(--fx-accent, #5eead4); }
979
+ .fx-hovering .fx-oedge.hot { opacity: .8; }
980
+ .fx-hovering .fx-wedge.hot { opacity: .9; }
165
981
  `;