@statelyai/layout 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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-UciYx7lD.mjs +2973 -0
  12. package/dist/elkjs-ggjsBuVP.cjs +3002 -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-DGFTeQSl.mjs → layered-C7ERzZjR.mjs} +9605 -1655
  26. package/dist/{layered-B2buJqMf.cjs → layered-CBPg6_6_.cjs} +12481 -4435
  27. package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  28. package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-DC3IYkIy.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 +1392 -449
  41. package/src/elkjs/layout-quality.ts +219 -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 +221 -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 +83 -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 +886 -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 +944 -0
  89. package/src/layered/orthogonal-cycle-order.ts +106 -0
  90. package/src/layered/orthogonal-hypersegments.ts +92 -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 +2024 -883
  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
@@ -1,3 +1,28 @@
1
+ import { hierarchicalPortSides } from "../layered/hierarchical-port-phases";
2
+ import { isPerpendicularPortRestored } from "../layered/hierarchical-port-restoration";
3
+ import { useBottomUpHierarchySweep } from "../layered/hierarchy-sweepiness";
4
+ import {
5
+ createLayeredScopePipeline,
6
+ type LayeredCrossingPhase,
7
+ type LayeredLayoutOptions,
8
+ type LayerOrder,
9
+ } from "../layered";
10
+ import { createLayerSweepSession } from "../layered/strategies";
11
+ import {
12
+ minimizeHierarchyCrossings,
13
+ type HierarchyCrossingScope,
14
+ } from "../layered/hierarchy-crossing";
15
+ import { transferExternalPort, joinCompoundRouteSegments } from "../layered/compound-boundaries";
16
+ import {
17
+ attachExternalPortDummy,
18
+ createExternalPortDummy,
19
+ externalPortDummyOf,
20
+ type ExternalPortConstraints,
21
+ type ExternalPortSide,
22
+ } from "../layered/external-port-dummy";
23
+ import { isBetterLayout, measureLayout } from "./layout-quality";
24
+ import { applyOrthogonalJunctions } from "./orthogonal-junctions";
25
+ import { separateElkOpposingTracks } from "./opposing-tracks";
1
26
  import { createGraph, type Graph, type VisualGraph } from "@statelyai/graph";
2
27
  import {
3
28
  elkLayeredOptionDefinitions,
@@ -28,6 +53,7 @@ import type {
28
53
  } from "./public-types";
29
54
 
30
55
  function isLayoutEdgeLabel(label: ElkLabel): boolean {
56
+ // Unlike ELK, a dimensioned label without text still reserves space: apps often render the text themselves.
31
57
  return (
32
58
  getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true &&
33
59
  (Boolean(label.text) || label.width !== undefined || label.height !== undefined)
@@ -57,6 +83,15 @@ export type {
57
83
  } from "./public-types";
58
84
  export type { ElkId, ElkLogging } from "./types";
59
85
 
86
+ interface PreparedElkScope {
87
+ graph: ElkNode;
88
+ native: Graph;
89
+ phase?: LayeredCrossingPhase;
90
+ options?: LayeredLayoutOptions;
91
+ children: ReadonlyMap<string, PreparedElkScope>;
92
+ finish(orders?: ReadonlyMap<PreparedElkScope, LayerOrder>): Promise<ElkNode>;
93
+ }
94
+
60
95
  export default class ELK {
61
96
  readonly #options: ElkConstructorArguments;
62
97
  readonly #algorithmIds: ReadonlySet<string>;
@@ -128,17 +163,74 @@ export default class ELK {
128
163
  arguments_?: PublicElkLayoutArguments,
129
164
  ): Promise<PublicElkNode & PublicLaidOutElkNode<T>>;
130
165
  layout<T extends ElkNode>(graph: T, arguments_?: ElkLayoutArguments): Promise<LaidOutElkNode<T>>;
131
- layout<T extends ElkNode>(
166
+ async layout<T extends ElkNode>(
132
167
  graph: T,
133
168
  arguments_: ElkLayoutArguments = {},
134
169
  ): Promise<LaidOutElkNode<T>> {
135
- return this.#layout(graph, arguments_);
170
+ const options = { ...this.#options.defaultLayoutOptions, ...arguments_.layoutOptions };
171
+ const variants = crossingVariants(options, graph);
172
+ const pristine = variants.length ? structuredClone(graph) : undefined;
173
+ let best = separated(await this.#layoutWithoutDefectiveCompaction(graph, arguments_), options);
174
+ if (!pristine) return best;
175
+ // A compound layout with defects tries other random seeds and keeps the
176
+ // best measured result. Above ELK's default thoroughness, every compound
177
+ // layout does, trading time for fewer crossings and bends.
178
+ let quality = measureLayout(best);
179
+ if (quality.defects === 0 && !thorough(options, graph)) return best;
180
+ for (const variant of variants) {
181
+ const candidate = separated(
182
+ await this.#layoutWithoutDefectiveCompaction(structuredClone(pristine), {
183
+ ...arguments_,
184
+ layoutOptions: { ...arguments_.layoutOptions, ...variant },
185
+ }),
186
+ options,
187
+ );
188
+ const candidateQuality = measureLayout(candidate);
189
+ if (isBetterLayout(candidateQuality, quality)) {
190
+ best = candidate;
191
+ quality = candidateQuality;
192
+ }
193
+ }
194
+ return best;
195
+ }
196
+
197
+ async #layoutWithoutDefectiveCompaction<T extends ElkNode>(
198
+ graph: T,
199
+ arguments_: ElkLayoutArguments,
200
+ ): Promise<LaidOutElkNode<T>> {
201
+ const compacting = usesPostCompaction(
202
+ { ...this.#options.defaultLayoutOptions, ...arguments_.layoutOptions },
203
+ graph,
204
+ );
205
+ const pristine = compacting ? structuredClone(graph) : undefined;
206
+ const result = await this.#layout(graph, arguments_);
207
+ const defects = pristine ? measureLayout(result).defects : 0;
208
+ if (!pristine || defects === 0) return result;
209
+ // Post-compaction is an optimization; it must not route edges through nodes
210
+ // or fold a route back onto itself.
211
+ const strategy = { "elk.layered.compaction.postCompaction.strategy": "NONE" };
212
+ const uncompacted = await this.#layout(disablePostCompaction(pristine), {
213
+ ...arguments_,
214
+ layoutOptions: { ...arguments_.layoutOptions, ...strategy },
215
+ });
216
+ return measureLayout(uncompacted).defects < defects ? uncompacted : result;
136
217
  }
137
218
 
138
219
  async #layout<T extends ElkNode>(
139
220
  graph: T,
140
221
  arguments_: ElkLayoutArguments = {},
222
+ compoundLayout = false,
141
223
  ): Promise<LaidOutElkNode<T>> {
224
+ const prepared = await this.#prepareLayout(graph, arguments_, compoundLayout);
225
+ const orders = coordinatePreparedScopes(prepared);
226
+ return (await prepared.finish(orders)) as LaidOutElkNode<T>;
227
+ }
228
+
229
+ async #prepareLayout<T extends ElkNode>(
230
+ graph: T,
231
+ arguments_: ElkLayoutArguments = {},
232
+ compoundLayout = false,
233
+ ): Promise<PreparedElkScope> {
142
234
  const startedAt = performance.now();
143
235
  if (graph === undefined || graph === null) {
144
236
  throw new TypeError("Missing mandatory parameter: graph");
@@ -201,6 +293,8 @@ export default class ELK {
201
293
  `org.eclipse.elk.core.UnsupportedConfigurationException: Layout algorithm '${requestedAlgorithm}' not found`,
202
294
  );
203
295
  }
296
+ const preparedChildren = new Map<string, PreparedElkScope>();
297
+ const finishChildren: Array<() => Promise<void>> = [];
204
298
  const hasHierarchy = (graph.children ?? []).some((child) => (child.children?.length ?? 0) > 0);
205
299
  const insideSelfLoopBaseHeightByNodeId = new Map<string, number>();
206
300
  const hierarchyRestorations: Array<{
@@ -210,10 +304,16 @@ export default class ELK {
210
304
  source?: ElkId;
211
305
  target?: ElkId;
212
306
  }> = [];
307
+ const boundaryRoutes = new Map<
308
+ ElkEdge,
309
+ {
310
+ source?: { owner: ElkNode; points: ElkPoint[]; junctionPoints?: ElkPoint[] };
311
+ target?: { owner: ElkNode; points: ElkPoint[]; junctionPoints?: ElkPoint[] };
312
+ }
313
+ >();
213
314
  const syntheticPortIds = new Set<string>();
214
315
  const authoredPortsByCompound = new Map<ElkNode, ElkPort[] | undefined>();
215
316
  const authoredOptionsByCompound = new Map<ElkNode, Record<string, unknown> | undefined>();
216
- const hierarchyBoundaryCountByEdge = new Map<ElkEdge, number>();
217
317
  const originalHierarchyEndpoints = new Map(
218
318
  (graph.edges ?? []).map((edge) => [
219
319
  edge,
@@ -227,7 +327,6 @@ export default class ELK {
227
327
  },
228
328
  ]),
229
329
  );
230
- let hasHierarchyCrossingEdges = false;
231
330
  const hierarchyHandling = getOption(layoutOptions, "hierarchyHandling");
232
331
  const topdownLayout = getBooleanOption(layoutOptions, "topdownLayout") === true;
233
332
  const separateHierarchy =
@@ -296,17 +395,15 @@ export default class ELK {
296
395
  );
297
396
  }
298
397
  } else if (hasHierarchy && !separateHierarchy) {
299
- // The probability only chooses between equivalent top-down and bottom-up
300
- // sweep schedules. The deterministic proxy decomposition below preserves
301
- // the resulting exported order for either schedule.
302
- void getNumberOption(layoutOptions, "layered.crossingMinimization.hierarchicalSweepiness");
398
+ // Prepare boundary identities before coordinating parent and child crossing sweeps.
399
+ const scopeSegmentOrder =
400
+ segmentOrderByScope.get(graph) ??
401
+ elkSegmentOrder(graph, graph, originalHierarchyEndpoints, false);
303
402
  for (const child of graph.children ?? []) {
304
403
  if ((child.children?.length ?? 0) === 0) continue;
305
- const descendantById = new Map<string, ElkNode>();
306
404
  const descendantOwnerByEndpointId = new Map<string, ElkNode>();
307
405
  const collectDescendants = (node: ElkNode): void => {
308
406
  for (const descendant of node.children ?? []) {
309
- descendantById.set(String(descendant.id), descendant);
310
407
  descendantOwnerByEndpointId.set(String(descendant.id), descendant);
311
408
  for (const port of descendant.ports ?? []) {
312
409
  descendantOwnerByEndpointId.set(String(port.id), descendant);
@@ -321,198 +418,611 @@ export default class ELK {
321
418
  descendantOwnerByEndpointId.has(sourceId) && descendantOwnerByEndpointId.has(targetId)
322
419
  );
323
420
  });
324
- const crossingEdges = (graph.edges ?? []).filter((edge) => {
421
+ for (const edge of child.edges ?? [])
422
+ if (!originalHierarchyEndpoints.has(edge))
423
+ originalHierarchyEndpoints.set(edge, {
424
+ sourceId: String(edge.sources?.[0] ?? edge.source),
425
+ targetId: String(edge.targets?.[0] ?? edge.target),
426
+ sources: edge.sources,
427
+ targets: edge.targets,
428
+ source: edge.source,
429
+ target: edge.target,
430
+ });
431
+ const crossingEdges = [...(graph.edges ?? []), ...(child.edges ?? [])].filter((edge) => {
325
432
  const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
326
433
  const sourceInside = descendantOwnerByEndpointId.has(sourceId);
327
434
  const targetInside = descendantOwnerByEndpointId.has(targetId);
328
435
  return sourceInside !== targetInside;
329
436
  });
330
- for (const edge of crossingEdges) {
331
- hierarchyBoundaryCountByEdge.set(edge, (hierarchyBoundaryCountByEdge.get(edge) ?? 0) + 1);
332
- }
333
- hasHierarchyCrossingEdges ||= crossingEdges.length > 0;
437
+ // CompoundGraphPreprocessor creates boundary ports bottom-up: ports
438
+ // exported by nested groups first, then direct children's ports in
439
+ // port order, outgoing edges before incoming edges.
440
+ const boundaryRank = new Map(
441
+ elkBoundaryEdgeOrder(child, graph, originalHierarchyEndpoints).map((edge, index) => [
442
+ edge,
443
+ index,
444
+ ]),
445
+ );
446
+ crossingEdges.sort(
447
+ (a, b) =>
448
+ (boundaryRank.get(a) ?? Number.MAX_SAFE_INTEGER) -
449
+ (boundaryRank.get(b) ?? Number.MAX_SAFE_INTEGER),
450
+ );
334
451
  const mergeHierarchyEdges =
335
452
  getBooleanOption(layoutOptions, "layered.mergeHierarchyEdges") !== false;
336
- // The option changes the number of internal external-port dummies. They
337
- // are removed before elkjs serialization and do not alter the exported
338
- // geometry for a shared hierarchy boundary.
339
- void mergeHierarchyEdges;
453
+ // ELK merges only edges incident to the same descendant port.
340
454
  const proxyByKind = new Map<string, ElkNode>();
341
- const proxyFor = (kind: "input" | "output"): ElkNode => {
342
- const key = kind;
455
+ const implicitOwnPorts = new Map<ElkEdge, ElkPort>();
456
+ const ownPortFor = (edge: ElkEdge): ElkPort | undefined => {
457
+ const e = originalHierarchyEndpoints.get(edge)!;
458
+ const outside = descendantOwnerByEndpointId.has(e.sourceId) ? e.targetId : e.sourceId;
459
+ if (e.sourceId === String(child.id) || e.targetId === String(child.id)) {
460
+ let port = implicitOwnPorts.get(edge);
461
+ if (!port) {
462
+ const direction = getDirection(layoutOptions);
463
+ const sourceInside = e.sourceId === String(child.id);
464
+ const side = sourceInside
465
+ ? ({ right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" } as const)[direction]
466
+ : ({ right: "WEST", left: "EAST", down: "NORTH", up: "SOUTH" } as const)[direction];
467
+ port = {
468
+ id: `__native_hierarchy_own_${String(child.id)}_${implicitOwnPorts.size}`,
469
+ width: 0,
470
+ height: 0,
471
+ layoutOptions: { "elk.port.side": side },
472
+ };
473
+ implicitOwnPorts.set(edge, port);
474
+ syntheticPortIds.add(String(port.id));
475
+ }
476
+ return child.ports?.find((p) => p.id === port!.id) ?? port;
477
+ }
478
+ return child.ports?.find((p) => String(p.id) === outside);
479
+ };
480
+
481
+ const proxyKey = (kind: "input" | "output", edge: ElkEdge): string => {
482
+ const own = ownPortFor(edge);
483
+ if (own) return JSON.stringify(["authored", own.id]);
484
+ const endpoints = originalHierarchyEndpoints.get(edge)!;
485
+ const endpoint = kind === "output" ? endpoints.sourceId : endpoints.targetId;
486
+ const owner = descendantOwnerByEndpointId.get(endpoint);
487
+ const explicitPort = owner?.ports?.some((port) => String(port.id) === endpoint);
488
+ return JSON.stringify([
489
+ kind,
490
+ mergeHierarchyEdges && explicitPort ? endpoint : String(edge.id),
491
+ ]);
492
+ };
493
+ const createBoundaryProxy = (
494
+ key: string,
495
+ kind: "input" | "output",
496
+ own: ElkPort | undefined,
497
+ netFlow: number,
498
+ ): ElkNode => {
343
499
  let proxy = proxyByKind.get(key);
344
500
  if (!proxy) {
345
- proxy = {
346
- id: `__native_hierarchy_${String(child.id)}_${key.replace(/[^a-zA-Z0-9]/g, "_")}`,
347
- width: 0,
348
- height: 0,
349
- };
501
+ const direction = getDirection({ ...layoutOptions, ...child.layoutOptions });
502
+ const authoredAnchor = own
503
+ ? (getElementLayeredSettings(own.layoutOptions ?? {})["port.anchor"] as
504
+ | ElkPoint
505
+ | undefined)
506
+ : undefined;
507
+ const origin = createExternalPortDummy({
508
+ constraints: (getOption(child.layoutOptions ?? {}, "portConstraints") ??
509
+ "FREE") as ExternalPortConstraints,
510
+ side: own
511
+ ? ((getOption(own.layoutOptions ?? {}, "port.side") ??
512
+ (kind === "output" ? "EAST" : "WEST")) as ExternalPortSide)
513
+ : kind === "output"
514
+ ? "EAST"
515
+ : "WEST",
516
+ position: { x: own?.x ?? 0, y: own?.y ?? 0 },
517
+ anchor: authoredAnchor,
518
+ index: own ? getNumberOption(own.layoutOptions ?? {}, "port.index") : undefined,
519
+ direction: direction.toUpperCase() as "RIGHT" | "LEFT" | "DOWN" | "UP",
520
+ netFlow,
521
+ borderOffset: own
522
+ ? (getNumberOption(own.layoutOptions ?? {}, "port.borderOffset") ?? 0)
523
+ : (getNumberOption(layoutOptions, "spacing.edgeEdge") ?? 10) / 2,
524
+ size: { width: own?.width ?? 0, height: own?.height ?? 0 },
525
+ });
526
+ if (own) origin.parentPortId = String(own.id);
527
+ const id = `__native_hierarchy_${String(child.id)}_${proxyByKind.size}`;
528
+ proxy = attachExternalPortDummy(
529
+ {
530
+ id,
531
+ width: origin.width,
532
+ height: origin.height,
533
+ layoutOptions: {
534
+ "elk.portConstraints": origin.constraints,
535
+ "elk.layered.layering.layerConstraint":
536
+ origin.side ===
537
+ ({ right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" } as const)[
538
+ direction
539
+ ]
540
+ ? "LAST_SEPARATE"
541
+ : origin.side ===
542
+ ({ right: "WEST", left: "EAST", down: "NORTH", up: "SOUTH" } as const)[
543
+ direction
544
+ ]
545
+ ? "FIRST_SEPARATE"
546
+ : undefined,
547
+ },
548
+ ports: [
549
+ {
550
+ id: `${id}:boundary`,
551
+ x: origin.port.x,
552
+ y: origin.port.y,
553
+ width: 0,
554
+ height: 0,
555
+ layoutOptions: { "elk.port.side": origin.port.side },
556
+ },
557
+ ],
558
+ },
559
+ origin,
560
+ );
350
561
  proxyByKind.set(key, proxy);
351
562
  }
352
563
  return proxy;
353
564
  };
565
+ const ownedPortNetFlow = (id: string): number => {
566
+ let votes = 0;
567
+ for (const edge of originalHierarchyEndpoints.values()) {
568
+ if (edge.sourceId === id)
569
+ votes += descendantOwnerByEndpointId.has(edge.targetId) ? -1 : 1;
570
+ if (edge.targetId === id)
571
+ votes += descendantOwnerByEndpointId.has(edge.sourceId) ? 1 : -1;
572
+ }
573
+ return votes;
574
+ };
575
+ const proxyFor = (kind: "input" | "output", edge: ElkEdge): ElkNode => {
576
+ const own = ownPortFor(edge);
577
+ const flow = implicitOwnPorts.has(edge)
578
+ ? kind === "output"
579
+ ? 1
580
+ : -1
581
+ : own
582
+ ? ownedPortNetFlow(String(own.id))
583
+ : kind === "output"
584
+ ? 1
585
+ : -1;
586
+ return createBoundaryProxy(proxyKey(kind, edge), kind, own, flow);
587
+ };
588
+ // ELK imports ancestor edges before descendant-local edges. Preserve
589
+ // that implicit port creation order through child phase preparation.
354
590
  const temporaryEdges: ElkEdge[] = [
355
- ...(child.edges ?? []),
356
- ...internalEdges.filter(
357
- (edge) =>
358
- !(child.edges ?? []).some((candidate) => String(candidate.id) === String(edge.id)),
359
- ),
360
591
  ...crossingEdges.map((edge) => {
361
592
  const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
362
593
  const sourceInside = descendantOwnerByEndpointId.has(sourceId);
363
- const proxy = proxyFor(sourceInside ? "output" : "input");
364
- return {
594
+ const proxy = proxyFor(sourceInside ? "output" : "input", edge);
595
+ const segment: ElkEdge = {
365
596
  ...edge,
366
597
  id: `__native_hierarchy_edge_${String(child.id)}_${String(edge.id)}`,
367
- sources: [sourceInside ? sourceId : proxy.id!],
368
- targets: [sourceInside ? proxy.id! : targetId],
598
+ sources: [sourceInside ? sourceId : proxy.ports![0]!.id!],
599
+ targets: [sourceInside ? proxy.ports![0]!.id! : targetId],
369
600
  source: undefined,
370
601
  target: undefined,
371
602
  sections: undefined,
372
603
  };
604
+ originalEdgeId.set(segment, originalEdgeId.get(edge) ?? String(edge.id));
605
+ return segment;
373
606
  }),
607
+ ...(child.edges ?? []).filter((edge) => !crossingEdges.includes(edge)),
608
+ ...internalEdges.filter(
609
+ (edge) =>
610
+ !(child.edges ?? []).some((candidate) => String(candidate.id) === String(edge.id)),
611
+ ),
374
612
  ];
613
+ const hasExternalPorts = proxyByKind.size > 0;
614
+ const inactiveAuthoredPorts: Array<{ port: ElkPort; proxy: ElkNode }> = [];
615
+ if (hasExternalPorts) {
616
+ for (const port of child.ports ?? []) {
617
+ const key = JSON.stringify(["authored", port.id]);
618
+ if (proxyByKind.has(key)) continue;
619
+ const flow = ownedPortNetFlow(String(port.id));
620
+ inactiveAuthoredPorts.push({
621
+ port,
622
+ proxy: createBoundaryProxy(key, flow >= 0 ? "output" : "input", port, flow),
623
+ });
624
+ }
625
+ }
626
+ const outsideNodeEdges = !hasExternalPorts
627
+ ? []
628
+ : (graph.edges ?? []).filter((edge) => {
629
+ const e = originalHierarchyEndpoints.get(edge)!;
630
+ return (
631
+ (e.sourceId === String(child.id) || e.targetId === String(child.id)) &&
632
+ !descendantOwnerByEndpointId.has(e.sourceId) &&
633
+ !descendantOwnerByEndpointId.has(e.targetId)
634
+ );
635
+ });
636
+ for (const edge of outsideNodeEdges) {
637
+ const e = originalHierarchyEndpoints.get(edge)!;
638
+ proxyFor(e.sourceId === String(child.id) ? "output" : "input", edge);
639
+ }
375
640
  const temporaryChild: ElkNode = {
376
641
  ...child,
642
+ layoutOptions: proxyByKind.size
643
+ ? { ...child.layoutOptions, "elk.portConstraints": "FIXED_SIDE" }
644
+ : child.layoutOptions,
377
645
  children: [...(child.children ?? []), ...proxyByKind.values()],
378
646
  edges: temporaryEdges,
379
647
  };
380
- await this.#layout(temporaryChild, {
381
- ...arguments_,
382
- layoutOptions: {
383
- ...arguments_.layoutOptions,
384
- hierarchyHandling: "INCLUDE_CHILDREN",
648
+ const childSegmentOrder = elkSegmentOrder(child, graph, originalHierarchyEndpoints, true);
649
+ segmentOrderByScope.set(temporaryChild, childSegmentOrder);
650
+ // Boundary segments copied into the child scope keep their original identity.
651
+ for (const edge of temporaryEdges) {
652
+ const id = originalEdgeId.get(edge);
653
+ const rank = id === undefined ? undefined : childSegmentOrder.get(id);
654
+ if (rank !== undefined) hierarchySegmentRank.set(edge, rank);
655
+ }
656
+ const preparedChild = await this.#prepareLayout(
657
+ temporaryChild,
658
+ {
659
+ ...arguments_,
660
+ layoutOptions: {
661
+ ...arguments_.layoutOptions,
662
+ // The compound's own direction wins over the inherited one.
663
+ direction: getDirection(
664
+ getOption(child.layoutOptions ?? {}, "direction") === undefined
665
+ ? layoutOptions
666
+ : child.layoutOptions!,
667
+ ).toUpperCase(),
668
+ hierarchyHandling: "INCLUDE_CHILDREN",
669
+ },
670
+ logging: false,
671
+ measureExecutionTime: false,
385
672
  },
386
- logging: false,
387
- measureExecutionTime: false,
388
- });
389
- const childPadding = parsePadding(
390
- getOption({ ...layoutOptions, ...child.layoutOptions }, "padding"),
391
- 12,
673
+ true,
392
674
  );
393
- if (proxyByKind.has("input")) {
394
- const direction = getDirection(layoutOptions);
395
- const horizontal = direction === "right" || direction === "left";
396
- const increasing = direction === "right" || direction === "down";
397
- if (increasing) {
398
- const minimumFlow = Math.min(
399
- ...(child.children ?? []).map((node) => (horizontal ? (node.x ?? 0) : (node.y ?? 0))),
400
- );
401
- const desiredFlow = horizontal ? childPadding.left : childPadding.top;
402
- const delta = desiredFlow - minimumFlow;
403
- for (const node of child.children ?? []) {
404
- if (horizontal) node.x = (node.x ?? 0) + delta;
405
- else node.y = (node.y ?? 0) + delta;
406
- }
407
- for (const edge of temporaryEdges) {
408
- for (const section of edge.sections ?? []) {
409
- for (const point of [
410
- section.startPoint,
411
- ...(section.bendPoints ?? []),
412
- section.endPoint,
413
- ]) {
414
- if (horizontal) point.x += delta;
415
- else point.y += delta;
416
- }
417
- }
418
- }
419
- }
420
- }
421
- for (const internalEdge of internalEdges) {
422
- const temporary = temporaryEdges.find(
423
- (candidate) => String(candidate.id) === String(internalEdge.id),
424
- );
425
- if (temporary?.sections) internalEdge.sections = temporary.sections;
426
- }
427
- child.width =
428
- Math.max(0, ...(child.children ?? []).map((node) => (node.x ?? 0) + (node.width ?? 0))) +
429
- childPadding.right;
430
- child.height =
431
- Math.max(0, ...(child.children ?? []).map((node) => (node.y ?? 0) + (node.height ?? 0))) +
432
- childPadding.bottom;
433
-
434
- const relativeRect = (id: string): ElkShape | undefined => {
435
- const owner = descendantOwnerByEndpointId.get(id);
436
- if (!owner) return undefined;
437
- const path: ElkNode[] = [];
438
- const visit = (parent: ElkNode): boolean => {
439
- for (const candidate of parent.children ?? []) {
440
- path.push(candidate);
441
- if (candidate === owner || visit(candidate)) return true;
442
- path.pop();
443
- }
444
- return false;
445
- };
446
- if (!visit(child)) return undefined;
447
- const port = owner.ports?.find((candidate) => String(candidate.id) === id);
448
- const ownerX = path.reduce((sum, node) => sum + (node.x ?? 0), 0);
449
- const ownerY = path.reduce((sum, node) => sum + (node.y ?? 0), 0);
450
- if (port) {
451
- return {
452
- x: ownerX + (port.x ?? 0) + (port.width ?? 0) / 2,
453
- y: ownerY + (port.y ?? 0) + (port.height ?? 0) / 2,
454
- width: 0,
455
- height: 0,
456
- };
457
- }
458
- return {
459
- x: ownerX,
460
- y: ownerY,
461
- width: path.at(-1)?.width ?? 0,
462
- height: path.at(-1)?.height ?? 0,
463
- };
464
- };
675
+ preparedChildren.set(String(child.id), preparedChild);
465
676
  authoredPortsByCompound.set(child, child.ports);
466
677
  authoredOptionsByCompound.set(child, child.layoutOptions);
467
- const ports = [...(child.ports ?? [])];
678
+ const initialPorts = (child.ports ?? []).map((port) => ({
679
+ ...port,
680
+ layoutOptions: port.layoutOptions ? { ...port.layoutOptions } : undefined,
681
+ }));
468
682
  for (const edge of crossingEdges) {
683
+ const own = ownPortFor(edge);
684
+ if (own) {
685
+ const original = originalHierarchyEndpoints.get(edge)!;
686
+ const sourceInside = descendantOwnerByEndpointId.has(original.sourceId);
687
+ const origin = externalPortDummyOf(
688
+ proxyByKind.get(proxyKey(sourceInside ? "output" : "input", edge))!,
689
+ )!;
690
+ let port = initialPorts.find((p) => p.id === own.id);
691
+ if (!port) {
692
+ port = { ...own, layoutOptions: { ...own.layoutOptions } };
693
+ initialPorts.push(port);
694
+ }
695
+ port.layoutOptions = { ...port.layoutOptions, "elk.port.side": origin.side };
696
+ continue;
697
+ }
469
698
  const original = originalHierarchyEndpoints.get(edge)!;
470
- const { sourceId, targetId } = original;
471
- const sourceInside = descendantOwnerByEndpointId.has(sourceId);
472
- const descendantId = sourceInside ? sourceId : targetId;
473
- const rect = relativeRect(descendantId);
474
- if (!rect) continue;
475
- const portId = `__native_hierarchy_port_${String(child.id)}_${String(edge.id)}`;
699
+ const sourceInside = descendantOwnerByEndpointId.has(original.sourceId);
700
+ const proxy = proxyByKind.get(proxyKey(sourceInside ? "output" : "input", edge))!;
701
+ const origin = externalPortDummyOf(proxy)!;
702
+ const portId = `${String(proxy.id)}:parent`;
476
703
  syntheticPortIds.add(portId);
477
- const direction = getDirection(layoutOptions);
478
- const outgoing = sourceInside;
479
- const flowForward = direction === "right" || direction === "down";
480
- const useFarSide = outgoing === flowForward;
481
- ports.push({
482
- id: portId,
483
- width: 0,
484
- height: 0,
485
- x:
486
- direction === "right" || direction === "left"
487
- ? (rect.x ?? 0) + (useFarSide ? (rect.width ?? 0) : 0)
488
- : (rect.x ?? 0) + (rect.width ?? 0) / 2,
489
- y:
490
- direction === "down" || direction === "up"
491
- ? (rect.y ?? 0) + (useFarSide ? (rect.height ?? 0) : 0)
492
- : (rect.y ?? 0) + (rect.height ?? 0) / 2,
493
- });
494
- if (!hierarchyRestorations.some((restoration) => restoration.edge === edge)) {
704
+ if (!initialPorts.some((port) => port.id === portId))
705
+ initialPorts.push({
706
+ id: portId,
707
+ width: origin.externalSize.width,
708
+ height: origin.externalSize.height,
709
+ x: 0,
710
+ y: 0,
711
+ layoutOptions: {
712
+ "elk.port.side": origin.side,
713
+ "elk.port.borderOffset": origin.borderOffset,
714
+ },
715
+ });
716
+ if (!hierarchyRestorations.some((restoration) => restoration.edge === edge))
495
717
  hierarchyRestorations.push({ edge, ...original });
496
- }
497
718
  if (sourceInside) edge.sources = [portId];
498
719
  else edge.targets = [portId];
720
+ const rank = scopeSegmentOrder.get(originalEdgeId.get(edge) ?? String(edge.id));
721
+ if (rank !== undefined) hierarchySegmentRank.set(edge, rank);
499
722
  edge.source = undefined;
500
723
  edge.target = undefined;
501
724
  }
502
- child.ports = ports;
503
- child.layoutOptions = { ...child.layoutOptions, "elk.portConstraints": "FIXED_POS" };
504
- const insideLoopCount = (graph.edges ?? []).filter((edge) =>
505
- isInsideSelfLoop(graph, edge, String(child.id)),
506
- ).length;
507
- if (insideLoopCount > 0) {
508
- const baseHeight = child.height ?? 0;
509
- insideSelfLoopBaseHeightByNodeId.set(String(child.id), baseHeight);
510
- child.height = baseHeight + insideLoopCount * 11;
725
+ for (const { port, proxy } of inactiveAuthoredPorts) {
726
+ const actual = initialPorts.find((p) => p.id === port.id)!;
727
+ actual.layoutOptions = {
728
+ ...actual.layoutOptions,
729
+ "elk.port.side": externalPortDummyOf(proxy)!.side,
730
+ };
511
731
  }
732
+ for (const edge of outsideNodeEdges) {
733
+ const original = originalHierarchyEndpoints.get(edge)!;
734
+ const source = original.sourceId === String(child.id);
735
+ const proxy = proxyFor(source ? "output" : "input", edge),
736
+ own = ownPortFor(edge)!;
737
+ const origin = externalPortDummyOf(proxy)!;
738
+ if (!initialPorts.some((p) => p.id === own.id))
739
+ initialPorts.push({
740
+ ...own,
741
+ layoutOptions: { ...own.layoutOptions, "elk.port.side": origin.side },
742
+ });
743
+ if (!hierarchyRestorations.some((r) => r.edge === edge))
744
+ hierarchyRestorations.push({ edge, ...original });
745
+ if (source) edge.sources = [own.id!];
746
+ else edge.targets = [own.id!];
747
+ edge.source = undefined;
748
+ edge.target = undefined;
749
+ }
750
+ child.ports = initialPorts;
751
+ child.layoutOptions = {
752
+ ...child.layoutOptions,
753
+ "elk.portConstraints":
754
+ // ELK fixes physical boundary sides after introducing hierarchy dummies.
755
+ // Authored constraints are restored when finishing the public graph.
756
+ crossingEdges.length > 0 || outsideNodeEdges.length > 0
757
+ ? "FIXED_SIDE"
758
+ : (getOption(authoredOptionsByCompound.get(child) ?? {}, "portConstraints") ??
759
+ "FREE"),
760
+ };
761
+ finishChildren.push(async () => {
762
+ await preparedChild.finish(activeOrders);
763
+ const childPadding = parsePadding(
764
+ getOption({ ...layoutOptions, ...child.layoutOptions }, "padding"),
765
+ 12,
766
+ );
767
+ for (const internalEdge of internalEdges) {
768
+ const temporary = temporaryEdges.find(
769
+ (candidate) => String(candidate.id) === String(internalEdge.id),
770
+ );
771
+ if (temporary?.sections) {
772
+ internalEdge.sections = temporary.sections;
773
+ // A nested scope may own the route (an edge from a compound's
774
+ // own port to its child); keep that coordinate frame.
775
+ internalEdge.container = (temporary as { container?: string }).container ?? child.id;
776
+ }
777
+ }
778
+ const childDirection = getDirection({ ...layoutOptions, ...child.layoutOptions });
779
+ const horizontalChild = childDirection === "right" || childDirection === "left";
780
+ // External dummy positions retain any routing corridor at the boundary.
781
+ const restoredPerpendicular = [...proxyByKind.values()].some(isPerpendicularPortRestored);
782
+ child.width = restoredPerpendicular
783
+ ? temporaryChild.width
784
+ : horizontalChild
785
+ ? Math.max(
786
+ 0,
787
+ ...(temporaryChild.children ?? []).map(
788
+ (node) => (node.x ?? 0) + (node.width ?? 0),
789
+ ),
790
+ ) + childPadding.right
791
+ : temporaryChild.width;
792
+ child.height = restoredPerpendicular
793
+ ? temporaryChild.height
794
+ : horizontalChild
795
+ ? temporaryChild.height
796
+ : Math.max(
797
+ 0,
798
+ ...(temporaryChild.children ?? []).map(
799
+ (node) => (node.y ?? 0) + (node.height ?? 0),
800
+ ),
801
+ ) + childPadding.bottom;
802
+
803
+ const relativeRect = (id: string): ElkShape | undefined => {
804
+ const owner = descendantOwnerByEndpointId.get(id);
805
+ if (!owner) return undefined;
806
+ const path: ElkNode[] = [];
807
+ const visit = (parent: ElkNode): boolean => {
808
+ for (const candidate of parent.children ?? []) {
809
+ path.push(candidate);
810
+ if (candidate === owner || visit(candidate)) return true;
811
+ path.pop();
812
+ }
813
+ return false;
814
+ };
815
+ if (!visit(child)) return undefined;
816
+ const port = owner.ports?.find((candidate) => String(candidate.id) === id);
817
+ const ownerX = path.reduce((sum, node) => sum + (node.x ?? 0), 0);
818
+ const ownerY = path.reduce((sum, node) => sum + (node.y ?? 0), 0);
819
+ if (port) {
820
+ return {
821
+ x: ownerX + (port.x ?? 0) + (port.width ?? 0) / 2,
822
+ y: ownerY + (port.y ?? 0) + (port.height ?? 0) / 2,
823
+ width: 0,
824
+ height: 0,
825
+ };
826
+ }
827
+ return {
828
+ x: ownerX,
829
+ y: ownerY,
830
+ width: path.at(-1)?.width ?? 0,
831
+ height: path.at(-1)?.height ?? 0,
832
+ };
833
+ };
834
+ const ports = (authoredPortsByCompound.get(child) ?? []).map(
835
+ (original) => child.ports?.find((port) => port.id === original.id) ?? original,
836
+ );
837
+ for (const edge of crossingEdges) {
838
+ const original = originalHierarchyEndpoints.get(edge)!;
839
+ const { sourceId, targetId } = original;
840
+ const sourceInside = descendantOwnerByEndpointId.has(sourceId);
841
+ const descendantId = sourceInside ? sourceId : targetId;
842
+ const rect = relativeRect(descendantId);
843
+ if (!rect) continue;
844
+ const internalEdge = temporaryEdges.find(
845
+ (candidate) =>
846
+ String(candidate.id) ===
847
+ `__native_hierarchy_edge_${String(child.id)}_${String(edge.id)}`,
848
+ );
849
+ const internalRoute = internalEdge?.sections;
850
+ const kind = sourceInside ? "output" : "input";
851
+ const proxy = proxyByKind.get(proxyKey(kind, edge))!;
852
+ const origin = externalPortDummyOf(proxy)!;
853
+ const endpoint = sourceInside
854
+ ? internalRoute?.at(-1)?.endPoint
855
+ : internalRoute?.[0]?.startPoint;
856
+ if (!endpoint)
857
+ throw new Error(`Missing compound boundary route for ${String(edge.id)}`);
858
+ const transferred = transferExternalPort({
859
+ contentSize: {
860
+ width: child.width! - childPadding.left - childPadding.right,
861
+ height: child.height! - childPadding.top - childPadding.bottom,
862
+ },
863
+ padding: childPadding,
864
+ offset: { x: 0, y: 0 },
865
+ dummy: {
866
+ x: endpoint.x - childPadding.left - (ownPortFor(edge) ? origin.port.x : 0),
867
+ y: endpoint.y - childPadding.top - (ownPortFor(edge) ? origin.port.y : 0),
868
+ width: origin.width,
869
+ height: origin.height,
870
+ },
871
+ side: origin.side,
872
+ borderOffset: origin.borderOffset,
873
+ portSize: origin.externalSize,
874
+ });
875
+ const points = internalRoute!
876
+ .flatMap((section, index) => [
877
+ ...(index === 0 ? [section.startPoint] : []),
878
+ ...(section.bendPoints ?? []),
879
+ section.endPoint,
880
+ ])
881
+ .map((point) => ({ ...point }));
882
+ const boundary = sourceInside ? points.at(-1)! : points[0]!;
883
+ boundary.x = transferred.dummy.x + childPadding.left;
884
+ boundary.y = transferred.dummy.y + childPadding.top;
885
+ const own = ownPortFor(edge);
886
+ if (own) {
887
+ if (implicitOwnPorts.has(edge) && !ports.some((p) => p.id === own.id))
888
+ ports.push(own);
889
+ own.x = transferred.port.x;
890
+ own.y = transferred.port.y;
891
+ const anchor = (getElementLayeredSettings(own.layoutOptions ?? {})["port.anchor"] as
892
+ | ElkPoint
893
+ | undefined) ?? {
894
+ x:
895
+ origin.side === "EAST"
896
+ ? (own.width ?? 0)
897
+ : origin.side === "WEST"
898
+ ? 0
899
+ : (own.width ?? 0) / 2,
900
+ y:
901
+ origin.side === "SOUTH"
902
+ ? (own.height ?? 0)
903
+ : origin.side === "NORTH"
904
+ ? 0
905
+ : (own.height ?? 0) / 2,
906
+ };
907
+ boundary.x = own.x + anchor.x;
908
+ boundary.y = own.y + anchor.y;
909
+ edge.sections = [
910
+ {
911
+ id: `${String(edge.id)}_s0`,
912
+ startPoint: points[0]!,
913
+ endPoint: points.at(-1)!,
914
+ ...(points.length > 2 ? { bendPoints: points.slice(1, -1) } : {}),
915
+ incomingShape: sourceId,
916
+ outgoingShape: targetId,
917
+ },
918
+ ];
919
+ if (internalEdge?.junctionPoints?.length)
920
+ edge.junctionPoints = internalEdge.junctionPoints.map((point) => ({ ...point }));
921
+ else delete edge.junctionPoints;
922
+ edge.container = child.id;
923
+ continue;
924
+ }
925
+ const routes = boundaryRoutes.get(edge) ?? {};
926
+ routes[sourceInside ? "source" : "target"] = {
927
+ owner: child,
928
+ points,
929
+ junctionPoints: internalEdge?.junctionPoints,
930
+ };
931
+ boundaryRoutes.set(edge, routes);
932
+ const portId = `${String(proxy.id)}:parent`;
933
+ syntheticPortIds.add(portId);
934
+ if (!ports.some((port) => port.id === portId))
935
+ ports.push({
936
+ id: portId,
937
+ width: origin.externalSize.width,
938
+ height: origin.externalSize.height,
939
+ x: transferred.port.x,
940
+ y: transferred.port.y,
941
+ layoutOptions: {
942
+ "elk.port.side": origin.side,
943
+ "elk.port.borderOffset": origin.borderOffset,
944
+ },
945
+ });
946
+ if (!hierarchyRestorations.some((restoration) => restoration.edge === edge)) {
947
+ hierarchyRestorations.push({ edge, ...original });
948
+ }
949
+ if (sourceInside) edge.sources = [portId];
950
+ else edge.targets = [portId];
951
+ edge.source = undefined;
952
+ edge.target = undefined;
953
+ }
954
+ for (const { port, proxy } of inactiveAuthoredPorts) {
955
+ const origin = externalPortDummyOf(proxy)!;
956
+ const actual = ports.find((p) => p.id === port.id)!;
957
+ const transferred = transferExternalPort({
958
+ contentSize: {
959
+ width: child.width! - childPadding.left - childPadding.right,
960
+ height: child.height! - childPadding.top - childPadding.bottom,
961
+ },
962
+ padding: childPadding,
963
+ offset: { x: 0, y: 0 },
964
+ dummy: {
965
+ x: (proxy.x ?? 0) - childPadding.left,
966
+ y: (proxy.y ?? 0) - childPadding.top,
967
+ width: origin.width,
968
+ height: origin.height,
969
+ },
970
+ side: origin.side,
971
+ borderOffset: origin.borderOffset,
972
+ portSize: origin.externalSize,
973
+ });
974
+ actual.x = transferred.port.x;
975
+ actual.y = transferred.port.y;
976
+ }
977
+ for (const edge of outsideNodeEdges) {
978
+ const original = originalHierarchyEndpoints.get(edge)!;
979
+ const proxy = proxyFor(
980
+ original.sourceId === String(child.id) ? "output" : "input",
981
+ edge,
982
+ ),
983
+ origin = externalPortDummyOf(proxy)!,
984
+ own = ownPortFor(edge)!;
985
+ const transferred = transferExternalPort({
986
+ contentSize: {
987
+ width: child.width! - childPadding.left - childPadding.right,
988
+ height: child.height! - childPadding.top - childPadding.bottom,
989
+ },
990
+ padding: childPadding,
991
+ offset: { x: 0, y: 0 },
992
+ dummy: {
993
+ x: (proxy.x ?? 0) - childPadding.left,
994
+ y: (proxy.y ?? 0) - childPadding.top,
995
+ width: origin.width,
996
+ height: origin.height,
997
+ },
998
+ side: origin.side,
999
+ borderOffset: origin.borderOffset,
1000
+ portSize: origin.externalSize,
1001
+ });
1002
+ own.x = transferred.port.x;
1003
+ own.y = transferred.port.y;
1004
+ if (!ports.some((p) => p.id === own.id)) ports.push(own);
1005
+ }
1006
+ child.ports = ports;
1007
+ child.layoutOptions = { ...child.layoutOptions, "elk.portConstraints": "FIXED_POS" };
1008
+ const insideLoopCount = (graph.edges ?? []).filter((edge) =>
1009
+ isInsideSelfLoop(graph, edge, String(child.id)),
1010
+ ).length;
1011
+ if (insideLoopCount > 0) {
1012
+ const baseHeight = child.height ?? 0;
1013
+ insideSelfLoopBaseHeightByNodeId.set(String(child.id), baseHeight);
1014
+ child.height = baseHeight + insideLoopCount * 11;
1015
+ }
1016
+ });
512
1017
  }
513
1018
  }
514
1019
  applyNodeMicroLayout(graph, layoutOptions);
515
1020
  const graph_ = toGraph(graph, layoutOptions);
1021
+ const hierarchyProxyIds = new Set(
1022
+ (graph.children ?? [])
1023
+ .filter((node) => String(node.id).startsWith("__native_hierarchy_"))
1024
+ .map((node) => String(node.id)),
1025
+ );
516
1026
  const layerConstraintByNodeId = new Map(
517
1027
  (graph.children ?? []).map((node) => [
518
1028
  String(node.id),
@@ -527,6 +1037,10 @@ export default class ELK {
527
1037
  algorithm === "layered" &&
528
1038
  graph_.edges.some((edge) => {
529
1039
  if (edge.sourceId === edge.targetId) return false;
1040
+ // A boundary proxy for an edge from the enclosing compound sits in its own
1041
+ // separate layer ahead of the scope, so it never conflicts with a first node.
1042
+ if (hierarchyProxyIds.has(edge.sourceId) || hierarchyProxyIds.has(edge.targetId))
1043
+ return false;
530
1044
  const sourceConstraint = layerConstraintByNodeId.get(edge.sourceId);
531
1045
  const targetConstraint = layerConstraintByNodeId.get(edge.targetId);
532
1046
  return (
@@ -543,307 +1057,471 @@ export default class ELK {
543
1057
  getOption(layoutOptions, "padding"),
544
1058
  algorithm === "layered" ? 12 : 0,
545
1059
  );
546
- const laidOut = await (algorithm === "sporeCompaction"
547
- ? executeElkjs0111Layout({
548
- algorithm: "sporeCompaction",
549
- graph: graph_,
550
- options: {
551
- padding,
552
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
553
- },
554
- })
555
- : algorithm === "sporeOverlap"
1060
+ const options: LayeredLayoutOptions = {
1061
+ direction: getDirection(layoutOptions),
1062
+ spacing: {
1063
+ node:
1064
+ getNumberOption(layoutOptions, "spacing.nodeNode") ??
1065
+ getNumberOption(layoutOptions, "layered.spacing.baseValue"),
1066
+ layer:
1067
+ getNumberOption(layoutOptions, "layered.spacing.nodeNodeBetweenLayers") ??
1068
+ getNumberOption(layoutOptions, "layered.spacing.baseValue") ??
1069
+ 20,
1070
+ },
1071
+ padding,
1072
+ constraints: {
1073
+ layer: () => undefined,
1074
+ },
1075
+ settings: {
1076
+ ...getLayeredSettings(layoutOptions),
1077
+ ...(compoundLayout || (hasHierarchy && !separateHierarchy && !topdownLayout)
1078
+ ? { separateConnectedComponents: false }
1079
+ : {}),
1080
+ ...(hierarchyHandling === "INCLUDE_CHILDREN"
1081
+ ? {
1082
+ "crossingMinimization.greedySwitch.type": String(
1083
+ getOption(
1084
+ layoutOptions,
1085
+ "layered.crossingMinimization.greedySwitchHierarchical.type",
1086
+ ) ?? "TWO_SIDED",
1087
+ ) as LayeredAdvancedOptions["crossingMinimization.greedySwitch.type"],
1088
+ }
1089
+ : {}),
1090
+ },
1091
+ nodeSettings: (node) => {
1092
+ const child = findById(graph.children, node.id)?.item;
1093
+ return getElementLayeredSettings(child?.layoutOptions ?? {});
1094
+ },
1095
+ edgeSettings: (edge) => {
1096
+ const elkEdge = findById(graph.edges, edge.id)?.item;
1097
+ return {
1098
+ ...getElementLayeredSettings(elkEdge?.layoutOptions ?? {}),
1099
+ ...getElementLayeredSettings(elkEdge?.labels?.[0]?.layoutOptions ?? {}),
1100
+ // ELK appends hierarchy segments to port edge lists in creation order.
1101
+ "edge.hierarchyRank": elkEdge && hierarchySegmentRank.get(elkEdge),
1102
+ };
1103
+ },
1104
+ portSettings: (port, node) => {
1105
+ const child = findById(graph.children, node.id)?.item;
1106
+ const found = findById(child?.ports, port.name);
1107
+ const elkPort = found?.item;
1108
+ return {
1109
+ ...getElementLayeredSettings(elkPort?.layoutOptions ?? {}),
1110
+ // ELK imports ports in authored order and sorts sides after dummy insertion.
1111
+ "port.authoredIndex": found?.position,
1112
+ "port.labelWidth": Math.max(
1113
+ 0,
1114
+ ...(elkPort?.labels ?? []).map((label) => label.width ?? 0),
1115
+ ),
1116
+ "port.labelHeight": Math.max(
1117
+ 0,
1118
+ ...(elkPort?.labels ?? []).map((label) => label.height ?? 0),
1119
+ ),
1120
+ } as ElkLayeredOptionValueByName;
1121
+ },
1122
+ };
1123
+ const pipeline =
1124
+ algorithm === "layered" ? createLayeredScopePipeline(graph_, options) : undefined;
1125
+ const initial = pipeline?.next();
1126
+ const phase = initial && !initial.done ? initial.value : undefined;
1127
+ let activeOrders: ReadonlyMap<PreparedElkScope, LayerOrder> | undefined;
1128
+ let finished: Promise<ElkNode> | undefined;
1129
+ const prepared: PreparedElkScope = {
1130
+ graph,
1131
+ native: graph_,
1132
+ phase,
1133
+ options,
1134
+ children: preparedChildren,
1135
+ finish: (orders) => {
1136
+ if (finished) return finished;
1137
+ activeOrders = orders;
1138
+ finished = finish();
1139
+ return finished;
1140
+ },
1141
+ };
1142
+ const finishLayered = (): VisualGraph => {
1143
+ if (!pipeline || !initial) throw new Error("Missing layered pipeline");
1144
+ if (initial.done) return initial.value;
1145
+ const completed = pipeline.next(
1146
+ activeOrders?.has(prepared)
1147
+ ? initial.value.finishOrder(activeOrders.get(prepared)!)
1148
+ : initial.value.minimize(),
1149
+ );
1150
+ if (!completed.done) throw new Error("Layered scope yielded more than one crossing phase");
1151
+ return completed.value;
1152
+ };
1153
+ const finish = async (): Promise<ElkNode> => {
1154
+ for (const complete of finishChildren) await complete();
1155
+ for (const nativeGraph of new Set(
1156
+ [graph_, phase?.input.graph].filter((value) => value !== undefined),
1157
+ )) {
1158
+ for (const child of graph.children ?? []) {
1159
+ const native = nativeGraph.nodes.find((node) => node.id === String(child.id));
1160
+ if (!native || !preparedChildren.has(String(child.id))) continue;
1161
+ phase?.updateNodeSize(native.id, { width: child.width ?? 0, height: child.height ?? 0 });
1162
+ Object.assign(native, { width: child.width, height: child.height });
1163
+ for (const port of native.ports ?? []) {
1164
+ const actual = child.ports?.find((candidate) => String(candidate.id) === port.name);
1165
+ if (actual)
1166
+ Object.assign(port, {
1167
+ x: actual.x,
1168
+ y: actual.y,
1169
+ width: actual.width,
1170
+ height: actual.height,
1171
+ });
1172
+ }
1173
+ }
1174
+ }
1175
+ const laidOut = await (algorithm === "sporeCompaction"
556
1176
  ? executeElkjs0111Layout({
557
- algorithm: "sporeOverlap",
1177
+ algorithm: "sporeCompaction",
558
1178
  graph: graph_,
559
1179
  options: {
560
1180
  padding,
561
1181
  spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
562
1182
  },
563
1183
  })
564
- : algorithm === "rectpacking"
1184
+ : algorithm === "sporeOverlap"
565
1185
  ? executeElkjs0111Layout({
566
- algorithm: "rectpacking",
1186
+ algorithm: "sporeOverlap",
567
1187
  graph: graph_,
568
1188
  options: {
569
- padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
570
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode") ?? 15,
1189
+ padding,
1190
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
571
1191
  },
572
1192
  })
573
- : algorithm === "random"
1193
+ : algorithm === "rectpacking"
574
1194
  ? executeElkjs0111Layout({
575
- algorithm: "random",
1195
+ algorithm: "rectpacking",
576
1196
  graph: graph_,
577
1197
  options: {
578
1198
  padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
579
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
580
- aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
581
- seed: getNumberOption(layoutOptions, "randomSeed"),
1199
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode") ?? 15,
582
1200
  },
583
1201
  })
584
- : algorithm === "box"
1202
+ : algorithm === "random"
585
1203
  ? executeElkjs0111Layout({
586
- algorithm: "box",
1204
+ algorithm: "random",
587
1205
  graph: graph_,
588
1206
  options: {
589
1207
  padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
590
1208
  spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
591
1209
  aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
592
- interactive: getBooleanOption(layoutOptions, "interactive"),
593
- expandNodes: getBooleanOption(layoutOptions, "expandNodes"),
594
- priority: (node) => {
595
- const child = graph.children?.find(
596
- (candidate) => String(candidate.id) === node.id,
597
- );
598
- return getNumberOption(child?.layoutOptions ?? {}, "priority");
599
- },
1210
+ seed: getNumberOption(layoutOptions, "randomSeed"),
600
1211
  },
601
1212
  })
602
- : algorithm === "fixed"
1213
+ : algorithm === "box"
603
1214
  ? executeElkjs0111Layout({
604
- algorithm: "fixed",
605
- graph: graph_,
606
- options: { direction: getDirection(layoutOptions) },
607
- })
608
- : executeElkjs0111Layout({
609
- algorithm: "layered",
1215
+ algorithm: "box",
610
1216
  graph: graph_,
611
1217
  options: {
612
- direction: getDirection(layoutOptions),
613
- spacing: {
614
- node:
615
- getNumberOption(layoutOptions, "spacing.nodeNode") ??
616
- getNumberOption(layoutOptions, "layered.spacing.baseValue"),
617
- layer:
618
- (getNumberOption(
619
- layoutOptions,
620
- "layered.spacing.nodeNodeBetweenLayers",
621
- ) ??
622
- getNumberOption(layoutOptions, "layered.spacing.baseValue") ??
623
- 20) +
624
- (hasHierarchyCrossingEdges
625
- ? 5 * Math.max(...hierarchyBoundaryCountByEdge.values())
626
- : 0),
627
- },
628
- padding,
629
- constraints: {
630
- layer: () => undefined,
631
- },
632
- settings: {
633
- ...getLayeredSettings(layoutOptions),
634
- ...(hierarchyHandling === "INCLUDE_CHILDREN" &&
635
- getOption(
636
- layoutOptions,
637
- "layered.crossingMinimization.greedySwitchHierarchical.type",
638
- ) !== undefined
639
- ? {
640
- "crossingMinimization.greedySwitch.type": String(
641
- getOption(
642
- layoutOptions,
643
- "layered.crossingMinimization.greedySwitchHierarchical.type",
644
- ),
645
- ) as LayeredAdvancedOptions["crossingMinimization.greedySwitch.type"],
646
- }
647
- : {}),
648
- },
649
- nodeSettings: (node) => {
650
- const child = graph.children?.find(
651
- (candidate) => String(candidate.id) === node.id,
652
- );
653
- return getElementLayeredSettings(child?.layoutOptions ?? {});
654
- },
655
- edgeSettings: (edge) => {
656
- const elkEdge = graph.edges?.find(
657
- (candidate) => String(candidate.id) === edge.id,
658
- );
659
- return {
660
- ...getElementLayeredSettings(elkEdge?.layoutOptions ?? {}),
661
- ...getElementLayeredSettings(elkEdge?.labels?.[0]?.layoutOptions ?? {}),
662
- };
663
- },
664
- portSettings: (port, node) => {
1218
+ padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
1219
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
1220
+ aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
1221
+ interactive: getBooleanOption(layoutOptions, "interactive"),
1222
+ expandNodes: getBooleanOption(layoutOptions, "expandNodes"),
1223
+ priority: (node) => {
665
1224
  const child = graph.children?.find(
666
1225
  (candidate) => String(candidate.id) === node.id,
667
1226
  );
668
- const elkPort = child?.ports?.find(
669
- (candidate) => String(candidate.id) === port.name,
670
- );
671
- return {
672
- ...getElementLayeredSettings(elkPort?.layoutOptions ?? {}),
673
- "port.labelWidth": Math.max(
674
- 0,
675
- ...(elkPort?.labels ?? []).map((label) => label.width ?? 0),
676
- ),
677
- "port.labelHeight": Math.max(
678
- 0,
679
- ...(elkPort?.labels ?? []).map((label) => label.height ?? 0),
680
- ),
681
- } as ElkLayeredOptionValueByName;
1227
+ return getNumberOption(child?.layoutOptions ?? {}, "priority");
682
1228
  },
683
1229
  },
684
- }));
685
- if (hierarchyRestorations.length > 0) {
686
- const direction = getDirection(layoutOptions);
687
- const horizontal = direction === "right" || direction === "left";
688
- const cross = (point: ElkPoint): number => (horizontal ? point.y : point.x);
689
- const nodeSpacing = getNumberOption(layoutOptions, "spacing.nodeNode") ?? 20;
690
- const shiftsByOutsideId = new Map<string, number[]>();
691
- for (const restoration of hierarchyRestorations) {
692
- const route = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id))?.points;
693
- if (!route || route.length < 2) continue;
694
- const originalSourceId = String(restoration.sources?.[0] ?? restoration.source);
695
- const originalTargetId = String(restoration.targets?.[0] ?? restoration.target);
696
- const sourceInside = !laidOut.nodes.some((node) => node.id === originalSourceId);
697
- const outsideId = sourceInside ? originalTargetId : originalSourceId;
698
- const delta = sourceInside
699
- ? cross(route[0]!) - cross(route.at(-1)!)
700
- : cross(route.at(-1)!) - cross(route[0]!);
701
- const candidates = shiftsByOutsideId.get(outsideId) ?? [];
702
- candidates.push(delta);
703
- shiftsByOutsideId.set(outsideId, candidates);
704
- }
705
- for (const [outsideId, candidates] of shiftsByOutsideId) {
706
- const delta = [...candidates].sort((left, right) => Math.abs(left) - Math.abs(right))[0]!;
707
- const node = laidOut.nodes.find((candidate) => candidate.id === outsideId);
708
- if (!node || Math.abs(delta) < 1e-9 || Math.abs(delta) > nodeSpacing) continue;
709
- if (horizontal) node.y = (node.y ?? 0) + delta;
710
- else node.x = (node.x ?? 0) + delta;
711
- for (const edge of laidOut.edges) {
712
- const points = edge.points;
713
- if (!points || points.length === 0) continue;
714
- if (edge.sourceId === outsideId) {
715
- if (horizontal) points[0]!.y += delta;
716
- else points[0]!.x += delta;
717
- }
718
- if (edge.targetId === outsideId) {
719
- if (horizontal) points.at(-1)!.y += delta;
720
- else points.at(-1)!.x += delta;
721
- }
1230
+ })
1231
+ : algorithm === "fixed"
1232
+ ? executeElkjs0111Layout({
1233
+ algorithm: "fixed",
1234
+ graph: graph_,
1235
+ options: { direction: getDirection(layoutOptions) },
1236
+ })
1237
+ : finishLayered());
1238
+ const resultPolicy = (
1239
+ laidOut as typeof laidOut & {
1240
+ [elkjs0111ResultPolicy]?: Elkjs0111ResultPolicy;
722
1241
  }
1242
+ )[elkjs0111ResultPolicy];
1243
+ applyLayout(graph, laidOut, padding, layoutOptions, resultPolicy);
1244
+ if (resultPolicy?.providerBounds) {
1245
+ graph.width = resultPolicy.providerBounds.width;
1246
+ graph.height = resultPolicy.providerBounds.height;
723
1247
  }
724
- const modelOrder = new Map(
725
- (graph.children ?? []).map((node, index) => [String(node.id), index]),
726
- );
727
- const flowLayers = new Map<number, (typeof laidOut.nodes)[number][]>();
728
- for (const node of laidOut.nodes) {
729
- const flow = horizontal ? (node.x ?? 0) : (node.y ?? 0);
730
- const layer = flowLayers.get(flow) ?? [];
731
- layer.push(node);
732
- flowLayers.set(flow, layer);
733
- }
734
- for (const layer of flowLayers.values()) {
735
- layer.sort(
736
- (left, right) =>
737
- (modelOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
738
- (modelOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER),
739
- );
740
- let crossEnd = Number.NEGATIVE_INFINITY;
741
- for (const node of layer) {
742
- const authoredCross = horizontal ? (node.y ?? 0) : (node.x ?? 0);
743
- const compactedCross = Math.max(
744
- authoredCross,
745
- crossEnd === Number.NEGATIVE_INFINITY ? authoredCross : crossEnd + nodeSpacing,
1248
+ for (const restoration of hierarchyRestorations) {
1249
+ const splits = boundaryRoutes.get(restoration.edge);
1250
+ const sections = restoration.edge.sections;
1251
+ if (splits && sections?.length) {
1252
+ const parentPoints = sections.flatMap((section, index) => [
1253
+ ...(index === 0 ? [section.startPoint] : []),
1254
+ ...(section.bendPoints ?? []),
1255
+ section.endPoint,
1256
+ ]);
1257
+ const segments = [
1258
+ ...(splits.source
1259
+ ? [
1260
+ {
1261
+ points: splits.source.points,
1262
+ offset: { x: splits.source.owner.x ?? 0, y: splits.source.owner.y ?? 0 },
1263
+ },
1264
+ ]
1265
+ : []),
1266
+ { points: parentPoints, offset: { x: 0, y: 0 } },
1267
+ ...(splits.target
1268
+ ? [
1269
+ {
1270
+ points: splits.target.points,
1271
+ offset: { x: splits.target.owner.x ?? 0, y: splits.target.owner.y ?? 0 },
1272
+ },
1273
+ ]
1274
+ : []),
1275
+ ];
1276
+ const points = joinCompoundRouteSegments(
1277
+ segments,
1278
+ getBooleanOption(layoutOptions, "unnecessaryBendpoints") === true,
746
1279
  );
747
- const delta = compactedCross - authoredCross;
748
- if (horizontal) node.y = compactedCross;
749
- else node.x = compactedCross;
750
- if (Math.abs(delta) > 1e-9) {
751
- for (const edge of laidOut.edges) {
752
- const points = edge.points;
753
- if (!points || points.length === 0) continue;
754
- if (edge.sourceId === node.id) {
755
- if (horizontal) points[0]!.y += delta;
756
- else points[0]!.x += delta;
757
- }
758
- if (edge.targetId === node.id) {
759
- if (horizontal) points.at(-1)!.y += delta;
760
- else points.at(-1)!.x += delta;
761
- }
762
- }
1280
+ const childJunctions = (split: typeof splits.source) =>
1281
+ (split?.junctionPoints ?? []).map((point) => ({
1282
+ x: point.x + (split?.owner.x ?? 0),
1283
+ y: point.y + (split?.owner.y ?? 0),
1284
+ }));
1285
+ const junctions = [
1286
+ ...childJunctions(splits.source),
1287
+ ...(restoration.edge.junctionPoints ?? []),
1288
+ ...childJunctions(splits.target),
1289
+ ];
1290
+ if (junctions.length) restoration.edge.junctionPoints = junctions;
1291
+ else delete restoration.edge.junctionPoints;
1292
+ restoration.edge.sections = [
1293
+ {
1294
+ ...sections[0]!,
1295
+ startPoint: points[0]!,
1296
+ endPoint: points.at(-1)!,
1297
+ bendPoints: points.slice(1, -1),
1298
+ },
1299
+ ];
1300
+ }
1301
+ restoration.edge.sources = restoration.sources;
1302
+ restoration.edge.targets = restoration.targets;
1303
+ restoration.edge.source = restoration.source;
1304
+ restoration.edge.target = restoration.target;
1305
+ for (const section of restoration.edge.sections ?? []) {
1306
+ if (
1307
+ section.incomingShape != null &&
1308
+ syntheticPortIds.has(String(section.incomingShape))
1309
+ ) {
1310
+ section.incomingShape = restoration.sources?.[0] ?? restoration.source;
1311
+ }
1312
+ if (
1313
+ section.outgoingShape != null &&
1314
+ syntheticPortIds.has(String(section.outgoingShape))
1315
+ ) {
1316
+ section.outgoingShape = restoration.targets?.[0] ?? restoration.target;
763
1317
  }
764
- crossEnd = compactedCross + (horizontal ? node.height : node.width);
765
1318
  }
766
1319
  }
767
- const edgeNodeSpacing = getNumberOption(layoutOptions, "spacing.edgeNodeBetweenLayers") ?? 10;
768
- for (const restoration of hierarchyRestorations) {
769
- const nativeEdge = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id));
770
- const route = nativeEdge?.points;
771
- if (!route || route.length < 2) continue;
772
- const start = route[0]!;
773
- const end = route.at(-1)!;
774
- // Decomposed ancestor/descendant edges are native self-loops. Preserve
775
- // their reserved exterior route and label placement instead of treating
776
- // equal endpoint coordinates as a straight inter-node connection.
777
- if (nativeEdge.sourceId === nativeEdge.targetId) continue;
778
- if (Math.abs(cross(start) - cross(end)) < 1e-9) {
779
- route.splice(1, route.length - 2);
780
- continue;
1320
+ for (const [compound, ports] of authoredPortsByCompound) {
1321
+ for (const port of ports ?? []) {
1322
+ const actual = compound.ports?.find((p) => p.id === port.id);
1323
+ if (actual)
1324
+ Object.assign(port, {
1325
+ x: actual.x,
1326
+ y: actual.y,
1327
+ width: actual.width,
1328
+ height: actual.height,
1329
+ });
781
1330
  }
782
- const originalSourceId = String(restoration.sources?.[0] ?? restoration.source);
783
- const sourceInside = !laidOut.nodes.some((node) => node.id === originalSourceId);
784
- const flowForward = direction === "right" || direction === "down";
785
- const track = sourceInside
786
- ? (horizontal ? end.x : end.y) - (flowForward ? edgeNodeSpacing : -edgeNodeSpacing)
787
- : (horizontal ? start.x : start.y) + (flowForward ? edgeNodeSpacing : -edgeNodeSpacing);
788
- route.splice(
789
- 1,
790
- route.length - 2,
791
- horizontal ? { x: track, y: start.y } : { x: start.x, y: track },
792
- horizontal ? { x: track, y: end.y } : { x: end.x, y: track },
793
- );
794
- }
795
- }
796
- const resultPolicy = (
797
- laidOut as typeof laidOut & {
798
- [elkjs0111ResultPolicy]?: Elkjs0111ResultPolicy;
1331
+ compound.ports = ports;
799
1332
  }
800
- )[elkjs0111ResultPolicy];
801
- applyLayout(graph, laidOut, padding, layoutOptions, resultPolicy);
802
- if (resultPolicy?.providerBounds) {
803
- graph.width = resultPolicy.providerBounds.width;
804
- graph.height = resultPolicy.providerBounds.height;
805
- }
806
- for (const restoration of hierarchyRestorations) {
807
- restoration.edge.sources = restoration.sources;
808
- restoration.edge.targets = restoration.targets;
809
- restoration.edge.source = restoration.source;
810
- restoration.edge.target = restoration.target;
811
- for (const section of restoration.edge.sections ?? []) {
812
- if (section.incomingShape != null && syntheticPortIds.has(String(section.incomingShape))) {
813
- section.incomingShape = restoration.sources?.[0] ?? restoration.source;
814
- }
815
- if (section.outgoingShape != null && syntheticPortIds.has(String(section.outgoingShape))) {
816
- section.outgoingShape = restoration.targets?.[0] ?? restoration.target;
1333
+ for (const [compound, options_] of authoredOptionsByCompound)
1334
+ compound.layoutOptions = options_;
1335
+ if (hasHierarchy && topdownLayout) {
1336
+ for (const child of graph.children ?? []) {
1337
+ if ((child.children?.length ?? 0) === 0) continue;
1338
+ const position = { x: child.x, y: child.y };
1339
+ await this.#layout(child, {
1340
+ ...arguments_,
1341
+ logging: false,
1342
+ measureExecutionTime: false,
1343
+ });
1344
+ child.x = position.x;
1345
+ child.y = position.y;
817
1346
  }
818
1347
  }
819
- }
820
- for (const [compound, ports] of authoredPortsByCompound) compound.ports = ports;
821
- for (const [compound, options_] of authoredOptionsByCompound) compound.layoutOptions = options_;
822
- if (hasHierarchy && topdownLayout) {
823
- for (const child of graph.children ?? []) {
824
- if ((child.children?.length ?? 0) === 0) continue;
825
- const position = { x: child.x, y: child.y };
826
- await this.#layout(child, {
827
- ...arguments_,
828
- logging: false,
829
- measureExecutionTime: false,
830
- });
831
- child.x = position.x;
832
- child.y = position.y;
1348
+ applyInsideSelfLoops(graph, insideSelfLoopBaseHeightByNodeId);
1349
+ if (arguments_.logging || arguments_.measureExecutionTime) {
1350
+ graph.logging = {
1351
+ name: "Native TypeScript layout",
1352
+ children: [{ name: String(algorithm) }],
1353
+ ...(arguments_.measureExecutionTime
1354
+ ? { executionTime: (performance.now() - startedAt) / 1_000 }
1355
+ : {}),
1356
+ };
833
1357
  }
834
- }
835
- applyInsideSelfLoops(graph, insideSelfLoopBaseHeightByNodeId);
836
- if (arguments_.logging || arguments_.measureExecutionTime) {
837
- graph.logging = {
838
- name: "Native TypeScript layout",
839
- children: [{ name: String(algorithm) }],
840
- ...(arguments_.measureExecutionTime
841
- ? { executionTime: (performance.now() - startedAt) / 1_000 }
842
- : {}),
1358
+ return graph;
1359
+ };
1360
+ return prepared;
1361
+ }
1362
+ }
1363
+
1364
+ /** Move opposite-direction track sharing apart on the finished layout. */
1365
+ function separated<T extends ElkNode>(graph: T, options: Readonly<Record<string, unknown>>): T {
1366
+ separateElkOpposingTracks(graph, { ...options, ...graph.layoutOptions });
1367
+ return graph;
1368
+ }
1369
+
1370
+ function coordinatePreparedScopes(
1371
+ root: PreparedElkScope,
1372
+ ): ReadonlyMap<PreparedElkScope, LayerOrder> | undefined {
1373
+ if (!root.phase || root.children.size === 0) return undefined;
1374
+ const prepared = [root];
1375
+ for (let i = 0; i < prepared.length; i++) prepared.push(...prepared[i]!.children.values());
1376
+ if (
1377
+ prepared.some(
1378
+ (scope) =>
1379
+ !scope.phase ||
1380
+ !["LAYER_SWEEP", "MEDIAN_LAYER_SWEEP"].includes(
1381
+ scope.options?.settings?.["crossingMinimization.strategy"] ?? "LAYER_SWEEP",
1382
+ ),
1383
+ )
1384
+ )
1385
+ return undefined;
1386
+ const scopes = new Map<PreparedElkScope, HierarchyCrossingScope>();
1387
+ for (const scope of prepared) {
1388
+ const phase = scope.phase!;
1389
+ scopes.set(scope, {
1390
+ session: createLayerSweepSession(
1391
+ phase.input,
1392
+ phase.orientation,
1393
+ phase.assignment,
1394
+ scope.options?.settings?.["crossingMinimization.strategy"] === "MEDIAN_LAYER_SWEEP"
1395
+ ? "median"
1396
+ : "mean",
1397
+ 7,
1398
+ { resetSeed: scope === root },
1399
+ ),
1400
+ childrenByNodeId: new Map(),
1401
+ useBottomUp: true,
1402
+ independentRandom: scope !== root,
1403
+ });
1404
+ }
1405
+ for (const parent of prepared) {
1406
+ const parentScope = scopes.get(parent)!;
1407
+ for (const [id, child] of parent.children) {
1408
+ const nativeParent = parent.phase!.input.graph.nodes.find((node) => node.id === id)!;
1409
+ const parentPortOrderFixed = ["FIXED_ORDER", "FIXED_POS", "FIXED_RATIO"].includes(
1410
+ parent.phase!.input.nodeSettings?.(nativeParent)?.portConstraints ?? "FREE",
1411
+ );
1412
+ const ports = nativeParent.ports ?? [];
1413
+ const childPhase = child.phase!;
1414
+ const childSession = scopes.get(child)!.session;
1415
+ const childNodeById = new Map<string, (typeof childPhase.input.graph.nodes)[number]>();
1416
+ for (const node of childPhase.input.graph.nodes)
1417
+ if (!childNodeById.has(node.id)) childNodeById.set(node.id, node);
1418
+ const parentEdgeById = new Map<string, (typeof childPhase.input.graph.edges)[number]>();
1419
+ for (const edge of parent.phase!.input.graph.edges)
1420
+ if (!parentEdgeById.has(edge.id)) parentEdgeById.set(edge.id, edge);
1421
+ const flowSides = hierarchicalPortSides(parent.phase!.input);
1422
+ const inputPorts = ports.filter(
1423
+ (port) =>
1424
+ parent.phase!.input.portSettings?.(port, nativeParent)?.["port.side"] ===
1425
+ flowSides.before,
1426
+ ).length;
1427
+ const outputPorts = ports.filter(
1428
+ (port) =>
1429
+ parent.phase!.input.portSettings?.(port, nativeParent)?.["port.side"] === flowSides.after,
1430
+ ).length;
1431
+ const childScope: HierarchyCrossingScope = {
1432
+ ...scopes.get(child)!,
1433
+ useBottomUp: useBottomUpHierarchySweep(
1434
+ childPhase.input,
1435
+ childPhase.orientation,
1436
+ childSession.snapshot().layers,
1437
+ {
1438
+ portOrderFixed: parentPortOrderFixed,
1439
+ inputPorts,
1440
+ outputPorts,
1441
+ deterministic:
1442
+ child.options?.settings?.["crossingMinimization.strategy"] === "MEDIAN_LAYER_SWEEP",
1443
+ },
1444
+ ),
1445
+ alignBoundary: (forward) => {
1446
+ const childLayers = childSession.currentLayers();
1447
+ const layerIndex = forward ? 0 : childLayers.length - 1;
1448
+ const layer = childLayers[layerIndex]!;
1449
+ if (!layer.every((id) => externalPortDummyOf(childNodeById.get(id)!))) return false;
1450
+ // Parent port positions are physical; feedback reversal changes
1451
+ // edge direction without changing the boundary port's identity.
1452
+ const physical = parentScope.session.physicalPortOrder(id);
1453
+ const dummyByParentPort = new Map(
1454
+ childPhase.input.graph.nodes
1455
+ .filter((n) => externalPortDummyOf(n))
1456
+ .map((n) => [externalPortDummyOf(n)!.parentPortId ?? `${n.id}:parent`, n.id]),
1457
+ );
1458
+ const ordered = physical.flatMap((portId) => {
1459
+ const [nodeId, portName] = JSON.parse(portId) as [string, string];
1460
+ const dummy = nodeId === id ? dummyByParentPort.get(portName) : undefined;
1461
+ return dummy && layer.includes(dummy) ? [dummy] : [];
1462
+ });
1463
+ if (forward) ordered.reverse();
1464
+ const unique = [...new Set(ordered)];
1465
+ if (unique.length !== layer.length)
1466
+ throw new Error(`Incomplete hierarchical port order for ${id}`);
1467
+ childSession.replaceLayer(layerIndex, unique);
1468
+ return true;
1469
+ },
1470
+ publishBoundary: (forward) => {
1471
+ const childLayers = childSession.currentLayers();
1472
+ const layer = childLayers[forward ? childLayers.length - 1 : 0]!;
1473
+ if (!layer.every((id) => externalPortDummyOf(childNodeById.get(id)!))) return;
1474
+ const rank = new Map(
1475
+ layer.map((dummy, index) => [
1476
+ externalPortDummyOf(childNodeById.get(dummy)!)!.parentPortId ?? `${dummy}:parent`,
1477
+ index,
1478
+ ]),
1479
+ );
1480
+ const physical = parentScope.session.physicalPortOrder(id);
1481
+ const boundarySlots = physical.flatMap((portId, index) => {
1482
+ const [nodeId, portName] = JSON.parse(portId) as [string, string];
1483
+ return nodeId === id && rank.has(portName) ? [index] : [];
1484
+ });
1485
+ const boundaryPorts = boundarySlots
1486
+ .map((index) => physical[index]!)
1487
+ .sort((a, b) => {
1488
+ const portA = (JSON.parse(a) as [string, string])[1];
1489
+ const portB = (JSON.parse(b) as [string, string])[1];
1490
+ const result = rank.get(portA)! - rank.get(portB)!;
1491
+ return forward ? result : -result;
1492
+ });
1493
+ const updatedPhysical = [...physical];
1494
+ boundarySlots.forEach((slot, index) => {
1495
+ updatedPhysical[slot] = boundaryPorts[index]!;
1496
+ });
1497
+ const edgePortRank = (edgeId: string): number => {
1498
+ const edge = parentEdgeById.get(edgeId)!;
1499
+ const portName = edge.sourceId === id ? edge.sourcePort : edge.targetPort;
1500
+ return updatedPhysical.indexOf(JSON.stringify([id, portName]));
1501
+ };
1502
+ const { incoming, outgoing } = parentScope.session.edgeOrders(id);
1503
+ const byPort = (current: readonly string[]) =>
1504
+ [...current].sort((a, b) => edgePortRank(a) - edgePortRank(b));
1505
+ parentScope.session.reorderNodePorts(
1506
+ id,
1507
+ updatedPhysical,
1508
+ byPort(incoming),
1509
+ byPort(outgoing),
1510
+ );
1511
+ },
843
1512
  };
1513
+ childScope.publishBottomUp = () => {
1514
+ childScope.publishBoundary!(true);
1515
+ childScope.publishBoundary!(false);
1516
+ parentScope.session.lockPortOrder(id);
1517
+ };
1518
+ scopes.set(child, childScope);
1519
+ (parentScope.childrenByNodeId as Map<string, HierarchyCrossingScope>).set(id, childScope);
844
1520
  }
845
- return graph as LaidOutElkNode<T>;
846
1521
  }
1522
+ const orders = minimizeHierarchyCrossings(scopes.get(root)!);
1523
+
1524
+ return new Map(prepared.map((scope) => [scope, orders.get(scopes.get(scope)!)!]));
847
1525
  }
848
1526
 
849
1527
  function parsePadding(value: unknown, fallback = 0) {
@@ -1083,16 +1761,56 @@ function parseMargin(value: unknown): unknown {
1083
1761
  return parsePadding(value, 0);
1084
1762
  }
1085
1763
 
1764
+ // Every accepted spelling of a layered option, with its precedence (lower wins).
1765
+ const layeredOptionAliases = new Map<
1766
+ string,
1767
+ { definition: (typeof elkLayeredOptionDefinitions)[number]; rank: number }
1768
+ >();
1769
+ for (const definition of elkLayeredOptionDefinitions) {
1770
+ const suffix = definition.elkId.replace(/^org\.eclipse\.elk\./, "");
1771
+ [definition.name, suffix, `elk.${suffix}`, definition.elkId].forEach((key, rank) => {
1772
+ const current = layeredOptionAliases.get(key);
1773
+ if (!current || rank < current.rank) layeredOptionAliases.set(key, { definition, rank });
1774
+ });
1775
+ }
1776
+
1777
+ const indexByIdCache = new WeakMap<
1778
+ readonly { id?: ElkId }[],
1779
+ { length: number; index: Map<string, number> }
1780
+ >();
1781
+
1782
+ /** First element whose stringified id matches, like `find`, via a per-array index. */
1783
+ function findById<T extends { id?: ElkId }>(items: readonly T[] | undefined, id: string) {
1784
+ if (!items) return undefined;
1785
+ let cached = indexByIdCache.get(items);
1786
+ if (!cached || cached.length !== items.length) {
1787
+ const index = new Map<string, number>();
1788
+ items.forEach((item, position) => {
1789
+ const key = String(item.id);
1790
+ if (!index.has(key)) index.set(key, position);
1791
+ });
1792
+ indexByIdCache.set(items, (cached = { length: items.length, index }));
1793
+ }
1794
+ const position = cached.index.get(id);
1795
+ return position === undefined ? undefined : { item: items[position]!, position };
1796
+ }
1797
+
1086
1798
  function getElementLayeredSettings(
1087
1799
  options: Readonly<Record<string, unknown>>,
1088
1800
  ): ElkLayeredOptionValueByName {
1089
1801
  const settings: ElkLayeredOptionValueByName = {};
1090
- for (const definition of elkLayeredOptionDefinitions) {
1091
- const suffix = definition.elkId.replace(/^org\.eclipse\.elk\./, "");
1092
- const value = [definition.name, suffix, `elk.${suffix}`, definition.elkId]
1093
- .map((key) => options[key])
1094
- .find((candidate) => candidate !== undefined);
1095
- if (value === undefined) continue;
1802
+ const chosen = new Map<
1803
+ (typeof elkLayeredOptionDefinitions)[number],
1804
+ { rank: number; value: unknown }
1805
+ >();
1806
+ for (const [key, value] of Object.entries(options)) {
1807
+ const alias = layeredOptionAliases.get(key);
1808
+ if (!alias || value === undefined) continue;
1809
+ const current = chosen.get(alias.definition);
1810
+ if (!current || alias.rank < current.rank)
1811
+ chosen.set(alias.definition, { rank: alias.rank, value });
1812
+ }
1813
+ for (const [definition, { value }] of chosen) {
1096
1814
  const vectorMatch =
1097
1815
  definition.name === "port.anchor" && typeof value === "string"
1098
1816
  ? value.match(/^\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*$/)
@@ -1260,7 +1978,16 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1260
1978
  if (String(getOption(child.layoutOptions ?? {}, "portConstraints")) !== "FIXED_SIDE") {
1261
1979
  return ports;
1262
1980
  }
1263
- const side = (port: ElkPort) => String(getOption(port.layoutOptions ?? {}, "port.side"));
1981
+ const canonicalSides: Record<string, string> =
1982
+ getDirection(globalOptions) === "left"
1983
+ ? { NORTH: "NORTH", EAST: "WEST", SOUTH: "SOUTH", WEST: "EAST" }
1984
+ : getDirection(globalOptions) === "down"
1985
+ ? { NORTH: "WEST", EAST: "SOUTH", SOUTH: "EAST", WEST: "NORTH" }
1986
+ : getDirection(globalOptions) === "up"
1987
+ ? { NORTH: "EAST", EAST: "SOUTH", SOUTH: "WEST", WEST: "NORTH" }
1988
+ : { NORTH: "NORTH", EAST: "EAST", SOUTH: "SOUTH", WEST: "WEST" };
1989
+ const side = (port: ElkPort) =>
1990
+ canonicalSides[String(getOption(port.layoutOptions ?? {}, "port.side"))] ?? "UNDEFINED";
1264
1991
  const sideOrder = ["NORTH", "EAST", "SOUTH", "WEST"];
1265
1992
  ports.sort((left, right) => {
1266
1993
  const leftSide = side(left);
@@ -1287,10 +2014,12 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1287
2014
  const usePortModelOrder =
1288
2015
  getBooleanOption(globalOptions, "layered.considerModelOrder.portModelOrder") === true;
1289
2016
  if (modelOrderStrategy !== "NONE" && !usePortModelOrder) {
1290
- const edgeOrderDifference =
1291
- (edgeModelOrderByPortId.get(String(left.id)) ?? Infinity) -
1292
- (edgeModelOrderByPortId.get(String(right.id)) ?? Infinity);
1293
- if (edgeOrderDifference !== 0) return edgeOrderDifference;
2017
+ const leftOrder = edgeModelOrderByPortId.get(String(left.id));
2018
+ const rightOrder = edgeModelOrderByPortId.get(String(right.id));
2019
+ // Ports without edges order last and keep their order among themselves,
2020
+ // unreversed even on the WEST side, as ELK does.
2021
+ if (leftOrder === undefined && rightOrder === undefined) return 0;
2022
+ if (leftOrder !== rightOrder) return (leftOrder ?? Infinity) - (rightOrder ?? Infinity);
1294
2023
  }
1295
2024
  const direction = leftSide === "WEST" ? -1 : 1;
1296
2025
  return direction * ((child.ports?.indexOf(left) ?? 0) - (child.ports?.indexOf(right) ?? 0));
@@ -1298,7 +2027,7 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1298
2027
  return ports;
1299
2028
  };
1300
2029
 
1301
- return createGraph({
2030
+ const native = createGraph({
1302
2031
  id: String(root.id),
1303
2032
  nodes: children.map((child) => ({
1304
2033
  id: String(child.id),
@@ -1353,6 +2082,12 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1353
2082
  : [];
1354
2083
  }),
1355
2084
  });
2085
+ for (const node of native.nodes) {
2086
+ const child = children.find((candidate) => String(candidate.id) === node.id);
2087
+ const origin = child && externalPortDummyOf(child);
2088
+ if (origin) attachExternalPortDummy(node, origin);
2089
+ }
2090
+ return native;
1356
2091
  }
1357
2092
 
1358
2093
  function parsePoints(value: unknown): ElkPoint[] | undefined {
@@ -1431,11 +2166,21 @@ function applyLayout(
1431
2166
  continue;
1432
2167
  }
1433
2168
  const section = getParentEdgeSection(root, edge);
1434
- if (section) edge.sections = [section];
2169
+ if (section) {
2170
+ edge.sections = [section];
2171
+ edge.container = root.id;
2172
+ }
1435
2173
  continue;
1436
2174
  }
2175
+ const nativeJunctions = resultPolicy?.junctionPointsByEdgeId?.get(String(edge.id));
2176
+ if (nativeJunctions) {
2177
+ if (nativeJunctions.length)
2178
+ edge.junctionPoints = nativeJunctions.map((point) => ({ ...point }));
2179
+ else delete edge.junctionPoints;
2180
+ }
1437
2181
  const section = toSection(edge, laidOutEdge.points ?? []);
1438
2182
  edge.sections = section ? [section] : [];
2183
+ if (section) edge.container = root.id;
1439
2184
  const target = root.children?.find(
1440
2185
  (child) => String(child.id) === String(edge.targets?.[0] ?? edge.target),
1441
2186
  );
@@ -1452,6 +2197,8 @@ function applyLayout(
1452
2197
  const midpointX = (laidOutEdge.x ?? 0) + (laidOutEdge.width ?? 0) / 2;
1453
2198
  const edgeLabelSpacing = getNumberOption(layoutOptions, "spacing.edgeLabel") ?? 2;
1454
2199
  const labelLabelSpacing = getNumberOption(layoutOptions, "spacing.labelLabel") ?? 0;
2200
+ // A lone label keeps the native position, which already avoids collisions.
2201
+ const loneLabel = (edge.labels ?? []).filter(isLayoutEdgeLabel).length === 1;
1455
2202
  for (const label of edge.labels ?? []) {
1456
2203
  if (getBooleanOption(label.layoutOptions ?? {}, "noLayout") === true) {
1457
2204
  label.x ??= 0;
@@ -1468,17 +2215,51 @@ function applyLayout(
1468
2215
  );
1469
2216
  const width = label.width ?? 0;
1470
2217
  label.x =
1471
- placement === "TAIL"
1472
- ? firstPoint.x + edgeLabelSpacing
1473
- : placement === "HEAD"
1474
- ? lastPoint.x - width - edgeLabelSpacing
1475
- : midpointX - width / 2;
2218
+ loneLabel && placement !== "CENTER" && laidOutEdge.x !== undefined
2219
+ ? laidOutEdge.x
2220
+ : placement === "TAIL"
2221
+ ? firstPoint.x + edgeLabelSpacing
2222
+ : placement === "HEAD"
2223
+ ? lastPoint.x - width - edgeLabelSpacing
2224
+ : midpointX - width / 2;
1476
2225
  label.y = labelY;
1477
2226
  labelY += (label.height ?? 0) + labelLabelSpacing;
1478
2227
  }
1479
2228
  }
1480
2229
  if (!resultPolicy?.skipBoundsNormalization) {
1481
- normalizeElkGraphBounds(root, padding, layoutOptions);
2230
+ normalizeElkGraphBounds(
2231
+ root,
2232
+ padding,
2233
+ layoutOptions,
2234
+ resultPolicy?.normalizationBounds,
2235
+ resultPolicy?.placementCrossBounds,
2236
+ );
2237
+ }
2238
+ if (
2239
+ !resultPolicy?.preserveEdgeSections &&
2240
+ String(getOption(layoutOptions, "edgeRouting") ?? "ORTHOGONAL") === "ORTHOGONAL"
2241
+ ) {
2242
+ const direction = getDirection(layoutOptions);
2243
+ applyOrthogonalJunctions(
2244
+ root,
2245
+ direction === "right" || direction === "left",
2246
+ direction === "right" || direction === "down",
2247
+ getBooleanOption(layoutOptions, "layered.mergeEdges") === true,
2248
+ new Set(
2249
+ (root.children ?? [])
2250
+ .filter((n) => getBooleanOption(n.layoutOptions ?? {}, "hypernode") === true)
2251
+ .map((n) => String(n.id)),
2252
+ ),
2253
+ new Set(
2254
+ (root.edges ?? [])
2255
+ .filter(
2256
+ (e) =>
2257
+ getBooleanOption(e.layoutOptions ?? {}, "noLayout") === true ||
2258
+ resultPolicy?.junctionPointsByEdgeId?.has(String(e.id)) === true,
2259
+ )
2260
+ .map((e) => String(e.id)),
2261
+ ),
2262
+ );
1482
2263
  }
1483
2264
  }
1484
2265
 
@@ -1486,15 +2267,21 @@ function normalizeElkGraphBounds(
1486
2267
  root: ElkNode,
1487
2268
  padding: { top: number; right: number; bottom: number; left: number },
1488
2269
  layoutOptions: Readonly<Record<string, unknown>> = {},
2270
+ compactionBounds?: Elkjs0111ResultPolicy["normalizationBounds"],
2271
+ placementCrossBounds?: Elkjs0111ResultPolicy["placementCrossBounds"],
1489
2272
  ): void {
2273
+ // Edge coordinates belong to their layout container, not their JSON storage owner.
2274
+ const layoutEdges = (root.edges ?? []).filter(
2275
+ (edge) => edge.container == null || String(edge.container) === String(root.id),
2276
+ );
1490
2277
  const authoredWidth = root.width;
1491
2278
  const authoredHeight = root.height;
1492
2279
  const fixedGraphSize = getBooleanOption(layoutOptions, "nodeSize.fixedGraphSize") === true;
1493
2280
  const layoutChildren = (root.children ?? []).filter(
1494
2281
  (node) => getBooleanOption(node.layoutOptions ?? {}, "noLayout") !== true,
1495
2282
  );
1496
- let minimumX = Number.POSITIVE_INFINITY;
1497
- let minimumY = Number.POSITIVE_INFINITY;
2283
+ let minimumX = compactionBounds?.left ?? Number.POSITIVE_INFINITY;
2284
+ let minimumY = compactionBounds?.top ?? Number.POSITIVE_INFINITY;
1498
2285
  for (const node of layoutChildren) {
1499
2286
  minimumX = Math.min(
1500
2287
  minimumX,
@@ -1515,7 +2302,7 @@ function normalizeElkGraphBounds(
1515
2302
  ),
1516
2303
  );
1517
2304
  }
1518
- for (const edge of root.edges ?? []) {
2305
+ for (const edge of layoutEdges) {
1519
2306
  const laidOutLabels = (edge.labels ?? []).filter(isLayoutEdgeLabel);
1520
2307
  minimumX = Math.min(minimumX, ...laidOutLabels.map((label) => label.x ?? 0));
1521
2308
  minimumY = Math.min(minimumY, ...laidOutLabels.map((label) => label.y ?? 0));
@@ -1536,13 +2323,17 @@ function normalizeElkGraphBounds(
1536
2323
  node.x = (node.x ?? 0) + shiftX;
1537
2324
  node.y = (node.y ?? 0) + shiftY;
1538
2325
  }
1539
- for (const edge of root.edges ?? []) {
2326
+ for (const edge of layoutEdges) {
1540
2327
  for (const section of edge.sections ?? []) {
1541
2328
  for (const point of [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]) {
1542
2329
  point.x += shiftX;
1543
2330
  point.y += shiftY;
1544
2331
  }
1545
2332
  }
2333
+ for (const point of edge.junctionPoints ?? []) {
2334
+ point.x += shiftX;
2335
+ point.y += shiftY;
2336
+ }
1546
2337
  for (const label of (edge.labels ?? []).filter(isLayoutEdgeLabel)) {
1547
2338
  label.x = (label.x ?? 0) + shiftX;
1548
2339
  label.y = (label.y ?? 0) + shiftY;
@@ -1557,7 +2348,7 @@ function normalizeElkGraphBounds(
1557
2348
  0,
1558
2349
  ...layoutChildren.map((node) => (node.y ?? 0) + (node.height ?? 0)),
1559
2350
  );
1560
- const layoutEdgePoints = (root.edges ?? []).flatMap((edge) =>
2351
+ const layoutEdgePoints = layoutEdges.flatMap((edge) =>
1561
2352
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1562
2353
  ? []
1563
2354
  : (edge.sections ?? []).flatMap((section) => [
@@ -1573,7 +2364,7 @@ function normalizeElkGraphBounds(
1573
2364
  for (const child of root.children ?? []) {
1574
2365
  for (const port of child.ports ?? []) childByEndpointId.set(String(port.id), child);
1575
2366
  }
1576
- const wrappedEdgeCount = (root.edges ?? []).filter((edge) => {
2367
+ const wrappedEdgeCount = layoutEdges.filter((edge) => {
1577
2368
  const source = childByEndpointId.get(String(edge.sources?.[0] ?? edge.source));
1578
2369
  const target = childByEndpointId.get(String(edge.targets?.[0] ?? edge.target));
1579
2370
  if (!source || !target) return false;
@@ -1586,24 +2377,44 @@ function normalizeElkGraphBounds(
1586
2377
  : (source.y ?? 0) < (target.y ?? 0);
1587
2378
  }).length;
1588
2379
  const hasWrappedEdge = wrappedEdgeCount > 0;
1589
- const addBoundaryPixel =
2380
+ const retainPlacementBounds =
1590
2381
  wrappingStrategy !== "MULTI_EDGE" &&
1591
2382
  !(wrappingStrategy === "SINGLE_EDGE" && hasWrappedEdge) &&
1592
2383
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") ===
1593
2384
  "NONE" &&
1594
2385
  getBooleanOption(layoutOptions, "layered.feedbackEdges") !== true &&
1595
2386
  String(getOption(layoutOptions, "layered.layering.nodePromotion.strategy") ?? "NONE") !==
1596
- "MODEL_ORDER_LEFT_TO_RIGHT" &&
1597
- !(root.edges ?? []).some((edge) => edge.sources?.[0] === edge.targets?.[0]) &&
2387
+ "MODEL_ORDER_LEFT_TO_RIGHT";
2388
+ // Actual helper extents survive nested scopes. The legacy route-pixel
2389
+ // allowance still excludes compound scopes.
2390
+ const addBoundaryPixel =
2391
+ retainPlacementBounds &&
1598
2392
  !(root.children ?? []).some((child) => (child.children?.length ?? 0) > 0);
2393
+ // Prefer the retained placement extent on the cross axis. Other placers
2394
+ // retain the legacy route-bound allowance until they expose phase bounds.
2395
+ // Loop envelopes own their bounds. Ordinary long-edge dummies still have
2396
+ // a one-pixel cross-axis extent when another edge in the graph is a loop.
2397
+ // Apply that allowance to route points, before unioning labels and margins.
2398
+ const isSelfLoop = (edge: ElkEdge) => {
2399
+ const source = childByEndpointId.get(String(edge.sources?.[0] ?? edge.source));
2400
+ return (
2401
+ source !== undefined &&
2402
+ source === childByEndpointId.get(String(edge.targets?.[0] ?? edge.target))
2403
+ );
2404
+ };
2405
+ const hasSelfLoops = layoutEdges.some(isSelfLoop);
1599
2406
  const edgeBoundsExtraX =
1600
2407
  addBoundaryPixel &&
2408
+ placementCrossBounds?.axis !== "x" &&
2409
+ !(hasSelfLoops && (direction === "right" || direction === "left")) &&
1601
2410
  layoutEdgePoints.length > 0 &&
1602
2411
  Math.max(...layoutEdgePoints.map((point) => point.x)) >= maximumNodeX - 1e-9
1603
2412
  ? 1
1604
2413
  : 0;
1605
2414
  const edgeBoundsExtraY =
1606
2415
  addBoundaryPixel &&
2416
+ placementCrossBounds?.axis !== "y" &&
2417
+ !(hasSelfLoops && (direction === "down" || direction === "up")) &&
1607
2418
  layoutEdgePoints.length > 0 &&
1608
2419
  Math.max(...layoutEdgePoints.map((point) => point.y)) >= maximumNodeY - 1e-9
1609
2420
  ? 1
@@ -1616,6 +2427,7 @@ function normalizeElkGraphBounds(
1616
2427
  ? 1
1617
2428
  : 0;
1618
2429
  const postCompactionBoundsExtraX =
2430
+ !compactionBounds &&
1619
2431
  !addBoundaryPixel &&
1620
2432
  !hasWrappedEdge &&
1621
2433
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") !==
@@ -1625,6 +2437,7 @@ function normalizeElkGraphBounds(
1625
2437
  ? 0.04
1626
2438
  : 0;
1627
2439
  const postCompactionBoundsExtraY =
2440
+ !compactionBounds &&
1628
2441
  !addBoundaryPixel &&
1629
2442
  !hasWrappedEdge &&
1630
2443
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") !==
@@ -1635,7 +2448,10 @@ function normalizeElkGraphBounds(
1635
2448
  : 0;
1636
2449
  const calculatedWidth =
1637
2450
  Math.max(
1638
- 0,
2451
+ compactionBounds ? compactionBounds.right + shiftX : 0,
2452
+ retainPlacementBounds && placementCrossBounds?.axis === "x"
2453
+ ? placementCrossBounds.maximum + shiftX
2454
+ : 0,
1639
2455
  ...layoutChildren.flatMap((node) => [
1640
2456
  (node.x ?? 0) + (node.width ?? 0),
1641
2457
  ...(node.labels ?? []).map((label) => (node.x ?? 0) + (label.x ?? 0) + (label.width ?? 0)),
@@ -1646,23 +2462,24 @@ function normalizeElkGraphBounds(
1646
2462
  ),
1647
2463
  ),
1648
2464
  ]),
1649
- ...(root.edges ?? []).flatMap((edge) =>
2465
+ ...layoutEdges.flatMap((edge) =>
1650
2466
  (edge.labels ?? [])
1651
2467
  .filter(isLayoutEdgeLabel)
1652
2468
  .map((label) => (label.x ?? 0) + (label.width ?? 0)),
1653
2469
  ),
1654
- ...(root.edges ?? []).flatMap((edge) =>
2470
+ ...layoutEdges.flatMap((edge) =>
1655
2471
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1656
2472
  ? []
1657
2473
  : (edge.sections ?? []).flatMap((section) => [
1658
- section.startPoint.x,
1659
- ...(section.bendPoints ?? []).map((point) => point.x),
1660
- section.endPoint.x,
2474
+ section.startPoint.x + (isSelfLoop(edge) ? 0 : edgeBoundsExtraX),
2475
+ ...(section.bendPoints ?? []).map(
2476
+ (point) => point.x + (isSelfLoop(edge) ? 0 : edgeBoundsExtraX),
2477
+ ),
2478
+ section.endPoint.x + (isSelfLoop(edge) ? 0 : edgeBoundsExtraX),
1661
2479
  ]),
1662
2480
  ),
1663
2481
  ) +
1664
2482
  padding.right +
1665
- edgeBoundsExtraX +
1666
2483
  postCompactionBoundsExtraX +
1667
2484
  (getBooleanOption(layoutOptions, "layered.feedbackEdges") === true &&
1668
2485
  (getDirection(layoutOptions) === "down" || getDirection(layoutOptions) === "up")
@@ -1670,7 +2487,10 @@ function normalizeElkGraphBounds(
1670
2487
  : 0);
1671
2488
  const calculatedHeight =
1672
2489
  Math.max(
1673
- 0,
2490
+ compactionBounds ? compactionBounds.bottom + shiftY : 0,
2491
+ retainPlacementBounds && placementCrossBounds?.axis === "y"
2492
+ ? placementCrossBounds.maximum + shiftY
2493
+ : 0,
1674
2494
  ...layoutChildren.flatMap((node) => [
1675
2495
  (node.y ?? 0) + (node.height ?? 0),
1676
2496
  ...(node.labels ?? []).map((label) => (node.y ?? 0) + (label.y ?? 0) + (label.height ?? 0)),
@@ -1681,7 +2501,7 @@ function normalizeElkGraphBounds(
1681
2501
  ),
1682
2502
  ),
1683
2503
  ]),
1684
- ...(root.edges ?? []).flatMap((edge) =>
2504
+ ...layoutEdges.flatMap((edge) =>
1685
2505
  (edge.labels ?? [])
1686
2506
  .filter(isLayoutEdgeLabel)
1687
2507
  .map(
@@ -1689,23 +2509,26 @@ function normalizeElkGraphBounds(
1689
2509
  (label.y ?? 0) +
1690
2510
  (label.height ?? 0) +
1691
2511
  (String(getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER") ===
1692
- "CENTER"
2512
+ "CENTER" &&
2513
+ childByEndpointId.get(String(edge.sources?.[0] ?? edge.source)) !==
2514
+ childByEndpointId.get(String(edge.targets?.[0] ?? edge.target))
1693
2515
  ? 1
1694
2516
  : 0),
1695
2517
  ),
1696
2518
  ),
1697
- ...(root.edges ?? []).flatMap((edge) =>
2519
+ ...layoutEdges.flatMap((edge) =>
1698
2520
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1699
2521
  ? []
1700
2522
  : (edge.sections ?? []).flatMap((section) => [
1701
- section.startPoint.y,
1702
- ...(section.bendPoints ?? []).map((point) => point.y),
1703
- section.endPoint.y,
2523
+ section.startPoint.y + (isSelfLoop(edge) ? 0 : edgeBoundsExtraY),
2524
+ ...(section.bendPoints ?? []).map(
2525
+ (point) => point.y + (isSelfLoop(edge) ? 0 : edgeBoundsExtraY),
2526
+ ),
2527
+ section.endPoint.y + (isSelfLoop(edge) ? 0 : edgeBoundsExtraY),
1704
2528
  ]),
1705
2529
  ),
1706
2530
  ) +
1707
2531
  padding.bottom +
1708
- edgeBoundsExtraY +
1709
2532
  singleMultiEdgeCutBoundsExtraY +
1710
2533
  postCompactionBoundsExtraY +
1711
2534
  (getBooleanOption(layoutOptions, "layered.feedbackEdges") === true &&
@@ -1903,3 +2726,123 @@ export type {
1903
2726
 
1904
2727
  export { getElkRoutes } from "./routes";
1905
2728
  export type { ElkRouteOptions } from "./routes";
2729
+
2730
+ /** Edges crossing `group`, in the order ELK's CompoundGraphPreprocessor creates its boundary ports. */
2731
+ function elkBoundaryEdgeOrder(
2732
+ group: ElkNode,
2733
+ scope: ElkNode,
2734
+ originals: ReadonlyMap<ElkEdge, { sourceId: string; targetId: string }>,
2735
+ ): ElkEdge[] {
2736
+ const edges: ElkEdge[] = [];
2737
+ const collect = (node: ElkNode) => {
2738
+ edges.push(...(node.edges ?? []));
2739
+ for (const child of node.children ?? []) collect(child);
2740
+ };
2741
+ collect(scope);
2742
+ const endpoints = (edge: ElkEdge) =>
2743
+ originals.get(edge) ?? {
2744
+ sourceId: String(edge.sources?.[0] ?? edge.source),
2745
+ targetId: String(edge.targets?.[0] ?? edge.target),
2746
+ };
2747
+ const order = (node: ElkNode): ElkEdge[] => {
2748
+ const inside = new Set<string>();
2749
+ const mark = (parent: ElkNode) => {
2750
+ for (const child of parent.children ?? []) {
2751
+ inside.add(String(child.id));
2752
+ for (const port of child.ports ?? []) inside.add(String(port.id));
2753
+ mark(child);
2754
+ }
2755
+ };
2756
+ mark(node);
2757
+ const leaves = (edge: ElkEdge) =>
2758
+ inside.has(endpoints(edge).sourceId) !== inside.has(endpoints(edge).targetId);
2759
+ const result = new Set<ElkEdge>();
2760
+ for (const child of node.children ?? [])
2761
+ if (child.children?.length)
2762
+ for (const edge of order(child)) if (leaves(edge)) result.add(edge);
2763
+ for (const child of node.children ?? []) {
2764
+ for (const port of [...(child.ports ?? []).map((p) => String(p.id)), String(child.id)]) {
2765
+ for (const edge of edges)
2766
+ if (endpoints(edge).sourceId === port && leaves(edge)) result.add(edge);
2767
+ for (const edge of edges)
2768
+ if (endpoints(edge).targetId === port && leaves(edge)) result.add(edge);
2769
+ }
2770
+ }
2771
+ return [...result];
2772
+ };
2773
+ return order(group);
2774
+ }
2775
+
2776
+ /** Creation order of hierarchy segments in ELK's CompoundGraphPreprocessor. */
2777
+ const hierarchySegmentRank = new WeakMap<ElkEdge, number>();
2778
+ const segmentOrderByScope = new WeakMap<ElkNode, ReadonlyMap<string, number>>();
2779
+ const originalEdgeId = new WeakMap<ElkEdge, string>();
2780
+
2781
+ /**
2782
+ * Segments inside `group`, in creation order: the inner pass connects child
2783
+ * groups' exported ports first, then the outer pass leaves `group` itself.
2784
+ */
2785
+ function elkSegmentOrder(
2786
+ group: ElkNode,
2787
+ scope: ElkNode,
2788
+ originals: ReadonlyMap<ElkEdge, { sourceId: string; targetId: string }>,
2789
+ ownBoundary: boolean,
2790
+ ): Map<string, number> {
2791
+ const order = new Map<string, number>();
2792
+ const add = (edge: ElkEdge) => {
2793
+ const id = originalEdgeId.get(edge) ?? String(edge.id);
2794
+ if (!order.has(id)) order.set(id, order.size);
2795
+ };
2796
+ for (const child of group.children ?? [])
2797
+ if (child.children?.length)
2798
+ for (const edge of elkBoundaryEdgeOrder(child, scope, originals)) add(edge);
2799
+ if (ownBoundary) for (const edge of elkBoundaryEdgeOrder(group, scope, originals)) add(edge);
2800
+ return order;
2801
+ }
2802
+
2803
+ const isPostCompactionKey = (key: string) =>
2804
+ /(^|\.)compaction\.postCompaction\.strategy$/.test(key);
2805
+ function usesPostCompaction(options: Readonly<Record<string, unknown>>, node: ElkNode): boolean {
2806
+ const active = (values: Readonly<Record<string, unknown>> | undefined) =>
2807
+ Object.entries(values ?? {}).some(
2808
+ ([key, value]) => isPostCompactionKey(key) && String(value) !== "NONE",
2809
+ );
2810
+ const visit = (current: ElkNode): boolean =>
2811
+ active(current.layoutOptions) ||
2812
+ active(current.properties as Record<string, unknown> | undefined) ||
2813
+ (current.children ?? []).some(visit);
2814
+ return active(options) || visit(node);
2815
+ }
2816
+
2817
+ function disablePostCompaction<T extends ElkNode>(node: T): T {
2818
+ for (const values of [node.layoutOptions, node.properties as Record<string, unknown> | undefined])
2819
+ for (const key of Object.keys(values ?? {}))
2820
+ if (isPostCompactionKey(key)) (values as Record<string, unknown>)[key] = "NONE";
2821
+ for (const child of node.children ?? []) disablePostCompaction(child);
2822
+ return node;
2823
+ }
2824
+
2825
+ /**
2826
+ * Alternative random seeds worth trying for a compound layout, unless the
2827
+ * graph chooses its hierarchical greedy switch explicitly.
2828
+ */
2829
+ function crossingVariants(
2830
+ options: Readonly<Record<string, unknown>>,
2831
+ graph: ElkNode,
2832
+ ): Array<Record<string, string>> {
2833
+ const own = { ...options, ...graph.properties, ...graph.layoutOptions };
2834
+ const compound = (graph.children ?? []).some((child) => child.children?.length);
2835
+ if (
2836
+ !compound ||
2837
+ getOption(own, "hierarchyHandling") !== "INCLUDE_CHILDREN" ||
2838
+ getOption(own, "layered.crossingMinimization.greedySwitchHierarchical.type") !== undefined
2839
+ )
2840
+ return [];
2841
+ return [{ "elk.randomSeed": "2" }, { "elk.randomSeed": "3" }];
2842
+ }
2843
+
2844
+ /** Whether the graph asks for more than ELK's default layered thoroughness (7). */
2845
+ function thorough(options: Readonly<Record<string, unknown>>, graph: ElkNode): boolean {
2846
+ const own = { ...options, ...graph.properties, ...graph.layoutOptions };
2847
+ return Number(getOption(own, "layered.thoroughness") ?? 7) > 7;
2848
+ }