@principal-ai/principal-view-react 0.16.57 → 0.16.59
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 +14 -5
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +436 -50
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +73 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +91 -11
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +38 -3
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +95 -4
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +26 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +152 -13
- 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/stories/Subsystem/ComponentGraph/Processes.stories.tsx +68 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +600 -50
- package/src/subsystem/model.test.ts +56 -0
- package/src/subsystem/model.ts +156 -12
- package/src/subsystem/nodes.test.ts +56 -0
- package/src/subsystem/nodes.tsx +122 -6
- package/src/utils/elkLayout.ts +164 -13
|
@@ -3,10 +3,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
* SubsystemComponentGraph — a clickable, read-only React Flow component graph
|
|
4
4
|
* for a subsystem snapshot.
|
|
5
5
|
*
|
|
6
|
-
* Nodes are positioned with ELK auto-layout (layered, minimized crossings
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Nodes are positioned with ELK auto-layout (layered, minimized crossings,
|
|
7
|
+
* process-aware compound groups). Components sharing a `process` render
|
|
8
|
+
* inside one labeled boundary frame; nodes without one sit outside every
|
|
9
|
+
* boundary. Clicking a component invokes `onSelect`.
|
|
10
10
|
*
|
|
11
11
|
* This is a focused fork of the package's `GraphRenderer` pipeline (same ELK
|
|
12
12
|
* edge routing, delayed fitView, Background/Controls/MiniMap, node/edge type
|
|
@@ -15,18 +15,24 @@ 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, SubsystemGroupNode, 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';
|
|
25
25
|
import { FileDrawer } from './FileDrawer';
|
|
26
26
|
import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
|
|
27
27
|
import { buildRepoGroups, repoAvatarUrl } from './paths';
|
|
28
|
+
/** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
|
|
29
|
+
const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
|
|
30
|
+
/** Rough monospace width at fontSize 10 + horizontal padding/border. */
|
|
31
|
+
const EDGE_LABEL_CHAR_PX = 6.2;
|
|
32
|
+
const EDGE_LABEL_PAD_PX = 18;
|
|
28
33
|
const nodeTypes = {
|
|
29
34
|
'subsystem-component': SubsystemComponentNode,
|
|
35
|
+
'subsystem-group': SubsystemGroupNode,
|
|
30
36
|
};
|
|
31
37
|
const edgeTypes = {
|
|
32
38
|
'subsystem-edge': SubsystemEdge,
|
|
@@ -40,7 +46,7 @@ const DrawerContent = memo(function DrawerContent({ render, file, startLine, })
|
|
|
40
46
|
return null;
|
|
41
47
|
return _jsx(_Fragment, { children: render(file, startLine != null ? { startLine } : undefined) });
|
|
42
48
|
});
|
|
43
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }) {
|
|
49
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }) {
|
|
44
50
|
const { theme } = useTheme();
|
|
45
51
|
const { fitView } = useReactFlow();
|
|
46
52
|
const viewport = useViewport();
|
|
@@ -58,6 +64,16 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
58
64
|
const [legendOpen, setLegendOpen] = useState(false);
|
|
59
65
|
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
60
66
|
const [hoveredComponentId, setHoveredComponentId] = useState(null);
|
|
67
|
+
// Throughline focus — selected flow (or step) is full strength; other
|
|
68
|
+
// opened-flow members stay visible but dimmed; everything else is hidden.
|
|
69
|
+
const [focusedThroughlineId, setFocusedThroughlineId] = useState(null);
|
|
70
|
+
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
71
|
+
const [focusedStepIndex, setFocusedStepIndex] = useState(null);
|
|
72
|
+
// Sidebar bottom half: which panel is shown when throughlines exist.
|
|
73
|
+
const [sidebarView, setSidebarView] = useState(() => throughlines?.length ? 'flows' : 'files');
|
|
74
|
+
// Throughline flows the user has expanded (via the title row). Closed by
|
|
75
|
+
// default so a graph with several flows doesn't dump every step list at once.
|
|
76
|
+
const [expandedThroughlines, setExpandedThroughlines] = useState(new Set());
|
|
61
77
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
62
78
|
// closure over the effect deps) can toggle without a stale value.
|
|
63
79
|
const selectedRef = useRef(null);
|
|
@@ -109,16 +125,19 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
109
125
|
// These arrive as { type: 'dimensions', id, dimensions } in onNodesChange.
|
|
110
126
|
const measuredDimsRef = useRef(new Map());
|
|
111
127
|
const pendingMeasuredRef = useRef(false);
|
|
112
|
-
// Pass 2: once every node has a measured dimension, re-run ELK.
|
|
128
|
+
// Pass 2: once every leaf node has a measured dimension, re-run ELK.
|
|
129
|
+
// Group parents are sized by ELK, not measured — exclude them or pass 2
|
|
130
|
+
// would wait forever for dimensions that never arrive.
|
|
113
131
|
const prevMeasuredSigRef = useRef('');
|
|
114
132
|
const pass2DoneRef = useRef(false);
|
|
115
133
|
const triggerPass2 = useCallback(() => {
|
|
116
134
|
if (pass2DoneRef.current)
|
|
117
135
|
return;
|
|
118
136
|
const dims = measuredDimsRef.current;
|
|
119
|
-
|
|
137
|
+
const leafNodes = built.nodes.filter((n) => n.type !== 'subsystem-group');
|
|
138
|
+
if (dims.size < leafNodes.length)
|
|
120
139
|
return;
|
|
121
|
-
const sig =
|
|
140
|
+
const sig = leafNodes.map((n) => `${n.id}:${dims.get(n.id)?.width ?? '?'}`).join(',');
|
|
122
141
|
if (sig.includes('?:'))
|
|
123
142
|
return;
|
|
124
143
|
if (sig === prevMeasuredSigRef.current)
|
|
@@ -126,8 +145,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
126
145
|
prevMeasuredSigRef.current = sig;
|
|
127
146
|
pendingMeasuredRef.current = false;
|
|
128
147
|
pass2DoneRef.current = true;
|
|
129
|
-
const measuredWidths = new Map(
|
|
130
|
-
const measuredHeights = new Map(
|
|
148
|
+
const measuredWidths = new Map(leafNodes.map((n) => [n.id, dims.get(n.id).width]));
|
|
149
|
+
const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id).height]));
|
|
131
150
|
let alive = true;
|
|
132
151
|
void buildSubsystemGraph({ components, edges }, { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights }).then(({ nodes, edges: e }) => {
|
|
133
152
|
if (!alive)
|
|
@@ -150,6 +169,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
150
169
|
}
|
|
151
170
|
const src = edgeById.get(edgeId);
|
|
152
171
|
setSelectedEdgeId(edgeId);
|
|
172
|
+
// A direct edge selection on the canvas supersedes any throughline focus.
|
|
173
|
+
setFocusedThroughlineId(null);
|
|
174
|
+
setFocusedStepIndex(null);
|
|
153
175
|
if (src)
|
|
154
176
|
onEdgeSelect?.(src);
|
|
155
177
|
}, [edgeById, onEdgeSelect]);
|
|
@@ -166,6 +188,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
166
188
|
}
|
|
167
189
|
setSelected(comp);
|
|
168
190
|
setSelectedEdgeId(null);
|
|
191
|
+
setFocusedThroughlineId(null);
|
|
192
|
+
setFocusedStepIndex(null);
|
|
169
193
|
onSelect?.(id);
|
|
170
194
|
}
|
|
171
195
|
};
|
|
@@ -182,49 +206,177 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
182
206
|
const { nodes, edges: convertedEdges } = built;
|
|
183
207
|
const xyflowNodesBase = nodes;
|
|
184
208
|
const baseEdges = convertedEdges;
|
|
209
|
+
// When an edge is selected, dim every other edge + its label to focus it.
|
|
210
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
|
|
211
|
+
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
212
|
+
// without a stale closure value.
|
|
213
|
+
const selectedEdgeIdRef = useRef(null);
|
|
214
|
+
selectedEdgeIdRef.current = selectedEdgeId;
|
|
215
|
+
// Edge ids in throughline focus (an active flow's edge set, or a single
|
|
216
|
+
// step's edge). Used to frame the camera. `null` = no throughline focus.
|
|
217
|
+
const focusEdgeIds = useMemo(() => {
|
|
218
|
+
if (focusedThroughlineId == null || !throughlines)
|
|
219
|
+
return null;
|
|
220
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
221
|
+
if (!tl)
|
|
222
|
+
return null;
|
|
223
|
+
if (focusedStepIndex != null) {
|
|
224
|
+
const step = tl.steps[focusedStepIndex];
|
|
225
|
+
return step ? new Set([step.edgeId]) : null;
|
|
226
|
+
}
|
|
227
|
+
return new Set(tl.steps.map((s) => s.edgeId));
|
|
228
|
+
}, [throughlines, focusedThroughlineId, focusedStepIndex]);
|
|
229
|
+
// 1-based step numbers per edge of the selected flow (an edge can appear
|
|
230
|
+
// in more than one step).
|
|
231
|
+
const selectedFlowStepNos = useMemo(() => {
|
|
232
|
+
if (focusedThroughlineId == null || !throughlines)
|
|
233
|
+
return null;
|
|
234
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
235
|
+
if (!tl)
|
|
236
|
+
return null;
|
|
237
|
+
const map = new Map();
|
|
238
|
+
tl.steps.forEach((s, i) => {
|
|
239
|
+
const list = map.get(s.edgeId) ?? [];
|
|
240
|
+
list.push(i + 1);
|
|
241
|
+
map.set(s.edgeId, list);
|
|
242
|
+
});
|
|
243
|
+
return map;
|
|
244
|
+
}, [throughlines, focusedThroughlineId]);
|
|
245
|
+
// Union of every expanded (opened) throughline's edges — the visible set.
|
|
246
|
+
const openedEdgeIds = useMemo(() => {
|
|
247
|
+
if (!throughlines || expandedThroughlines.size === 0)
|
|
248
|
+
return null;
|
|
249
|
+
const ids = new Set();
|
|
250
|
+
for (const tl of throughlines) {
|
|
251
|
+
if (!expandedThroughlines.has(tl.id))
|
|
252
|
+
continue;
|
|
253
|
+
for (const s of tl.steps)
|
|
254
|
+
ids.add(s.edgeId);
|
|
255
|
+
}
|
|
256
|
+
return ids.size > 0 ? ids : null;
|
|
257
|
+
}, [throughlines, expandedThroughlines]);
|
|
258
|
+
const endpointsOf = (edgeIds) => {
|
|
259
|
+
if (!edgeIds)
|
|
260
|
+
return null;
|
|
261
|
+
const ids = new Set();
|
|
262
|
+
for (const e of baseEdges) {
|
|
263
|
+
if (!edgeIds.has(e.id))
|
|
264
|
+
continue;
|
|
265
|
+
ids.add(e.source);
|
|
266
|
+
ids.add(e.target);
|
|
267
|
+
}
|
|
268
|
+
return ids.size > 0 ? ids : null;
|
|
269
|
+
};
|
|
270
|
+
const openedNodeIds = useMemo(() => endpointsOf(openedEdgeIds),
|
|
271
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
272
|
+
[baseEdges, openedEdgeIds]);
|
|
273
|
+
// Endpoints of the bright set: the selected step's edge, or the whole flow
|
|
274
|
+
// when no step is focused.
|
|
275
|
+
const brightNodeIds = useMemo(() => endpointsOf(focusEdgeIds),
|
|
276
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
277
|
+
[baseEdges, focusEdgeIds]);
|
|
278
|
+
// Source + target of the focused step/flow (or a canvas-selected edge). If a
|
|
279
|
+
// file is open, those endpoints stay undimmed even when they don't live in
|
|
280
|
+
// that file.
|
|
281
|
+
const focusNodeIds = useMemo(() => {
|
|
282
|
+
if (brightNodeIds)
|
|
283
|
+
return brightNodeIds;
|
|
284
|
+
if (selectedEdgeId) {
|
|
285
|
+
const e = baseEdges.find((x) => x.id === selectedEdgeId);
|
|
286
|
+
if (e)
|
|
287
|
+
return new Set([e.source, e.target]);
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}, [baseEdges, brightNodeIds, selectedEdgeId]);
|
|
185
291
|
// While a file is open in the drawer, tag each node with whether its
|
|
186
292
|
// component lives in that file — the node renderer spotlights matches and
|
|
187
293
|
// dims non-matches (mirrors the edge-dimming behavior on selection).
|
|
294
|
+
// Opened-but-unselected members are dimmed; a selected step further dims
|
|
295
|
+
// the rest of its own flow. Nodes not on any opened flow are hidden.
|
|
188
296
|
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
189
|
-
// React Flow's own selection state from
|
|
297
|
+
// React Flow's own selection state from updating.
|
|
190
298
|
const dispNodes = useMemo(() => {
|
|
191
299
|
return xyflowNodesBase.map((n) => {
|
|
300
|
+
// Boundary frames follow their members: hidden when no member is
|
|
301
|
+
// visible, dimmed when members are dimmed. Never selectable.
|
|
302
|
+
if (n.type === 'subsystem-group') {
|
|
303
|
+
const memberIds = (n.data?.region?.memberIds) ?? [];
|
|
304
|
+
const vis = flowElementVisibility({
|
|
305
|
+
inOpened: memberIds.some((id) => openedNodeIds?.has(id) === true),
|
|
306
|
+
inSelected: memberIds.some((id) => brightNodeIds?.has(id) === true),
|
|
307
|
+
anyOpened: openedNodeIds != null,
|
|
308
|
+
anySelected: brightNodeIds != null,
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
...n,
|
|
312
|
+
hidden: vis.hidden,
|
|
313
|
+
selectable: false,
|
|
314
|
+
data: {
|
|
315
|
+
...n.data,
|
|
316
|
+
...(vis.dimmed && { dimmed: true }),
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
192
320
|
const comp = n.data?.component;
|
|
193
|
-
const fileMatch =
|
|
321
|
+
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
194
322
|
const isSelected = selected?.id !== undefined && comp?.id === selected.id;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
323
|
+
const vis = flowElementVisibility({
|
|
324
|
+
inOpened: openedNodeIds?.has(n.id) === true,
|
|
325
|
+
inSelected: brightNodeIds?.has(n.id) === true,
|
|
326
|
+
anyOpened: openedNodeIds != null,
|
|
327
|
+
anySelected: brightNodeIds != null,
|
|
328
|
+
});
|
|
329
|
+
if (fileMatch === undefined && !isSelected && !vis.dimmed) {
|
|
330
|
+
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data;
|
|
331
|
+
return { ...n, hidden: vis.hidden, data: rest };
|
|
198
332
|
}
|
|
199
333
|
return {
|
|
200
334
|
...n,
|
|
201
|
-
|
|
335
|
+
hidden: vis.hidden,
|
|
336
|
+
data: {
|
|
337
|
+
...n.data,
|
|
338
|
+
...(fileMatch !== undefined && { fileMatch }),
|
|
339
|
+
...(isSelected && { isSelected }),
|
|
340
|
+
...(vis.dimmed && { dimmed: true }),
|
|
341
|
+
},
|
|
202
342
|
};
|
|
203
343
|
});
|
|
204
|
-
}, [xyflowNodesBase, openFile, selected]);
|
|
344
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
|
|
205
345
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
206
346
|
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
347
|
const dispEdges = useMemo(() => {
|
|
348
|
+
const paint = (e, dimmed) => {
|
|
349
|
+
const markerEnd = e.markerEnd;
|
|
350
|
+
const nextMarker = dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
|
|
351
|
+
? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
|
|
352
|
+
: markerEnd;
|
|
353
|
+
return {
|
|
354
|
+
...e,
|
|
355
|
+
data: { ...e.data, dimmed },
|
|
356
|
+
markerEnd: nextMarker,
|
|
357
|
+
};
|
|
358
|
+
};
|
|
359
|
+
if (openedEdgeIds || focusEdgeIds) {
|
|
360
|
+
return baseEdges.map((e) => {
|
|
361
|
+
const vis = flowElementVisibility({
|
|
362
|
+
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
363
|
+
inSelected: focusEdgeIds?.has(e.id) === true,
|
|
364
|
+
anyOpened: openedEdgeIds != null,
|
|
365
|
+
anySelected: focusEdgeIds != null,
|
|
366
|
+
});
|
|
367
|
+
return { ...paint(e, vis.dimmed), hidden: vis.hidden };
|
|
368
|
+
});
|
|
369
|
+
}
|
|
214
370
|
if (!selectedEdgeId)
|
|
215
371
|
return baseEdges;
|
|
216
|
-
return baseEdges.map((e) => (
|
|
217
|
-
|
|
218
|
-
data: {
|
|
219
|
-
...e.data,
|
|
220
|
-
dimmed: e.id !== selectedEdgeId,
|
|
221
|
-
},
|
|
222
|
-
}));
|
|
223
|
-
}, [baseEdges, selectedEdgeId]);
|
|
372
|
+
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
373
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
|
|
224
374
|
const onNodesChange = useCallback((changes) => {
|
|
225
375
|
// Capture dimension changes (React Flow's measurement callback).
|
|
376
|
+
// Group parents are ELK-sized — ignore their measurements.
|
|
377
|
+
const groupIds = new Set(dispNodes.filter((n) => n.type === 'subsystem-group').map((n) => n.id));
|
|
226
378
|
for (const ch of changes) {
|
|
227
|
-
if (ch.type === 'dimensions' && ch.dimensions) {
|
|
379
|
+
if (ch.type === 'dimensions' && ch.dimensions && !groupIds.has(ch.id)) {
|
|
228
380
|
measuredDimsRef.current.set(ch.id, ch.dimensions);
|
|
229
381
|
pendingMeasuredRef.current = true;
|
|
230
382
|
}
|
|
@@ -250,6 +402,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
250
402
|
const onNodeClick = useCallback((_e, node) => {
|
|
251
403
|
const comp = node.data?.component;
|
|
252
404
|
setSelectedEdgeId(null);
|
|
405
|
+
setFocusedThroughlineId(null);
|
|
406
|
+
setFocusedStepIndex(null);
|
|
253
407
|
if (node.type === 'subsystem-component' && comp) {
|
|
254
408
|
// Clicking the already-selected node unselects it (toggle off).
|
|
255
409
|
// Selection is independent of the file drawer — nodes never open it.
|
|
@@ -269,10 +423,13 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
269
423
|
const onPaneClick = useCallback(() => {
|
|
270
424
|
setSelected(null);
|
|
271
425
|
setSelectedEdgeId(null);
|
|
426
|
+
setFocusedThroughlineId(null);
|
|
427
|
+
setFocusedStepIndex(null);
|
|
272
428
|
}, []);
|
|
273
429
|
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
274
430
|
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
275
431
|
const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
|
|
432
|
+
const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
|
|
276
433
|
const [collapsedRepos, setCollapsedRepos] = useState(new Set());
|
|
277
434
|
const toggleRepoCollapsed = useCallback((key) => {
|
|
278
435
|
setCollapsedRepos((prev) => {
|
|
@@ -303,6 +460,62 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
303
460
|
setOpenFileTarget({ file, startLine });
|
|
304
461
|
onFileSelect?.(file);
|
|
305
462
|
}, [onFileSelect]);
|
|
463
|
+
// Camera helper shared by the throughline interactions: frames the focused
|
|
464
|
+
// edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
|
|
465
|
+
// live positions + measured dims — so the frame always includes BOTH
|
|
466
|
+
// components the edge attaches to (and therefore the edge line between them).
|
|
467
|
+
const fitFocusBounds = useCallback((ids) => {
|
|
468
|
+
const nodeIds = new Set();
|
|
469
|
+
for (const e of baseEdges) {
|
|
470
|
+
if (!ids.has(e.id))
|
|
471
|
+
continue;
|
|
472
|
+
nodeIds.add(e.source);
|
|
473
|
+
nodeIds.add(e.target);
|
|
474
|
+
}
|
|
475
|
+
if (nodeIds.size === 0)
|
|
476
|
+
return;
|
|
477
|
+
fitView({
|
|
478
|
+
nodes: [...nodeIds].map((id) => ({ id })),
|
|
479
|
+
padding: 0.25,
|
|
480
|
+
duration: 300,
|
|
481
|
+
});
|
|
482
|
+
}, [baseEdges, fitView]);
|
|
483
|
+
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
484
|
+
// frame the flow on the canvas. Selection state is cleared — the graph now
|
|
485
|
+
// reads as the narrative.
|
|
486
|
+
const focusThroughlineEdges = useCallback((tl) => {
|
|
487
|
+
setSelected(null);
|
|
488
|
+
setSelectedEdgeId(null);
|
|
489
|
+
setFocusedStepIndex(null);
|
|
490
|
+
setFocusedThroughlineId(tl.id);
|
|
491
|
+
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
492
|
+
}, [fitFocusBounds]);
|
|
493
|
+
const clearThroughlineFocus = useCallback(() => {
|
|
494
|
+
setFocusedThroughlineId(null);
|
|
495
|
+
setFocusedStepIndex(null);
|
|
496
|
+
}, []);
|
|
497
|
+
// Focus a single step's edge on the canvas. The step's file:line is listed
|
|
498
|
+
// in the row; we don't open the drawer from here.
|
|
499
|
+
const focusThroughlineStep = useCallback((tl, stepIndex) => {
|
|
500
|
+
const step = tl.steps[stepIndex];
|
|
501
|
+
if (!step)
|
|
502
|
+
return;
|
|
503
|
+
setSelected(null);
|
|
504
|
+
setSelectedEdgeId(null);
|
|
505
|
+
setFocusedStepIndex(stepIndex);
|
|
506
|
+
setFocusedThroughlineId(tl.id);
|
|
507
|
+
fitFocusBounds(new Set([step.edgeId]));
|
|
508
|
+
}, [fitFocusBounds]);
|
|
509
|
+
const toggleThroughlineCollapsed = useCallback((tlId) => {
|
|
510
|
+
setExpandedThroughlines((prev) => {
|
|
511
|
+
const next = new Set(prev);
|
|
512
|
+
if (next.has(tlId))
|
|
513
|
+
next.delete(tlId);
|
|
514
|
+
else
|
|
515
|
+
next.add(tlId);
|
|
516
|
+
return next;
|
|
517
|
+
});
|
|
518
|
+
}, []);
|
|
306
519
|
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
307
520
|
// the declaration panel's file link (toggle + tree sync, no start line).
|
|
308
521
|
useEffect(() => {
|
|
@@ -327,13 +540,17 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
327
540
|
return;
|
|
328
541
|
setSelected(comp);
|
|
329
542
|
setSelectedEdgeId(null);
|
|
543
|
+
setFocusedThroughlineId(null);
|
|
544
|
+
setFocusedStepIndex(null);
|
|
330
545
|
onSelect?.(comp.id);
|
|
331
546
|
}, [components, onSelect]);
|
|
332
547
|
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
333
548
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
334
549
|
// the actual edge path (not node-center approximations).
|
|
335
550
|
const edgeLabels = useMemo(() => {
|
|
336
|
-
return dispEdges
|
|
551
|
+
return dispEdges
|
|
552
|
+
.filter((e) => !e.hidden)
|
|
553
|
+
.map((e) => {
|
|
337
554
|
const d = e.data;
|
|
338
555
|
return {
|
|
339
556
|
id: e.id,
|
|
@@ -341,9 +558,11 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
341
558
|
dimmed: d?.dimmed === true,
|
|
342
559
|
midX: d?.labelX ?? 0,
|
|
343
560
|
midY: d?.labelY ?? 0,
|
|
561
|
+
pathLength: d?.pathLength ?? 0,
|
|
562
|
+
stepNos: selectedFlowStepNos?.get(e.id),
|
|
344
563
|
};
|
|
345
564
|
});
|
|
346
|
-
}, [dispEdges]);
|
|
565
|
+
}, [dispEdges, selectedFlowStepNos]);
|
|
347
566
|
const usedMechanisms = useMemo(() => {
|
|
348
567
|
return new Set(edgeLabels.map((l) => l.mechanism));
|
|
349
568
|
}, [edgeLabels]);
|
|
@@ -372,7 +591,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
372
591
|
const fileViewerRef = useRef(fileViewer);
|
|
373
592
|
fileViewerRef.current = fileViewer;
|
|
374
593
|
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: {
|
|
594
|
+
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
595
|
width: 340,
|
|
377
596
|
minWidth: 340,
|
|
378
597
|
borderRight: `1px solid ${theme.colors.border}`,
|
|
@@ -394,7 +613,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
394
613
|
fontWeight: 600,
|
|
395
614
|
color: theme.colors.text,
|
|
396
615
|
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 && (
|
|
616
|
+
}, 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
617
|
height: '50%',
|
|
399
618
|
minHeight: 160,
|
|
400
619
|
flexShrink: 0,
|
|
@@ -402,17 +621,42 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
402
621
|
display: 'flex',
|
|
403
622
|
flexDirection: 'column',
|
|
404
623
|
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,
|
|
624
|
+
}, children: [hasThroughlines && (_jsx("div", { role: "tablist", "aria-label": "Sidebar view", style: {
|
|
411
625
|
display: 'flex',
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
626
|
+
width: '100%',
|
|
627
|
+
flexShrink: 0,
|
|
628
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
629
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
630
|
+
}, children: ['files', 'flows'].map((view) => (_jsx("button", { type: "button", role: "tab", "aria-selected": sidebarView === view, onClick: () => setSidebarView(view), style: {
|
|
631
|
+
flex: 1,
|
|
632
|
+
minWidth: 0,
|
|
633
|
+
padding: '8px 8px',
|
|
634
|
+
border: 'none',
|
|
635
|
+
borderRadius: 0,
|
|
636
|
+
background: sidebarView === view ? theme.colors.background : 'transparent',
|
|
637
|
+
color: sidebarView === view
|
|
638
|
+
? theme.colors.text
|
|
639
|
+
: theme.colors.textSecondary,
|
|
640
|
+
fontSize: theme.fontSizes[1],
|
|
641
|
+
fontFamily: theme.fonts.monospace,
|
|
642
|
+
textTransform: 'capitalize',
|
|
643
|
+
cursor: 'pointer',
|
|
644
|
+
}, children: view }, view))) })), sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (_jsx("div", { style: {
|
|
645
|
+
flex: 1,
|
|
646
|
+
minHeight: 0,
|
|
647
|
+
overflowY: 'auto',
|
|
648
|
+
padding: '0 4px 12px',
|
|
649
|
+
}, 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) => {
|
|
650
|
+
const groupKey = group.repoKey ?? '__no-repo__';
|
|
651
|
+
const collapsed = collapsedRepos.has(groupKey);
|
|
652
|
+
return (_jsxs("div", { style: {
|
|
653
|
+
flex: collapsed ? '0 0 auto' : 1,
|
|
654
|
+
minHeight: collapsed ? 0 : undefined,
|
|
655
|
+
display: 'flex',
|
|
656
|
+
flexDirection: 'column',
|
|
657
|
+
borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
|
|
658
|
+
}, 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));
|
|
659
|
+
}) })) : 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
660
|
position: 'absolute',
|
|
417
661
|
inset: 0,
|
|
418
662
|
overflow: 'hidden',
|
|
@@ -424,6 +668,16 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
424
668
|
const verifiable = MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] ?? true;
|
|
425
669
|
const screenX = lbl.midX * viewport.zoom + viewport.x;
|
|
426
670
|
const screenY = lbl.midY * viewport.zoom + viewport.y;
|
|
671
|
+
const text = lbl.stepNos?.length
|
|
672
|
+
? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}`
|
|
673
|
+
: lbl.mechanism;
|
|
674
|
+
// Labels stay readable at full size until they'd exceed a share of
|
|
675
|
+
// the edge's screen length, then shrink with zoom.
|
|
676
|
+
const estWidth = text.length * EDGE_LABEL_CHAR_PX + EDGE_LABEL_PAD_PX;
|
|
677
|
+
const screenEdgeLen = lbl.pathLength * viewport.zoom;
|
|
678
|
+
const scale = lbl.pathLength > 0 && estWidth > 0
|
|
679
|
+
? Math.min(1, (screenEdgeLen * EDGE_LABEL_MAX_EDGE_FRACTION) / estWidth)
|
|
680
|
+
: 1;
|
|
427
681
|
return (_jsx("div", { "data-edge-label": lbl.id, title: verifiable ? undefined : 'Not directly verifiable with graphify', onClick: (e) => {
|
|
428
682
|
e.stopPropagation();
|
|
429
683
|
selectEdge(lbl.id);
|
|
@@ -431,19 +685,27 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
431
685
|
position: 'absolute',
|
|
432
686
|
left: screenX,
|
|
433
687
|
top: screenY,
|
|
688
|
+
// Center on the flow-space midpoint. Labels live in screen
|
|
689
|
+
// space (fixed size when zoomed out), so top-left anchoring
|
|
690
|
+
// would drift them right/down of the edge as zoom drops.
|
|
691
|
+
transform: `translate(-50%, -50%) scale(${scale})`,
|
|
692
|
+
transformOrigin: 'center center',
|
|
693
|
+
display: 'flex',
|
|
694
|
+
alignItems: 'center',
|
|
434
695
|
fontSize: 10,
|
|
696
|
+
lineHeight: 1,
|
|
435
697
|
fontFamily: theme.fonts.monospace,
|
|
436
698
|
fontWeight: 500,
|
|
437
699
|
color,
|
|
438
700
|
background: 'rgba(21,21,21,0.9)',
|
|
439
701
|
border: verifiable ? `0.5px solid ${color}` : `1px dashed ${color}`,
|
|
440
702
|
borderRadius: verifiable ? 4 : '10px 14px 12px 16px / 14px 10px 16px 12px',
|
|
441
|
-
padding: '
|
|
703
|
+
padding: '3px 8px',
|
|
442
704
|
cursor: 'pointer',
|
|
443
705
|
pointerEvents: 'auto',
|
|
444
706
|
opacity: lbl.dimmed ? 0.15 : 1,
|
|
445
707
|
whiteSpace: 'nowrap',
|
|
446
|
-
}, children:
|
|
708
|
+
}, children: text }, lbl.id));
|
|
447
709
|
}) })), _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
710
|
width: '100%',
|
|
449
711
|
height: '100%',
|
|
@@ -521,6 +783,130 @@ function RepoGroupHeader({ group, collapsed, onToggle, }) {
|
|
|
521
783
|
whiteSpace: 'nowrap',
|
|
522
784
|
}, children: label })] }));
|
|
523
785
|
}
|
|
786
|
+
/** One collapsible throughline in the sidebar's flows panel. Clicking the
|
|
787
|
+
* title: closed → open + select; open and unselected → select; open and
|
|
788
|
+
* selected → close + clear focus. The right-aligned close button collapses
|
|
789
|
+
* without selecting. A step row focuses that step's edge. */
|
|
790
|
+
function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollapsed, onFocusFlow, onClearFocus, onFocusStep, }) {
|
|
791
|
+
const { theme } = useTheme();
|
|
792
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
793
|
+
const hoverBg = theme.colors.background;
|
|
794
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
795
|
+
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
796
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
797
|
+
const [closeHover, setCloseHover] = useState(false);
|
|
798
|
+
const [hoveredStep, setHoveredStep] = useState(null);
|
|
799
|
+
return (_jsxs("div", { style: { margin: '4px 0', borderRadius: 8 }, children: [_jsxs("div", { onMouseEnter: () => setHeaderHover(true), onMouseLeave: () => setHeaderHover(false), style: {
|
|
800
|
+
display: 'flex',
|
|
801
|
+
alignItems: 'center',
|
|
802
|
+
gap: 4,
|
|
803
|
+
borderRadius: 6,
|
|
804
|
+
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
805
|
+
transition: 'background 120ms ease',
|
|
806
|
+
}, children: [_jsx("button", { type: "button", onClick: () => {
|
|
807
|
+
if (collapsed) {
|
|
808
|
+
onToggleCollapsed(throughline.id);
|
|
809
|
+
onFocusFlow(throughline);
|
|
810
|
+
}
|
|
811
|
+
else if (active === null) {
|
|
812
|
+
onFocusFlow(throughline);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
onToggleCollapsed(throughline.id);
|
|
816
|
+
onClearFocus();
|
|
817
|
+
}
|
|
818
|
+
}, style: {
|
|
819
|
+
flex: 1,
|
|
820
|
+
display: 'flex',
|
|
821
|
+
alignItems: 'center',
|
|
822
|
+
minWidth: 0,
|
|
823
|
+
padding: '6px 8px',
|
|
824
|
+
borderRadius: 6,
|
|
825
|
+
border: 'none',
|
|
826
|
+
background: 'transparent',
|
|
827
|
+
textAlign: 'left',
|
|
828
|
+
cursor: 'pointer',
|
|
829
|
+
}, children: _jsx("span", { style: {
|
|
830
|
+
overflow: 'hidden',
|
|
831
|
+
textOverflow: 'ellipsis',
|
|
832
|
+
whiteSpace: 'nowrap',
|
|
833
|
+
fontSize: theme.fontSizes[1],
|
|
834
|
+
fontFamily: theme.fonts.monospace,
|
|
835
|
+
fontWeight: 600,
|
|
836
|
+
color: theme.colors.text,
|
|
837
|
+
}, children: throughline.title }) }), !collapsed && (_jsx("button", { type: "button", "aria-label": `Close ${throughline.title}`, onMouseEnter: () => setCloseHover(true), onMouseLeave: () => setCloseHover(false), onClick: (e) => {
|
|
838
|
+
e.stopPropagation();
|
|
839
|
+
onToggleCollapsed(throughline.id);
|
|
840
|
+
if (active !== null)
|
|
841
|
+
onClearFocus();
|
|
842
|
+
}, style: {
|
|
843
|
+
display: 'inline-flex',
|
|
844
|
+
alignItems: 'center',
|
|
845
|
+
justifyContent: 'center',
|
|
846
|
+
flexShrink: 0,
|
|
847
|
+
width: 22,
|
|
848
|
+
height: 22,
|
|
849
|
+
marginRight: 4,
|
|
850
|
+
padding: 0,
|
|
851
|
+
border: 'none',
|
|
852
|
+
borderRadius: 4,
|
|
853
|
+
background: closeHover ? theme.colors.border : 'transparent',
|
|
854
|
+
color: closeHover ? theme.colors.text : muted,
|
|
855
|
+
cursor: 'pointer',
|
|
856
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
857
|
+
}, children: _jsx(X, { size: 12, strokeWidth: 2 }) }))] }), !collapsed && (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 1 }, children: throughline.steps.map((step, i) => {
|
|
858
|
+
const edge = edgeById.get(step.edgeId);
|
|
859
|
+
const mech = edge?.mechanism;
|
|
860
|
+
const color = mech ? MECHANISM_COLOR[mech] : muted;
|
|
861
|
+
const stepActive = active !== null && active.stepIndex === i;
|
|
862
|
+
return (_jsxs("button", { type: "button", onMouseEnter: () => setHoveredStep(i), onMouseLeave: () => setHoveredStep(null), onClick: () => onFocusStep(throughline, i), style: {
|
|
863
|
+
display: 'flex',
|
|
864
|
+
alignItems: 'center',
|
|
865
|
+
gap: 8,
|
|
866
|
+
minWidth: 0,
|
|
867
|
+
padding: '4px 8px 4px 12px',
|
|
868
|
+
textAlign: 'left',
|
|
869
|
+
borderRadius: 6,
|
|
870
|
+
border: 'none',
|
|
871
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
872
|
+
cursor: 'pointer',
|
|
873
|
+
transition: 'background 120ms ease',
|
|
874
|
+
}, children: [_jsx("span", { style: {
|
|
875
|
+
flexShrink: 0,
|
|
876
|
+
width: 14,
|
|
877
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
878
|
+
fontFamily: theme.fonts.monospace,
|
|
879
|
+
color: stepActive ? theme.colors.text : muted,
|
|
880
|
+
}, children: i + 1 }), step.symbol ? (_jsx("span", { style: {
|
|
881
|
+
flex: 1,
|
|
882
|
+
minWidth: 0,
|
|
883
|
+
overflow: 'hidden',
|
|
884
|
+
textOverflow: 'ellipsis',
|
|
885
|
+
whiteSpace: 'nowrap',
|
|
886
|
+
fontSize: theme.fontSizes[0],
|
|
887
|
+
fontFamily: theme.fonts.monospace,
|
|
888
|
+
color: stepActive ? theme.colors.text : muted,
|
|
889
|
+
}, children: step.symbol })) : (_jsxs(_Fragment, { children: [_jsx("span", { style: {
|
|
890
|
+
flexShrink: 0,
|
|
891
|
+
width: 74,
|
|
892
|
+
overflow: 'hidden',
|
|
893
|
+
textOverflow: 'ellipsis',
|
|
894
|
+
whiteSpace: 'nowrap',
|
|
895
|
+
fontSize: theme.fontSizes[0],
|
|
896
|
+
fontFamily: theme.fonts.monospace,
|
|
897
|
+
color,
|
|
898
|
+
}, children: mech ?? step.edgeId }), _jsxs("span", { style: {
|
|
899
|
+
flex: 1,
|
|
900
|
+
minWidth: 0,
|
|
901
|
+
overflow: 'hidden',
|
|
902
|
+
textOverflow: 'ellipsis',
|
|
903
|
+
whiteSpace: 'nowrap',
|
|
904
|
+
fontSize: theme.fontSizes[0],
|
|
905
|
+
fontFamily: theme.fonts.monospace,
|
|
906
|
+
color: stepActive ? theme.colors.text : muted,
|
|
907
|
+
}, children: [step.file.split('/').pop(), _jsxs("span", { style: { opacity: 0.7 }, children: [":", step.line] })] })] }))] }, `${step.edgeId}-${i}`));
|
|
908
|
+
}) }))] }));
|
|
909
|
+
}
|
|
524
910
|
export function SubsystemComponentGraph(props) {
|
|
525
911
|
const wrapRef = useRef(null);
|
|
526
912
|
const [size, setSize] = useState(null);
|