@principal-ai/principal-view-react 0.16.21 → 0.16.23
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/components/GraphRenderer.js.map +1 -1
- package/dist/components/session-events/SessionEventFeed.d.ts.map +1 -1
- package/dist/components/session-events/SessionEventFeed.js +10 -3
- package/dist/components/session-events/SessionEventFeed.js.map +1 -1
- package/dist/graphify/consolidated.d.ts +170 -0
- package/dist/graphify/consolidated.d.ts.map +1 -0
- package/dist/graphify/consolidated.js +11 -0
- package/dist/graphify/consolidated.js.map +1 -0
- package/dist/graphify/index.d.ts +9 -0
- package/dist/graphify/index.d.ts.map +1 -0
- package/dist/graphify/index.js +8 -0
- package/dist/graphify/index.js.map +1 -0
- package/dist/graphify/types.d.ts +141 -0
- package/dist/graphify/types.d.ts.map +1 -0
- package/dist/graphify/types.js +8 -0
- package/dist/graphify/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +28 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.js +365 -0
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -0
- package/dist/subsystem/model.d.ts +131 -0
- package/dist/subsystem/model.d.ts.map +1 -0
- package/dist/subsystem/model.js +296 -0
- package/dist/subsystem/model.js.map +1 -0
- package/dist/subsystem/nodes.d.ts +28 -0
- package/dist/subsystem/nodes.d.ts.map +1 -0
- package/dist/subsystem/nodes.js +230 -0
- package/dist/subsystem/nodes.js.map +1 -0
- package/dist/utils/elkLayout.d.ts +19 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +72 -9
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +3 -3
- package/src/components/GraphRenderer.tsx +2 -2
- package/src/components/session-events/SessionEventFeed.tsx +20 -3
- package/src/graphify/consolidated.ts +202 -0
- package/src/graphify/index.ts +36 -0
- package/src/graphify/types.ts +164 -0
- package/src/index.ts +31 -0
- package/src/stories/SubsystemComponentGraph.stories.tsx +761 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +585 -0
- package/src/subsystem/model.test.ts +81 -0
- package/src/subsystem/model.ts +434 -0
- package/src/subsystem/nodes.tsx +353 -0
- package/src/utils/elkLayout.ts +98 -9
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subsystem component-graph data model + converters.
|
|
3
|
+
*
|
|
4
|
+
* A subsystem snapshot (see the "Subsystem artifact: facets" topic) is captured
|
|
5
|
+
* as component nodes (kind-tagged source units), file refs, integration edges,
|
|
6
|
+
* and entry points. This module defines the minimal document shape for the
|
|
7
|
+
* *graph* facet and converts it to React Flow nodes/edges — packages render as
|
|
8
|
+
* subgraphs, only cross-package edges leave the box, and shared seams
|
|
9
|
+
* (registries/barrels/facades) are edge targets, never member nodes.
|
|
10
|
+
*
|
|
11
|
+
* Self-contained in this package (not yet promoted to `@principal-ai/core`), so
|
|
12
|
+
* we can iterate on the UI + story without publishing a dependency.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
MarkerType,
|
|
17
|
+
type Edge,
|
|
18
|
+
type Node,
|
|
19
|
+
} from '@xyflow/react';
|
|
20
|
+
import { computeElkLayout } from '../utils/elkLayout';
|
|
21
|
+
import type { GraphifyComponentDetail } from '../graphify';
|
|
22
|
+
|
|
23
|
+
export type SubsystemComponentKind =
|
|
24
|
+
| 'class'
|
|
25
|
+
| 'module'
|
|
26
|
+
| 'script'
|
|
27
|
+
| 'registry'
|
|
28
|
+
| 'service'
|
|
29
|
+
| 'consumer'
|
|
30
|
+
| 'function'
|
|
31
|
+
| 'method'
|
|
32
|
+
| 'type'
|
|
33
|
+
| 'package';
|
|
34
|
+
|
|
35
|
+
export type SubsystemEdgeMechanism =
|
|
36
|
+
| 'imports'
|
|
37
|
+
| 'imports_from'
|
|
38
|
+
| 're_exports'
|
|
39
|
+
| 'defines'
|
|
40
|
+
| 'calls'
|
|
41
|
+
| 'extends'
|
|
42
|
+
| 'inherits'
|
|
43
|
+
| 'implements'
|
|
44
|
+
| 'mixes_in'
|
|
45
|
+
| 'uses'
|
|
46
|
+
| 'method'
|
|
47
|
+
| 'references'
|
|
48
|
+
| 'contains'
|
|
49
|
+
| 'feeds'
|
|
50
|
+
| 'produces'
|
|
51
|
+
| 'registers-into';
|
|
52
|
+
|
|
53
|
+
/** A component node — the named unit, kind-tagged; `file` is its location. */
|
|
54
|
+
export interface SubsystemComponent {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
kind: SubsystemComponentKind;
|
|
58
|
+
/** Source location the component lives in (repo-root-relative path). */
|
|
59
|
+
file: string;
|
|
60
|
+
/** PURL identifying the repo or package this component lives in (for subgraph grouping). */
|
|
61
|
+
purl: string;
|
|
62
|
+
/** One-line purpose shown on the node. */
|
|
63
|
+
purpose?: string;
|
|
64
|
+
/** A symbol this component exposes / is (the node's identity). */
|
|
65
|
+
symbol?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Semantic layer/phase for the layout (e.g. `1` = input, `2` = processing,
|
|
68
|
+
* `3` = output). When set, ELK places the component in this layer so the
|
|
69
|
+
* graph reads as a left-to-right pipeline and *conveys the idea* rather than
|
|
70
|
+
* letting ELK guess the order.
|
|
71
|
+
*/
|
|
72
|
+
layer?: number;
|
|
73
|
+
/**
|
|
74
|
+
* How this session *occupied* the component — `edited` (modified it) vs
|
|
75
|
+
* `analyzed` (read deeply / built an understanding) vs `referenced`
|
|
76
|
+
* (touched in passing). Drives clustering: edits strengthen a subsystem,
|
|
77
|
+
* but a read-only investigation can still form one around an analyzed seam.
|
|
78
|
+
*/
|
|
79
|
+
capture?: 'edited' | 'analyzed' | 'referenced';
|
|
80
|
+
/**
|
|
81
|
+
* Graphify drill-down detail for this component, when the facet is anchored
|
|
82
|
+
* to a graphify node. Discriminated by kind (class/function/type/module/
|
|
83
|
+
* external); rendered in the detail panel on click.
|
|
84
|
+
*/
|
|
85
|
+
detail?: GraphifyComponentDetail;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A cross-component edge in the subsystem graph. */
|
|
89
|
+
export interface SubsystemComponentEdge {
|
|
90
|
+
id: string;
|
|
91
|
+
from: string; // component id
|
|
92
|
+
to: string; // component id or external target label
|
|
93
|
+
mechanism: SubsystemEdgeMechanism;
|
|
94
|
+
/** Concrete file/symbol refs backing the edge (the seam). */
|
|
95
|
+
refs?: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Derive a consistent display `name` from a code `symbol` + kind.
|
|
100
|
+
*
|
|
101
|
+
* `symbol` is the source of truth (fully-qualified code identity). For most
|
|
102
|
+
* kinds the name is the symbol itself or its last dotted segment:
|
|
103
|
+
* - class/type/module/function/script/... symbol → symbol (e.g. `SessionReader`)
|
|
104
|
+
* - method `Owner.method` → last segment (e.g. `SessionReader.normalize` → `normalize`)
|
|
105
|
+
* - module, no symbol → basename of `file` (e.g. `transcript.ts` → `transcript`) —
|
|
106
|
+
* a common-sense convention for whole-file modules, not a real TS name
|
|
107
|
+
* - otherwise no symbol → fall back to an existing name
|
|
108
|
+
*/
|
|
109
|
+
export function deriveNameFromSymbol(
|
|
110
|
+
symbol: string | undefined,
|
|
111
|
+
kind: SubsystemComponentKind,
|
|
112
|
+
existingName?: string,
|
|
113
|
+
file?: string,
|
|
114
|
+
): string {
|
|
115
|
+
if (symbol && symbol.trim()) {
|
|
116
|
+
if (kind === 'method') {
|
|
117
|
+
const idx = symbol.lastIndexOf('.');
|
|
118
|
+
return idx >= 0 ? symbol.slice(idx + 1) : symbol;
|
|
119
|
+
}
|
|
120
|
+
return symbol;
|
|
121
|
+
}
|
|
122
|
+
// Module nodes without a symbol are whole files → use the file basename.
|
|
123
|
+
if (kind === 'module' && file) {
|
|
124
|
+
const base = file.split('/').pop() ?? '';
|
|
125
|
+
const clean = base.replace(/\.[^.]+$/, ''); // strip extension
|
|
126
|
+
if (clean) return clean;
|
|
127
|
+
}
|
|
128
|
+
return existingName ?? 'untitled';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface SubsystemGraphDocument {
|
|
132
|
+
components: SubsystemComponent[];
|
|
133
|
+
edges: SubsystemComponentEdge[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// React Flow conversion
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
|
|
141
|
+
|
|
142
|
+
export interface SubsystemGraphNodeData extends Record<string, unknown> {
|
|
143
|
+
component: SubsystemComponent;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
|
|
147
|
+
|
|
148
|
+
export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
149
|
+
mechanism: SubsystemEdgeMechanism;
|
|
150
|
+
refs?: string[];
|
|
151
|
+
/** True while another edge is selected — render this edge (and its label)
|
|
152
|
+
* dimmed to focus the selected relationship. */
|
|
153
|
+
dimmed?: boolean;
|
|
154
|
+
/** ELK-computed label midpoint (from the actual edge path, not node centers). */
|
|
155
|
+
labelX?: number;
|
|
156
|
+
labelY?: number;
|
|
157
|
+
/** ELK-computed SVG edge path (overrides React Flow's default path). */
|
|
158
|
+
elkPath?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type SubsystemGraphEdge = Edge<SubsystemGraphEdgeData>;
|
|
162
|
+
|
|
163
|
+
export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
|
|
164
|
+
imports: '#0893d2', // blue
|
|
165
|
+
imports_from: '#5aa9e6', // light blue
|
|
166
|
+
re_exports: '#3aa5c9', // cyan-blue
|
|
167
|
+
defines: '#2e86ab', // steel blue
|
|
168
|
+
calls: '#4ec9b0', // teal
|
|
169
|
+
extends: '#b48ead', // purple
|
|
170
|
+
inherits: '#9b6fd0', // purple
|
|
171
|
+
implements: '#c586c0', // magenta
|
|
172
|
+
mixes_in: '#d474a8', // pink-magenta
|
|
173
|
+
uses: '#e3b341', // gold
|
|
174
|
+
method: '#c586c0', // magenta
|
|
175
|
+
references: '#e07a5f', // terracotta
|
|
176
|
+
contains: '#6c5ce7', // indigo
|
|
177
|
+
feeds: '#22c55e', // green — data-flow into a processor
|
|
178
|
+
produces: '#e07a5f', // terracotta — emits an output type
|
|
179
|
+
'registers-into': '#ff6b35', // orange
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed' | 'dotted'> = {
|
|
183
|
+
imports: 'solid',
|
|
184
|
+
imports_from: 'solid',
|
|
185
|
+
re_exports: 'solid',
|
|
186
|
+
defines: 'solid',
|
|
187
|
+
calls: 'solid',
|
|
188
|
+
extends: 'dashed',
|
|
189
|
+
inherits: 'dashed',
|
|
190
|
+
implements: 'dashed',
|
|
191
|
+
mixes_in: 'dashed',
|
|
192
|
+
uses: 'solid',
|
|
193
|
+
method: 'solid',
|
|
194
|
+
references: 'dotted',
|
|
195
|
+
contains: 'solid',
|
|
196
|
+
feeds: 'solid',
|
|
197
|
+
produces: 'solid',
|
|
198
|
+
'registers-into': 'dashed',
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Package color palette (derived deterministically from the package name). */
|
|
202
|
+
export function packageColor(name: string): string {
|
|
203
|
+
const palette = [
|
|
204
|
+
'#0893d2',
|
|
205
|
+
'#4ec9b0',
|
|
206
|
+
'#ff6b35',
|
|
207
|
+
'#b48ead',
|
|
208
|
+
'#e3b341',
|
|
209
|
+
'#5aa9e6',
|
|
210
|
+
];
|
|
211
|
+
let h = 0;
|
|
212
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
|
213
|
+
return palette[h % palette.length];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Color per component kind — the informative signal (rather than package). */
|
|
217
|
+
export const KIND_COLOR: Record<SubsystemComponentKind, string> = {
|
|
218
|
+
class: '#0893d2', // blue
|
|
219
|
+
module: '#4ec9b0', // teal
|
|
220
|
+
script: '#ff6b35', // orange
|
|
221
|
+
registry: '#b48ead', // purple
|
|
222
|
+
service: '#e3b341', // gold
|
|
223
|
+
consumer: '#5aa9e6', // light blue
|
|
224
|
+
function: '#6c5ce7', // indigo (distinct from module teal + script orange)
|
|
225
|
+
method: '#c586c0', // magenta
|
|
226
|
+
type: '#e07a5f', // terracotta
|
|
227
|
+
package: '#2e86ab', // steel blue (an npm package container)
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Convert a subsystem graph document into React Flow nodes. We render **flat**
|
|
232
|
+
* (no React Flow parent/group nodes) for robustness: package regions are laid
|
|
233
|
+
* out in a grid and each component carries its package + a `pkgBounds`
|
|
234
|
+
* rectangle on its node data so the group wrapper (drawn by the graph
|
|
235
|
+
* component) can frame it. Only components' real positions matter to React
|
|
236
|
+
* Flow; the package boundary is a visual region, not a sub-flow node.
|
|
237
|
+
*/
|
|
238
|
+
export function convertSubsystemToNodes(
|
|
239
|
+
doc: SubsystemGraphDocument,
|
|
240
|
+
opts: { maxNodeWidth?: number } = {},
|
|
241
|
+
): SubsystemGraphNode[] {
|
|
242
|
+
const { maxNodeWidth } = opts;
|
|
243
|
+
// Group components by package.
|
|
244
|
+
const byPkg = new Map<string, SubsystemComponent[]>();
|
|
245
|
+
for (const c of doc.components) {
|
|
246
|
+
const list = byPkg.get(c.purl) ?? [];
|
|
247
|
+
list.push(c);
|
|
248
|
+
byPkg.set(c.purl, list);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const nodes: SubsystemGraphNode[] = [];
|
|
252
|
+
const COLS = 2;
|
|
253
|
+
const COL_W = 240;
|
|
254
|
+
const ROW_H = 150;
|
|
255
|
+
const PAD = 30;
|
|
256
|
+
const GROUP_GAP = 60;
|
|
257
|
+
|
|
258
|
+
let cursorY = PAD;
|
|
259
|
+
for (const comps of byPkg.values()) {
|
|
260
|
+
const rows = Math.ceil(comps.length / COLS);
|
|
261
|
+
const heightPx = ROW_H * rows;
|
|
262
|
+
|
|
263
|
+
comps.forEach((c, i) => {
|
|
264
|
+
const col = i % COLS;
|
|
265
|
+
const row = Math.floor(i / COLS);
|
|
266
|
+
const isPkg = c.kind === 'package';
|
|
267
|
+
// Estimate rendered node width from the longest text line so ELK reserves
|
|
268
|
+
// the right space (names can wrap, so we cap at the configurable max).
|
|
269
|
+
const text = [c.symbol, c.name, c.file.split('/').pop() ?? '']
|
|
270
|
+
.filter((t): t is string => !!t)
|
|
271
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
272
|
+
const cap = maxNodeWidth ?? (isPkg ? 320 : 300);
|
|
273
|
+
const textWidth = Math.min(cap, Math.max(60, (text?.length ?? 10) * 8));
|
|
274
|
+
// Account for CSS minWidth and padding/border so ELK's port positions match
|
|
275
|
+
// the actual rendered node boundaries.
|
|
276
|
+
const cssMinWidth = isPkg ? 200 : 150;
|
|
277
|
+
const cssPadding = isPkg ? 24 : 20; // horizontal padding (left + right)
|
|
278
|
+
const cssBorder = 4; // 2px border each side
|
|
279
|
+
const rawWidth = Math.max(cssMinWidth, (isPkg ? Math.max(220, textWidth) : textWidth) + cssPadding + cssBorder);
|
|
280
|
+
const nodeWidth = Math.max(cssMinWidth, Math.min(cap, rawWidth));
|
|
281
|
+
nodes.push({
|
|
282
|
+
id: c.id,
|
|
283
|
+
type: 'subsystem-component',
|
|
284
|
+
position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
|
|
285
|
+
width: nodeWidth,
|
|
286
|
+
height: isPkg ? 100 : 84,
|
|
287
|
+
data: { component: c },
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
cursorY += heightPx + PAD * 2 + GROUP_GAP;
|
|
291
|
+
}
|
|
292
|
+
return nodes;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Convert a subsystem graph document into React Flow edges. Edges whose target
|
|
297
|
+
* is an external label (not a component id) point at a synthetic stub so the
|
|
298
|
+
* relationship is visible without a member node.
|
|
299
|
+
*/
|
|
300
|
+
export function convertSubsystemToEdges(doc: SubsystemGraphDocument): SubsystemGraphEdge[] {
|
|
301
|
+
const compIds = new Set(doc.components.map((c) => c.id));
|
|
302
|
+
const edges: SubsystemGraphEdge[] = [];
|
|
303
|
+
|
|
304
|
+
for (const e of doc.edges) {
|
|
305
|
+
const color = MECHANISM_COLOR[e.mechanism];
|
|
306
|
+
const style = MECHANISM_STYLE[e.mechanism];
|
|
307
|
+
// If `to` is a real component, connect directly; otherwise point at a stub node.
|
|
308
|
+
const isExternal = !compIds.has(e.to);
|
|
309
|
+
const targetId = isExternal ? `external:${e.to}` : e.to;
|
|
310
|
+
|
|
311
|
+
edges.push({
|
|
312
|
+
id: e.id,
|
|
313
|
+
source: e.from,
|
|
314
|
+
target: targetId,
|
|
315
|
+
data: { mechanism: e.mechanism, refs: e.refs },
|
|
316
|
+
type: 'subsystem-edge',
|
|
317
|
+
markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
|
|
318
|
+
style: { color, stroke: color, strokeDasharray: style === 'dashed' ? '6 4' : undefined },
|
|
319
|
+
// `label` feeds ELK's label-space reservation only; the visible label is
|
|
320
|
+
// rendered by the custom SubsystemEdge as an HTML overlay.
|
|
321
|
+
label: e.mechanism,
|
|
322
|
+
// Mechanism label rendered via the custom SubsystemEdge (an HTML overlay
|
|
323
|
+
// above the SVG edges, so it can't be hidden behind other edge lines).
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return edges;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build the full React Flow graph (nodes + edges) using **ELK auto-layout** to
|
|
331
|
+
* position every node (including external stub targets), so the graph is
|
|
332
|
+
* layered with minimized crossings.
|
|
333
|
+
*/
|
|
334
|
+
export async function buildSubsystemGraph(
|
|
335
|
+
doc: SubsystemGraphDocument,
|
|
336
|
+
opts: { maxNodeWidth?: number; showEdgeLabels?: boolean; measuredWidths?: Map<string, number>; measuredHeights?: Map<string, number> } = {},
|
|
337
|
+
): Promise<{
|
|
338
|
+
nodes: SubsystemGraphNode[];
|
|
339
|
+
edges: SubsystemGraphEdge[];
|
|
340
|
+
}> {
|
|
341
|
+
const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
|
|
342
|
+
const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
|
|
343
|
+
const edges = convertSubsystemToEdges(doc);
|
|
344
|
+
|
|
345
|
+
// External edge targets that aren't real components → create stub nodes so
|
|
346
|
+
// cross-package edges have something to land on.
|
|
347
|
+
const realIds = new Set(doc.components.map((c) => c.id));
|
|
348
|
+
const externalIds: string[] = [];
|
|
349
|
+
for (const e of doc.edges) {
|
|
350
|
+
if (!realIds.has(e.to)) {
|
|
351
|
+
const extId = `external:${e.to}`;
|
|
352
|
+
if (!externalIds.includes(extId)) externalIds.push(extId);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
for (const extId of externalIds) {
|
|
356
|
+
const label = extId.replace(/^external:/, '');
|
|
357
|
+
const extTextWidth = Math.min(300, Math.max(150, label.length * 8));
|
|
358
|
+
nodes.push({
|
|
359
|
+
id: extId,
|
|
360
|
+
type: 'subsystem-component',
|
|
361
|
+
position: { x: 0, y: 0 },
|
|
362
|
+
width: Math.max(150, extTextWidth + 24),
|
|
363
|
+
height: 60,
|
|
364
|
+
data: {
|
|
365
|
+
component: {
|
|
366
|
+
id: extId,
|
|
367
|
+
name: label,
|
|
368
|
+
kind: 'consumer',
|
|
369
|
+
purl: 'external',
|
|
370
|
+
file: '',
|
|
371
|
+
purpose: 'cross-package integration target (not a member node)',
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
draggable: false,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Apply measured dimensions (second pass) so ELK gets the real node sizes.
|
|
379
|
+
if (measuredWidths && measuredWidths.size > 0) {
|
|
380
|
+
for (const n of nodes) {
|
|
381
|
+
const mw = measuredWidths.get(n.id);
|
|
382
|
+
if (mw) n.width = mw;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (measuredHeights && measuredHeights.size > 0) {
|
|
386
|
+
for (const n of nodes) {
|
|
387
|
+
const mh = measuredHeights.get(n.id);
|
|
388
|
+
if (mh) n.height = mh;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ELK auto-layout: position nodes (layered, minimized crossings).
|
|
393
|
+
let placedNodes = nodes;
|
|
394
|
+
let labelPositions = new Map<string, { x: number; y: number }>();
|
|
395
|
+
let elkPathStrings = new Map<string, string>();
|
|
396
|
+
if (nodes.length > 0) {
|
|
397
|
+
try {
|
|
398
|
+
const result = await computeElkLayout(nodes, edges, {
|
|
399
|
+
routingStyle: 'orthogonal',
|
|
400
|
+
direction: 'RIGHT',
|
|
401
|
+
nodeSpacing: 60,
|
|
402
|
+
edgeSpacing: 30,
|
|
403
|
+
edgeNodeSpacing: 60,
|
|
404
|
+
interLayerSpacing: 120,
|
|
405
|
+
preserveNodePositions: false,
|
|
406
|
+
edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
|
|
407
|
+
});
|
|
408
|
+
placedNodes = result.nodes as SubsystemGraphNode[];
|
|
409
|
+
labelPositions = result.edgeLabelPositions;
|
|
410
|
+
elkPathStrings = result.edgePaths;
|
|
411
|
+
} catch (err) {
|
|
412
|
+
// Fall back to the (unpositioned) grid if ELK is unavailable.
|
|
413
|
+
console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Stamp ELK-computed label midpoints onto edge data so the overlay can
|
|
418
|
+
// position labels at the actual path midpoint (not the node-center midpoint).
|
|
419
|
+
for (const e of edges) {
|
|
420
|
+
const pos = labelPositions.get(e.id);
|
|
421
|
+
if (pos) {
|
|
422
|
+
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
423
|
+
d.labelX = pos.x;
|
|
424
|
+
d.labelY = pos.y;
|
|
425
|
+
}
|
|
426
|
+
const elkP = elkPathStrings.get(e.id);
|
|
427
|
+
if (elkP) {
|
|
428
|
+
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
429
|
+
d.elkPath = elkP;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { nodes: placedNodes, edges };
|
|
434
|
+
}
|