@fluixi/devtools 0.2.0-alpha.2 → 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.
- package/README.md +80 -2
- package/dist/callsite.d.ts +22 -0
- package/dist/callsite.d.ts.map +1 -0
- package/dist/callsite.js +82 -0
- package/dist/change-view.d.ts +10 -0
- package/dist/change-view.d.ts.map +1 -0
- package/dist/change-view.js +102 -0
- package/dist/copy.d.ts +9 -0
- package/dist/copy.d.ts.map +1 -0
- package/dist/copy.js +52 -0
- package/dist/describe.d.ts +3 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +47 -0
- package/dist/detail-panel.d.ts +17 -0
- package/dist/detail-panel.d.ts.map +1 -0
- package/dist/detail-panel.js +217 -0
- package/dist/diff.d.ts +31 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +132 -0
- package/dist/dom.d.ts +81 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +269 -0
- package/dist/export.d.ts +55 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +90 -0
- package/dist/float.d.ts +54 -0
- package/dist/float.d.ts.map +1 -0
- package/dist/float.js +256 -0
- package/dist/format.d.ts +16 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +188 -0
- package/dist/graph-view.d.ts +79 -2
- package/dist/graph-view.d.ts.map +1 -1
- package/dist/graph-view.js +866 -50
- package/dist/highlight.d.ts +56 -0
- package/dist/highlight.d.ts.map +1 -0
- package/dist/highlight.js +154 -0
- package/dist/history.d.ts +59 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +85 -0
- package/dist/hook.d.ts +60 -0
- package/dist/hook.d.ts.map +1 -0
- package/dist/hook.js +142 -0
- package/dist/index.d.ts +44 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/inspector.d.ts +60 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +415 -0
- package/dist/instrument.d.ts +147 -5
- package/dist/instrument.d.ts.map +1 -1
- package/dist/instrument.js +164 -44
- package/dist/kind-filter.d.ts +39 -0
- package/dist/kind-filter.d.ts.map +1 -0
- package/dist/kind-filter.js +151 -0
- package/dist/menu.d.ts +37 -0
- package/dist/menu.d.ts.map +1 -0
- package/dist/menu.js +125 -0
- package/dist/observe.d.ts +80 -0
- package/dist/observe.d.ts.map +1 -0
- package/dist/observe.js +893 -0
- package/dist/palette.d.ts +11 -0
- package/dist/palette.d.ts.map +1 -0
- package/dist/palette.js +27 -0
- package/dist/path.d.ts +19 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +49 -0
- package/dist/plugin.d.ts +121 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +74 -0
- package/dist/plugins/context.d.ts +10 -0
- package/dist/plugins/context.d.ts.map +1 -0
- package/dist/plugins/context.js +80 -0
- package/dist/plugins/directives.d.ts +25 -0
- package/dist/plugins/directives.d.ts.map +1 -0
- package/dist/plugins/directives.js +78 -0
- package/dist/plugins/index.d.ts +7 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +9 -0
- package/dist/source.d.ts +72 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +153 -0
- package/dist/timeline.d.ts +30 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +271 -0
- package/dist/tree.d.ts +128 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +645 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/value-pane.d.ts +10 -0
- package/dist/value-pane.d.ts.map +1 -0
- package/dist/value-pane.js +61 -0
- package/dist/wire.d.ts +100 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +93 -0
- package/package.json +7 -2
package/dist/graph-view.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
67
|
-
nodes.forEach((node, i) => pos.set(node.id,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
631
|
+
for (const node of visible.values()) {
|
|
92
632
|
const p = pos.get(node.id);
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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(
|
|
804
|
+
rebuild(shown, folded);
|
|
805
|
+
// The elements are new, so what was lit is not.
|
|
806
|
+
if (focused)
|
|
807
|
+
light(focused);
|
|
123
808
|
}
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
.fx-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
.fx-
|
|
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-
|
|
164
|
-
.fx-
|
|
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
|
`;
|