@statelyai/layout 0.2.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.
Files changed (132) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +221 -13
  3. package/dist/elkjs/index.cjs +3 -2
  4. package/dist/elkjs/index.d.cts +13 -2
  5. package/dist/elkjs/index.d.mts +13 -2
  6. package/dist/elkjs/index.mjs +3 -3
  7. package/dist/elkjs/main.cjs +2 -2
  8. package/dist/elkjs/main.d.cts +2 -2
  9. package/dist/elkjs/main.d.mts +2 -2
  10. package/dist/elkjs/main.mjs +2 -2
  11. package/dist/elkjs/worker-api.cjs +1 -1
  12. package/dist/elkjs/worker-api.d.cts +2 -2
  13. package/dist/elkjs/worker-api.d.mts +2 -2
  14. package/dist/elkjs/worker-api.mjs +1 -1
  15. package/dist/elkjs/worker.cjs +2 -2
  16. package/dist/elkjs/worker.mjs +2 -2
  17. package/dist/elkjs-UciYx7lD.mjs +2973 -0
  18. package/dist/elkjs-ggjsBuVP.cjs +3002 -0
  19. package/dist/index-B6cWNrMw.d.cts +80 -0
  20. package/dist/index-CJQLUKD9.d.mts +80 -0
  21. package/dist/{index-BSH2bloA.d.mts → index-DT6lPMfS.d.cts} +117 -5
  22. package/dist/{index-CG712jzV.d.cts → index-F1U-fCTu.d.mts} +117 -5
  23. package/dist/index.cjs +39 -9
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.mts +4 -3
  26. package/dist/index.mjs +4 -3
  27. package/dist/layered/index.cjs +3 -1
  28. package/dist/layered/index.d.cts +3 -2
  29. package/dist/layered/index.d.mts +3 -2
  30. package/dist/layered/index.mjs +2 -2
  31. package/dist/{layered-Bz2nWmVp.mjs → layered-C7ERzZjR.mjs} +12592 -4682
  32. package/dist/{layered-Cf9Rn8l7.cjs → layered-CBPg6_6_.cjs} +12711 -4717
  33. package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  34. package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
  35. package/dist/routing/index.cjs +29 -0
  36. package/dist/routing/index.d.cts +3 -0
  37. package/dist/routing/index.d.mts +3 -0
  38. package/dist/routing/index.mjs +3 -0
  39. package/dist/routing-CR8flv8b.mjs +2780 -0
  40. package/dist/routing-QIdGGFBe.cjs +2996 -0
  41. package/dist/types-CT2akdK0.d.mts +133 -0
  42. package/dist/types-uxPlGRof.d.cts +133 -0
  43. package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
  44. package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
  45. package/package.json +14 -3
  46. package/src/authoring/coordinates.ts +38 -10
  47. package/src/authoring/partial.ts +7 -2
  48. package/src/authoring/routing.ts +4 -1
  49. package/src/constraints.ts +174 -6
  50. package/src/elkjs/index.ts +1397 -452
  51. package/src/elkjs/layout-quality.ts +219 -0
  52. package/src/elkjs/opposing-tracks.ts +161 -0
  53. package/src/elkjs/orthogonal-junctions.ts +174 -0
  54. package/src/elkjs/routes.ts +147 -0
  55. package/src/elkjs/statechart.ts +9 -3
  56. package/src/elkjs/types.ts +2 -0
  57. package/src/index.ts +8 -0
  58. package/src/internal/elkjs-compatibility.ts +4 -1
  59. package/src/internal/layered-trace.ts +48 -0
  60. package/src/internal/layout-engine.ts +2 -1
  61. package/src/java-random.ts +6 -0
  62. package/src/layered/bk-node-placement.ts +403 -92
  63. package/src/layered/center-labels.ts +398 -0
  64. package/src/layered/compaction-bounds.ts +26 -0
  65. package/src/layered/compaction-errors.ts +2 -0
  66. package/src/layered/compaction-scanline.ts +63 -0
  67. package/src/layered/compound-boundaries.ts +99 -0
  68. package/src/layered/compound.ts +445 -0
  69. package/src/layered/constraint-orientation.ts +141 -0
  70. package/src/layered/crossing-constraints.ts +243 -0
  71. package/src/layered/crossing-counter.ts +221 -0
  72. package/src/layered/crossing-graph.ts +401 -0
  73. package/src/layered/cycle-random.ts +22 -0
  74. package/src/layered/elk-port-lists.ts +243 -0
  75. package/src/layered/end-labels.ts +124 -0
  76. package/src/layered/external-port-dummy.ts +137 -0
  77. package/src/layered/feedback-chains.ts +114 -0
  78. package/src/layered/fixed-self-loop.ts +150 -0
  79. package/src/layered/greedy-switch-decider.ts +356 -0
  80. package/src/layered/grouped-compaction.ts +787 -0
  81. package/src/layered/gwt-sort.ts +53 -0
  82. package/src/layered/hierarchical-port-phases.ts +242 -0
  83. package/src/layered/hierarchical-port-restoration.ts +233 -0
  84. package/src/layered/hierarchical-port-routing.ts +179 -0
  85. package/src/layered/hierarchy-crossing.ts +121 -0
  86. package/src/layered/hierarchy-sweepiness.ts +83 -0
  87. package/src/layered/hints.ts +371 -0
  88. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  89. package/src/layered/index.ts +911 -261
  90. package/src/layered/initial-model-order.ts +404 -0
  91. package/src/layered/inverted-ports.ts +207 -0
  92. package/src/layered/label-dummy-switcher.ts +200 -0
  93. package/src/layered/long-edges.ts +135 -44
  94. package/src/layered/loop-envelopes.ts +184 -0
  95. package/src/layered/native-routing.ts +275 -0
  96. package/src/layered/network-simplex.ts +11 -15
  97. package/src/layered/node-margins.ts +59 -0
  98. package/src/layered/north-south-ports.ts +271 -0
  99. package/src/layered/opposing-tracks.ts +944 -0
  100. package/src/layered/orthogonal-cycle-order.ts +106 -0
  101. package/src/layered/orthogonal-hypersegments.ts +92 -0
  102. package/src/layered/orthogonal-junctions.ts +202 -0
  103. package/src/layered/orthogonal-segments.ts +243 -0
  104. package/src/layered/placement-orientation.ts +22 -0
  105. package/src/layered/port-distributor.ts +192 -0
  106. package/src/layered/port-edge-order.ts +20 -0
  107. package/src/layered/replace-routing.ts +45 -0
  108. package/src/layered/routing-coordinates.ts +32 -0
  109. package/src/layered/separate-exterior-labels.ts +24 -6
  110. package/src/layered/spacing.ts +34 -12
  111. package/src/layered/splitter-order.ts +106 -0
  112. package/src/layered/strategies.ts +2024 -883
  113. package/src/layered/types.ts +34 -0
  114. package/src/layered/weighted-compaction.ts +84 -0
  115. package/src/routing/adapters.ts +143 -0
  116. package/src/routing/algorithms.ts +1088 -0
  117. package/src/routing/coordination.ts +91 -0
  118. package/src/routing/engine.ts +473 -0
  119. package/src/routing/index.ts +69 -0
  120. package/src/routing/layout-cache.ts +65 -0
  121. package/src/routing/model.ts +218 -0
  122. package/src/routing/path.ts +324 -0
  123. package/src/routing/persistent.ts +117 -0
  124. package/src/routing/search.ts +358 -0
  125. package/src/routing/spatial.ts +78 -0
  126. package/src/routing/types.ts +146 -0
  127. package/dist/elkjs-BVnDwnBQ.cjs +0 -1551
  128. package/dist/elkjs-CFlfkEBz.mjs +0 -1528
  129. /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
  130. /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
  131. /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
  132. /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
@@ -0,0 +1,944 @@
1
+ import { worldGeometry } from "../authoring/coordinates";
2
+ import { recordRouteGeometry } from "../routing/layout-cache";
3
+ import type { CompoundVisualGraph } from "./compound";
4
+ import type { LayeredLayoutOptions } from "./types";
5
+
6
+ /**
7
+ * Two different edges on one collinear track, heading opposite ways, read as
8
+ * one path. Move one of the two segments to a free parallel track, keeping its
9
+ * neighbours orthogonal and never adding node hits, label hits or retraces.
10
+ */
11
+ export interface TrackPoint {
12
+ x: number;
13
+ y: number;
14
+ }
15
+ export interface TrackRect extends TrackPoint {
16
+ width: number;
17
+ height: number;
18
+ }
19
+ export interface TrackRoute {
20
+ id: string;
21
+ /** Endpoint node ids; shared endpoint nodes get the quality metric's terminal clearance. */
22
+ ends: readonly string[];
23
+ /** World-coordinate polyline. */
24
+ points: readonly TrackPoint[];
25
+ labels: readonly TrackRect[];
26
+ /** Port ids at the source and target end, when the route attaches to a port. */
27
+ ports?: readonly [string | undefined, string | undefined];
28
+ /** False when the route may be measured but not changed. */
29
+ movable: boolean;
30
+ }
31
+ export interface TrackPort {
32
+ /** Owning node id. */
33
+ node: string;
34
+ /** World rect. */
35
+ rect: TrackRect;
36
+ }
37
+ export interface TrackScene {
38
+ routes: readonly TrackRoute[];
39
+ /** World rect of every node, by id. */
40
+ nodes: ReadonlyMap<string, TrackRect>;
41
+ /** Ids of nodes without children: routes never pass through them. */
42
+ leaves: ReadonlySet<string>;
43
+ /** Parallel track distance (elk.spacing.edgeEdge). */
44
+ spacing: number;
45
+ /** Ports by id, for routes that name them. */
46
+ ports?: ReadonlyMap<string, TrackPort>;
47
+ }
48
+
49
+ const EPS = 1e-6;
50
+ /** The quality metric's clearance around endpoints two edges share. */
51
+ const TERMINAL_MARGIN = 12;
52
+ const MINSTUB = 0.5;
53
+ type Segment = { a: TrackPoint; b: TrackPoint };
54
+ const horizontal = (s: Segment) => Math.abs(s.a.y - s.b.y) < EPS;
55
+ const vertical = (s: Segment) => Math.abs(s.a.x - s.b.x) < EPS;
56
+ const size = (s: Segment) => Math.hypot(s.b.x - s.a.x, s.b.y - s.a.y);
57
+ const inflate = (r: TrackRect, m: number): TrackRect => ({
58
+ x: r.x - m,
59
+ y: r.y - m,
60
+ width: r.width + 2 * m,
61
+ height: r.height + 2 * m,
62
+ });
63
+
64
+ /** The parts of `s` outside `r`; `closed` also clips segments running along its border. */
65
+ function outside(s: Segment, r: TrackRect, closed: boolean): Segment[] {
66
+ let enter = 0,
67
+ exit = 1;
68
+ for (const axis of ["x", "y"] as const) {
69
+ const delta = s.b[axis] - s.a[axis];
70
+ const lo = r[axis],
71
+ hi = lo + (axis === "x" ? r.width : r.height);
72
+ if (Math.abs(delta) < EPS) {
73
+ if (
74
+ closed
75
+ ? s.a[axis] < lo - EPS || s.a[axis] > hi + EPS
76
+ : s.a[axis] <= lo + EPS || s.a[axis] >= hi - EPS
77
+ )
78
+ return [s];
79
+ continue;
80
+ }
81
+ const a = (lo - s.a[axis]) / delta,
82
+ b = (hi - s.a[axis]) / delta;
83
+ enter = Math.max(enter, Math.min(a, b));
84
+ exit = Math.min(exit, Math.max(a, b));
85
+ }
86
+ const length = size(s);
87
+ if ((exit - enter) * length <= EPS) return [s];
88
+ const at = (t: number) => ({ x: s.a.x + t * (s.b.x - s.a.x), y: s.a.y + t * (s.b.y - s.a.y) });
89
+ const parts: Segment[] = [];
90
+ if (enter * length > EPS) parts.push({ a: s.a, b: at(enter) });
91
+ if ((1 - exit) * length > EPS) parts.push({ a: at(exit), b: s.b });
92
+ return parts;
93
+ }
94
+ const clip = (segments: Segment[], rects: readonly TrackRect[], closed = false) =>
95
+ rects.reduce((list, r) => list.flatMap((s) => outside(s, r, closed)), segments);
96
+ const hits = (s: Segment, r: TrackRect) =>
97
+ clip([s], [r]).reduce((sum, part) => sum + size(part), 0) < size(s) - EPS;
98
+
99
+ /** Collinear shared length of two axis-parallel segments. */
100
+ function shared(s: Segment, t: Segment): number {
101
+ const h = horizontal(s) && horizontal(t),
102
+ v = vertical(s) && vertical(t);
103
+ if (!h && !v) return 0;
104
+ if (Math.abs(h ? s.a.y - t.a.y : s.a.x - t.a.x) > EPS) return 0;
105
+ const axis = h ? "x" : "y";
106
+ const lo = Math.max(Math.min(s.a[axis], s.b[axis]), Math.min(t.a[axis], t.b[axis]));
107
+ const hi = Math.min(Math.max(s.a[axis], s.b[axis]), Math.max(t.a[axis], t.b[axis]));
108
+ return Math.max(0, hi - lo);
109
+ }
110
+ const opposite = (s: Segment, t: Segment) =>
111
+ (s.b.x - s.a.x) * (t.b.x - t.a.x) + (s.b.y - s.a.y) * (t.b.y - t.a.y) < 0;
112
+ function crossing(s: Segment, t: Segment): boolean {
113
+ const h = horizontal(s) && vertical(t) ? s : horizontal(t) && vertical(s) ? t : undefined;
114
+ if (!h) return false;
115
+ const v = h === s ? t : s;
116
+ return (
117
+ v.a.x > Math.min(h.a.x, h.b.x) + EPS &&
118
+ v.a.x < Math.max(h.a.x, h.b.x) - EPS &&
119
+ h.a.y > Math.min(v.a.y, v.b.y) + EPS &&
120
+ h.a.y < Math.max(v.a.y, v.b.y) - EPS
121
+ );
122
+ }
123
+ const segmentsOf = (points: readonly TrackPoint[]): Segment[] =>
124
+ points.slice(1).flatMap((b, i) => {
125
+ const s = { a: points[i]!, b };
126
+ return size(s) > EPS ? [s] : [];
127
+ });
128
+
129
+ interface State {
130
+ route: TrackRoute;
131
+ points: TrackPoint[];
132
+ /** Segments outside the route's own labels, as the metric measures them. */
133
+ measured: Segment[];
134
+ }
135
+
136
+ /** Returns the routes whose points changed. */
137
+ export function separateOpposingTracks(scene: TrackScene): Map<string, TrackPoint[]> {
138
+ const { nodes, leaves, spacing } = scene;
139
+ const leafRects = [...leaves].flatMap((id) => (nodes.has(id) ? [nodes.get(id)!] : []));
140
+ const compounds = [...nodes].flatMap(([id, r]) => (leaves.has(id) ? [] : [r]));
141
+ const allLabels = scene.routes.flatMap((route) => route.labels.map((rect) => ({ route, rect })));
142
+ const states: State[] = scene.routes.map((route) => ({
143
+ route,
144
+ // Repeated points carry no segment; dropping them lets every segment move.
145
+ points: route.points
146
+ .filter((p, i) => i === 0 || size({ a: route.points[i - 1]!, b: p }) > EPS)
147
+ .map((p) => ({ ...p })),
148
+ measured: clip(segmentsOf(route.points), route.labels),
149
+ }));
150
+ const terminals = (a: TrackRoute, b: TrackRoute) =>
151
+ [...new Set(a.ends)]
152
+ .filter((id) => b.ends.includes(id) && nodes.has(id))
153
+ .map((id) => inflate(nodes.get(id)!, TERMINAL_MARGIN));
154
+ const terminalCache = new Map<string, TrackRect[]>();
155
+ const terminalsOf = (a: State, b: State) => {
156
+ const key =
157
+ a.route.id < b.route.id ? `${a.route.id}\0${b.route.id}` : `${b.route.id}\0${a.route.id}`;
158
+ let rects = terminalCache.get(key);
159
+ if (!rects) terminalCache.set(key, (rects = terminals(a.route, b.route)));
160
+ return rects;
161
+ };
162
+ // The metric's pair measures: opposite-direction and same-direction shared length, crossings.
163
+ const pair = (mine: Segment[], self: State, other: State) => {
164
+ const rects = terminalsOf(self, other);
165
+ const as = rects.length ? clip(mine, rects, true) : mine,
166
+ bs = rects.length ? clip(other.measured, rects, true) : other.measured;
167
+ let opposing = 0,
168
+ overlap = 0,
169
+ crossings = 0;
170
+ for (const s of as)
171
+ for (const t of bs) {
172
+ const length = shared(s, t);
173
+ if (length > EPS) {
174
+ overlap += length;
175
+ if (opposite(s, t)) opposing += length;
176
+ } else if (crossing(s, t)) crossings++;
177
+ }
178
+ return { opposing, overlap, crossings };
179
+ };
180
+ const measure = (state: State, mine: Segment[]) => {
181
+ let opposing = 0,
182
+ overlap = 0,
183
+ crossings = 0;
184
+ for (const other of states) {
185
+ if (other === state) continue;
186
+ const result = pair(mine, state, other);
187
+ opposing += result.opposing;
188
+ overlap += result.overlap;
189
+ crossings += result.crossings;
190
+ }
191
+ return { opposing, overlap, crossings };
192
+ };
193
+ const loop = (route: TrackRoute) =>
194
+ route.ends.length === 2 && route.ends[0] === route.ends[1] && nodes.has(route.ends[0]!)
195
+ ? inflate(nodes.get(route.ends[0]!)!, TERMINAL_MARGIN)
196
+ : undefined;
197
+ const retrace = (route: TrackRoute, segments: Segment[]) => {
198
+ const rect = loop(route);
199
+ const own = rect ? clip(segments, [rect], true) : segments;
200
+ let sum = 0;
201
+ for (let i = 0; i < own.length; i++)
202
+ for (let j = i + 1; j < own.length; j++) sum += shared(own[i]!, own[j]!);
203
+ return sum;
204
+ };
205
+ // Tracks of other routes closer than the spacing along the moved segment.
206
+ const crowding = (state: State, s: Segment) => {
207
+ let count = 0;
208
+ const h = horizontal(s),
209
+ fixed = h ? s.a.y : s.a.x,
210
+ axis = h ? "x" : "y";
211
+ const lo = Math.min(s.a[axis], s.b[axis]),
212
+ hi = Math.max(s.a[axis], s.b[axis]);
213
+ for (const other of states) {
214
+ if (other === state) continue;
215
+ for (const t of other.measured) {
216
+ if (h ? !horizontal(t) : !vertical(t)) continue;
217
+ const distance = Math.abs((h ? t.a.y : t.a.x) - fixed);
218
+ if (distance >= spacing - EPS) continue;
219
+ const overlap =
220
+ Math.min(hi, Math.max(t.a[axis], t.b[axis])) -
221
+ Math.max(lo, Math.min(t.a[axis], t.b[axis]));
222
+ if (overlap > EPS) count++;
223
+ }
224
+ }
225
+ return count;
226
+ };
227
+ // Border crossings of axis-parallel segments with container rects.
228
+ const borders = (list: Segment[]) =>
229
+ compounds.reduce((sum, r) => {
230
+ for (const s of list) {
231
+ const h = horizontal(s),
232
+ fixed = h ? s.a.y : s.a.x,
233
+ axis = h ? "x" : "y";
234
+ const lo = Math.min(s.a[axis], s.b[axis]),
235
+ hi = Math.max(s.a[axis], s.b[axis]);
236
+ const across = h ? [r.y, r.y + r.height] : [r.x, r.x + r.width],
237
+ along = h ? [r.x, r.x + r.width] : [r.y, r.y + r.height];
238
+ if (fixed <= across[0]! + EPS || fixed >= across[1]! - EPS) continue;
239
+ for (const edge of along) if (edge > lo + EPS && edge < hi - EPS) sum++;
240
+ }
241
+ return sum;
242
+ }, 0);
243
+ const better = (a: number[], b: number[]) => {
244
+ const i = a.findIndex((value, index) => Math.abs(value - b[index]!) > EPS);
245
+ return i >= 0 && a[i]! < b[i]!;
246
+ };
247
+ const changed = new Set<State>();
248
+ splitSharedPorts(false);
249
+ // Opposite-direction sharing is a hard defect: moves may add crossings,
250
+ // but only where no crossing-neutral move remains, and then the fewest.
251
+ splitLanes(false);
252
+ separateRoutes(false);
253
+ reshapeLoops(false);
254
+ separateCheapestFirst();
255
+ // A shared port whose split must cross something takes the cheapest split.
256
+ splitSharedPorts(true);
257
+ reshapeLoops(true);
258
+ return new Map([...changed].map((state) => [state.route.id, state.points]));
259
+
260
+ /**
261
+ * A self-loop on a port that other edges also use leaves along their line.
262
+ * Redraw it as a plain rectangle on the same side, its two attachments
263
+ * slid along the side and the loop turning either way, keeping its depth.
264
+ */
265
+ function reshapeLoops(tolerant: boolean) {
266
+ for (const state of states) {
267
+ const { route } = state;
268
+ const node = route.ends[0] === route.ends[1] ? nodes.get(route.ends[0]!) : undefined;
269
+ if (!node || !route.movable || route.labels.length || state.points.length < 4) continue;
270
+ const current = measure(state, state.measured);
271
+ if (current.opposing <= EPS) continue;
272
+ const [p, q] = state.points as [TrackPoint, TrackPoint];
273
+ const first = { a: p, b: q };
274
+ if (size(first) <= EPS || (!horizontal(first) && !vertical(first))) continue;
275
+ const normal = { x: Math.sign(q.x - p.x), y: Math.sign(q.y - p.y) };
276
+ const lateral = normal.x !== 0 ? "y" : "x",
277
+ across = normal.x !== 0 ? "x" : "y";
278
+ const lo = node[lateral],
279
+ hi = lo + (lateral === "x" ? node.width : node.height);
280
+ // The loop leaves from its port's face, at most a spacing off the node border.
281
+ const side =
282
+ normal[across] > 0
283
+ ? node[across] + (across === "x" ? node.width : node.height)
284
+ : node[across];
285
+ if ((p[across] - side) * normal[across] < -EPS || Math.abs(p[across] - side) > spacing)
286
+ continue;
287
+ const border = p[across];
288
+ const depth = Math.max(
289
+ spacing / 2,
290
+ ...state.points.map((point) => Math.abs(point[across] - border)),
291
+ );
292
+ const width = Math.max(
293
+ spacing / 2,
294
+ ...state.points.map((point) => Math.abs(point[lateral] - p[lateral])),
295
+ );
296
+ let best: { next: Map<State, TrackPoint[]>; key: number[] } | undefined;
297
+ for (const slide of [0, 1, -1, 2, -2].map((k) => (k * spacing) / 2))
298
+ for (const turn of [1, -1]) {
299
+ const start = p[lateral] + slide,
300
+ end = start + turn * width;
301
+ if (Math.min(start, end) < lo - EPS || Math.max(start, end) > hi + EPS) continue;
302
+ const at = (lateralValue: number, out: number) =>
303
+ ({
304
+ [lateral]: lateralValue,
305
+ [across]: border + normal[across] * out,
306
+ }) as unknown as TrackPoint;
307
+ const points = [at(start, 0), at(start, depth), at(end, depth), at(end, 0)];
308
+ const next = new Map([[state, points]]);
309
+ const result = evaluate(next);
310
+ if (!result || result.opposing >= -current.opposing + EPS) continue;
311
+ if (!tolerant && result.crossings > 0) continue;
312
+ const key = [
313
+ Math.max(0, result.crossings),
314
+ result.opposing,
315
+ result.overlap,
316
+ Math.abs(slide),
317
+ ];
318
+ if (!best || better(key, best.key)) best = { next, key };
319
+ }
320
+ if (best) commit(best.next);
321
+ }
322
+ }
323
+
324
+ /** Move one segment of a route at a time to a free parallel track. */
325
+ function separateRoutes(tolerant: boolean) {
326
+ for (const state of states) {
327
+ if (!state.route.movable) continue;
328
+ for (let moves = 0; moves < 8; moves++) {
329
+ const best = routeMove(state, tolerant);
330
+ if (!best) break;
331
+ state.points = best.points;
332
+ state.measured = best.measured;
333
+ changed.add(state);
334
+ }
335
+ }
336
+ }
337
+
338
+ /** The best single-segment move for a route, keyed by added crossings first. */
339
+ function routeMove(state: State, tolerant: boolean) {
340
+ {
341
+ {
342
+ const current = measure(state, state.measured);
343
+ if (current.opposing <= EPS) return undefined;
344
+ const raw = segmentsOf(state.points);
345
+ if (raw.length !== state.points.length - 1) return undefined;
346
+ // Segments on or beside an opposing stretch are candidates.
347
+ const involved = new Set<number>();
348
+ for (const [index, s] of raw.entries())
349
+ if (
350
+ states.some(
351
+ (other) =>
352
+ other !== state && other.measured.some((t) => opposite(s, t) && shared(s, t) > EPS),
353
+ )
354
+ )
355
+ for (const k of [index - 1, index, index + 1]) involved.add(k);
356
+ const baseRetrace = retrace(state.route, state.measured);
357
+ let best: { points: TrackPoint[]; measured: Segment[]; key: number[] } | undefined;
358
+ const consider = (
359
+ points: TrackPoint[],
360
+ old: Segment[],
361
+ next: Segment[],
362
+ track: Segment,
363
+ bends: number,
364
+ delta: number,
365
+ ) => {
366
+ if (next.some((s) => leafRects.some((r) => hits(s, r)))) return;
367
+ if (
368
+ allLabels.some(
369
+ ({ route, rect }) =>
370
+ next.some((s) => hits(s, rect)) &&
371
+ !(route === state.route && old.some((s) => hits(s, rect))),
372
+ )
373
+ )
374
+ return;
375
+ // Never carry a route across a container border it did not cross.
376
+ if (borders(next) !== borders(old)) return;
377
+ // The new track must be free: no collinear sharing with another route.
378
+ if (
379
+ states.some(
380
+ (other) => other !== state && other.measured.some((t) => shared(track, t) > EPS),
381
+ )
382
+ )
383
+ return;
384
+ const measured = clip(segmentsOf(points), state.route.labels);
385
+ if (retrace(state.route, measured) > baseRetrace + EPS) return;
386
+ const result = measure(state, measured);
387
+ if (result.opposing >= current.opposing - EPS) return;
388
+ if (!tolerant && result.crossings > current.crossings) return;
389
+ const key = [
390
+ Math.max(0, result.crossings - current.crossings),
391
+ result.opposing,
392
+ bends,
393
+ crowding(state, track),
394
+ result.crossings,
395
+ result.overlap,
396
+ Math.abs(delta),
397
+ ];
398
+ if (!best || better(key, best.key)) best = { points, measured, key };
399
+ };
400
+ // A neighbour keeps its direction and a usable length.
401
+ const keeps = (old: Segment, next: Segment) =>
402
+ (old.b.x - old.a.x) * (next.b.x - next.a.x) +
403
+ (old.b.y - old.a.y) * (next.b.y - next.a.y) >
404
+ 0 && size(next) >= Math.min(size(old), MINSTUB * spacing) - EPS;
405
+ const offsets = Array.from(
406
+ { length: 16 },
407
+ (_, i) => ((i % 2 ? 1 : -1) * (1 + (i >> 1)) * spacing) / 2,
408
+ );
409
+ for (const k of [...involved].sort((a, b) => a - b)) {
410
+ if (k < 0 || k >= raw.length) continue;
411
+ const segment = raw[k]!;
412
+ const h = horizontal(segment);
413
+ if (!h && !vertical(segment)) continue;
414
+ // A segment carrying its own label stays with it.
415
+ if (state.route.labels.some((label) => hits(segment, inflate(label, spacing)))) continue;
416
+ const shift = (p: TrackPoint, delta: number) =>
417
+ h ? { x: p.x, y: p.y + delta } : { x: p.x + delta, y: p.y };
418
+ if (k >= 1 && k <= raw.length - 2) {
419
+ // Shift an interior segment; its perpendicular neighbours stretch.
420
+ const before = raw[k - 1]!,
421
+ after = raw[k + 1]!;
422
+ if (
423
+ h ? !vertical(before) || !vertical(after) : !horizontal(before) || !horizontal(after)
424
+ )
425
+ continue;
426
+ for (const delta of offsets) {
427
+ const points = state.points.map((p, i) =>
428
+ i === k || i === k + 1 ? shift(p, delta) : p,
429
+ );
430
+ const next = [k - 1, k, k + 1].map((i) => ({ a: points[i]!, b: points[i + 1]! }));
431
+ if (!keeps(before, next[0]!) || !keeps(after, next[2]!)) continue;
432
+ consider(points, [before, segment, after], next, next[1]!, 0, delta);
433
+ }
434
+ continue;
435
+ }
436
+ if (raw.length < 2) continue;
437
+ // A terminal segment is pinned to its port: jog off the shared line
438
+ // just past the shared terminal clearance and run parallel instead.
439
+ const reversed = k === raw.length - 1;
440
+ const path = reversed ? [...state.points].reverse() : state.points;
441
+ const [port, corner, beyond] = path as [TrackPoint, TrackPoint, TrackPoint];
442
+ const along = size({ a: port, b: corner });
443
+ const unit = { x: (corner.x - port.x) / along, y: (corner.y - port.y) / along };
444
+ const end = state.route.ends[reversed ? 1 : 0];
445
+ const rect = end === undefined ? undefined : nodes.get(end);
446
+ const clearance = rect
447
+ ? clip([{ a: port, b: corner }], [inflate(rect, TERMINAL_MARGIN)], true).reduce(
448
+ (sum, part) => sum + size(part),
449
+ 0,
450
+ )
451
+ : along;
452
+ for (const stub of new Set([along - clearance, spacing]))
453
+ for (const delta of offsets) {
454
+ if (stub < 1 || along - stub < spacing) continue;
455
+ const q = { x: port.x + unit.x * stub, y: port.y + unit.y * stub };
456
+ const jog = shift(q, delta),
457
+ rejoin = shift(corner, delta);
458
+ const next = [
459
+ { a: port, b: q },
460
+ { a: q, b: jog },
461
+ { a: jog, b: rejoin },
462
+ { a: rejoin, b: beyond },
463
+ ];
464
+ const old = [
465
+ { a: port, b: corner },
466
+ { a: corner, b: beyond },
467
+ ];
468
+ // The rejoined neighbour keeps its direction; a stretched one may grow.
469
+ if (
470
+ (beyond.x - corner.x) * (beyond.x - rejoin.x) +
471
+ (beyond.y - corner.y) * (beyond.y - rejoin.y) <=
472
+ 0 ||
473
+ size(next[3]!) < Math.min(size(old[1]!), spacing) - EPS
474
+ )
475
+ continue;
476
+ const replaced = [port, q, jog, rejoin, ...path.slice(2)];
477
+ const points = reversed ? replaced.reverse() : replaced;
478
+ consider(points, old, next, next[2]!, 2, delta);
479
+ }
480
+ }
481
+ return best && { ...best, gain: best.key[1]! - current.opposing };
482
+ }
483
+ }
484
+ }
485
+
486
+ /**
487
+ * A port carrying edges in both directions gives each direction its own
488
+ * attachment point along the port's side, so the two stubs run parallel
489
+ * instead of on one track. A wide port splits across its extent; a port
490
+ * narrower than the spacing moves the attachments onto the node border,
491
+ * half a spacing either side of the port's centre.
492
+ */
493
+ function splitSharedPorts(tolerant: boolean) {
494
+ const ports = scene.ports;
495
+ if (!ports?.size) return;
496
+ type End = { state: State; end: 0 | 1 };
497
+ const groups = new Map<string, { port: TrackPort; normal: TrackPoint; ends: End[] }>();
498
+ for (const state of states)
499
+ for (const end of [0, 1] as const) {
500
+ const id = state.route.ports?.[end];
501
+ const port = id === undefined ? undefined : ports.get(id);
502
+ if (!port || state.points.length < 2) continue;
503
+ const path = end ? [...state.points].reverse() : state.points;
504
+ const [p, q] = path as [TrackPoint, TrackPoint];
505
+ const s = { a: p, b: q };
506
+ if (size(s) <= EPS || (!horizontal(s) && !vertical(s))) continue;
507
+ const normal = { x: Math.sign(q.x - p.x), y: Math.sign(q.y - p.y) };
508
+ const key = `${id}\0${normal.x}\0${normal.y}`;
509
+ let group = groups.get(key);
510
+ if (!group) groups.set(key, (group = { port, normal, ends: [] }));
511
+ group.ends.push({ state, end });
512
+ }
513
+ for (const { port, normal, ends } of groups.values()) {
514
+ const incoming = ends.filter((e) => e.end === 1),
515
+ outgoing = ends.filter((e) => e.end === 0);
516
+ if (!incoming.length || !outgoing.length) continue;
517
+ const stub = ({ state, end }: End) => {
518
+ const path = end ? [...state.points].reverse() : state.points;
519
+ return { a: path[0]!, b: path[1]! };
520
+ };
521
+ if (!incoming.some((i) => outgoing.some((o) => shared(stub(i), stub(o)) > EPS))) continue;
522
+ if (ends.some(({ state }) => !state.route.movable)) continue;
523
+ const node = nodes.get(port.node);
524
+ if (!node) continue;
525
+ // The lateral axis runs along the port's side.
526
+ const lateral = normal.x !== 0 ? "y" : "x",
527
+ across = normal.x !== 0 ? "x" : "y";
528
+ const extent = lateral === "x" ? port.rect.width : port.rect.height;
529
+ const sideLo = node[lateral],
530
+ sideHi = node[lateral] + (lateral === "x" ? node.width : node.height);
531
+ const wide = extent >= spacing - EPS;
532
+ const half = spacing / 2;
533
+ // Move one end of a route sideways by `offset`, keeping it orthogonal.
534
+ const shifted = (points: TrackPoint[], end: 0 | 1, offset: number) => {
535
+ if (Math.abs(offset) <= EPS) return points;
536
+ const path = end ? [...points].reverse() : [...points];
537
+ if (path.length < 3) return undefined;
538
+ const [p, q, r] = path as [TrackPoint, TrackPoint, TrackPoint];
539
+ const along = r[lateral] - q[lateral];
540
+ if (Math.abs(r[across] - q[across]) > EPS || Math.abs(along) <= EPS) return undefined;
541
+ const moved = q[lateral] + offset;
542
+ if ((r[lateral] - moved) * along <= EPS) return undefined;
543
+ if (moved < sideLo - EPS || moved > sideHi + EPS) return undefined;
544
+ const attach = { ...p, [lateral]: moved };
545
+ if (!wide) {
546
+ // Onto the node border nearest the old attachment.
547
+ const lo = node[across],
548
+ hi = lo + (across === "x" ? node.width : node.height);
549
+ attach[across] = Math.abs(p[across] - lo) < Math.abs(p[across] - hi) ? lo : hi;
550
+ }
551
+ path[0] = attach;
552
+ path[1] = { ...q, [lateral]: moved };
553
+ return end ? path.reverse() : path;
554
+ };
555
+ let best: { points: Map<State, TrackPoint[]>; key: number[] } | undefined;
556
+ // Lateral order without crossings: ends turning towards lower
557
+ // coordinates first, earliest turn outermost; straight ends in the
558
+ // middle; then ends turning higher, earliest turn outermost. Adjacent
559
+ // ends of one direction share a slot.
560
+ const turnOf = ({ state, end }: End) => {
561
+ const path = end ? [...state.points].reverse() : state.points;
562
+ const [p, q, r] = path as [TrackPoint, TrackPoint, TrackPoint?];
563
+ const depth = Math.abs(q[across] - p[across]);
564
+ if (!r || Math.abs(r[across] - q[across]) > EPS) return { turn: 0, depth };
565
+ return { turn: Math.sign(r[lateral] - q[lateral]), depth };
566
+ };
567
+ const ordered = ends
568
+ .map((e) => ({ e, ...turnOf(e) }))
569
+ .sort((a, b) => a.turn - b.turn || (a.turn < 0 ? a.depth - b.depth : b.depth - a.depth));
570
+ const slotOf = new Map<End, number>();
571
+ let slots = 0;
572
+ for (const [index, item] of ordered.entries()) {
573
+ if (index && item.e.end !== ordered[index - 1]!.e.end) slots++;
574
+ slotOf.set(item.e, slots);
575
+ }
576
+ const straightSlots = new Set(
577
+ ordered.filter((item) => item.turn === 0).map((item) => slotOf.get(item.e)!),
578
+ );
579
+ const candidates: Array<(e: End) => number> = [];
580
+ if (straightSlots.size <= 1) {
581
+ const anchor = straightSlots.size ? [...straightSlots][0]! : slots / 2;
582
+ candidates.push((e) => (slotOf.get(e)! - anchor) * spacing);
583
+ }
584
+ for (const [inOffset, outOffset] of [
585
+ [half, -half],
586
+ [-half, half],
587
+ [0, spacing],
588
+ [0, -spacing],
589
+ [spacing, 0],
590
+ [-spacing, 0],
591
+ ] as const)
592
+ candidates.push((e) => (e.end ? inOffset : outOffset));
593
+ for (const offsetOf of candidates) {
594
+ const next = new Map<State, TrackPoint[]>();
595
+ let feasible = true;
596
+ for (const e of ends) {
597
+ const points = shifted(next.get(e.state) ?? e.state.points, e.end, offsetOf(e));
598
+ if (!points) {
599
+ feasible = false;
600
+ break;
601
+ }
602
+ next.set(e.state, points);
603
+ }
604
+ if (!feasible) continue;
605
+ const result = evaluate(next);
606
+ if (!result || (!tolerant && result.crossings > 0) || result.opposing > EPS) continue;
607
+ const { crossings, opposing, overlap } = result;
608
+ const shift = ends.reduce((sum, e) => sum + Math.abs(offsetOf(e)), 0);
609
+ const key = [Math.max(0, crossings), opposing, crossings, overlap, shift];
610
+ if (!best || better(key, best.key)) best = { points: next, key };
611
+ }
612
+ if (!best) continue;
613
+ commit(best.points);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * A track carrying both directions splits into lanes: every interior
619
+ * segment of one direction on it moves together to a parallel track, so
620
+ * same-direction edges keep sharing their track and only the opposite
621
+ * direction leaves it.
622
+ */
623
+ function splitLanes(tolerant: boolean) {
624
+ for (let round = 0; round < 4; round++) {
625
+ let moved = false;
626
+ for (const track of mixedTracks()) {
627
+ const best = laneMove(track, tolerant);
628
+ if (best) {
629
+ commit(best.next);
630
+ moved = true;
631
+ }
632
+ }
633
+ if (!moved) break;
634
+ }
635
+ }
636
+
637
+ /** Collinear overlapping segments carrying both directions. */
638
+ function mixedTracks() {
639
+ type Member = { state: State; index: number; lo: number; hi: number; sign: number };
640
+ const result: Member[][] = [];
641
+ {
642
+ const lines = new Map<string, Member[]>();
643
+ for (const state of states) {
644
+ if (!state.route.movable) continue;
645
+ const raw = segmentsOf(state.points);
646
+ if (raw.length !== state.points.length - 1) continue;
647
+ for (const [index, s] of raw.entries()) {
648
+ const h = horizontal(s);
649
+ if (!h && !vertical(s)) continue;
650
+ const axis = h ? "x" : "y";
651
+ const key = `${h ? "h" : "v"}:${Math.round((h ? s.a.y : s.a.x) * 1e3)}`;
652
+ if (!lines.has(key)) lines.set(key, []);
653
+ lines.get(key)!.push({
654
+ state,
655
+ index,
656
+ lo: Math.min(s.a[axis], s.b[axis]),
657
+ hi: Math.max(s.a[axis], s.b[axis]),
658
+ sign: Math.sign(s.b[axis] - s.a[axis]),
659
+ });
660
+ }
661
+ }
662
+ for (const members of lines.values()) {
663
+ members.sort((a, b) => a.lo - b.lo);
664
+ // Overlapping members form one track.
665
+ const tracks: Member[][] = [];
666
+ let end = -Infinity;
667
+ for (const member of members) {
668
+ if (member.lo < end - EPS) tracks.at(-1)!.push(member);
669
+ else tracks.push([member]);
670
+ end = Math.max(end, member.hi);
671
+ }
672
+ for (const track of tracks) {
673
+ const mixed = track.some((a) =>
674
+ track.some(
675
+ (b) =>
676
+ a.state !== b.state &&
677
+ a.sign !== b.sign &&
678
+ Math.min(a.hi, b.hi) - Math.max(a.lo, b.lo) > EPS,
679
+ ),
680
+ );
681
+ if (mixed) result.push(track);
682
+ }
683
+ }
684
+ }
685
+ return result;
686
+ }
687
+
688
+ /**
689
+ * Where crossing-neutral moves are exhausted, apply the cheapest move
690
+ * anywhere in the layout, then retry neutral moves, until no sharing is left.
691
+ */
692
+ function separateCheapestFirst() {
693
+ for (let step = 0; step < 4 * states.length; step++) {
694
+ let pick: { apply: () => void; key: number[] } | undefined;
695
+ const offer = (key: number[], apply: () => void) => {
696
+ if (!pick || better(key, pick.key)) pick = { apply, key };
697
+ };
698
+ for (const track of mixedTracks()) {
699
+ const best = laneMove(track, true);
700
+ if (best) offer([best.key[0]!, best.key[1]!], () => commit(best.next));
701
+ }
702
+ for (const state of states) {
703
+ if (!state.route.movable) continue;
704
+ const best = routeMove(state, true);
705
+ if (best)
706
+ offer([best.key[0]!, best.gain], () => {
707
+ state.points = best.points;
708
+ state.measured = best.measured;
709
+ changed.add(state);
710
+ });
711
+ }
712
+ if (!pick) return;
713
+ pick.apply();
714
+ splitLanes(false);
715
+ separateRoutes(false);
716
+ }
717
+ }
718
+
719
+ function laneMove(
720
+ track: ReadonlyArray<{ state: State; index: number; sign: number }>,
721
+ tolerant: boolean,
722
+ ) {
723
+ const offsets = Array.from(
724
+ { length: 8 },
725
+ (_, i) => ((i % 2 ? 1 : -1) * (1 + (i >> 1)) * spacing) / 2,
726
+ );
727
+ let best: { next: Map<State, TrackPoint[]>; key: number[] } | undefined;
728
+ const plans: Array<{
729
+ moves: Array<{ state: State; index: number; delta: number }>;
730
+ delta: number;
731
+ }> = [];
732
+ for (const sign of [1, -1])
733
+ for (const delta of offsets)
734
+ plans.push({
735
+ delta,
736
+ moves: track.filter((m) => m.sign === sign).map((m) => ({ ...m, delta })),
737
+ });
738
+ for (const delta of [spacing / 2, -spacing / 2])
739
+ plans.push({
740
+ delta: delta / 2,
741
+ moves: track.map((m) => ({ ...m, delta: m.sign > 0 ? delta / 2 : -delta / 2 })),
742
+ });
743
+ {
744
+ for (const plan of plans) {
745
+ const next = new Map<State, TrackPoint[]>();
746
+ let feasible = true;
747
+ for (const { state, index, delta } of plan.moves) {
748
+ const points = next.get(state) ?? state.points.map((p) => ({ ...p }));
749
+ const raw = segmentsOf(points);
750
+ if (index < 1 || index > raw.length - 2) {
751
+ feasible = false;
752
+ break;
753
+ }
754
+ const segment = raw[index]!,
755
+ before = raw[index - 1]!,
756
+ after = raw[index + 1]!;
757
+ const h = horizontal(segment);
758
+ if (
759
+ h ? !vertical(before) || !vertical(after) : !horizontal(before) || !horizontal(after)
760
+ ) {
761
+ feasible = false;
762
+ break;
763
+ }
764
+ if (state.route.labels.some((label) => hits(segment, inflate(label, spacing)))) {
765
+ feasible = false;
766
+ break;
767
+ }
768
+ const shift = (p: TrackPoint) =>
769
+ h ? { x: p.x, y: p.y + delta } : { x: p.x + delta, y: p.y };
770
+ points[index] = shift(points[index]!);
771
+ points[index + 1] = shift(points[index + 1]!);
772
+ const a = { a: points[index - 1]!, b: points[index]! },
773
+ b = { a: points[index + 1]!, b: points[index + 2]! };
774
+ const keeps = (old: Segment, now: Segment) =>
775
+ (old.b.x - old.a.x) * (now.b.x - now.a.x) + (old.b.y - old.a.y) * (now.b.y - now.a.y) >
776
+ 0 && size(now) >= Math.min(size(old), MINSTUB * spacing) - EPS;
777
+ if (!keeps(before, a) || !keeps(after, b)) {
778
+ feasible = false;
779
+ break;
780
+ }
781
+ next.set(state, points);
782
+ }
783
+ if (!feasible || !next.size) continue;
784
+ const result = evaluate(next);
785
+ if (!result || result.opposing >= -EPS) continue;
786
+ const key = [
787
+ Math.max(0, result.crossings),
788
+ result.opposing,
789
+ result.overlap,
790
+ Math.abs(plan.delta),
791
+ ];
792
+ if (!best || better(key, best.key)) best = { next, key };
793
+ }
794
+ }
795
+ return !best || (!tolerant && best.key[0]! > 0) ? undefined : best;
796
+ }
797
+
798
+ function commit(next: ReadonlyMap<State, TrackPoint[]>) {
799
+ for (const [state, points] of next) {
800
+ state.points = points;
801
+ state.measured = clip(segmentsOf(points), state.route.labels);
802
+ changed.add(state);
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Measure moving several routes at once: undefined when a move adds node or
808
+ * label hits, container-border crossings or retraces; otherwise the change
809
+ * in crossings and opposing length, and the resulting shared length.
810
+ */
811
+ function evaluate(next: ReadonlyMap<State, TrackPoint[]>) {
812
+ const touched = [...next.keys()];
813
+ const before = new Map(touched.map((state) => [state, state.points]));
814
+ const baseline = touched.map((state) => {
815
+ const result = measure(state, state.measured);
816
+ return {
817
+ state,
818
+ crossings: result.crossings,
819
+ opposing: result.opposing,
820
+ retrace: retrace(state.route, state.measured),
821
+ borders: borders(segmentsOf(state.points)),
822
+ };
823
+ });
824
+ for (const [state, points] of next) {
825
+ state.points = points;
826
+ state.measured = clip(segmentsOf(points), state.route.labels);
827
+ }
828
+ let crossings = 0,
829
+ opposing = 0,
830
+ overlap = 0,
831
+ ok = true;
832
+ for (const base of baseline) {
833
+ const { state } = base;
834
+ const old = segmentsOf(before.get(state)!),
835
+ segments = segmentsOf(state.points);
836
+ const added = segments.filter(
837
+ (s) => !old.some((o) => size(o) > EPS && shared(o, s) >= size(s) - EPS),
838
+ );
839
+ if (
840
+ added.some((s) => leafRects.some((r) => hits(s, r))) ||
841
+ allLabels.some(({ rect }) => added.some((s) => hits(s, rect))) ||
842
+ borders(segments) !== base.borders ||
843
+ retrace(state.route, state.measured) > base.retrace + EPS
844
+ ) {
845
+ ok = false;
846
+ break;
847
+ }
848
+ const result = measure(state, state.measured);
849
+ crossings += result.crossings - base.crossings;
850
+ opposing += result.opposing - base.opposing;
851
+ overlap += result.overlap;
852
+ }
853
+ for (const state of touched) {
854
+ state.points = before.get(state)!;
855
+ state.measured = clip(segmentsOf(state.points), state.route.labels);
856
+ }
857
+ return ok ? { crossings, opposing, overlap } : undefined;
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Separate opposite-direction track sharing in a finished native layout, in
863
+ * world coordinates. Only orthogonal layouts are changed.
864
+ */
865
+ export function separateLayoutOpposingTracks<N, E, G, P>(
866
+ graph: CompoundVisualGraph<N, E, G, P>,
867
+ options: LayeredLayoutOptions,
868
+ ): CompoundVisualGraph<N, E, G, P> {
869
+ if ((options.settings?.edgeRouting ?? "ORTHOGONAL") !== "ORTHOGONAL") return graph;
870
+ const world = worldGeometry(graph);
871
+ const parents = new Set(world.nodes.map((node) => node.parentId));
872
+ const nodes = new Map<string, TrackRect>(),
873
+ ports = new Map<string, TrackPort>();
874
+ for (const node of world.nodes) {
875
+ nodes.set(node.id, { x: node.x, y: node.y, width: node.width, height: node.height });
876
+ for (const port of node.ports ?? [])
877
+ ports.set(`${node.id}\0${port.name}`, {
878
+ node: node.id,
879
+ rect: { x: node.x + port.x, y: node.y + port.y, width: port.width, height: port.height },
880
+ });
881
+ }
882
+ const leaves = new Set(world.nodes.filter((node) => !parents.has(node.id)).map((n) => n.id));
883
+ const routes: TrackRoute[] = world.edges.map((edge) => {
884
+ const points = edge.points ?? [];
885
+ const route = graph.compoundRoutes.get(edge.id);
886
+ const portOf = (node: string, port: string | undefined) =>
887
+ port !== undefined && ports.has(`${node}\0${port}`) ? `${node}\0${port}` : undefined;
888
+ return {
889
+ id: edge.id,
890
+ ends: [edge.sourceId, edge.targetId],
891
+ points,
892
+ labels: edge.width > 0 && edge.height > 0 ? [edge] : [],
893
+ ports: [portOf(edge.sourceId, edge.sourcePort), portOf(edge.targetId, edge.targetPort)],
894
+ movable:
895
+ points.length > 1 &&
896
+ points.every(
897
+ (p, i) =>
898
+ i === 0 ||
899
+ Math.abs(p.x - points[i - 1]!.x) < EPS ||
900
+ Math.abs(p.y - points[i - 1]!.y) < EPS,
901
+ ) &&
902
+ (!route ||
903
+ (route.sections.length === 1 &&
904
+ route.sections[0]!.path.segments.every((segment) => segment.kind === "line"))),
905
+ };
906
+ });
907
+ if (!routes.some((route) => route.movable)) return graph;
908
+ const spacing = Number(options.settings?.["spacing.edgeEdge"] ?? 10);
909
+ const changed = separateOpposingTracks({
910
+ routes,
911
+ nodes,
912
+ leaves,
913
+ ports,
914
+ spacing: Number.isFinite(spacing) && spacing > 0 ? spacing : 10,
915
+ });
916
+ if (!changed.size) return graph;
917
+ const worldEdges = new Map(world.edges.map((edge) => [edge.id, edge]));
918
+ const compoundRoutes = new Map(graph.compoundRoutes);
919
+ const edges = graph.edges.map((edge) => {
920
+ const points = changed.get(edge.id);
921
+ if (!points || !edge.points?.length) return edge;
922
+ const from = worldEdges.get(edge.id)!.points![0]!,
923
+ to = edge.points[0]!;
924
+ const local = points.map((p) => ({ x: p.x - from.x + to.x, y: p.y - from.y + to.y }));
925
+ const route = compoundRoutes.get(edge.id);
926
+ if (route)
927
+ compoundRoutes.set(edge.id, {
928
+ ...route,
929
+ sections: [
930
+ {
931
+ ...route.sections[0]!,
932
+ path: {
933
+ start: points[0]!,
934
+ segments: points.slice(1).map((p) => ({ kind: "line" as const, to: p })),
935
+ },
936
+ },
937
+ ],
938
+ });
939
+ return { ...edge, points: local };
940
+ });
941
+ const result = { ...graph, edges, compoundRoutes };
942
+ if (compoundRoutes.size) recordRouteGeometry(result, compoundRoutes);
943
+ return result;
944
+ }