@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,371 @@
1
+ import type { GraphNode, VisualGraph } from "@statelyai/graph";
2
+ import { worldGeometry } from "../authoring/coordinates";
3
+ import type { LayoutDiagnostic } from "../types";
4
+ import type { LayerOrder, LayeredPhaseInput, NodePlacement } from "./types";
5
+
6
+ /**
7
+ * Hints steer the layered phases toward a reader's expectations. They are
8
+ * preferences unless marked `require`, and never at the cost of a defect: a
9
+ * hint that would make nodes overlap is relaxed and reported instead.
10
+ */
11
+ export type HintStrength = "prefer" | "require";
12
+ interface HintBase {
13
+ id: string;
14
+ strength?: HintStrength;
15
+ }
16
+ /** Place a node at the start (top-left) or end of its container. */
17
+ export interface AnchorHint extends HintBase {
18
+ kind: "anchor";
19
+ nodeId: string;
20
+ corner: "start" | "end";
21
+ }
22
+ /** Align the centers of nodes that follow each other, such as sequential states. */
23
+ export interface ChainHint extends HintBase {
24
+ kind: "chain";
25
+ nodeIds: readonly string[];
26
+ }
27
+ export type LayoutHint = AnchorHint | ChainHint;
28
+
29
+ /** Serializable hint constructors, alongside the `c` geometry constraint constructors. */
30
+ export const hint = {
31
+ anchor: (value: Omit<AnchorHint, "kind">): AnchorHint => ({ ...value, kind: "anchor" }),
32
+ chain: (value: Omit<ChainHint, "kind">): ChainHint => ({ ...value, kind: "chain" }),
33
+ };
34
+
35
+ const relaxed = (
36
+ diagnostics: LayoutDiagnostic[] | undefined,
37
+ item: LayoutHint,
38
+ message: string,
39
+ ): void => {
40
+ diagnostics?.push({
41
+ severity: "info",
42
+ code: "HINT_RELAXED",
43
+ message: `Hint ${item.id}: ${message}`,
44
+ entityIds: item.kind === "anchor" ? [item.nodeId] : [...item.nodeIds],
45
+ });
46
+ };
47
+
48
+ /** Layering: an anchored node takes the first or last layer of its scope. */
49
+ export function hintLayerConstraint(
50
+ hints: readonly LayoutHint[] | undefined,
51
+ node: GraphNode,
52
+ ): "FIRST" | "LAST" | undefined {
53
+ const anchor = hints?.find(
54
+ (item): item is AnchorHint => item.kind === "anchor" && item.nodeId === node.id,
55
+ );
56
+ return anchor ? (anchor.corner === "start" ? "FIRST" : "LAST") : undefined;
57
+ }
58
+
59
+ /**
60
+ * Ordering: an anchored node, with any helper nodes kept beside it, moves to
61
+ * the start or end of its layer. A preferred anchor yields when the move
62
+ * would add crossings.
63
+ */
64
+ export function applyOrderHints(
65
+ hints: readonly LayoutHint[] | undefined,
66
+ order: LayerOrder,
67
+ unitOf: (id: string) => readonly string[],
68
+ crossings: (layers: LayerOrder["layers"]) => number,
69
+ diagnostics?: LayoutDiagnostic[],
70
+ ): LayerOrder {
71
+ let layers = order.layers;
72
+ for (const item of hints ?? []) {
73
+ if (item.kind !== "anchor") continue;
74
+ const index = layers.findIndex((layer) => layer.includes(item.nodeId));
75
+ if (index < 0) continue;
76
+ const unit = new Set([item.nodeId, ...unitOf(item.nodeId)]);
77
+ const layer = layers[index]!;
78
+ const members = layer.filter((id) => unit.has(id)),
79
+ rest = layer.filter((id) => !unit.has(id));
80
+ const moved = item.corner === "start" ? [...members, ...rest] : [...rest, ...members];
81
+ if (moved.every((id, position) => id === layer[position])) continue;
82
+ const candidate = layers.map((current, position) => (position === index ? moved : current));
83
+ if ((item.strength ?? "prefer") === "prefer" && crossings(candidate) > crossings(layers)) {
84
+ relaxed(diagnostics, item, "kept its layer position to avoid adding crossings");
85
+ continue;
86
+ }
87
+ layers = candidate;
88
+ }
89
+ return layers === order.layers ? order : { ...order, layers };
90
+ }
91
+
92
+ /**
93
+ * Placement: an anchored node moves to the start (or end) of its scope on the
94
+ * cross axis, and chained nodes move onto the first member's center line,
95
+ * each only as far as its layer neighbors allow.
96
+ */
97
+ export function applyPlacementHints(
98
+ hints: readonly LayoutHint[] | undefined,
99
+ input: LayeredPhaseInput,
100
+ order: LayerOrder,
101
+ placement: NodePlacement,
102
+ diagnostics?: LayoutDiagnostic[],
103
+ ): void {
104
+ if (!hints?.length) return;
105
+ const rects = placement.rectByNodeId as Map<
106
+ string,
107
+ { x: number; y: number; width: number; height: number }
108
+ >;
109
+ const horizontal = input.direction === "right" || input.direction === "left";
110
+ const cross = (id: string) => {
111
+ const rect = rects.get(id)!;
112
+ return horizontal ? { start: rect.y, size: rect.height } : { start: rect.x, size: rect.width };
113
+ };
114
+ const setCross = (id: string, start: number) => {
115
+ const rect = rects.get(id)!;
116
+ rects.set(id, horizontal ? { ...rect, y: start } : { ...rect, x: start });
117
+ };
118
+ const position = new Map<string, { layer: readonly string[]; index: number }>();
119
+ for (const layer of order.layers)
120
+ for (const [index, id] of layer.entries()) position.set(id, { layer, index });
121
+ const gap = (id: string) => (id.startsWith("__") ? 10 : input.spacing.node);
122
+ // The free interval for a node's cross start between its layer neighbors.
123
+ const room = (id: string) => {
124
+ const at = position.get(id);
125
+ const own = cross(id);
126
+ if (!at) return { low: own.start, high: own.start };
127
+ const before = at.layer[at.index - 1],
128
+ after = at.layer[at.index + 1];
129
+ const low = before
130
+ ? cross(before).start + cross(before).size + Math.max(gap(before), gap(id))
131
+ : Number.NEGATIVE_INFINITY;
132
+ const high = after
133
+ ? cross(after).start - own.size - Math.max(gap(after), gap(id))
134
+ : Number.POSITIVE_INFINITY;
135
+ // Never grow the scope: stay within its current cross extent.
136
+ const bounded = {
137
+ low: Math.max(low, extent.start),
138
+ high: Math.min(high, extent.end - own.size),
139
+ };
140
+ return {
141
+ low: Math.min(bounded.low, own.start),
142
+ high: Math.max(bounded.high, own.start),
143
+ };
144
+ };
145
+ const real = [...rects.keys()].filter((id) => !id.startsWith("__"));
146
+ const extent = {
147
+ start: Math.min(...[...rects.keys()].map((id) => cross(id).start)),
148
+ end: Math.max(...[...rects.keys()].map((id) => cross(id).start + cross(id).size)),
149
+ };
150
+ for (const item of hints) {
151
+ if (item.kind === "anchor") {
152
+ if (!rects.has(item.nodeId)) continue;
153
+ const own = cross(item.nodeId);
154
+ const target =
155
+ item.corner === "start"
156
+ ? Math.min(...real.map((id) => cross(id).start))
157
+ : Math.max(...real.map((id) => cross(id).start + cross(id).size)) - own.size;
158
+ const { low, high } = room(item.nodeId);
159
+ const placed = Math.min(high, Math.max(low, target));
160
+ setCross(item.nodeId, placed);
161
+ if (Math.abs(placed - target) > 1e-6)
162
+ relaxed(diagnostics, item, "a neighbor in its layer kept it from the corner");
163
+ } else {
164
+ const members = item.nodeIds.filter((id) => rects.has(id));
165
+ if (members.length < 2) continue;
166
+ const anchored = members.find((id) =>
167
+ hints.some((other) => other.kind === "anchor" && other.nodeId === id),
168
+ );
169
+ // Find the center line every member can reach, nearest the first member
170
+ // (or the anchored one, which stays put).
171
+ const reach = members.map((id) => {
172
+ const own = cross(id);
173
+ if (id === anchored) {
174
+ const center = own.start + own.size / 2;
175
+ return { low: center, high: center };
176
+ }
177
+ const { low, high } = room(id);
178
+ return { low: low + own.size / 2, high: high + own.size / 2 };
179
+ });
180
+ const line = cross(anchored ?? members[0]!);
181
+ const preferred = line.start + line.size / 2;
182
+ const low = Math.max(...reach.map((range) => range.low)),
183
+ high = Math.min(...reach.map((range) => range.high));
184
+ const center = low <= high ? Math.min(high, Math.max(low, preferred)) : preferred;
185
+ let blocked = low > high;
186
+ for (const [index, id] of members.entries()) {
187
+ if (id === anchored) continue;
188
+ const own = cross(id);
189
+ const range = reach[index]!;
190
+ const placed = Math.min(range.high, Math.max(range.low, center)) - own.size / 2;
191
+ setCross(id, placed);
192
+ if (Math.abs(placed + own.size / 2 - center) > 1e-6) blocked = true;
193
+ }
194
+ if (blocked) relaxed(diagnostics, item, "layer neighbors kept some members off the line");
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Hints for statecharts: each container's initial state (and the graph's)
201
+ * is required at the start, and each run of states joined one-to-one within
202
+ * the same parent forms a preferred chain. Callers can filter or extend the
203
+ * result.
204
+ */
205
+ export function statechartHints(graph: {
206
+ initialNodeId?: string | null;
207
+ nodes: readonly { id: string; parentId?: string | null; initialNodeId?: string | null }[];
208
+ edges: readonly { sourceId: string; targetId: string }[];
209
+ }): LayoutHint[] {
210
+ const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
211
+ const hints: LayoutHint[] = [];
212
+ const initials = [
213
+ ...(graph.initialNodeId ? [{ scope: "root", id: graph.initialNodeId }] : []),
214
+ ...graph.nodes.flatMap((node) =>
215
+ node.initialNodeId ? [{ scope: node.id, id: node.initialNodeId }] : [],
216
+ ),
217
+ ];
218
+ for (const { scope, id } of initials)
219
+ if (nodes.has(id))
220
+ hints.push(
221
+ hint.anchor({ id: `initial:${scope}`, nodeId: id, corner: "start", strength: "require" }),
222
+ );
223
+ // One-to-one transitions between siblings, ignoring self loops.
224
+ const outgoing = new Map<string, Set<string>>(),
225
+ incoming = new Map<string, Set<string>>();
226
+ for (const edge of graph.edges) {
227
+ if (edge.sourceId === edge.targetId) continue;
228
+ outgoing.set(edge.sourceId, (outgoing.get(edge.sourceId) ?? new Set()).add(edge.targetId));
229
+ incoming.set(edge.targetId, (incoming.get(edge.targetId) ?? new Set()).add(edge.sourceId));
230
+ }
231
+ const next = (id: string) => {
232
+ const targets = outgoing.get(id);
233
+ if (targets?.size !== 1) return undefined;
234
+ const [target] = targets;
235
+ const node = nodes.get(id),
236
+ other = nodes.get(target!);
237
+ return other &&
238
+ (other.parentId ?? null) === (node?.parentId ?? null) &&
239
+ incoming.get(target!)?.size === 1
240
+ ? target
241
+ : undefined;
242
+ };
243
+ const previous = new Set(
244
+ graph.nodes.flatMap((node) => {
245
+ const target = next(node.id);
246
+ return target ? [target] : [];
247
+ }),
248
+ );
249
+ for (const node of graph.nodes) {
250
+ if (previous.has(node.id)) continue;
251
+ const chain = [node.id];
252
+ for (let id = next(node.id); id && !chain.includes(id); id = next(id)) chain.push(id);
253
+ if (chain.length >= 2) hints.push(hint.chain({ id: `chain:${node.id}`, nodeIds: chain }));
254
+ }
255
+ return hints;
256
+ }
257
+
258
+ interface SoftQuality {
259
+ crossings: number;
260
+ bends: number;
261
+ length: number;
262
+ area: number;
263
+ }
264
+ /** Soft measures of a finished layout, in world coordinates. */
265
+ export function measureSoftQuality(graph: VisualGraph): SoftQuality {
266
+ const world = worldGeometry(graph);
267
+ const rects = new Map(world.nodes.map((node) => [node.id, node]));
268
+ const routes = world.edges.map((edge) => {
269
+ const points = edge.points ?? [];
270
+ return {
271
+ ends: [edge.sourceId, edge.targetId],
272
+ segments: points.slice(1).map((b, index) => ({ a: points[index]!, b })),
273
+ };
274
+ });
275
+ let bends = 0,
276
+ length = 0;
277
+ for (const { segments } of routes)
278
+ for (const [index, { a, b }] of segments.entries()) {
279
+ length += Math.abs(b.x - a.x) + Math.abs(b.y - a.y);
280
+ const previous = segments[index - 1];
281
+ if (previous && Math.abs(previous.a.x - previous.b.x) < 1e-9 !== Math.abs(a.x - b.x) < 1e-9)
282
+ bends++;
283
+ }
284
+ let crossings = 0;
285
+ for (const [index, first] of routes.entries())
286
+ for (const second of routes.slice(index + 1)) {
287
+ const shared = first.ends
288
+ .filter((id) => second.ends.includes(id))
289
+ .flatMap((id) => {
290
+ const rect = rects.get(id);
291
+ return rect ? [rect] : [];
292
+ });
293
+ for (const s of first.segments)
294
+ for (const t of second.segments) {
295
+ const sHorizontal = Math.abs(s.a.y - s.b.y) < 1e-9,
296
+ tHorizontal = Math.abs(t.a.y - t.b.y) < 1e-9;
297
+ if (sHorizontal === tHorizontal) continue;
298
+ const h = sHorizontal ? s : t,
299
+ v = sHorizontal ? t : s;
300
+ const x = v.a.x,
301
+ y = h.a.y;
302
+ if (
303
+ x > Math.min(h.a.x, h.b.x) + 1e-6 &&
304
+ x < Math.max(h.a.x, h.b.x) - 1e-6 &&
305
+ y > Math.min(v.a.y, v.b.y) + 1e-6 &&
306
+ y < Math.max(v.a.y, v.b.y) - 1e-6 &&
307
+ shared.every(
308
+ (rect) =>
309
+ x < rect.x - 12 ||
310
+ x > rect.x + rect.width + 12 ||
311
+ y < rect.y - 12 ||
312
+ y > rect.y + rect.height + 12,
313
+ )
314
+ )
315
+ crossings++;
316
+ }
317
+ }
318
+ const xs = world.nodes.flatMap((node) => [node.x, node.x + node.width]),
319
+ ys = world.nodes.flatMap((node) => [node.y, node.y + node.height]);
320
+ const area = xs.length
321
+ ? (Math.max(...xs) - Math.min(...xs)) * (Math.max(...ys) - Math.min(...ys))
322
+ : 0;
323
+ return { crossings, bends, length, area };
324
+ }
325
+
326
+ /** True when `candidate` is worse at the first soft measure where the two differ. */
327
+ export function isSoftRegression(candidate: SoftQuality, baseline: SoftQuality): boolean {
328
+ for (const key of ["crossings", "bends", "length", "area"] as const) {
329
+ const limit = key === "length" || key === "area" ? 0.02 * Math.abs(baseline[key]) : 0;
330
+ if (Math.abs(candidate[key] - baseline[key]) > limit) return candidate[key] > baseline[key];
331
+ }
332
+ return false;
333
+ }
334
+
335
+ /**
336
+ * Lay out with every hint, keeping preferred hints only while they do not
337
+ * make the layout worse than one without them. Usually two layouts; when the
338
+ * full set regresses, preferred hints are added back one at a time.
339
+ */
340
+ export function layoutWithHints<T extends VisualGraph>(
341
+ hints: readonly LayoutHint[] | undefined,
342
+ run: (hints: readonly LayoutHint[] | undefined, diagnostics: LayoutDiagnostic[]) => T,
343
+ diagnostics?: LayoutDiagnostic[],
344
+ ): T {
345
+ const preferred = hints?.filter((item) => (item.strength ?? "prefer") === "prefer") ?? [];
346
+ if (!preferred.length) return run(hints, diagnostics ?? []);
347
+ const required = hints!.filter((item) => item.strength === "require");
348
+ const attempt = (selected: readonly LayoutHint[]) => {
349
+ const own: LayoutDiagnostic[] = [];
350
+ const graph = run(selected, own);
351
+ return { graph, own, quality: measureSoftQuality(graph) };
352
+ };
353
+ let best = attempt(required);
354
+ const all = attempt(hints!);
355
+ let kept: LayoutHint[] = [...required];
356
+ if (!isSoftRegression(all.quality, best.quality)) {
357
+ best = all;
358
+ kept = [...hints!];
359
+ } else
360
+ for (const item of preferred) {
361
+ const candidate = attempt([...kept, item]);
362
+ if (isSoftRegression(candidate.quality, best.quality)) continue;
363
+ best = candidate;
364
+ kept.push(item);
365
+ }
366
+ diagnostics?.push(...best.own);
367
+ for (const item of preferred)
368
+ if (!kept.includes(item))
369
+ relaxed(diagnostics, item, "dropped because it made the layout worse");
370
+ return best.graph;
371
+ }
@@ -0,0 +1,195 @@
1
+ /*
2
+ * Copyright (c) 2015, 2020 Kiel University and others.
3
+ * Native adaptation of ELK HyperedgeDummyMerger (v0.11.0).
4
+ * SPDX-License-Identifier: EPL-2.0
5
+ */
6
+ import type { LayeredPhaseInput, LayerOrder } from "./types";
7
+ import type { LongEdgeExpansion } from "./long-edges";
8
+
9
+ const mergedNodes = new WeakMap<LayeredPhaseInput, Set<string>>();
10
+ export const isMergedHyperedgeDummy = (input: LayeredPhaseInput, id: string): boolean =>
11
+ mergedNodes.get(input)?.has(id) ?? false;
12
+
13
+ /**
14
+ * ELK schedules HyperedgeDummyMerger only when import finds a port with more
15
+ * than one incoming or outgoing edge (or a hypernode). Hierarchy scopes keep
16
+ * the merger: their imported edges are not visible here.
17
+ */
18
+ export function importsHyperedges(input: LayeredPhaseInput): boolean {
19
+ const { graph } = input;
20
+ if (
21
+ graph.nodes.some(
22
+ (node) =>
23
+ node.id.startsWith("__native_hierarchy") || input.nodeSettings?.(node)?.hypernode === true,
24
+ ) ||
25
+ graph.edges.some((edge) => edge.id.startsWith("__native_hierarchy"))
26
+ )
27
+ return true;
28
+ const counts = new Map<string, number>();
29
+ const exceeds = (nodeId: string, port: string | undefined, kind: string, edgeId: string) => {
30
+ const key =
31
+ port !== undefined
32
+ ? `${nodeId}\0${port}\0${kind}`
33
+ : nodeId.startsWith("__") || input.settings.mergeEdges === true
34
+ ? `${nodeId}\0\0${kind}`
35
+ : `\0${edgeId}\0${kind}`;
36
+ const count = (counts.get(key) ?? 0) + 1;
37
+ counts.set(key, count);
38
+ return count > 1;
39
+ };
40
+ let found = false;
41
+ for (const edge of graph.edges) {
42
+ const source = exceeds(edge.sourceId, edge.sourcePort, "out", edge.id);
43
+ const target = exceeds(edge.targetId, edge.targetPort, "in", edge.id);
44
+ found ||= source || target;
45
+ }
46
+ return found;
47
+ }
48
+
49
+ /** Merge adjacent long-edge dummies connected through the same physical hyperedge. */
50
+ export function mergeHyperedgeDummies(expansion: LongEdgeExpansion, order: LayerOrder): LayerOrder {
51
+ const { input } = expansion;
52
+ const labelNodes = new Set(expansion.labelDummyIdByEdgeId.values());
53
+ const longDummy = (id: string) =>
54
+ id.startsWith("__layout_dummy:") &&
55
+ !id.startsWith("__layout_dummy:north-south:") &&
56
+ !id.startsWith("__layout_dummy:label:") &&
57
+ !labelNodes.has(id);
58
+ const parent = new Map<string, string>();
59
+ const root = (id: string): string => {
60
+ const next = parent.get(id);
61
+ if (next === undefined) {
62
+ parent.set(id, id);
63
+ return id;
64
+ }
65
+ if (next === id) return id;
66
+ const result = root(next);
67
+ parent.set(id, result);
68
+ return result;
69
+ };
70
+ const port = (id: string, name: string | undefined, edge: string, endpoint: string) =>
71
+ longDummy(id)
72
+ ? JSON.stringify(["dummy", id])
73
+ : name !== undefined
74
+ ? JSON.stringify(["port", id, name])
75
+ : input.settings.mergeEdges === true
76
+ ? JSON.stringify(["implicit", id, endpoint])
77
+ : JSON.stringify(["edge", edge, endpoint]);
78
+ for (const edge of input.graph.edges) {
79
+ const source = port(edge.sourceId, edge.sourcePort, edge.id, "source");
80
+ const target = port(edge.targetId, edge.targetPort, edge.id, "target");
81
+ parent.set(root(source), root(target));
82
+ }
83
+ // Label-bearing chains merge only before a shared source or after a shared target.
84
+ type Metadata = {
85
+ source?: string;
86
+ target?: string;
87
+ label?: boolean;
88
+ before?: boolean;
89
+ reversed?: boolean;
90
+ };
91
+ const metadata = new Map<string, Metadata>();
92
+ const edges = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
93
+ for (const [edgeId, ids] of expansion.segmentIdsByEdgeId) {
94
+ const chain = ids.map((id) => edges.get(id)).filter((edge) => edge !== undefined);
95
+ if (!chain.length) continue;
96
+ const first = chain[0]!,
97
+ last = chain.at(-1)!;
98
+ const reversed =
99
+ (expansion.assignment.layerByNodeId.get(first.sourceId) ?? 0) >
100
+ (expansion.assignment.layerByNodeId.get(last.targetId) ?? 0);
101
+ const source = port(
102
+ reversed ? last.targetId : first.sourceId,
103
+ reversed ? last.targetPort : first.sourcePort,
104
+ edgeId,
105
+ "source",
106
+ );
107
+ const target = port(
108
+ reversed ? first.sourceId : last.targetId,
109
+ reversed ? first.sourcePort : last.targetPort,
110
+ edgeId,
111
+ "target",
112
+ );
113
+ const labelId = expansion.labelDummyIdByEdgeId.get(edgeId);
114
+ const labelLayer =
115
+ labelId === undefined ? undefined : expansion.assignment.layerByNodeId.get(labelId);
116
+ for (const edge of chain)
117
+ for (const id of [edge.sourceId, edge.targetId]) {
118
+ if (!longDummy(id)) continue;
119
+ metadata.set(id, {
120
+ source,
121
+ target,
122
+ label: labelId !== undefined,
123
+ reversed,
124
+ before:
125
+ labelLayer !== undefined &&
126
+ (expansion.assignment.layerByNodeId.get(id) ?? 0) < labelLayer,
127
+ });
128
+ }
129
+ }
130
+ const aliases = new Map<string, string>(),
131
+ merged = new Set<string>();
132
+ const layers = order.layers.map((layer) => {
133
+ const result: string[] = [];
134
+ for (const id of layer) {
135
+ const previous = result.at(-1);
136
+ if (previous !== undefined && longDummy(id) && longDummy(previous)) {
137
+ const currentInfo = metadata.get(id) ?? {},
138
+ priorInfo = metadata.get(previous) ?? {};
139
+ const sameSource =
140
+ currentInfo.source !== undefined && currentInfo.source === priorInfo.source;
141
+ const sameTarget =
142
+ currentInfo.target !== undefined && currentInfo.target === priorInfo.target;
143
+ // Opposite-direction chains never share a track.
144
+ const allowed =
145
+ currentInfo.reversed === priorInfo.reversed &&
146
+ (!currentInfo.label && !priorInfo.label
147
+ ? root(JSON.stringify(["dummy", id])) === root(JSON.stringify(["dummy", previous]))
148
+ : (sameSource &&
149
+ (!currentInfo.label || currentInfo.before) &&
150
+ (!priorInfo.label || priorInfo.before)) ||
151
+ (sameTarget &&
152
+ (!currentInfo.label || !currentInfo.before) &&
153
+ (!priorInfo.label || !priorInfo.before)));
154
+ if (allowed) {
155
+ aliases.set(id, previous);
156
+ merged.add(previous);
157
+ metadata.set(previous, {
158
+ ...priorInfo,
159
+ source: sameSource ? priorInfo.source : undefined,
160
+ target: sameTarget ? priorInfo.target : undefined,
161
+ });
162
+ continue;
163
+ }
164
+ }
165
+ result.push(id);
166
+ }
167
+ return result;
168
+ });
169
+ if (!aliases.size) return order;
170
+ input.graph = {
171
+ ...input.graph,
172
+ nodes: input.graph.nodes.filter((node) => !aliases.has(node.id)),
173
+ edges: input.graph.edges.map((edge) => ({
174
+ ...edge,
175
+ sourceId: aliases.get(edge.sourceId) ?? edge.sourceId,
176
+ targetId: aliases.get(edge.targetId) ?? edge.targetId,
177
+ })),
178
+ } as LayeredPhaseInput["graph"];
179
+ mergedNodes.set(input, merged);
180
+ const portOrders = (original: ReadonlyMap<string, readonly string[]> | undefined) => {
181
+ if (!original) return undefined;
182
+ const result = new Map(original);
183
+ for (const [removed, kept] of aliases) {
184
+ result.set(kept, [...(result.get(kept) ?? []), ...(result.get(removed) ?? [])]);
185
+ result.delete(removed);
186
+ }
187
+ return result;
188
+ };
189
+ return {
190
+ ...order,
191
+ layers,
192
+ inputPortOrderByNodeId: portOrders(order.inputPortOrderByNodeId),
193
+ outputPortOrderByNodeId: portOrders(order.outputPortOrderByNodeId),
194
+ };
195
+ }