@principal-ai/subsystems-react 0.51.3 → 0.52.0
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/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +3 -7
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +202 -63
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +26 -26
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +56 -30
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +20 -1
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +74 -26
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +48 -49
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +50 -0
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/c4Evidence.d.ts +88 -84
- package/dist/subsystem/c4Evidence.d.ts.map +1 -1
- package/dist/subsystem/c4Evidence.js +113 -166
- package/dist/subsystem/c4Evidence.js.map +1 -1
- package/dist/subsystem/model.d.ts +6 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +9 -7
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/techIcons/index.d.ts +5 -0
- package/dist/subsystem/techIcons/index.d.ts.map +1 -1
- package/dist/subsystem/techIcons/index.js +3 -0
- package/dist/subsystem/techIcons/index.js.map +1 -1
- package/dist/utils/edgeLabel.d.ts +14 -0
- package/dist/utils/edgeLabel.d.ts.map +1 -1
- package/dist/utils/edgeLabel.js +24 -0
- package/dist/utils/edgeLabel.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +65 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +145 -11
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -6
- package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
- package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
- package/src/subsystem/C4Graph.tsx +296 -99
- package/src/subsystem/C4NodeCard.test.ts +32 -22
- package/src/subsystem/C4NodeCard.tsx +67 -39
- package/src/subsystem/IssueList.tsx +23 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +79 -23
- package/src/subsystem/c4.protocolColor.test.ts +26 -0
- package/src/subsystem/c4.ts +76 -52
- package/src/subsystem/c4Evidence.test.ts +177 -237
- package/src/subsystem/c4Evidence.ts +160 -235
- package/src/subsystem/model.ts +6 -0
- package/src/subsystem/nodes.group.test.tsx +15 -0
- package/src/subsystem/nodes.tsx +12 -9
- package/src/subsystem/techIcons/index.tsx +8 -0
- package/src/utils/edgeLabel.test.ts +21 -2
- package/src/utils/edgeLabel.ts +28 -0
- package/src/utils/elkLayout.test.ts +50 -0
- package/src/utils/elkLayout.ts +191 -11
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
|
@@ -12,9 +12,13 @@ import {
|
|
|
12
12
|
pointsToPath,
|
|
13
13
|
pointsToSmoothPath,
|
|
14
14
|
calculatePathMidpoint,
|
|
15
|
+
pointAlongPath,
|
|
16
|
+
fractionAlongPath,
|
|
15
17
|
planCompoundGroups,
|
|
18
|
+
getElkOptions,
|
|
16
19
|
type Point,
|
|
17
20
|
} from './elkLayout';
|
|
21
|
+
import { EDGE_LABEL_WIDTH, EDGE_LABEL_SIDE_PADDING } from './edgeLabel';
|
|
18
22
|
|
|
19
23
|
describe('planCompoundGroups', () => {
|
|
20
24
|
test('drops a single-leaf group by default and promotes its member', () => {
|
|
@@ -129,6 +133,52 @@ describe('planCompoundGroups', () => {
|
|
|
129
133
|
});
|
|
130
134
|
|
|
131
135
|
describe('elkLayout helper functions', () => {
|
|
136
|
+
describe('getElkOptions — label-aware between-layer spacing', () => {
|
|
137
|
+
test('does not stack spacing on top of ELK’s reserved label layer', () => {
|
|
138
|
+
// CENTER_LAYER already reserves the label; adding the box width here
|
|
139
|
+
// double-counts and balloons the run (measured 548px for 140 + 204).
|
|
140
|
+
const opts = getElkOptions({ edgeLabels: { enabled: true, placement: 'CENTER' } });
|
|
141
|
+
expect(opts['elk.layered.spacing.nodeNodeBetweenLayers']).toBe('0');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('an explicit interLayerSpacing still wins', () => {
|
|
145
|
+
const opts = getElkOptions({ interLayerSpacing: 120, edgeLabels: { enabled: true } });
|
|
146
|
+
expect(opts['elk.layered.spacing.nodeNodeBetweenLayers']).toBe('120');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('pointAlongPath / fractionAlongPath', () => {
|
|
151
|
+
const bent: Point[] = [
|
|
152
|
+
{ x: 0, y: 0 },
|
|
153
|
+
{ x: 100, y: 0 },
|
|
154
|
+
{ x: 100, y: 100 },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
test('half is the arc-length midpoint, not a corner', () => {
|
|
158
|
+
// Total length 200; half is 100 along, which is the corner (100,0).
|
|
159
|
+
const mid = pointAlongPath(bent, 0.5);
|
|
160
|
+
expect(mid).toEqual({ x: 100, y: 0 });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a fraction lands on the correct segment', () => {
|
|
164
|
+
// 0.75 of 200 = 150 → 50 down the second segment.
|
|
165
|
+
expect(pointAlongPath(bent, 0.75)).toEqual({ x: 100, y: 50 });
|
|
166
|
+
// 0.25 of 200 = 50 → half along the first segment.
|
|
167
|
+
expect(pointAlongPath(bent, 0.25)).toEqual({ x: 50, y: 0 });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('clamps and handles degenerate input', () => {
|
|
171
|
+
expect(pointAlongPath(bent, -1)).toEqual({ x: 0, y: 0 });
|
|
172
|
+
expect(pointAlongPath(bent, 2)).toEqual({ x: 100, y: 100 });
|
|
173
|
+
expect(pointAlongPath([{ x: 5, y: 5 }], 0.5)).toEqual({ x: 5, y: 5 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('fractionAlongPath inverts pointAlongPath', () => {
|
|
177
|
+
const at = pointAlongPath(bent, 0.75);
|
|
178
|
+
expect(fractionAlongPath(bent, at)).toBeCloseTo(0.75, 5);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
132
182
|
describe('pointsToPath', () => {
|
|
133
183
|
test('should return empty string for empty array', () => {
|
|
134
184
|
expect(pointsToPath([])).toBe('');
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { Node, Edge } from '@xyflow/react';
|
|
|
10
10
|
import {
|
|
11
11
|
EDGE_LABEL_WIDTH,
|
|
12
12
|
EDGE_LABEL_HEIGHT,
|
|
13
|
-
|
|
13
|
+
EDGE_LABEL_SIDE_PADDING,
|
|
14
14
|
} from './edgeLabel';
|
|
15
15
|
|
|
16
16
|
/** ELK layout options for different routing styles */
|
|
@@ -76,6 +76,22 @@ export interface ElkLayoutOptions {
|
|
|
76
76
|
enabled?: boolean;
|
|
77
77
|
/** Placement of inline labels. @default 'CENTER' */
|
|
78
78
|
placement?: 'CENTER' | 'TAIL' | 'HEAD';
|
|
79
|
+
/**
|
|
80
|
+
* Width of the reserved label box, in flow px. This is what actually sets
|
|
81
|
+
* the between-layer run for a labelled edge — ELK's CENTER_LAYER creates a
|
|
82
|
+
* label layer of this width, which dominates the layering spacing. Defaults
|
|
83
|
+
* to the shared `EDGE_LABEL_WIDTH`; a caller with shorter labels passes a
|
|
84
|
+
* smaller value to tighten its edges.
|
|
85
|
+
*/
|
|
86
|
+
width?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Per-label width, computed from the label text. When supplied, wins over
|
|
89
|
+
* `width` for each edge, so the reserved box tracks content instead of one
|
|
90
|
+
* fixed width. The overlay must use the same function.
|
|
91
|
+
*/
|
|
92
|
+
measure?: (text: string) => number;
|
|
93
|
+
/** Height of the reserved label box. Defaults to `EDGE_LABEL_HEIGHT`. */
|
|
94
|
+
height?: number;
|
|
79
95
|
};
|
|
80
96
|
|
|
81
97
|
/**
|
|
@@ -121,6 +137,34 @@ export interface ElkLayoutOptions {
|
|
|
121
137
|
* on the duplicate. @default false
|
|
122
138
|
*/
|
|
123
139
|
keepSingletonGroups?: boolean;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Lay a group's members out at the root instead of nesting them inside an
|
|
143
|
+
* ELK parent, then synthesize the frame's bounds as the bounding box of its
|
|
144
|
+
* members.
|
|
145
|
+
*
|
|
146
|
+
* A nested frame is a real ELK parent, so edges wholly inside it inherit the
|
|
147
|
+
* frame's padding and its own between-layer spacing on top — which makes an
|
|
148
|
+
* in-frame edge run longer than a root-level one touching the same nodes.
|
|
149
|
+
* Flattening removes that: every edge uses one spacing, and the frame is pure
|
|
150
|
+
* decoration drawn around wherever the members landed.
|
|
151
|
+
*
|
|
152
|
+
* Only for groups whose members need no parent-relative positioning (C4's
|
|
153
|
+
* system frame). @default false
|
|
154
|
+
*/
|
|
155
|
+
flatGroups?: boolean;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* ELK partition per node id. Nodes are banded by `partition` — ELK places all
|
|
159
|
+
* of partition 0, then partition 1, etc., in the layout direction — so a
|
|
160
|
+
* group of nodes can be kept apart from the rest without nesting them in a
|
|
161
|
+
* parent. C4 uses this to push out-of-boundary nodes (external systems,
|
|
162
|
+
* people) into their own band, away from the framed containers.
|
|
163
|
+
*
|
|
164
|
+
* Requires every laid-out node to have a partition; unknown ids are ignored,
|
|
165
|
+
* unlisted nodes get partition 0. @default undefined (partitioning off)
|
|
166
|
+
*/
|
|
167
|
+
partitionByNode?: ReadonlyMap<string, number>;
|
|
124
168
|
}
|
|
125
169
|
|
|
126
170
|
/** Result of ELK layout computation */
|
|
@@ -330,10 +374,74 @@ export function calculatePathLength(points: Point[]): number {
|
|
|
330
374
|
return total;
|
|
331
375
|
}
|
|
332
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Point a given fraction along a polyline, by arc length.
|
|
379
|
+
*
|
|
380
|
+
* Using arc length, not the nearest-point snap, is what keeps a label chip on
|
|
381
|
+
* the visual middle of a *bent* route. Nearest-point (`closestPointOnPath`)
|
|
382
|
+
* jumps to whichever segment happens to be nearest the target, so a fanned-out
|
|
383
|
+
* orthogonal route put the chip on a corner near the source node.
|
|
384
|
+
*
|
|
385
|
+
* @public Exported for testing
|
|
386
|
+
*/
|
|
387
|
+
export function pointAlongPath(points: Point[], fraction: number): Point {
|
|
388
|
+
if (points.length === 0) return { x: 0, y: 0 };
|
|
389
|
+
if (points.length === 1) return points[0];
|
|
390
|
+
const total = calculatePathLength(points);
|
|
391
|
+
if (total === 0) return points[0];
|
|
392
|
+
|
|
393
|
+
let remaining = Math.max(0, Math.min(1, fraction)) * total;
|
|
394
|
+
for (let i = 1; i < points.length; i++) {
|
|
395
|
+
const a = points[i - 1];
|
|
396
|
+
const b = points[i];
|
|
397
|
+
const seg = Math.hypot(b.x - a.x, b.y - a.y);
|
|
398
|
+
if (seg === 0) continue;
|
|
399
|
+
if (remaining <= seg) {
|
|
400
|
+
const t = remaining / seg;
|
|
401
|
+
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
402
|
+
}
|
|
403
|
+
remaining -= seg;
|
|
404
|
+
}
|
|
405
|
+
return points[points.length - 1];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* How far along a polyline a point lies, as a 0…1 fraction of arc length.
|
|
410
|
+
* The point is assumed to lie on (or near) the path.
|
|
411
|
+
*
|
|
412
|
+
* @public Exported for testing
|
|
413
|
+
*/
|
|
414
|
+
export function fractionAlongPath(points: Point[], at: Point): number {
|
|
415
|
+
if (points.length < 2) return 0.5;
|
|
416
|
+
const total = calculatePathLength(points);
|
|
417
|
+
if (total === 0) return 0.5;
|
|
418
|
+
|
|
419
|
+
let travelled = 0;
|
|
420
|
+
let bestDist = Infinity;
|
|
421
|
+
let bestTravelled = 0;
|
|
422
|
+
for (let i = 1; i < points.length; i++) {
|
|
423
|
+
const a = points[i - 1];
|
|
424
|
+
const b = points[i];
|
|
425
|
+
const seg = Math.hypot(b.x - a.x, b.y - a.y);
|
|
426
|
+
const dx = b.x - a.x;
|
|
427
|
+
const dy = b.y - a.y;
|
|
428
|
+
const t = seg === 0 ? 0 : Math.max(0, Math.min(1, ((at.x - a.x) * dx + (at.y - a.y) * dy) / (seg * seg)));
|
|
429
|
+
const px = a.x + dx * t;
|
|
430
|
+
const py = a.y + dy * t;
|
|
431
|
+
const dist = Math.hypot(at.x - px, at.y - py);
|
|
432
|
+
if (dist < bestDist) {
|
|
433
|
+
bestDist = dist;
|
|
434
|
+
bestTravelled = travelled + seg * t;
|
|
435
|
+
}
|
|
436
|
+
travelled += seg;
|
|
437
|
+
}
|
|
438
|
+
return bestTravelled / total;
|
|
439
|
+
}
|
|
440
|
+
|
|
333
441
|
/**
|
|
334
442
|
* Get ELK layout options based on configuration
|
|
335
443
|
*/
|
|
336
|
-
function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
|
|
444
|
+
export function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
|
|
337
445
|
const {
|
|
338
446
|
routingStyle = 'orthogonal',
|
|
339
447
|
nodeSpacing = 50,
|
|
@@ -357,6 +465,12 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
|
|
|
357
465
|
'elk.spacing.edgeNode': String(edgeNodeSpacing),
|
|
358
466
|
'elk.layered.spacing.edgeEdgeBetweenLayers': String(edgeSpacing),
|
|
359
467
|
'elk.layered.spacing.edgeNodeBetweenLayers': String(edgeNodeSpacing),
|
|
468
|
+
// Edges host inline labels and `CENTER_LAYER` already reserves a label
|
|
469
|
+
// layer, so this is EXTRA space stacked on top of that layer — not a
|
|
470
|
+
// replacement for it. Reserving the full label box here double-counts and
|
|
471
|
+
// the run balloons (measured: w=140 + 204 here = 548px between two nodes).
|
|
472
|
+
// `interLayerSpacing` is the only caller-set value; otherwise this is the
|
|
473
|
+
// small clearance the label layer can't express.
|
|
360
474
|
'elk.layered.spacing.nodeNodeBetweenLayers': String(interLayerSpacing),
|
|
361
475
|
// Port constraints - edges connect at specific sides
|
|
362
476
|
'elk.portConstraints': 'FIXED_SIDE',
|
|
@@ -534,7 +648,12 @@ export async function computeElkLayout(
|
|
|
534
648
|
edges: Edge[],
|
|
535
649
|
options: ElkLayoutOptions = {}
|
|
536
650
|
): Promise<ElkLayoutResult> {
|
|
537
|
-
const {
|
|
651
|
+
const {
|
|
652
|
+
preserveNodePositions = true,
|
|
653
|
+
keepSingletonGroups = false,
|
|
654
|
+
flatGroups = false,
|
|
655
|
+
partitionByNode,
|
|
656
|
+
} = options;
|
|
538
657
|
const edgeLabels = options.edgeLabels;
|
|
539
658
|
const endpointInset = options.endpointInset ?? 0;
|
|
540
659
|
const direction = options.direction ?? 'RIGHT';
|
|
@@ -584,6 +703,7 @@ export async function computeElkLayout(
|
|
|
584
703
|
properties: {
|
|
585
704
|
'portConstraints': 'FIXED_SIDE',
|
|
586
705
|
...(layer !== undefined ? { 'layering.layer': String(layer) } : {}),
|
|
706
|
+
...(partitionByNode ? { partition: String(partitionByNode.get(node.id) ?? 0) } : {}),
|
|
587
707
|
},
|
|
588
708
|
};
|
|
589
709
|
});
|
|
@@ -681,11 +801,20 @@ export async function computeElkLayout(
|
|
|
681
801
|
sources: [sourcePort],
|
|
682
802
|
targets: [targetPort],
|
|
683
803
|
};
|
|
684
|
-
// Reserve the
|
|
685
|
-
//
|
|
804
|
+
// Reserve the label box. ELK's `CENTER_LAYER` sizes a dedicated label layer
|
|
805
|
+
// from this and adds no clearance of its own, so the box carries the chip
|
|
806
|
+
// width PLUS the clearance we want on each side — that is the only place
|
|
807
|
+
// the side padding can take effect. The overlay draws the chip at the
|
|
808
|
+
// narrower `edgeLabels.width`, centred in this reserved run.
|
|
686
809
|
if (edgeLabels?.enabled !== false && typeof edge.label === 'string') {
|
|
810
|
+
const chipWidth =
|
|
811
|
+
edgeLabels?.measure?.(edge.label) ?? edgeLabels?.width ?? EDGE_LABEL_WIDTH;
|
|
687
812
|
elkEdge.labels = [
|
|
688
|
-
{
|
|
813
|
+
{
|
|
814
|
+
text: edge.label,
|
|
815
|
+
width: chipWidth + EDGE_LABEL_SIDE_PADDING * 2,
|
|
816
|
+
height: edgeLabels?.height ?? EDGE_LABEL_HEIGHT,
|
|
817
|
+
},
|
|
689
818
|
];
|
|
690
819
|
}
|
|
691
820
|
return elkEdge;
|
|
@@ -714,7 +843,9 @@ export async function computeElkLayout(
|
|
|
714
843
|
// the full reserved width (label box + clearance at each end); labels off
|
|
715
844
|
// falls back to a plain between-layer gap.
|
|
716
845
|
'elk.layered.spacing.nodeNodeBetweenLayers': String(
|
|
717
|
-
edgeLabels?.enabled === false
|
|
846
|
+
edgeLabels?.enabled === false
|
|
847
|
+
? 40
|
|
848
|
+
: (edgeLabels?.width ?? EDGE_LABEL_WIDTH) + EDGE_LABEL_SIDE_PADDING * 2,
|
|
718
849
|
),
|
|
719
850
|
};
|
|
720
851
|
// Match the root's inline-label reservation, so ELK inside the frame leaves a
|
|
@@ -729,7 +860,15 @@ export async function computeElkLayout(
|
|
|
729
860
|
const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
|
|
730
861
|
const skippedGroups = new Set(plan.skipped);
|
|
731
862
|
const builtGroups = new Map<string, ElkNode>();
|
|
863
|
+
// Flat groups are not ELK parents at all: their members lay out at the root,
|
|
864
|
+
// and the frame is synthesized from the members' bounds after layout. This
|
|
865
|
+
// keeps every edge on one spacing instead of inheriting a nested frame's.
|
|
866
|
+
const flatGroupIds = new Set<string>();
|
|
867
|
+
if (flatGroups) {
|
|
868
|
+
for (const g of plan.built) flatGroupIds.add(g.id);
|
|
869
|
+
}
|
|
732
870
|
for (const g of plan.built) {
|
|
871
|
+
if (flatGroups) break;
|
|
733
872
|
const children: ElkNode[] = [];
|
|
734
873
|
for (const cid of g.childIds) {
|
|
735
874
|
const leaf = elkById.get(cid);
|
|
@@ -793,6 +932,9 @@ export async function computeElkLayout(
|
|
|
793
932
|
|
|
794
933
|
// Create ELK graph
|
|
795
934
|
const rootOptions = getElkOptions(options);
|
|
935
|
+
if (partitionByNode) {
|
|
936
|
+
rootOptions['elk.partitioning.activate'] = 'true';
|
|
937
|
+
}
|
|
796
938
|
if (elkParents.length > 0 || builtGroups.size > 0) {
|
|
797
939
|
rootOptions['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
|
|
798
940
|
}
|
|
@@ -865,6 +1007,40 @@ export async function computeElkLayout(
|
|
|
865
1007
|
}
|
|
866
1008
|
}
|
|
867
1009
|
|
|
1010
|
+
// Flat groups: synthesize each frame from the bounding box of its members.
|
|
1011
|
+
// `plan.built` is bottom-up, so a nested frame's bounds already exist when its
|
|
1012
|
+
// parent is computed — a frame containing frames unions the child frames, not
|
|
1013
|
+
// just leaves. Without that, an outer frame (the system) would omit the
|
|
1014
|
+
// container frames inside it and mis-size.
|
|
1015
|
+
if (flatGroupIds.size > 0) {
|
|
1016
|
+
const FLAT_PAD = 24;
|
|
1017
|
+
const FLAT_TOP = 64;
|
|
1018
|
+
/** Absolute rect of a group member: a leaf rect, or an already-built frame. */
|
|
1019
|
+
const rectOf = (id: string) =>
|
|
1020
|
+
absoluteRects.get(id) ??
|
|
1021
|
+
(() => {
|
|
1022
|
+
const b = groupBounds.get(id);
|
|
1023
|
+
return b ? { x: b.x, y: b.y, width: b.width, height: b.height } : undefined;
|
|
1024
|
+
})();
|
|
1025
|
+
for (const g of plan.built) {
|
|
1026
|
+
if (!flatGroupIds.has(g.id)) continue;
|
|
1027
|
+
const rects = g.childIds
|
|
1028
|
+
.map(rectOf)
|
|
1029
|
+
.filter((r): r is { x: number; y: number; width: number; height: number } => !!r);
|
|
1030
|
+
if (rects.length === 0) continue;
|
|
1031
|
+
const minX = Math.min(...rects.map((r) => r.x));
|
|
1032
|
+
const minY = Math.min(...rects.map((r) => r.y));
|
|
1033
|
+
const maxX = Math.max(...rects.map((r) => r.x + r.width));
|
|
1034
|
+
const maxY = Math.max(...rects.map((r) => r.y + r.height));
|
|
1035
|
+
groupBounds.set(g.id, {
|
|
1036
|
+
x: minX - FLAT_PAD,
|
|
1037
|
+
y: minY - FLAT_TOP,
|
|
1038
|
+
width: maxX - minX + FLAT_PAD * 2,
|
|
1039
|
+
height: maxY - minY + FLAT_TOP + FLAT_PAD,
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
868
1044
|
// Extract results
|
|
869
1045
|
const edgePaths = new Map<string, string>();
|
|
870
1046
|
const edgeLabelPositions = new Map<string, { x: number; y: number }>();
|
|
@@ -958,10 +1134,14 @@ export async function computeElkLayout(
|
|
|
958
1134
|
lx += sourceOffset.x + (targetOffset.x - sourceOffset.x) * t;
|
|
959
1135
|
ly += sourceOffset.y + (targetOffset.y - sourceOffset.y) * t;
|
|
960
1136
|
}
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
|
|
1137
|
+
// Place the chip by arc length, using ELK's label position only as a
|
|
1138
|
+
// hint for *how far along* the route it belongs. Snapping to the
|
|
1139
|
+
// nearest point (the old behaviour) jumped to whichever segment was
|
|
1140
|
+
// nearest the target, which put fanned-out orthogonal labels on a
|
|
1141
|
+
// corner near the source instead of the middle of the line.
|
|
1142
|
+
const hint = closestPointOnPath(allPoints, { x: lx, y: ly });
|
|
1143
|
+
const hintFraction = fractionAlongPath(allPoints, hint);
|
|
1144
|
+
const onPath = pointAlongPath(allPoints, hintFraction);
|
|
965
1145
|
edgeLabelPositions.set(edge.id, onPath);
|
|
966
1146
|
}
|
|
967
1147
|
|