@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,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
|
+
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
466
|
-
//
|
|
467
|
-
if (
|
|
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
|
|