@statelyai/layout 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +221 -13
  3. package/dist/elkjs/index.cjs +3 -2
  4. package/dist/elkjs/index.d.cts +13 -2
  5. package/dist/elkjs/index.d.mts +13 -2
  6. package/dist/elkjs/index.mjs +3 -3
  7. package/dist/elkjs/main.cjs +2 -2
  8. package/dist/elkjs/main.d.cts +2 -2
  9. package/dist/elkjs/main.d.mts +2 -2
  10. package/dist/elkjs/main.mjs +2 -2
  11. package/dist/elkjs/worker-api.cjs +1 -1
  12. package/dist/elkjs/worker-api.d.cts +2 -2
  13. package/dist/elkjs/worker-api.d.mts +2 -2
  14. package/dist/elkjs/worker-api.mjs +1 -1
  15. package/dist/elkjs/worker.cjs +2 -2
  16. package/dist/elkjs/worker.mjs +2 -2
  17. package/dist/elkjs-UciYx7lD.mjs +2973 -0
  18. package/dist/elkjs-ggjsBuVP.cjs +3002 -0
  19. package/dist/index-B6cWNrMw.d.cts +80 -0
  20. package/dist/index-CJQLUKD9.d.mts +80 -0
  21. package/dist/{index-BSH2bloA.d.mts → index-DT6lPMfS.d.cts} +117 -5
  22. package/dist/{index-CG712jzV.d.cts → index-F1U-fCTu.d.mts} +117 -5
  23. package/dist/index.cjs +39 -9
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.mts +4 -3
  26. package/dist/index.mjs +4 -3
  27. package/dist/layered/index.cjs +3 -1
  28. package/dist/layered/index.d.cts +3 -2
  29. package/dist/layered/index.d.mts +3 -2
  30. package/dist/layered/index.mjs +2 -2
  31. package/dist/{layered-Bz2nWmVp.mjs → layered-C7ERzZjR.mjs} +12592 -4682
  32. package/dist/{layered-Cf9Rn8l7.cjs → layered-CBPg6_6_.cjs} +12711 -4717
  33. package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  34. package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
  35. package/dist/routing/index.cjs +29 -0
  36. package/dist/routing/index.d.cts +3 -0
  37. package/dist/routing/index.d.mts +3 -0
  38. package/dist/routing/index.mjs +3 -0
  39. package/dist/routing-CR8flv8b.mjs +2780 -0
  40. package/dist/routing-QIdGGFBe.cjs +2996 -0
  41. package/dist/types-CT2akdK0.d.mts +133 -0
  42. package/dist/types-uxPlGRof.d.cts +133 -0
  43. package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
  44. package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
  45. package/package.json +14 -3
  46. package/src/authoring/coordinates.ts +38 -10
  47. package/src/authoring/partial.ts +7 -2
  48. package/src/authoring/routing.ts +4 -1
  49. package/src/constraints.ts +174 -6
  50. package/src/elkjs/index.ts +1397 -452
  51. package/src/elkjs/layout-quality.ts +219 -0
  52. package/src/elkjs/opposing-tracks.ts +161 -0
  53. package/src/elkjs/orthogonal-junctions.ts +174 -0
  54. package/src/elkjs/routes.ts +147 -0
  55. package/src/elkjs/statechart.ts +9 -3
  56. package/src/elkjs/types.ts +2 -0
  57. package/src/index.ts +8 -0
  58. package/src/internal/elkjs-compatibility.ts +4 -1
  59. package/src/internal/layered-trace.ts +48 -0
  60. package/src/internal/layout-engine.ts +2 -1
  61. package/src/java-random.ts +6 -0
  62. package/src/layered/bk-node-placement.ts +403 -92
  63. package/src/layered/center-labels.ts +398 -0
  64. package/src/layered/compaction-bounds.ts +26 -0
  65. package/src/layered/compaction-errors.ts +2 -0
  66. package/src/layered/compaction-scanline.ts +63 -0
  67. package/src/layered/compound-boundaries.ts +99 -0
  68. package/src/layered/compound.ts +445 -0
  69. package/src/layered/constraint-orientation.ts +141 -0
  70. package/src/layered/crossing-constraints.ts +243 -0
  71. package/src/layered/crossing-counter.ts +221 -0
  72. package/src/layered/crossing-graph.ts +401 -0
  73. package/src/layered/cycle-random.ts +22 -0
  74. package/src/layered/elk-port-lists.ts +243 -0
  75. package/src/layered/end-labels.ts +124 -0
  76. package/src/layered/external-port-dummy.ts +137 -0
  77. package/src/layered/feedback-chains.ts +114 -0
  78. package/src/layered/fixed-self-loop.ts +150 -0
  79. package/src/layered/greedy-switch-decider.ts +356 -0
  80. package/src/layered/grouped-compaction.ts +787 -0
  81. package/src/layered/gwt-sort.ts +53 -0
  82. package/src/layered/hierarchical-port-phases.ts +242 -0
  83. package/src/layered/hierarchical-port-restoration.ts +233 -0
  84. package/src/layered/hierarchical-port-routing.ts +179 -0
  85. package/src/layered/hierarchy-crossing.ts +121 -0
  86. package/src/layered/hierarchy-sweepiness.ts +83 -0
  87. package/src/layered/hints.ts +371 -0
  88. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  89. package/src/layered/index.ts +911 -261
  90. package/src/layered/initial-model-order.ts +404 -0
  91. package/src/layered/inverted-ports.ts +207 -0
  92. package/src/layered/label-dummy-switcher.ts +200 -0
  93. package/src/layered/long-edges.ts +135 -44
  94. package/src/layered/loop-envelopes.ts +184 -0
  95. package/src/layered/native-routing.ts +275 -0
  96. package/src/layered/network-simplex.ts +11 -15
  97. package/src/layered/node-margins.ts +59 -0
  98. package/src/layered/north-south-ports.ts +271 -0
  99. package/src/layered/opposing-tracks.ts +944 -0
  100. package/src/layered/orthogonal-cycle-order.ts +106 -0
  101. package/src/layered/orthogonal-hypersegments.ts +92 -0
  102. package/src/layered/orthogonal-junctions.ts +202 -0
  103. package/src/layered/orthogonal-segments.ts +243 -0
  104. package/src/layered/placement-orientation.ts +22 -0
  105. package/src/layered/port-distributor.ts +192 -0
  106. package/src/layered/port-edge-order.ts +20 -0
  107. package/src/layered/replace-routing.ts +45 -0
  108. package/src/layered/routing-coordinates.ts +32 -0
  109. package/src/layered/separate-exterior-labels.ts +24 -6
  110. package/src/layered/spacing.ts +34 -12
  111. package/src/layered/splitter-order.ts +106 -0
  112. package/src/layered/strategies.ts +2024 -883
  113. package/src/layered/types.ts +34 -0
  114. package/src/layered/weighted-compaction.ts +84 -0
  115. package/src/routing/adapters.ts +143 -0
  116. package/src/routing/algorithms.ts +1088 -0
  117. package/src/routing/coordination.ts +91 -0
  118. package/src/routing/engine.ts +473 -0
  119. package/src/routing/index.ts +69 -0
  120. package/src/routing/layout-cache.ts +65 -0
  121. package/src/routing/model.ts +218 -0
  122. package/src/routing/path.ts +324 -0
  123. package/src/routing/persistent.ts +117 -0
  124. package/src/routing/search.ts +358 -0
  125. package/src/routing/spatial.ts +78 -0
  126. package/src/routing/types.ts +146 -0
  127. package/dist/elkjs-BVnDwnBQ.cjs +0 -1551
  128. package/dist/elkjs-CFlfkEBz.mjs +0 -1528
  129. /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
  130. /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
  131. /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
  132. /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
@@ -1,5 +1,29 @@
1
- import { createGraph, type Graph } from "@statelyai/graph";
2
- import type { getLayeredLayout } from "../layered";
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";
26
+ import { createGraph, type Graph, type VisualGraph } from "@statelyai/graph";
3
27
  import {
4
28
  elkLayeredOptionDefinitions,
5
29
  type ElkLayeredOptionValueByName,
@@ -29,6 +53,7 @@ import type {
29
53
  } from "./public-types";
30
54
 
31
55
  function isLayoutEdgeLabel(label: ElkLabel): boolean {
56
+ // Unlike ELK, a dimensioned label without text still reserves space: apps often render the text themselves.
32
57
  return (
33
58
  getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true &&
34
59
  (Boolean(label.text) || label.width !== undefined || label.height !== undefined)
@@ -58,6 +83,15 @@ export type {
58
83
  } from "./public-types";
59
84
  export type { ElkId, ElkLogging } from "./types";
60
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
+
61
95
  export default class ELK {
62
96
  readonly #options: ElkConstructorArguments;
63
97
  readonly #algorithmIds: ReadonlySet<string>;
@@ -129,17 +163,74 @@ export default class ELK {
129
163
  arguments_?: PublicElkLayoutArguments,
130
164
  ): Promise<PublicElkNode & PublicLaidOutElkNode<T>>;
131
165
  layout<T extends ElkNode>(graph: T, arguments_?: ElkLayoutArguments): Promise<LaidOutElkNode<T>>;
132
- layout<T extends ElkNode>(
166
+ async layout<T extends ElkNode>(
133
167
  graph: T,
134
168
  arguments_: ElkLayoutArguments = {},
135
169
  ): Promise<LaidOutElkNode<T>> {
136
- 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;
137
217
  }
138
218
 
139
219
  async #layout<T extends ElkNode>(
140
220
  graph: T,
141
221
  arguments_: ElkLayoutArguments = {},
222
+ compoundLayout = false,
142
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> {
143
234
  const startedAt = performance.now();
144
235
  if (graph === undefined || graph === null) {
145
236
  throw new TypeError("Missing mandatory parameter: graph");
@@ -202,6 +293,8 @@ export default class ELK {
202
293
  `org.eclipse.elk.core.UnsupportedConfigurationException: Layout algorithm '${requestedAlgorithm}' not found`,
203
294
  );
204
295
  }
296
+ const preparedChildren = new Map<string, PreparedElkScope>();
297
+ const finishChildren: Array<() => Promise<void>> = [];
205
298
  const hasHierarchy = (graph.children ?? []).some((child) => (child.children?.length ?? 0) > 0);
206
299
  const insideSelfLoopBaseHeightByNodeId = new Map<string, number>();
207
300
  const hierarchyRestorations: Array<{
@@ -211,10 +304,16 @@ export default class ELK {
211
304
  source?: ElkId;
212
305
  target?: ElkId;
213
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
+ >();
214
314
  const syntheticPortIds = new Set<string>();
215
315
  const authoredPortsByCompound = new Map<ElkNode, ElkPort[] | undefined>();
216
316
  const authoredOptionsByCompound = new Map<ElkNode, Record<string, unknown> | undefined>();
217
- const hierarchyBoundaryCountByEdge = new Map<ElkEdge, number>();
218
317
  const originalHierarchyEndpoints = new Map(
219
318
  (graph.edges ?? []).map((edge) => [
220
319
  edge,
@@ -228,7 +327,6 @@ export default class ELK {
228
327
  },
229
328
  ]),
230
329
  );
231
- let hasHierarchyCrossingEdges = false;
232
330
  const hierarchyHandling = getOption(layoutOptions, "hierarchyHandling");
233
331
  const topdownLayout = getBooleanOption(layoutOptions, "topdownLayout") === true;
234
332
  const separateHierarchy =
@@ -297,17 +395,15 @@ export default class ELK {
297
395
  );
298
396
  }
299
397
  } else if (hasHierarchy && !separateHierarchy) {
300
- // The probability only chooses between equivalent top-down and bottom-up
301
- // sweep schedules. The deterministic proxy decomposition below preserves
302
- // the resulting exported order for either schedule.
303
- 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);
304
402
  for (const child of graph.children ?? []) {
305
403
  if ((child.children?.length ?? 0) === 0) continue;
306
- const descendantById = new Map<string, ElkNode>();
307
404
  const descendantOwnerByEndpointId = new Map<string, ElkNode>();
308
405
  const collectDescendants = (node: ElkNode): void => {
309
406
  for (const descendant of node.children ?? []) {
310
- descendantById.set(String(descendant.id), descendant);
311
407
  descendantOwnerByEndpointId.set(String(descendant.id), descendant);
312
408
  for (const port of descendant.ports ?? []) {
313
409
  descendantOwnerByEndpointId.set(String(port.id), descendant);
@@ -322,198 +418,611 @@ export default class ELK {
322
418
  descendantOwnerByEndpointId.has(sourceId) && descendantOwnerByEndpointId.has(targetId)
323
419
  );
324
420
  });
325
- 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) => {
326
432
  const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
327
433
  const sourceInside = descendantOwnerByEndpointId.has(sourceId);
328
434
  const targetInside = descendantOwnerByEndpointId.has(targetId);
329
435
  return sourceInside !== targetInside;
330
436
  });
331
- for (const edge of crossingEdges) {
332
- hierarchyBoundaryCountByEdge.set(edge, (hierarchyBoundaryCountByEdge.get(edge) ?? 0) + 1);
333
- }
334
- 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
+ );
335
451
  const mergeHierarchyEdges =
336
452
  getBooleanOption(layoutOptions, "layered.mergeHierarchyEdges") !== false;
337
- // The option changes the number of internal external-port dummies. They
338
- // are removed before elkjs serialization and do not alter the exported
339
- // geometry for a shared hierarchy boundary.
340
- void mergeHierarchyEdges;
453
+ // ELK merges only edges incident to the same descendant port.
341
454
  const proxyByKind = new Map<string, ElkNode>();
342
- const proxyFor = (kind: "input" | "output"): ElkNode => {
343
- 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 => {
344
499
  let proxy = proxyByKind.get(key);
345
500
  if (!proxy) {
346
- proxy = {
347
- id: `__native_hierarchy_${String(child.id)}_${key.replace(/[^a-zA-Z0-9]/g, "_")}`,
348
- width: 0,
349
- height: 0,
350
- };
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
+ );
351
561
  proxyByKind.set(key, proxy);
352
562
  }
353
563
  return proxy;
354
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.
355
590
  const temporaryEdges: ElkEdge[] = [
356
- ...(child.edges ?? []),
357
- ...internalEdges.filter(
358
- (edge) =>
359
- !(child.edges ?? []).some((candidate) => String(candidate.id) === String(edge.id)),
360
- ),
361
591
  ...crossingEdges.map((edge) => {
362
592
  const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
363
593
  const sourceInside = descendantOwnerByEndpointId.has(sourceId);
364
- const proxy = proxyFor(sourceInside ? "output" : "input");
365
- return {
594
+ const proxy = proxyFor(sourceInside ? "output" : "input", edge);
595
+ const segment: ElkEdge = {
366
596
  ...edge,
367
597
  id: `__native_hierarchy_edge_${String(child.id)}_${String(edge.id)}`,
368
- sources: [sourceInside ? sourceId : proxy.id!],
369
- targets: [sourceInside ? proxy.id! : targetId],
598
+ sources: [sourceInside ? sourceId : proxy.ports![0]!.id!],
599
+ targets: [sourceInside ? proxy.ports![0]!.id! : targetId],
370
600
  source: undefined,
371
601
  target: undefined,
372
602
  sections: undefined,
373
603
  };
604
+ originalEdgeId.set(segment, originalEdgeId.get(edge) ?? String(edge.id));
605
+ return segment;
374
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
+ ),
375
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
+ }
376
640
  const temporaryChild: ElkNode = {
377
641
  ...child,
642
+ layoutOptions: proxyByKind.size
643
+ ? { ...child.layoutOptions, "elk.portConstraints": "FIXED_SIDE" }
644
+ : child.layoutOptions,
378
645
  children: [...(child.children ?? []), ...proxyByKind.values()],
379
646
  edges: temporaryEdges,
380
647
  };
381
- await this.#layout(temporaryChild, {
382
- ...arguments_,
383
- layoutOptions: {
384
- ...arguments_.layoutOptions,
385
- 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,
386
672
  },
387
- logging: false,
388
- measureExecutionTime: false,
389
- });
390
- const childPadding = parsePadding(
391
- getOption({ ...layoutOptions, ...child.layoutOptions }, "padding"),
392
- 12,
673
+ true,
393
674
  );
394
- if (proxyByKind.has("input")) {
395
- const direction = getDirection(layoutOptions);
396
- const horizontal = direction === "right" || direction === "left";
397
- const increasing = direction === "right" || direction === "down";
398
- if (increasing) {
399
- const minimumFlow = Math.min(
400
- ...(child.children ?? []).map((node) => (horizontal ? (node.x ?? 0) : (node.y ?? 0))),
401
- );
402
- const desiredFlow = horizontal ? childPadding.left : childPadding.top;
403
- const delta = desiredFlow - minimumFlow;
404
- for (const node of child.children ?? []) {
405
- if (horizontal) node.x = (node.x ?? 0) + delta;
406
- else node.y = (node.y ?? 0) + delta;
407
- }
408
- for (const edge of temporaryEdges) {
409
- for (const section of edge.sections ?? []) {
410
- for (const point of [
411
- section.startPoint,
412
- ...(section.bendPoints ?? []),
413
- section.endPoint,
414
- ]) {
415
- if (horizontal) point.x += delta;
416
- else point.y += delta;
417
- }
418
- }
419
- }
420
- }
421
- }
422
- for (const internalEdge of internalEdges) {
423
- const temporary = temporaryEdges.find(
424
- (candidate) => String(candidate.id) === String(internalEdge.id),
425
- );
426
- if (temporary?.sections) internalEdge.sections = temporary.sections;
427
- }
428
- child.width =
429
- Math.max(0, ...(child.children ?? []).map((node) => (node.x ?? 0) + (node.width ?? 0))) +
430
- childPadding.right;
431
- child.height =
432
- Math.max(0, ...(child.children ?? []).map((node) => (node.y ?? 0) + (node.height ?? 0))) +
433
- childPadding.bottom;
434
-
435
- const relativeRect = (id: string): ElkShape | undefined => {
436
- const owner = descendantOwnerByEndpointId.get(id);
437
- if (!owner) return undefined;
438
- const path: ElkNode[] = [];
439
- const visit = (parent: ElkNode): boolean => {
440
- for (const candidate of parent.children ?? []) {
441
- path.push(candidate);
442
- if (candidate === owner || visit(candidate)) return true;
443
- path.pop();
444
- }
445
- return false;
446
- };
447
- if (!visit(child)) return undefined;
448
- const port = owner.ports?.find((candidate) => String(candidate.id) === id);
449
- const ownerX = path.reduce((sum, node) => sum + (node.x ?? 0), 0);
450
- const ownerY = path.reduce((sum, node) => sum + (node.y ?? 0), 0);
451
- if (port) {
452
- return {
453
- x: ownerX + (port.x ?? 0) + (port.width ?? 0) / 2,
454
- y: ownerY + (port.y ?? 0) + (port.height ?? 0) / 2,
455
- width: 0,
456
- height: 0,
457
- };
458
- }
459
- return {
460
- x: ownerX,
461
- y: ownerY,
462
- width: path.at(-1)?.width ?? 0,
463
- height: path.at(-1)?.height ?? 0,
464
- };
465
- };
675
+ preparedChildren.set(String(child.id), preparedChild);
466
676
  authoredPortsByCompound.set(child, child.ports);
467
677
  authoredOptionsByCompound.set(child, child.layoutOptions);
468
- const ports = [...(child.ports ?? [])];
678
+ const initialPorts = (child.ports ?? []).map((port) => ({
679
+ ...port,
680
+ layoutOptions: port.layoutOptions ? { ...port.layoutOptions } : undefined,
681
+ }));
469
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
+ }
470
698
  const original = originalHierarchyEndpoints.get(edge)!;
471
- const { sourceId, targetId } = original;
472
- const sourceInside = descendantOwnerByEndpointId.has(sourceId);
473
- const descendantId = sourceInside ? sourceId : targetId;
474
- const rect = relativeRect(descendantId);
475
- if (!rect) continue;
476
- 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`;
477
703
  syntheticPortIds.add(portId);
478
- const direction = getDirection(layoutOptions);
479
- const outgoing = sourceInside;
480
- const flowForward = direction === "right" || direction === "down";
481
- const useFarSide = outgoing === flowForward;
482
- ports.push({
483
- id: portId,
484
- width: 0,
485
- height: 0,
486
- x:
487
- direction === "right" || direction === "left"
488
- ? (rect.x ?? 0) + (useFarSide ? (rect.width ?? 0) : 0)
489
- : (rect.x ?? 0) + (rect.width ?? 0) / 2,
490
- y:
491
- direction === "down" || direction === "up"
492
- ? (rect.y ?? 0) + (useFarSide ? (rect.height ?? 0) : 0)
493
- : (rect.y ?? 0) + (rect.height ?? 0) / 2,
494
- });
495
- 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))
496
717
  hierarchyRestorations.push({ edge, ...original });
497
- }
498
718
  if (sourceInside) edge.sources = [portId];
499
719
  else edge.targets = [portId];
720
+ const rank = scopeSegmentOrder.get(originalEdgeId.get(edge) ?? String(edge.id));
721
+ if (rank !== undefined) hierarchySegmentRank.set(edge, rank);
500
722
  edge.source = undefined;
501
723
  edge.target = undefined;
502
724
  }
503
- child.ports = ports;
504
- child.layoutOptions = { ...child.layoutOptions, "elk.portConstraints": "FIXED_POS" };
505
- const insideLoopCount = (graph.edges ?? []).filter((edge) =>
506
- isInsideSelfLoop(graph, edge, String(child.id)),
507
- ).length;
508
- if (insideLoopCount > 0) {
509
- const baseHeight = child.height ?? 0;
510
- insideSelfLoopBaseHeightByNodeId.set(String(child.id), baseHeight);
511
- 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
+ };
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;
512
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
+ });
513
1017
  }
514
1018
  }
515
1019
  applyNodeMicroLayout(graph, layoutOptions);
516
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
+ );
517
1026
  const layerConstraintByNodeId = new Map(
518
1027
  (graph.children ?? []).map((node) => [
519
1028
  String(node.id),
@@ -528,6 +1037,10 @@ export default class ELK {
528
1037
  algorithm === "layered" &&
529
1038
  graph_.edges.some((edge) => {
530
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;
531
1044
  const sourceConstraint = layerConstraintByNodeId.get(edge.sourceId);
532
1045
  const targetConstraint = layerConstraintByNodeId.get(edge.targetId);
533
1046
  return (
@@ -544,307 +1057,471 @@ export default class ELK {
544
1057
  getOption(layoutOptions, "padding"),
545
1058
  algorithm === "layered" ? 12 : 0,
546
1059
  );
547
- const laidOut = await (algorithm === "sporeCompaction"
548
- ? executeElkjs0111Layout({
549
- algorithm: "sporeCompaction",
550
- graph: graph_,
551
- options: {
552
- padding,
553
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
554
- },
555
- })
556
- : 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"
557
1176
  ? executeElkjs0111Layout({
558
- algorithm: "sporeOverlap",
1177
+ algorithm: "sporeCompaction",
559
1178
  graph: graph_,
560
1179
  options: {
561
1180
  padding,
562
1181
  spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
563
1182
  },
564
1183
  })
565
- : algorithm === "rectpacking"
1184
+ : algorithm === "sporeOverlap"
566
1185
  ? executeElkjs0111Layout({
567
- algorithm: "rectpacking",
1186
+ algorithm: "sporeOverlap",
568
1187
  graph: graph_,
569
1188
  options: {
570
- padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
571
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode") ?? 15,
1189
+ padding,
1190
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
572
1191
  },
573
1192
  })
574
- : algorithm === "random"
1193
+ : algorithm === "rectpacking"
575
1194
  ? executeElkjs0111Layout({
576
- algorithm: "random",
1195
+ algorithm: "rectpacking",
577
1196
  graph: graph_,
578
1197
  options: {
579
1198
  padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
580
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
581
- aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
582
- seed: getNumberOption(layoutOptions, "randomSeed"),
1199
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode") ?? 15,
583
1200
  },
584
1201
  })
585
- : algorithm === "box"
1202
+ : algorithm === "random"
586
1203
  ? executeElkjs0111Layout({
587
- algorithm: "box",
1204
+ algorithm: "random",
588
1205
  graph: graph_,
589
1206
  options: {
590
1207
  padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
591
1208
  spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
592
1209
  aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
593
- interactive: getBooleanOption(layoutOptions, "interactive"),
594
- expandNodes: getBooleanOption(layoutOptions, "expandNodes"),
595
- priority: (node) => {
596
- const child = graph.children?.find(
597
- (candidate) => String(candidate.id) === node.id,
598
- );
599
- return getNumberOption(child?.layoutOptions ?? {}, "priority");
600
- },
1210
+ seed: getNumberOption(layoutOptions, "randomSeed"),
601
1211
  },
602
1212
  })
603
- : algorithm === "fixed"
1213
+ : algorithm === "box"
604
1214
  ? executeElkjs0111Layout({
605
- algorithm: "fixed",
606
- graph: graph_,
607
- options: { direction: getDirection(layoutOptions) },
608
- })
609
- : executeElkjs0111Layout({
610
- algorithm: "layered",
1215
+ algorithm: "box",
611
1216
  graph: graph_,
612
1217
  options: {
613
- direction: getDirection(layoutOptions),
614
- spacing: {
615
- node:
616
- getNumberOption(layoutOptions, "spacing.nodeNode") ??
617
- getNumberOption(layoutOptions, "layered.spacing.baseValue"),
618
- layer:
619
- (getNumberOption(
620
- layoutOptions,
621
- "layered.spacing.nodeNodeBetweenLayers",
622
- ) ??
623
- getNumberOption(layoutOptions, "layered.spacing.baseValue") ??
624
- 20) +
625
- (hasHierarchyCrossingEdges
626
- ? 5 * Math.max(...hierarchyBoundaryCountByEdge.values())
627
- : 0),
628
- },
629
- padding,
630
- constraints: {
631
- layer: () => undefined,
632
- },
633
- settings: {
634
- ...getLayeredSettings(layoutOptions),
635
- ...(hierarchyHandling === "INCLUDE_CHILDREN" &&
636
- getOption(
637
- layoutOptions,
638
- "layered.crossingMinimization.greedySwitchHierarchical.type",
639
- ) !== undefined
640
- ? {
641
- "crossingMinimization.greedySwitch.type": String(
642
- getOption(
643
- layoutOptions,
644
- "layered.crossingMinimization.greedySwitchHierarchical.type",
645
- ),
646
- ) as LayeredAdvancedOptions["crossingMinimization.greedySwitch.type"],
647
- }
648
- : {}),
649
- },
650
- nodeSettings: (node) => {
651
- const child = graph.children?.find(
652
- (candidate) => String(candidate.id) === node.id,
653
- );
654
- return getElementLayeredSettings(child?.layoutOptions ?? {});
655
- },
656
- edgeSettings: (edge) => {
657
- const elkEdge = graph.edges?.find(
658
- (candidate) => String(candidate.id) === edge.id,
659
- );
660
- return {
661
- ...getElementLayeredSettings(elkEdge?.layoutOptions ?? {}),
662
- ...getElementLayeredSettings(elkEdge?.labels?.[0]?.layoutOptions ?? {}),
663
- };
664
- },
665
- 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) => {
666
1224
  const child = graph.children?.find(
667
1225
  (candidate) => String(candidate.id) === node.id,
668
1226
  );
669
- const elkPort = child?.ports?.find(
670
- (candidate) => String(candidate.id) === port.name,
671
- );
672
- return {
673
- ...getElementLayeredSettings(elkPort?.layoutOptions ?? {}),
674
- "port.labelWidth": Math.max(
675
- 0,
676
- ...(elkPort?.labels ?? []).map((label) => label.width ?? 0),
677
- ),
678
- "port.labelHeight": Math.max(
679
- 0,
680
- ...(elkPort?.labels ?? []).map((label) => label.height ?? 0),
681
- ),
682
- } as ElkLayeredOptionValueByName;
1227
+ return getNumberOption(child?.layoutOptions ?? {}, "priority");
683
1228
  },
684
1229
  },
685
- }));
686
- if (hierarchyRestorations.length > 0) {
687
- const direction = getDirection(layoutOptions);
688
- const horizontal = direction === "right" || direction === "left";
689
- const cross = (point: ElkPoint): number => (horizontal ? point.y : point.x);
690
- const nodeSpacing = getNumberOption(layoutOptions, "spacing.nodeNode") ?? 20;
691
- const shiftsByOutsideId = new Map<string, number[]>();
692
- for (const restoration of hierarchyRestorations) {
693
- const route = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id))?.points;
694
- if (!route || route.length < 2) continue;
695
- const originalSourceId = String(restoration.sources?.[0] ?? restoration.source);
696
- const originalTargetId = String(restoration.targets?.[0] ?? restoration.target);
697
- const sourceInside = !laidOut.nodes.some((node) => node.id === originalSourceId);
698
- const outsideId = sourceInside ? originalTargetId : originalSourceId;
699
- const delta = sourceInside
700
- ? cross(route[0]!) - cross(route.at(-1)!)
701
- : cross(route.at(-1)!) - cross(route[0]!);
702
- const candidates = shiftsByOutsideId.get(outsideId) ?? [];
703
- candidates.push(delta);
704
- shiftsByOutsideId.set(outsideId, candidates);
705
- }
706
- for (const [outsideId, candidates] of shiftsByOutsideId) {
707
- const delta = [...candidates].sort((left, right) => Math.abs(left) - Math.abs(right))[0]!;
708
- const node = laidOut.nodes.find((candidate) => candidate.id === outsideId);
709
- if (!node || Math.abs(delta) < 1e-9 || Math.abs(delta) > nodeSpacing) continue;
710
- if (horizontal) node.y = (node.y ?? 0) + delta;
711
- else node.x = (node.x ?? 0) + delta;
712
- for (const edge of laidOut.edges) {
713
- const points = edge.points;
714
- if (!points || points.length === 0) continue;
715
- if (edge.sourceId === outsideId) {
716
- if (horizontal) points[0]!.y += delta;
717
- else points[0]!.x += delta;
718
- }
719
- if (edge.targetId === outsideId) {
720
- if (horizontal) points.at(-1)!.y += delta;
721
- else points.at(-1)!.x += delta;
722
- }
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;
723
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;
724
1247
  }
725
- const modelOrder = new Map(
726
- (graph.children ?? []).map((node, index) => [String(node.id), index]),
727
- );
728
- const flowLayers = new Map<number, (typeof laidOut.nodes)[number][]>();
729
- for (const node of laidOut.nodes) {
730
- const flow = horizontal ? (node.x ?? 0) : (node.y ?? 0);
731
- const layer = flowLayers.get(flow) ?? [];
732
- layer.push(node);
733
- flowLayers.set(flow, layer);
734
- }
735
- for (const layer of flowLayers.values()) {
736
- layer.sort(
737
- (left, right) =>
738
- (modelOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
739
- (modelOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER),
740
- );
741
- let crossEnd = Number.NEGATIVE_INFINITY;
742
- for (const node of layer) {
743
- const authoredCross = horizontal ? (node.y ?? 0) : (node.x ?? 0);
744
- const compactedCross = Math.max(
745
- authoredCross,
746
- 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,
747
1279
  );
748
- const delta = compactedCross - authoredCross;
749
- if (horizontal) node.y = compactedCross;
750
- else node.x = compactedCross;
751
- if (Math.abs(delta) > 1e-9) {
752
- for (const edge of laidOut.edges) {
753
- const points = edge.points;
754
- if (!points || points.length === 0) continue;
755
- if (edge.sourceId === node.id) {
756
- if (horizontal) points[0]!.y += delta;
757
- else points[0]!.x += delta;
758
- }
759
- if (edge.targetId === node.id) {
760
- if (horizontal) points.at(-1)!.y += delta;
761
- else points.at(-1)!.x += delta;
762
- }
763
- }
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;
764
1317
  }
765
- crossEnd = compactedCross + (horizontal ? node.height : node.width);
766
1318
  }
767
1319
  }
768
- const edgeNodeSpacing = getNumberOption(layoutOptions, "spacing.edgeNodeBetweenLayers") ?? 10;
769
- for (const restoration of hierarchyRestorations) {
770
- const nativeEdge = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id));
771
- const route = nativeEdge?.points;
772
- if (!route || route.length < 2) continue;
773
- const start = route[0]!;
774
- const end = route.at(-1)!;
775
- // Decomposed ancestor/descendant edges are native self-loops. Preserve
776
- // their reserved exterior route and label placement instead of treating
777
- // equal endpoint coordinates as a straight inter-node connection.
778
- if (nativeEdge.sourceId === nativeEdge.targetId) continue;
779
- if (Math.abs(cross(start) - cross(end)) < 1e-9) {
780
- route.splice(1, route.length - 2);
781
- 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
+ });
782
1330
  }
783
- const originalSourceId = String(restoration.sources?.[0] ?? restoration.source);
784
- const sourceInside = !laidOut.nodes.some((node) => node.id === originalSourceId);
785
- const flowForward = direction === "right" || direction === "down";
786
- const track = sourceInside
787
- ? (horizontal ? end.x : end.y) - (flowForward ? edgeNodeSpacing : -edgeNodeSpacing)
788
- : (horizontal ? start.x : start.y) + (flowForward ? edgeNodeSpacing : -edgeNodeSpacing);
789
- route.splice(
790
- 1,
791
- route.length - 2,
792
- horizontal ? { x: track, y: start.y } : { x: start.x, y: track },
793
- horizontal ? { x: track, y: end.y } : { x: end.x, y: track },
794
- );
795
- }
796
- }
797
- const resultPolicy = (
798
- laidOut as typeof laidOut & {
799
- [elkjs0111ResultPolicy]?: Elkjs0111ResultPolicy;
1331
+ compound.ports = ports;
800
1332
  }
801
- )[elkjs0111ResultPolicy];
802
- applyLayout(graph, laidOut, padding, layoutOptions, resultPolicy);
803
- if (resultPolicy?.providerBounds) {
804
- graph.width = resultPolicy.providerBounds.width;
805
- graph.height = resultPolicy.providerBounds.height;
806
- }
807
- for (const restoration of hierarchyRestorations) {
808
- restoration.edge.sources = restoration.sources;
809
- restoration.edge.targets = restoration.targets;
810
- restoration.edge.source = restoration.source;
811
- restoration.edge.target = restoration.target;
812
- for (const section of restoration.edge.sections ?? []) {
813
- if (section.incomingShape != null && syntheticPortIds.has(String(section.incomingShape))) {
814
- section.incomingShape = restoration.sources?.[0] ?? restoration.source;
815
- }
816
- if (section.outgoingShape != null && syntheticPortIds.has(String(section.outgoingShape))) {
817
- 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;
818
1346
  }
819
1347
  }
820
- }
821
- for (const [compound, ports] of authoredPortsByCompound) compound.ports = ports;
822
- for (const [compound, options_] of authoredOptionsByCompound) compound.layoutOptions = options_;
823
- if (hasHierarchy && topdownLayout) {
824
- for (const child of graph.children ?? []) {
825
- if ((child.children?.length ?? 0) === 0) continue;
826
- const position = { x: child.x, y: child.y };
827
- await this.#layout(child, {
828
- ...arguments_,
829
- logging: false,
830
- measureExecutionTime: false,
831
- });
832
- child.x = position.x;
833
- 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
+ };
834
1357
  }
835
- }
836
- applyInsideSelfLoops(graph, insideSelfLoopBaseHeightByNodeId);
837
- if (arguments_.logging || arguments_.measureExecutionTime) {
838
- graph.logging = {
839
- name: "Native TypeScript layout",
840
- children: [{ name: String(algorithm) }],
841
- ...(arguments_.measureExecutionTime
842
- ? { executionTime: (performance.now() - startedAt) / 1_000 }
843
- : {}),
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
+ },
1512
+ };
1513
+ childScope.publishBottomUp = () => {
1514
+ childScope.publishBoundary!(true);
1515
+ childScope.publishBoundary!(false);
1516
+ parentScope.session.lockPortOrder(id);
844
1517
  };
1518
+ scopes.set(child, childScope);
1519
+ (parentScope.childrenByNodeId as Map<string, HierarchyCrossingScope>).set(id, childScope);
845
1520
  }
846
- return graph as LaidOutElkNode<T>;
847
1521
  }
1522
+ const orders = minimizeHierarchyCrossings(scopes.get(root)!);
1523
+
1524
+ return new Map(prepared.map((scope) => [scope, orders.get(scopes.get(scope)!)!]));
848
1525
  }
849
1526
 
850
1527
  function parsePadding(value: unknown, fallback = 0) {
@@ -1084,16 +1761,56 @@ function parseMargin(value: unknown): unknown {
1084
1761
  return parsePadding(value, 0);
1085
1762
  }
1086
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
+
1087
1798
  function getElementLayeredSettings(
1088
1799
  options: Readonly<Record<string, unknown>>,
1089
1800
  ): ElkLayeredOptionValueByName {
1090
1801
  const settings: ElkLayeredOptionValueByName = {};
1091
- for (const definition of elkLayeredOptionDefinitions) {
1092
- const suffix = definition.elkId.replace(/^org\.eclipse\.elk\./, "");
1093
- const value = [definition.name, suffix, `elk.${suffix}`, definition.elkId]
1094
- .map((key) => options[key])
1095
- .find((candidate) => candidate !== undefined);
1096
- 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) {
1097
1814
  const vectorMatch =
1098
1815
  definition.name === "port.anchor" && typeof value === "string"
1099
1816
  ? value.match(/^\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*$/)
@@ -1261,7 +1978,16 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1261
1978
  if (String(getOption(child.layoutOptions ?? {}, "portConstraints")) !== "FIXED_SIDE") {
1262
1979
  return ports;
1263
1980
  }
1264
- 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";
1265
1991
  const sideOrder = ["NORTH", "EAST", "SOUTH", "WEST"];
1266
1992
  ports.sort((left, right) => {
1267
1993
  const leftSide = side(left);
@@ -1288,10 +2014,12 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1288
2014
  const usePortModelOrder =
1289
2015
  getBooleanOption(globalOptions, "layered.considerModelOrder.portModelOrder") === true;
1290
2016
  if (modelOrderStrategy !== "NONE" && !usePortModelOrder) {
1291
- const edgeOrderDifference =
1292
- (edgeModelOrderByPortId.get(String(left.id)) ?? Infinity) -
1293
- (edgeModelOrderByPortId.get(String(right.id)) ?? Infinity);
1294
- 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);
1295
2023
  }
1296
2024
  const direction = leftSide === "WEST" ? -1 : 1;
1297
2025
  return direction * ((child.ports?.indexOf(left) ?? 0) - (child.ports?.indexOf(right) ?? 0));
@@ -1299,7 +2027,7 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1299
2027
  return ports;
1300
2028
  };
1301
2029
 
1302
- return createGraph({
2030
+ const native = createGraph({
1303
2031
  id: String(root.id),
1304
2032
  nodes: children.map((child) => ({
1305
2033
  id: String(child.id),
@@ -1354,6 +2082,12 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1354
2082
  : [];
1355
2083
  }),
1356
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;
1357
2091
  }
1358
2092
 
1359
2093
  function parsePoints(value: unknown): ElkPoint[] | undefined {
@@ -1388,7 +2122,7 @@ function toSection(edge: ElkEdge, points: readonly ElkPoint[]) {
1388
2122
 
1389
2123
  function applyLayout(
1390
2124
  root: ElkNode,
1391
- graph: ReturnType<typeof getLayeredLayout>,
2125
+ graph: VisualGraph,
1392
2126
  padding: { top: number; right: number; bottom: number; left: number },
1393
2127
  layoutOptions: Readonly<Record<string, unknown>>,
1394
2128
  resultPolicy?: Elkjs0111ResultPolicy,
@@ -1432,11 +2166,21 @@ function applyLayout(
1432
2166
  continue;
1433
2167
  }
1434
2168
  const section = getParentEdgeSection(root, edge);
1435
- if (section) edge.sections = [section];
2169
+ if (section) {
2170
+ edge.sections = [section];
2171
+ edge.container = root.id;
2172
+ }
1436
2173
  continue;
1437
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
+ }
1438
2181
  const section = toSection(edge, laidOutEdge.points ?? []);
1439
2182
  edge.sections = section ? [section] : [];
2183
+ if (section) edge.container = root.id;
1440
2184
  const target = root.children?.find(
1441
2185
  (child) => String(child.id) === String(edge.targets?.[0] ?? edge.target),
1442
2186
  );
@@ -1453,6 +2197,8 @@ function applyLayout(
1453
2197
  const midpointX = (laidOutEdge.x ?? 0) + (laidOutEdge.width ?? 0) / 2;
1454
2198
  const edgeLabelSpacing = getNumberOption(layoutOptions, "spacing.edgeLabel") ?? 2;
1455
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;
1456
2202
  for (const label of edge.labels ?? []) {
1457
2203
  if (getBooleanOption(label.layoutOptions ?? {}, "noLayout") === true) {
1458
2204
  label.x ??= 0;
@@ -1469,17 +2215,51 @@ function applyLayout(
1469
2215
  );
1470
2216
  const width = label.width ?? 0;
1471
2217
  label.x =
1472
- placement === "TAIL"
1473
- ? firstPoint.x + edgeLabelSpacing
1474
- : placement === "HEAD"
1475
- ? lastPoint.x - width - edgeLabelSpacing
1476
- : 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;
1477
2225
  label.y = labelY;
1478
2226
  labelY += (label.height ?? 0) + labelLabelSpacing;
1479
2227
  }
1480
2228
  }
1481
2229
  if (!resultPolicy?.skipBoundsNormalization) {
1482
- 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
+ );
1483
2263
  }
1484
2264
  }
1485
2265
 
@@ -1487,15 +2267,21 @@ function normalizeElkGraphBounds(
1487
2267
  root: ElkNode,
1488
2268
  padding: { top: number; right: number; bottom: number; left: number },
1489
2269
  layoutOptions: Readonly<Record<string, unknown>> = {},
2270
+ compactionBounds?: Elkjs0111ResultPolicy["normalizationBounds"],
2271
+ placementCrossBounds?: Elkjs0111ResultPolicy["placementCrossBounds"],
1490
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
+ );
1491
2277
  const authoredWidth = root.width;
1492
2278
  const authoredHeight = root.height;
1493
2279
  const fixedGraphSize = getBooleanOption(layoutOptions, "nodeSize.fixedGraphSize") === true;
1494
2280
  const layoutChildren = (root.children ?? []).filter(
1495
2281
  (node) => getBooleanOption(node.layoutOptions ?? {}, "noLayout") !== true,
1496
2282
  );
1497
- let minimumX = Number.POSITIVE_INFINITY;
1498
- let minimumY = Number.POSITIVE_INFINITY;
2283
+ let minimumX = compactionBounds?.left ?? Number.POSITIVE_INFINITY;
2284
+ let minimumY = compactionBounds?.top ?? Number.POSITIVE_INFINITY;
1499
2285
  for (const node of layoutChildren) {
1500
2286
  minimumX = Math.min(
1501
2287
  minimumX,
@@ -1516,7 +2302,7 @@ function normalizeElkGraphBounds(
1516
2302
  ),
1517
2303
  );
1518
2304
  }
1519
- for (const edge of root.edges ?? []) {
2305
+ for (const edge of layoutEdges) {
1520
2306
  const laidOutLabels = (edge.labels ?? []).filter(isLayoutEdgeLabel);
1521
2307
  minimumX = Math.min(minimumX, ...laidOutLabels.map((label) => label.x ?? 0));
1522
2308
  minimumY = Math.min(minimumY, ...laidOutLabels.map((label) => label.y ?? 0));
@@ -1537,13 +2323,17 @@ function normalizeElkGraphBounds(
1537
2323
  node.x = (node.x ?? 0) + shiftX;
1538
2324
  node.y = (node.y ?? 0) + shiftY;
1539
2325
  }
1540
- for (const edge of root.edges ?? []) {
2326
+ for (const edge of layoutEdges) {
1541
2327
  for (const section of edge.sections ?? []) {
1542
2328
  for (const point of [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]) {
1543
2329
  point.x += shiftX;
1544
2330
  point.y += shiftY;
1545
2331
  }
1546
2332
  }
2333
+ for (const point of edge.junctionPoints ?? []) {
2334
+ point.x += shiftX;
2335
+ point.y += shiftY;
2336
+ }
1547
2337
  for (const label of (edge.labels ?? []).filter(isLayoutEdgeLabel)) {
1548
2338
  label.x = (label.x ?? 0) + shiftX;
1549
2339
  label.y = (label.y ?? 0) + shiftY;
@@ -1558,7 +2348,7 @@ function normalizeElkGraphBounds(
1558
2348
  0,
1559
2349
  ...layoutChildren.map((node) => (node.y ?? 0) + (node.height ?? 0)),
1560
2350
  );
1561
- const layoutEdgePoints = (root.edges ?? []).flatMap((edge) =>
2351
+ const layoutEdgePoints = layoutEdges.flatMap((edge) =>
1562
2352
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1563
2353
  ? []
1564
2354
  : (edge.sections ?? []).flatMap((section) => [
@@ -1574,7 +2364,7 @@ function normalizeElkGraphBounds(
1574
2364
  for (const child of root.children ?? []) {
1575
2365
  for (const port of child.ports ?? []) childByEndpointId.set(String(port.id), child);
1576
2366
  }
1577
- const wrappedEdgeCount = (root.edges ?? []).filter((edge) => {
2367
+ const wrappedEdgeCount = layoutEdges.filter((edge) => {
1578
2368
  const source = childByEndpointId.get(String(edge.sources?.[0] ?? edge.source));
1579
2369
  const target = childByEndpointId.get(String(edge.targets?.[0] ?? edge.target));
1580
2370
  if (!source || !target) return false;
@@ -1587,24 +2377,44 @@ function normalizeElkGraphBounds(
1587
2377
  : (source.y ?? 0) < (target.y ?? 0);
1588
2378
  }).length;
1589
2379
  const hasWrappedEdge = wrappedEdgeCount > 0;
1590
- const addBoundaryPixel =
2380
+ const retainPlacementBounds =
1591
2381
  wrappingStrategy !== "MULTI_EDGE" &&
1592
2382
  !(wrappingStrategy === "SINGLE_EDGE" && hasWrappedEdge) &&
1593
2383
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") ===
1594
2384
  "NONE" &&
1595
2385
  getBooleanOption(layoutOptions, "layered.feedbackEdges") !== true &&
1596
2386
  String(getOption(layoutOptions, "layered.layering.nodePromotion.strategy") ?? "NONE") !==
1597
- "MODEL_ORDER_LEFT_TO_RIGHT" &&
1598
- !(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 &&
1599
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);
1600
2406
  const edgeBoundsExtraX =
1601
2407
  addBoundaryPixel &&
2408
+ placementCrossBounds?.axis !== "x" &&
2409
+ !(hasSelfLoops && (direction === "right" || direction === "left")) &&
1602
2410
  layoutEdgePoints.length > 0 &&
1603
2411
  Math.max(...layoutEdgePoints.map((point) => point.x)) >= maximumNodeX - 1e-9
1604
2412
  ? 1
1605
2413
  : 0;
1606
2414
  const edgeBoundsExtraY =
1607
2415
  addBoundaryPixel &&
2416
+ placementCrossBounds?.axis !== "y" &&
2417
+ !(hasSelfLoops && (direction === "down" || direction === "up")) &&
1608
2418
  layoutEdgePoints.length > 0 &&
1609
2419
  Math.max(...layoutEdgePoints.map((point) => point.y)) >= maximumNodeY - 1e-9
1610
2420
  ? 1
@@ -1617,6 +2427,7 @@ function normalizeElkGraphBounds(
1617
2427
  ? 1
1618
2428
  : 0;
1619
2429
  const postCompactionBoundsExtraX =
2430
+ !compactionBounds &&
1620
2431
  !addBoundaryPixel &&
1621
2432
  !hasWrappedEdge &&
1622
2433
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") !==
@@ -1626,6 +2437,7 @@ function normalizeElkGraphBounds(
1626
2437
  ? 0.04
1627
2438
  : 0;
1628
2439
  const postCompactionBoundsExtraY =
2440
+ !compactionBounds &&
1629
2441
  !addBoundaryPixel &&
1630
2442
  !hasWrappedEdge &&
1631
2443
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") !==
@@ -1636,7 +2448,10 @@ function normalizeElkGraphBounds(
1636
2448
  : 0;
1637
2449
  const calculatedWidth =
1638
2450
  Math.max(
1639
- 0,
2451
+ compactionBounds ? compactionBounds.right + shiftX : 0,
2452
+ retainPlacementBounds && placementCrossBounds?.axis === "x"
2453
+ ? placementCrossBounds.maximum + shiftX
2454
+ : 0,
1640
2455
  ...layoutChildren.flatMap((node) => [
1641
2456
  (node.x ?? 0) + (node.width ?? 0),
1642
2457
  ...(node.labels ?? []).map((label) => (node.x ?? 0) + (label.x ?? 0) + (label.width ?? 0)),
@@ -1647,23 +2462,24 @@ function normalizeElkGraphBounds(
1647
2462
  ),
1648
2463
  ),
1649
2464
  ]),
1650
- ...(root.edges ?? []).flatMap((edge) =>
2465
+ ...layoutEdges.flatMap((edge) =>
1651
2466
  (edge.labels ?? [])
1652
2467
  .filter(isLayoutEdgeLabel)
1653
2468
  .map((label) => (label.x ?? 0) + (label.width ?? 0)),
1654
2469
  ),
1655
- ...(root.edges ?? []).flatMap((edge) =>
2470
+ ...layoutEdges.flatMap((edge) =>
1656
2471
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1657
2472
  ? []
1658
2473
  : (edge.sections ?? []).flatMap((section) => [
1659
- section.startPoint.x,
1660
- ...(section.bendPoints ?? []).map((point) => point.x),
1661
- 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),
1662
2479
  ]),
1663
2480
  ),
1664
2481
  ) +
1665
2482
  padding.right +
1666
- edgeBoundsExtraX +
1667
2483
  postCompactionBoundsExtraX +
1668
2484
  (getBooleanOption(layoutOptions, "layered.feedbackEdges") === true &&
1669
2485
  (getDirection(layoutOptions) === "down" || getDirection(layoutOptions) === "up")
@@ -1671,7 +2487,10 @@ function normalizeElkGraphBounds(
1671
2487
  : 0);
1672
2488
  const calculatedHeight =
1673
2489
  Math.max(
1674
- 0,
2490
+ compactionBounds ? compactionBounds.bottom + shiftY : 0,
2491
+ retainPlacementBounds && placementCrossBounds?.axis === "y"
2492
+ ? placementCrossBounds.maximum + shiftY
2493
+ : 0,
1675
2494
  ...layoutChildren.flatMap((node) => [
1676
2495
  (node.y ?? 0) + (node.height ?? 0),
1677
2496
  ...(node.labels ?? []).map((label) => (node.y ?? 0) + (label.y ?? 0) + (label.height ?? 0)),
@@ -1682,7 +2501,7 @@ function normalizeElkGraphBounds(
1682
2501
  ),
1683
2502
  ),
1684
2503
  ]),
1685
- ...(root.edges ?? []).flatMap((edge) =>
2504
+ ...layoutEdges.flatMap((edge) =>
1686
2505
  (edge.labels ?? [])
1687
2506
  .filter(isLayoutEdgeLabel)
1688
2507
  .map(
@@ -1690,23 +2509,26 @@ function normalizeElkGraphBounds(
1690
2509
  (label.y ?? 0) +
1691
2510
  (label.height ?? 0) +
1692
2511
  (String(getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER") ===
1693
- "CENTER"
2512
+ "CENTER" &&
2513
+ childByEndpointId.get(String(edge.sources?.[0] ?? edge.source)) !==
2514
+ childByEndpointId.get(String(edge.targets?.[0] ?? edge.target))
1694
2515
  ? 1
1695
2516
  : 0),
1696
2517
  ),
1697
2518
  ),
1698
- ...(root.edges ?? []).flatMap((edge) =>
2519
+ ...layoutEdges.flatMap((edge) =>
1699
2520
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1700
2521
  ? []
1701
2522
  : (edge.sections ?? []).flatMap((section) => [
1702
- section.startPoint.y,
1703
- ...(section.bendPoints ?? []).map((point) => point.y),
1704
- 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),
1705
2528
  ]),
1706
2529
  ),
1707
2530
  ) +
1708
2531
  padding.bottom +
1709
- edgeBoundsExtraY +
1710
2532
  singleMultiEdgeCutBoundsExtraY +
1711
2533
  postCompactionBoundsExtraY +
1712
2534
  (getBooleanOption(layoutOptions, "layered.feedbackEdges") === true &&
@@ -1901,3 +2723,126 @@ export type {
1901
2723
  StatechartLayoutScore,
1902
2724
  StatechartLayoutPlan,
1903
2725
  } from "./statechart";
2726
+
2727
+ export { getElkRoutes } from "./routes";
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
+ }