@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.
Files changed (109) hide show
  1. package/dist/graphify/consolidated.d.ts +16 -1
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/index.d.ts +1 -1
  4. package/dist/graphify/index.d.ts.map +1 -1
  5. package/dist/graphify/index.js.map +1 -1
  6. package/dist/graphify/kind.d.ts +1 -1
  7. package/dist/graphify/kind.d.ts.map +1 -1
  8. package/dist/graphify/kind.js +7 -1
  9. package/dist/graphify/kind.js.map +1 -1
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/pierre/constructColors.d.ts +24 -0
  15. package/dist/pierre/constructColors.d.ts.map +1 -0
  16. package/dist/pierre/constructColors.js +91 -0
  17. package/dist/pierre/constructColors.js.map +1 -0
  18. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +8 -0
  19. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -0
  20. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +116 -0
  21. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -0
  22. package/dist/subsystem/ComponentDeclaration.d.ts +2 -2
  23. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  24. package/dist/subsystem/ComponentDeclaration.js +12 -11
  25. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  26. package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -1
  27. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  28. package/dist/subsystem/SubsystemComponentGraph.js +401 -40
  29. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  30. package/dist/subsystem/formatDeclaration.d.ts.map +1 -1
  31. package/dist/subsystem/formatDeclaration.js +49 -10
  32. package/dist/subsystem/formatDeclaration.js.map +1 -1
  33. package/dist/subsystem/model.d.ts +89 -12
  34. package/dist/subsystem/model.d.ts.map +1 -1
  35. package/dist/subsystem/model.js +55 -25
  36. package/dist/subsystem/model.js.map +1 -1
  37. package/dist/subsystem/nodes.d.ts +32 -1
  38. package/dist/subsystem/nodes.d.ts.map +1 -1
  39. package/dist/subsystem/nodes.js +122 -24
  40. package/dist/subsystem/nodes.js.map +1 -1
  41. package/dist/subsystem/tokenizeComponent.js +1 -1
  42. package/dist/subsystem/tokenizeComponent.js.map +1 -1
  43. package/dist/utils/elkLayout.d.ts.map +1 -1
  44. package/dist/utils/elkLayout.js +4 -3
  45. package/dist/utils/elkLayout.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/graphify/consolidated.ts +18 -1
  48. package/src/graphify/index.ts +1 -0
  49. package/src/graphify/kind.ts +7 -1
  50. package/src/index.ts +3 -2
  51. package/src/pierre/constructColors.test.ts +52 -0
  52. package/src/pierre/constructColors.ts +112 -0
  53. package/src/stories/{BoundaryNodes.stories.tsx → Audit/BoundaryNodes.stories.tsx} +1 -1
  54. package/src/stories/{CanvasEdgeTypes.stories.tsx → Audit/CanvasEdgeTypes.stories.tsx} +1 -1
  55. package/src/stories/{CanvasNodeTypes.stories.tsx → Audit/CanvasNodeTypes.stories.tsx} +1 -1
  56. package/src/stories/{ColorPriority.stories.tsx → Audit/ColorPriority.stories.tsx} +1 -1
  57. package/src/stories/{DiamondBadges.stories.tsx → Audit/DiamondBadges.stories.tsx} +1 -1
  58. package/src/stories/{NodeDimensionsTesting.stories.tsx → Audit/NodeDimensionsTesting.stories.tsx} +2 -2
  59. package/src/stories/{NodeFieldsAudit.stories.tsx → Audit/NodeFieldsAudit.stories.tsx} +1 -1
  60. package/src/stories/{NodeFontSizeTesting.stories.tsx → Audit/NodeFontSizeTesting.stories.tsx} +2 -2
  61. package/src/stories/{NodeShapes.stories.tsx → Audit/NodeShapes.stories.tsx} +1 -1
  62. package/src/stories/{WideNodesTesting.stories.tsx → Audit/WideNodesTesting.stories.tsx} +2 -2
  63. package/src/stories/{dashboard → Components}/DashboardRenderer.stories.tsx +2 -2
  64. package/src/stories/{GraphRenderer.stories.tsx → Components/GraphRenderer.stories.tsx} +2 -2
  65. package/src/stories/{MultiCanvasRenderer.stories.tsx → Components/MultiCanvasRenderer.stories.tsx} +2 -2
  66. package/src/stories/{SequenceDiagram.stories.tsx → Components/SequenceDiagram.stories.tsx} +2 -2
  67. package/src/stories/{StateView.stories.tsx → Components/StateView.stories.tsx} +2 -2
  68. package/src/stories/{TraceViewer.stories.tsx → Components/TraceViewer.stories.tsx} +3 -3
  69. package/src/stories/{WorkflowSequenceDiagram.stories.tsx → Components/WorkflowSequenceDiagram.stories.tsx} +1 -1
  70. package/src/stories/{AnimationWorkshop.stories.tsx → Features/AnimationWorkshop.stories.tsx} +5 -5
  71. package/src/stories/{ChangeTypeVisual.stories.tsx → Features/ChangeTypeVisual.stories.tsx} +2 -2
  72. package/src/stories/{EventDrivenAnimations.stories.tsx → Features/EventDrivenAnimations.stories.tsx} +2 -2
  73. package/src/stories/{MultiConfig.stories.tsx → Features/MultiConfig.stories.tsx} +2 -2
  74. package/src/stories/{ElkEdgeRouting.stories.tsx → Layout/ElkEdgeRouting.stories.tsx} +2 -2
  75. package/src/stories/{GraphOrientation.stories.tsx → Layout/GraphOrientation.stories.tsx} +3 -3
  76. package/src/stories/{MultiDirectionalConnections.stories.tsx → Layout/MultiDirectionalConnections.stories.tsx} +2 -2
  77. package/src/stories/{NodeDefinitionComparison.stories.tsx → Nodes/NodeDefinitionComparison.stories.tsx} +2 -2
  78. package/src/stories/{NodeNameFallbacks.stories.tsx → Nodes/NodeNameFallbacks.stories.tsx} +2 -2
  79. package/src/stories/{NumericColors.stories.tsx → Nodes/NumericColors.stories.tsx} +2 -2
  80. package/src/stories/{EventNameDisplay.stories.tsx → OTEL/EventNameDisplay.stories.tsx} +2 -2
  81. package/src/stories/{EventNameFallback.stories.tsx → OTEL/EventNameFallback.stories.tsx} +2 -2
  82. package/src/stories/{OtelComponents.stories.tsx → OTEL/OtelComponents.stories.tsx} +3 -3
  83. package/src/stories/{OtelNodeTypesPrototype.stories.tsx → OTEL/OtelNodeTypesPrototype.stories.tsx} +2 -2
  84. package/src/stories/{RawEventCards.stories.tsx → OTEL/RawEventCards.stories.tsx} +3 -3
  85. package/src/stories/{SpanBadges.stories.tsx → OTEL/SpanBadges.stories.tsx} +1 -1
  86. package/src/stories/{ValidationEventsCanvas.stories.tsx → OTEL/ValidationEventsCanvas.stories.tsx} +3 -3
  87. package/src/stories/{BacklogMdCanvases.stories.tsx → RealWorld/BacklogMdCanvases.stories.tsx} +2 -2
  88. package/src/stories/{FileCitySequence.stories.tsx → RealWorld/FileCitySequence.stories.tsx} +4 -4
  89. package/src/stories/{RealCanvasFiles.stories.tsx → RealWorld/RealCanvasFiles.stories.tsx} +7 -7
  90. package/src/stories/{ComponentDeclarationAudit.stories.tsx → Subsystem/ComponentDeclarationAudit.stories.tsx} +19 -20
  91. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +365 -0
  92. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +137 -0
  93. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +238 -0
  94. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +315 -0
  95. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +181 -0
  96. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +959 -0
  97. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +646 -0
  98. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +129 -0
  99. package/src/subsystem/ComponentDeclaration.tsx +13 -13
  100. package/src/subsystem/SubsystemComponentGraph.tsx +558 -44
  101. package/src/subsystem/formatDeclaration.ts +57 -11
  102. package/src/subsystem/model.test.ts +38 -6
  103. package/src/subsystem/model.ts +145 -32
  104. package/src/subsystem/nodes.test.ts +56 -0
  105. package/src/subsystem/nodes.tsx +161 -31
  106. package/src/subsystem/tokenizeComponent.ts +1 -1
  107. package/src/utils/elkLayout.ts +4 -3
  108. package/src/stories/SubsystemComponentGraph.stories.tsx +0 -1225
  109. /package/src/stories/{dashboard/sample-dashboards → data}/activity-feed-analytics.dashboard.json +0 -0
@@ -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);
@@ -88,11 +98,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
88
98
  measuredDimsRef.current = new Map();
89
99
  prevMeasuredSigRef.current = '';
90
100
  const doc = { components: componentsRef.current, edges: edgesRef.current };
91
- void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels }).then(({ nodes, edges: e }) => {
101
+ void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels })
102
+ .then(({ nodes, edges: e }) => {
92
103
  if (!alive)
93
104
  return;
94
105
  setBuilt({ nodes, edges: e });
95
106
  setLayoutReady(false);
107
+ })
108
+ .catch((err) => {
109
+ console.warn('[subsystem-graph] initial layout failed:', err);
110
+ if (!alive)
111
+ return;
112
+ // Reveal the cover even on failure so the UI is not stuck forever.
113
+ setBuilt({ nodes: [], edges: [] });
114
+ setLayoutReady(true);
96
115
  });
97
116
  return () => { alive = false; };
98
117
  }, [layoutKey, maxNodeWidth, showEdgeLabels]);
@@ -141,6 +160,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
141
160
  }
142
161
  const src = edgeById.get(edgeId);
143
162
  setSelectedEdgeId(edgeId);
163
+ // A direct edge selection on the canvas supersedes any throughline focus.
164
+ setFocusedThroughlineId(null);
165
+ setFocusedStepIndex(null);
144
166
  if (src)
145
167
  onEdgeSelect?.(src);
146
168
  }, [edgeById, onEdgeSelect]);
@@ -157,6 +179,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
157
179
  }
158
180
  setSelected(comp);
159
181
  setSelectedEdgeId(null);
182
+ setFocusedThroughlineId(null);
183
+ setFocusedStepIndex(null);
160
184
  onSelect?.(id);
161
185
  }
162
186
  };
@@ -173,45 +197,151 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
173
197
  const { nodes, edges: convertedEdges } = built;
174
198
  const xyflowNodesBase = nodes;
175
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]);
176
282
  // While a file is open in the drawer, tag each node with whether its
177
283
  // component lives in that file — the node renderer spotlights matches and
178
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.
179
287
  // `isSelected` rides in data because the node's stopPropagation() keeps
180
- // React Flow's own selection state from ever updating.
288
+ // React Flow's own selection state from updating.
181
289
  const dispNodes = useMemo(() => {
182
290
  return xyflowNodesBase.map((n) => {
183
291
  const comp = n.data?.component;
184
- const fileMatch = openFile ? comp?.file === openFile : undefined;
292
+ const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
185
293
  const isSelected = selected?.id !== undefined && comp?.id === selected.id;
186
- if (fileMatch === undefined && !isSelected) {
187
- const { fileMatch: _f, isSelected: _s, ...rest } = n.data;
188
- return { ...n, data: rest };
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 };
189
303
  }
190
304
  return {
191
305
  ...n,
192
- data: { ...n.data, ...(fileMatch !== undefined && { fileMatch }), ...(isSelected && { isSelected }) },
306
+ hidden: vis.hidden,
307
+ data: {
308
+ ...n.data,
309
+ ...(fileMatch !== undefined && { fileMatch }),
310
+ ...(isSelected && { isSelected }),
311
+ ...(vis.dimmed && { dimmed: true }),
312
+ },
193
313
  };
194
314
  });
195
- }, [xyflowNodesBase, openFile, selected]);
315
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
196
316
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
197
317
  const baseEdgesKey = useMemo(() => convertedEdges.map((e) => e.id).sort().join(','), [convertedEdges]);
198
- // When an edge is selected, dim every other edge + its label to focus it.
199
- const [selectedEdgeId, setSelectedEdgeId] = useState(null);
200
- // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
201
- // without a stale closure value.
202
- const selectedEdgeIdRef = useRef(null);
203
- selectedEdgeIdRef.current = selectedEdgeId;
204
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
+ }
205
341
  if (!selectedEdgeId)
206
342
  return baseEdges;
207
- return baseEdges.map((e) => ({
208
- ...e,
209
- data: {
210
- ...e.data,
211
- dimmed: e.id !== selectedEdgeId,
212
- },
213
- }));
214
- }, [baseEdges, selectedEdgeId]);
343
+ return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
344
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
215
345
  const onNodesChange = useCallback((changes) => {
216
346
  // Capture dimension changes (React Flow's measurement callback).
217
347
  for (const ch of changes) {
@@ -241,6 +371,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
241
371
  const onNodeClick = useCallback((_e, node) => {
242
372
  const comp = node.data?.component;
243
373
  setSelectedEdgeId(null);
374
+ setFocusedThroughlineId(null);
375
+ setFocusedStepIndex(null);
244
376
  if (node.type === 'subsystem-component' && comp) {
245
377
  // Clicking the already-selected node unselects it (toggle off).
246
378
  // Selection is independent of the file drawer — nodes never open it.
@@ -260,10 +392,13 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
260
392
  const onPaneClick = useCallback(() => {
261
393
  setSelected(null);
262
394
  setSelectedEdgeId(null);
395
+ setFocusedThroughlineId(null);
396
+ setFocusedStepIndex(null);
263
397
  }, []);
264
398
  // Sidebar file trees — one per repo on multi-repo graphs, each under its
265
399
  // own owner-avatar header. Clicking a header collapses that repo's tree.
266
400
  const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
401
+ const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
267
402
  const [collapsedRepos, setCollapsedRepos] = useState(new Set());
268
403
  const toggleRepoCollapsed = useCallback((key) => {
269
404
  setCollapsedRepos((prev) => {
@@ -294,6 +429,74 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
294
429
  setOpenFileTarget({ file, startLine });
295
430
  onFileSelect?.(file);
296
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
+ }, []);
488
+ // Filename-badge clicks on nodes open the drawer through the same path as
489
+ // the declaration panel's file link (toggle + tree sync, no start line).
490
+ useEffect(() => {
491
+ SUBSYSTEM_CALLBACKS.onOpenFile = (componentId) => {
492
+ const comp = components.find((c) => c.id === componentId);
493
+ if (comp?.file)
494
+ onOpenDeclarationFile(comp.file);
495
+ };
496
+ return () => {
497
+ SUBSYSTEM_CALLBACKS.onOpenFile = undefined;
498
+ };
499
+ }, [components, onOpenDeclarationFile]);
297
500
  // Detail-panel links: related-name clicks select the matching component —
298
501
  // resolved by id, name, or symbol (call labels may carry a trailing `()`).
299
502
  const resolveRelatedComponent = useCallback((ref) => {
@@ -306,13 +509,17 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
306
509
  return;
307
510
  setSelected(comp);
308
511
  setSelectedEdgeId(null);
512
+ setFocusedThroughlineId(null);
513
+ setFocusedStepIndex(null);
309
514
  onSelect?.(comp.id);
310
515
  }, [components, onSelect]);
311
516
  // Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
312
517
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
313
518
  // the actual edge path (not node-center approximations).
314
519
  const edgeLabels = useMemo(() => {
315
- return dispEdges.map((e) => {
520
+ return dispEdges
521
+ .filter((e) => !e.hidden)
522
+ .map((e) => {
316
523
  const d = e.data;
317
524
  return {
318
525
  id: e.id,
@@ -320,9 +527,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
320
527
  dimmed: d?.dimmed === true,
321
528
  midX: d?.labelX ?? 0,
322
529
  midY: d?.labelY ?? 0,
530
+ stepNos: selectedFlowStepNos?.get(e.id),
323
531
  };
324
532
  });
325
- }, [dispEdges]);
533
+ }, [dispEdges, selectedFlowStepNos]);
326
534
  const usedMechanisms = useMemo(() => {
327
535
  return new Set(edgeLabels.map((l) => l.mechanism));
328
536
  }, [edgeLabels]);
@@ -351,7 +559,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
351
559
  const fileViewerRef = useRef(fileViewer);
352
560
  fileViewerRef.current = fileViewer;
353
561
  const renderDrawerContent = useCallback((file, opts) => fileViewerRef.current?.(file, opts) ?? null, []);
354
- 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: {
355
563
  width: 340,
356
564
  minWidth: 340,
357
565
  borderRight: `1px solid ${theme.colors.border}`,
@@ -373,7 +581,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
373
581
  fontWeight: 600,
374
582
  color: theme.colors.text,
375
583
  fontFamily: theme.fonts.heading,
376
- }, 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 && (_jsx("div", { style: {
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: {
377
585
  height: '50%',
378
586
  minHeight: 160,
379
587
  flexShrink: 0,
@@ -381,17 +589,42 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
381
589
  display: 'flex',
382
590
  flexDirection: 'column',
383
591
  overflow: 'hidden',
384
- }, children: repoGroups.groups.map((group, i) => {
385
- const groupKey = group.repoKey ?? '__no-repo__';
386
- const collapsed = collapsedRepos.has(groupKey);
387
- return (_jsxs("div", { style: {
388
- flex: collapsed ? '0 0 auto' : 1,
389
- minHeight: collapsed ? 0 : undefined,
592
+ }, children: [hasThroughlines && (_jsx("div", { role: "tablist", "aria-label": "Sidebar view", style: {
390
593
  display: 'flex',
391
- flexDirection: 'column',
392
- borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
393
- }, 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));
394
- }) }))] })), _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: {
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: {
395
628
  position: 'absolute',
396
629
  inset: 0,
397
630
  overflow: 'hidden',
@@ -410,6 +643,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
410
643
  position: 'absolute',
411
644
  left: screenX,
412
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%)',
413
650
  fontSize: 10,
414
651
  fontFamily: theme.fonts.monospace,
415
652
  fontWeight: 500,
@@ -422,7 +659,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
422
659
  pointerEvents: 'auto',
423
660
  opacity: lbl.dimmed ? 0.15 : 1,
424
661
  whiteSpace: 'nowrap',
425
- }, children: lbl.mechanism }, lbl.id));
662
+ }, children: lbl.stepNos?.length ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}` : lbl.mechanism }, lbl.id));
426
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: {
427
664
  width: '100%',
428
665
  height: '100%',
@@ -500,6 +737,130 @@ function RepoGroupHeader({ group, collapsed, onToggle, }) {
500
737
  whiteSpace: 'nowrap',
501
738
  }, children: label })] }));
502
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
+ }
503
864
  export function SubsystemComponentGraph(props) {
504
865
  const wrapRef = useRef(null);
505
866
  const [size, setSize] = useState(null);