@statelyai/layout 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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-UciYx7lD.mjs +2973 -0
  12. package/dist/elkjs-ggjsBuVP.cjs +3002 -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-DGFTeQSl.mjs → layered-C7ERzZjR.mjs} +9605 -1655
  26. package/dist/{layered-B2buJqMf.cjs → layered-CBPg6_6_.cjs} +12481 -4435
  27. package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  28. package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-DC3IYkIy.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 +1392 -449
  41. package/src/elkjs/layout-quality.ts +219 -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 +221 -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 +83 -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 +886 -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 +944 -0
  89. package/src/layered/orthogonal-cycle-order.ts +106 -0
  90. package/src/layered/orthogonal-hypersegments.ts +92 -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 +2024 -883
  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
@@ -0,0 +1,192 @@
1
+ /*
2
+ * Copyright (c) 2016, 2020 Kiel University and others.
3
+ * Native adaptation of ELK AbstractBarycenterPortDistributor,
4
+ * NodeRelativePortDistributor and LayerTotalPortDistributor.
5
+ * SPDX-License-Identifier: EPL-2.0
6
+ */
7
+ import type { CrossingGraph, CrossingNode, CrossingPort } from "./crossing-counter";
8
+
9
+ export interface PortDistributionState {
10
+ ranks: Record<string, number>;
11
+ barycenters: Record<string, number>;
12
+ positions: Record<string, number>;
13
+ }
14
+ export interface PortDistributionOptions {
15
+ nodeRelative: boolean;
16
+ fixedOrder: ReadonlySet<string>;
17
+ hierarchical: ReadonlySet<string>;
18
+ }
19
+
20
+ /** Stateful clockwise port distribution across successive layer sweeps. */
21
+ export class CanonicalPortDistributor {
22
+ readonly state: PortDistributionState;
23
+ private topologyGraph?: CrossingGraph;
24
+ private owner = new Map<string, CrossingNode>();
25
+ private layerByPort = new Map<string, number>();
26
+ private nodes = new Map<string, CrossingNode>();
27
+ private incoming = new Map<string, string[]>();
28
+ private outgoing = new Map<string, string[]>();
29
+ constructor(graph: CrossingGraph, state?: PortDistributionState) {
30
+ this.state = state ? structuredClone(state) : { ranks: {}, barycenters: {}, positions: {} };
31
+ for (const layer of graph.layers)
32
+ for (const [position, node] of layer.entries()) {
33
+ this.state.positions[node.id] ??= position;
34
+ for (const port of node.ports) {
35
+ this.state.ranks[port.id] ??= 0;
36
+ this.state.barycenters[port.id] ??= 0;
37
+ }
38
+ }
39
+ }
40
+
41
+ private prepareTopology(graph: CrossingGraph): void {
42
+ // Sweeps reorder nodes within their layers; connectivity stays constant.
43
+ // A restored graph object invalidates the topology once, not per layer.
44
+ if (this.topologyGraph !== graph) {
45
+ this.topologyGraph = graph;
46
+ this.owner.clear();
47
+ this.layerByPort.clear();
48
+ this.nodes.clear();
49
+ this.incoming.clear();
50
+ this.outgoing.clear();
51
+ for (const [layer, members] of graph.layers.entries())
52
+ for (const node of members) {
53
+ this.nodes.set(node.id, node);
54
+ for (const port of node.ports) {
55
+ this.owner.set(port.id, node);
56
+ this.layerByPort.set(port.id, layer);
57
+ this.incoming.set(port.id, []);
58
+ this.outgoing.set(port.id, []);
59
+ }
60
+ }
61
+ for (const edge of graph.edges) {
62
+ this.incoming.get(edge.target)!.push(edge.source);
63
+ this.outgoing.get(edge.source)!.push(edge.target);
64
+ }
65
+ }
66
+ }
67
+
68
+ calculatePortRanks(
69
+ graph: CrossingGraph,
70
+ layer: readonly CrossingNode[],
71
+ input: boolean,
72
+ nodeRelative: boolean,
73
+ ): void {
74
+ this.prepareTopology(graph);
75
+ let consumed = 0;
76
+ for (const node of layer) {
77
+ const ports = node.ports.filter(
78
+ (port) => (input ? this.incoming : this.outgoing).get(port.id)!.length > 0,
79
+ );
80
+ const increment = nodeRelative ? 1 / (ports.length + 1) : 1;
81
+ if (input) {
82
+ let north = consumed + ports.filter((port) => port.side === "NORTH").length * increment;
83
+ let rest = nodeRelative ? consumed + 1 - increment : consumed + ports.length;
84
+ for (const port of ports) {
85
+ this.state.ranks[port.id] = port.side === "NORTH" ? north : rest;
86
+ if (port.side === "NORTH") north -= increment;
87
+ else rest -= increment;
88
+ }
89
+ } else {
90
+ let position = consumed + increment;
91
+ for (const port of ports) {
92
+ this.state.ranks[port.id] = position;
93
+ position += increment;
94
+ }
95
+ }
96
+ consumed += nodeRelative ? 1 : ports.length;
97
+ }
98
+ }
99
+
100
+ distribute(
101
+ graph: CrossingGraph,
102
+ index: number,
103
+ forward: boolean,
104
+ options: PortDistributionOptions,
105
+ ): void {
106
+ this.prepareTopology(graph);
107
+ const { owner, layerByPort, nodes, incoming, outgoing } = this;
108
+ const degree = (port: string) => incoming.get(port)!.length + outgoing.get(port)!.length;
109
+ const ranks = (layer: CrossingNode[], input: boolean) =>
110
+ this.calculatePortRanks(graph, layer, input, options.nodeRelative);
111
+ const distributeNode = (node: CrossingNode, side: "EAST" | "WEST") => {
112
+ if (options.fixedOrder.has(node.id)) return;
113
+ const layerIndex = node.ports.length ? layerByPort.get(node.ports[0]!.id)! : index;
114
+ const size = graph.layers[layerIndex]!.length;
115
+ for (const face of [side, "SOUTH", "NORTH"] as const) {
116
+ let minimum = 0,
117
+ maximum = 0;
118
+ const inLayer: CrossingPort[] = [];
119
+ for (const port of node.ports.filter((port) => port.side === face)) {
120
+ let sum = 0;
121
+ const cross = port.side === "NORTH" || port.side === "SOUTH";
122
+ if (cross) {
123
+ if (!port.dummy) continue;
124
+ const dummy = nodes.get(port.dummy)!;
125
+ let input = false,
126
+ output = false;
127
+ for (const peer of dummy.ports) {
128
+ if (peer.origin !== port.id) continue;
129
+ if (outgoing.get(peer.id)!.length) output = true;
130
+ else if (incoming.get(peer.id)!.length) input = true;
131
+ }
132
+ const position = this.state.positions[dummy.id]!;
133
+ const large = 2 * size + 1;
134
+ if (input && !output) sum += port.side === "NORTH" ? -position : large - position;
135
+ else if (output && !input) sum += position + 1;
136
+ else if (input && output) sum += port.side === "NORTH" ? 0 : large / 2;
137
+ } else {
138
+ const peers = [...outgoing.get(port.id)!, ...incoming.get(port.id)!];
139
+ if (peers.some((peer) => layerByPort.get(peer) === layerIndex)) {
140
+ inLayer.push(port);
141
+ continue;
142
+ }
143
+ for (const peer of outgoing.get(port.id)!) sum += this.state.ranks[peer]!;
144
+ for (const peer of incoming.get(port.id)!) sum -= this.state.ranks[peer]!;
145
+ }
146
+ if (degree(port.id)) {
147
+ const value = sum / degree(port.id);
148
+ this.state.barycenters[port.id] = value;
149
+ minimum = Math.min(minimum, value);
150
+ maximum = Math.max(maximum, value);
151
+ } else if (cross) this.state.barycenters[port.id] = sum;
152
+ }
153
+ for (const port of inLayer) {
154
+ const peers = [...incoming.get(port.id)!, ...outgoing.get(port.id)!].filter(
155
+ (peer) => layerByPort.get(peer) === layerIndex,
156
+ );
157
+ const value =
158
+ peers.reduce((sum, peer) => sum + this.state.positions[owner.get(peer)!.id]! + 1, 0) /
159
+ peers.length;
160
+ const above = value < this.state.positions[node.id]! + 1;
161
+ this.state.barycenters[port.id] =
162
+ port.side === "EAST"
163
+ ? above
164
+ ? minimum - value
165
+ : maximum + (size + 1 - value)
166
+ : above
167
+ ? maximum + value
168
+ : minimum - (size + 1 - value);
169
+ }
170
+ }
171
+ const sideRank = { NORTH: 0, EAST: 1, SOUTH: 2, WEST: 3 };
172
+ node.ports.sort((a, b) => {
173
+ const side = sideRank[a.side] - sideRank[b.side];
174
+ if (side) return side;
175
+ const left = this.state.barycenters[a.id]!,
176
+ right = this.state.barycenters[b.id]!;
177
+ return left === 0 && right === 0 ? 0 : left === 0 ? -1 : right === 0 ? 1 : left - right;
178
+ });
179
+ };
180
+ const free = graph.layers[index]!;
181
+ for (const [position, node] of free.entries()) this.state.positions[node.id] = position;
182
+ const side = forward ? "WEST" : "EAST";
183
+ const fixed = graph.layers[index + (forward ? -1 : 1)];
184
+ if (fixed) {
185
+ ranks(fixed, !forward);
186
+ for (const node of free) distributeNode(node, side);
187
+ ranks(free, forward);
188
+ for (const node of fixed)
189
+ if (!options.hierarchical.has(node.id)) distributeNode(node, forward ? "EAST" : "WEST");
190
+ } else for (const node of free) distributeNode(node, side);
191
+ }
192
+ }
@@ -0,0 +1,20 @@
1
+ import type { GraphEdge } from "@statelyai/graph";
2
+ import type { LayeredPhaseInput } from "./types";
3
+
4
+ /** ELK traverses connected edges through the node's port list, preserving each port's edge order. */
5
+ export function connectedEdgesInPortOrder(input: LayeredPhaseInput, nodeId: string): GraphEdge[] {
6
+ const node = input.graph.nodes.find((candidate) => candidate.id === nodeId);
7
+ const portRank = new Map(node?.ports?.map((port, index) => [port.name, index]));
8
+ const explicitCount = portRank.size;
9
+ return input.graph.edges
10
+ .map((edge, index) => ({ edge, index }))
11
+ .filter(({ edge }) => edge.sourceId === nodeId || edge.targetId === nodeId)
12
+ .sort((a, b) => {
13
+ const rank = ({ edge, index }: { edge: GraphEdge; index: number }) => {
14
+ const port = edge.sourceId === nodeId ? edge.sourcePort : edge.targetPort;
15
+ return (port === undefined ? undefined : portRank.get(port)) ?? explicitCount + index;
16
+ };
17
+ return rank(a) - rank(b) || a.index - b.index;
18
+ })
19
+ .map(({ edge }) => edge);
20
+ }
@@ -0,0 +1,45 @@
1
+ import { worldGeometry } from "../authoring/coordinates";
2
+ import { routeToPoints } from "../routing/adapters";
3
+ import { recordRouteGeometry } from "../routing/layout-cache";
4
+ import type { CompoundVisualGraph } from "./compound";
5
+ import type { LayeredLayoutOptions } from "./types";
6
+
7
+ /** Run a replacement on finalized geometry, with no initial route hints. */
8
+ export function replaceLayoutRouting<N, E, G, P>(
9
+ graph: CompoundVisualGraph<N, E, G, P>,
10
+ options: LayeredLayoutOptions,
11
+ ): CompoundVisualGraph<N, E, G, P> {
12
+ if (!options.routing) return graph;
13
+ const { compoundRoutes: _routes, compoundGeometry: _geometry, ...geometry } = graph;
14
+ const world = worldGeometry(geometry);
15
+ const input = {
16
+ ...geometry,
17
+ nodes: world.nodes.map((n) => ({ ...n, ports: n.ports?.map((p) => ({ ...p })) })),
18
+ edges: world.edges.map(({ points: _points, routing: _routing, ...e }) => e),
19
+ };
20
+ const snapshot = options.routing.strategy.route(input, {
21
+ ...options.routing.settings,
22
+ coordinateSpace: "world",
23
+ });
24
+ if (snapshot instanceof Promise)
25
+ throw new TypeError(
26
+ "Layered route replacement must be synchronous; run async routing separately",
27
+ );
28
+ if (
29
+ snapshot.routes.size !== graph.edges.length ||
30
+ graph.edges.some((e) => !snapshot.routes.has(e.id))
31
+ )
32
+ throw new Error("Replacement router must return exactly one route for every edge");
33
+ const result = {
34
+ ...graph,
35
+ edgeCoordinateSpace: "world" as const,
36
+ edges: graph.edges.map((edge) => ({
37
+ ...edge,
38
+ points: routeToPoints(snapshot.routes.get(edge.id)!),
39
+ routing: "polyline" as const,
40
+ })),
41
+ compoundRoutes: snapshot.routes,
42
+ };
43
+ recordRouteGeometry(result, result.compoundRoutes);
44
+ return result;
45
+ }
@@ -0,0 +1,32 @@
1
+ import type { NodePlacement } from "./types";
2
+
3
+ // ELK routes in the node placer's coordinate space, before graph-padding normalization.
4
+ const coordinateSpaces = new WeakMap<
5
+ NodePlacement,
6
+ { raw: NodePlacement; normalized: NodePlacement }
7
+ >();
8
+
9
+ export function recordRoutingCoordinates(placement: NodePlacement, raw: NodePlacement): void {
10
+ coordinateSpaces.set(placement, {
11
+ raw,
12
+ normalized: { rectByNodeId: new Map(placement.rectByNodeId) },
13
+ });
14
+ }
15
+
16
+ export function routingCoordinates(placement: NodePlacement): NodePlacement {
17
+ const spaces = coordinateSpaces.get(placement);
18
+ if (!spaces) return placement;
19
+ return {
20
+ rectByNodeId: new Map(
21
+ [...placement.rectByNodeId].map(([id, rect]) => {
22
+ const original = spaces.normalized.rectByNodeId.get(id),
23
+ raw = spaces.raw.rectByNodeId.get(id);
24
+ if (!original || !raw) return [id, rect];
25
+ return [
26
+ id,
27
+ { ...rect, x: raw.x + (rect.x - original.x), y: raw.y + (rect.y - original.y) },
28
+ ];
29
+ }),
30
+ ),
31
+ };
32
+ }
@@ -108,10 +108,31 @@ export function separateExteriorLabels<E extends ExteriorLabelEdge>({
108
108
  right.length - left.length,
109
109
  )[0];
110
110
  if (!routeTrack) return [];
111
+ // Long-edge joining can retain several collinear points through a label
112
+ // dummy. They form one track; moving only one fragment creates diagonals
113
+ // at the retained junctions.
114
+ const pointCross = (point: Point) => (horizontalFlow ? point.y : point.x);
115
+ const pointFlow = (point: Point) => (horizontalFlow ? point.x : point.y);
116
+ let startIndex = routeTrack.startIndex;
117
+ let endIndex = startIndex + 1;
118
+ while (startIndex > 0 && pointCross(edge.points[startIndex - 1]!) === routeTrack.cross)
119
+ startIndex--;
120
+ while (
121
+ endIndex + 1 < edge.points.length &&
122
+ pointCross(edge.points[endIndex + 1]!) === routeTrack.cross
123
+ )
124
+ endIndex++;
125
+ const trackPoints = edge.points.slice(startIndex, endIndex + 1);
111
126
  return [
112
127
  {
113
128
  edge,
114
- exteriorTrack: routeTrack,
129
+ exteriorTrack: {
130
+ ...routeTrack,
131
+ startIndex,
132
+ endIndex,
133
+ flowStart: Math.min(...trackPoints.map(pointFlow)),
134
+ flowEnd: Math.max(...trackPoints.map(pointFlow)),
135
+ },
115
136
  preserveAnchors: exteriorTrack === undefined,
116
137
  labelRect,
117
138
  lowSide:
@@ -222,17 +243,14 @@ export function separateExteriorLabels<E extends ExteriorLabelEdge>({
222
243
  else edge.x += totalDelta;
223
244
  if (!exteriorTrack) continue;
224
245
  edge.points = edge.points.flatMap((point, index) => {
225
- if (index !== exteriorTrack.startIndex && index !== exteriorTrack.startIndex + 1)
226
- return [point];
246
+ if (index < exteriorTrack.startIndex || index > exteriorTrack.endIndex) return [point];
227
247
  const shifted = horizontalFlow
228
248
  ? { ...point, y: point.y + totalDelta }
229
249
  : { ...point, x: point.x + totalDelta };
230
250
  // Keep authored endpoint anchors attached when the chosen track ends there.
231
251
  return preserveAnchors && index === exteriorTrack.startIndex && index === 0
232
252
  ? [point, shifted]
233
- : preserveAnchors &&
234
- index === exteriorTrack.startIndex + 1 &&
235
- index === edge.points.length - 1
253
+ : preserveAnchors && index === exteriorTrack.endIndex && index === edge.points.length - 1
236
254
  ? [shifted, point]
237
255
  : [shifted];
238
256
  });
@@ -1,3 +1,4 @@
1
+ import { externalPortDummyOf } from "./external-port-dummy";
1
2
  import type { LayeredPhaseInput } from "./types";
2
3
 
3
4
  export type IndividualSpacing = Readonly<Record<string, number>>;
@@ -10,20 +11,41 @@ export function nodeNodeSpacing(
10
11
  ): number {
11
12
  const firstBreakingPoint = firstId.startsWith("__layout_breaking:");
12
13
  const secondBreakingPoint = secondId.startsWith("__layout_breaking:");
13
- const firstDummy = firstId.startsWith("__layout_dummy:") || firstBreakingPoint;
14
- const secondDummy = secondId.startsWith("__layout_dummy:") || secondBreakingPoint;
14
+ const firstLabel = firstId.startsWith("__layout_dummy:label:");
15
+ const secondLabel = secondId.startsWith("__layout_dummy:label:");
16
+ const firstNode = input.graph.nodes.find((node) => node.id === firstId);
17
+ const secondNode = input.graph.nodes.find((node) => node.id === secondId);
18
+ const firstExternal = firstNode !== undefined && externalPortDummyOf(firstNode) !== undefined;
19
+ const secondExternal = secondNode !== undefined && externalPortDummyOf(secondNode) !== undefined;
20
+ const firstDummy = firstId.startsWith("__layout_dummy:") || firstBreakingPoint || firstExternal;
21
+ const secondDummy =
22
+ secondId.startsWith("__layout_dummy:") || secondBreakingPoint || secondExternal;
15
23
  const spacingName =
16
- firstDummy && secondDummy && firstBreakingPoint === secondBreakingPoint
17
- ? "spacing.edgeEdge"
18
- : firstDummy || secondDummy
19
- ? "spacing.edgeNode"
20
- : "spacing.node";
24
+ firstExternal && secondExternal
25
+ ? "spacing.portPort"
26
+ : (firstExternal && secondLabel) || (secondExternal && firstLabel)
27
+ ? "spacing.labelPortVertical"
28
+ : firstLabel && secondLabel
29
+ ? "spacing.edgeEdge"
30
+ : (firstLabel && !secondDummy) || (secondLabel && !firstDummy)
31
+ ? "spacing.node"
32
+ : (firstLabel && secondDummy) || (secondLabel && firstDummy)
33
+ ? "spacing.edgeNode"
34
+ : firstDummy && secondDummy && firstBreakingPoint === secondBreakingPoint
35
+ ? "spacing.edgeEdge"
36
+ : firstDummy || secondDummy
37
+ ? "spacing.edgeNode"
38
+ : "spacing.node";
21
39
  let spacing =
22
- spacingName === "spacing.edgeEdge"
23
- ? Number(input.settings["spacing.edgeEdge"] ?? 10)
24
- : spacingName === "spacing.edgeNode"
25
- ? Number(input.settings["spacing.edgeNode"] ?? 10)
26
- : input.spacing.node;
40
+ spacingName === "spacing.portPort"
41
+ ? Number(input.settings["spacing.portPort"] ?? 10)
42
+ : spacingName === "spacing.labelPortVertical"
43
+ ? Number(input.settings["spacing.labelPortVertical"] ?? 1)
44
+ : spacingName === "spacing.edgeEdge"
45
+ ? Number(input.settings["spacing.edgeEdge"] ?? 10)
46
+ : spacingName === "spacing.edgeNode"
47
+ ? Number(input.settings["spacing.edgeNode"] ?? 10)
48
+ : input.spacing.node;
27
49
  for (const id of [firstId, secondId]) {
28
50
  const node = input.graph.nodes.find((candidate) => candidate.id === id);
29
51
  if (!node) continue;
@@ -0,0 +1,106 @@
1
+ import type { GraphEdge } from "@statelyai/graph";
2
+ import type { PortEdgeList } from "./elk-port-lists";
3
+ import type { LongEdgeExpansion } from "./long-edges";
4
+ import type { LayeredPhaseInput } from "./types";
5
+
6
+ /**
7
+ * ELK's LongEdgeSplitter appends each dummy to the next layer while visiting
8
+ * the current layer's nodes, their ports and each port's outgoing edges. That
9
+ * insertion order is the initial crossing-minimization order, so seed it here.
10
+ */
11
+ export function withLongEdgeSplitterOrder(
12
+ unsplit: LayeredPhaseInput,
13
+ expansion: LongEdgeExpansion,
14
+ baseOrder: readonly string[],
15
+ portLists: ReadonlyMap<string, readonly PortEdgeList[]>,
16
+ ): LongEdgeExpansion {
17
+ const { input, orientation, assignment } = expansion;
18
+ const authoredIds = new Set(unsplit.graph.nodes.map((node) => node.id));
19
+ const dummyIds = new Set(
20
+ input.graph.nodes.map((node) => node.id).filter((id) => !authoredIds.has(id)),
21
+ );
22
+ if (dummyIds.size === 0) return expansion;
23
+ const layerOf = (id: string) => assignment.layerByNodeId.get(id) ?? 0;
24
+ const placed = new Set<string>();
25
+ const layers: string[][] = [];
26
+ for (const id of [...baseOrder, ...authoredIds]) {
27
+ if (placed.has(id) || !authoredIds.has(id)) continue;
28
+ placed.add(id);
29
+ (layers[layerOf(id)] ??= []).push(id);
30
+ }
31
+ const reversed = (edge: GraphEdge) => orientation.reversedEdgeIds.has(edge.id);
32
+ const outgoing = new Map<string, GraphEdge[]>();
33
+ for (const edge of input.graph.edges) {
34
+ const source = reversed(edge) ? edge.targetId : edge.sourceId;
35
+ if (edge.sourceId === edge.targetId) continue;
36
+ if (!outgoing.has(source)) outgoing.set(source, []);
37
+ outgoing.get(source)!.push(edge);
38
+ }
39
+ // Visit ports and their outgoing edges in ELK's simulated list order, which
40
+ // records reversal history. Expanded segment ids keep the original edge id.
41
+ const original = (id: string) => id.split("::")[0]!;
42
+ const targetsInPortOrder = (id: string): string[] => {
43
+ const edges = outgoing.get(id) ?? [];
44
+ const lists = portLists.get(id);
45
+ if (!lists) return edges.map((edge) => (reversed(edge) ? edge.sourceId : edge.targetId));
46
+ const rank = new Map<string, number>();
47
+ for (const port of lists) for (const edgeId of port.outgoing) rank.set(edgeId, rank.size);
48
+ return edges
49
+ .map((edge, index) => ({
50
+ edge,
51
+ index,
52
+ rank: rank.get(original(edge.id)) ?? rank.size + index,
53
+ }))
54
+ .sort((a, b) => a.rank - b.rank || a.index - b.index)
55
+ .map(({ edge }) => (reversed(edge) ? edge.sourceId : edge.targetId));
56
+ };
57
+ for (let index = 0; index < layers.length; index++) {
58
+ const layer = layers[index] ?? [];
59
+ for (let position = 0; position < layer.length; position++) {
60
+ for (const target of targetsInPortOrder(layer[position]!)) {
61
+ if (!dummyIds.has(target) || placed.has(target) || layerOf(target) !== index + 1) continue;
62
+ placed.add(target);
63
+ (layers[index + 1] ??= []).push(target);
64
+ }
65
+ }
66
+ }
67
+ const seedOrder = layers.flatMap((layer) => layer ?? []);
68
+ for (const id of dummyIds) if (!placed.has(id)) seedOrder.push(id);
69
+ return { ...expansion, assignment: { ...assignment, seedOrder } };
70
+ }
71
+
72
+ /**
73
+ * Each split re-targets a long edge through a new segment appended to the
74
+ * target port's incoming edges. Long edges therefore follow retained short
75
+ * edges, in the order of their final split.
76
+ */
77
+ export function incomingAfterLongEdgeSplitting(
78
+ lists: ReadonlyMap<string, readonly PortEdgeList[]>,
79
+ expansion: LongEdgeExpansion,
80
+ ): Map<string, PortEdgeList[]> {
81
+ const { input, orientation, assignment } = expansion;
82
+ const seed = new Map((assignment.seedOrder ?? []).map((id, index) => [id, index]));
83
+ const listed = (id: string) => id.replace(/::segment:\d+$/, "");
84
+ const finalSplit = new Map<string, number>();
85
+ for (const edge of input.graph.edges) {
86
+ const reversed = orientation.reversedEdgeIds.has(edge.id);
87
+ const [source, target] = reversed
88
+ ? [edge.targetId, edge.sourceId]
89
+ : [edge.sourceId, edge.targetId];
90
+ const at = seed.get(source);
91
+ if (source.startsWith("__layout_dummy:") && at !== undefined)
92
+ finalSplit.set(`${target}\0${listed(edge.id)}`, at);
93
+ }
94
+ return new Map(
95
+ [...lists].map(([id, ports]) => [
96
+ id,
97
+ ports.map((port) => ({
98
+ ...port,
99
+ incoming: port.incoming
100
+ .map((edgeId, index) => ({ edgeId, index, split: finalSplit.get(`${id}\0${edgeId}`) }))
101
+ .sort((a, b) => (a.split ?? -1) - (b.split ?? -1) || a.index - b.index)
102
+ .map(({ edgeId }) => edgeId),
103
+ })),
104
+ ]),
105
+ );
106
+ }