@principal-ai/principal-view-react 0.16.58 → 0.16.60

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.
@@ -2,10 +2,10 @@
2
2
  * SubsystemComponentGraph — a clickable, read-only React Flow component graph
3
3
  * for a subsystem snapshot.
4
4
  *
5
- * Nodes are positioned with ELK auto-layout (layered, minimized crossings) and
6
- * colored by package. Only cross-package edges leave a package region. Clicking
7
- * a component invokes `onSelect`. (Package frames are deferred nodes are
8
- * color-coded by package for now.)
5
+ * Nodes are positioned with ELK auto-layout (layered, minimized crossings,
6
+ * process-aware compound groups). Components sharing a `process` render
7
+ * inside one labeled boundary frame; nodes without one sit outside every
8
+ * boundary. Clicking a component invokes `onSelect`.
9
9
  *
10
10
  * This is a focused fork of the package's `GraphRenderer` pipeline (same ELK
11
11
  * edge routing, delayed fitView, Background/Controls/MiniMap, node/edge type
@@ -44,7 +44,7 @@ import {
44
44
  type SubsystemThroughline,
45
45
  } from './model';
46
46
  import type { SubsystemOpenFileOptions } from './declarationRef';
47
- import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
47
+ import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
48
48
  import { SubsystemFileTree } from './SubsystemFileTree';
49
49
  import { GraphLayoutCover } from './GraphLayoutCover';
50
50
  import { ComponentDeclaration } from './ComponentDeclaration';
@@ -53,6 +53,12 @@ import { FileDrawer } from './FileDrawer';
53
53
  import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
54
54
  import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
55
55
 
56
+ /** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
57
+ const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
58
+ /** Rough monospace width at fontSize 10 + horizontal padding/border. */
59
+ const EDGE_LABEL_CHAR_PX = 6.2;
60
+ const EDGE_LABEL_PAD_PX = 18;
61
+
56
62
  export interface SubsystemComponentGraphProps {
57
63
  components: SubsystemComponent[];
58
64
  edges: SubsystemComponentEdge[];
@@ -73,8 +79,22 @@ export interface SubsystemComponentGraphProps {
73
79
  maxNodeWidth?: number;
74
80
  /** Show edge labels (mechanism names) on the graph. @default true */
75
81
  showEdgeLabels?: boolean;
82
+ /** Show the mechanism-legend button overlay on the canvas. @default true */
83
+ showLegend?: boolean;
76
84
  /** Subsystem title displayed in the sidebar. */
77
85
  title?: string;
86
+ /**
87
+ * Suppresses the sidebar entirely (title, description, file tree,
88
+ * throughlines) for graph-only embeds. Pair with `graphTitle` to keep the
89
+ * subsystem name visible as an overlay on the canvas.
90
+ */
91
+ hideSidebar?: boolean;
92
+ /**
93
+ * Subsystem title rendered as a non-interactive overlay chip on the graph
94
+ * canvas (top-center). Does not trigger the sidebar — for graph-only
95
+ * embeds that still need to name what they show.
96
+ */
97
+ graphTitle?: string;
78
98
  /** Markdown description rendered in the sidebar. */
79
99
  description?: string;
80
100
  /** Rendered over the graph canvas only (not the title/legend sidebar). */
@@ -109,6 +129,7 @@ export interface SubsystemComponentGraphProps {
109
129
 
110
130
  const nodeTypes: NodeTypes = {
111
131
  'subsystem-component': SubsystemComponentNode,
132
+ 'subsystem-group': SubsystemGroupNode,
112
133
  };
113
134
 
114
135
  const edgeTypes: EdgeTypes = {
@@ -136,7 +157,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
136
157
  measured: { w: number; h: number } | null;
137
158
  }
138
159
 
139
- function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
160
+ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showLegend, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
140
161
  const { theme } = useTheme();
141
162
  const { fitView } = useReactFlow();
142
163
  const viewport = useViewport();
@@ -224,22 +245,25 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
224
245
  const measuredDimsRef = useRef(new Map<string, { width: number; height: number }>());
225
246
  const pendingMeasuredRef = useRef(false);
226
247
 
227
- // Pass 2: once every node has a measured dimension, re-run ELK.
248
+ // Pass 2: once every leaf node has a measured dimension, re-run ELK.
249
+ // Group parents are sized by ELK, not measured — exclude them or pass 2
250
+ // would wait forever for dimensions that never arrive.
228
251
  const prevMeasuredSigRef = useRef('');
229
252
  const pass2DoneRef = useRef(false);
230
253
  const triggerPass2 = useCallback(() => {
231
254
  if (pass2DoneRef.current) return;
232
255
  const dims = measuredDimsRef.current;
233
- if (dims.size < built.nodes.length) return;
234
- const sig = built.nodes.map((n) => `${n.id}:${dims.get(n.id)?.width ?? '?'}`).join(',');
256
+ const leafNodes = built.nodes.filter((n) => n.type !== 'subsystem-group');
257
+ if (dims.size < leafNodes.length) return;
258
+ const sig = leafNodes.map((n) => `${n.id}:${dims.get(n.id)?.width ?? '?'}`).join(',');
235
259
  if (sig.includes('?:')) return;
236
260
  if (sig === prevMeasuredSigRef.current) return;
237
261
  prevMeasuredSigRef.current = sig;
238
262
  pendingMeasuredRef.current = false;
239
263
  pass2DoneRef.current = true;
240
264
 
241
- const measuredWidths = new Map(built.nodes.map((n) => [n.id, dims.get(n.id)!.width]));
242
- const measuredHeights = new Map(built.nodes.map((n) => [n.id, dims.get(n.id)!.height]));
265
+ const measuredWidths = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.width]));
266
+ const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.height]));
243
267
  let alive = true;
244
268
  void buildSubsystemGraph(
245
269
  { components, edges },
@@ -397,6 +421,26 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
397
421
  // React Flow's own selection state from updating.
398
422
  const dispNodes = useMemo(() => {
399
423
  return xyflowNodesBase.map((n) => {
424
+ // Boundary frames follow their members: hidden when no member is
425
+ // visible, dimmed when members are dimmed. Never selectable.
426
+ if (n.type === 'subsystem-group') {
427
+ const memberIds = ((n.data as { region?: { memberIds?: string[] } } | undefined)?.region?.memberIds) ?? [];
428
+ const vis = flowElementVisibility({
429
+ inOpened: memberIds.some((id) => openedNodeIds?.has(id) === true),
430
+ inSelected: memberIds.some((id) => brightNodeIds?.has(id) === true),
431
+ anyOpened: openedNodeIds != null,
432
+ anySelected: brightNodeIds != null,
433
+ });
434
+ return {
435
+ ...n,
436
+ hidden: vis.hidden,
437
+ selectable: false,
438
+ data: {
439
+ ...(n.data as object),
440
+ ...(vis.dimmed && { dimmed: true }),
441
+ },
442
+ };
443
+ }
400
444
  const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
401
445
  const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
402
446
  const isSelected = selected?.id !== undefined && comp?.id === selected.id;
@@ -460,8 +504,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
460
504
  const onNodesChange = useCallback(
461
505
  (changes: NodeChange[]) => {
462
506
  // Capture dimension changes (React Flow's measurement callback).
507
+ // Group parents are ELK-sized — ignore their measurements.
508
+ const groupIds = new Set(
509
+ dispNodes.filter((n) => n.type === 'subsystem-group').map((n) => n.id),
510
+ );
463
511
  for (const ch of changes) {
464
- if (ch.type === 'dimensions' && ch.dimensions) {
512
+ if (ch.type === 'dimensions' && ch.dimensions && !groupIds.has(ch.id)) {
465
513
  measuredDimsRef.current.set(ch.id, ch.dimensions);
466
514
  pendingMeasuredRef.current = true;
467
515
  }
@@ -677,13 +725,20 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
677
725
  return dispEdges
678
726
  .filter((e) => !e.hidden)
679
727
  .map((e) => {
680
- const d = e.data as { mechanism?: string; dimmed?: boolean; labelX?: number; labelY?: number } | undefined;
728
+ const d = e.data as {
729
+ mechanism?: string;
730
+ dimmed?: boolean;
731
+ labelX?: number;
732
+ labelY?: number;
733
+ pathLength?: number;
734
+ } | undefined;
681
735
  return {
682
736
  id: e.id,
683
737
  mechanism: d?.mechanism ?? 'imports',
684
738
  dimmed: d?.dimmed === true,
685
739
  midX: d?.labelX ?? 0,
686
740
  midY: d?.labelY ?? 0,
741
+ pathLength: d?.pathLength ?? 0,
687
742
  stepNos: selectedFlowStepNos?.get(e.id),
688
743
  };
689
744
  });
@@ -729,7 +784,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
729
784
  return (
730
785
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
731
786
  {/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
732
- {(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
787
+ {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
733
788
  <div
734
789
  style={{
735
790
  width: 340,
@@ -923,6 +978,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
923
978
  const verifiable = MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] ?? true;
924
979
  const screenX = lbl.midX * viewport.zoom + viewport.x;
925
980
  const screenY = lbl.midY * viewport.zoom + viewport.y;
981
+ const text = lbl.stepNos?.length
982
+ ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}`
983
+ : lbl.mechanism;
984
+ // Labels stay readable at full size until they'd exceed a share of
985
+ // the edge's screen length, then shrink with zoom.
986
+ const estWidth = text.length * EDGE_LABEL_CHAR_PX + EDGE_LABEL_PAD_PX;
987
+ const screenEdgeLen = lbl.pathLength * viewport.zoom;
988
+ const scale =
989
+ lbl.pathLength > 0 && estWidth > 0
990
+ ? Math.min(1, (screenEdgeLen * EDGE_LABEL_MAX_EDGE_FRACTION) / estWidth)
991
+ : 1;
926
992
  return (
927
993
  <div
928
994
  key={lbl.id}
@@ -939,22 +1005,26 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
939
1005
  // Center on the flow-space midpoint. Labels live in screen
940
1006
  // space (fixed size when zoomed out), so top-left anchoring
941
1007
  // would drift them right/down of the edge as zoom drops.
942
- transform: 'translate(-50%, -50%)',
1008
+ transform: `translate(-50%, -50%) scale(${scale})`,
1009
+ transformOrigin: 'center center',
1010
+ display: 'flex',
1011
+ alignItems: 'center',
943
1012
  fontSize: 10,
1013
+ lineHeight: 1,
944
1014
  fontFamily: theme.fonts.monospace,
945
1015
  fontWeight: 500,
946
1016
  color,
947
1017
  background: 'rgba(21,21,21,0.9)',
948
1018
  border: verifiable ? `0.5px solid ${color}` : `1px dashed ${color}`,
949
1019
  borderRadius: verifiable ? 4 : '10px 14px 12px 16px / 14px 10px 16px 12px',
950
- padding: '1px 5px',
1020
+ padding: '3px 8px',
951
1021
  cursor: 'pointer',
952
1022
  pointerEvents: 'auto',
953
1023
  opacity: lbl.dimmed ? 0.15 : 1,
954
1024
  whiteSpace: 'nowrap',
955
1025
  }}
956
1026
  >
957
- {lbl.stepNos?.length ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}` : lbl.mechanism}
1027
+ {text}
958
1028
  </div>
959
1029
  );
960
1030
  })}
@@ -996,7 +1066,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
996
1066
  <Controls showZoom showFitView showInteractive />
997
1067
  </ReactFlow>
998
1068
  {/* Legend button — top-left overlay on the canvas; opens the modal. */}
999
- {usedMechanisms.size > 0 && (
1069
+ {showLegend !== false && usedMechanisms.size > 0 && (
1000
1070
  <button
1001
1071
  type="button"
1002
1072
  onClick={() => setLegendOpen(true)}
@@ -1022,6 +1092,36 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1022
1092
  <MapIcon size={13} />
1023
1093
  Legend
1024
1094
  </button>
1095
+ )}
1096
+ {/* Graph title — non-interactive chip centered at the top of the canvas
1097
+ (clear of the top-left legend button and top-right declaration
1098
+ card). Lets graph-only embeds name the subsystem they show. */}
1099
+ {graphTitle && (
1100
+ <div
1101
+ style={{
1102
+ position: 'absolute',
1103
+ top: 10,
1104
+ left: '50%',
1105
+ transform: 'translateX(-50%)',
1106
+ zIndex: 6,
1107
+ maxWidth: '60%',
1108
+ overflow: 'hidden',
1109
+ textOverflow: 'ellipsis',
1110
+ whiteSpace: 'nowrap',
1111
+ padding: '6px 18px',
1112
+ fontSize: theme.fontSizes[3],
1113
+ fontWeight: 600,
1114
+ fontFamily: theme.fonts.heading,
1115
+ color: theme.colors.text,
1116
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1117
+ border: `1px solid ${theme.colors.border}`,
1118
+ borderRadius: 6,
1119
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1120
+ pointerEvents: 'none',
1121
+ }}
1122
+ >
1123
+ {graphTitle}
1124
+ </div>
1025
1125
  )}
1026
1126
  {/* Selected-component declaration — floating card over the canvas
1027
1127
  (top-right, clear of the top-left legend button). The graph never
@@ -2,6 +2,9 @@ import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  convertSubsystemToNodes,
4
4
  convertSubsystemToEdges,
5
+ convertSubsystemToGroups,
6
+ getSubsystemRegions,
7
+ processGroupNodeId,
5
8
  buildSubsystemGraph,
6
9
  deriveNameFromSymbol,
7
10
  formatPurl,
@@ -121,6 +124,59 @@ describe('subsystem graph model', () => {
121
124
  expect(formatPurl('not-a-purl')).toBe('not-a-purl');
122
125
  });
123
126
 
127
+ test('getSubsystemRegions groups by process, skipping process-less nodes', () => {
128
+ const regions = getSubsystemRegions({
129
+ components: [
130
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
131
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
132
+ { id: 'c', name: 'c', construct: 'function', file: 'c.ts', purl: 'pkg:github/acme/app', process: 'app/renderer' },
133
+ { id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/acme/app' },
134
+ ],
135
+ });
136
+ expect(regions.map((r) => r.key)).toEqual(['app/host', 'app/renderer']);
137
+ expect(regions[0]!.memberIds).toEqual(['a', 'b']);
138
+ });
139
+
140
+ test('convertSubsystemToNodes stamps parentId for process members only', () => {
141
+ const nodes = convertSubsystemToNodes({
142
+ components: [
143
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
144
+ { id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/acme/app' },
145
+ ],
146
+ edges: [],
147
+ });
148
+ expect((nodes.find((n) => n.id === 'a') as { parentId?: string }).parentId).toBe(
149
+ processGroupNodeId('app/host'),
150
+ );
151
+ expect((nodes.find((n) => n.id === 'd') as { parentId?: string }).parentId).toBeUndefined();
152
+ });
153
+
154
+ test('convertSubsystemToGroups emits one parent per process', () => {
155
+ const groups = convertSubsystemToGroups({
156
+ components: [
157
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
158
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
159
+ ],
160
+ });
161
+ expect(groups).toHaveLength(1);
162
+ expect(groups[0]!.id).toBe(processGroupNodeId('app/host'));
163
+ expect(groups[0]!.type).toBe('subsystem-group');
164
+ });
165
+
166
+ test('buildSubsystemGraph drops singleton process frames (no parentId, no group)', async () => {
167
+ const { nodes, regions } = await buildSubsystemGraph({
168
+ components: [
169
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
170
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
171
+ { id: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
172
+ ],
173
+ edges: [{ id: 'e1', from: 'a', to: 'b', mechanism: 'calls' }],
174
+ });
175
+ expect(regions.map((r) => r.key)).toEqual(['app/host']);
176
+ expect((nodes.find((n) => n.id === 'solo') as { parentId?: string }).parentId).toBeUndefined();
177
+ expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
178
+ });
179
+
124
180
  test('subsystemGraphLayoutKey ignores declarationRef-only changes', () => {
125
181
  const base = { components: comps, edges };
126
182
  const withRef = {
@@ -17,7 +17,7 @@ import {
17
17
  type Edge,
18
18
  type Node,
19
19
  } from '@xyflow/react';
20
- import { computeElkLayout } from '../utils/elkLayout';
20
+ import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
21
21
  import type { GraphifyComponentDetail } from '../graphify';
22
22
  import type { SubsystemDeclarationRef } from './declarationRef';
23
23
 
@@ -28,6 +28,7 @@ export type SubsystemComponentConstruct =
28
28
  | 'interface'
29
29
  | 'type_alias'
30
30
  | 'enum'
31
+ | 'react_component'
31
32
  | 'module'
32
33
  | 'store'
33
34
  | 'external';
@@ -92,12 +93,14 @@ export interface SubsystemComponent {
92
93
  name: string;
93
94
  /**
94
95
  * The node's construct — what it IS as a declaration (class, function,
95
- * method, interface, type alias, enum, store, external), driving node
96
- * anatomy, color, badge, and the verification strategy. Every construct
97
- * anchors to a definition; runtime occurrences (variables, activations,
98
- * instances) are NOT constructs — they belong to a future execution-mode
99
- * graph whose occurrence nodes reference these definitions. Ontology:
100
- * construct = what it is, role = where it sits, process = where it runs.
96
+ * method, interface, type alias, enum, react_component, store, external),
97
+ * driving node anatomy, color, badge, and the verification strategy. Every
98
+ * construct anchors to a definition; runtime occurrences (variables,
99
+ * activations, instances) are NOT constructs — they belong to a future
100
+ * execution-mode graph whose occurrence nodes reference these definitions.
101
+ * Ontology: construct = what it is, role = where it sits, process = where
102
+ * it runs. Use `react_component` (not `function`) for JSX/TSX UI units so
103
+ * the badge reads "component" instead of "function".
101
104
  */
102
105
  construct: SubsystemComponentConstruct;
103
106
  /** Source location the component lives in (repo-root-relative path). */
@@ -292,6 +295,46 @@ export interface SubsystemGraphDocument {
292
295
 
293
296
  export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
294
297
 
298
+ /**
299
+ * One process boundary region — all components sharing a `process` value.
300
+ * Nodes without a `process` sit outside every boundary (no region).
301
+ */
302
+ export interface SubsystemProcessRegion {
303
+ /** The `process` value (e.g. `trail-viewer/host`). */
304
+ key: string;
305
+ /** Display label for the boundary frame. */
306
+ label: string;
307
+ /** Component ids that are members of this region. */
308
+ memberIds: string[];
309
+ }
310
+
311
+ /** React Flow id for a process boundary group node. */
312
+ export function processGroupNodeId(processKey: string): string {
313
+ return `process:${processKey}`;
314
+ }
315
+
316
+ /**
317
+ * Derive boundary regions from a document — one per distinct non-empty
318
+ * `process` value, in first-appearance order.
319
+ */
320
+ export function getSubsystemRegions(
321
+ doc: Pick<SubsystemGraphDocument, 'components'>,
322
+ ): SubsystemProcessRegion[] {
323
+ const byProcess = new Map<string, string[]>();
324
+ for (const c of doc.components) {
325
+ const p = c.process?.trim();
326
+ if (!p) continue;
327
+ const list = byProcess.get(p) ?? [];
328
+ list.push(c.id);
329
+ byProcess.set(p, list);
330
+ }
331
+ return [...byProcess.entries()].map(([key, memberIds]) => ({
332
+ key,
333
+ label: key,
334
+ memberIds,
335
+ }));
336
+ }
337
+
295
338
  export interface SubsystemGraphNodeData extends Record<string, unknown> {
296
339
  component: SubsystemComponent;
297
340
  /** Set while a file is open in the drawer: true if this node's component
@@ -302,7 +345,15 @@ export interface SubsystemGraphNodeData extends Record<string, unknown> {
302
345
  dimmed?: boolean;
303
346
  }
304
347
 
305
- export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
348
+ export interface SubsystemGroupNodeData extends Record<string, unknown> {
349
+ region: SubsystemProcessRegion;
350
+ /** True while the region's members are dimmed by flow focus. */
351
+ dimmed?: boolean;
352
+ }
353
+
354
+ export type SubsystemGraphNode =
355
+ | Node<SubsystemGraphNodeData, 'subsystem-component'>
356
+ | Node<SubsystemGroupNodeData, 'subsystem-group'>;
306
357
 
307
358
  export interface SubsystemGraphEdgeData extends Record<string, unknown> {
308
359
  mechanism: SubsystemEdgeMechanism;
@@ -313,6 +364,8 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
313
364
  /** ELK-computed label midpoint (from the actual edge path, not node centers). */
314
365
  labelX?: number;
315
366
  labelY?: number;
367
+ /** Polyline length in flow-space units (for capping screen-space label size). */
368
+ pathLength?: number;
316
369
  /** ELK-computed SVG edge path (overrides React Flow's default path). */
317
370
  elkPath?: string;
318
371
  }
@@ -394,12 +447,11 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
394
447
  };
395
448
 
396
449
  /**
397
- * Convert a subsystem graph document into React Flow nodes. We render **flat**
398
- * (no React Flow parent/group nodes) for robustness: package regions are laid
399
- * out in a grid and each component carries its package + a `pkgBounds`
400
- * rectangle on its node data so the group wrapper (drawn by the graph
401
- * component) can frame it. Only components' real positions matter to React
402
- * Flow; the package boundary is a visual region, not a sub-flow node.
450
+ * Convert a subsystem graph document into React Flow nodes. Components that
451
+ * carry a `process` get a `parentId` pointing at their boundary group node
452
+ * (`process:<process>`); nodes without one stay top-level (outside every
453
+ * boundary). The initial grid groups by `process ?? purl` so the pre-ELK
454
+ * positions are already clustered; ELK then refines with compound layout.
403
455
  */
404
456
  export function convertSubsystemToNodes(
405
457
  doc: SubsystemGraphDocument,
@@ -447,9 +499,11 @@ export function convertSubsystemToNodes(
447
499
  const cssBorder = 4; // 2px border each side
448
500
  const rawWidth = Math.max(cssMinWidth, textWidth + cssPadding + cssBorder);
449
501
  const nodeWidth = Math.max(cssMinWidth, Math.min(cap, rawWidth));
502
+ const processKey = c.process?.trim();
450
503
  nodes.push({
451
504
  id: c.id,
452
505
  type: 'subsystem-component',
506
+ ...(processKey ? { parentId: processGroupNodeId(processKey) } : {}),
453
507
  position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
454
508
  width: nodeWidth,
455
509
  height: 84,
@@ -461,6 +515,24 @@ export function convertSubsystemToNodes(
461
515
  return nodes;
462
516
  }
463
517
 
518
+ /**
519
+ * Convert boundary regions into React Flow parent (group) nodes. One per
520
+ * distinct `process` value; member components point at these via `parentId`.
521
+ * Positions/sizes are placeholders — ELK compound layout overwrites them.
522
+ */
523
+ export function convertSubsystemToGroups(
524
+ doc: Pick<SubsystemGraphDocument, 'components'>,
525
+ ): SubsystemGraphNode[] {
526
+ return getSubsystemRegions(doc).map((region) => ({
527
+ id: processGroupNodeId(region.key),
528
+ type: 'subsystem-group',
529
+ position: { x: 0, y: 0 },
530
+ width: 400,
531
+ height: 300,
532
+ data: { region },
533
+ }));
534
+ }
535
+
464
536
  /**
465
537
  * Convert a subsystem graph document into React Flow edges. Edges whose target
466
538
  * is an external label (not a component id) point at a synthetic stub so the
@@ -522,10 +594,23 @@ export async function buildSubsystemGraph(
522
594
  ): Promise<{
523
595
  nodes: SubsystemGraphNode[];
524
596
  edges: SubsystemGraphEdge[];
597
+ regions: SubsystemProcessRegion[];
525
598
  }> {
526
599
  const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
527
600
  const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
528
601
  const edges = convertSubsystemToEdges(doc);
602
+ // Boundary regions: one per multi-member process. Singletons get no frame —
603
+ // strip the parentId convertSubsystemToNodes stamped so React Flow never
604
+ // points at a non-existent parent.
605
+ const regions = getSubsystemRegions(doc).filter((r) => r.memberIds.length >= 2);
606
+ const regionKeys = new Set(regions.map((r) => r.key));
607
+ for (const n of nodes) {
608
+ if (n.type !== 'subsystem-component') continue;
609
+ const proc = (n.data as SubsystemGraphNodeData).component?.process?.trim();
610
+ if (proc && !regionKeys.has(proc)) {
611
+ delete (n as { parentId?: string }).parentId;
612
+ }
613
+ }
529
614
 
530
615
  // External edge targets that aren't real components → create stub nodes so
531
616
  // cross-package edges have something to land on.
@@ -574,10 +659,12 @@ export async function buildSubsystemGraph(
574
659
  }
575
660
  }
576
661
 
577
- // ELK auto-layout: position nodes (layered, minimized crossings).
662
+ // ELK auto-layout: position nodes (layered, minimized crossings) with
663
+ // process partitions as compound parents so boundaries shape the layout.
578
664
  let placedNodes = nodes;
579
665
  let labelPositions = new Map<string, { x: number; y: number }>();
580
666
  let elkPathStrings = new Map<string, string>();
667
+ let elkPathPoints = new Map<string, { x: number; y: number }[]>();
581
668
  if (nodes.length > 0) {
582
669
  try {
583
670
  const result = await computeElkLayout(nodes, edges, {
@@ -589,10 +676,29 @@ export async function buildSubsystemGraph(
589
676
  interLayerSpacing: 120,
590
677
  preserveNodePositions: false,
591
678
  edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
679
+ groups: regions.map((r) => ({
680
+ id: processGroupNodeId(r.key),
681
+ memberIds: r.memberIds,
682
+ })),
592
683
  });
593
- placedNodes = result.nodes as SubsystemGraphNode[];
684
+ const groupNodes: SubsystemGraphNode[] = regions.flatMap((region) => {
685
+ const bounds = result.groupBounds.get(processGroupNodeId(region.key));
686
+ if (!bounds) return [];
687
+ const group: SubsystemGraphNode = {
688
+ id: processGroupNodeId(region.key),
689
+ type: 'subsystem-group',
690
+ position: { x: bounds.x, y: bounds.y },
691
+ width: Math.max(200, bounds.width),
692
+ height: Math.max(160, bounds.height),
693
+ data: { region },
694
+ };
695
+ return [group];
696
+ });
697
+ // Parents first — React Flow resolves children via parentId.
698
+ placedNodes = [...groupNodes, ...(result.nodes as SubsystemGraphNode[])];
594
699
  labelPositions = result.edgeLabelPositions;
595
700
  elkPathStrings = result.edgePaths;
701
+ elkPathPoints = result.edgePathPoints;
596
702
  } catch (err) {
597
703
  // Fall back to the (unpositioned) grid if ELK is unavailable.
598
704
  console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
@@ -613,7 +719,12 @@ export async function buildSubsystemGraph(
613
719
  const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
614
720
  d.elkPath = elkP;
615
721
  }
722
+ const pts = elkPathPoints.get(e.id);
723
+ if (pts && pts.length > 1) {
724
+ const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
725
+ d.pathLength = calculatePathLength(pts);
726
+ }
616
727
  }
617
728
 
618
- return { nodes: placedNodes, edges };
729
+ return { nodes: placedNodes, edges, regions };
619
730
  }