@statelyai/layout 0.0.1 → 0.0.3

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 (42) hide show
  1. package/README.md +1 -3
  2. package/dist/elkjs/index.mjs +15 -11
  3. package/dist/{index-D2RodsZY.d.mts → index-8xYkohbz.d.mts} +2 -0
  4. package/dist/index.d.mts +1 -1
  5. package/dist/index.mjs +2 -2
  6. package/dist/layered/index.d.mts +1 -1
  7. package/dist/layered/index.mjs +1 -1
  8. package/dist/{layered-Dd868WZY.mjs → layered-f_3mmGHr.mjs} +717 -62
  9. package/dist/{spore-fTgSoRLP.mjs → spore-BfTduMDc.mjs} +1 -1
  10. package/package.json +20 -6
  11. package/src/box.ts +135 -0
  12. package/src/elkjs/index.ts +1832 -0
  13. package/src/elkjs/types.ts +103 -0
  14. package/src/errors.ts +16 -0
  15. package/src/fixed.ts +80 -0
  16. package/src/index.ts +92 -0
  17. package/src/java-random.ts +46 -0
  18. package/src/layered/bk-node-placement.ts +715 -0
  19. package/src/layered/elk-enum-values.ts +171 -0
  20. package/src/layered/elk-options.generated.ts +315 -0
  21. package/src/layered/elk-options.ts +98 -0
  22. package/src/layered/flexible-ports.ts +11 -0
  23. package/src/layered/high-degree.ts +127 -0
  24. package/src/layered/index.ts +2555 -0
  25. package/src/layered/layer-unzipping.ts +217 -0
  26. package/src/layered/linear-segments-node-placement.ts +447 -0
  27. package/src/layered/long-edges.ts +406 -0
  28. package/src/layered/min-width.ts +159 -0
  29. package/src/layered/multi-edge-wrapping.ts +460 -0
  30. package/src/layered/network-simplex-node-placement.ts +500 -0
  31. package/src/layered/network-simplex.ts +346 -0
  32. package/src/layered/node-promotion.ts +197 -0
  33. package/src/layered/spacing.ts +37 -0
  34. package/src/layered/spline-bezier.ts +102 -0
  35. package/src/layered/strategies.ts +5403 -0
  36. package/src/layered/stretch-width.ts +136 -0
  37. package/src/layered/types.ts +111 -0
  38. package/src/layout.ts +202 -0
  39. package/src/packing.ts +74 -0
  40. package/src/random.ts +142 -0
  41. package/src/spore.ts +103 -0
  42. package/src/types.ts +84 -0
@@ -0,0 +1,715 @@
1
+ /*******************************************************************************
2
+ * Derived from Eclipse Layout Kernel's Brandes-Koepf node placer.
3
+ * Copyright (c) 2015 Kiel University and others.
4
+ * SPDX-License-Identifier: EPL-2.0
5
+ *******************************************************************************/
6
+
7
+ import type { EntityRect } from "@statelyai/graph";
8
+ import { placeNodesInLayers, placePorts } from "./strategies";
9
+ import type { LayerOrder, LayeredPhaseInput, NodePlacement } from "./types";
10
+ import { nodeNodeSpacing } from "./spacing";
11
+
12
+ type HDirection = "LEFT" | "RIGHT";
13
+ type VDirection = "UP" | "DOWN";
14
+
15
+ interface Alignment {
16
+ hdir: HDirection;
17
+ vdir: VDirection;
18
+ root: Map<string, string>;
19
+ align: Map<string, string>;
20
+ innerShift: Map<string, number>;
21
+ blockSize: Map<string, number>;
22
+ sink: Map<string, string>;
23
+ shift: Map<string, number>;
24
+ y: Map<string, number>;
25
+ }
26
+
27
+ interface Neighbor {
28
+ id: string;
29
+ edgeId: string;
30
+ }
31
+
32
+ function actualCrossSize(input: LayeredPhaseInput, id: string): number {
33
+ const size = input.sizes.get(id);
34
+ return input.direction === "left" || input.direction === "right"
35
+ ? (size?.height ?? 0)
36
+ : (size?.width ?? 0);
37
+ }
38
+
39
+ function anchorCrossSize(input: LayeredPhaseInput, id: string): number {
40
+ return id.startsWith("__layout_dummy:wrap:") ? 0 : actualCrossSize(input, id);
41
+ }
42
+
43
+ function crossSize(input: LayeredPhaseInput, id: string): number {
44
+ const value = actualCrossSize(input, id);
45
+ if (id.startsWith("__layout_breaking:")) return value;
46
+ if (value !== 0 || (!id.startsWith("__layout_dummy:") && !id.startsWith("__layout_breaking:"))) {
47
+ return value;
48
+ }
49
+ return Math.max(
50
+ 1,
51
+ ...input.graph.edges
52
+ .filter((edge) => edge.sourceId === id || edge.targetId === id)
53
+ .map((edge) => Number(input.edgeSettings?.(edge)?.["edge.thickness"] ?? 1)),
54
+ );
55
+ }
56
+
57
+ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
58
+ const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
59
+ const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
60
+ const layerIndex = new Map<string, number>();
61
+ const nodeIndex = new Map<string, number>();
62
+ order.layers.forEach((layer, layerNo) =>
63
+ layer.forEach((id, index) => {
64
+ layerIndex.set(id, layerNo);
65
+ nodeIndex.set(id, index);
66
+ }),
67
+ );
68
+ const left = new Map<string, Neighbor[]>();
69
+ const right = new Map<string, Neighbor[]>();
70
+ const anchor = new Map<string, number>();
71
+ const edgeIdByNodePair = new Map<string, string>();
72
+ const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
73
+ for (const id of layerIndex.keys()) {
74
+ left.set(id, []);
75
+ right.set(id, []);
76
+ }
77
+ for (const edge of input.graph.edges) {
78
+ const forwardPair = `${edge.sourceId}\0${edge.targetId}`;
79
+ const reversePair = `${edge.targetId}\0${edge.sourceId}`;
80
+ if (!edgeIdByNodePair.has(forwardPair)) edgeIdByNodePair.set(forwardPair, edge.id);
81
+ if (!edgeIdByNodePair.has(reversePair)) edgeIdByNodePair.set(reversePair, edge.id);
82
+ const sourceLayer = layerIndex.get(edge.sourceId);
83
+ const targetLayer = layerIndex.get(edge.targetId);
84
+ if (sourceLayer === undefined || targetLayer === undefined || sourceLayer === targetLayer)
85
+ continue;
86
+ const lower = sourceLayer < targetLayer ? edge.sourceId : edge.targetId;
87
+ const upper = sourceLayer < targetLayer ? edge.targetId : edge.sourceId;
88
+ right.get(lower)?.push({ id: upper, edgeId: edge.id });
89
+ left.get(upper)?.push({ id: lower, edgeId: edge.id });
90
+ }
91
+ for (const neighbors of [...left.values(), ...right.values()]) {
92
+ neighbors.sort((a, b) => (nodeIndex.get(a.id) ?? 0) - (nodeIndex.get(b.id) ?? 0));
93
+ }
94
+ const useLayerOrderPorts =
95
+ (input.settings["layerUnzipping.strategy"] ?? "NONE") === "ALTERNATING";
96
+ const hasCenteredPorts = (id: string): boolean => {
97
+ const node = nodeById.get(id);
98
+ return (
99
+ input.settings.mergeEdges === true ||
100
+ (node !== undefined && input.nodeSettings?.(node)?.hypernode === true)
101
+ );
102
+ };
103
+ const portAnchor = (
104
+ id: string,
105
+ index: number,
106
+ count: number,
107
+ side: "north" | "south" | "west" | "east",
108
+ ): number => {
109
+ const size = anchorCrossSize(input, id);
110
+ if (hasCenteredPorts(id)) return size / 2;
111
+ const node = nodeById.get(id);
112
+ const settings = node === undefined ? undefined : input.nodeSettings?.(node);
113
+ const constraints = settings?.portConstraints;
114
+ if (constraints === "FIXED_RATIO" || constraints === "FIXED_POS") return 0;
115
+ const alignment =
116
+ settings?.[`portAlignment.${side}`] ??
117
+ settings?.["portAlignment.default"] ??
118
+ (String(settings?.["nodeSize.options"] ?? "")
119
+ .split(/[\s,;]+/)
120
+ .includes("PORTS_OVERHANG")
121
+ ? "CENTER"
122
+ : undefined);
123
+ const spacing = Number(input.settings["spacing.portPort"] ?? 10);
124
+ const surrounding = (
125
+ id.startsWith("__layout_dummy:") ? {} : (input.settings["spacing.portsSurrounding"] ?? {})
126
+ ) as Partial<Record<"top" | "right" | "bottom" | "left", number>>;
127
+ const startValue = side === "north" || side === "south" ? surrounding.left : surrounding.top;
128
+ const endValue = side === "north" || side === "south" ? surrounding.right : surrounding.bottom;
129
+ const start = Math.max(0, Number(startValue ?? 0) - (Number(startValue ?? 0) > 0 ? 1 : 0));
130
+ const end = Math.max(0, Number(endValue ?? 0) - (Number(endValue ?? 0) > 0 ? 1 : 0));
131
+ const available = Math.max(0, size - start - end);
132
+ if (alignment === "BEGIN") return start + index * spacing;
133
+ if (alignment === "END") return start + available - (count - index - 1) * spacing;
134
+ if (alignment === "CENTER")
135
+ return start + (available - (count - 1) * spacing) / 2 + index * spacing;
136
+ if (alignment === "JUSTIFIED")
137
+ return count === 1 ? start + available / 2 : start + (index * available) / (count - 1);
138
+ return start + (available * (index + 1)) / (count + 1);
139
+ };
140
+ const explicitPortAnchor = (id: string, edgeId: string): number | undefined => {
141
+ const node = nodeById.get(id);
142
+ const edge = edgeById.get(edgeId);
143
+ if (!node || !edge) return undefined;
144
+ const portName = edge.sourceId === id ? edge.sourcePort : edge.targetPort;
145
+ if (portName === undefined) return undefined;
146
+ const port = node.ports?.find((candidate) => candidate.name === portName);
147
+ if (!port) return undefined;
148
+ const size = input.sizes.get(id) ?? { width: 0, height: 0 };
149
+ const placed = placePorts(
150
+ node.ports,
151
+ { x: 0, y: 0, ...size },
152
+ input.direction,
153
+ (candidate) => input.portSettings?.(candidate, node),
154
+ { ...input.settings, ...input.nodeSettings?.(node) },
155
+ )?.find((candidate) => candidate.name === portName);
156
+ if (!placed) return undefined;
157
+ const anchor = input.portSettings?.(port, node)?.["port.anchor"] as
158
+ | { x?: number; y?: number }
159
+ | undefined;
160
+ const constraints = input.nodeSettings?.(node)?.portConstraints;
161
+ if (anchor === undefined && constraints !== "FIXED_RATIO" && constraints !== "FIXED_POS") {
162
+ return undefined;
163
+ }
164
+ if (input.direction === "left" || input.direction === "right") {
165
+ const y = placed.y ?? 0;
166
+ const height = placed.height ?? 0;
167
+ const defaultAnchor = y >= size.height ? height : y + height <= 0 ? 0 : height / 2;
168
+ return y + (anchor?.y ?? defaultAnchor);
169
+ }
170
+ const x = placed.x ?? 0;
171
+ const width = placed.width ?? 0;
172
+ const defaultAnchor = x >= size.width ? width : x + width <= 0 ? 0 : width / 2;
173
+ return x + (anchor?.x ?? defaultAnchor);
174
+ };
175
+ const afterSide =
176
+ input.direction === "right"
177
+ ? "east"
178
+ : input.direction === "left"
179
+ ? "west"
180
+ : input.direction === "down"
181
+ ? "south"
182
+ : "north";
183
+ const beforeSide =
184
+ input.direction === "right"
185
+ ? "west"
186
+ : input.direction === "left"
187
+ ? "east"
188
+ : input.direction === "down"
189
+ ? "north"
190
+ : "south";
191
+ const customPortAnchors =
192
+ input.nodeSettings !== undefined ||
193
+ input.settings.mergeEdges === true ||
194
+ input.settings["spacing.portPort"] !== undefined ||
195
+ input.settings["spacing.portsSurrounding"] !== undefined ||
196
+ input.graph.nodes.some((node) => (node.ports?.length ?? 0) > 0);
197
+ const neighborAnchor = (
198
+ id: string,
199
+ edgeId: string,
200
+ index: number,
201
+ count: number,
202
+ side: "north" | "south" | "west" | "east",
203
+ ): number =>
204
+ input.settings.hierarchyHandling === "INCLUDE_CHILDREN" || !customPortAnchors
205
+ ? (anchorCrossSize(input, id) * (index + 1)) / (count + 1)
206
+ : (explicitPortAnchor(id, edgeId) ?? portAnchor(id, index, count, side));
207
+ for (const [id, entries] of right) {
208
+ const sweptOrder = order.outputPortOrderByNodeId?.get(id);
209
+ const interactiveForwardLongEdgeSource =
210
+ input.direction !== "left" &&
211
+ input.settings["crossingMinimization.strategy"] === "INTERACTIVE" &&
212
+ entries.some((entry) => entry.id.startsWith("__layout_dummy:"));
213
+ const portOrder =
214
+ sweptOrder !== undefined
215
+ ? [...entries].sort(
216
+ (leftEntry, rightEntry) =>
217
+ sweptOrder.indexOf(leftEntry.edgeId) - sweptOrder.indexOf(rightEntry.edgeId),
218
+ )
219
+ : interactiveForwardLongEdgeSource
220
+ ? [...entries].sort((leftEntry, rightEntry) => {
221
+ const leftDummy = leftEntry.id.startsWith("__layout_dummy:");
222
+ const rightDummy = rightEntry.id.startsWith("__layout_dummy:");
223
+ return (
224
+ Number(leftDummy) - Number(rightDummy) ||
225
+ (nodeIndex.get(leftEntry.id) ?? 0) - (nodeIndex.get(rightEntry.id) ?? 0)
226
+ );
227
+ })
228
+ : useLayerOrderPorts || entries.some((entry) => entry.id.startsWith("__layout_dummy:"))
229
+ ? entries
230
+ : [...entries].sort(
231
+ (leftEntry, rightEntry) =>
232
+ (edgeModelOrder.get(leftEntry.edgeId) ?? 0) -
233
+ (edgeModelOrder.get(rightEntry.edgeId) ?? 0),
234
+ );
235
+ portOrder.forEach((entry, index) => {
236
+ anchor.set(
237
+ `${entry.edgeId}:${id}`,
238
+ neighborAnchor(id, entry.edgeId, index, entries.length, afterSide),
239
+ );
240
+ });
241
+ }
242
+ for (const [id, entries] of left) {
243
+ const sweptOrder = order.inputPortOrderByNodeId?.get(id);
244
+ const interactiveForwardTargetOrder =
245
+ (input.direction === "up" || input.direction === "right") &&
246
+ input.settings["crossingMinimization.strategy"] === "INTERACTIVE";
247
+ const portOrder =
248
+ sweptOrder !== undefined
249
+ ? [...entries].sort(
250
+ (leftEntry, rightEntry) =>
251
+ sweptOrder.indexOf(rightEntry.edgeId) - sweptOrder.indexOf(leftEntry.edgeId),
252
+ )
253
+ : interactiveForwardTargetOrder
254
+ ? [...entries].sort((leftEntry, rightEntry) => {
255
+ const leftDummy = leftEntry.id.startsWith("__layout_dummy:");
256
+ const rightDummy = rightEntry.id.startsWith("__layout_dummy:");
257
+ return (
258
+ Number(leftDummy) - Number(rightDummy) ||
259
+ (edgeModelOrder.get(leftEntry.edgeId) ?? 0) -
260
+ (edgeModelOrder.get(rightEntry.edgeId) ?? 0)
261
+ );
262
+ })
263
+ : useLayerOrderPorts || entries.some((entry) => entry.id.startsWith("__layout_dummy:"))
264
+ ? entries
265
+ : [...entries].sort(
266
+ (leftEntry, rightEntry) =>
267
+ (edgeModelOrder.get(rightEntry.edgeId) ?? 0) -
268
+ (edgeModelOrder.get(leftEntry.edgeId) ?? 0),
269
+ );
270
+ portOrder.forEach((entry, index) => {
271
+ anchor.set(
272
+ `${entry.edgeId}:${id}`,
273
+ neighborAnchor(id, entry.edgeId, index, entries.length, beforeSide),
274
+ );
275
+ });
276
+ }
277
+ return { layerIndex, nodeIndex, left, right, anchor, edgeIdByNodePair };
278
+ }
279
+
280
+ function makeAlignment(hdir: HDirection, vdir: VDirection): Alignment {
281
+ return {
282
+ hdir,
283
+ vdir,
284
+ root: new Map(),
285
+ align: new Map(),
286
+ innerShift: new Map(),
287
+ blockSize: new Map(),
288
+ sink: new Map(),
289
+ shift: new Map(),
290
+ y: new Map(),
291
+ };
292
+ }
293
+
294
+ function markConflicts(
295
+ order: LayerOrder,
296
+ neighbors: ReturnType<typeof buildNeighbors>,
297
+ ): Set<string> {
298
+ const marked = new Set<string>();
299
+ if (order.layers.length < 3) return marked;
300
+ const isInner = (id: string): boolean =>
301
+ id.startsWith("__layout_dummy:") &&
302
+ (neighbors.left.get(id) ?? []).some((neighbor) => neighbor.id.startsWith("__layout_dummy:"));
303
+ for (let previousLayerNo = 1; previousLayerNo + 1 < order.layers.length; previousLayerNo++) {
304
+ const previousLayer = order.layers[previousLayerNo]!;
305
+ const currentLayer = order.layers[previousLayerNo + 1]!;
306
+ let lowerBound = 0;
307
+ let scan = 0;
308
+ for (let currentIndex = 0; currentIndex < currentLayer.length; currentIndex++) {
309
+ const id = currentLayer[currentIndex]!;
310
+ const inner = isInner(id);
311
+ if (currentIndex !== currentLayer.length - 1 && !inner) continue;
312
+ const upperBound = inner
313
+ ? (neighbors.nodeIndex.get(neighbors.left.get(id)?.[0]?.id ?? "") ??
314
+ previousLayer.length - 1)
315
+ : previousLayer.length - 1;
316
+ while (scan <= currentIndex) {
317
+ const scannedId = currentLayer[scan++]!;
318
+ if (isInner(scannedId)) continue;
319
+ for (const neighbor of neighbors.left.get(scannedId) ?? []) {
320
+ const index = neighbors.nodeIndex.get(neighbor.id) ?? 0;
321
+ if (index < lowerBound || index > upperBound) marked.add(neighbor.edgeId);
322
+ }
323
+ }
324
+ lowerBound = upperBound;
325
+ }
326
+ }
327
+ return marked;
328
+ }
329
+
330
+ function alignBlocks(
331
+ input: LayeredPhaseInput,
332
+ order: LayerOrder,
333
+ bal: Alignment,
334
+ neighbors: ReturnType<typeof buildNeighbors>,
335
+ markedEdges: ReadonlySet<string>,
336
+ ): void {
337
+ for (const layer of order.layers) {
338
+ for (const id of layer) {
339
+ bal.root.set(id, id);
340
+ bal.align.set(id, id);
341
+ bal.innerShift.set(id, 0);
342
+ }
343
+ }
344
+ const layers = bal.hdir === "LEFT" ? [...order.layers].reverse() : order.layers;
345
+ for (const originalLayer of layers) {
346
+ let r = bal.vdir === "UP" ? Number.POSITIVE_INFINITY : -1;
347
+ const layer = bal.vdir === "UP" ? [...originalLayer].reverse() : originalLayer;
348
+ for (const id of layer) {
349
+ const adjacent = (bal.hdir === "LEFT" ? neighbors.right : neighbors.left).get(id) ?? [];
350
+ const low = Math.floor((adjacent.length + 1) / 2) - 1;
351
+ const high = Math.ceil((adjacent.length + 1) / 2) - 1;
352
+ const indices =
353
+ bal.vdir === "UP"
354
+ ? Array.from({ length: Math.max(0, high - low + 1) }, (_, i) => high - i)
355
+ : Array.from({ length: Math.max(0, high - low + 1) }, (_, i) => low + i);
356
+ for (const median of indices) {
357
+ if (bal.align.get(id) !== id) break;
358
+ const neighbor = adjacent[median];
359
+ if (neighbor === undefined) continue;
360
+ if (markedEdges.has(neighbor.edgeId)) continue;
361
+ const index = neighbors.nodeIndex.get(neighbor.id) ?? 0;
362
+ if ((bal.vdir === "UP" && r > index) || (bal.vdir === "DOWN" && r < index)) {
363
+ bal.align.set(neighbor.id, id);
364
+ bal.root.set(id, bal.root.get(neighbor.id) ?? neighbor.id);
365
+ bal.align.set(id, bal.root.get(id) ?? id);
366
+ r = index;
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ const roots = new Set(bal.root.values());
373
+ for (const root of roots) {
374
+ let above = 0;
375
+ let below = crossSize(input, root);
376
+ bal.innerShift.set(root, 0);
377
+ let current = root;
378
+ let next = bal.align.get(current) ?? root;
379
+ while (next !== root) {
380
+ const edgeId = neighbors.edgeIdByNodePair.get(`${current}\0${next}`);
381
+ const currentAnchor = edgeId
382
+ ? (neighbors.anchor.get(`${edgeId}:${current}`) ?? anchorCrossSize(input, current) / 2)
383
+ : anchorCrossSize(input, current) / 2;
384
+ const nextAnchor = edgeId
385
+ ? (neighbors.anchor.get(`${edgeId}:${next}`) ?? anchorCrossSize(input, next) / 2)
386
+ : anchorCrossSize(input, next) / 2;
387
+ const nextShift = (bal.innerShift.get(current) ?? 0) + currentAnchor - nextAnchor;
388
+ bal.innerShift.set(next, nextShift);
389
+ above = Math.max(above, -nextShift);
390
+ below = Math.max(below, nextShift + crossSize(input, next));
391
+ current = next;
392
+ next = bal.align.get(current) ?? root;
393
+ }
394
+ current = root;
395
+ do {
396
+ bal.innerShift.set(current, (bal.innerShift.get(current) ?? 0) + above);
397
+ current = bal.align.get(current) ?? root;
398
+ } while (current !== root);
399
+ bal.blockSize.set(root, above + below);
400
+ }
401
+ }
402
+
403
+ function compact(
404
+ input: LayeredPhaseInput,
405
+ order: LayerOrder,
406
+ bal: Alignment,
407
+ neighbors: ReturnType<typeof buildNeighbors>,
408
+ ): void {
409
+ for (const layer of order.layers) {
410
+ for (const id of layer) {
411
+ bal.sink.set(id, id);
412
+ bal.shift.set(id, bal.vdir === "UP" ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY);
413
+ }
414
+ }
415
+ const classEdges = new Map<string, Array<{ target: string; separation: number }>>();
416
+ const classIndegree = new Map<string, number>();
417
+ const addClassEdge = (source: string, target: string, separation: number): void => {
418
+ const edges = classEdges.get(source) ?? [];
419
+ edges.push({ target, separation });
420
+ classEdges.set(source, edges);
421
+ if (!classEdges.has(target)) classEdges.set(target, []);
422
+ classIndegree.set(source, classIndegree.get(source) ?? 0);
423
+ classIndegree.set(target, (classIndegree.get(target) ?? 0) + 1);
424
+ };
425
+
426
+ const placeBlock = (root: string): void => {
427
+ if (bal.y.has(root)) return;
428
+ bal.y.set(root, 0);
429
+ let assigned = false;
430
+ let current = root;
431
+ do {
432
+ const layerNo = neighbors.layerIndex.get(current) ?? 0;
433
+ const layer = order.layers[layerNo] ?? [];
434
+ const index = neighbors.nodeIndex.get(current) ?? 0;
435
+ const neighbor = bal.vdir === "UP" ? layer[index + 1] : layer[index - 1];
436
+ if (neighbor !== undefined) {
437
+ const neighborRoot = bal.root.get(neighbor) ?? neighbor;
438
+ placeBlock(neighborRoot);
439
+ if (bal.sink.get(root) === root)
440
+ bal.sink.set(root, bal.sink.get(neighborRoot) ?? neighborRoot);
441
+ const rootSink = bal.sink.get(root) ?? root;
442
+ const neighborSink = bal.sink.get(neighborRoot) ?? neighborRoot;
443
+ const spacing = nodeNodeSpacing(input, current, neighbor);
444
+ if (rootSink === neighborSink) {
445
+ const candidate =
446
+ bal.vdir === "UP"
447
+ ? (bal.y.get(neighborRoot) ?? 0) +
448
+ (bal.innerShift.get(neighbor) ?? 0) -
449
+ spacing -
450
+ crossSize(input, current) -
451
+ (bal.innerShift.get(current) ?? 0)
452
+ : (bal.y.get(neighborRoot) ?? 0) +
453
+ (bal.innerShift.get(neighbor) ?? 0) +
454
+ crossSize(input, neighbor) +
455
+ spacing -
456
+ (bal.innerShift.get(current) ?? 0);
457
+ bal.y.set(
458
+ root,
459
+ assigned
460
+ ? bal.vdir === "UP"
461
+ ? Math.min(bal.y.get(root) ?? 0, candidate)
462
+ : Math.max(bal.y.get(root) ?? 0, candidate)
463
+ : candidate,
464
+ );
465
+ assigned = true;
466
+ } else {
467
+ // ELK deliberately uses the global node-node spacing when it builds
468
+ // the class graph. Type-specific spacing only applies within a class.
469
+ const classSpacing = input.spacing.node;
470
+ const separation =
471
+ bal.vdir === "UP"
472
+ ? (bal.y.get(root) ?? 0) +
473
+ (bal.innerShift.get(current) ?? 0) +
474
+ crossSize(input, current) +
475
+ classSpacing -
476
+ (bal.y.get(neighborRoot) ?? 0) -
477
+ (bal.innerShift.get(neighbor) ?? 0)
478
+ : (bal.y.get(root) ?? 0) +
479
+ (bal.innerShift.get(current) ?? 0) -
480
+ (bal.y.get(neighborRoot) ?? 0) -
481
+ (bal.innerShift.get(neighbor) ?? 0) -
482
+ crossSize(input, neighbor) -
483
+ classSpacing;
484
+ addClassEdge(rootSink, neighborSink, separation);
485
+ }
486
+ }
487
+ current = bal.align.get(current) ?? root;
488
+ } while (current !== root);
489
+ };
490
+
491
+ const layers = bal.hdir === "LEFT" ? [...order.layers].reverse() : order.layers;
492
+ for (const originalLayer of layers) {
493
+ const layer = bal.vdir === "UP" ? [...originalLayer].reverse() : originalLayer;
494
+ for (const id of layer) if (bal.root.get(id) === id) placeBlock(id);
495
+ }
496
+ const classShift = new Map<string, number>();
497
+ const queue = [...classEdges.keys()].filter((id) => (classIndegree.get(id) ?? 0) === 0);
498
+ for (let index = 0; index < queue.length; index++) {
499
+ const source = queue[index]!;
500
+ const sourceShift = classShift.get(source) ?? 0;
501
+ classShift.set(source, sourceShift);
502
+ for (const edge of classEdges.get(source) ?? []) {
503
+ const candidate = sourceShift + edge.separation;
504
+ const previous = classShift.get(edge.target);
505
+ classShift.set(
506
+ edge.target,
507
+ previous === undefined
508
+ ? candidate
509
+ : bal.vdir === "DOWN"
510
+ ? Math.min(previous, candidate)
511
+ : Math.max(previous, candidate),
512
+ );
513
+ const indegree = (classIndegree.get(edge.target) ?? 1) - 1;
514
+ classIndegree.set(edge.target, indegree);
515
+ if (indegree === 0) queue.push(edge.target);
516
+ }
517
+ }
518
+ for (const [id, shift] of classShift) bal.shift.set(id, shift);
519
+ const rootPositions = new Map(bal.y);
520
+ for (const layer of layers) {
521
+ for (const id of layer) {
522
+ const root = bal.root.get(id) ?? id;
523
+ let value = rootPositions.get(root) ?? 0;
524
+ const sinkShift = bal.shift.get(bal.sink.get(root) ?? root);
525
+ if (sinkShift !== undefined && Number.isFinite(sinkShift)) value += sinkShift;
526
+ bal.y.set(id, value + (bal.innerShift.get(id) ?? 0));
527
+ }
528
+ }
529
+ }
530
+
531
+ function extent(input: LayeredPhaseInput, bal: Alignment): [number, number] {
532
+ let min = Number.POSITIVE_INFINITY;
533
+ let max = Number.NEGATIVE_INFINITY;
534
+ for (const [id, value] of bal.y) {
535
+ min = Math.min(min, value);
536
+ max = Math.max(max, value + crossSize(input, id));
537
+ }
538
+ return [min, max];
539
+ }
540
+
541
+ function preservesLayerOrder(
542
+ input: LayeredPhaseInput,
543
+ order: LayerOrder,
544
+ positions: ReadonlyMap<string, number>,
545
+ ): boolean {
546
+ for (const layer of order.layers) {
547
+ let previousEnd = Number.NEGATIVE_INFINITY;
548
+ for (const id of layer) {
549
+ const start = positions.get(id) ?? 0;
550
+ const end = start + crossSize(input, id);
551
+ if (start <= previousEnd || end <= previousEnd) return false;
552
+ previousEnd = end;
553
+ }
554
+ }
555
+ return true;
556
+ }
557
+
558
+ function improveEdgeStraightness(
559
+ input: LayeredPhaseInput,
560
+ order: LayerOrder,
561
+ bal: Alignment,
562
+ neighbors: ReturnType<typeof buildNeighbors>,
563
+ ): void {
564
+ if (
565
+ (input.settings["nodePlacement.bk.edgeStraightening"] ?? "IMPROVE_STRAIGHTNESS") !==
566
+ "IMPROVE_STRAIGHTNESS"
567
+ ) {
568
+ return;
569
+ }
570
+ const lockedRoots = new Set<string>();
571
+ const nodesByRoot = new Map<string, string[]>();
572
+ for (const id of neighbors.layerIndex.keys()) {
573
+ const root = bal.root.get(id) ?? id;
574
+ const nodes = nodesByRoot.get(root) ?? [];
575
+ nodes.push(id);
576
+ nodesByRoot.set(root, nodes);
577
+ }
578
+ const canShift = (root: string, delta: number): boolean =>
579
+ (nodesByRoot.get(root) ?? []).every((id) => {
580
+ const candidate = (bal.y.get(id) ?? 0) + delta;
581
+ const end = candidate + crossSize(input, id);
582
+ const layer = order.layers[neighbors.layerIndex.get(id) ?? 0] ?? [];
583
+ return layer.every((otherId) => {
584
+ if ((bal.root.get(otherId) ?? otherId) === root) return true;
585
+ const other = bal.y.get(otherId) ?? 0;
586
+ const spacing = nodeNodeSpacing(input, id, otherId);
587
+ return end + spacing <= other || other + crossSize(input, otherId) + spacing <= candidate;
588
+ });
589
+ });
590
+ const layers = bal.hdir === "LEFT" ? [...order.layers].reverse() : order.layers;
591
+ for (const layer of layers) {
592
+ const nodes = bal.vdir === "UP" ? [...layer].reverse() : layer;
593
+ for (const [traversalIndex, id] of nodes.entries()) {
594
+ if (traversalIndex === 0) continue;
595
+ const root = bal.root.get(id) ?? id;
596
+ if (lockedRoots.has(root) || root !== id) continue;
597
+ const candidateEdges = input.graph.edges.filter((edge) =>
598
+ bal.hdir === "RIGHT" ? edge.targetId === id : edge.sourceId === id,
599
+ );
600
+ const edge = candidateEdges.find((candidate) => {
601
+ const otherId = candidate.sourceId === id ? candidate.targetId : candidate.sourceId;
602
+ return !lockedRoots.has(bal.root.get(otherId) ?? otherId);
603
+ });
604
+ if (!edge) continue;
605
+ const otherId = edge.sourceId === id ? edge.targetId : edge.sourceId;
606
+ const currentAnchor = neighbors.anchor.get(`${edge.id}:${id}`) ?? crossSize(input, id) / 2;
607
+ const otherAnchor =
608
+ neighbors.anchor.get(`${edge.id}:${otherId}`) ?? crossSize(input, otherId) / 2;
609
+ const desired = (bal.y.get(otherId) ?? 0) + otherAnchor - currentAnchor;
610
+ const delta = desired - (bal.y.get(id) ?? 0);
611
+ if (
612
+ ((bal.vdir === "DOWN" && delta > 0) || (bal.vdir === "UP" && delta < 0)) &&
613
+ canShift(root, delta)
614
+ ) {
615
+ for (const blockNode of nodesByRoot.get(root) ?? []) {
616
+ bal.y.set(blockNode, (bal.y.get(blockNode) ?? 0) + delta);
617
+ }
618
+ lockedRoots.add(root);
619
+ lockedRoots.add(bal.root.get(otherId) ?? otherId);
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ /** ELK-compatible Brandes-Koepf placement for implicit center ports. */
626
+ export function placeNodesWithBrandesKoepf(
627
+ input: LayeredPhaseInput,
628
+ order: LayerOrder,
629
+ ): NodePlacement {
630
+ const base = placeNodesInLayers(input, order);
631
+ const neighbors = buildNeighbors(input, order);
632
+ const markedEdges = markConflicts(order, neighbors);
633
+ const fixed = String(input.settings["nodePlacement.bk.fixedAlignment"] ?? "NONE");
634
+ const requested: Array<[HDirection, VDirection]> =
635
+ fixed === "LEFTDOWN"
636
+ ? [["LEFT", "DOWN"]]
637
+ : fixed === "LEFTUP"
638
+ ? [["LEFT", "UP"]]
639
+ : fixed === "RIGHTDOWN"
640
+ ? [["RIGHT", "DOWN"]]
641
+ : fixed === "RIGHTUP"
642
+ ? [["RIGHT", "UP"]]
643
+ : [
644
+ ["RIGHT", "DOWN"],
645
+ ["RIGHT", "UP"],
646
+ ["LEFT", "DOWN"],
647
+ ["LEFT", "UP"],
648
+ ];
649
+ const layouts = requested.map(([hdir, vdir]) => {
650
+ const layout = makeAlignment(hdir, vdir);
651
+ alignBlocks(input, order, layout, neighbors, markedEdges);
652
+ compact(input, order, layout, neighbors);
653
+ improveEdgeStraightness(input, order, layout, neighbors);
654
+ return layout;
655
+ });
656
+ const smallestFeasibleLayout = (): Alignment => {
657
+ let chosen: Alignment | undefined;
658
+ for (const layout of layouts) {
659
+ if (!preservesLayerOrder(input, order, layout.y)) continue;
660
+ if (chosen === undefined) {
661
+ chosen = layout;
662
+ continue;
663
+ }
664
+ const [min, max] = extent(input, layout);
665
+ const [chosenMin, chosenMax] = extent(input, chosen);
666
+ if (max - min < chosenMax - chosenMin) chosen = layout;
667
+ }
668
+ return chosen ?? layouts[0]!;
669
+ };
670
+
671
+ let positions: Map<string, number>;
672
+ const favorStraight =
673
+ input.settings["nodePlacement.favorStraightEdges"] === undefined
674
+ ? (input.settings.edgeRouting ?? "ORTHOGONAL") === "ORTHOGONAL"
675
+ : Boolean(input.settings["nodePlacement.favorStraightEdges"]);
676
+ if (layouts.length === 4 && (fixed === "BALANCED" || (fixed === "NONE" && !favorStraight))) {
677
+ const extents = layouts.map((layout) => extent(input, layout));
678
+ let smallest = 0;
679
+ for (let i = 1; i < layouts.length; i++) {
680
+ if (extents[i]![1] - extents[i]![0] < extents[smallest]![1] - extents[smallest]![0])
681
+ smallest = i;
682
+ }
683
+ const shifts = layouts.map((layout, index) =>
684
+ layout.vdir === "DOWN"
685
+ ? extents[smallest]![0] - extents[index]![0]
686
+ : extents[smallest]![1] - extents[index]![1],
687
+ );
688
+ const balanced = new Map<string, number>();
689
+ for (const id of neighbors.layerIndex.keys()) {
690
+ const values = layouts
691
+ .map((layout, index) => (layout.y.get(id) ?? 0) + shifts[index]!)
692
+ .sort((a, b) => a - b);
693
+ balanced.set(id, (values[1]! + values[2]!) / 2);
694
+ }
695
+ positions = preservesLayerOrder(input, order, balanced) ? balanced : smallestFeasibleLayout().y;
696
+ } else {
697
+ positions = smallestFeasibleLayout().y;
698
+ }
699
+ const minimum = Math.min(...positions.values());
700
+ const crossPadding =
701
+ input.direction === "left" || input.direction === "right"
702
+ ? input.padding.top
703
+ : input.padding.left;
704
+ const rectByNodeId = new Map<string, EntityRect>();
705
+ for (const [id, rect] of base.rectByNodeId) {
706
+ const cross = (positions.get(id) ?? 0) - minimum + crossPadding;
707
+ rectByNodeId.set(
708
+ id,
709
+ input.direction === "left" || input.direction === "right"
710
+ ? { ...rect, y: cross }
711
+ : { ...rect, x: cross },
712
+ );
713
+ }
714
+ return { rectByNodeId };
715
+ }