@statelyai/layout 0.3.0 → 0.4.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/NOTICE.md +4 -2
- package/README.md +150 -11
- package/dist/elkjs/index.cjs +2 -2
- package/dist/elkjs/index.d.cts +2 -0
- package/dist/elkjs/index.d.mts +2 -0
- package/dist/elkjs/index.mjs +2 -2
- package/dist/elkjs/main.cjs +1 -1
- package/dist/elkjs/main.mjs +1 -1
- package/dist/elkjs/worker.cjs +2 -2
- package/dist/elkjs/worker.mjs +2 -2
- package/dist/elkjs-UciYx7lD.mjs +2973 -0
- package/dist/elkjs-ggjsBuVP.cjs +3002 -0
- package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
- package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
- package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
- package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
- package/dist/index.cjs +6 -3
- package/dist/index.d.cts +3 -3
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +4 -4
- package/dist/layered/index.cjs +3 -1
- package/dist/layered/index.d.cts +2 -2
- package/dist/layered/index.d.mts +2 -2
- package/dist/layered/index.mjs +2 -2
- package/dist/{layered-DGFTeQSl.mjs → layered-C7ERzZjR.mjs} +9605 -1655
- package/dist/{layered-B2buJqMf.cjs → layered-CBPg6_6_.cjs} +12481 -4435
- package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-DAw06RhN.cjs} +5 -18
- package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
- package/dist/routing/index.cjs +2 -1
- package/dist/routing/index.d.cts +2 -2
- package/dist/routing/index.d.mts +2 -2
- package/dist/routing/index.mjs +2 -2
- package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
- package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
- package/package.json +8 -2
- package/src/authoring/coordinates.ts +28 -8
- package/src/authoring/partial.ts +7 -2
- package/src/authoring/routing.ts +4 -1
- package/src/constraints.ts +174 -6
- package/src/elkjs/index.ts +1392 -449
- package/src/elkjs/layout-quality.ts +219 -0
- package/src/elkjs/opposing-tracks.ts +161 -0
- package/src/elkjs/orthogonal-junctions.ts +174 -0
- package/src/elkjs/statechart.ts +9 -3
- package/src/elkjs/types.ts +2 -0
- package/src/index.ts +2 -0
- package/src/internal/elkjs-compatibility.ts +4 -1
- package/src/internal/layered-trace.ts +48 -0
- package/src/internal/layout-engine.ts +2 -1
- package/src/java-random.ts +6 -0
- package/src/layered/bk-node-placement.ts +403 -92
- package/src/layered/center-labels.ts +398 -0
- package/src/layered/compaction-bounds.ts +26 -0
- package/src/layered/compaction-errors.ts +2 -0
- package/src/layered/compaction-scanline.ts +63 -0
- package/src/layered/compound-boundaries.ts +99 -0
- package/src/layered/compound.ts +35 -7
- package/src/layered/constraint-orientation.ts +141 -0
- package/src/layered/crossing-constraints.ts +243 -0
- package/src/layered/crossing-counter.ts +221 -0
- package/src/layered/crossing-graph.ts +401 -0
- package/src/layered/cycle-random.ts +22 -0
- package/src/layered/elk-port-lists.ts +243 -0
- package/src/layered/end-labels.ts +124 -0
- package/src/layered/external-port-dummy.ts +137 -0
- package/src/layered/feedback-chains.ts +114 -0
- package/src/layered/fixed-self-loop.ts +150 -0
- package/src/layered/greedy-switch-decider.ts +356 -0
- package/src/layered/grouped-compaction.ts +787 -0
- package/src/layered/gwt-sort.ts +53 -0
- package/src/layered/hierarchical-port-phases.ts +242 -0
- package/src/layered/hierarchical-port-restoration.ts +233 -0
- package/src/layered/hierarchical-port-routing.ts +179 -0
- package/src/layered/hierarchy-crossing.ts +121 -0
- package/src/layered/hierarchy-sweepiness.ts +83 -0
- package/src/layered/hints.ts +371 -0
- package/src/layered/hyperedge-dummy-merger.ts +195 -0
- package/src/layered/index.ts +886 -161
- package/src/layered/initial-model-order.ts +404 -0
- package/src/layered/inverted-ports.ts +207 -0
- package/src/layered/label-dummy-switcher.ts +200 -0
- package/src/layered/long-edges.ts +135 -44
- package/src/layered/loop-envelopes.ts +184 -0
- package/src/layered/native-routing.ts +275 -0
- package/src/layered/network-simplex.ts +11 -15
- package/src/layered/node-margins.ts +59 -0
- package/src/layered/north-south-ports.ts +271 -0
- package/src/layered/opposing-tracks.ts +944 -0
- package/src/layered/orthogonal-cycle-order.ts +106 -0
- package/src/layered/orthogonal-hypersegments.ts +92 -0
- package/src/layered/orthogonal-junctions.ts +202 -0
- package/src/layered/orthogonal-segments.ts +243 -0
- package/src/layered/placement-orientation.ts +22 -0
- package/src/layered/port-distributor.ts +192 -0
- package/src/layered/port-edge-order.ts +20 -0
- package/src/layered/replace-routing.ts +45 -0
- package/src/layered/routing-coordinates.ts +32 -0
- package/src/layered/separate-exterior-labels.ts +24 -6
- package/src/layered/spacing.ts +34 -12
- package/src/layered/splitter-order.ts +106 -0
- package/src/layered/strategies.ts +2024 -883
- package/src/layered/types.ts +13 -0
- package/src/layered/weighted-compaction.ts +84 -0
- package/src/routing/adapters.ts +32 -0
- package/src/routing/algorithms.ts +433 -21
- package/src/routing/coordination.ts +8 -3
- package/src/routing/index.ts +1 -0
- package/src/routing/search.ts +93 -11
- package/dist/elkjs-DaHMISnQ.mjs +0 -1677
- package/dist/elkjs-dMghiiWW.cjs +0 -1706
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { ElkNode } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Geometry measures used to choose between candidate layouts of one graph. */
|
|
4
|
+
export interface LayoutQuality {
|
|
5
|
+
/** Routes through leaf nodes, routes folded onto themselves, and diagonal segments. */
|
|
6
|
+
defects: number;
|
|
7
|
+
crossings: number;
|
|
8
|
+
overlap: number;
|
|
9
|
+
bends: number;
|
|
10
|
+
length: number;
|
|
11
|
+
area: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Point {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
}
|
|
18
|
+
interface Rect extends Point {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
interface Segment {
|
|
23
|
+
a: Point;
|
|
24
|
+
b: Point;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const EPS = 1e-6;
|
|
28
|
+
/** Shared endpoint nodes get this clearance, so fan-in and fan-out are not penalized. */
|
|
29
|
+
const TERMINAL_MARGIN = 12;
|
|
30
|
+
const horizontal = (s: Segment) => Math.abs(s.a.y - s.b.y) < EPS;
|
|
31
|
+
const vertical = (s: Segment) => Math.abs(s.a.x - s.b.x) < EPS;
|
|
32
|
+
const span = (s: Segment, axis: "x" | "y") =>
|
|
33
|
+
[Math.min(s.a[axis], s.b[axis]), Math.max(s.a[axis], s.b[axis])] as const;
|
|
34
|
+
const strictlyInside = (value: number, low: number, high: number) =>
|
|
35
|
+
value > low + EPS && value < high - EPS;
|
|
36
|
+
const overlapLength = (a: readonly [number, number], b: readonly [number, number]) =>
|
|
37
|
+
Math.max(0, Math.min(a[1], b[1]) - Math.max(a[0], b[0]));
|
|
38
|
+
const inRect = (point: Point, rect: Rect, margin: number) =>
|
|
39
|
+
point.x > rect.x - margin &&
|
|
40
|
+
point.x < rect.x + rect.width + margin &&
|
|
41
|
+
point.y > rect.y - margin &&
|
|
42
|
+
point.y < rect.y + rect.height + margin;
|
|
43
|
+
|
|
44
|
+
/** Measure an ELK JSON result in absolute coordinates. */
|
|
45
|
+
export function measureLayout(root: ElkNode): LayoutQuality {
|
|
46
|
+
const frames = new Map<string, Point>([[String(root.id), { x: 0, y: 0 }]]);
|
|
47
|
+
const rects = new Map<string, Rect>();
|
|
48
|
+
const owner = new Map<string, string>();
|
|
49
|
+
const leaves: Rect[] = [];
|
|
50
|
+
const bounds: Point[] = [];
|
|
51
|
+
const place = (parent: ElkNode, offset: Point) => {
|
|
52
|
+
for (const child of parent.children ?? []) {
|
|
53
|
+
const id = String(child.id);
|
|
54
|
+
const at = { x: offset.x + (child.x ?? 0), y: offset.y + (child.y ?? 0) };
|
|
55
|
+
const rect = { ...at, width: child.width ?? 0, height: child.height ?? 0 };
|
|
56
|
+
frames.set(id, at);
|
|
57
|
+
rects.set(id, rect);
|
|
58
|
+
owner.set(id, id);
|
|
59
|
+
for (const port of child.ports ?? []) owner.set(String(port.id), id);
|
|
60
|
+
bounds.push(at, { x: at.x + rect.width, y: at.y + rect.height });
|
|
61
|
+
if (child.children?.length) place(child, at);
|
|
62
|
+
else leaves.push(rect);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
place(root, { x: 0, y: 0 });
|
|
66
|
+
|
|
67
|
+
const routes: Array<{ ends: string[]; segments: Segment[]; labels: Rect[] }> = [];
|
|
68
|
+
let defects = 0,
|
|
69
|
+
bends = 0,
|
|
70
|
+
length = 0;
|
|
71
|
+
const collect = (parent: ElkNode) => {
|
|
72
|
+
for (const edge of parent.edges ?? []) {
|
|
73
|
+
const container = String((edge as { container?: string }).container ?? parent.id);
|
|
74
|
+
const offset = frames.get(container) ?? { x: 0, y: 0 };
|
|
75
|
+
const ends = [edge.sources?.[0], edge.targets?.[0]].flatMap((end) => {
|
|
76
|
+
const node = owner.get(String(end));
|
|
77
|
+
return node === undefined ? [] : [node];
|
|
78
|
+
});
|
|
79
|
+
const segments: Segment[] = [];
|
|
80
|
+
for (const section of edge.sections ?? []) {
|
|
81
|
+
const points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint].map(
|
|
82
|
+
(point) => ({ x: point.x + offset.x, y: point.y + offset.y }),
|
|
83
|
+
);
|
|
84
|
+
bounds.push(...points);
|
|
85
|
+
let direction: Point | undefined;
|
|
86
|
+
for (let index = 0; index + 1 < points.length; index++) {
|
|
87
|
+
const segment = { a: points[index]!, b: points[index + 1]! };
|
|
88
|
+
const size = Math.hypot(segment.b.x - segment.a.x, segment.b.y - segment.a.y);
|
|
89
|
+
if (size < EPS) continue;
|
|
90
|
+
if (!horizontal(segment) && !vertical(segment)) defects++;
|
|
91
|
+
const next = {
|
|
92
|
+
x: Math.sign(segment.b.x - segment.a.x),
|
|
93
|
+
y: Math.sign(segment.b.y - segment.a.y),
|
|
94
|
+
};
|
|
95
|
+
if (direction && (direction.x !== next.x || direction.y !== next.y)) bends++;
|
|
96
|
+
direction = next;
|
|
97
|
+
length += size;
|
|
98
|
+
segments.push(segment);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const labels = (edge.labels ?? []).flatMap((label) =>
|
|
102
|
+
label.width && label.height
|
|
103
|
+
? [
|
|
104
|
+
{
|
|
105
|
+
x: (label.x ?? 0) + offset.x,
|
|
106
|
+
y: (label.y ?? 0) + offset.y,
|
|
107
|
+
width: label.width,
|
|
108
|
+
height: label.height,
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
: [],
|
|
112
|
+
);
|
|
113
|
+
for (const label of labels)
|
|
114
|
+
bounds.push(label, { x: label.x + label.width, y: label.y + label.height });
|
|
115
|
+
// A route through a leaf, or folding back onto itself, is a defect.
|
|
116
|
+
for (const segment of segments)
|
|
117
|
+
for (const rect of leaves)
|
|
118
|
+
if (
|
|
119
|
+
(horizontal(segment) &&
|
|
120
|
+
strictlyInside(segment.a.y, rect.y, rect.y + rect.height) &&
|
|
121
|
+
overlapLength(span(segment, "x"), [rect.x, rect.x + rect.width]) > EPS) ||
|
|
122
|
+
(vertical(segment) &&
|
|
123
|
+
strictlyInside(segment.a.x, rect.x, rect.x + rect.width) &&
|
|
124
|
+
overlapLength(span(segment, "y"), [rect.y, rect.y + rect.height]) > EPS)
|
|
125
|
+
)
|
|
126
|
+
defects++;
|
|
127
|
+
for (const [index, first] of segments.entries())
|
|
128
|
+
for (const second of segments.slice(index + 1))
|
|
129
|
+
if (sharedLength(first, second) > EPS) defects++;
|
|
130
|
+
routes.push({ ends, segments, labels });
|
|
131
|
+
}
|
|
132
|
+
for (const child of parent.children ?? []) collect(child);
|
|
133
|
+
};
|
|
134
|
+
collect(root);
|
|
135
|
+
|
|
136
|
+
// Labels covering nodes or each other, and routes through other edges'
|
|
137
|
+
// labels, are defects.
|
|
138
|
+
const allLabels = routes.flatMap((route) => route.labels);
|
|
139
|
+
const covers = (a: Rect, b: Rect) =>
|
|
140
|
+
a.x < b.x + b.width - EPS &&
|
|
141
|
+
a.x + a.width > b.x + EPS &&
|
|
142
|
+
a.y < b.y + b.height - EPS &&
|
|
143
|
+
a.y + a.height > b.y + EPS;
|
|
144
|
+
for (const [index, label] of allLabels.entries()) {
|
|
145
|
+
for (const rect of leaves) if (covers(label, rect)) defects++;
|
|
146
|
+
for (const other of allLabels.slice(index + 1)) if (covers(label, other)) defects++;
|
|
147
|
+
}
|
|
148
|
+
for (const route of routes)
|
|
149
|
+
for (const other of routes)
|
|
150
|
+
if (other !== route)
|
|
151
|
+
for (const label of other.labels)
|
|
152
|
+
if (
|
|
153
|
+
route.segments.some(
|
|
154
|
+
(segment) =>
|
|
155
|
+
(horizontal(segment) &&
|
|
156
|
+
strictlyInside(segment.a.y, label.y, label.y + label.height) &&
|
|
157
|
+
overlapLength(span(segment, "x"), [label.x, label.x + label.width]) > EPS) ||
|
|
158
|
+
(vertical(segment) &&
|
|
159
|
+
strictlyInside(segment.a.x, label.x, label.x + label.width) &&
|
|
160
|
+
overlapLength(span(segment, "y"), [label.y, label.y + label.height]) > EPS),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
defects++;
|
|
164
|
+
|
|
165
|
+
let crossings = 0,
|
|
166
|
+
overlap = 0;
|
|
167
|
+
for (const [index, first] of routes.entries())
|
|
168
|
+
for (const second of routes.slice(index + 1)) {
|
|
169
|
+
const shared = first.ends
|
|
170
|
+
.filter((id) => second.ends.includes(id))
|
|
171
|
+
.flatMap((id) => {
|
|
172
|
+
const rect = rects.get(id);
|
|
173
|
+
return rect ? [rect] : [];
|
|
174
|
+
});
|
|
175
|
+
const clear = (point: Point) => shared.every((rect) => !inRect(point, rect, TERMINAL_MARGIN));
|
|
176
|
+
for (const s of first.segments)
|
|
177
|
+
for (const t of second.segments) {
|
|
178
|
+
const h = horizontal(s) && vertical(t) ? s : horizontal(t) && vertical(s) ? t : undefined;
|
|
179
|
+
if (h) {
|
|
180
|
+
const v = h === s ? t : s;
|
|
181
|
+
const point = { x: v.a.x, y: h.a.y };
|
|
182
|
+
if (
|
|
183
|
+
strictlyInside(point.x, ...span(h, "x")) &&
|
|
184
|
+
strictlyInside(point.y, ...span(v, "y")) &&
|
|
185
|
+
clear(point)
|
|
186
|
+
)
|
|
187
|
+
crossings++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const shared = sharedLength(s, t);
|
|
191
|
+
if (shared > EPS && clear(s.a) && clear(s.b)) overlap += shared;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const xs = bounds.map((point) => point.x).filter(Number.isFinite),
|
|
195
|
+
ys = bounds.map((point) => point.y).filter(Number.isFinite);
|
|
196
|
+
const area = xs.length
|
|
197
|
+
? (Math.max(...xs) - Math.min(...xs)) * (Math.max(...ys) - Math.min(...ys))
|
|
198
|
+
: 0;
|
|
199
|
+
return { defects, crossings, overlap, bends, length, area };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sharedLength(s: Segment, t: Segment): number {
|
|
203
|
+
if (horizontal(s) && horizontal(t) && Math.abs(s.a.y - t.a.y) < EPS)
|
|
204
|
+
return overlapLength(span(s, "x"), span(t, "x"));
|
|
205
|
+
if (vertical(s) && vertical(t) && Math.abs(s.a.x - t.a.x) < EPS)
|
|
206
|
+
return overlapLength(span(s, "y"), span(t, "y"));
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const TOLERANCE = 0.02;
|
|
211
|
+
/** True when `candidate` is better at the first measure where the two layouts differ. */
|
|
212
|
+
export function isBetterLayout(candidate: LayoutQuality, current: LayoutQuality): boolean {
|
|
213
|
+
for (const key of ["defects", "crossings", "overlap", "bends", "length", "area"] as const) {
|
|
214
|
+
const tolerant = key === "overlap" || key === "length" || key === "area";
|
|
215
|
+
const limit = tolerant ? Math.max(TOLERANCE * Math.abs(current[key]), EPS) : 0;
|
|
216
|
+
if (Math.abs(candidate[key] - current[key]) > limit) return candidate[key] < current[key];
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
separateOpposingTracks,
|
|
3
|
+
type TrackPoint,
|
|
4
|
+
type TrackPort,
|
|
5
|
+
type TrackRect,
|
|
6
|
+
type TrackRoute,
|
|
7
|
+
} from "../layered/opposing-tracks";
|
|
8
|
+
import type { ElkEdge, ElkNode } from "./types";
|
|
9
|
+
|
|
10
|
+
const option = (options: Readonly<Record<string, unknown>> | undefined, suffix: string) =>
|
|
11
|
+
Object.entries(options ?? {}).find(([key]) => key === suffix || key.endsWith(`.${suffix}`))?.[1];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Separate opposite-direction track sharing in a finished ELK JSON layout,
|
|
15
|
+
* across nested containers, in world coordinates.
|
|
16
|
+
*/
|
|
17
|
+
export function separateElkOpposingTracks(
|
|
18
|
+
root: ElkNode,
|
|
19
|
+
options: Readonly<Record<string, unknown>>,
|
|
20
|
+
): void {
|
|
21
|
+
const orthogonal = (node: ElkNode, inherited: boolean) => {
|
|
22
|
+
const value = option(node.layoutOptions, "edgeRouting");
|
|
23
|
+
return value === undefined ? inherited : String(value) === "ORTHOGONAL";
|
|
24
|
+
};
|
|
25
|
+
const rootOrthogonal = orthogonal(
|
|
26
|
+
root,
|
|
27
|
+
String(option(options, "edgeRouting") ?? "ORTHOGONAL") === "ORTHOGONAL",
|
|
28
|
+
);
|
|
29
|
+
const frames = new Map<string, TrackPoint>([[String(root.id), { x: 0, y: 0 }]]);
|
|
30
|
+
const routing = new Map<string, boolean>([[String(root.id), rootOrthogonal]]);
|
|
31
|
+
const nodes = new Map<string, TrackRect>(),
|
|
32
|
+
leaves = new Set<string>(),
|
|
33
|
+
owner = new Map<string, string>(),
|
|
34
|
+
ports = new Map<string, TrackPort>();
|
|
35
|
+
const place = (parent: ElkNode, offset: TrackPoint) => {
|
|
36
|
+
for (const child of parent.children ?? []) {
|
|
37
|
+
const id = String(child.id);
|
|
38
|
+
const at = { x: offset.x + (child.x ?? 0), y: offset.y + (child.y ?? 0) };
|
|
39
|
+
frames.set(id, at);
|
|
40
|
+
routing.set(id, orthogonal(child, routing.get(String(parent.id)) ?? rootOrthogonal));
|
|
41
|
+
nodes.set(id, { ...at, width: child.width ?? 0, height: child.height ?? 0 });
|
|
42
|
+
owner.set(id, id);
|
|
43
|
+
for (const port of child.ports ?? []) {
|
|
44
|
+
owner.set(String(port.id), id);
|
|
45
|
+
ports.set(String(port.id), {
|
|
46
|
+
node: id,
|
|
47
|
+
rect: {
|
|
48
|
+
x: at.x + (port.x ?? 0),
|
|
49
|
+
y: at.y + (port.y ?? 0),
|
|
50
|
+
width: port.width ?? 0,
|
|
51
|
+
height: port.height ?? 0,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (child.children?.length) place(child, at);
|
|
56
|
+
else leaves.add(id);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
place(root, { x: 0, y: 0 });
|
|
60
|
+
const records: Array<{ edge: ElkEdge; offset: TrackPoint; route: TrackRoute }> = [];
|
|
61
|
+
const collect = (parent: ElkNode) => {
|
|
62
|
+
for (const edge of parent.edges ?? []) {
|
|
63
|
+
// Multi-section routes are not single polylines; leave them unmeasured.
|
|
64
|
+
if ((edge.sections?.length ?? 0) > 1) continue;
|
|
65
|
+
const container = String(edge.container ?? parent.id);
|
|
66
|
+
const offset = frames.get(container) ?? { x: 0, y: 0 };
|
|
67
|
+
const sections = edge.sections ?? [];
|
|
68
|
+
const points = sections.flatMap((section) =>
|
|
69
|
+
[section.startPoint, ...(section.bendPoints ?? []), section.endPoint].map((p) => ({
|
|
70
|
+
x: p.x + offset.x,
|
|
71
|
+
y: p.y + offset.y,
|
|
72
|
+
})),
|
|
73
|
+
);
|
|
74
|
+
const axisParallel = points.every(
|
|
75
|
+
(p, i) =>
|
|
76
|
+
i === 0 ||
|
|
77
|
+
Math.abs(p.x - points[i - 1]!.x) < 1e-6 ||
|
|
78
|
+
Math.abs(p.y - points[i - 1]!.y) < 1e-6,
|
|
79
|
+
);
|
|
80
|
+
const ends = [edge.sources?.[0] ?? edge.source, edge.targets?.[0] ?? edge.target].flatMap(
|
|
81
|
+
(end) => {
|
|
82
|
+
const id = owner.get(String(end));
|
|
83
|
+
return id === undefined ? [] : [id];
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
const portOf = (end: unknown) => (ports.has(String(end)) ? String(end) : undefined);
|
|
87
|
+
records.push({
|
|
88
|
+
edge,
|
|
89
|
+
offset,
|
|
90
|
+
route: {
|
|
91
|
+
id: String(edge.id),
|
|
92
|
+
ends,
|
|
93
|
+
points,
|
|
94
|
+
ports: [
|
|
95
|
+
portOf(edge.sources?.[0] ?? edge.source),
|
|
96
|
+
portOf(edge.targets?.[0] ?? edge.target),
|
|
97
|
+
],
|
|
98
|
+
labels: (edge.labels ?? []).flatMap((label) =>
|
|
99
|
+
label.width && label.height
|
|
100
|
+
? [
|
|
101
|
+
{
|
|
102
|
+
x: (label.x ?? 0) + offset.x,
|
|
103
|
+
y: (label.y ?? 0) + offset.y,
|
|
104
|
+
width: label.width,
|
|
105
|
+
height: label.height,
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
: [],
|
|
109
|
+
),
|
|
110
|
+
movable: points.length > 0 && axisParallel && (routing.get(container) ?? rootOrthogonal),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
for (const child of parent.children ?? []) collect(child);
|
|
115
|
+
};
|
|
116
|
+
collect(root);
|
|
117
|
+
if (!records.some((record) => record.route.movable)) return;
|
|
118
|
+
const spacing = Number(option(options, "spacing.edgeEdge") ?? 10);
|
|
119
|
+
const changed = separateOpposingTracks({
|
|
120
|
+
routes: records.map((record) => record.route),
|
|
121
|
+
nodes,
|
|
122
|
+
leaves,
|
|
123
|
+
ports,
|
|
124
|
+
spacing: Number.isFinite(spacing) && spacing > 0 ? spacing : 10,
|
|
125
|
+
});
|
|
126
|
+
if (!changed.size) return;
|
|
127
|
+
const routes = new Map(
|
|
128
|
+
records.map((record) => [record.route.id, changed.get(record.route.id) ?? record.route.points]),
|
|
129
|
+
);
|
|
130
|
+
const on = (point: TrackPoint, points: readonly TrackPoint[]) =>
|
|
131
|
+
points.some((b, i) => {
|
|
132
|
+
const a = points[i - 1];
|
|
133
|
+
return (
|
|
134
|
+
a !== undefined &&
|
|
135
|
+
point.x >= Math.min(a.x, b.x) - 1e-6 &&
|
|
136
|
+
point.x <= Math.max(a.x, b.x) + 1e-6 &&
|
|
137
|
+
point.y >= Math.min(a.y, b.y) - 1e-6 &&
|
|
138
|
+
point.y <= Math.max(a.y, b.y) + 1e-6
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
for (const { edge, offset, route } of records) {
|
|
142
|
+
const points = changed.get(route.id);
|
|
143
|
+
if (points) {
|
|
144
|
+
const section = edge.sections![0]!;
|
|
145
|
+
const local = points.map((p) => ({ x: p.x - offset.x, y: p.y - offset.y }));
|
|
146
|
+
section.startPoint = local[0]!;
|
|
147
|
+
section.endPoint = local.at(-1)!;
|
|
148
|
+
section.bendPoints = local.slice(1, -1);
|
|
149
|
+
}
|
|
150
|
+
// A junction marks where routes part; keep only those still shared.
|
|
151
|
+
if (!edge.junctionPoints?.length) continue;
|
|
152
|
+
const own = routes.get(route.id)!;
|
|
153
|
+
edge.junctionPoints = edge.junctionPoints.filter((junction) => {
|
|
154
|
+
const point = { x: junction.x + offset.x, y: junction.y + offset.y };
|
|
155
|
+
return (
|
|
156
|
+
on(point, own) && [...routes].some(([id, other]) => id !== route.id && on(point, other))
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
if (!edge.junctionPoints.length) delete edge.junctionPoints;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2010, 2020 Kiel University and others.
|
|
3
|
+
* Adapted from ELK v0.11.0 BaseRoutingDirectionStrategy junction generation.
|
|
4
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
5
|
+
*******************************************************************************/
|
|
6
|
+
import type { ElkNode, ElkPoint } from "./types";
|
|
7
|
+
import {
|
|
8
|
+
createOrthogonalHypersegments,
|
|
9
|
+
type OrthogonalPort,
|
|
10
|
+
} from "../layered/orthogonal-hypersegments";
|
|
11
|
+
/** Reconstruct native segment spans from final orthogonal tracks, then emit ELK's unique junctions. */
|
|
12
|
+
export function applyOrthogonalJunctions(
|
|
13
|
+
root: ElkNode,
|
|
14
|
+
horizontal: boolean,
|
|
15
|
+
increasing: boolean,
|
|
16
|
+
mergeEdges: boolean,
|
|
17
|
+
hypernodes: ReadonlySet<string>,
|
|
18
|
+
preservedEdges: ReadonlySet<string>,
|
|
19
|
+
) {
|
|
20
|
+
const edges = root.edges ?? [],
|
|
21
|
+
ports: OrthogonalPort[] = [],
|
|
22
|
+
byPort = new Map<string, OrthogonalPort>();
|
|
23
|
+
const explicit = new Map(
|
|
24
|
+
(root.children ?? []).flatMap((n) => (n.ports ?? []).map((p) => [String(p.id), n] as const)),
|
|
25
|
+
);
|
|
26
|
+
const owner = new Map((root.children ?? []).map((node) => [String(node.id), node]));
|
|
27
|
+
for (const [id, node] of explicit) owner.set(id, node);
|
|
28
|
+
const records = edges.flatMap((edge, index) => {
|
|
29
|
+
if (preservedEdges.has(String(edge.id))) return [];
|
|
30
|
+
const sourceOwner = owner.get(String(edge.sources?.[0] ?? edge.source));
|
|
31
|
+
if (
|
|
32
|
+
sourceOwner !== undefined &&
|
|
33
|
+
sourceOwner === owner.get(String(edge.targets?.[0] ?? edge.target))
|
|
34
|
+
)
|
|
35
|
+
return [];
|
|
36
|
+
const section = edge.sections?.[0];
|
|
37
|
+
if (!section || edge.sections?.length !== 1) return [];
|
|
38
|
+
let points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint];
|
|
39
|
+
if (
|
|
40
|
+
points.some(
|
|
41
|
+
(p, i) =>
|
|
42
|
+
i > 0 &&
|
|
43
|
+
Math.abs(p.x - points[i - 1]!.x) > 1e-3 &&
|
|
44
|
+
Math.abs(p.y - points[i - 1]!.y) > 1e-3,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
return [];
|
|
48
|
+
let source = String(edge.sources?.[0] ?? edge.source),
|
|
49
|
+
target = String(edge.targets?.[0] ?? edge.target);
|
|
50
|
+
const flow = (p: ElkPoint) => (horizontal ? p.x : p.y);
|
|
51
|
+
if (flow(points[0]!) > flow(points.at(-1)!) === increasing) {
|
|
52
|
+
points = [...points].reverse();
|
|
53
|
+
[source, target] = [target, source];
|
|
54
|
+
}
|
|
55
|
+
const key = (ref: string, side: "source" | "target") =>
|
|
56
|
+
explicit.has(ref)
|
|
57
|
+
? JSON.stringify(["port", ref])
|
|
58
|
+
: mergeEdges || hypernodes.has(ref)
|
|
59
|
+
? JSON.stringify(["implicit", ref, side])
|
|
60
|
+
: JSON.stringify(["edge", index, side]);
|
|
61
|
+
return [{ edge, points, source: key(source, "source"), target: key(target, "target") }];
|
|
62
|
+
});
|
|
63
|
+
const cross = (p: ElkPoint) => (horizontal ? p.y : p.x),
|
|
64
|
+
flow = (p: ElkPoint) => (horizontal ? p.x : p.y);
|
|
65
|
+
for (const side of ["source", "target"] as const) {
|
|
66
|
+
const ordered = [...records].sort(
|
|
67
|
+
(a, b) =>
|
|
68
|
+
cross(side === "source" ? a.points[0]! : a.points.at(-1)!) -
|
|
69
|
+
cross(side === "source" ? b.points[0]! : b.points.at(-1)!),
|
|
70
|
+
);
|
|
71
|
+
for (const record of ordered) {
|
|
72
|
+
const id = record[side];
|
|
73
|
+
if (!byPort.has(id)) {
|
|
74
|
+
const port = {
|
|
75
|
+
id,
|
|
76
|
+
side,
|
|
77
|
+
position: cross(side === "source" ? record.points[0]! : record.points.at(-1)!),
|
|
78
|
+
};
|
|
79
|
+
byPort.set(id, port);
|
|
80
|
+
ports.push(port);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const grouped = createOrthogonalHypersegments(
|
|
85
|
+
ports,
|
|
86
|
+
records.map((r) => ({ source: r.source, target: r.target })),
|
|
87
|
+
);
|
|
88
|
+
const created = new Set<string>();
|
|
89
|
+
for (const group of grouped.segments) {
|
|
90
|
+
const members = records.filter((r) => group.ports.includes(r.source));
|
|
91
|
+
// Restoring a north/south endpoint may add an elbow after routing. A
|
|
92
|
+
// singleton connection has no branch; that elbow is not a junction.
|
|
93
|
+
if (members.length === 1) {
|
|
94
|
+
delete members[0]!.edge.junctionPoints;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const tracks = new Map<
|
|
98
|
+
number,
|
|
99
|
+
{ start: number; end: number; incoming: number[]; outgoing: number[] }
|
|
100
|
+
>();
|
|
101
|
+
const track = (point: ElkPoint) => {
|
|
102
|
+
const key = flow(point);
|
|
103
|
+
let t = tracks.get(key);
|
|
104
|
+
if (!t) {
|
|
105
|
+
t = { start: Infinity, end: -Infinity, incoming: [], outgoing: [] };
|
|
106
|
+
tracks.set(key, t);
|
|
107
|
+
}
|
|
108
|
+
t.start = Math.min(t.start, cross(point));
|
|
109
|
+
t.end = Math.max(t.end, cross(point));
|
|
110
|
+
return t;
|
|
111
|
+
};
|
|
112
|
+
for (const r of members) {
|
|
113
|
+
const bends = r.points.slice(1, -1);
|
|
114
|
+
if (!bends.length) continue;
|
|
115
|
+
for (const p of bends) track(p);
|
|
116
|
+
track(bends[0]!).incoming.push(cross(bends[0]!));
|
|
117
|
+
track(bends.at(-1)!).outgoing.push(cross(bends.at(-1)!));
|
|
118
|
+
for (let i = 1; i < bends.length; i++)
|
|
119
|
+
if (Math.abs(flow(bends[i]!) - flow(bends[i - 1]!)) > 1e-3) {
|
|
120
|
+
track(bends[i - 1]!).outgoing.push(cross(bends[i - 1]!));
|
|
121
|
+
track(bends[i]!).incoming.push(cross(bends[i]!));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const r of members.filter((r) => r.points.length === 2)) {
|
|
125
|
+
const start = Math.min(flow(r.points[0]!), flow(r.points[1]!));
|
|
126
|
+
const end = Math.max(flow(r.points[0]!), flow(r.points[1]!));
|
|
127
|
+
for (const [position, t] of tracks) {
|
|
128
|
+
if (position < start || position > end) continue;
|
|
129
|
+
t.incoming.push(cross(r.points[0]!));
|
|
130
|
+
t.outgoing.push(cross(r.points[1]!));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Long-edge joining removes collinear dummy anchors. A straight backbone
|
|
134
|
+
// still intersects every intermediate routing track it physically spans.
|
|
135
|
+
for (const r of members) {
|
|
136
|
+
for (let index = 1; index < r.points.length; index++) {
|
|
137
|
+
const a = r.points[index - 1]!,
|
|
138
|
+
b = r.points[index]!;
|
|
139
|
+
if (Math.abs(cross(a) - cross(b)) >= 1e-3) continue;
|
|
140
|
+
const start = Math.min(flow(a), flow(b)),
|
|
141
|
+
end = Math.max(flow(a), flow(b));
|
|
142
|
+
for (const [position, t] of tracks) {
|
|
143
|
+
if (position <= start || position >= end) continue;
|
|
144
|
+
t.incoming.push(cross(a));
|
|
145
|
+
t.outgoing.push(cross(b));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const t of tracks.values()) {
|
|
150
|
+
t.start = Math.min(t.start, ...t.incoming, ...t.outgoing);
|
|
151
|
+
t.end = Math.max(t.end, ...t.incoming, ...t.outgoing);
|
|
152
|
+
}
|
|
153
|
+
for (const port of group.ports)
|
|
154
|
+
for (const r of members.filter((r) => r.source === port)) {
|
|
155
|
+
delete r.edge.junctionPoints;
|
|
156
|
+
for (const p of r.points.slice(1, -1)) {
|
|
157
|
+
const t = tracks.get(flow(p))!,
|
|
158
|
+
value = cross(p),
|
|
159
|
+
key = `${p.x}:${p.y}`;
|
|
160
|
+
const boundary =
|
|
161
|
+
t.incoming.length &&
|
|
162
|
+
t.outgoing.length &&
|
|
163
|
+
((Math.abs(value - Math.min(...t.incoming)) < 1e-3 &&
|
|
164
|
+
Math.abs(value - Math.min(...t.outgoing)) < 1e-3) ||
|
|
165
|
+
(Math.abs(value - Math.max(...t.incoming)) < 1e-3 &&
|
|
166
|
+
Math.abs(value - Math.max(...t.outgoing)) < 1e-3));
|
|
167
|
+
if (!created.has(key) && ((value > t.start && value < t.end) || boundary)) {
|
|
168
|
+
(r.edge.junctionPoints ??= []).push({ ...p });
|
|
169
|
+
created.add(key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/elkjs/statechart.ts
CHANGED
|
@@ -178,8 +178,14 @@ export function scoreStatechartLayout(
|
|
|
178
178
|
height: shape.height ?? 0,
|
|
179
179
|
};
|
|
180
180
|
};
|
|
181
|
-
const visit = (
|
|
182
|
-
|
|
181
|
+
const visit = (
|
|
182
|
+
node: ElkNode,
|
|
183
|
+
x: number,
|
|
184
|
+
y: number,
|
|
185
|
+
ancestors: ElkNode[],
|
|
186
|
+
inheritedDirection = "RIGHT",
|
|
187
|
+
) => {
|
|
188
|
+
const direction = String(option(node, "direction") ?? inheritedDirection).toUpperCase();
|
|
183
189
|
const horizontal = direction === "RIGHT" || direction === "LEFT";
|
|
184
190
|
const sign = direction === "LEFT" || direction === "UP" ? -1 : 1;
|
|
185
191
|
const path = paths[String(node.id)] ?? [];
|
|
@@ -199,7 +205,7 @@ export function scoreStatechartLayout(
|
|
|
199
205
|
)
|
|
200
206
|
score.invalid++;
|
|
201
207
|
boxes.push({ rect: box, ancestors, node: child });
|
|
202
|
-
visit(child, box.x, box.y, [...ancestors, child]);
|
|
208
|
+
visit(child, box.x, box.y, [...ancestors, child], direction);
|
|
203
209
|
}
|
|
204
210
|
for (const edge of node.edges ?? []) {
|
|
205
211
|
if (String(option(edge, "noLayout")).toLowerCase() === "true") continue;
|
package/src/elkjs/types.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface ElkEdgeSection extends ElkGraphElement {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface ElkEdge extends ElkGraphElement {
|
|
38
|
+
/** Node whose coordinate frame contains the exported route. */
|
|
39
|
+
container?: ElkId;
|
|
38
40
|
sources?: ElkId[];
|
|
39
41
|
targets?: ElkId[];
|
|
40
42
|
source?: ElkId;
|
package/src/index.ts
CHANGED
|
@@ -96,6 +96,8 @@ export type {
|
|
|
96
96
|
} from "./layered";
|
|
97
97
|
|
|
98
98
|
export { c } from "./constraints";
|
|
99
|
+
export { hint, statechartHints } from "./layered/hints";
|
|
100
|
+
export type { AnchorHint, ChainHint, HintStrength, LayoutHint } from "./layered/hints";
|
|
99
101
|
export type {
|
|
100
102
|
LayoutConstraint,
|
|
101
103
|
GeometryReference,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type { VisualGraph } from "@statelyai/graph";
|
|
1
|
+
import type { VisualGraph, Point } from "@statelyai/graph";
|
|
2
2
|
|
|
3
3
|
export interface Elkjs0111ResultPolicy {
|
|
4
|
+
junctionPointsByEdgeId?: ReadonlyMap<string, readonly Point[]>;
|
|
5
|
+
placementCrossBounds?: { axis: "x" | "y"; maximum: number };
|
|
6
|
+
normalizationBounds?: { left: number; top: number; right: number; bottom: number };
|
|
4
7
|
providerBounds?: { width: number; height: number };
|
|
5
8
|
preserveEdgeSections?: boolean;
|
|
6
9
|
skipBoundsNormalization?: boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Development-only phase observations for differential tracing against real ELK. */
|
|
2
|
+
export type LayeredTraceEvent =
|
|
3
|
+
| {
|
|
4
|
+
kind: "sweep";
|
|
5
|
+
scope: string;
|
|
6
|
+
attempt: number;
|
|
7
|
+
forward: boolean;
|
|
8
|
+
firstSweep: boolean;
|
|
9
|
+
crossings: number;
|
|
10
|
+
layers: readonly (readonly string[])[];
|
|
11
|
+
}
|
|
12
|
+
| {
|
|
13
|
+
kind: "port-lists";
|
|
14
|
+
scope: string;
|
|
15
|
+
ports: ReadonlyMap<
|
|
16
|
+
string,
|
|
17
|
+
readonly { name?: string; outgoing: readonly string[]; incoming: readonly string[] }[]
|
|
18
|
+
>;
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
kind: "initial-order";
|
|
22
|
+
scope: string;
|
|
23
|
+
layers: readonly (readonly string[])[];
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
kind: "crossing-order";
|
|
27
|
+
scope: string;
|
|
28
|
+
edgeIds: readonly string[];
|
|
29
|
+
reversedEdgeIds: readonly string[];
|
|
30
|
+
layerByNodeId: ReadonlyMap<string, number>;
|
|
31
|
+
layers: readonly (readonly string[])[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let observer: ((event: LayeredTraceEvent) => void) | undefined;
|
|
35
|
+
|
|
36
|
+
/** Install an observer and return the previous one. Production code never sets one. */
|
|
37
|
+
export function setLayeredTraceObserver(
|
|
38
|
+
next: ((event: LayeredTraceEvent) => void) | undefined,
|
|
39
|
+
): ((event: LayeredTraceEvent) => void) | undefined {
|
|
40
|
+
const previous = observer;
|
|
41
|
+
observer = next;
|
|
42
|
+
return previous;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build and emit an event only while an observer is installed. */
|
|
46
|
+
export function traceLayeredPhase(event: () => LayeredTraceEvent): void {
|
|
47
|
+
observer?.(event());
|
|
48
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Graph, VisualGraph } from "@statelyai/graph";
|
|
2
2
|
import { boxAlgorithm, elkjs0111BoxAlgorithm, type BoxLayoutOptions } from "../box";
|
|
3
3
|
import { fixedAlgorithm, type FixedLayoutOptions } from "../fixed";
|
|
4
|
-
import { layeredAlgorithm, type LayeredLayoutOptions } from "../layered";
|
|
4
|
+
import { layeredAlgorithm, elkjs0111LayeredAlgorithm, type LayeredLayoutOptions } from "../layered";
|
|
5
5
|
import {
|
|
6
6
|
elkjs0111RectanglePackingAlgorithm,
|
|
7
7
|
rectanglePackingAlgorithm,
|
|
@@ -43,6 +43,7 @@ const elkjs0111LayoutAlgorithms = {
|
|
|
43
43
|
...builtInLayoutAlgorithms,
|
|
44
44
|
box: elkjs0111BoxAlgorithm,
|
|
45
45
|
random: elkjs0111RandomAlgorithm,
|
|
46
|
+
layered: elkjs0111LayeredAlgorithm,
|
|
46
47
|
rectpacking: elkjs0111RectanglePackingAlgorithm,
|
|
47
48
|
} satisfies {
|
|
48
49
|
[Id in BuiltInLayoutAlgorithmId]: LayoutAlgorithm<BuiltInLayoutOptionsById[Id]>;
|