@principal-ai/principal-view-react 0.16.56 → 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/graphify/consolidated.d.ts +16 -1
- package/dist/graphify/consolidated.d.ts.map +1 -1
- package/dist/graphify/index.d.ts +1 -1
- package/dist/graphify/index.d.ts.map +1 -1
- package/dist/graphify/index.js.map +1 -1
- package/dist/graphify/kind.d.ts +1 -1
- package/dist/graphify/kind.d.ts.map +1 -1
- package/dist/graphify/kind.js +7 -1
- package/dist/graphify/kind.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pierre/constructColors.d.ts +24 -0
- package/dist/pierre/constructColors.d.ts.map +1 -0
- package/dist/pierre/constructColors.js +91 -0
- package/dist/pierre/constructColors.js.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +8 -0
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -0
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js +116 -0
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -0
- package/dist/subsystem/ComponentDeclaration.d.ts +2 -2
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +12 -11
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +401 -40
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/formatDeclaration.d.ts.map +1 -1
- package/dist/subsystem/formatDeclaration.js +49 -10
- package/dist/subsystem/formatDeclaration.js.map +1 -1
- package/dist/subsystem/model.d.ts +89 -12
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +55 -25
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +32 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +122 -24
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/tokenizeComponent.js +1 -1
- package/dist/subsystem/tokenizeComponent.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/graphify/consolidated.ts +18 -1
- package/src/graphify/index.ts +1 -0
- package/src/graphify/kind.ts +7 -1
- package/src/index.ts +3 -2
- package/src/pierre/constructColors.test.ts +52 -0
- package/src/pierre/constructColors.ts +112 -0
- package/src/stories/{BoundaryNodes.stories.tsx → Audit/BoundaryNodes.stories.tsx} +1 -1
- package/src/stories/{CanvasEdgeTypes.stories.tsx → Audit/CanvasEdgeTypes.stories.tsx} +1 -1
- package/src/stories/{CanvasNodeTypes.stories.tsx → Audit/CanvasNodeTypes.stories.tsx} +1 -1
- package/src/stories/{ColorPriority.stories.tsx → Audit/ColorPriority.stories.tsx} +1 -1
- package/src/stories/{DiamondBadges.stories.tsx → Audit/DiamondBadges.stories.tsx} +1 -1
- package/src/stories/{NodeDimensionsTesting.stories.tsx → Audit/NodeDimensionsTesting.stories.tsx} +2 -2
- package/src/stories/{NodeFieldsAudit.stories.tsx → Audit/NodeFieldsAudit.stories.tsx} +1 -1
- package/src/stories/{NodeFontSizeTesting.stories.tsx → Audit/NodeFontSizeTesting.stories.tsx} +2 -2
- package/src/stories/{NodeShapes.stories.tsx → Audit/NodeShapes.stories.tsx} +1 -1
- package/src/stories/{WideNodesTesting.stories.tsx → Audit/WideNodesTesting.stories.tsx} +2 -2
- package/src/stories/{dashboard → Components}/DashboardRenderer.stories.tsx +2 -2
- package/src/stories/{GraphRenderer.stories.tsx → Components/GraphRenderer.stories.tsx} +2 -2
- package/src/stories/{MultiCanvasRenderer.stories.tsx → Components/MultiCanvasRenderer.stories.tsx} +2 -2
- package/src/stories/{SequenceDiagram.stories.tsx → Components/SequenceDiagram.stories.tsx} +2 -2
- package/src/stories/{StateView.stories.tsx → Components/StateView.stories.tsx} +2 -2
- package/src/stories/{TraceViewer.stories.tsx → Components/TraceViewer.stories.tsx} +3 -3
- package/src/stories/{WorkflowSequenceDiagram.stories.tsx → Components/WorkflowSequenceDiagram.stories.tsx} +1 -1
- package/src/stories/{AnimationWorkshop.stories.tsx → Features/AnimationWorkshop.stories.tsx} +5 -5
- package/src/stories/{ChangeTypeVisual.stories.tsx → Features/ChangeTypeVisual.stories.tsx} +2 -2
- package/src/stories/{EventDrivenAnimations.stories.tsx → Features/EventDrivenAnimations.stories.tsx} +2 -2
- package/src/stories/{MultiConfig.stories.tsx → Features/MultiConfig.stories.tsx} +2 -2
- package/src/stories/{ElkEdgeRouting.stories.tsx → Layout/ElkEdgeRouting.stories.tsx} +2 -2
- package/src/stories/{GraphOrientation.stories.tsx → Layout/GraphOrientation.stories.tsx} +3 -3
- package/src/stories/{MultiDirectionalConnections.stories.tsx → Layout/MultiDirectionalConnections.stories.tsx} +2 -2
- package/src/stories/{NodeDefinitionComparison.stories.tsx → Nodes/NodeDefinitionComparison.stories.tsx} +2 -2
- package/src/stories/{NodeNameFallbacks.stories.tsx → Nodes/NodeNameFallbacks.stories.tsx} +2 -2
- package/src/stories/{NumericColors.stories.tsx → Nodes/NumericColors.stories.tsx} +2 -2
- package/src/stories/{EventNameDisplay.stories.tsx → OTEL/EventNameDisplay.stories.tsx} +2 -2
- package/src/stories/{EventNameFallback.stories.tsx → OTEL/EventNameFallback.stories.tsx} +2 -2
- package/src/stories/{OtelComponents.stories.tsx → OTEL/OtelComponents.stories.tsx} +3 -3
- package/src/stories/{OtelNodeTypesPrototype.stories.tsx → OTEL/OtelNodeTypesPrototype.stories.tsx} +2 -2
- package/src/stories/{RawEventCards.stories.tsx → OTEL/RawEventCards.stories.tsx} +3 -3
- package/src/stories/{SpanBadges.stories.tsx → OTEL/SpanBadges.stories.tsx} +1 -1
- package/src/stories/{ValidationEventsCanvas.stories.tsx → OTEL/ValidationEventsCanvas.stories.tsx} +3 -3
- package/src/stories/{BacklogMdCanvases.stories.tsx → RealWorld/BacklogMdCanvases.stories.tsx} +2 -2
- package/src/stories/{FileCitySequence.stories.tsx → RealWorld/FileCitySequence.stories.tsx} +4 -4
- package/src/stories/{RealCanvasFiles.stories.tsx → RealWorld/RealCanvasFiles.stories.tsx} +7 -7
- package/src/stories/{ComponentDeclarationAudit.stories.tsx → Subsystem/ComponentDeclarationAudit.stories.tsx} +19 -20
- package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +365 -0
- package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +137 -0
- package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +238 -0
- package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +315 -0
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +181 -0
- package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +959 -0
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +646 -0
- package/src/stories/Subsystem/ComponentGraph/fixtures.ts +129 -0
- package/src/subsystem/ComponentDeclaration.tsx +13 -13
- package/src/subsystem/SubsystemComponentGraph.tsx +558 -44
- package/src/subsystem/formatDeclaration.ts +57 -11
- package/src/subsystem/model.test.ts +38 -6
- package/src/subsystem/model.ts +145 -32
- package/src/subsystem/nodes.test.ts +56 -0
- package/src/subsystem/nodes.tsx +161 -31
- package/src/subsystem/tokenizeComponent.ts +1 -1
- package/src/utils/elkLayout.ts +4 -3
- package/src/stories/SubsystemComponentGraph.stories.tsx +0 -1225
- /package/src/stories/{dashboard/sample-dashboards → data}/activity-feed-analytics.dashboard.json +0 -0
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
applyNodeChanges,
|
|
33
33
|
} from '@xyflow/react';
|
|
34
34
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
35
|
-
import { Map as MapIcon } from 'lucide-react';
|
|
35
|
+
import { Map as MapIcon, X } from 'lucide-react';
|
|
36
36
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
37
37
|
import {
|
|
38
38
|
buildSubsystemGraph,
|
|
@@ -41,9 +41,10 @@ import {
|
|
|
41
41
|
type SubsystemComponentEdge,
|
|
42
42
|
type SubsystemComponent,
|
|
43
43
|
type SubsystemEdgeMechanism,
|
|
44
|
+
type SubsystemThroughline,
|
|
44
45
|
} from './model';
|
|
45
46
|
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
46
|
-
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
|
|
47
|
+
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
47
48
|
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
48
49
|
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
49
50
|
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
@@ -55,6 +56,15 @@ import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
|
55
56
|
export interface SubsystemComponentGraphProps {
|
|
56
57
|
components: SubsystemComponent[];
|
|
57
58
|
edges: SubsystemComponentEdge[];
|
|
59
|
+
/**
|
|
60
|
+
* Ordered execution stories over the graph's edges — one throughline per
|
|
61
|
+
* flow. When present the sidebar's bottom half offers a Files/Flows toggle:
|
|
62
|
+
* the flows panel lists each throughline's steps (`symbol` or `file:line`);
|
|
63
|
+
* clicking a flow row toggles its steps; clicking a step focuses that
|
|
64
|
+
* step's edge. Opened flows stay on the canvas (unselected ones dimmed);
|
|
65
|
+
* everything else is hidden.
|
|
66
|
+
*/
|
|
67
|
+
throughlines?: SubsystemThroughline[];
|
|
58
68
|
onSelect?: (componentId: string) => void;
|
|
59
69
|
/** Called when an edge is clicked (relationship / mechanism + refs seam). */
|
|
60
70
|
onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
|
|
@@ -126,7 +136,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
126
136
|
measured: { w: number; h: number } | null;
|
|
127
137
|
}
|
|
128
138
|
|
|
129
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
139
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
130
140
|
const { theme } = useTheme();
|
|
131
141
|
const { fitView } = useReactFlow();
|
|
132
142
|
const viewport = useViewport();
|
|
@@ -147,6 +157,18 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
147
157
|
const [legendOpen, setLegendOpen] = useState(false);
|
|
148
158
|
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
149
159
|
const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
|
|
160
|
+
// Throughline focus — selected flow (or step) is full strength; other
|
|
161
|
+
// opened-flow members stay visible but dimmed; everything else is hidden.
|
|
162
|
+
const [focusedThroughlineId, setFocusedThroughlineId] = useState<string | null>(null);
|
|
163
|
+
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
164
|
+
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
|
|
165
|
+
// Sidebar bottom half: which panel is shown when throughlines exist.
|
|
166
|
+
const [sidebarView, setSidebarView] = useState<'files' | 'flows'>(() =>
|
|
167
|
+
throughlines?.length ? 'flows' : 'files',
|
|
168
|
+
);
|
|
169
|
+
// Throughline flows the user has expanded (via the title row). Closed by
|
|
170
|
+
// default so a graph with several flows doesn't dump every step list at once.
|
|
171
|
+
const [expandedThroughlines, setExpandedThroughlines] = useState<Set<string>>(new Set());
|
|
150
172
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
151
173
|
// closure over the effect deps) can toggle without a stale value.
|
|
152
174
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
@@ -181,11 +203,19 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
181
203
|
measuredDimsRef.current = new Map();
|
|
182
204
|
prevMeasuredSigRef.current = '';
|
|
183
205
|
const doc = { components: componentsRef.current, edges: edgesRef.current };
|
|
184
|
-
void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels })
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
206
|
+
void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels })
|
|
207
|
+
.then(({ nodes, edges: e }) => {
|
|
208
|
+
if (!alive) return;
|
|
209
|
+
setBuilt({ nodes, edges: e as Edge[] });
|
|
210
|
+
setLayoutReady(false);
|
|
211
|
+
})
|
|
212
|
+
.catch((err) => {
|
|
213
|
+
console.warn('[subsystem-graph] initial layout failed:', err);
|
|
214
|
+
if (!alive) return;
|
|
215
|
+
// Reveal the cover even on failure so the UI is not stuck forever.
|
|
216
|
+
setBuilt({ nodes: [], edges: [] });
|
|
217
|
+
setLayoutReady(true);
|
|
218
|
+
});
|
|
189
219
|
return () => { alive = false; };
|
|
190
220
|
}, [layoutKey, maxNodeWidth, showEdgeLabels]);
|
|
191
221
|
|
|
@@ -237,6 +267,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
237
267
|
}
|
|
238
268
|
const src = edgeById.get(edgeId);
|
|
239
269
|
setSelectedEdgeId(edgeId);
|
|
270
|
+
// A direct edge selection on the canvas supersedes any throughline focus.
|
|
271
|
+
setFocusedThroughlineId(null);
|
|
272
|
+
setFocusedStepIndex(null);
|
|
240
273
|
if (src) onEdgeSelect?.(src);
|
|
241
274
|
},
|
|
242
275
|
[edgeById, onEdgeSelect],
|
|
@@ -255,6 +288,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
255
288
|
}
|
|
256
289
|
setSelected(comp);
|
|
257
290
|
setSelectedEdgeId(null);
|
|
291
|
+
setFocusedThroughlineId(null);
|
|
292
|
+
setFocusedStepIndex(null);
|
|
258
293
|
onSelect?.(id);
|
|
259
294
|
}
|
|
260
295
|
};
|
|
@@ -273,26 +308,120 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
273
308
|
const xyflowNodesBase = nodes as Node[];
|
|
274
309
|
const baseEdges = convertedEdges as Edge[];
|
|
275
310
|
|
|
311
|
+
// When an edge is selected, dim every other edge + its label to focus it.
|
|
312
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
313
|
+
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
314
|
+
// without a stale closure value.
|
|
315
|
+
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
316
|
+
selectedEdgeIdRef.current = selectedEdgeId;
|
|
317
|
+
// Edge ids in throughline focus (an active flow's edge set, or a single
|
|
318
|
+
// step's edge). Used to frame the camera. `null` = no throughline focus.
|
|
319
|
+
const focusEdgeIds = useMemo(() => {
|
|
320
|
+
if (focusedThroughlineId == null || !throughlines) return null;
|
|
321
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
322
|
+
if (!tl) return null;
|
|
323
|
+
if (focusedStepIndex != null) {
|
|
324
|
+
const step = tl.steps[focusedStepIndex];
|
|
325
|
+
return step ? new Set([step.edgeId]) : null;
|
|
326
|
+
}
|
|
327
|
+
return new Set(tl.steps.map((s) => s.edgeId));
|
|
328
|
+
}, [throughlines, focusedThroughlineId, focusedStepIndex]);
|
|
329
|
+
|
|
330
|
+
// 1-based step numbers per edge of the selected flow (an edge can appear
|
|
331
|
+
// in more than one step).
|
|
332
|
+
const selectedFlowStepNos = useMemo(() => {
|
|
333
|
+
if (focusedThroughlineId == null || !throughlines) return null;
|
|
334
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
335
|
+
if (!tl) return null;
|
|
336
|
+
const map = new Map<string, number[]>();
|
|
337
|
+
tl.steps.forEach((s, i) => {
|
|
338
|
+
const list = map.get(s.edgeId) ?? [];
|
|
339
|
+
list.push(i + 1);
|
|
340
|
+
map.set(s.edgeId, list);
|
|
341
|
+
});
|
|
342
|
+
return map;
|
|
343
|
+
}, [throughlines, focusedThroughlineId]);
|
|
344
|
+
|
|
345
|
+
// Union of every expanded (opened) throughline's edges — the visible set.
|
|
346
|
+
const openedEdgeIds = useMemo(() => {
|
|
347
|
+
if (!throughlines || expandedThroughlines.size === 0) return null;
|
|
348
|
+
const ids = new Set<string>();
|
|
349
|
+
for (const tl of throughlines) {
|
|
350
|
+
if (!expandedThroughlines.has(tl.id)) continue;
|
|
351
|
+
for (const s of tl.steps) ids.add(s.edgeId);
|
|
352
|
+
}
|
|
353
|
+
return ids.size > 0 ? ids : null;
|
|
354
|
+
}, [throughlines, expandedThroughlines]);
|
|
355
|
+
|
|
356
|
+
const endpointsOf = (edgeIds: ReadonlySet<string> | null): Set<string> | null => {
|
|
357
|
+
if (!edgeIds) return null;
|
|
358
|
+
const ids = new Set<string>();
|
|
359
|
+
for (const e of baseEdges) {
|
|
360
|
+
if (!edgeIds.has(e.id)) continue;
|
|
361
|
+
ids.add(e.source);
|
|
362
|
+
ids.add(e.target);
|
|
363
|
+
}
|
|
364
|
+
return ids.size > 0 ? ids : null;
|
|
365
|
+
};
|
|
366
|
+
const openedNodeIds = useMemo(
|
|
367
|
+
() => endpointsOf(openedEdgeIds),
|
|
368
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
369
|
+
[baseEdges, openedEdgeIds],
|
|
370
|
+
);
|
|
371
|
+
// Endpoints of the bright set: the selected step's edge, or the whole flow
|
|
372
|
+
// when no step is focused.
|
|
373
|
+
const brightNodeIds = useMemo(
|
|
374
|
+
() => endpointsOf(focusEdgeIds),
|
|
375
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
376
|
+
[baseEdges, focusEdgeIds],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Source + target of the focused step/flow (or a canvas-selected edge). If a
|
|
380
|
+
// file is open, those endpoints stay undimmed even when they don't live in
|
|
381
|
+
// that file.
|
|
382
|
+
const focusNodeIds = useMemo(() => {
|
|
383
|
+
if (brightNodeIds) return brightNodeIds;
|
|
384
|
+
if (selectedEdgeId) {
|
|
385
|
+
const e = baseEdges.find((x) => x.id === selectedEdgeId);
|
|
386
|
+
if (e) return new Set([e.source, e.target]);
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}, [baseEdges, brightNodeIds, selectedEdgeId]);
|
|
390
|
+
|
|
276
391
|
// While a file is open in the drawer, tag each node with whether its
|
|
277
392
|
// component lives in that file — the node renderer spotlights matches and
|
|
278
393
|
// dims non-matches (mirrors the edge-dimming behavior on selection).
|
|
394
|
+
// Opened-but-unselected members are dimmed; a selected step further dims
|
|
395
|
+
// the rest of its own flow. Nodes not on any opened flow are hidden.
|
|
279
396
|
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
280
|
-
// React Flow's own selection state from
|
|
397
|
+
// React Flow's own selection state from updating.
|
|
281
398
|
const dispNodes = useMemo(() => {
|
|
282
399
|
return xyflowNodesBase.map((n) => {
|
|
283
400
|
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
284
|
-
const fileMatch =
|
|
401
|
+
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
285
402
|
const isSelected = selected?.id !== undefined && comp?.id === selected.id;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
403
|
+
const vis = flowElementVisibility({
|
|
404
|
+
inOpened: openedNodeIds?.has(n.id) === true,
|
|
405
|
+
inSelected: brightNodeIds?.has(n.id) === true,
|
|
406
|
+
anyOpened: openedNodeIds != null,
|
|
407
|
+
anySelected: brightNodeIds != null,
|
|
408
|
+
});
|
|
409
|
+
if (fileMatch === undefined && !isSelected && !vis.dimmed) {
|
|
410
|
+
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
411
|
+
return { ...n, hidden: vis.hidden, data: rest };
|
|
289
412
|
}
|
|
290
413
|
return {
|
|
291
414
|
...n,
|
|
292
|
-
|
|
415
|
+
hidden: vis.hidden,
|
|
416
|
+
data: {
|
|
417
|
+
...(n.data as object),
|
|
418
|
+
...(fileMatch !== undefined && { fileMatch }),
|
|
419
|
+
...(isSelected && { isSelected }),
|
|
420
|
+
...(vis.dimmed && { dimmed: true }),
|
|
421
|
+
},
|
|
293
422
|
};
|
|
294
423
|
});
|
|
295
|
-
}, [xyflowNodesBase, openFile, selected]);
|
|
424
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
|
|
296
425
|
|
|
297
426
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
298
427
|
const baseEdgesKey = useMemo(
|
|
@@ -300,22 +429,33 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
300
429
|
[convertedEdges],
|
|
301
430
|
);
|
|
302
431
|
|
|
303
|
-
// When an edge is selected, dim every other edge + its label to focus it.
|
|
304
|
-
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
305
|
-
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
306
|
-
// without a stale closure value.
|
|
307
|
-
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
308
|
-
selectedEdgeIdRef.current = selectedEdgeId;
|
|
309
432
|
const dispEdges = useMemo(() => {
|
|
433
|
+
const paint = (e: Edge, dimmed: boolean): Edge => {
|
|
434
|
+
const markerEnd = e.markerEnd;
|
|
435
|
+
const nextMarker =
|
|
436
|
+
dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
|
|
437
|
+
? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
|
|
438
|
+
: markerEnd;
|
|
439
|
+
return {
|
|
440
|
+
...e,
|
|
441
|
+
data: { ...(e.data as object), dimmed },
|
|
442
|
+
markerEnd: nextMarker,
|
|
443
|
+
};
|
|
444
|
+
};
|
|
445
|
+
if (openedEdgeIds || focusEdgeIds) {
|
|
446
|
+
return baseEdges.map((e) => {
|
|
447
|
+
const vis = flowElementVisibility({
|
|
448
|
+
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
449
|
+
inSelected: focusEdgeIds?.has(e.id) === true,
|
|
450
|
+
anyOpened: openedEdgeIds != null,
|
|
451
|
+
anySelected: focusEdgeIds != null,
|
|
452
|
+
});
|
|
453
|
+
return { ...paint(e, vis.dimmed), hidden: vis.hidden };
|
|
454
|
+
});
|
|
455
|
+
}
|
|
310
456
|
if (!selectedEdgeId) return baseEdges;
|
|
311
|
-
return baseEdges.map((e) => (
|
|
312
|
-
|
|
313
|
-
data: {
|
|
314
|
-
...(e.data as object),
|
|
315
|
-
dimmed: e.id !== selectedEdgeId,
|
|
316
|
-
},
|
|
317
|
-
}));
|
|
318
|
-
}, [baseEdges, selectedEdgeId]);
|
|
457
|
+
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
458
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
|
|
319
459
|
|
|
320
460
|
const onNodesChange = useCallback(
|
|
321
461
|
(changes: NodeChange[]) => {
|
|
@@ -355,6 +495,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
355
495
|
(_e, node: Node) => {
|
|
356
496
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
357
497
|
setSelectedEdgeId(null);
|
|
498
|
+
setFocusedThroughlineId(null);
|
|
499
|
+
setFocusedStepIndex(null);
|
|
358
500
|
if (node.type === 'subsystem-component' && comp) {
|
|
359
501
|
// Clicking the already-selected node unselects it (toggle off).
|
|
360
502
|
// Selection is independent of the file drawer — nodes never open it.
|
|
@@ -380,11 +522,14 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
380
522
|
const onPaneClick = useCallback(() => {
|
|
381
523
|
setSelected(null);
|
|
382
524
|
setSelectedEdgeId(null);
|
|
525
|
+
setFocusedThroughlineId(null);
|
|
526
|
+
setFocusedStepIndex(null);
|
|
383
527
|
}, []);
|
|
384
528
|
|
|
385
529
|
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
386
530
|
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
387
531
|
const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
|
|
532
|
+
const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
|
|
388
533
|
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
|
|
389
534
|
const toggleRepoCollapsed = useCallback((key: string) => {
|
|
390
535
|
setCollapsedRepos((prev) => {
|
|
@@ -426,6 +571,83 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
426
571
|
[onFileSelect],
|
|
427
572
|
);
|
|
428
573
|
|
|
574
|
+
// Camera helper shared by the throughline interactions: frames the focused
|
|
575
|
+
// edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
|
|
576
|
+
// live positions + measured dims — so the frame always includes BOTH
|
|
577
|
+
// components the edge attaches to (and therefore the edge line between them).
|
|
578
|
+
const fitFocusBounds = useCallback(
|
|
579
|
+
(ids: ReadonlySet<string>) => {
|
|
580
|
+
const nodeIds = new Set<string>();
|
|
581
|
+
for (const e of baseEdges) {
|
|
582
|
+
if (!ids.has(e.id)) continue;
|
|
583
|
+
nodeIds.add(e.source);
|
|
584
|
+
nodeIds.add(e.target);
|
|
585
|
+
}
|
|
586
|
+
if (nodeIds.size === 0) return;
|
|
587
|
+
fitView({
|
|
588
|
+
nodes: [...nodeIds].map((id) => ({ id })),
|
|
589
|
+
padding: 0.25,
|
|
590
|
+
duration: 300,
|
|
591
|
+
});
|
|
592
|
+
},
|
|
593
|
+
[baseEdges, fitView],
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
597
|
+
// frame the flow on the canvas. Selection state is cleared — the graph now
|
|
598
|
+
// reads as the narrative.
|
|
599
|
+
const focusThroughlineEdges = useCallback(
|
|
600
|
+
(tl: SubsystemThroughline) => {
|
|
601
|
+
setSelected(null);
|
|
602
|
+
setSelectedEdgeId(null);
|
|
603
|
+
setFocusedStepIndex(null);
|
|
604
|
+
setFocusedThroughlineId(tl.id);
|
|
605
|
+
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
606
|
+
},
|
|
607
|
+
[fitFocusBounds],
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
const clearThroughlineFocus = useCallback(() => {
|
|
611
|
+
setFocusedThroughlineId(null);
|
|
612
|
+
setFocusedStepIndex(null);
|
|
613
|
+
}, []);
|
|
614
|
+
|
|
615
|
+
// Focus a single step's edge on the canvas. The step's file:line is listed
|
|
616
|
+
// in the row; we don't open the drawer from here.
|
|
617
|
+
const focusThroughlineStep = useCallback(
|
|
618
|
+
(tl: SubsystemThroughline, stepIndex: number) => {
|
|
619
|
+
const step = tl.steps[stepIndex];
|
|
620
|
+
if (!step) return;
|
|
621
|
+
setSelected(null);
|
|
622
|
+
setSelectedEdgeId(null);
|
|
623
|
+
setFocusedStepIndex(stepIndex);
|
|
624
|
+
setFocusedThroughlineId(tl.id);
|
|
625
|
+
fitFocusBounds(new Set([step.edgeId]));
|
|
626
|
+
},
|
|
627
|
+
[fitFocusBounds],
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
const toggleThroughlineCollapsed = useCallback((tlId: string) => {
|
|
631
|
+
setExpandedThroughlines((prev) => {
|
|
632
|
+
const next = new Set(prev);
|
|
633
|
+
if (next.has(tlId)) next.delete(tlId);
|
|
634
|
+
else next.add(tlId);
|
|
635
|
+
return next;
|
|
636
|
+
});
|
|
637
|
+
}, []);
|
|
638
|
+
|
|
639
|
+
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
640
|
+
// the declaration panel's file link (toggle + tree sync, no start line).
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
SUBSYSTEM_CALLBACKS.onOpenFile = (componentId: string) => {
|
|
643
|
+
const comp = components.find((c) => c.id === componentId);
|
|
644
|
+
if (comp?.file) onOpenDeclarationFile(comp.file);
|
|
645
|
+
};
|
|
646
|
+
return () => {
|
|
647
|
+
SUBSYSTEM_CALLBACKS.onOpenFile = undefined;
|
|
648
|
+
};
|
|
649
|
+
}, [components, onOpenDeclarationFile]);
|
|
650
|
+
|
|
429
651
|
// Detail-panel links: related-name clicks select the matching component —
|
|
430
652
|
// resolved by id, name, or symbol (call labels may carry a trailing `()`).
|
|
431
653
|
const resolveRelatedComponent = useCallback(
|
|
@@ -441,6 +663,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
441
663
|
if (!comp || comp.id === selectedRef.current?.id) return;
|
|
442
664
|
setSelected(comp);
|
|
443
665
|
setSelectedEdgeId(null);
|
|
666
|
+
setFocusedThroughlineId(null);
|
|
667
|
+
setFocusedStepIndex(null);
|
|
444
668
|
onSelect?.(comp.id);
|
|
445
669
|
},
|
|
446
670
|
[components, onSelect],
|
|
@@ -450,17 +674,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
450
674
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
451
675
|
// the actual edge path (not node-center approximations).
|
|
452
676
|
const edgeLabels = useMemo(() => {
|
|
453
|
-
return dispEdges
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
677
|
+
return dispEdges
|
|
678
|
+
.filter((e) => !e.hidden)
|
|
679
|
+
.map((e) => {
|
|
680
|
+
const d = e.data as { mechanism?: string; dimmed?: boolean; labelX?: number; labelY?: number } | undefined;
|
|
681
|
+
return {
|
|
682
|
+
id: e.id,
|
|
683
|
+
mechanism: d?.mechanism ?? 'imports',
|
|
684
|
+
dimmed: d?.dimmed === true,
|
|
685
|
+
midX: d?.labelX ?? 0,
|
|
686
|
+
midY: d?.labelY ?? 0,
|
|
687
|
+
stepNos: selectedFlowStepNos?.get(e.id),
|
|
688
|
+
};
|
|
689
|
+
});
|
|
690
|
+
}, [dispEdges, selectedFlowStepNos]);
|
|
464
691
|
|
|
465
692
|
const usedMechanisms = useMemo(() => {
|
|
466
693
|
return new Set(edgeLabels.map((l) => l.mechanism));
|
|
@@ -501,8 +728,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
501
728
|
|
|
502
729
|
return (
|
|
503
730
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
504
|
-
{/* Sidebar: scrollable title/description on top,
|
|
505
|
-
{(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (
|
|
731
|
+
{/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
|
|
732
|
+
{(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
506
733
|
<div
|
|
507
734
|
style={{
|
|
508
735
|
width: 340,
|
|
@@ -556,7 +783,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
556
783
|
)}
|
|
557
784
|
{sidebarAfterDescription}
|
|
558
785
|
</div>
|
|
559
|
-
{treeFilePaths.length > 0 && (
|
|
786
|
+
{(treeFilePaths.length > 0 || hasThroughlines) && (
|
|
560
787
|
<div
|
|
561
788
|
style={{
|
|
562
789
|
height: '50%',
|
|
@@ -568,6 +795,72 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
568
795
|
overflow: 'hidden',
|
|
569
796
|
}}
|
|
570
797
|
>
|
|
798
|
+
{hasThroughlines && (
|
|
799
|
+
<div
|
|
800
|
+
role="tablist"
|
|
801
|
+
aria-label="Sidebar view"
|
|
802
|
+
style={{
|
|
803
|
+
display: 'flex',
|
|
804
|
+
width: '100%',
|
|
805
|
+
flexShrink: 0,
|
|
806
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
807
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
808
|
+
}}
|
|
809
|
+
>
|
|
810
|
+
{(['files', 'flows'] as const).map((view) => (
|
|
811
|
+
<button
|
|
812
|
+
key={view}
|
|
813
|
+
type="button"
|
|
814
|
+
role="tab"
|
|
815
|
+
aria-selected={sidebarView === view}
|
|
816
|
+
onClick={() => setSidebarView(view)}
|
|
817
|
+
style={{
|
|
818
|
+
flex: 1,
|
|
819
|
+
minWidth: 0,
|
|
820
|
+
padding: '8px 8px',
|
|
821
|
+
border: 'none',
|
|
822
|
+
borderRadius: 0,
|
|
823
|
+
background: sidebarView === view ? theme.colors.background : 'transparent',
|
|
824
|
+
color:
|
|
825
|
+
sidebarView === view
|
|
826
|
+
? theme.colors.text
|
|
827
|
+
: theme.colors.textSecondary,
|
|
828
|
+
fontSize: theme.fontSizes[1],
|
|
829
|
+
fontFamily: theme.fonts.monospace,
|
|
830
|
+
textTransform: 'capitalize',
|
|
831
|
+
cursor: 'pointer',
|
|
832
|
+
}}
|
|
833
|
+
>
|
|
834
|
+
{view}
|
|
835
|
+
</button>
|
|
836
|
+
))}
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
839
|
+
{sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (
|
|
840
|
+
<div
|
|
841
|
+
style={{
|
|
842
|
+
flex: 1,
|
|
843
|
+
minHeight: 0,
|
|
844
|
+
overflowY: 'auto',
|
|
845
|
+
padding: '0 4px 12px',
|
|
846
|
+
}}
|
|
847
|
+
>
|
|
848
|
+
{throughlines.map((tl) => (
|
|
849
|
+
<ThroughlineFlow
|
|
850
|
+
key={tl.id}
|
|
851
|
+
throughline={tl}
|
|
852
|
+
edges={edges}
|
|
853
|
+
collapsed={!expandedThroughlines.has(tl.id)}
|
|
854
|
+
active={focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null}
|
|
855
|
+
onToggleCollapsed={toggleThroughlineCollapsed}
|
|
856
|
+
onFocusFlow={focusThroughlineEdges}
|
|
857
|
+
onClearFocus={clearThroughlineFocus}
|
|
858
|
+
onFocusStep={focusThroughlineStep}
|
|
859
|
+
/>
|
|
860
|
+
))}
|
|
861
|
+
</div>
|
|
862
|
+
) : treeFilePaths.length > 0 ? (
|
|
863
|
+
<>
|
|
571
864
|
{repoGroups.groups.map((group, i) => {
|
|
572
865
|
const groupKey = group.repoKey ?? '__no-repo__';
|
|
573
866
|
const collapsed = collapsedRepos.has(groupKey);
|
|
@@ -601,6 +894,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
601
894
|
</div>
|
|
602
895
|
);
|
|
603
896
|
})}
|
|
897
|
+
</>
|
|
898
|
+
) : null}
|
|
604
899
|
</div>
|
|
605
900
|
)}
|
|
606
901
|
</div>
|
|
@@ -641,6 +936,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
641
936
|
position: 'absolute',
|
|
642
937
|
left: screenX,
|
|
643
938
|
top: screenY,
|
|
939
|
+
// Center on the flow-space midpoint. Labels live in screen
|
|
940
|
+
// space (fixed size when zoomed out), so top-left anchoring
|
|
941
|
+
// would drift them right/down of the edge as zoom drops.
|
|
942
|
+
transform: 'translate(-50%, -50%)',
|
|
644
943
|
fontSize: 10,
|
|
645
944
|
fontFamily: theme.fonts.monospace,
|
|
646
945
|
fontWeight: 500,
|
|
@@ -655,7 +954,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
655
954
|
whiteSpace: 'nowrap',
|
|
656
955
|
}}
|
|
657
956
|
>
|
|
658
|
-
{lbl.mechanism}
|
|
957
|
+
{lbl.stepNos?.length ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}` : lbl.mechanism}
|
|
659
958
|
</div>
|
|
660
959
|
);
|
|
661
960
|
})}
|
|
@@ -852,6 +1151,221 @@ function RepoGroupHeader({
|
|
|
852
1151
|
);
|
|
853
1152
|
}
|
|
854
1153
|
|
|
1154
|
+
/** One collapsible throughline in the sidebar's flows panel. Clicking the
|
|
1155
|
+
* title: closed → open + select; open and unselected → select; open and
|
|
1156
|
+
* selected → close + clear focus. The right-aligned close button collapses
|
|
1157
|
+
* without selecting. A step row focuses that step's edge. */
|
|
1158
|
+
function ThroughlineFlow({
|
|
1159
|
+
throughline,
|
|
1160
|
+
edges,
|
|
1161
|
+
collapsed,
|
|
1162
|
+
active,
|
|
1163
|
+
onToggleCollapsed,
|
|
1164
|
+
onFocusFlow,
|
|
1165
|
+
onClearFocus,
|
|
1166
|
+
onFocusStep,
|
|
1167
|
+
}: {
|
|
1168
|
+
throughline: SubsystemThroughline;
|
|
1169
|
+
edges: SubsystemComponentEdge[];
|
|
1170
|
+
collapsed: boolean;
|
|
1171
|
+
/** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
|
|
1172
|
+
active: { stepIndex: number | null } | null;
|
|
1173
|
+
onToggleCollapsed: (tlId: string) => void;
|
|
1174
|
+
onFocusFlow: (tl: SubsystemThroughline) => void;
|
|
1175
|
+
onClearFocus: () => void;
|
|
1176
|
+
onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
|
|
1177
|
+
}) {
|
|
1178
|
+
const { theme } = useTheme();
|
|
1179
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
1180
|
+
const hoverBg = theme.colors.background;
|
|
1181
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
1182
|
+
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
1183
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
1184
|
+
const [closeHover, setCloseHover] = useState(false);
|
|
1185
|
+
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
1186
|
+
|
|
1187
|
+
return (
|
|
1188
|
+
<div style={{ margin: '4px 0', borderRadius: 8 }}>
|
|
1189
|
+
<div
|
|
1190
|
+
onMouseEnter={() => setHeaderHover(true)}
|
|
1191
|
+
onMouseLeave={() => setHeaderHover(false)}
|
|
1192
|
+
style={{
|
|
1193
|
+
display: 'flex',
|
|
1194
|
+
alignItems: 'center',
|
|
1195
|
+
gap: 4,
|
|
1196
|
+
borderRadius: 6,
|
|
1197
|
+
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
1198
|
+
transition: 'background 120ms ease',
|
|
1199
|
+
}}
|
|
1200
|
+
>
|
|
1201
|
+
<button
|
|
1202
|
+
type="button"
|
|
1203
|
+
onClick={() => {
|
|
1204
|
+
if (collapsed) {
|
|
1205
|
+
onToggleCollapsed(throughline.id);
|
|
1206
|
+
onFocusFlow(throughline);
|
|
1207
|
+
} else if (active === null) {
|
|
1208
|
+
onFocusFlow(throughline);
|
|
1209
|
+
} else {
|
|
1210
|
+
onToggleCollapsed(throughline.id);
|
|
1211
|
+
onClearFocus();
|
|
1212
|
+
}
|
|
1213
|
+
}}
|
|
1214
|
+
style={{
|
|
1215
|
+
flex: 1,
|
|
1216
|
+
display: 'flex',
|
|
1217
|
+
alignItems: 'center',
|
|
1218
|
+
minWidth: 0,
|
|
1219
|
+
padding: '6px 8px',
|
|
1220
|
+
borderRadius: 6,
|
|
1221
|
+
border: 'none',
|
|
1222
|
+
background: 'transparent',
|
|
1223
|
+
textAlign: 'left',
|
|
1224
|
+
cursor: 'pointer',
|
|
1225
|
+
}}
|
|
1226
|
+
>
|
|
1227
|
+
<span
|
|
1228
|
+
style={{
|
|
1229
|
+
overflow: 'hidden',
|
|
1230
|
+
textOverflow: 'ellipsis',
|
|
1231
|
+
whiteSpace: 'nowrap',
|
|
1232
|
+
fontSize: theme.fontSizes[1],
|
|
1233
|
+
fontFamily: theme.fonts.monospace,
|
|
1234
|
+
fontWeight: 600,
|
|
1235
|
+
color: theme.colors.text,
|
|
1236
|
+
}}
|
|
1237
|
+
>
|
|
1238
|
+
{throughline.title}
|
|
1239
|
+
</span>
|
|
1240
|
+
</button>
|
|
1241
|
+
{!collapsed && (
|
|
1242
|
+
<button
|
|
1243
|
+
type="button"
|
|
1244
|
+
aria-label={`Close ${throughline.title}`}
|
|
1245
|
+
onMouseEnter={() => setCloseHover(true)}
|
|
1246
|
+
onMouseLeave={() => setCloseHover(false)}
|
|
1247
|
+
onClick={(e) => {
|
|
1248
|
+
e.stopPropagation();
|
|
1249
|
+
onToggleCollapsed(throughline.id);
|
|
1250
|
+
if (active !== null) onClearFocus();
|
|
1251
|
+
}}
|
|
1252
|
+
style={{
|
|
1253
|
+
display: 'inline-flex',
|
|
1254
|
+
alignItems: 'center',
|
|
1255
|
+
justifyContent: 'center',
|
|
1256
|
+
flexShrink: 0,
|
|
1257
|
+
width: 22,
|
|
1258
|
+
height: 22,
|
|
1259
|
+
marginRight: 4,
|
|
1260
|
+
padding: 0,
|
|
1261
|
+
border: 'none',
|
|
1262
|
+
borderRadius: 4,
|
|
1263
|
+
background: closeHover ? theme.colors.border : 'transparent',
|
|
1264
|
+
color: closeHover ? theme.colors.text : muted,
|
|
1265
|
+
cursor: 'pointer',
|
|
1266
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
1267
|
+
}}
|
|
1268
|
+
>
|
|
1269
|
+
<X size={12} strokeWidth={2} />
|
|
1270
|
+
</button>
|
|
1271
|
+
)}
|
|
1272
|
+
</div>
|
|
1273
|
+
{!collapsed && (
|
|
1274
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
1275
|
+
{throughline.steps.map((step, i) => {
|
|
1276
|
+
const edge = edgeById.get(step.edgeId);
|
|
1277
|
+
const mech = edge?.mechanism;
|
|
1278
|
+
const color = mech ? MECHANISM_COLOR[mech] : muted;
|
|
1279
|
+
const stepActive = active !== null && active.stepIndex === i;
|
|
1280
|
+
return (
|
|
1281
|
+
<button
|
|
1282
|
+
key={`${step.edgeId}-${i}`}
|
|
1283
|
+
type="button"
|
|
1284
|
+
onMouseEnter={() => setHoveredStep(i)}
|
|
1285
|
+
onMouseLeave={() => setHoveredStep(null)}
|
|
1286
|
+
onClick={() => onFocusStep(throughline, i)}
|
|
1287
|
+
style={{
|
|
1288
|
+
display: 'flex',
|
|
1289
|
+
alignItems: 'center',
|
|
1290
|
+
gap: 8,
|
|
1291
|
+
minWidth: 0,
|
|
1292
|
+
padding: '4px 8px 4px 12px',
|
|
1293
|
+
textAlign: 'left',
|
|
1294
|
+
borderRadius: 6,
|
|
1295
|
+
border: 'none',
|
|
1296
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
1297
|
+
cursor: 'pointer',
|
|
1298
|
+
transition: 'background 120ms ease',
|
|
1299
|
+
}}
|
|
1300
|
+
>
|
|
1301
|
+
<span
|
|
1302
|
+
style={{
|
|
1303
|
+
flexShrink: 0,
|
|
1304
|
+
width: 14,
|
|
1305
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
1306
|
+
fontFamily: theme.fonts.monospace,
|
|
1307
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1308
|
+
}}
|
|
1309
|
+
>
|
|
1310
|
+
{i + 1}
|
|
1311
|
+
</span>
|
|
1312
|
+
{step.symbol ? (
|
|
1313
|
+
<span
|
|
1314
|
+
style={{
|
|
1315
|
+
flex: 1,
|
|
1316
|
+
minWidth: 0,
|
|
1317
|
+
overflow: 'hidden',
|
|
1318
|
+
textOverflow: 'ellipsis',
|
|
1319
|
+
whiteSpace: 'nowrap',
|
|
1320
|
+
fontSize: theme.fontSizes[0],
|
|
1321
|
+
fontFamily: theme.fonts.monospace,
|
|
1322
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1323
|
+
}}
|
|
1324
|
+
>
|
|
1325
|
+
{step.symbol}
|
|
1326
|
+
</span>
|
|
1327
|
+
) : (
|
|
1328
|
+
<>
|
|
1329
|
+
<span
|
|
1330
|
+
style={{
|
|
1331
|
+
flexShrink: 0,
|
|
1332
|
+
width: 74,
|
|
1333
|
+
overflow: 'hidden',
|
|
1334
|
+
textOverflow: 'ellipsis',
|
|
1335
|
+
whiteSpace: 'nowrap',
|
|
1336
|
+
fontSize: theme.fontSizes[0],
|
|
1337
|
+
fontFamily: theme.fonts.monospace,
|
|
1338
|
+
color,
|
|
1339
|
+
}}
|
|
1340
|
+
>
|
|
1341
|
+
{mech ?? step.edgeId}
|
|
1342
|
+
</span>
|
|
1343
|
+
<span
|
|
1344
|
+
style={{
|
|
1345
|
+
flex: 1,
|
|
1346
|
+
minWidth: 0,
|
|
1347
|
+
overflow: 'hidden',
|
|
1348
|
+
textOverflow: 'ellipsis',
|
|
1349
|
+
whiteSpace: 'nowrap',
|
|
1350
|
+
fontSize: theme.fontSizes[0],
|
|
1351
|
+
fontFamily: theme.fonts.monospace,
|
|
1352
|
+
color: stepActive ? theme.colors.text : muted,
|
|
1353
|
+
}}
|
|
1354
|
+
>
|
|
1355
|
+
{step.file.split('/').pop()}
|
|
1356
|
+
<span style={{ opacity: 0.7 }}>:{step.line}</span>
|
|
1357
|
+
</span>
|
|
1358
|
+
</>
|
|
1359
|
+
)}
|
|
1360
|
+
</button>
|
|
1361
|
+
);
|
|
1362
|
+
})}
|
|
1363
|
+
</div>
|
|
1364
|
+
)}
|
|
1365
|
+
</div>
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
855
1369
|
export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
|
|
856
1370
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
857
1371
|
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
|