@principal-ai/principal-view-react 0.16.58 → 0.16.59
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/subsystem/SubsystemComponentGraph.d.ts +4 -4
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +60 -14
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +39 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +90 -10
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +10 -3
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +43 -1
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +26 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +150 -12
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +68 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +71 -15
- package/src/subsystem/model.test.ts +56 -0
- package/src/subsystem/model.ts +119 -11
- package/src/subsystem/nodes.tsx +64 -2
- package/src/utils/elkLayout.ts +162 -12
|
@@ -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 = {
|
package/src/subsystem/model.ts
CHANGED
|
@@ -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
|
|
|
@@ -292,6 +292,46 @@ export interface SubsystemGraphDocument {
|
|
|
292
292
|
|
|
293
293
|
export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* One process boundary region — all components sharing a `process` value.
|
|
297
|
+
* Nodes without a `process` sit outside every boundary (no region).
|
|
298
|
+
*/
|
|
299
|
+
export interface SubsystemProcessRegion {
|
|
300
|
+
/** The `process` value (e.g. `trail-viewer/host`). */
|
|
301
|
+
key: string;
|
|
302
|
+
/** Display label for the boundary frame. */
|
|
303
|
+
label: string;
|
|
304
|
+
/** Component ids that are members of this region. */
|
|
305
|
+
memberIds: string[];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** React Flow id for a process boundary group node. */
|
|
309
|
+
export function processGroupNodeId(processKey: string): string {
|
|
310
|
+
return `process:${processKey}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Derive boundary regions from a document — one per distinct non-empty
|
|
315
|
+
* `process` value, in first-appearance order.
|
|
316
|
+
*/
|
|
317
|
+
export function getSubsystemRegions(
|
|
318
|
+
doc: Pick<SubsystemGraphDocument, 'components'>,
|
|
319
|
+
): SubsystemProcessRegion[] {
|
|
320
|
+
const byProcess = new Map<string, string[]>();
|
|
321
|
+
for (const c of doc.components) {
|
|
322
|
+
const p = c.process?.trim();
|
|
323
|
+
if (!p) continue;
|
|
324
|
+
const list = byProcess.get(p) ?? [];
|
|
325
|
+
list.push(c.id);
|
|
326
|
+
byProcess.set(p, list);
|
|
327
|
+
}
|
|
328
|
+
return [...byProcess.entries()].map(([key, memberIds]) => ({
|
|
329
|
+
key,
|
|
330
|
+
label: key,
|
|
331
|
+
memberIds,
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
|
|
295
335
|
export interface SubsystemGraphNodeData extends Record<string, unknown> {
|
|
296
336
|
component: SubsystemComponent;
|
|
297
337
|
/** Set while a file is open in the drawer: true if this node's component
|
|
@@ -302,7 +342,15 @@ export interface SubsystemGraphNodeData extends Record<string, unknown> {
|
|
|
302
342
|
dimmed?: boolean;
|
|
303
343
|
}
|
|
304
344
|
|
|
305
|
-
export
|
|
345
|
+
export interface SubsystemGroupNodeData extends Record<string, unknown> {
|
|
346
|
+
region: SubsystemProcessRegion;
|
|
347
|
+
/** True while the region's members are dimmed by flow focus. */
|
|
348
|
+
dimmed?: boolean;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export type SubsystemGraphNode =
|
|
352
|
+
| Node<SubsystemGraphNodeData, 'subsystem-component'>
|
|
353
|
+
| Node<SubsystemGroupNodeData, 'subsystem-group'>;
|
|
306
354
|
|
|
307
355
|
export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
308
356
|
mechanism: SubsystemEdgeMechanism;
|
|
@@ -313,6 +361,8 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
|
313
361
|
/** ELK-computed label midpoint (from the actual edge path, not node centers). */
|
|
314
362
|
labelX?: number;
|
|
315
363
|
labelY?: number;
|
|
364
|
+
/** Polyline length in flow-space units (for capping screen-space label size). */
|
|
365
|
+
pathLength?: number;
|
|
316
366
|
/** ELK-computed SVG edge path (overrides React Flow's default path). */
|
|
317
367
|
elkPath?: string;
|
|
318
368
|
}
|
|
@@ -394,12 +444,11 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
|
|
|
394
444
|
};
|
|
395
445
|
|
|
396
446
|
/**
|
|
397
|
-
* Convert a subsystem graph document into React Flow nodes.
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
* Flow; the package boundary is a visual region, not a sub-flow node.
|
|
447
|
+
* Convert a subsystem graph document into React Flow nodes. Components that
|
|
448
|
+
* carry a `process` get a `parentId` pointing at their boundary group node
|
|
449
|
+
* (`process:<process>`); nodes without one stay top-level (outside every
|
|
450
|
+
* boundary). The initial grid groups by `process ?? purl` so the pre-ELK
|
|
451
|
+
* positions are already clustered; ELK then refines with compound layout.
|
|
403
452
|
*/
|
|
404
453
|
export function convertSubsystemToNodes(
|
|
405
454
|
doc: SubsystemGraphDocument,
|
|
@@ -447,9 +496,11 @@ export function convertSubsystemToNodes(
|
|
|
447
496
|
const cssBorder = 4; // 2px border each side
|
|
448
497
|
const rawWidth = Math.max(cssMinWidth, textWidth + cssPadding + cssBorder);
|
|
449
498
|
const nodeWidth = Math.max(cssMinWidth, Math.min(cap, rawWidth));
|
|
499
|
+
const processKey = c.process?.trim();
|
|
450
500
|
nodes.push({
|
|
451
501
|
id: c.id,
|
|
452
502
|
type: 'subsystem-component',
|
|
503
|
+
...(processKey ? { parentId: processGroupNodeId(processKey) } : {}),
|
|
453
504
|
position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
|
|
454
505
|
width: nodeWidth,
|
|
455
506
|
height: 84,
|
|
@@ -461,6 +512,24 @@ export function convertSubsystemToNodes(
|
|
|
461
512
|
return nodes;
|
|
462
513
|
}
|
|
463
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Convert boundary regions into React Flow parent (group) nodes. One per
|
|
517
|
+
* distinct `process` value; member components point at these via `parentId`.
|
|
518
|
+
* Positions/sizes are placeholders — ELK compound layout overwrites them.
|
|
519
|
+
*/
|
|
520
|
+
export function convertSubsystemToGroups(
|
|
521
|
+
doc: Pick<SubsystemGraphDocument, 'components'>,
|
|
522
|
+
): SubsystemGraphNode[] {
|
|
523
|
+
return getSubsystemRegions(doc).map((region) => ({
|
|
524
|
+
id: processGroupNodeId(region.key),
|
|
525
|
+
type: 'subsystem-group',
|
|
526
|
+
position: { x: 0, y: 0 },
|
|
527
|
+
width: 400,
|
|
528
|
+
height: 300,
|
|
529
|
+
data: { region },
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
|
|
464
533
|
/**
|
|
465
534
|
* Convert a subsystem graph document into React Flow edges. Edges whose target
|
|
466
535
|
* is an external label (not a component id) point at a synthetic stub so the
|
|
@@ -522,10 +591,23 @@ export async function buildSubsystemGraph(
|
|
|
522
591
|
): Promise<{
|
|
523
592
|
nodes: SubsystemGraphNode[];
|
|
524
593
|
edges: SubsystemGraphEdge[];
|
|
594
|
+
regions: SubsystemProcessRegion[];
|
|
525
595
|
}> {
|
|
526
596
|
const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
|
|
527
597
|
const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
|
|
528
598
|
const edges = convertSubsystemToEdges(doc);
|
|
599
|
+
// Boundary regions: one per multi-member process. Singletons get no frame —
|
|
600
|
+
// strip the parentId convertSubsystemToNodes stamped so React Flow never
|
|
601
|
+
// points at a non-existent parent.
|
|
602
|
+
const regions = getSubsystemRegions(doc).filter((r) => r.memberIds.length >= 2);
|
|
603
|
+
const regionKeys = new Set(regions.map((r) => r.key));
|
|
604
|
+
for (const n of nodes) {
|
|
605
|
+
if (n.type !== 'subsystem-component') continue;
|
|
606
|
+
const proc = (n.data as SubsystemGraphNodeData).component?.process?.trim();
|
|
607
|
+
if (proc && !regionKeys.has(proc)) {
|
|
608
|
+
delete (n as { parentId?: string }).parentId;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
529
611
|
|
|
530
612
|
// External edge targets that aren't real components → create stub nodes so
|
|
531
613
|
// cross-package edges have something to land on.
|
|
@@ -574,10 +656,12 @@ export async function buildSubsystemGraph(
|
|
|
574
656
|
}
|
|
575
657
|
}
|
|
576
658
|
|
|
577
|
-
// ELK auto-layout: position nodes (layered, minimized crossings)
|
|
659
|
+
// ELK auto-layout: position nodes (layered, minimized crossings) with
|
|
660
|
+
// process partitions as compound parents so boundaries shape the layout.
|
|
578
661
|
let placedNodes = nodes;
|
|
579
662
|
let labelPositions = new Map<string, { x: number; y: number }>();
|
|
580
663
|
let elkPathStrings = new Map<string, string>();
|
|
664
|
+
let elkPathPoints = new Map<string, { x: number; y: number }[]>();
|
|
581
665
|
if (nodes.length > 0) {
|
|
582
666
|
try {
|
|
583
667
|
const result = await computeElkLayout(nodes, edges, {
|
|
@@ -589,10 +673,29 @@ export async function buildSubsystemGraph(
|
|
|
589
673
|
interLayerSpacing: 120,
|
|
590
674
|
preserveNodePositions: false,
|
|
591
675
|
edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
|
|
676
|
+
groups: regions.map((r) => ({
|
|
677
|
+
id: processGroupNodeId(r.key),
|
|
678
|
+
memberIds: r.memberIds,
|
|
679
|
+
})),
|
|
592
680
|
});
|
|
593
|
-
|
|
681
|
+
const groupNodes: SubsystemGraphNode[] = regions.flatMap((region) => {
|
|
682
|
+
const bounds = result.groupBounds.get(processGroupNodeId(region.key));
|
|
683
|
+
if (!bounds) return [];
|
|
684
|
+
const group: SubsystemGraphNode = {
|
|
685
|
+
id: processGroupNodeId(region.key),
|
|
686
|
+
type: 'subsystem-group',
|
|
687
|
+
position: { x: bounds.x, y: bounds.y },
|
|
688
|
+
width: Math.max(200, bounds.width),
|
|
689
|
+
height: Math.max(160, bounds.height),
|
|
690
|
+
data: { region },
|
|
691
|
+
};
|
|
692
|
+
return [group];
|
|
693
|
+
});
|
|
694
|
+
// Parents first — React Flow resolves children via parentId.
|
|
695
|
+
placedNodes = [...groupNodes, ...(result.nodes as SubsystemGraphNode[])];
|
|
594
696
|
labelPositions = result.edgeLabelPositions;
|
|
595
697
|
elkPathStrings = result.edgePaths;
|
|
698
|
+
elkPathPoints = result.edgePathPoints;
|
|
596
699
|
} catch (err) {
|
|
597
700
|
// Fall back to the (unpositioned) grid if ELK is unavailable.
|
|
598
701
|
console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
|
|
@@ -613,7 +716,12 @@ export async function buildSubsystemGraph(
|
|
|
613
716
|
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
614
717
|
d.elkPath = elkP;
|
|
615
718
|
}
|
|
719
|
+
const pts = elkPathPoints.get(e.id);
|
|
720
|
+
if (pts && pts.length > 1) {
|
|
721
|
+
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
722
|
+
d.pathLength = calculatePathLength(pts);
|
|
723
|
+
}
|
|
616
724
|
}
|
|
617
725
|
|
|
618
|
-
return { nodes: placedNodes, edges };
|
|
726
|
+
return { nodes: placedNodes, edges, regions };
|
|
619
727
|
}
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { useState } from 'react';
|
|
|
11
11
|
import {
|
|
12
12
|
Handle,
|
|
13
13
|
Position,
|
|
14
|
+
type Node,
|
|
14
15
|
type NodeProps,
|
|
15
16
|
type EdgeProps,
|
|
16
17
|
} from '@xyflow/react';
|
|
@@ -21,7 +22,9 @@ import {
|
|
|
21
22
|
ROLE_COLOR,
|
|
22
23
|
ROLE_LABEL,
|
|
23
24
|
deriveNameFromSymbol,
|
|
24
|
-
|
|
25
|
+
packageColor,
|
|
26
|
+
type SubsystemGraphNodeData,
|
|
27
|
+
type SubsystemGroupNodeData,
|
|
25
28
|
type SubsystemGraphEdge,
|
|
26
29
|
} from './model';
|
|
27
30
|
import { constructColorsFromPierreTheme } from '../pierre/constructColors';
|
|
@@ -70,7 +73,7 @@ export interface SubsystemGraphCallbacks {
|
|
|
70
73
|
/** Root callbacks carried through node data (injected by the graph component). */
|
|
71
74
|
export const SUBSYSTEM_CALLBACKS: SubsystemGraphCallbacks = {};
|
|
72
75
|
|
|
73
|
-
export function SubsystemComponentNode(props: NodeProps<
|
|
76
|
+
export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeData, 'subsystem-component'>>) {
|
|
74
77
|
const { theme, mode } = useTheme();
|
|
75
78
|
const { data, selected, width: nodeWidth, height: nodeHeight } = props;
|
|
76
79
|
const c = data.component;
|
|
@@ -267,6 +270,65 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
|
|
|
267
270
|
);
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Process boundary frame — a React Flow parent node. Members render inside
|
|
275
|
+
* via `parentId`; this draws the labeled container only (no handles, no
|
|
276
|
+
* selection). The border color derives deterministically from the process key
|
|
277
|
+
* so each deployment unit reads as its own region.
|
|
278
|
+
*/
|
|
279
|
+
export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData, 'subsystem-group'>>) {
|
|
280
|
+
const { theme } = useTheme();
|
|
281
|
+
const { data, width, height, selected } = props as unknown as {
|
|
282
|
+
data: SubsystemGroupNodeData;
|
|
283
|
+
width?: number;
|
|
284
|
+
height?: number;
|
|
285
|
+
selected?: boolean;
|
|
286
|
+
};
|
|
287
|
+
const region = data.region;
|
|
288
|
+
const color = packageColor(region?.key ?? 'process');
|
|
289
|
+
const dimmed = data.dimmed === true;
|
|
290
|
+
const hidden = (data as { hidden?: boolean }).hidden === true;
|
|
291
|
+
|
|
292
|
+
if (!region) return null;
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
style={{
|
|
297
|
+
position: 'relative',
|
|
298
|
+
width: width ?? 400,
|
|
299
|
+
height: height ?? 300,
|
|
300
|
+
boxSizing: 'border-box',
|
|
301
|
+
borderRadius: 12,
|
|
302
|
+
border: `2px ${selected ? 'solid' : 'dashed'} ${color}`,
|
|
303
|
+
background: `${color}14`,
|
|
304
|
+
opacity: hidden ? 0 : dimmed ? 0.35 : 1,
|
|
305
|
+
transition: 'opacity 150ms ease',
|
|
306
|
+
pointerEvents: 'none',
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
309
|
+
<div
|
|
310
|
+
style={{
|
|
311
|
+
position: 'absolute',
|
|
312
|
+
top: -13,
|
|
313
|
+
left: 12,
|
|
314
|
+
fontFamily: theme.fonts.monospace,
|
|
315
|
+
fontSize: theme.fontSizes[0],
|
|
316
|
+
fontWeight: 700,
|
|
317
|
+
letterSpacing: 0.6,
|
|
318
|
+
color,
|
|
319
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
320
|
+
border: `1px solid ${color}`,
|
|
321
|
+
borderRadius: 4,
|
|
322
|
+
padding: '1px 7px',
|
|
323
|
+
whiteSpace: 'nowrap',
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
{`process: ${region.label}`}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
270
332
|
/** `#rrggbb` + alpha → `#rrggbbaa`. Used to dim a stroke/marker by color so
|
|
271
333
|
* each opacity gets its own SVG marker id — path `opacity` leaks across every
|
|
272
334
|
* edge that shares a `url(#marker)` (the focused edge's arrowhead dims). */
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -71,6 +71,14 @@ export interface ElkLayoutOptions {
|
|
|
71
71
|
* @default 'RIGHT'
|
|
72
72
|
*/
|
|
73
73
|
direction?: 'RIGHT' | 'LEFT' | 'DOWN' | 'UP';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compound groups — each becomes a nested ELK parent whose `memberIds`
|
|
77
|
+
* are laid out inside it. Members reference the group via React Flow
|
|
78
|
+
* `parentId`; ELK returns parent-relative child positions which this
|
|
79
|
+
* module flattens back to absolute flow coordinates.
|
|
80
|
+
*/
|
|
81
|
+
groups?: Array<{ id: string; memberIds: string[] }>;
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/** Result of ELK layout computation */
|
|
@@ -83,6 +91,8 @@ export interface ElkLayoutResult {
|
|
|
83
91
|
edgeLabelPositions: Map<string, { x: number; y: number }>;
|
|
84
92
|
/** Raw ELK path points per edge (for debugging). */
|
|
85
93
|
edgePathPoints: Map<string, Point[]>;
|
|
94
|
+
/** Compound parent bounds from ELK (absolute flow coords), keyed by group id. */
|
|
95
|
+
groupBounds: Map<string, { x: number; y: number; width: number; height: number }>;
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
/** Point in 2D space */
|
|
@@ -102,6 +112,8 @@ interface ElkEdgeSection {
|
|
|
102
112
|
/** Extended ELK edge with sections */
|
|
103
113
|
interface ElkEdgeWithSections extends ElkExtendedEdge {
|
|
104
114
|
sections?: ElkEdgeSection[];
|
|
115
|
+
/** Id of the compound node whose coordinate frame sections/labels use. */
|
|
116
|
+
container?: string;
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
// Create ELK instance lazily to avoid issues in test environments
|
|
@@ -228,6 +240,47 @@ export function calculatePathMidpoint(points: Point[]): Point {
|
|
|
228
240
|
return points[points.length - 1];
|
|
229
241
|
}
|
|
230
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Closest point on a polyline to a target (for snapping labels onto the stroke).
|
|
245
|
+
*/
|
|
246
|
+
export function closestPointOnPath(points: Point[], target: Point): Point {
|
|
247
|
+
if (points.length === 0) return { x: 0, y: 0 };
|
|
248
|
+
if (points.length === 1) return points[0];
|
|
249
|
+
|
|
250
|
+
let best = points[0];
|
|
251
|
+
let bestDist = Infinity;
|
|
252
|
+
|
|
253
|
+
for (let i = 1; i < points.length; i++) {
|
|
254
|
+
const a = points[i - 1];
|
|
255
|
+
const b = points[i];
|
|
256
|
+
const dx = b.x - a.x;
|
|
257
|
+
const dy = b.y - a.y;
|
|
258
|
+
const lenSq = dx * dx + dy * dy;
|
|
259
|
+
const t = lenSq === 0 ? 0 : Math.max(0, Math.min(1, ((target.x - a.x) * dx + (target.y - a.y) * dy) / lenSq));
|
|
260
|
+
const px = a.x + dx * t;
|
|
261
|
+
const py = a.y + dy * t;
|
|
262
|
+
const dist = Math.hypot(target.x - px, target.y - py);
|
|
263
|
+
if (dist < bestDist) {
|
|
264
|
+
bestDist = dist;
|
|
265
|
+
best = { x: px, y: py };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return best;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Polyline length in flow-space units (sum of segment lengths).
|
|
274
|
+
* @public Exported for testing
|
|
275
|
+
*/
|
|
276
|
+
export function calculatePathLength(points: Point[]): number {
|
|
277
|
+
let total = 0;
|
|
278
|
+
for (let i = 1; i < points.length; i++) {
|
|
279
|
+
total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
|
280
|
+
}
|
|
281
|
+
return total;
|
|
282
|
+
}
|
|
283
|
+
|
|
231
284
|
/**
|
|
232
285
|
* Get ELK layout options based on configuration
|
|
233
286
|
*/
|
|
@@ -447,22 +500,103 @@ export async function computeElkLayout(
|
|
|
447
500
|
return elkEdge;
|
|
448
501
|
});
|
|
449
502
|
|
|
503
|
+
// Partition leaf nodes into compound parents when groups are given.
|
|
504
|
+
// Group shells themselves are NOT part of `nodes` — they are reconstructed
|
|
505
|
+
// by the caller from `groupBounds`. Only leaf ids in `memberIds` nest.
|
|
506
|
+
const groupDefs = (options.groups ?? []).filter((g) => g.memberIds.length > 0);
|
|
507
|
+
const memberToGroup = new Map<string, string>();
|
|
508
|
+
const groupedLeafIds = new Set<string>();
|
|
509
|
+
for (const g of groupDefs) {
|
|
510
|
+
for (const mid of g.memberIds) {
|
|
511
|
+
if (!memberToGroup.has(mid)) {
|
|
512
|
+
memberToGroup.set(mid, g.id);
|
|
513
|
+
groupedLeafIds.add(mid);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const elkById = new Map(elkNodes.map((n) => [n.id, n]));
|
|
518
|
+
const ungroupedElkNodes: ElkNode[] = [];
|
|
519
|
+
for (const n of elkNodes) {
|
|
520
|
+
if (!groupedLeafIds.has(n.id)) ungroupedElkNodes.push(n);
|
|
521
|
+
}
|
|
522
|
+
const elkParents: ElkNode[] = [];
|
|
523
|
+
for (const g of groupDefs) {
|
|
524
|
+
const children = g.memberIds
|
|
525
|
+
.map((mid) => elkById.get(mid))
|
|
526
|
+
.filter((n): n is ElkNode => !!n);
|
|
527
|
+
// Skip groups with <2 real members — a single-child frame adds noise;
|
|
528
|
+
// the caller drops the shell and leaves the node top-level.
|
|
529
|
+
if (children.length < 2) {
|
|
530
|
+
for (const c of children) ungroupedElkNodes.push(c);
|
|
531
|
+
memberToGroup.delete(g.memberIds[0]);
|
|
532
|
+
groupedLeafIds.delete(g.memberIds[0]);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
elkParents.push({
|
|
536
|
+
id: g.id,
|
|
537
|
+
children,
|
|
538
|
+
layoutOptions: {
|
|
539
|
+
'elk.algorithm': 'layered',
|
|
540
|
+
'elk.direction': direction,
|
|
541
|
+
'elk.padding': '[top=48,left=24,bottom=24,right=24]',
|
|
542
|
+
'elk.spacing.nodeNode': '40',
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
450
547
|
// Create ELK graph
|
|
548
|
+
const rootOptions = getElkOptions(options);
|
|
549
|
+
if (elkParents.length > 0) {
|
|
550
|
+
rootOptions['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
|
|
551
|
+
}
|
|
451
552
|
const elkGraph: ElkNode = {
|
|
452
553
|
id: 'root',
|
|
453
|
-
layoutOptions:
|
|
454
|
-
children:
|
|
554
|
+
layoutOptions: rootOptions,
|
|
555
|
+
children: [...ungroupedElkNodes, ...elkParents],
|
|
455
556
|
edges: elkEdges,
|
|
456
557
|
};
|
|
457
558
|
|
|
458
559
|
// Run ELK layout
|
|
459
560
|
const layoutedGraph = await getElkInstance().layout(elkGraph);
|
|
460
561
|
|
|
461
|
-
// Build
|
|
562
|
+
// Build maps of ELK-computed positions. Nested children report
|
|
563
|
+
// parent-relative coords — flatten to absolute for edges, and keep the
|
|
564
|
+
// relative form for React Flow children (whose position is parent-relative).
|
|
565
|
+
// Absolute offset of every ELK node (parents included), accumulated down
|
|
566
|
+
// the ancestor chain — edge sections/labels are relative to their
|
|
567
|
+
// `container`, so each edge needs its container's absolute offset.
|
|
568
|
+
const elkAbsOffsets = new Map<string, { x: number; y: number }>();
|
|
462
569
|
const elkPositions = new Map<string, { x: number; y: number }>();
|
|
570
|
+
const elkRelativePositions = new Map<string, { x: number; y: number }>();
|
|
571
|
+
const groupBounds = new Map<string, { x: number; y: number; width: number; height: number }>();
|
|
572
|
+
const walkElk = (n: ElkNode, ox: number, oy: number) => {
|
|
573
|
+
const ax = ox + (n.x ?? 0);
|
|
574
|
+
const ay = oy + (n.y ?? 0);
|
|
575
|
+
elkAbsOffsets.set(n.id, { x: ax, y: ay });
|
|
576
|
+
for (const c of n.children ?? []) walkElk(c, ax, ay);
|
|
577
|
+
};
|
|
578
|
+
walkElk(layoutedGraph, 0, 0);
|
|
463
579
|
if (layoutedGraph.children) {
|
|
464
580
|
for (const child of layoutedGraph.children) {
|
|
465
|
-
|
|
581
|
+
if (child.children && child.children.length > 0 && groupDefs.some((g) => g.id === child.id)) {
|
|
582
|
+
const gx = child.x ?? 0;
|
|
583
|
+
const gy = child.y ?? 0;
|
|
584
|
+
groupBounds.set(child.id, {
|
|
585
|
+
x: gx,
|
|
586
|
+
y: gy,
|
|
587
|
+
width: child.width ?? 0,
|
|
588
|
+
height: child.height ?? 0,
|
|
589
|
+
});
|
|
590
|
+
for (const grand of child.children) {
|
|
591
|
+
const rx = grand.x ?? 0;
|
|
592
|
+
const ry = grand.y ?? 0;
|
|
593
|
+
elkRelativePositions.set(grand.id, { x: rx, y: ry });
|
|
594
|
+
elkPositions.set(grand.id, { x: gx + rx, y: gy + ry });
|
|
595
|
+
}
|
|
596
|
+
} else {
|
|
597
|
+
elkPositions.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 });
|
|
598
|
+
elkRelativePositions.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 });
|
|
599
|
+
}
|
|
466
600
|
}
|
|
467
601
|
}
|
|
468
602
|
|
|
@@ -486,7 +620,10 @@ export async function computeElkLayout(
|
|
|
486
620
|
const targetOriginal = targetId ? originalPositions.get(targetId) : null;
|
|
487
621
|
const targetElk = targetId ? elkPositions.get(targetId) : null;
|
|
488
622
|
|
|
489
|
-
// Collect all points from sections
|
|
623
|
+
// Collect all points from sections. Sections (and labels) are
|
|
624
|
+
// relative to the edge's `container` — intra-group edges live in
|
|
625
|
+
// the parent's frame, so translate to root-absolute flow coords.
|
|
626
|
+
const containerOffset = elkAbsOffsets.get(edge.container ?? 'root') ?? { x: 0, y: 0 };
|
|
490
627
|
const allPoints: Point[] = [];
|
|
491
628
|
|
|
492
629
|
for (const section of edge.sections) {
|
|
@@ -496,6 +633,12 @@ export async function computeElkLayout(
|
|
|
496
633
|
}
|
|
497
634
|
allPoints.push(section.endPoint);
|
|
498
635
|
}
|
|
636
|
+
if (containerOffset.x !== 0 || containerOffset.y !== 0) {
|
|
637
|
+
for (const p of allPoints) {
|
|
638
|
+
p.x += containerOffset.x;
|
|
639
|
+
p.y += containerOffset.y;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
499
642
|
|
|
500
643
|
// If preserving positions, we need to offset the edge points
|
|
501
644
|
// The edge path is relative to ELK's layout, so we translate it
|
|
@@ -531,8 +674,8 @@ export async function computeElkLayout(
|
|
|
531
674
|
const elkLabel = edge.labels[0];
|
|
532
675
|
// ELK reports the label's top-left; convert to center so screen-space
|
|
533
676
|
// overlays can anchor with translate(-50%, -50%) at any zoom.
|
|
534
|
-
let lx = (elkLabel.x ?? 0) + (elkLabel.width ?? 0) / 2;
|
|
535
|
-
let ly = (elkLabel.y ?? 0) + (elkLabel.height ?? 0) / 2;
|
|
677
|
+
let lx = (elkLabel.x ?? 0) + (elkLabel.width ?? 0) / 2 + containerOffset.x;
|
|
678
|
+
let ly = (elkLabel.y ?? 0) + (elkLabel.height ?? 0) / 2 + containerOffset.y;
|
|
536
679
|
if (preserveNodePositions && sourceOriginal && sourceElk && targetOriginal && targetElk) {
|
|
537
680
|
const sourceOffset = {
|
|
538
681
|
x: sourceOriginal.x - sourceElk.x,
|
|
@@ -550,7 +693,11 @@ export async function computeElkLayout(
|
|
|
550
693
|
lx += sourceOffset.x + (targetOffset.x - sourceOffset.x) * t;
|
|
551
694
|
ly += sourceOffset.y + (targetOffset.y - sourceOffset.y) * t;
|
|
552
695
|
}
|
|
553
|
-
|
|
696
|
+
// Snap onto the polyline stroke. ELK's label box can sit slightly
|
|
697
|
+
// off the route (side selection / reserved label space); we keep
|
|
698
|
+
// its along-edge placement but center on the actual path.
|
|
699
|
+
const onPath = closestPointOnPath(allPoints, { x: lx, y: ly });
|
|
700
|
+
edgeLabelPositions.set(edge.id, onPath);
|
|
554
701
|
}
|
|
555
702
|
|
|
556
703
|
// For orthogonal routing with preserved positions, the offset can distort
|
|
@@ -599,15 +746,17 @@ export async function computeElkLayout(
|
|
|
599
746
|
}
|
|
600
747
|
}
|
|
601
748
|
|
|
602
|
-
// Process nodes (update positions if not preserving)
|
|
749
|
+
// Process nodes (update positions if not preserving). Grouped children use
|
|
750
|
+
// parent-relative coords (React Flow child semantics); everything else uses
|
|
751
|
+
// absolute coords.
|
|
603
752
|
const resultNodes = preserveNodePositions
|
|
604
753
|
? nodes
|
|
605
754
|
: nodes.map((node) => {
|
|
606
|
-
const
|
|
607
|
-
if (
|
|
755
|
+
const rel = elkRelativePositions.get(node.id);
|
|
756
|
+
if (rel) {
|
|
608
757
|
return {
|
|
609
758
|
...node,
|
|
610
|
-
position: { x:
|
|
759
|
+
position: { x: rel.x, y: rel.y },
|
|
611
760
|
};
|
|
612
761
|
}
|
|
613
762
|
return node;
|
|
@@ -618,6 +767,7 @@ export async function computeElkLayout(
|
|
|
618
767
|
edgePaths,
|
|
619
768
|
edgeLabelPositions,
|
|
620
769
|
edgePathPoints,
|
|
770
|
+
groupBounds,
|
|
621
771
|
};
|
|
622
772
|
}
|
|
623
773
|
|