@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.
Files changed (47) hide show
  1. package/dist/components/GraphRenderer.js.map +1 -1
  2. package/dist/components/session-events/SessionEventFeed.d.ts.map +1 -1
  3. package/dist/components/session-events/SessionEventFeed.js +10 -3
  4. package/dist/components/session-events/SessionEventFeed.js.map +1 -1
  5. package/dist/graphify/consolidated.d.ts +170 -0
  6. package/dist/graphify/consolidated.d.ts.map +1 -0
  7. package/dist/graphify/consolidated.js +11 -0
  8. package/dist/graphify/consolidated.js.map +1 -0
  9. package/dist/graphify/index.d.ts +9 -0
  10. package/dist/graphify/index.d.ts.map +1 -0
  11. package/dist/graphify/index.js +8 -0
  12. package/dist/graphify/index.js.map +1 -0
  13. package/dist/graphify/types.d.ts +141 -0
  14. package/dist/graphify/types.d.ts.map +1 -0
  15. package/dist/graphify/types.js +8 -0
  16. package/dist/graphify/types.js.map +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts +28 -0
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -0
  21. package/dist/subsystem/SubsystemComponentGraph.js +365 -0
  22. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -0
  23. package/dist/subsystem/model.d.ts +131 -0
  24. package/dist/subsystem/model.d.ts.map +1 -0
  25. package/dist/subsystem/model.js +296 -0
  26. package/dist/subsystem/model.js.map +1 -0
  27. package/dist/subsystem/nodes.d.ts +28 -0
  28. package/dist/subsystem/nodes.d.ts.map +1 -0
  29. package/dist/subsystem/nodes.js +230 -0
  30. package/dist/subsystem/nodes.js.map +1 -0
  31. package/dist/utils/elkLayout.d.ts +19 -0
  32. package/dist/utils/elkLayout.d.ts.map +1 -1
  33. package/dist/utils/elkLayout.js +72 -9
  34. package/dist/utils/elkLayout.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/components/GraphRenderer.tsx +2 -2
  37. package/src/components/session-events/SessionEventFeed.tsx +20 -3
  38. package/src/graphify/consolidated.ts +202 -0
  39. package/src/graphify/index.ts +36 -0
  40. package/src/graphify/types.ts +164 -0
  41. package/src/index.ts +31 -0
  42. package/src/stories/SubsystemComponentGraph.stories.tsx +761 -0
  43. package/src/subsystem/SubsystemComponentGraph.tsx +585 -0
  44. package/src/subsystem/model.test.ts +81 -0
  45. package/src/subsystem/model.ts +434 -0
  46. package/src/subsystem/nodes.tsx +353 -0
  47. package/src/utils/elkLayout.ts +98 -9
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Subsystem component/group node + edge renderers.
3
+ *
4
+ * Lightweight, purpose-built for the subsystem component graph: a component
5
+ * renders its name (kind-tagged, colored by package) plus its symbol/purpose
6
+ * and a click-to-open file chip. Groups render as package containers. Clicking
7
+ * a component calls `onSelect` (to open the entry point / file).
8
+ */
9
+
10
+ import { useState } from 'react';
11
+ import {
12
+ Handle,
13
+ Position,
14
+ type NodeProps,
15
+ type EdgeProps,
16
+ } from '@xyflow/react';
17
+ import { useTheme } from '@principal-ade/industry-theme';
18
+ import {
19
+ KIND_COLOR,
20
+ MECHANISM_COLOR,
21
+ deriveNameFromSymbol,
22
+ type SubsystemGraphNode,
23
+ type SubsystemGraphEdge,
24
+ } from './model';
25
+
26
+ export const KIND_LABEL: Record<string, string> = {
27
+ class: 'class',
28
+ module: 'module',
29
+ script: 'script',
30
+ registry: 'registry',
31
+ service: 'service',
32
+ consumer: 'consumer',
33
+ function: 'function',
34
+ method: 'method',
35
+ type: 'type',
36
+ package: 'package',
37
+ };
38
+
39
+ /** Owning class of a method, derived from its `Symbol.Class.method` symbol. */
40
+ function ownerClass(symbol?: string): string | undefined {
41
+ if (!symbol) return undefined;
42
+ const idx = symbol.lastIndexOf('.');
43
+ return idx > 0 ? symbol.slice(0, idx) : undefined;
44
+ }
45
+
46
+ /** Namespace/owner extracted from a PURL or npm scope. */
47
+ function purlNamespace(purl?: string): string | undefined {
48
+ if (!purl) return undefined;
49
+ // pkg:github/owner/repo → owner
50
+ const hosted = purl.match(/^pkg:(github|gitlab|bitbucket)\/([^/]+)\//);
51
+ if (hosted) return hosted[2];
52
+ // pkg:npm/@scope/name → @scope
53
+ const scoped = purl.match(/^pkg:npm\/(@[^/]+)\//);
54
+ if (scoped) return scoped[1];
55
+ // pkg:generic/local/... → local
56
+ if (purl.startsWith('pkg:generic/local/')) return 'local';
57
+ // @scope/name → @scope (legacy npm)
58
+ if (purl.startsWith('@')) {
59
+ const idx = purl.indexOf('/');
60
+ return idx > 0 ? purl.slice(0, idx) : undefined;
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ /** Short label for the hosting platform of a PURL. */
66
+ function purlRegistry(purl?: string): string {
67
+ if (!purl) return '';
68
+ if (purl.startsWith('pkg:github/')) return ' · github';
69
+ if (purl.startsWith('pkg:gitlab/')) return ' · gitlab';
70
+ if (purl.startsWith('pkg:bitbucket/')) return ' · bitbucket';
71
+ if (purl.startsWith('pkg:generic/local/')) return ' · local';
72
+ if (purl.startsWith('pkg:npm/')) return ' · npm';
73
+ if (purl.startsWith('@')) return ' · npm';
74
+ return '';
75
+ }
76
+
77
+ /** Insert zero-width spaces at identifier word boundaries so long names wrap
78
+ * on naming conventions (snake_case `foo_`|`bar`, camelCase `foo`|`Bar`,
79
+ * PascalCase `Foo`|`Bar`, acronym `ABC`|`Def`) instead of mid-character. */
80
+ function breakWords(s: string): string {
81
+ const zwsp = '\u200b';
82
+ return s
83
+ // camelCase: lower/digit → Upper (and the boundary before it holds)
84
+ .replace(/([a-z0-9])([A-Z])/g, `$1${zwsp}$2`)
85
+ // acronym → word: `ABC`|`Def` (two Uppercase then a lowercase)
86
+ .replace(/([A-Z])([A-Z][a-z])/g, `$1${zwsp}$2`)
87
+ // separators: break after `_`, `-`, `.`
88
+ .replace(/([_\-.])([^_\-.\u200b])/g, `$1${zwsp}$2`);
89
+ }
90
+
91
+ export interface SubsystemGraphCallbacks {
92
+ /** Click a component — open its file/entry point. */
93
+ onSelect?: (componentId: string) => void;
94
+ /** Click an edge (or its label) — select the relationship. */
95
+ onEdgeSelect?: (edgeId: string) => void;
96
+ /** Upper bound for node width; nodes grow with content up to this, then wrap. */
97
+ maxNodeWidth?: number;
98
+ }
99
+
100
+ /** Root callbacks carried through node data (injected by the graph component). */
101
+ export const SUBSYSTEM_CALLBACKS: SubsystemGraphCallbacks = {};
102
+
103
+ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
104
+ const { theme } = useTheme();
105
+ const { data, selected, width: nodeWidth, height: nodeHeight } = props;
106
+ const c = data.component;
107
+ const color = KIND_COLOR[c.kind] ?? '#888';
108
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
109
+ const [hover, setHover] = useState(false);
110
+ const isPackage = c.kind === 'package';
111
+ const configuredMax = SUBSYSTEM_CALLBACKS.maxNodeWidth;
112
+ const maxWidth = isPackage ? (configuredMax ?? 320) : (configuredMax ?? 300);
113
+ // `symbol` is the source of truth; `name` is derived from it consistently.
114
+ const displayName = deriveNameFromSymbol(c.symbol, c.kind, c.name, c.file);
115
+
116
+ return (
117
+ <div
118
+ onMouseEnter={() => setHover(true)}
119
+ onMouseLeave={() => setHover(false)}
120
+ onClick={(e) => {
121
+ e.stopPropagation();
122
+ SUBSYSTEM_CALLBACKS.onSelect?.(c.id);
123
+ }}
124
+ style={{
125
+ position: 'relative',
126
+ display: 'flex',
127
+ flexDirection: 'column',
128
+ justifyContent: 'center',
129
+ alignItems: 'center',
130
+ boxSizing: 'border-box',
131
+ width: nodeWidth,
132
+ height: nodeHeight,
133
+ minWidth: isPackage ? 200 : 150,
134
+ maxWidth,
135
+ padding: isPackage ? '10px 12px' : '6px 10px',
136
+ borderRadius: isPackage ? 10 : 8,
137
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
138
+ border: `2px ${isPackage ? 'dashed' : 'solid'} ${selected ? theme.colors.primary : color}`,
139
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
140
+ cursor: 'pointer',
141
+ fontFamily: theme.fonts.body,
142
+ }}
143
+ >
144
+ {/* Kind + package tooltip, shown at the top on hover */}
145
+ {hover && (
146
+ <div
147
+ style={{
148
+ position: 'absolute',
149
+ top: -26,
150
+ left: 0,
151
+ zIndex: 1000,
152
+ display: 'flex',
153
+ alignItems: 'center',
154
+ gap: 6,
155
+ whiteSpace: 'nowrap',
156
+ fontSize: theme.fontSizes[0] * 0.8,
157
+ fontFamily: theme.fonts.monospace,
158
+ textTransform: 'uppercase',
159
+ letterSpacing: 0.5,
160
+ color,
161
+ background: theme.colors.background,
162
+ border: `1px solid ${theme.colors.border}`,
163
+ borderRadius: 4,
164
+ padding: '1px 6px',
165
+ }}
166
+ >
167
+ {KIND_LABEL[c.kind] ?? c.kind}
168
+ {/* For package nodes, show the registry instead of the (already shown)
169
+ package name; for other kinds, show the owning package. */}
170
+ {c.kind === 'package' ? purlRegistry(c.purl) : c.purl ? ` · ${c.purl}` : ''}
171
+ {c.capture && c.capture !== 'edited' ? ` · ${c.capture}` : ''}
172
+ </div>
173
+ )}
174
+
175
+ {/* Purpose tooltip, shown below the node on hover */}
176
+ {hover && c.purpose && (
177
+ <div
178
+ style={{
179
+ position: 'absolute',
180
+ top: '100%',
181
+ left: 0,
182
+ marginTop: 4,
183
+ zIndex: 1000,
184
+ maxWidth: 260,
185
+ fontSize: theme.fontSizes[0] * 0.85,
186
+ fontFamily: theme.fonts.body,
187
+ color: theme.colors.text,
188
+ background: theme.colors.background,
189
+ border: `1px solid ${theme.colors.border}`,
190
+ borderRadius: 4,
191
+ padding: '4px 8px',
192
+ boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
193
+ lineHeight: 1.4,
194
+ }}
195
+ >
196
+ {c.purpose}
197
+ </div>
198
+ )}
199
+
200
+ {/* Method nodes show their owning class above the method name. */}
201
+ {c.kind === 'method' && ownerClass(c.symbol) && (
202
+ <div
203
+ style={{
204
+ fontSize: theme.fontSizes[0] * 0.78,
205
+ fontFamily: theme.fonts.monospace,
206
+ color: muted,
207
+ textAlign: 'center',
208
+ whiteSpace: 'nowrap',
209
+ overflow: 'hidden',
210
+ textOverflow: 'ellipsis',
211
+ maxWidth: '100%',
212
+ marginBottom: 1,
213
+ }}
214
+ title={ownerClass(c.symbol)}
215
+ >
216
+ {ownerClass(c.symbol)}
217
+ </div>
218
+ )}
219
+
220
+ {/* Package nodes show their namespace (@scope) above the package name. */}
221
+ {c.kind === 'package' && purlNamespace(c.purl) && (
222
+ <div
223
+ style={{
224
+ fontSize: theme.fontSizes[0] * 0.78,
225
+ fontFamily: theme.fonts.monospace,
226
+ color: muted,
227
+ textAlign: 'center',
228
+ whiteSpace: 'nowrap',
229
+ overflow: 'hidden',
230
+ textOverflow: 'ellipsis',
231
+ maxWidth: '100%',
232
+ marginBottom: 1,
233
+ }}
234
+ title={c.purl}
235
+ >
236
+ {purlNamespace(c.purl)}
237
+ </div>
238
+ )}
239
+
240
+ <div
241
+ style={{
242
+ fontSize: theme.fontSizes[2],
243
+ fontWeight: 600,
244
+ color: theme.colors.text,
245
+ lineHeight: 1.2,
246
+ textAlign: 'center',
247
+ // Let long names wrap within the node's capped width (instead of
248
+ // truncating), but cap each line so the node doesn't grow unbounded.
249
+ whiteSpace: 'normal',
250
+ overflowWrap: 'anywhere',
251
+ maxWidth: '100%',
252
+ // Types get a serif name to distinguish them from runtime units.
253
+ fontFamily:
254
+ c.kind === 'type' ? 'Georgia, "Times New Roman", serif' : theme.fonts.body,
255
+ }}
256
+ >
257
+ {c.kind === 'method' ? `.${breakWords(displayName)}` : breakWords(displayName)}
258
+ </div>
259
+
260
+ {c.symbol && c.kind !== 'method' && c.kind !== 'package' && c.symbol !== displayName && (
261
+ <div
262
+ style={{
263
+ fontSize: theme.fontSizes[0] * 0.82,
264
+ fontFamily: theme.fonts.monospace,
265
+ color: color,
266
+ marginTop: 1,
267
+ textAlign: 'center',
268
+ whiteSpace: 'nowrap',
269
+ overflow: 'hidden',
270
+ textOverflow: 'ellipsis',
271
+ maxWidth: '100%',
272
+ }}
273
+ title={c.symbol}
274
+ >
275
+ {c.symbol}
276
+ </div>
277
+ )}
278
+
279
+ {c.file && c.kind !== 'package' && (
280
+ <div
281
+ onClick={(e) => {
282
+ e.stopPropagation();
283
+ SUBSYSTEM_CALLBACKS.onSelect?.(c.id);
284
+ }}
285
+ style={{
286
+ alignSelf: 'center',
287
+ marginTop: 3,
288
+ fontSize: theme.fontSizes[0] * 0.78,
289
+ fontFamily: theme.fonts.monospace,
290
+ color: theme.colors.primary,
291
+ border: `1px solid ${theme.colors.border}`,
292
+ borderRadius: 4,
293
+ padding: '1px 5px',
294
+ cursor: 'pointer',
295
+ maxWidth: '100%',
296
+ overflow: 'hidden',
297
+ textOverflow: 'ellipsis',
298
+ whiteSpace: 'nowrap',
299
+ }}
300
+ >
301
+ {c.file.split('/').pop()}
302
+ </div>
303
+ )}
304
+
305
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
306
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
307
+ </div>
308
+ );
309
+ }
310
+
311
+ /** Subsystem edge — SVG path only. The mechanism label is rendered as an
312
+ * absolutely-positioned HTML overlay OUTSIDE the ReactFlow tree (by the
313
+ * parent Inner component) so it sits above the pane and receives pointer
314
+ * events. */
315
+ export function SubsystemEdge({
316
+ data,
317
+ markerEnd,
318
+ }: EdgeProps<SubsystemGraphEdge>) {
319
+ const path = data?.elkPath ?? '';
320
+ const mechanism = data?.mechanism ?? 'imports';
321
+ const color = MECHANISM_COLOR[mechanism] ?? '#888';
322
+ const isDashed = mechanism === 'registers-into';
323
+ const dimmed = data?.dimmed === true;
324
+ const opacity = dimmed ? 0.15 : 1;
325
+
326
+ return (
327
+ <>
328
+ {/* Invisible wide interaction path — React Flow's pane uses this for
329
+ hit-testing onEdgeClick. Must be pointer-events:stroke so the pane
330
+ delegates the click to onEdgeClick. */}
331
+ <path
332
+ d={path}
333
+ fill="none"
334
+ stroke="transparent"
335
+ strokeWidth={20}
336
+ className="react-flow__edge-interaction"
337
+ />
338
+ {/* Visible edge line — pointer-events:none so it never intercepts the
339
+ HTML label overlay rendered by the parent component. */}
340
+ <path
341
+ d={path}
342
+ fill="none"
343
+ stroke={color}
344
+ strokeWidth={1.5}
345
+ strokeDasharray={isDashed ? '6 4' : undefined}
346
+ opacity={opacity}
347
+ markerEnd={markerEnd}
348
+ style={{ pointerEvents: 'none' }}
349
+ />
350
+ </>
351
+ );
352
+ }
353
+
@@ -47,6 +47,25 @@ export interface ElkLayoutOptions {
47
47
  */
48
48
  edgeNodeSpacing?: number;
49
49
 
50
+ /**
51
+ * Minimum horizontal distance between layers (node-to-node across layers).
52
+ * Controls the gap that prevents nodes in adjacent layers from overlapping.
53
+ * @default 0
54
+ */
55
+ interLayerSpacing?: number;
56
+
57
+ /**
58
+ * Reserve space along edges for inline labels so they don't overlap nodes
59
+ * or other edges. When enabled ELK places labels inline on the edge with the
60
+ * given margin model.
61
+ */
62
+ edgeLabels?: {
63
+ /** Whether to reserve label space in the layout. @default true */
64
+ enabled?: boolean;
65
+ /** Placement of inline labels. @default 'CENTER' */
66
+ placement?: 'CENTER' | 'TAIL' | 'HEAD';
67
+ };
68
+
50
69
  /**
51
70
  * Layout direction
52
71
  * @default 'RIGHT'
@@ -62,6 +81,8 @@ export interface ElkLayoutResult {
62
81
  edgePaths: Map<string, string>;
63
82
  /** Edge label positions, keyed by edge ID */
64
83
  edgeLabelPositions: Map<string, { x: number; y: number }>;
84
+ /** Raw ELK path points per edge (for debugging). */
85
+ edgePathPoints: Map<string, Point[]>;
65
86
  }
66
87
 
67
88
  /** Point in 2D space */
@@ -216,6 +237,8 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
216
237
  nodeSpacing = 50,
217
238
  edgeSpacing = 8,
218
239
  edgeNodeSpacing = 10,
240
+ interLayerSpacing = 0,
241
+ edgeLabels,
219
242
  direction = 'RIGHT',
220
243
  } = options;
221
244
 
@@ -228,6 +251,7 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
228
251
  'elk.spacing.edgeNode': String(edgeNodeSpacing),
229
252
  'elk.layered.spacing.edgeEdgeBetweenLayers': String(edgeSpacing),
230
253
  'elk.layered.spacing.edgeNodeBetweenLayers': String(edgeNodeSpacing),
254
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(interLayerSpacing),
231
255
  // Port constraints - edges connect at specific sides
232
256
  'elk.portConstraints': 'FIXED_SIDE',
233
257
  // Improve orthogonal routing quality
@@ -239,6 +263,14 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
239
263
  'elk.layered.thoroughness': '50',
240
264
  };
241
265
 
266
+ // Reserve space for inline edge labels so they don't overlap nodes/edges.
267
+ if (edgeLabels?.enabled !== false) {
268
+ const placement = edgeLabels?.placement ?? 'CENTER';
269
+ baseOptions['elk.edgeLabels.inline'] = 'true';
270
+ baseOptions['elk.edgeLabels.inlinePlacement'] = placement;
271
+ baseOptions['elk.layered.edgeLabels.centerLabelPlacementStrategy'] = 'CENTER_LAYER';
272
+ }
273
+
242
274
  // Set edge routing style
243
275
  switch (routingStyle) {
244
276
  case 'orthogonal':
@@ -269,6 +301,8 @@ export async function computeElkLayout(
269
301
  options: ElkLayoutOptions = {}
270
302
  ): Promise<ElkLayoutResult> {
271
303
  const { preserveNodePositions = true } = options;
304
+ const edgeLabels = options.edgeLabels;
305
+ const direction = options.direction ?? 'RIGHT';
272
306
 
273
307
  // Build a map of original node positions BEFORE passing to ELK
274
308
  // (ELK mutates the input nodes in place, so we must save positions first)
@@ -282,6 +316,13 @@ export async function computeElkLayout(
282
316
  const width = node.measured?.width ?? node.width ?? 200;
283
317
  const height = node.measured?.height ?? node.height ?? 100;
284
318
 
319
+ // Optional explicit layout layer, read from node data (e.g. our subsystem
320
+ // components carry `data.component.layer`). Forces ELK to place the node in
321
+ // that layer so the graph reads as a pipeline rather than a guess.
322
+ let layer: number | undefined;
323
+ const nd = node.data as { component?: { layer?: number }; layer?: number } | undefined;
324
+ layer = nd?.layer ?? nd?.component?.layer;
325
+
285
326
  return {
286
327
  id: node.id,
287
328
  width,
@@ -297,6 +338,7 @@ export async function computeElkLayout(
297
338
  ],
298
339
  properties: {
299
340
  'portConstraints': 'FIXED_SIDE',
341
+ ...(layer !== undefined ? { 'layering.layer': String(layer) } : {}),
300
342
  },
301
343
  };
302
344
  });
@@ -332,7 +374,18 @@ export async function computeElkLayout(
332
374
  };
333
375
  }
334
376
 
335
- // Fallback to position-based calculation
377
+ // When ELK repositions nodes (preserveNodePositions=false), the initial
378
+ // grid positions are unreliable — use the layout direction instead.
379
+ if (!preserveNodePositions) {
380
+ switch (direction) {
381
+ case 'RIGHT': return { sourcePort: `${sourceId}_right`, targetPort: `${targetId}_left` };
382
+ case 'LEFT': return { sourcePort: `${sourceId}_left`, targetPort: `${targetId}_right` };
383
+ case 'DOWN': return { sourcePort: `${sourceId}_bottom`, targetPort: `${targetId}_top` };
384
+ case 'UP': return { sourcePort: `${sourceId}_top`, targetPort: `${targetId}_bottom` };
385
+ }
386
+ }
387
+
388
+ // Fallback to position-based calculation (for preserveNodePositions=true)
336
389
  const sourcePos = originalPositions.get(sourceId);
337
390
  const targetPos = originalPositions.get(targetId);
338
391
 
@@ -378,11 +431,20 @@ export async function computeElkLayout(
378
431
  const isHorizontalFirst = sourcePort.endsWith('_right') || sourcePort.endsWith('_left');
379
432
  edgeRoutingDirection.set(edge.id, isHorizontalFirst);
380
433
 
381
- return {
434
+ const elkEdge: ElkExtendedEdge = {
382
435
  id: edge.id,
383
436
  sources: [sourcePort],
384
437
  targets: [targetPort],
385
438
  };
439
+ // Estimated label size so ELK reserves room to render the inline label
440
+ // without it overlapping nodes or sibling edges.
441
+ if (edgeLabels?.enabled !== false && typeof edge.label === 'string') {
442
+ const text = edge.label;
443
+ const labelWidth = Math.max(20, text.length * 7); // ~7px per mono char
444
+ const labelHeight = 14;
445
+ elkEdge.labels = [{ text, width: labelWidth, height: labelHeight }];
446
+ }
447
+ return elkEdge;
386
448
  });
387
449
 
388
450
  // Create ELK graph
@@ -407,6 +469,7 @@ export async function computeElkLayout(
407
469
  // Extract results
408
470
  const edgePaths = new Map<string, string>();
409
471
  const edgeLabelPositions = new Map<string, { x: number; y: number }>();
472
+ const edgePathPoints = new Map<string, Point[]>();
410
473
 
411
474
  // Process edges
412
475
  if (layoutedGraph.edges) {
@@ -462,9 +525,37 @@ export async function computeElkLayout(
462
525
  }
463
526
  }
464
527
 
465
- // For orthogonal routing, rebuild the path with proper right-angle bends
466
- // The transformation can distort ELK's bend points, so we compute fresh bends
467
- if (options.routingStyle === 'orthogonal') {
528
+ // Use ELK's native label position (it accounts for node avoidance)
529
+ // and apply the same coordinate offset.
530
+ if (edge.labels && edge.labels.length > 0) {
531
+ const elkLabel = edge.labels[0];
532
+ // Raw ELK label position — no conversion.
533
+ let lx = elkLabel.x ?? 0;
534
+ let ly = elkLabel.y ?? 0;
535
+ if (preserveNodePositions && sourceOriginal && sourceElk && targetOriginal && targetElk) {
536
+ const sourceOffset = {
537
+ x: sourceOriginal.x - sourceElk.x,
538
+ y: sourceOriginal.y - sourceElk.y,
539
+ };
540
+ const targetOffset = {
541
+ x: targetOriginal.x - targetElk.x,
542
+ y: targetOriginal.y - targetElk.y,
543
+ };
544
+ // Interpolate offset based on label position along the edge
545
+ const startX = allPoints[0].x;
546
+ const endX = allPoints[allPoints.length - 1].x;
547
+ const rangeX = Math.abs(endX - startX) || 1;
548
+ const t = Math.min(1, Math.max(0, Math.abs(lx - startX) / rangeX));
549
+ lx += sourceOffset.x + (targetOffset.x - sourceOffset.x) * t;
550
+ ly += sourceOffset.y + (targetOffset.y - sourceOffset.y) * t;
551
+ }
552
+ edgeLabelPositions.set(edge.id, { x: lx, y: ly });
553
+ }
554
+
555
+ // For orthogonal routing with preserved positions, the offset can distort
556
+ // ELK's bend points, so we rebuild the path. Otherwise, use ELK's bends
557
+ // as-is since they already account for label placement and node avoidance.
558
+ if (options.routingStyle === 'orthogonal' && preserveNodePositions) {
468
559
  const start = allPoints[0];
469
560
  const end = allPoints[allPoints.length - 1];
470
561
  const dx = Math.abs(end.x - start.x);
@@ -502,10 +593,7 @@ export async function computeElkLayout(
502
593
  : pointsToPath(allPoints);
503
594
 
504
595
  edgePaths.set(edge.id, path);
505
-
506
- // Calculate label position
507
- const labelPos = calculatePathMidpoint(allPoints);
508
- edgeLabelPositions.set(edge.id, labelPos);
596
+ edgePathPoints.set(edge.id, [...allPoints]);
509
597
  }
510
598
  }
511
599
  }
@@ -528,6 +616,7 @@ export async function computeElkLayout(
528
616
  nodes: resultNodes,
529
617
  edgePaths,
530
618
  edgeLabelPositions,
619
+ edgePathPoints,
531
620
  };
532
621
  }
533
622