@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,398 @@
1
+ /*
2
+ * Copyright (c) 2012, 2017, 2020 Kiel University and others.
3
+ * Native adaptation of ELK LabelDummyInserter, LabelSideSelector and LabelDummyRemover (v0.11.0).
4
+ * SPDX-License-Identifier: EPL-2.0
5
+ */
6
+ import type { GraphNode, GraphEdge, Point, EntityRect } from "@statelyai/graph";
7
+ import type { LayeredPhaseInput, AcyclicOrientation, LayerOrder, EdgeRoutes } from "./types";
8
+ import type { LongEdgeExpansion } from "./long-edges";
9
+ import { inheritCycleRandom } from "./cycle-random";
10
+
11
+ type LabelSide = "ABOVE" | "BELOW" | "INLINE";
12
+ interface LabelDummy {
13
+ edge: GraphEdge;
14
+ thickness: number;
15
+ spacing: number;
16
+ inline: boolean;
17
+ reversed: boolean;
18
+ }
19
+ export interface CenterLabelExpansion {
20
+ input: LayeredPhaseInput;
21
+ orientation: AcyclicOrientation;
22
+ segmentIdsByEdgeId: ReadonlyMap<string, readonly string[]>;
23
+ labelDummyIdByEdgeId: ReadonlyMap<string, string>;
24
+ dummyById: ReadonlyMap<string, LabelDummy>;
25
+ }
26
+
27
+ /** Before layering: reserve raw label dimensions, then select sides after crossing minimization. */
28
+ export function insertCenterLabelDummies(
29
+ input: LayeredPhaseInput,
30
+ orientation: AcyclicOrientation,
31
+ ): CenterLabelExpansion {
32
+ const nodes: GraphNode[] = [...input.graph.nodes],
33
+ edges: GraphEdge[] = [];
34
+ const sizes = new Map(input.sizes),
35
+ ids = new Set(nodes.map((n) => n.id)),
36
+ edgeIds = new Set(input.graph.edges.map((e) => e.id));
37
+ const dummyById = new Map<string, LabelDummy>(),
38
+ originals = new Map<string, GraphEdge>();
39
+ const reversed = new Set<string>(),
40
+ segments = new Map<string, string[]>(),
41
+ labels = new Map<string, string>();
42
+ const horizontal = input.direction === "right" || input.direction === "left";
43
+ const forward =
44
+ input.direction === "right"
45
+ ? "EAST"
46
+ : input.direction === "left"
47
+ ? "WEST"
48
+ : input.direction === "down"
49
+ ? "SOUTH"
50
+ : "NORTH";
51
+ const negativeFlow = input.direction === "left" || input.direction === "up";
52
+ const backward = ({ EAST: "WEST", WEST: "EAST", SOUTH: "NORTH", NORTH: "SOUTH" } as const)[
53
+ forward
54
+ ];
55
+ for (const edge of input.graph.edges) {
56
+ const settings = input.edgeSettings?.(edge);
57
+ if (
58
+ edge.sourceId === edge.targetId ||
59
+ !((edge.width ?? 0) > 0 || (edge.height ?? 0) > 0) ||
60
+ (settings?.["edgeLabels.placement"] ?? "CENTER") !== "CENTER"
61
+ ) {
62
+ edges.push(edge);
63
+ segments.set(edge.id, [edge.id]);
64
+ if (orientation.reversedEdgeIds.has(edge.id)) reversed.add(edge.id);
65
+ continue;
66
+ }
67
+ let id = `__layout_dummy:label:${edge.id}`;
68
+ while (ids.has(id)) id += ":";
69
+ ids.add(id);
70
+ const inline = settings?.["edgeLabels.inline"] === true;
71
+ const thickness = Math.max(0, Number(settings?.["edge.thickness"] ?? 1));
72
+ const spacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
73
+ // ELK stacks horizontal label heights on the initial edge thickness,
74
+ // but takes the maximum for vertical labels before adding edge spacing.
75
+ const width = horizontal
76
+ ? (edge.width ?? 0)
77
+ : Math.max(edge.width ?? 0, thickness) + spacing + thickness;
78
+ const height = (edge.height ?? 0) + (horizontal ? spacing + 2 * thickness : 0);
79
+ const anchor = Math.floor(thickness / 2);
80
+ const rect = { width, height };
81
+ const node: GraphNode = {
82
+ type: "node",
83
+ id,
84
+ data: undefined,
85
+ ...rect,
86
+ ports: [
87
+ {
88
+ name: "input",
89
+ direction: "in",
90
+ x: horizontal ? (negativeFlow ? width : 0) : anchor,
91
+ y: horizontal ? anchor : negativeFlow ? height : 0,
92
+ width: 0,
93
+ height: 0,
94
+ data: undefined,
95
+ },
96
+ {
97
+ name: "output",
98
+ direction: "out",
99
+ x: horizontal ? (negativeFlow ? 0 : width) : anchor,
100
+ y: horizontal ? anchor : negativeFlow ? 0 : height,
101
+ width: 0,
102
+ height: 0,
103
+ data: undefined,
104
+ },
105
+ ],
106
+ };
107
+ nodes.push(node);
108
+ sizes.set(id, rect);
109
+ labels.set(edge.id, id);
110
+ dummyById.set(id, {
111
+ edge,
112
+ thickness,
113
+ spacing,
114
+ inline,
115
+ reversed: orientation.reversedEdgeIds.has(edge.id),
116
+ });
117
+ const chain = [edge.sourceId, id, edge.targetId],
118
+ partIds: string[] = [];
119
+ const rev = orientation.reversedEdgeIds.has(edge.id);
120
+ for (let i = 0; i < 2; i++) {
121
+ let partId = `${edge.id}::label:${i}`;
122
+ while (edgeIds.has(partId)) partId += ":";
123
+ edgeIds.add(partId);
124
+ partIds.push(partId);
125
+ const part: GraphEdge = {
126
+ ...edge,
127
+ id: partId,
128
+ sourceId: chain[i]!,
129
+ targetId: chain[i + 1]!,
130
+ sourcePort: i === 0 ? edge.sourcePort : rev ? "input" : "output",
131
+ targetPort: i === 1 ? edge.targetPort : rev ? "output" : "input",
132
+ width: 0,
133
+ height: 0,
134
+ points: undefined,
135
+ };
136
+ edges.push(part);
137
+ originals.set(partId, edge);
138
+ if (rev) reversed.add(partId);
139
+ }
140
+ segments.set(edge.id, partIds);
141
+ }
142
+ const prepared = labels.size
143
+ ? inheritCycleRandom(input, {
144
+ ...input,
145
+ modelOrderByEdgeId: new Map(
146
+ edges.map((edge) => {
147
+ const original = originals.get(edge.id) ?? edge;
148
+ return [
149
+ edge.id,
150
+ input.modelOrderByEdgeId?.get(original.id) ?? input.graph.edges.indexOf(original),
151
+ ];
152
+ }),
153
+ ),
154
+ graph: { ...input.graph, nodes, edges },
155
+ sizes,
156
+ nodeSettings: (n: GraphNode) =>
157
+ dummyById.has(n.id) ? { portConstraints: "FIXED_POS" as const } : input.nodeSettings?.(n),
158
+ portSettings: (p: NonNullable<GraphNode["ports"]>[number], n: GraphNode) =>
159
+ dummyById.has(n.id)
160
+ ? { "port.side": p.name === "input" ? backward : forward }
161
+ : input.portSettings?.(p, n),
162
+ edgeSettings: (e: GraphEdge) => input.edgeSettings?.(originals.get(e.id) ?? e),
163
+ })
164
+ : input;
165
+ return {
166
+ input: prepared,
167
+ orientation: labels.size ? { reversedEdgeIds: reversed } : orientation,
168
+ segmentIdsByEdgeId: segments,
169
+ labelDummyIdByEdgeId: labels,
170
+ dummyById,
171
+ };
172
+ }
173
+
174
+ /** Before node placement: reproduce direction and consecutive-dummy label-side rules. */
175
+ export function selectCenterLabelSides(
176
+ expansion: LongEdgeExpansion,
177
+ labels: CenterLabelExpansion,
178
+ order: LayerOrder,
179
+ ): { expansion: LongEdgeExpansion; sides: ReadonlyMap<string, LabelSide> } {
180
+ const input = expansion.input;
181
+ const mode = String(input.settings["edgeLabels.sideSelection"] ?? "SMART_DOWN");
182
+ const horizontal = input.direction === "right" || input.direction === "left";
183
+ // ELK transposes the default side before vertical layout. Smart run choices
184
+ // already refer to the canonical cross axis and must not be transposed again.
185
+ const defaultSide: LabelSide = mode.endsWith("_UP") === horizontal ? "ABOVE" : "BELOW";
186
+ const sides = new Map<string, LabelSide>();
187
+ const set = (id: string, side: LabelSide) => {
188
+ const info = labels.dummyById.get(id);
189
+ if (info) sides.set(id, info.inline ? "INLINE" : side);
190
+ };
191
+ // ELK pairs consecutive dummies of any kind whose long edges connect the
192
+ // same nodes (LONG_EDGE_SOURCE/TARGET), in their cycle-broken direction.
193
+ const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
194
+ const endpointsByDummy = new Map<string, string>();
195
+ for (const [original, ids] of expansion.segmentIdsByEdgeId) {
196
+ const chain = ids.flatMap((id) => edgeById.get(id) ?? []);
197
+ if (chain.length < 2) continue;
198
+ const reversed =
199
+ expansion.orientation.reversedEdgeIds.has(original) ||
200
+ expansion.orientation.reversedEdgeIds.has(chain[0]!.id);
201
+ const [source, target] = [chain[0]!.sourceId, chain.at(-1)!.targetId];
202
+ const key = reversed ? `${target}\0${source}` : `${source}\0${target}`;
203
+ for (const edge of chain)
204
+ for (const id of [edge.sourceId, edge.targetId])
205
+ if (id.startsWith("__layout_dummy:")) endpointsByDummy.set(id, key);
206
+ }
207
+ const endpoints = (id: string) => {
208
+ const info = labels.dummyById.get(id);
209
+ if (!info) return endpointsByDummy.get(id);
210
+ return info.reversed
211
+ ? `${info.edge.targetId}\0${info.edge.sourceId}`
212
+ : `${info.edge.sourceId}\0${info.edge.targetId}`;
213
+ };
214
+ const sameEndpoints = (first: string, second: string) => {
215
+ const a = endpoints(first);
216
+ return a !== undefined && a === endpoints(second);
217
+ };
218
+ for (const layer of order.layers) {
219
+ if (!mode.startsWith("SMART_")) {
220
+ for (const id of layer) {
221
+ const info = labels.dummyById.get(id);
222
+ if (info)
223
+ set(
224
+ id,
225
+ mode.startsWith("DIRECTION_") && info.reversed
226
+ ? defaultSide === "ABOVE"
227
+ ? "BELOW"
228
+ : "ABOVE"
229
+ : defaultSide,
230
+ );
231
+ }
232
+ continue;
233
+ }
234
+ const applyRun = (run: string[], top: boolean, bottom: boolean) => {
235
+ const labelIds = run.filter((id) => labels.dummyById.has(id));
236
+ if (
237
+ top &&
238
+ (!bottom || run.length > 1) &&
239
+ labelIds.length === 1 &&
240
+ labels.dummyById.has(run[0]!)
241
+ )
242
+ set(run[0]!, "ABOVE");
243
+ else if (
244
+ bottom &&
245
+ (!top || run.length > 1) &&
246
+ labelIds.length === 1 &&
247
+ labels.dummyById.has(run.at(-1)!)
248
+ )
249
+ set(run.at(-1)!, "BELOW");
250
+ else if (run.length === 2) {
251
+ set(run[0]!, "ABOVE");
252
+ set(run[1]!, "BELOW");
253
+ } else {
254
+ let group: string[] = [];
255
+ const flush = () => {
256
+ if (group.length === 2) {
257
+ set(group[0]!, "ABOVE");
258
+ set(group[1]!, "BELOW");
259
+ } else for (const id of group) set(id, defaultSide);
260
+ group = [];
261
+ };
262
+ for (const id of run) {
263
+ if (group.length && !sameEndpoints(group.at(-1)!, id)) flush();
264
+ group.push(id);
265
+ }
266
+ flush();
267
+ }
268
+ };
269
+ let run: string[] = [],
270
+ top = true;
271
+ for (const id of layer) {
272
+ if (id.startsWith("__layout_dummy:")) run.push(id);
273
+ else {
274
+ if (run.length) applyRun(run, top, false);
275
+ run = [];
276
+ top = false;
277
+ }
278
+ }
279
+ if (run.length) applyRun(run, top, true);
280
+ }
281
+ const sizes = new Map(input.sizes);
282
+ const nodes = input.graph.nodes.map((node) => {
283
+ const info = labels.dummyById.get(node.id),
284
+ side = sides.get(node.id);
285
+ if (!info || !side) return node;
286
+ const original = sizes.get(node.id)!;
287
+ const cross =
288
+ (horizontal ? original.height : original.width) -
289
+ (side === "INLINE" ? info.spacing + info.thickness : 0);
290
+ const anchor =
291
+ side === "INLINE"
292
+ ? Math.ceil(cross) / 2
293
+ : side === "ABOVE"
294
+ ? cross - Math.ceil(info.thickness / 2)
295
+ : Math.floor(info.thickness / 2);
296
+ const rect = horizontal ? { ...original, height: cross } : { ...original, width: cross };
297
+ sizes.set(node.id, rect);
298
+ return {
299
+ ...node,
300
+ ...rect,
301
+ ports: node.ports?.map((port) => ({
302
+ ...port,
303
+ x: horizontal ? port.x : anchor,
304
+ y: horizontal ? anchor : port.y,
305
+ })),
306
+ };
307
+ });
308
+ return {
309
+ expansion: {
310
+ ...expansion,
311
+ input: inheritCycleRandom(input, { ...input, graph: { ...input.graph, nodes }, sizes }),
312
+ },
313
+ // Label restoration consumes sides in the original layout direction.
314
+ sides: horizontal
315
+ ? sides
316
+ : new Map(
317
+ [...sides].map(([id, side]) => [
318
+ id,
319
+ side === "INLINE" ? side : side === "ABOVE" ? "BELOW" : "ABOVE",
320
+ ]),
321
+ ),
322
+ };
323
+ }
324
+
325
+ export function composeCenterLabelExpansion(
326
+ labels: CenterLabelExpansion,
327
+ expanded: LongEdgeExpansion,
328
+ ): LongEdgeExpansion {
329
+ return {
330
+ ...expanded,
331
+ segmentIdsByEdgeId: new Map(
332
+ [...labels.segmentIdsByEdgeId].map(([id, parts]) => [
333
+ id,
334
+ parts.flatMap((p) => expanded.segmentIdsByEdgeId.get(p) ?? [p]),
335
+ ]),
336
+ ),
337
+ labelDummyIdByEdgeId: new Map([
338
+ ...expanded.labelDummyIdByEdgeId,
339
+ ...labels.labelDummyIdByEdgeId,
340
+ ]),
341
+ };
342
+ }
343
+
344
+ /** LABEL junctions do not introduce auxiliary orthogonal bend points. */
345
+ export function removeCenterLabelJunctions(
346
+ routes: EdgeRoutes,
347
+ expansion: LongEdgeExpansion,
348
+ labels: ReadonlyMap<string, string>,
349
+ routing: string,
350
+ ): EdgeRoutes {
351
+ if (routing !== "ORTHOGONAL" || !labels.size) return routes;
352
+ const horizontal = expansion.input.direction === "right" || expansion.input.direction === "left";
353
+ const cross = (point: Point) => (horizontal ? point.y : point.x);
354
+ const ids = new Set<string>();
355
+ for (const id of labels.values()) {
356
+ const neighbors: Point[] = [];
357
+ for (const edge of expansion.input.graph.edges) {
358
+ const route = routes.pointsByEdgeId.get(edge.id);
359
+ if (!route || route.length < 2) continue;
360
+ if (edge.sourceId === id) neighbors.push(route[1]!);
361
+ if (edge.targetId === id) neighbors.push(route.at(-2)!);
362
+ }
363
+ // Remove straight synthetic junctions only. A compacted track can still
364
+ // turn at a label port; deleting that corner would create a diagonal.
365
+ if (neighbors.length === 2 && cross(neighbors[0]!) === cross(neighbors[1]!)) ids.add(id);
366
+ }
367
+ const points = new Map(routes.pointsByEdgeId);
368
+ for (const edge of expansion.input.graph.edges) {
369
+ const route = points.get(edge.id);
370
+ if (!route) continue;
371
+ const first = ids.has(edge.sourceId) ? 1 : 0;
372
+ const last = route.length - (ids.has(edge.targetId) ? 1 : 0);
373
+ if (first || last !== route.length) points.set(edge.id, route.slice(first, last));
374
+ }
375
+ return { ...routes, pointsByEdgeId: points };
376
+ }
377
+
378
+ export function centerLabelPosition(
379
+ rect: EntityRect,
380
+ info: LabelDummy,
381
+ side: LabelSide | undefined,
382
+ direction: string,
383
+ ): Point {
384
+ const horizontal = direction === "right" || direction === "left";
385
+ const crossOffset = side === "BELOW" ? info.thickness + info.spacing : 0;
386
+ return horizontal
387
+ ? { x: rect.x + (rect.width - (info.edge.width ?? 0)) / 2, y: rect.y + crossOffset }
388
+ : {
389
+ x:
390
+ rect.x +
391
+ (side === "INLINE"
392
+ ? (rect.width - (info.edge.width ?? 0)) / 2
393
+ : side === "ABOVE"
394
+ ? info.thickness + info.spacing
395
+ : 0),
396
+ y: rect.y + (info.inline ? (rect.height - (info.edge.height ?? 0)) / 2 : 0),
397
+ };
398
+ }
@@ -0,0 +1,26 @@
1
+ import type { NodePlacement } from "./types";
2
+
3
+ export interface CompactionBounds {
4
+ left: number;
5
+ top: number;
6
+ right: number;
7
+ bottom: number;
8
+ }
9
+ const boundsByPlacement = new WeakMap<NodePlacement, CompactionBounds>();
10
+ export function recordCompactionBounds(placement: NodePlacement, bounds: CompactionBounds): void {
11
+ boundsByPlacement.set(placement, bounds);
12
+ }
13
+ export function compactionBounds(placement: NodePlacement): CompactionBounds | undefined {
14
+ return boundsByPlacement.get(placement);
15
+ }
16
+
17
+ const crossBoundsByPlacement = new WeakMap<NodePlacement, { axis: "x" | "y"; maximum: number }>();
18
+ export function recordPlacementCrossBounds(
19
+ placement: NodePlacement,
20
+ bounds: { axis: "x" | "y"; maximum: number },
21
+ ): void {
22
+ crossBoundsByPlacement.set(placement, bounds);
23
+ }
24
+ export function placementCrossBounds(placement: NodePlacement) {
25
+ return crossBoundsByPlacement.get(placement);
26
+ }
@@ -0,0 +1,2 @@
1
+ /** A constraint relation that cannot produce finite compacted geometry. */
2
+ export class InfeasibleCompactionError extends Error {}
@@ -0,0 +1,63 @@
1
+ /*******************************************************************************
2
+ * Copyright (c) 2017 Kiel University and others.
3
+ * Adapted from ELK v0.11.0 ScanlineConstraintCalculator.java.
4
+ * Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
5
+ * SPDX-License-Identifier: EPL-2.0
6
+ *******************************************************************************/
7
+ import type { EntityRect } from "@statelyai/graph";
8
+ import { InfeasibleCompactionError } from "./compaction-errors";
9
+
10
+ export interface ScanlineItem extends EntityRect {
11
+ group: string;
12
+ }
13
+
14
+ /** Visibility constraints, rather than all pairs of overlapping intervals. */
15
+ export function scanlineConstraints<T extends ScanlineItem>(
16
+ items: readonly T[],
17
+ includeDegenerate = false,
18
+ ): [T, T][] {
19
+ const events = items.flatMap((item) => [
20
+ { item, low: true, y: item.y },
21
+ { item, low: false, y: item.y + item.height },
22
+ ]);
23
+ events.sort((a, b) => a.y - b.y || Number(a.low) - Number(b.low));
24
+ const active: T[] = [];
25
+ const candidates = new Map<T, T | undefined>();
26
+ const constraints: [T, T][] = [];
27
+ const center = (item: T) => item.x + item.width / 2;
28
+ for (const event of events) {
29
+ const { item } = event;
30
+ // Geometric callers omit empty intervals. ELK's compactor retains them
31
+ // because grouped hitbox restoration can invert an endpoint lead.
32
+ if (!includeDegenerate && item.height <= 0) continue;
33
+ if (event.low) {
34
+ const index = active.findIndex((other) => center(other) >= center(item));
35
+ const position = index < 0 ? active.length : index;
36
+ if (active[position] && center(active[position]!) === center(item))
37
+ throw new InfeasibleCompactionError(
38
+ "Invalid hitboxes for scanline constraint calculation.",
39
+ {
40
+ cause: { first: active[position], second: item },
41
+ },
42
+ );
43
+ active.splice(position, 0, item);
44
+ candidates.set(item, active[position - 1]);
45
+ const right = active[position + 1];
46
+ if (right) candidates.set(right, item);
47
+ } else {
48
+ // ELK queries the tree by center even when an end event precedes its
49
+ // start (zero or negative hitbox height after grouped restoration).
50
+ const position = active.indexOf(item);
51
+ const insertion = active.findIndex((other) => center(other) >= center(item));
52
+ const boundary = insertion < 0 ? active.length : insertion;
53
+ const left = active[boundary - 1],
54
+ right = active[boundary + (position >= 0 ? 1 : 0)];
55
+ if (left && candidates.get(item) === left && left.group !== item.group)
56
+ constraints.push([left, item]);
57
+ if (right && candidates.get(right) === item && right.group !== item.group)
58
+ constraints.push([item, right]);
59
+ if (position >= 0) active.splice(position, 1);
60
+ }
61
+ }
62
+ return constraints;
63
+ }
@@ -0,0 +1,99 @@
1
+ /*
2
+ * Copyright (c) 2011, 2020 Kiel University and others.
3
+ * Native adaptations of ELK getExternalPortPosition and CompoundGraphPostprocessor.
4
+ * SPDX-License-Identifier: EPL-2.0
5
+ */
6
+ import type { Point } from "@statelyai/graph";
7
+ import type { ExternalPortSide } from "./external-port-dummy";
8
+ import type { LayoutPadding, NodeSize } from "./types";
9
+
10
+ export interface ExternalPortTransfer {
11
+ contentSize: NodeSize;
12
+ padding: LayoutPadding;
13
+ offset: Point;
14
+ dummy: Point & NodeSize;
15
+ side: ExternalPortSide;
16
+ borderOffset: number;
17
+ portSize: NodeSize;
18
+ }
19
+ /** Return both effects of ELK's transfer: parent port and corrected content-space dummy. */
20
+ export function transferExternalPort(input: ExternalPortTransfer): { port: Point; dummy: Point } {
21
+ const { contentSize: size, padding: p, offset: o, borderOffset: b, portSize } = input;
22
+ const dummy = { x: input.dummy.x, y: input.dummy.y };
23
+ const port = { x: dummy.x + input.dummy.width / 2, y: dummy.y + input.dummy.height / 2 };
24
+ switch (input.side) {
25
+ case "NORTH":
26
+ port.x += p.left + o.x - portSize.width / 2;
27
+ port.y = -portSize.height - b;
28
+ dummy.y = -(p.top + b + o.y);
29
+ break;
30
+ case "EAST":
31
+ port.x = size.width + p.left + p.right + b;
32
+ port.y += p.top + o.y - portSize.height / 2;
33
+ dummy.x = size.width + p.right + b - o.x;
34
+ break;
35
+ case "SOUTH":
36
+ port.x += p.left + o.x - portSize.width / 2;
37
+ port.y = size.height + p.top + p.bottom + b;
38
+ dummy.y = size.height + p.bottom + b - o.y;
39
+ break;
40
+ case "WEST":
41
+ port.x = -portSize.width - b;
42
+ port.y += p.top + o.y - portSize.height / 2;
43
+ dummy.x = -(p.left + b + o.x);
44
+ break;
45
+ }
46
+ return { port, dummy };
47
+ }
48
+
49
+ export interface CompoundRouteSegment {
50
+ points: readonly Point[];
51
+ offset: Point;
52
+ }
53
+ /** Segments are directed source-to-target and translated to one reference graph. */
54
+ export function joinCompoundRouteSegments(
55
+ segments: readonly CompoundRouteSegment[],
56
+ unnecessaryBendpoints = false,
57
+ ): Point[] {
58
+ const bends: Point[] = [];
59
+ let start: Point | undefined, end: Point | undefined, last: Point | undefined;
60
+ for (const segment of segments) {
61
+ if (segment.points.length < 2) throw new Error("Compound route segment needs two anchors");
62
+ const points = segment.points.map((p) => ({
63
+ x: p.x + segment.offset.x,
64
+ y: p.y + segment.offset.y,
65
+ }));
66
+ const source = points[0]!,
67
+ target = points.at(-1)!;
68
+ const inner = points.slice(1, -1);
69
+ if (!start) start = source;
70
+ if (last) {
71
+ const next = inner[0] ?? target;
72
+ const dx = Math.abs(last.x - next.x) > 1e-6;
73
+ const dy = Math.abs(last.y - next.y) > 1e-6;
74
+ if (unnecessaryBendpoints ? dx || dy : dx && dy) bends.push(source);
75
+ }
76
+ bends.push(...inner);
77
+ last = inner.at(-1) ?? source;
78
+ end = target;
79
+ }
80
+ if (!start || !end) return [];
81
+ // Child and parent boundary anchors can disagree on both axes. Join them
82
+ // with an elbow collinear with a neighboring segment instead of a diagonal.
83
+ const route = [start, ...bends, end];
84
+ const straight = (a: Point, b: Point) =>
85
+ Math.abs(a.x - b.x) <= 1e-6 || Math.abs(a.y - b.y) <= 1e-6;
86
+ for (let index = 0; index + 1 < route.length; index++) {
87
+ const a = route[index]!,
88
+ b = route[index + 1]!;
89
+ if (straight(a, b)) continue;
90
+ const next = route[index + 2],
91
+ previous = route[index - 1];
92
+ const verticalFirst =
93
+ next !== undefined
94
+ ? Math.abs(b.y - next.y) <= 1e-6
95
+ : previous !== undefined && Math.abs(previous.x - a.x) <= 1e-6;
96
+ route.splice(index + 1, 0, verticalFirst ? { x: a.x, y: b.y } : { x: b.x, y: a.y });
97
+ }
98
+ return route;
99
+ }