@statelyai/layout 0.3.0 → 0.4.1
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-BBfKN4Dw.mjs +3075 -0
- package/dist/elkjs-DNfXDPup.cjs +3104 -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-B2buJqMf.cjs → layered-5RxQhryb.cjs} +11184 -2932
- package/dist/{layered-DGFTeQSl.mjs → layered-DSesbctm.mjs} +12059 -3903
- package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-CLFySz3R.cjs} +5 -18
- package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-GpBIX6ng.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 +1476 -449
- package/src/elkjs/layout-quality.ts +267 -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 +233 -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 +79 -0
- package/src/layered/hints.ts +371 -0
- package/src/layered/hyperedge-dummy-merger.ts +195 -0
- package/src/layered/index.ts +875 -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 +1044 -0
- package/src/layered/orthogonal-cycle-order.ts +106 -0
- package/src/layered/orthogonal-hypersegments.ts +154 -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 +2124 -886
- 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,243 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2010, 2020 Kiel University and others.
|
|
3
|
+
* Adapted from ELK v0.11.0 orthogonal segment splitting and routing generation.
|
|
4
|
+
* Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
|
|
5
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
6
|
+
*******************************************************************************/
|
|
7
|
+
import type { JavaRandom } from "../java-random";
|
|
8
|
+
import { detectOrthogonalCycles, type OrthogonalDependency } from "./orthogonal-cycle-order";
|
|
9
|
+
export interface OrthogonalSegmentInput {
|
|
10
|
+
incoming: readonly number[];
|
|
11
|
+
outgoing: readonly number[];
|
|
12
|
+
}
|
|
13
|
+
export interface OrthogonalSegment {
|
|
14
|
+
incoming: number[];
|
|
15
|
+
outgoing: number[];
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
slot: number;
|
|
19
|
+
partner?: number;
|
|
20
|
+
splitBy?: number;
|
|
21
|
+
}
|
|
22
|
+
const extent = (s: OrthogonalSegmentInput) => {
|
|
23
|
+
const values = [...s.incoming, ...s.outgoing];
|
|
24
|
+
return {
|
|
25
|
+
start: values.length ? Math.min(...values) : NaN,
|
|
26
|
+
end: values.length ? Math.max(...values) : NaN,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const crossings = (positions: readonly number[], start: number, end: number) =>
|
|
30
|
+
positions.filter((p) => p >= start && p <= end).length;
|
|
31
|
+
const orderingCrossings = (a: OrthogonalSegment, b: OrthogonalSegment) =>
|
|
32
|
+
crossings(a.outgoing, b.start, b.end) + crossings(b.incoming, a.start, a.end);
|
|
33
|
+
/** Full ELK segment dependency, critical split, cycle removal and rank phases. */
|
|
34
|
+
export function routeOrthogonalSegments(
|
|
35
|
+
input: readonly OrthogonalSegmentInput[],
|
|
36
|
+
conflictThreshold: number,
|
|
37
|
+
criticalThreshold: number,
|
|
38
|
+
random: JavaRandom,
|
|
39
|
+
) {
|
|
40
|
+
const segments: OrthogonalSegment[] = input.map((s) => ({
|
|
41
|
+
incoming: [...s.incoming],
|
|
42
|
+
outgoing: [...s.outgoing],
|
|
43
|
+
...extent(s),
|
|
44
|
+
slot: 0,
|
|
45
|
+
}));
|
|
46
|
+
let dependencies: OrthogonalDependency[] = [];
|
|
47
|
+
const add = (source: number, target: number, critical: boolean, weight: number) =>
|
|
48
|
+
dependencies.push({ source, target, critical, weight });
|
|
49
|
+
const conflicts = (a: readonly number[], b: readonly number[]) => {
|
|
50
|
+
if (!a.length || !b.length) return 0;
|
|
51
|
+
let i = 0,
|
|
52
|
+
j = 0,
|
|
53
|
+
count = 0;
|
|
54
|
+
while (true) {
|
|
55
|
+
const x = a[i]!,
|
|
56
|
+
y = b[j]!;
|
|
57
|
+
if (x > y - criticalThreshold && x < y + criticalThreshold) return -1;
|
|
58
|
+
if (x > y - conflictThreshold && x < y + conflictThreshold) count++;
|
|
59
|
+
if (x <= y && i < a.length - 1) i++;
|
|
60
|
+
else if (y <= x && j < b.length - 1) j++;
|
|
61
|
+
else break;
|
|
62
|
+
}
|
|
63
|
+
return count;
|
|
64
|
+
};
|
|
65
|
+
const create = (a: number, b: number) => {
|
|
66
|
+
const left = segments[a]!,
|
|
67
|
+
right = segments[b]!;
|
|
68
|
+
if (Math.abs(left.start - left.end) < 1e-3 || Math.abs(right.start - right.end) < 1e-3)
|
|
69
|
+
return 0;
|
|
70
|
+
const c1 = conflicts(left.outgoing, right.incoming),
|
|
71
|
+
c2 = conflicts(right.outgoing, left.incoming);
|
|
72
|
+
if (c1 === -1 || c2 === -1) {
|
|
73
|
+
if (c1 === -1) add(b, a, true, 1);
|
|
74
|
+
if (c2 === -1) add(a, b, true, 1);
|
|
75
|
+
return Number(c1 === -1) + Number(c2 === -1);
|
|
76
|
+
}
|
|
77
|
+
const v1 = c1 + 16 * orderingCrossings(left, right),
|
|
78
|
+
v2 = c2 + 16 * orderingCrossings(right, left);
|
|
79
|
+
if (v1 < v2) add(a, b, false, v2 - v1);
|
|
80
|
+
else if (v1 > v2) add(b, a, false, v1 - v2);
|
|
81
|
+
else if (v1 > 0) {
|
|
82
|
+
add(a, b, false, 0);
|
|
83
|
+
add(b, a, false, 0);
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
};
|
|
87
|
+
let criticalCount = 0;
|
|
88
|
+
for (let a = 0; a < segments.length - 1; a++)
|
|
89
|
+
for (let b = a + 1; b < segments.length; b++) criticalCount += create(a, b);
|
|
90
|
+
if (criticalCount >= 2) {
|
|
91
|
+
const cycle = detectOrthogonalCycles(segments.length, dependencies, true, random);
|
|
92
|
+
const selected = new Set<number>();
|
|
93
|
+
for (const dependency of cycle.backwards) {
|
|
94
|
+
let { source, target } = dependency;
|
|
95
|
+
if (selected.has(source) || selected.has(target)) continue;
|
|
96
|
+
const a = segments[source]!,
|
|
97
|
+
b = segments[target]!;
|
|
98
|
+
if (a.incoming.length + a.outgoing.length > 2 && b.incoming.length + b.outgoing.length <= 2)
|
|
99
|
+
[source, target] = [target, source];
|
|
100
|
+
selected.add(source);
|
|
101
|
+
segments[source]!.splitBy = target;
|
|
102
|
+
}
|
|
103
|
+
const coords = segments.flatMap((s) => [...s.incoming, ...s.outgoing]).sort((a, b) => a - b);
|
|
104
|
+
const areas: Array<{ start: number; end: number }> = [];
|
|
105
|
+
for (let i = 1; i < coords.length; i++)
|
|
106
|
+
if (coords[i]! - coords[i - 1]! >= 2 * criticalThreshold)
|
|
107
|
+
areas.push({
|
|
108
|
+
start: coords[i - 1]! + criticalThreshold,
|
|
109
|
+
end: coords[i]! - criticalThreshold,
|
|
110
|
+
});
|
|
111
|
+
const ordered = [...selected].sort(
|
|
112
|
+
(a, b) => segments[a]!.end - segments[a]!.start - (segments[b]!.end - segments[b]!.start),
|
|
113
|
+
);
|
|
114
|
+
for (const index of ordered) {
|
|
115
|
+
const segment = segments[index]!,
|
|
116
|
+
causing = segment.splitBy!;
|
|
117
|
+
let position = (segment.start + segment.end) / 2;
|
|
118
|
+
const possible = areas.flatMap((area, i) =>
|
|
119
|
+
area.start <= segment.end && area.end >= segment.start ? [i] : [],
|
|
120
|
+
);
|
|
121
|
+
if (possible.length) {
|
|
122
|
+
const rating = (areaIndex: number) => {
|
|
123
|
+
const area = areas[areaIndex]!,
|
|
124
|
+
center = (area.start + area.end) / 2;
|
|
125
|
+
// ELK rates using the provisional halves' extents before adding the bridge.
|
|
126
|
+
const left = {
|
|
127
|
+
...segment,
|
|
128
|
+
outgoing: [center],
|
|
129
|
+
...extent({ incoming: segment.incoming, outgoing: [] }),
|
|
130
|
+
};
|
|
131
|
+
const right = {
|
|
132
|
+
...segment,
|
|
133
|
+
incoming: [center],
|
|
134
|
+
...extent({ incoming: [], outgoing: segment.outgoing }),
|
|
135
|
+
};
|
|
136
|
+
let depCount = 2,
|
|
137
|
+
cross = 0;
|
|
138
|
+
const neighbors = [
|
|
139
|
+
...dependencies.filter((d) => d.target === index).map((d) => d.source),
|
|
140
|
+
...dependencies.filter((d) => d.source === index).map((d) => d.target),
|
|
141
|
+
];
|
|
142
|
+
for (const other of neighbors)
|
|
143
|
+
for (const half of [left, right]) {
|
|
144
|
+
const c1 = orderingCrossings(half, segments[other]!),
|
|
145
|
+
c2 = orderingCrossings(segments[other]!, half);
|
|
146
|
+
if (c1 === c2) {
|
|
147
|
+
if (c1 > 0) {
|
|
148
|
+
depCount += 2;
|
|
149
|
+
cross += c1;
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
depCount++;
|
|
153
|
+
cross += Math.min(c1, c2);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
cross +=
|
|
157
|
+
orderingCrossings(left, segments[causing]!) +
|
|
158
|
+
orderingCrossings(segments[causing]!, right);
|
|
159
|
+
return { cross, depCount, size: area.end - area.start };
|
|
160
|
+
};
|
|
161
|
+
let best = possible[0]!,
|
|
162
|
+
score = rating(best);
|
|
163
|
+
for (const candidate of possible.slice(1)) {
|
|
164
|
+
const curr = rating(candidate);
|
|
165
|
+
if (
|
|
166
|
+
curr.cross < score.cross ||
|
|
167
|
+
(curr.cross === score.cross &&
|
|
168
|
+
(curr.depCount < score.depCount ||
|
|
169
|
+
(curr.depCount === score.depCount && curr.size > score.size)))
|
|
170
|
+
) {
|
|
171
|
+
best = candidate;
|
|
172
|
+
score = curr;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const area = areas[best]!;
|
|
176
|
+
position = (area.start + area.end) / 2;
|
|
177
|
+
areas.splice(best, 1);
|
|
178
|
+
if ((area.end - area.start) / 2 >= criticalThreshold) {
|
|
179
|
+
const replacement = [];
|
|
180
|
+
if (area.start <= position - criticalThreshold)
|
|
181
|
+
replacement.push({ start: area.start, end: position - criticalThreshold });
|
|
182
|
+
if (position + criticalThreshold <= area.end)
|
|
183
|
+
replacement.push({ start: position + criticalThreshold, end: area.end });
|
|
184
|
+
areas.splice(best, 0, ...replacement);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const partner = segments.length;
|
|
188
|
+
const outgoing = segment.outgoing;
|
|
189
|
+
segment.outgoing = [position];
|
|
190
|
+
segment.partner = partner;
|
|
191
|
+
Object.assign(segment, extent(segment));
|
|
192
|
+
segments.push({
|
|
193
|
+
incoming: [position],
|
|
194
|
+
outgoing,
|
|
195
|
+
...extent({ incoming: [position], outgoing }),
|
|
196
|
+
slot: 0,
|
|
197
|
+
partner: index,
|
|
198
|
+
});
|
|
199
|
+
dependencies = dependencies.filter((d) => d.source !== index && d.target !== index);
|
|
200
|
+
add(index, causing, true, 1);
|
|
201
|
+
add(causing, partner, true, 1);
|
|
202
|
+
for (let other = 0; other < segments.length; other++)
|
|
203
|
+
if (other !== index && other !== partner && other !== causing) {
|
|
204
|
+
create(other, index);
|
|
205
|
+
create(other, partner);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const regular = detectOrthogonalCycles(segments.length, dependencies, false, random);
|
|
210
|
+
// Reversal appends to both adjacency lists, just like ELK's setters.
|
|
211
|
+
for (const backwards of regular.backwards) {
|
|
212
|
+
dependencies.splice(dependencies.indexOf(backwards), 1);
|
|
213
|
+
if (backwards.weight > 0)
|
|
214
|
+
dependencies.push({ ...backwards, source: backwards.target, target: backwards.source });
|
|
215
|
+
}
|
|
216
|
+
const inCount = segments.map((_, i) => dependencies.filter((d) => d.target === i).length),
|
|
217
|
+
outCount = segments.map((_, i) => dependencies.filter((d) => d.source === i).length);
|
|
218
|
+
const sources = inCount.flatMap((n, i) => (n === 0 ? [i] : [])),
|
|
219
|
+
targets = outCount.flatMap((n, i) =>
|
|
220
|
+
n === 0 && segments[i]!.incoming.length === 0 ? [i] : [],
|
|
221
|
+
);
|
|
222
|
+
let maxRank = -1;
|
|
223
|
+
while (sources.length) {
|
|
224
|
+
const index = sources.shift()!;
|
|
225
|
+
for (const d of dependencies.filter((d) => d.source === index)) {
|
|
226
|
+
segments[d.target]!.slot = Math.max(segments[d.target]!.slot, segments[index]!.slot + 1);
|
|
227
|
+
maxRank = Math.max(maxRank, segments[d.target]!.slot);
|
|
228
|
+
if (--inCount[d.target]! === 0) sources.push(d.target);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (maxRank > -1) {
|
|
232
|
+
for (const index of targets) segments[index]!.slot = maxRank;
|
|
233
|
+
while (targets.length) {
|
|
234
|
+
const index = targets.shift()!;
|
|
235
|
+
for (const d of dependencies.filter((d) => d.target === index)) {
|
|
236
|
+
if (segments[d.source]!.incoming.length) continue;
|
|
237
|
+
segments[d.source]!.slot = Math.min(segments[d.source]!.slot, segments[index]!.slot - 1);
|
|
238
|
+
if (--outCount[d.source]! === 0) targets.push(d.source);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { segments, dependencies };
|
|
243
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AcyclicOrientation, LayeredPhaseInput, LayerOrder } from "./types";
|
|
2
|
+
|
|
3
|
+
const orientationByInput = new WeakMap<LayeredPhaseInput, AcyclicOrientation>();
|
|
4
|
+
|
|
5
|
+
/** Retain the active acyclic topology without altering authored endpoints or public output. */
|
|
6
|
+
export function setPlacementOrientation(
|
|
7
|
+
input: LayeredPhaseInput,
|
|
8
|
+
orientation: AcyclicOrientation,
|
|
9
|
+
): void {
|
|
10
|
+
orientationByInput.set(input, orientation);
|
|
11
|
+
}
|
|
12
|
+
export function getPlacementOrientation(input: LayeredPhaseInput): AcyclicOrientation | undefined {
|
|
13
|
+
return orientationByInput.get(input);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const orderByInput = new WeakMap<LayeredPhaseInput, LayerOrder>();
|
|
17
|
+
export function setPlacementOrder(input: LayeredPhaseInput, order: LayerOrder): void {
|
|
18
|
+
orderByInput.set(input, order);
|
|
19
|
+
}
|
|
20
|
+
export function getPlacementOrder(input: LayeredPhaseInput): LayerOrder | undefined {
|
|
21
|
+
return orderByInput.get(input);
|
|
22
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2016, 2020 Kiel University and others.
|
|
3
|
+
* Native adaptation of ELK AbstractBarycenterPortDistributor,
|
|
4
|
+
* NodeRelativePortDistributor and LayerTotalPortDistributor.
|
|
5
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
6
|
+
*/
|
|
7
|
+
import type { CrossingGraph, CrossingNode, CrossingPort } from "./crossing-counter";
|
|
8
|
+
|
|
9
|
+
export interface PortDistributionState {
|
|
10
|
+
ranks: Record<string, number>;
|
|
11
|
+
barycenters: Record<string, number>;
|
|
12
|
+
positions: Record<string, number>;
|
|
13
|
+
}
|
|
14
|
+
export interface PortDistributionOptions {
|
|
15
|
+
nodeRelative: boolean;
|
|
16
|
+
fixedOrder: ReadonlySet<string>;
|
|
17
|
+
hierarchical: ReadonlySet<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stateful clockwise port distribution across successive layer sweeps. */
|
|
21
|
+
export class CanonicalPortDistributor {
|
|
22
|
+
readonly state: PortDistributionState;
|
|
23
|
+
private topologyGraph?: CrossingGraph;
|
|
24
|
+
private owner = new Map<string, CrossingNode>();
|
|
25
|
+
private layerByPort = new Map<string, number>();
|
|
26
|
+
private nodes = new Map<string, CrossingNode>();
|
|
27
|
+
private incoming = new Map<string, string[]>();
|
|
28
|
+
private outgoing = new Map<string, string[]>();
|
|
29
|
+
constructor(graph: CrossingGraph, state?: PortDistributionState) {
|
|
30
|
+
this.state = state ? structuredClone(state) : { ranks: {}, barycenters: {}, positions: {} };
|
|
31
|
+
for (const layer of graph.layers)
|
|
32
|
+
for (const [position, node] of layer.entries()) {
|
|
33
|
+
this.state.positions[node.id] ??= position;
|
|
34
|
+
for (const port of node.ports) {
|
|
35
|
+
this.state.ranks[port.id] ??= 0;
|
|
36
|
+
this.state.barycenters[port.id] ??= 0;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private prepareTopology(graph: CrossingGraph): void {
|
|
42
|
+
// Sweeps reorder nodes within their layers; connectivity stays constant.
|
|
43
|
+
// A restored graph object invalidates the topology once, not per layer.
|
|
44
|
+
if (this.topologyGraph !== graph) {
|
|
45
|
+
this.topologyGraph = graph;
|
|
46
|
+
this.owner.clear();
|
|
47
|
+
this.layerByPort.clear();
|
|
48
|
+
this.nodes.clear();
|
|
49
|
+
this.incoming.clear();
|
|
50
|
+
this.outgoing.clear();
|
|
51
|
+
for (const [layer, members] of graph.layers.entries())
|
|
52
|
+
for (const node of members) {
|
|
53
|
+
this.nodes.set(node.id, node);
|
|
54
|
+
for (const port of node.ports) {
|
|
55
|
+
this.owner.set(port.id, node);
|
|
56
|
+
this.layerByPort.set(port.id, layer);
|
|
57
|
+
this.incoming.set(port.id, []);
|
|
58
|
+
this.outgoing.set(port.id, []);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const edge of graph.edges) {
|
|
62
|
+
this.incoming.get(edge.target)!.push(edge.source);
|
|
63
|
+
this.outgoing.get(edge.source)!.push(edge.target);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
calculatePortRanks(
|
|
69
|
+
graph: CrossingGraph,
|
|
70
|
+
layer: readonly CrossingNode[],
|
|
71
|
+
input: boolean,
|
|
72
|
+
nodeRelative: boolean,
|
|
73
|
+
): void {
|
|
74
|
+
this.prepareTopology(graph);
|
|
75
|
+
let consumed = 0;
|
|
76
|
+
for (const node of layer) {
|
|
77
|
+
const ports = node.ports.filter(
|
|
78
|
+
(port) => (input ? this.incoming : this.outgoing).get(port.id)!.length > 0,
|
|
79
|
+
);
|
|
80
|
+
const increment = nodeRelative ? 1 / (ports.length + 1) : 1;
|
|
81
|
+
if (input) {
|
|
82
|
+
let north = consumed + ports.filter((port) => port.side === "NORTH").length * increment;
|
|
83
|
+
let rest = nodeRelative ? consumed + 1 - increment : consumed + ports.length;
|
|
84
|
+
for (const port of ports) {
|
|
85
|
+
this.state.ranks[port.id] = port.side === "NORTH" ? north : rest;
|
|
86
|
+
if (port.side === "NORTH") north -= increment;
|
|
87
|
+
else rest -= increment;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
let position = consumed + increment;
|
|
91
|
+
for (const port of ports) {
|
|
92
|
+
this.state.ranks[port.id] = position;
|
|
93
|
+
position += increment;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
consumed += nodeRelative ? 1 : ports.length;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
distribute(
|
|
101
|
+
graph: CrossingGraph,
|
|
102
|
+
index: number,
|
|
103
|
+
forward: boolean,
|
|
104
|
+
options: PortDistributionOptions,
|
|
105
|
+
): void {
|
|
106
|
+
this.prepareTopology(graph);
|
|
107
|
+
const { owner, layerByPort, nodes, incoming, outgoing } = this;
|
|
108
|
+
const degree = (port: string) => incoming.get(port)!.length + outgoing.get(port)!.length;
|
|
109
|
+
const ranks = (layer: CrossingNode[], input: boolean) =>
|
|
110
|
+
this.calculatePortRanks(graph, layer, input, options.nodeRelative);
|
|
111
|
+
const distributeNode = (node: CrossingNode, side: "EAST" | "WEST") => {
|
|
112
|
+
if (options.fixedOrder.has(node.id)) return;
|
|
113
|
+
const layerIndex = node.ports.length ? layerByPort.get(node.ports[0]!.id)! : index;
|
|
114
|
+
const size = graph.layers[layerIndex]!.length;
|
|
115
|
+
for (const face of [side, "SOUTH", "NORTH"] as const) {
|
|
116
|
+
let minimum = 0,
|
|
117
|
+
maximum = 0;
|
|
118
|
+
const inLayer: CrossingPort[] = [];
|
|
119
|
+
for (const port of node.ports.filter((port) => port.side === face)) {
|
|
120
|
+
let sum = 0;
|
|
121
|
+
const cross = port.side === "NORTH" || port.side === "SOUTH";
|
|
122
|
+
if (cross) {
|
|
123
|
+
if (!port.dummy) continue;
|
|
124
|
+
const dummy = nodes.get(port.dummy)!;
|
|
125
|
+
let input = false,
|
|
126
|
+
output = false;
|
|
127
|
+
for (const peer of dummy.ports) {
|
|
128
|
+
if (peer.origin !== port.id) continue;
|
|
129
|
+
if (outgoing.get(peer.id)!.length) output = true;
|
|
130
|
+
else if (incoming.get(peer.id)!.length) input = true;
|
|
131
|
+
}
|
|
132
|
+
const position = this.state.positions[dummy.id]!;
|
|
133
|
+
const large = 2 * size + 1;
|
|
134
|
+
if (input && !output) sum += port.side === "NORTH" ? -position : large - position;
|
|
135
|
+
else if (output && !input) sum += position + 1;
|
|
136
|
+
else if (input && output) sum += port.side === "NORTH" ? 0 : large / 2;
|
|
137
|
+
} else {
|
|
138
|
+
const peers = [...outgoing.get(port.id)!, ...incoming.get(port.id)!];
|
|
139
|
+
if (peers.some((peer) => layerByPort.get(peer) === layerIndex)) {
|
|
140
|
+
inLayer.push(port);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
for (const peer of outgoing.get(port.id)!) sum += this.state.ranks[peer]!;
|
|
144
|
+
for (const peer of incoming.get(port.id)!) sum -= this.state.ranks[peer]!;
|
|
145
|
+
}
|
|
146
|
+
if (degree(port.id)) {
|
|
147
|
+
const value = sum / degree(port.id);
|
|
148
|
+
this.state.barycenters[port.id] = value;
|
|
149
|
+
minimum = Math.min(minimum, value);
|
|
150
|
+
maximum = Math.max(maximum, value);
|
|
151
|
+
} else if (cross) this.state.barycenters[port.id] = sum;
|
|
152
|
+
}
|
|
153
|
+
for (const port of inLayer) {
|
|
154
|
+
const peers = [...incoming.get(port.id)!, ...outgoing.get(port.id)!].filter(
|
|
155
|
+
(peer) => layerByPort.get(peer) === layerIndex,
|
|
156
|
+
);
|
|
157
|
+
const value =
|
|
158
|
+
peers.reduce((sum, peer) => sum + this.state.positions[owner.get(peer)!.id]! + 1, 0) /
|
|
159
|
+
peers.length;
|
|
160
|
+
const above = value < this.state.positions[node.id]! + 1;
|
|
161
|
+
this.state.barycenters[port.id] =
|
|
162
|
+
port.side === "EAST"
|
|
163
|
+
? above
|
|
164
|
+
? minimum - value
|
|
165
|
+
: maximum + (size + 1 - value)
|
|
166
|
+
: above
|
|
167
|
+
? maximum + value
|
|
168
|
+
: minimum - (size + 1 - value);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const sideRank = { NORTH: 0, EAST: 1, SOUTH: 2, WEST: 3 };
|
|
172
|
+
node.ports.sort((a, b) => {
|
|
173
|
+
const side = sideRank[a.side] - sideRank[b.side];
|
|
174
|
+
if (side) return side;
|
|
175
|
+
const left = this.state.barycenters[a.id]!,
|
|
176
|
+
right = this.state.barycenters[b.id]!;
|
|
177
|
+
return left === 0 && right === 0 ? 0 : left === 0 ? -1 : right === 0 ? 1 : left - right;
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
const free = graph.layers[index]!;
|
|
181
|
+
for (const [position, node] of free.entries()) this.state.positions[node.id] = position;
|
|
182
|
+
const side = forward ? "WEST" : "EAST";
|
|
183
|
+
const fixed = graph.layers[index + (forward ? -1 : 1)];
|
|
184
|
+
if (fixed) {
|
|
185
|
+
ranks(fixed, !forward);
|
|
186
|
+
for (const node of free) distributeNode(node, side);
|
|
187
|
+
ranks(free, forward);
|
|
188
|
+
for (const node of fixed)
|
|
189
|
+
if (!options.hierarchical.has(node.id)) distributeNode(node, forward ? "EAST" : "WEST");
|
|
190
|
+
} else for (const node of free) distributeNode(node, side);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GraphEdge } from "@statelyai/graph";
|
|
2
|
+
import type { LayeredPhaseInput } from "./types";
|
|
3
|
+
|
|
4
|
+
/** ELK traverses connected edges through the node's port list, preserving each port's edge order. */
|
|
5
|
+
export function connectedEdgesInPortOrder(input: LayeredPhaseInput, nodeId: string): GraphEdge[] {
|
|
6
|
+
const node = input.graph.nodes.find((candidate) => candidate.id === nodeId);
|
|
7
|
+
const portRank = new Map(node?.ports?.map((port, index) => [port.name, index]));
|
|
8
|
+
const explicitCount = portRank.size;
|
|
9
|
+
return input.graph.edges
|
|
10
|
+
.map((edge, index) => ({ edge, index }))
|
|
11
|
+
.filter(({ edge }) => edge.sourceId === nodeId || edge.targetId === nodeId)
|
|
12
|
+
.sort((a, b) => {
|
|
13
|
+
const rank = ({ edge, index }: { edge: GraphEdge; index: number }) => {
|
|
14
|
+
const port = edge.sourceId === nodeId ? edge.sourcePort : edge.targetPort;
|
|
15
|
+
return (port === undefined ? undefined : portRank.get(port)) ?? explicitCount + index;
|
|
16
|
+
};
|
|
17
|
+
return rank(a) - rank(b) || a.index - b.index;
|
|
18
|
+
})
|
|
19
|
+
.map(({ edge }) => edge);
|
|
20
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { worldGeometry } from "../authoring/coordinates";
|
|
2
|
+
import { routeToPoints } from "../routing/adapters";
|
|
3
|
+
import { recordRouteGeometry } from "../routing/layout-cache";
|
|
4
|
+
import type { CompoundVisualGraph } from "./compound";
|
|
5
|
+
import type { LayeredLayoutOptions } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Run a replacement on finalized geometry, with no initial route hints. */
|
|
8
|
+
export function replaceLayoutRouting<N, E, G, P>(
|
|
9
|
+
graph: CompoundVisualGraph<N, E, G, P>,
|
|
10
|
+
options: LayeredLayoutOptions,
|
|
11
|
+
): CompoundVisualGraph<N, E, G, P> {
|
|
12
|
+
if (!options.routing) return graph;
|
|
13
|
+
const { compoundRoutes: _routes, compoundGeometry: _geometry, ...geometry } = graph;
|
|
14
|
+
const world = worldGeometry(geometry);
|
|
15
|
+
const input = {
|
|
16
|
+
...geometry,
|
|
17
|
+
nodes: world.nodes.map((n) => ({ ...n, ports: n.ports?.map((p) => ({ ...p })) })),
|
|
18
|
+
edges: world.edges.map(({ points: _points, routing: _routing, ...e }) => e),
|
|
19
|
+
};
|
|
20
|
+
const snapshot = options.routing.strategy.route(input, {
|
|
21
|
+
...options.routing.settings,
|
|
22
|
+
coordinateSpace: "world",
|
|
23
|
+
});
|
|
24
|
+
if (snapshot instanceof Promise)
|
|
25
|
+
throw new TypeError(
|
|
26
|
+
"Layered route replacement must be synchronous; run async routing separately",
|
|
27
|
+
);
|
|
28
|
+
if (
|
|
29
|
+
snapshot.routes.size !== graph.edges.length ||
|
|
30
|
+
graph.edges.some((e) => !snapshot.routes.has(e.id))
|
|
31
|
+
)
|
|
32
|
+
throw new Error("Replacement router must return exactly one route for every edge");
|
|
33
|
+
const result = {
|
|
34
|
+
...graph,
|
|
35
|
+
edgeCoordinateSpace: "world" as const,
|
|
36
|
+
edges: graph.edges.map((edge) => ({
|
|
37
|
+
...edge,
|
|
38
|
+
points: routeToPoints(snapshot.routes.get(edge.id)!),
|
|
39
|
+
routing: "polyline" as const,
|
|
40
|
+
})),
|
|
41
|
+
compoundRoutes: snapshot.routes,
|
|
42
|
+
};
|
|
43
|
+
recordRouteGeometry(result, result.compoundRoutes);
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { NodePlacement } from "./types";
|
|
2
|
+
|
|
3
|
+
// ELK routes in the node placer's coordinate space, before graph-padding normalization.
|
|
4
|
+
const coordinateSpaces = new WeakMap<
|
|
5
|
+
NodePlacement,
|
|
6
|
+
{ raw: NodePlacement; normalized: NodePlacement }
|
|
7
|
+
>();
|
|
8
|
+
|
|
9
|
+
export function recordRoutingCoordinates(placement: NodePlacement, raw: NodePlacement): void {
|
|
10
|
+
coordinateSpaces.set(placement, {
|
|
11
|
+
raw,
|
|
12
|
+
normalized: { rectByNodeId: new Map(placement.rectByNodeId) },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function routingCoordinates(placement: NodePlacement): NodePlacement {
|
|
17
|
+
const spaces = coordinateSpaces.get(placement);
|
|
18
|
+
if (!spaces) return placement;
|
|
19
|
+
return {
|
|
20
|
+
rectByNodeId: new Map(
|
|
21
|
+
[...placement.rectByNodeId].map(([id, rect]) => {
|
|
22
|
+
const original = spaces.normalized.rectByNodeId.get(id),
|
|
23
|
+
raw = spaces.raw.rectByNodeId.get(id);
|
|
24
|
+
if (!original || !raw) return [id, rect];
|
|
25
|
+
return [
|
|
26
|
+
id,
|
|
27
|
+
{ ...rect, x: raw.x + (rect.x - original.x), y: raw.y + (rect.y - original.y) },
|
|
28
|
+
];
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -108,10 +108,31 @@ export function separateExteriorLabels<E extends ExteriorLabelEdge>({
|
|
|
108
108
|
right.length - left.length,
|
|
109
109
|
)[0];
|
|
110
110
|
if (!routeTrack) return [];
|
|
111
|
+
// Long-edge joining can retain several collinear points through a label
|
|
112
|
+
// dummy. They form one track; moving only one fragment creates diagonals
|
|
113
|
+
// at the retained junctions.
|
|
114
|
+
const pointCross = (point: Point) => (horizontalFlow ? point.y : point.x);
|
|
115
|
+
const pointFlow = (point: Point) => (horizontalFlow ? point.x : point.y);
|
|
116
|
+
let startIndex = routeTrack.startIndex;
|
|
117
|
+
let endIndex = startIndex + 1;
|
|
118
|
+
while (startIndex > 0 && pointCross(edge.points[startIndex - 1]!) === routeTrack.cross)
|
|
119
|
+
startIndex--;
|
|
120
|
+
while (
|
|
121
|
+
endIndex + 1 < edge.points.length &&
|
|
122
|
+
pointCross(edge.points[endIndex + 1]!) === routeTrack.cross
|
|
123
|
+
)
|
|
124
|
+
endIndex++;
|
|
125
|
+
const trackPoints = edge.points.slice(startIndex, endIndex + 1);
|
|
111
126
|
return [
|
|
112
127
|
{
|
|
113
128
|
edge,
|
|
114
|
-
exteriorTrack:
|
|
129
|
+
exteriorTrack: {
|
|
130
|
+
...routeTrack,
|
|
131
|
+
startIndex,
|
|
132
|
+
endIndex,
|
|
133
|
+
flowStart: Math.min(...trackPoints.map(pointFlow)),
|
|
134
|
+
flowEnd: Math.max(...trackPoints.map(pointFlow)),
|
|
135
|
+
},
|
|
115
136
|
preserveAnchors: exteriorTrack === undefined,
|
|
116
137
|
labelRect,
|
|
117
138
|
lowSide:
|
|
@@ -222,17 +243,14 @@ export function separateExteriorLabels<E extends ExteriorLabelEdge>({
|
|
|
222
243
|
else edge.x += totalDelta;
|
|
223
244
|
if (!exteriorTrack) continue;
|
|
224
245
|
edge.points = edge.points.flatMap((point, index) => {
|
|
225
|
-
if (index
|
|
226
|
-
return [point];
|
|
246
|
+
if (index < exteriorTrack.startIndex || index > exteriorTrack.endIndex) return [point];
|
|
227
247
|
const shifted = horizontalFlow
|
|
228
248
|
? { ...point, y: point.y + totalDelta }
|
|
229
249
|
: { ...point, x: point.x + totalDelta };
|
|
230
250
|
// Keep authored endpoint anchors attached when the chosen track ends there.
|
|
231
251
|
return preserveAnchors && index === exteriorTrack.startIndex && index === 0
|
|
232
252
|
? [point, shifted]
|
|
233
|
-
: preserveAnchors &&
|
|
234
|
-
index === exteriorTrack.startIndex + 1 &&
|
|
235
|
-
index === edge.points.length - 1
|
|
253
|
+
: preserveAnchors && index === exteriorTrack.endIndex && index === edge.points.length - 1
|
|
236
254
|
? [shifted, point]
|
|
237
255
|
: [shifted];
|
|
238
256
|
});
|
package/src/layered/spacing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { externalPortDummyOf } from "./external-port-dummy";
|
|
1
2
|
import type { LayeredPhaseInput } from "./types";
|
|
2
3
|
|
|
3
4
|
export type IndividualSpacing = Readonly<Record<string, number>>;
|
|
@@ -10,20 +11,41 @@ export function nodeNodeSpacing(
|
|
|
10
11
|
): number {
|
|
11
12
|
const firstBreakingPoint = firstId.startsWith("__layout_breaking:");
|
|
12
13
|
const secondBreakingPoint = secondId.startsWith("__layout_breaking:");
|
|
13
|
-
const
|
|
14
|
-
const
|
|
14
|
+
const firstLabel = firstId.startsWith("__layout_dummy:label:");
|
|
15
|
+
const secondLabel = secondId.startsWith("__layout_dummy:label:");
|
|
16
|
+
const firstNode = input.graph.nodes.find((node) => node.id === firstId);
|
|
17
|
+
const secondNode = input.graph.nodes.find((node) => node.id === secondId);
|
|
18
|
+
const firstExternal = firstNode !== undefined && externalPortDummyOf(firstNode) !== undefined;
|
|
19
|
+
const secondExternal = secondNode !== undefined && externalPortDummyOf(secondNode) !== undefined;
|
|
20
|
+
const firstDummy = firstId.startsWith("__layout_dummy:") || firstBreakingPoint || firstExternal;
|
|
21
|
+
const secondDummy =
|
|
22
|
+
secondId.startsWith("__layout_dummy:") || secondBreakingPoint || secondExternal;
|
|
15
23
|
const spacingName =
|
|
16
|
-
|
|
17
|
-
? "spacing.
|
|
18
|
-
:
|
|
19
|
-
? "spacing.
|
|
20
|
-
:
|
|
24
|
+
firstExternal && secondExternal
|
|
25
|
+
? "spacing.portPort"
|
|
26
|
+
: (firstExternal && secondLabel) || (secondExternal && firstLabel)
|
|
27
|
+
? "spacing.labelPortVertical"
|
|
28
|
+
: firstLabel && secondLabel
|
|
29
|
+
? "spacing.edgeEdge"
|
|
30
|
+
: (firstLabel && !secondDummy) || (secondLabel && !firstDummy)
|
|
31
|
+
? "spacing.node"
|
|
32
|
+
: (firstLabel && secondDummy) || (secondLabel && firstDummy)
|
|
33
|
+
? "spacing.edgeNode"
|
|
34
|
+
: firstDummy && secondDummy && firstBreakingPoint === secondBreakingPoint
|
|
35
|
+
? "spacing.edgeEdge"
|
|
36
|
+
: firstDummy || secondDummy
|
|
37
|
+
? "spacing.edgeNode"
|
|
38
|
+
: "spacing.node";
|
|
21
39
|
let spacing =
|
|
22
|
-
spacingName === "spacing.
|
|
23
|
-
? Number(input.settings["spacing.
|
|
24
|
-
: spacingName === "spacing.
|
|
25
|
-
? Number(input.settings["spacing.
|
|
26
|
-
:
|
|
40
|
+
spacingName === "spacing.portPort"
|
|
41
|
+
? Number(input.settings["spacing.portPort"] ?? 10)
|
|
42
|
+
: spacingName === "spacing.labelPortVertical"
|
|
43
|
+
? Number(input.settings["spacing.labelPortVertical"] ?? 1)
|
|
44
|
+
: spacingName === "spacing.edgeEdge"
|
|
45
|
+
? Number(input.settings["spacing.edgeEdge"] ?? 10)
|
|
46
|
+
: spacingName === "spacing.edgeNode"
|
|
47
|
+
? Number(input.settings["spacing.edgeNode"] ?? 10)
|
|
48
|
+
: input.spacing.node;
|
|
27
49
|
for (const id of [firstId, secondId]) {
|
|
28
50
|
const node = input.graph.nodes.find((candidate) => candidate.id === id);
|
|
29
51
|
if (!node) continue;
|