@principal-ai/principal-view-react 0.16.57 → 0.16.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +379 -39
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +34 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +28 -0
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +52 -3
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +4 -3
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +181 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +533 -39
- package/src/subsystem/model.ts +37 -1
- package/src/subsystem/nodes.test.ts +56 -0
- package/src/subsystem/nodes.tsx +58 -4
- package/src/utils/elkLayout.ts +4 -3
|
@@ -12,12 +12,21 @@
|
|
|
12
12
|
* injection, onNodeClick) adapted to the subsystem model — read-only.
|
|
13
13
|
*/
|
|
14
14
|
import type { ReactNode } from 'react';
|
|
15
|
-
import { type SubsystemComponentEdge, type SubsystemComponent } from './model';
|
|
15
|
+
import { type SubsystemComponentEdge, type SubsystemComponent, type SubsystemThroughline } from './model';
|
|
16
16
|
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
17
17
|
import type { ComponentVerificationState } from './ComponentDeclaration';
|
|
18
18
|
export interface SubsystemComponentGraphProps {
|
|
19
19
|
components: SubsystemComponent[];
|
|
20
20
|
edges: SubsystemComponentEdge[];
|
|
21
|
+
/**
|
|
22
|
+
* Ordered execution stories over the graph's edges — one throughline per
|
|
23
|
+
* flow. When present the sidebar's bottom half offers a Files/Flows toggle:
|
|
24
|
+
* the flows panel lists each throughline's steps (`symbol` or `file:line`);
|
|
25
|
+
* clicking a flow row toggles its steps; clicking a step focuses that
|
|
26
|
+
* step's edge. Opened flows stay on the canvas (unselected ones dimmed);
|
|
27
|
+
* everything else is hidden.
|
|
28
|
+
*/
|
|
29
|
+
throughlines?: SubsystemThroughline[];
|
|
21
30
|
onSelect?: (componentId: string) => void;
|
|
22
31
|
/** Called when an edge is clicked (relationship / mechanism + refs seam). */
|
|
23
32
|
onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SubsystemComponentGraph.d.ts","sourceRoot":"","sources":["../../src/subsystem/SubsystemComponentGraph.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAiC,SAAS,EAAE,MAAM,OAAO,CAAC;AAqBtE,OAAO,EAIL,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,
|
|
1
|
+
{"version":3,"file":"SubsystemComponentGraph.d.ts","sourceRoot":"","sources":["../../src/subsystem/SubsystemComponentGraph.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAiC,SAAS,EAAE,MAAM,OAAO,CAAC;AAqBtE,OAAO,EAIL,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EAEvB,KAAK,oBAAoB,EAC1B,MAAM,SAAS,CAAC;AACjB,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAKjE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AAKzE,MAAM,WAAW,4BAA4B;IAC3C,UAAU,EAAE,kBAAkB,EAAE,CAAC;IACjC,KAAK,EAAE,sBAAsB,EAAE,CAAC;IAChC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,oBAAoB,EAAE,CAAC;IACtC,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,sBAAsB,KAAK,IAAI,CAAC;IACtD;kFAC8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,SAAS,CAAC;IAC1B,6DAA6D;IAC7D,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,gFAAgF;IAChF,uBAAuB,CAAC,EAAE,SAAS,CAAC;IACpC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,wBAAwB,KAAK,SAAS,CAAC;IAChF;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,kBAAkB,KAAK,SAAS,CAAC;IAC9D;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,0EAA0E;IAC1E,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,2DAA2D;IAC3D,qBAAqB,CAAC,EAAE,0BAA0B,GAAG,IAAI,CAAC;CAC3D;AA6uCD,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,4BAA4B,2CAsC1E"}
|
|
@@ -15,10 +15,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
15
15
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
16
|
import { ReactFlow, ReactFlowProvider, Background, BackgroundVariant, Controls, useReactFlow, useViewport, applyNodeChanges, } from '@xyflow/react';
|
|
17
17
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
18
|
-
import { Map as MapIcon } from 'lucide-react';
|
|
18
|
+
import { Map as MapIcon, X } from 'lucide-react';
|
|
19
19
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
20
20
|
import { buildSubsystemGraph, MECHANISM_COLOR, subsystemGraphLayoutKey, } from './model';
|
|
21
|
-
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
|
|
21
|
+
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
22
22
|
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
23
23
|
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
24
24
|
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
@@ -40,7 +40,7 @@ const DrawerContent = memo(function DrawerContent({ render, file, startLine, })
|
|
|
40
40
|
return null;
|
|
41
41
|
return _jsx(_Fragment, { children: render(file, startLine != null ? { startLine } : undefined) });
|
|
42
42
|
});
|
|
43
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }) {
|
|
43
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }) {
|
|
44
44
|
const { theme } = useTheme();
|
|
45
45
|
const { fitView } = useReactFlow();
|
|
46
46
|
const viewport = useViewport();
|
|
@@ -58,6 +58,16 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
58
58
|
const [legendOpen, setLegendOpen] = useState(false);
|
|
59
59
|
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
60
60
|
const [hoveredComponentId, setHoveredComponentId] = useState(null);
|
|
61
|
+
// Throughline focus — selected flow (or step) is full strength; other
|
|
62
|
+
// opened-flow members stay visible but dimmed; everything else is hidden.
|
|
63
|
+
const [focusedThroughlineId, setFocusedThroughlineId] = useState(null);
|
|
64
|
+
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
65
|
+
const [focusedStepIndex, setFocusedStepIndex] = useState(null);
|
|
66
|
+
// Sidebar bottom half: which panel is shown when throughlines exist.
|
|
67
|
+
const [sidebarView, setSidebarView] = useState(() => throughlines?.length ? 'flows' : 'files');
|
|
68
|
+
// Throughline flows the user has expanded (via the title row). Closed by
|
|
69
|
+
// default so a graph with several flows doesn't dump every step list at once.
|
|
70
|
+
const [expandedThroughlines, setExpandedThroughlines] = useState(new Set());
|
|
61
71
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
62
72
|
// closure over the effect deps) can toggle without a stale value.
|
|
63
73
|
const selectedRef = useRef(null);
|
|
@@ -150,6 +160,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
150
160
|
}
|
|
151
161
|
const src = edgeById.get(edgeId);
|
|
152
162
|
setSelectedEdgeId(edgeId);
|
|
163
|
+
// A direct edge selection on the canvas supersedes any throughline focus.
|
|
164
|
+
setFocusedThroughlineId(null);
|
|
165
|
+
setFocusedStepIndex(null);
|
|
153
166
|
if (src)
|
|
154
167
|
onEdgeSelect?.(src);
|
|
155
168
|
}, [edgeById, onEdgeSelect]);
|
|
@@ -166,6 +179,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
166
179
|
}
|
|
167
180
|
setSelected(comp);
|
|
168
181
|
setSelectedEdgeId(null);
|
|
182
|
+
setFocusedThroughlineId(null);
|
|
183
|
+
setFocusedStepIndex(null);
|
|
169
184
|
onSelect?.(id);
|
|
170
185
|
}
|
|
171
186
|
};
|
|
@@ -182,45 +197,151 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
182
197
|
const { nodes, edges: convertedEdges } = built;
|
|
183
198
|
const xyflowNodesBase = nodes;
|
|
184
199
|
const baseEdges = convertedEdges;
|
|
200
|
+
// When an edge is selected, dim every other edge + its label to focus it.
|
|
201
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
|
|
202
|
+
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
203
|
+
// without a stale closure value.
|
|
204
|
+
const selectedEdgeIdRef = useRef(null);
|
|
205
|
+
selectedEdgeIdRef.current = selectedEdgeId;
|
|
206
|
+
// Edge ids in throughline focus (an active flow's edge set, or a single
|
|
207
|
+
// step's edge). Used to frame the camera. `null` = no throughline focus.
|
|
208
|
+
const focusEdgeIds = useMemo(() => {
|
|
209
|
+
if (focusedThroughlineId == null || !throughlines)
|
|
210
|
+
return null;
|
|
211
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
212
|
+
if (!tl)
|
|
213
|
+
return null;
|
|
214
|
+
if (focusedStepIndex != null) {
|
|
215
|
+
const step = tl.steps[focusedStepIndex];
|
|
216
|
+
return step ? new Set([step.edgeId]) : null;
|
|
217
|
+
}
|
|
218
|
+
return new Set(tl.steps.map((s) => s.edgeId));
|
|
219
|
+
}, [throughlines, focusedThroughlineId, focusedStepIndex]);
|
|
220
|
+
// 1-based step numbers per edge of the selected flow (an edge can appear
|
|
221
|
+
// in more than one step).
|
|
222
|
+
const selectedFlowStepNos = useMemo(() => {
|
|
223
|
+
if (focusedThroughlineId == null || !throughlines)
|
|
224
|
+
return null;
|
|
225
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
226
|
+
if (!tl)
|
|
227
|
+
return null;
|
|
228
|
+
const map = new Map();
|
|
229
|
+
tl.steps.forEach((s, i) => {
|
|
230
|
+
const list = map.get(s.edgeId) ?? [];
|
|
231
|
+
list.push(i + 1);
|
|
232
|
+
map.set(s.edgeId, list);
|
|
233
|
+
});
|
|
234
|
+
return map;
|
|
235
|
+
}, [throughlines, focusedThroughlineId]);
|
|
236
|
+
// Union of every expanded (opened) throughline's edges — the visible set.
|
|
237
|
+
const openedEdgeIds = useMemo(() => {
|
|
238
|
+
if (!throughlines || expandedThroughlines.size === 0)
|
|
239
|
+
return null;
|
|
240
|
+
const ids = new Set();
|
|
241
|
+
for (const tl of throughlines) {
|
|
242
|
+
if (!expandedThroughlines.has(tl.id))
|
|
243
|
+
continue;
|
|
244
|
+
for (const s of tl.steps)
|
|
245
|
+
ids.add(s.edgeId);
|
|
246
|
+
}
|
|
247
|
+
return ids.size > 0 ? ids : null;
|
|
248
|
+
}, [throughlines, expandedThroughlines]);
|
|
249
|
+
const endpointsOf = (edgeIds) => {
|
|
250
|
+
if (!edgeIds)
|
|
251
|
+
return null;
|
|
252
|
+
const ids = new Set();
|
|
253
|
+
for (const e of baseEdges) {
|
|
254
|
+
if (!edgeIds.has(e.id))
|
|
255
|
+
continue;
|
|
256
|
+
ids.add(e.source);
|
|
257
|
+
ids.add(e.target);
|
|
258
|
+
}
|
|
259
|
+
return ids.size > 0 ? ids : null;
|
|
260
|
+
};
|
|
261
|
+
const openedNodeIds = useMemo(() => endpointsOf(openedEdgeIds),
|
|
262
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
263
|
+
[baseEdges, openedEdgeIds]);
|
|
264
|
+
// Endpoints of the bright set: the selected step's edge, or the whole flow
|
|
265
|
+
// when no step is focused.
|
|
266
|
+
const brightNodeIds = useMemo(() => endpointsOf(focusEdgeIds),
|
|
267
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
268
|
+
[baseEdges, focusEdgeIds]);
|
|
269
|
+
// Source + target of the focused step/flow (or a canvas-selected edge). If a
|
|
270
|
+
// file is open, those endpoints stay undimmed even when they don't live in
|
|
271
|
+
// that file.
|
|
272
|
+
const focusNodeIds = useMemo(() => {
|
|
273
|
+
if (brightNodeIds)
|
|
274
|
+
return brightNodeIds;
|
|
275
|
+
if (selectedEdgeId) {
|
|
276
|
+
const e = baseEdges.find((x) => x.id === selectedEdgeId);
|
|
277
|
+
if (e)
|
|
278
|
+
return new Set([e.source, e.target]);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}, [baseEdges, brightNodeIds, selectedEdgeId]);
|
|
185
282
|
// While a file is open in the drawer, tag each node with whether its
|
|
186
283
|
// component lives in that file — the node renderer spotlights matches and
|
|
187
284
|
// dims non-matches (mirrors the edge-dimming behavior on selection).
|
|
285
|
+
// Opened-but-unselected members are dimmed; a selected step further dims
|
|
286
|
+
// the rest of its own flow. Nodes not on any opened flow are hidden.
|
|
188
287
|
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
189
|
-
// React Flow's own selection state from
|
|
288
|
+
// React Flow's own selection state from updating.
|
|
190
289
|
const dispNodes = useMemo(() => {
|
|
191
290
|
return xyflowNodesBase.map((n) => {
|
|
192
291
|
const comp = n.data?.component;
|
|
193
|
-
const fileMatch =
|
|
292
|
+
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
194
293
|
const isSelected = selected?.id !== undefined && comp?.id === selected.id;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
294
|
+
const vis = flowElementVisibility({
|
|
295
|
+
inOpened: openedNodeIds?.has(n.id) === true,
|
|
296
|
+
inSelected: brightNodeIds?.has(n.id) === true,
|
|
297
|
+
anyOpened: openedNodeIds != null,
|
|
298
|
+
anySelected: brightNodeIds != null,
|
|
299
|
+
});
|
|
300
|
+
if (fileMatch === undefined && !isSelected && !vis.dimmed) {
|
|
301
|
+
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data;
|
|
302
|
+
return { ...n, hidden: vis.hidden, data: rest };
|
|
198
303
|
}
|
|
199
304
|
return {
|
|
200
305
|
...n,
|
|
201
|
-
|
|
306
|
+
hidden: vis.hidden,
|
|
307
|
+
data: {
|
|
308
|
+
...n.data,
|
|
309
|
+
...(fileMatch !== undefined && { fileMatch }),
|
|
310
|
+
...(isSelected && { isSelected }),
|
|
311
|
+
...(vis.dimmed && { dimmed: true }),
|
|
312
|
+
},
|
|
202
313
|
};
|
|
203
314
|
});
|
|
204
|
-
}, [xyflowNodesBase, openFile, selected]);
|
|
315
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
|
|
205
316
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
206
317
|
const baseEdgesKey = useMemo(() => convertedEdges.map((e) => e.id).sort().join(','), [convertedEdges]);
|
|
207
|
-
// When an edge is selected, dim every other edge + its label to focus it.
|
|
208
|
-
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
|
|
209
|
-
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
210
|
-
// without a stale closure value.
|
|
211
|
-
const selectedEdgeIdRef = useRef(null);
|
|
212
|
-
selectedEdgeIdRef.current = selectedEdgeId;
|
|
213
318
|
const dispEdges = useMemo(() => {
|
|
319
|
+
const paint = (e, dimmed) => {
|
|
320
|
+
const markerEnd = e.markerEnd;
|
|
321
|
+
const nextMarker = dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
|
|
322
|
+
? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
|
|
323
|
+
: markerEnd;
|
|
324
|
+
return {
|
|
325
|
+
...e,
|
|
326
|
+
data: { ...e.data, dimmed },
|
|
327
|
+
markerEnd: nextMarker,
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
if (openedEdgeIds || focusEdgeIds) {
|
|
331
|
+
return baseEdges.map((e) => {
|
|
332
|
+
const vis = flowElementVisibility({
|
|
333
|
+
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
334
|
+
inSelected: focusEdgeIds?.has(e.id) === true,
|
|
335
|
+
anyOpened: openedEdgeIds != null,
|
|
336
|
+
anySelected: focusEdgeIds != null,
|
|
337
|
+
});
|
|
338
|
+
return { ...paint(e, vis.dimmed), hidden: vis.hidden };
|
|
339
|
+
});
|
|
340
|
+
}
|
|
214
341
|
if (!selectedEdgeId)
|
|
215
342
|
return baseEdges;
|
|
216
|
-
return baseEdges.map((e) => (
|
|
217
|
-
|
|
218
|
-
data: {
|
|
219
|
-
...e.data,
|
|
220
|
-
dimmed: e.id !== selectedEdgeId,
|
|
221
|
-
},
|
|
222
|
-
}));
|
|
223
|
-
}, [baseEdges, selectedEdgeId]);
|
|
343
|
+
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
344
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
|
|
224
345
|
const onNodesChange = useCallback((changes) => {
|
|
225
346
|
// Capture dimension changes (React Flow's measurement callback).
|
|
226
347
|
for (const ch of changes) {
|
|
@@ -250,6 +371,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
250
371
|
const onNodeClick = useCallback((_e, node) => {
|
|
251
372
|
const comp = node.data?.component;
|
|
252
373
|
setSelectedEdgeId(null);
|
|
374
|
+
setFocusedThroughlineId(null);
|
|
375
|
+
setFocusedStepIndex(null);
|
|
253
376
|
if (node.type === 'subsystem-component' && comp) {
|
|
254
377
|
// Clicking the already-selected node unselects it (toggle off).
|
|
255
378
|
// Selection is independent of the file drawer — nodes never open it.
|
|
@@ -269,10 +392,13 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
269
392
|
const onPaneClick = useCallback(() => {
|
|
270
393
|
setSelected(null);
|
|
271
394
|
setSelectedEdgeId(null);
|
|
395
|
+
setFocusedThroughlineId(null);
|
|
396
|
+
setFocusedStepIndex(null);
|
|
272
397
|
}, []);
|
|
273
398
|
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
274
399
|
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
275
400
|
const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
|
|
401
|
+
const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
|
|
276
402
|
const [collapsedRepos, setCollapsedRepos] = useState(new Set());
|
|
277
403
|
const toggleRepoCollapsed = useCallback((key) => {
|
|
278
404
|
setCollapsedRepos((prev) => {
|
|
@@ -303,6 +429,62 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
303
429
|
setOpenFileTarget({ file, startLine });
|
|
304
430
|
onFileSelect?.(file);
|
|
305
431
|
}, [onFileSelect]);
|
|
432
|
+
// Camera helper shared by the throughline interactions: frames the focused
|
|
433
|
+
// edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
|
|
434
|
+
// live positions + measured dims — so the frame always includes BOTH
|
|
435
|
+
// components the edge attaches to (and therefore the edge line between them).
|
|
436
|
+
const fitFocusBounds = useCallback((ids) => {
|
|
437
|
+
const nodeIds = new Set();
|
|
438
|
+
for (const e of baseEdges) {
|
|
439
|
+
if (!ids.has(e.id))
|
|
440
|
+
continue;
|
|
441
|
+
nodeIds.add(e.source);
|
|
442
|
+
nodeIds.add(e.target);
|
|
443
|
+
}
|
|
444
|
+
if (nodeIds.size === 0)
|
|
445
|
+
return;
|
|
446
|
+
fitView({
|
|
447
|
+
nodes: [...nodeIds].map((id) => ({ id })),
|
|
448
|
+
padding: 0.25,
|
|
449
|
+
duration: 300,
|
|
450
|
+
});
|
|
451
|
+
}, [baseEdges, fitView]);
|
|
452
|
+
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
453
|
+
// frame the flow on the canvas. Selection state is cleared — the graph now
|
|
454
|
+
// reads as the narrative.
|
|
455
|
+
const focusThroughlineEdges = useCallback((tl) => {
|
|
456
|
+
setSelected(null);
|
|
457
|
+
setSelectedEdgeId(null);
|
|
458
|
+
setFocusedStepIndex(null);
|
|
459
|
+
setFocusedThroughlineId(tl.id);
|
|
460
|
+
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
461
|
+
}, [fitFocusBounds]);
|
|
462
|
+
const clearThroughlineFocus = useCallback(() => {
|
|
463
|
+
setFocusedThroughlineId(null);
|
|
464
|
+
setFocusedStepIndex(null);
|
|
465
|
+
}, []);
|
|
466
|
+
// Focus a single step's edge on the canvas. The step's file:line is listed
|
|
467
|
+
// in the row; we don't open the drawer from here.
|
|
468
|
+
const focusThroughlineStep = useCallback((tl, stepIndex) => {
|
|
469
|
+
const step = tl.steps[stepIndex];
|
|
470
|
+
if (!step)
|
|
471
|
+
return;
|
|
472
|
+
setSelected(null);
|
|
473
|
+
setSelectedEdgeId(null);
|
|
474
|
+
setFocusedStepIndex(stepIndex);
|
|
475
|
+
setFocusedThroughlineId(tl.id);
|
|
476
|
+
fitFocusBounds(new Set([step.edgeId]));
|
|
477
|
+
}, [fitFocusBounds]);
|
|
478
|
+
const toggleThroughlineCollapsed = useCallback((tlId) => {
|
|
479
|
+
setExpandedThroughlines((prev) => {
|
|
480
|
+
const next = new Set(prev);
|
|
481
|
+
if (next.has(tlId))
|
|
482
|
+
next.delete(tlId);
|
|
483
|
+
else
|
|
484
|
+
next.add(tlId);
|
|
485
|
+
return next;
|
|
486
|
+
});
|
|
487
|
+
}, []);
|
|
306
488
|
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
307
489
|
// the declaration panel's file link (toggle + tree sync, no start line).
|
|
308
490
|
useEffect(() => {
|
|
@@ -327,13 +509,17 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
327
509
|
return;
|
|
328
510
|
setSelected(comp);
|
|
329
511
|
setSelectedEdgeId(null);
|
|
512
|
+
setFocusedThroughlineId(null);
|
|
513
|
+
setFocusedStepIndex(null);
|
|
330
514
|
onSelect?.(comp.id);
|
|
331
515
|
}, [components, onSelect]);
|
|
332
516
|
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
333
517
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
334
518
|
// the actual edge path (not node-center approximations).
|
|
335
519
|
const edgeLabels = useMemo(() => {
|
|
336
|
-
return dispEdges
|
|
520
|
+
return dispEdges
|
|
521
|
+
.filter((e) => !e.hidden)
|
|
522
|
+
.map((e) => {
|
|
337
523
|
const d = e.data;
|
|
338
524
|
return {
|
|
339
525
|
id: e.id,
|
|
@@ -341,9 +527,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
341
527
|
dimmed: d?.dimmed === true,
|
|
342
528
|
midX: d?.labelX ?? 0,
|
|
343
529
|
midY: d?.labelY ?? 0,
|
|
530
|
+
stepNos: selectedFlowStepNos?.get(e.id),
|
|
344
531
|
};
|
|
345
532
|
});
|
|
346
|
-
}, [dispEdges]);
|
|
533
|
+
}, [dispEdges, selectedFlowStepNos]);
|
|
347
534
|
const usedMechanisms = useMemo(() => {
|
|
348
535
|
return new Set(edgeLabels.map((l) => l.mechanism));
|
|
349
536
|
}, [edgeLabels]);
|
|
@@ -372,7 +559,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
372
559
|
const fileViewerRef = useRef(fileViewer);
|
|
373
560
|
fileViewerRef.current = fileViewer;
|
|
374
561
|
const renderDrawerContent = useCallback((file, opts) => fileViewerRef.current?.(file, opts) ?? null, []);
|
|
375
|
-
return (_jsxs("div", { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }, children: [(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (_jsxs("div", { style: {
|
|
562
|
+
return (_jsxs("div", { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }, children: [(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (_jsxs("div", { style: {
|
|
376
563
|
width: 340,
|
|
377
564
|
minWidth: 340,
|
|
378
565
|
borderRight: `1px solid ${theme.colors.border}`,
|
|
@@ -394,7 +581,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
394
581
|
fontWeight: 600,
|
|
395
582
|
color: theme.colors.text,
|
|
396
583
|
fontFamily: theme.fonts.heading,
|
|
397
|
-
}, children: title })), description && (_jsx("div", { style: { fontSize: theme.fontSizes[0], lineHeight: 1.5 }, children: _jsx(IndustryMarkdownSlide, { content: description, slideIdPrefix: "subsystem-desc", slideIndex: 0, isVisible: true, theme: theme, disableScroll: true, fontSizeScale: 0.9, enableKeyboardScrolling: false, autoFocusOnVisible: false }) })), sidebarAfterDescription] }), treeFilePaths.length > 0 && (
|
|
584
|
+
}, children: title })), description && (_jsx("div", { style: { fontSize: theme.fontSizes[0], lineHeight: 1.5 }, children: _jsx(IndustryMarkdownSlide, { content: description, slideIdPrefix: "subsystem-desc", slideIndex: 0, isVisible: true, theme: theme, disableScroll: true, fontSizeScale: 0.9, enableKeyboardScrolling: false, autoFocusOnVisible: false }) })), sidebarAfterDescription] }), (treeFilePaths.length > 0 || hasThroughlines) && (_jsxs("div", { style: {
|
|
398
585
|
height: '50%',
|
|
399
586
|
minHeight: 160,
|
|
400
587
|
flexShrink: 0,
|
|
@@ -402,17 +589,42 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
402
589
|
display: 'flex',
|
|
403
590
|
flexDirection: 'column',
|
|
404
591
|
overflow: 'hidden',
|
|
405
|
-
}, children:
|
|
406
|
-
const groupKey = group.repoKey ?? '__no-repo__';
|
|
407
|
-
const collapsed = collapsedRepos.has(groupKey);
|
|
408
|
-
return (_jsxs("div", { style: {
|
|
409
|
-
flex: collapsed ? '0 0 auto' : 1,
|
|
410
|
-
minHeight: collapsed ? 0 : undefined,
|
|
592
|
+
}, children: [hasThroughlines && (_jsx("div", { role: "tablist", "aria-label": "Sidebar view", style: {
|
|
411
593
|
display: 'flex',
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
594
|
+
width: '100%',
|
|
595
|
+
flexShrink: 0,
|
|
596
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
597
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
598
|
+
}, children: ['files', 'flows'].map((view) => (_jsx("button", { type: "button", role: "tab", "aria-selected": sidebarView === view, onClick: () => setSidebarView(view), style: {
|
|
599
|
+
flex: 1,
|
|
600
|
+
minWidth: 0,
|
|
601
|
+
padding: '8px 8px',
|
|
602
|
+
border: 'none',
|
|
603
|
+
borderRadius: 0,
|
|
604
|
+
background: sidebarView === view ? theme.colors.background : 'transparent',
|
|
605
|
+
color: sidebarView === view
|
|
606
|
+
? theme.colors.text
|
|
607
|
+
: theme.colors.textSecondary,
|
|
608
|
+
fontSize: theme.fontSizes[1],
|
|
609
|
+
fontFamily: theme.fonts.monospace,
|
|
610
|
+
textTransform: 'capitalize',
|
|
611
|
+
cursor: 'pointer',
|
|
612
|
+
}, children: view }, view))) })), sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (_jsx("div", { style: {
|
|
613
|
+
flex: 1,
|
|
614
|
+
minHeight: 0,
|
|
615
|
+
overflowY: 'auto',
|
|
616
|
+
padding: '0 4px 12px',
|
|
617
|
+
}, children: throughlines.map((tl) => (_jsx(ThroughlineFlow, { throughline: tl, edges: edges, collapsed: !expandedThroughlines.has(tl.id), active: focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null, onToggleCollapsed: toggleThroughlineCollapsed, onFocusFlow: focusThroughlineEdges, onClearFocus: clearThroughlineFocus, onFocusStep: focusThroughlineStep }, tl.id))) })) : treeFilePaths.length > 0 ? (_jsx(_Fragment, { children: repoGroups.groups.map((group, i) => {
|
|
618
|
+
const groupKey = group.repoKey ?? '__no-repo__';
|
|
619
|
+
const collapsed = collapsedRepos.has(groupKey);
|
|
620
|
+
return (_jsxs("div", { style: {
|
|
621
|
+
flex: collapsed ? '0 0 auto' : 1,
|
|
622
|
+
minHeight: collapsed ? 0 : undefined,
|
|
623
|
+
display: 'flex',
|
|
624
|
+
flexDirection: 'column',
|
|
625
|
+
borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
|
|
626
|
+
}, children: [repoGroups.multiRepo && (_jsx(RepoGroupHeader, { group: group, collapsed: collapsed, onToggle: () => toggleRepoCollapsed(groupKey) })), !collapsed && (_jsx(SubsystemFileTree, { files: group.entries.map((e) => e.displayPath), selectedFile: selected?.file ?? openFile, hoveredFile: hoveredFile, onSelectFile: onTreeSelectFile, headerless: repoGroups.multiRepo }))] }, groupKey));
|
|
627
|
+
}) })) : null] }))] })), _jsxs("div", { style: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }, children: [showEdgeLabels !== false && (_jsx("div", { style: {
|
|
416
628
|
position: 'absolute',
|
|
417
629
|
inset: 0,
|
|
418
630
|
overflow: 'hidden',
|
|
@@ -431,6 +643,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
431
643
|
position: 'absolute',
|
|
432
644
|
left: screenX,
|
|
433
645
|
top: screenY,
|
|
646
|
+
// Center on the flow-space midpoint. Labels live in screen
|
|
647
|
+
// space (fixed size when zoomed out), so top-left anchoring
|
|
648
|
+
// would drift them right/down of the edge as zoom drops.
|
|
649
|
+
transform: 'translate(-50%, -50%)',
|
|
434
650
|
fontSize: 10,
|
|
435
651
|
fontFamily: theme.fonts.monospace,
|
|
436
652
|
fontWeight: 500,
|
|
@@ -443,7 +659,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
443
659
|
pointerEvents: 'auto',
|
|
444
660
|
opacity: lbl.dimmed ? 0.15 : 1,
|
|
445
661
|
whiteSpace: 'nowrap',
|
|
446
|
-
}, children: lbl.mechanism }, lbl.id));
|
|
662
|
+
}, children: lbl.stepNos?.length ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}` : lbl.mechanism }, lbl.id));
|
|
447
663
|
}) })), _jsxs(ReactFlow, { nodes: dispNodes, edges: dispEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, minZoom: 0.05, maxZoom: 4, onNodeClick: onNodeClick, onEdgeClick: onEdgeClick, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, proOptions: { hideAttribution: true }, nodesDraggable: false, elementsSelectable: true, selectNodesOnDrag: false, nodesConnectable: false, edgesReconnectable: false, onPaneClick: onPaneClick, panOnDrag: true, panOnScroll: true, zoomOnScroll: false, zoomOnPinch: true, zoomOnDoubleClick: false, style: {
|
|
448
664
|
width: '100%',
|
|
449
665
|
height: '100%',
|
|
@@ -521,6 +737,130 @@ function RepoGroupHeader({ group, collapsed, onToggle, }) {
|
|
|
521
737
|
whiteSpace: 'nowrap',
|
|
522
738
|
}, children: label })] }));
|
|
523
739
|
}
|
|
740
|
+
/** One collapsible throughline in the sidebar's flows panel. Clicking the
|
|
741
|
+
* title: closed → open + select; open and unselected → select; open and
|
|
742
|
+
* selected → close + clear focus. The right-aligned close button collapses
|
|
743
|
+
* without selecting. A step row focuses that step's edge. */
|
|
744
|
+
function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollapsed, onFocusFlow, onClearFocus, onFocusStep, }) {
|
|
745
|
+
const { theme } = useTheme();
|
|
746
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
747
|
+
const hoverBg = theme.colors.background;
|
|
748
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
749
|
+
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
750
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
751
|
+
const [closeHover, setCloseHover] = useState(false);
|
|
752
|
+
const [hoveredStep, setHoveredStep] = useState(null);
|
|
753
|
+
return (_jsxs("div", { style: { margin: '4px 0', borderRadius: 8 }, children: [_jsxs("div", { onMouseEnter: () => setHeaderHover(true), onMouseLeave: () => setHeaderHover(false), style: {
|
|
754
|
+
display: 'flex',
|
|
755
|
+
alignItems: 'center',
|
|
756
|
+
gap: 4,
|
|
757
|
+
borderRadius: 6,
|
|
758
|
+
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
759
|
+
transition: 'background 120ms ease',
|
|
760
|
+
}, children: [_jsx("button", { type: "button", onClick: () => {
|
|
761
|
+
if (collapsed) {
|
|
762
|
+
onToggleCollapsed(throughline.id);
|
|
763
|
+
onFocusFlow(throughline);
|
|
764
|
+
}
|
|
765
|
+
else if (active === null) {
|
|
766
|
+
onFocusFlow(throughline);
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
onToggleCollapsed(throughline.id);
|
|
770
|
+
onClearFocus();
|
|
771
|
+
}
|
|
772
|
+
}, style: {
|
|
773
|
+
flex: 1,
|
|
774
|
+
display: 'flex',
|
|
775
|
+
alignItems: 'center',
|
|
776
|
+
minWidth: 0,
|
|
777
|
+
padding: '6px 8px',
|
|
778
|
+
borderRadius: 6,
|
|
779
|
+
border: 'none',
|
|
780
|
+
background: 'transparent',
|
|
781
|
+
textAlign: 'left',
|
|
782
|
+
cursor: 'pointer',
|
|
783
|
+
}, children: _jsx("span", { style: {
|
|
784
|
+
overflow: 'hidden',
|
|
785
|
+
textOverflow: 'ellipsis',
|
|
786
|
+
whiteSpace: 'nowrap',
|
|
787
|
+
fontSize: theme.fontSizes[1],
|
|
788
|
+
fontFamily: theme.fonts.monospace,
|
|
789
|
+
fontWeight: 600,
|
|
790
|
+
color: theme.colors.text,
|
|
791
|
+
}, children: throughline.title }) }), !collapsed && (_jsx("button", { type: "button", "aria-label": `Close ${throughline.title}`, onMouseEnter: () => setCloseHover(true), onMouseLeave: () => setCloseHover(false), onClick: (e) => {
|
|
792
|
+
e.stopPropagation();
|
|
793
|
+
onToggleCollapsed(throughline.id);
|
|
794
|
+
if (active !== null)
|
|
795
|
+
onClearFocus();
|
|
796
|
+
}, style: {
|
|
797
|
+
display: 'inline-flex',
|
|
798
|
+
alignItems: 'center',
|
|
799
|
+
justifyContent: 'center',
|
|
800
|
+
flexShrink: 0,
|
|
801
|
+
width: 22,
|
|
802
|
+
height: 22,
|
|
803
|
+
marginRight: 4,
|
|
804
|
+
padding: 0,
|
|
805
|
+
border: 'none',
|
|
806
|
+
borderRadius: 4,
|
|
807
|
+
background: closeHover ? theme.colors.border : 'transparent',
|
|
808
|
+
color: closeHover ? theme.colors.text : muted,
|
|
809
|
+
cursor: 'pointer',
|
|
810
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
811
|
+
}, children: _jsx(X, { size: 12, strokeWidth: 2 }) }))] }), !collapsed && (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 1 }, children: throughline.steps.map((step, i) => {
|
|
812
|
+
const edge = edgeById.get(step.edgeId);
|
|
813
|
+
const mech = edge?.mechanism;
|
|
814
|
+
const color = mech ? MECHANISM_COLOR[mech] : muted;
|
|
815
|
+
const stepActive = active !== null && active.stepIndex === i;
|
|
816
|
+
return (_jsxs("button", { type: "button", onMouseEnter: () => setHoveredStep(i), onMouseLeave: () => setHoveredStep(null), onClick: () => onFocusStep(throughline, i), style: {
|
|
817
|
+
display: 'flex',
|
|
818
|
+
alignItems: 'center',
|
|
819
|
+
gap: 8,
|
|
820
|
+
minWidth: 0,
|
|
821
|
+
padding: '4px 8px 4px 12px',
|
|
822
|
+
textAlign: 'left',
|
|
823
|
+
borderRadius: 6,
|
|
824
|
+
border: 'none',
|
|
825
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
826
|
+
cursor: 'pointer',
|
|
827
|
+
transition: 'background 120ms ease',
|
|
828
|
+
}, children: [_jsx("span", { style: {
|
|
829
|
+
flexShrink: 0,
|
|
830
|
+
width: 14,
|
|
831
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
832
|
+
fontFamily: theme.fonts.monospace,
|
|
833
|
+
color: stepActive ? theme.colors.text : muted,
|
|
834
|
+
}, children: i + 1 }), step.symbol ? (_jsx("span", { style: {
|
|
835
|
+
flex: 1,
|
|
836
|
+
minWidth: 0,
|
|
837
|
+
overflow: 'hidden',
|
|
838
|
+
textOverflow: 'ellipsis',
|
|
839
|
+
whiteSpace: 'nowrap',
|
|
840
|
+
fontSize: theme.fontSizes[0],
|
|
841
|
+
fontFamily: theme.fonts.monospace,
|
|
842
|
+
color: stepActive ? theme.colors.text : muted,
|
|
843
|
+
}, children: step.symbol })) : (_jsxs(_Fragment, { children: [_jsx("span", { style: {
|
|
844
|
+
flexShrink: 0,
|
|
845
|
+
width: 74,
|
|
846
|
+
overflow: 'hidden',
|
|
847
|
+
textOverflow: 'ellipsis',
|
|
848
|
+
whiteSpace: 'nowrap',
|
|
849
|
+
fontSize: theme.fontSizes[0],
|
|
850
|
+
fontFamily: theme.fonts.monospace,
|
|
851
|
+
color,
|
|
852
|
+
}, children: mech ?? step.edgeId }), _jsxs("span", { style: {
|
|
853
|
+
flex: 1,
|
|
854
|
+
minWidth: 0,
|
|
855
|
+
overflow: 'hidden',
|
|
856
|
+
textOverflow: 'ellipsis',
|
|
857
|
+
whiteSpace: 'nowrap',
|
|
858
|
+
fontSize: theme.fontSizes[0],
|
|
859
|
+
fontFamily: theme.fonts.monospace,
|
|
860
|
+
color: stepActive ? theme.colors.text : muted,
|
|
861
|
+
}, children: [step.file.split('/').pop(), _jsxs("span", { style: { opacity: 0.7 }, children: [":", step.line] })] })] }))] }, `${step.edgeId}-${i}`));
|
|
862
|
+
}) }))] }));
|
|
863
|
+
}
|
|
524
864
|
export function SubsystemComponentGraph(props) {
|
|
525
865
|
const wrapRef = useRef(null);
|
|
526
866
|
const [size, setSize] = useState(null);
|