@statelyai/layout 0.4.3 → 0.5.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 (57) hide show
  1. package/README.md +32 -3
  2. package/dist/elkjs/index.cjs +2 -2
  3. package/dist/elkjs/index.d.cts +1 -1
  4. package/dist/elkjs/index.d.mts +1 -1
  5. package/dist/elkjs/index.mjs +2 -2
  6. package/dist/elkjs/main.cjs +1 -1
  7. package/dist/elkjs/main.mjs +1 -1
  8. package/dist/elkjs/worker.cjs +2 -2
  9. package/dist/elkjs/worker.mjs +2 -2
  10. package/dist/{elkjs-Cj-lWiK1.cjs → elkjs-Bh_E59N_.cjs} +3 -3
  11. package/dist/{elkjs-B5kN_zS6.mjs → elkjs-Dk9yOk9U.mjs} +3 -3
  12. package/dist/{index-B6cWNrMw.d.cts → index-0YVSvMvA.d.mts} +13 -3
  13. package/dist/{index-CJQLUKD9.d.mts → index-C0J4ttZe.d.cts} +13 -3
  14. package/dist/{index-DT6lPMfS.d.cts → index-Xc4R6y3z.d.cts} +9 -2
  15. package/dist/{index-F1U-fCTu.d.mts → index-lSp0oCJC.d.mts} +9 -2
  16. package/dist/index.cjs +7 -4
  17. package/dist/index.d.cts +4 -4
  18. package/dist/index.d.mts +4 -4
  19. package/dist/index.mjs +4 -4
  20. package/dist/layered/index.cjs +1 -1
  21. package/dist/layered/index.d.cts +2 -2
  22. package/dist/layered/index.d.mts +2 -2
  23. package/dist/layered/index.mjs +1 -1
  24. package/dist/{layered-BsAdm_u8.mjs → layered-CUwft7on.mjs} +488 -91
  25. package/dist/{layered-BJu8A21s.cjs → layered-QzYtokgN.cjs} +488 -91
  26. package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CQ5FcBPC.cjs} +1 -1
  27. package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-DvDkMQM3.mjs} +1 -1
  28. package/dist/routing/index.cjs +5 -2
  29. package/dist/routing/index.d.cts +3 -3
  30. package/dist/routing/index.d.mts +3 -3
  31. package/dist/routing/index.mjs +2 -2
  32. package/dist/routing-D-LfMXOF.mjs +5189 -0
  33. package/dist/routing-Phxu1R0x.cjs +5423 -0
  34. package/dist/{types-CT2akdK0.d.mts → types-D8pYSn8I.d.cts} +112 -2
  35. package/dist/{types-uxPlGRof.d.cts → types-MAUtJfKQ.d.mts} +112 -2
  36. package/package.json +4 -1
  37. package/src/authoring/partial.ts +1 -7
  38. package/src/authoring/routing.ts +64 -19
  39. package/src/layered/feedback-chains.ts +22 -4
  40. package/src/layered/index.ts +186 -42
  41. package/src/layered/loop-envelopes.ts +96 -26
  42. package/src/layered/north-south-ports.ts +17 -4
  43. package/src/layered/self-loop-ports.ts +158 -0
  44. package/src/layered/strategies.ts +183 -32
  45. package/src/routing/algorithms.ts +1436 -148
  46. package/src/routing/bezier-fit.ts +605 -0
  47. package/src/routing/bezier-visibility.ts +107 -0
  48. package/src/routing/bezier.ts +596 -0
  49. package/src/routing/engine.ts +65 -13
  50. package/src/routing/index.ts +14 -0
  51. package/src/routing/model.ts +185 -1
  52. package/src/routing/path.ts +1 -1
  53. package/src/routing/policy-geometry.ts +536 -0
  54. package/src/routing/search.ts +10 -2
  55. package/src/routing/types.ts +77 -1
  56. package/dist/routing-CR8flv8b.mjs +0 -2780
  57. package/dist/routing-QIdGGFBe.cjs +0 -2996
@@ -0,0 +1,158 @@
1
+ /* ELK self-loop port hiding and restoration. SPDX-License-Identifier: EPL-2.0 */
2
+ import type { Graph, GraphEdge, GraphPort, Point, VisualGraph, VisualPort } from "@statelyai/graph";
3
+ import type { LayeredLayoutOptions } from "./index";
4
+
5
+ interface PortSize {
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface HiddenLoopPorts {
10
+ source: PortSize;
11
+ target: PortSize;
12
+ }
13
+
14
+ const hiddenBySettings = new WeakMap<object, ReadonlyMap<string, HiddenLoopPorts>>();
15
+
16
+ /** Sizes of the ports SelfLoopPreProcessor hid from a loop, if any. */
17
+ export function hiddenLoopPorts(settings: object, edgeId: string): HiddenLoopPorts | undefined {
18
+ return hiddenBySettings.get(settings)?.get(edgeId);
19
+ }
20
+
21
+ /**
22
+ * ELK's SelfLoopPreProcessor removes ports that carry only self loops from
23
+ * nodes whose port order is not fixed, and SelfLoopPortRestorer later puts
24
+ * them on the loop's side. The layered phases then see implicit loop ports;
25
+ * {@link restoreSelfLoopPorts} places the hidden ports at the loop ends.
26
+ */
27
+ export function hideSelfLoopPorts<N, E, G, P>(
28
+ graph: Graph<N, E, G, P>,
29
+ options: LayeredLayoutOptions,
30
+ ):
31
+ | {
32
+ graph: Graph<N, E, G, P>;
33
+ options: LayeredLayoutOptions;
34
+ edges: ReadonlyMap<string, GraphEdge<E>>;
35
+ }
36
+ | undefined {
37
+ const uses = new Map<string, number>();
38
+ const nonLoop = new Set<string>();
39
+ for (const edge of graph.edges) {
40
+ for (const [node, port] of [
41
+ [edge.sourceId, edge.sourcePort],
42
+ [edge.targetId, edge.targetPort],
43
+ ] as const) {
44
+ if (port === undefined) continue;
45
+ const key = `${node}\0${port}`;
46
+ uses.set(key, (uses.get(key) ?? 0) + 1);
47
+ if (edge.sourceId !== edge.targetId) nonLoop.add(key);
48
+ }
49
+ }
50
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
51
+ const hidden = new Map<string, HiddenLoopPorts>();
52
+ const edges = new Map<string, GraphEdge<E>>();
53
+ const hiddenPorts = new Set<string>();
54
+ for (const edge of graph.edges) {
55
+ if (
56
+ edge.sourceId !== edge.targetId ||
57
+ edge.sourcePort === undefined ||
58
+ edge.targetPort === undefined ||
59
+ edge.sourcePort === edge.targetPort
60
+ )
61
+ continue;
62
+ const node = nodeById.get(edge.sourceId);
63
+ const constraints = node && options.nodeSettings?.(node)?.portConstraints;
64
+ if (
65
+ !node ||
66
+ (constraints !== undefined && constraints !== "FREE" && constraints !== "UNDEFINED")
67
+ )
68
+ continue;
69
+ // One loop per port: shared ports would make a self hyperloop.
70
+ const ports = [edge.sourcePort, edge.targetPort].map((name) =>
71
+ node.ports?.find((port) => port.name === name),
72
+ );
73
+ if (
74
+ ports.some(
75
+ (port) =>
76
+ !port ||
77
+ uses.get(`${node.id}\0${port.name}`) !== 1 ||
78
+ nonLoop.has(`${node.id}\0${port.name}`),
79
+ )
80
+ )
81
+ continue;
82
+ const size = (port: GraphPort<P>) => ({ width: port.width ?? 0, height: port.height ?? 0 });
83
+ hidden.set(edge.id, { source: size(ports[0]!), target: size(ports[1]!) });
84
+ edges.set(edge.id, edge);
85
+ for (const port of ports) hiddenPorts.add(`${node.id}\0${port!.name}`);
86
+ }
87
+ if (hidden.size === 0) return undefined;
88
+ const settings = { ...options.settings };
89
+ hiddenBySettings.set(settings, hidden);
90
+ return {
91
+ graph: {
92
+ ...graph,
93
+ nodes: graph.nodes.map((node) =>
94
+ node.ports?.some((port) => hiddenPorts.has(`${node.id}\0${port.name}`))
95
+ ? {
96
+ ...node,
97
+ ports: node.ports.filter((port) => !hiddenPorts.has(`${node.id}\0${port.name}`)),
98
+ }
99
+ : node,
100
+ ),
101
+ edges: graph.edges.map((edge) =>
102
+ hidden.has(edge.id) ? { ...edge, sourcePort: undefined, targetPort: undefined } : edge,
103
+ ),
104
+ },
105
+ options: { ...options, settings },
106
+ edges,
107
+ };
108
+ }
109
+
110
+ /** Put hidden loop ports back, anchored on the side their loop leaves from. */
111
+ export function restoreSelfLoopPorts<N, E, G, P>(
112
+ graph: Graph<N, E, G, P>,
113
+ result: VisualGraph<N, E, G, P>,
114
+ edges: ReadonlyMap<string, GraphEdge<E>>,
115
+ ): VisualGraph<N, E, G, P> {
116
+ const anchors = new Map<string, Point>();
117
+ for (const edge of result.edges) {
118
+ const original = edges.get(edge.id);
119
+ const points = edge.points;
120
+ if (!original || !points?.length) continue;
121
+ anchors.set(`${edge.sourceId}\0${original.sourcePort}`, points[0]!);
122
+ anchors.set(`${edge.targetId}\0${original.targetPort}`, points.at(-1)!);
123
+ }
124
+ const originalById = new Map(graph.nodes.map((node) => [node.id, node]));
125
+ return {
126
+ ...result,
127
+ nodes: result.nodes.map((node) => {
128
+ const original = originalById.get(node.id)?.ports;
129
+ if (!original?.some((port) => anchors.has(`${node.id}\0${port.name}`))) return node;
130
+ const placed = new Map(node.ports?.map((port) => [port.name, port]));
131
+ return {
132
+ ...node,
133
+ ports: original.map((port): VisualPort<P> => {
134
+ const anchor = anchors.get(`${node.id}\0${port.name}`);
135
+ const width = port.width ?? 0,
136
+ height = port.height ?? 0;
137
+ if (!anchor) return placed.get(port.name) ?? { ...port, width, height, x: 0, y: 0 };
138
+ const x = anchor.x - node.x,
139
+ y = anchor.y - node.y;
140
+ // The loop leaves from the port's outer border, centered on it.
141
+ return {
142
+ ...port,
143
+ width,
144
+ height,
145
+ x: x <= 0 ? x : x >= node.width ? x - width : x - width / 2,
146
+ y: x <= 0 || x >= node.width ? y - height / 2 : y <= 0 ? y : y - height,
147
+ };
148
+ }),
149
+ };
150
+ }),
151
+ edges: result.edges.map((edge) => {
152
+ const original = edges.get(edge.id);
153
+ return original
154
+ ? { ...edge, sourcePort: original.sourcePort, targetPort: original.targetPort }
155
+ : edge;
156
+ }),
157
+ };
158
+ }
@@ -19,6 +19,8 @@ import { routingCoordinates } from "./routing-coordinates";
19
19
  import {
20
20
  hasMovableLoopPorts,
21
21
  hasPlacementLoopEnvelope,
22
+ movableLoopPortOffset,
23
+ movableLoopTrack,
22
24
  loopEnvelopes,
23
25
  preparedLoopEnvelopes,
24
26
  } from "./loop-envelopes";
@@ -72,6 +74,24 @@ const modelSweepInputs = new WeakSet<LayeredPhaseInput>();
72
74
 
73
75
  const nodeRelativePortRanksByInput = new WeakMap<LayeredPhaseInput, boolean>();
74
76
 
77
+ const labelPortSideFeedbackByInput = new WeakMap<
78
+ LayeredPhaseInput,
79
+ { exempt: ReadonlySet<string>; routed: Set<string> }
80
+ >();
81
+
82
+ /**
83
+ * @internal Collect the feedback segments routed to a center-label dummy's port
84
+ * sides. Exempt segments keep the plain feedback detour instead.
85
+ */
86
+ export function trackLabelPortSideFeedback(
87
+ input: LayeredPhaseInput,
88
+ exempt: ReadonlySet<string>,
89
+ ): ReadonlySet<string> {
90
+ const routed = new Set<string>();
91
+ labelPortSideFeedbackByInput.set(input, { exempt, routed });
92
+ return routed;
93
+ }
94
+
75
95
  function getOrientedEndpoints(
76
96
  edge: GraphEdge,
77
97
  orientation: AcyclicOrientation,
@@ -1794,6 +1814,8 @@ function sortLayerByAdjacentPosition(
1794
1814
  sameLayerNeighbors: ReadonlyMap<string, readonly string[]> = new Map(),
1795
1815
  orderedVisits?: ReadonlyMap<string, readonly (number | string)[]>,
1796
1816
  forcedCompare?: (left: string, right: string) => number,
1817
+ /** Nodes ELK's forced model-order comparator ties with everything. */
1818
+ forcedTies?: ReadonlySet<string>,
1797
1819
  ): void {
1798
1820
  const originalIndex = new Map(layer.map((nodeId, index) => [nodeId, index] as const));
1799
1821
  const adjacentPosition = new Map<string, number | undefined>();
@@ -1870,6 +1892,7 @@ function sortLayerByAdjacentPosition(
1870
1892
  }
1871
1893
 
1872
1894
  const compare = (a: string, b: string) => {
1895
+ if (forcedTies?.has(a) || forcedTies?.has(b)) return 0;
1873
1896
  return (
1874
1897
  forcedCompare?.(a, b) ||
1875
1898
  (adjacentPosition.get(a) ?? 0) - (adjacentPosition.get(b) ?? 0) ||
@@ -2185,6 +2208,18 @@ export function createLayerSweepSession(
2185
2208
  usesInitialModelOrder && input.settings["crossingMinimization.forceNodeModelOrder"] === true
2186
2209
  ? normalModelComparator(input)
2187
2210
  : undefined;
2211
+ // ELK's forced comparator returns 0 for separate-layer nodes before
2212
+ // consulting barycenters, so insertion sort never moves past them.
2213
+ const forcedTies = forcedCompare
2214
+ ? new Set(
2215
+ input.graph.nodes
2216
+ .filter((node) => {
2217
+ const constraint = input.nodeSettings?.(node)?.["layering.layerConstraint"];
2218
+ return constraint === "FIRST_SEPARATE" || constraint === "LAST_SEPARATE";
2219
+ })
2220
+ .map((node) => node.id),
2221
+ )
2222
+ : undefined;
2188
2223
  const usePortRanks = true;
2189
2224
  const medianWeights = new Map<string, number>();
2190
2225
  const listedEdgeIds = new Map<string, string>();
@@ -2440,8 +2475,10 @@ export function createLayerSweepSession(
2440
2475
  (working[firstLayerIndex] ?? []).map((id) => [id, random.nextDouble()]),
2441
2476
  );
2442
2477
  const compare = (left: string, right: string) =>
2443
- forcedCompare?.(left, right) ||
2444
- (firstLayerWeights.get(left) ?? 0) - (firstLayerWeights.get(right) ?? 0);
2478
+ forcedTies?.has(left) || forcedTies?.has(right)
2479
+ ? 0
2480
+ : forcedCompare?.(left, right) ||
2481
+ (firstLayerWeights.get(left) ?? 0) - (firstLayerWeights.get(right) ?? 0);
2445
2482
  const first = working[firstLayerIndex];
2446
2483
  if (first) {
2447
2484
  if (forcedCompare)
@@ -2596,6 +2633,7 @@ export function createLayerSweepSession(
2596
2633
  sameLayerNeighbors(isForward),
2597
2634
  adjacent.visits,
2598
2635
  forcedCompare,
2636
+ forcedTies,
2599
2637
  );
2600
2638
  }
2601
2639
  groupMixedPortDirections(current, previous, working[layer + 1]);
@@ -2625,6 +2663,7 @@ export function createLayerSweepSession(
2625
2663
  sameLayerNeighbors(isForward),
2626
2664
  adjacent.visits,
2627
2665
  forcedCompare,
2666
+ forcedTies,
2628
2667
  );
2629
2668
  }
2630
2669
  groupMixedPortDirections(current, next, working[layer - 1]);
@@ -4300,16 +4339,14 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4300
4339
  edge: GraphEdge,
4301
4340
  side: string,
4302
4341
  ordering: string,
4303
- canonical = false,
4304
4342
  ): number => {
4305
- const crossSide = side === "NORTH" || side === "SOUTH";
4306
- const axis = crossSide === (!canonical || horizontal) ? "height" : "width";
4343
+ const axis = side === "NORTH" || side === "SOUTH" ? "height" : "width";
4307
4344
  const ordered = ordering === "REVERSE_STACKED" ? [...loops].reverse() : loops;
4308
4345
  const index = ordered.indexOf(edge);
4309
4346
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
4310
4347
  const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
4311
4348
  const size = (candidate: GraphEdge) =>
4312
- canonical || input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
4349
+ input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true
4313
4350
  ? (candidate[axis] ?? 0)
4314
4351
  : 0;
4315
4352
  return (
@@ -4405,7 +4442,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4405
4442
  for (const [id, loops] of selfLoopsByNodeId) {
4406
4443
  const rect = mutableRects.get(id);
4407
4444
  const node = nodeById.get(id);
4408
- if (!rect || !node) continue;
4445
+ if (!rect || !node || hasPlacementLoopEnvelope(placement, id)) continue;
4409
4446
  const settings = input.nodeSettings?.(node);
4410
4447
  const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
4411
4448
  const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
@@ -5845,6 +5882,25 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5845
5882
  input,
5846
5883
  )
5847
5884
  : targetFallback;
5885
+ // A reversed chain enters a center-label dummy through its forward-side
5886
+ // port and leaves through its backward-side port: route to those sides.
5887
+ const [forwardSide, backwardSide] = (
5888
+ {
5889
+ right: ["EAST", "WEST"],
5890
+ left: ["WEST", "EAST"],
5891
+ down: ["SOUTH", "NORTH"],
5892
+ up: ["NORTH", "SOUTH"],
5893
+ } as const
5894
+ )[input.direction];
5895
+ const labelPortSides = labelPortSideFeedbackByInput.get(input);
5896
+ const faces = (node: GraphNode, side: unknown, away: string) =>
5897
+ !fixedSideFeedback &&
5898
+ !labelPortSides?.exempt.has(edge.id) &&
5899
+ node.id.startsWith("__layout_dummy:label:") &&
5900
+ side === away;
5901
+ const sourceFace = faces(source, feedbackSourcePortSide, backwardSide) ? -1 : 1;
5902
+ const targetFace = faces(target, feedbackTargetPortSide, forwardSide) ? -1 : 1;
5903
+ if (sourceFace < 0 || targetFace < 0) labelPortSides?.routed.add(edge.id);
5848
5904
  if (horizontal) {
5849
5905
  const sign = fixedSideFeedback
5850
5906
  ? Math.sign(sourceRect.x - targetRect.x) || 1
@@ -5859,16 +5915,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5859
5915
  fixedSideFeedback && input.settings.unnecessaryBendpoints === true;
5860
5916
  pointsByEdgeId.set(edge.id, [
5861
5917
  start,
5862
- { x: start.x + sign * flowSpacing, y: start.y },
5863
- { x: start.x + sign * flowSpacing, y: outerCross },
5918
+ { x: start.x + sourceFace * sign * flowSpacing, y: start.y },
5919
+ { x: start.x + sourceFace * sign * flowSpacing, y: outerCross },
5864
5920
  ...(preserveCenters
5865
5921
  ? [
5866
5922
  { x: sourceRect.x + sourceRect.width / 2, y: outerCross },
5867
5923
  { x: targetRect.x + targetRect.width / 2, y: outerCross },
5868
5924
  ]
5869
5925
  : []),
5870
- { x: end.x - sign * flowSpacing, y: outerCross },
5871
- { x: end.x - sign * flowSpacing, y: end.y },
5926
+ { x: end.x - targetFace * sign * flowSpacing, y: outerCross },
5927
+ { x: end.x - targetFace * sign * flowSpacing, y: end.y },
5872
5928
  end,
5873
5929
  ]);
5874
5930
  } else {
@@ -5885,16 +5941,16 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
5885
5941
  fixedSideFeedback && input.settings.unnecessaryBendpoints === true;
5886
5942
  pointsByEdgeId.set(edge.id, [
5887
5943
  start,
5888
- { x: start.x, y: start.y + sign * flowSpacing },
5889
- { x: outerCross, y: start.y + sign * flowSpacing },
5944
+ { x: start.x, y: start.y + sourceFace * sign * flowSpacing },
5945
+ { x: outerCross, y: start.y + sourceFace * sign * flowSpacing },
5890
5946
  ...(preserveCenters
5891
5947
  ? [
5892
5948
  { x: outerCross, y: sourceRect.y + sourceRect.height / 2 },
5893
5949
  { x: outerCross, y: targetRect.y + targetRect.height / 2 },
5894
5950
  ]
5895
5951
  : []),
5896
- { x: outerCross, y: end.y - sign * flowSpacing },
5897
- { x: end.x, y: end.y - sign * flowSpacing },
5952
+ { x: outerCross, y: end.y - targetFace * sign * flowSpacing },
5953
+ { x: end.x, y: end.y - targetFace * sign * flowSpacing },
5898
5954
  end,
5899
5955
  ]);
5900
5956
  }
@@ -6090,9 +6146,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
6090
6146
  continue;
6091
6147
  }
6092
6148
  const movable =
6093
- hasMovableLoopPorts(input, edge) &&
6094
- hasPlacementLoopEnvelope(placement, source.id) &&
6095
- input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true;
6149
+ hasMovableLoopPorts(input, edge) && hasPlacementLoopEnvelope(placement, source.id);
6096
6150
  // Route unconstrained loops in the canonical rightward system, then
6097
6151
  // apply the same direction transform as the layer pipeline.
6098
6152
  const loopRect = movable
@@ -6144,7 +6198,21 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
6144
6198
  const sideLoops = loops.filter(
6145
6199
  (candidate, i) => loopSide(distribution, i, candidate) === side,
6146
6200
  );
6147
- const distance = loopDistance(sideLoops, edge, side, ordering, movable);
6201
+ if (movable) {
6202
+ const track = movableLoopTrack(input, sideLoops, edge, side, ordering);
6203
+ const sign = side === "NORTH" ? -1 : 1;
6204
+ const base = side === "NORTH" ? 0 : loopRect.height;
6205
+ const [first, last] = [startRatio, endRatio].map((ratio) =>
6206
+ movableLoopPortOffset(input, sideLoops, side, loopRect.width, ratio),
6207
+ );
6208
+ return [
6209
+ { x: first!.along, y: base + sign * first!.normal },
6210
+ { x: first!.along, y: base + sign * track },
6211
+ { x: last!.along, y: base + sign * track },
6212
+ { x: last!.along, y: base + sign * last!.normal },
6213
+ ];
6214
+ }
6215
+ const distance = loopDistance(sideLoops, edge, side, ordering);
6148
6216
  const y =
6149
6217
  side === "NORTH" ? loopRect.y - distance : loopRect.y + loopRect.height + distance;
6150
6218
  const start = {
@@ -6168,15 +6236,26 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
6168
6236
  const secondRatio = sequenced ? 1 / denominator : 2 / denominator;
6169
6237
  const startRatio = side === "EAST" ? firstRatio : secondRatio;
6170
6238
  const endRatio = side === "EAST" ? secondRatio : firstRatio;
6239
+ const sideLoops = loops.filter(
6240
+ (candidate, i) => loopSide(distribution, i, candidate) === side,
6241
+ );
6242
+ if (movable) {
6243
+ const track = movableLoopTrack(input, sideLoops, edge, side, ordering);
6244
+ const sign = side === "WEST" ? -1 : 1;
6245
+ const base = side === "WEST" ? 0 : loopRect.width;
6246
+ const [first, last] = [startRatio, endRatio].map((ratio) =>
6247
+ movableLoopPortOffset(input, sideLoops, side, loopRect.height, ratio),
6248
+ );
6249
+ return [
6250
+ { x: base + sign * first!.normal, y: first!.along },
6251
+ { x: base + sign * track, y: first!.along },
6252
+ { x: base + sign * track, y: last!.along },
6253
+ { x: base + sign * last!.normal, y: last!.along },
6254
+ ];
6255
+ }
6171
6256
  const distance =
6172
- movable || input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
6173
- ? loopDistance(
6174
- loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side),
6175
- edge,
6176
- side,
6177
- ordering,
6178
- movable,
6179
- )
6257
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true
6258
+ ? loopDistance(sideLoops, edge, side, ordering)
6180
6259
  : spacing * (indexOnSide + 1);
6181
6260
  const x =
6182
6261
  side === "EAST" ? loopRect.x + loopRect.width + distance : loopRect.x - distance;
@@ -6528,6 +6607,67 @@ export const routeEdgesOrthogonally = routeEdges("ORTHOGONAL");
6528
6607
  export const routeEdgesWithPolylines = routeEdges("POLYLINE");
6529
6608
  export const routeEdgesWithSplines = routeEdges("SPLINES");
6530
6609
 
6610
+ type PortSide = "NORTH" | "SOUTH" | "WEST" | "EAST";
6611
+ const sweptPortRanks = new WeakMap<
6612
+ readonly GraphPort<unknown>[],
6613
+ (port: GraphPort<unknown>, side: PortSide) => number | undefined
6614
+ >();
6615
+
6616
+ /**
6617
+ * Order movable ports along each physical side as crossing minimization left
6618
+ * them, as ELK places ports from its swept port lists. The lists are
6619
+ * clockwise per canonical side.
6620
+ */
6621
+ export function recordSweptPortOrder(
6622
+ graph: Pick<LayeredPhaseInput["graph"], "nodes" | "edges">,
6623
+ direction: LayeredPhaseInput["direction"],
6624
+ order: LayerOrder,
6625
+ ): void {
6626
+ // Unconnected ports carry no swept decision.
6627
+ const connected = new Set(
6628
+ graph.edges.flatMap((edge) => [
6629
+ JSON.stringify([edge.sourceId, edge.sourcePort]),
6630
+ JSON.stringify([edge.targetId, edge.targetPort]),
6631
+ ]),
6632
+ );
6633
+ const vertical = direction === "down" || direction === "up";
6634
+ const reverse = direction === "up" || direction === "left";
6635
+ const canonical: Record<PortSide, PortSide> =
6636
+ direction === "right"
6637
+ ? { NORTH: "NORTH", EAST: "EAST", SOUTH: "SOUTH", WEST: "WEST" }
6638
+ : direction === "left"
6639
+ ? { NORTH: "NORTH", EAST: "WEST", SOUTH: "SOUTH", WEST: "EAST" }
6640
+ : direction === "down"
6641
+ ? { NORTH: "WEST", EAST: "SOUTH", SOUTH: "EAST", WEST: "NORTH" }
6642
+ : { NORTH: "EAST", EAST: "SOUTH", SOUTH: "WEST", WEST: "NORTH" };
6643
+ for (const node of graph.nodes) {
6644
+ if (!node.ports) continue;
6645
+ const swept = order.physicalPortOrderByNodeId?.get(node.id);
6646
+ if (!swept) {
6647
+ sweptPortRanks.delete(node.ports);
6648
+ continue;
6649
+ }
6650
+ const index = new Map(swept.flatMap((id, i) => (connected.has(id) ? [[id, i] as const] : [])));
6651
+ const rankByPort = new Map(
6652
+ node.ports.map((port) => [port, index.get(JSON.stringify([node.id, port.name]))]),
6653
+ );
6654
+ sweptPortRanks.set(node.ports, (port, side) => {
6655
+ const rank = rankByPort.get(port);
6656
+ if (rank === undefined) return undefined;
6657
+ // Canonical clockwise ranks rise with the flow coordinate on NORTH,
6658
+ // with the cross coordinate on EAST, and fall on SOUTH and WEST.
6659
+ const alongFlow = (side === "NORTH" || side === "SOUTH") !== vertical;
6660
+ const c = canonical[side];
6661
+ const sign = alongFlow
6662
+ ? (c === "NORTH" ? 1 : -1) * (reverse ? -1 : 1)
6663
+ : c === "EAST"
6664
+ ? 1
6665
+ : -1;
6666
+ return sign * rank;
6667
+ });
6668
+ }
6669
+ }
6670
+
6531
6671
  export function placePorts<P>(
6532
6672
  ports: readonly GraphPort<P>[] | undefined,
6533
6673
  rect: EntityRect,
@@ -6573,13 +6713,24 @@ export function placePorts<P>(
6573
6713
  group.push(port);
6574
6714
  grouped.set(side, group);
6575
6715
  }
6716
+ const swept =
6717
+ constraints === "FIXED_ORDER" || constraints === "FIXED_POS" || constraints === "FIXED_RATIO"
6718
+ ? undefined
6719
+ : sweptPortRanks.get(ports as readonly GraphPort<unknown>[]);
6576
6720
  for (const group of grouped.values()) {
6577
- group.sort(
6578
- (left, right) =>
6579
- Number(portSettings?.(left)?.["port.index"] ?? ports.indexOf(left)) -
6580
- Number(portSettings?.(right)?.["port.index"] ?? ports.indexOf(right)),
6581
- );
6582
6721
  const side = sideByPort.get(group[0]!);
6722
+ const ranks = swept && group.map((port) => swept(port as GraphPort<unknown>, side!));
6723
+ const ranked =
6724
+ ranks?.every((rank) => rank !== undefined) &&
6725
+ group.every((port) => portSettings?.(port)?.["port.index"] === undefined)
6726
+ ? new Map(group.map((port, i) => [port, ranks[i]!]))
6727
+ : undefined;
6728
+ group.sort((left, right) =>
6729
+ ranked
6730
+ ? ranked.get(left)! - ranked.get(right)!
6731
+ : Number(portSettings?.(left)?.["port.index"] ?? ports.indexOf(left)) -
6732
+ Number(portSettings?.(right)?.["port.index"] ?? ports.indexOf(right)),
6733
+ );
6583
6734
  if (constraints === "FIXED_ORDER" && (side === "SOUTH" || side === "WEST")) {
6584
6735
  group.reverse();
6585
6736
  }