@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.
Files changed (110) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +150 -11
  3. package/dist/elkjs/index.cjs +2 -2
  4. package/dist/elkjs/index.d.cts +2 -0
  5. package/dist/elkjs/index.d.mts +2 -0
  6. package/dist/elkjs/index.mjs +2 -2
  7. package/dist/elkjs/main.cjs +1 -1
  8. package/dist/elkjs/main.mjs +1 -1
  9. package/dist/elkjs/worker.cjs +2 -2
  10. package/dist/elkjs/worker.mjs +2 -2
  11. package/dist/elkjs-BBfKN4Dw.mjs +3075 -0
  12. package/dist/elkjs-DNfXDPup.cjs +3104 -0
  13. package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
  14. package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
  15. package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
  16. package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
  17. package/dist/index.cjs +6 -3
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.mts +3 -3
  20. package/dist/index.mjs +4 -4
  21. package/dist/layered/index.cjs +3 -1
  22. package/dist/layered/index.d.cts +2 -2
  23. package/dist/layered/index.d.mts +2 -2
  24. package/dist/layered/index.mjs +2 -2
  25. package/dist/{layered-B2buJqMf.cjs → layered-5RxQhryb.cjs} +11184 -2932
  26. package/dist/{layered-DGFTeQSl.mjs → layered-DSesbctm.mjs} +12059 -3903
  27. package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-CLFySz3R.cjs} +5 -18
  28. package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-GpBIX6ng.mjs} +3 -10
  29. package/dist/routing/index.cjs +2 -1
  30. package/dist/routing/index.d.cts +2 -2
  31. package/dist/routing/index.d.mts +2 -2
  32. package/dist/routing/index.mjs +2 -2
  33. package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
  34. package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
  35. package/package.json +8 -2
  36. package/src/authoring/coordinates.ts +28 -8
  37. package/src/authoring/partial.ts +7 -2
  38. package/src/authoring/routing.ts +4 -1
  39. package/src/constraints.ts +174 -6
  40. package/src/elkjs/index.ts +1476 -449
  41. package/src/elkjs/layout-quality.ts +267 -0
  42. package/src/elkjs/opposing-tracks.ts +161 -0
  43. package/src/elkjs/orthogonal-junctions.ts +174 -0
  44. package/src/elkjs/statechart.ts +9 -3
  45. package/src/elkjs/types.ts +2 -0
  46. package/src/index.ts +2 -0
  47. package/src/internal/elkjs-compatibility.ts +4 -1
  48. package/src/internal/layered-trace.ts +48 -0
  49. package/src/internal/layout-engine.ts +2 -1
  50. package/src/java-random.ts +6 -0
  51. package/src/layered/bk-node-placement.ts +403 -92
  52. package/src/layered/center-labels.ts +398 -0
  53. package/src/layered/compaction-bounds.ts +26 -0
  54. package/src/layered/compaction-errors.ts +2 -0
  55. package/src/layered/compaction-scanline.ts +63 -0
  56. package/src/layered/compound-boundaries.ts +99 -0
  57. package/src/layered/compound.ts +35 -7
  58. package/src/layered/constraint-orientation.ts +141 -0
  59. package/src/layered/crossing-constraints.ts +243 -0
  60. package/src/layered/crossing-counter.ts +233 -0
  61. package/src/layered/crossing-graph.ts +401 -0
  62. package/src/layered/cycle-random.ts +22 -0
  63. package/src/layered/elk-port-lists.ts +243 -0
  64. package/src/layered/end-labels.ts +124 -0
  65. package/src/layered/external-port-dummy.ts +137 -0
  66. package/src/layered/feedback-chains.ts +114 -0
  67. package/src/layered/fixed-self-loop.ts +150 -0
  68. package/src/layered/greedy-switch-decider.ts +356 -0
  69. package/src/layered/grouped-compaction.ts +787 -0
  70. package/src/layered/gwt-sort.ts +53 -0
  71. package/src/layered/hierarchical-port-phases.ts +242 -0
  72. package/src/layered/hierarchical-port-restoration.ts +233 -0
  73. package/src/layered/hierarchical-port-routing.ts +179 -0
  74. package/src/layered/hierarchy-crossing.ts +121 -0
  75. package/src/layered/hierarchy-sweepiness.ts +79 -0
  76. package/src/layered/hints.ts +371 -0
  77. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  78. package/src/layered/index.ts +875 -161
  79. package/src/layered/initial-model-order.ts +404 -0
  80. package/src/layered/inverted-ports.ts +207 -0
  81. package/src/layered/label-dummy-switcher.ts +200 -0
  82. package/src/layered/long-edges.ts +135 -44
  83. package/src/layered/loop-envelopes.ts +184 -0
  84. package/src/layered/native-routing.ts +275 -0
  85. package/src/layered/network-simplex.ts +11 -15
  86. package/src/layered/node-margins.ts +59 -0
  87. package/src/layered/north-south-ports.ts +271 -0
  88. package/src/layered/opposing-tracks.ts +1044 -0
  89. package/src/layered/orthogonal-cycle-order.ts +106 -0
  90. package/src/layered/orthogonal-hypersegments.ts +154 -0
  91. package/src/layered/orthogonal-junctions.ts +202 -0
  92. package/src/layered/orthogonal-segments.ts +243 -0
  93. package/src/layered/placement-orientation.ts +22 -0
  94. package/src/layered/port-distributor.ts +192 -0
  95. package/src/layered/port-edge-order.ts +20 -0
  96. package/src/layered/replace-routing.ts +45 -0
  97. package/src/layered/routing-coordinates.ts +32 -0
  98. package/src/layered/separate-exterior-labels.ts +24 -6
  99. package/src/layered/spacing.ts +34 -12
  100. package/src/layered/splitter-order.ts +106 -0
  101. package/src/layered/strategies.ts +2124 -886
  102. package/src/layered/types.ts +13 -0
  103. package/src/layered/weighted-compaction.ts +84 -0
  104. package/src/routing/adapters.ts +32 -0
  105. package/src/routing/algorithms.ts +433 -21
  106. package/src/routing/coordination.ts +8 -3
  107. package/src/routing/index.ts +1 -0
  108. package/src/routing/search.ts +93 -11
  109. package/dist/elkjs-DaHMISnQ.mjs +0 -1677
  110. package/dist/elkjs-dMghiiWW.cjs +0 -1706
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { orthogonalRouting, routeToPolylines } from "../routing";
1
+ import { orthogonalRouting, routeToPoints } from "../routing";
2
2
  import { recordRouteGeometry } from "../routing/layout-cache";
3
3
  import type { EdgeRoutingSettings, Route } from "../routing/types";
4
4
  import type {
@@ -10,6 +10,7 @@ import type {
10
10
  VisualNode,
11
11
  } from "@statelyai/graph";
12
12
  import type { CompoundLayoutGeometry, LayeredLayoutOptions, LayoutPadding } from "./types";
13
+ import { placePorts } from "./strategies";
13
14
 
14
15
  export type CompoundVisualGraph<N = unknown, E = unknown, G = unknown, P = unknown> = VisualGraph<
15
16
  N,
@@ -213,7 +214,20 @@ export function layoutCompounds<N, E, G, P>(
213
214
  );
214
215
  for (const placed of result.nodes) {
215
216
  const node = byId.get(placed.id);
216
- if (node) Object.assign(node, { x: placed.x, y: placed.y, ports: placed.ports });
217
+ if (node)
218
+ Object.assign(node, {
219
+ x: placed.x,
220
+ y: placed.y,
221
+ // Placement reserves an envelope for boundary labels. Ports belong to
222
+ // the actual node rectangle, never that larger occupied envelope.
223
+ ports: placePorts(
224
+ node.ports,
225
+ { ...placed, width: node.width, height: node.height },
226
+ direction,
227
+ (port) => options.portSettings?.(port, node),
228
+ { ...options.settings, ...options.nodeSettings?.(node) },
229
+ ),
230
+ });
217
231
  }
218
232
  for (const edge of projected) {
219
233
  const labelId = labelIds.get(edge.id);
@@ -365,8 +379,23 @@ export function layoutCompounds<N, E, G, P>(
365
379
  },
366
380
  }
367
381
  : {}),
368
- ...(targetGeometry && intent?.target === "content"
369
- ? { targetAttachment: { bounds: targetGeometry.content, facing: "inward" } }
382
+ ...(targetGeometry &&
383
+ edge.sourceId !== edge.targetId &&
384
+ (intent?.target === "content" || ancestry(edge.sourceId).includes(edge.targetId))
385
+ ? {
386
+ targetAttachment: {
387
+ bounds:
388
+ intent?.target === "outer"
389
+ ? {
390
+ x: 0,
391
+ y: 0,
392
+ width: targetGeometry.bounds.width,
393
+ height: targetGeometry.bounds.height,
394
+ }
395
+ : targetGeometry.content,
396
+ facing: intent?.target === "outer" ? "outward" : "inward",
397
+ },
398
+ }
370
399
  : {}),
371
400
  };
372
401
  }
@@ -392,12 +421,11 @@ export function layoutCompounds<N, E, G, P>(
392
421
  });
393
422
  const snapshot = orthogonalRouting.route(
394
423
  { ...graph, nodes: [...worldNodes, ...headers], edges: [...edges.values()] },
395
- { coordinateSpace: "world", edges: attachmentSettings },
424
+ { coordinateSpace: "world", edges: attachmentSettings, maxSearchNodes: 40000 },
396
425
  );
397
426
  for (const [id, route] of snapshot.routes) {
398
- const polylines = routeToPolylines(route);
399
427
  Object.assign(edges.get(id)!, {
400
- points: polylines.flatMap((p) => p.map((point) => ({ ...point }))),
428
+ points: routeToPoints(route),
401
429
  routing: "orthogonal",
402
430
  });
403
431
  }