@statelyai/layout 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +221 -13
  3. package/dist/elkjs/index.cjs +3 -2
  4. package/dist/elkjs/index.d.cts +13 -2
  5. package/dist/elkjs/index.d.mts +13 -2
  6. package/dist/elkjs/index.mjs +3 -3
  7. package/dist/elkjs/main.cjs +2 -2
  8. package/dist/elkjs/main.d.cts +2 -2
  9. package/dist/elkjs/main.d.mts +2 -2
  10. package/dist/elkjs/main.mjs +2 -2
  11. package/dist/elkjs/worker-api.cjs +1 -1
  12. package/dist/elkjs/worker-api.d.cts +2 -2
  13. package/dist/elkjs/worker-api.d.mts +2 -2
  14. package/dist/elkjs/worker-api.mjs +1 -1
  15. package/dist/elkjs/worker.cjs +2 -2
  16. package/dist/elkjs/worker.mjs +2 -2
  17. package/dist/elkjs-UciYx7lD.mjs +2973 -0
  18. package/dist/elkjs-ggjsBuVP.cjs +3002 -0
  19. package/dist/index-B6cWNrMw.d.cts +80 -0
  20. package/dist/index-CJQLUKD9.d.mts +80 -0
  21. package/dist/{index-BSH2bloA.d.mts → index-DT6lPMfS.d.cts} +117 -5
  22. package/dist/{index-CG712jzV.d.cts → index-F1U-fCTu.d.mts} +117 -5
  23. package/dist/index.cjs +39 -9
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.mts +4 -3
  26. package/dist/index.mjs +4 -3
  27. package/dist/layered/index.cjs +3 -1
  28. package/dist/layered/index.d.cts +3 -2
  29. package/dist/layered/index.d.mts +3 -2
  30. package/dist/layered/index.mjs +2 -2
  31. package/dist/{layered-Bz2nWmVp.mjs → layered-C7ERzZjR.mjs} +12592 -4682
  32. package/dist/{layered-Cf9Rn8l7.cjs → layered-CBPg6_6_.cjs} +12711 -4717
  33. package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  34. package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
  35. package/dist/routing/index.cjs +29 -0
  36. package/dist/routing/index.d.cts +3 -0
  37. package/dist/routing/index.d.mts +3 -0
  38. package/dist/routing/index.mjs +3 -0
  39. package/dist/routing-CR8flv8b.mjs +2780 -0
  40. package/dist/routing-QIdGGFBe.cjs +2996 -0
  41. package/dist/types-CT2akdK0.d.mts +133 -0
  42. package/dist/types-uxPlGRof.d.cts +133 -0
  43. package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
  44. package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
  45. package/package.json +14 -3
  46. package/src/authoring/coordinates.ts +38 -10
  47. package/src/authoring/partial.ts +7 -2
  48. package/src/authoring/routing.ts +4 -1
  49. package/src/constraints.ts +174 -6
  50. package/src/elkjs/index.ts +1397 -452
  51. package/src/elkjs/layout-quality.ts +219 -0
  52. package/src/elkjs/opposing-tracks.ts +161 -0
  53. package/src/elkjs/orthogonal-junctions.ts +174 -0
  54. package/src/elkjs/routes.ts +147 -0
  55. package/src/elkjs/statechart.ts +9 -3
  56. package/src/elkjs/types.ts +2 -0
  57. package/src/index.ts +8 -0
  58. package/src/internal/elkjs-compatibility.ts +4 -1
  59. package/src/internal/layered-trace.ts +48 -0
  60. package/src/internal/layout-engine.ts +2 -1
  61. package/src/java-random.ts +6 -0
  62. package/src/layered/bk-node-placement.ts +403 -92
  63. package/src/layered/center-labels.ts +398 -0
  64. package/src/layered/compaction-bounds.ts +26 -0
  65. package/src/layered/compaction-errors.ts +2 -0
  66. package/src/layered/compaction-scanline.ts +63 -0
  67. package/src/layered/compound-boundaries.ts +99 -0
  68. package/src/layered/compound.ts +445 -0
  69. package/src/layered/constraint-orientation.ts +141 -0
  70. package/src/layered/crossing-constraints.ts +243 -0
  71. package/src/layered/crossing-counter.ts +221 -0
  72. package/src/layered/crossing-graph.ts +401 -0
  73. package/src/layered/cycle-random.ts +22 -0
  74. package/src/layered/elk-port-lists.ts +243 -0
  75. package/src/layered/end-labels.ts +124 -0
  76. package/src/layered/external-port-dummy.ts +137 -0
  77. package/src/layered/feedback-chains.ts +114 -0
  78. package/src/layered/fixed-self-loop.ts +150 -0
  79. package/src/layered/greedy-switch-decider.ts +356 -0
  80. package/src/layered/grouped-compaction.ts +787 -0
  81. package/src/layered/gwt-sort.ts +53 -0
  82. package/src/layered/hierarchical-port-phases.ts +242 -0
  83. package/src/layered/hierarchical-port-restoration.ts +233 -0
  84. package/src/layered/hierarchical-port-routing.ts +179 -0
  85. package/src/layered/hierarchy-crossing.ts +121 -0
  86. package/src/layered/hierarchy-sweepiness.ts +83 -0
  87. package/src/layered/hints.ts +371 -0
  88. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  89. package/src/layered/index.ts +911 -261
  90. package/src/layered/initial-model-order.ts +404 -0
  91. package/src/layered/inverted-ports.ts +207 -0
  92. package/src/layered/label-dummy-switcher.ts +200 -0
  93. package/src/layered/long-edges.ts +135 -44
  94. package/src/layered/loop-envelopes.ts +184 -0
  95. package/src/layered/native-routing.ts +275 -0
  96. package/src/layered/network-simplex.ts +11 -15
  97. package/src/layered/node-margins.ts +59 -0
  98. package/src/layered/north-south-ports.ts +271 -0
  99. package/src/layered/opposing-tracks.ts +944 -0
  100. package/src/layered/orthogonal-cycle-order.ts +106 -0
  101. package/src/layered/orthogonal-hypersegments.ts +92 -0
  102. package/src/layered/orthogonal-junctions.ts +202 -0
  103. package/src/layered/orthogonal-segments.ts +243 -0
  104. package/src/layered/placement-orientation.ts +22 -0
  105. package/src/layered/port-distributor.ts +192 -0
  106. package/src/layered/port-edge-order.ts +20 -0
  107. package/src/layered/replace-routing.ts +45 -0
  108. package/src/layered/routing-coordinates.ts +32 -0
  109. package/src/layered/separate-exterior-labels.ts +24 -6
  110. package/src/layered/spacing.ts +34 -12
  111. package/src/layered/splitter-order.ts +106 -0
  112. package/src/layered/strategies.ts +2024 -883
  113. package/src/layered/types.ts +34 -0
  114. package/src/layered/weighted-compaction.ts +84 -0
  115. package/src/routing/adapters.ts +143 -0
  116. package/src/routing/algorithms.ts +1088 -0
  117. package/src/routing/coordination.ts +91 -0
  118. package/src/routing/engine.ts +473 -0
  119. package/src/routing/index.ts +69 -0
  120. package/src/routing/layout-cache.ts +65 -0
  121. package/src/routing/model.ts +218 -0
  122. package/src/routing/path.ts +324 -0
  123. package/src/routing/persistent.ts +117 -0
  124. package/src/routing/search.ts +358 -0
  125. package/src/routing/spatial.ts +78 -0
  126. package/src/routing/types.ts +146 -0
  127. package/dist/elkjs-BVnDwnBQ.cjs +0 -1551
  128. package/dist/elkjs-CFlfkEBz.mjs +0 -1528
  129. /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
  130. /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
  131. /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
  132. /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
@@ -0,0 +1,401 @@
1
+ import type { GraphEdge, GraphNode } from "@statelyai/graph";
2
+ import type { CrossingGraph, CrossingNode, CrossingPort } from "./crossing-counter";
3
+ import { getCrossingUnits } from "./crossing-constraints";
4
+ import type { AcyclicOrientation, LayeredPhaseInput } from "./types";
5
+
6
+ /** Translate the native candidate's physical ports into canonical clockwise order. */
7
+ export function crossingGraph(
8
+ input: LayeredPhaseInput,
9
+ orientation: AcyclicOrientation,
10
+ layers: readonly (readonly string[])[],
11
+ incoming?: ReadonlyMap<string, readonly string[]>,
12
+ outgoing?: ReadonlyMap<string, readonly string[]>,
13
+ ): CrossingGraph {
14
+ return createCrossingGraphBuilder(input)(orientation, layers, incoming, outgoing);
15
+ }
16
+
17
+ /**
18
+ * Reusable {@link crossingGraph} for repeated counts over one phase input.
19
+ * Port keys and node/port settings are memoized, so the input's settings must
20
+ * not change while the builder is in use.
21
+ */
22
+ export function createCrossingGraphBuilder(input: LayeredPhaseInput) {
23
+ const keys = new Map<string, Map<string, string>>();
24
+ const key = (id: string, name: string) => {
25
+ let byName = keys.get(id);
26
+ if (!byName) keys.set(id, (byName = new Map()));
27
+ let value = byName.get(name);
28
+ if (value === undefined) byName.set(name, (value = JSON.stringify([id, name])));
29
+ return value;
30
+ };
31
+ const nodeInfo = new Map<GraphNode, { constraints: unknown; hypernode: boolean }>();
32
+ const infoOf = (node: GraphNode) => {
33
+ let info = nodeInfo.get(node);
34
+ if (!info) {
35
+ const settings = input.nodeSettings?.(node);
36
+ info = { constraints: settings?.portConstraints, hypernode: settings?.hypernode === true };
37
+ nodeInfo.set(node, info);
38
+ }
39
+ return info;
40
+ };
41
+ const portInfo = new Map<
42
+ GraphNode,
43
+ Map<
44
+ string,
45
+ { authored?: NonNullable<GraphNode["ports"]>[number]; index: number; side?: string }
46
+ >
47
+ >();
48
+ const authoredPort = (node: GraphNode, name: string) => {
49
+ let byName = portInfo.get(node);
50
+ if (!byName) portInfo.set(node, (byName = new Map()));
51
+ let info = byName.get(name);
52
+ if (!info) {
53
+ const index = node.ports?.findIndex((port) => port.name === name) ?? -1;
54
+ const authored = index < 0 ? undefined : node.ports![index];
55
+ info = {
56
+ authored,
57
+ index,
58
+ side: authored && input.portSettings?.(authored, node)?.["port.side"],
59
+ };
60
+ byName.set(name, info);
61
+ }
62
+ return info;
63
+ };
64
+ const degreesByOrientation = new WeakMap<
65
+ AcyclicOrientation,
66
+ ReadonlyMap<string, { incoming: number; outgoing: number }>
67
+ >();
68
+ const degreesOf = (orientation: AcyclicOrientation) => {
69
+ let degrees = degreesByOrientation.get(orientation);
70
+ if (!degrees) {
71
+ const counted = new Map<string, { incoming: number; outgoing: number }>();
72
+ for (const edge of input.graph.edges) {
73
+ if (edge.sourceId === edge.targetId) continue;
74
+ const reversed = orientation.reversedEdgeIds.has(edge.id);
75
+ for (const [nodeId, name, source] of [
76
+ [edge.sourceId, edge.sourcePort, !reversed],
77
+ [edge.targetId, edge.targetPort, reversed],
78
+ ] as const) {
79
+ if (name === undefined) continue;
80
+ const id = key(nodeId, name),
81
+ degree = counted.get(id) ?? { incoming: 0, outgoing: 0 };
82
+ if (source) degree.outgoing++;
83
+ else degree.incoming++;
84
+ counted.set(id, degree);
85
+ }
86
+ }
87
+ degreesByOrientation.set(orientation, (degrees = counted));
88
+ }
89
+ return degrees;
90
+ };
91
+ // Port identities, sides and fallback ranks depend only on the orientation;
92
+ // each build replays them against the current port orders.
93
+ const plans = new WeakMap<AcyclicOrientation, CrossingGraphPlan>();
94
+ return (
95
+ orientation: AcyclicOrientation,
96
+ layers: readonly (readonly string[])[],
97
+ incoming?: ReadonlyMap<string, readonly string[]>,
98
+ outgoing?: ReadonlyMap<string, readonly string[]>,
99
+ ): CrossingGraph => {
100
+ let plan = plans.get(orientation);
101
+ if (
102
+ !plan ||
103
+ plan.units !== getCrossingUnits(input) ||
104
+ plan.nodes !== input.graph.nodes ||
105
+ plan.edgeList !== input.graph.edges
106
+ ) {
107
+ plan = planCrossingGraph(input, orientation, key, infoOf, authoredPort, degreesOf);
108
+ plans.set(orientation, plan);
109
+ }
110
+ return buildCrossingGraph(plan, layers, incoming, outgoing);
111
+ };
112
+ }
113
+
114
+ /** One `add` of the original builder with its build-independent values resolved. */
115
+ interface PortOperation {
116
+ nodeId: string;
117
+ id: string;
118
+ side: CrossingPort["side"];
119
+ /** Incident edge whose position in the selected port order ranks the port. */
120
+ edgeId?: string;
121
+ source: boolean;
122
+ /** Rank when the edge is absent from the selected port order. */
123
+ fallback: number;
124
+ /** FIXED_POS/FIXED_RATIO geometric rank, overriding the order rank. */
125
+ fixedRank?: number;
126
+ /** FIXED_POS/FIXED_RATIO shared ports keep their first rank. */
127
+ keepRank: boolean;
128
+ /** North/south dummy detached from this port, linked after the add. */
129
+ northSouthDummy?: string;
130
+ }
131
+
132
+ interface CrossingGraphPlan {
133
+ units: ReturnType<typeof getCrossingUnits>;
134
+ nodes: LayeredPhaseInput["graph"]["nodes"];
135
+ edgeList: LayeredPhaseInput["graph"]["edges"];
136
+ operations: PortOperation[];
137
+ edges: Array<{ source: string; target: string }>;
138
+ nodeKinds: Map<string, { type: CrossingNode["type"]; unit?: string }>;
139
+ }
140
+
141
+ function planCrossingGraph(
142
+ input: LayeredPhaseInput,
143
+ orientation: AcyclicOrientation,
144
+ key: (id: string, name: string) => string,
145
+ infoOf: (node: GraphNode) => { constraints: unknown; hypernode: boolean },
146
+ authoredPort: (
147
+ node: GraphNode,
148
+ name: string,
149
+ ) => { authored?: NonNullable<GraphNode["ports"]>[number]; index: number; side?: string },
150
+ degreesOf: (
151
+ orientation: AcyclicOrientation,
152
+ ) => ReadonlyMap<string, { incoming: number; outgoing: number }>,
153
+ ): CrossingGraphPlan {
154
+ const units = getCrossingUnits(input);
155
+ const nodes = new Map(input.graph.nodes.map((node) => [node.id, node]));
156
+ const canonicalSide = (side: string): CrossingPort["side"] => {
157
+ const sides: Record<string, CrossingPort["side"]> =
158
+ input.direction === "right"
159
+ ? { NORTH: "NORTH", EAST: "EAST", SOUTH: "SOUTH", WEST: "WEST" }
160
+ : input.direction === "left"
161
+ ? { NORTH: "NORTH", EAST: "WEST", SOUTH: "SOUTH", WEST: "EAST" }
162
+ : input.direction === "down"
163
+ ? { NORTH: "WEST", EAST: "SOUTH", SOUTH: "EAST", WEST: "NORTH" }
164
+ : { NORTH: "EAST", EAST: "SOUTH", SOUTH: "WEST", WEST: "NORTH" };
165
+ return sides[side]!;
166
+ };
167
+ const degrees = degreesOf(orientation);
168
+ const operations: PortOperation[] = [];
169
+ // Distinct port ids per node, in first-add order, as the build's members map.
170
+ const ports = new Map<string, Set<string>>();
171
+ const add = (node: GraphNode, name: string, source: boolean, edge?: GraphEdge): string => {
172
+ let members = ports.get(node.id);
173
+ if (!members) ports.set(node.id, (members = new Set()));
174
+ const id = key(node.id, name);
175
+ const { authored, index: authoredIndex, side: configuredSide } = authoredPort(node, name);
176
+ const { constraints } = infoOf(node);
177
+ const flexible =
178
+ constraints === undefined || constraints === "UNDEFINED" || constraints === "FREE";
179
+ const degree = degrees.get(id);
180
+ // The initial model-order pass requires ELK PortSideProcessor's free sides.
181
+ // Existing non-model sweep integration retains its separate side policy.
182
+ // Resolve these ports from cycle-broken connectivity,
183
+ // even when an authored side was supplied. Equal degrees choose WEST.
184
+ const side =
185
+ flexible &&
186
+ (input.settings["considerModelOrder.strategy"] ?? "NONE") !== "NONE" &&
187
+ authored &&
188
+ degree
189
+ ? degree.outgoing > degree.incoming
190
+ ? "EAST"
191
+ : "WEST"
192
+ : configuredSide
193
+ ? canonicalSide(configuredSide)
194
+ : source
195
+ ? "EAST"
196
+ : "WEST";
197
+ const keepRank = constraints === "FIXED_POS" || constraints === "FIXED_RATIO";
198
+ let fixedRank: number | undefined;
199
+ if (authored && keepRank) {
200
+ const vertical = input.direction === "down" || input.direction === "up";
201
+ const reverse = input.direction === "left" || input.direction === "up";
202
+ const size = input.sizes.get(node.id)!;
203
+ const cross = vertical
204
+ ? (authored.x ?? 0) + (authored.width ?? 0) / 2
205
+ : (authored.y ?? 0) + (authored.height ?? 0) / 2;
206
+ const flow = vertical
207
+ ? (authored.y ?? 0) + (authored.height ?? 0) / 2
208
+ : (authored.x ?? 0) + (authored.width ?? 0) / 2;
209
+ const canonicalFlow = reverse ? (vertical ? size.height : size.width) - flow : flow;
210
+ fixedRank =
211
+ side === "NORTH"
212
+ ? canonicalFlow
213
+ : side === "SOUTH"
214
+ ? -canonicalFlow
215
+ : side === "EAST"
216
+ ? cross
217
+ : -cross;
218
+ }
219
+ operations.push({
220
+ nodeId: node.id,
221
+ id,
222
+ side,
223
+ edgeId: edge?.id,
224
+ source,
225
+ fallback: authored ? authoredIndex : members.size,
226
+ fixedRank,
227
+ keepRank,
228
+ });
229
+ members.add(id);
230
+ return id;
231
+ };
232
+ const endpoint = (edge: GraphEdge, source: boolean) => {
233
+ const authoredSource = source !== orientation.reversedEdgeIds.has(edge.id);
234
+ const node = nodes.get(authoredSource ? edge.sourceId : edge.targetId)!;
235
+ const name = authoredSource ? edge.sourcePort : edge.targetPort;
236
+ const merged = input.settings.mergeEdges === true || infoOf(node).hypernode;
237
+ return add(
238
+ node,
239
+ name ?? (merged ? `__implicit:${source}` : `__implicit:${edge.id}:${source}`),
240
+ source,
241
+ edge,
242
+ );
243
+ };
244
+ const edges = input.graph.edges
245
+ .filter((edge) => edge.sourceId !== edge.targetId)
246
+ .map((edge) => ({ source: endpoint(edge, true), target: endpoint(edge, false) }));
247
+ // Real ELK retains ports with no incident edge in this scope.
248
+ for (const node of input.graph.nodes)
249
+ for (const port of node.ports ?? []) {
250
+ if (!ports.get(node.id)?.has(key(node.id, port.name)))
251
+ add(node, port.name, port.direction === "out");
252
+ }
253
+ for (const [dummy, origin] of units?.northSouthOrigins ?? []) {
254
+ const owner = nodes.get(origin.node.id)!;
255
+ add(owner, origin.port.name, false);
256
+ operations.at(-1)!.northSouthDummy = dummy;
257
+ }
258
+ const nodeKinds = new Map<string, { type: CrossingNode["type"]; unit?: string }>();
259
+ for (const node of input.graph.nodes) {
260
+ const id = node.id;
261
+ nodeKinds.set(id, {
262
+ type: id.startsWith("__layout_dummy:north-south:")
263
+ ? "NORTH_SOUTH_PORT"
264
+ : units?.longEdgeNodes?.has(id)
265
+ ? "LONG_EDGE"
266
+ : id.startsWith("__layout_dummy:label:")
267
+ ? "LABEL"
268
+ : "NORMAL",
269
+ unit: units?.units.get(id),
270
+ });
271
+ }
272
+ return {
273
+ units,
274
+ nodes: input.graph.nodes,
275
+ edgeList: input.graph.edges,
276
+ operations,
277
+ edges,
278
+ nodeKinds,
279
+ };
280
+ }
281
+
282
+ function buildCrossingGraph(
283
+ plan: CrossingGraphPlan,
284
+ layers: readonly (readonly string[])[],
285
+ incoming: ReadonlyMap<string, readonly string[]> | undefined,
286
+ outgoing: ReadonlyMap<string, readonly string[]> | undefined,
287
+ ): CrossingGraph {
288
+ // First index of each edge id, like `indexOf`, per selected port order.
289
+ const ranks = new Map<readonly string[], Map<string, number>>();
290
+ const rankIn = (selected: readonly string[]) => {
291
+ let rank = ranks.get(selected);
292
+ if (!rank) {
293
+ rank = new Map();
294
+ for (let index = 0; index < selected.length; index++)
295
+ if (!rank.has(selected[index]!)) rank.set(selected[index]!, index);
296
+ ranks.set(selected, rank);
297
+ }
298
+ return rank;
299
+ };
300
+ const ports = new Map<string, Map<string, { port: CrossingPort; rank: number }>>();
301
+ for (const operation of plan.operations) {
302
+ let members = ports.get(operation.nodeId);
303
+ if (!members) ports.set(operation.nodeId, (members = new Map()));
304
+ const selected =
305
+ operation.edgeId === undefined
306
+ ? undefined
307
+ : (operation.source ? outgoing : incoming)?.get(operation.nodeId);
308
+ let rank = selected ? (rankIn(selected).get(operation.edgeId!) ?? -1) : -1;
309
+ if (rank < 0) rank = operation.fallback;
310
+ if (operation.fixedRank !== undefined) rank = operation.fixedRank;
311
+ const existing = members.get(operation.id);
312
+ if (existing) {
313
+ // A shared port occupies its first position in the selected port order;
314
+ // later incident edges must not move that one physical port.
315
+ if (!operation.keepRank) existing.rank = Math.min(existing.rank, rank);
316
+ } else members.set(operation.id, { port: { id: operation.id, side: operation.side }, rank });
317
+ if (operation.northSouthDummy !== undefined) {
318
+ members.get(operation.id)!.port.dummy = operation.northSouthDummy;
319
+ for (const member of ports.get(operation.northSouthDummy)?.values() ?? [])
320
+ member.port.origin = operation.id;
321
+ }
322
+ }
323
+ const sideRank = { NORTH: 0, EAST: 1, SOUTH: 2, WEST: 3 };
324
+ return {
325
+ layers: layers.map((layer) =>
326
+ layer.map((id) => {
327
+ const kind = plan.nodeKinds.get(id);
328
+ return {
329
+ id,
330
+ type: kind?.type ?? kindOf(id, plan.units),
331
+ unit: kind ? kind.unit : plan.units?.units.get(id),
332
+ ports: [...(ports.get(id)?.values() ?? [])]
333
+ .sort((a, b) => sideRank[a.port.side] - sideRank[b.port.side] || a.rank - b.rank)
334
+ .map((member) => member.port),
335
+ };
336
+ }),
337
+ ),
338
+ edges: plan.edges.map((edge) => ({ source: edge.source, target: edge.target })),
339
+ };
340
+ }
341
+
342
+ /**
343
+ * A port carrying edges of both directions attaches each direction separately
344
+ * once routed, so a crossing between them is real. Count it: give each
345
+ * east or west mixed port a twin for its reversed edges, after it on the
346
+ * east side and before it on the west (so forward edges sit above), and move
347
+ * those edges to the twin. `reversed` is index-aligned with `graph.edges`.
348
+ */
349
+ export function splitMixedCrossingPorts(
350
+ graph: CrossingGraph,
351
+ reversed: readonly boolean[],
352
+ ): CrossingGraph {
353
+ const directions = new Map<string, number>();
354
+ graph.edges.forEach((edge, index) => {
355
+ const bit = reversed[index] ? 2 : 1;
356
+ for (const port of [edge.source, edge.target])
357
+ directions.set(port, (directions.get(port) ?? 0) | bit);
358
+ });
359
+ const mixed = new Set<string>();
360
+ for (const layer of graph.layers)
361
+ for (const node of layer)
362
+ for (const port of node.ports)
363
+ if (directions.get(port.id) === 3 && (port.side === "EAST" || port.side === "WEST"))
364
+ mixed.add(port.id);
365
+ if (!mixed.size) return graph;
366
+ const twin = (id: string) => `${id}\0reversed`;
367
+ return {
368
+ layers: graph.layers.map((layer) =>
369
+ layer.map((node) =>
370
+ node.ports.some((port) => mixed.has(port.id))
371
+ ? {
372
+ ...node,
373
+ ports: node.ports.flatMap((port) => {
374
+ if (!mixed.has(port.id)) return [port];
375
+ const other = { ...port, id: twin(port.id) };
376
+ return port.side === "EAST" ? [port, other] : [other, port];
377
+ }),
378
+ }
379
+ : node,
380
+ ),
381
+ ),
382
+ edges: graph.edges.map((edge, index) =>
383
+ reversed[index] && (mixed.has(edge.source) || mixed.has(edge.target))
384
+ ? {
385
+ source: mixed.has(edge.source) ? twin(edge.source) : edge.source,
386
+ target: mixed.has(edge.target) ? twin(edge.target) : edge.target,
387
+ }
388
+ : edge,
389
+ ),
390
+ };
391
+ }
392
+
393
+ function kindOf(id: string, units: ReturnType<typeof getCrossingUnits>): CrossingNode["type"] {
394
+ return id.startsWith("__layout_dummy:north-south:")
395
+ ? "NORTH_SOUTH_PORT"
396
+ : units?.longEdgeNodes?.has(id)
397
+ ? "LONG_EDGE"
398
+ : id.startsWith("__layout_dummy:label:")
399
+ ? "LABEL"
400
+ : "NORMAL";
401
+ }
@@ -0,0 +1,22 @@
1
+ import { JavaRandom } from "../java-random";
2
+ import type { LayeredPhaseInput } from "./types";
3
+
4
+ export const phaseRandomByInput = new WeakMap<LayeredPhaseInput, JavaRandom>();
5
+
6
+ const afterCycle = new WeakMap<LayeredPhaseInput, JavaRandom>();
7
+
8
+ export function recordCycleRandom(input: LayeredPhaseInput, random: JavaRandom): void {
9
+ afterCycle.set(input, random.clone());
10
+ }
11
+
12
+ export function crossingRandom(input: LayeredPhaseInput): JavaRandom {
13
+ return afterCycle.get(input)?.clone() ?? new JavaRandom(input.settings.randomSeed ?? 1);
14
+ }
15
+
16
+ export function inheritCycleRandom<T extends LayeredPhaseInput>(from: LayeredPhaseInput, to: T): T {
17
+ const phaseRandom = phaseRandomByInput.get(from);
18
+ if (phaseRandom) phaseRandomByInput.set(to, phaseRandom);
19
+ const random = afterCycle.get(from);
20
+ if (random) afterCycle.set(to, random);
21
+ return to;
22
+ }
@@ -0,0 +1,243 @@
1
+ import type { GraphEdge, GraphNode } from "@statelyai/graph";
2
+ import { orientConstrainedEdges } from "./constraint-orientation";
3
+ import type { AcyclicOrientation, LayeredPhaseInput } from "./types";
4
+
5
+ /** One ELK port: its authored name (implicit edge ports have none) and edge lists. */
6
+ export interface PortEdgeList {
7
+ name?: string;
8
+ outgoing: string[];
9
+ incoming: string[];
10
+ }
11
+
12
+ /** ELK imports ports in authored order; side sorting happens after dummy insertion. */
13
+ export function authoredPortIndex(
14
+ input: LayeredPhaseInput,
15
+ node: GraphNode | undefined,
16
+ name: string | undefined,
17
+ ): number {
18
+ const port = name === undefined ? undefined : node?.ports?.find((p) => p.name === name);
19
+ if (!node || !port) return -1;
20
+ const settings = input.portSettings?.(port, node) as
21
+ | { "port.authoredIndex"?: number }
22
+ | undefined;
23
+ return settings?.["port.authoredIndex"] ?? node.ports!.indexOf(port);
24
+ }
25
+
26
+ /**
27
+ * Replays ELK's per-port edge lists through import, EdgeAndLayerConstraintEdgeReverser
28
+ * and cycle breaking. ELK appends a reversed edge to the end of its new ports'
29
+ * lists, so an edge's position records its reversal history.
30
+ */
31
+ export function simulatePortEdgeLists(
32
+ input: LayeredPhaseInput,
33
+ orientation: AcyclicOrientation,
34
+ ): Map<string, PortEdgeList[]> {
35
+ const ports = new Map<string, PortEdgeList[]>();
36
+ for (const node of input.graph.nodes) {
37
+ const authored = (node.ports ?? [])
38
+ .map((port) => ({ name: port.name, index: authoredPortIndex(input, node, port.name) }))
39
+ .sort((a, b) => a.index - b.index);
40
+ ports.set(
41
+ node.id,
42
+ authored.map(({ name }) => ({ name, outgoing: [], incoming: [] })),
43
+ );
44
+ }
45
+ const ends = new Map<string, { source: PortEdgeList; target: PortEdgeList }>();
46
+ const portOf = (nodeId: string, name: string | undefined): PortEdgeList | undefined => {
47
+ const list = ports.get(nodeId);
48
+ if (!list) return undefined;
49
+ const named = name === undefined ? undefined : list.find((port) => port.name === name);
50
+ if (named) return named;
51
+ // Edges without an authored port receive their own port at import.
52
+ const port: PortEdgeList = { outgoing: [], incoming: [] };
53
+ list.push(port);
54
+ return port;
55
+ };
56
+ const edges: GraphEdge[] = [];
57
+ for (const edge of input.graph.edges) {
58
+ if (edge.sourceId === edge.targetId) continue;
59
+ const source = portOf(edge.sourceId, edge.sourcePort);
60
+ const target = portOf(edge.targetId, edge.targetPort);
61
+ if (!source || !target) continue;
62
+ source.outgoing.push(edge.id);
63
+ target.incoming.push(edge.id);
64
+ ends.set(edge.id, { source, target });
65
+ edges.push(edge);
66
+ }
67
+ // Local edges keep import order; hierarchy segments follow in creation order.
68
+ const position = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
69
+ const segment = new Map(
70
+ input.graph.edges.flatMap((edge) => {
71
+ const rank = (input.edgeSettings?.(edge) as { "edge.hierarchyRank"?: number } | undefined)?.[
72
+ "edge.hierarchyRank"
73
+ ];
74
+ return rank === undefined ? [] : [[edge.id, rank] as const];
75
+ }),
76
+ );
77
+ const importOrder = (a: string, b: string) =>
78
+ Number(segment.has(a)) - Number(segment.has(b)) ||
79
+ (segment.get(a) ?? 0) - (segment.get(b) ?? 0) ||
80
+ position.get(a)! - position.get(b)!;
81
+ for (const list of ports.values())
82
+ for (const port of list) {
83
+ port.outgoing.sort(importOrder);
84
+ port.incoming.sort(importOrder);
85
+ }
86
+ const reverse = (id: string) => {
87
+ const end = ends.get(id)!;
88
+ end.source.outgoing.splice(end.source.outgoing.indexOf(id), 1);
89
+ end.target.incoming.splice(end.target.incoming.indexOf(id), 1);
90
+ end.target.outgoing.push(id);
91
+ end.source.incoming.push(id);
92
+ ends.set(id, { source: end.target, target: end.source });
93
+ };
94
+ const replay = (
95
+ pending: Set<string>,
96
+ visit: (node: GraphNode) => "outgoing" | "incoming" | "all" | undefined,
97
+ ) => {
98
+ for (const node of input.graph.nodes) {
99
+ const kind = visit(node);
100
+ if (!kind) continue;
101
+ // Like ELK, iterate copies: reversal moves edges between these lists.
102
+ for (const port of (ports.get(node.id) ?? []).slice()) {
103
+ if (kind !== "incoming")
104
+ for (const id of port.outgoing.slice()) if (pending.delete(id)) reverse(id);
105
+ if (kind !== "outgoing")
106
+ for (const id of port.incoming.slice()) if (pending.delete(id)) reverse(id);
107
+ }
108
+ }
109
+ };
110
+ const known = new Set(edges.map((edge) => edge.id));
111
+ const prepared = orientConstrainedEdges(input, new Set());
112
+ const constrained = new Set([...prepared.reversedEdgeIds].filter((id) => known.has(id)));
113
+ const wholeNodes = new Set(prepared.reversedNodeIds);
114
+ const constraint = (node: GraphNode) =>
115
+ String(input.nodeSettings?.(node)?.["layering.layerConstraint"] ?? "NONE");
116
+ // Outer FIRST/LAST nodes reverse first; the remaining nodes may reverse whole.
117
+ const pending = new Set(constrained);
118
+ replay(pending, (node) =>
119
+ constraint(node).startsWith("FIRST")
120
+ ? "incoming"
121
+ : constraint(node).startsWith("LAST")
122
+ ? "outgoing"
123
+ : undefined,
124
+ );
125
+ replay(pending, (node) => (wholeNodes.has(node.id) ? "all" : undefined));
126
+ for (const id of pending) reverse(id);
127
+ // The cycle breaker reverses the remaining difference while visiting outgoing edges.
128
+ const cycles = new Set(
129
+ [...known].filter((id) => orientation.reversedEdgeIds.has(id) !== constrained.has(id)),
130
+ );
131
+ replay(cycles, () => "outgoing");
132
+ for (const id of cycles) reverse(id);
133
+ return ports;
134
+ }
135
+
136
+ /**
137
+ * LabelDummyInserter keeps a labeled edge on its source port and moves the
138
+ * dummy's outgoing part to the end of the target port's incoming edges.
139
+ */
140
+ export function insertLabelDummyPorts(
141
+ lists: ReadonlyMap<string, readonly PortEdgeList[]>,
142
+ orientation: AcyclicOrientation,
143
+ segmentIdsByEdgeId: ReadonlyMap<string, readonly string[]>,
144
+ ): Map<string, PortEdgeList[]> {
145
+ const result = new Map(
146
+ [...lists].map(([id, ports]) => [
147
+ id,
148
+ ports.map((port) => ({
149
+ ...port,
150
+ outgoing: [...port.outgoing],
151
+ incoming: [...port.incoming],
152
+ })),
153
+ ]),
154
+ );
155
+ const dummies: [string, PortEdgeList[]][] = [];
156
+ for (const ports of result.values()) {
157
+ for (const port of ports) {
158
+ for (const id of port.outgoing) {
159
+ const parts = segmentIdsByEdgeId.get(id);
160
+ if (parts?.length !== 2) continue;
161
+ const reversed = orientation.reversedEdgeIds.has(id);
162
+ const [head, tail] = reversed ? [parts[1]!, parts[0]!] : [parts[0]!, parts[1]!];
163
+ port.outgoing[port.outgoing.indexOf(id)] = head;
164
+ for (const target of result.values())
165
+ for (const candidate of target) {
166
+ const at = candidate.incoming.indexOf(id);
167
+ if (at < 0) continue;
168
+ candidate.incoming.splice(at, 1);
169
+ candidate.incoming.push(tail);
170
+ }
171
+ dummies.push([
172
+ `__layout_dummy:label:${id}`,
173
+ [
174
+ { name: "input", outgoing: [], incoming: [head] },
175
+ { name: "output", outgoing: [tail], incoming: [] },
176
+ ],
177
+ ]);
178
+ }
179
+ }
180
+ }
181
+ for (const [id, ports] of dummies) result.set(id, ports);
182
+ return result;
183
+ }
184
+
185
+ /**
186
+ * ELK's network-simplex layerer orders nodes by connected-component DFS. Each
187
+ * visit walks ports in list order, incoming edges before outgoing edges; a
188
+ * component larger than the current first one moves to the front.
189
+ */
190
+ export function elkComponentOrder(
191
+ input: LayeredPhaseInput,
192
+ lists: ReadonlyMap<string, readonly PortEdgeList[]>,
193
+ ): string[] {
194
+ const endpoints = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
195
+ const nodeIds = new Set(input.graph.nodes.map((node) => node.id));
196
+ const visited = new Set<string>();
197
+ const components: string[][] = [];
198
+ const neighbors = (id: string) =>
199
+ (lists.get(id) ?? []).flatMap((port) =>
200
+ [...port.incoming, ...port.outgoing].flatMap((edgeId) => {
201
+ const edge = endpoints.get(edgeId);
202
+ const opposite = edge && (edge.sourceId === id ? edge.targetId : edge.sourceId);
203
+ return opposite && nodeIds.has(opposite) ? [opposite] : [];
204
+ }),
205
+ );
206
+ const visit = (root: string, component: string[]) => {
207
+ visited.add(root);
208
+ component.push(root);
209
+ const stack = [{ next: neighbors(root), index: 0 }];
210
+ while (stack.length > 0) {
211
+ const frame = stack.at(-1)!;
212
+ const id = frame.next[frame.index++];
213
+ if (id === undefined) stack.pop();
214
+ else if (!visited.has(id)) {
215
+ visited.add(id);
216
+ component.push(id);
217
+ stack.push({ next: neighbors(id), index: 0 });
218
+ }
219
+ }
220
+ };
221
+ for (const node of input.graph.nodes) {
222
+ if (visited.has(node.id)) continue;
223
+ const component: string[] = [];
224
+ visit(node.id, component);
225
+ if (components.length > 0 && components[0]!.length >= component.length)
226
+ components.push(component);
227
+ else components.unshift(component);
228
+ }
229
+ return components.flat();
230
+ }
231
+
232
+ /** Each node's edge positions within ELK's port edge lists, keyed by listed edge id. */
233
+ export function edgeListRanks(
234
+ lists: ReadonlyMap<string, readonly PortEdgeList[]>,
235
+ kind: "incoming" | "outgoing",
236
+ ): Map<string, Map<string, number>> {
237
+ return new Map(
238
+ [...lists].map(([id, ports]) => [
239
+ id,
240
+ new Map(ports.flatMap((port) => port[kind]).map((edgeId, index) => [edgeId, index])),
241
+ ]),
242
+ );
243
+ }