@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
@@ -0,0 +1,275 @@
1
+ import type { GraphNode, VisualGraph } from "@statelyai/graph";
2
+ import type { LayeredLayoutOptions } from "./types";
3
+ import { routeCrosses } from "../authoring/routing";
4
+ import {
5
+ orthogonalRouting,
6
+ routeToPoints,
7
+ type EdgeRoutingSettings,
8
+ type RouteSide,
9
+ type RouteEndpoint,
10
+ } from "../routing";
11
+ import { recordRouteGeometry } from "../routing/layout-cache";
12
+
13
+ /** Keep valid layered tracks; repair infeasible port leads and obstacle crossings. */
14
+ export function repairFlatRouting<N, E, G, P>(
15
+ graph: VisualGraph<N, E, G, P>,
16
+ options: LayeredLayoutOptions,
17
+ ): VisualGraph<N, E, G, P> {
18
+ if (
19
+ graph.nodes.some((n) => n.parentId != null) ||
20
+ options.settings?.noLayout ||
21
+ (options.settings?.edgeRouting ?? "ORTHOGONAL") !== "ORTHOGONAL" ||
22
+ options.strategies?.routeEdges
23
+ )
24
+ return graph;
25
+ const overlaps = (
26
+ a: { x: number; y: number; width: number; height: number },
27
+ b: { x: number; y: number; width: number; height: number },
28
+ ) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
29
+ // Leave room for port centers outside the node and their 9px exit leads.
30
+ const portClearance = (n: (typeof graph.nodes)[number]) => {
31
+ const reach = Math.max(
32
+ 0,
33
+ ...(n.ports ?? []).flatMap((p) => [
34
+ -(p.x ?? 0) - (p.width ?? 0) / 2,
35
+ -(p.y ?? 0) - (p.height ?? 0) / 2,
36
+ (p.x ?? 0) + (p.width ?? 0) / 2 - n.width,
37
+ (p.y ?? 0) + (p.height ?? 0) / 2 - n.height,
38
+ ]),
39
+ );
40
+ const margin = 9 + reach;
41
+ return {
42
+ x: n.x - margin,
43
+ y: n.y - margin,
44
+ width: n.width + 2 * margin,
45
+ height: n.height + 2 * margin,
46
+ };
47
+ };
48
+ const labels = graph.edges.filter((e) => e.width > 0 && e.height > 0);
49
+ const labelCollision = labels.some(
50
+ (e, i) =>
51
+ graph.nodes.some((n) => overlaps(e, portClearance(n))) ||
52
+ labels.slice(i + 1).some((other) => overlaps(e, other)),
53
+ );
54
+ const routingDefect = graph.edges.some((edge) => {
55
+ if (
56
+ graph.nodes.some((node) => routeCrosses(edge.points, node)) ||
57
+ labels.some((label) => label.id !== edge.id && routeCrosses(edge.points, label))
58
+ )
59
+ return true;
60
+ const points = edge.points ?? [];
61
+ const loopNode =
62
+ edge.sourceId === edge.targetId ? graph.nodes.find((n) => n.id === edge.sourceId) : undefined;
63
+ const terminals = [
64
+ ...(loopNode
65
+ ? [
66
+ {
67
+ x: loopNode.x - 12,
68
+ y: loopNode.y - 12,
69
+ width: loopNode.width + 24,
70
+ height: loopNode.height + 24,
71
+ },
72
+ ]
73
+ : []),
74
+ ...(edge.width > 0 && edge.height > 0 ? [edge] : []),
75
+ ];
76
+ for (let i = 1; i < points.length; i++) {
77
+ const a = points[i - 1]!,
78
+ b = points[i]!;
79
+ if (Math.abs(a.x - b.x) > 1e-8 && Math.abs(a.y - b.y) > 1e-8) return true;
80
+ for (let j = i + 1; j < points.length; j++) {
81
+ const c = points[j - 1]!,
82
+ d = points[j]!;
83
+ const horizontal =
84
+ Math.abs(a.y - b.y) < 1e-8 && Math.abs(c.y - d.y) < 1e-8 && Math.abs(a.y - c.y) < 1e-8;
85
+ const vertical =
86
+ Math.abs(a.x - b.x) < 1e-8 && Math.abs(c.x - d.x) < 1e-8 && Math.abs(a.x - c.x) < 1e-8;
87
+ if (!horizontal && !vertical) continue;
88
+ const axis = horizontal ? "x" : "y",
89
+ fixed = horizontal ? "y" : "x";
90
+ const lo = Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis]));
91
+ const hi = Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis]));
92
+ if (hi - lo < 1e-8) continue;
93
+ let intervals = [[lo, hi]];
94
+ for (const rect of terminals) {
95
+ const span = horizontal ? rect.height : rect.width;
96
+ if (a[fixed] < rect[fixed] || a[fixed] > rect[fixed] + span) continue;
97
+ const lower = rect[axis],
98
+ upper = lower + (horizontal ? rect.width : rect.height);
99
+ intervals = intervals
100
+ .flatMap(([start, end]) => [
101
+ [start!, Math.min(end!, lower)],
102
+ [Math.max(start!, upper), end!],
103
+ ])
104
+ .filter(([start, end]) => end! - start! > 1e-8);
105
+ }
106
+ if (intervals.length) return true;
107
+ }
108
+ }
109
+ return false;
110
+ });
111
+ if (!labelCollision && !routingDefect) return graph;
112
+ const edges = graph.edges.map((edge) => ({ ...edge }));
113
+ // The original track may put an exterior label partly inside a node. Move
114
+ // only colliding labels before routing their actual boundary connections.
115
+ for (const edge of edges.filter((e) => e.width > 0 && e.height > 0)) {
116
+ const obstacles = [
117
+ ...graph.nodes.map(portClearance),
118
+ ...edges.filter((e) => e.id !== edge.id && e.width > 0 && e.height > 0),
119
+ ];
120
+ if (!obstacles.some((rect) => overlaps(edge, rect))) continue;
121
+ const xs = [edge.x, ...obstacles.flatMap((r) => [r.x - edge.width - 8, r.x + r.width + 8])];
122
+ const ys = [edge.y, ...obstacles.flatMap((r) => [r.y - edge.height - 8, r.y + r.height + 8])];
123
+ let best: { x: number; y: number; distance: number } | undefined;
124
+ for (const x of xs)
125
+ for (const y of ys) {
126
+ const distance = Math.abs(x - edge.x) + Math.abs(y - edge.y);
127
+ if (best && distance >= best.distance) continue;
128
+ if (!obstacles.some((rect) => overlaps({ ...edge, x, y }, rect))) best = { x, y, distance };
129
+ }
130
+ if (best) {
131
+ edge.x = best.x;
132
+ edge.y = best.y;
133
+ }
134
+ }
135
+ const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
136
+ const fixedImplicitSide = (
137
+ nodeId: string,
138
+ portName: string | undefined,
139
+ point: { x: number; y: number } | undefined,
140
+ away: { x: number; y: number } | undefined,
141
+ ): RouteSide | undefined => {
142
+ const node = nodes.get(nodeId);
143
+ if (
144
+ !node ||
145
+ portName !== undefined ||
146
+ !point ||
147
+ !["FIXED_SIDE", "FIXED_ORDER", "FIXED_RATIO", "FIXED_POS"].includes(
148
+ String(options.nodeSettings?.(node)?.portConstraints),
149
+ )
150
+ )
151
+ return undefined;
152
+ // Fixed implicit attachments are assigned before reversal. Repair must
153
+ // preserve the initial face, including special self-loop attachments.
154
+ const faces: [RouteSide, number][] = [
155
+ ["left", Math.abs(point.x - node.x)],
156
+ ["right", Math.abs(point.x - node.x - node.width)],
157
+ ["top", Math.abs(point.y - node.y)],
158
+ ["bottom", Math.abs(point.y - node.y - node.height)],
159
+ ];
160
+ const leadSide: RouteSide | undefined =
161
+ away && (Math.abs(away.x - point.x) > 1e-8 || Math.abs(away.y - point.y) > 1e-8)
162
+ ? Math.abs(away.x - point.x) >= Math.abs(away.y - point.y)
163
+ ? away.x > point.x
164
+ ? "right"
165
+ : "left"
166
+ : away.y > point.y
167
+ ? "bottom"
168
+ : "top"
169
+ : undefined;
170
+ faces.sort((a, b) => a[1] - b[1] || Number(b[0] === leadSide) - Number(a[0] === leadSide));
171
+ return faces[0]![1] < 1e-6 ? faces[0]![0] : undefined;
172
+ };
173
+ const attachments: Record<string, EdgeRoutingSettings> = Object.fromEntries(
174
+ graph.edges.map((edge) => [
175
+ edge.id,
176
+ {
177
+ sourceSide: fixedImplicitSide(
178
+ edge.sourceId,
179
+ edge.sourcePort,
180
+ edge.points?.[0],
181
+ edge.points?.[1],
182
+ ),
183
+ targetSide: fixedImplicitSide(
184
+ edge.targetId,
185
+ edge.targetPort,
186
+ edge.points?.at(-1),
187
+ edge.points?.at(-2),
188
+ ),
189
+ },
190
+ ]),
191
+ );
192
+ const routingNodes = new Map<string, GraphNode>(graph.nodes.map((node) => [node.id, node]));
193
+ const syntheticPorts = new Map<string, Set<string>>();
194
+ const attach = (
195
+ edgeId: string,
196
+ nodeId: string,
197
+ portName: string | undefined,
198
+ point: { x: number; y: number } | undefined,
199
+ source: boolean,
200
+ ) => {
201
+ const side = source ? attachments[edgeId]?.sourceSide : attachments[edgeId]?.targetSide;
202
+ if (
203
+ !side ||
204
+ !point ||
205
+ !["FIXED_POS", "FIXED_RATIO"].includes(
206
+ String(options.nodeSettings?.(nodes.get(nodeId)!)?.portConstraints),
207
+ )
208
+ )
209
+ return portName;
210
+ const node = routingNodes.get(nodeId)!;
211
+ let name = `__layout_implicit:${edgeId}:${source ? "source" : "target"}`;
212
+ while (node.ports?.some((p) => p.name === name)) name += ":";
213
+ const names = syntheticPorts.get(nodeId) ?? new Set<string>();
214
+ names.add(name);
215
+ syntheticPorts.set(nodeId, names);
216
+ routingNodes.set(nodeId, {
217
+ ...node,
218
+ ports: [
219
+ ...(node.ports ?? []),
220
+ {
221
+ name,
222
+ data: undefined,
223
+ direction: "inout",
224
+ x: point.x - node.x!,
225
+ y: point.y - node.y!,
226
+ width: 0,
227
+ height: 0,
228
+ },
229
+ ],
230
+ });
231
+ return name;
232
+ };
233
+ const routingEdges = edges.map((edge) => ({
234
+ ...edge,
235
+ sourcePort: attach(edge.id, edge.sourceId, edge.sourcePort, edge.points?.[0], true),
236
+ targetPort: attach(edge.id, edge.targetId, edge.targetPort, edge.points?.at(-1), false),
237
+ }));
238
+ const snapshot = orthogonalRouting.route(
239
+ { ...graph, nodes: [...routingNodes.values()], edges: routingEdges },
240
+ { coordinateSpace: "world", maxSearchNodes: 40000, edges: attachments },
241
+ );
242
+ const authoredEndpoint = (endpoint: RouteEndpoint): RouteEndpoint => {
243
+ if (
244
+ endpoint.kind !== "node" ||
245
+ endpoint.port === undefined ||
246
+ !syntheticPorts.get(endpoint.nodeId)?.has(endpoint.port)
247
+ )
248
+ return endpoint;
249
+ const { port: _privatePort, ...authored } = endpoint;
250
+ return authored;
251
+ };
252
+ const routes = new Map(
253
+ [...snapshot.routes].map(([id, route]) => [
254
+ id,
255
+ {
256
+ ...route,
257
+ sections: route.sections.map((section) => ({
258
+ ...section,
259
+ from: authoredEndpoint(section.from),
260
+ to: authoredEndpoint(section.to),
261
+ })),
262
+ },
263
+ ]),
264
+ );
265
+ const result = {
266
+ ...graph,
267
+ edges: edges.map((edge) => ({
268
+ ...edge,
269
+ points: routeToPoints(routes.get(edge.id)!),
270
+ })),
271
+ compoundRoutes: routes,
272
+ };
273
+ recordRouteGeometry(result, routes);
274
+ return result;
275
+ }
@@ -5,6 +5,7 @@
5
5
  * Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
6
6
  * SPDX-License-Identifier: EPL-2.0
7
7
  *******************************************************************************/
8
+ import { connectedEdgesInPortOrder } from "./port-edge-order";
8
9
  import type { GraphEdge } from "@statelyai/graph";
9
10
  import type { AcyclicOrientation, LayerAssigner, LayeredPhaseInput } from "./types";
10
11
 
@@ -44,13 +45,13 @@ function getOrientedEndpoints(
44
45
  : [edge.sourceId, edge.targetId];
45
46
  }
46
47
 
47
- function getComponents(input: LayeredPhaseInput, orientation: AcyclicOrientation): string[][] {
48
+ export function networkSimplexComponents(input: LayeredPhaseInput): string[][] {
48
49
  const adjacent = new Map(input.graph.nodes.map((node) => [node.id, [] as string[]]));
49
- for (const edge of input.graph.edges) {
50
- const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
51
- if (sourceId === targetId) continue;
52
- adjacent.get(sourceId)?.push(targetId);
53
- adjacent.get(targetId)?.push(sourceId);
50
+ for (const node of input.graph.nodes) {
51
+ for (const edge of connectedEdgesInPortOrder(input, node.id)) {
52
+ if (edge.sourceId === edge.targetId) continue;
53
+ adjacent.get(node.id)?.push(edge.sourceId === node.id ? edge.targetId : edge.sourceId);
54
+ }
54
55
  }
55
56
  const visited = new Set<string>();
56
57
  const components: string[][] = [];
@@ -96,17 +97,12 @@ function createSimplexGraph(
96
97
  treeNode: false,
97
98
  }));
98
99
  const nodeById = new Map(nodes.map((node) => [node.id, node]));
99
- const edgeBySource = new Map(component.map((id) => [id, [] as GraphEdge[]]));
100
- for (const edge of input.graph.edges) {
101
- const [sourceId, targetId] = getOrientedEndpoints(edge, orientation);
102
- if (sourceId !== targetId && memberIds.has(sourceId) && memberIds.has(targetId)) {
103
- edgeBySource.get(sourceId)?.push(edge);
104
- }
105
- }
106
100
  const edges: SimplexEdge[] = [];
107
101
  for (const sourceId of component) {
108
- for (const graphEdge of edgeBySource.get(sourceId) ?? []) {
102
+ for (const graphEdge of connectedEdgesInPortOrder(input, sourceId)) {
109
103
  const [orientedSourceId, targetId] = getOrientedEndpoints(graphEdge, orientation);
104
+ if (orientedSourceId !== sourceId || sourceId === targetId || !memberIds.has(targetId))
105
+ continue;
110
106
  const source = nodeById.get(orientedSourceId);
111
107
  const target = nodeById.get(targetId);
112
108
  if (!source || !target) continue;
@@ -328,7 +324,7 @@ export function runNetworkSimplex(
328
324
  }
329
325
 
330
326
  export const assignLayersWithNetworkSimplex: LayerAssigner = (input, orientation) => {
331
- const components = getComponents(input, orientation);
327
+ const components = networkSimplexComponents(input);
332
328
  const layerByNodeId = new Map<string, number>();
333
329
  let previousLayerCounts: number[] | undefined;
334
330
  const thoroughness = input.settings.thoroughness ?? 7;
@@ -0,0 +1,59 @@
1
+ import { getOrientedPortDirection, placePorts } from "./strategies";
2
+ import type { AcyclicOrientation, LayeredPhaseInput } from "./types";
3
+
4
+ interface CrossMargins {
5
+ before: number;
6
+ after: number;
7
+ flowBefore: number;
8
+ flowAfter: number;
9
+ }
10
+ const preparedMargins = new WeakMap<LayeredPhaseInput, ReadonlyMap<string, CrossMargins>>();
11
+
12
+ /** Physical port boxes contribute to node margins before BK alignment and compaction. */
13
+ export function preparePortMargins(
14
+ input: LayeredPhaseInput,
15
+ sizes = input.sizes,
16
+ orientation?: AcyclicOrientation,
17
+ ): void {
18
+ const horizontal = input.direction === "right" || input.direction === "left";
19
+ const margins = new Map<string, CrossMargins>();
20
+ for (const node of input.graph.nodes) {
21
+ const size = sizes.get(node.id) ?? { width: 0, height: 0 };
22
+ const ports = placePorts(
23
+ node.ports,
24
+ { x: 0, y: 0, width: size.width, height: size.height },
25
+ input.direction,
26
+ (port) => input.portSettings?.(port, node),
27
+ { ...input.settings, ...input.nodeSettings?.(node) },
28
+ // Free ports take their side from the acyclic edge direction, as in final placement.
29
+ orientation ? (port) => getOrientedPortDirection(input, orientation, node, port) : undefined,
30
+ );
31
+ let before = 0,
32
+ after = 0,
33
+ flowBefore = 0,
34
+ flowAfter = 0;
35
+ for (const port of ports ?? []) {
36
+ const start = horizontal ? (port.y ?? 0) : (port.x ?? 0);
37
+ const end = start + (horizontal ? (port.height ?? 0) : (port.width ?? 0));
38
+ before = Math.max(before, -start);
39
+ after = Math.max(after, end - (horizontal ? size.height : size.width));
40
+ const flowStart = horizontal ? (port.x ?? 0) : (port.y ?? 0);
41
+ const flowEnd = flowStart + (horizontal ? (port.width ?? 0) : (port.height ?? 0));
42
+ flowBefore = Math.max(flowBefore, -flowStart);
43
+ flowAfter = Math.max(flowAfter, flowEnd - (horizontal ? size.width : size.height));
44
+ }
45
+ if (before || after || flowBefore || flowAfter) {
46
+ const reverse = input.direction === "left" || input.direction === "up";
47
+ margins.set(node.id, {
48
+ before,
49
+ after,
50
+ flowBefore: reverse ? flowAfter : flowBefore,
51
+ flowAfter: reverse ? flowBefore : flowAfter,
52
+ });
53
+ }
54
+ }
55
+ preparedMargins.set(input, margins);
56
+ }
57
+
58
+ export const portCrossMargins = (input: LayeredPhaseInput, id: string): CrossMargins | undefined =>
59
+ preparedMargins.get(input)?.get(id);
@@ -0,0 +1,271 @@
1
+ /*******************************************************************************
2
+ * Copyright (c) 2011, 2020 Kiel University and others.
3
+ * Adapted from ELK v0.11.0 NorthSouthPortPreprocessor.java (new approach).
4
+ * Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
5
+ * SPDX-License-Identifier: EPL-2.0
6
+ *******************************************************************************/
7
+ import type { GraphNode, GraphPort, Point } from "@statelyai/graph";
8
+ import type { LongEdgeExpansion } from "./long-edges";
9
+ import { inheritCycleRandom } from "./cycle-random";
10
+
11
+ export interface CrossPortOrigin {
12
+ node: GraphNode;
13
+ port: GraphPort;
14
+ /** Physical port face. */
15
+ side: "NORTH" | "SOUTH" | "WEST" | "EAST";
16
+ /** Position relative to the owner's canonical cross coordinate. */
17
+ beforeOwner: boolean;
18
+ input: boolean;
19
+ output: boolean;
20
+ }
21
+ export interface NorthSouthPortExpansion {
22
+ expansion: LongEdgeExpansion;
23
+ originsByDummyId: ReadonlyMap<string, CrossPortOrigin>;
24
+ incomingEdgeOrderByDummyId?: ReadonlyMap<string, readonly string[]>;
25
+ successorsByNodeId: ReadonlyMap<string, readonly string[]>;
26
+ layoutUnitByNodeId: ReadonlyMap<string, string>;
27
+ }
28
+
29
+ /** Detach fixed cross-axis ports into same-layer units before crossing minimization. */
30
+ export function insertNorthSouthPortDummies(expansion: LongEdgeExpansion): NorthSouthPortExpansion {
31
+ const { input, orientation } = expansion;
32
+ const vertical = input.direction === "down" || input.direction === "up";
33
+ const forward =
34
+ input.direction === "right"
35
+ ? "EAST"
36
+ : input.direction === "left"
37
+ ? "WEST"
38
+ : input.direction === "down"
39
+ ? "SOUTH"
40
+ : "NORTH";
41
+ const backward = ({ EAST: "WEST", WEST: "EAST", NORTH: "SOUTH", SOUTH: "NORTH" } as const)[
42
+ forward
43
+ ];
44
+ const nodes = [...input.graph.nodes];
45
+ const used = new Set(nodes.map((n) => n.id));
46
+ const sizes = new Map(input.sizes);
47
+ const layers = new Map(expansion.assignment.layerByNodeId);
48
+ const origins = new Map<string, CrossPortOrigin>();
49
+ const byPort = new Map<GraphPort, string>();
50
+ const successors = new Map<string, string[]>();
51
+ const units = new Map<string, string>();
52
+ // Upstream gives every fixed-side normal node its own layout unit, even
53
+ // when it has no connected cross-axis ports.
54
+ for (const node of input.graph.nodes) {
55
+ const constraints = input.nodeSettings?.(node)?.portConstraints;
56
+ if (
57
+ !node.id.startsWith("__layout_dummy:") &&
58
+ constraints &&
59
+ constraints !== "UNDEFINED" &&
60
+ constraints !== "FREE"
61
+ )
62
+ units.set(node.id, node.id);
63
+ }
64
+ const attach = (
65
+ owner: GraphNode,
66
+ port: GraphPort,
67
+ side: CrossPortOrigin["side"],
68
+ incoming: boolean,
69
+ ) => {
70
+ let id = byPort.get(port);
71
+ if (!id) {
72
+ id = `__layout_dummy:north-south:${owner.id}:${port.name}`;
73
+ while (used.has(id)) id += ":";
74
+ used.add(id);
75
+ byPort.set(port, id);
76
+ // Canonical cross coordinates remain physical x in vertical layouts, y otherwise.
77
+ const beforeOwner = vertical ? side === "WEST" : side === "NORTH";
78
+ origins.set(id, { node: owner, port, side, beforeOwner, input: false, output: false });
79
+ nodes.push({ type: "node", id, data: undefined, width: 0, height: 0, ports: [] });
80
+ sizes.set(id, { width: 0, height: 0 });
81
+ layers.set(id, layers.get(owner.id) ?? 0);
82
+ units.set(owner.id, owner.id);
83
+ units.set(id, owner.id);
84
+ const from = beforeOwner ? id : owner.id;
85
+ const to = beforeOwner ? owner.id : id;
86
+ if (input.portSettings?.(port, owner)?.allowNonFlowPortsToSwitchSides !== true)
87
+ successors.set(from, [...(successors.get(from) ?? []), to]);
88
+ }
89
+ const origin = origins.get(id)!;
90
+ const role = incoming ? "input" : "output";
91
+ if (!origin[role]) {
92
+ origin[role] = true;
93
+ nodes
94
+ .find((n) => n.id === id)!
95
+ .ports!.push({
96
+ name: role,
97
+ direction: incoming ? "in" : "out",
98
+ x: 0,
99
+ y: 0,
100
+ width: 0,
101
+ height: 0,
102
+ data: undefined,
103
+ });
104
+ }
105
+ return { id, role };
106
+ };
107
+ const nodeById = new Map(input.graph.nodes.map((n) => [n.id, n]));
108
+ const edges = input.graph.edges.map((edge) => {
109
+ const reverse = orientation.reversedEdgeIds.has(edge.id);
110
+ const endpoint = (nodeId: string, portName: string | undefined, incoming: boolean) => {
111
+ const owner = nodeById.get(nodeId);
112
+ if (!owner || owner.id.startsWith("__layout_dummy:")) return undefined;
113
+ const constraints = input.nodeSettings?.(owner)?.portConstraints;
114
+ if (!constraints || constraints === "UNDEFINED" || constraints === "FREE") return undefined;
115
+ const port = owner.ports?.find((p) => p.name === portName);
116
+ if (!port) return undefined;
117
+ const side = input.portSettings?.(port, owner)?.["port.side"];
118
+ if (side !== "NORTH" && side !== "SOUTH" && side !== "WEST" && side !== "EAST")
119
+ return undefined;
120
+ if (vertical ? side !== "WEST" && side !== "EAST" : side !== "NORTH" && side !== "SOUTH")
121
+ return undefined;
122
+ return attach(owner, port, side, incoming);
123
+ };
124
+ // Dedicated same-side and north/south loop dummies are a separate upstream phase.
125
+ if (edge.sourceId === edge.targetId) return edge;
126
+ const source = endpoint(edge.sourceId, edge.sourcePort, reverse);
127
+ const target = endpoint(edge.targetId, edge.targetPort, !reverse);
128
+ return source || target
129
+ ? {
130
+ ...edge,
131
+ sourceId: source?.id ?? edge.sourceId,
132
+ sourcePort: source?.role ?? edge.sourcePort,
133
+ targetId: target?.id ?? edge.targetId,
134
+ targetPort: target?.role ?? edge.targetPort,
135
+ }
136
+ : edge;
137
+ });
138
+ if (!origins.size)
139
+ return {
140
+ expansion,
141
+ originsByDummyId: origins,
142
+ successorsByNodeId: successors,
143
+ layoutUnitByNodeId: units,
144
+ };
145
+ // The upstream mixed-role dummy creates its WEST input before its EAST output.
146
+ for (const node of nodes)
147
+ if (origins.has(node.id))
148
+ node.ports?.sort((a, b) => Number(a.name === "output") - Number(b.name === "output"));
149
+ // ELK creates each side's input, output, then mixed-role dummies in
150
+ // canonical flow order. Barycenter associates retain that creation order;
151
+ // insertion at one northern index reverses it in the layer itself.
152
+ const roleOrder = (origin: CrossPortOrigin) =>
153
+ origin.input && origin.output ? 2 : origin.input ? 0 : 1;
154
+ const negativeFlow = input.direction === "left" || input.direction === "up";
155
+ const orderedOrigins = input.graph.nodes.flatMap((node) => {
156
+ const entries = [...origins].filter(([, origin]) => origin.node.id === node.id);
157
+ const constraints = input.nodeSettings?.(node)?.portConstraints;
158
+ const coordinate = (origin: CrossPortOrigin) => {
159
+ if (constraints === "FIXED_POS" || constraints === "FIXED_RATIO")
160
+ return (vertical ? (origin.port.y ?? 0) : (origin.port.x ?? 0)) * (negativeFlow ? -1 : 1);
161
+ const index = node.ports?.indexOf(origin.port) ?? 0;
162
+ return origin.beforeOwner ? index : -index;
163
+ };
164
+ return entries.sort(
165
+ ([, a], [, b]) =>
166
+ Number(!a.beforeOwner) - Number(!b.beforeOwner) ||
167
+ roleOrder(a) - roleOrder(b) ||
168
+ coordinate(a) - coordinate(b),
169
+ );
170
+ });
171
+ const incomingEdgeOrderByDummyId = new Map<string, readonly string[]>();
172
+ for (const [dummy, origin] of origins) {
173
+ if (!origin.input) continue;
174
+ const ordered = expansion.incomingEdgeOrderByNodeId?.get(origin.node.id) ?? [];
175
+ if (!ordered.length) continue;
176
+ const ids = ordered.flatMap((id) => expansion.segmentIdsByEdgeId.get(id) ?? [id]);
177
+ incomingEdgeOrderByDummyId.set(
178
+ dummy,
179
+ ids.filter((id) =>
180
+ edges.some(
181
+ (edge) =>
182
+ edge.id === id &&
183
+ (orientation.reversedEdgeIds.has(id) ? edge.sourceId : edge.targetId) === dummy,
184
+ ),
185
+ ),
186
+ );
187
+ }
188
+ origins.clear();
189
+ for (const [id, origin] of orderedOrigins) origins.set(id, origin);
190
+ const seed = expansion.assignment.seedOrder ?? input.graph.nodes.map((n) => n.id);
191
+ const seedOrder = seed.flatMap((id) => [
192
+ ...[...origins]
193
+ .filter(([, o]) => o.node.id === id && o.beforeOwner)
194
+ .map(([dummy]) => dummy)
195
+ .reverse(),
196
+ id,
197
+ ...[...origins].filter(([, o]) => o.node.id === id && !o.beforeOwner).map(([dummy]) => dummy),
198
+ ]);
199
+ return {
200
+ expansion: {
201
+ ...expansion,
202
+ input: inheritCycleRandom(input, {
203
+ ...input,
204
+ graph: { ...input.graph, nodes, edges },
205
+ sizes,
206
+ nodeSettings: (n) =>
207
+ origins.has(n.id) ? { portConstraints: "FIXED_POS" } : input.nodeSettings?.(n),
208
+ portSettings: (p, n) =>
209
+ origins.has(n.id)
210
+ ? { "port.side": p.name === "input" ? backward : forward }
211
+ : input.portSettings?.(p, n),
212
+ }),
213
+ assignment: { ...expansion.assignment, layerByNodeId: layers, seedOrder },
214
+ },
215
+ originsByDummyId: origins,
216
+ incomingEdgeOrderByDummyId,
217
+ successorsByNodeId: successors,
218
+ layoutUnitByNodeId: units,
219
+ };
220
+ }
221
+
222
+ /** Reconnect orthogonal/polyline endpoints after routing, preserving the dummy-row bend. */
223
+ export function restoreNorthSouthPortRoutes(
224
+ phase: NorthSouthPortExpansion,
225
+ placement: import("./types").NodePlacement,
226
+ routes: import("./types").EdgeRoutes,
227
+ anchor: (origin: CrossPortOrigin) => import("@statelyai/graph").Point,
228
+ restoredJunctionCounts?: Map<string, number>,
229
+ ): import("./types").EdgeRoutes {
230
+ if (phase.originsByDummyId.size === 0) return routes;
231
+ const vertical =
232
+ phase.expansion.input.direction === "down" || phase.expansion.input.direction === "up";
233
+ const pointsByEdgeId = new Map(routes.pointsByEdgeId);
234
+ const junctionPointsByEdgeId = new Map(routes.junctionPointsByEdgeId);
235
+ const addJunction = (edge: string, origin: CrossPortOrigin, bend: Point) => {
236
+ // ELK marks every restored branch when both dummy faces refer to one port.
237
+ // Spline restoration records its cross coordinate instead of junctions.
238
+ if (origin.input && origin.output && phase.expansion.input.settings.edgeRouting !== "SPLINES") {
239
+ junctionPointsByEdgeId.set(edge, [...(junctionPointsByEdgeId.get(edge) ?? []), bend]);
240
+ restoredJunctionCounts?.set(edge, (restoredJunctionCounts.get(edge) ?? 0) + 1);
241
+ }
242
+ };
243
+ for (const edge of phase.expansion.input.graph.edges) {
244
+ const original = pointsByEdgeId.get(edge.id);
245
+ if (!original?.length) continue;
246
+ const source = phase.originsByDummyId.get(edge.sourceId);
247
+ const target = phase.originsByDummyId.get(edge.targetId);
248
+ if (!source && !target) continue;
249
+ // Routing can collapse coincident physical endpoints to one coordinate.
250
+ // Reconnecting one face must not consume the opposite endpoint's identity.
251
+ let points = original.length === 1 ? [{ ...original[0]! }, { ...original[0]! }] : [...original];
252
+ if (source) {
253
+ const endpoint = anchor(source);
254
+ const rect = placement.rectByNodeId.get(edge.sourceId);
255
+ if (!rect) throw new Error(`Missing cross-port dummy placement: ${edge.sourceId}`);
256
+ const bend = vertical ? { x: rect.x, y: endpoint.y } : { x: endpoint.x, y: rect.y };
257
+ points = [endpoint, bend, ...points.slice(1)];
258
+ addJunction(edge.id, source, bend);
259
+ }
260
+ if (target) {
261
+ const endpoint = anchor(target);
262
+ const rect = placement.rectByNodeId.get(edge.targetId);
263
+ if (!rect) throw new Error(`Missing cross-port dummy placement: ${edge.targetId}`);
264
+ const bend = vertical ? { x: rect.x, y: endpoint.y } : { x: endpoint.x, y: rect.y };
265
+ points = [...points.slice(0, -1), bend, endpoint];
266
+ addJunction(edge.id, target, bend);
267
+ }
268
+ pointsByEdgeId.set(edge.id, points);
269
+ }
270
+ return { ...routes, pointsByEdgeId, junctionPointsByEdgeId };
271
+ }