@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,9 +1,48 @@
1
+ import { inheritCycleRandom } from "./cycle-random";
2
+ import {
3
+ prepareHierarchicalPortConstraints,
4
+ sizeHierarchicalPortDummies,
5
+ } from "./hierarchical-port-phases";
6
+ import { restoreHierarchicalPorts } from "./hierarchical-port-restoration";
7
+ import { countAllCrossings } from "./crossing-counter";
8
+ import { crossingGraph } from "./crossing-graph";
9
+ import { sortInitialModelOrder, markInitialModelOrderPrepared } from "./initial-model-order";
10
+ import { hasMovableLoopPorts } from "./loop-envelopes";
11
+ import { fixedSelfLoopLabels, fixedSelfLoopSide } from "./fixed-self-loop";
12
+ import { compactionBounds, placementCrossBounds } from "./compaction-bounds";
13
+ import { setElkjs0111ResultPolicy } from "../internal/elkjs-compatibility";
14
+ import { traceLayeredPhase } from "../internal/layered-trace";
15
+ import { networkSimplexComponents } from "./network-simplex";
16
+ import { incomingAfterLongEdgeSplitting, withLongEdgeSplitterOrder } from "./splitter-order";
17
+ import {
18
+ edgeListRanks,
19
+ elkComponentOrder,
20
+ insertLabelDummyPorts,
21
+ simulatePortEdgeLists,
22
+ type PortEdgeList,
23
+ } from "./elk-port-lists";
24
+ import { importsHyperedges, mergeHyperedgeDummies } from "./hyperedge-dummy-merger";
25
+ import {
26
+ applyOrderHints,
27
+ applyPlacementHints,
28
+ hintLayerConstraint,
29
+ layoutWithHints,
30
+ } from "./hints";
31
+ import { getCrossingUnits, recordCrossingUnits } from "./crossing-constraints";
32
+ import { externalPortDummyOf } from "./external-port-dummy";
33
+ import type { EntityRect } from "@statelyai/graph";
34
+ import { setPlacementOrientation, setPlacementOrder } from "./placement-orientation";
35
+ import { replaceLayoutRouting } from "./replace-routing";
1
36
  import { layoutCompounds, type CompoundVisualGraph } from "./compound";
37
+ import { separateLayoutOpposingTracks } from "./opposing-tracks";
38
+ import { repairFlatRouting } from "./native-routing";
2
39
  import type { Graph, GraphEdge, GraphNode, Point, VisualGraph, VisualNode } from "@statelyai/graph";
3
40
  import { labelReferences, runPartialLayout } from "../authoring/partial";
4
41
  import { UnsupportedLayoutError } from "../errors";
5
42
  import type { LayoutAlgorithm, LayoutExecutionContext } from "../types";
6
43
  import { separateExteriorLabels } from "./separate-exterior-labels";
44
+ import { placeEndLabels } from "./end-labels";
45
+
7
46
  import {
8
47
  assignLayersByLongestPath,
9
48
  assignLayersByLongestPathToSink,
@@ -12,7 +51,6 @@ import {
12
51
  assignLayersInteractively,
13
52
  assignLayersWithCoffmanGraham,
14
53
  applyLayerConstraints,
15
- applyLayerConstraintOrientation,
16
54
  applyPartitionOrientation,
17
55
  applyPartitions,
18
56
  applyLayerConstraintOrder,
@@ -32,6 +70,9 @@ import {
32
70
  breakCyclesWithModelOrderBreadthFirstSearch,
33
71
  breakCyclesWithDepthFirstSearch,
34
72
  getPolylineMidpoint,
73
+ getPortPoint,
74
+ removeRouteSpurs,
75
+ simplifyRoute,
35
76
  getOrientedPortDirection,
36
77
  minimizeCrossingsWithBarycenter,
37
78
  minimizeCrossingsWithMedian,
@@ -45,17 +86,36 @@ import {
45
86
  routeEdgesWithPolylines,
46
87
  routeEdgesWithSplines,
47
88
  } from "./strategies";
48
- import type { LayeredLayoutOptions, LayeredPhaseInput, NodeSize } from "./types";
89
+ import type {
90
+ AcyclicOrientation,
91
+ LayerAssignment,
92
+ LayerOrder,
93
+ LayeredLayoutOptions,
94
+ LayeredPhaseInput,
95
+ NodeSize,
96
+ } from "./types";
49
97
  import { assignLayersWithNetworkSimplex } from "./network-simplex";
50
98
  import { assignLayersWithMinWidth } from "./min-width";
51
99
  import { assignLayersWithStretchWidth } from "./stretch-width";
100
+ import {
101
+ insertCenterLabelDummies,
102
+ selectCenterLabelSides,
103
+ centerLabelPosition,
104
+ removeCenterLabelJunctions,
105
+ composeCenterLabelExpansion,
106
+ } from "./center-labels";
107
+ import { switchCenterLabelDummies } from "./label-dummy-switcher";
108
+ import { insertNorthSouthPortDummies, restoreNorthSouthPortRoutes } from "./north-south-ports";
109
+ import { insertInvertedPortDummies } from "./inverted-ports";
52
110
  import { joinLongEdgeRoutes, splitLongEdges } from "./long-edges";
53
111
  import { unzipLayersAlternating } from "./layer-unzipping";
54
112
  import { placeNodesWithBrandesKoepf } from "./bk-node-placement";
113
+ import { breakCyclesWithConstraints } from "./constraint-orientation";
55
114
  import { placeNodesWithLinearSegments } from "./linear-segments-node-placement";
56
115
  import { placeNodesWithNetworkSimplex } from "./network-simplex-node-placement";
57
116
  import { applyHighDegreeNodeTreatment } from "./high-degree";
58
117
  import { applyNodePromotion } from "./node-promotion";
118
+ import { straightenFeedbackChains } from "./feedback-chains";
59
119
  import {
60
120
  foldMultiEdgeBreakingPoints,
61
121
  insertMultiEdgeBreakingPoints,
@@ -664,7 +724,9 @@ function runWrappedMultiEdgePipeline<N, E, G, P>(
664
724
  { layerByNodeId: rank },
665
725
  cuts,
666
726
  );
667
- const brokenExpansion = splitLongEdges(prepared.input, prepared.orientation, prepared.assignment);
727
+ const brokenExpansion = insertInvertedPortDummies(
728
+ splitLongEdges(prepared.input, prepared.orientation, prepared.assignment),
729
+ );
668
730
  const crossingStrategy = options.settings?.["crossingMinimization.strategy"] ?? "LAYER_SWEEP";
669
731
  const crossingMinimizer =
670
732
  crossingStrategy === "MEDIAN_LAYER_SWEEP"
@@ -694,6 +756,7 @@ function runWrappedMultiEdgePipeline<N, E, G, P>(
694
756
  ),
695
757
  );
696
758
  const folded = foldMultiEdgeBreakingPoints(brokenExpansion, crossedOrder, prepared);
759
+ setPlacementOrientation(folded.expansion.input, folded.expansion.orientation);
697
760
  const placementStrategy = options.settings?.["nodePlacement.strategy"] ?? "BRANDES_KOEPF";
698
761
  const placement =
699
762
  placementStrategy === "INTERACTIVE"
@@ -1100,6 +1163,17 @@ function runSeparatedComponents<N, E, G, P>(
1100
1163
  if ((options.settings?.["considerModelOrder.components"] ?? "NONE") === "NONE") {
1101
1164
  components.sort((left, right) => left.area - right.area || left.modelOrder - right.modelOrder);
1102
1165
  }
1166
+ // The component holding the flow's start (a first-layer node, else the largest)
1167
+ // packs first, so no other component precedes that start along the flow.
1168
+ const constrainedFirst = (component: (typeof components)[number]) =>
1169
+ component.result.nodes.some((node) => {
1170
+ const constraint = options.nodeSettings?.(node)?.["layering.layerConstraint"];
1171
+ return constraint === "FIRST" || constraint === "FIRST_SEPARATE";
1172
+ });
1173
+ const start =
1174
+ components.find(constrainedFirst) ??
1175
+ components.toSorted((left, right) => right.result.nodes.length - left.result.nodes.length)[0];
1176
+ if (start) components.unshift(...components.splice(components.indexOf(start), 1));
1103
1177
  const componentSpacing = Number(
1104
1178
  options.settings?.["spacing.componentComponent"] ??
1105
1179
  options.settings?.["spacing.baseValue"] ??
@@ -1132,6 +1206,7 @@ function runSeparatedComponents<N, E, G, P>(
1132
1206
  const placedShapes: Array<{ left: number; right: number; top: number; bottom: number }> = [];
1133
1207
  const nodeResults = new Map<string, VisualNode<N, P>>();
1134
1208
  const edgeResults = new Map<string, (typeof components)[number]["result"]["edges"][number]>();
1209
+ const placements: Array<{ component: (typeof components)[number]; x: number; y: number }> = [];
1135
1210
  for (const component of components) {
1136
1211
  if (x > 0 && x + component.bounds.width > maxRowWidth) {
1137
1212
  x = 0;
@@ -1152,17 +1227,30 @@ function runSeparatedComponents<N, E, G, P>(
1152
1227
  }
1153
1228
  compactedY = Math.min(y, requiredY);
1154
1229
  }
1155
- const dx = padding.left + x - component.bounds.left;
1156
- const dy = padding.top + compactedY - component.bounds.top;
1157
- for (const node of component.result.nodes) {
1158
- nodeResults.set(node.id, { ...node, x: (node.x ?? 0) + dx, y: (node.y ?? 0) + dy });
1230
+ for (const node of component.result.nodes)
1159
1231
  placedShapes.push({
1160
1232
  left: x + (node.x ?? 0) - component.bounds.left,
1161
1233
  right: x + (node.x ?? 0) - component.bounds.left + (node.width ?? 0),
1162
1234
  top: compactedY + (node.y ?? 0) - component.bounds.top,
1163
1235
  bottom: compactedY + (node.y ?? 0) - component.bounds.top + (node.height ?? 0),
1164
1236
  });
1165
- }
1237
+ placements.push({ component, x, y: compactedY });
1238
+ x += component.bounds.width + componentSpacing;
1239
+ rowHeight = Math.max(rowHeight, component.bounds.height);
1240
+ }
1241
+ // Components are packed in reading order. For UP and LEFT layouts, mirror the
1242
+ // packing along the flow so later components follow the first one's flow
1243
+ // instead of preceding it (e.g. an isolated state below an UP statechart's start).
1244
+ const direction = options.direction ?? graph.direction ?? "right";
1245
+ const packedWidth = Math.max(0, ...placements.map((p) => p.x + p.component.bounds.width));
1246
+ const packedHeight = Math.max(0, ...placements.map((p) => p.y + p.component.bounds.height));
1247
+ for (const { component, x: packedX, y: packedY } of placements) {
1248
+ const left = direction === "left" ? packedWidth - packedX - component.bounds.width : packedX;
1249
+ const top = direction === "up" ? packedHeight - packedY - component.bounds.height : packedY;
1250
+ const dx = padding.left + left - component.bounds.left;
1251
+ const dy = padding.top + top - component.bounds.top;
1252
+ for (const node of component.result.nodes)
1253
+ nodeResults.set(node.id, { ...node, x: (node.x ?? 0) + dx, y: (node.y ?? 0) + dy });
1166
1254
  for (const edge of component.result.edges) {
1167
1255
  edgeResults.set(edge.id, {
1168
1256
  ...edge,
@@ -1171,8 +1259,6 @@ function runSeparatedComponents<N, E, G, P>(
1171
1259
  points: edge.points?.map((point) => ({ x: point.x + dx, y: point.y + dy })),
1172
1260
  });
1173
1261
  }
1174
- x += component.bounds.width + componentSpacing;
1175
- rowHeight = Math.max(rowHeight, component.bounds.height);
1176
1262
  }
1177
1263
 
1178
1264
  return {
@@ -1209,6 +1295,52 @@ function runLayeredPipeline<N, E, G, P>(
1209
1295
  options: LayeredLayoutOptions,
1210
1296
  context?: LayoutExecutionContext,
1211
1297
  ): VisualGraph<N, E, G, P> {
1298
+ const pipeline = createLayeredScopePipeline(graph, options, context);
1299
+ const prepared = pipeline.next();
1300
+ if (prepared.done) return prepared.value;
1301
+ const completed = pipeline.next(prepared.value.minimize());
1302
+ if (!completed.done) throw new Error("Layered scope yielded more than one crossing phase");
1303
+ return completed.value;
1304
+ }
1305
+
1306
+ /** @internal A prepared native scope, before crossing, placement or routing. */
1307
+ export interface LayeredCrossingPhase {
1308
+ input: LayeredPhaseInput;
1309
+ orientation: AcyclicOrientation;
1310
+ assignment: LayerAssignment;
1311
+ /** Refresh compound body dimensions after child placement, before this scope resumes. */
1312
+ updateNodeSize(id: string, size: NodeSize): void;
1313
+ finishOrder(order: LayerOrder): LayerOrder;
1314
+ /** Run this scope's standalone minimizer, including authored ordering policies. */
1315
+ minimize(): LayerOrder;
1316
+ }
1317
+
1318
+ /**
1319
+ * @internal Suspend a native scope at crossing minimization. A hierarchy
1320
+ * coordinator can prepare every scope, share sweep state, then resume each
1321
+ * scope with its final order. The ordinary entry point drains this same pipeline.
1322
+ */
1323
+ export function* createLayeredScopePipeline<N, E, G, P>(
1324
+ graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
1325
+ options: LayeredLayoutOptions,
1326
+ context?: LayoutExecutionContext,
1327
+ ): Generator<LayeredCrossingPhase, VisualGraph<N, E, G, P>, LayerOrder> {
1328
+ if ((options.settings?.["spacing.edgeEdge"] ?? 10) < 2) {
1329
+ options = { ...options, settings: { ...options.settings, "spacing.edgeEdge": 2 } };
1330
+ }
1331
+ if (options.hints?.length) {
1332
+ const { hints, nodeSettings } = options;
1333
+ options = {
1334
+ ...options,
1335
+ nodeSettings: (node) => {
1336
+ const base = nodeSettings?.(node);
1337
+ const constraint = hintLayerConstraint(hints, node);
1338
+ return constraint && base?.["layering.layerConstraint"] === undefined
1339
+ ? { ...base, "layering.layerConstraint": constraint }
1340
+ : base;
1341
+ },
1342
+ };
1343
+ }
1212
1344
  if (options.settings?.noLayout) {
1213
1345
  return {
1214
1346
  ...graph,
@@ -1346,8 +1478,11 @@ function runLayeredPipeline<N, E, G, P>(
1346
1478
  );
1347
1479
  })();
1348
1480
  const orientation = measure("cycle-breaking", () =>
1349
- applyPartitionOrientation(input, applyLayerConstraintOrientation(input, cycleBreaker(input))),
1481
+ applyPartitionOrientation(input, breakCyclesWithConstraints(input, cycleBreaker)),
1350
1482
  );
1483
+ const portLists = simulatePortEdgeLists(input, orientation);
1484
+ const hyperedges = importsHyperedges(input);
1485
+ traceLayeredPhase(() => ({ kind: "port-lists", scope: graph.id, ports: portLists }));
1351
1486
  const layeringStrategy = options.settings?.["layering.strategy"] ?? "NETWORK_SIMPLEX";
1352
1487
  const layerAssigner = (() => {
1353
1488
  if (options.strategies?.assignLayers) return options.strategies.assignLayers;
@@ -1364,20 +1499,249 @@ function runLayeredPipeline<N, E, G, P>(
1364
1499
  `Layering strategy ${layeringStrategy} is not implemented yet`,
1365
1500
  );
1366
1501
  })();
1367
- const assignment = measure("layer-assignment", () =>
1502
+ const labels = measure("center-label-preprocessing", () =>
1503
+ insertCenterLabelDummies(input, orientation),
1504
+ );
1505
+ let phaseInput = labels.input;
1506
+ const phaseOrientation = labels.orientation;
1507
+ // ELK's layer-constraint preprocessor detaches separate external boundaries.
1508
+ // Their incident edges must not stretch unrelated internal components.
1509
+ const separateBoundaryIds = new Set(
1510
+ phaseInput.graph.nodes
1511
+ .filter((node) => {
1512
+ if (!externalPortDummyOf(node)) return false;
1513
+ const constraint = phaseInput.nodeSettings?.(node)?.["layering.layerConstraint"];
1514
+ return constraint === "FIRST_SEPARATE" || constraint === "LAST_SEPARATE";
1515
+ })
1516
+ .map((node) => node.id),
1517
+ );
1518
+ const boundaryConnections = new Map<string, Set<string>>();
1519
+ for (const edge of phaseInput.graph.edges) {
1520
+ const hiddenId = separateBoundaryIds.has(edge.sourceId)
1521
+ ? edge.sourceId
1522
+ : separateBoundaryIds.has(edge.targetId)
1523
+ ? edge.targetId
1524
+ : undefined;
1525
+ if (hiddenId === undefined) continue;
1526
+ const oppositeId = edge.sourceId === hiddenId ? edge.targetId : edge.sourceId;
1527
+ if (separateBoundaryIds.has(oppositeId)) continue;
1528
+ const hiddenNode = phaseInput.graph.nodes.find((node) => node.id === hiddenId)!;
1529
+ const connections = boundaryConnections.get(oppositeId) ?? new Set<string>();
1530
+ connections.add(String(phaseInput.nodeSettings?.(hiddenNode)?.["layering.layerConstraint"]));
1531
+ boundaryConnections.set(oppositeId, connections);
1532
+ }
1533
+ const inferredConstraints = new Map<string, "FIRST" | "LAST">();
1534
+ for (const [id, connections] of boundaryConnections) {
1535
+ const node = phaseInput.graph.nodes.find((node) => node.id === id)!;
1536
+ if (
1537
+ phaseInput.nodeSettings?.(node)?.["layering.layerConstraint"] !== undefined ||
1538
+ connections.size !== 1
1539
+ )
1540
+ continue;
1541
+ if (
1542
+ phaseInput.graph.edges.some(
1543
+ (edge) =>
1544
+ (edge.sourceId === id && !separateBoundaryIds.has(edge.targetId)) ||
1545
+ (edge.targetId === id && !separateBoundaryIds.has(edge.sourceId)),
1546
+ )
1547
+ )
1548
+ continue;
1549
+ inferredConstraints.set(id, connections.has("FIRST_SEPARATE") ? "FIRST" : "LAST");
1550
+ }
1551
+ if (inferredConstraints.size > 0) {
1552
+ const authoredSettings = phaseInput.nodeSettings;
1553
+ phaseInput = {
1554
+ ...phaseInput,
1555
+ nodeSettings: (node) => {
1556
+ const inferred = inferredConstraints.get(node.id);
1557
+ return inferred
1558
+ ? { ...authoredSettings?.(node), "layering.layerConstraint": inferred }
1559
+ : authoredSettings?.(node);
1560
+ },
1561
+ };
1562
+ }
1563
+ const layeringInput =
1564
+ separateBoundaryIds.size === 0
1565
+ ? phaseInput
1566
+ : {
1567
+ ...phaseInput,
1568
+ graph: {
1569
+ ...phaseInput.graph,
1570
+ nodes: phaseInput.graph.nodes.filter((node) => !separateBoundaryIds.has(node.id)),
1571
+ edges: phaseInput.graph.edges.filter(
1572
+ (edge) =>
1573
+ !separateBoundaryIds.has(edge.sourceId) && !separateBoundaryIds.has(edge.targetId),
1574
+ ),
1575
+ },
1576
+ };
1577
+ let assignment = measure("layer-assignment", () =>
1368
1578
  applyHighDegreeNodeTreatment(
1369
- input,
1370
- orientation,
1579
+ phaseInput,
1580
+ phaseOrientation,
1371
1581
  applyNodePromotion(
1372
- input,
1373
- orientation,
1374
- applyPartitions(input, applyLayerConstraints(input, layerAssigner(input, orientation))),
1582
+ phaseInput,
1583
+ phaseOrientation,
1584
+ applyPartitions(
1585
+ phaseInput,
1586
+ applyLayerConstraints(phaseInput, layerAssigner(layeringInput, phaseOrientation)),
1587
+ ),
1375
1588
  ),
1376
1589
  ),
1377
1590
  );
1378
- let expanded = measure("long-edge-splitting", () =>
1379
- splitLongEdges(input, orientation, assignment),
1591
+ // Preserve network-simplex component order before restoring detached boundary
1592
+ // nodes; recomputing components after splitting changes the initial sweep.
1593
+ // ELK restores FIRST/LAST nodes before LongEdgeSplitter appends dummies.
1594
+ // Changing that order changes the initial sweep and selected port order.
1595
+ if (
1596
+ separateBoundaryIds.size > 0 ||
1597
+ phaseInput.graph.nodes.some((node) => {
1598
+ const constraint = phaseInput.nodeSettings?.(node)?.["layering.layerConstraint"];
1599
+ return constraint === "FIRST" || constraint === "LAST";
1600
+ })
1601
+ ) {
1602
+ const layers = Array.from(
1603
+ { length: Math.max(0, ...assignment.layerByNodeId.values()) + 1 },
1604
+ () => [] as string[],
1605
+ );
1606
+ const seed =
1607
+ assignment.seedOrder ??
1608
+ ((phaseInput.settings["layering.strategy"] ?? "NETWORK_SIMPLEX") === "NETWORK_SIMPLEX"
1609
+ ? networkSimplexComponents(layeringInput).flat()
1610
+ : phaseInput.graph.nodes.map((node) => node.id));
1611
+ const seeded = new Set(seed);
1612
+ for (const id of [
1613
+ ...seed,
1614
+ ...phaseInput.graph.nodes.flatMap((node) => (seeded.has(node.id) ? [] : [node.id])),
1615
+ ]) {
1616
+ layers[assignment.layerByNodeId.get(id) ?? 0]!.push(id);
1617
+ }
1618
+ assignment = {
1619
+ ...assignment,
1620
+ seedOrder: applyLayerConstraintOrder(phaseInput, { layers }).layers.flat(),
1621
+ };
1622
+ }
1623
+
1624
+ const hierarchicalPorts = prepareHierarchicalPortConstraints(
1625
+ phaseInput,
1626
+ phaseOrientation,
1627
+ assignment,
1380
1628
  );
1629
+ phaseInput = hierarchicalPorts.input;
1630
+ assignment = hierarchicalPorts.assignment;
1631
+ const labeledPortLists = insertLabelDummyPorts(portLists, orientation, labels.segmentIdsByEdgeId);
1632
+ let incomingLists: ReadonlyMap<string, readonly PortEdgeList[]> = labeledPortLists;
1633
+ let expanded = measure("long-edge-splitting", () => {
1634
+ const split = withLongEdgeSplitterOrder(
1635
+ phaseInput,
1636
+ splitLongEdges(phaseInput, phaseOrientation, assignment),
1637
+ assignment.seedOrder ??
1638
+ ((phaseInput.settings["layering.strategy"] ?? "NETWORK_SIMPLEX") === "NETWORK_SIMPLEX"
1639
+ ? elkComponentOrder(layeringInput, labeledPortLists)
1640
+ : phaseInput.graph.nodes.map((node) => node.id)),
1641
+ portLists,
1642
+ );
1643
+ incomingLists = incomingAfterLongEdgeSplitting(labeledPortLists, split);
1644
+ return composeCenterLabelExpansion(labels, insertInvertedPortDummies(split, incomingLists));
1645
+ });
1646
+ // ELK sorts before north/south helpers exist. Carry both node and physical
1647
+ // port order into the crossing session, including newly generated helpers.
1648
+ const prepareModelOrder =
1649
+ (expanded.input.settings["considerModelOrder.strategy"] ?? "NONE") !== "NONE" &&
1650
+ (expanded.input.settings["crossingMinimization.strategy"] ?? "LAYER_SWEEP") === "LAYER_SWEEP" &&
1651
+ !options.strategies?.minimizeCrossings;
1652
+ let preparedPorts: ReadonlyMap<string, readonly string[]> | undefined;
1653
+ if (prepareModelOrder) {
1654
+ const count =
1655
+ expanded.assignment.layerCount ?? Math.max(...expanded.assignment.layerByNodeId.values()) + 1;
1656
+ const layers: string[][] = Array.from({ length: count }, () => []);
1657
+ const seeded = expanded.assignment.seedOrder ?? [];
1658
+ const initialIds = [
1659
+ ...seeded,
1660
+ ...expanded.input.graph.nodes.map((node) => node.id).filter((id) => !seeded.includes(id)),
1661
+ ];
1662
+ for (const id of initialIds) layers[expanded.assignment.layerByNodeId.get(id) ?? 0]!.push(id);
1663
+ const initial = crossingGraph(expanded.input, expanded.orientation, layers);
1664
+ for (const node of initial.layers.flat())
1665
+ if (node.id.startsWith("__layout_dummy:"))
1666
+ node.type = node.id.startsWith("__layout_dummy:label:") ? "LABEL" : "LONG_EDGE";
1667
+ const adjacency = new Map(
1668
+ [...(expanded.incomingEdgeOrderByNodeId ?? [])].map(([id, edges]) => [
1669
+ id,
1670
+ edges.flatMap((edge) => expanded.segmentIdsByEdgeId.get(edge) ?? [edge]),
1671
+ ]),
1672
+ );
1673
+ sortInitialModelOrder(expanded.input, expanded.orientation, initial, adjacency);
1674
+ preparedPorts = new Map(
1675
+ initial.layers.flat().map((node) => [node.id, node.ports.map((port) => port.id)]),
1676
+ );
1677
+ expanded = {
1678
+ ...expanded,
1679
+ assignment: {
1680
+ ...expanded.assignment,
1681
+ seedOrder: initial.layers.flatMap((layer) => layer.map((node) => node.id)),
1682
+ },
1683
+ };
1684
+ }
1685
+ const northSouth = insertNorthSouthPortDummies(expanded);
1686
+ expanded = northSouth.expansion;
1687
+ if (prepareModelOrder) markInitialModelOrderPrepared(expanded.input, preparedPorts!);
1688
+ const associates = new Map<string, string[]>(),
1689
+ unitMembers = new Map<string, string[]>();
1690
+ for (const [dummy, origin] of northSouth.originsByDummyId)
1691
+ associates.set(origin.node.id, [...(associates.get(origin.node.id) ?? []), dummy]);
1692
+ for (const id of expanded.assignment.seedOrder ?? expanded.input.graph.nodes.map((n) => n.id)) {
1693
+ const owner = northSouth.layoutUnitByNodeId.get(id);
1694
+ if (owner) unitMembers.set(owner, [...(unitMembers.get(owner) ?? []), id]);
1695
+ }
1696
+ const northEdges = new Set<string>(),
1697
+ southEdges = new Set<string>();
1698
+ const upper = direction === "right" || direction === "left" ? "NORTH" : "WEST";
1699
+ const lower = direction === "right" || direction === "left" ? "SOUTH" : "EAST";
1700
+ for (const edge of expanded.input.graph.edges)
1701
+ for (const [id, name] of [
1702
+ [edge.sourceId, edge.sourcePort],
1703
+ [edge.targetId, edge.targetPort],
1704
+ ]) {
1705
+ const node = expanded.input.graph.nodes.find((n) => n.id === id),
1706
+ port = node?.ports?.find((p) => p.name === name);
1707
+ const side =
1708
+ node && port ? expanded.input.portSettings?.(port, node)?.["port.side"] : undefined;
1709
+ if (side === upper) northEdges.add(id!);
1710
+ if (side === lower) southEdges.add(id!);
1711
+ }
1712
+ recordCrossingUnits(expanded.input, {
1713
+ edgeListRanks: {
1714
+ incoming: edgeListRanks(incomingLists, "incoming"),
1715
+ outgoing: edgeListRanks(labeledPortLists, "outgoing"),
1716
+ },
1717
+ northSouthOrigins: northSouth.originsByDummyId,
1718
+ incomingEdgeOrderByDummyId: northSouth.incomingEdgeOrderByDummyId,
1719
+ successors: northSouth.successorsByNodeId,
1720
+ units: northSouth.layoutUnitByNodeId,
1721
+ unitMembers,
1722
+ associates,
1723
+ northEdges,
1724
+ southEdges,
1725
+ normalNodes: new Set(
1726
+ expanded.input.graph.nodes
1727
+ .filter(
1728
+ (n) => !n.id.startsWith("__layout_dummy:") && !n.id.startsWith("__layout_breaking:"),
1729
+ )
1730
+ .map((n) => n.id),
1731
+ ),
1732
+ longEdgeNodes: new Set(
1733
+ expanded.input.graph.nodes
1734
+ .filter(
1735
+ (n) =>
1736
+ (n.id.startsWith("__layout_dummy:") &&
1737
+ !n.id.startsWith("__layout_dummy:label:") &&
1738
+ !n.id.startsWith("__layout_dummy:north-south:")) ||
1739
+ n.id.startsWith("__layout_breaking:"),
1740
+ )
1741
+ .map((n) => n.id),
1742
+ ),
1743
+ });
1744
+
1381
1745
  const crossingStrategy = options.settings?.["crossingMinimization.strategy"] ?? "LAYER_SWEEP";
1382
1746
  const crossingMinimizer = (() => {
1383
1747
  if (options.strategies?.minimizeCrossings) return options.strategies.minimizeCrossings;
@@ -1393,23 +1757,143 @@ function runLayeredPipeline<N, E, G, P>(
1393
1757
  `Crossing-minimization strategy ${crossingStrategy} is not implemented`,
1394
1758
  );
1395
1759
  })();
1396
- let order = measure("crossing-minimization", () =>
1397
- applyLayerConstraintOrder(
1760
+ const finishCrossingOrder = (order: LayerOrder) =>
1761
+ applyGreedySwitch(
1398
1762
  expanded.input,
1399
- applyGreedySwitch(
1763
+ expanded.orientation,
1764
+ applySemiInteractiveOrder(
1400
1765
  expanded.input,
1401
- expanded.orientation,
1402
- applySemiInteractiveOrder(
1403
- expanded.input,
1404
- applyForcedModelOrder(
1766
+ applyForcedModelOrder(expanded.input, expanded.orientation, order),
1767
+ ),
1768
+ );
1769
+ const minimizeScope = () =>
1770
+ measure("crossing-minimization", () =>
1771
+ finishCrossingOrder(
1772
+ crossingMinimizer(expanded.input, expanded.orientation, expanded.assignment),
1773
+ ),
1774
+ );
1775
+ let order = yield {
1776
+ input: expanded.input,
1777
+ orientation: expanded.orientation,
1778
+ assignment: expanded.assignment,
1779
+ updateNodeSize: (id, size) => {
1780
+ for (const holder of [input, labels.input, phaseInput, expanded.input]) {
1781
+ (holder.sizes as Map<string, NodeSize>).set(id, { ...size });
1782
+ }
1783
+ },
1784
+ minimize: minimizeScope,
1785
+ finishOrder: finishCrossingOrder,
1786
+ };
1787
+ traceLayeredPhase(() => ({
1788
+ kind: "crossing-order",
1789
+ scope: graph.id,
1790
+ edgeIds: phaseInput.graph.edges.map((edge) => edge.id),
1791
+ reversedEdgeIds: [...phaseOrientation.reversedEdgeIds],
1792
+ layerByNodeId: assignment.layerByNodeId,
1793
+ layers: order.layers,
1794
+ }));
1795
+ // ELK's InLayerConstraintProcessor: northern hierarchical port helpers lead
1796
+ // their layer and southern ones end it, keeping their relative order.
1797
+ const inLayerSide = new Map(
1798
+ [...hierarchicalPorts.replacements.values()].map((record) => [record.helper.id, record.side]),
1799
+ );
1800
+ if (inLayerSide.size)
1801
+ order = {
1802
+ ...order,
1803
+ layers: order.layers.map((layer) => [
1804
+ ...layer.filter((id) => inLayerSide.get(id) === "NORTH"),
1805
+ ...layer.filter((id) => !inLayerSide.has(id)),
1806
+ ...layer.filter((id) => inLayerSide.get(id) === "SOUTH"),
1807
+ ]),
1808
+ };
1809
+ // Forced model order can move other nodes between a node and its
1810
+ // north/south port dummies, whose edges would then cross those nodes.
1811
+ // Rejoin them, moving either the dummies or the intervening nodes,
1812
+ // whichever crosses fewer edges.
1813
+ const dummiesByOwner = new Map<string, Set<string>>();
1814
+ for (const [dummy, origin] of northSouth.originsByDummyId)
1815
+ dummiesByOwner.set(
1816
+ origin.node.id,
1817
+ (dummiesByOwner.get(origin.node.id) ?? new Set()).add(dummy),
1818
+ );
1819
+ const separated = (layer: readonly string[], owner: string, own: ReadonlySet<string>) => {
1820
+ const at = layer.indexOf(owner);
1821
+ const span = layer.flatMap((id, index) => (own.has(id) ? [index] : []));
1822
+ const [from, to] = [Math.min(at, ...span), Math.max(at, ...span)];
1823
+ return {
1824
+ at,
1825
+ from,
1826
+ to,
1827
+ between: layer.slice(from, to + 1).filter((id) => id !== owner && !own.has(id)),
1828
+ };
1829
+ };
1830
+ const intrudes = (between: readonly string[]) => between.some((id) => !id.startsWith("__"));
1831
+ for (let layerIndex = 0; layerIndex < order.layers.length; layerIndex++)
1832
+ for (const [owner, own] of dummiesByOwner) {
1833
+ const current = order.layers[layerIndex]!;
1834
+ if (!current.includes(owner)) continue;
1835
+ const { at, from, to, between } = separated(current, owner, own);
1836
+ if (!intrudes(between)) continue;
1837
+ const before = current.slice(from, at).filter((id) => own.has(id)),
1838
+ after = current.slice(at + 1, to + 1).filter((id) => own.has(id));
1839
+ const near = between.filter((id) => current.indexOf(id) < at),
1840
+ far = between.filter((id) => current.indexOf(id) > at);
1841
+ const head = current.slice(0, from),
1842
+ tail = current.slice(to + 1);
1843
+ const unit = [...before, owner, ...after];
1844
+ // Intervening nodes stay on their side of the owner, or pass it.
1845
+ const candidates = [
1846
+ [...head, ...near, ...unit, ...far, ...tail],
1847
+ [...head, ...far, ...unit, ...near, ...tail],
1848
+ ];
1849
+ const score = (candidate: string[]) =>
1850
+ countAllCrossings(
1851
+ crossingGraph(
1405
1852
  expanded.input,
1406
1853
  expanded.orientation,
1407
- crossingMinimizer(expanded.input, expanded.orientation, expanded.assignment),
1854
+ order.layers.map((members, index) => (index === layerIndex ? candidate : members)),
1408
1855
  ),
1856
+ ).total;
1857
+ // Never separate another node from its own dummies.
1858
+ const valid = candidates.filter((candidate) =>
1859
+ [...dummiesByOwner].every(
1860
+ ([other, dummies]) =>
1861
+ other === owner ||
1862
+ !candidate.includes(other) ||
1863
+ intrudes(separated(candidate, other, dummies).between) ===
1864
+ intrudes(separated(current, other, dummies).between),
1409
1865
  ),
1410
- ),
1411
- ),
1866
+ );
1867
+ const best = (valid.length ? valid : candidates.slice(0, 1)).reduce((winner, candidate) =>
1868
+ score(candidate) < score(winner) ? candidate : winner,
1869
+ );
1870
+ order = {
1871
+ ...order,
1872
+ layers: order.layers.map((members, index) => (index === layerIndex ? best : members)),
1873
+ };
1874
+ }
1875
+ order = applyOrderHints(
1876
+ options.hints,
1877
+ order,
1878
+ (id) => [...(dummiesByOwner.get(id) ?? [])],
1879
+ (layers) =>
1880
+ countAllCrossings(crossingGraph(expanded.input, expanded.orientation, layers)).total,
1881
+ context?.diagnostics,
1412
1882
  );
1883
+ // ELK's SweepCopy.assertCorrectPortSides: a north/south port whose dummy
1884
+ // ended on the other side of its node switches to that side.
1885
+ for (const layer of order.layers) {
1886
+ const position = new Map(layer.map((id, index) => [id, index]));
1887
+ for (const [dummy, origin] of northSouth.originsByDummyId) {
1888
+ const at = position.get(dummy),
1889
+ owner = position.get(origin.node.id);
1890
+ if (at === undefined || owner === undefined || at < owner === origin.beforeOwner) continue;
1891
+ const opposite = ({ NORTH: "SOUTH", SOUTH: "NORTH", WEST: "EAST", EAST: "WEST" } as const)[
1892
+ origin.side
1893
+ ];
1894
+ switchedSideByPort.set(`${origin.node.id}\0${origin.port.name}`, opposite);
1895
+ }
1896
+ }
1413
1897
  const unzippingFanIn =
1414
1898
  graph.edges.length === graph.nodes.length - 1 &&
1415
1899
  graph.nodes.some(
@@ -1426,6 +1910,19 @@ function runLayeredPipeline<N, E, G, P>(
1426
1910
  }
1427
1911
  }
1428
1912
  order = applyDirectionCongruency(expanded.input, order);
1913
+ const crossingUnits = getCrossingUnits(expanded.input);
1914
+ const switchedLabels = switchCenterLabelDummies(expanded, labels, order);
1915
+ expanded = switchedLabels.expansion;
1916
+ order = switchedLabels.order;
1917
+ const labelSelection = selectCenterLabelSides(expanded, labels, order);
1918
+ expanded = labelSelection.expansion;
1919
+ if (crossingUnits) recordCrossingUnits(expanded.input, crossingUnits);
1920
+ if (hyperedges) order = mergeHyperedgeDummies(expanded, order);
1921
+ expanded = {
1922
+ ...expanded,
1923
+ input: sizeHierarchicalPortDummies(expanded.input, order, hierarchicalPorts),
1924
+ };
1925
+ setPlacementOrder(expanded.input, order);
1429
1926
  const nodePlacementStrategy = options.settings?.["nodePlacement.strategy"] ?? "BRANDES_KOEPF";
1430
1927
  const nodePlacer = (() => {
1431
1928
  if (options.strategies?.placeNodes) return options.strategies.placeNodes;
@@ -1435,7 +1932,11 @@ function runLayeredPipeline<N, E, G, P>(
1435
1932
  if (nodePlacementStrategy === "NETWORK_SIMPLEX") return placeNodesWithNetworkSimplex;
1436
1933
  return placeNodesInLayers;
1437
1934
  })();
1438
- const placement = measure("node-placement", () => nodePlacer(expanded.input, order));
1935
+ const placement = measure("node-placement", () => {
1936
+ setPlacementOrientation(expanded.input, expanded.orientation);
1937
+ return nodePlacer(expanded.input, order);
1938
+ });
1939
+ applyPlacementHints(options.hints, expanded.input, order, placement, context?.diagnostics);
1439
1940
  const mutableRects = placement.rectByNodeId as Map<
1440
1941
  string,
1441
1942
  { x: number; y: number; width: number; height: number }
@@ -1583,54 +2084,6 @@ function runLayeredPipeline<N, E, G, P>(
1583
2084
  );
1584
2085
  }
1585
2086
  }
1586
- {
1587
- const horizontal = direction === "right" || direction === "left";
1588
- const targetsBySource = new Map<string, Set<string>>();
1589
- for (const edge of graph.edges) {
1590
- if (edge.sourceId === edge.targetId) continue;
1591
- const targets = targetsBySource.get(edge.sourceId) ?? new Set<string>();
1592
- targets.add(edge.targetId);
1593
- targetsBySource.set(edge.sourceId, targets);
1594
- }
1595
- for (const edge of graph.edges) {
1596
- if (edge.sourcePort === undefined) continue;
1597
- const source = graph.nodes.find((node) => node.id === edge.sourceId);
1598
- const targetRect = mutableRects.get(edge.targetId);
1599
- const sourceRect = mutableRects.get(edge.sourceId);
1600
- const port = source?.ports?.find((candidate) => candidate.name === edge.sourcePort);
1601
- if (!source || !sourceRect || !targetRect || !port) continue;
1602
- const side = input.portSettings?.(port, source)?.["port.side"];
1603
- // A port side constrains its route, not the positions of an entire fan-out.
1604
- // Align an isolated target only when the placement's separation survives.
1605
- if (edge.sourceId === edge.targetId || (targetsBySource.get(edge.sourceId)?.size ?? 0) > 1)
1606
- continue;
1607
- const candidate =
1608
- horizontal && side === "NORTH"
1609
- ? { ...targetRect, y: sourceRect.y - (port.height ?? 0) - targetRect.height }
1610
- : horizontal && side === "SOUTH"
1611
- ? { ...targetRect, y: sourceRect.y + sourceRect.height + (port.height ?? 0) }
1612
- : !horizontal && side === "WEST"
1613
- ? { ...targetRect, x: sourceRect.x - (port.width ?? 0) - targetRect.width }
1614
- : !horizontal && side === "EAST"
1615
- ? { ...targetRect, x: sourceRect.x + sourceRect.width + (port.width ?? 0) }
1616
- : undefined;
1617
- if (
1618
- !candidate ||
1619
- [...mutableRects].some(
1620
- ([id, other]) =>
1621
- id !== edge.targetId &&
1622
- other.width > 0 &&
1623
- other.height > 0 &&
1624
- candidate.x < other.x + other.width &&
1625
- candidate.x + candidate.width > other.x &&
1626
- candidate.y < other.y + other.height &&
1627
- candidate.y + candidate.height > other.y,
1628
- )
1629
- )
1630
- continue;
1631
- mutableRects.set(edge.targetId, candidate);
1632
- }
1633
- }
1634
2087
  measure("port-margin-normalization", () =>
1635
2088
  normalizePlacementForPortExtents(expanded.input, placement, order, expanded.orientation),
1636
2089
  );
@@ -1692,12 +2145,70 @@ function runLayeredPipeline<N, E, G, P>(
1692
2145
  : edgeRouting === "SPLINES"
1693
2146
  ? routeEdgesWithSplines
1694
2147
  : routeEdgesOrthogonally);
2148
+ // A custom router owns its output. Compact placement before invoking it,
2149
+ // rather than moving or orthogonalizing its routes afterwards.
2150
+ if (options.strategies?.routeEdges)
2151
+ measure("post-compaction", () => applyPostCompaction(expanded.input, placement));
1695
2152
  let expandedRoutes = measure("edge-routing", () =>
1696
2153
  edgeRouter(expanded.input, expanded.orientation, placement),
1697
2154
  );
1698
- measure("post-compaction", () =>
1699
- applyPostCompaction(expanded.input, placement, expandedRoutes, graph.edges),
2155
+ const restoredJunctionCounts = new Map<string, number>();
2156
+ expandedRoutes = restoreNorthSouthPortRoutes(
2157
+ { ...northSouth, expansion: expanded },
2158
+ placement,
2159
+ expandedRoutes,
2160
+ (origin) => {
2161
+ const rect = placement.rectByNodeId.get(origin.node.id)!;
2162
+ return getPortPoint(
2163
+ origin.node,
2164
+ origin.port.name,
2165
+ rect,
2166
+ { x: rect.x, y: rect.y },
2167
+ direction,
2168
+ expanded.input,
2169
+ );
2170
+ },
2171
+ restoredJunctionCounts,
2172
+ );
2173
+ const restoredNodes = expanded.input.graph.nodes.filter(
2174
+ (node) => !northSouth.originsByDummyId.has(node.id),
1700
2175
  );
2176
+ const restoredEdges = expanded.input.graph.edges.map((edge) => {
2177
+ const source = northSouth.originsByDummyId.get(edge.sourceId),
2178
+ target = northSouth.originsByDummyId.get(edge.targetId);
2179
+ return source || target
2180
+ ? {
2181
+ ...edge,
2182
+ sourceId: source?.node.id ?? edge.sourceId,
2183
+ sourcePort: source?.port.name ?? edge.sourcePort,
2184
+ targetId: target?.node.id ?? edge.targetId,
2185
+ targetPort: target?.port.name ?? edge.targetPort,
2186
+ }
2187
+ : edge;
2188
+ });
2189
+ for (const dummy of northSouth.originsByDummyId.keys())
2190
+ (placement.rectByNodeId as Map<string, EntityRect>).delete(dummy);
2191
+ expanded = {
2192
+ ...expanded,
2193
+ input: inheritCycleRandom(expanded.input, {
2194
+ ...expanded.input,
2195
+ graph: { ...expanded.input.graph, nodes: restoredNodes, edges: restoredEdges },
2196
+ }),
2197
+ };
2198
+ const hierarchicalRestoration = restoreHierarchicalPorts(
2199
+ expanded.input,
2200
+ order,
2201
+ hierarchicalPorts,
2202
+ placement,
2203
+ expandedRoutes,
2204
+ );
2205
+ expanded = { ...expanded, input: hierarchicalRestoration.input };
2206
+ expandedRoutes = hierarchicalRestoration.routes;
2207
+ setPlacementOrientation(expanded.input, expanded.orientation);
2208
+ if (!options.strategies?.routeEdges)
2209
+ measure("post-compaction", () =>
2210
+ applyPostCompaction(expanded.input, placement, expandedRoutes, graph.edges),
2211
+ );
1701
2212
  const antiparallelLabelPositions = new Map<string, Point>();
1702
2213
  const outerAntiparallelLabelIds = new Set<string>();
1703
2214
  const parallelLabelPositions = new Map<string, Point>();
@@ -1911,9 +2422,20 @@ function runLayeredPipeline<N, E, G, P>(
1911
2422
  }
1912
2423
 
1913
2424
  const forwardLabel = antiparallelLabelPositions.get(forward.id)!;
1914
- const forwardCross = horizontal
1915
- ? Math.round(forwardLabel.y + (forward.height ?? 0) / 2)
1916
- : Math.round(forwardLabel.x + (forward.width ?? 0) / 2);
2425
+ // BK keeps the forward chain straight through its center label dummy.
2426
+ const forwardDummy = expanded.input.graph.nodes.find(
2427
+ (node) => node.id === expanded.labelDummyIdByEdgeId.get(forward.id),
2428
+ );
2429
+ const forwardDummyRect = forwardDummy && placement.rectByNodeId.get(forwardDummy.id);
2430
+ const forwardDummyPort = forwardDummy?.ports?.[0];
2431
+ const forwardCross =
2432
+ forwardDummyRect && forwardDummyPort
2433
+ ? horizontal
2434
+ ? forwardDummyRect.y + (forwardDummyPort.y ?? 0)
2435
+ : forwardDummyRect.x + (forwardDummyPort.x ?? 0)
2436
+ : horizontal
2437
+ ? Math.round(forwardLabel.y + (forward.height ?? 0) / 2)
2438
+ : Math.round(forwardLabel.x + (forward.width ?? 0) / 2);
1917
2439
  const alignPort = (node: GraphNode, portName: string): void => {
1918
2440
  const rect = placement.rectByNodeId.get(node.id);
1919
2441
  const port = node.ports?.find((candidate) => candidate.name === portName);
@@ -1950,7 +2472,8 @@ function runLayeredPipeline<N, E, G, P>(
1950
2472
  }
1951
2473
  if (postCompactionNodeCrossDeltas.size > 0) {
1952
2474
  const pointsByEdgeId = new Map(expandedRoutes.pointsByEdgeId);
1953
- for (const edge of graph.edges) {
2475
+ // Routes are keyed by long-edge and label-dummy segments, not by original edges.
2476
+ for (const edge of expanded.input.graph.edges) {
1954
2477
  const points = [...(pointsByEdgeId.get(edge.id) ?? [])];
1955
2478
  if (points.length === 0) continue;
1956
2479
  const sourceDelta = postCompactionNodeCrossDeltas.get(edge.sourceId) ?? 0;
@@ -1972,19 +2495,62 @@ function runLayeredPipeline<N, E, G, P>(
1972
2495
  };
1973
2496
  shiftEndpoint(0, 1, sourceDelta);
1974
2497
  shiftEndpoint(points.length - 1, points.length - 2, targetDelta);
1975
- pointsByEdgeId.set(edge.id, points);
2498
+ // A shift that straightens the jog leaves collinear bends behind.
2499
+ pointsByEdgeId.set(
2500
+ edge.id,
2501
+ sourceDelta === 0 && targetDelta === 0
2502
+ ? points
2503
+ : points.filter(
2504
+ (point, index) =>
2505
+ index === 0 ||
2506
+ index === points.length - 1 ||
2507
+ !(
2508
+ (point.x === points[index - 1]!.x && point.x === points[index + 1]!.x) ||
2509
+ (point.y === points[index - 1]!.y && point.y === points[index + 1]!.y)
2510
+ ),
2511
+ ),
2512
+ );
1976
2513
  }
1977
2514
  expandedRoutes = { ...expandedRoutes, pointsByEdgeId };
1978
2515
  }
1979
2516
  let routes = measure("long-edge-joining", () =>
1980
2517
  joinLongEdgeRoutes(
1981
- expandedRoutes,
2518
+ removeCenterLabelJunctions(
2519
+ expandedRoutes,
2520
+ expanded,
2521
+ labels.labelDummyIdByEdgeId,
2522
+ edgeRouting,
2523
+ ),
1982
2524
  expanded.segmentIdsByEdgeId,
1983
2525
  edgeRouting === "SPLINES" || options.settings?.unnecessaryBendpoints === true,
1984
2526
  edgeRouting === "SPLINES",
1985
2527
  Number(options.settings?.["spacing.edgeNodeBetweenLayers"] ?? 10),
2528
+ edgeRouting === "ORTHOGONAL",
2529
+ restoredJunctionCounts,
2530
+ expanded.orientation.reversedEdgeIds,
1986
2531
  ),
1987
2532
  );
2533
+ if (options.settings?.feedbackEdges === true && edgeRouting === "ORTHOGONAL") {
2534
+ const horizontal = direction === "right" || direction === "left";
2535
+ const reversed = expanded.orientation.reversedEdgeIds;
2536
+ straightenFeedbackChains(
2537
+ [...expanded.segmentIdsByEdgeId].flatMap(([edgeId, ids]) => {
2538
+ if (ids.length < 2 || !ids.every((id) => reversed.has(id))) return [];
2539
+ if (routes.junctionPointsByEdgeId?.get(edgeId)?.length) return [];
2540
+ const labelId = labels.labelDummyIdByEdgeId.get(edgeId);
2541
+ if (labelId === undefined) return [{ edgeId }];
2542
+ const into = expanded.input.graph.edges.find(
2543
+ (edge) => ids.includes(edge.id) && edge.targetId === labelId,
2544
+ );
2545
+ const anchor = into && expandedRoutes.pointsByEdgeId.get(into.id)?.at(-1);
2546
+ if (!anchor) return [];
2547
+ return [{ edgeId, label: { id: labelId, cross: horizontal ? anchor.y : anchor.x } }];
2548
+ }),
2549
+ routes.pointsByEdgeId as Map<string, readonly Point[]>,
2550
+ placement.rectByNodeId as Map<string, EntityRect>,
2551
+ horizontal,
2552
+ );
2553
+ }
1988
2554
  if (options.settings?.["layering.nodePromotion.strategy"] === "MODEL_ORDER_LEFT_TO_RIGHT") {
1989
2555
  const horizontal = direction === "right" || direction === "left";
1990
2556
  const pointsByEdgeId = new Map(routes.pointsByEdgeId);
@@ -2096,39 +2662,6 @@ function runLayeredPipeline<N, E, G, P>(
2096
2662
  };
2097
2663
  }
2098
2664
 
2099
- for (const edge of graph.edges) {
2100
- if (edge.sourcePort === undefined || edge.targetPort !== undefined) continue;
2101
- const source = expanded.input.graph.nodes.find((node) => node.id === edge.sourceId);
2102
- const port = source?.ports?.find((candidate) => candidate.name === edge.sourcePort);
2103
- const targetRect = mutableRects.get(edge.targetId);
2104
- if (!source || !port || !targetRect) continue;
2105
- const settings = expanded.input.portSettings?.(port, source);
2106
- const forwardSide =
2107
- direction === "right"
2108
- ? "EAST"
2109
- : direction === "left"
2110
- ? "WEST"
2111
- : direction === "down"
2112
- ? "SOUTH"
2113
- : "NORTH";
2114
- if (settings?.["port.anchor"] === undefined || settings["port.side"] !== forwardSide) continue;
2115
- const horizontal = direction === "right" || direction === "left";
2116
- const protrusion = horizontal ? (port.width ?? 0) : (port.height ?? 0);
2117
- const delta = (direction === "right" || direction === "down" ? 1 : -1) * protrusion;
2118
- mutableRects.set(
2119
- edge.targetId,
2120
- horizontal
2121
- ? { ...targetRect, x: targetRect.x + delta }
2122
- : { ...targetRect, y: targetRect.y + delta },
2123
- );
2124
- const points = [...(routes.pointsByEdgeId.get(edge.id) ?? [])];
2125
- const end = points.at(-1);
2126
- if (end)
2127
- points[points.length - 1] = horizontal
2128
- ? { ...end, x: end.x + delta }
2129
- : { ...end, y: end.y + delta };
2130
- (routes.pointsByEdgeId as Map<string, readonly Point[]>).set(edge.id, points);
2131
- }
2132
2665
  for (const pair of labeledEdgesByPair.values()) {
2133
2666
  if (pair.length < 2) continue;
2134
2667
  const sourceRect = mutableRects.get(pair[0]!.sourceId);
@@ -2298,6 +2831,9 @@ function runLayeredPipeline<N, E, G, P>(
2298
2831
  const maximumFeedbackNodeX = Math.max(...feedbackNodeRects.map((rect) => rect.x + rect.width));
2299
2832
  const minimumFeedbackNodeY = Math.min(...feedbackNodeRects.map((rect) => rect.y));
2300
2833
  const maximumFeedbackNodeY = Math.max(...feedbackNodeRects.map((rect) => rect.y + rect.height));
2834
+ if (edgeRouting === "ORTHOGONAL")
2835
+ for (const [id, points] of routes.pointsByEdgeId)
2836
+ (routes.pointsByEdgeId as Map<string, readonly Point[]>).set(id, removeRouteSpurs(points));
2301
2837
  const edges = graph.edges.map((edge) => {
2302
2838
  const points = [...(routes.pointsByEdgeId.get(edge.id) ?? [])];
2303
2839
  const midpoint = getPolylineMidpoint(points);
@@ -2345,10 +2881,12 @@ function runLayeredPipeline<N, E, G, P>(
2345
2881
  flowDelta !== 0 &&
2346
2882
  firstLeadDelta * flowDelta < 0 &&
2347
2883
  lastLeadDelta * flowDelta < 0);
2884
+ // Retained dummy junctions do not split the physical label track.
2885
+ const feedbackTrackPoints = outsideFeedback ? simplifyRoute(points) : [];
2348
2886
  const horizontalFeedbackCandidate = outsideFeedback
2349
- ? points
2887
+ ? feedbackTrackPoints
2350
2888
  .flatMap((point, index) => {
2351
- const next = points[index + 1];
2889
+ const next = feedbackTrackPoints[index + 1];
2352
2890
  return next !== undefined &&
2353
2891
  point.y === next.y &&
2354
2892
  (point.y < minimumFeedbackNodeY || point.y > maximumFeedbackNodeY)
@@ -2358,9 +2896,9 @@ function runLayeredPipeline<N, E, G, P>(
2358
2896
  .sort((left, right) => right.length - left.length)[0]
2359
2897
  : undefined;
2360
2898
  const verticalFeedbackCandidate = outsideFeedback
2361
- ? points
2899
+ ? feedbackTrackPoints
2362
2900
  .flatMap((point, index) => {
2363
- const next = points[index + 1];
2901
+ const next = feedbackTrackPoints[index + 1];
2364
2902
  return next !== undefined &&
2365
2903
  point.x === next.x &&
2366
2904
  (point.x < minimumFeedbackNodeX || point.x > maximumFeedbackNodeX)
@@ -2405,8 +2943,8 @@ function runLayeredPipeline<N, E, G, P>(
2405
2943
  ? (horizontalFeedbackTrack.start.x + horizontalFeedbackTrack.end.x - width) / 2
2406
2944
  : inlineLabel && verticalFeedbackTrack
2407
2945
  ? verticalFeedbackTrack.start.x > (minimumFeedbackNodeX + maximumFeedbackNodeX) / 2
2408
- ? verticalFeedbackTrack.start.x + labelSpacing + 1
2409
- : verticalFeedbackTrack.start.x - labelSpacing - width - 1
2946
+ ? Math.max(...points.map((point) => point.x)) + labelSpacing + 1
2947
+ : Math.min(...points.map((point) => point.x)) - labelSpacing - width - 1
2410
2948
  : inlineLabel && verticalTrack
2411
2949
  ? labelBeforeTrack
2412
2950
  ? verticalTrack.x - edgeNodeSpacing - width
@@ -2515,33 +3053,157 @@ function runLayeredPipeline<N, E, G, P>(
2515
3053
  y: (points[1]!.y + points[2]!.y - height) / 2,
2516
3054
  }
2517
3055
  : undefined;
3056
+ const movableExteriorLoopLabelPosition = (() => {
3057
+ if (
3058
+ edgeRouting !== "ORTHOGONAL" ||
3059
+ labelPlacement !== "CENTER" ||
3060
+ inlineLabel ||
3061
+ !hasMovableLoopPorts(input, edge) ||
3062
+ !sourceRect ||
3063
+ points.length !== 4
3064
+ )
3065
+ return undefined;
3066
+ const first = points[1]!,
3067
+ last = points[2]!;
3068
+ // Cross-axis loop labels are centered on the node. Flow-side loop
3069
+ // labels align with the first physical port, as ELK's label placer does.
3070
+ if (first.y === last.y) {
3071
+ const low = first.y < sourceRect.y;
3072
+ return {
3073
+ x: horizontal ? sourceRect.x + (sourceRect.width - width) / 2 : Math.min(first.x, last.x),
3074
+ y: low ? first.y - labelSpacing - height : first.y + labelSpacing,
3075
+ };
3076
+ }
3077
+ if (first.x === last.x) {
3078
+ const low = first.x < sourceRect.x;
3079
+ return {
3080
+ x: low ? first.x - labelSpacing - width : first.x + labelSpacing,
3081
+ y: horizontal
3082
+ ? Math.min(first.y, last.y)
3083
+ : sourceRect.y + (sourceRect.height - height) / 2,
3084
+ };
3085
+ }
3086
+ return undefined;
3087
+ })();
3088
+ // A self-loop label whose default spot covers a node or another route
3089
+ // sits beside one of the loop's segments instead, longest first, on the
3090
+ // side away from its node, at the first spot clear of both.
3091
+ const clearLabelSpot = (box: { x: number; y: number }) =>
3092
+ [...placement.rectByNodeId.values()].every(
3093
+ (rect) =>
3094
+ box.x >= rect.x + rect.width ||
3095
+ box.x + width <= rect.x ||
3096
+ box.y >= rect.y + rect.height ||
3097
+ box.y + height <= rect.y,
3098
+ ) &&
3099
+ [...routes.pointsByEdgeId].every(
3100
+ ([id, route]) =>
3101
+ id === edge.id ||
3102
+ route.every((a, index) => {
3103
+ const b = route[index + 1];
3104
+ if (!b) return true;
3105
+ return (
3106
+ Math.max(a.x, b.x) <= box.x ||
3107
+ Math.min(a.x, b.x) >= box.x + width ||
3108
+ Math.max(a.y, b.y) <= box.y ||
3109
+ Math.min(a.y, b.y) >= box.y + height
3110
+ );
3111
+ }),
3112
+ );
3113
+ const outerLoopLabelPosition = (() => {
3114
+ if (edge.sourceId !== edge.targetId || !sourceRect || points.length < 3) return undefined;
3115
+ const centerX = sourceRect.x + sourceRect.width / 2,
3116
+ centerY = sourceRect.y + sourceRect.height / 2;
3117
+ const segments = points
3118
+ .slice(1)
3119
+ .map((b, index) => [points[index]!, b] as const)
3120
+ .sort(
3121
+ ([a, b], [c, d]) =>
3122
+ Math.abs(c.x - d.x) + Math.abs(c.y - d.y) - (Math.abs(a.x - b.x) + Math.abs(a.y - b.y)),
3123
+ );
3124
+ for (const [a, b] of segments) {
3125
+ const box =
3126
+ a.y === b.y
3127
+ ? {
3128
+ x: (a.x + b.x - width) / 2,
3129
+ y: a.y < centerY ? a.y - labelSpacing - height : a.y + labelSpacing,
3130
+ }
3131
+ : {
3132
+ x: a.x < centerX ? a.x - labelSpacing - width : a.x + labelSpacing,
3133
+ y: (a.y + b.y - height) / 2,
3134
+ };
3135
+ if (clearLabelSpot(box)) return box;
3136
+ }
3137
+ return undefined;
3138
+ })();
3139
+ // A fixed same-side loop is a small square beside its port; its label
3140
+ // sits beyond that square, inside the space the loop envelope reserved.
3141
+ const fixedLoopSide = fixedSelfLoopSide(sourcePortSide);
3142
+ const fixedLoopConstraints = sourceNode && options.nodeSettings?.(sourceNode)?.portConstraints;
3143
+ const fixedLoopLabelPosition =
3144
+ edgeRouting === "ORTHOGONAL" &&
3145
+ labelPlacement === "CENTER" &&
3146
+ !inlineLabel &&
3147
+ edge.sourceId === edge.targetId &&
3148
+ fixedLoopSide &&
3149
+ targetPortSide === sourcePortSide &&
3150
+ fixedLoopConstraints &&
3151
+ fixedLoopConstraints !== "FREE" &&
3152
+ fixedLoopConstraints !== "UNDEFINED"
3153
+ ? (() => {
3154
+ const spots = fixedSelfLoopLabels(points, fixedLoopSide, width, height, labelSpacing);
3155
+ return spots.find(clearLabelSpot) ?? spots[0];
3156
+ })()
3157
+ : undefined;
2518
3158
  const explicitLabelPosition =
3159
+ fixedLoopLabelPosition ??
3160
+ movableExteriorLoopLabelPosition ??
2519
3161
  selfLoopLabelPosition ??
2520
3162
  outerAntiparallelLabelPosition ??
2521
3163
  antiparallelLabelPosition ??
2522
- parallelLabelPositions.get(edge.id);
2523
- const x = explicitLabelPosition
2524
- ? explicitLabelPosition.x
2525
- : flexibleFeedbackLabel
2526
- ? horizontal
2527
- ? (beforeFlowRect!.x + beforeFlowRect!.width + afterFlowRect!.x - width) / 2
2528
- : targetRect.x + (targetRect.width - width) / 2
2529
- : centerFlowPosition !== undefined && horizontal
2530
- ? centerFlowPosition
2531
- : labelPlacement === "CENTER" && horizontal && labelDummyRect
2532
- ? labelDummyRect.x
2533
- : routeX;
2534
- const y = explicitLabelPosition
2535
- ? explicitLabelPosition.y
2536
- : flexibleFeedbackLabel
2537
- ? horizontal
2538
- ? targetRect.y + (targetRect.height - height) / 2
2539
- : (beforeFlowRect!.y + beforeFlowRect!.height + afterFlowRect!.y - height) / 2
2540
- : centerFlowPosition !== undefined && !horizontal
2541
- ? centerFlowPosition
2542
- : labelPlacement === "CENTER" && !horizontal && labelDummyRect
2543
- ? labelDummyRect.y
2544
- : routeY;
3164
+ parallelLabelPositions.get(edge.id) ??
3165
+ (width > 0 && height > 0 && !clearLabelSpot({ x: routeX, y: routeY })
3166
+ ? outerLoopLabelPosition
3167
+ : undefined);
3168
+ const centerLabelDummyId = labels.labelDummyIdByEdgeId.get(edge.id);
3169
+ const centerLabelInfo = centerLabelDummyId
3170
+ ? labels.dummyById.get(centerLabelDummyId)
3171
+ : undefined;
3172
+ const centerLabelRect =
3173
+ labelDummyRect && centerLabelInfo
3174
+ ? centerLabelPosition(
3175
+ labelDummyRect,
3176
+ centerLabelInfo,
3177
+ labelSelection.sides.get(centerLabelDummyId!),
3178
+ direction,
3179
+ )
3180
+ : undefined;
3181
+ const x = centerLabelRect
3182
+ ? centerLabelRect.x
3183
+ : explicitLabelPosition
3184
+ ? explicitLabelPosition.x
3185
+ : flexibleFeedbackLabel
3186
+ ? horizontal
3187
+ ? (beforeFlowRect!.x + beforeFlowRect!.width + afterFlowRect!.x - width) / 2
3188
+ : targetRect.x + (targetRect.width - width) / 2
3189
+ : centerFlowPosition !== undefined && horizontal
3190
+ ? centerFlowPosition
3191
+ : labelPlacement === "CENTER" && horizontal && labelDummyRect
3192
+ ? labelDummyRect.x
3193
+ : routeX;
3194
+ const y = centerLabelRect
3195
+ ? centerLabelRect.y
3196
+ : explicitLabelPosition
3197
+ ? explicitLabelPosition.y
3198
+ : flexibleFeedbackLabel
3199
+ ? horizontal
3200
+ ? targetRect.y + (targetRect.height - height) / 2
3201
+ : (beforeFlowRect!.y + beforeFlowRect!.height + afterFlowRect!.y - height) / 2
3202
+ : centerFlowPosition !== undefined && !horizontal
3203
+ ? centerFlowPosition
3204
+ : labelPlacement === "CENTER" && !horizontal && labelDummyRect
3205
+ ? labelDummyRect.y
3206
+ : routeY;
2545
3207
  return {
2546
3208
  ...edge,
2547
3209
  x,
@@ -2549,6 +3211,7 @@ function runLayeredPipeline<N, E, G, P>(
2549
3211
  width,
2550
3212
  height,
2551
3213
  points,
3214
+
2552
3215
  routing:
2553
3216
  edgeRouting === "POLYLINE"
2554
3217
  ? ("polyline" as const)
@@ -2558,6 +3221,13 @@ function runLayeredPipeline<N, E, G, P>(
2558
3221
  };
2559
3222
  });
2560
3223
 
3224
+ placeEndLabels({
3225
+ edges,
3226
+ nodeRects: feedbackNodeRects,
3227
+ spacing: Number(options.settings?.["spacing.edgeLabel"] ?? 2),
3228
+ placement: (edge) => options.edgeSettings?.(edge)?.["edgeLabels.placement"] ?? "CENTER",
3229
+ inline: (edge) => options.edgeSettings?.(edge)?.["edgeLabels.inline"] === true,
3230
+ });
2561
3231
  if (edgeRouting === "ORTHOGONAL") {
2562
3232
  separateExteriorLabels({
2563
3233
  edges,
@@ -2574,12 +3244,16 @@ function runLayeredPipeline<N, E, G, P>(
2574
3244
  });
2575
3245
  }
2576
3246
 
2577
- return {
2578
- ...graph,
2579
- direction,
2580
- nodes,
2581
- edges,
2582
- };
3247
+ return setElkjs0111ResultPolicy(
3248
+ { ...graph, direction, nodes, edges },
3249
+ {
3250
+ providerBounds: hierarchicalRestoration.bounds,
3251
+ skipBoundsNormalization: !!hierarchicalRestoration.bounds,
3252
+ normalizationBounds: compactionBounds(placement),
3253
+ placementCrossBounds: placementCrossBounds(placement),
3254
+ junctionPointsByEdgeId: routes.junctionPointsByEdgeId,
3255
+ },
3256
+ );
2583
3257
  }
2584
3258
 
2585
3259
  /**
@@ -2592,12 +3266,18 @@ export function getLayeredLayout<N, E, G, P>(
2592
3266
  graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
2593
3267
  options: LayeredLayoutOptions = {},
2594
3268
  ): CompoundVisualGraph<N, E, G, P> {
2595
- const result = runLayeredPipeline(graph, options) as CompoundVisualGraph<N, E, G, P>;
2596
- return {
3269
+ const result = layoutWithHints(options.hints, (hints) =>
3270
+ repairFlatRouting(runLayeredPipeline(graph, { ...options, hints }), options),
3271
+ ) as CompoundVisualGraph<N, E, G, P>;
3272
+ const complete = {
2597
3273
  ...result,
2598
3274
  compoundGeometry: result.compoundGeometry ?? new Map(),
2599
3275
  compoundRoutes: result.compoundRoutes ?? new Map(),
2600
3276
  };
3277
+ // A replacement router owns its routes; otherwise opposite directions never share a track.
3278
+ return options.routing
3279
+ ? replaceLayoutRouting(complete, options)
3280
+ : separateLayoutOpposingTracks(complete, options);
2601
3281
  }
2602
3282
 
2603
3283
  export const layeredAlgorithm: LayoutAlgorithm<LayeredLayoutOptions> = {
@@ -2612,17 +3292,43 @@ export const layeredAlgorithm: LayoutAlgorithm<LayeredLayoutOptions> = {
2612
3292
  ports: true,
2613
3293
  },
2614
3294
  layout(graph, options, context) {
3295
+ if (options?.routing && (context.scope.mode !== "full" || context.constraints?.length))
3296
+ throw new UnsupportedLayoutError(
3297
+ "Post-layout route replacement requires unconstrained full layout; run routing separately for constrained or scoped layouts",
3298
+ );
2615
3299
  if (context.scope.mode === "partial" || context.scope.mode === "route-only") {
2616
3300
  return context.measurePhase("partial-layout", () =>
2617
3301
  runPartialLayout(graph, options ?? {}, context, getLayeredLayout),
2618
3302
  );
2619
3303
  }
2620
- if (context.constraints?.length && graph.nodes.some((node) => node.parentId != null))
2621
- throw new UnsupportedLayoutError(
2622
- "Full layout with geometry constraints does not support containers; use partial leaf constraints or full layout without geometry constraints",
3304
+ const result = layoutWithHints(
3305
+ options?.hints,
3306
+ (hints, diagnostics) =>
3307
+ repairFlatRouting(
3308
+ runLayeredPipeline(graph, { ...options, hints }, { ...context, diagnostics }),
3309
+ options ?? {},
3310
+ ),
3311
+ context.diagnostics,
3312
+ );
3313
+ if (!context.constraints?.length) {
3314
+ if (!options?.routing)
3315
+ return separateLayoutOpposingTracks(
3316
+ {
3317
+ ...result,
3318
+ compoundGeometry: (result as CompoundVisualGraph).compoundGeometry ?? new Map(),
3319
+ compoundRoutes: (result as CompoundVisualGraph).compoundRoutes ?? new Map(),
3320
+ },
3321
+ options ?? {},
3322
+ );
3323
+ return replaceLayoutRouting(
3324
+ {
3325
+ ...result,
3326
+ compoundGeometry: (result as CompoundVisualGraph).compoundGeometry ?? new Map(),
3327
+ compoundRoutes: (result as CompoundVisualGraph).compoundRoutes ?? new Map(),
3328
+ },
3329
+ options ?? {},
2623
3330
  );
2624
- const result = runLayeredPipeline(graph, options ?? {}, context);
2625
- if (!context.constraints?.length) return result;
3331
+ }
2626
3332
  return context.measurePhase("constraints", () =>
2627
3333
  runPartialLayout(
2628
3334
  result,
@@ -2641,3 +3347,11 @@ export const layeredAlgorithm: LayoutAlgorithm<LayeredLayoutOptions> = {
2641
3347
  );
2642
3348
  },
2643
3349
  };
3350
+
3351
+ /** The pinned ELK adapter retains its established placement/routing contract. */
3352
+ export const elkjs0111LayeredAlgorithm: LayoutAlgorithm<LayeredLayoutOptions> = {
3353
+ ...layeredAlgorithm,
3354
+ layout(graph, options, context) {
3355
+ return runLayeredPipeline(graph, options ?? {}, context);
3356
+ },
3357
+ };