@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,200 @@
1
+ /*
2
+ * Copyright (c) 2012, 2017 Kiel University and others.
3
+ * Native adaptation of ELK LabelDummySwitcher (v0.11.0).
4
+ * SPDX-License-Identifier: EPL-2.0
5
+ */
6
+ import type { GraphEdge } from "@statelyai/graph";
7
+ import type { LayerOrder } from "./types";
8
+ import type { LongEdgeExpansion } from "./long-edges";
9
+ import type { CenterLabelExpansion } from "./center-labels";
10
+ import { inheritCycleRandom } from "./cycle-random";
11
+
12
+ /** Before placement: move labels along their own proper long-edge chain without changing crossing order. */
13
+ export function switchCenterLabelDummies(
14
+ expansion: LongEdgeExpansion,
15
+ labels: CenterLabelExpansion,
16
+ order: LayerOrder,
17
+ ): { expansion: LongEdgeExpansion; order: LayerOrder } {
18
+ if (!labels.dummyById.size) return { expansion, order };
19
+ const input = expansion.input;
20
+ const horizontal = input.direction === "left" || input.direction === "right";
21
+ const layers = order.layers.map((layer) => [...layer]);
22
+ const layerByNodeId = new Map(expansion.assignment.layerByNodeId);
23
+ const widths = layers.map((layer) =>
24
+ Math.max(
25
+ 0,
26
+ ...layer
27
+ .filter((id) => !id.startsWith("__layout_dummy:"))
28
+ .map((id) => {
29
+ const size = input.sizes.get(id);
30
+ return horizontal ? (size?.width ?? 0) : (size?.height ?? 0);
31
+ }),
32
+ ),
33
+ );
34
+ let edges = [...input.graph.edges];
35
+ const alignments = new Map<string, "LEFT" | "RIGHT">();
36
+ const nodeIds = new Set(input.graph.nodes.map((node) => node.id));
37
+ const longDummy = (id: string) =>
38
+ nodeIds.has(id) &&
39
+ id.startsWith("__layout_dummy:") &&
40
+ !labels.dummyById.has(id) &&
41
+ !id.startsWith("__layout_dummy:inverted:") &&
42
+ !id.startsWith("__layout_dummy:north-south:");
43
+ const orientedSource = (edge: GraphEdge) =>
44
+ expansion.orientation.reversedEdgeIds.has(edge.id) ? edge.targetId : edge.sourceId;
45
+ const orientedTarget = (edge: GraphEdge) =>
46
+ expansion.orientation.reversedEdgeIds.has(edge.id) ? edge.sourceId : edge.targetId;
47
+ const infos = layers
48
+ .flatMap((layer) => layer.filter((id) => labels.dummyById.has(id)))
49
+ .map((id) => {
50
+ const label = labels.dummyById.get(id)!;
51
+ const walk = (backward: boolean) => {
52
+ const result: string[] = [],
53
+ seen = new Set([id]);
54
+ let current = id;
55
+ for (;;) {
56
+ const edge = edges.find(
57
+ (edge) => (backward ? orientedTarget(edge) : orientedSource(edge)) === current,
58
+ );
59
+ if (!edge) break;
60
+ current = backward ? orientedSource(edge) : orientedTarget(edge);
61
+ if (!longDummy(current) || seen.has(current)) break;
62
+ seen.add(current);
63
+ result.push(current);
64
+ }
65
+ return backward ? result.reverse() : result;
66
+ };
67
+ const chain = [...walk(true), id, ...walk(false)];
68
+ const strategy = String(
69
+ input.edgeSettings?.(label.edge)?.["edgeLabels.centerLabelPlacementStrategy"] ??
70
+ input.settings["edgeLabels.centerLabelPlacementStrategy"] ??
71
+ "MEDIAN_LAYER",
72
+ );
73
+ return {
74
+ id,
75
+ chain,
76
+ strategy,
77
+ reversed: label.reversed,
78
+ width: horizontal ? input.sizes.get(id)!.width : input.sizes.get(id)!.height,
79
+ };
80
+ });
81
+ type Info = (typeof infos)[number];
82
+ const assign = (info: Info, target: number) => {
83
+ const other = info.chain.find((id) => layerByNodeId.get(id) === target)!;
84
+ if (other !== info.id) {
85
+ const original = layerByNodeId.get(info.id)!;
86
+ const firstSlot = layers[original]!.indexOf(info.id),
87
+ secondSlot = layers[target]!.indexOf(other);
88
+ layers[original]![firstSlot] = other;
89
+ layers[target]![secondSlot] = info.id;
90
+ layerByNodeId.set(info.id, target);
91
+ layerByNodeId.set(other, original);
92
+ edges = edges.map((edge) => {
93
+ const swap = (id: string) => (id === info.id ? other : id === other ? info.id : id);
94
+ const sourceId = swap(edge.sourceId),
95
+ targetId = swap(edge.targetId);
96
+ const reversed = expansion.orientation.reversedEdgeIds.has(edge.id);
97
+ return {
98
+ ...edge,
99
+ sourceId,
100
+ targetId,
101
+ sourcePort:
102
+ sourceId === info.id
103
+ ? reversed
104
+ ? "input"
105
+ : "output"
106
+ : edge.sourceId === info.id
107
+ ? undefined
108
+ : edge.sourcePort,
109
+ targetPort:
110
+ targetId === info.id
111
+ ? reversed
112
+ ? "output"
113
+ : "input"
114
+ : edge.targetId === info.id
115
+ ? undefined
116
+ : edge.targetPort,
117
+ };
118
+ });
119
+ }
120
+ widths[target] = Math.max(widths[target]!, info.width);
121
+ };
122
+ const validLayers = (info: Info) =>
123
+ info.chain.map((id) => layerByNodeId.get(id)!).sort((a, b) => a - b);
124
+ const widest = (valid: number[]) =>
125
+ valid.reduce((best, layer) => (widths[layer]! > widths[best]! ? layer : best), valid[0]!);
126
+ // ELK processes strategies independent of width before width-dependent strategies.
127
+ for (const strategy of [
128
+ "MEDIAN_LAYER",
129
+ "HEAD_LAYER",
130
+ "TAIL_LAYER",
131
+ "CENTER_LAYER",
132
+ "WIDEST_LAYER",
133
+ ]) {
134
+ for (const info of infos.filter((info) => info.strategy === strategy)) {
135
+ const valid = validLayers(info);
136
+ let target = valid[Math.floor((valid.length - 1) / 2)]!;
137
+ if (strategy === "HEAD_LAYER" || strategy === "TAIL_LAYER") {
138
+ const right = (strategy === "HEAD_LAYER") !== info.reversed;
139
+ target = right ? valid.at(-1)! : valid[0]!;
140
+ alignments.set(info.id, right ? "RIGHT" : "LEFT");
141
+ } else if (strategy === "WIDEST_LAYER") target = widest(valid);
142
+ else if (strategy === "CENTER_LAYER") {
143
+ const spacing = Math.max(
144
+ Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10) * 2,
145
+ input.spacing.layer,
146
+ );
147
+ let sum = -spacing;
148
+ const sums = valid.map((layer) => {
149
+ sum += widths[layer]! + spacing;
150
+ return sum;
151
+ });
152
+ target = valid[sums.findIndex((value) => value >= sum / 2)]!;
153
+ }
154
+ assign(info, target);
155
+ }
156
+ }
157
+ const pending: Info[] = [];
158
+ for (const info of infos.filter((info) => info.strategy === "SPACE_EFFICIENT_LAYER")) {
159
+ const valid = validLayers(info),
160
+ wide = valid.find((layer) => widths[layer]! >= info.width);
161
+ if (valid.length === 1 || wide !== undefined) assign(info, wide ?? valid[0]!);
162
+ else pending.push(info);
163
+ }
164
+ pending.sort((a, b) => b.width - a.width);
165
+ for (const [index, info] of pending.entries()) {
166
+ const valid = validLayers(info);
167
+ let target = valid[0]!,
168
+ best = 0;
169
+ for (const layer of valid) {
170
+ if (widths[layer]! >= info.width) {
171
+ target = layer;
172
+ break;
173
+ }
174
+ let potential = widths[layer]!;
175
+ // Preserve ELK's last eligible pending-label selection and first-layer tie breaking.
176
+ for (const other of pending.slice(index + 1))
177
+ if (validLayers(other).includes(layer)) potential = Math.max(widths[layer]!, other.width);
178
+ if (potential > best) {
179
+ target = layer;
180
+ best = potential;
181
+ }
182
+ }
183
+ assign(info, target);
184
+ }
185
+ return {
186
+ expansion: {
187
+ ...expansion,
188
+ assignment: { ...expansion.assignment, layerByNodeId },
189
+ input: inheritCycleRandom(input, {
190
+ ...input,
191
+ graph: { ...input.graph, edges },
192
+ nodeSettings: (node) => ({
193
+ ...input.nodeSettings?.(node),
194
+ ...(alignments.has(node.id) ? { alignment: alignments.get(node.id)! } : {}),
195
+ }),
196
+ }),
197
+ },
198
+ order: { ...order, layers },
199
+ };
200
+ }
@@ -1,3 +1,4 @@
1
+ import { inheritCycleRandom } from "./cycle-random";
1
2
  import type { GraphEdge, GraphNode, Point } from "@statelyai/graph";
2
3
  import type {
3
4
  AcyclicOrientation,
@@ -14,6 +15,8 @@ export interface LongEdgeExpansion {
14
15
  assignment: LayerAssignment;
15
16
  segmentIdsByEdgeId: ReadonlyMap<string, readonly string[]>;
16
17
  labelDummyIdByEdgeId: ReadonlyMap<string, string>;
18
+ /** Physical incoming adjacency after cycle reversal and long-edge retargeting. */
19
+ incomingEdgeOrderByNodeId?: ReadonlyMap<string, readonly string[]>;
17
20
  }
18
21
 
19
22
  function uniqueDummyId(usedIds: Set<string>, edgeId: string, layer: number): string {
@@ -41,50 +44,15 @@ export function splitLongEdges(
41
44
  const originalEdgeBySegmentId = new Map<string, GraphEdge>();
42
45
  const usedNodeIds = new Set(nodes.map((node) => node.id));
43
46
  const originalNodeIds = new Set(usedNodeIds);
44
- const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
45
- const forwardSourceSide =
46
- input.direction === "right"
47
- ? "EAST"
48
- : input.direction === "left"
49
- ? "WEST"
50
- : input.direction === "down"
51
- ? "SOUTH"
52
- : "NORTH";
53
- const forwardTargetSide =
54
- input.direction === "right"
55
- ? "WEST"
56
- : input.direction === "left"
57
- ? "EAST"
58
- : input.direction === "down"
59
- ? "NORTH"
60
- : "SOUTH";
61
47
 
62
48
  for (const edge of input.graph.edges) {
63
49
  const sourceLayer = layerByNodeId.get(edge.sourceId) ?? 0;
64
50
  const targetLayer = layerByNodeId.get(edge.targetId) ?? 0;
65
51
  const span = Math.abs(targetLayer - sourceLayer);
66
- const source = nodeById.get(edge.sourceId);
67
- const target = nodeById.get(edge.targetId);
68
- const sourcePort = source?.ports?.find((port) => port.name === edge.sourcePort);
69
- const targetPort = target?.ports?.find((port) => port.name === edge.targetPort);
70
- const sourceSide =
71
- source && sourcePort ? input.portSettings?.(sourcePort, source)?.["port.side"] : undefined;
72
- const targetSide =
73
- target && targetPort ? input.portSettings?.(targetPort, target)?.["port.side"] : undefined;
74
- const hasFixedPortSide = (node: GraphNode | undefined): boolean => {
75
- if (!node) return false;
76
- const constraints = String(input.nodeSettings?.(node)?.portConstraints ?? "UNDEFINED");
77
- return constraints !== "UNDEFINED" && constraints !== "FREE";
78
- };
79
- const fixedSideFeedback =
80
- sourceLayer > targetLayer &&
81
- ((hasFixedPortSide(source) && sourceSide === forwardSourceSide) ||
82
- (hasFixedPortSide(target) && targetSide === forwardTargetSide));
83
52
  if (
84
53
  span <= 1 ||
85
54
  edge.sourceId === edge.targetId ||
86
- (input.settings.feedbackEdges === true && orientation.reversedEdgeIds.has(edge.id)) ||
87
- fixedSideFeedback
55
+ (input.settings.feedbackEdges === true && orientation.reversedEdgeIds.has(edge.id))
88
56
  ) {
89
57
  edges.push(edge);
90
58
  originalEdgeBySegmentId.set(edge.id, edge);
@@ -175,10 +143,58 @@ export function splitLongEdges(
175
143
  segmentIdsByEdgeId.set(edge.id, segmentIds);
176
144
  }
177
145
 
146
+ const incomingEdgeOrderByNodeId = new Map<string, string[]>();
147
+ const orientedSource = (edge: GraphEdge) =>
148
+ orientation.reversedEdgeIds.has(edge.id) ? edge.targetId : edge.sourceId;
149
+ const orientedTarget = (edge: GraphEdge) =>
150
+ orientation.reversedEdgeIds.has(edge.id) ? edge.sourceId : edge.targetId;
151
+ const appendIncoming = (edge: GraphEdge) => {
152
+ const target = orientedTarget(edge);
153
+ incomingEdgeOrderByNodeId.set(target, [
154
+ ...(incomingEdgeOrderByNodeId.get(target) ?? []),
155
+ edge.id,
156
+ ]);
157
+ };
158
+ for (const edge of input.graph.edges)
159
+ if (!orientation.reversedEdgeIds.has(edge.id)) appendIncoming(edge);
160
+ for (const id of orientation.reversedEdgeIds) {
161
+ const edge = input.graph.edges.find((candidate) => candidate.id === id);
162
+ if (edge) appendIncoming(edge);
163
+ }
164
+ // Upstream walks source layers, nodes, then their existing ports. Splitting
165
+ // retargets the last chain segment, appending it to its physical target port.
166
+ const sourceNodes = [...input.graph.nodes].sort(
167
+ (a, b) => (assignment.layerByNodeId.get(a.id) ?? 0) - (assignment.layerByNodeId.get(b.id) ?? 0),
168
+ );
169
+ for (const node of sourceNodes) {
170
+ const outgoing = input.graph.edges.filter((edge) => orientedSource(edge) === node.id);
171
+ const portIndex = (edge: GraphEdge) =>
172
+ node.ports?.findIndex(
173
+ (port) =>
174
+ port.name ===
175
+ (orientation.reversedEdgeIds.has(edge.id) ? edge.targetPort : edge.sourcePort),
176
+ ) ?? -1;
177
+ outgoing.sort(
178
+ (a, b) =>
179
+ portIndex(a) - portIndex(b) ||
180
+ Number(orientation.reversedEdgeIds.has(a.id)) -
181
+ Number(orientation.reversedEdgeIds.has(b.id)),
182
+ );
183
+ for (const edge of outgoing) {
184
+ if ((segmentIdsByEdgeId.get(edge.id)?.length ?? 0) < 2) continue;
185
+ const target = orientedTarget(edge);
186
+ incomingEdgeOrderByNodeId.set(
187
+ target,
188
+ incomingEdgeOrderByNodeId.get(target)!.filter((id) => id !== edge.id),
189
+ );
190
+ appendIncoming(edge);
191
+ }
192
+ }
193
+
178
194
  // ELK creates long-edge dummies while walking layers. Thus dummies for an
179
195
  // edge whose source is in the next layer can precede later parts of an edge
180
196
  // that started in an earlier layer. Preserve that order for crossing ties.
181
- const maximumLayer = Math.max(0, ...layerByNodeId.values());
197
+ const maximumLayer = Math.max(0, (assignment.layerCount ?? 1) - 1, ...layerByNodeId.values());
182
198
  const nodesByLayer = Array.from({ length: maximumLayer + 1 }, () => [] as string[]);
183
199
  for (const node of input.graph.nodes) {
184
200
  nodesByLayer[layerByNodeId.get(node.id) ?? 0]?.push(node.id);
@@ -223,10 +239,25 @@ export function splitLongEdges(
223
239
  ),
224
240
  ];
225
241
  const graph = { ...input.graph, nodes: orderedNodes, edges } as LayeredPhaseInput["graph"];
242
+ const edgeIdByLabelDummyId = new Map<string, string>();
243
+ for (const [edgeId, id] of labelDummyIdByEdgeId)
244
+ if (!edgeIdByLabelDummyId.has(id)) edgeIdByLabelDummyId.set(id, edgeId);
245
+ const inputEdgeById = new Map<string, (typeof input.graph.edges)[number]>();
246
+ for (const edge of input.graph.edges)
247
+ if (!inputEdgeById.has(edge.id)) inputEdgeById.set(edge.id, edge);
226
248
  return {
227
- input: {
249
+ input: inheritCycleRandom(input, {
228
250
  ...input,
229
251
  graph,
252
+ modelOrderByEdgeId: new Map(
253
+ edges.map((edge) => {
254
+ const original = originalEdgeBySegmentId.get(edge.id) ?? edge;
255
+ return [
256
+ edge.id,
257
+ input.modelOrderByEdgeId?.get(original.id) ?? input.graph.edges.indexOf(original),
258
+ ];
259
+ }),
260
+ ),
230
261
  sizes,
231
262
  edgeSettings: (edge) => {
232
263
  const original = originalEdgeBySegmentId.get(edge.id) ?? edge;
@@ -234,9 +265,9 @@ export function splitLongEdges(
234
265
  },
235
266
  nodeSettings: (node) => {
236
267
  const original = input.nodeSettings?.(node);
237
- const edgeId = [...labelDummyIdByEdgeId].find(([, id]) => id === node.id)?.[0];
268
+ const edgeId = edgeIdByLabelDummyId.get(node.id);
238
269
  if (!edgeId) return original;
239
- const edge = input.graph.edges.find((candidate) => candidate.id === edgeId);
270
+ const edge = inputEdgeById.get(edgeId);
240
271
  const strategy = String(
241
272
  (edge && input.edgeSettings?.(edge)?.["edgeLabels.centerLabelPlacementStrategy"]) ??
242
273
  input.settings["edgeLabels.centerLabelPlacementStrategy"] ??
@@ -257,7 +288,8 @@ export function splitLongEdges(
257
288
  : {}),
258
289
  };
259
290
  },
260
- },
291
+ }),
292
+ incomingEdgeOrderByNodeId,
261
293
  orientation: { reversedEdgeIds },
262
294
  assignment: { ...assignment, layerByNodeId },
263
295
  segmentIdsByEdgeId,
@@ -289,6 +321,9 @@ export function joinLongEdgeRoutes(
289
321
  preserveInternalDuplicates = false,
290
322
  convertLongSplines = false,
291
323
  longSplineEdgeNodeSpacing = 10,
324
+ preserveOrthogonalCorners = false,
325
+ restoredJunctionCounts?: ReadonlyMap<string, number>,
326
+ reversedSegmentIds?: ReadonlySet<string>,
292
327
  ): EdgeRoutes {
293
328
  const simplify = (points: readonly Point[]): Point[] => {
294
329
  const result: Point[] = [];
@@ -381,7 +416,36 @@ export function joinLongEdgeRoutes(
381
416
  }
382
417
  }
383
418
  const points: Point[] = [];
384
- if (!preserveInternalDuplicates && segmentIds.length > 1) {
419
+ // ELK joins segment bend lists without their dummy anchors. Orthogonal
420
+ // compaction can collapse bends; those original bends still remain public.
421
+ if (!preserveInternalDuplicates && preserveOrthogonalCorners && segmentIds.length > 1) {
422
+ const joined = segmentIds.flatMap((segmentId, segmentIndex) => {
423
+ const segment = routes.pointsByEdgeId.get(segmentId) ?? [];
424
+ return segment.map((point, index) => ({
425
+ point,
426
+ dummyAnchor:
427
+ (index === 0 && segmentIndex !== 0) ||
428
+ (index === segment.length - 1 && segmentIndex !== segmentIds.length - 1),
429
+ }));
430
+ });
431
+ // Native dummy anchors may supply a real corner. Discard only redundant
432
+ // anchors; retain segment bends even when compaction made them collinear.
433
+ for (let index = 1; index + 1 < joined.length;) {
434
+ const first = joined[index - 1]!.point;
435
+ const middle = joined[index]!;
436
+ const last = joined[index + 1]!.point;
437
+ const equal = (a: number, b: number) => Math.abs(a - b) < 1e-9;
438
+ if (
439
+ middle.dummyAnchor &&
440
+ ((equal(first.x, middle.point.x) && equal(middle.point.x, last.x)) ||
441
+ (equal(first.y, middle.point.y) && equal(middle.point.y, last.y)))
442
+ ) {
443
+ joined.splice(index, 1);
444
+ index = Math.max(1, index - 1);
445
+ } else index++;
446
+ }
447
+ points.push(...joined.map(({ point }) => point));
448
+ } else if (!preserveInternalDuplicates && segmentIds.length > 1) {
385
449
  const segments = segmentIds.map((segmentId) => routes.pointsByEdgeId.get(segmentId) ?? []);
386
450
  const firstPoint = segments[0]?.[0];
387
451
  if (firstPoint) points.push(firstPoint);
@@ -399,8 +463,35 @@ export function joinLongEdgeRoutes(
399
463
  }
400
464
  pointsByEdgeId.set(
401
465
  edgeId,
402
- preserveInternalDuplicates || segmentIds.length === 1 ? points : simplify(points),
466
+ preserveInternalDuplicates || preserveOrthogonalCorners || segmentIds.length === 1
467
+ ? points
468
+ : simplify(points),
403
469
  );
404
470
  }
405
- return { pointsByEdgeId, outsideFeedbackEdgeIds };
471
+ const junctionPointsByEdgeId =
472
+ routes.junctionPointsByEdgeId &&
473
+ new Map(
474
+ [...segmentIdsByEdgeId].map(([edgeId, ids]) => [
475
+ edgeId,
476
+ // ELK restores north/south branches after long-edge joining. Keep
477
+ // those appended junctions after all original routing junctions.
478
+ [
479
+ // Long-edge joining runs before edge-direction restoration. Its
480
+ // junction chain retains physical flow order on reversed edges.
481
+ ...(reversedSegmentIds?.has(ids[0]!) ? [...ids].reverse() : ids).flatMap((id) => {
482
+ const points = routes.junctionPointsByEdgeId?.get(id) ?? [];
483
+ return points.slice(0, points.length - (restoredJunctionCounts?.get(id) ?? 0));
484
+ }),
485
+ ...ids.flatMap((id) => {
486
+ const count = restoredJunctionCounts?.get(id) ?? 0;
487
+ return count ? (routes.junctionPointsByEdgeId?.get(id) ?? []).slice(-count) : [];
488
+ }),
489
+ ],
490
+ ]),
491
+ );
492
+ return {
493
+ pointsByEdgeId,
494
+ outsideFeedbackEdgeIds,
495
+ ...(junctionPointsByEdgeId ? { junctionPointsByEdgeId } : {}),
496
+ };
406
497
  }
@@ -0,0 +1,184 @@
1
+ import type { GraphEdge } from "@statelyai/graph";
2
+ import type { LayeredPhaseInput, NodePlacement } from "./types";
3
+ import { getPortPoint } from "./strategies";
4
+ import {
5
+ fixedSelfLoopLabels,
6
+ fixedSelfLoopSide,
7
+ routeFixedSelfLoop,
8
+ selfLoopTracks,
9
+ } from "./fixed-self-loop";
10
+
11
+ export interface LoopEnvelope {
12
+ before: number;
13
+ after: number;
14
+ flowBefore: number;
15
+ flowAfter: number;
16
+ }
17
+
18
+ /** Unconstrained implicit loop ports are restored on loop faces after the sweep. */
19
+ export function hasMovableLoopPorts(input: LayeredPhaseInput, edge: GraphEdge): boolean {
20
+ if (
21
+ edge.sourceId !== edge.targetId ||
22
+ edge.sourcePort !== undefined ||
23
+ edge.targetPort !== undefined
24
+ )
25
+ return false;
26
+ const node = input.graph.nodes.find((candidate) => candidate.id === edge.sourceId);
27
+ const constraints = node && input.nodeSettings?.(node)?.portConstraints;
28
+ return constraints === undefined || constraints === "FREE" || constraints === "UNDEFINED";
29
+ }
30
+
31
+ export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, LoopEnvelope> {
32
+ const envelopes = new Map<string, LoopEnvelope>();
33
+ if ((input.settings.edgeRouting ?? "ORTHOGONAL") !== "ORTHOGONAL") return envelopes;
34
+ for (const node of input.graph.nodes) {
35
+ const allLoops = input.graph.edges.filter(
36
+ (edge) => edge.sourceId === node.id && edge.targetId === node.id,
37
+ );
38
+ const constraints = input.nodeSettings?.(node)?.portConstraints;
39
+ if (
40
+ allLoops.length &&
41
+ constraints &&
42
+ constraints !== "FREE" &&
43
+ constraints !== "UNDEFINED" &&
44
+ allLoops.every(
45
+ (edge) =>
46
+ edge.sourcePort !== undefined &&
47
+ edge.targetPort !== undefined &&
48
+ input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true &&
49
+ (((edge.width ?? 0) === 0 && (edge.height ?? 0) === 0) ||
50
+ (input.edgeSettings?.(edge)?.["edgeLabels.placement"] ?? "CENTER") === "CENTER"),
51
+ )
52
+ ) {
53
+ const size = input.sizes.get(node.id) ?? { width: 0, height: 0 };
54
+ const rect = { x: 0, y: 0, ...size };
55
+ const envelope = { before: 0, after: 0, flowBefore: 0, flowAfter: 0 };
56
+ const horizontal = input.direction === "right" || input.direction === "left";
57
+ const reverse = input.direction === "left" || input.direction === "up";
58
+ const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
59
+ let supported = true;
60
+ const tracks = selfLoopTracks(allLoops);
61
+ for (const edge of allLoops) {
62
+ const from = node.ports?.find((port) => port.name === edge.sourcePort);
63
+ const to = node.ports?.find((port) => port.name === edge.targetPort);
64
+ const sourceSide =
65
+ from && fixedSelfLoopSide(input.portSettings?.(from, node)?.["port.side"]);
66
+ const targetSide = to && fixedSelfLoopSide(input.portSettings?.(to, node)?.["port.side"]);
67
+ const labeled = (edge.width ?? 0) > 0 || (edge.height ?? 0) > 0;
68
+ if (!sourceSide || !targetSide || (labeled && sourceSide !== targetSide)) {
69
+ supported = false;
70
+ break;
71
+ }
72
+ const start = getPortPoint(
73
+ node,
74
+ edge.sourcePort,
75
+ rect,
76
+ { x: 0, y: 0 },
77
+ input.direction,
78
+ input,
79
+ );
80
+ const end = getPortPoint(
81
+ node,
82
+ edge.targetPort,
83
+ rect,
84
+ { x: 0, y: 0 },
85
+ input.direction,
86
+ input,
87
+ );
88
+ const route = routeFixedSelfLoop(
89
+ rect,
90
+ start,
91
+ end,
92
+ sourceSide,
93
+ targetSide,
94
+ spacing * (tracks.get(edge.id)! + 1),
95
+ input.direction,
96
+ );
97
+ if (labeled) {
98
+ const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
99
+ const { width = 0, height = 0 } = edge;
100
+ for (const label of fixedSelfLoopLabels(route, sourceSide, width, height, labelSpacing))
101
+ route.push(label, { x: label.x + width, y: label.y + height });
102
+ }
103
+ for (const point of route) {
104
+ const cross = horizontal ? point.y : point.x;
105
+ const flow = horizontal ? point.x : point.y;
106
+ const flowSize = horizontal ? size.width : size.height;
107
+ envelope.before = Math.max(envelope.before, -cross);
108
+ envelope.after = Math.max(
109
+ envelope.after,
110
+ cross - (horizontal ? size.height : size.width),
111
+ );
112
+ envelope.flowBefore = Math.max(envelope.flowBefore, reverse ? flow - flowSize : -flow);
113
+ envelope.flowAfter = Math.max(envelope.flowAfter, reverse ? -flow : flow - flowSize);
114
+ }
115
+ }
116
+ if (supported) {
117
+ envelopes.set(node.id, envelope);
118
+ continue;
119
+ }
120
+ }
121
+ const loops = allLoops.filter((edge) => hasMovableLoopPorts(input, edge));
122
+ // Inline labels remain owned by the existing labeled-loop phase. Move
123
+ // the entire node together when that phase is ported, rather than mixing
124
+ // two different loop distributions and reservations on the same node.
125
+ if (
126
+ loops.length === 0 ||
127
+ loops.some((edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
128
+ )
129
+ continue;
130
+ const settings = input.nodeSettings?.(node);
131
+ const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
132
+ const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
133
+ const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
134
+ const envelope = { before: 0, after: 0, flowBefore: 0, flowAfter: 0 };
135
+ const sides =
136
+ distribution === "EQUALLY"
137
+ ? (["before", "after", "flowAfter", "flowBefore"] as const)
138
+ : distribution === "NORTH_SOUTH"
139
+ ? (["before", "after"] as const)
140
+ : (["before"] as const);
141
+ for (const side of sides) {
142
+ const selected = loops.filter((_, index) => sides[index % sides.length] === side);
143
+ const horizontal = input.direction === "right" || input.direction === "left";
144
+ const crossSize =
145
+ side === "before" || side === "after"
146
+ ? horizontal
147
+ ? "height"
148
+ : "width"
149
+ : horizontal
150
+ ? "width"
151
+ : "height";
152
+ const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
153
+ envelope[side] =
154
+ spacing * (ordering === "SEQUENCED" ? Math.min(1, selected.length) : selected.length) +
155
+ selected.reduce(
156
+ (sum, edge) =>
157
+ sum + ((edge[crossSize] ?? 0) > 0 ? (edge[crossSize] ?? 0) + labelSpacing : 0),
158
+ 0,
159
+ );
160
+ }
161
+ envelopes.set(node.id, envelope);
162
+ }
163
+ return envelopes;
164
+ }
165
+
166
+ const reservedByPlacement = new WeakMap<NodePlacement, ReadonlySet<string>>();
167
+ export function recordLoopEnvelopes(placement: NodePlacement, ids: ReadonlySet<string>): void {
168
+ reservedByPlacement.set(placement, ids);
169
+ }
170
+ export function hasPlacementLoopEnvelope(placement: NodePlacement, id: string): boolean {
171
+ return reservedByPlacement.get(placement)?.has(id) ?? false;
172
+ }
173
+
174
+ const preparedByInput = new WeakMap<LayeredPhaseInput, ReadonlyMap<string, LoopEnvelope>>();
175
+ export function prepareLoopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, LoopEnvelope> {
176
+ const envelopes = loopEnvelopes(input);
177
+ preparedByInput.set(input, envelopes);
178
+ return envelopes;
179
+ }
180
+ export function preparedLoopEnvelopes(
181
+ input: LayeredPhaseInput,
182
+ ): ReadonlyMap<string, LoopEnvelope> | undefined {
183
+ return preparedByInput.get(input);
184
+ }