@statelyai/layout 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +150 -11
  3. package/dist/elkjs/index.cjs +2 -2
  4. package/dist/elkjs/index.d.cts +2 -0
  5. package/dist/elkjs/index.d.mts +2 -0
  6. package/dist/elkjs/index.mjs +2 -2
  7. package/dist/elkjs/main.cjs +1 -1
  8. package/dist/elkjs/main.mjs +1 -1
  9. package/dist/elkjs/worker.cjs +2 -2
  10. package/dist/elkjs/worker.mjs +2 -2
  11. package/dist/elkjs-BBfKN4Dw.mjs +3075 -0
  12. package/dist/elkjs-DNfXDPup.cjs +3104 -0
  13. package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
  14. package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
  15. package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
  16. package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
  17. package/dist/index.cjs +6 -3
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.mts +3 -3
  20. package/dist/index.mjs +4 -4
  21. package/dist/layered/index.cjs +3 -1
  22. package/dist/layered/index.d.cts +2 -2
  23. package/dist/layered/index.d.mts +2 -2
  24. package/dist/layered/index.mjs +2 -2
  25. package/dist/{layered-B2buJqMf.cjs → layered-5RxQhryb.cjs} +11184 -2932
  26. package/dist/{layered-DGFTeQSl.mjs → layered-DSesbctm.mjs} +12059 -3903
  27. package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-CLFySz3R.cjs} +5 -18
  28. package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-GpBIX6ng.mjs} +3 -10
  29. package/dist/routing/index.cjs +2 -1
  30. package/dist/routing/index.d.cts +2 -2
  31. package/dist/routing/index.d.mts +2 -2
  32. package/dist/routing/index.mjs +2 -2
  33. package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
  34. package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
  35. package/package.json +8 -2
  36. package/src/authoring/coordinates.ts +28 -8
  37. package/src/authoring/partial.ts +7 -2
  38. package/src/authoring/routing.ts +4 -1
  39. package/src/constraints.ts +174 -6
  40. package/src/elkjs/index.ts +1476 -449
  41. package/src/elkjs/layout-quality.ts +267 -0
  42. package/src/elkjs/opposing-tracks.ts +161 -0
  43. package/src/elkjs/orthogonal-junctions.ts +174 -0
  44. package/src/elkjs/statechart.ts +9 -3
  45. package/src/elkjs/types.ts +2 -0
  46. package/src/index.ts +2 -0
  47. package/src/internal/elkjs-compatibility.ts +4 -1
  48. package/src/internal/layered-trace.ts +48 -0
  49. package/src/internal/layout-engine.ts +2 -1
  50. package/src/java-random.ts +6 -0
  51. package/src/layered/bk-node-placement.ts +403 -92
  52. package/src/layered/center-labels.ts +398 -0
  53. package/src/layered/compaction-bounds.ts +26 -0
  54. package/src/layered/compaction-errors.ts +2 -0
  55. package/src/layered/compaction-scanline.ts +63 -0
  56. package/src/layered/compound-boundaries.ts +99 -0
  57. package/src/layered/compound.ts +35 -7
  58. package/src/layered/constraint-orientation.ts +141 -0
  59. package/src/layered/crossing-constraints.ts +243 -0
  60. package/src/layered/crossing-counter.ts +233 -0
  61. package/src/layered/crossing-graph.ts +401 -0
  62. package/src/layered/cycle-random.ts +22 -0
  63. package/src/layered/elk-port-lists.ts +243 -0
  64. package/src/layered/end-labels.ts +124 -0
  65. package/src/layered/external-port-dummy.ts +137 -0
  66. package/src/layered/feedback-chains.ts +114 -0
  67. package/src/layered/fixed-self-loop.ts +150 -0
  68. package/src/layered/greedy-switch-decider.ts +356 -0
  69. package/src/layered/grouped-compaction.ts +787 -0
  70. package/src/layered/gwt-sort.ts +53 -0
  71. package/src/layered/hierarchical-port-phases.ts +242 -0
  72. package/src/layered/hierarchical-port-restoration.ts +233 -0
  73. package/src/layered/hierarchical-port-routing.ts +179 -0
  74. package/src/layered/hierarchy-crossing.ts +121 -0
  75. package/src/layered/hierarchy-sweepiness.ts +79 -0
  76. package/src/layered/hints.ts +371 -0
  77. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  78. package/src/layered/index.ts +875 -161
  79. package/src/layered/initial-model-order.ts +404 -0
  80. package/src/layered/inverted-ports.ts +207 -0
  81. package/src/layered/label-dummy-switcher.ts +200 -0
  82. package/src/layered/long-edges.ts +135 -44
  83. package/src/layered/loop-envelopes.ts +184 -0
  84. package/src/layered/native-routing.ts +275 -0
  85. package/src/layered/network-simplex.ts +11 -15
  86. package/src/layered/node-margins.ts +59 -0
  87. package/src/layered/north-south-ports.ts +271 -0
  88. package/src/layered/opposing-tracks.ts +1044 -0
  89. package/src/layered/orthogonal-cycle-order.ts +106 -0
  90. package/src/layered/orthogonal-hypersegments.ts +154 -0
  91. package/src/layered/orthogonal-junctions.ts +202 -0
  92. package/src/layered/orthogonal-segments.ts +243 -0
  93. package/src/layered/placement-orientation.ts +22 -0
  94. package/src/layered/port-distributor.ts +192 -0
  95. package/src/layered/port-edge-order.ts +20 -0
  96. package/src/layered/replace-routing.ts +45 -0
  97. package/src/layered/routing-coordinates.ts +32 -0
  98. package/src/layered/separate-exterior-labels.ts +24 -6
  99. package/src/layered/spacing.ts +34 -12
  100. package/src/layered/splitter-order.ts +106 -0
  101. package/src/layered/strategies.ts +2124 -886
  102. package/src/layered/types.ts +13 -0
  103. package/src/layered/weighted-compaction.ts +84 -0
  104. package/src/routing/adapters.ts +32 -0
  105. package/src/routing/algorithms.ts +433 -21
  106. package/src/routing/coordination.ts +8 -3
  107. package/src/routing/index.ts +1 -0
  108. package/src/routing/search.ts +93 -11
  109. package/dist/elkjs-DaHMISnQ.mjs +0 -1677
  110. package/dist/elkjs-dMghiiWW.cjs +0 -1706
@@ -1,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, type CrossingGraph } 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,
@@ -79,6 +124,24 @@ function cycleModelOrder(input: LayeredPhaseInput): Map<string, number> {
79
124
  );
80
125
  }
81
126
 
127
+ /**
128
+ * Internal setting: how crossing minimization models ports and tracks.
129
+ * `mixedPorts` (default true) counts a port carrying both directions as two
130
+ * ports and groups its neighbours by direction. `separateSharedTracks`
131
+ * (default false) counts a hyperedge joining two or more ports on each side
132
+ * of its gap edge by edge, since routing never lets edges run opposite ways
133
+ * along one track. A `probe` records whether either choice can change the
134
+ * order. Each helps on average but steers the sweeps into worse orders on
135
+ * some graphs, so the elkjs facade also lays such graphs out with the
136
+ * alternatives and keeps the best result.
137
+ */
138
+ export const CROSSING_MODEL = "internal.crossingModel";
139
+ export interface CrossingModel {
140
+ mixedPorts?: boolean;
141
+ separateSharedTracks?: boolean;
142
+ probe?: { mixedPorts: boolean; sharedTracks: boolean };
143
+ }
144
+
82
145
  export const breakCyclesWithDepthFirstSearch: CycleBreaker = (input) => {
83
146
  const outgoing = new Map<string, GraphEdge[]>();
84
147
  for (const node of input.graph.nodes) outgoing.set(node.id, []);
@@ -243,6 +306,7 @@ function breakCyclesWithGreedyHeuristic(
243
306
  if (source === undefined || target === undefined) continue;
244
307
  if ((marks[source] ?? 0) > (marks[target] ?? 0)) reversedEdgeIds.add(edge.id);
245
308
  }
309
+ recordCycleRandom(input, random);
246
310
  return { reversedEdgeIds };
247
311
  }
248
312
 
@@ -252,9 +316,39 @@ export const breakCyclesGreedily: CycleBreaker = (input) =>
252
316
  export const breakCyclesGreedilyByModelOrder: CycleBreaker = (input) =>
253
317
  breakCyclesWithGreedyHeuristic(input, "model-order");
254
318
 
255
- /** ELK MODEL_ORDER for flat graphs without FIRST/LAST layer constraints. */
319
+ /**
320
+ * A zero-size port on a FIXED_SIDE node used by exactly one edge has no
321
+ * physical extent, so its terminal takes a slot in its side's shared endpoint order.
322
+ */
323
+ export function usesSharedSideSlot(
324
+ input: LayeredPhaseInput,
325
+ node: GraphNode,
326
+ portName: string | undefined,
327
+ ): boolean {
328
+ const port = node.ports?.find((candidate) => candidate.name === portName);
329
+ return (
330
+ port !== undefined &&
331
+ (port.width ?? 8) === 0 &&
332
+ (port.height ?? 8) === 0 &&
333
+ input.nodeSettings?.(node)?.portConstraints === "FIXED_SIDE" &&
334
+ input.graph.edges.filter(
335
+ (edge) =>
336
+ (edge.sourceId === node.id && edge.sourcePort === portName) ||
337
+ (edge.targetId === node.id && edge.targetPort === portName),
338
+ ).length === 1
339
+ );
340
+ }
341
+
342
+ /** ELK MODEL_ORDER: layer-constrained nodes (e.g. external port dummies) order before/after the rest. */
256
343
  export const breakCyclesByModelOrder: CycleBreaker = (input) => {
257
344
  const order = cycleModelOrder(input);
345
+ const offset = Math.max(1, input.graph.nodes.length);
346
+ const shift = { FIRST_SEPARATE: -2, FIRST: -1, LAST: 1, LAST_SEPARATE: 2 } as const;
347
+ for (const node of input.graph.nodes) {
348
+ const constraint = input.nodeSettings?.(node)?.["layering.layerConstraint"];
349
+ const factor = shift[constraint as keyof typeof shift];
350
+ if (factor) order.set(node.id, factor * offset + order.get(node.id)!);
351
+ }
258
352
  return {
259
353
  reversedEdgeIds: new Set(
260
354
  input.graph.edges
@@ -668,83 +762,6 @@ export function applyLayerConstraints(
668
762
  };
669
763
  }
670
764
 
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
765
  /** Keep activated ELK partitions in ascending, contiguous layer blocks. */
749
766
  export function applyPartitions(
750
767
  input: LayeredPhaseInput,
@@ -819,14 +836,7 @@ export function applyLayerConstraintOrder(input: LayeredPhaseInput, order: Layer
819
836
  ...order,
820
837
  layers: order.layers.map((layer) => {
821
838
  const unconstrained = layer.filter((id) => !constrained.has(id));
822
- const mergedOrder =
823
- input.settings.mergeEdges === true
824
- ? [
825
- ...unconstrained.filter((id) => id.startsWith("__layout_dummy:")),
826
- ...unconstrained.filter((id) => !id.startsWith("__layout_dummy:")),
827
- ]
828
- : unconstrained;
829
- return [...mergedOrder, ...layer.filter((id) => constrained.has(id))];
839
+ return [...unconstrained, ...layer.filter((id) => constrained.has(id))];
830
840
  }),
831
841
  };
832
842
  }
@@ -911,56 +921,112 @@ export function applyGreedySwitch(
911
921
  ): LayerOrder {
912
922
  if (input.settings["crossingMinimization.strategy"] === "INTERACTIVE") return order;
913
923
  if (input.settings["crossingMinimization.semiInteractive"] === true) return order;
924
+ const type = input.settings["crossingMinimization.greedySwitch.type"] ?? "TWO_SIDED";
925
+ const threshold = input.settings["crossingMinimization.greedySwitch.activationThreshold"] ?? 40;
926
+ const originalNodeCount = input.graph.nodes.filter(
927
+ (node) => !node.id.startsWith("__layout_dummy:"),
928
+ ).length;
929
+ if (type === "OFF" || (threshold !== 0 && threshold <= originalNodeCount)) return order;
930
+ if (order.layers.length === 0 || (order.layers.length === 1 && order.layers[0]?.length === 1))
931
+ return order;
932
+ const random = phaseRandomByInput.get(input) ?? crossingRandom(input);
933
+ // ELK initializes another layer-sweep processor for greedy switching. It
934
+ // consumes a saved seed, chooses a distributor for ONE_SIDED, then draws
935
+ // the sweep direction. Routing continues with this same graph RNG state.
936
+ random.nextLong();
937
+ if (type === "ONE_SIDED") random.nextBoolean();
938
+ let forward = random.nextBoolean();
939
+ phaseRandomByInput.set(input, random);
914
940
  const modelOrderStrategy = input.settings["considerModelOrder.strategy"] ?? "NONE";
915
941
  if (
916
942
  (modelOrderStrategy === "NODES_AND_EDGES" || modelOrderStrategy === "PREFER_NODES") &&
917
- (input.settings["crossingMinimization.forceNodeModelOrder"] === true ||
918
- Number(input.settings["considerModelOrder.crossingCounterNodeInfluence"] ?? 0) >= 1)
943
+ Number(input.settings["considerModelOrder.crossingCounterNodeInfluence"] ?? 0) >= 1
919
944
  ) {
920
945
  return order;
921
946
  }
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
947
  const layers = order.layers.map((layer) => [...layer]);
928
948
  const layerByNodeId = new Map<string, number>();
929
949
  for (const [layerIndex, layer] of layers.entries()) {
930
950
  for (const id of layer) layerByNodeId.set(id, layerIndex);
931
951
  }
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
- }
952
+ const greedyGraph =
953
+ order.inputPortOrderByNodeId && order.outputPortOrderByNodeId
954
+ ? crossingGraph(
955
+ input,
956
+ orientation,
957
+ layers,
958
+ order.inputPortOrderByNodeId,
959
+ order.outputPortOrderByNodeId,
960
+ )
961
+ : undefined;
962
+ const greedyNodes = new Map(greedyGraph?.layers.flat().map((node) => [node.id, node]));
963
+ if (greedyGraph) assertNorthSouthPortSides(greedyGraph);
964
+ const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
965
+ const portPositions = (layer: readonly string[], incoming: boolean) => {
966
+ const positions = new Map<string, number>();
967
+ let consumed = 0;
968
+ for (const nodeId of layer) {
969
+ const selected = (
970
+ incoming ? order.inputPortOrderByNodeId : order.outputPortOrderByNodeId
971
+ )?.get(nodeId);
972
+ const edgeIds =
973
+ selected ??
974
+ input.graph.edges
975
+ .filter((edge) => {
976
+ const [source, target] = getOrientedEndpoints(edge, orientation);
977
+ return source !== target && (incoming ? target : source) === nodeId;
978
+ })
979
+ .map((edge) => edge.id);
980
+ // WEST ports occur clockwise bottom-to-top; greedy crossing counters
981
+ // enumerate both neighboring faces in physical top-to-bottom order.
982
+ const physical = incoming ? [...edgeIds].reverse() : edgeIds;
983
+ const shared = new Map<string, number>();
984
+ for (const edgeId of physical) {
985
+ const edge = edgeById.get(edgeId)!;
986
+ const reversed = orientation.reversedEdgeIds.has(edgeId);
987
+ const port = incoming !== reversed ? edge.targetPort : edge.sourcePort;
988
+ if (port !== undefined && shared.has(port)) {
989
+ positions.set(edgeId, shared.get(port)!);
990
+ } else {
991
+ positions.set(edgeId, consumed);
992
+ if (port !== undefined) shared.set(port, consumed);
993
+ consumed++;
994
+ }
995
+ }
996
+ // Preserve the old node-level fallback for custom minimizers that do
997
+ // not provide selected port orders.
998
+ if (!selected) {
999
+ for (const edgeId of edgeIds) positions.set(edgeId, consumed);
1000
+ consumed++;
1001
+ }
1002
+ }
1003
+ return positions;
1004
+ };
940
1005
  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
- );
1006
+ const sourcePositions = portPositions(layers[boundary] ?? [], false);
1007
+ const targetPositions = portPositions(layers[boundary + 1] ?? [], true);
1008
+ const edges = input.graph.edges.filter((edge) => {
1009
+ const [source, target] = getOrientedEndpoints(edge, orientation);
1010
+ return layerByNodeId.get(source) === boundary && layerByNodeId.get(target) === boundary + 1;
1011
+ });
948
1012
  let crossings = 0;
949
1013
  for (let left = 0; left < edges.length; left++) {
950
- const leftEdge = edges[left]!;
951
1014
  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
1015
  const sourceDifference =
955
- (sourcePositions.get(leftEdge[0]!) ?? 0) - (sourcePositions.get(rightEdge[0]!) ?? 0);
1016
+ sourcePositions.get(edges[left]!.id)! - sourcePositions.get(edges[right]!.id)!;
956
1017
  const targetDifference =
957
- (targetPositions.get(leftEdge[1]!) ?? 0) - (targetPositions.get(rightEdge[1]!) ?? 0);
1018
+ targetPositions.get(edges[left]!.id)! - targetPositions.get(edges[right]!.id)!;
958
1019
  if (sourceDifference * targetDifference < 0) crossings++;
959
1020
  }
960
1021
  }
961
1022
  return crossings;
962
1023
  };
963
1024
  const countForLayer = (layerIndex: number, oneSidedBoundary?: number) => {
1025
+ // Greedy swapping counts edge pairs, rather than the sweep's hyperedge estimate.
1026
+ if (oneSidedBoundary === undefined && greedyGraph) {
1027
+ greedyGraph.layers = layers.map((layer) => layer.map((id) => greedyNodes.get(id)!));
1028
+ return countAllCrossings(greedyGraph, "edges").total;
1029
+ }
964
1030
  if (oneSidedBoundary !== undefined) return countBoundary(oneSidedBoundary);
965
1031
  return (
966
1032
  (layerIndex > 0 ? countBoundary(layerIndex - 1) : 0) +
@@ -975,6 +1041,31 @@ export function applyGreedySwitch(
975
1041
  for (const layerIndex of indices) {
976
1042
  const layer = layers[layerIndex]!;
977
1043
  let improved: boolean;
1044
+ if (greedyGraph) {
1045
+ // ELK decides each switch from local two-node crossing counts.
1046
+ const units = getCrossingUnits(input);
1047
+ greedyGraph.layers = layers.map((ids) => ids.map((id) => greedyNodes.get(id)!));
1048
+ const decider = new GreedySwitchDecider(
1049
+ greedyGraph,
1050
+ layerIndex,
1051
+ type === "ONE_SIDED" ? (forward ? "WEST" : "EAST") : undefined,
1052
+ );
1053
+ do {
1054
+ improved = false;
1055
+ for (let index = 0; index + 1 < layer.length; index++) {
1056
+ const upper = greedyNodes.get(layer[index]!)!,
1057
+ lower = greedyNodes.get(layer[index + 1]!)!;
1058
+ if (decider.preventsSwitch(upper, lower, units?.successors ?? new Map())) continue;
1059
+ const [current, switched] = decider.crossings(upper, lower);
1060
+ if (current <= switched) continue;
1061
+ decider.notifySwitch(upper, lower);
1062
+ [layer[index], layer[index + 1]] = [layer[index + 1]!, layer[index]!];
1063
+ improved = true;
1064
+ changed = true;
1065
+ }
1066
+ } while (improved && layerIndex !== indices[0]);
1067
+ continue;
1068
+ }
978
1069
  do {
979
1070
  improved = false;
980
1071
  for (let index = 0; index + 1 < layer.length; index++) {
@@ -986,6 +1077,8 @@ export function applyGreedySwitch(
986
1077
  ) {
987
1078
  continue;
988
1079
  }
1080
+ const units = getCrossingUnits(input);
1081
+ if (units && !canSwapCrossingUnits(layer[index]!, layer[index + 1]!, units)) continue;
989
1082
  const before = countForLayer(layerIndex, oneSidedBoundary);
990
1083
  [layer[index], layer[index + 1]] = [layer[index + 1]!, layer[index]!];
991
1084
  const after = countForLayer(layerIndex, oneSidedBoundary);
@@ -996,13 +1089,56 @@ export function applyGreedySwitch(
996
1089
  [layer[index], layer[index + 1]] = [layer[index + 1]!, layer[index]!];
997
1090
  }
998
1091
  }
999
- } while (improved);
1092
+ // ELK scans the starting layer once, then converges each later layer.
1093
+ // Repeating the start immediately changes the following layers' local optimum.
1094
+ } while (improved && layerIndex !== indices[0]);
1000
1095
  }
1001
1096
  return changed;
1002
1097
  };
1003
- while (sweep(true) || sweep(false)) {
1004
- // Repeat until every adjacent exchange is locally optimal.
1098
+ const trace = (crossings: number) =>
1099
+ traceLayeredPhase(() => ({
1100
+ kind: "sweep",
1101
+ scope: input.graph.id,
1102
+ attempt: -1,
1103
+ forward,
1104
+ firstSweep: false,
1105
+ crossings,
1106
+ layers,
1107
+ }));
1108
+ if (type === "ONE_SIDED") {
1109
+ // ONE_SIDED does not always improve (opposite sweeps can undo each other),
1110
+ // so ELK's minimizeCrossingsWithCounter alternates sweeps only while the
1111
+ // total crossing count strictly drops, keeping the order before the last sweep.
1112
+ const countAll = () => {
1113
+ if (greedyGraph) return countForLayer(0);
1114
+ let total = 0;
1115
+ for (let boundary = 0; boundary + 1 < layers.length; boundary++)
1116
+ total += countBoundary(boundary);
1117
+ return total;
1118
+ };
1119
+ sweep(forward);
1120
+ let crossings = countAll();
1121
+ trace(crossings);
1122
+ while (crossings > 0) {
1123
+ const previous = layers.map((layer) => [...layer]);
1124
+ forward = !forward;
1125
+ sweep(forward);
1126
+ const next = countAll();
1127
+ trace(next);
1128
+ if (next >= crossings) {
1129
+ for (const [index, layer] of previous.entries()) layers[index] = layer;
1130
+ break;
1131
+ }
1132
+ crossings = next;
1133
+ }
1134
+ return { ...order, layers };
1005
1135
  }
1136
+ let improved: boolean;
1137
+ do {
1138
+ improved = sweep(forward);
1139
+ trace(-1);
1140
+ forward = !forward;
1141
+ } while (improved);
1006
1142
  return { ...order, layers };
1007
1143
  }
1008
1144
 
@@ -1053,7 +1189,7 @@ export function applyForcedModelOrder(
1053
1189
  order: LayerOrder,
1054
1190
  ): LayerOrder {
1055
1191
  const strategy = input.settings["considerModelOrder.strategy"] ?? "NONE";
1056
- if (strategy === "NONE") return order;
1192
+ if (strategy === "NONE" || modelSweepInputs.has(input)) return order;
1057
1193
  const forceNodeOrder =
1058
1194
  input.settings["crossingMinimization.forceNodeModelOrder"] === true &&
1059
1195
  (strategy === "NODES_AND_EDGES" || strategy === "PREFER_NODES");
@@ -1191,10 +1327,48 @@ export function applyForcedModelOrder(
1191
1327
  }
1192
1328
  }
1193
1329
  }
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 };
1330
+ // Node reordering invalidates flexible physical-port ranks. Reconcile them
1331
+ // using the same distributor and rank convention as the preceding sweep;
1332
+ // retain fixed boundary orders for the canonical greedy crossing counter.
1333
+ const incoming = clonePortOrders(order.inputPortOrderByNodeId);
1334
+ const outgoing = clonePortOrders(order.outputPortOrderByNodeId);
1335
+ const graph = crossingGraph(input, orientation, layers, incoming, outgoing);
1336
+ const distributor = new CanonicalPortDistributor(graph);
1337
+ const fixedOrder = new Set(
1338
+ input.graph.nodes
1339
+ .filter((node) => {
1340
+ const constraint = input.nodeSettings?.(node)?.portConstraints;
1341
+ return (
1342
+ constraint === "FIXED_ORDER" || constraint === "FIXED_POS" || constraint === "FIXED_RATIO"
1343
+ );
1344
+ })
1345
+ .map((node) => node.id),
1346
+ );
1347
+ for (let layer = layers.length - 1; layer >= 0; layer--) {
1348
+ distributor.distribute(graph, layer, false, {
1349
+ nodeRelative: nodeRelativePortRanksByInput.get(input) ?? true,
1350
+ fixedOrder,
1351
+ hierarchical: new Set(),
1352
+ });
1353
+ }
1354
+ const edges = input.graph.edges.filter((edge) => edge.sourceId !== edge.targetId);
1355
+ const endpoints = new Map(edges.map((edge, index) => [edge.id, graph.edges[index]!]));
1356
+ for (const node of graph.layers.flat()) {
1357
+ const ordinal = new Map(node.ports.map((port, index) => [port.id, index]));
1358
+ for (const [orders, source] of [
1359
+ [outgoing, true],
1360
+ [incoming, false],
1361
+ ] as const) {
1362
+ orders
1363
+ .get(node.id)
1364
+ ?.sort(
1365
+ (left, right) =>
1366
+ ordinal.get(endpoints.get(left)![source ? "source" : "target"])! -
1367
+ ordinal.get(endpoints.get(right)![source ? "source" : "target"])!,
1368
+ );
1369
+ }
1370
+ }
1371
+ return { ...order, layers, inputPortOrderByNodeId: incoming, outputPortOrderByNodeId: outgoing };
1198
1372
  }
1199
1373
 
1200
1374
  /** ELK LONGEST_PATH: align sinks on the final layer. */
@@ -1607,10 +1781,25 @@ function sortLayerByAdjacentPosition(
1607
1781
  preOrdered = true,
1608
1782
  sourceUnknownPlacement = true,
1609
1783
  perturbSummedWeight = true,
1784
+ units?: CrossingUnits,
1785
+ sameLayerNeighbors: ReadonlyMap<string, readonly string[]> = new Map(),
1786
+ orderedVisits?: ReadonlyMap<string, readonly (number | string)[]>,
1787
+ forcedCompare?: (left: string, right: string) => number,
1610
1788
  ): void {
1611
1789
  const originalIndex = new Map(layer.map((nodeId, index) => [nodeId, index] as const));
1612
1790
  const adjacentPosition = new Map<string, number | undefined>();
1613
- for (const nodeId of layer) {
1791
+ if (units && statistic === "mean") {
1792
+ for (const [id, score] of associatedBarycenters(
1793
+ layer,
1794
+ ranksByNodeId,
1795
+ sameLayerNeighbors,
1796
+ units.associates,
1797
+ random,
1798
+ orderedVisits,
1799
+ ))
1800
+ adjacentPosition.set(id, score);
1801
+ }
1802
+ for (const nodeId of units && statistic === "mean" ? [] : layer) {
1614
1803
  const positions = [...(ranksByNodeId.get(nodeId) ?? [])].sort((left, right) => left - right);
1615
1804
  if (positions.length === 0) {
1616
1805
  adjacentPosition.set(nodeId, undefined);
@@ -1671,174 +1860,374 @@ function sortLayerByAdjacentPosition(
1671
1860
  }
1672
1861
  }
1673
1862
 
1674
- layer.sort((a, b) => {
1863
+ const compare = (a: string, b: string) => {
1675
1864
  return (
1865
+ forcedCompare?.(a, b) ||
1676
1866
  (adjacentPosition.get(a) ?? 0) - (adjacentPosition.get(b) ?? 0) ||
1677
1867
  (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0)
1678
1868
  );
1679
- });
1869
+ };
1870
+ if (forcedCompare)
1871
+ insertionSort(
1872
+ layer,
1873
+ transitiveComparator((id: string) => id, compare),
1874
+ );
1875
+ else layer.sort(compare);
1876
+ if (units && !forcedCompare)
1877
+ layer.splice(0, layer.length, ...resolveCrossingConstraints(layer, adjacentPosition, units));
1878
+ }
1879
+
1880
+ type LayerSweepVisitor = (
1881
+ layerIndex: number,
1882
+ nodeIds: readonly string[],
1883
+ forward: boolean,
1884
+ firstSweep: boolean,
1885
+ ) => void;
1886
+
1887
+ /** @internal */
1888
+ export interface LayerSweepSession {
1889
+ /** Scope graph id, for development tracing. */
1890
+ readonly scope: string;
1891
+ readonly random: JavaRandom;
1892
+ readonly attempts: number;
1893
+ readonly restoreRejectedSweep: boolean;
1894
+ readonly usesInitialModelOrder?: boolean;
1895
+ /** Clear the scope flag after its first counter-based sweep. */
1896
+ finishInitialOrderAttempt?(): void;
1897
+ useRandom(random: JavaRandom): void;
1898
+ lockPortOrder(nodeId: string): void;
1899
+ markHierarchicalNode(nodeId: string): void;
1900
+ minimize(): LayerOrder;
1901
+ shuffleFirstLayer(forward: boolean): void;
1902
+ sweep(forward: boolean, firstSweep: boolean, visitLayer?: LayerSweepVisitor): void;
1903
+ countCrossings(): number;
1904
+ snapshot(): LayerOrder;
1905
+ restore(order: LayerOrder): void;
1906
+ finish(order: LayerOrder): LayerOrder;
1907
+ /** Live current layers; callers must not mutate them. */
1908
+ currentLayers(): readonly (readonly string[])[];
1909
+ /** Current canonical port ids of one node, as {@link snapshot} records them. */
1910
+ physicalPortOrder(nodeId: string): string[];
1911
+ /** Canonical crossing graph in the current order; callers must not mutate it. */
1912
+ crossingGraph(): CrossingGraph;
1913
+ /** Live incident edge orders of one node; callers must not mutate them. */
1914
+ edgeOrders(nodeId: string): { incoming: readonly string[]; outgoing: readonly string[] };
1915
+ /** Same as restoring a snapshot that differs only in one layer. */
1916
+ replaceLayer(index: number, nodeIds: readonly string[]): void;
1917
+ /** Same as restoring a snapshot that differs only in one node's port and edge orders. */
1918
+ reorderNodePorts(
1919
+ nodeId: string,
1920
+ physical: readonly string[],
1921
+ incoming: readonly string[],
1922
+ outgoing: readonly string[],
1923
+ ): void;
1924
+ }
1925
+
1926
+ function clonePortOrders(orders?: ReadonlyMap<string, readonly string[]>): Map<string, string[]> {
1927
+ return new Map([...(orders ?? [])].map(([id, edges]) => [id, [...edges]]));
1680
1928
  }
1681
1929
 
1682
1930
  function minimizeCrossingsWithLayerSweep(
1683
1931
  statistic: "mean" | "median",
1684
- sweeps = 4,
1932
+ sweeps = 7,
1685
1933
  ): 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);
1934
+ return (input, orientation, assignment) =>
1935
+ createLayerSweepSession(input, orientation, assignment, statistic, sweeps).minimize();
1936
+ }
1937
+
1938
+ /** Internal mutable sweep state shared by standalone and hierarchy coordination. */
1939
+ export function createLayerSweepSession(
1940
+ input: LayeredPhaseInput,
1941
+ orientation: AcyclicOrientation,
1942
+ assignment: LayerAssignment,
1943
+ statistic: "mean" | "median" = "mean",
1944
+ sweeps = 7,
1945
+ initialization: { resetSeed?: boolean } = {},
1946
+ ): LayerSweepSession {
1947
+ const exactPortSweep =
1948
+ (input.settings["wrapping.strategy"] ?? "OFF") === "OFF" &&
1949
+ (input.settings["layerUnzipping.strategy"] ?? "NONE") === "NONE";
1950
+ let maximumLayer = 0;
1951
+ for (const layer of assignment.layerByNodeId.values()) {
1952
+ maximumLayer = Math.max(maximumLayer, layer);
1953
+ }
1954
+ const layerCount = maximumLayer + 1;
1955
+ const layers = Array.from({ length: layerCount }, () => [] as string[]);
1956
+ const initialNodes = assignment.seedOrder
1957
+ ? completeSeedOrder(input, assignment)
1958
+ : (input.settings["layering.strategy"] ?? "NETWORK_SIMPLEX") === "NETWORK_SIMPLEX"
1959
+ ? networkSimplexComponentOrder(input)
1960
+ : input.graph.nodes.map((node) => node.id);
1961
+ for (const nodeId of initialNodes) {
1962
+ layers[assignment.layerByNodeId.get(nodeId) ?? 0]?.push(nodeId);
1963
+ }
1964
+ const units = getCrossingUnits(input);
1965
+ // Edges, orientation and layer assignment stay fixed for the session.
1966
+ const sameLayerNeighborsByDirection = new Map<boolean, Map<string, string[]>>();
1967
+ const sameLayerNeighbors = (forward: boolean) => {
1968
+ let cached = sameLayerNeighborsByDirection.get(forward);
1969
+ if (!cached)
1970
+ sameLayerNeighborsByDirection.set(forward, (cached = findSameLayerNeighbors(forward)));
1971
+ return cached;
1972
+ };
1973
+ const findSameLayerNeighbors = (forward: boolean) => {
1974
+ const neighbors = new Map<string, string[]>();
1975
+ for (const edge of input.graph.edges) {
1976
+ const [source, target] = getOrientedEndpoints(edge, orientation);
1977
+ if (
1978
+ source !== target &&
1979
+ assignment.layerByNodeId.get(source) === assignment.layerByNodeId.get(target)
1980
+ ) {
1981
+ const free = forward ? target : source,
1982
+ fixed = forward ? source : target;
1983
+ neighbors.set(free, [...(neighbors.get(free) ?? []), fixed]);
1984
+ }
1695
1985
  }
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);
1986
+ return neighbors;
1987
+ };
1988
+ const inputPortOrder = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
1989
+ const outputPortOrder = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
1990
+ for (const edge of input.graph.edges) {
1991
+ const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
1992
+ // ELK detaches self-loop edges before crossing minimization, then restores
1993
+ // them afterward. Their connectivity must not consume sweep port ranks.
1994
+ if (sourceId === targetId) continue;
1995
+ outputPortOrder.get(sourceId)?.push(edge.id);
1996
+ inputPortOrder.get(targetId)?.push(edge.id);
1997
+ }
1998
+ const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
1999
+ const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
2000
+ // ELK lists a compound's boundary ports in creation order: edges on them
2001
+ // take their slots in that order, in model order within a port. Authored
2002
+ // ports already arrive in ELK's sorted order and keep their edge order.
2003
+ const boundaryPortIndex = (nodeId: string, edgeId: string, incoming: boolean) => {
2004
+ const edge = edgeById.get(edgeId)!;
2005
+ const name =
2006
+ incoming !== orientation.reversedEdgeIds.has(edgeId) ? edge.targetPort : edge.sourcePort;
2007
+ if (!name?.startsWith("__native_hierarchy")) return -1;
2008
+ return nodeById.get(nodeId)!.ports?.findIndex((port) => port.name === name) ?? -1;
2009
+ };
2010
+ for (const [orders, incoming] of [
2011
+ [outputPortOrder, false],
2012
+ [inputPortOrder, true],
2013
+ ] as const)
2014
+ for (const [nodeId, edgeIds] of orders) {
2015
+ if (edgeIds.length < 2) continue;
2016
+ const slots = edgeIds.flatMap((id, slot) =>
2017
+ boundaryPortIndex(nodeId, id, incoming) < 0 ? [] : [slot],
2018
+ );
2019
+ const sorted = slots
2020
+ .map((slot) => edgeIds[slot]!)
2021
+ .sort(
2022
+ (a, b) => boundaryPortIndex(nodeId, a, incoming) - boundaryPortIndex(nodeId, b, incoming),
2023
+ );
2024
+ slots.forEach((slot, index) => (edgeIds[slot] = sorted[index]!));
1705
2025
  }
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[]]));
1708
- 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);
2026
+ const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
2027
+ const hierarchicalNodes = new Set<string>();
2028
+ const fixedOrderNodes = new Set(
2029
+ input.graph.nodes
2030
+ .filter((node) => {
2031
+ const constraint = input.nodeSettings?.(node)?.portConstraints;
2032
+ return !!node.ports?.length && (constraint === "FIXED_POS" || constraint === "FIXED_RATIO");
2033
+ })
2034
+ .map((node) => node.id),
2035
+ );
2036
+ const fixedPortRank = (nodeId: string, edgeId: string, incoming: boolean): number => {
2037
+ const node = nodeById.get(nodeId)!;
2038
+ const edge = edgeById.get(edgeId)!;
2039
+ const reversed = orientation.reversedEdgeIds.has(edge.id);
2040
+ const portName = incoming !== reversed ? edge.targetPort : edge.sourcePort;
2041
+ const port = node.ports?.find((candidate) => candidate.name === portName);
2042
+ const horizontal = input.direction === "right" || input.direction === "left";
2043
+ const reverse = input.direction === "left" || input.direction === "up";
2044
+ const size = input.sizes.get(nodeId)!;
2045
+ const width = horizontal ? size.width : size.height;
2046
+ const height = horizontal ? size.height : size.width;
2047
+ const x = (port?.x ?? size.width / 2) + (port?.width ?? 0) / 2;
2048
+ const y = (port?.y ?? size.height / 2) + (port?.height ?? 0) / 2;
2049
+ const flow = reverse ? width - (horizontal ? x : y) : horizontal ? x : y;
2050
+ const cross = horizontal ? y : x;
2051
+ const side = port && input.portSettings?.(port, node)?.["port.side"];
2052
+ const flowSide = horizontal ? (reverse ? "WEST" : "EAST") : reverse ? "NORTH" : "SOUTH";
2053
+ const backSide = horizontal ? (reverse ? "EAST" : "WEST") : reverse ? "SOUTH" : "NORTH";
2054
+ const upperSide = horizontal ? "NORTH" : "WEST";
2055
+ // Clockwise boundary order in the canonical rightward coordinate system.
2056
+ if (side === upperSide) return flow;
2057
+ if (side === flowSide || (!side && !incoming)) return width + cross;
2058
+ if (side === backSide || (!side && incoming)) return 2 * width + 2 * height - cross;
2059
+ return 2 * width + height - flow;
2060
+ };
2061
+ // Port distribution may reorder flexible ports, never fixed physical positions.
2062
+ for (const nodeId of fixedOrderNodes) {
2063
+ outputPortOrder
2064
+ .get(nodeId)
2065
+ ?.sort((a, b) => fixedPortRank(nodeId, a, false) - fixedPortRank(nodeId, b, false));
2066
+ inputPortOrder
2067
+ .get(nodeId)
2068
+ ?.sort((a, b) => fixedPortRank(nodeId, a, true) - fixedPortRank(nodeId, b, true));
2069
+ }
2070
+ if (exactPortSweep) {
2071
+ for (const [nodeId, edgeIds] of inputPortOrder) {
2072
+ if (fixedOrderNodes.has(nodeId)) continue;
2073
+ if (edgeIds.length >= 4) {
2074
+ edgeIds.splice(0, edgeIds.length - 1, ...edgeIds.slice(0, -1).reverse());
2075
+ }
1712
2076
  }
2077
+ }
2078
+ const buildCrossingGraph = createCrossingGraphBuilder(input);
2079
+ const crossingModel: CrossingModel =
2080
+ (input.settings as { [CROSSING_MODEL]?: CrossingModel })[CROSSING_MODEL] ?? {};
2081
+ const mixedPorts = crossingModel.mixedPorts !== false,
2082
+ probe = crossingModel.probe;
2083
+ const countCrossings = (candidateLayers: readonly (readonly string[])[]): number => {
1713
2084
  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());
2085
+ const graph = buildCrossingGraph(
2086
+ orientation,
2087
+ candidateLayers,
2088
+ inputPortOrder,
2089
+ outputPortOrder,
2090
+ );
2091
+ // Edge orders rank a side's ports per direction; a fixed-side port with
2092
+ // a reversed edge mixes both, so count the sweep's physical port order.
2093
+ for (const layer of graph.layers)
2094
+ for (const node of layer) {
2095
+ const physical = node.ports.length > 1 ? canonicalNodes.get(node.id)?.ports : undefined;
2096
+ if (physical?.length !== node.ports.length) continue;
2097
+ const slot = new Map(physical.map((port, index) => [port.id, index]));
2098
+ if (node.ports.every((port) => slot.has(port.id)))
2099
+ node.ports.sort((a, b) => slot.get(a.id)! - slot.get(b.id)!);
1717
2100
  }
2101
+ const split = mixedPorts ? splitMixedCrossingPorts(graph, canonicalReversed) : graph;
2102
+ const score = countAllCrossings(
2103
+ split,
2104
+ crossingModel.separateSharedTracks ? "separated" : "hyperedges",
2105
+ );
2106
+ if (probe) {
2107
+ if (split !== graph) probe.mixedPorts = true;
2108
+ if (score.sharedTracks) probe.sharedTracks = true;
1718
2109
  }
1719
- }
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;
2110
+ let total = score.total;
2111
+ const nodeInfluence = Number(
2112
+ input.settings["considerModelOrder.crossingCounterNodeInfluence"] ?? 0,
2113
+ );
2114
+ // ELK 0.11.1 tests node influence twice when selecting the weighted counter.
2115
+ // Port influence alone therefore does not enable its model-order objective.
2116
+ if (
2117
+ (input.settings["considerModelOrder.strategy"] ?? "NONE") !== "NONE" &&
2118
+ nodeInfluence !== 0
2119
+ ) {
2120
+ const changes = countModelOrderChanges(input, orientation, graph);
2121
+ total +=
2122
+ nodeInfluence * changes.nodeChanges +
2123
+ Number(input.settings["considerModelOrder.crossingCounterPortInfluence"] ?? 0) *
2124
+ changes.portChanges;
1746
2125
  }
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
- }
2126
+ return total;
2127
+ }
2128
+ const positions = new Map<string, number>();
2129
+ for (const layer of candidateLayers) {
2130
+ for (const [index, id] of layer.entries()) positions.set(id, index);
2131
+ }
2132
+ let crossings = 0;
2133
+ for (let left = 0; left < input.graph.edges.length; left++) {
2134
+ const [leftSource, leftTarget] = getOrientedEndpoints(input.graph.edges[left]!, orientation);
2135
+ for (let right = left + 1; right < input.graph.edges.length; right++) {
2136
+ const [rightSource, rightTarget] = getOrientedEndpoints(
2137
+ input.graph.edges[right]!,
2138
+ orientation,
2139
+ );
2140
+ if (leftSource === rightSource || leftTarget === rightTarget) continue;
2141
+ const sourceDifference =
2142
+ (positions.get(leftSource) ?? 0) - (positions.get(rightSource) ?? 0);
2143
+ const targetDifference =
2144
+ (positions.get(leftTarget) ?? 0) - (positions.get(rightTarget) ?? 0);
2145
+ if (sourceDifference * targetDifference < 0) crossings++;
1752
2146
  }
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++;
2147
+ }
2148
+ return crossings;
2149
+ };
2150
+
2151
+ const sharedRandom = crossingRandom(input);
2152
+ const randomSeed = initialization.resetSeed === false ? undefined : sharedRandom.nextLong();
2153
+ const portDistributorUsesNodeRelativeRanks = sharedRandom.nextBoolean();
2154
+ const nodeRelativePortRanks = portDistributorUsesNodeRelativeRanks;
2155
+ nodeRelativePortRanksByInput.set(input, nodeRelativePortRanks);
2156
+ // ELK treats the median heuristic as deterministic, so it keeps using the
2157
+ // graph's shared RNG after port-distributor selection. Barycenter resets to
2158
+ // the saved seed while comparing randomized layouts.
2159
+ let random =
2160
+ statistic === "median" || randomSeed === undefined ? sharedRandom : new JavaRandom(randomSeed);
2161
+ const thoroughness = Math.max(1, input.settings.thoroughness ?? sweeps ?? 7);
2162
+ let working = layers.map((layer) => [...layer]);
2163
+ const usesInitialModelOrder =
2164
+ statistic === "mean" && (input.settings["considerModelOrder.strategy"] ?? "NONE") !== "NONE";
2165
+ // Preserved model order makes unknown barycenters interpolate rather than
2166
+ // consume random floats during the first attempt. Child scopes keep their
2167
+ // own flag until their counter-based attempt finishes.
2168
+ let initialOrderAttempt = usesInitialModelOrder;
2169
+ const sourceUnknownPlacement = true;
2170
+ const forcedCompare =
2171
+ usesInitialModelOrder && input.settings["crossingMinimization.forceNodeModelOrder"] === true
2172
+ ? normalModelComparator(input)
2173
+ : undefined;
2174
+ const usePortRanks = true;
2175
+ const medianWeights = new Map<string, number>();
2176
+ const listedEdgeIds = new Map<string, string>();
2177
+ const listedEdgeId = (edgeId: string) => {
2178
+ let listed = listedEdgeIds.get(edgeId);
2179
+ if (listed === undefined)
2180
+ listedEdgeIds.set(edgeId, (listed = edgeId.replace(/(::(segment|inverted):\d+:*)+$/, "")));
2181
+ return listed;
2182
+ };
2183
+
2184
+ const adjacentRanks = (
2185
+ fixedLayer: readonly string[],
2186
+ freeLayer: readonly string[],
2187
+ forward: boolean,
2188
+ ): { ranks: Map<string, number[]>; visits: Map<string, (number | string)[]> } => {
2189
+ const edgeRanks = new Map<string, number>();
2190
+ const visits = new Map(freeLayer.map((id) => [id, [] as (number | string)[]]));
2191
+ const freeIds = new Set(freeLayer);
2192
+ const ranks = new Map(freeLayer.map((id) => [id, [] as number[]]));
2193
+ if (!usePortRanks) {
2194
+ const fixedPosition = new Map(fixedLayer.map((id, index) => [id, index]));
2195
+ for (const edge of input.graph.edges) {
2196
+ const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
2197
+ if (forward && fixedPosition.has(sourceId) && freeIds.has(targetId)) {
2198
+ ranks.get(targetId)?.push(fixedPosition.get(sourceId)!);
2199
+ } else if (!forward && fixedPosition.has(targetId) && freeIds.has(sourceId)) {
2200
+ ranks.get(sourceId)?.push(fixedPosition.get(targetId)!);
1799
2201
  }
1800
2202
  }
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
- }
2203
+ return { ranks, visits };
2204
+ }
2205
+ if (exactPortSweep) {
2206
+ const fixedIndex = assignment.layerByNodeId.get(fixedLayer[0] ?? "");
2207
+ if (fixedIndex !== undefined) {
2208
+ const nodes = fixedLayer.map((id) => canonicalNodes.get(id)!);
2209
+ canonicalGraph.layers[fixedIndex] = nodes;
2210
+ canonicalDistributor.calculatePortRanks(
2211
+ canonicalGraph,
2212
+ nodes,
2213
+ !forward,
2214
+ nodeRelativePortRanks,
2215
+ );
2216
+ // The fixed layer's edges toward the free layer, in edge order.
2217
+ const byEnd = forward ? canonicalEdgesBySource : canonicalEdgesByTarget;
2218
+ const indices = fixedLayer.flatMap((id) => byEnd.get(id) ?? []).sort((a, b) => a - b);
2219
+ for (const index of indices) {
2220
+ const edge = canonicalEdges[index]!;
2221
+ const [source, target] = canonicalOrientedEndpoints[index]!;
2222
+ if (!freeIds.has(forward ? target : source)) continue;
2223
+ const endpoint = canonicalEndpoints.get(edge.id)!;
2224
+ const rank =
2225
+ canonicalDistributor.state.ranks[forward ? endpoint.source : endpoint.target]!;
2226
+ ranks.get(forward ? target : source)!.push(rank);
2227
+ edgeRanks.set(edge.id, rank);
1839
2228
  }
1840
- return ranks;
1841
2229
  }
2230
+ } else {
1842
2231
  let rankSum = 0;
1843
2232
  for (const fixedId of fixedLayer) {
1844
2233
  const groups = new Map<string, { edges: GraphEdge[]; portOrder: number }>();
@@ -1886,188 +2275,343 @@ function minimizeCrossingsWithLayerSweep(
1886
2275
  for (const edge of group.edges) {
1887
2276
  const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
1888
2277
  ranks.get(forward ? targetId : sourceId)?.push(rank);
2278
+ edgeRanks.set(edge.id, rank);
1889
2279
  }
1890
2280
  }
1891
2281
  rankSum += nodeRelativePortRanks ? 1 : count;
1892
2282
  }
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
- });
2283
+ }
2284
+ // Visit each free node's ports in their current canonical order, while
2285
+ // preserving incident-edge order within a shared port. Fixed-layer ranks
2286
+ // and same-layer recursion must not be separated into independent sums.
2287
+ for (const id of freeLayer) {
2288
+ const order = (forward ? inputPortOrder : outputPortOrder).get(id) ?? [];
2289
+ const groups = new Map<string, string[]>();
2290
+ for (const edgeId of order) {
2291
+ const edge = edgeById.get(edgeId)!;
2292
+ const reversed = orientation.reversedEdgeIds.has(edge.id);
2293
+ const name = forward !== reversed ? edge.targetPort : edge.sourcePort;
2294
+ const key = name ?? `__implicit:${edgeId}`;
2295
+ const group = groups.get(key);
2296
+ if (group) group.push(edgeId);
2297
+ else groups.set(key, [edgeId]);
2298
+ }
2299
+ for (const group of groups.values()) {
2300
+ // Port sorting reorders ports, not the edges on one port; those keep
2301
+ // ELK's edge list order. Expanded segments keep their listed edge id.
2302
+ const listed = units?.edgeListRanks?.[forward ? "incoming" : "outgoing"].get(id);
2303
+ const rank = (edgeId: string) => listed?.get(listedEdgeId(edgeId));
2304
+ group.sort(
2305
+ (a, b) =>
2306
+ (rank(a) !== undefined && rank(b) !== undefined ? rank(a)! - rank(b)! : 0) ||
2307
+ edgeModelOrder.get(a)! - edgeModelOrder.get(b)!,
2308
+ );
2309
+ for (const edgeId of group) {
2310
+ const edge = edgeById.get(edgeId)!;
2311
+ const [source, target] = getOrientedEndpoints(edge, orientation);
2312
+ const neighbor = forward ? source : target;
2313
+ const rank = edgeRanks.get(edgeId);
2314
+ if (assignment.layerByNodeId.get(neighbor) === assignment.layerByNodeId.get(id))
2315
+ visits.get(id)!.push(neighbor);
2316
+ else if (rank !== undefined) visits.get(id)!.push(rank);
1936
2317
  }
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
2318
  }
1949
- };
2319
+ }
2320
+ return { ranks, visits };
2321
+ };
1950
2322
 
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));
2323
+ let canonicalGraph = buildCrossingGraph(orientation, working, inputPortOrder, outputPortOrder);
2324
+ if (usesInitialModelOrder) {
2325
+ if (!restoreInitialModelOrder(input, canonicalGraph))
2326
+ sortInitialModelOrder(input, orientation, canonicalGraph);
2327
+ working = canonicalGraph.layers.map((layer) => layer.map((node) => node.id));
2328
+ const edges = input.graph.edges.filter((edge) => edge.sourceId !== edge.targetId);
2329
+ const endpoints = new Map(edges.map((edge, index) => [edge.id, canonicalGraph.edges[index]!]));
2330
+ for (const node of canonicalGraph.layers.flat()) {
2331
+ const ordinal = new Map(node.ports.map((port, index) => [port.id, index]));
2332
+ for (const [orders, source] of [
2333
+ [outputPortOrder, true],
2334
+ [inputPortOrder, false],
2335
+ ] as const) {
2336
+ orders
2337
+ .get(node.id)
2338
+ ?.sort(
2339
+ (a, b) =>
2340
+ ordinal.get(endpoints.get(a)![source ? "source" : "target"])! -
2341
+ ordinal.get(endpoints.get(b)![source ? "source" : "target"])!,
2342
+ );
1963
2343
  }
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,
2344
+ }
2345
+ modelSweepInputs.add(input);
2346
+ }
2347
+ let canonicalNodes = new Map(canonicalGraph.layers.flat().map((node) => [node.id, node]));
2348
+ const canonicalDistributor = new CanonicalPortDistributor(canonicalGraph);
2349
+ const canonicalEdges = input.graph.edges.filter((edge) => edge.sourceId !== edge.targetId);
2350
+ const canonicalReversed = canonicalEdges.map((edge) => orientation.reversedEdgeIds.has(edge.id));
2351
+ const canonicalOrientedEndpoints = canonicalEdges.map((edge) =>
2352
+ getOrientedEndpoints(edge, orientation),
2353
+ );
2354
+ const canonicalEdgesBySource = new Map<string, number[]>(),
2355
+ canonicalEdgesByTarget = new Map<string, number[]>();
2356
+ canonicalOrientedEndpoints.forEach(([source, target], index) => {
2357
+ for (const [byEnd, id] of [
2358
+ [canonicalEdgesBySource, source],
2359
+ [canonicalEdgesByTarget, target],
2360
+ ] as const) {
2361
+ const list = byEnd.get(id);
2362
+ if (list) list.push(index);
2363
+ else byEnd.set(id, [index]);
2364
+ }
2365
+ });
2366
+ const canonicalEndpoints = new Map(
2367
+ canonicalEdges.map((edge, index) => [edge.id, canonicalGraph.edges[index]!]),
2368
+ );
2369
+ const canonicalFixedOrder = new Set(
2370
+ input.graph.nodes
2371
+ .filter((node) => {
2372
+ const constraint = input.nodeSettings?.(node)?.portConstraints;
2373
+ return (
2374
+ constraint === "FIXED_ORDER" || constraint === "FIXED_POS" || constraint === "FIXED_RATIO"
2375
+ );
2376
+ })
2377
+ .map((node) => node.id),
2378
+ );
2379
+ // Every canonically fixed node plus each port order locked by a child scope.
2380
+ const fixedPortOrders = new Set(canonicalFixedOrder);
2381
+ const distributePorts = (layerIndex: number, forward: boolean) => {
2382
+ const touched = [layerIndex, layerIndex + (forward ? -1 : 1)].filter(
2383
+ (index) => index >= 0 && index < working.length,
2384
+ );
2385
+ for (const index of touched)
2386
+ canonicalGraph.layers[index] = working[index]!.map((id) => canonicalNodes.get(id)!);
2387
+ canonicalDistributor.distribute(canonicalGraph, layerIndex, forward, {
2388
+ nodeRelative: nodeRelativePortRanks,
2389
+ fixedOrder: fixedPortOrders,
2390
+ hierarchical: hierarchicalNodes,
2391
+ });
2392
+ for (const index of touched)
2393
+ for (const node of canonicalGraph.layers[index]!) {
2394
+ const ordinal = new Map(node.ports.map((port, index) => [port.id, index]));
2395
+ for (const [orders, source] of [
2396
+ [outputPortOrder, true],
2397
+ [inputPortOrder, false],
2398
+ ] as const)
2399
+ orders
2400
+ .get(node.id)
2401
+ ?.sort(
2402
+ (left, right) =>
2403
+ ordinal.get(canonicalEndpoints.get(left)![source ? "source" : "target"])! -
2404
+ ordinal.get(canonicalEndpoints.get(right)![source ? "source" : "target"])!,
1986
2405
  );
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
- };
2406
+ }
2407
+ };
2034
2408
 
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]]),
2409
+ const shuffleFirstLayer = (forward: boolean) => {
2410
+ const firstLayerIndex = forward ? 0 : Math.max(0, working.length - 1);
2411
+ const firstLayerWeights = new Map(
2412
+ (working[firstLayerIndex] ?? []).map((id) => [id, random.nextDouble()]),
2413
+ );
2414
+ const compare = (left: string, right: string) =>
2415
+ forcedCompare?.(left, right) ||
2416
+ (firstLayerWeights.get(left) ?? 0) - (firstLayerWeights.get(right) ?? 0);
2417
+ const first = working[firstLayerIndex];
2418
+ if (first) {
2419
+ if (forcedCompare)
2420
+ insertionSort(
2421
+ first,
2422
+ transitiveComparator((id: string) => id, compare),
2423
+ );
2424
+ else first.sort(compare);
2425
+ }
2426
+ if (units && !forcedCompare && working[firstLayerIndex])
2427
+ working[firstLayerIndex] = resolveCrossingConstraints(
2428
+ working[firstLayerIndex]!,
2429
+ firstLayerWeights,
2430
+ units,
2043
2431
  );
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;
2432
+ if (statistic === "median") {
2433
+ working[firstLayerIndex]?.forEach((id, index) => medianWeights.set(id, index + 1));
2434
+ }
2435
+ };
2436
+ // Non-loop edges by endpoint, for grouping a mixed port's neighbours.
2437
+ const edgesByNode = new Map<string, GraphEdge[]>();
2438
+ for (const edge of input.graph.edges) {
2439
+ if (edge.sourceId === edge.targetId) continue;
2440
+ for (const id of [edge.sourceId, edge.targetId]) {
2441
+ const list = edgesByNode.get(id);
2442
+ if (list) list.push(edge);
2443
+ else edgesByNode.set(id, [edge]);
2444
+ }
2445
+ }
2446
+ /**
2447
+ * A port carrying both directions gives each direction its own track and
2448
+ * attachment, so its edges are no longer interchangeable: neighbours of one
2449
+ * direction must not interleave with the other's, or the separated routes
2450
+ * cross. Barycenters tie (up to noise) for nodes reached only through one
2451
+ * port; within the slots those nodes occupy, put forward edges before
2452
+ * reversed ones. Both ends of a chain see the same reversal, so the order
2453
+ * holds along it.
2454
+ */
2455
+ const constrained = new Set([
2456
+ ...(units?.units.keys() ?? []),
2457
+ ...[...(units?.successors ?? [])].flatMap(([id, after]) => [id, ...after]),
2458
+ ]);
2459
+ const groupMixedPortDirections = (
2460
+ free: string[],
2461
+ fixed: readonly string[],
2462
+ beyond: readonly string[] = [],
2463
+ ) => {
2464
+ if (!mixedPorts) return;
2465
+ const fixedIds = new Set(fixed);
2466
+ const groups = new Map<string, Array<{ id: string; reversed: boolean }>>();
2467
+ for (const id of free) {
2468
+ if (constrained.has(id)) continue;
2469
+ let only: GraphEdge | undefined,
2470
+ count = 0;
2471
+ for (const edge of edgesByNode.get(id) ?? [])
2472
+ if (fixedIds.has(edge.sourceId === id ? edge.targetId : edge.sourceId)) {
2473
+ only = edge;
2474
+ count++;
2052
2475
  }
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]]),
2476
+ if (count !== 1) continue;
2477
+ const atSource = only!.sourceId !== id;
2478
+ const name = atSource ? only!.sourcePort : only!.targetPort;
2479
+ if (name === undefined) continue;
2480
+ const key = JSON.stringify([atSource ? only!.sourceId : only!.targetId, name]);
2481
+ const group = groups.get(key) ?? [];
2482
+ group.push({ id, reversed: orientation.reversedEdgeIds.has(only!.id) });
2483
+ groups.set(key, group);
2484
+ }
2485
+ if (![...groups.values()].some((g) => g.some((m) => m.reversed) && g.some((m) => !m.reversed)))
2486
+ return;
2487
+ if (probe) probe.mixedPorts = true;
2488
+ const index = new Map(free.map((id, i) => [id, i]));
2489
+ // The direction whose nodes lead further toward the start of the layer
2490
+ // beyond (from the previous sweep) goes first; forward edges by default.
2491
+ const far = new Map(beyond.map((id, i) => [id, i]));
2492
+ const reach = (members: ReadonlyArray<{ id: string }>) => {
2493
+ let sum = 0,
2494
+ count = 0;
2495
+ for (const { id } of members)
2496
+ for (const edge of edgesByNode.get(id) ?? []) {
2497
+ const at = far.get(edge.sourceId === id ? edge.targetId : edge.sourceId);
2498
+ if (at === undefined) continue;
2499
+ sum += at;
2500
+ count++;
2501
+ }
2502
+ return count ? sum / count : undefined;
2503
+ };
2504
+ for (const group of groups.values()) {
2505
+ if (!group.some((m) => m.reversed) || group.every((m) => m.reversed)) continue;
2506
+ const forward = reach(group.filter((m) => !m.reversed)),
2507
+ backward = reach(group.filter((m) => m.reversed));
2508
+ const reversedFirst =
2509
+ forward === undefined || backward === undefined ? false : backward < forward;
2510
+ const slots = group.map((m) => index.get(m.id)!).sort((a, b) => a - b);
2511
+ const members = group
2512
+ .sort((a, b) => index.get(a.id)! - index.get(b.id)!)
2513
+ .sort((a, b) => (reversedFirst ? -1 : 1) * (Number(a.reversed) - Number(b.reversed)));
2514
+ for (const [i, slot] of slots.entries()) free[slot] = members[i]!.id;
2515
+ }
2516
+ };
2517
+ const sweep = (isForward: boolean, firstSweep: boolean, visitLayer?: LayerSweepVisitor) => {
2518
+ const firstLayer = isForward ? 0 : Math.max(0, working.length - 1);
2519
+ if (exactPortSweep) distributePorts(firstLayer, isForward);
2520
+ visitLayer?.(firstLayer, [...(working[firstLayer] ?? [])], isForward, firstSweep);
2521
+ const sortWithMedianWeights = (current: string[], reference: readonly string[]) => {
2522
+ const referenceIds = new Set(reference);
2523
+ const originalIndex = new Map(current.map((id, index) => [id, index]));
2524
+ for (const id of current) {
2525
+ const connectedWeights = input.graph.edges
2526
+ .flatMap((edge) => {
2527
+ if (edge.sourceId === id && referenceIds.has(edge.targetId)) {
2528
+ return [medianWeights.get(edge.targetId) ?? 0];
2529
+ }
2530
+ if (edge.targetId === id && referenceIds.has(edge.sourceId)) {
2531
+ return [medianWeights.get(edge.sourceId) ?? 0];
2532
+ }
2533
+ return [];
2534
+ })
2535
+ .sort((left, right) => left - right);
2536
+ medianWeights.set(
2537
+ id,
2538
+ connectedWeights.length > 0
2539
+ ? connectedWeights[Math.floor(connectedWeights.length / 2)]!
2540
+ : Number.MAX_VALUE / 2,
2060
2541
  );
2061
2542
  }
2062
- if (crossings < bestCrossings) {
2063
- bestCrossings = crossings;
2064
- bestLayers = attemptBestLayers;
2065
- bestInputPortOrder = attemptBestInputPortOrder;
2066
- bestOutputPortOrder = attemptBestOutputPortOrder;
2067
- if (crossings === 0) break;
2543
+ current.sort(
2544
+ (left, right) =>
2545
+ (medianWeights.get(left) ?? 0) - (medianWeights.get(right) ?? 0) ||
2546
+ (originalIndex.get(left) ?? 0) - (originalIndex.get(right) ?? 0),
2547
+ );
2548
+ };
2549
+ if (isForward) {
2550
+ for (let layer = 1; layer < working.length; layer++) {
2551
+ const current = working[layer];
2552
+ const previous = working[layer - 1];
2553
+ if (current && previous) {
2554
+ if (statistic === "median") sortWithMedianWeights(current, previous);
2555
+ else {
2556
+ const adjacent = adjacentRanks(previous, current, true);
2557
+ sortLayerByAdjacentPosition(
2558
+ current,
2559
+ adjacent.ranks,
2560
+ statistic,
2561
+ random,
2562
+ !firstSweep ||
2563
+ initialOrderAttempt ||
2564
+ canonicalNodes.get(current[0] ?? "")?.type === "EXTERNAL_PORT",
2565
+ sourceUnknownPlacement,
2566
+ true,
2567
+ units,
2568
+ sameLayerNeighbors(isForward),
2569
+ adjacent.visits,
2570
+ forcedCompare,
2571
+ );
2572
+ }
2573
+ groupMixedPortDirections(current, previous, working[layer + 1]);
2574
+ if (exactPortSweep) distributePorts(layer, true);
2575
+ visitLayer?.(layer, [...current], isForward, firstSweep);
2576
+ }
2577
+ }
2578
+ } else {
2579
+ for (let layer = working.length - 2; layer >= 0; layer--) {
2580
+ const current = working[layer];
2581
+ const next = working[layer + 1];
2582
+ if (current && next) {
2583
+ if (statistic === "median") sortWithMedianWeights(current, next);
2584
+ else {
2585
+ const adjacent = adjacentRanks(next, current, false);
2586
+ sortLayerByAdjacentPosition(
2587
+ current,
2588
+ adjacent.ranks,
2589
+ statistic,
2590
+ random,
2591
+ !firstSweep ||
2592
+ initialOrderAttempt ||
2593
+ canonicalNodes.get(current[0] ?? "")?.type === "EXTERNAL_PORT",
2594
+ sourceUnknownPlacement,
2595
+ true,
2596
+ units,
2597
+ sameLayerNeighbors(isForward),
2598
+ adjacent.visits,
2599
+ forcedCompare,
2600
+ );
2601
+ }
2602
+ groupMixedPortDirections(current, next, working[layer - 1]);
2603
+ if (exactPortSweep) distributePorts(layer, false);
2604
+ visitLayer?.(layer, [...current], isForward, firstSweep);
2605
+ }
2068
2606
  }
2069
2607
  }
2608
+ };
2070
2609
 
2610
+ const attempts = statistic === "median" ? 1 : thoroughness;
2611
+ const finish = (order: LayerOrder): LayerOrder => {
2612
+ const bestLayers = order.layers.map((layer) => [...layer]);
2613
+ const bestInputPortOrder = clonePortOrders(order.inputPortOrderByNodeId);
2614
+ const bestOutputPortOrder = clonePortOrders(order.outputPortOrderByNodeId);
2071
2615
  if (statistic === "median" && input.direction === "left") {
2072
2616
  const edgeOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
2073
2617
  for (const layer of bestLayers) {
@@ -2117,41 +2661,100 @@ function minimizeCrossingsWithLayerSweep(
2117
2661
  phaseRandomByInput.set(input, random);
2118
2662
  return exactPortSweep
2119
2663
  ? {
2664
+ physicalPortOrderByNodeId: clonePortOrders(order.physicalPortOrderByNodeId),
2120
2665
  layers: bestLayers,
2121
2666
  inputPortOrderByNodeId: bestInputPortOrder,
2122
2667
  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);
2668
+ }
2669
+ : { layers: bestLayers };
2670
+ };
2671
+ const restore = (order: LayerOrder) => {
2672
+ working = order.layers.map((layer) => [...layer]);
2673
+ inputPortOrder.clear();
2674
+ outputPortOrder.clear();
2675
+ for (const [id, edges] of clonePortOrders(order.inputPortOrderByNodeId))
2676
+ inputPortOrder.set(id, edges);
2677
+ for (const [id, edges] of clonePortOrders(order.outputPortOrderByNodeId))
2678
+ outputPortOrder.set(id, edges);
2679
+ canonicalGraph = buildCrossingGraph(orientation, working, inputPortOrder, outputPortOrder);
2680
+ canonicalNodes = new Map(canonicalGraph.layers.flat().map((node) => [node.id, node]));
2681
+ for (const [id, ports] of order.physicalPortOrderByNodeId ?? []) {
2682
+ const node = canonicalNodes.get(id);
2683
+ if (node) node.ports.sort((a, b) => ports.indexOf(a.id) - ports.indexOf(b.id));
2152
2684
  }
2153
- }
2154
- return components.flat();
2685
+ };
2686
+ traceLayeredPhase(() => ({
2687
+ kind: "initial-order",
2688
+ scope: input.graph.id,
2689
+ layers: working.map((layer) => [...layer]),
2690
+ }));
2691
+ const session: LayerSweepSession = {
2692
+ scope: input.graph.id,
2693
+ get random() {
2694
+ return random;
2695
+ },
2696
+ attempts,
2697
+ usesInitialModelOrder,
2698
+ finishInitialOrderAttempt: () => {
2699
+ initialOrderAttempt = false;
2700
+ },
2701
+ restoreRejectedSweep: !exactPortSweep,
2702
+ lockPortOrder: (id) => {
2703
+ fixedPortOrders.add(id);
2704
+ },
2705
+ markHierarchicalNode: (id) => {
2706
+ hierarchicalNodes.add(id);
2707
+ },
2708
+ useRandom: (source) => {
2709
+ random = source;
2710
+ },
2711
+ minimize: () => {
2712
+ const root = { session, childrenByNodeId: new Map(), useBottomUp: true };
2713
+ return minimizeHierarchyCrossings(root).get(root)!;
2714
+ },
2715
+ shuffleFirstLayer,
2716
+ sweep,
2717
+ countCrossings: () => countCrossings(working),
2718
+ snapshot: () => ({
2719
+ physicalPortOrderByNodeId: new Map(
2720
+ [...canonicalNodes].map(([id, node]) => [id, node.ports.map((port) => port.id)]),
2721
+ ),
2722
+ layers: working.map((layer) => [...layer]),
2723
+ inputPortOrderByNodeId: clonePortOrders(inputPortOrder),
2724
+ outputPortOrderByNodeId: clonePortOrders(outputPortOrder),
2725
+ }),
2726
+ restore,
2727
+ finish,
2728
+ currentLayers: () => working,
2729
+ crossingGraph: () => ({
2730
+ layers: working.map((layer) => layer.map((id) => canonicalNodes.get(id)!)),
2731
+ edges: canonicalGraph.edges,
2732
+ }),
2733
+ physicalPortOrder: (id) => canonicalNodes.get(id)?.ports.map((port) => port.id) ?? [],
2734
+ edgeOrders: (id) => ({
2735
+ incoming: inputPortOrder.get(id) ?? [],
2736
+ outgoing: outputPortOrder.get(id) ?? [],
2737
+ }),
2738
+ // A full restore would rebuild an equivalent canonical graph: ports keep
2739
+ // their recorded physical order and layers are re-read before distribution.
2740
+ replaceLayer: (index, ids) => {
2741
+ working[index] = [...ids];
2742
+ },
2743
+ reorderNodePorts: (id, physical, incoming, outgoing) => {
2744
+ inputPortOrder.set(id, [...incoming]);
2745
+ outputPortOrder.set(id, [...outgoing]);
2746
+ const node = canonicalNodes.get(id);
2747
+ if (!node) return;
2748
+ const slot = new Map(physical.map((port, index) => [port, index]));
2749
+ node.ports.sort((a, b) => (slot.get(a.id) ?? -1) - (slot.get(b.id) ?? -1));
2750
+ },
2751
+ };
2752
+ return session;
2753
+ }
2754
+
2755
+ /** NetworkSimplexLayerer processes the largest undirected component first. */
2756
+ function networkSimplexComponentOrder(input: LayeredPhaseInput): string[] {
2757
+ return networkSimplexComponents(input).flat();
2155
2758
  }
2156
2759
 
2157
2760
  /** Keep model order within each component while placing connected work before isolated nodes. */
@@ -2193,7 +2796,11 @@ export function minimizeCrossingsWithMedian(sweeps = 7): CrossingMinimizer {
2193
2796
  }
2194
2797
 
2195
2798
  function layersFromAssignment(input: LayeredPhaseInput, assignment: LayerAssignment): string[][] {
2196
- const maximumLayer = Math.max(0, ...assignment.layerByNodeId.values());
2799
+ const maximumLayer = Math.max(
2800
+ 0,
2801
+ (assignment.layerCount ?? 1) - 1,
2802
+ ...assignment.layerByNodeId.values(),
2803
+ );
2197
2804
  const layers = Array.from({ length: maximumLayer + 1 }, () => [] as string[]);
2198
2805
  for (const node of input.graph.nodes) {
2199
2806
  layers[assignment.layerByNodeId.get(node.id) ?? 0]?.push(node.id);
@@ -2215,7 +2822,11 @@ export const minimizeCrossingsWithModelOrder: CrossingMinimizer = (
2215
2822
  _orientation,
2216
2823
  assignment,
2217
2824
  ) => {
2218
- const maximumLayer = Math.max(0, ...assignment.layerByNodeId.values());
2825
+ const maximumLayer = Math.max(
2826
+ 0,
2827
+ (assignment.layerCount ?? 1) - 1,
2828
+ ...assignment.layerByNodeId.values(),
2829
+ );
2219
2830
  const layers = Array.from({ length: maximumLayer + 1 }, () => [] as string[]);
2220
2831
  const layeringStrategy = input.settings["layering.strategy"] ?? "NETWORK_SIMPLEX";
2221
2832
  const nodeOrder = assignment.seedOrder
@@ -2410,6 +3021,19 @@ export function applyPostCompaction(
2410
3021
  const constraintStrategy = input.settings["compaction.postCompaction.constraints"] ?? "SCANLINE";
2411
3022
  void constraintStrategy;
2412
3023
  if (strategy === "NONE") return placement;
3024
+ if (
3025
+ strategy === "EDGE_LENGTH" ||
3026
+ ((input.settings.edgeRouting ?? "ORTHOGONAL") === "ORTHOGONAL" &&
3027
+ ["LEFT", "RIGHT", "LEFT_RIGHT_CONSTRAINT_LOCKING", "LEFT_RIGHT_CONNECTION_LOCKING"].includes(
3028
+ String(strategy),
3029
+ ))
3030
+ )
3031
+ return applyGroupedEdgeLengthCompaction(
3032
+ input,
3033
+ placement,
3034
+ routes,
3035
+ getPlacementOrientation(input),
3036
+ );
2413
3037
  const rects = placement.rectByNodeId as Map<string, EntityRect>;
2414
3038
  const originalEndpointsByEdgeId = new Map(
2415
3039
  [...(routes?.pointsByEdgeId ?? [])].flatMap(([edgeId, points]) => {
@@ -2485,8 +3109,7 @@ export function applyPostCompaction(
2485
3109
  // Compaction must preserve the space reserved for an inline center label,
2486
3110
  // even when its endpoint rectangles do not intersect on the cross axis.
2487
3111
  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")) {
3112
+ if (input.direction === "left" || input.direction === "right") {
2490
3113
  for (const edge of labelEdges) {
2491
3114
  const settings = input.edgeSettings?.(edge);
2492
3115
  if (
@@ -2508,7 +3131,16 @@ export function applyPostCompaction(
2508
3131
  : 0;
2509
3132
  const horizontalSpacing = (left: ConstraintNode, right: ConstraintNode): number =>
2510
3133
  left.kind === "node" && right.kind === "node"
2511
- ? Math.max(input.spacing.node, labelSpace(left, right))
3134
+ ? Math.max(
3135
+ input.spacing.node,
3136
+ (left.nodeId?.startsWith("__layout_dummy:label:") &&
3137
+ !right.nodeId?.startsWith("__layout_dummy:")) ||
3138
+ (right.nodeId?.startsWith("__layout_dummy:label:") &&
3139
+ !left.nodeId?.startsWith("__layout_dummy:"))
3140
+ ? input.spacing.layer
3141
+ : 0,
3142
+ labelSpace(left, right),
3143
+ )
2512
3144
  : left.kind === "segment" && right.kind === "segment" && left.edgeId === right.edgeId
2513
3145
  ? 0
2514
3146
  : Number(
@@ -2619,90 +3251,8 @@ export function applyPostCompaction(
2619
3251
  );
2620
3252
  } else {
2621
3253
  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
3254
  }
3255
+
2706
3256
  if (input.direction === "right" && centerLabeledEdgeIds.size > 0) {
2707
3257
  const itemByNodeId = new Map(
2708
3258
  compactables.flatMap((item) =>
@@ -2715,7 +3265,7 @@ export function applyPostCompaction(
2715
3265
  // A late target nudge would invalidate its outgoing label constraints.
2716
3266
  if (
2717
3267
  !centerLabeledEdgeIds.has(edge.id) ||
2718
- (strategy !== "EDGE_LENGTH" && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
3268
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
2719
3269
  )
2720
3270
  continue;
2721
3271
  const source = itemByNodeId.get(edge.sourceId);
@@ -2870,6 +3420,7 @@ function nodeFlowOffset(
2870
3420
  layerFlowSize: number,
2871
3421
  nodeFlowSize: number,
2872
3422
  layerByNodeId: ReadonlyMap<string, number>,
3423
+ margins?: { leading: number; trailing: number; maxLeading: number; maxTrailing: number },
2873
3424
  ): number {
2874
3425
  const node = input.graph.nodes.find((candidate) => candidate.id === id);
2875
3426
  const alignment = node
@@ -2880,33 +3431,87 @@ function nodeFlowOffset(
2880
3431
  else if (alignment === "RIGHT" || alignment === "BOTTOM") ratio = 1;
2881
3432
  else if (alignment === "CENTER") ratio = 0.5;
2882
3433
  else {
2883
- let incoming = 0;
2884
- let outgoing = 0;
3434
+ const incoming = new Set<string>();
3435
+ const outgoing = new Set<string>();
2885
3436
  const ownLayer = layerByNodeId.get(id) ?? 0;
3437
+ const orientation = getPlacementOrientation(input);
2886
3438
  for (const edge of input.graph.edges) {
2887
3439
  if (edge.sourceId !== id && edge.targetId !== id) continue;
2888
- const otherId = edge.sourceId === id ? edge.targetId : edge.sourceId;
3440
+ if (hasMovableLoopPorts(input, edge)) continue;
3441
+ const source = edge.sourceId === id;
3442
+ const portName = source ? edge.sourcePort : edge.targetPort;
3443
+ const key = portName === undefined ? `edge:${edge.id}` : `port:${portName}`;
3444
+ const otherId = source ? edge.targetId : edge.sourceId;
2889
3445
  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++;
3446
+ const port = node?.ports?.find((candidate) => candidate.name === portName);
3447
+ const isOutgoing = orientation
3448
+ ? source !== orientation.reversedEdgeIds.has(edge.id)
3449
+ : otherLayer !== ownLayer
3450
+ ? otherLayer > ownLayer
3451
+ : id.startsWith("__layout_dummy:") && port?.direction !== "inout"
3452
+ ? port?.direction === "out"
3453
+ : source;
3454
+ (isOutgoing ? outgoing : incoming).add(key);
2894
3455
  }
2895
- ratio = incoming + outgoing === 0 ? 0.5 : outgoing / (incoming + outgoing);
3456
+ ratio =
3457
+ incoming.size + outgoing.size === 0 ? 0.5 : outgoing.size / (incoming.size + outgoing.size);
3458
+ }
3459
+ let position = (layerFlowSize - nodeFlowSize) * ratio;
3460
+ if (margins) {
3461
+ if (ratio > 0.5) position -= margins.maxTrailing * 2 * (ratio - 0.5);
3462
+ else if (ratio < 0.5) position += margins.maxLeading * 2 * (0.5 - ratio);
3463
+ position = Math.max(position, margins.leading);
3464
+ position = Math.min(position, layerFlowSize - margins.trailing - nodeFlowSize);
2896
3465
  }
2897
- return (layerFlowSize - nodeFlowSize) * ratio;
3466
+ return position;
2898
3467
  }
2899
3468
 
2900
3469
  export const placeNodesInLayers: NodePlacer = (input, order) => {
2901
3470
  const horizontal = input.direction === "left" || input.direction === "right";
3471
+ const reverse = input.direction === "left" || input.direction === "up";
3472
+ const orientation = getPlacementOrientation(input);
3473
+ const loopMargins = preparedLoopEnvelopes(input);
3474
+ const flowMargins = new Map(
3475
+ input.graph.nodes.map((node) => {
3476
+ const size = input.sizes.get(node.id) ?? { width: 0, height: 0 };
3477
+ const ports = placePorts(
3478
+ node.ports,
3479
+ { x: 0, y: 0, ...size },
3480
+ input.direction,
3481
+ (port) => input.portSettings?.(port, node),
3482
+ { ...input.settings, ...input.nodeSettings?.(node) },
3483
+ orientation
3484
+ ? (port) => getOrientedPortDirection(input, orientation, node, port)
3485
+ : undefined,
3486
+ );
3487
+ let low = 0,
3488
+ high = 0;
3489
+ for (const port of ports ?? []) {
3490
+ const start = horizontal ? (port.x ?? 0) : (port.y ?? 0);
3491
+ const end = start + (horizontal ? (port.width ?? 0) : (port.height ?? 0));
3492
+ low = Math.max(low, -start);
3493
+ high = Math.max(high, end - (horizontal ? size.width : size.height));
3494
+ }
3495
+ return [
3496
+ node.id,
3497
+ {
3498
+ leading: Math.max(reverse ? high : low, loopMargins?.get(node.id)?.flowBefore ?? 0),
3499
+ trailing: Math.max(reverse ? low : high, loopMargins?.get(node.id)?.flowAfter ?? 0),
3500
+ },
3501
+ ] as const;
3502
+ }),
3503
+ );
2902
3504
  const layerByNodeId = new Map(
2903
3505
  order.layers.flatMap((layer, layerIndex) => layer.map((id) => [id, layerIndex] as const)),
2904
3506
  );
2905
3507
  const layerFlowSizes = order.layers.map((layer) => {
2906
3508
  const size = Math.max(
2907
3509
  0,
2908
- ...layer.map((id) =>
2909
- horizontal ? (input.sizes.get(id)?.width ?? 0) : (input.sizes.get(id)?.height ?? 0),
3510
+ ...layer.map(
3511
+ (id) =>
3512
+ (horizontal ? (input.sizes.get(id)?.width ?? 0) : (input.sizes.get(id)?.height ?? 0)) +
3513
+ (flowMargins.get(id)?.leading ?? 0) +
3514
+ (flowMargins.get(id)?.trailing ?? 0),
2910
3515
  ),
2911
3516
  );
2912
3517
  return size === 0 &&
@@ -2941,6 +3546,12 @@ export const placeNodesInLayers: NodePlacer = (input, order) => {
2941
3546
  layerFlowSizes[layerIndex] ?? 0,
2942
3547
  horizontal ? size.width : size.height,
2943
3548
  layerByNodeId,
3549
+ {
3550
+ leading: flowMargins.get(id)?.leading ?? 0,
3551
+ trailing: flowMargins.get(id)?.trailing ?? 0,
3552
+ maxLeading: Math.max(0, ...layer.map((id) => flowMargins.get(id)?.leading ?? 0)),
3553
+ maxTrailing: Math.max(0, ...layer.map((id) => flowMargins.get(id)?.trailing ?? 0)),
3554
+ },
2944
3555
  );
2945
3556
  const rect = horizontal
2946
3557
  ? { x: centeredFlow, y: cross, ...size }
@@ -3073,22 +3684,42 @@ export const placeNodesInteractively: NodePlacer = (input, order) => {
3073
3684
  return { rectByNodeId };
3074
3685
  };
3075
3686
 
3076
- function getPortPoint(
3687
+ export function getPortPoint(
3077
3688
  node: GraphNode,
3078
3689
  portName: string | undefined,
3079
3690
  rect: EntityRect,
3080
3691
  fallback: Point,
3081
3692
  direction: LayeredPhaseInput["direction"],
3082
3693
  input: LayeredPhaseInput,
3694
+ /** Reuses each node's placed ports across calls while settings stay fixed. */
3695
+ placedPorts?: Map<GraphNode, { rect: EntityRect; ports: GraphPort[] | undefined }>,
3083
3696
  ): Point {
3084
3697
  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);
3698
+ const cached = placedPorts?.get(node);
3699
+ let ports: GraphPort[] | undefined;
3700
+ if (
3701
+ cached &&
3702
+ cached.rect.x === rect.x &&
3703
+ cached.rect.y === rect.y &&
3704
+ cached.rect.width === rect.width &&
3705
+ cached.rect.height === rect.height
3706
+ )
3707
+ ports = cached.ports;
3708
+ else {
3709
+ const orientation = getPlacementOrientation(input);
3710
+ ports = placePorts(
3711
+ node.ports,
3712
+ rect,
3713
+ direction,
3714
+ (candidate) => input.portSettings?.(candidate, node),
3715
+ { ...input.settings, ...input.nodeSettings?.(node) },
3716
+ orientation
3717
+ ? (candidate) => getOrientedPortDirection(input, orientation, node, candidate)
3718
+ : undefined,
3719
+ );
3720
+ placedPorts?.set(node, { rect: { ...rect }, ports });
3721
+ }
3722
+ const port = ports?.find((candidate) => candidate.name === portName);
3092
3723
  if (port?.x === undefined || port.y === undefined) return fallback;
3093
3724
  const settings = input.portSettings?.(port, node);
3094
3725
  const configuredAnchor = settings?.["port.anchor"] as { x?: number; y?: number } | undefined;
@@ -3102,7 +3733,31 @@ function getPortPoint(
3102
3733
  };
3103
3734
  }
3104
3735
 
3105
- function simplifyRoute(points: readonly Point[]): Point[] {
3736
+ /**
3737
+ * Drop interior bends where an orthogonal route doubles back along one line,
3738
+ * so it never retraces itself. Terminal segments keep their port direction.
3739
+ */
3740
+ export function removeRouteSpurs(points: readonly Point[]): readonly Point[] {
3741
+ const equal = (left: number, right: number) => Math.abs(left - right) < 1e-9;
3742
+ const route = points.filter(
3743
+ (point, index) =>
3744
+ index === 0 || !equal(point.x, points[index - 1]!.x) || !equal(point.y, points[index - 1]!.y),
3745
+ );
3746
+ let changed = false;
3747
+ for (let index = 2; index < route.length - 2; index++) {
3748
+ const [previous, middle, next] = [route[index - 1]!, route[index]!, route[index + 1]!];
3749
+ const sameX = equal(previous.x, middle.x) && equal(middle.x, next.x),
3750
+ sameY = equal(previous.y, middle.y) && equal(middle.y, next.y);
3751
+ const axis = sameX ? "y" : sameY ? "x" : undefined;
3752
+ if (!axis || (middle[axis] - previous[axis]) * (next[axis] - middle[axis]) >= 0) continue;
3753
+ route.splice(index, 1);
3754
+ changed = true;
3755
+ index = Math.max(1, index - 2);
3756
+ }
3757
+ return changed ? route : points;
3758
+ }
3759
+
3760
+ export function simplifyRoute(points: readonly Point[]): Point[] {
3106
3761
  const equal = (left: number, right: number) => Math.abs(left - right) < 1e-9;
3107
3762
  const simplified: Point[] = [];
3108
3763
  for (const point of points) {
@@ -3135,6 +3790,7 @@ function implicitEdgeEndpoints(
3135
3790
  const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
3136
3791
  const groups = new Map<string, Array<{ edge: GraphEdge; endpoint: "source" | "target" }>>();
3137
3792
  for (const edge of input.graph.edges) {
3793
+ if (hasMovableLoopPorts(input, edge)) continue;
3138
3794
  const sourceRect = placement.rectByNodeId.get(edge.sourceId);
3139
3795
  const targetRect = placement.rectByNodeId.get(edge.targetId);
3140
3796
  if (!sourceRect || !targetRect) continue;
@@ -3142,7 +3798,16 @@ function implicitEdgeEndpoints(
3142
3798
  const targetFlow = horizontal ? targetRect.x : targetRect.y;
3143
3799
  const modelOrderPromotion =
3144
3800
  input.settings["layering.nodePromotion.strategy"] === "MODEL_ORDER_LEFT_TO_RIGHT";
3145
- const forward = modelOrderPromotion || sourceFlow <= targetFlow;
3801
+ const sourceBoundary = externalPortDummyOf(nodeById.get(edge.sourceId) ?? {});
3802
+ const targetBoundary = externalPortDummyOf(nodeById.get(edge.targetId) ?? {});
3803
+ // A zero-gap boundary can coincide with the node's flow origin.
3804
+ const forward =
3805
+ modelOrderPromotion ||
3806
+ (sourceFlow === targetFlow && (sourceBoundary || targetBoundary)
3807
+ ? sourceBoundary
3808
+ ? sourceBoundary.port.side === "EAST" || sourceBoundary.port.side === "SOUTH"
3809
+ : targetBoundary!.port.side === "WEST" || targetBoundary!.port.side === "NORTH"
3810
+ : sourceFlow <= targetFlow);
3146
3811
  const feedback =
3147
3812
  input.settings.feedbackEdges === true && orientation?.reversedEdgeIds.has(edge.id) === true;
3148
3813
  const directionReversed = input.direction === "left" || input.direction === "up";
@@ -3167,11 +3832,25 @@ function implicitEdgeEndpoints(
3167
3832
  ? "after"
3168
3833
  : undefined;
3169
3834
  };
3835
+ const fixedImplicitSide = (nodeId: string, portName: string | undefined, source: boolean) => {
3836
+ const node = nodeById.get(nodeId);
3837
+ const constraints = node && input.nodeSettings?.(node)?.portConstraints;
3838
+ if (
3839
+ portName !== undefined ||
3840
+ !["FIXED_SIDE", "FIXED_ORDER", "FIXED_RATIO", "FIXED_POS"].includes(String(constraints))
3841
+ )
3842
+ return undefined;
3843
+ // ELK assigns implicit port sides before cycle breaking. Fixed sides
3844
+ // survive edge reversal; a feedback edge cannot move its endpoint.
3845
+ return source !== directionReversed ? "after" : "before";
3846
+ };
3170
3847
  const sourceSide =
3171
3848
  endpointSide(edge.sourceId, edge.sourcePort) ??
3849
+ fixedImplicitSide(edge.sourceId, edge.sourcePort, true) ??
3172
3850
  (feedback ? (directionReversed ? "before" : "after") : forward ? "after" : "before");
3173
3851
  const targetSide =
3174
3852
  endpointSide(edge.targetId, edge.targetPort) ??
3853
+ fixedImplicitSide(edge.targetId, edge.targetPort, false) ??
3175
3854
  (feedback ? (directionReversed ? "after" : "before") : forward ? "before" : "after");
3176
3855
  const sourceKey = `${edge.sourceId}:${sourceSide}`;
3177
3856
  const targetKey = `${edge.targetId}:${targetSide}`;
@@ -3185,6 +3864,32 @@ function implicitEdgeEndpoints(
3185
3864
 
3186
3865
  const result = new Map<string, { source: Point; target: Point }>();
3187
3866
  const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
3867
+ // First positions in swept port orders, as `indexOf` finds them.
3868
+ const orderIndexes = new Map<readonly string[], Map<string, number>>();
3869
+ const orderIndex = (list: readonly string[], id: string) => {
3870
+ let positions = orderIndexes.get(list);
3871
+ if (!positions) {
3872
+ positions = new Map();
3873
+ for (const [index, item] of list.entries())
3874
+ if (!positions.has(item)) positions.set(item, index);
3875
+ orderIndexes.set(list, positions);
3876
+ }
3877
+ return positions.get(id) ?? -1;
3878
+ };
3879
+ const placedPorts = new Map<GraphNode, { rect: EntityRect; ports: GraphPort[] | undefined }>();
3880
+ const portSideCounts = new Map<GraphNode, Map<unknown, number>>();
3881
+ const portSideCount = (node: GraphNode, side: unknown): number => {
3882
+ let counts = portSideCounts.get(node);
3883
+ if (!counts) {
3884
+ counts = new Map();
3885
+ for (const port of node.ports ?? []) {
3886
+ const portSide = input.portSettings?.(port, node)?.["port.side"];
3887
+ counts.set(portSide, (counts.get(portSide) ?? 0) + 1);
3888
+ }
3889
+ portSideCounts.set(node, counts);
3890
+ }
3891
+ return counts.get(side) ?? 0;
3892
+ };
3188
3893
  for (const [key, entries] of groups) {
3189
3894
  const split = key.lastIndexOf(":");
3190
3895
  const nodeId = key.slice(0, split);
@@ -3235,9 +3940,23 @@ function implicitEdgeEndpoints(
3235
3940
  unzipping ||
3236
3941
  interactiveTargetOrder ||
3237
3942
  verticalNoneTargetOrder ||
3238
- (input.settings.hierarchyHandling !== "INCLUDE_CHILDREN" &&
3239
- (crossingStrategy === "LAYER_SWEEP" || crossingStrategy === "MEDIAN_LAYER_SWEEP"));
3943
+ crossingStrategy === "LAYER_SWEEP" ||
3944
+ crossingStrategy === "MEDIAN_LAYER_SWEEP";
3945
+ const sweptOrder = getPlacementOrder(input);
3240
3946
  entries.sort((left, right) => {
3947
+ const leftIncoming =
3948
+ (left.endpoint === "target") !== (orientation?.reversedEdgeIds.has(left.edge.id) ?? false);
3949
+ const rightIncoming =
3950
+ (right.endpoint === "target") !==
3951
+ (orientation?.reversedEdgeIds.has(right.edge.id) ?? false);
3952
+ if (leftIncoming === rightIncoming) {
3953
+ const selected = (
3954
+ leftIncoming ? sweptOrder?.inputPortOrderByNodeId : sweptOrder?.outputPortOrderByNodeId
3955
+ )?.get(nodeId);
3956
+ const a = selected ? orderIndex(selected, left.edge.id) : -1,
3957
+ b = selected ? orderIndex(selected, right.edge.id) : -1;
3958
+ if (a >= 0 && b >= 0 && a !== b) return (leftIncoming ? -1 : 1) * (a - b);
3959
+ }
3241
3960
  if (interactiveLeftSourceOrder) {
3242
3961
  const leftExtreme = interactiveLeftSourceOrder[0] === left.edge.id;
3243
3962
  const rightExtreme = interactiveLeftSourceOrder[0] === right.edge.id;
@@ -3377,6 +4096,32 @@ function implicitEdgeEndpoints(
3377
4096
  ? { ...point, y: Math.round(point.y) }
3378
4097
  : { ...point, x: Math.round(point.x) };
3379
4098
  }
4099
+ const portName = endpoint === "source" ? edge.sourcePort : edge.targetPort;
4100
+ const explicitPort = node?.ports?.find((port) => port.name === portName);
4101
+ const physicalSide =
4102
+ node && explicitPort ? input.portSettings?.(explicitPort, node)?.["port.side"] : undefined;
4103
+ const singleFixedSidePort =
4104
+ constraints === "FIXED_SIDE" &&
4105
+ physicalSide !== undefined &&
4106
+ node?.ports !== undefined &&
4107
+ portSideCount(node, physicalSide) === 1;
4108
+ // Movable port ordering comes from the layer sweep above. Fixed coordinates
4109
+ // and authored anchors must replace the synthesized node-origin fallback.
4110
+ const authoredAnchor =
4111
+ node && explicitPort
4112
+ ? input.portSettings?.(explicitPort, node)?.["port.anchor"]
4113
+ : undefined;
4114
+ if (
4115
+ portName !== undefined &&
4116
+ node &&
4117
+ (constraints === "FIXED_POS" ||
4118
+ constraints === "FIXED_RATIO" ||
4119
+ constraints === "FIXED_ORDER" ||
4120
+ singleFixedSidePort ||
4121
+ authoredAnchor !== undefined)
4122
+ ) {
4123
+ point = getPortPoint(node, portName, rect, point, input.direction, input, placedPorts);
4124
+ }
3380
4125
  const pair = result.get(edge.id) ?? { source: point, target: point };
3381
4126
  pair[endpoint] = point;
3382
4127
  result.set(edge.id, pair);
@@ -3400,6 +4145,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3400
4145
  );
3401
4146
  };
3402
4147
  const pointsByEdgeId = new Map<string, readonly Point[]>();
4148
+ const junctionGroups: OrthogonalJunctionGroup[] = [];
4149
+ const physicalOrder = new Map(
4150
+ [
4151
+ ...input.graph.edges
4152
+ .filter((edge) => !orientation.reversedEdgeIds.has(edge.id))
4153
+ .map((edge) => edge.id),
4154
+ ...orientation.reversedEdgeIds,
4155
+ ].map((id, index) => [id, index]),
4156
+ );
4157
+
3403
4158
  const splineNubControlsByEdgeId = new Map<string, readonly Point[]>();
3404
4159
  const outsideFeedbackEdgeIds = new Set<string>();
3405
4160
  const horizontal = input.direction === "left" || input.direction === "right";
@@ -3417,6 +4172,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3417
4172
  (mutableRects.get(leftId)?.x ?? 0) - (mutableRects.get(rightId)?.x ?? 0),
3418
4173
  );
3419
4174
  for (const [id, loops] of selfLoopEntriesByX) {
4175
+ if (hasPlacementLoopEnvelope(placement, id)) continue;
3420
4176
  const rect = mutableRects.get(id);
3421
4177
  const node = nodeById.get(id);
3422
4178
  if (!rect || !node) continue;
@@ -3479,14 +4235,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3479
4235
  edge: GraphEdge,
3480
4236
  side: string,
3481
4237
  ordering: string,
4238
+ canonical = false,
3482
4239
  ): number => {
3483
- const axis = side === "NORTH" || side === "SOUTH" ? "height" : "width";
4240
+ const crossSide = side === "NORTH" || side === "SOUTH";
4241
+ const axis = crossSide === (!canonical || horizontal) ? "height" : "width";
3484
4242
  const ordered = ordering === "REVERSE_STACKED" ? [...loops].reverse() : loops;
3485
4243
  const index = ordered.indexOf(edge);
3486
4244
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
3487
4245
  const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
3488
4246
  const size = (candidate: GraphEdge) =>
3489
- input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
4247
+ canonical || input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
3490
4248
  ? (candidate[axis] ?? 0)
3491
4249
  : 0;
3492
4250
  return (
@@ -3498,11 +4256,19 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3498
4256
  extent + (size(candidate) > 0 ? size(candidate) + labelSpacing : 0),
3499
4257
  0,
3500
4258
  ) +
3501
- (size(edge) > 0 ? size(edge) / 2 + labelSpacing : 0)
4259
+ (size(edge) > 0 && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
4260
+ ? size(edge) / 2 + labelSpacing
4261
+ : 0)
3502
4262
  );
3503
4263
  };
4264
+ const hasFixedLoopPorts = (node: GraphNode): boolean => {
4265
+ const constraints = String(input.nodeSettings?.(node)?.portConstraints ?? "UNDEFINED");
4266
+ return constraints !== "UNDEFINED" && constraints !== "FREE";
4267
+ };
3504
4268
  const northReserveByLayer = new Map<number, number>();
3505
- for (const [id, loops] of selfLoopsByNodeId) {
4269
+ for (const [id, nodeLoops] of selfLoopsByNodeId) {
4270
+ const loops = nodeLoops.filter(() => !hasPlacementLoopEnvelope(placement, id));
4271
+ if (loops.length === 0) continue;
3506
4272
  const rect = mutableRects.get(id);
3507
4273
  if (!rect) continue;
3508
4274
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
@@ -3518,8 +4284,36 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3518
4284
  y: rect.y + spacing + splineOffset,
3519
4285
  });
3520
4286
  } else {
3521
- const sideLoopCount =
3522
- distribution === "NORTH_SOUTH" ? Math.ceil(loops.length / 2) : loops.length;
4287
+ const northLoops = loops.filter((edge, index) => {
4288
+ if (
4289
+ style === "ORTHOGONAL" &&
4290
+ node &&
4291
+ edge.sourcePort !== undefined &&
4292
+ edge.targetPort !== undefined &&
4293
+ hasFixedLoopPorts(node)
4294
+ ) {
4295
+ const sourcePort = node.ports?.find((port) => port.name === edge.sourcePort);
4296
+ const targetPort = node.ports?.find((port) => port.name === edge.targetPort);
4297
+ return (
4298
+ (sourcePort && input.portSettings?.(sourcePort, node)?.["port.side"] === "NORTH") ||
4299
+ (targetPort && input.portSettings?.(targetPort, node)?.["port.side"] === "NORTH")
4300
+ );
4301
+ }
4302
+ if (
4303
+ style === "ORTHOGONAL" &&
4304
+ node &&
4305
+ (edge.sourcePort === undefined || edge.targetPort === undefined) &&
4306
+ String(nodeSettings?.portConstraints ?? "FREE") !== "FREE" &&
4307
+ String(nodeSettings?.portConstraints ?? "UNDEFINED") !== "UNDEFINED"
4308
+ ) {
4309
+ const port = node.ports?.find(
4310
+ (candidate) => candidate.name === (edge.sourcePort ?? edge.targetPort),
4311
+ );
4312
+ if (port) return input.portSettings?.(port, node)?.["port.side"] === "NORTH";
4313
+ }
4314
+ return distribution !== "NORTH_SOUTH" || index % 2 === 0;
4315
+ });
4316
+ const sideLoopCount = northLoops.length;
3523
4317
  const reserve =
3524
4318
  (ordering === "SEQUENCED" ? Math.min(1, sideLoopCount) : sideLoopCount) * spacing +
3525
4319
  splineOffset;
@@ -3644,6 +4438,50 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3644
4438
  }
3645
4439
  flowLayerByNodeId.set(interval.id, flowLayers.length - 1);
3646
4440
  }
4441
+ const bodyFlowLayers = flowLayers.map((bounds) => ({ ...bounds }));
4442
+ const labelFlowLayers = new Set(
4443
+ [...flowLayerByNodeId]
4444
+ .filter(([id]) => id.startsWith("__layout_dummy:label:"))
4445
+ .map(([, layer]) => layer),
4446
+ );
4447
+
4448
+ // Routing advances past the occupied layer, including protruding ports.
4449
+ // Assign layers from bodies first so port envelopes cannot merge ranks.
4450
+ for (const [id, rect] of placement.rectByNodeId) {
4451
+ const node = nodeById.get(id);
4452
+ const bounds = flowLayers[flowLayerByNodeId.get(id) ?? -1];
4453
+ if (!node || !bounds) continue;
4454
+ const envelope = hasPlacementLoopEnvelope(placement, id)
4455
+ ? preparedLoopEnvelopes(input)?.get(id)
4456
+ : undefined;
4457
+ if (envelope) {
4458
+ const negative = input.direction === "left" || input.direction === "up";
4459
+ const start = horizontal ? rect.x : rect.y;
4460
+ const size = horizontal ? rect.width : rect.height;
4461
+ bounds.start = Math.min(
4462
+ bounds.start,
4463
+ start - (negative ? envelope.flowAfter : envelope.flowBefore),
4464
+ );
4465
+ bounds.end = Math.max(
4466
+ bounds.end,
4467
+ start + size + (negative ? envelope.flowBefore : envelope.flowAfter),
4468
+ );
4469
+ }
4470
+ const ports = placePorts(
4471
+ node.ports,
4472
+ rect,
4473
+ input.direction,
4474
+ (port) => input.portSettings?.(port, node),
4475
+ { ...input.settings, ...input.nodeSettings?.(node) },
4476
+ (port) => getOrientedPortDirection(input, orientation, node, port),
4477
+ );
4478
+ for (const port of ports ?? []) {
4479
+ const start = horizontal ? rect.x + (port.x ?? 0) : rect.y + (port.y ?? 0);
4480
+ const end = start + (horizontal ? (port.width ?? 0) : (port.height ?? 0));
4481
+ bounds.start = Math.min(bounds.start, start);
4482
+ bounds.end = Math.max(bounds.end, end);
4483
+ }
4484
+ }
3647
4485
 
3648
4486
  const increasing = input.direction === "right" || input.direction === "down";
3649
4487
  const flowPortProtrusion = (edge: GraphEdge, endpoint: "source" | "target"): number => {
@@ -3711,7 +4549,9 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3711
4549
  const requiredGap =
3712
4550
  labelFlowSize +
3713
4551
  Math.max(input.spacing.layer, flowPortProtrusion(edge, "source") + edgeNodeSpacing) +
3714
- Math.max(input.spacing.layer, flowPortProtrusion(edge, "target") + edgeNodeSpacing);
4552
+ Math.max(input.spacing.layer, flowPortProtrusion(edge, "target") + edgeNodeSpacing) -
4553
+ flowPortProtrusion(edge, "source") -
4554
+ flowPortProtrusion(edge, "target");
3715
4555
  const gap = Math.min(sourceLayer, targetLayer);
3716
4556
  const availableGap = flowLayers[gap + 1]!.start - flowLayers[gap]!.end;
3717
4557
  const forward = increasing ? targetLayer > sourceLayer : targetLayer < sourceLayer;
@@ -3743,26 +4583,84 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3743
4583
  if (labelShift !== 0) implicitEndpoints = implicitEdgeEndpoints(input, placement, orientation);
3744
4584
 
3745
4585
  const orthogonalTrackByEdgeId = new Map<string, number>();
4586
+ // Cross-axis shift of an edge's attachment at a port carrying both directions.
4587
+ const attachmentShiftByEdgeId = new Map<string, { source?: number; target?: number }>();
4588
+ const attached = (edge: GraphEdge, end: "source" | "target", point: Point): Point => {
4589
+ const shift = attachmentShiftByEdgeId.get(edge.id)?.[end];
4590
+ if (shift === undefined) return point;
4591
+ return horizontal ? { x: point.x, y: point.y + shift } : { x: point.x + shift, y: point.y };
4592
+ };
4593
+ // Tracks given to edges that routing saw as straight, by cross extent.
4594
+ const fallbackTracks: Array<{ track: number; lo: number; hi: number; sign: number }> = [];
4595
+ /**
4596
+ * The gap's middle for an edge routing saw as straight. When its ends
4597
+ * moved apart since, the middle can be the track of an edge heading the
4598
+ * other way: take the nearest track free of those instead.
4599
+ */
4600
+ const freeGapTrack = (edgeId: string, from: number, to: number, a: Point, b: Point) => {
4601
+ const middle = (from + to) / 2;
4602
+ if (style !== "ORTHOGONAL") return middle;
4603
+ const cross = (p: Point) => (horizontal ? p.y : p.x);
4604
+ const lo = Math.min(cross(a), cross(b)),
4605
+ hi = Math.max(cross(a), cross(b)),
4606
+ sign = Math.sign(cross(b) - cross(a));
4607
+ if (hi - lo < 1e-6) return middle;
4608
+ const spans = [
4609
+ ...fallbackTracks,
4610
+ ...[...orthogonalTrackByEdgeId].flatMap(([id, track]) => {
4611
+ const ends = implicitEndpoints.get(id);
4612
+ if (id === edgeId || !ends) return [];
4613
+ const values = [cross(ends.source), cross(ends.target)];
4614
+ return [
4615
+ {
4616
+ track,
4617
+ lo: Math.min(...values),
4618
+ hi: Math.max(...values),
4619
+ sign: Math.sign(values[1]! - values[0]!),
4620
+ },
4621
+ ];
4622
+ }),
4623
+ ];
4624
+ const spacing = Number(input.settings["spacing.edgeEdgeBetweenLayers"] ?? 10) / 2;
4625
+ const free = (track: number) =>
4626
+ !spans.some(
4627
+ (span) =>
4628
+ span.sign === -sign &&
4629
+ Math.abs(span.track - track) < 1e-6 &&
4630
+ Math.min(span.hi, hi) - Math.max(span.lo, lo) > 1e-6,
4631
+ );
4632
+ let chosen = middle;
4633
+ for (
4634
+ let step = 1;
4635
+ !free(chosen) && step <= 2 * Math.floor((to - from) / 2 / spacing);
4636
+ step++
4637
+ ) {
4638
+ const candidate = middle + (step % 2 ? -1 : 1) * Math.ceil(step / 2) * spacing;
4639
+ if (candidate > from && candidate < to && free(candidate)) chosen = candidate;
4640
+ }
4641
+ fallbackTracks.push({ track: chosen, lo, hi, sign });
4642
+ return chosen;
4643
+ };
3746
4644
  const orthogonalDetourByEdgeId = new Map<
3747
4645
  string,
3748
4646
  { firstTrack: number; secondTrack: number; crossover: number }
3749
4647
  >();
3750
4648
  if (
3751
4649
  style === "ORTHOGONAL" &&
3752
- input.settings.mergeEdges !== true &&
3753
4650
  (input.settings["wrapping.strategy"] ?? "OFF") === "OFF" &&
3754
- input.settings.hierarchyHandling !== "INCLUDE_CHILDREN" &&
3755
4651
  (input.settings["layerUnzipping.strategy"] ?? "NONE") === "NONE"
3756
4652
  ) {
3757
4653
  const edgeNodeSpacing = Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10);
3758
4654
  const edgeEdgeSpacing = Number(input.settings["spacing.edgeEdgeBetweenLayers"] ?? 10);
3759
- const candidatesByGap = flowLayers.slice(0, -1).map(
4655
+ const candidatesByGap = Array.from(
4656
+ { length: flowLayers.length + 1 },
3760
4657
  () =>
3761
4658
  [] as Array<{
3762
4659
  edge: GraphEdge;
3763
4660
  sourceCross: number;
3764
4661
  targetCross: number;
3765
4662
  straight: boolean;
4663
+ sameLayerPortSide?: "source" | "target";
3766
4664
  slot?: number;
3767
4665
  secondSlot?: number;
3768
4666
  crossover?: number;
@@ -3776,30 +4674,78 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3776
4674
  endpoints === undefined ||
3777
4675
  sourceLayer === undefined ||
3778
4676
  targetLayer === undefined ||
3779
- Math.abs(sourceLayer - targetLayer) !== 1
4677
+ Math.abs(sourceLayer - targetLayer) > 1
3780
4678
  ) {
3781
4679
  continue;
3782
4680
  }
4681
+ const sameLayer = sourceLayer === targetLayer;
4682
+ let positiveFace = true;
4683
+ if (sameLayer) {
4684
+ if (edge.sourceId === edge.targetId) continue;
4685
+ const positiveSide = horizontal ? "EAST" : "SOUTH";
4686
+ const face = (id: string, name: string | undefined) => {
4687
+ const node = nodeById.get(id)!;
4688
+ const port = node.ports?.find((port) => port.name === name);
4689
+ if (port) return input.portSettings?.(port, node)?.["port.side"];
4690
+ // A merged hyperedge dummy keeps an inverted dummy's in-layer
4691
+ // edge, whose dummy ports face backward (input) or forward (output).
4692
+ if (!id.startsWith("__layout_dummy:") || (name !== "input" && name !== "output"))
4693
+ return undefined;
4694
+ const forward = { right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" }[
4695
+ input.direction
4696
+ ]!;
4697
+ const backward = { EAST: "WEST", WEST: "EAST", SOUTH: "NORTH", NORTH: "SOUTH" }[
4698
+ forward
4699
+ ]!;
4700
+ return name === "output" ? forward : backward;
4701
+ };
4702
+ const negativeSide = horizontal ? "WEST" : "NORTH";
4703
+ const sourceFace = face(edge.sourceId, edge.sourcePort);
4704
+ if (
4705
+ sourceFace !== face(edge.targetId, edge.targetPort) ||
4706
+ (sourceFace !== positiveSide && sourceFace !== negativeSide)
4707
+ )
4708
+ continue;
4709
+ positiveFace = sourceFace === positiveSide;
4710
+ }
3783
4711
  const graphSourceCross = horizontal ? endpoints.source.y : endpoints.source.x;
3784
4712
  const graphTargetCross = horizontal ? endpoints.target.y : endpoints.target.x;
3785
- const sourceBeforeTarget = sourceLayer < targetLayer;
4713
+ const sourceBeforeTarget = sameLayer
4714
+ ? !orientation.reversedEdgeIds.has(edge.id)
4715
+ : sourceLayer < targetLayer;
3786
4716
  const sourceCross = sourceBeforeTarget ? graphSourceCross : graphTargetCross;
3787
4717
  const targetCross = sourceBeforeTarget ? graphTargetCross : graphSourceCross;
3788
- candidatesByGap[Math.min(sourceLayer, targetLayer)]?.push({
4718
+ const gap = sameLayer
4719
+ ? sourceLayer - (positiveFace ? 0 : 1)
4720
+ : Math.min(sourceLayer, targetLayer);
4721
+ candidatesByGap[gap + 1]?.push({
3789
4722
  edge,
4723
+ ...(sameLayer
4724
+ ? {
4725
+ sameLayerPortSide:
4726
+ increasing === positiveFace ? ("source" as const) : ("target" as const),
4727
+ }
4728
+ : {}),
3790
4729
  sourceCross,
3791
4730
  targetCross,
3792
4731
  straight: Math.abs(sourceCross - targetCross) < 1e-3,
3793
4732
  });
3794
4733
  }
3795
4734
 
4735
+ const routingPlacement = routingCoordinates(placement);
4736
+ const rawEndpoints = implicitEdgeEndpoints(input, routingPlacement, orientation);
3796
4737
  const slotsByGap = candidatesByGap.map((candidates) => {
3797
4738
  if (candidates.length === 0) return 0;
3798
4739
  // ELK creates hyperedge segments by walking layer nodes and their ports,
3799
4740
  // so dependency-cycle tie breaking observes cross-axis order.
3800
4741
  candidates.sort(
3801
4742
  (left, right) =>
3802
- left.sourceCross - right.sourceCross || left.targetCross - right.targetCross,
4743
+ (increasing
4744
+ ? left.sourceCross - right.sourceCross
4745
+ : left.targetCross - right.targetCross) ||
4746
+ (increasing
4747
+ ? left.targetCross - right.targetCross
4748
+ : left.sourceCross - right.sourceCross),
3803
4749
  );
3804
4750
  const minimumDifference = (values: readonly number[]) => {
3805
4751
  const distinct = [...new Set(values)].sort((left, right) => left - right);
@@ -3809,261 +4755,291 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3809
4755
  }
3810
4756
  return minimum;
3811
4757
  };
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}`);
4758
+ const ports: OrthogonalPort[] = [];
4759
+ const byPort = new Map<string, OrthogonalPort>();
4760
+ const portByCandidate = new Map<
4761
+ (typeof candidates)[number],
4762
+ { source: string; target: string }
4763
+ >();
4764
+ const physicalPort = (
4765
+ candidate: (typeof candidates)[number],
4766
+ side: "source" | "target",
4767
+ ) => {
4768
+ const edge = candidate.edge;
4769
+ const sourceIsBefore =
4770
+ flowLayerByNodeId.get(edge.sourceId)! < flowLayerByNodeId.get(edge.targetId)!;
4771
+ const graphSource = candidate.sameLayerPortSide
4772
+ ? (side === "source") !== orientation.reversedEdgeIds.has(edge.id)
4773
+ : (side === "source") === (sourceIsBefore === increasing);
4774
+ const nodeId = graphSource ? edge.sourceId : edge.targetId;
4775
+ const name = graphSource ? edge.sourcePort : edge.targetPort;
4776
+ return { graphSource, nodeId, name };
3830
4777
  };
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;
4778
+ // A port whose edges leave and enter it in this gap gives each
4779
+ // direction its own hypersegment: opposite directions never share a track.
4780
+ const directionsByPort = new Map<string, Set<boolean>>();
4781
+ for (const candidate of candidates)
4782
+ for (const side of ["source", "target"] as const) {
4783
+ const { graphSource, nodeId, name } = physicalPort(candidate, side);
4784
+ if (name === undefined || isMergedHyperedgeDummy(input, nodeId)) continue;
4785
+ const key = JSON.stringify([nodeId, name]);
4786
+ let directions = directionsByPort.get(key);
4787
+ if (!directions) directionsByPort.set(key, (directions = new Set()));
4788
+ directions.add(graphSource);
4789
+ }
4790
+ // The cross coordinate where a candidate meets this gap on `side`.
4791
+ const endPosition = (candidate: (typeof candidates)[number], side: "source" | "target") => {
4792
+ const position = candidate.sameLayerPortSide
4793
+ ? side === "source"
4794
+ ? candidate.sourceCross
4795
+ : candidate.targetCross
4796
+ : (side === "source") === increasing
4797
+ ? candidate.sourceCross
4798
+ : candidate.targetCross;
4799
+ const endpoints = rawEndpoints.get(candidate.edge.id);
4800
+ const sourceBefore =
4801
+ flowLayerByNodeId.get(candidate.edge.sourceId)! <
4802
+ flowLayerByNodeId.get(candidate.edge.targetId)!;
4803
+ const rawPoint =
4804
+ endpoints &&
4805
+ ((
4806
+ candidate.sameLayerPortSide
4807
+ ? (side === "source") !== orientation.reversedEdgeIds.has(candidate.edge.id)
4808
+ : (side === "source") === (sourceBefore === increasing)
4809
+ )
4810
+ ? endpoints.source
4811
+ : endpoints.target);
4812
+ return rawPoint ? (horizontal ? rawPoint.y : rawPoint.x) : position;
3835
4813
  };
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);
4814
+ /**
4815
+ * At a port carrying both directions, the edges fan out from separate
4816
+ * attachments. Ordered by where their far ends lie (back along the
4817
+ * port's own boundary on its near side first, then across the gap,
4818
+ * then back along the boundary on its far side), the edges nest
4819
+ * without crossing; consecutive edges of one direction share an
4820
+ * attachment and a hypersegment. Routing sees each attachment where
4821
+ * it will be, so the track order respects the split.
4822
+ */
4823
+ const runByEnd = new Map<string, number>();
4824
+ const runShift = new Map<string, number>();
4825
+ if (style === "ORTHOGONAL")
4826
+ for (const [key, directions] of directionsByPort) {
4827
+ if (directions.size < 2) continue;
4828
+ const [nodeId, name] = JSON.parse(key) as [string, string];
4829
+ const ends = candidates.flatMap((candidate, index) =>
4830
+ (["source", "target"] as const).flatMap((side) => {
4831
+ const port = physicalPort(candidate, side);
4832
+ if (port.nodeId !== nodeId || port.name !== name) return [];
4833
+ const other = side === "source" ? "target" : "source";
4834
+ return [
4835
+ {
4836
+ end: `${index}:${side}`,
4837
+ graphSource: port.graphSource,
4838
+ at: endPosition(candidate, side),
4839
+ far: endPosition(candidate, other),
4840
+ near: candidate.sameLayerPortSide !== undefined,
4841
+ },
4842
+ ];
4843
+ }),
4844
+ );
4845
+ const at = ends[0]!.at;
4846
+ if (ends.some((end) => Math.abs(end.at - at) > 1e-6)) continue;
4847
+ const group = (end: (typeof ends)[number]) => (!end.near ? 1 : end.far < at ? 0 : 2);
4848
+ const sorted = [...ends].sort(
4849
+ (a, b) =>
4850
+ group(a) - group(b) ||
4851
+ (group(a) === 1 ? a.far - b.far : b.far - a.far) ||
4852
+ Number(a.graphSource) - Number(b.graphSource),
4853
+ );
4854
+ let runs = 0;
4855
+ for (const [index, end] of sorted.entries()) {
4856
+ if (index && end.graphSource !== sorted[index - 1]!.graphSource) runs++;
4857
+ runByEnd.set(end.end, runs);
3871
4858
  }
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
- }
4859
+ const count = runs + 1;
4860
+ // A run holding an edge straight across the gap stays in place.
4861
+ const straight = new Set(
4862
+ sorted
4863
+ .filter((end) => !end.near && Math.abs(end.far - at) < 1e-3)
4864
+ .map((end) => runByEnd.get(end.end)!),
4865
+ );
4866
+ const anchor = straight.size === 1 ? [...straight][0]! : (count - 1) / 2;
4867
+ const rect = routingPlacement.rectByNodeId.get(nodeId);
4868
+ const low = rect ? (horizontal ? rect.y : rect.x) : -Infinity,
4869
+ high = rect ? low + (horizontal ? rect.height : rect.width) : Infinity;
4870
+ // Attachments stay on the node's side, closer together when it is short.
4871
+ let step = edgeEdgeSpacing;
4872
+ for (const run of [0, count - 1]) {
4873
+ const offset = run - anchor;
4874
+ if (offset < 0) step = Math.min(step, (at - low) / -offset);
4875
+ if (offset > 0) step = Math.min(step, (high - at) / offset);
3892
4876
  }
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;
4877
+ if (!(step >= 2)) {
4878
+ for (const end of ends) runByEnd.delete(end.end);
4879
+ continue;
3918
4880
  }
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
- }
4881
+ for (let run = 0; run < count; run++)
4882
+ runShift.set(JSON.stringify(["port", nodeId, name, run]), (run - anchor) * step);
4883
+ for (const end of ends) {
4884
+ const index = end.end.split(":")[0]!;
4885
+ const shift = (runByEnd.get(end.end)! - anchor) * step;
4886
+ if (Math.abs(shift) < 1e-9) continue;
4887
+ const id = candidates[Number(index)]!.edge.id;
4888
+ const record = attachmentShiftByEdgeId.get(id) ?? {};
4889
+ record[end.graphSource ? "source" : "target"] = shift;
4890
+ attachmentShiftByEdgeId.set(id, record);
3936
4891
  }
3937
4892
  }
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;
4893
+ const portKey = (candidate: (typeof candidates)[number], side: "source" | "target") => {
4894
+ const edge = candidate.edge;
4895
+ const { graphSource, nodeId, name } = physicalPort(candidate, side);
4896
+ const node = nodeById.get(nodeId)!;
4897
+ if (isMergedHyperedgeDummy(input, nodeId))
4898
+ return JSON.stringify(["hyperedge", nodeId, candidate.sameLayerPortSide ?? side]);
4899
+ const run = runByEnd.get(`${candidates.indexOf(candidate)}:${side}`);
4900
+ if (name !== undefined && run !== undefined)
4901
+ return JSON.stringify(["port", nodeId, name, run]);
4902
+ if (name !== undefined)
4903
+ return directionsByPort.get(JSON.stringify([nodeId, name]))!.size > 1
4904
+ ? JSON.stringify(["port", nodeId, name, graphSource])
4905
+ : JSON.stringify(["port", nodeId, name]);
4906
+ if (input.settings.mergeEdges === true || input.nodeSettings?.(node)?.hypernode === true)
4907
+ return JSON.stringify(["implicit", nodeId, candidate.sameLayerPortSide ?? side]);
4908
+ return JSON.stringify(["edge", edge.id, side]);
4909
+ };
4910
+ for (const side of ["source", "target"] as const)
4911
+ for (const candidate of candidates) {
4912
+ const key = portKey(candidate, side);
4913
+ if (!byPort.has(key)) {
4914
+ const port = {
4915
+ id: key,
4916
+ side: candidate.sameLayerPortSide ?? side,
4917
+ position: endPosition(candidate, side) + (runShift.get(key) ?? 0),
4918
+ };
4919
+ byPort.set(key, port);
4920
+ ports.push(port);
3959
4921
  }
3960
4922
  }
3961
- const sources = inWeight.flatMap((weight, index) =>
3962
- weight === 0 && (outWeight[index] ?? 0) > 0 ? [index] : [],
4923
+ for (const candidate of candidates)
4924
+ portByCandidate.set(candidate, {
4925
+ source: portKey(candidate, "source"),
4926
+ target: portKey(candidate, "target"),
4927
+ });
4928
+ const edgeLists = getCrossingUnits(input)?.edgeListRanks;
4929
+ const listedId = (edgeId: string) => edgeId.replace(/(::(segment|inverted):\d+:*)+$/, "");
4930
+ const grouped = createOrthogonalHypersegments(
4931
+ ports,
4932
+ candidates.map((candidate) => portByCandidate.get(candidate)!),
4933
+ (index, list) => {
4934
+ const { edge } = candidates[index]!;
4935
+ const reversed = orientation.reversedEdgeIds.has(edge.id);
4936
+ // The port side that lists this connection: its source lists it as outgoing.
4937
+ const node = (list === "outgoing") !== reversed ? edge.sourceId : edge.targetId;
4938
+ return edgeLists?.[list].get(node)?.get(listedId(edge.id));
4939
+ },
4940
+ );
4941
+ const criticalThreshold =
4942
+ 0.2 *
4943
+ Math.min(
4944
+ minimumDifference(grouped.segments.flatMap((segment) => segment.incoming)),
4945
+ minimumDifference(grouped.segments.flatMap((segment) => segment.outgoing)),
3963
4946
  );
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]);
4947
+ const random =
4948
+ phaseRandomByInput.get(input) ?? new JavaRandom(input.settings.randomSeed ?? 1);
4949
+ const result = routeOrthogonalSegments(
4950
+ grouped.segments,
4951
+ 0.5 * edgeEdgeSpacing,
4952
+ criticalThreshold,
4953
+ random,
4954
+ );
4955
+ const junctionGroup: (typeof junctionGroups)[number] = [];
4956
+ for (const group of grouped.segments) {
4957
+ for (const port of group.ports) {
4958
+ const outgoing = candidates.filter(
4959
+ (candidate) => portByCandidate.get(candidate)!.source === port,
4960
+ );
4961
+ // Merging adjacent helpers concatenates their physical incident
4962
+ // edges in retained layer order; root edge order loses that state.
4963
+ const listedOutgoing = getCrossingUnits(input)?.edgeListRanks?.outgoing;
4964
+ const listed = (edgeId: string) => edgeId.replace(/(::(segment|inverted):\d+:*)+$/, "");
4965
+ outgoing.sort((a, b) => {
4966
+ // ELK visits a port's outgoing edges in list order, which records
4967
+ // reversal history; edges from dummies keep the fallbacks below.
4968
+ const sourceOf = (candidate: (typeof outgoing)[number]) =>
4969
+ orientation.reversedEdgeIds.has(candidate.edge.id)
4970
+ ? candidate.edge.targetId
4971
+ : candidate.edge.sourceId;
4972
+ const ranks =
4973
+ sourceOf(a) === sourceOf(b) ? listedOutgoing?.get(sourceOf(a)) : undefined;
4974
+ const ra = ranks?.get(listed(a.edge.id)),
4975
+ rb = ranks?.get(listed(b.edge.id));
4976
+ if (ra !== undefined && rb !== undefined) return ra - rb;
4977
+ // Merging appends a lower dummy's edges after the kept dummy's.
4978
+ if (sourceOf(a) === sourceOf(b) && isMergedHyperedgeDummy(input, sourceOf(a))) {
4979
+ const merged = getPlacementOrder(input)?.outputPortOrderByNodeId?.get(sourceOf(a));
4980
+ const ma = merged?.indexOf(a.edge.id) ?? -1,
4981
+ mb = merged?.indexOf(b.edge.id) ?? -1;
4982
+ if (ma >= 0 && mb >= 0) return ma - mb;
4983
+ }
4984
+ // Reversing incoming edges appends them after retained outgoing
4985
+ // edges on the physical port, regardless of original model order.
4986
+ const reverseOrder =
4987
+ Number(orientation.reversedEdgeIds.has(a.edge.id)) -
4988
+ Number(orientation.reversedEdgeIds.has(b.edge.id));
4989
+ if (reverseOrder) return reverseOrder;
4990
+ const source = orientation.reversedEdgeIds.has(a.edge.id)
4991
+ ? a.edge.targetId
4992
+ : a.edge.sourceId;
4993
+ const selected = getPlacementOrder(input)?.outputPortOrderByNodeId?.get(source);
4994
+ const aa = selected?.indexOf(a.edge.id) ?? -1;
4995
+ const bb = selected?.indexOf(b.edge.id) ?? -1;
4996
+ return aa >= 0 && bb >= 0
4997
+ ? aa - bb
4998
+ : physicalOrder.get(a.edge.id)! - physicalOrder.get(b.edge.id)!;
4999
+ });
5000
+ for (const candidate of outgoing) {
5001
+ const segment = result.segments[grouped.segmentByPort.get(port)!]!;
5002
+ const reversed = orientation.reversedEdgeIds.has(candidate.edge.id);
5003
+ junctionGroup.push({
5004
+ edgeId: candidate.edge.id,
5005
+ reversed,
5006
+ segment,
5007
+ sourceValue: byPort.get(portByCandidate.get(candidate)!.source)!.position,
5008
+ targetValue: byPort.get(portByCandidate.get(candidate)!.target)!.position,
5009
+ partner:
5010
+ segment.partner === undefined ? undefined : result.segments[segment.partner],
5011
+ });
4028
5012
  }
4029
5013
  }
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
5014
  }
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
- }
5015
+ junctionGroups.push(junctionGroup);
5016
+ let maximumSlot = -1;
5017
+ for (const segment of result.segments) {
5018
+ if (Math.abs(segment.start - segment.end) >= 1e-3)
5019
+ maximumSlot = Math.max(maximumSlot, segment.slot);
4054
5020
  }
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;
5021
+ for (const candidate of candidates) {
5022
+ const segment =
5023
+ result.segments[grouped.segmentByPort.get(portByCandidate.get(candidate)!.source)!]!;
5024
+ candidate.straight = Math.abs(segment.start - segment.end) < 1e-3;
5025
+ candidate.slot = increasing ? segment.slot : maximumSlot - segment.slot;
5026
+ if (segment.partner !== undefined) {
5027
+ // The route leaves the graph source on the half holding that end.
5028
+ const partner = result.segments[segment.partner]!;
5029
+ const [first, second] = physicalPort(candidate, "source").graphSource
5030
+ ? [segment, partner]
5031
+ : [partner, segment];
5032
+ candidate.slot = increasing ? first.slot : maximumSlot - first.slot;
5033
+ candidate.secondSlot = increasing ? second.slot : maximumSlot - second.slot;
5034
+ const sourcePort = portByCandidate.get(candidate)!.source;
5035
+ const offset =
5036
+ (increasing ? candidate.sourceCross : candidate.targetCross) -
5037
+ byPort.get(sourcePort)!.position +
5038
+ (runShift.get(sourcePort) ?? 0);
5039
+ candidate.crossover = segment.outgoing[0]! + offset;
4064
5040
  }
4065
5041
  }
4066
- return nonStraight.length === 0 ? 0 : maximumSlot + 1;
5042
+ return maximumSlot + 1;
4067
5043
  });
4068
5044
 
4069
5045
  const existingGapByLayer = flowLayers
@@ -4080,7 +5056,26 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4080
5056
  input.nodeSettings?.(node)?.["nodePlacement.networkSimplex.nodeFlexibility"] !==
4081
5057
  "NONE",
4082
5058
  ));
4083
- let nextStart = flowLayers[0]?.start ?? 0;
5059
+ const leadingSlots = slotsByGap[0] ?? 0;
5060
+ const leadingWidth =
5061
+ leadingSlots > 0 ? edgeNodeSpacing + (leadingSlots - 1) * edgeEdgeSpacing : 0;
5062
+ let nextStart = (flowLayers[0]?.start ?? 0) + leadingWidth;
5063
+ // ELK omits ordinary node spacing beside an external-port-only layer.
5064
+ const externalFlowLayers = new Set(
5065
+ flowLayers.flatMap((_, layer) => {
5066
+ const ids = [...flowLayerByNodeId].filter(([, index]) => index === layer);
5067
+ return ids.length &&
5068
+ ids.every(([id]) => {
5069
+ const node = nodeById.get(id);
5070
+ const side = node && externalPortDummyOf(node)?.side;
5071
+ return horizontal
5072
+ ? side === "WEST" || side === "EAST"
5073
+ : side === "NORTH" || side === "SOUTH";
5074
+ })
5075
+ ? [layer]
5076
+ : [];
5077
+ }),
5078
+ );
4084
5079
  for (const [layerNo, bounds] of flowLayers.entries()) {
4085
5080
  const shift = nextStart - bounds.start;
4086
5081
  for (const [id, rect] of placement.rectByNodeId) {
@@ -4093,23 +5088,44 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4093
5088
  const size = bounds.end - bounds.start;
4094
5089
  bounds.start = nextStart;
4095
5090
  bounds.end = nextStart + size;
4096
- const slots = slotsByGap[layerNo] ?? 0;
4097
- const routesNearTarget = candidatesByGap[layerNo]?.some(
5091
+ const slots = slotsByGap[layerNo + 1] ?? 0;
5092
+ const routesNearTarget = candidatesByGap[layerNo + 1]?.some(
4098
5093
  (candidate) =>
4099
5094
  hasZeroFixedSideTarget(candidate.edge) &&
4100
- candidatesByGap[layerNo]!.filter(
5095
+ candidatesByGap[layerNo + 1]!.filter(
4101
5096
  ({ edge }) => edge.targetId === candidate.edge.targetId,
4102
5097
  ).length >
4103
- candidatesByGap[layerNo]!.filter(
5098
+ candidatesByGap[layerNo + 1]!.filter(
4104
5099
  ({ edge }) => edge.sourceId === candidate.edge.sourceId,
4105
5100
  ).length,
4106
5101
  );
4107
5102
  const preservedGap =
4108
5103
  (existingGapByLayer[layerNo] ?? input.spacing.layer) -
4109
5104
  (routesNearTarget ? edgeEdgeSpacing : 0);
5105
+ const labelAdjacent = labelFlowLayers.has(layerNo) || labelFlowLayers.has(layerNo + 1);
5106
+ const portMargin =
5107
+ flowLayers[layerNo]!.end -
5108
+ flowLayers[layerNo]!.start -
5109
+ (bodyFlowLayers[layerNo]!.end - bodyFlowLayers[layerNo]!.start) +
5110
+ (flowLayers[layerNo + 1]
5111
+ ? flowLayers[layerNo + 1]!.end -
5112
+ flowLayers[layerNo + 1]!.start -
5113
+ (bodyFlowLayers[layerNo + 1]!.end - bodyFlowLayers[layerNo + 1]!.start)
5114
+ : 0);
5115
+ const externalBoundary =
5116
+ externalFlowLayers.has(layerNo) || externalFlowLayers.has(layerNo + 1);
5117
+ const minimumGap = externalBoundary
5118
+ ? 0
5119
+ : labelAdjacent
5120
+ ? Math.max(edgeNodeSpacing, input.spacing.layer - portMargin)
5121
+ : input.spacing.layer;
4110
5122
  const gapSpacing =
4111
5123
  slots === 0
4112
- ? (existingGapByLayer[layerNo] ?? input.spacing.layer)
5124
+ ? externalBoundary
5125
+ ? 0
5126
+ : labelAdjacent
5127
+ ? minimumGap
5128
+ : (existingGapByLayer[layerNo] ?? input.spacing.layer)
4113
5129
  : Math.max(
4114
5130
  preservesNodeFlexibilityGap ||
4115
5131
  (labelExtraByGap[layerNo] ?? 0) > 0 ||
@@ -4123,14 +5139,22 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4123
5139
  ),
4124
5140
  )
4125
5141
  ? preservedGap
4126
- : input.spacing.layer,
5142
+ : minimumGap,
5143
+ // LABEL nodes also need clearance beyond the final routing track.
4127
5144
  2 * edgeNodeSpacing + Math.max(0, slots - 1) * edgeEdgeSpacing,
4128
5145
  );
4129
5146
  nextStart = bounds.end + gapSpacing;
4130
5147
  }
4131
5148
  implicitEndpoints = implicitEdgeEndpoints(input, placement, orientation);
4132
5149
 
4133
- for (const [gap, candidates] of candidatesByGap.entries()) {
5150
+ for (const [boundary, candidates] of candidatesByGap.entries()) {
5151
+ const gap = boundary - 1;
5152
+ const firstBoundaryTrack =
5153
+ gap < 0
5154
+ ? (flowLayers[0]?.start ?? 0) -
5155
+ edgeNodeSpacing -
5156
+ Math.max(0, leadingSlots - 1) * edgeEdgeSpacing
5157
+ : (flowLayers[gap]?.end ?? 0) + edgeNodeSpacing;
4134
5158
  for (const candidate of candidates) {
4135
5159
  if (candidate.straight) continue;
4136
5160
  const routeNearTarget =
@@ -4142,16 +5166,11 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4142
5166
  ? (flowLayers[gap + 1]?.start ?? 0) -
4143
5167
  edgeNodeSpacing -
4144
5168
  (candidate.slot ?? 0) * edgeEdgeSpacing
4145
- : (flowLayers[gap]?.end ?? 0) +
4146
- edgeNodeSpacing +
4147
- (candidate.slot ?? 0) * edgeEdgeSpacing;
5169
+ : firstBoundaryTrack + (candidate.slot ?? 0) * edgeEdgeSpacing;
4148
5170
  if (candidate.secondSlot !== undefined && candidate.crossover !== undefined) {
4149
5171
  orthogonalDetourByEdgeId.set(candidate.edge.id, {
4150
5172
  firstTrack,
4151
- secondTrack:
4152
- (flowLayers[gap]?.end ?? 0) +
4153
- edgeNodeSpacing +
4154
- candidate.secondSlot * edgeEdgeSpacing,
5173
+ secondTrack: firstBoundaryTrack + candidate.secondSlot * edgeEdgeSpacing,
4155
5174
  crossover: candidate.crossover,
4156
5175
  });
4157
5176
  } else {
@@ -4543,18 +5562,101 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4543
5562
  const feedbackTargetPortSide = feedbackTargetPort
4544
5563
  ? input.portSettings?.(feedbackTargetPort, target)?.["port.side"]
4545
5564
  : undefined;
5565
+ // A merged hyperedge dummy keeps an inverted dummy's in-layer edge,
5566
+ // whose dummy ports face backward (input) or forward (output).
5567
+ const mergedFace = (id: string, name: string | undefined) => {
5568
+ if (!isMergedHyperedgeDummy(input, id) || (name !== "input" && name !== "output"))
5569
+ return undefined;
5570
+ const forward = { right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" }[
5571
+ input.direction
5572
+ ]!;
5573
+ const backward = { EAST: "WEST", WEST: "EAST", SOUTH: "NORTH", NORTH: "SOUTH" }[forward]!;
5574
+ return name === "output" ? forward : backward;
5575
+ };
5576
+ const inLayerSourceSide = feedbackSourcePortSide ?? mergedFace(source.id, edge.sourcePort);
5577
+ const inLayerTargetSide = feedbackTargetPortSide ?? mergedFace(target.id, edge.targetPort);
5578
+ // Inverted-port dummies keep the turn in the node's own layer. Both
5579
+ // terminals face the same exterior corridor; this is not an outer
5580
+ // feedback loop around the entire graph.
5581
+ if (
5582
+ style === "ORTHOGONAL" &&
5583
+ (source.id.startsWith("__layout_dummy:inverted:") ||
5584
+ target.id.startsWith("__layout_dummy:inverted:") ||
5585
+ isMergedHyperedgeDummy(input, source.id) ||
5586
+ isMergedHyperedgeDummy(input, target.id)) &&
5587
+ flowLayerByNodeId.get(source.id) === flowLayerByNodeId.get(target.id) &&
5588
+ inLayerSourceSide === inLayerTargetSide &&
5589
+ inLayerSourceSide !== undefined
5590
+ ) {
5591
+ const sourceFallback = implicitEndpoints.get(edge.id)?.source ?? {
5592
+ x: sourceRect.x,
5593
+ y: sourceRect.y,
5594
+ };
5595
+ const targetFallback = implicitEndpoints.get(edge.id)?.target ?? {
5596
+ x: targetRect.x,
5597
+ y: targetRect.y,
5598
+ };
5599
+ const start = attached(
5600
+ edge,
5601
+ "source",
5602
+ getPortPoint(source, edge.sourcePort, sourceRect, sourceFallback, input.direction, input),
5603
+ );
5604
+ const end = attached(
5605
+ edge,
5606
+ "target",
5607
+ getPortPoint(target, edge.targetPort, targetRect, targetFallback, input.direction, input),
5608
+ );
5609
+ if (reversedEdge) outsideFeedbackEdgeIds.add(edge.id);
5610
+ const side = inLayerSourceSide;
5611
+ const spacing = Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10);
5612
+ // Clear the whole occupied layer, including ports and self-loop
5613
+ // reservations; endpoint anchors alone omit those margins.
5614
+ const occupied = flowLayers[flowLayerByNodeId.get(source.id) ?? -1];
5615
+ const track =
5616
+ orthogonalTrackByEdgeId.get(edge.id) ??
5617
+ (side === "WEST"
5618
+ ? (horizontal
5619
+ ? (occupied?.start ?? Math.min(start.x, end.x))
5620
+ : Math.min(start.x, end.x)) - spacing
5621
+ : side === "EAST"
5622
+ ? (horizontal
5623
+ ? (occupied?.end ?? Math.max(start.x, end.x))
5624
+ : Math.max(start.x, end.x)) + spacing
5625
+ : side === "NORTH"
5626
+ ? (!horizontal
5627
+ ? (occupied?.start ?? Math.min(start.y, end.y))
5628
+ : Math.min(start.y, end.y)) - spacing
5629
+ : (!horizontal
5630
+ ? (occupied?.end ?? Math.max(start.y, end.y))
5631
+ : Math.max(start.y, end.y)) + spacing);
5632
+ pointsByEdgeId.set(
5633
+ edge.id,
5634
+ simplifyRoute(
5635
+ side === "WEST" || side === "EAST"
5636
+ ? [start, { x: track, y: start.y }, { x: track, y: end.y }, end]
5637
+ : [start, { x: start.x, y: track }, { x: end.x, y: track }, end],
5638
+ ),
5639
+ );
5640
+ continue;
5641
+ }
5642
+ const sourceFlow = horizontal ? sourceRect.x : sourceRect.y;
5643
+ const targetFlow = horizontal ? targetRect.x : targetRect.y;
5644
+ const sourceBeforeTarget =
5645
+ sourceFlow < targetFlow ||
5646
+ (sourceFlow === targetFlow &&
5647
+ (flowLayerByNodeId.get(source.id) ?? 0) < (flowLayerByNodeId.get(target.id) ?? 0));
4546
5648
  const sourceAwaySide = horizontal
4547
- ? sourceRect.x < targetRect.x
5649
+ ? sourceBeforeTarget
4548
5650
  ? "WEST"
4549
5651
  : "EAST"
4550
- : sourceRect.y < targetRect.y
5652
+ : sourceBeforeTarget
4551
5653
  ? "NORTH"
4552
5654
  : "SOUTH";
4553
5655
  const targetAwaySide = horizontal
4554
- ? sourceRect.x < targetRect.x
5656
+ ? sourceBeforeTarget
4555
5657
  ? "EAST"
4556
5658
  : "WEST"
4557
- : sourceRect.y < targetRect.y
5659
+ : sourceBeforeTarget
4558
5660
  ? "SOUTH"
4559
5661
  : "NORTH";
4560
5662
  const sameSideSelfLoop =
@@ -4565,6 +5667,63 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4565
5667
  const constraints = String(input.nodeSettings?.(node)?.portConstraints ?? "UNDEFINED");
4566
5668
  return constraints !== "UNDEFINED" && constraints !== "FREE";
4567
5669
  };
5670
+ if (
5671
+ style === "ORTHOGONAL" &&
5672
+ source.id === target.id &&
5673
+ hasFixedPortSide(source) &&
5674
+ (feedbackSourcePortSide !== undefined || feedbackTargetPortSide !== undefined) &&
5675
+ (edge.sourcePort === undefined ||
5676
+ edge.targetPort === undefined ||
5677
+ !sameSideSelfLoop ||
5678
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true)
5679
+ ) {
5680
+ const endpoints = implicitEndpoints.get(edge.id)!;
5681
+ // Like other edges, a single-edge zero-size fixed-side port takes its
5682
+ // slot in the side's shared endpoint order, so it cannot coincide
5683
+ // with a sibling port that already uses that order.
5684
+ const terminal = (endpoint: "source" | "target"): Point => {
5685
+ const portName = endpoint === "source" ? edge.sourcePort : edge.targetPort;
5686
+ const fallback = endpoints[endpoint];
5687
+ const port = source.ports?.find((candidate) => candidate.name === portName);
5688
+ const side = port ? input.portSettings?.(port, source)?.["port.side"] : undefined;
5689
+ const onSide =
5690
+ (side === "EAST" && Math.abs(fallback.x - sourceRect.x - sourceRect.width) < 1e-9) ||
5691
+ (side === "WEST" && Math.abs(fallback.x - sourceRect.x) < 1e-9) ||
5692
+ (side === "SOUTH" && Math.abs(fallback.y - sourceRect.y - sourceRect.height) < 1e-9) ||
5693
+ (side === "NORTH" && Math.abs(fallback.y - sourceRect.y) < 1e-9);
5694
+ return onSide && usesSharedSideSlot(input, source, portName)
5695
+ ? fallback
5696
+ : getPortPoint(source, portName, sourceRect, fallback, input.direction, input);
5697
+ };
5698
+ const start = terminal("source");
5699
+ const end = terminal("target");
5700
+ const outputSide = ({ right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" } as const)[
5701
+ input.direction
5702
+ ];
5703
+ const inputSide = ({ right: "WEST", left: "EAST", down: "NORTH", up: "SOUTH" } as const)[
5704
+ input.direction
5705
+ ];
5706
+ const loops = selfLoopsByNodeId.get(source.id) ?? [edge];
5707
+ const distance =
5708
+ Number(input.settings["spacing.nodeSelfLoop"] ?? 10) *
5709
+ (selfLoopTracks(loops).get(edge.id)! + 1);
5710
+ pointsByEdgeId.set(
5711
+ edge.id,
5712
+ (sameSideSelfLoop ? (points: Point[]) => points : simplifyRoute)(
5713
+ routeFixedSelfLoop(
5714
+ sourceRect,
5715
+ start,
5716
+ end,
5717
+ fixedSelfLoopSide(feedbackSourcePortSide) ?? outputSide,
5718
+ fixedSelfLoopSide(feedbackTargetPortSide) ?? inputSide,
5719
+ distance,
5720
+ input.direction,
5721
+ ),
5722
+ ),
5723
+ );
5724
+ outsideFeedbackEdgeIds.add(edge.id);
5725
+ continue;
5726
+ }
4568
5727
  const fixedSideFeedback =
4569
5728
  style === "ORTHOGONAL" &&
4570
5729
  !sameSideSelfLoop &&
@@ -4850,6 +6009,31 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4850
6009
  outsideFeedbackEdgeIds.add(edge.id);
4851
6010
  continue;
4852
6011
  }
6012
+ const movable =
6013
+ hasMovableLoopPorts(input, edge) &&
6014
+ hasPlacementLoopEnvelope(placement, source.id) &&
6015
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true;
6016
+ // Route unconstrained loops in the canonical rightward system, then
6017
+ // apply the same direction transform as the layer pipeline.
6018
+ const loopRect = movable
6019
+ ? {
6020
+ x: 0,
6021
+ y: 0,
6022
+ width: horizontal ? sourceRect.width : sourceRect.height,
6023
+ height: horizontal ? sourceRect.height : sourceRect.width,
6024
+ }
6025
+ : sourceRect;
6026
+ const publishLoop = (points: readonly Point[]) => {
6027
+ const physical = !movable
6028
+ ? points
6029
+ : points.map((point) => {
6030
+ const flow = reverse ? loopRect.width - point.x : point.x;
6031
+ return horizontal
6032
+ ? { x: sourceRect.x + flow, y: sourceRect.y + point.y }
6033
+ : { x: sourceRect.x + point.y, y: sourceRect.y + flow };
6034
+ });
6035
+ pointsByEdgeId.set(edge.id, physical);
6036
+ };
4853
6037
  const routeHorizontalSide = (
4854
6038
  side: "NORTH" | "SOUTH",
4855
6039
  indexOnSide: number,
@@ -4880,17 +6064,15 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4880
6064
  const sideLoops = loops.filter(
4881
6065
  (candidate, i) => loopSide(distribution, i, candidate) === side,
4882
6066
  );
4883
- const distance = loopDistance(sideLoops, edge, side, ordering);
6067
+ const distance = loopDistance(sideLoops, edge, side, ordering, movable);
4884
6068
  const y =
4885
- side === "NORTH"
4886
- ? sourceRect.y - distance
4887
- : sourceRect.y + sourceRect.height + distance;
6069
+ side === "NORTH" ? loopRect.y - distance : loopRect.y + loopRect.height + distance;
4888
6070
  const start = {
4889
- x: sourceRect.x + sourceRect.width * startRatio,
4890
- y: side === "NORTH" ? sourceRect.y : sourceRect.y + sourceRect.height,
6071
+ x: loopRect.x + loopRect.width * startRatio,
6072
+ y: side === "NORTH" ? loopRect.y : loopRect.y + loopRect.height,
4891
6073
  };
4892
6074
  const end = {
4893
- x: sourceRect.x + sourceRect.width * endRatio,
6075
+ x: loopRect.x + loopRect.width * endRatio,
4894
6076
  y: start.y,
4895
6077
  };
4896
6078
  return [start, { x: start.x, y }, { x: end.x, y }, end];
@@ -4907,43 +6089,41 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4907
6089
  const startRatio = side === "EAST" ? firstRatio : secondRatio;
4908
6090
  const endRatio = side === "EAST" ? secondRatio : firstRatio;
4909
6091
  const distance =
4910
- input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
6092
+ movable || input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
4911
6093
  ? loopDistance(
4912
6094
  loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side),
4913
6095
  edge,
4914
6096
  side,
4915
6097
  ordering,
6098
+ movable,
4916
6099
  )
4917
6100
  : spacing * (indexOnSide + 1);
4918
6101
  const x =
4919
- side === "EAST"
4920
- ? sourceRect.x + sourceRect.width + distance
4921
- : sourceRect.x - distance;
6102
+ side === "EAST" ? loopRect.x + loopRect.width + distance : loopRect.x - distance;
4922
6103
  const start = {
4923
- x: side === "EAST" ? sourceRect.x + sourceRect.width : sourceRect.x,
4924
- y: sourceRect.y + sourceRect.height * startRatio,
6104
+ x: side === "EAST" ? loopRect.x + loopRect.width : loopRect.x,
6105
+ y: loopRect.y + loopRect.height * startRatio,
4925
6106
  };
4926
- const end = { x: start.x, y: sourceRect.y + sourceRect.height * endRatio };
6107
+ const end = { x: start.x, y: loopRect.y + loopRect.height * endRatio };
4927
6108
  return [start, { x, y: start.y }, { x, y: end.y }, end];
4928
6109
  };
4929
6110
  if (distribution === "NORTH_SOUTH") {
4930
6111
  const side = loopIndex % 2 === 0 ? "NORTH" : "SOUTH";
4931
6112
  const indexOnSide = Math.floor(loopIndex / 2);
4932
6113
  const countOnSide = Math.ceil((loops.length - (side === "SOUTH" ? 1 : 0)) / 2);
4933
- pointsByEdgeId.set(edge.id, routeHorizontalSide(side, indexOnSide, countOnSide));
6114
+ publishLoop(routeHorizontalSide(side, indexOnSide, countOnSide));
4934
6115
  } else if (distribution === "EQUALLY") {
4935
6116
  const sides = ["NORTH", "SOUTH", "EAST", "WEST"] as const;
4936
6117
  const side = sides[loopIndex % sides.length]!;
4937
6118
  const indexOnSide = Math.floor(loopIndex / sides.length);
4938
6119
  const countOnSide = Math.ceil((loops.length - sides.indexOf(side)) / sides.length);
4939
- pointsByEdgeId.set(
4940
- edge.id,
6120
+ publishLoop(
4941
6121
  side === "NORTH" || side === "SOUTH"
4942
6122
  ? routeHorizontalSide(side, indexOnSide, countOnSide)
4943
6123
  : routeVerticalSide(side, indexOnSide, countOnSide),
4944
6124
  );
4945
6125
  } else {
4946
- pointsByEdgeId.set(edge.id, routeHorizontalSide("NORTH", loopIndex, loops.length));
6126
+ publishLoop(routeHorizontalSide("NORTH", loopIndex, loops.length));
4947
6127
  }
4948
6128
  continue;
4949
6129
  }
@@ -5047,7 +6227,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5047
6227
  ).length === 1;
5048
6228
  const flexibleFeedback =
5049
6229
  reversedEdge && !hasFixedPortSide(source) && !hasFixedPortSide(target);
5050
- const start = flexibleFeedback
6230
+ const unshiftedStart = flexibleFeedback
5051
6231
  ? sourceFallback
5052
6232
  : sourceFixedSide
5053
6233
  ? sourceFallback
@@ -5059,7 +6239,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5059
6239
  input.direction,
5060
6240
  input,
5061
6241
  );
5062
- const end = flexibleFeedback
6242
+ const unshiftedEnd = flexibleFeedback
5063
6243
  ? targetFallback
5064
6244
  : targetFixedSide
5065
6245
  ? targetFallback
@@ -5071,19 +6251,23 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5071
6251
  input.direction,
5072
6252
  input,
5073
6253
  );
6254
+ const start = attached(edge, "source", unshiftedStart);
6255
+ const end = attached(edge, "target", unshiftedEnd);
5074
6256
  const sourceLayer = flowLayerByNodeId.get(edge.sourceId) ?? 0;
5075
6257
  const targetLayer = flowLayerByNodeId.get(edge.targetId) ?? 0;
5076
6258
  const earlierLayer = Math.min(sourceLayer, targetLayer);
5077
6259
  const laterLayer = Math.max(sourceLayer, targetLayer);
5078
6260
  const earlier = flowLayers[earlierLayer];
5079
6261
  const later = flowLayers[laterLayer];
6262
+ // Inverted-port links clear the occupied layer, including protruding
6263
+ // ports and routing reservations, rather than only the two node bodies.
5080
6264
  const sameLayerTrack =
5081
6265
  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)
6266
+ ? (flowLayers[sourceLayer]?.end ??
6267
+ (horizontal
6268
+ ? Math.max(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width)
6269
+ : Math.max(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height))) +
6270
+ Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10)
5087
6271
  : undefined;
5088
6272
  const track =
5089
6273
  orthogonalTrackByEdgeId.get(edge.id) ??
@@ -5091,7 +6275,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5091
6275
  splineTrackByEdgeId.get(edge.id) ??
5092
6276
  sameLayerTrack ??
5093
6277
  (earlier && later && earlierLayer !== laterLayer
5094
- ? (earlier.end + later.start) / 2
6278
+ ? freeGapTrack(edge.id, earlier.end, later.start, start, end)
5095
6279
  : horizontal
5096
6280
  ? (start.x + end.x) / 2
5097
6281
  : (start.y + end.y) / 2);
@@ -5237,7 +6421,26 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5237
6421
  );
5238
6422
  }
5239
6423
 
5240
- return { pointsByEdgeId, splineNubControlsByEdgeId, outsideFeedbackEdgeIds };
6424
+ const junctionPointsByEdgeId =
6425
+ style === "ORTHOGONAL"
6426
+ ? orthogonalJunctionPoints(
6427
+ input,
6428
+ orientation,
6429
+ implicitEdgeEndpoints(input, routingCoordinates(placement), orientation),
6430
+ flowLayerByNodeId,
6431
+ junctionGroups,
6432
+ pointsByEdgeId,
6433
+ orthogonalTrackByEdgeId,
6434
+ orthogonalDetourByEdgeId,
6435
+ (node, port) => getOrientedPortDirection(input, orientation, node, port),
6436
+ )
6437
+ : undefined;
6438
+ return {
6439
+ pointsByEdgeId,
6440
+ splineNubControlsByEdgeId,
6441
+ outsideFeedbackEdgeIds,
6442
+ ...(style === "ORTHOGONAL" ? { junctionPointsByEdgeId } : {}),
6443
+ };
5241
6444
  };
5242
6445
  }
5243
6446
 
@@ -5329,11 +6532,26 @@ export function placePorts<P>(
5329
6532
  return { ...port, ...size, x: port.x, y: port.y };
5330
6533
  }
5331
6534
  const side = sideByPort.get(port)!;
6535
+ const borderOffset = Number(portSettings?.(port)?.["port.borderOffset"] ?? 0);
5332
6536
  return {
5333
6537
  ...port,
5334
6538
  ...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,
6539
+ x:
6540
+ side === "EAST"
6541
+ ? rect.width + borderOffset
6542
+ : side === "WEST"
6543
+ ? constraints === "FIXED_POS" && direction === "left" && size.width === 0
6544
+ ? 0 - borderOffset
6545
+ : -size.width - borderOffset
6546
+ : port.x,
6547
+ y:
6548
+ side === "SOUTH"
6549
+ ? rect.height + borderOffset
6550
+ : side === "NORTH"
6551
+ ? constraints === "FIXED_POS" && direction === "up" && size.height === 0
6552
+ ? 0 - borderOffset
6553
+ : -size.height - borderOffset
6554
+ : port.y,
5337
6555
  };
5338
6556
  }
5339
6557
  const side = sideByPort.get(port)!;
@@ -5549,8 +6767,8 @@ export function normalizePlacementForPortExtents(
5549
6767
  }
5550
6768
  const nextLayer = physicalLayers[layerIndex + 1];
5551
6769
  if (!nextLayer) continue;
5552
- let trailingExtent = 0;
5553
- let leadingExtent = 0;
6770
+ let trailingBoundary = Number.NEGATIVE_INFINITY;
6771
+ let leadingBoundary = Number.POSITIVE_INFINITY;
5554
6772
  for (const [ids, trailing] of [
5555
6773
  [layer, true],
5556
6774
  [nextLayer, false],
@@ -5559,6 +6777,10 @@ export function normalizePlacementForPortExtents(
5559
6777
  const node = input.graph.nodes.find((candidate) => candidate.id === id);
5560
6778
  const rect = placement.rectByNodeId.get(id);
5561
6779
  if (!node || !rect) continue;
6780
+ const bodyStart = horizontal ? rect.x : rect.y;
6781
+ const bodySize = horizontal ? rect.width : rect.height;
6782
+ if (trailing) trailingBoundary = Math.max(trailingBoundary, bodyStart + bodySize);
6783
+ else leadingBoundary = Math.min(leadingBoundary, bodyStart);
5562
6784
  const ports = placePorts(
5563
6785
  node.ports,
5564
6786
  rect,
@@ -5575,21 +6797,37 @@ export function normalizePlacementForPortExtents(
5575
6797
  : trailing
5576
6798
  ? Math.max(0, (port.y ?? 0) + (port.height ?? 0) - rect.height)
5577
6799
  : Math.max(0, -(port.y ?? 0));
5578
- if (trailing) trailingExtent = Math.max(trailingExtent, extent);
5579
- else leadingExtent = Math.max(leadingExtent, extent);
6800
+ if (trailing)
6801
+ trailingBoundary = Math.max(trailingBoundary, bodyStart + bodySize + extent);
6802
+ else leadingBoundary = Math.min(leadingBoundary, bodyStart - extent);
5580
6803
  }
5581
6804
  }
5582
6805
  }
5583
- accumulatedShift += trailingExtent + leadingExtent;
6806
+ accumulatedShift += Math.max(
6807
+ 0,
6808
+ input.spacing.layer - (leadingBoundary + accumulatedShift - trailingBoundary),
6809
+ );
5584
6810
  }
5585
6811
 
5586
6812
  let minimumX = Number.POSITIVE_INFINITY;
5587
6813
  let minimumY = Number.POSITIVE_INFINITY;
6814
+ const envelopes = loopEnvelopes(input);
6815
+ const reverse = input.direction === "left" || input.direction === "up";
5588
6816
  for (const node of input.graph.nodes) {
5589
6817
  const rect = placement.rectByNodeId.get(node.id);
5590
6818
  if (!rect) continue;
5591
- minimumX = Math.min(minimumX, rect.x);
5592
- minimumY = Math.min(minimumY, rect.y);
6819
+ const envelope = hasPlacementLoopEnvelope(placement, node.id)
6820
+ ? envelopes.get(node.id)
6821
+ : undefined;
6822
+ const flowBefore = reverse ? envelope?.flowAfter : envelope?.flowBefore;
6823
+ minimumX = Math.min(
6824
+ minimumX,
6825
+ rect.x - (horizontal ? (flowBefore ?? 0) : (envelope?.before ?? 0)),
6826
+ );
6827
+ minimumY = Math.min(
6828
+ minimumY,
6829
+ rect.y - (horizontal ? (envelope?.before ?? 0) : (flowBefore ?? 0)),
6830
+ );
5593
6831
  const ports = placePorts(
5594
6832
  node.ports,
5595
6833
  rect,