@statelyai/layout 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +221 -13
  3. package/dist/elkjs/index.cjs +3 -2
  4. package/dist/elkjs/index.d.cts +13 -2
  5. package/dist/elkjs/index.d.mts +13 -2
  6. package/dist/elkjs/index.mjs +3 -3
  7. package/dist/elkjs/main.cjs +2 -2
  8. package/dist/elkjs/main.d.cts +2 -2
  9. package/dist/elkjs/main.d.mts +2 -2
  10. package/dist/elkjs/main.mjs +2 -2
  11. package/dist/elkjs/worker-api.cjs +1 -1
  12. package/dist/elkjs/worker-api.d.cts +2 -2
  13. package/dist/elkjs/worker-api.d.mts +2 -2
  14. package/dist/elkjs/worker-api.mjs +1 -1
  15. package/dist/elkjs/worker.cjs +2 -2
  16. package/dist/elkjs/worker.mjs +2 -2
  17. package/dist/elkjs-UciYx7lD.mjs +2973 -0
  18. package/dist/elkjs-ggjsBuVP.cjs +3002 -0
  19. package/dist/index-B6cWNrMw.d.cts +80 -0
  20. package/dist/index-CJQLUKD9.d.mts +80 -0
  21. package/dist/{index-BSH2bloA.d.mts → index-DT6lPMfS.d.cts} +117 -5
  22. package/dist/{index-CG712jzV.d.cts → index-F1U-fCTu.d.mts} +117 -5
  23. package/dist/index.cjs +39 -9
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.mts +4 -3
  26. package/dist/index.mjs +4 -3
  27. package/dist/layered/index.cjs +3 -1
  28. package/dist/layered/index.d.cts +3 -2
  29. package/dist/layered/index.d.mts +3 -2
  30. package/dist/layered/index.mjs +2 -2
  31. package/dist/{layered-Bz2nWmVp.mjs → layered-C7ERzZjR.mjs} +12592 -4682
  32. package/dist/{layered-Cf9Rn8l7.cjs → layered-CBPg6_6_.cjs} +12711 -4717
  33. package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  34. package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
  35. package/dist/routing/index.cjs +29 -0
  36. package/dist/routing/index.d.cts +3 -0
  37. package/dist/routing/index.d.mts +3 -0
  38. package/dist/routing/index.mjs +3 -0
  39. package/dist/routing-CR8flv8b.mjs +2780 -0
  40. package/dist/routing-QIdGGFBe.cjs +2996 -0
  41. package/dist/types-CT2akdK0.d.mts +133 -0
  42. package/dist/types-uxPlGRof.d.cts +133 -0
  43. package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
  44. package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
  45. package/package.json +14 -3
  46. package/src/authoring/coordinates.ts +38 -10
  47. package/src/authoring/partial.ts +7 -2
  48. package/src/authoring/routing.ts +4 -1
  49. package/src/constraints.ts +174 -6
  50. package/src/elkjs/index.ts +1397 -452
  51. package/src/elkjs/layout-quality.ts +219 -0
  52. package/src/elkjs/opposing-tracks.ts +161 -0
  53. package/src/elkjs/orthogonal-junctions.ts +174 -0
  54. package/src/elkjs/routes.ts +147 -0
  55. package/src/elkjs/statechart.ts +9 -3
  56. package/src/elkjs/types.ts +2 -0
  57. package/src/index.ts +8 -0
  58. package/src/internal/elkjs-compatibility.ts +4 -1
  59. package/src/internal/layered-trace.ts +48 -0
  60. package/src/internal/layout-engine.ts +2 -1
  61. package/src/java-random.ts +6 -0
  62. package/src/layered/bk-node-placement.ts +403 -92
  63. package/src/layered/center-labels.ts +398 -0
  64. package/src/layered/compaction-bounds.ts +26 -0
  65. package/src/layered/compaction-errors.ts +2 -0
  66. package/src/layered/compaction-scanline.ts +63 -0
  67. package/src/layered/compound-boundaries.ts +99 -0
  68. package/src/layered/compound.ts +445 -0
  69. package/src/layered/constraint-orientation.ts +141 -0
  70. package/src/layered/crossing-constraints.ts +243 -0
  71. package/src/layered/crossing-counter.ts +221 -0
  72. package/src/layered/crossing-graph.ts +401 -0
  73. package/src/layered/cycle-random.ts +22 -0
  74. package/src/layered/elk-port-lists.ts +243 -0
  75. package/src/layered/end-labels.ts +124 -0
  76. package/src/layered/external-port-dummy.ts +137 -0
  77. package/src/layered/feedback-chains.ts +114 -0
  78. package/src/layered/fixed-self-loop.ts +150 -0
  79. package/src/layered/greedy-switch-decider.ts +356 -0
  80. package/src/layered/grouped-compaction.ts +787 -0
  81. package/src/layered/gwt-sort.ts +53 -0
  82. package/src/layered/hierarchical-port-phases.ts +242 -0
  83. package/src/layered/hierarchical-port-restoration.ts +233 -0
  84. package/src/layered/hierarchical-port-routing.ts +179 -0
  85. package/src/layered/hierarchy-crossing.ts +121 -0
  86. package/src/layered/hierarchy-sweepiness.ts +83 -0
  87. package/src/layered/hints.ts +371 -0
  88. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  89. package/src/layered/index.ts +911 -261
  90. package/src/layered/initial-model-order.ts +404 -0
  91. package/src/layered/inverted-ports.ts +207 -0
  92. package/src/layered/label-dummy-switcher.ts +200 -0
  93. package/src/layered/long-edges.ts +135 -44
  94. package/src/layered/loop-envelopes.ts +184 -0
  95. package/src/layered/native-routing.ts +275 -0
  96. package/src/layered/network-simplex.ts +11 -15
  97. package/src/layered/node-margins.ts +59 -0
  98. package/src/layered/north-south-ports.ts +271 -0
  99. package/src/layered/opposing-tracks.ts +944 -0
  100. package/src/layered/orthogonal-cycle-order.ts +106 -0
  101. package/src/layered/orthogonal-hypersegments.ts +92 -0
  102. package/src/layered/orthogonal-junctions.ts +202 -0
  103. package/src/layered/orthogonal-segments.ts +243 -0
  104. package/src/layered/placement-orientation.ts +22 -0
  105. package/src/layered/port-distributor.ts +192 -0
  106. package/src/layered/port-edge-order.ts +20 -0
  107. package/src/layered/replace-routing.ts +45 -0
  108. package/src/layered/routing-coordinates.ts +32 -0
  109. package/src/layered/separate-exterior-labels.ts +24 -6
  110. package/src/layered/spacing.ts +34 -12
  111. package/src/layered/splitter-order.ts +106 -0
  112. package/src/layered/strategies.ts +2024 -883
  113. package/src/layered/types.ts +34 -0
  114. package/src/layered/weighted-compaction.ts +84 -0
  115. package/src/routing/adapters.ts +143 -0
  116. package/src/routing/algorithms.ts +1088 -0
  117. package/src/routing/coordination.ts +91 -0
  118. package/src/routing/engine.ts +473 -0
  119. package/src/routing/index.ts +69 -0
  120. package/src/routing/layout-cache.ts +65 -0
  121. package/src/routing/model.ts +218 -0
  122. package/src/routing/path.ts +324 -0
  123. package/src/routing/persistent.ts +117 -0
  124. package/src/routing/search.ts +358 -0
  125. package/src/routing/spatial.ts +78 -0
  126. package/src/routing/types.ts +146 -0
  127. package/dist/elkjs-BVnDwnBQ.cjs +0 -1551
  128. package/dist/elkjs-CFlfkEBz.mjs +0 -1528
  129. /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
  130. /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
  131. /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
  132. /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
@@ -1,3 +1,43 @@
1
+ import {
2
+ sortInitialModelOrder,
3
+ restoreInitialModelOrder,
4
+ countModelOrderChanges,
5
+ normalModelComparator,
6
+ transitiveComparator,
7
+ } from "./initial-model-order";
8
+ import { insertionSort } from "./gwt-sort";
9
+ import { CanonicalPortDistributor } from "./port-distributor";
10
+ import { countAllCrossings } from "./crossing-counter";
11
+ import {
12
+ createCrossingGraphBuilder,
13
+ crossingGraph,
14
+ splitMixedCrossingPorts,
15
+ } from "./crossing-graph";
16
+ import { isMergedHyperedgeDummy } from "./hyperedge-dummy-merger";
17
+ import { externalPortDummyOf } from "./external-port-dummy";
18
+ import { routingCoordinates } from "./routing-coordinates";
19
+ import {
20
+ hasMovableLoopPorts,
21
+ hasPlacementLoopEnvelope,
22
+ loopEnvelopes,
23
+ preparedLoopEnvelopes,
24
+ } from "./loop-envelopes";
25
+ import { routeFixedSelfLoop, fixedSelfLoopSide, selfLoopTracks } from "./fixed-self-loop";
26
+ import { networkSimplexComponents } from "./network-simplex";
27
+ import { minimizeHierarchyCrossings } from "./hierarchy-crossing";
28
+ import { orthogonalJunctionPoints, type OrthogonalJunctionGroup } from "./orthogonal-junctions";
29
+ import { routeOrthogonalSegments } from "./orthogonal-segments";
30
+ import { createOrthogonalHypersegments, type OrthogonalPort } from "./orthogonal-hypersegments";
31
+ import {
32
+ associatedBarycenters,
33
+ resolveCrossingConstraints,
34
+ getCrossingUnits,
35
+ canSwapCrossingUnits,
36
+ type CrossingUnits,
37
+ } from "./crossing-constraints";
38
+ import { applyGroupedEdgeLengthCompaction } from "./grouped-compaction";
39
+ import { getPlacementOrientation, getPlacementOrder } from "./placement-orientation";
40
+ import { recordCycleRandom, crossingRandom, phaseRandomByInput } from "./cycle-random";
1
41
  import type { EntityRect, GraphEdge, GraphNode, GraphPort, Point } from "@statelyai/graph";
2
42
  import { LayoutError } from "../errors";
3
43
  import { JavaRandom } from "../java-random";
@@ -15,11 +55,16 @@ import type {
15
55
  NodePlacer,
16
56
  } from "./types";
17
57
 
18
- const phaseRandomByInput = new WeakMap<LayeredPhaseInput, JavaRandom>();
19
58
  import { nodeNodeSpacing } from "./spacing";
20
59
  import type { ElkLayeredOptionValueByName } from "./elk-options";
21
60
  import { conservativeSpline } from "./spline-bezier";
22
61
  import { getFlexiblePortPosition } from "./flexible-ports";
62
+ import { traceLayeredPhase } from "../internal/layered-trace";
63
+ import { assertNorthSouthPortSides, GreedySwitchDecider } from "./greedy-switch-decider";
64
+
65
+ const modelSweepInputs = new WeakSet<LayeredPhaseInput>();
66
+
67
+ const nodeRelativePortRanksByInput = new WeakMap<LayeredPhaseInput, boolean>();
23
68
 
24
69
  function getOrientedEndpoints(
25
70
  edge: GraphEdge,
@@ -243,6 +288,7 @@ function breakCyclesWithGreedyHeuristic(
243
288
  if (source === undefined || target === undefined) continue;
244
289
  if ((marks[source] ?? 0) > (marks[target] ?? 0)) reversedEdgeIds.add(edge.id);
245
290
  }
291
+ recordCycleRandom(input, random);
246
292
  return { reversedEdgeIds };
247
293
  }
248
294
 
@@ -252,9 +298,39 @@ export const breakCyclesGreedily: CycleBreaker = (input) =>
252
298
  export const breakCyclesGreedilyByModelOrder: CycleBreaker = (input) =>
253
299
  breakCyclesWithGreedyHeuristic(input, "model-order");
254
300
 
255
- /** ELK MODEL_ORDER for flat graphs without FIRST/LAST layer constraints. */
301
+ /**
302
+ * A zero-size port on a FIXED_SIDE node used by exactly one edge has no
303
+ * physical extent, so its terminal takes a slot in its side's shared endpoint order.
304
+ */
305
+ export function usesSharedSideSlot(
306
+ input: LayeredPhaseInput,
307
+ node: GraphNode,
308
+ portName: string | undefined,
309
+ ): boolean {
310
+ const port = node.ports?.find((candidate) => candidate.name === portName);
311
+ return (
312
+ port !== undefined &&
313
+ (port.width ?? 8) === 0 &&
314
+ (port.height ?? 8) === 0 &&
315
+ input.nodeSettings?.(node)?.portConstraints === "FIXED_SIDE" &&
316
+ input.graph.edges.filter(
317
+ (edge) =>
318
+ (edge.sourceId === node.id && edge.sourcePort === portName) ||
319
+ (edge.targetId === node.id && edge.targetPort === portName),
320
+ ).length === 1
321
+ );
322
+ }
323
+
324
+ /** ELK MODEL_ORDER: layer-constrained nodes (e.g. external port dummies) order before/after the rest. */
256
325
  export const breakCyclesByModelOrder: CycleBreaker = (input) => {
257
326
  const order = cycleModelOrder(input);
327
+ const offset = Math.max(1, input.graph.nodes.length);
328
+ const shift = { FIRST_SEPARATE: -2, FIRST: -1, LAST: 1, LAST_SEPARATE: 2 } as const;
329
+ for (const node of input.graph.nodes) {
330
+ const constraint = input.nodeSettings?.(node)?.["layering.layerConstraint"];
331
+ const factor = shift[constraint as keyof typeof shift];
332
+ if (factor) order.set(node.id, factor * offset + order.get(node.id)!);
333
+ }
258
334
  return {
259
335
  reversedEdgeIds: new Set(
260
336
  input.graph.edges
@@ -668,83 +744,6 @@ export function applyLayerConstraints(
668
744
  };
669
745
  }
670
746
 
671
- /** Orient constrained outer nodes toward the graph interior before layering. */
672
- export function applyLayerConstraintOrientation(
673
- input: LayeredPhaseInput,
674
- orientation: AcyclicOrientation,
675
- ): AcyclicOrientation {
676
- if (
677
- input.nodeSettings === undefined &&
678
- !input.graph.nodes.some((node) => (node.ports?.length ?? 0) > 0)
679
- ) {
680
- return orientation;
681
- }
682
- const reversedEdgeIds = new Set(orientation.reversedEdgeIds);
683
- const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
684
- const constraintByNodeId = new Map(
685
- input.graph.nodes.map((node) => [
686
- node.id,
687
- input.nodeSettings?.(node)?.["layering.layerConstraint"],
688
- ]),
689
- );
690
- for (const edge of input.graph.edges) {
691
- const reversed = reversedEdgeIds.has(edge.id);
692
- const sourceId = reversed ? edge.targetId : edge.sourceId;
693
- const targetId = reversed ? edge.sourceId : edge.targetId;
694
- const sourceConstraint = constraintByNodeId.get(sourceId);
695
- const targetConstraint = constraintByNodeId.get(targetId);
696
- const shouldReverse =
697
- targetConstraint === "FIRST" ||
698
- targetConstraint === "FIRST_SEPARATE" ||
699
- sourceConstraint === "LAST" ||
700
- sourceConstraint === "LAST_SEPARATE";
701
- if (shouldReverse) {
702
- if (reversed) reversedEdgeIds.delete(edge.id);
703
- else reversedEdgeIds.add(edge.id);
704
- }
705
- }
706
- const oppositeFlowSide =
707
- input.direction === "right"
708
- ? "WEST"
709
- : input.direction === "left"
710
- ? "EAST"
711
- : input.direction === "down"
712
- ? "NORTH"
713
- : "SOUTH";
714
- const forwardFlowSide =
715
- input.direction === "right"
716
- ? "EAST"
717
- : input.direction === "left"
718
- ? "WEST"
719
- : input.direction === "down"
720
- ? "SOUTH"
721
- : "NORTH";
722
- for (const edge of input.graph.edges) {
723
- const source = nodeById.get(edge.sourceId);
724
- const target = nodeById.get(edge.targetId);
725
- const sourcePort = source?.ports?.find((port) => port.name === edge.sourcePort);
726
- const targetPort = target?.ports?.find((port) => port.name === edge.targetPort);
727
- const sourceSide = sourcePort
728
- ? input.portSettings?.(sourcePort, source!)?.["port.side"]
729
- : undefined;
730
- const targetSide = targetPort
731
- ? input.portSettings?.(targetPort, target!)?.["port.side"]
732
- : undefined;
733
- const reverseForPort =
734
- sourceSide === oppositeFlowSide ||
735
- (sourceSide === "UNDEFINED" &&
736
- input.nodeSettings?.(source!)?.portConstraints === "FIXED_SIDE") ||
737
- targetSide === forwardFlowSide ||
738
- (targetSide === "UNDEFINED" &&
739
- input.nodeSettings?.(target!)?.portConstraints === "FIXED_SIDE");
740
- if (!reverseForPort) continue;
741
- if (reversedEdgeIds.has(edge.id)) reversedEdgeIds.delete(edge.id);
742
- else reversedEdgeIds.add(edge.id);
743
- }
744
- // Port-side preferences can reintroduce cycles after the cycle breaker.
745
- return { reversedEdgeIds: addDepthFirstBackEdges(input, reversedEdgeIds, "model") };
746
- }
747
-
748
747
  /** Keep activated ELK partitions in ascending, contiguous layer blocks. */
749
748
  export function applyPartitions(
750
749
  input: LayeredPhaseInput,
@@ -911,56 +910,112 @@ export function applyGreedySwitch(
911
910
  ): LayerOrder {
912
911
  if (input.settings["crossingMinimization.strategy"] === "INTERACTIVE") return order;
913
912
  if (input.settings["crossingMinimization.semiInteractive"] === true) return order;
913
+ const type = input.settings["crossingMinimization.greedySwitch.type"] ?? "TWO_SIDED";
914
+ const threshold = input.settings["crossingMinimization.greedySwitch.activationThreshold"] ?? 40;
915
+ const originalNodeCount = input.graph.nodes.filter(
916
+ (node) => !node.id.startsWith("__layout_dummy:"),
917
+ ).length;
918
+ if (type === "OFF" || (threshold !== 0 && threshold <= originalNodeCount)) return order;
919
+ if (order.layers.length === 0 || (order.layers.length === 1 && order.layers[0]?.length === 1))
920
+ return order;
921
+ const random = phaseRandomByInput.get(input) ?? crossingRandom(input);
922
+ // ELK initializes another layer-sweep processor for greedy switching. It
923
+ // consumes a saved seed, chooses a distributor for ONE_SIDED, then draws
924
+ // the sweep direction. Routing continues with this same graph RNG state.
925
+ random.nextLong();
926
+ if (type === "ONE_SIDED") random.nextBoolean();
927
+ let forward = random.nextBoolean();
928
+ phaseRandomByInput.set(input, random);
914
929
  const modelOrderStrategy = input.settings["considerModelOrder.strategy"] ?? "NONE";
915
930
  if (
916
931
  (modelOrderStrategy === "NODES_AND_EDGES" || modelOrderStrategy === "PREFER_NODES") &&
917
- (input.settings["crossingMinimization.forceNodeModelOrder"] === true ||
918
- Number(input.settings["considerModelOrder.crossingCounterNodeInfluence"] ?? 0) >= 1)
932
+ Number(input.settings["considerModelOrder.crossingCounterNodeInfluence"] ?? 0) >= 1
919
933
  ) {
920
934
  return order;
921
935
  }
922
- const type = input.settings["crossingMinimization.greedySwitch.type"] ?? "TWO_SIDED";
923
- const threshold = input.settings["crossingMinimization.greedySwitch.activationThreshold"] ?? 40;
924
- if (type === "OFF" || (threshold !== 0 && threshold <= input.graph.nodes.length)) {
925
- return order;
926
- }
927
936
  const layers = order.layers.map((layer) => [...layer]);
928
937
  const layerByNodeId = new Map<string, number>();
929
938
  for (const [layerIndex, layer] of layers.entries()) {
930
939
  for (const id of layer) layerByNodeId.set(id, layerIndex);
931
940
  }
932
- const between = Array.from({ length: Math.max(0, layers.length - 1) }, () => [] as string[][]);
933
- for (const edge of input.graph.edges) {
934
- const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
935
- const sourceLayer = layerByNodeId.get(sourceId);
936
- const targetLayer = layerByNodeId.get(targetId);
937
- if (sourceLayer === undefined || targetLayer !== sourceLayer + 1) continue;
938
- between[sourceLayer]?.push([sourceId, targetId]);
939
- }
941
+ const greedyGraph =
942
+ order.inputPortOrderByNodeId && order.outputPortOrderByNodeId
943
+ ? crossingGraph(
944
+ input,
945
+ orientation,
946
+ layers,
947
+ order.inputPortOrderByNodeId,
948
+ order.outputPortOrderByNodeId,
949
+ )
950
+ : undefined;
951
+ const greedyNodes = new Map(greedyGraph?.layers.flat().map((node) => [node.id, node]));
952
+ if (greedyGraph) assertNorthSouthPortSides(greedyGraph);
953
+ const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
954
+ const portPositions = (layer: readonly string[], incoming: boolean) => {
955
+ const positions = new Map<string, number>();
956
+ let consumed = 0;
957
+ for (const nodeId of layer) {
958
+ const selected = (
959
+ incoming ? order.inputPortOrderByNodeId : order.outputPortOrderByNodeId
960
+ )?.get(nodeId);
961
+ const edgeIds =
962
+ selected ??
963
+ input.graph.edges
964
+ .filter((edge) => {
965
+ const [source, target] = getOrientedEndpoints(edge, orientation);
966
+ return source !== target && (incoming ? target : source) === nodeId;
967
+ })
968
+ .map((edge) => edge.id);
969
+ // WEST ports occur clockwise bottom-to-top; greedy crossing counters
970
+ // enumerate both neighboring faces in physical top-to-bottom order.
971
+ const physical = incoming ? [...edgeIds].reverse() : edgeIds;
972
+ const shared = new Map<string, number>();
973
+ for (const edgeId of physical) {
974
+ const edge = edgeById.get(edgeId)!;
975
+ const reversed = orientation.reversedEdgeIds.has(edgeId);
976
+ const port = incoming !== reversed ? edge.targetPort : edge.sourcePort;
977
+ if (port !== undefined && shared.has(port)) {
978
+ positions.set(edgeId, shared.get(port)!);
979
+ } else {
980
+ positions.set(edgeId, consumed);
981
+ if (port !== undefined) shared.set(port, consumed);
982
+ consumed++;
983
+ }
984
+ }
985
+ // Preserve the old node-level fallback for custom minimizers that do
986
+ // not provide selected port orders.
987
+ if (!selected) {
988
+ for (const edgeId of edgeIds) positions.set(edgeId, consumed);
989
+ consumed++;
990
+ }
991
+ }
992
+ return positions;
993
+ };
940
994
  const countBoundary = (boundary: number): number => {
941
- const edges = between[boundary] ?? [];
942
- const sourcePositions = new Map(
943
- (layers[boundary] ?? []).map((id, index) => [id, index] as const),
944
- );
945
- const targetPositions = new Map(
946
- (layers[boundary + 1] ?? []).map((id, index) => [id, index] as const),
947
- );
995
+ const sourcePositions = portPositions(layers[boundary] ?? [], false);
996
+ const targetPositions = portPositions(layers[boundary + 1] ?? [], true);
997
+ const edges = input.graph.edges.filter((edge) => {
998
+ const [source, target] = getOrientedEndpoints(edge, orientation);
999
+ return layerByNodeId.get(source) === boundary && layerByNodeId.get(target) === boundary + 1;
1000
+ });
948
1001
  let crossings = 0;
949
1002
  for (let left = 0; left < edges.length; left++) {
950
- const leftEdge = edges[left]!;
951
1003
  for (let right = left + 1; right < edges.length; right++) {
952
- const rightEdge = edges[right]!;
953
- if (leftEdge[0] === rightEdge[0] || leftEdge[1] === rightEdge[1]) continue;
954
1004
  const sourceDifference =
955
- (sourcePositions.get(leftEdge[0]!) ?? 0) - (sourcePositions.get(rightEdge[0]!) ?? 0);
1005
+ sourcePositions.get(edges[left]!.id)! - sourcePositions.get(edges[right]!.id)!;
956
1006
  const targetDifference =
957
- (targetPositions.get(leftEdge[1]!) ?? 0) - (targetPositions.get(rightEdge[1]!) ?? 0);
1007
+ targetPositions.get(edges[left]!.id)! - targetPositions.get(edges[right]!.id)!;
958
1008
  if (sourceDifference * targetDifference < 0) crossings++;
959
1009
  }
960
1010
  }
961
1011
  return crossings;
962
1012
  };
963
1013
  const countForLayer = (layerIndex: number, oneSidedBoundary?: number) => {
1014
+ // Greedy swapping counts edge pairs, rather than the sweep's hyperedge estimate.
1015
+ if (oneSidedBoundary === undefined && greedyGraph) {
1016
+ greedyGraph.layers = layers.map((layer) => layer.map((id) => greedyNodes.get(id)!));
1017
+ return countAllCrossings(greedyGraph, "edges").total;
1018
+ }
964
1019
  if (oneSidedBoundary !== undefined) return countBoundary(oneSidedBoundary);
965
1020
  return (
966
1021
  (layerIndex > 0 ? countBoundary(layerIndex - 1) : 0) +
@@ -975,6 +1030,31 @@ export function applyGreedySwitch(
975
1030
  for (const layerIndex of indices) {
976
1031
  const layer = layers[layerIndex]!;
977
1032
  let improved: boolean;
1033
+ if (greedyGraph) {
1034
+ // ELK decides each switch from local two-node crossing counts.
1035
+ const units = getCrossingUnits(input);
1036
+ greedyGraph.layers = layers.map((ids) => ids.map((id) => greedyNodes.get(id)!));
1037
+ const decider = new GreedySwitchDecider(
1038
+ greedyGraph,
1039
+ layerIndex,
1040
+ type === "ONE_SIDED" ? (forward ? "WEST" : "EAST") : undefined,
1041
+ );
1042
+ do {
1043
+ improved = false;
1044
+ for (let index = 0; index + 1 < layer.length; index++) {
1045
+ const upper = greedyNodes.get(layer[index]!)!,
1046
+ lower = greedyNodes.get(layer[index + 1]!)!;
1047
+ if (decider.preventsSwitch(upper, lower, units?.successors ?? new Map())) continue;
1048
+ const [current, switched] = decider.crossings(upper, lower);
1049
+ if (current <= switched) continue;
1050
+ decider.notifySwitch(upper, lower);
1051
+ [layer[index], layer[index + 1]] = [layer[index + 1]!, layer[index]!];
1052
+ improved = true;
1053
+ changed = true;
1054
+ }
1055
+ } while (improved && layerIndex !== indices[0]);
1056
+ continue;
1057
+ }
978
1058
  do {
979
1059
  improved = false;
980
1060
  for (let index = 0; index + 1 < layer.length; index++) {
@@ -986,6 +1066,8 @@ export function applyGreedySwitch(
986
1066
  ) {
987
1067
  continue;
988
1068
  }
1069
+ const units = getCrossingUnits(input);
1070
+ if (units && !canSwapCrossingUnits(layer[index]!, layer[index + 1]!, units)) continue;
989
1071
  const before = countForLayer(layerIndex, oneSidedBoundary);
990
1072
  [layer[index], layer[index + 1]] = [layer[index + 1]!, layer[index]!];
991
1073
  const after = countForLayer(layerIndex, oneSidedBoundary);
@@ -996,13 +1078,56 @@ export function applyGreedySwitch(
996
1078
  [layer[index], layer[index + 1]] = [layer[index + 1]!, layer[index]!];
997
1079
  }
998
1080
  }
999
- } while (improved);
1081
+ // ELK scans the starting layer once, then converges each later layer.
1082
+ // Repeating the start immediately changes the following layers' local optimum.
1083
+ } while (improved && layerIndex !== indices[0]);
1000
1084
  }
1001
1085
  return changed;
1002
1086
  };
1003
- while (sweep(true) || sweep(false)) {
1004
- // Repeat until every adjacent exchange is locally optimal.
1087
+ const trace = (crossings: number) =>
1088
+ traceLayeredPhase(() => ({
1089
+ kind: "sweep",
1090
+ scope: input.graph.id,
1091
+ attempt: -1,
1092
+ forward,
1093
+ firstSweep: false,
1094
+ crossings,
1095
+ layers,
1096
+ }));
1097
+ if (type === "ONE_SIDED") {
1098
+ // ONE_SIDED does not always improve (opposite sweeps can undo each other),
1099
+ // so ELK's minimizeCrossingsWithCounter alternates sweeps only while the
1100
+ // total crossing count strictly drops, keeping the order before the last sweep.
1101
+ const countAll = () => {
1102
+ if (greedyGraph) return countForLayer(0);
1103
+ let total = 0;
1104
+ for (let boundary = 0; boundary + 1 < layers.length; boundary++)
1105
+ total += countBoundary(boundary);
1106
+ return total;
1107
+ };
1108
+ sweep(forward);
1109
+ let crossings = countAll();
1110
+ trace(crossings);
1111
+ while (crossings > 0) {
1112
+ const previous = layers.map((layer) => [...layer]);
1113
+ forward = !forward;
1114
+ sweep(forward);
1115
+ const next = countAll();
1116
+ trace(next);
1117
+ if (next >= crossings) {
1118
+ for (const [index, layer] of previous.entries()) layers[index] = layer;
1119
+ break;
1120
+ }
1121
+ crossings = next;
1122
+ }
1123
+ return { ...order, layers };
1005
1124
  }
1125
+ let improved: boolean;
1126
+ do {
1127
+ improved = sweep(forward);
1128
+ trace(-1);
1129
+ forward = !forward;
1130
+ } while (improved);
1006
1131
  return { ...order, layers };
1007
1132
  }
1008
1133
 
@@ -1053,7 +1178,7 @@ export function applyForcedModelOrder(
1053
1178
  order: LayerOrder,
1054
1179
  ): LayerOrder {
1055
1180
  const strategy = input.settings["considerModelOrder.strategy"] ?? "NONE";
1056
- if (strategy === "NONE") return order;
1181
+ if (strategy === "NONE" || modelSweepInputs.has(input)) return order;
1057
1182
  const forceNodeOrder =
1058
1183
  input.settings["crossingMinimization.forceNodeModelOrder"] === true &&
1059
1184
  (strategy === "NODES_AND_EDGES" || strategy === "PREFER_NODES");
@@ -1191,10 +1316,48 @@ export function applyForcedModelOrder(
1191
1316
  }
1192
1317
  }
1193
1318
  }
1194
- // Ports are kept in their configured model/edge order by the importer and
1195
- // port placer, so increasing their objective weight requires no reordering.
1196
- Number(input.settings["considerModelOrder.crossingCounterPortInfluence"] ?? 0);
1197
- return { layers };
1319
+ // Node reordering invalidates flexible physical-port ranks. Reconcile them
1320
+ // using the same distributor and rank convention as the preceding sweep;
1321
+ // retain fixed boundary orders for the canonical greedy crossing counter.
1322
+ const incoming = clonePortOrders(order.inputPortOrderByNodeId);
1323
+ const outgoing = clonePortOrders(order.outputPortOrderByNodeId);
1324
+ const graph = crossingGraph(input, orientation, layers, incoming, outgoing);
1325
+ const distributor = new CanonicalPortDistributor(graph);
1326
+ const fixedOrder = new Set(
1327
+ input.graph.nodes
1328
+ .filter((node) => {
1329
+ const constraint = input.nodeSettings?.(node)?.portConstraints;
1330
+ return (
1331
+ constraint === "FIXED_ORDER" || constraint === "FIXED_POS" || constraint === "FIXED_RATIO"
1332
+ );
1333
+ })
1334
+ .map((node) => node.id),
1335
+ );
1336
+ for (let layer = layers.length - 1; layer >= 0; layer--) {
1337
+ distributor.distribute(graph, layer, false, {
1338
+ nodeRelative: nodeRelativePortRanksByInput.get(input) ?? true,
1339
+ fixedOrder,
1340
+ hierarchical: new Set(),
1341
+ });
1342
+ }
1343
+ const edges = input.graph.edges.filter((edge) => edge.sourceId !== edge.targetId);
1344
+ const endpoints = new Map(edges.map((edge, index) => [edge.id, graph.edges[index]!]));
1345
+ for (const node of graph.layers.flat()) {
1346
+ const ordinal = new Map(node.ports.map((port, index) => [port.id, index]));
1347
+ for (const [orders, source] of [
1348
+ [outgoing, true],
1349
+ [incoming, false],
1350
+ ] as const) {
1351
+ orders
1352
+ .get(node.id)
1353
+ ?.sort(
1354
+ (left, right) =>
1355
+ ordinal.get(endpoints.get(left)![source ? "source" : "target"])! -
1356
+ ordinal.get(endpoints.get(right)![source ? "source" : "target"])!,
1357
+ );
1358
+ }
1359
+ }
1360
+ return { ...order, layers, inputPortOrderByNodeId: incoming, outputPortOrderByNodeId: outgoing };
1198
1361
  }
1199
1362
 
1200
1363
  /** ELK LONGEST_PATH: align sinks on the final layer. */
@@ -1607,10 +1770,25 @@ function sortLayerByAdjacentPosition(
1607
1770
  preOrdered = true,
1608
1771
  sourceUnknownPlacement = true,
1609
1772
  perturbSummedWeight = true,
1773
+ units?: CrossingUnits,
1774
+ sameLayerNeighbors: ReadonlyMap<string, readonly string[]> = new Map(),
1775
+ orderedVisits?: ReadonlyMap<string, readonly (number | string)[]>,
1776
+ forcedCompare?: (left: string, right: string) => number,
1610
1777
  ): void {
1611
1778
  const originalIndex = new Map(layer.map((nodeId, index) => [nodeId, index] as const));
1612
1779
  const adjacentPosition = new Map<string, number | undefined>();
1613
- for (const nodeId of layer) {
1780
+ if (units && statistic === "mean") {
1781
+ for (const [id, score] of associatedBarycenters(
1782
+ layer,
1783
+ ranksByNodeId,
1784
+ sameLayerNeighbors,
1785
+ units.associates,
1786
+ random,
1787
+ orderedVisits,
1788
+ ))
1789
+ adjacentPosition.set(id, score);
1790
+ }
1791
+ for (const nodeId of units && statistic === "mean" ? [] : layer) {
1614
1792
  const positions = [...(ranksByNodeId.get(nodeId) ?? [])].sort((left, right) => left - right);
1615
1793
  if (positions.length === 0) {
1616
1794
  adjacentPosition.set(nodeId, undefined);
@@ -1671,174 +1849,322 @@ function sortLayerByAdjacentPosition(
1671
1849
  }
1672
1850
  }
1673
1851
 
1674
- layer.sort((a, b) => {
1852
+ const compare = (a: string, b: string) => {
1675
1853
  return (
1854
+ forcedCompare?.(a, b) ||
1676
1855
  (adjacentPosition.get(a) ?? 0) - (adjacentPosition.get(b) ?? 0) ||
1677
1856
  (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0)
1678
1857
  );
1679
- });
1858
+ };
1859
+ if (forcedCompare)
1860
+ insertionSort(
1861
+ layer,
1862
+ transitiveComparator((id: string) => id, compare),
1863
+ );
1864
+ else layer.sort(compare);
1865
+ if (units && !forcedCompare)
1866
+ layer.splice(0, layer.length, ...resolveCrossingConstraints(layer, adjacentPosition, units));
1867
+ }
1868
+
1869
+ type LayerSweepVisitor = (
1870
+ layerIndex: number,
1871
+ nodeIds: readonly string[],
1872
+ forward: boolean,
1873
+ firstSweep: boolean,
1874
+ ) => void;
1875
+
1876
+ /** @internal */
1877
+ export interface LayerSweepSession {
1878
+ /** Scope graph id, for development tracing. */
1879
+ readonly scope: string;
1880
+ readonly random: JavaRandom;
1881
+ readonly attempts: number;
1882
+ readonly restoreRejectedSweep: boolean;
1883
+ readonly usesInitialModelOrder?: boolean;
1884
+ /** Clear the scope flag after its first counter-based sweep. */
1885
+ finishInitialOrderAttempt?(): void;
1886
+ useRandom(random: JavaRandom): void;
1887
+ lockPortOrder(nodeId: string): void;
1888
+ markHierarchicalNode(nodeId: string): void;
1889
+ minimize(): LayerOrder;
1890
+ shuffleFirstLayer(forward: boolean): void;
1891
+ sweep(forward: boolean, firstSweep: boolean, visitLayer?: LayerSweepVisitor): void;
1892
+ countCrossings(): number;
1893
+ snapshot(): LayerOrder;
1894
+ restore(order: LayerOrder): void;
1895
+ finish(order: LayerOrder): LayerOrder;
1896
+ /** Live current layers; callers must not mutate them. */
1897
+ currentLayers(): readonly (readonly string[])[];
1898
+ /** Current canonical port ids of one node, as {@link snapshot} records them. */
1899
+ physicalPortOrder(nodeId: string): string[];
1900
+ /** Live incident edge orders of one node; callers must not mutate them. */
1901
+ edgeOrders(nodeId: string): { incoming: readonly string[]; outgoing: readonly string[] };
1902
+ /** Same as restoring a snapshot that differs only in one layer. */
1903
+ replaceLayer(index: number, nodeIds: readonly string[]): void;
1904
+ /** Same as restoring a snapshot that differs only in one node's port and edge orders. */
1905
+ reorderNodePorts(
1906
+ nodeId: string,
1907
+ physical: readonly string[],
1908
+ incoming: readonly string[],
1909
+ outgoing: readonly string[],
1910
+ ): void;
1911
+ }
1912
+
1913
+ function clonePortOrders(orders?: ReadonlyMap<string, readonly string[]>): Map<string, string[]> {
1914
+ return new Map([...(orders ?? [])].map(([id, edges]) => [id, [...edges]]));
1680
1915
  }
1681
1916
 
1682
1917
  function minimizeCrossingsWithLayerSweep(
1683
1918
  statistic: "mean" | "median",
1684
- sweeps = 4,
1919
+ sweeps = 7,
1685
1920
  ): CrossingMinimizer {
1686
- return (input, orientation, assignment) => {
1687
- const exactPortSweep =
1688
- (input.settings.hierarchyHandling !== "INCLUDE_CHILDREN" ||
1689
- input.graph.nodes.some((node) => node.id.startsWith("__native_hierarchy_"))) &&
1690
- (input.settings["wrapping.strategy"] ?? "OFF") === "OFF" &&
1691
- (input.settings["layerUnzipping.strategy"] ?? "NONE") === "NONE";
1692
- let maximumLayer = 0;
1693
- for (const layer of assignment.layerByNodeId.values()) {
1694
- maximumLayer = Math.max(maximumLayer, layer);
1695
- }
1696
- const layerCount = maximumLayer + 1;
1697
- const layers = Array.from({ length: layerCount }, () => [] as string[]);
1698
- const initialNodes = assignment.seedOrder
1699
- ? completeSeedOrder(input, assignment)
1700
- : (input.settings["layering.strategy"] ?? "NETWORK_SIMPLEX") === "NETWORK_SIMPLEX"
1701
- ? networkSimplexComponentOrder(input)
1702
- : input.graph.nodes.map((node) => node.id);
1703
- for (const nodeId of initialNodes) {
1704
- layers[assignment.layerByNodeId.get(nodeId) ?? 0]?.push(nodeId);
1705
- }
1706
- const inputPortOrder = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
1707
- const outputPortOrder = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
1921
+ return (input, orientation, assignment) =>
1922
+ createLayerSweepSession(input, orientation, assignment, statistic, sweeps).minimize();
1923
+ }
1924
+
1925
+ /** Internal mutable sweep state shared by standalone and hierarchy coordination. */
1926
+ export function createLayerSweepSession(
1927
+ input: LayeredPhaseInput,
1928
+ orientation: AcyclicOrientation,
1929
+ assignment: LayerAssignment,
1930
+ statistic: "mean" | "median" = "mean",
1931
+ sweeps = 7,
1932
+ initialization: { resetSeed?: boolean } = {},
1933
+ ): LayerSweepSession {
1934
+ const exactPortSweep =
1935
+ (input.settings["wrapping.strategy"] ?? "OFF") === "OFF" &&
1936
+ (input.settings["layerUnzipping.strategy"] ?? "NONE") === "NONE";
1937
+ let maximumLayer = 0;
1938
+ for (const layer of assignment.layerByNodeId.values()) {
1939
+ maximumLayer = Math.max(maximumLayer, layer);
1940
+ }
1941
+ const layerCount = maximumLayer + 1;
1942
+ const layers = Array.from({ length: layerCount }, () => [] as string[]);
1943
+ const initialNodes = assignment.seedOrder
1944
+ ? completeSeedOrder(input, assignment)
1945
+ : (input.settings["layering.strategy"] ?? "NETWORK_SIMPLEX") === "NETWORK_SIMPLEX"
1946
+ ? networkSimplexComponentOrder(input)
1947
+ : input.graph.nodes.map((node) => node.id);
1948
+ for (const nodeId of initialNodes) {
1949
+ layers[assignment.layerByNodeId.get(nodeId) ?? 0]?.push(nodeId);
1950
+ }
1951
+ const units = getCrossingUnits(input);
1952
+ // Edges, orientation and layer assignment stay fixed for the session.
1953
+ const sameLayerNeighborsByDirection = new Map<boolean, Map<string, string[]>>();
1954
+ const sameLayerNeighbors = (forward: boolean) => {
1955
+ let cached = sameLayerNeighborsByDirection.get(forward);
1956
+ if (!cached)
1957
+ sameLayerNeighborsByDirection.set(forward, (cached = findSameLayerNeighbors(forward)));
1958
+ return cached;
1959
+ };
1960
+ const findSameLayerNeighbors = (forward: boolean) => {
1961
+ const neighbors = new Map<string, string[]>();
1708
1962
  for (const edge of input.graph.edges) {
1709
- const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
1710
- outputPortOrder.get(sourceId)?.push(edge.id);
1711
- inputPortOrder.get(targetId)?.push(edge.id);
1963
+ const [source, target] = getOrientedEndpoints(edge, orientation);
1964
+ if (
1965
+ source !== target &&
1966
+ assignment.layerByNodeId.get(source) === assignment.layerByNodeId.get(target)
1967
+ ) {
1968
+ const free = forward ? target : source,
1969
+ fixed = forward ? source : target;
1970
+ neighbors.set(free, [...(neighbors.get(free) ?? []), fixed]);
1971
+ }
1712
1972
  }
1713
- if (exactPortSweep) {
1714
- for (const edgeIds of inputPortOrder.values()) {
1715
- if (edgeIds.length >= 4) {
1716
- edgeIds.splice(0, edgeIds.length - 1, ...edgeIds.slice(0, -1).reverse());
1717
- }
1973
+ return neighbors;
1974
+ };
1975
+ const inputPortOrder = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
1976
+ const outputPortOrder = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
1977
+ for (const edge of input.graph.edges) {
1978
+ const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
1979
+ // ELK detaches self-loop edges before crossing minimization, then restores
1980
+ // them afterward. Their connectivity must not consume sweep port ranks.
1981
+ if (sourceId === targetId) continue;
1982
+ outputPortOrder.get(sourceId)?.push(edge.id);
1983
+ inputPortOrder.get(targetId)?.push(edge.id);
1984
+ }
1985
+ const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
1986
+ const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
1987
+ const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
1988
+ const hierarchicalNodes = new Set<string>();
1989
+ const fixedOrderNodes = new Set(
1990
+ input.graph.nodes
1991
+ .filter((node) => {
1992
+ const constraint = input.nodeSettings?.(node)?.portConstraints;
1993
+ return !!node.ports?.length && (constraint === "FIXED_POS" || constraint === "FIXED_RATIO");
1994
+ })
1995
+ .map((node) => node.id),
1996
+ );
1997
+ const fixedPortRank = (nodeId: string, edgeId: string, incoming: boolean): number => {
1998
+ const node = nodeById.get(nodeId)!;
1999
+ const edge = edgeById.get(edgeId)!;
2000
+ const reversed = orientation.reversedEdgeIds.has(edge.id);
2001
+ const portName = incoming !== reversed ? edge.targetPort : edge.sourcePort;
2002
+ const port = node.ports?.find((candidate) => candidate.name === portName);
2003
+ const horizontal = input.direction === "right" || input.direction === "left";
2004
+ const reverse = input.direction === "left" || input.direction === "up";
2005
+ const size = input.sizes.get(nodeId)!;
2006
+ const width = horizontal ? size.width : size.height;
2007
+ const height = horizontal ? size.height : size.width;
2008
+ const x = (port?.x ?? size.width / 2) + (port?.width ?? 0) / 2;
2009
+ const y = (port?.y ?? size.height / 2) + (port?.height ?? 0) / 2;
2010
+ const flow = reverse ? width - (horizontal ? x : y) : horizontal ? x : y;
2011
+ const cross = horizontal ? y : x;
2012
+ const side = port && input.portSettings?.(port, node)?.["port.side"];
2013
+ const flowSide = horizontal ? (reverse ? "WEST" : "EAST") : reverse ? "NORTH" : "SOUTH";
2014
+ const backSide = horizontal ? (reverse ? "EAST" : "WEST") : reverse ? "SOUTH" : "NORTH";
2015
+ const upperSide = horizontal ? "NORTH" : "WEST";
2016
+ // Clockwise boundary order in the canonical rightward coordinate system.
2017
+ if (side === upperSide) return flow;
2018
+ if (side === flowSide || (!side && !incoming)) return width + cross;
2019
+ if (side === backSide || (!side && incoming)) return 2 * width + 2 * height - cross;
2020
+ return 2 * width + height - flow;
2021
+ };
2022
+ // Port distribution may reorder flexible ports, never fixed physical positions.
2023
+ for (const nodeId of fixedOrderNodes) {
2024
+ outputPortOrder
2025
+ .get(nodeId)
2026
+ ?.sort((a, b) => fixedPortRank(nodeId, a, false) - fixedPortRank(nodeId, b, false));
2027
+ inputPortOrder
2028
+ .get(nodeId)
2029
+ ?.sort((a, b) => fixedPortRank(nodeId, a, true) - fixedPortRank(nodeId, b, true));
2030
+ }
2031
+ if (exactPortSweep) {
2032
+ for (const [nodeId, edgeIds] of inputPortOrder) {
2033
+ if (fixedOrderNodes.has(nodeId)) continue;
2034
+ if (edgeIds.length >= 4) {
2035
+ edgeIds.splice(0, edgeIds.length - 1, ...edgeIds.slice(0, -1).reverse());
1718
2036
  }
1719
2037
  }
1720
- const countCrossings = (candidateLayers: readonly (readonly string[])[]): number => {
1721
- if (!exactPortSweep) {
1722
- const positions = new Map<string, number>();
1723
- for (const layer of candidateLayers) {
1724
- for (const [index, id] of layer.entries()) positions.set(id, index);
1725
- }
1726
- let crossings = 0;
1727
- for (let left = 0; left < input.graph.edges.length; left++) {
1728
- const [leftSource, leftTarget] = getOrientedEndpoints(
1729
- input.graph.edges[left]!,
1730
- orientation,
1731
- );
1732
- for (let right = left + 1; right < input.graph.edges.length; right++) {
1733
- const [rightSource, rightTarget] = getOrientedEndpoints(
1734
- input.graph.edges[right]!,
1735
- orientation,
1736
- );
1737
- if (leftSource === rightSource || leftTarget === rightTarget) continue;
1738
- const sourceDifference =
1739
- (positions.get(leftSource) ?? 0) - (positions.get(rightSource) ?? 0);
1740
- const targetDifference =
1741
- (positions.get(leftTarget) ?? 0) - (positions.get(rightTarget) ?? 0);
1742
- if (sourceDifference * targetDifference < 0) crossings++;
1743
- }
1744
- }
1745
- return crossings;
2038
+ }
2039
+ const buildCrossingGraph = createCrossingGraphBuilder(input);
2040
+ const countCrossings = (candidateLayers: readonly (readonly string[])[]): number => {
2041
+ if (exactPortSweep) {
2042
+ const graph = buildCrossingGraph(
2043
+ orientation,
2044
+ candidateLayers,
2045
+ inputPortOrder,
2046
+ outputPortOrder,
2047
+ );
2048
+ let total = countAllCrossings(splitMixedCrossingPorts(graph, canonicalReversed)).total;
2049
+ const nodeInfluence = Number(
2050
+ input.settings["considerModelOrder.crossingCounterNodeInfluence"] ?? 0,
2051
+ );
2052
+ // ELK 0.11.1 tests node influence twice when selecting the weighted counter.
2053
+ // Port influence alone therefore does not enable its model-order objective.
2054
+ if (
2055
+ (input.settings["considerModelOrder.strategy"] ?? "NONE") !== "NONE" &&
2056
+ nodeInfluence !== 0
2057
+ ) {
2058
+ const changes = countModelOrderChanges(input, orientation, graph);
2059
+ total +=
2060
+ nodeInfluence * changes.nodeChanges +
2061
+ Number(input.settings["considerModelOrder.crossingCounterPortInfluence"] ?? 0) *
2062
+ changes.portChanges;
1746
2063
  }
1747
- const layerIndex = new Map<string, number>();
1748
- for (const [index, layer] of candidateLayers.entries()) {
1749
- for (const id of layer) {
1750
- layerIndex.set(id, index);
1751
- }
2064
+ return total;
2065
+ }
2066
+ const positions = new Map<string, number>();
2067
+ for (const layer of candidateLayers) {
2068
+ for (const [index, id] of layer.entries()) positions.set(id, index);
2069
+ }
2070
+ let crossings = 0;
2071
+ for (let left = 0; left < input.graph.edges.length; left++) {
2072
+ const [leftSource, leftTarget] = getOrientedEndpoints(input.graph.edges[left]!, orientation);
2073
+ for (let right = left + 1; right < input.graph.edges.length; right++) {
2074
+ const [rightSource, rightTarget] = getOrientedEndpoints(
2075
+ input.graph.edges[right]!,
2076
+ orientation,
2077
+ );
2078
+ if (leftSource === rightSource || leftTarget === rightTarget) continue;
2079
+ const sourceDifference =
2080
+ (positions.get(leftSource) ?? 0) - (positions.get(rightSource) ?? 0);
2081
+ const targetDifference =
2082
+ (positions.get(leftTarget) ?? 0) - (positions.get(rightTarget) ?? 0);
2083
+ if (sourceDifference * targetDifference < 0) crossings++;
1752
2084
  }
1753
- let crossings = 0;
1754
- for (let index = 0; index < candidateLayers.length - 1; index++) {
1755
- const between = input.graph.edges.filter((edge) => {
1756
- const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
1757
- return layerIndex.get(sourceId) === index && layerIndex.get(targetId) === index + 1;
1758
- });
1759
- const sourceRanks = new Map<string, number>();
1760
- const targetRanks = new Map<string, number>();
1761
- let consumed = 0;
1762
- for (const nodeId of candidateLayers[index] ?? []) {
1763
- const edgeIds = outputPortOrder.get(nodeId) ?? [];
1764
- for (const [portIndex, edgeId] of edgeIds.entries()) {
1765
- sourceRanks.set(edgeId, consumed + portIndex + 1);
1766
- }
1767
- consumed += edgeIds.length;
1768
- }
1769
- consumed = 0;
1770
- for (const nodeId of candidateLayers[index + 1] ?? []) {
1771
- const edgeIds = inputPortOrder.get(nodeId) ?? [];
1772
- for (const [portIndex, edgeId] of edgeIds.entries()) {
1773
- targetRanks.set(edgeId, consumed + edgeIds.length - portIndex);
1774
- }
1775
- consumed += edgeIds.length;
1776
- }
1777
- const orderedTargets = between
1778
- .map((edge) => ({
1779
- source: sourceRanks.get(edge.id) ?? 0,
1780
- target: targetRanks.get(edge.id) ?? 0,
1781
- }))
1782
- .sort((left, right) => left.source - right.source)
1783
- .map(({ target }) => target);
1784
- const sortedTargets = [...orderedTargets].sort((left, right) => left - right);
1785
- const targetIndex = new Map(sortedTargets.map((rank, rankIndex) => [rank, rankIndex + 1]));
1786
- const fenwick = Array.from({ length: sortedTargets.length + 1 }, () => 0);
1787
- let seen = 0;
1788
- for (const target of orderedTargets) {
1789
- const rank = targetIndex.get(target) ?? 1;
1790
- let preceding = 0;
1791
- for (let cursor = rank; cursor > 0; cursor -= cursor & -cursor) {
1792
- preceding += fenwick[cursor] ?? 0;
1793
- }
1794
- crossings += seen - preceding;
1795
- for (let cursor = rank; cursor < fenwick.length; cursor += cursor & -cursor) {
1796
- fenwick[cursor] = (fenwick[cursor] ?? 0) + 1;
1797
- }
1798
- seen++;
2085
+ }
2086
+ return crossings;
2087
+ };
2088
+
2089
+ const sharedRandom = crossingRandom(input);
2090
+ const randomSeed = initialization.resetSeed === false ? undefined : sharedRandom.nextLong();
2091
+ const portDistributorUsesNodeRelativeRanks = sharedRandom.nextBoolean();
2092
+ const nodeRelativePortRanks = portDistributorUsesNodeRelativeRanks;
2093
+ nodeRelativePortRanksByInput.set(input, nodeRelativePortRanks);
2094
+ // ELK treats the median heuristic as deterministic, so it keeps using the
2095
+ // graph's shared RNG after port-distributor selection. Barycenter resets to
2096
+ // the saved seed while comparing randomized layouts.
2097
+ let random =
2098
+ statistic === "median" || randomSeed === undefined ? sharedRandom : new JavaRandom(randomSeed);
2099
+ const thoroughness = Math.max(1, input.settings.thoroughness ?? sweeps ?? 7);
2100
+ let working = layers.map((layer) => [...layer]);
2101
+ const usesInitialModelOrder =
2102
+ statistic === "mean" && (input.settings["considerModelOrder.strategy"] ?? "NONE") !== "NONE";
2103
+ // Preserved model order makes unknown barycenters interpolate rather than
2104
+ // consume random floats during the first attempt. Child scopes keep their
2105
+ // own flag until their counter-based attempt finishes.
2106
+ let initialOrderAttempt = usesInitialModelOrder;
2107
+ const sourceUnknownPlacement = true;
2108
+ const forcedCompare =
2109
+ usesInitialModelOrder && input.settings["crossingMinimization.forceNodeModelOrder"] === true
2110
+ ? normalModelComparator(input)
2111
+ : undefined;
2112
+ const usePortRanks = true;
2113
+ const medianWeights = new Map<string, number>();
2114
+ const listedEdgeIds = new Map<string, string>();
2115
+ const listedEdgeId = (edgeId: string) => {
2116
+ let listed = listedEdgeIds.get(edgeId);
2117
+ if (listed === undefined)
2118
+ listedEdgeIds.set(edgeId, (listed = edgeId.replace(/(::(segment|inverted):\d+:*)+$/, "")));
2119
+ return listed;
2120
+ };
2121
+
2122
+ const adjacentRanks = (
2123
+ fixedLayer: readonly string[],
2124
+ freeLayer: readonly string[],
2125
+ forward: boolean,
2126
+ ): { ranks: Map<string, number[]>; visits: Map<string, (number | string)[]> } => {
2127
+ const edgeRanks = new Map<string, number>();
2128
+ const visits = new Map(freeLayer.map((id) => [id, [] as (number | string)[]]));
2129
+ const freeIds = new Set(freeLayer);
2130
+ const ranks = new Map(freeLayer.map((id) => [id, [] as number[]]));
2131
+ if (!usePortRanks) {
2132
+ const fixedPosition = new Map(fixedLayer.map((id, index) => [id, index]));
2133
+ for (const edge of input.graph.edges) {
2134
+ const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
2135
+ if (forward && fixedPosition.has(sourceId) && freeIds.has(targetId)) {
2136
+ ranks.get(targetId)?.push(fixedPosition.get(sourceId)!);
2137
+ } else if (!forward && fixedPosition.has(targetId) && freeIds.has(sourceId)) {
2138
+ ranks.get(sourceId)?.push(fixedPosition.get(targetId)!);
1799
2139
  }
1800
2140
  }
1801
- return crossings;
1802
- };
1803
-
1804
- const sharedRandom = new JavaRandom(input.settings.randomSeed ?? 1);
1805
- const randomSeed = sharedRandom.nextLong();
1806
- const portDistributorUsesNodeRelativeRanks = sharedRandom.nextBoolean();
1807
- const nodeRelativePortRanks = portDistributorUsesNodeRelativeRanks;
1808
- // ELK treats the median heuristic as deterministic, so it keeps using the
1809
- // graph's shared RNG after port-distributor selection. Barycenter resets to
1810
- // the saved seed while comparing randomized layouts.
1811
- const random = statistic === "median" ? sharedRandom : new JavaRandom(randomSeed);
1812
- const thoroughness = Math.max(1, input.settings.thoroughness ?? sweeps ?? 7);
1813
- let bestLayers = layers.map((layer) => [...layer]);
1814
- let bestInputPortOrder = new Map<string, string[]>();
1815
- let bestOutputPortOrder = new Map<string, string[]>();
1816
- let bestCrossings = Number.POSITIVE_INFINITY;
1817
- let working = layers.map((layer) => [...layer]);
1818
- const sourceUnknownPlacement =
1819
- (input.settings["considerModelOrder.strategy"] ?? "NONE") === "NONE";
1820
- const usePortRanks = true;
1821
- const medianWeights = new Map<string, number>();
1822
-
1823
- const adjacentRanks = (
1824
- fixedLayer: readonly string[],
1825
- freeLayer: readonly string[],
1826
- forward: boolean,
1827
- ): Map<string, number[]> => {
1828
- const freeIds = new Set(freeLayer);
1829
- const ranks = new Map(freeLayer.map((id) => [id, [] as number[]]));
1830
- if (!usePortRanks) {
1831
- const fixedPosition = new Map(fixedLayer.map((id, index) => [id, index]));
1832
- for (const edge of input.graph.edges) {
1833
- const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
1834
- if (forward && fixedPosition.has(sourceId) && freeIds.has(targetId)) {
1835
- ranks.get(targetId)?.push(fixedPosition.get(sourceId)!);
1836
- } else if (!forward && fixedPosition.has(targetId) && freeIds.has(sourceId)) {
1837
- ranks.get(sourceId)?.push(fixedPosition.get(targetId)!);
1838
- }
2141
+ return { ranks, visits };
2142
+ }
2143
+ if (exactPortSweep) {
2144
+ const fixedIndex = assignment.layerByNodeId.get(fixedLayer[0] ?? "");
2145
+ if (fixedIndex !== undefined) {
2146
+ const nodes = fixedLayer.map((id) => canonicalNodes.get(id)!);
2147
+ canonicalGraph.layers[fixedIndex] = nodes;
2148
+ canonicalDistributor.calculatePortRanks(
2149
+ canonicalGraph,
2150
+ nodes,
2151
+ !forward,
2152
+ nodeRelativePortRanks,
2153
+ );
2154
+ const fixedIds = new Set(fixedLayer);
2155
+ for (let index = 0; index < canonicalEdges.length; index++) {
2156
+ const edge = canonicalEdges[index]!;
2157
+ const [source, target] = canonicalOrientedEndpoints[index]!;
2158
+ if (!fixedIds.has(forward ? source : target) || !freeIds.has(forward ? target : source))
2159
+ continue;
2160
+ const endpoint = canonicalEndpoints.get(edge.id)!;
2161
+ const rank =
2162
+ canonicalDistributor.state.ranks[forward ? endpoint.source : endpoint.target]!;
2163
+ ranks.get(forward ? target : source)!.push(rank);
2164
+ edgeRanks.set(edge.id, rank);
1839
2165
  }
1840
- return ranks;
1841
2166
  }
2167
+ } else {
1842
2168
  let rankSum = 0;
1843
2169
  for (const fixedId of fixedLayer) {
1844
2170
  const groups = new Map<string, { edges: GraphEdge[]; portOrder: number }>();
@@ -1886,188 +2212,329 @@ function minimizeCrossingsWithLayerSweep(
1886
2212
  for (const edge of group.edges) {
1887
2213
  const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
1888
2214
  ranks.get(forward ? targetId : sourceId)?.push(rank);
2215
+ edgeRanks.set(edge.id, rank);
1889
2216
  }
1890
2217
  }
1891
2218
  rankSum += nodeRelativePortRanks ? 1 : count;
1892
2219
  }
1893
- return ranks;
1894
- };
1895
-
1896
- const distributePorts = (
1897
- fixedLayer: readonly string[],
1898
- freeLayer: readonly string[],
1899
- forward: boolean,
1900
- ) => {
1901
- const calculateRanks = (
1902
- layer: readonly string[],
1903
- orders: ReadonlyMap<string, readonly string[]>,
1904
- inputPorts: boolean,
1905
- ) => {
1906
- const ranks = new Map<string, number>();
1907
- let consumed = 0;
1908
- for (const nodeId of layer) {
1909
- const edgeIds = orders.get(nodeId) ?? [];
1910
- for (const [index, edgeId] of edgeIds.entries()) {
1911
- ranks.set(
1912
- edgeId,
1913
- nodeRelativePortRanks
1914
- ? consumed +
1915
- (inputPorts
1916
- ? 1 - (index + 1) / (edgeIds.length + 1)
1917
- : (index + 1) / (edgeIds.length + 1))
1918
- : consumed + (inputPorts ? edgeIds.length - index : index + 1),
1919
- );
1920
- }
1921
- consumed += nodeRelativePortRanks ? 1 : edgeIds.length;
1922
- }
1923
- return ranks;
1924
- };
1925
- const reorder = (
1926
- nodeIds: readonly string[],
1927
- orders: Map<string, string[]>,
1928
- oppositeRanks: ReadonlyMap<string, number>,
1929
- reverse: boolean,
1930
- ) => {
1931
- for (const nodeId of nodeIds) {
1932
- orders.get(nodeId)?.sort((leftId, rightId) => {
1933
- const difference = (oppositeRanks.get(leftId) ?? 0) - (oppositeRanks.get(rightId) ?? 0);
1934
- return reverse ? -difference : difference;
1935
- });
2220
+ }
2221
+ // Visit each free node's ports in their current canonical order, while
2222
+ // preserving incident-edge order within a shared port. Fixed-layer ranks
2223
+ // and same-layer recursion must not be separated into independent sums.
2224
+ for (const id of freeLayer) {
2225
+ const order = (forward ? inputPortOrder : outputPortOrder).get(id) ?? [];
2226
+ const groups = new Map<string, string[]>();
2227
+ for (const edgeId of order) {
2228
+ const edge = edgeById.get(edgeId)!;
2229
+ const reversed = orientation.reversedEdgeIds.has(edge.id);
2230
+ const name = forward !== reversed ? edge.targetPort : edge.sourcePort;
2231
+ const key = name ?? `__implicit:${edgeId}`;
2232
+ const group = groups.get(key);
2233
+ if (group) group.push(edgeId);
2234
+ else groups.set(key, [edgeId]);
2235
+ }
2236
+ for (const group of groups.values()) {
2237
+ // Port sorting reorders ports, not the edges on one port; those keep
2238
+ // ELK's edge list order. Expanded segments keep their listed edge id.
2239
+ const listed = units?.edgeListRanks?.[forward ? "incoming" : "outgoing"].get(id);
2240
+ const rank = (edgeId: string) => listed?.get(listedEdgeId(edgeId));
2241
+ group.sort(
2242
+ (a, b) =>
2243
+ (rank(a) !== undefined && rank(b) !== undefined ? rank(a)! - rank(b)! : 0) ||
2244
+ edgeModelOrder.get(a)! - edgeModelOrder.get(b)!,
2245
+ );
2246
+ for (const edgeId of group) {
2247
+ const edge = edgeById.get(edgeId)!;
2248
+ const [source, target] = getOrientedEndpoints(edge, orientation);
2249
+ const neighbor = forward ? source : target;
2250
+ const rank = edgeRanks.get(edgeId);
2251
+ if (assignment.layerByNodeId.get(neighbor) === assignment.layerByNodeId.get(id))
2252
+ visits.get(id)!.push(neighbor);
2253
+ else if (rank !== undefined) visits.get(id)!.push(rank);
1936
2254
  }
1937
- };
1938
- if (forward) {
1939
- const fixedRanks = calculateRanks(fixedLayer, outputPortOrder, false);
1940
- reorder(freeLayer, inputPortOrder, fixedRanks, true);
1941
- const freeRanks = calculateRanks(freeLayer, inputPortOrder, true);
1942
- reorder(fixedLayer, outputPortOrder, freeRanks, false);
1943
- } else {
1944
- const fixedRanks = calculateRanks(fixedLayer, inputPortOrder, true);
1945
- reorder(freeLayer, outputPortOrder, fixedRanks, false);
1946
- const freeRanks = calculateRanks(freeLayer, outputPortOrder, false);
1947
- reorder(fixedLayer, inputPortOrder, freeRanks, true);
1948
2255
  }
1949
- };
2256
+ }
2257
+ return { ranks, visits };
2258
+ };
1950
2259
 
1951
- const attempts = statistic === "median" ? 1 : thoroughness;
1952
- for (let attempt = 0; attempt < attempts; attempt++) {
1953
- let forward = random.nextBoolean();
1954
- const firstLayerIndex = forward ? 0 : Math.max(0, working.length - 1);
1955
- const firstLayerWeights = new Map(
1956
- (working[firstLayerIndex] ?? []).map((id) => [id, random.nextDouble()]),
1957
- );
1958
- working[firstLayerIndex]?.sort(
1959
- (left, right) => (firstLayerWeights.get(left) ?? 0) - (firstLayerWeights.get(right) ?? 0),
1960
- );
1961
- if (statistic === "median") {
1962
- working[firstLayerIndex]?.forEach((id, index) => medianWeights.set(id, index + 1));
2260
+ let canonicalGraph = buildCrossingGraph(orientation, working, inputPortOrder, outputPortOrder);
2261
+ if (usesInitialModelOrder) {
2262
+ if (!restoreInitialModelOrder(input, canonicalGraph))
2263
+ sortInitialModelOrder(input, orientation, canonicalGraph);
2264
+ working = canonicalGraph.layers.map((layer) => layer.map((node) => node.id));
2265
+ const edges = input.graph.edges.filter((edge) => edge.sourceId !== edge.targetId);
2266
+ const endpoints = new Map(edges.map((edge, index) => [edge.id, canonicalGraph.edges[index]!]));
2267
+ for (const node of canonicalGraph.layers.flat()) {
2268
+ const ordinal = new Map(node.ports.map((port, index) => [port.id, index]));
2269
+ for (const [orders, source] of [
2270
+ [outputPortOrder, true],
2271
+ [inputPortOrder, false],
2272
+ ] as const) {
2273
+ orders
2274
+ .get(node.id)
2275
+ ?.sort(
2276
+ (a, b) =>
2277
+ ordinal.get(endpoints.get(a)![source ? "source" : "target"])! -
2278
+ ordinal.get(endpoints.get(b)![source ? "source" : "target"])!,
2279
+ );
1963
2280
  }
1964
-
1965
- const sweep = (isForward: boolean, firstSweep: boolean) => {
1966
- const sortWithMedianWeights = (current: string[], reference: readonly string[]) => {
1967
- const referenceIds = new Set(reference);
1968
- const originalIndex = new Map(current.map((id, index) => [id, index]));
1969
- for (const id of current) {
1970
- const connectedWeights = input.graph.edges
1971
- .flatMap((edge) => {
1972
- if (edge.sourceId === id && referenceIds.has(edge.targetId)) {
1973
- return [medianWeights.get(edge.targetId) ?? 0];
1974
- }
1975
- if (edge.targetId === id && referenceIds.has(edge.sourceId)) {
1976
- return [medianWeights.get(edge.sourceId) ?? 0];
1977
- }
1978
- return [];
1979
- })
1980
- .sort((left, right) => left - right);
1981
- medianWeights.set(
1982
- id,
1983
- connectedWeights.length > 0
1984
- ? connectedWeights[Math.floor(connectedWeights.length / 2)]!
1985
- : Number.MAX_VALUE / 2,
2281
+ }
2282
+ modelSweepInputs.add(input);
2283
+ }
2284
+ let canonicalNodes = new Map(canonicalGraph.layers.flat().map((node) => [node.id, node]));
2285
+ const canonicalDistributor = new CanonicalPortDistributor(canonicalGraph);
2286
+ const canonicalEdges = input.graph.edges.filter((edge) => edge.sourceId !== edge.targetId);
2287
+ const canonicalReversed = canonicalEdges.map((edge) => orientation.reversedEdgeIds.has(edge.id));
2288
+ const canonicalOrientedEndpoints = canonicalEdges.map((edge) =>
2289
+ getOrientedEndpoints(edge, orientation),
2290
+ );
2291
+ const canonicalEndpoints = new Map(
2292
+ canonicalEdges.map((edge, index) => [edge.id, canonicalGraph.edges[index]!]),
2293
+ );
2294
+ const canonicalFixedOrder = new Set(
2295
+ input.graph.nodes
2296
+ .filter((node) => {
2297
+ const constraint = input.nodeSettings?.(node)?.portConstraints;
2298
+ return (
2299
+ constraint === "FIXED_ORDER" || constraint === "FIXED_POS" || constraint === "FIXED_RATIO"
2300
+ );
2301
+ })
2302
+ .map((node) => node.id),
2303
+ );
2304
+ // Every canonically fixed node plus each port order locked by a child scope.
2305
+ const fixedPortOrders = new Set(canonicalFixedOrder);
2306
+ const distributePorts = (layerIndex: number, forward: boolean) => {
2307
+ const touched = [layerIndex, layerIndex + (forward ? -1 : 1)].filter(
2308
+ (index) => index >= 0 && index < working.length,
2309
+ );
2310
+ for (const index of touched)
2311
+ canonicalGraph.layers[index] = working[index]!.map((id) => canonicalNodes.get(id)!);
2312
+ canonicalDistributor.distribute(canonicalGraph, layerIndex, forward, {
2313
+ nodeRelative: nodeRelativePortRanks,
2314
+ fixedOrder: fixedPortOrders,
2315
+ hierarchical: hierarchicalNodes,
2316
+ });
2317
+ for (const index of touched)
2318
+ for (const node of canonicalGraph.layers[index]!) {
2319
+ const ordinal = new Map(node.ports.map((port, index) => [port.id, index]));
2320
+ for (const [orders, source] of [
2321
+ [outputPortOrder, true],
2322
+ [inputPortOrder, false],
2323
+ ] as const)
2324
+ orders
2325
+ .get(node.id)
2326
+ ?.sort(
2327
+ (left, right) =>
2328
+ ordinal.get(canonicalEndpoints.get(left)![source ? "source" : "target"])! -
2329
+ ordinal.get(canonicalEndpoints.get(right)![source ? "source" : "target"])!,
1986
2330
  );
1987
- }
1988
- current.sort(
1989
- (left, right) =>
1990
- (medianWeights.get(left) ?? 0) - (medianWeights.get(right) ?? 0) ||
1991
- (originalIndex.get(left) ?? 0) - (originalIndex.get(right) ?? 0),
1992
- );
1993
- };
1994
- if (isForward) {
1995
- for (let layer = 1; layer < working.length; layer++) {
1996
- const current = working[layer];
1997
- const previous = working[layer - 1];
1998
- if (current && previous) {
1999
- if (statistic === "median") sortWithMedianWeights(current, previous);
2000
- else
2001
- sortLayerByAdjacentPosition(
2002
- current,
2003
- adjacentRanks(previous, current, true),
2004
- statistic,
2005
- random,
2006
- !firstSweep,
2007
- sourceUnknownPlacement,
2008
- true,
2009
- );
2010
- if (exactPortSweep) distributePorts(previous, current, true);
2011
- }
2012
- }
2013
- } else {
2014
- for (let layer = working.length - 2; layer >= 0; layer--) {
2015
- const current = working[layer];
2016
- const next = working[layer + 1];
2017
- if (current && next) {
2018
- if (statistic === "median") sortWithMedianWeights(current, next);
2019
- else
2020
- sortLayerByAdjacentPosition(
2021
- current,
2022
- adjacentRanks(next, current, false),
2023
- statistic,
2024
- random,
2025
- !firstSweep,
2026
- sourceUnknownPlacement,
2027
- true,
2028
- );
2029
- if (exactPortSweep) distributePorts(next, current, false);
2030
- }
2031
- }
2032
- }
2033
- };
2331
+ }
2332
+ };
2034
2333
 
2035
- sweep(forward, true);
2036
- let crossings = countCrossings(working);
2037
- let attemptBestLayers = working.map((layer) => [...layer]);
2038
- let attemptBestInputPortOrder = new Map(
2039
- [...inputPortOrder].map(([id, edgeIds]) => [id, [...edgeIds]]),
2040
- );
2041
- let attemptBestOutputPortOrder = new Map(
2042
- [...outputPortOrder].map(([id, edgeIds]) => [id, [...edgeIds]]),
2334
+ const shuffleFirstLayer = (forward: boolean) => {
2335
+ const firstLayerIndex = forward ? 0 : Math.max(0, working.length - 1);
2336
+ const firstLayerWeights = new Map(
2337
+ (working[firstLayerIndex] ?? []).map((id) => [id, random.nextDouble()]),
2338
+ );
2339
+ const compare = (left: string, right: string) =>
2340
+ forcedCompare?.(left, right) ||
2341
+ (firstLayerWeights.get(left) ?? 0) - (firstLayerWeights.get(right) ?? 0);
2342
+ const first = working[firstLayerIndex];
2343
+ if (first) {
2344
+ if (forcedCompare)
2345
+ insertionSort(
2346
+ first,
2347
+ transitiveComparator((id: string) => id, compare),
2348
+ );
2349
+ else first.sort(compare);
2350
+ }
2351
+ if (units && !forcedCompare && working[firstLayerIndex])
2352
+ working[firstLayerIndex] = resolveCrossingConstraints(
2353
+ working[firstLayerIndex]!,
2354
+ firstLayerWeights,
2355
+ units,
2043
2356
  );
2044
- while (crossings > 0) {
2045
- forward = !forward;
2046
- const before = exactPortSweep ? undefined : working.map((layer) => [...layer]);
2047
- sweep(forward, false);
2048
- const nextCrossings = countCrossings(working);
2049
- if (nextCrossings >= crossings) {
2050
- if (before) working = before;
2051
- break;
2357
+ if (statistic === "median") {
2358
+ working[firstLayerIndex]?.forEach((id, index) => medianWeights.set(id, index + 1));
2359
+ }
2360
+ };
2361
+ // Non-loop edges by endpoint, for grouping a mixed port's neighbours.
2362
+ const edgesByNode = new Map<string, GraphEdge[]>();
2363
+ for (const edge of input.graph.edges) {
2364
+ if (edge.sourceId === edge.targetId) continue;
2365
+ for (const id of [edge.sourceId, edge.targetId]) {
2366
+ const list = edgesByNode.get(id);
2367
+ if (list) list.push(edge);
2368
+ else edgesByNode.set(id, [edge]);
2369
+ }
2370
+ }
2371
+ /**
2372
+ * A port carrying both directions gives each direction its own track and
2373
+ * attachment, so its edges are no longer interchangeable: neighbours of one
2374
+ * direction must not interleave with the other's, or the separated routes
2375
+ * cross. Barycenters tie (up to noise) for nodes reached only through one
2376
+ * port; within the slots those nodes occupy, put forward edges before
2377
+ * reversed ones. Both ends of a chain see the same reversal, so the order
2378
+ * holds along it.
2379
+ */
2380
+ const constrained = new Set([
2381
+ ...(units?.units.keys() ?? []),
2382
+ ...[...(units?.successors ?? [])].flatMap(([id, after]) => [id, ...after]),
2383
+ ]);
2384
+ const groupMixedPortDirections = (
2385
+ free: string[],
2386
+ fixed: readonly string[],
2387
+ beyond: readonly string[] = [],
2388
+ ) => {
2389
+ const fixedIds = new Set(fixed);
2390
+ const groups = new Map<string, Array<{ id: string; reversed: boolean }>>();
2391
+ for (const id of free) {
2392
+ if (constrained.has(id)) continue;
2393
+ let only: GraphEdge | undefined,
2394
+ count = 0;
2395
+ for (const edge of edgesByNode.get(id) ?? [])
2396
+ if (fixedIds.has(edge.sourceId === id ? edge.targetId : edge.sourceId)) {
2397
+ only = edge;
2398
+ count++;
2052
2399
  }
2053
- crossings = nextCrossings;
2054
- attemptBestLayers = working.map((layer) => [...layer]);
2055
- attemptBestInputPortOrder = new Map(
2056
- [...inputPortOrder].map(([id, edgeIds]) => [id, [...edgeIds]]),
2057
- );
2058
- attemptBestOutputPortOrder = new Map(
2059
- [...outputPortOrder].map(([id, edgeIds]) => [id, [...edgeIds]]),
2400
+ if (count !== 1) continue;
2401
+ const atSource = only!.sourceId !== id;
2402
+ const name = atSource ? only!.sourcePort : only!.targetPort;
2403
+ if (name === undefined) continue;
2404
+ const key = JSON.stringify([atSource ? only!.sourceId : only!.targetId, name]);
2405
+ const group = groups.get(key) ?? [];
2406
+ group.push({ id, reversed: orientation.reversedEdgeIds.has(only!.id) });
2407
+ groups.set(key, group);
2408
+ }
2409
+ if (![...groups.values()].some((g) => g.some((m) => m.reversed) && g.some((m) => !m.reversed)))
2410
+ return;
2411
+ const index = new Map(free.map((id, i) => [id, i]));
2412
+ // The direction whose nodes lead further toward the start of the layer
2413
+ // beyond (from the previous sweep) goes first; forward edges by default.
2414
+ const far = new Map(beyond.map((id, i) => [id, i]));
2415
+ const reach = (members: ReadonlyArray<{ id: string }>) => {
2416
+ let sum = 0,
2417
+ count = 0;
2418
+ for (const { id } of members)
2419
+ for (const edge of edgesByNode.get(id) ?? []) {
2420
+ const at = far.get(edge.sourceId === id ? edge.targetId : edge.sourceId);
2421
+ if (at === undefined) continue;
2422
+ sum += at;
2423
+ count++;
2424
+ }
2425
+ return count ? sum / count : undefined;
2426
+ };
2427
+ for (const group of groups.values()) {
2428
+ if (!group.some((m) => m.reversed) || group.every((m) => m.reversed)) continue;
2429
+ const forward = reach(group.filter((m) => !m.reversed)),
2430
+ backward = reach(group.filter((m) => m.reversed));
2431
+ const reversedFirst =
2432
+ forward === undefined || backward === undefined ? false : backward < forward;
2433
+ const slots = group.map((m) => index.get(m.id)!).sort((a, b) => a - b);
2434
+ const members = group
2435
+ .sort((a, b) => index.get(a.id)! - index.get(b.id)!)
2436
+ .sort((a, b) => (reversedFirst ? -1 : 1) * (Number(a.reversed) - Number(b.reversed)));
2437
+ for (const [i, slot] of slots.entries()) free[slot] = members[i]!.id;
2438
+ }
2439
+ };
2440
+ const sweep = (isForward: boolean, firstSweep: boolean, visitLayer?: LayerSweepVisitor) => {
2441
+ const firstLayer = isForward ? 0 : Math.max(0, working.length - 1);
2442
+ if (exactPortSweep) distributePorts(firstLayer, isForward);
2443
+ visitLayer?.(firstLayer, [...(working[firstLayer] ?? [])], isForward, firstSweep);
2444
+ const sortWithMedianWeights = (current: string[], reference: readonly string[]) => {
2445
+ const referenceIds = new Set(reference);
2446
+ const originalIndex = new Map(current.map((id, index) => [id, index]));
2447
+ for (const id of current) {
2448
+ const connectedWeights = input.graph.edges
2449
+ .flatMap((edge) => {
2450
+ if (edge.sourceId === id && referenceIds.has(edge.targetId)) {
2451
+ return [medianWeights.get(edge.targetId) ?? 0];
2452
+ }
2453
+ if (edge.targetId === id && referenceIds.has(edge.sourceId)) {
2454
+ return [medianWeights.get(edge.sourceId) ?? 0];
2455
+ }
2456
+ return [];
2457
+ })
2458
+ .sort((left, right) => left - right);
2459
+ medianWeights.set(
2460
+ id,
2461
+ connectedWeights.length > 0
2462
+ ? connectedWeights[Math.floor(connectedWeights.length / 2)]!
2463
+ : Number.MAX_VALUE / 2,
2060
2464
  );
2061
2465
  }
2062
- if (crossings < bestCrossings) {
2063
- bestCrossings = crossings;
2064
- bestLayers = attemptBestLayers;
2065
- bestInputPortOrder = attemptBestInputPortOrder;
2066
- bestOutputPortOrder = attemptBestOutputPortOrder;
2067
- if (crossings === 0) break;
2466
+ current.sort(
2467
+ (left, right) =>
2468
+ (medianWeights.get(left) ?? 0) - (medianWeights.get(right) ?? 0) ||
2469
+ (originalIndex.get(left) ?? 0) - (originalIndex.get(right) ?? 0),
2470
+ );
2471
+ };
2472
+ if (isForward) {
2473
+ for (let layer = 1; layer < working.length; layer++) {
2474
+ const current = working[layer];
2475
+ const previous = working[layer - 1];
2476
+ if (current && previous) {
2477
+ if (statistic === "median") sortWithMedianWeights(current, previous);
2478
+ else {
2479
+ const adjacent = adjacentRanks(previous, current, true);
2480
+ sortLayerByAdjacentPosition(
2481
+ current,
2482
+ adjacent.ranks,
2483
+ statistic,
2484
+ random,
2485
+ !firstSweep ||
2486
+ initialOrderAttempt ||
2487
+ canonicalNodes.get(current[0] ?? "")?.type === "EXTERNAL_PORT",
2488
+ sourceUnknownPlacement,
2489
+ true,
2490
+ units,
2491
+ sameLayerNeighbors(isForward),
2492
+ adjacent.visits,
2493
+ forcedCompare,
2494
+ );
2495
+ }
2496
+ groupMixedPortDirections(current, previous, working[layer + 1]);
2497
+ if (exactPortSweep) distributePorts(layer, true);
2498
+ visitLayer?.(layer, [...current], isForward, firstSweep);
2499
+ }
2500
+ }
2501
+ } else {
2502
+ for (let layer = working.length - 2; layer >= 0; layer--) {
2503
+ const current = working[layer];
2504
+ const next = working[layer + 1];
2505
+ if (current && next) {
2506
+ if (statistic === "median") sortWithMedianWeights(current, next);
2507
+ else {
2508
+ const adjacent = adjacentRanks(next, current, false);
2509
+ sortLayerByAdjacentPosition(
2510
+ current,
2511
+ adjacent.ranks,
2512
+ statistic,
2513
+ random,
2514
+ !firstSweep ||
2515
+ initialOrderAttempt ||
2516
+ canonicalNodes.get(current[0] ?? "")?.type === "EXTERNAL_PORT",
2517
+ sourceUnknownPlacement,
2518
+ true,
2519
+ units,
2520
+ sameLayerNeighbors(isForward),
2521
+ adjacent.visits,
2522
+ forcedCompare,
2523
+ );
2524
+ }
2525
+ groupMixedPortDirections(current, next, working[layer - 1]);
2526
+ if (exactPortSweep) distributePorts(layer, false);
2527
+ visitLayer?.(layer, [...current], isForward, firstSweep);
2528
+ }
2068
2529
  }
2069
2530
  }
2531
+ };
2070
2532
 
2533
+ const attempts = statistic === "median" ? 1 : thoroughness;
2534
+ const finish = (order: LayerOrder): LayerOrder => {
2535
+ const bestLayers = order.layers.map((layer) => [...layer]);
2536
+ const bestInputPortOrder = clonePortOrders(order.inputPortOrderByNodeId);
2537
+ const bestOutputPortOrder = clonePortOrders(order.outputPortOrderByNodeId);
2071
2538
  if (statistic === "median" && input.direction === "left") {
2072
2539
  const edgeOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
2073
2540
  for (const layer of bestLayers) {
@@ -2117,41 +2584,96 @@ function minimizeCrossingsWithLayerSweep(
2117
2584
  phaseRandomByInput.set(input, random);
2118
2585
  return exactPortSweep
2119
2586
  ? {
2120
- layers: bestLayers,
2121
- inputPortOrderByNodeId: bestInputPortOrder,
2122
- outputPortOrderByNodeId: bestOutputPortOrder,
2123
- }
2124
- : { layers: bestLayers };
2125
- };
2126
- }
2127
-
2128
- /** NetworkSimplexLayerer processes the largest undirected component first. */
2129
- function networkSimplexComponentOrder(input: LayeredPhaseInput): string[] {
2130
- const neighbors = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
2131
- for (const edge of input.graph.edges) {
2132
- if (edge.sourceId === edge.targetId) continue;
2133
- neighbors.get(edge.sourceId)?.push(edge.targetId);
2134
- neighbors.get(edge.targetId)?.push(edge.sourceId);
2135
- }
2136
- const visited = new Set<string>();
2137
- const components: string[][] = [];
2138
- for (const node of input.graph.nodes) {
2139
- if (visited.has(node.id)) continue;
2140
- const component: string[] = [];
2141
- const visit = (id: string): void => {
2142
- if (visited.has(id)) return;
2143
- visited.add(id);
2144
- component.push(id);
2145
- for (const neighbor of neighbors.get(id) ?? []) visit(neighbor);
2146
- };
2147
- visit(node.id);
2148
- if (components.length === 0 || components[0]!.length < component.length) {
2149
- components.unshift(component);
2150
- } else {
2151
- components.push(component);
2587
+ physicalPortOrderByNodeId: clonePortOrders(order.physicalPortOrderByNodeId),
2588
+ layers: bestLayers,
2589
+ inputPortOrderByNodeId: bestInputPortOrder,
2590
+ outputPortOrderByNodeId: bestOutputPortOrder,
2591
+ }
2592
+ : { layers: bestLayers };
2593
+ };
2594
+ const restore = (order: LayerOrder) => {
2595
+ working = order.layers.map((layer) => [...layer]);
2596
+ inputPortOrder.clear();
2597
+ outputPortOrder.clear();
2598
+ for (const [id, edges] of clonePortOrders(order.inputPortOrderByNodeId))
2599
+ inputPortOrder.set(id, edges);
2600
+ for (const [id, edges] of clonePortOrders(order.outputPortOrderByNodeId))
2601
+ outputPortOrder.set(id, edges);
2602
+ canonicalGraph = buildCrossingGraph(orientation, working, inputPortOrder, outputPortOrder);
2603
+ canonicalNodes = new Map(canonicalGraph.layers.flat().map((node) => [node.id, node]));
2604
+ for (const [id, ports] of order.physicalPortOrderByNodeId ?? []) {
2605
+ const node = canonicalNodes.get(id);
2606
+ if (node) node.ports.sort((a, b) => ports.indexOf(a.id) - ports.indexOf(b.id));
2152
2607
  }
2153
- }
2154
- return components.flat();
2608
+ };
2609
+ traceLayeredPhase(() => ({
2610
+ kind: "initial-order",
2611
+ scope: input.graph.id,
2612
+ layers: working.map((layer) => [...layer]),
2613
+ }));
2614
+ const session: LayerSweepSession = {
2615
+ scope: input.graph.id,
2616
+ get random() {
2617
+ return random;
2618
+ },
2619
+ attempts,
2620
+ usesInitialModelOrder,
2621
+ finishInitialOrderAttempt: () => {
2622
+ initialOrderAttempt = false;
2623
+ },
2624
+ restoreRejectedSweep: !exactPortSweep,
2625
+ lockPortOrder: (id) => {
2626
+ fixedPortOrders.add(id);
2627
+ },
2628
+ markHierarchicalNode: (id) => {
2629
+ hierarchicalNodes.add(id);
2630
+ },
2631
+ useRandom: (source) => {
2632
+ random = source;
2633
+ },
2634
+ minimize: () => {
2635
+ const root = { session, childrenByNodeId: new Map(), useBottomUp: true };
2636
+ return minimizeHierarchyCrossings(root).get(root)!;
2637
+ },
2638
+ shuffleFirstLayer,
2639
+ sweep,
2640
+ countCrossings: () => countCrossings(working),
2641
+ snapshot: () => ({
2642
+ physicalPortOrderByNodeId: new Map(
2643
+ [...canonicalNodes].map(([id, node]) => [id, node.ports.map((port) => port.id)]),
2644
+ ),
2645
+ layers: working.map((layer) => [...layer]),
2646
+ inputPortOrderByNodeId: clonePortOrders(inputPortOrder),
2647
+ outputPortOrderByNodeId: clonePortOrders(outputPortOrder),
2648
+ }),
2649
+ restore,
2650
+ finish,
2651
+ currentLayers: () => working,
2652
+ physicalPortOrder: (id) => canonicalNodes.get(id)?.ports.map((port) => port.id) ?? [],
2653
+ edgeOrders: (id) => ({
2654
+ incoming: inputPortOrder.get(id) ?? [],
2655
+ outgoing: outputPortOrder.get(id) ?? [],
2656
+ }),
2657
+ // A full restore would rebuild an equivalent canonical graph: ports keep
2658
+ // their recorded physical order and layers are re-read before distribution.
2659
+ replaceLayer: (index, ids) => {
2660
+ working[index] = [...ids];
2661
+ },
2662
+ reorderNodePorts: (id, physical, incoming, outgoing) => {
2663
+ inputPortOrder.set(id, [...incoming]);
2664
+ outputPortOrder.set(id, [...outgoing]);
2665
+ const node = canonicalNodes.get(id);
2666
+ if (!node) return;
2667
+ const slot = new Map(physical.map((port, index) => [port, index]));
2668
+ node.ports.sort((a, b) => (slot.get(a.id) ?? -1) - (slot.get(b.id) ?? -1));
2669
+ },
2670
+ };
2671
+ return session;
2672
+ }
2673
+
2674
+ /** NetworkSimplexLayerer processes the largest undirected component first. */
2675
+ function networkSimplexComponentOrder(input: LayeredPhaseInput): string[] {
2676
+ return networkSimplexComponents(input).flat();
2155
2677
  }
2156
2678
 
2157
2679
  /** Keep model order within each component while placing connected work before isolated nodes. */
@@ -2193,7 +2715,11 @@ export function minimizeCrossingsWithMedian(sweeps = 7): CrossingMinimizer {
2193
2715
  }
2194
2716
 
2195
2717
  function layersFromAssignment(input: LayeredPhaseInput, assignment: LayerAssignment): string[][] {
2196
- const maximumLayer = Math.max(0, ...assignment.layerByNodeId.values());
2718
+ const maximumLayer = Math.max(
2719
+ 0,
2720
+ (assignment.layerCount ?? 1) - 1,
2721
+ ...assignment.layerByNodeId.values(),
2722
+ );
2197
2723
  const layers = Array.from({ length: maximumLayer + 1 }, () => [] as string[]);
2198
2724
  for (const node of input.graph.nodes) {
2199
2725
  layers[assignment.layerByNodeId.get(node.id) ?? 0]?.push(node.id);
@@ -2215,7 +2741,11 @@ export const minimizeCrossingsWithModelOrder: CrossingMinimizer = (
2215
2741
  _orientation,
2216
2742
  assignment,
2217
2743
  ) => {
2218
- const maximumLayer = Math.max(0, ...assignment.layerByNodeId.values());
2744
+ const maximumLayer = Math.max(
2745
+ 0,
2746
+ (assignment.layerCount ?? 1) - 1,
2747
+ ...assignment.layerByNodeId.values(),
2748
+ );
2219
2749
  const layers = Array.from({ length: maximumLayer + 1 }, () => [] as string[]);
2220
2750
  const layeringStrategy = input.settings["layering.strategy"] ?? "NETWORK_SIMPLEX";
2221
2751
  const nodeOrder = assignment.seedOrder
@@ -2410,6 +2940,19 @@ export function applyPostCompaction(
2410
2940
  const constraintStrategy = input.settings["compaction.postCompaction.constraints"] ?? "SCANLINE";
2411
2941
  void constraintStrategy;
2412
2942
  if (strategy === "NONE") return placement;
2943
+ if (
2944
+ strategy === "EDGE_LENGTH" ||
2945
+ ((input.settings.edgeRouting ?? "ORTHOGONAL") === "ORTHOGONAL" &&
2946
+ ["LEFT", "RIGHT", "LEFT_RIGHT_CONSTRAINT_LOCKING", "LEFT_RIGHT_CONNECTION_LOCKING"].includes(
2947
+ String(strategy),
2948
+ ))
2949
+ )
2950
+ return applyGroupedEdgeLengthCompaction(
2951
+ input,
2952
+ placement,
2953
+ routes,
2954
+ getPlacementOrientation(input),
2955
+ );
2413
2956
  const rects = placement.rectByNodeId as Map<string, EntityRect>;
2414
2957
  const originalEndpointsByEdgeId = new Map(
2415
2958
  [...(routes?.pointsByEdgeId ?? [])].flatMap(([edgeId, points]) => {
@@ -2485,8 +3028,7 @@ export function applyPostCompaction(
2485
3028
  // Compaction must preserve the space reserved for an inline center label,
2486
3029
  // even when its endpoint rectangles do not intersect on the cross axis.
2487
3030
  const inlineLabelSpaceByPair = new Map<string, number>();
2488
- // EDGE_LENGTH retains its route-track label constraints below.
2489
- if (strategy !== "EDGE_LENGTH" && (input.direction === "left" || input.direction === "right")) {
3031
+ if (input.direction === "left" || input.direction === "right") {
2490
3032
  for (const edge of labelEdges) {
2491
3033
  const settings = input.edgeSettings?.(edge);
2492
3034
  if (
@@ -2508,7 +3050,16 @@ export function applyPostCompaction(
2508
3050
  : 0;
2509
3051
  const horizontalSpacing = (left: ConstraintNode, right: ConstraintNode): number =>
2510
3052
  left.kind === "node" && right.kind === "node"
2511
- ? Math.max(input.spacing.node, labelSpace(left, right))
3053
+ ? Math.max(
3054
+ input.spacing.node,
3055
+ (left.nodeId?.startsWith("__layout_dummy:label:") &&
3056
+ !right.nodeId?.startsWith("__layout_dummy:")) ||
3057
+ (right.nodeId?.startsWith("__layout_dummy:label:") &&
3058
+ !left.nodeId?.startsWith("__layout_dummy:"))
3059
+ ? input.spacing.layer
3060
+ : 0,
3061
+ labelSpace(left, right),
3062
+ )
2512
3063
  : left.kind === "segment" && right.kind === "segment" && left.edgeId === right.edgeId
2513
3064
  ? 0
2514
3065
  : Number(
@@ -2619,90 +3170,8 @@ export function applyPostCompaction(
2619
3170
  );
2620
3171
  } else {
2621
3172
  compact("LEFT");
2622
- if (strategy === "EDGE_LENGTH") {
2623
- const itemByNodeId = new Map(
2624
- compactables.flatMap((item) =>
2625
- item.kind === "node" && item.nodeId ? [[item.nodeId, item] as const] : [],
2626
- ),
2627
- );
2628
- const incomingDegree = new Map(input.graph.nodes.map((node) => [node.id, 0]));
2629
- const outgoing = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
2630
- for (const edge of input.graph.edges) {
2631
- outgoing.get(edge.sourceId)?.push(edge.targetId);
2632
- incomingDegree.set(edge.targetId, (incomingDegree.get(edge.targetId) ?? 0) + 1);
2633
- }
2634
- for (const node of input.graph.nodes) {
2635
- const targets = outgoing.get(node.id) ?? [];
2636
- if (targets.length <= (incomingDegree.get(node.id) ?? 0) || targets.length === 0) continue;
2637
- if (
2638
- input.graph.edges.some(
2639
- (edge) => edge.sourceId === node.id && centerLabeledEdgeIds.has(edge.id),
2640
- )
2641
- ) {
2642
- continue;
2643
- }
2644
- const item = itemByNodeId.get(node.id);
2645
- if (!item) continue;
2646
- const upper = Math.min(
2647
- ...targets.map(
2648
- (targetId) =>
2649
- (itemByNodeId.get(targetId)?.x ?? item.x) - item.width - input.spacing.node,
2650
- ),
2651
- );
2652
- item.x = Math.max(item.x, upper);
2653
- }
2654
- }
2655
- }
2656
- if (
2657
- strategy === "EDGE_LENGTH" &&
2658
- input.settings["nodePlacement.favorStraightEdges"] === true &&
2659
- (input.direction === "down" || input.direction === "up")
2660
- ) {
2661
- const itemByNodeId = new Map(
2662
- compactables.flatMap((item) =>
2663
- item.kind === "node" && item.nodeId ? [[item.nodeId, item] as const] : [],
2664
- ),
2665
- );
2666
- const incomingDegree = new Map(input.graph.nodes.map((node) => [node.id, 0]));
2667
- for (const edge of input.graph.edges) {
2668
- if (edge.sourceId !== edge.targetId) {
2669
- incomingDegree.set(edge.targetId, (incomingDegree.get(edge.targetId) ?? 0) + 1);
2670
- }
2671
- }
2672
- for (const edge of input.graph.edges) {
2673
- if (edge.sourceId === edge.targetId || (incomingDegree.get(edge.targetId) ?? 0) !== 1) {
2674
- continue;
2675
- }
2676
- const endpoints = originalEndpointsByEdgeId.get(edge.id);
2677
- const source = itemByNodeId.get(edge.sourceId);
2678
- const target = itemByNodeId.get(edge.targetId);
2679
- if (!endpoints || !source || !target) continue;
2680
- const forward =
2681
- input.direction === "down"
2682
- ? endpoints.start.y < endpoints.end.y
2683
- : endpoints.start.y > endpoints.end.y;
2684
- if (!forward) continue;
2685
- const desiredTargetX =
2686
- source.x + (endpoints.start.x - source.originalX) - (endpoints.end.x - target.originalX);
2687
- const collides = compactables.some((candidate) => {
2688
- if (candidate.kind !== "node" || candidate === target) return false;
2689
- const flowOverlap =
2690
- candidate.y < target.y + target.height && candidate.y + candidate.height > target.y;
2691
- if (!flowOverlap) return false;
2692
- const spacing = input.spacing.node;
2693
- return (
2694
- desiredTargetX < candidate.x + candidate.width + spacing &&
2695
- desiredTargetX + target.width + spacing > candidate.x
2696
- );
2697
- });
2698
- if (collides) continue;
2699
- target.x = desiredTargetX;
2700
- const alignedX = source.x + (endpoints.start.x - source.originalX);
2701
- for (const segment of compactables) {
2702
- if (segment.kind === "segment" && segment.edgeId === edge.id) segment.x = alignedX;
2703
- }
2704
- }
2705
3173
  }
3174
+
2706
3175
  if (input.direction === "right" && centerLabeledEdgeIds.size > 0) {
2707
3176
  const itemByNodeId = new Map(
2708
3177
  compactables.flatMap((item) =>
@@ -2715,7 +3184,7 @@ export function applyPostCompaction(
2715
3184
  // A late target nudge would invalidate its outgoing label constraints.
2716
3185
  if (
2717
3186
  !centerLabeledEdgeIds.has(edge.id) ||
2718
- (strategy !== "EDGE_LENGTH" && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
3187
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
2719
3188
  )
2720
3189
  continue;
2721
3190
  const source = itemByNodeId.get(edge.sourceId);
@@ -2870,6 +3339,7 @@ function nodeFlowOffset(
2870
3339
  layerFlowSize: number,
2871
3340
  nodeFlowSize: number,
2872
3341
  layerByNodeId: ReadonlyMap<string, number>,
3342
+ margins?: { leading: number; trailing: number; maxLeading: number; maxTrailing: number },
2873
3343
  ): number {
2874
3344
  const node = input.graph.nodes.find((candidate) => candidate.id === id);
2875
3345
  const alignment = node
@@ -2880,33 +3350,87 @@ function nodeFlowOffset(
2880
3350
  else if (alignment === "RIGHT" || alignment === "BOTTOM") ratio = 1;
2881
3351
  else if (alignment === "CENTER") ratio = 0.5;
2882
3352
  else {
2883
- let incoming = 0;
2884
- let outgoing = 0;
3353
+ const incoming = new Set<string>();
3354
+ const outgoing = new Set<string>();
2885
3355
  const ownLayer = layerByNodeId.get(id) ?? 0;
3356
+ const orientation = getPlacementOrientation(input);
2886
3357
  for (const edge of input.graph.edges) {
2887
3358
  if (edge.sourceId !== id && edge.targetId !== id) continue;
2888
- const otherId = edge.sourceId === id ? edge.targetId : edge.sourceId;
3359
+ if (hasMovableLoopPorts(input, edge)) continue;
3360
+ const source = edge.sourceId === id;
3361
+ const portName = source ? edge.sourcePort : edge.targetPort;
3362
+ const key = portName === undefined ? `edge:${edge.id}` : `port:${portName}`;
3363
+ const otherId = source ? edge.targetId : edge.sourceId;
2889
3364
  const otherLayer = layerByNodeId.get(otherId) ?? ownLayer;
2890
- if (otherLayer > ownLayer) outgoing++;
2891
- else if (otherLayer < ownLayer) incoming++;
2892
- else if (edge.sourceId === id) outgoing++;
2893
- else incoming++;
3365
+ const port = node?.ports?.find((candidate) => candidate.name === portName);
3366
+ const isOutgoing = orientation
3367
+ ? source !== orientation.reversedEdgeIds.has(edge.id)
3368
+ : otherLayer !== ownLayer
3369
+ ? otherLayer > ownLayer
3370
+ : id.startsWith("__layout_dummy:") && port?.direction !== "inout"
3371
+ ? port?.direction === "out"
3372
+ : source;
3373
+ (isOutgoing ? outgoing : incoming).add(key);
2894
3374
  }
2895
- ratio = incoming + outgoing === 0 ? 0.5 : outgoing / (incoming + outgoing);
3375
+ ratio =
3376
+ incoming.size + outgoing.size === 0 ? 0.5 : outgoing.size / (incoming.size + outgoing.size);
3377
+ }
3378
+ let position = (layerFlowSize - nodeFlowSize) * ratio;
3379
+ if (margins) {
3380
+ if (ratio > 0.5) position -= margins.maxTrailing * 2 * (ratio - 0.5);
3381
+ else if (ratio < 0.5) position += margins.maxLeading * 2 * (0.5 - ratio);
3382
+ position = Math.max(position, margins.leading);
3383
+ position = Math.min(position, layerFlowSize - margins.trailing - nodeFlowSize);
2896
3384
  }
2897
- return (layerFlowSize - nodeFlowSize) * ratio;
3385
+ return position;
2898
3386
  }
2899
3387
 
2900
3388
  export const placeNodesInLayers: NodePlacer = (input, order) => {
2901
3389
  const horizontal = input.direction === "left" || input.direction === "right";
3390
+ const reverse = input.direction === "left" || input.direction === "up";
3391
+ const orientation = getPlacementOrientation(input);
3392
+ const loopMargins = preparedLoopEnvelopes(input);
3393
+ const flowMargins = new Map(
3394
+ input.graph.nodes.map((node) => {
3395
+ const size = input.sizes.get(node.id) ?? { width: 0, height: 0 };
3396
+ const ports = placePorts(
3397
+ node.ports,
3398
+ { x: 0, y: 0, ...size },
3399
+ input.direction,
3400
+ (port) => input.portSettings?.(port, node),
3401
+ { ...input.settings, ...input.nodeSettings?.(node) },
3402
+ orientation
3403
+ ? (port) => getOrientedPortDirection(input, orientation, node, port)
3404
+ : undefined,
3405
+ );
3406
+ let low = 0,
3407
+ high = 0;
3408
+ for (const port of ports ?? []) {
3409
+ const start = horizontal ? (port.x ?? 0) : (port.y ?? 0);
3410
+ const end = start + (horizontal ? (port.width ?? 0) : (port.height ?? 0));
3411
+ low = Math.max(low, -start);
3412
+ high = Math.max(high, end - (horizontal ? size.width : size.height));
3413
+ }
3414
+ return [
3415
+ node.id,
3416
+ {
3417
+ leading: Math.max(reverse ? high : low, loopMargins?.get(node.id)?.flowBefore ?? 0),
3418
+ trailing: Math.max(reverse ? low : high, loopMargins?.get(node.id)?.flowAfter ?? 0),
3419
+ },
3420
+ ] as const;
3421
+ }),
3422
+ );
2902
3423
  const layerByNodeId = new Map(
2903
3424
  order.layers.flatMap((layer, layerIndex) => layer.map((id) => [id, layerIndex] as const)),
2904
3425
  );
2905
3426
  const layerFlowSizes = order.layers.map((layer) => {
2906
3427
  const size = Math.max(
2907
3428
  0,
2908
- ...layer.map((id) =>
2909
- horizontal ? (input.sizes.get(id)?.width ?? 0) : (input.sizes.get(id)?.height ?? 0),
3429
+ ...layer.map(
3430
+ (id) =>
3431
+ (horizontal ? (input.sizes.get(id)?.width ?? 0) : (input.sizes.get(id)?.height ?? 0)) +
3432
+ (flowMargins.get(id)?.leading ?? 0) +
3433
+ (flowMargins.get(id)?.trailing ?? 0),
2910
3434
  ),
2911
3435
  );
2912
3436
  return size === 0 &&
@@ -2941,6 +3465,12 @@ export const placeNodesInLayers: NodePlacer = (input, order) => {
2941
3465
  layerFlowSizes[layerIndex] ?? 0,
2942
3466
  horizontal ? size.width : size.height,
2943
3467
  layerByNodeId,
3468
+ {
3469
+ leading: flowMargins.get(id)?.leading ?? 0,
3470
+ trailing: flowMargins.get(id)?.trailing ?? 0,
3471
+ maxLeading: Math.max(0, ...layer.map((id) => flowMargins.get(id)?.leading ?? 0)),
3472
+ maxTrailing: Math.max(0, ...layer.map((id) => flowMargins.get(id)?.trailing ?? 0)),
3473
+ },
2944
3474
  );
2945
3475
  const rect = horizontal
2946
3476
  ? { x: centeredFlow, y: cross, ...size }
@@ -3073,22 +3603,42 @@ export const placeNodesInteractively: NodePlacer = (input, order) => {
3073
3603
  return { rectByNodeId };
3074
3604
  };
3075
3605
 
3076
- function getPortPoint(
3606
+ export function getPortPoint(
3077
3607
  node: GraphNode,
3078
3608
  portName: string | undefined,
3079
3609
  rect: EntityRect,
3080
3610
  fallback: Point,
3081
3611
  direction: LayeredPhaseInput["direction"],
3082
3612
  input: LayeredPhaseInput,
3613
+ /** Reuses each node's placed ports across calls while settings stay fixed. */
3614
+ placedPorts?: Map<GraphNode, { rect: EntityRect; ports: GraphPort[] | undefined }>,
3083
3615
  ): Point {
3084
3616
  if (portName === undefined) return fallback;
3085
- const port = placePorts(
3086
- node.ports,
3087
- rect,
3088
- direction,
3089
- (candidate) => input.portSettings?.(candidate, node),
3090
- { ...input.settings, ...input.nodeSettings?.(node) },
3091
- )?.find((candidate) => candidate.name === portName);
3617
+ const cached = placedPorts?.get(node);
3618
+ let ports: GraphPort[] | undefined;
3619
+ if (
3620
+ cached &&
3621
+ cached.rect.x === rect.x &&
3622
+ cached.rect.y === rect.y &&
3623
+ cached.rect.width === rect.width &&
3624
+ cached.rect.height === rect.height
3625
+ )
3626
+ ports = cached.ports;
3627
+ else {
3628
+ const orientation = getPlacementOrientation(input);
3629
+ ports = placePorts(
3630
+ node.ports,
3631
+ rect,
3632
+ direction,
3633
+ (candidate) => input.portSettings?.(candidate, node),
3634
+ { ...input.settings, ...input.nodeSettings?.(node) },
3635
+ orientation
3636
+ ? (candidate) => getOrientedPortDirection(input, orientation, node, candidate)
3637
+ : undefined,
3638
+ );
3639
+ placedPorts?.set(node, { rect: { ...rect }, ports });
3640
+ }
3641
+ const port = ports?.find((candidate) => candidate.name === portName);
3092
3642
  if (port?.x === undefined || port.y === undefined) return fallback;
3093
3643
  const settings = input.portSettings?.(port, node);
3094
3644
  const configuredAnchor = settings?.["port.anchor"] as { x?: number; y?: number } | undefined;
@@ -3102,7 +3652,31 @@ function getPortPoint(
3102
3652
  };
3103
3653
  }
3104
3654
 
3105
- function simplifyRoute(points: readonly Point[]): Point[] {
3655
+ /**
3656
+ * Drop interior bends where an orthogonal route doubles back along one line,
3657
+ * so it never retraces itself. Terminal segments keep their port direction.
3658
+ */
3659
+ export function removeRouteSpurs(points: readonly Point[]): readonly Point[] {
3660
+ const equal = (left: number, right: number) => Math.abs(left - right) < 1e-9;
3661
+ const route = points.filter(
3662
+ (point, index) =>
3663
+ index === 0 || !equal(point.x, points[index - 1]!.x) || !equal(point.y, points[index - 1]!.y),
3664
+ );
3665
+ let changed = false;
3666
+ for (let index = 2; index < route.length - 2; index++) {
3667
+ const [previous, middle, next] = [route[index - 1]!, route[index]!, route[index + 1]!];
3668
+ const sameX = equal(previous.x, middle.x) && equal(middle.x, next.x),
3669
+ sameY = equal(previous.y, middle.y) && equal(middle.y, next.y);
3670
+ const axis = sameX ? "y" : sameY ? "x" : undefined;
3671
+ if (!axis || (middle[axis] - previous[axis]) * (next[axis] - middle[axis]) >= 0) continue;
3672
+ route.splice(index, 1);
3673
+ changed = true;
3674
+ index = Math.max(1, index - 2);
3675
+ }
3676
+ return changed ? route : points;
3677
+ }
3678
+
3679
+ export function simplifyRoute(points: readonly Point[]): Point[] {
3106
3680
  const equal = (left: number, right: number) => Math.abs(left - right) < 1e-9;
3107
3681
  const simplified: Point[] = [];
3108
3682
  for (const point of points) {
@@ -3135,6 +3709,7 @@ function implicitEdgeEndpoints(
3135
3709
  const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
3136
3710
  const groups = new Map<string, Array<{ edge: GraphEdge; endpoint: "source" | "target" }>>();
3137
3711
  for (const edge of input.graph.edges) {
3712
+ if (hasMovableLoopPorts(input, edge)) continue;
3138
3713
  const sourceRect = placement.rectByNodeId.get(edge.sourceId);
3139
3714
  const targetRect = placement.rectByNodeId.get(edge.targetId);
3140
3715
  if (!sourceRect || !targetRect) continue;
@@ -3142,7 +3717,16 @@ function implicitEdgeEndpoints(
3142
3717
  const targetFlow = horizontal ? targetRect.x : targetRect.y;
3143
3718
  const modelOrderPromotion =
3144
3719
  input.settings["layering.nodePromotion.strategy"] === "MODEL_ORDER_LEFT_TO_RIGHT";
3145
- const forward = modelOrderPromotion || sourceFlow <= targetFlow;
3720
+ const sourceBoundary = externalPortDummyOf(nodeById.get(edge.sourceId) ?? {});
3721
+ const targetBoundary = externalPortDummyOf(nodeById.get(edge.targetId) ?? {});
3722
+ // A zero-gap boundary can coincide with the node's flow origin.
3723
+ const forward =
3724
+ modelOrderPromotion ||
3725
+ (sourceFlow === targetFlow && (sourceBoundary || targetBoundary)
3726
+ ? sourceBoundary
3727
+ ? sourceBoundary.port.side === "EAST" || sourceBoundary.port.side === "SOUTH"
3728
+ : targetBoundary!.port.side === "WEST" || targetBoundary!.port.side === "NORTH"
3729
+ : sourceFlow <= targetFlow);
3146
3730
  const feedback =
3147
3731
  input.settings.feedbackEdges === true && orientation?.reversedEdgeIds.has(edge.id) === true;
3148
3732
  const directionReversed = input.direction === "left" || input.direction === "up";
@@ -3167,11 +3751,25 @@ function implicitEdgeEndpoints(
3167
3751
  ? "after"
3168
3752
  : undefined;
3169
3753
  };
3754
+ const fixedImplicitSide = (nodeId: string, portName: string | undefined, source: boolean) => {
3755
+ const node = nodeById.get(nodeId);
3756
+ const constraints = node && input.nodeSettings?.(node)?.portConstraints;
3757
+ if (
3758
+ portName !== undefined ||
3759
+ !["FIXED_SIDE", "FIXED_ORDER", "FIXED_RATIO", "FIXED_POS"].includes(String(constraints))
3760
+ )
3761
+ return undefined;
3762
+ // ELK assigns implicit port sides before cycle breaking. Fixed sides
3763
+ // survive edge reversal; a feedback edge cannot move its endpoint.
3764
+ return source !== directionReversed ? "after" : "before";
3765
+ };
3170
3766
  const sourceSide =
3171
3767
  endpointSide(edge.sourceId, edge.sourcePort) ??
3768
+ fixedImplicitSide(edge.sourceId, edge.sourcePort, true) ??
3172
3769
  (feedback ? (directionReversed ? "before" : "after") : forward ? "after" : "before");
3173
3770
  const targetSide =
3174
3771
  endpointSide(edge.targetId, edge.targetPort) ??
3772
+ fixedImplicitSide(edge.targetId, edge.targetPort, false) ??
3175
3773
  (feedback ? (directionReversed ? "after" : "before") : forward ? "before" : "after");
3176
3774
  const sourceKey = `${edge.sourceId}:${sourceSide}`;
3177
3775
  const targetKey = `${edge.targetId}:${targetSide}`;
@@ -3185,6 +3783,20 @@ function implicitEdgeEndpoints(
3185
3783
 
3186
3784
  const result = new Map<string, { source: Point; target: Point }>();
3187
3785
  const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
3786
+ const placedPorts = new Map<GraphNode, { rect: EntityRect; ports: GraphPort[] | undefined }>();
3787
+ const portSideCounts = new Map<GraphNode, Map<unknown, number>>();
3788
+ const portSideCount = (node: GraphNode, side: unknown): number => {
3789
+ let counts = portSideCounts.get(node);
3790
+ if (!counts) {
3791
+ counts = new Map();
3792
+ for (const port of node.ports ?? []) {
3793
+ const portSide = input.portSettings?.(port, node)?.["port.side"];
3794
+ counts.set(portSide, (counts.get(portSide) ?? 0) + 1);
3795
+ }
3796
+ portSideCounts.set(node, counts);
3797
+ }
3798
+ return counts.get(side) ?? 0;
3799
+ };
3188
3800
  for (const [key, entries] of groups) {
3189
3801
  const split = key.lastIndexOf(":");
3190
3802
  const nodeId = key.slice(0, split);
@@ -3235,9 +3847,23 @@ function implicitEdgeEndpoints(
3235
3847
  unzipping ||
3236
3848
  interactiveTargetOrder ||
3237
3849
  verticalNoneTargetOrder ||
3238
- (input.settings.hierarchyHandling !== "INCLUDE_CHILDREN" &&
3239
- (crossingStrategy === "LAYER_SWEEP" || crossingStrategy === "MEDIAN_LAYER_SWEEP"));
3850
+ crossingStrategy === "LAYER_SWEEP" ||
3851
+ crossingStrategy === "MEDIAN_LAYER_SWEEP";
3852
+ const sweptOrder = getPlacementOrder(input);
3240
3853
  entries.sort((left, right) => {
3854
+ const leftIncoming =
3855
+ (left.endpoint === "target") !== (orientation?.reversedEdgeIds.has(left.edge.id) ?? false);
3856
+ const rightIncoming =
3857
+ (right.endpoint === "target") !==
3858
+ (orientation?.reversedEdgeIds.has(right.edge.id) ?? false);
3859
+ if (leftIncoming === rightIncoming) {
3860
+ const selected = (
3861
+ leftIncoming ? sweptOrder?.inputPortOrderByNodeId : sweptOrder?.outputPortOrderByNodeId
3862
+ )?.get(nodeId);
3863
+ const a = selected?.indexOf(left.edge.id) ?? -1,
3864
+ b = selected?.indexOf(right.edge.id) ?? -1;
3865
+ if (a >= 0 && b >= 0 && a !== b) return (leftIncoming ? -1 : 1) * (a - b);
3866
+ }
3241
3867
  if (interactiveLeftSourceOrder) {
3242
3868
  const leftExtreme = interactiveLeftSourceOrder[0] === left.edge.id;
3243
3869
  const rightExtreme = interactiveLeftSourceOrder[0] === right.edge.id;
@@ -3377,6 +4003,32 @@ function implicitEdgeEndpoints(
3377
4003
  ? { ...point, y: Math.round(point.y) }
3378
4004
  : { ...point, x: Math.round(point.x) };
3379
4005
  }
4006
+ const portName = endpoint === "source" ? edge.sourcePort : edge.targetPort;
4007
+ const explicitPort = node?.ports?.find((port) => port.name === portName);
4008
+ const physicalSide =
4009
+ node && explicitPort ? input.portSettings?.(explicitPort, node)?.["port.side"] : undefined;
4010
+ const singleFixedSidePort =
4011
+ constraints === "FIXED_SIDE" &&
4012
+ physicalSide !== undefined &&
4013
+ node?.ports !== undefined &&
4014
+ portSideCount(node, physicalSide) === 1;
4015
+ // Movable port ordering comes from the layer sweep above. Fixed coordinates
4016
+ // and authored anchors must replace the synthesized node-origin fallback.
4017
+ const authoredAnchor =
4018
+ node && explicitPort
4019
+ ? input.portSettings?.(explicitPort, node)?.["port.anchor"]
4020
+ : undefined;
4021
+ if (
4022
+ portName !== undefined &&
4023
+ node &&
4024
+ (constraints === "FIXED_POS" ||
4025
+ constraints === "FIXED_RATIO" ||
4026
+ constraints === "FIXED_ORDER" ||
4027
+ singleFixedSidePort ||
4028
+ authoredAnchor !== undefined)
4029
+ ) {
4030
+ point = getPortPoint(node, portName, rect, point, input.direction, input, placedPorts);
4031
+ }
3380
4032
  const pair = result.get(edge.id) ?? { source: point, target: point };
3381
4033
  pair[endpoint] = point;
3382
4034
  result.set(edge.id, pair);
@@ -3400,6 +4052,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3400
4052
  );
3401
4053
  };
3402
4054
  const pointsByEdgeId = new Map<string, readonly Point[]>();
4055
+ const junctionGroups: OrthogonalJunctionGroup[] = [];
4056
+ const physicalOrder = new Map(
4057
+ [
4058
+ ...input.graph.edges
4059
+ .filter((edge) => !orientation.reversedEdgeIds.has(edge.id))
4060
+ .map((edge) => edge.id),
4061
+ ...orientation.reversedEdgeIds,
4062
+ ].map((id, index) => [id, index]),
4063
+ );
4064
+
3403
4065
  const splineNubControlsByEdgeId = new Map<string, readonly Point[]>();
3404
4066
  const outsideFeedbackEdgeIds = new Set<string>();
3405
4067
  const horizontal = input.direction === "left" || input.direction === "right";
@@ -3417,6 +4079,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3417
4079
  (mutableRects.get(leftId)?.x ?? 0) - (mutableRects.get(rightId)?.x ?? 0),
3418
4080
  );
3419
4081
  for (const [id, loops] of selfLoopEntriesByX) {
4082
+ if (hasPlacementLoopEnvelope(placement, id)) continue;
3420
4083
  const rect = mutableRects.get(id);
3421
4084
  const node = nodeById.get(id);
3422
4085
  if (!rect || !node) continue;
@@ -3479,14 +4142,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3479
4142
  edge: GraphEdge,
3480
4143
  side: string,
3481
4144
  ordering: string,
4145
+ canonical = false,
3482
4146
  ): number => {
3483
- const axis = side === "NORTH" || side === "SOUTH" ? "height" : "width";
4147
+ const crossSide = side === "NORTH" || side === "SOUTH";
4148
+ const axis = crossSide === (!canonical || horizontal) ? "height" : "width";
3484
4149
  const ordered = ordering === "REVERSE_STACKED" ? [...loops].reverse() : loops;
3485
4150
  const index = ordered.indexOf(edge);
3486
4151
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
3487
4152
  const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
3488
4153
  const size = (candidate: GraphEdge) =>
3489
- input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
4154
+ canonical || input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
3490
4155
  ? (candidate[axis] ?? 0)
3491
4156
  : 0;
3492
4157
  return (
@@ -3498,11 +4163,19 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3498
4163
  extent + (size(candidate) > 0 ? size(candidate) + labelSpacing : 0),
3499
4164
  0,
3500
4165
  ) +
3501
- (size(edge) > 0 ? size(edge) / 2 + labelSpacing : 0)
4166
+ (size(edge) > 0 && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
4167
+ ? size(edge) / 2 + labelSpacing
4168
+ : 0)
3502
4169
  );
3503
4170
  };
4171
+ const hasFixedLoopPorts = (node: GraphNode): boolean => {
4172
+ const constraints = String(input.nodeSettings?.(node)?.portConstraints ?? "UNDEFINED");
4173
+ return constraints !== "UNDEFINED" && constraints !== "FREE";
4174
+ };
3504
4175
  const northReserveByLayer = new Map<number, number>();
3505
- for (const [id, loops] of selfLoopsByNodeId) {
4176
+ for (const [id, nodeLoops] of selfLoopsByNodeId) {
4177
+ const loops = nodeLoops.filter(() => !hasPlacementLoopEnvelope(placement, id));
4178
+ if (loops.length === 0) continue;
3506
4179
  const rect = mutableRects.get(id);
3507
4180
  if (!rect) continue;
3508
4181
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
@@ -3518,8 +4191,36 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3518
4191
  y: rect.y + spacing + splineOffset,
3519
4192
  });
3520
4193
  } else {
3521
- const sideLoopCount =
3522
- distribution === "NORTH_SOUTH" ? Math.ceil(loops.length / 2) : loops.length;
4194
+ const northLoops = loops.filter((edge, index) => {
4195
+ if (
4196
+ style === "ORTHOGONAL" &&
4197
+ node &&
4198
+ edge.sourcePort !== undefined &&
4199
+ edge.targetPort !== undefined &&
4200
+ hasFixedLoopPorts(node)
4201
+ ) {
4202
+ const sourcePort = node.ports?.find((port) => port.name === edge.sourcePort);
4203
+ const targetPort = node.ports?.find((port) => port.name === edge.targetPort);
4204
+ return (
4205
+ (sourcePort && input.portSettings?.(sourcePort, node)?.["port.side"] === "NORTH") ||
4206
+ (targetPort && input.portSettings?.(targetPort, node)?.["port.side"] === "NORTH")
4207
+ );
4208
+ }
4209
+ if (
4210
+ style === "ORTHOGONAL" &&
4211
+ node &&
4212
+ (edge.sourcePort === undefined || edge.targetPort === undefined) &&
4213
+ String(nodeSettings?.portConstraints ?? "FREE") !== "FREE" &&
4214
+ String(nodeSettings?.portConstraints ?? "UNDEFINED") !== "UNDEFINED"
4215
+ ) {
4216
+ const port = node.ports?.find(
4217
+ (candidate) => candidate.name === (edge.sourcePort ?? edge.targetPort),
4218
+ );
4219
+ if (port) return input.portSettings?.(port, node)?.["port.side"] === "NORTH";
4220
+ }
4221
+ return distribution !== "NORTH_SOUTH" || index % 2 === 0;
4222
+ });
4223
+ const sideLoopCount = northLoops.length;
3523
4224
  const reserve =
3524
4225
  (ordering === "SEQUENCED" ? Math.min(1, sideLoopCount) : sideLoopCount) * spacing +
3525
4226
  splineOffset;
@@ -3644,6 +4345,50 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3644
4345
  }
3645
4346
  flowLayerByNodeId.set(interval.id, flowLayers.length - 1);
3646
4347
  }
4348
+ const bodyFlowLayers = flowLayers.map((bounds) => ({ ...bounds }));
4349
+ const labelFlowLayers = new Set(
4350
+ [...flowLayerByNodeId]
4351
+ .filter(([id]) => id.startsWith("__layout_dummy:label:"))
4352
+ .map(([, layer]) => layer),
4353
+ );
4354
+
4355
+ // Routing advances past the occupied layer, including protruding ports.
4356
+ // Assign layers from bodies first so port envelopes cannot merge ranks.
4357
+ for (const [id, rect] of placement.rectByNodeId) {
4358
+ const node = nodeById.get(id);
4359
+ const bounds = flowLayers[flowLayerByNodeId.get(id) ?? -1];
4360
+ if (!node || !bounds) continue;
4361
+ const envelope = hasPlacementLoopEnvelope(placement, id)
4362
+ ? preparedLoopEnvelopes(input)?.get(id)
4363
+ : undefined;
4364
+ if (envelope) {
4365
+ const negative = input.direction === "left" || input.direction === "up";
4366
+ const start = horizontal ? rect.x : rect.y;
4367
+ const size = horizontal ? rect.width : rect.height;
4368
+ bounds.start = Math.min(
4369
+ bounds.start,
4370
+ start - (negative ? envelope.flowAfter : envelope.flowBefore),
4371
+ );
4372
+ bounds.end = Math.max(
4373
+ bounds.end,
4374
+ start + size + (negative ? envelope.flowBefore : envelope.flowAfter),
4375
+ );
4376
+ }
4377
+ const ports = placePorts(
4378
+ node.ports,
4379
+ rect,
4380
+ input.direction,
4381
+ (port) => input.portSettings?.(port, node),
4382
+ { ...input.settings, ...input.nodeSettings?.(node) },
4383
+ (port) => getOrientedPortDirection(input, orientation, node, port),
4384
+ );
4385
+ for (const port of ports ?? []) {
4386
+ const start = horizontal ? rect.x + (port.x ?? 0) : rect.y + (port.y ?? 0);
4387
+ const end = start + (horizontal ? (port.width ?? 0) : (port.height ?? 0));
4388
+ bounds.start = Math.min(bounds.start, start);
4389
+ bounds.end = Math.max(bounds.end, end);
4390
+ }
4391
+ }
3647
4392
 
3648
4393
  const increasing = input.direction === "right" || input.direction === "down";
3649
4394
  const flowPortProtrusion = (edge: GraphEdge, endpoint: "source" | "target"): number => {
@@ -3711,7 +4456,9 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3711
4456
  const requiredGap =
3712
4457
  labelFlowSize +
3713
4458
  Math.max(input.spacing.layer, flowPortProtrusion(edge, "source") + edgeNodeSpacing) +
3714
- Math.max(input.spacing.layer, flowPortProtrusion(edge, "target") + edgeNodeSpacing);
4459
+ Math.max(input.spacing.layer, flowPortProtrusion(edge, "target") + edgeNodeSpacing) -
4460
+ flowPortProtrusion(edge, "source") -
4461
+ flowPortProtrusion(edge, "target");
3715
4462
  const gap = Math.min(sourceLayer, targetLayer);
3716
4463
  const availableGap = flowLayers[gap + 1]!.start - flowLayers[gap]!.end;
3717
4464
  const forward = increasing ? targetLayer > sourceLayer : targetLayer < sourceLayer;
@@ -3743,26 +4490,84 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3743
4490
  if (labelShift !== 0) implicitEndpoints = implicitEdgeEndpoints(input, placement, orientation);
3744
4491
 
3745
4492
  const orthogonalTrackByEdgeId = new Map<string, number>();
4493
+ // Cross-axis shift of an edge's attachment at a port carrying both directions.
4494
+ const attachmentShiftByEdgeId = new Map<string, { source?: number; target?: number }>();
4495
+ const attached = (edge: GraphEdge, end: "source" | "target", point: Point): Point => {
4496
+ const shift = attachmentShiftByEdgeId.get(edge.id)?.[end];
4497
+ if (shift === undefined) return point;
4498
+ return horizontal ? { x: point.x, y: point.y + shift } : { x: point.x + shift, y: point.y };
4499
+ };
4500
+ // Tracks given to edges that routing saw as straight, by cross extent.
4501
+ const fallbackTracks: Array<{ track: number; lo: number; hi: number; sign: number }> = [];
4502
+ /**
4503
+ * The gap's middle for an edge routing saw as straight. When its ends
4504
+ * moved apart since, the middle can be the track of an edge heading the
4505
+ * other way: take the nearest track free of those instead.
4506
+ */
4507
+ const freeGapTrack = (edgeId: string, from: number, to: number, a: Point, b: Point) => {
4508
+ const middle = (from + to) / 2;
4509
+ if (style !== "ORTHOGONAL") return middle;
4510
+ const cross = (p: Point) => (horizontal ? p.y : p.x);
4511
+ const lo = Math.min(cross(a), cross(b)),
4512
+ hi = Math.max(cross(a), cross(b)),
4513
+ sign = Math.sign(cross(b) - cross(a));
4514
+ if (hi - lo < 1e-6) return middle;
4515
+ const spans = [
4516
+ ...fallbackTracks,
4517
+ ...[...orthogonalTrackByEdgeId].flatMap(([id, track]) => {
4518
+ const ends = implicitEndpoints.get(id);
4519
+ if (id === edgeId || !ends) return [];
4520
+ const values = [cross(ends.source), cross(ends.target)];
4521
+ return [
4522
+ {
4523
+ track,
4524
+ lo: Math.min(...values),
4525
+ hi: Math.max(...values),
4526
+ sign: Math.sign(values[1]! - values[0]!),
4527
+ },
4528
+ ];
4529
+ }),
4530
+ ];
4531
+ const spacing = Number(input.settings["spacing.edgeEdgeBetweenLayers"] ?? 10) / 2;
4532
+ const free = (track: number) =>
4533
+ !spans.some(
4534
+ (span) =>
4535
+ span.sign === -sign &&
4536
+ Math.abs(span.track - track) < 1e-6 &&
4537
+ Math.min(span.hi, hi) - Math.max(span.lo, lo) > 1e-6,
4538
+ );
4539
+ let chosen = middle;
4540
+ for (
4541
+ let step = 1;
4542
+ !free(chosen) && step <= 2 * Math.floor((to - from) / 2 / spacing);
4543
+ step++
4544
+ ) {
4545
+ const candidate = middle + (step % 2 ? -1 : 1) * Math.ceil(step / 2) * spacing;
4546
+ if (candidate > from && candidate < to && free(candidate)) chosen = candidate;
4547
+ }
4548
+ fallbackTracks.push({ track: chosen, lo, hi, sign });
4549
+ return chosen;
4550
+ };
3746
4551
  const orthogonalDetourByEdgeId = new Map<
3747
4552
  string,
3748
4553
  { firstTrack: number; secondTrack: number; crossover: number }
3749
4554
  >();
3750
4555
  if (
3751
4556
  style === "ORTHOGONAL" &&
3752
- input.settings.mergeEdges !== true &&
3753
4557
  (input.settings["wrapping.strategy"] ?? "OFF") === "OFF" &&
3754
- input.settings.hierarchyHandling !== "INCLUDE_CHILDREN" &&
3755
4558
  (input.settings["layerUnzipping.strategy"] ?? "NONE") === "NONE"
3756
4559
  ) {
3757
4560
  const edgeNodeSpacing = Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10);
3758
4561
  const edgeEdgeSpacing = Number(input.settings["spacing.edgeEdgeBetweenLayers"] ?? 10);
3759
- const candidatesByGap = flowLayers.slice(0, -1).map(
4562
+ const candidatesByGap = Array.from(
4563
+ { length: flowLayers.length + 1 },
3760
4564
  () =>
3761
4565
  [] as Array<{
3762
4566
  edge: GraphEdge;
3763
4567
  sourceCross: number;
3764
4568
  targetCross: number;
3765
4569
  straight: boolean;
4570
+ sameLayerPortSide?: "source" | "target";
3766
4571
  slot?: number;
3767
4572
  secondSlot?: number;
3768
4573
  crossover?: number;
@@ -3776,30 +4581,78 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3776
4581
  endpoints === undefined ||
3777
4582
  sourceLayer === undefined ||
3778
4583
  targetLayer === undefined ||
3779
- Math.abs(sourceLayer - targetLayer) !== 1
4584
+ Math.abs(sourceLayer - targetLayer) > 1
3780
4585
  ) {
3781
4586
  continue;
3782
4587
  }
4588
+ const sameLayer = sourceLayer === targetLayer;
4589
+ let positiveFace = true;
4590
+ if (sameLayer) {
4591
+ if (edge.sourceId === edge.targetId) continue;
4592
+ const positiveSide = horizontal ? "EAST" : "SOUTH";
4593
+ const face = (id: string, name: string | undefined) => {
4594
+ const node = nodeById.get(id)!;
4595
+ const port = node.ports?.find((port) => port.name === name);
4596
+ if (port) return input.portSettings?.(port, node)?.["port.side"];
4597
+ // A merged hyperedge dummy keeps an inverted dummy's in-layer
4598
+ // edge, whose dummy ports face backward (input) or forward (output).
4599
+ if (!id.startsWith("__layout_dummy:") || (name !== "input" && name !== "output"))
4600
+ return undefined;
4601
+ const forward = { right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" }[
4602
+ input.direction
4603
+ ]!;
4604
+ const backward = { EAST: "WEST", WEST: "EAST", SOUTH: "NORTH", NORTH: "SOUTH" }[
4605
+ forward
4606
+ ]!;
4607
+ return name === "output" ? forward : backward;
4608
+ };
4609
+ const negativeSide = horizontal ? "WEST" : "NORTH";
4610
+ const sourceFace = face(edge.sourceId, edge.sourcePort);
4611
+ if (
4612
+ sourceFace !== face(edge.targetId, edge.targetPort) ||
4613
+ (sourceFace !== positiveSide && sourceFace !== negativeSide)
4614
+ )
4615
+ continue;
4616
+ positiveFace = sourceFace === positiveSide;
4617
+ }
3783
4618
  const graphSourceCross = horizontal ? endpoints.source.y : endpoints.source.x;
3784
4619
  const graphTargetCross = horizontal ? endpoints.target.y : endpoints.target.x;
3785
- const sourceBeforeTarget = sourceLayer < targetLayer;
4620
+ const sourceBeforeTarget = sameLayer
4621
+ ? !orientation.reversedEdgeIds.has(edge.id)
4622
+ : sourceLayer < targetLayer;
3786
4623
  const sourceCross = sourceBeforeTarget ? graphSourceCross : graphTargetCross;
3787
4624
  const targetCross = sourceBeforeTarget ? graphTargetCross : graphSourceCross;
3788
- candidatesByGap[Math.min(sourceLayer, targetLayer)]?.push({
4625
+ const gap = sameLayer
4626
+ ? sourceLayer - (positiveFace ? 0 : 1)
4627
+ : Math.min(sourceLayer, targetLayer);
4628
+ candidatesByGap[gap + 1]?.push({
3789
4629
  edge,
4630
+ ...(sameLayer
4631
+ ? {
4632
+ sameLayerPortSide:
4633
+ increasing === positiveFace ? ("source" as const) : ("target" as const),
4634
+ }
4635
+ : {}),
3790
4636
  sourceCross,
3791
4637
  targetCross,
3792
4638
  straight: Math.abs(sourceCross - targetCross) < 1e-3,
3793
4639
  });
3794
4640
  }
3795
4641
 
4642
+ const routingPlacement = routingCoordinates(placement);
4643
+ const rawEndpoints = implicitEdgeEndpoints(input, routingPlacement, orientation);
3796
4644
  const slotsByGap = candidatesByGap.map((candidates) => {
3797
4645
  if (candidates.length === 0) return 0;
3798
4646
  // ELK creates hyperedge segments by walking layer nodes and their ports,
3799
4647
  // so dependency-cycle tie breaking observes cross-axis order.
3800
4648
  candidates.sort(
3801
4649
  (left, right) =>
3802
- left.sourceCross - right.sourceCross || left.targetCross - right.targetCross,
4650
+ (increasing
4651
+ ? left.sourceCross - right.sourceCross
4652
+ : left.targetCross - right.targetCross) ||
4653
+ (increasing
4654
+ ? left.targetCross - right.targetCross
4655
+ : left.sourceCross - right.sourceCross),
3803
4656
  );
3804
4657
  const minimumDifference = (values: readonly number[]) => {
3805
4658
  const distinct = [...new Set(values)].sort((left, right) => left - right);
@@ -3809,261 +4662,287 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3809
4662
  }
3810
4663
  return minimum;
3811
4664
  };
3812
- const criticalThreshold =
3813
- 0.2 *
3814
- Math.min(
3815
- minimumDifference(candidates.map(({ sourceCross }) => sourceCross)),
3816
- minimumDifference(candidates.map(({ targetCross }) => targetCross)),
3817
- );
3818
- const dependencies = candidates.map(() => new Set<number>());
3819
- const dependencyWeights = candidates.map(() => new Map<number, number>());
3820
- const criticalDependencies = new Set<string>();
3821
- const addDependency = (source: number, target: number, critical = false, weight = 1) => {
3822
- dependencies[source]?.add(target);
3823
- dependencyWeights[source]?.set(target, weight);
3824
- if (critical) criticalDependencies.add(`${source}:${target}`);
3825
- };
3826
- const removeDependency = (source: number, target: number) => {
3827
- dependencies[source]?.delete(target);
3828
- dependencyWeights[source]?.delete(target);
3829
- criticalDependencies.delete(`${source}:${target}`);
4665
+ const ports: OrthogonalPort[] = [];
4666
+ const byPort = new Map<string, OrthogonalPort>();
4667
+ const portByCandidate = new Map<
4668
+ (typeof candidates)[number],
4669
+ { source: string; target: string }
4670
+ >();
4671
+ const physicalPort = (
4672
+ candidate: (typeof candidates)[number],
4673
+ side: "source" | "target",
4674
+ ) => {
4675
+ const edge = candidate.edge;
4676
+ const sourceIsBefore =
4677
+ flowLayerByNodeId.get(edge.sourceId)! < flowLayerByNodeId.get(edge.targetId)!;
4678
+ const graphSource = candidate.sameLayerPortSide
4679
+ ? (side === "source") !== orientation.reversedEdgeIds.has(edge.id)
4680
+ : (side === "source") === (sourceIsBefore === increasing);
4681
+ const nodeId = graphSource ? edge.sourceId : edge.targetId;
4682
+ const name = graphSource ? edge.sourcePort : edge.targetPort;
4683
+ return { graphSource, nodeId, name };
3830
4684
  };
3831
- const conflicts = (left: number, right: number) => {
3832
- const difference = Math.abs(left - right);
3833
- if (difference < criticalThreshold) return -1;
3834
- return difference < 0.5 * edgeEdgeSpacing ? 1 : 0;
4685
+ // A port whose edges leave and enter it in this gap gives each
4686
+ // direction its own hypersegment: opposite directions never share a track.
4687
+ const directionsByPort = new Map<string, Set<boolean>>();
4688
+ for (const candidate of candidates)
4689
+ for (const side of ["source", "target"] as const) {
4690
+ const { graphSource, nodeId, name } = physicalPort(candidate, side);
4691
+ if (name === undefined || isMergedHyperedgeDummy(input, nodeId)) continue;
4692
+ const key = JSON.stringify([nodeId, name]);
4693
+ let directions = directionsByPort.get(key);
4694
+ if (!directions) directionsByPort.set(key, (directions = new Set()));
4695
+ directions.add(graphSource);
4696
+ }
4697
+ // The cross coordinate where a candidate meets this gap on `side`.
4698
+ const endPosition = (candidate: (typeof candidates)[number], side: "source" | "target") => {
4699
+ const position = candidate.sameLayerPortSide
4700
+ ? side === "source"
4701
+ ? candidate.sourceCross
4702
+ : candidate.targetCross
4703
+ : (side === "source") === increasing
4704
+ ? candidate.sourceCross
4705
+ : candidate.targetCross;
4706
+ const endpoints = rawEndpoints.get(candidate.edge.id);
4707
+ const sourceBefore =
4708
+ flowLayerByNodeId.get(candidate.edge.sourceId)! <
4709
+ flowLayerByNodeId.get(candidate.edge.targetId)!;
4710
+ const rawPoint =
4711
+ endpoints &&
4712
+ ((
4713
+ candidate.sameLayerPortSide
4714
+ ? (side === "source") !== orientation.reversedEdgeIds.has(candidate.edge.id)
4715
+ : (side === "source") === (sourceBefore === increasing)
4716
+ )
4717
+ ? endpoints.source
4718
+ : endpoints.target);
4719
+ return rawPoint ? (horizontal ? rawPoint.y : rawPoint.x) : position;
3835
4720
  };
3836
- const crossesExtent = (position: number, candidate: (typeof candidates)[number]) =>
3837
- position >= Math.min(candidate.sourceCross, candidate.targetCross) &&
3838
- position <= Math.max(candidate.sourceCross, candidate.targetCross)
3839
- ? 1
3840
- : 0;
3841
-
3842
- for (let left = 0; left < candidates.length - 1; left++) {
3843
- for (let right = left + 1; right < candidates.length; right++) {
3844
- const first = candidates[left]!;
3845
- const second = candidates[right]!;
3846
- if (first.straight || second.straight) continue;
3847
- const conflictsFirst = conflicts(first.targetCross, second.sourceCross);
3848
- const conflictsSecond = conflicts(second.targetCross, first.sourceCross);
3849
- if (conflictsFirst < 0 || conflictsSecond < 0) {
3850
- if (conflictsFirst < 0) addDependency(right, left, true);
3851
- if (conflictsSecond < 0) addDependency(left, right, true);
3852
- continue;
3853
- }
3854
- const firstPenalty =
3855
- conflictsFirst +
3856
- 16 *
3857
- (crossesExtent(first.targetCross, second) +
3858
- crossesExtent(second.sourceCross, first));
3859
- const secondPenalty =
3860
- conflictsSecond +
3861
- 16 *
3862
- (crossesExtent(second.targetCross, first) +
3863
- crossesExtent(first.sourceCross, second));
3864
- if (firstPenalty < secondPenalty)
3865
- addDependency(left, right, false, secondPenalty - firstPenalty);
3866
- else if (firstPenalty > secondPenalty)
3867
- addDependency(right, left, false, firstPenalty - secondPenalty);
3868
- else if (firstPenalty > 0) {
3869
- addDependency(left, right, false, 0);
3870
- addDependency(right, left, false, 0);
4721
+ /**
4722
+ * At a port carrying both directions, the edges fan out from separate
4723
+ * attachments. Ordered by where their far ends lie (back along the
4724
+ * port's own boundary on its near side first, then across the gap,
4725
+ * then back along the boundary on its far side), the edges nest
4726
+ * without crossing; consecutive edges of one direction share an
4727
+ * attachment and a hypersegment. Routing sees each attachment where
4728
+ * it will be, so the track order respects the split.
4729
+ */
4730
+ const runByEnd = new Map<string, number>();
4731
+ const runShift = new Map<string, number>();
4732
+ if (style === "ORTHOGONAL")
4733
+ for (const [key, directions] of directionsByPort) {
4734
+ if (directions.size < 2) continue;
4735
+ const [nodeId, name] = JSON.parse(key) as [string, string];
4736
+ const ends = candidates.flatMap((candidate, index) =>
4737
+ (["source", "target"] as const).flatMap((side) => {
4738
+ const port = physicalPort(candidate, side);
4739
+ if (port.nodeId !== nodeId || port.name !== name) return [];
4740
+ const other = side === "source" ? "target" : "source";
4741
+ return [
4742
+ {
4743
+ end: `${index}:${side}`,
4744
+ graphSource: port.graphSource,
4745
+ at: endPosition(candidate, side),
4746
+ far: endPosition(candidate, other),
4747
+ near: candidate.sameLayerPortSide !== undefined,
4748
+ },
4749
+ ];
4750
+ }),
4751
+ );
4752
+ const at = ends[0]!.at;
4753
+ if (ends.some((end) => Math.abs(end.at - at) > 1e-6)) continue;
4754
+ const group = (end: (typeof ends)[number]) => (!end.near ? 1 : end.far < at ? 0 : 2);
4755
+ const sorted = [...ends].sort(
4756
+ (a, b) =>
4757
+ group(a) - group(b) ||
4758
+ (group(a) === 1 ? a.far - b.far : b.far - a.far) ||
4759
+ Number(a.graphSource) - Number(b.graphSource),
4760
+ );
4761
+ let runs = 0;
4762
+ for (const [index, end] of sorted.entries()) {
4763
+ if (index && end.graphSource !== sorted[index - 1]!.graphSource) runs++;
4764
+ runByEnd.set(end.end, runs);
3871
4765
  }
3872
- }
3873
- }
3874
-
3875
- if (input.direction === "left") {
3876
- for (let left = 0; left < candidates.length - 1; left++) {
3877
- for (let right = left + 1; right < candidates.length; right++) {
3878
- const leftDummy = candidates[left]!.edge.id.includes("::segment:");
3879
- const rightDummy = candidates[right]!.edge.id.includes("::segment:");
3880
- if (leftDummy === rightDummy) continue;
3881
- const dummy = leftDummy ? left : right;
3882
- const ordinary = leftDummy ? right : left;
3883
- const candidate = candidates[dummy]!;
3884
- if (
3885
- Math.abs(candidate.sourceCross - candidate.targetCross) < edgeEdgeSpacing &&
3886
- dependencies[dummy]?.has(ordinary)
3887
- ) {
3888
- removeDependency(dummy, ordinary);
3889
- dependencies[ordinary]?.add(dummy);
3890
- dependencyWeights[ordinary]?.set(dummy, 1);
3891
- }
4766
+ const count = runs + 1;
4767
+ // A run holding an edge straight across the gap stays in place.
4768
+ const straight = new Set(
4769
+ sorted
4770
+ .filter((end) => !end.near && Math.abs(end.far - at) < 1e-3)
4771
+ .map((end) => runByEnd.get(end.end)!),
4772
+ );
4773
+ const anchor = straight.size === 1 ? [...straight][0]! : (count - 1) / 2;
4774
+ const rect = routingPlacement.rectByNodeId.get(nodeId);
4775
+ const low = rect ? (horizontal ? rect.y : rect.x) : -Infinity,
4776
+ high = rect ? low + (horizontal ? rect.height : rect.width) : Infinity;
4777
+ // Attachments stay on the node's side, closer together when it is short.
4778
+ let step = edgeEdgeSpacing;
4779
+ for (const run of [0, count - 1]) {
4780
+ const offset = run - anchor;
4781
+ if (offset < 0) step = Math.min(step, (at - low) / -offset);
4782
+ if (offset > 0) step = Math.min(step, (high - at) / offset);
3892
4783
  }
3893
- }
3894
- }
3895
-
3896
- const nonStraight = candidates.filter(({ straight }) => !straight);
3897
- let splitCycle = false;
3898
- for (let left = 0; left < candidates.length - 1 && !splitCycle; left++) {
3899
- for (let right = left + 1; right < candidates.length; right++) {
3900
- if (
3901
- dependencies[left]?.has(right) &&
3902
- dependencies[right]?.has(left) &&
3903
- criticalDependencies.has(`${left}:${right}`) &&
3904
- criticalDependencies.has(`${right}:${left}`)
3905
- ) {
3906
- const random =
3907
- phaseRandomByInput.get(input) ?? new JavaRandom(input.settings.randomSeed ?? 1);
3908
- const detour = random.nextInt(2) === 0 ? candidates[right]! : candidates[left]!;
3909
- const central = detour === candidates[left] ? candidates[right]! : candidates[left]!;
3910
- detour.slot = 0;
3911
- detour.secondSlot = 2;
3912
- detour.crossover = (detour.sourceCross + central.sourceCross) / 2;
3913
- central.slot = 1;
3914
- removeDependency(left, right);
3915
- removeDependency(right, left);
3916
- splitCycle = true;
3917
- break;
4784
+ if (!(step >= 2)) {
4785
+ for (const end of ends) runByEnd.delete(end.end);
4786
+ continue;
3918
4787
  }
3919
- }
3920
- }
3921
-
3922
- if (horizontal) {
3923
- for (let left = 0; left < candidates.length - 1; left++) {
3924
- for (let right = left + 1; right < candidates.length; right++) {
3925
- if (
3926
- dependencies[left]?.has(right) &&
3927
- dependencies[right]?.has(left) &&
3928
- !criticalDependencies.has(`${left}:${right}`) &&
3929
- !criticalDependencies.has(`${right}:${left}`)
3930
- ) {
3931
- const lowerSource =
3932
- candidates[left]!.sourceCross < candidates[right]!.sourceCross ? left : right;
3933
- const higherSource = lowerSource === left ? right : left;
3934
- removeDependency(lowerSource, higherSource);
3935
- }
4788
+ for (let run = 0; run < count; run++)
4789
+ runShift.set(JSON.stringify(["port", nodeId, name, run]), (run - anchor) * step);
4790
+ for (const end of ends) {
4791
+ const index = end.end.split(":")[0]!;
4792
+ const shift = (runByEnd.get(end.end)! - anchor) * step;
4793
+ if (Math.abs(shift) < 1e-9) continue;
4794
+ const id = candidates[Number(index)]!.edge.id;
4795
+ const record = attachmentShiftByEdgeId.get(id) ?? {};
4796
+ record[end.graphSource ? "source" : "target"] = shift;
4797
+ attachmentShiftByEdgeId.set(id, record);
3936
4798
  }
3937
4799
  }
3938
-
3939
- const visiting = new Set<number>();
3940
- const visited = new Set<number>();
3941
- const removeCycles = (source: number) => {
3942
- visiting.add(source);
3943
- for (const target of dependencies[source] ?? []) {
3944
- if (visiting.has(target)) removeDependency(source, target);
3945
- else if (!visited.has(target)) removeCycles(target);
3946
- }
3947
- visiting.delete(source);
3948
- visited.add(source);
3949
- };
3950
- for (let index = 0; index < candidates.length; index++) removeCycles(index);
3951
- } else {
3952
- const marks = candidates.map((_, index) => -index - 1);
3953
- const inWeight = candidates.map(() => 0);
3954
- const outWeight = candidates.map(() => 0);
3955
- for (const [source, targets] of dependencyWeights.entries()) {
3956
- for (const [target, weight] of targets) {
3957
- outWeight[source] = (outWeight[source] ?? 0) + weight;
3958
- inWeight[target] = (inWeight[target] ?? 0) + weight;
4800
+ const portKey = (candidate: (typeof candidates)[number], side: "source" | "target") => {
4801
+ const edge = candidate.edge;
4802
+ const { graphSource, nodeId, name } = physicalPort(candidate, side);
4803
+ const node = nodeById.get(nodeId)!;
4804
+ if (isMergedHyperedgeDummy(input, nodeId))
4805
+ return JSON.stringify(["hyperedge", nodeId, candidate.sameLayerPortSide ?? side]);
4806
+ const run = runByEnd.get(`${candidates.indexOf(candidate)}:${side}`);
4807
+ if (name !== undefined && run !== undefined)
4808
+ return JSON.stringify(["port", nodeId, name, run]);
4809
+ if (name !== undefined)
4810
+ return directionsByPort.get(JSON.stringify([nodeId, name]))!.size > 1
4811
+ ? JSON.stringify(["port", nodeId, name, graphSource])
4812
+ : JSON.stringify(["port", nodeId, name]);
4813
+ if (input.settings.mergeEdges === true || input.nodeSettings?.(node)?.hypernode === true)
4814
+ return JSON.stringify(["implicit", nodeId, candidate.sameLayerPortSide ?? side]);
4815
+ return JSON.stringify(["edge", edge.id, side]);
4816
+ };
4817
+ for (const side of ["source", "target"] as const)
4818
+ for (const candidate of candidates) {
4819
+ const key = portKey(candidate, side);
4820
+ if (!byPort.has(key)) {
4821
+ const port = {
4822
+ id: key,
4823
+ side: candidate.sameLayerPortSide ?? side,
4824
+ position: endPosition(candidate, side) + (runShift.get(key) ?? 0),
4825
+ };
4826
+ byPort.set(key, port);
4827
+ ports.push(port);
3959
4828
  }
3960
4829
  }
3961
- const sources = inWeight.flatMap((weight, index) =>
3962
- weight === 0 && (outWeight[index] ?? 0) > 0 ? [index] : [],
4830
+ for (const candidate of candidates)
4831
+ portByCandidate.set(candidate, {
4832
+ source: portKey(candidate, "source"),
4833
+ target: portKey(candidate, "target"),
4834
+ });
4835
+ const edgeLists = getCrossingUnits(input)?.edgeListRanks;
4836
+ const listedId = (edgeId: string) => edgeId.replace(/(::(segment|inverted):\d+:*)+$/, "");
4837
+ const grouped = createOrthogonalHypersegments(
4838
+ ports,
4839
+ candidates.map((candidate) => portByCandidate.get(candidate)!),
4840
+ (index, list) => {
4841
+ const { edge } = candidates[index]!;
4842
+ const reversed = orientation.reversedEdgeIds.has(edge.id);
4843
+ // The port side that lists this connection: its source lists it as outgoing.
4844
+ const node = (list === "outgoing") !== reversed ? edge.sourceId : edge.targetId;
4845
+ return edgeLists?.[list].get(node)?.get(listedId(edge.id));
4846
+ },
4847
+ );
4848
+ const criticalThreshold =
4849
+ 0.2 *
4850
+ Math.min(
4851
+ minimumDifference(grouped.segments.flatMap((segment) => segment.incoming)),
4852
+ minimumDifference(grouped.segments.flatMap((segment) => segment.outgoing)),
3963
4853
  );
3964
- const sinks = outWeight.flatMap((weight, index) => (weight === 0 ? [index] : []));
3965
- const unprocessed = new Set(candidates.map((_, index) => index));
3966
- const update = (node: number) => {
3967
- for (const [target, weight] of dependencyWeights[node] ?? []) {
3968
- if (!unprocessed.has(target) || weight <= 0) continue;
3969
- inWeight[target] = (inWeight[target] ?? 0) - weight;
3970
- if ((inWeight[target] ?? 0) <= 0 && (outWeight[target] ?? 0) > 0)
3971
- sources.push(target);
3972
- }
3973
- for (let source = 0; source < candidates.length; source++) {
3974
- const weight = dependencyWeights[source]?.get(node);
3975
- if (!unprocessed.has(source) || weight === undefined || weight <= 0) continue;
3976
- outWeight[source] = (outWeight[source] ?? 0) - weight;
3977
- if ((outWeight[source] ?? 0) <= 0 && (inWeight[source] ?? 0) > 0) sinks.push(source);
3978
- }
3979
- };
3980
- const random =
3981
- phaseRandomByInput.get(input) ?? new JavaRandom(input.settings.randomSeed ?? 1);
3982
- const markBase = candidates.length;
3983
- let nextSink = markBase - 1;
3984
- let nextSource = markBase + 1;
3985
- while (unprocessed.size > 0) {
3986
- while (sinks.length > 0) {
3987
- const sink = sinks.shift()!;
3988
- if (!unprocessed.delete(sink)) continue;
3989
- marks[sink] = nextSink--;
3990
- update(sink);
3991
- }
3992
- while (sources.length > 0) {
3993
- const source = sources.shift()!;
3994
- if (!unprocessed.delete(source)) continue;
3995
- marks[source] = nextSource++;
3996
- update(source);
3997
- }
3998
- if (unprocessed.size === 0) break;
3999
- const ordered = [...unprocessed].sort((left, right) => marks[left]! - marks[right]!);
4000
- let maximum = Number.NEGATIVE_INFINITY;
4001
- let maxima: number[] = [];
4002
- for (const candidate of ordered) {
4003
- const outflow = (outWeight[candidate] ?? 0) - (inWeight[candidate] ?? 0);
4004
- if (outflow > maximum) {
4005
- maximum = outflow;
4006
- maxima = [candidate];
4007
- } else if (outflow === maximum) maxima.push(candidate);
4008
- }
4009
- const selected = maxima[random.nextInt(maxima.length)]!;
4010
- unprocessed.delete(selected);
4011
- marks[selected] = nextSource++;
4012
- update(selected);
4013
- }
4014
- const shift = candidates.length + 1;
4015
- for (let index = 0; index < marks.length; index++) {
4016
- if (marks[index]! < markBase) marks[index]! += shift;
4017
- }
4018
- const reversed: Array<[number, number, number]> = [];
4019
- for (const [source, targets] of dependencyWeights.entries()) {
4020
- for (const [target, weight] of targets) {
4021
- if (
4022
- marks[source]! <= marks[target]! ||
4023
- criticalDependencies.has(`${source}:${target}`)
4024
- )
4025
- continue;
4026
- removeDependency(source, target);
4027
- if (weight > 0) reversed.push([target, source, weight]);
4854
+ const random =
4855
+ phaseRandomByInput.get(input) ?? new JavaRandom(input.settings.randomSeed ?? 1);
4856
+ const result = routeOrthogonalSegments(
4857
+ grouped.segments,
4858
+ 0.5 * edgeEdgeSpacing,
4859
+ criticalThreshold,
4860
+ random,
4861
+ );
4862
+ const junctionGroup: (typeof junctionGroups)[number] = [];
4863
+ for (const group of grouped.segments) {
4864
+ for (const port of group.ports) {
4865
+ const outgoing = candidates.filter(
4866
+ (candidate) => portByCandidate.get(candidate)!.source === port,
4867
+ );
4868
+ // Merging adjacent helpers concatenates their physical incident
4869
+ // edges in retained layer order; root edge order loses that state.
4870
+ const listedOutgoing = getCrossingUnits(input)?.edgeListRanks?.outgoing;
4871
+ const listed = (edgeId: string) => edgeId.replace(/(::(segment|inverted):\d+:*)+$/, "");
4872
+ outgoing.sort((a, b) => {
4873
+ // ELK visits a port's outgoing edges in list order, which records
4874
+ // reversal history; edges from dummies keep the fallbacks below.
4875
+ const sourceOf = (candidate: (typeof outgoing)[number]) =>
4876
+ orientation.reversedEdgeIds.has(candidate.edge.id)
4877
+ ? candidate.edge.targetId
4878
+ : candidate.edge.sourceId;
4879
+ const ranks =
4880
+ sourceOf(a) === sourceOf(b) ? listedOutgoing?.get(sourceOf(a)) : undefined;
4881
+ const ra = ranks?.get(listed(a.edge.id)),
4882
+ rb = ranks?.get(listed(b.edge.id));
4883
+ if (ra !== undefined && rb !== undefined) return ra - rb;
4884
+ // Merging appends a lower dummy's edges after the kept dummy's.
4885
+ if (sourceOf(a) === sourceOf(b) && isMergedHyperedgeDummy(input, sourceOf(a))) {
4886
+ const merged = getPlacementOrder(input)?.outputPortOrderByNodeId?.get(sourceOf(a));
4887
+ const ma = merged?.indexOf(a.edge.id) ?? -1,
4888
+ mb = merged?.indexOf(b.edge.id) ?? -1;
4889
+ if (ma >= 0 && mb >= 0) return ma - mb;
4890
+ }
4891
+ // Reversing incoming edges appends them after retained outgoing
4892
+ // edges on the physical port, regardless of original model order.
4893
+ const reverseOrder =
4894
+ Number(orientation.reversedEdgeIds.has(a.edge.id)) -
4895
+ Number(orientation.reversedEdgeIds.has(b.edge.id));
4896
+ if (reverseOrder) return reverseOrder;
4897
+ const source = orientation.reversedEdgeIds.has(a.edge.id)
4898
+ ? a.edge.targetId
4899
+ : a.edge.sourceId;
4900
+ const selected = getPlacementOrder(input)?.outputPortOrderByNodeId?.get(source);
4901
+ const aa = selected?.indexOf(a.edge.id) ?? -1;
4902
+ const bb = selected?.indexOf(b.edge.id) ?? -1;
4903
+ return aa >= 0 && bb >= 0
4904
+ ? aa - bb
4905
+ : physicalOrder.get(a.edge.id)! - physicalOrder.get(b.edge.id)!;
4906
+ });
4907
+ for (const candidate of outgoing) {
4908
+ const segment = result.segments[grouped.segmentByPort.get(port)!]!;
4909
+ const reversed = orientation.reversedEdgeIds.has(candidate.edge.id);
4910
+ junctionGroup.push({
4911
+ edgeId: candidate.edge.id,
4912
+ reversed,
4913
+ segment,
4914
+ sourceValue: byPort.get(portByCandidate.get(candidate)!.source)!.position,
4915
+ targetValue: byPort.get(portByCandidate.get(candidate)!.target)!.position,
4916
+ partner:
4917
+ segment.partner === undefined ? undefined : result.segments[segment.partner],
4918
+ });
4028
4919
  }
4029
4920
  }
4030
- for (const [source, target, weight] of reversed)
4031
- addDependency(source, target, false, weight);
4032
- }
4033
-
4034
- const incoming = candidates.map(() => 0);
4035
- for (const targets of dependencies) {
4036
- for (const target of targets) incoming[target] = (incoming[target] ?? 0) + 1;
4037
4921
  }
4038
- const queue = incoming.flatMap((count, index) => (count === 0 ? [index] : []));
4039
- let maximumSlot = Math.max(
4040
- 0,
4041
- ...candidates.map(({ secondSlot, slot }) => secondSlot ?? slot ?? 0),
4042
- );
4043
- for (let queueIndex = 0; queueIndex < queue.length; queueIndex++) {
4044
- const source = queue[queueIndex]!;
4045
- const sourceSlot = candidates[source]?.slot ?? 0;
4046
- for (const target of dependencies[source] ?? []) {
4047
- const targetCandidate = candidates[target];
4048
- if (!targetCandidate) continue;
4049
- targetCandidate.slot = Math.max(targetCandidate.slot ?? 0, sourceSlot + 1);
4050
- maximumSlot = Math.max(maximumSlot, targetCandidate.slot);
4051
- incoming[target]!--;
4052
- if (incoming[target] === 0) queue.push(target);
4053
- }
4922
+ junctionGroups.push(junctionGroup);
4923
+ let maximumSlot = -1;
4924
+ for (const segment of result.segments) {
4925
+ if (Math.abs(segment.start - segment.end) >= 1e-3)
4926
+ maximumSlot = Math.max(maximumSlot, segment.slot);
4054
4927
  }
4055
- // ELK's topological numbering moves target-only hyperedge segments to
4056
- // the rightmost slot. Ordinary edges map to those segments here; long-
4057
- // edge dummy segments retain their dependency-derived rank.
4058
- for (const candidate of nonStraight) {
4059
- if (
4060
- !candidate.edge.id.includes("::segment:") &&
4061
- Math.abs(candidate.sourceCross - candidate.targetCross) < edgeEdgeSpacing / 2
4062
- ) {
4063
- candidate.slot = maximumSlot;
4928
+ for (const candidate of candidates) {
4929
+ const segment =
4930
+ result.segments[grouped.segmentByPort.get(portByCandidate.get(candidate)!.source)!]!;
4931
+ candidate.straight = Math.abs(segment.start - segment.end) < 1e-3;
4932
+ candidate.slot = increasing ? segment.slot : maximumSlot - segment.slot;
4933
+ if (segment.partner !== undefined) {
4934
+ const partner = result.segments[segment.partner]!;
4935
+ candidate.secondSlot = increasing ? partner.slot : maximumSlot - segment.slot;
4936
+ if (!increasing) candidate.slot = maximumSlot - partner.slot;
4937
+ const sourcePort = portByCandidate.get(candidate)!.source;
4938
+ const offset =
4939
+ (increasing ? candidate.sourceCross : candidate.targetCross) -
4940
+ byPort.get(sourcePort)!.position +
4941
+ (runShift.get(sourcePort) ?? 0);
4942
+ candidate.crossover = segment.outgoing[0]! + offset;
4064
4943
  }
4065
4944
  }
4066
- return nonStraight.length === 0 ? 0 : maximumSlot + 1;
4945
+ return maximumSlot + 1;
4067
4946
  });
4068
4947
 
4069
4948
  const existingGapByLayer = flowLayers
@@ -4080,7 +4959,26 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4080
4959
  input.nodeSettings?.(node)?.["nodePlacement.networkSimplex.nodeFlexibility"] !==
4081
4960
  "NONE",
4082
4961
  ));
4083
- let nextStart = flowLayers[0]?.start ?? 0;
4962
+ const leadingSlots = slotsByGap[0] ?? 0;
4963
+ const leadingWidth =
4964
+ leadingSlots > 0 ? edgeNodeSpacing + (leadingSlots - 1) * edgeEdgeSpacing : 0;
4965
+ let nextStart = (flowLayers[0]?.start ?? 0) + leadingWidth;
4966
+ // ELK omits ordinary node spacing beside an external-port-only layer.
4967
+ const externalFlowLayers = new Set(
4968
+ flowLayers.flatMap((_, layer) => {
4969
+ const ids = [...flowLayerByNodeId].filter(([, index]) => index === layer);
4970
+ return ids.length &&
4971
+ ids.every(([id]) => {
4972
+ const node = nodeById.get(id);
4973
+ const side = node && externalPortDummyOf(node)?.side;
4974
+ return horizontal
4975
+ ? side === "WEST" || side === "EAST"
4976
+ : side === "NORTH" || side === "SOUTH";
4977
+ })
4978
+ ? [layer]
4979
+ : [];
4980
+ }),
4981
+ );
4084
4982
  for (const [layerNo, bounds] of flowLayers.entries()) {
4085
4983
  const shift = nextStart - bounds.start;
4086
4984
  for (const [id, rect] of placement.rectByNodeId) {
@@ -4093,23 +4991,44 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4093
4991
  const size = bounds.end - bounds.start;
4094
4992
  bounds.start = nextStart;
4095
4993
  bounds.end = nextStart + size;
4096
- const slots = slotsByGap[layerNo] ?? 0;
4097
- const routesNearTarget = candidatesByGap[layerNo]?.some(
4994
+ const slots = slotsByGap[layerNo + 1] ?? 0;
4995
+ const routesNearTarget = candidatesByGap[layerNo + 1]?.some(
4098
4996
  (candidate) =>
4099
4997
  hasZeroFixedSideTarget(candidate.edge) &&
4100
- candidatesByGap[layerNo]!.filter(
4998
+ candidatesByGap[layerNo + 1]!.filter(
4101
4999
  ({ edge }) => edge.targetId === candidate.edge.targetId,
4102
5000
  ).length >
4103
- candidatesByGap[layerNo]!.filter(
5001
+ candidatesByGap[layerNo + 1]!.filter(
4104
5002
  ({ edge }) => edge.sourceId === candidate.edge.sourceId,
4105
5003
  ).length,
4106
5004
  );
4107
5005
  const preservedGap =
4108
5006
  (existingGapByLayer[layerNo] ?? input.spacing.layer) -
4109
5007
  (routesNearTarget ? edgeEdgeSpacing : 0);
5008
+ const labelAdjacent = labelFlowLayers.has(layerNo) || labelFlowLayers.has(layerNo + 1);
5009
+ const portMargin =
5010
+ flowLayers[layerNo]!.end -
5011
+ flowLayers[layerNo]!.start -
5012
+ (bodyFlowLayers[layerNo]!.end - bodyFlowLayers[layerNo]!.start) +
5013
+ (flowLayers[layerNo + 1]
5014
+ ? flowLayers[layerNo + 1]!.end -
5015
+ flowLayers[layerNo + 1]!.start -
5016
+ (bodyFlowLayers[layerNo + 1]!.end - bodyFlowLayers[layerNo + 1]!.start)
5017
+ : 0);
5018
+ const externalBoundary =
5019
+ externalFlowLayers.has(layerNo) || externalFlowLayers.has(layerNo + 1);
5020
+ const minimumGap = externalBoundary
5021
+ ? 0
5022
+ : labelAdjacent
5023
+ ? Math.max(edgeNodeSpacing, input.spacing.layer - portMargin)
5024
+ : input.spacing.layer;
4110
5025
  const gapSpacing =
4111
5026
  slots === 0
4112
- ? (existingGapByLayer[layerNo] ?? input.spacing.layer)
5027
+ ? externalBoundary
5028
+ ? 0
5029
+ : labelAdjacent
5030
+ ? minimumGap
5031
+ : (existingGapByLayer[layerNo] ?? input.spacing.layer)
4113
5032
  : Math.max(
4114
5033
  preservesNodeFlexibilityGap ||
4115
5034
  (labelExtraByGap[layerNo] ?? 0) > 0 ||
@@ -4123,14 +5042,22 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4123
5042
  ),
4124
5043
  )
4125
5044
  ? preservedGap
4126
- : input.spacing.layer,
5045
+ : minimumGap,
5046
+ // LABEL nodes also need clearance beyond the final routing track.
4127
5047
  2 * edgeNodeSpacing + Math.max(0, slots - 1) * edgeEdgeSpacing,
4128
5048
  );
4129
5049
  nextStart = bounds.end + gapSpacing;
4130
5050
  }
4131
5051
  implicitEndpoints = implicitEdgeEndpoints(input, placement, orientation);
4132
5052
 
4133
- for (const [gap, candidates] of candidatesByGap.entries()) {
5053
+ for (const [boundary, candidates] of candidatesByGap.entries()) {
5054
+ const gap = boundary - 1;
5055
+ const firstBoundaryTrack =
5056
+ gap < 0
5057
+ ? (flowLayers[0]?.start ?? 0) -
5058
+ edgeNodeSpacing -
5059
+ Math.max(0, leadingSlots - 1) * edgeEdgeSpacing
5060
+ : (flowLayers[gap]?.end ?? 0) + edgeNodeSpacing;
4134
5061
  for (const candidate of candidates) {
4135
5062
  if (candidate.straight) continue;
4136
5063
  const routeNearTarget =
@@ -4142,16 +5069,11 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4142
5069
  ? (flowLayers[gap + 1]?.start ?? 0) -
4143
5070
  edgeNodeSpacing -
4144
5071
  (candidate.slot ?? 0) * edgeEdgeSpacing
4145
- : (flowLayers[gap]?.end ?? 0) +
4146
- edgeNodeSpacing +
4147
- (candidate.slot ?? 0) * edgeEdgeSpacing;
5072
+ : firstBoundaryTrack + (candidate.slot ?? 0) * edgeEdgeSpacing;
4148
5073
  if (candidate.secondSlot !== undefined && candidate.crossover !== undefined) {
4149
5074
  orthogonalDetourByEdgeId.set(candidate.edge.id, {
4150
5075
  firstTrack,
4151
- secondTrack:
4152
- (flowLayers[gap]?.end ?? 0) +
4153
- edgeNodeSpacing +
4154
- candidate.secondSlot * edgeEdgeSpacing,
5076
+ secondTrack: firstBoundaryTrack + candidate.secondSlot * edgeEdgeSpacing,
4155
5077
  crossover: candidate.crossover,
4156
5078
  });
4157
5079
  } else {
@@ -4543,18 +5465,101 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4543
5465
  const feedbackTargetPortSide = feedbackTargetPort
4544
5466
  ? input.portSettings?.(feedbackTargetPort, target)?.["port.side"]
4545
5467
  : undefined;
5468
+ // A merged hyperedge dummy keeps an inverted dummy's in-layer edge,
5469
+ // whose dummy ports face backward (input) or forward (output).
5470
+ const mergedFace = (id: string, name: string | undefined) => {
5471
+ if (!isMergedHyperedgeDummy(input, id) || (name !== "input" && name !== "output"))
5472
+ return undefined;
5473
+ const forward = { right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" }[
5474
+ input.direction
5475
+ ]!;
5476
+ const backward = { EAST: "WEST", WEST: "EAST", SOUTH: "NORTH", NORTH: "SOUTH" }[forward]!;
5477
+ return name === "output" ? forward : backward;
5478
+ };
5479
+ const inLayerSourceSide = feedbackSourcePortSide ?? mergedFace(source.id, edge.sourcePort);
5480
+ const inLayerTargetSide = feedbackTargetPortSide ?? mergedFace(target.id, edge.targetPort);
5481
+ // Inverted-port dummies keep the turn in the node's own layer. Both
5482
+ // terminals face the same exterior corridor; this is not an outer
5483
+ // feedback loop around the entire graph.
5484
+ if (
5485
+ style === "ORTHOGONAL" &&
5486
+ (source.id.startsWith("__layout_dummy:inverted:") ||
5487
+ target.id.startsWith("__layout_dummy:inverted:") ||
5488
+ isMergedHyperedgeDummy(input, source.id) ||
5489
+ isMergedHyperedgeDummy(input, target.id)) &&
5490
+ flowLayerByNodeId.get(source.id) === flowLayerByNodeId.get(target.id) &&
5491
+ inLayerSourceSide === inLayerTargetSide &&
5492
+ inLayerSourceSide !== undefined
5493
+ ) {
5494
+ const sourceFallback = implicitEndpoints.get(edge.id)?.source ?? {
5495
+ x: sourceRect.x,
5496
+ y: sourceRect.y,
5497
+ };
5498
+ const targetFallback = implicitEndpoints.get(edge.id)?.target ?? {
5499
+ x: targetRect.x,
5500
+ y: targetRect.y,
5501
+ };
5502
+ const start = attached(
5503
+ edge,
5504
+ "source",
5505
+ getPortPoint(source, edge.sourcePort, sourceRect, sourceFallback, input.direction, input),
5506
+ );
5507
+ const end = attached(
5508
+ edge,
5509
+ "target",
5510
+ getPortPoint(target, edge.targetPort, targetRect, targetFallback, input.direction, input),
5511
+ );
5512
+ if (reversedEdge) outsideFeedbackEdgeIds.add(edge.id);
5513
+ const side = inLayerSourceSide;
5514
+ const spacing = Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10);
5515
+ // Clear the whole occupied layer, including ports and self-loop
5516
+ // reservations; endpoint anchors alone omit those margins.
5517
+ const occupied = flowLayers[flowLayerByNodeId.get(source.id) ?? -1];
5518
+ const track =
5519
+ orthogonalTrackByEdgeId.get(edge.id) ??
5520
+ (side === "WEST"
5521
+ ? (horizontal
5522
+ ? (occupied?.start ?? Math.min(start.x, end.x))
5523
+ : Math.min(start.x, end.x)) - spacing
5524
+ : side === "EAST"
5525
+ ? (horizontal
5526
+ ? (occupied?.end ?? Math.max(start.x, end.x))
5527
+ : Math.max(start.x, end.x)) + spacing
5528
+ : side === "NORTH"
5529
+ ? (!horizontal
5530
+ ? (occupied?.start ?? Math.min(start.y, end.y))
5531
+ : Math.min(start.y, end.y)) - spacing
5532
+ : (!horizontal
5533
+ ? (occupied?.end ?? Math.max(start.y, end.y))
5534
+ : Math.max(start.y, end.y)) + spacing);
5535
+ pointsByEdgeId.set(
5536
+ edge.id,
5537
+ simplifyRoute(
5538
+ side === "WEST" || side === "EAST"
5539
+ ? [start, { x: track, y: start.y }, { x: track, y: end.y }, end]
5540
+ : [start, { x: start.x, y: track }, { x: end.x, y: track }, end],
5541
+ ),
5542
+ );
5543
+ continue;
5544
+ }
5545
+ const sourceFlow = horizontal ? sourceRect.x : sourceRect.y;
5546
+ const targetFlow = horizontal ? targetRect.x : targetRect.y;
5547
+ const sourceBeforeTarget =
5548
+ sourceFlow < targetFlow ||
5549
+ (sourceFlow === targetFlow &&
5550
+ (flowLayerByNodeId.get(source.id) ?? 0) < (flowLayerByNodeId.get(target.id) ?? 0));
4546
5551
  const sourceAwaySide = horizontal
4547
- ? sourceRect.x < targetRect.x
5552
+ ? sourceBeforeTarget
4548
5553
  ? "WEST"
4549
5554
  : "EAST"
4550
- : sourceRect.y < targetRect.y
5555
+ : sourceBeforeTarget
4551
5556
  ? "NORTH"
4552
5557
  : "SOUTH";
4553
5558
  const targetAwaySide = horizontal
4554
- ? sourceRect.x < targetRect.x
5559
+ ? sourceBeforeTarget
4555
5560
  ? "EAST"
4556
5561
  : "WEST"
4557
- : sourceRect.y < targetRect.y
5562
+ : sourceBeforeTarget
4558
5563
  ? "SOUTH"
4559
5564
  : "NORTH";
4560
5565
  const sameSideSelfLoop =
@@ -4565,6 +5570,63 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4565
5570
  const constraints = String(input.nodeSettings?.(node)?.portConstraints ?? "UNDEFINED");
4566
5571
  return constraints !== "UNDEFINED" && constraints !== "FREE";
4567
5572
  };
5573
+ if (
5574
+ style === "ORTHOGONAL" &&
5575
+ source.id === target.id &&
5576
+ hasFixedPortSide(source) &&
5577
+ (feedbackSourcePortSide !== undefined || feedbackTargetPortSide !== undefined) &&
5578
+ (edge.sourcePort === undefined ||
5579
+ edge.targetPort === undefined ||
5580
+ !sameSideSelfLoop ||
5581
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true)
5582
+ ) {
5583
+ const endpoints = implicitEndpoints.get(edge.id)!;
5584
+ // Like other edges, a single-edge zero-size fixed-side port takes its
5585
+ // slot in the side's shared endpoint order, so it cannot coincide
5586
+ // with a sibling port that already uses that order.
5587
+ const terminal = (endpoint: "source" | "target"): Point => {
5588
+ const portName = endpoint === "source" ? edge.sourcePort : edge.targetPort;
5589
+ const fallback = endpoints[endpoint];
5590
+ const port = source.ports?.find((candidate) => candidate.name === portName);
5591
+ const side = port ? input.portSettings?.(port, source)?.["port.side"] : undefined;
5592
+ const onSide =
5593
+ (side === "EAST" && Math.abs(fallback.x - sourceRect.x - sourceRect.width) < 1e-9) ||
5594
+ (side === "WEST" && Math.abs(fallback.x - sourceRect.x) < 1e-9) ||
5595
+ (side === "SOUTH" && Math.abs(fallback.y - sourceRect.y - sourceRect.height) < 1e-9) ||
5596
+ (side === "NORTH" && Math.abs(fallback.y - sourceRect.y) < 1e-9);
5597
+ return onSide && usesSharedSideSlot(input, source, portName)
5598
+ ? fallback
5599
+ : getPortPoint(source, portName, sourceRect, fallback, input.direction, input);
5600
+ };
5601
+ const start = terminal("source");
5602
+ const end = terminal("target");
5603
+ const outputSide = ({ right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" } as const)[
5604
+ input.direction
5605
+ ];
5606
+ const inputSide = ({ right: "WEST", left: "EAST", down: "NORTH", up: "SOUTH" } as const)[
5607
+ input.direction
5608
+ ];
5609
+ const loops = selfLoopsByNodeId.get(source.id) ?? [edge];
5610
+ const distance =
5611
+ Number(input.settings["spacing.nodeSelfLoop"] ?? 10) *
5612
+ (selfLoopTracks(loops).get(edge.id)! + 1);
5613
+ pointsByEdgeId.set(
5614
+ edge.id,
5615
+ (sameSideSelfLoop ? (points: Point[]) => points : simplifyRoute)(
5616
+ routeFixedSelfLoop(
5617
+ sourceRect,
5618
+ start,
5619
+ end,
5620
+ fixedSelfLoopSide(feedbackSourcePortSide) ?? outputSide,
5621
+ fixedSelfLoopSide(feedbackTargetPortSide) ?? inputSide,
5622
+ distance,
5623
+ input.direction,
5624
+ ),
5625
+ ),
5626
+ );
5627
+ outsideFeedbackEdgeIds.add(edge.id);
5628
+ continue;
5629
+ }
4568
5630
  const fixedSideFeedback =
4569
5631
  style === "ORTHOGONAL" &&
4570
5632
  !sameSideSelfLoop &&
@@ -4850,6 +5912,31 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4850
5912
  outsideFeedbackEdgeIds.add(edge.id);
4851
5913
  continue;
4852
5914
  }
5915
+ const movable =
5916
+ hasMovableLoopPorts(input, edge) &&
5917
+ hasPlacementLoopEnvelope(placement, source.id) &&
5918
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true;
5919
+ // Route unconstrained loops in the canonical rightward system, then
5920
+ // apply the same direction transform as the layer pipeline.
5921
+ const loopRect = movable
5922
+ ? {
5923
+ x: 0,
5924
+ y: 0,
5925
+ width: horizontal ? sourceRect.width : sourceRect.height,
5926
+ height: horizontal ? sourceRect.height : sourceRect.width,
5927
+ }
5928
+ : sourceRect;
5929
+ const publishLoop = (points: readonly Point[]) => {
5930
+ const physical = !movable
5931
+ ? points
5932
+ : points.map((point) => {
5933
+ const flow = reverse ? loopRect.width - point.x : point.x;
5934
+ return horizontal
5935
+ ? { x: sourceRect.x + flow, y: sourceRect.y + point.y }
5936
+ : { x: sourceRect.x + point.y, y: sourceRect.y + flow };
5937
+ });
5938
+ pointsByEdgeId.set(edge.id, physical);
5939
+ };
4853
5940
  const routeHorizontalSide = (
4854
5941
  side: "NORTH" | "SOUTH",
4855
5942
  indexOnSide: number,
@@ -4880,17 +5967,15 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4880
5967
  const sideLoops = loops.filter(
4881
5968
  (candidate, i) => loopSide(distribution, i, candidate) === side,
4882
5969
  );
4883
- const distance = loopDistance(sideLoops, edge, side, ordering);
5970
+ const distance = loopDistance(sideLoops, edge, side, ordering, movable);
4884
5971
  const y =
4885
- side === "NORTH"
4886
- ? sourceRect.y - distance
4887
- : sourceRect.y + sourceRect.height + distance;
5972
+ side === "NORTH" ? loopRect.y - distance : loopRect.y + loopRect.height + distance;
4888
5973
  const start = {
4889
- x: sourceRect.x + sourceRect.width * startRatio,
4890
- y: side === "NORTH" ? sourceRect.y : sourceRect.y + sourceRect.height,
5974
+ x: loopRect.x + loopRect.width * startRatio,
5975
+ y: side === "NORTH" ? loopRect.y : loopRect.y + loopRect.height,
4891
5976
  };
4892
5977
  const end = {
4893
- x: sourceRect.x + sourceRect.width * endRatio,
5978
+ x: loopRect.x + loopRect.width * endRatio,
4894
5979
  y: start.y,
4895
5980
  };
4896
5981
  return [start, { x: start.x, y }, { x: end.x, y }, end];
@@ -4907,43 +5992,41 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4907
5992
  const startRatio = side === "EAST" ? firstRatio : secondRatio;
4908
5993
  const endRatio = side === "EAST" ? secondRatio : firstRatio;
4909
5994
  const distance =
4910
- input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
5995
+ movable || input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
4911
5996
  ? loopDistance(
4912
5997
  loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side),
4913
5998
  edge,
4914
5999
  side,
4915
6000
  ordering,
6001
+ movable,
4916
6002
  )
4917
6003
  : spacing * (indexOnSide + 1);
4918
6004
  const x =
4919
- side === "EAST"
4920
- ? sourceRect.x + sourceRect.width + distance
4921
- : sourceRect.x - distance;
6005
+ side === "EAST" ? loopRect.x + loopRect.width + distance : loopRect.x - distance;
4922
6006
  const start = {
4923
- x: side === "EAST" ? sourceRect.x + sourceRect.width : sourceRect.x,
4924
- y: sourceRect.y + sourceRect.height * startRatio,
6007
+ x: side === "EAST" ? loopRect.x + loopRect.width : loopRect.x,
6008
+ y: loopRect.y + loopRect.height * startRatio,
4925
6009
  };
4926
- const end = { x: start.x, y: sourceRect.y + sourceRect.height * endRatio };
6010
+ const end = { x: start.x, y: loopRect.y + loopRect.height * endRatio };
4927
6011
  return [start, { x, y: start.y }, { x, y: end.y }, end];
4928
6012
  };
4929
6013
  if (distribution === "NORTH_SOUTH") {
4930
6014
  const side = loopIndex % 2 === 0 ? "NORTH" : "SOUTH";
4931
6015
  const indexOnSide = Math.floor(loopIndex / 2);
4932
6016
  const countOnSide = Math.ceil((loops.length - (side === "SOUTH" ? 1 : 0)) / 2);
4933
- pointsByEdgeId.set(edge.id, routeHorizontalSide(side, indexOnSide, countOnSide));
6017
+ publishLoop(routeHorizontalSide(side, indexOnSide, countOnSide));
4934
6018
  } else if (distribution === "EQUALLY") {
4935
6019
  const sides = ["NORTH", "SOUTH", "EAST", "WEST"] as const;
4936
6020
  const side = sides[loopIndex % sides.length]!;
4937
6021
  const indexOnSide = Math.floor(loopIndex / sides.length);
4938
6022
  const countOnSide = Math.ceil((loops.length - sides.indexOf(side)) / sides.length);
4939
- pointsByEdgeId.set(
4940
- edge.id,
6023
+ publishLoop(
4941
6024
  side === "NORTH" || side === "SOUTH"
4942
6025
  ? routeHorizontalSide(side, indexOnSide, countOnSide)
4943
6026
  : routeVerticalSide(side, indexOnSide, countOnSide),
4944
6027
  );
4945
6028
  } else {
4946
- pointsByEdgeId.set(edge.id, routeHorizontalSide("NORTH", loopIndex, loops.length));
6029
+ publishLoop(routeHorizontalSide("NORTH", loopIndex, loops.length));
4947
6030
  }
4948
6031
  continue;
4949
6032
  }
@@ -5047,7 +6130,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5047
6130
  ).length === 1;
5048
6131
  const flexibleFeedback =
5049
6132
  reversedEdge && !hasFixedPortSide(source) && !hasFixedPortSide(target);
5050
- const start = flexibleFeedback
6133
+ const unshiftedStart = flexibleFeedback
5051
6134
  ? sourceFallback
5052
6135
  : sourceFixedSide
5053
6136
  ? sourceFallback
@@ -5059,7 +6142,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5059
6142
  input.direction,
5060
6143
  input,
5061
6144
  );
5062
- const end = flexibleFeedback
6145
+ const unshiftedEnd = flexibleFeedback
5063
6146
  ? targetFallback
5064
6147
  : targetFixedSide
5065
6148
  ? targetFallback
@@ -5071,19 +6154,23 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5071
6154
  input.direction,
5072
6155
  input,
5073
6156
  );
6157
+ const start = attached(edge, "source", unshiftedStart);
6158
+ const end = attached(edge, "target", unshiftedEnd);
5074
6159
  const sourceLayer = flowLayerByNodeId.get(edge.sourceId) ?? 0;
5075
6160
  const targetLayer = flowLayerByNodeId.get(edge.targetId) ?? 0;
5076
6161
  const earlierLayer = Math.min(sourceLayer, targetLayer);
5077
6162
  const laterLayer = Math.max(sourceLayer, targetLayer);
5078
6163
  const earlier = flowLayers[earlierLayer];
5079
6164
  const later = flowLayers[laterLayer];
6165
+ // Inverted-port links clear the occupied layer, including protruding
6166
+ // ports and routing reservations, rather than only the two node bodies.
5080
6167
  const sameLayerTrack =
5081
6168
  sourceLayer === targetLayer
5082
- ? horizontal
5083
- ? Math.max(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) +
5084
- Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10)
5085
- : Math.max(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) +
5086
- Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10)
6169
+ ? (flowLayers[sourceLayer]?.end ??
6170
+ (horizontal
6171
+ ? Math.max(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width)
6172
+ : Math.max(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height))) +
6173
+ Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10)
5087
6174
  : undefined;
5088
6175
  const track =
5089
6176
  orthogonalTrackByEdgeId.get(edge.id) ??
@@ -5091,7 +6178,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5091
6178
  splineTrackByEdgeId.get(edge.id) ??
5092
6179
  sameLayerTrack ??
5093
6180
  (earlier && later && earlierLayer !== laterLayer
5094
- ? (earlier.end + later.start) / 2
6181
+ ? freeGapTrack(edge.id, earlier.end, later.start, start, end)
5095
6182
  : horizontal
5096
6183
  ? (start.x + end.x) / 2
5097
6184
  : (start.y + end.y) / 2);
@@ -5237,7 +6324,26 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5237
6324
  );
5238
6325
  }
5239
6326
 
5240
- return { pointsByEdgeId, splineNubControlsByEdgeId, outsideFeedbackEdgeIds };
6327
+ const junctionPointsByEdgeId =
6328
+ style === "ORTHOGONAL"
6329
+ ? orthogonalJunctionPoints(
6330
+ input,
6331
+ orientation,
6332
+ implicitEdgeEndpoints(input, routingCoordinates(placement), orientation),
6333
+ flowLayerByNodeId,
6334
+ junctionGroups,
6335
+ pointsByEdgeId,
6336
+ orthogonalTrackByEdgeId,
6337
+ orthogonalDetourByEdgeId,
6338
+ (node, port) => getOrientedPortDirection(input, orientation, node, port),
6339
+ )
6340
+ : undefined;
6341
+ return {
6342
+ pointsByEdgeId,
6343
+ splineNubControlsByEdgeId,
6344
+ outsideFeedbackEdgeIds,
6345
+ ...(style === "ORTHOGONAL" ? { junctionPointsByEdgeId } : {}),
6346
+ };
5241
6347
  };
5242
6348
  }
5243
6349
 
@@ -5329,11 +6435,26 @@ export function placePorts<P>(
5329
6435
  return { ...port, ...size, x: port.x, y: port.y };
5330
6436
  }
5331
6437
  const side = sideByPort.get(port)!;
6438
+ const borderOffset = Number(portSettings?.(port)?.["port.borderOffset"] ?? 0);
5332
6439
  return {
5333
6440
  ...port,
5334
6441
  ...size,
5335
- x: side === "EAST" ? rect.width : side === "WEST" ? -size.width : port.x,
5336
- y: side === "SOUTH" ? rect.height : side === "NORTH" ? -size.height : port.y,
6442
+ x:
6443
+ side === "EAST"
6444
+ ? rect.width + borderOffset
6445
+ : side === "WEST"
6446
+ ? constraints === "FIXED_POS" && direction === "left" && size.width === 0
6447
+ ? 0 - borderOffset
6448
+ : -size.width - borderOffset
6449
+ : port.x,
6450
+ y:
6451
+ side === "SOUTH"
6452
+ ? rect.height + borderOffset
6453
+ : side === "NORTH"
6454
+ ? constraints === "FIXED_POS" && direction === "up" && size.height === 0
6455
+ ? 0 - borderOffset
6456
+ : -size.height - borderOffset
6457
+ : port.y,
5337
6458
  };
5338
6459
  }
5339
6460
  const side = sideByPort.get(port)!;
@@ -5549,8 +6670,8 @@ export function normalizePlacementForPortExtents(
5549
6670
  }
5550
6671
  const nextLayer = physicalLayers[layerIndex + 1];
5551
6672
  if (!nextLayer) continue;
5552
- let trailingExtent = 0;
5553
- let leadingExtent = 0;
6673
+ let trailingBoundary = Number.NEGATIVE_INFINITY;
6674
+ let leadingBoundary = Number.POSITIVE_INFINITY;
5554
6675
  for (const [ids, trailing] of [
5555
6676
  [layer, true],
5556
6677
  [nextLayer, false],
@@ -5559,6 +6680,10 @@ export function normalizePlacementForPortExtents(
5559
6680
  const node = input.graph.nodes.find((candidate) => candidate.id === id);
5560
6681
  const rect = placement.rectByNodeId.get(id);
5561
6682
  if (!node || !rect) continue;
6683
+ const bodyStart = horizontal ? rect.x : rect.y;
6684
+ const bodySize = horizontal ? rect.width : rect.height;
6685
+ if (trailing) trailingBoundary = Math.max(trailingBoundary, bodyStart + bodySize);
6686
+ else leadingBoundary = Math.min(leadingBoundary, bodyStart);
5562
6687
  const ports = placePorts(
5563
6688
  node.ports,
5564
6689
  rect,
@@ -5575,21 +6700,37 @@ export function normalizePlacementForPortExtents(
5575
6700
  : trailing
5576
6701
  ? Math.max(0, (port.y ?? 0) + (port.height ?? 0) - rect.height)
5577
6702
  : Math.max(0, -(port.y ?? 0));
5578
- if (trailing) trailingExtent = Math.max(trailingExtent, extent);
5579
- else leadingExtent = Math.max(leadingExtent, extent);
6703
+ if (trailing)
6704
+ trailingBoundary = Math.max(trailingBoundary, bodyStart + bodySize + extent);
6705
+ else leadingBoundary = Math.min(leadingBoundary, bodyStart - extent);
5580
6706
  }
5581
6707
  }
5582
6708
  }
5583
- accumulatedShift += trailingExtent + leadingExtent;
6709
+ accumulatedShift += Math.max(
6710
+ 0,
6711
+ input.spacing.layer - (leadingBoundary + accumulatedShift - trailingBoundary),
6712
+ );
5584
6713
  }
5585
6714
 
5586
6715
  let minimumX = Number.POSITIVE_INFINITY;
5587
6716
  let minimumY = Number.POSITIVE_INFINITY;
6717
+ const envelopes = loopEnvelopes(input);
6718
+ const reverse = input.direction === "left" || input.direction === "up";
5588
6719
  for (const node of input.graph.nodes) {
5589
6720
  const rect = placement.rectByNodeId.get(node.id);
5590
6721
  if (!rect) continue;
5591
- minimumX = Math.min(minimumX, rect.x);
5592
- minimumY = Math.min(minimumY, rect.y);
6722
+ const envelope = hasPlacementLoopEnvelope(placement, node.id)
6723
+ ? envelopes.get(node.id)
6724
+ : undefined;
6725
+ const flowBefore = reverse ? envelope?.flowAfter : envelope?.flowBefore;
6726
+ minimumX = Math.min(
6727
+ minimumX,
6728
+ rect.x - (horizontal ? (flowBefore ?? 0) : (envelope?.before ?? 0)),
6729
+ );
6730
+ minimumY = Math.min(
6731
+ minimumY,
6732
+ rect.y - (horizontal ? (envelope?.before ?? 0) : (flowBefore ?? 0)),
6733
+ );
5593
6734
  const ports = placePorts(
5594
6735
  node.ports,
5595
6736
  rect,