@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
@@ -1,13 +1,30 @@
1
+ import { preparePortMargins, portCrossMargins } from "./node-margins";
1
2
  /*******************************************************************************
2
3
  * Derived from Eclipse Layout Kernel's Brandes-Koepf node placer.
3
4
  * Copyright (c) 2015 Kiel University and others.
4
5
  * SPDX-License-Identifier: EPL-2.0
5
6
  *******************************************************************************/
6
7
 
7
- import type { EntityRect } from "@statelyai/graph";
8
- import { placeNodesInLayers, placePorts } from "./strategies";
8
+ import { getCrossingUnits } from "./crossing-constraints";
9
+ import type { EntityRect, GraphEdge, GraphNode } from "@statelyai/graph";
10
+ import { placeNodesInLayers, placePorts, usesSharedSideSlot } from "./strategies";
9
11
  import type { LayerOrder, LayeredPhaseInput, NodePlacement } from "./types";
10
12
  import { nodeNodeSpacing } from "./spacing";
13
+ import { prepareLoopEnvelopes, preparedLoopEnvelopes, recordLoopEnvelopes } from "./loop-envelopes";
14
+
15
+ import { recordPlacementCrossBounds } from "./compaction-bounds";
16
+ import { recordRoutingCoordinates } from "./routing-coordinates";
17
+
18
+ const beforeMargin = (input: LayeredPhaseInput, id: string) =>
19
+ Math.max(
20
+ preparedLoopEnvelopes(input)?.get(id)?.before ?? 0,
21
+ portCrossMargins(input, id)?.before ?? 0,
22
+ );
23
+ const afterMargin = (input: LayeredPhaseInput, id: string) =>
24
+ Math.max(
25
+ preparedLoopEnvelopes(input)?.get(id)?.after ?? 0,
26
+ portCrossMargins(input, id)?.after ?? 0,
27
+ );
11
28
 
12
29
  type HDirection = "LEFT" | "RIGHT";
13
30
  type VDirection = "UP" | "DOWN";
@@ -16,6 +33,7 @@ interface Alignment {
16
33
  hdir: HDirection;
17
34
  vdir: VDirection;
18
35
  root: Map<string, string>;
36
+ onlyDummies: Map<string, boolean>;
19
37
  align: Map<string, string>;
20
38
  innerShift: Map<string, number>;
21
39
  blockSize: Map<string, number>;
@@ -40,9 +58,35 @@ function anchorCrossSize(input: LayeredPhaseInput, id: string): number {
40
58
  return id.startsWith("__layout_dummy:wrap:") ? 0 : actualCrossSize(input, id);
41
59
  }
42
60
 
61
+ /** Per-run memo: one placement reads a fixed input. */
62
+ let placementMemo:
63
+ | { input: LayeredPhaseInput; crossSize: Map<string, number>; spacing: Map<string, number> }
64
+ | undefined;
65
+
43
66
  function crossSize(input: LayeredPhaseInput, id: string): number {
67
+ if (placementMemo?.input !== input) return uncachedCrossSize(input, id);
68
+ let value = placementMemo.crossSize.get(id);
69
+ if (value === undefined) placementMemo.crossSize.set(id, (value = uncachedCrossSize(input, id)));
70
+ return value;
71
+ }
72
+
73
+ function spacingBetween(input: LayeredPhaseInput, firstId: string, secondId: string): number {
74
+ if (placementMemo?.input !== input) return nodeNodeSpacing(input, firstId, secondId);
75
+ const key = `${firstId}\0${secondId}`;
76
+ let value = placementMemo.spacing.get(key);
77
+ if (value === undefined)
78
+ placementMemo.spacing.set(key, (value = nodeNodeSpacing(input, firstId, secondId)));
79
+ return value;
80
+ }
81
+
82
+ function uncachedCrossSize(input: LayeredPhaseInput, id: string): number {
44
83
  const value = actualCrossSize(input, id);
45
- if (id.startsWith("__layout_breaking:")) return value;
84
+ if (
85
+ id.startsWith("__layout_breaking:") ||
86
+ id.startsWith("__layout_dummy:inverted:") ||
87
+ id.startsWith("__layout_dummy:north-south:")
88
+ )
89
+ return value;
46
90
  if (value !== 0 || (!id.startsWith("__layout_dummy:") && !id.startsWith("__layout_breaking:"))) {
47
91
  return value;
48
92
  }
@@ -105,6 +149,7 @@ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
105
149
  index: number,
106
150
  count: number,
107
151
  side: "north" | "south" | "west" | "east",
152
+ widths: readonly number[] = [],
108
153
  ): number => {
109
154
  const size = anchorCrossSize(input, id);
110
155
  if (hasCenteredPorts(id)) return size / 2;
@@ -129,13 +174,42 @@ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
129
174
  const start = Math.max(0, Number(startValue ?? 0) - (Number(startValue ?? 0) > 0 ? 1 : 0));
130
175
  const end = Math.max(0, Number(endValue ?? 0) - (Number(endValue ?? 0) > 0 ? 1 : 0));
131
176
  const available = Math.max(0, size - start - end);
132
- if (alignment === "BEGIN") return start + index * spacing;
133
- if (alignment === "END") return start + available - (count - index - 1) * spacing;
177
+ // ELK spaces the side's ports by their cross-axis sizes and anchors at their centers.
178
+ const width = (at: number) => widths[at] ?? 0;
179
+ let before = 0,
180
+ total = 0;
181
+ for (let at = 0; at < count; at++) {
182
+ if (at < index) before += width(at);
183
+ total += width(at);
184
+ }
185
+ const own = width(index) / 2;
186
+ if (alignment === "BEGIN") return start + before + own + index * spacing;
187
+ if (alignment === "END")
188
+ return start + available - (total - before - own) - (count - index - 1) * spacing;
134
189
  if (alignment === "CENTER")
135
- return start + (available - (count - 1) * spacing) / 2 + index * spacing;
190
+ return (
191
+ start + (available - total - (count - 1) * spacing) / 2 + before + own + index * spacing
192
+ );
136
193
  if (alignment === "JUSTIFIED")
137
- return count === 1 ? start + available / 2 : start + (index * available) / (count - 1);
138
- return start + (available * (index + 1)) / (count + 1);
194
+ return count === 1
195
+ ? start + available / 2
196
+ : start + before + own + (index * (available - total)) / (count - 1);
197
+ return start + before + own + ((available - total) * (index + 1)) / (count + 1);
198
+ };
199
+ // Settings and sizes are fixed during placement: place each node's ports once.
200
+ const placedPortsByNode = new Map<GraphNode, ReturnType<typeof placePorts>>();
201
+ const portSideCounts = new Map<GraphNode, Map<unknown, number>>();
202
+ const portSideCount = (node: GraphNode, side: unknown): number => {
203
+ let counts = portSideCounts.get(node);
204
+ if (!counts) {
205
+ counts = new Map();
206
+ for (const port of node.ports ?? []) {
207
+ const portSide = input.portSettings?.(port, node)?.["port.side"];
208
+ counts.set(portSide, (counts.get(portSide) ?? 0) + 1);
209
+ }
210
+ portSideCounts.set(node, counts);
211
+ }
212
+ return counts.get(side) ?? 0;
139
213
  };
140
214
  const explicitPortAnchor = (id: string, edgeId: string): number | undefined => {
141
215
  const node = nodeById.get(id);
@@ -146,19 +220,51 @@ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
146
220
  const port = node.ports?.find((candidate) => candidate.name === portName);
147
221
  if (!port) return undefined;
148
222
  const size = input.sizes.get(id) ?? { width: 0, height: 0 };
149
- const placed = placePorts(
150
- node.ports,
151
- { x: 0, y: 0, ...size },
152
- input.direction,
153
- (candidate) => input.portSettings?.(candidate, node),
154
- { ...input.settings, ...input.nodeSettings?.(node) },
155
- )?.find((candidate) => candidate.name === portName);
223
+ if (!placedPortsByNode.has(node))
224
+ placedPortsByNode.set(
225
+ node,
226
+ placePorts(
227
+ node.ports,
228
+ { x: 0, y: 0, ...size },
229
+ input.direction,
230
+ (candidate) => input.portSettings?.(candidate, node),
231
+ { ...input.settings, ...input.nodeSettings?.(node) },
232
+ ),
233
+ );
234
+ const placed = placedPortsByNode.get(node)?.find((candidate) => candidate.name === portName);
156
235
  if (!placed) return undefined;
157
236
  const anchor = input.portSettings?.(port, node)?.["port.anchor"] as
158
237
  | { x?: number; y?: number }
159
238
  | undefined;
160
239
  const constraints = input.nodeSettings?.(node)?.portConstraints;
161
- if (anchor === undefined && constraints !== "FIXED_RATIO" && constraints !== "FIXED_POS") {
240
+ const sideEntries = right.get(id)?.some((entry) => entry.edgeId === edgeId)
241
+ ? right.get(id)!
242
+ : (left.get(id) ?? []);
243
+ const singlePhysicalPort =
244
+ node.ports?.filter((candidate) => candidate.direction === port.direction).length === 1 &&
245
+ sideEntries.every((entry) => {
246
+ const connected = edgeById.get(entry.edgeId)!;
247
+ return (
248
+ (connected.sourceId === id ? connected.sourcePort : connected.targetPort) === portName
249
+ );
250
+ });
251
+ const physicalSide = input.portSettings?.(port, node)?.["port.side"];
252
+ const uniqueFixedSide =
253
+ constraints === "FIXED_SIDE" &&
254
+ physicalSide !== undefined &&
255
+ physicalSide !== "UNDEFINED" &&
256
+ node.ports !== undefined &&
257
+ portSideCount(node, physicalSide) === 1;
258
+ // A fan-out on one physical port has one anchor, irrespective of edge count.
259
+ // Multiple FIXED_SIDE ports still follow the crossing sweep's selected order.
260
+ if (
261
+ anchor === undefined &&
262
+ constraints !== "FIXED_RATIO" &&
263
+ constraints !== "FIXED_POS" &&
264
+ !uniqueFixedSide &&
265
+ constraints !== "FIXED_ORDER" &&
266
+ !singlePhysicalPort
267
+ ) {
162
268
  return undefined;
163
269
  }
164
270
  if (input.direction === "left" || input.direction === "right") {
@@ -200,10 +306,43 @@ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
200
306
  index: number,
201
307
  count: number,
202
308
  side: "north" | "south" | "west" | "east",
309
+ widths?: readonly number[],
203
310
  ): number =>
204
- input.settings.hierarchyHandling === "INCLUDE_CHILDREN" || !customPortAnchors
311
+ !customPortAnchors
205
312
  ? (anchorCrossSize(input, id) * (index + 1)) / (count + 1)
206
- : (explicitPortAnchor(id, edgeId) ?? portAnchor(id, index, count, side));
313
+ : (explicitPortAnchor(id, edgeId) ?? portAnchor(id, index, count, side, widths));
314
+ /** Cross-axis sizes of a side's ports in order, when each edge there has its own port. */
315
+ const portWidths = (id: string, entries: readonly Neighbor[], count: number) => {
316
+ const node = nodeById.get(id);
317
+ if (!node?.ports || count !== entries.length) return undefined;
318
+ const names = entries.map(({ edgeId }) => {
319
+ const edge = edgeById.get(edgeId)!;
320
+ return edge.sourceId === id ? edge.sourcePort : edge.targetPort;
321
+ });
322
+ if (names.includes(undefined) || new Set(names).size !== names.length) return undefined;
323
+ const horizontal = input.direction === "left" || input.direction === "right";
324
+ return names.map((name) => {
325
+ const port = node.ports!.find((candidate) => candidate.name === name);
326
+ return (horizontal ? port?.height : port?.width) ?? 0;
327
+ });
328
+ };
329
+ // Routing gives fixed-side self-loop terminals on shared-slot ports a slot
330
+ // among the side's other edges (after them here), so anchors reserve it.
331
+ const loopTerminals = (id: string, side: string): number => {
332
+ const node = nodeById.get(id);
333
+ if (!node) return 0;
334
+ return input.graph.edges
335
+ .filter((edge) => edge.sourceId === id && edge.targetId === id)
336
+ .flatMap((edge) => [edge.sourcePort, edge.targetPort])
337
+ .filter((name) => {
338
+ const port = node.ports?.find((candidate) => candidate.name === name);
339
+ return (
340
+ port !== undefined &&
341
+ input.portSettings?.(port, node)?.["port.side"] === side.toUpperCase() &&
342
+ usesSharedSideSlot(input, node, name)
343
+ );
344
+ }).length;
345
+ };
207
346
  for (const [id, entries] of right) {
208
347
  const sweptOrder = order.outputPortOrderByNodeId?.get(id);
209
348
  const interactiveForwardLongEdgeSource =
@@ -232,10 +371,20 @@ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
232
371
  (edgeModelOrder.get(leftEntry.edgeId) ?? 0) -
233
372
  (edgeModelOrder.get(rightEntry.edgeId) ?? 0),
234
373
  );
374
+ // ELK finds the first connecting edge by walking the source's port list.
375
+ // Parallel edges must follow the selected port order, not root edge order.
376
+ const firstByNeighbor = new Set<string>();
377
+ for (const entry of portOrder) {
378
+ if (firstByNeighbor.has(entry.id)) continue;
379
+ firstByNeighbor.add(entry.id);
380
+ edgeIdByNodePair.set(`${id}\0${entry.id}`, entry.edgeId);
381
+ }
382
+ const count = entries.length + loopTerminals(id, afterSide);
383
+ const widths = portWidths(id, portOrder, count);
235
384
  portOrder.forEach((entry, index) => {
236
385
  anchor.set(
237
386
  `${entry.edgeId}:${id}`,
238
- neighborAnchor(id, entry.edgeId, index, entries.length, afterSide),
387
+ neighborAnchor(id, entry.edgeId, index, count, afterSide, widths),
239
388
  );
240
389
  });
241
390
  }
@@ -267,10 +416,20 @@ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
267
416
  (edgeModelOrder.get(rightEntry.edgeId) ?? 0) -
268
417
  (edgeModelOrder.get(leftEntry.edgeId) ?? 0),
269
418
  );
419
+ // getEdge(current, next) walks current's clockwise ports. The reverse
420
+ // sweep must choose from the target's order, independently of the source.
421
+ const firstByNeighbor = new Set<string>();
422
+ for (const entry of [...portOrder].reverse()) {
423
+ if (firstByNeighbor.has(entry.id)) continue;
424
+ firstByNeighbor.add(entry.id);
425
+ edgeIdByNodePair.set(`${id}\0${entry.id}`, entry.edgeId);
426
+ }
427
+ const count = entries.length + loopTerminals(id, beforeSide);
428
+ const widths = portWidths(id, portOrder, count);
270
429
  portOrder.forEach((entry, index) => {
271
430
  anchor.set(
272
431
  `${entry.edgeId}:${id}`,
273
- neighborAnchor(id, entry.edgeId, index, entries.length, beforeSide),
432
+ neighborAnchor(id, entry.edgeId, index, count, beforeSide, widths),
274
433
  );
275
434
  });
276
435
  }
@@ -282,6 +441,7 @@ function makeAlignment(hdir: HDirection, vdir: VDirection): Alignment {
282
441
  hdir,
283
442
  vdir,
284
443
  root: new Map(),
444
+ onlyDummies: new Map(),
285
445
  align: new Map(),
286
446
  innerShift: new Map(),
287
447
  blockSize: new Map(),
@@ -297,9 +457,14 @@ function markConflicts(
297
457
  ): Set<string> {
298
458
  const marked = new Set<string>();
299
459
  if (order.layers.length < 3) return marked;
300
- const isInner = (id: string): boolean =>
460
+ const longEdge = (id: string): boolean =>
301
461
  id.startsWith("__layout_dummy:") &&
302
- (neighbors.left.get(id) ?? []).some((neighbor) => neighbor.id.startsWith("__layout_dummy:"));
462
+ !id.startsWith("__layout_dummy:north-south:") &&
463
+ !id.startsWith("__layout_dummy:label:");
464
+ // ELK only marks inner segments between LONG_EDGE nodes. Port helpers are
465
+ // separate node types and must not block valid Brandes-Koepf alignments.
466
+ const isInner = (id: string): boolean =>
467
+ longEdge(id) && (neighbors.left.get(id) ?? []).some((neighbor) => longEdge(neighbor.id));
303
468
  for (let previousLayerNo = 1; previousLayerNo + 1 < order.layers.length; previousLayerNo++) {
304
469
  const previousLayer = order.layers[previousLayerNo]!;
305
470
  const currentLayer = order.layers[previousLayerNo + 1]!;
@@ -337,6 +502,7 @@ function alignBlocks(
337
502
  for (const layer of order.layers) {
338
503
  for (const id of layer) {
339
504
  bal.root.set(id, id);
505
+ bal.onlyDummies.set(id, true);
340
506
  bal.align.set(id, id);
341
507
  bal.innerShift.set(id, 0);
342
508
  }
@@ -363,6 +529,13 @@ function alignBlocks(
363
529
  bal.align.set(neighbor.id, id);
364
530
  bal.root.set(id, bal.root.get(neighbor.id) ?? neighbor.id);
365
531
  bal.align.set(id, bal.root.get(id) ?? id);
532
+ const root = bal.root.get(id)!;
533
+ const longEdge =
534
+ getCrossingUnits(input)?.longEdgeNodes?.has(id) ??
535
+ (id.startsWith("__layout_dummy:") &&
536
+ !id.startsWith("__layout_dummy:label:") &&
537
+ !id.startsWith("__layout_dummy:north-south:"));
538
+ bal.onlyDummies.set(root, bal.onlyDummies.get(root)! && longEdge);
366
539
  r = index;
367
540
  }
368
541
  }
@@ -371,8 +544,8 @@ function alignBlocks(
371
544
 
372
545
  const roots = new Set(bal.root.values());
373
546
  for (const root of roots) {
374
- let above = 0;
375
- let below = crossSize(input, root);
547
+ let above = beforeMargin(input, root);
548
+ let below = crossSize(input, root) + afterMargin(input, root);
376
549
  bal.innerShift.set(root, 0);
377
550
  let current = root;
378
551
  let next = bal.align.get(current) ?? root;
@@ -386,8 +559,8 @@ function alignBlocks(
386
559
  : anchorCrossSize(input, next) / 2;
387
560
  const nextShift = (bal.innerShift.get(current) ?? 0) + currentAnchor - nextAnchor;
388
561
  bal.innerShift.set(next, nextShift);
389
- above = Math.max(above, -nextShift);
390
- below = Math.max(below, nextShift + crossSize(input, next));
562
+ above = Math.max(above, beforeMargin(input, next) - nextShift);
563
+ below = Math.max(below, nextShift + crossSize(input, next) + afterMargin(input, next));
391
564
  current = next;
392
565
  next = bal.align.get(current) ?? root;
393
566
  }
@@ -423,10 +596,12 @@ function compact(
423
596
  classIndegree.set(target, (classIndegree.get(target) ?? 0) + 1);
424
597
  };
425
598
 
599
+ const threshold = createStraighteningThreshold(input, order, bal, neighbors);
426
600
  const placeBlock = (root: string): void => {
427
601
  if (bal.y.has(root)) return;
428
602
  bal.y.set(root, 0);
429
603
  let assigned = false;
604
+ let bound = bal.vdir === "UP" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
430
605
  let current = root;
431
606
  do {
432
607
  const layerNo = neighbors.layerIndex.get(current) ?? 0;
@@ -436,31 +611,38 @@ function compact(
436
611
  if (neighbor !== undefined) {
437
612
  const neighborRoot = bal.root.get(neighbor) ?? neighbor;
438
613
  placeBlock(neighborRoot);
614
+ bound = threshold.calculate(bound, root, current);
439
615
  if (bal.sink.get(root) === root)
440
616
  bal.sink.set(root, bal.sink.get(neighborRoot) ?? neighborRoot);
441
617
  const rootSink = bal.sink.get(root) ?? root;
442
618
  const neighborSink = bal.sink.get(neighborRoot) ?? neighborRoot;
443
- const spacing = nodeNodeSpacing(input, current, neighbor);
619
+ const spacing = spacingBetween(input, current, neighbor);
444
620
  if (rootSink === neighborSink) {
445
621
  const candidate =
446
622
  bal.vdir === "UP"
447
623
  ? (bal.y.get(neighborRoot) ?? 0) +
448
624
  (bal.innerShift.get(neighbor) ?? 0) -
625
+ beforeMargin(input, neighbor) -
626
+ afterMargin(input, current) -
449
627
  spacing -
450
628
  crossSize(input, current) -
451
629
  (bal.innerShift.get(current) ?? 0)
452
630
  : (bal.y.get(neighborRoot) ?? 0) +
453
631
  (bal.innerShift.get(neighbor) ?? 0) +
454
632
  crossSize(input, neighbor) +
633
+ afterMargin(input, neighbor) +
634
+ beforeMargin(input, current) +
455
635
  spacing -
456
636
  (bal.innerShift.get(current) ?? 0);
457
637
  bal.y.set(
458
638
  root,
459
639
  assigned
460
640
  ? bal.vdir === "UP"
461
- ? Math.min(bal.y.get(root) ?? 0, candidate)
462
- : Math.max(bal.y.get(root) ?? 0, candidate)
463
- : candidate,
641
+ ? Math.min(bal.y.get(root) ?? 0, candidate, bound)
642
+ : Math.max(bal.y.get(root) ?? 0, candidate, bound)
643
+ : bal.vdir === "UP"
644
+ ? Math.min(candidate, bound)
645
+ : Math.max(candidate, bound),
464
646
  );
465
647
  assigned = true;
466
648
  } else {
@@ -472,20 +654,27 @@ function compact(
472
654
  ? (bal.y.get(root) ?? 0) +
473
655
  (bal.innerShift.get(current) ?? 0) +
474
656
  crossSize(input, current) +
657
+ afterMargin(input, current) +
658
+ beforeMargin(input, neighbor) +
475
659
  classSpacing -
476
660
  (bal.y.get(neighborRoot) ?? 0) -
477
661
  (bal.innerShift.get(neighbor) ?? 0)
478
662
  : (bal.y.get(root) ?? 0) +
479
663
  (bal.innerShift.get(current) ?? 0) -
664
+ beforeMargin(input, current) -
665
+ afterMargin(input, neighbor) -
480
666
  (bal.y.get(neighborRoot) ?? 0) -
481
667
  (bal.innerShift.get(neighbor) ?? 0) -
482
668
  crossSize(input, neighbor) -
483
669
  classSpacing;
484
670
  addClassEdge(rootSink, neighborSink, separation);
485
671
  }
672
+ } else {
673
+ bound = threshold.calculate(bound, root, current);
486
674
  }
487
675
  current = bal.align.get(current) ?? root;
488
676
  } while (current !== root);
677
+ threshold.finish(root);
489
678
  };
490
679
 
491
680
  const layers = bal.hdir === "LEFT" ? [...order.layers].reverse() : order.layers;
@@ -526,14 +715,15 @@ function compact(
526
715
  bal.y.set(id, value + (bal.innerShift.get(id) ?? 0));
527
716
  }
528
717
  }
718
+ threshold.postProcess();
529
719
  }
530
720
 
531
721
  function extent(input: LayeredPhaseInput, bal: Alignment): [number, number] {
532
722
  let min = Number.POSITIVE_INFINITY;
533
723
  let max = Number.NEGATIVE_INFINITY;
534
724
  for (const [id, value] of bal.y) {
535
- min = Math.min(min, value);
536
- max = Math.max(max, value + crossSize(input, id));
725
+ min = Math.min(min, value - beforeMargin(input, id));
726
+ max = Math.max(max, value + crossSize(input, id) + afterMargin(input, id));
537
727
  }
538
728
  return [min, max];
539
729
  }
@@ -546,8 +736,9 @@ function preservesLayerOrder(
546
736
  for (const layer of order.layers) {
547
737
  let previousEnd = Number.NEGATIVE_INFINITY;
548
738
  for (const id of layer) {
549
- const start = positions.get(id) ?? 0;
550
- const end = start + crossSize(input, id);
739
+ const position = positions.get(id) ?? 0;
740
+ const start = position - beforeMargin(input, id);
741
+ const end = position + crossSize(input, id) + afterMargin(input, id);
551
742
  if (start <= previousEnd || end <= previousEnd) return false;
552
743
  previousEnd = end;
553
744
  }
@@ -555,78 +746,170 @@ function preservesLayerOrder(
555
746
  return true;
556
747
  }
557
748
 
558
- function improveEdgeStraightness(
749
+ /** ELK's compaction thresholds and deferred straightening share block state. */
750
+ function createStraighteningThreshold(
559
751
  input: LayeredPhaseInput,
560
752
  order: LayerOrder,
561
753
  bal: Alignment,
562
754
  neighbors: ReturnType<typeof buildNeighbors>,
563
- ): void {
564
- if (
565
- (input.settings["nodePlacement.bk.edgeStraightening"] ?? "IMPROVE_STRAIGHTNESS") !==
566
- "IMPROVE_STRAIGHTNESS"
567
- ) {
568
- return;
569
- }
570
- const lockedRoots = new Set<string>();
571
- const nodesByRoot = new Map<string, string[]>();
755
+ ) {
756
+ const enabled =
757
+ (input.settings["nodePlacement.bk.edgeStraightening"] ?? "IMPROVE_STRAIGHTNESS") ===
758
+ "IMPROVE_STRAIGHTNESS";
759
+ const invalid = bal.vdir === "UP" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
760
+ const finished = new Set<string>();
761
+ const used = new Set<string>();
762
+ const blocks = new Map<string, string[]>();
572
763
  for (const id of neighbors.layerIndex.keys()) {
573
764
  const root = bal.root.get(id) ?? id;
574
- const nodes = nodesByRoot.get(root) ?? [];
575
- nodes.push(id);
576
- nodesByRoot.set(root, nodes);
765
+ blocks.set(root, [...(blocks.get(root) ?? []), id]);
577
766
  }
578
- const canShift = (root: string, delta: number): boolean =>
579
- (nodesByRoot.get(root) ?? []).every((id) => {
580
- const candidate = (bal.y.get(id) ?? 0) + delta;
581
- const end = candidate + crossSize(input, id);
582
- const layer = order.layers[neighbors.layerIndex.get(id) ?? 0] ?? [];
583
- return layer.every((otherId) => {
584
- if ((bal.root.get(otherId) ?? otherId) === root) return true;
585
- const other = bal.y.get(otherId) ?? 0;
586
- const spacing = nodeNodeSpacing(input, id, otherId);
587
- return end + spacing <= other || other + crossSize(input, otherId) + spacing <= candidate;
588
- });
589
- });
590
- const layers = bal.hdir === "LEFT" ? [...order.layers].reverse() : order.layers;
591
- for (const layer of layers) {
592
- const nodes = bal.vdir === "UP" ? [...layer].reverse() : layer;
593
- for (const [traversalIndex, id] of nodes.entries()) {
594
- if (traversalIndex === 0) continue;
595
- const root = bal.root.get(id) ?? id;
596
- if (lockedRoots.has(root) || root !== id) continue;
597
- const candidateEdges = input.graph.edges.filter((edge) =>
598
- bal.hdir === "RIGHT" ? edge.targetId === id : edge.sourceId === id,
599
- );
600
- const edge = candidateEdges.find((candidate) => {
601
- const otherId = candidate.sourceId === id ? candidate.targetId : candidate.sourceId;
602
- return !lockedRoots.has(bal.root.get(otherId) ?? otherId);
767
+ type Pending = { free: string; isRoot: boolean; edge?: GraphEdge; hasEdges?: boolean };
768
+ const queue: Pending[] = [],
769
+ stack: Pending[] = [];
770
+ const rootOf = (id: string) => bal.root.get(id) ?? id;
771
+ // Feedback segments retain model direction; BK traverses their layer direction.
772
+ // SelfLoopPreProcessor hides self loops until after node placement.
773
+ const edgesByFree = new Map<boolean, Map<string, GraphEdge[]>>();
774
+ for (const incoming of [true, false]) {
775
+ const byFree = new Map<string, GraphEdge[]>();
776
+ for (const edge of input.graph.edges) {
777
+ if (edge.sourceId === edge.targetId) continue;
778
+ const reversed =
779
+ (neighbors.layerIndex.get(edge.sourceId) ?? 0) >
780
+ (neighbors.layerIndex.get(edge.targetId) ?? 0);
781
+ const free = incoming !== reversed ? edge.targetId : edge.sourceId;
782
+ const edges = byFree.get(free);
783
+ if (edges) edges.push(edge);
784
+ else byFree.set(free, [edge]);
785
+ }
786
+ edgesByFree.set(incoming, byFree);
787
+ }
788
+ const pick = (pending: Pending): Pending => {
789
+ const incoming = pending.isRoot ? bal.hdir === "RIGHT" : bal.hdir === "LEFT";
790
+ const ports =
791
+ (incoming
792
+ ? getCrossingUnits(input)?.incomingEdgeOrderByDummyId?.get(pending.free)
793
+ : undefined) ??
794
+ (incoming ? order.inputPortOrderByNodeId : order.outputPortOrderByNodeId)?.get(pending.free);
795
+ const edges = [...(edgesByFree.get(incoming)!.get(pending.free) ?? [])]
796
+ // Edges missing from the port order (a merged dummy's in-layer parts)
797
+ // follow the listed edges.
798
+ .sort((a, b) => {
799
+ if (!ports) return 0;
800
+ const rank = (id: string) => (ports.includes(id) ? ports.indexOf(id) : ports.length);
801
+ return rank(a.id) - rank(b.id);
603
802
  });
604
- if (!edge) continue;
605
- const otherId = edge.sourceId === id ? edge.targetId : edge.sourceId;
606
- const currentAnchor = neighbors.anchor.get(`${edge.id}:${id}`) ?? crossSize(input, id) / 2;
607
- const otherAnchor =
608
- neighbors.anchor.get(`${edge.id}:${otherId}`) ?? crossSize(input, otherId) / 2;
609
- const desired = (bal.y.get(otherId) ?? 0) + otherAnchor - currentAnchor;
610
- const delta = desired - (bal.y.get(id) ?? 0);
803
+ // ELK initializes this flag true and changes it only on alignment.
804
+ // A singleton normal node therefore remains eligible for same-layer edges.
805
+ const onlyDummies = bal.onlyDummies.get(rootOf(pending.free)) ?? true;
806
+ pending.hasEdges = false;
807
+ pending.edge = undefined;
808
+ for (const edge of edges) {
611
809
  if (
612
- ((bal.vdir === "DOWN" && delta > 0) || (bal.vdir === "UP" && delta < 0)) &&
613
- canShift(root, delta)
614
- ) {
615
- for (const blockNode of nodesByRoot.get(root) ?? []) {
616
- bal.y.set(blockNode, (bal.y.get(blockNode) ?? 0) + delta);
617
- }
618
- lockedRoots.add(root);
619
- lockedRoots.add(bal.root.get(otherId) ?? otherId);
810
+ !onlyDummies &&
811
+ neighbors.layerIndex.get(edge.sourceId) === neighbors.layerIndex.get(edge.targetId)
812
+ )
813
+ continue;
814
+ if (used.has(rootOf(pending.free))) continue;
815
+ pending.hasEdges = true;
816
+ const other = edge.sourceId === pending.free ? edge.targetId : edge.sourceId;
817
+ if (finished.has(rootOf(other))) {
818
+ pending.edge = edge;
819
+ return pending;
620
820
  }
621
821
  }
622
- }
822
+ return pending;
823
+ };
824
+ const anchor = (edge: GraphEdge, id: string) =>
825
+ neighbors.anchor.get(`${edge.id}:${id}`) ?? crossSize(input, id) / 2;
826
+ const bound = (free: string, isRoot: boolean): number => {
827
+ const pending = pick({ free, isRoot });
828
+ if (!pending.edge) {
829
+ if (pending.hasEdges) queue.push(pending);
830
+ return invalid;
831
+ }
832
+ const edge = pending.edge;
833
+ const other = edge.sourceId === free ? edge.targetId : edge.sourceId;
834
+ const threshold =
835
+ (bal.y.get(rootOf(other)) ?? 0) +
836
+ (bal.innerShift.get(other) ?? 0) +
837
+ anchor(edge, other) -
838
+ (bal.innerShift.get(free) ?? 0) -
839
+ anchor(edge, free);
840
+ used.add(rootOf(edge.sourceId));
841
+ used.add(rootOf(edge.targetId));
842
+ return threshold;
843
+ };
844
+ const calculate = (old: number, root: string, current: string): number => {
845
+ if (!enabled) return invalid;
846
+ const isRoot = root === current,
847
+ isLast = bal.align.get(current) === root;
848
+ if (!isRoot && !isLast) return old;
849
+ let value = old;
850
+ if (isRoot) value = bound(root, true);
851
+ if (!Number.isFinite(value) && isLast) value = bound(current, false);
852
+ return value;
853
+ };
854
+ const process = (pending: Pending): boolean => {
855
+ const edge = pending.edge!;
856
+ const free = pending.free;
857
+ const other = edge.sourceId === free ? edge.targetId : edge.sourceId;
858
+ const delta =
859
+ (bal.y.get(other) ?? 0) + anchor(edge, other) - (bal.y.get(free) ?? 0) - anchor(edge, free);
860
+ if (!Number.isFinite(delta) || delta === 0) return false;
861
+ let available = Math.abs(delta);
862
+ for (const id of blocks.get(rootOf(free)) ?? []) {
863
+ const layer = order.layers[neighbors.layerIndex.get(id) ?? 0] ?? [];
864
+ const index = neighbors.nodeIndex.get(id) ?? 0;
865
+ const adjacent = layer[index + (delta < 0 ? -1 : 1)];
866
+ if (adjacent === undefined) continue;
867
+ const gap =
868
+ delta < 0
869
+ ? (bal.y.get(id) ?? 0) - (bal.y.get(adjacent) ?? 0) - crossSize(input, adjacent)
870
+ : (bal.y.get(adjacent) ?? 0) - (bal.y.get(id) ?? 0) - crossSize(input, id);
871
+ const margins =
872
+ delta < 0
873
+ ? beforeMargin(input, id) + afterMargin(input, adjacent)
874
+ : afterMargin(input, id) + beforeMargin(input, adjacent);
875
+ available = Math.min(available, gap - margins - spacingBetween(input, id, adjacent));
876
+ }
877
+ for (const id of blocks.get(rootOf(free)) ?? [])
878
+ bal.y.set(id, (bal.y.get(id) ?? 0) + (delta < 0 ? -available : available));
879
+ return available > 0;
880
+ };
881
+ return {
882
+ calculate,
883
+ finish: (root: string) => finished.add(root),
884
+ postProcess: () => {
885
+ if (!enabled) return;
886
+ for (const pending of queue) {
887
+ pick(pending);
888
+ if (!pending.edge) continue;
889
+ if (!process(pending)) stack.push(pending);
890
+ }
891
+ while (stack.length) process(stack.pop()!);
892
+ },
893
+ };
623
894
  }
624
895
 
625
- /** ELK-compatible Brandes-Koepf placement for implicit center ports. */
896
+ /** ELK-compatible Brandes-Koepf placement with port-aware compaction. */
626
897
  export function placeNodesWithBrandesKoepf(
627
898
  input: LayeredPhaseInput,
628
899
  order: LayerOrder,
629
900
  ): NodePlacement {
901
+ const previous = placementMemo;
902
+ placementMemo = { input, crossSize: new Map(), spacing: new Map() };
903
+ try {
904
+ return placeNodes(input, order);
905
+ } finally {
906
+ placementMemo = previous;
907
+ }
908
+ }
909
+
910
+ function placeNodes(input: LayeredPhaseInput, order: LayerOrder): NodePlacement {
911
+ preparePortMargins(input);
912
+ const envelopes = prepareLoopEnvelopes(input);
630
913
  const base = placeNodesInLayers(input, order);
631
914
  const neighbors = buildNeighbors(input, order);
632
915
  const markedEdges = markConflicts(order, neighbors);
@@ -650,7 +933,6 @@ export function placeNodesWithBrandesKoepf(
650
933
  const layout = makeAlignment(hdir, vdir);
651
934
  alignBlocks(input, order, layout, neighbors, markedEdges);
652
935
  compact(input, order, layout, neighbors);
653
- improveEdgeStraightness(input, order, layout, neighbors);
654
936
  return layout;
655
937
  });
656
938
  const smallestFeasibleLayout = (): Alignment => {
@@ -696,7 +978,7 @@ export function placeNodesWithBrandesKoepf(
696
978
  } else {
697
979
  positions = smallestFeasibleLayout().y;
698
980
  }
699
- const minimum = Math.min(...positions.values());
981
+ const minimum = Math.min(...[...positions].map(([id, value]) => value - beforeMargin(input, id)));
700
982
  const crossPadding =
701
983
  input.direction === "left" || input.direction === "right"
702
984
  ? input.padding.top
@@ -711,5 +993,34 @@ export function placeNodesWithBrandesKoepf(
711
993
  : { ...rect, x: cross },
712
994
  );
713
995
  }
714
- return { rectByNodeId };
996
+ const placement = { rectByNodeId };
997
+ recordRoutingCoordinates(placement, {
998
+ rectByNodeId: new Map(
999
+ [...rectByNodeId].map(([id, rect]) => [
1000
+ id,
1001
+ input.direction === "left" || input.direction === "right"
1002
+ ? { ...rect, y: positions.get(id) ?? 0 }
1003
+ : { ...rect, x: positions.get(id) ?? 0 },
1004
+ ]),
1005
+ ),
1006
+ });
1007
+ // ELK's layer-size phase measures the final node of each ordered layer,
1008
+ // including its dummy thickness and trailing margin. Retain this extent
1009
+ // before routing restoration removes zero-size port and long-edge helpers.
1010
+ const horizontal = input.direction === "left" || input.direction === "right";
1011
+ recordPlacementCrossBounds(placement, {
1012
+ axis: horizontal ? "y" : "x",
1013
+ maximum: Math.max(
1014
+ 0,
1015
+ ...order.layers.flatMap((layer) => {
1016
+ const id = layer.at(-1);
1017
+ const rect = id === undefined ? undefined : rectByNodeId.get(id);
1018
+ return rect && id !== undefined
1019
+ ? [(horizontal ? rect.y : rect.x) + crossSize(input, id) + afterMargin(input, id)]
1020
+ : [];
1021
+ }),
1022
+ ),
1023
+ });
1024
+ recordLoopEnvelopes(placement, new Set(envelopes.keys()));
1025
+ return placement;
715
1026
  }