@statelyai/layout 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +150 -11
  3. package/dist/elkjs/index.cjs +2 -2
  4. package/dist/elkjs/index.d.cts +2 -0
  5. package/dist/elkjs/index.d.mts +2 -0
  6. package/dist/elkjs/index.mjs +2 -2
  7. package/dist/elkjs/main.cjs +1 -1
  8. package/dist/elkjs/main.mjs +1 -1
  9. package/dist/elkjs/worker.cjs +2 -2
  10. package/dist/elkjs/worker.mjs +2 -2
  11. package/dist/elkjs-BBfKN4Dw.mjs +3075 -0
  12. package/dist/elkjs-DNfXDPup.cjs +3104 -0
  13. package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
  14. package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
  15. package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
  16. package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
  17. package/dist/index.cjs +6 -3
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.mts +3 -3
  20. package/dist/index.mjs +4 -4
  21. package/dist/layered/index.cjs +3 -1
  22. package/dist/layered/index.d.cts +2 -2
  23. package/dist/layered/index.d.mts +2 -2
  24. package/dist/layered/index.mjs +2 -2
  25. package/dist/{layered-B2buJqMf.cjs → layered-5RxQhryb.cjs} +11184 -2932
  26. package/dist/{layered-DGFTeQSl.mjs → layered-DSesbctm.mjs} +12059 -3903
  27. package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-CLFySz3R.cjs} +5 -18
  28. package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-GpBIX6ng.mjs} +3 -10
  29. package/dist/routing/index.cjs +2 -1
  30. package/dist/routing/index.d.cts +2 -2
  31. package/dist/routing/index.d.mts +2 -2
  32. package/dist/routing/index.mjs +2 -2
  33. package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
  34. package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
  35. package/package.json +8 -2
  36. package/src/authoring/coordinates.ts +28 -8
  37. package/src/authoring/partial.ts +7 -2
  38. package/src/authoring/routing.ts +4 -1
  39. package/src/constraints.ts +174 -6
  40. package/src/elkjs/index.ts +1476 -449
  41. package/src/elkjs/layout-quality.ts +267 -0
  42. package/src/elkjs/opposing-tracks.ts +161 -0
  43. package/src/elkjs/orthogonal-junctions.ts +174 -0
  44. package/src/elkjs/statechart.ts +9 -3
  45. package/src/elkjs/types.ts +2 -0
  46. package/src/index.ts +2 -0
  47. package/src/internal/elkjs-compatibility.ts +4 -1
  48. package/src/internal/layered-trace.ts +48 -0
  49. package/src/internal/layout-engine.ts +2 -1
  50. package/src/java-random.ts +6 -0
  51. package/src/layered/bk-node-placement.ts +403 -92
  52. package/src/layered/center-labels.ts +398 -0
  53. package/src/layered/compaction-bounds.ts +26 -0
  54. package/src/layered/compaction-errors.ts +2 -0
  55. package/src/layered/compaction-scanline.ts +63 -0
  56. package/src/layered/compound-boundaries.ts +99 -0
  57. package/src/layered/compound.ts +35 -7
  58. package/src/layered/constraint-orientation.ts +141 -0
  59. package/src/layered/crossing-constraints.ts +243 -0
  60. package/src/layered/crossing-counter.ts +233 -0
  61. package/src/layered/crossing-graph.ts +401 -0
  62. package/src/layered/cycle-random.ts +22 -0
  63. package/src/layered/elk-port-lists.ts +243 -0
  64. package/src/layered/end-labels.ts +124 -0
  65. package/src/layered/external-port-dummy.ts +137 -0
  66. package/src/layered/feedback-chains.ts +114 -0
  67. package/src/layered/fixed-self-loop.ts +150 -0
  68. package/src/layered/greedy-switch-decider.ts +356 -0
  69. package/src/layered/grouped-compaction.ts +787 -0
  70. package/src/layered/gwt-sort.ts +53 -0
  71. package/src/layered/hierarchical-port-phases.ts +242 -0
  72. package/src/layered/hierarchical-port-restoration.ts +233 -0
  73. package/src/layered/hierarchical-port-routing.ts +179 -0
  74. package/src/layered/hierarchy-crossing.ts +121 -0
  75. package/src/layered/hierarchy-sweepiness.ts +79 -0
  76. package/src/layered/hints.ts +371 -0
  77. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  78. package/src/layered/index.ts +875 -161
  79. package/src/layered/initial-model-order.ts +404 -0
  80. package/src/layered/inverted-ports.ts +207 -0
  81. package/src/layered/label-dummy-switcher.ts +200 -0
  82. package/src/layered/long-edges.ts +135 -44
  83. package/src/layered/loop-envelopes.ts +184 -0
  84. package/src/layered/native-routing.ts +275 -0
  85. package/src/layered/network-simplex.ts +11 -15
  86. package/src/layered/node-margins.ts +59 -0
  87. package/src/layered/north-south-ports.ts +271 -0
  88. package/src/layered/opposing-tracks.ts +1044 -0
  89. package/src/layered/orthogonal-cycle-order.ts +106 -0
  90. package/src/layered/orthogonal-hypersegments.ts +154 -0
  91. package/src/layered/orthogonal-junctions.ts +202 -0
  92. package/src/layered/orthogonal-segments.ts +243 -0
  93. package/src/layered/placement-orientation.ts +22 -0
  94. package/src/layered/port-distributor.ts +192 -0
  95. package/src/layered/port-edge-order.ts +20 -0
  96. package/src/layered/replace-routing.ts +45 -0
  97. package/src/layered/routing-coordinates.ts +32 -0
  98. package/src/layered/separate-exterior-labels.ts +24 -6
  99. package/src/layered/spacing.ts +34 -12
  100. package/src/layered/splitter-order.ts +106 -0
  101. package/src/layered/strategies.ts +2124 -886
  102. package/src/layered/types.ts +13 -0
  103. package/src/layered/weighted-compaction.ts +84 -0
  104. package/src/routing/adapters.ts +32 -0
  105. package/src/routing/algorithms.ts +433 -21
  106. package/src/routing/coordination.ts +8 -3
  107. package/src/routing/index.ts +1 -0
  108. package/src/routing/search.ts +93 -11
  109. package/dist/elkjs-DaHMISnQ.mjs +0 -1677
  110. package/dist/elkjs-dMghiiWW.cjs +0 -1706
@@ -1,3 +1,28 @@
1
+ import { hierarchicalPortSides } from "../layered/hierarchical-port-phases";
2
+ import { isPerpendicularPortRestored } from "../layered/hierarchical-port-restoration";
3
+ import { useBottomUpHierarchySweep } from "../layered/hierarchy-sweepiness";
4
+ import {
5
+ createLayeredScopePipeline,
6
+ type LayeredCrossingPhase,
7
+ type LayeredLayoutOptions,
8
+ type LayerOrder,
9
+ } from "../layered";
10
+ import { createLayerSweepSession, CROSSING_MODEL, type CrossingModel } from "../layered/strategies";
11
+ import {
12
+ minimizeHierarchyCrossings,
13
+ type HierarchyCrossingScope,
14
+ } from "../layered/hierarchy-crossing";
15
+ import { transferExternalPort, joinCompoundRouteSegments } from "../layered/compound-boundaries";
16
+ import {
17
+ attachExternalPortDummy,
18
+ createExternalPortDummy,
19
+ externalPortDummyOf,
20
+ type ExternalPortConstraints,
21
+ type ExternalPortSide,
22
+ } from "../layered/external-port-dummy";
23
+ import { isBetterLayout, measureLayout } from "./layout-quality";
24
+ import { applyOrthogonalJunctions } from "./orthogonal-junctions";
25
+ import { separateElkOpposingTracks } from "./opposing-tracks";
1
26
  import { createGraph, type Graph, type VisualGraph } from "@statelyai/graph";
2
27
  import {
3
28
  elkLayeredOptionDefinitions,
@@ -28,6 +53,7 @@ import type {
28
53
  } from "./public-types";
29
54
 
30
55
  function isLayoutEdgeLabel(label: ElkLabel): boolean {
56
+ // Unlike ELK, a dimensioned label without text still reserves space: apps often render the text themselves.
31
57
  return (
32
58
  getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true &&
33
59
  (Boolean(label.text) || label.width !== undefined || label.height !== undefined)
@@ -57,6 +83,19 @@ export type {
57
83
  } from "./public-types";
58
84
  export type { ElkId, ElkLogging } from "./types";
59
85
 
86
+ /** The crossing-model setting a layout passes to crossing minimization. */
87
+ const crossingModel = Symbol("crossingModel");
88
+ type ModelArguments = ElkLayoutArguments & { [crossingModel]?: CrossingModel };
89
+
90
+ interface PreparedElkScope {
91
+ graph: ElkNode;
92
+ native: Graph;
93
+ phase?: LayeredCrossingPhase;
94
+ options?: LayeredLayoutOptions;
95
+ children: ReadonlyMap<string, PreparedElkScope>;
96
+ finish(orders?: ReadonlyMap<PreparedElkScope, LayerOrder>): Promise<ElkNode>;
97
+ }
98
+
60
99
  export default class ELK {
61
100
  readonly #options: ElkConstructorArguments;
62
101
  readonly #algorithmIds: ReadonlySet<string>;
@@ -128,17 +167,98 @@ export default class ELK {
128
167
  arguments_?: PublicElkLayoutArguments,
129
168
  ): Promise<PublicElkNode & PublicLaidOutElkNode<T>>;
130
169
  layout<T extends ElkNode>(graph: T, arguments_?: ElkLayoutArguments): Promise<LaidOutElkNode<T>>;
131
- layout<T extends ElkNode>(
170
+ async layout<T extends ElkNode>(
132
171
  graph: T,
133
172
  arguments_: ElkLayoutArguments = {},
134
173
  ): Promise<LaidOutElkNode<T>> {
135
- return this.#layout(graph, arguments_);
174
+ const options = { ...this.#options.defaultLayoutOptions, ...arguments_.layoutOptions };
175
+ const seeds = crossingVariants(options, graph);
176
+ const probe = mayShareTracks(graph, options)
177
+ ? { mixedPorts: false, sharedTracks: false }
178
+ : undefined;
179
+ const pristine = seeds.length || probe ? structuredClone(graph) : undefined;
180
+ let best = separated(
181
+ await this.#layoutWithoutDefectiveCompaction(
182
+ graph,
183
+ probe ? { ...arguments_, [crossingModel]: { probe } } : arguments_,
184
+ ),
185
+ options,
186
+ );
187
+ if (!pristine) return best;
188
+ // Crossing minimization models a port carrying both directions as two
189
+ // ports with grouped neighbours, and counts hyperedges as ELK routes
190
+ // them. Each choice steers the sweeps into worse orders on some graphs,
191
+ // so a graph where the mixed-port model applied is also laid out without
192
+ // it, and one with a hyperedge that routing must split is also laid out
193
+ // counting that hyperedge edge by edge. The best result wins.
194
+ // A compound layout with defects tries other random seeds too. Above
195
+ // ELK's default thoroughness, every compound layout does, trading time
196
+ // for fewer crossings and bends.
197
+ const reseed =
198
+ seeds.length > 0 && (thorough(options, graph) || measureLayout(best, true).defects > 0);
199
+ const variants: ModelArguments[] = [
200
+ ...(probe?.mixedPorts ? [{ ...arguments_, [crossingModel]: { mixedPorts: false } }] : []),
201
+ ...(probe?.sharedTracks
202
+ ? [{ ...arguments_, [crossingModel]: { mixedPorts: false, separateSharedTracks: true } }]
203
+ : []),
204
+ ...(reseed ? seeds : []).map((variant) => ({
205
+ ...arguments_,
206
+ layoutOptions: { ...arguments_.layoutOptions, ...variant },
207
+ })),
208
+ ];
209
+ if (!variants.length) return best;
210
+ let quality = measureLayout(best);
211
+ for (const variant of variants) {
212
+ const candidate = separated(
213
+ await this.#layoutWithoutDefectiveCompaction(structuredClone(pristine), variant),
214
+ options,
215
+ );
216
+ const candidateQuality = measureLayout(candidate);
217
+ if (isBetterLayout(candidateQuality, quality)) {
218
+ best = candidate;
219
+ quality = candidateQuality;
220
+ }
221
+ }
222
+ return best;
223
+ }
224
+
225
+ async #layoutWithoutDefectiveCompaction<T extends ElkNode>(
226
+ graph: T,
227
+ arguments_: ModelArguments,
228
+ ): Promise<LaidOutElkNode<T>> {
229
+ const compacting = usesPostCompaction(
230
+ { ...this.#options.defaultLayoutOptions, ...arguments_.layoutOptions },
231
+ graph,
232
+ );
233
+ const pristine = compacting ? structuredClone(graph) : undefined;
234
+ const result = await this.#layout(graph, arguments_);
235
+ const defects = pristine ? measureLayout(result, true).defects : 0;
236
+ if (!pristine || defects === 0) return result;
237
+ // Post-compaction is an optimization; it must not route edges through nodes
238
+ // or fold a route back onto itself.
239
+ const strategy = { "elk.layered.compaction.postCompaction.strategy": "NONE" };
240
+ const uncompacted = await this.#layout(disablePostCompaction(pristine), {
241
+ ...arguments_,
242
+ layoutOptions: { ...arguments_.layoutOptions, ...strategy },
243
+ });
244
+ return measureLayout(uncompacted, true).defects < defects ? uncompacted : result;
136
245
  }
137
246
 
138
247
  async #layout<T extends ElkNode>(
139
248
  graph: T,
140
249
  arguments_: ElkLayoutArguments = {},
250
+ compoundLayout = false,
141
251
  ): Promise<LaidOutElkNode<T>> {
252
+ const prepared = await this.#prepareLayout(graph, arguments_, compoundLayout);
253
+ const orders = coordinatePreparedScopes(prepared);
254
+ return (await prepared.finish(orders)) as LaidOutElkNode<T>;
255
+ }
256
+
257
+ async #prepareLayout<T extends ElkNode>(
258
+ graph: T,
259
+ arguments_: ElkLayoutArguments = {},
260
+ compoundLayout = false,
261
+ ): Promise<PreparedElkScope> {
142
262
  const startedAt = performance.now();
143
263
  if (graph === undefined || graph === null) {
144
264
  throw new TypeError("Missing mandatory parameter: graph");
@@ -201,6 +321,8 @@ export default class ELK {
201
321
  `org.eclipse.elk.core.UnsupportedConfigurationException: Layout algorithm '${requestedAlgorithm}' not found`,
202
322
  );
203
323
  }
324
+ const preparedChildren = new Map<string, PreparedElkScope>();
325
+ const finishChildren: Array<() => Promise<void>> = [];
204
326
  const hasHierarchy = (graph.children ?? []).some((child) => (child.children?.length ?? 0) > 0);
205
327
  const insideSelfLoopBaseHeightByNodeId = new Map<string, number>();
206
328
  const hierarchyRestorations: Array<{
@@ -210,10 +332,16 @@ export default class ELK {
210
332
  source?: ElkId;
211
333
  target?: ElkId;
212
334
  }> = [];
335
+ const boundaryRoutes = new Map<
336
+ ElkEdge,
337
+ {
338
+ source?: { owner: ElkNode; points: ElkPoint[]; junctionPoints?: ElkPoint[] };
339
+ target?: { owner: ElkNode; points: ElkPoint[]; junctionPoints?: ElkPoint[] };
340
+ }
341
+ >();
213
342
  const syntheticPortIds = new Set<string>();
214
343
  const authoredPortsByCompound = new Map<ElkNode, ElkPort[] | undefined>();
215
344
  const authoredOptionsByCompound = new Map<ElkNode, Record<string, unknown> | undefined>();
216
- const hierarchyBoundaryCountByEdge = new Map<ElkEdge, number>();
217
345
  const originalHierarchyEndpoints = new Map(
218
346
  (graph.edges ?? []).map((edge) => [
219
347
  edge,
@@ -227,7 +355,6 @@ export default class ELK {
227
355
  },
228
356
  ]),
229
357
  );
230
- let hasHierarchyCrossingEdges = false;
231
358
  const hierarchyHandling = getOption(layoutOptions, "hierarchyHandling");
232
359
  const topdownLayout = getBooleanOption(layoutOptions, "topdownLayout") === true;
233
360
  const separateHierarchy =
@@ -296,17 +423,15 @@ export default class ELK {
296
423
  );
297
424
  }
298
425
  } else if (hasHierarchy && !separateHierarchy) {
299
- // The probability only chooses between equivalent top-down and bottom-up
300
- // sweep schedules. The deterministic proxy decomposition below preserves
301
- // the resulting exported order for either schedule.
302
- void getNumberOption(layoutOptions, "layered.crossingMinimization.hierarchicalSweepiness");
426
+ // Prepare boundary identities before coordinating parent and child crossing sweeps.
427
+ const scopeSegmentOrder =
428
+ segmentOrderByScope.get(graph) ??
429
+ elkSegmentOrder(graph, graph, originalHierarchyEndpoints, false);
303
430
  for (const child of graph.children ?? []) {
304
431
  if ((child.children?.length ?? 0) === 0) continue;
305
- const descendantById = new Map<string, ElkNode>();
306
432
  const descendantOwnerByEndpointId = new Map<string, ElkNode>();
307
433
  const collectDescendants = (node: ElkNode): void => {
308
434
  for (const descendant of node.children ?? []) {
309
- descendantById.set(String(descendant.id), descendant);
310
435
  descendantOwnerByEndpointId.set(String(descendant.id), descendant);
311
436
  for (const port of descendant.ports ?? []) {
312
437
  descendantOwnerByEndpointId.set(String(port.id), descendant);
@@ -321,198 +446,622 @@ export default class ELK {
321
446
  descendantOwnerByEndpointId.has(sourceId) && descendantOwnerByEndpointId.has(targetId)
322
447
  );
323
448
  });
324
- const crossingEdges = (graph.edges ?? []).filter((edge) => {
449
+ for (const edge of child.edges ?? [])
450
+ if (!originalHierarchyEndpoints.has(edge))
451
+ originalHierarchyEndpoints.set(edge, {
452
+ sourceId: String(edge.sources?.[0] ?? edge.source),
453
+ targetId: String(edge.targets?.[0] ?? edge.target),
454
+ sources: edge.sources,
455
+ targets: edge.targets,
456
+ source: edge.source,
457
+ target: edge.target,
458
+ });
459
+ const crossingEdges = [...(graph.edges ?? []), ...(child.edges ?? [])].filter((edge) => {
325
460
  const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
326
461
  const sourceInside = descendantOwnerByEndpointId.has(sourceId);
327
462
  const targetInside = descendantOwnerByEndpointId.has(targetId);
328
463
  return sourceInside !== targetInside;
329
464
  });
330
- for (const edge of crossingEdges) {
331
- hierarchyBoundaryCountByEdge.set(edge, (hierarchyBoundaryCountByEdge.get(edge) ?? 0) + 1);
332
- }
333
- hasHierarchyCrossingEdges ||= crossingEdges.length > 0;
465
+ // CompoundGraphPreprocessor creates boundary ports bottom-up: ports
466
+ // exported by nested groups first, then direct children's ports in
467
+ // port order, outgoing edges before incoming edges.
468
+ const boundaryRank = new Map(
469
+ elkBoundaryEdgeOrder(child, graph, originalHierarchyEndpoints).map((edge, index) => [
470
+ edge,
471
+ index,
472
+ ]),
473
+ );
474
+ crossingEdges.sort(
475
+ (a, b) =>
476
+ (boundaryRank.get(a) ?? Number.MAX_SAFE_INTEGER) -
477
+ (boundaryRank.get(b) ?? Number.MAX_SAFE_INTEGER),
478
+ );
334
479
  const mergeHierarchyEdges =
335
480
  getBooleanOption(layoutOptions, "layered.mergeHierarchyEdges") !== false;
336
- // The option changes the number of internal external-port dummies. They
337
- // are removed before elkjs serialization and do not alter the exported
338
- // geometry for a shared hierarchy boundary.
339
- void mergeHierarchyEdges;
481
+ // ELK merges only edges incident to the same descendant port.
340
482
  const proxyByKind = new Map<string, ElkNode>();
341
- const proxyFor = (kind: "input" | "output"): ElkNode => {
342
- const key = kind;
483
+ const implicitOwnPorts = new Map<ElkEdge, ElkPort>();
484
+ const ownPortFor = (edge: ElkEdge): ElkPort | undefined => {
485
+ const e = originalHierarchyEndpoints.get(edge)!;
486
+ const outside = descendantOwnerByEndpointId.has(e.sourceId) ? e.targetId : e.sourceId;
487
+ if (e.sourceId === String(child.id) || e.targetId === String(child.id)) {
488
+ let port = implicitOwnPorts.get(edge);
489
+ if (!port) {
490
+ const direction = getDirection(layoutOptions);
491
+ const sourceInside = e.sourceId === String(child.id);
492
+ const side = sourceInside
493
+ ? ({ right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" } as const)[direction]
494
+ : ({ right: "WEST", left: "EAST", down: "NORTH", up: "SOUTH" } as const)[direction];
495
+ port = {
496
+ id: `__native_hierarchy_own_${String(child.id)}_${implicitOwnPorts.size}`,
497
+ width: 0,
498
+ height: 0,
499
+ layoutOptions: { "elk.port.side": side },
500
+ };
501
+ implicitOwnPorts.set(edge, port);
502
+ syntheticPortIds.add(String(port.id));
503
+ }
504
+ return child.ports?.find((p) => p.id === port!.id) ?? port;
505
+ }
506
+ return child.ports?.find((p) => String(p.id) === outside);
507
+ };
508
+
509
+ const proxyKey = (kind: "input" | "output", edge: ElkEdge): string => {
510
+ const own = ownPortFor(edge);
511
+ if (own) return JSON.stringify(["authored", own.id]);
512
+ const endpoints = originalHierarchyEndpoints.get(edge)!;
513
+ const endpoint = kind === "output" ? endpoints.sourceId : endpoints.targetId;
514
+ const owner = descendantOwnerByEndpointId.get(endpoint);
515
+ const explicitPort = owner?.ports?.some((port) => String(port.id) === endpoint);
516
+ return JSON.stringify([
517
+ kind,
518
+ mergeHierarchyEdges && explicitPort ? endpoint : String(edge.id),
519
+ ]);
520
+ };
521
+ const createBoundaryProxy = (
522
+ key: string,
523
+ kind: "input" | "output",
524
+ own: ElkPort | undefined,
525
+ netFlow: number,
526
+ ): ElkNode => {
343
527
  let proxy = proxyByKind.get(key);
344
528
  if (!proxy) {
345
- proxy = {
346
- id: `__native_hierarchy_${String(child.id)}_${key.replace(/[^a-zA-Z0-9]/g, "_")}`,
347
- width: 0,
348
- height: 0,
349
- };
529
+ const direction = getDirection({ ...layoutOptions, ...child.layoutOptions });
530
+ const authoredAnchor = own
531
+ ? (getElementLayeredSettings(own.layoutOptions ?? {})["port.anchor"] as
532
+ | ElkPoint
533
+ | undefined)
534
+ : undefined;
535
+ const origin = createExternalPortDummy({
536
+ constraints: (getOption(child.layoutOptions ?? {}, "portConstraints") ??
537
+ "FREE") as ExternalPortConstraints,
538
+ side: own
539
+ ? ((getOption(own.layoutOptions ?? {}, "port.side") ??
540
+ (kind === "output" ? "EAST" : "WEST")) as ExternalPortSide)
541
+ : kind === "output"
542
+ ? "EAST"
543
+ : "WEST",
544
+ position: { x: own?.x ?? 0, y: own?.y ?? 0 },
545
+ anchor: authoredAnchor,
546
+ index: own ? getNumberOption(own.layoutOptions ?? {}, "port.index") : undefined,
547
+ direction: direction.toUpperCase() as "RIGHT" | "LEFT" | "DOWN" | "UP",
548
+ netFlow,
549
+ borderOffset: own
550
+ ? (getNumberOption(own.layoutOptions ?? {}, "port.borderOffset") ?? 0)
551
+ : (getNumberOption(layoutOptions, "spacing.edgeEdge") ?? 10) / 2,
552
+ size: { width: own?.width ?? 0, height: own?.height ?? 0 },
553
+ });
554
+ if (own) origin.parentPortId = String(own.id);
555
+ const id = `__native_hierarchy_${String(child.id)}_${proxyByKind.size}`;
556
+ proxy = attachExternalPortDummy(
557
+ {
558
+ id,
559
+ width: origin.width,
560
+ height: origin.height,
561
+ layoutOptions: {
562
+ "elk.portConstraints": origin.constraints,
563
+ "elk.layered.layering.layerConstraint":
564
+ origin.side ===
565
+ ({ right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" } as const)[
566
+ direction
567
+ ]
568
+ ? "LAST_SEPARATE"
569
+ : origin.side ===
570
+ ({ right: "WEST", left: "EAST", down: "NORTH", up: "SOUTH" } as const)[
571
+ direction
572
+ ]
573
+ ? "FIRST_SEPARATE"
574
+ : undefined,
575
+ },
576
+ ports: [
577
+ {
578
+ id: `${id}:boundary`,
579
+ x: origin.port.x,
580
+ y: origin.port.y,
581
+ width: 0,
582
+ height: 0,
583
+ layoutOptions: { "elk.port.side": origin.port.side },
584
+ },
585
+ ],
586
+ },
587
+ origin,
588
+ );
350
589
  proxyByKind.set(key, proxy);
351
590
  }
352
591
  return proxy;
353
592
  };
593
+ const ownedPortNetFlow = (id: string): number => {
594
+ let votes = 0;
595
+ for (const edge of originalHierarchyEndpoints.values()) {
596
+ if (edge.sourceId === id)
597
+ votes += descendantOwnerByEndpointId.has(edge.targetId) ? -1 : 1;
598
+ if (edge.targetId === id)
599
+ votes += descendantOwnerByEndpointId.has(edge.sourceId) ? 1 : -1;
600
+ }
601
+ return votes;
602
+ };
603
+ const proxyFor = (kind: "input" | "output", edge: ElkEdge): ElkNode => {
604
+ const own = ownPortFor(edge);
605
+ const flow = implicitOwnPorts.has(edge)
606
+ ? kind === "output"
607
+ ? 1
608
+ : -1
609
+ : own
610
+ ? ownedPortNetFlow(String(own.id))
611
+ : kind === "output"
612
+ ? 1
613
+ : -1;
614
+ return createBoundaryProxy(proxyKey(kind, edge), kind, own, flow);
615
+ };
616
+ // ELK imports ancestor edges before descendant-local edges. Preserve
617
+ // that implicit port creation order through child phase preparation.
354
618
  const temporaryEdges: ElkEdge[] = [
355
- ...(child.edges ?? []),
356
- ...internalEdges.filter(
357
- (edge) =>
358
- !(child.edges ?? []).some((candidate) => String(candidate.id) === String(edge.id)),
359
- ),
360
619
  ...crossingEdges.map((edge) => {
361
620
  const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
362
621
  const sourceInside = descendantOwnerByEndpointId.has(sourceId);
363
- const proxy = proxyFor(sourceInside ? "output" : "input");
364
- return {
622
+ const proxy = proxyFor(sourceInside ? "output" : "input", edge);
623
+ // ELK keeps center labels on the shallowest segment: the parent's,
624
+ // unless the edge enters through the child's own port.
625
+ const labels =
626
+ (graph.edges ?? []).includes(edge) && !ownPortFor(edge)
627
+ ? edge.labels?.filter(
628
+ (label) =>
629
+ (getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER") !==
630
+ "CENTER",
631
+ )
632
+ : edge.labels;
633
+ const segment: ElkEdge = {
365
634
  ...edge,
635
+ labels,
366
636
  id: `__native_hierarchy_edge_${String(child.id)}_${String(edge.id)}`,
367
- sources: [sourceInside ? sourceId : proxy.id!],
368
- targets: [sourceInside ? proxy.id! : targetId],
637
+ sources: [sourceInside ? sourceId : proxy.ports![0]!.id!],
638
+ targets: [sourceInside ? proxy.ports![0]!.id! : targetId],
369
639
  source: undefined,
370
640
  target: undefined,
371
641
  sections: undefined,
372
642
  };
643
+ originalEdgeId.set(segment, originalEdgeId.get(edge) ?? String(edge.id));
644
+ return segment;
373
645
  }),
646
+ ...(child.edges ?? []).filter((edge) => !crossingEdges.includes(edge)),
647
+ ...internalEdges.filter(
648
+ (edge) =>
649
+ !(child.edges ?? []).some((candidate) => String(candidate.id) === String(edge.id)),
650
+ ),
374
651
  ];
652
+ const hasExternalPorts = proxyByKind.size > 0;
653
+ const inactiveAuthoredPorts: Array<{ port: ElkPort; proxy: ElkNode }> = [];
654
+ if (hasExternalPorts) {
655
+ for (const port of child.ports ?? []) {
656
+ const key = JSON.stringify(["authored", port.id]);
657
+ if (proxyByKind.has(key)) continue;
658
+ const flow = ownedPortNetFlow(String(port.id));
659
+ inactiveAuthoredPorts.push({
660
+ port,
661
+ proxy: createBoundaryProxy(key, flow >= 0 ? "output" : "input", port, flow),
662
+ });
663
+ }
664
+ }
665
+ const outsideNodeEdges = !hasExternalPorts
666
+ ? []
667
+ : (graph.edges ?? []).filter((edge) => {
668
+ const e = originalHierarchyEndpoints.get(edge)!;
669
+ return (
670
+ (e.sourceId === String(child.id) || e.targetId === String(child.id)) &&
671
+ !descendantOwnerByEndpointId.has(e.sourceId) &&
672
+ !descendantOwnerByEndpointId.has(e.targetId)
673
+ );
674
+ });
675
+ for (const edge of outsideNodeEdges) {
676
+ const e = originalHierarchyEndpoints.get(edge)!;
677
+ proxyFor(e.sourceId === String(child.id) ? "output" : "input", edge);
678
+ }
375
679
  const temporaryChild: ElkNode = {
376
680
  ...child,
681
+ layoutOptions: proxyByKind.size
682
+ ? { ...child.layoutOptions, "elk.portConstraints": "FIXED_SIDE" }
683
+ : child.layoutOptions,
377
684
  children: [...(child.children ?? []), ...proxyByKind.values()],
378
685
  edges: temporaryEdges,
379
686
  };
380
- await this.#layout(temporaryChild, {
381
- ...arguments_,
382
- layoutOptions: {
383
- ...arguments_.layoutOptions,
384
- hierarchyHandling: "INCLUDE_CHILDREN",
687
+ const childSegmentOrder = elkSegmentOrder(child, graph, originalHierarchyEndpoints, true);
688
+ segmentOrderByScope.set(temporaryChild, childSegmentOrder);
689
+ // Boundary segments copied into the child scope keep their original identity.
690
+ for (const edge of temporaryEdges) {
691
+ const id = originalEdgeId.get(edge);
692
+ const rank = id === undefined ? undefined : childSegmentOrder.get(id);
693
+ if (rank !== undefined) hierarchySegmentRank.set(edge, rank);
694
+ }
695
+ const preparedChild = await this.#prepareLayout(
696
+ temporaryChild,
697
+ {
698
+ ...arguments_,
699
+ layoutOptions: {
700
+ ...arguments_.layoutOptions,
701
+ // The compound's own direction wins over the inherited one.
702
+ direction: getDirection(
703
+ getOption(child.layoutOptions ?? {}, "direction") === undefined
704
+ ? layoutOptions
705
+ : child.layoutOptions!,
706
+ ).toUpperCase(),
707
+ hierarchyHandling: "INCLUDE_CHILDREN",
708
+ },
709
+ logging: false,
710
+ measureExecutionTime: false,
385
711
  },
386
- logging: false,
387
- measureExecutionTime: false,
388
- });
389
- const childPadding = parsePadding(
390
- getOption({ ...layoutOptions, ...child.layoutOptions }, "padding"),
391
- 12,
712
+ true,
392
713
  );
393
- if (proxyByKind.has("input")) {
394
- const direction = getDirection(layoutOptions);
395
- const horizontal = direction === "right" || direction === "left";
396
- const increasing = direction === "right" || direction === "down";
397
- if (increasing) {
398
- const minimumFlow = Math.min(
399
- ...(child.children ?? []).map((node) => (horizontal ? (node.x ?? 0) : (node.y ?? 0))),
400
- );
401
- const desiredFlow = horizontal ? childPadding.left : childPadding.top;
402
- const delta = desiredFlow - minimumFlow;
403
- for (const node of child.children ?? []) {
404
- if (horizontal) node.x = (node.x ?? 0) + delta;
405
- else node.y = (node.y ?? 0) + delta;
406
- }
407
- for (const edge of temporaryEdges) {
408
- for (const section of edge.sections ?? []) {
409
- for (const point of [
410
- section.startPoint,
411
- ...(section.bendPoints ?? []),
412
- section.endPoint,
413
- ]) {
414
- if (horizontal) point.x += delta;
415
- else point.y += delta;
416
- }
417
- }
418
- }
419
- }
420
- }
421
- for (const internalEdge of internalEdges) {
422
- const temporary = temporaryEdges.find(
423
- (candidate) => String(candidate.id) === String(internalEdge.id),
424
- );
425
- if (temporary?.sections) internalEdge.sections = temporary.sections;
426
- }
427
- child.width =
428
- Math.max(0, ...(child.children ?? []).map((node) => (node.x ?? 0) + (node.width ?? 0))) +
429
- childPadding.right;
430
- child.height =
431
- Math.max(0, ...(child.children ?? []).map((node) => (node.y ?? 0) + (node.height ?? 0))) +
432
- childPadding.bottom;
433
-
434
- const relativeRect = (id: string): ElkShape | undefined => {
435
- const owner = descendantOwnerByEndpointId.get(id);
436
- if (!owner) return undefined;
437
- const path: ElkNode[] = [];
438
- const visit = (parent: ElkNode): boolean => {
439
- for (const candidate of parent.children ?? []) {
440
- path.push(candidate);
441
- if (candidate === owner || visit(candidate)) return true;
442
- path.pop();
443
- }
444
- return false;
445
- };
446
- if (!visit(child)) return undefined;
447
- const port = owner.ports?.find((candidate) => String(candidate.id) === id);
448
- const ownerX = path.reduce((sum, node) => sum + (node.x ?? 0), 0);
449
- const ownerY = path.reduce((sum, node) => sum + (node.y ?? 0), 0);
450
- if (port) {
451
- return {
452
- x: ownerX + (port.x ?? 0) + (port.width ?? 0) / 2,
453
- y: ownerY + (port.y ?? 0) + (port.height ?? 0) / 2,
454
- width: 0,
455
- height: 0,
456
- };
457
- }
458
- return {
459
- x: ownerX,
460
- y: ownerY,
461
- width: path.at(-1)?.width ?? 0,
462
- height: path.at(-1)?.height ?? 0,
463
- };
464
- };
714
+ preparedChildren.set(String(child.id), preparedChild);
465
715
  authoredPortsByCompound.set(child, child.ports);
466
716
  authoredOptionsByCompound.set(child, child.layoutOptions);
467
- const ports = [...(child.ports ?? [])];
717
+ const initialPorts = (child.ports ?? []).map((port) => ({
718
+ ...port,
719
+ layoutOptions: port.layoutOptions ? { ...port.layoutOptions } : undefined,
720
+ }));
468
721
  for (const edge of crossingEdges) {
722
+ const own = ownPortFor(edge);
723
+ if (own) {
724
+ const original = originalHierarchyEndpoints.get(edge)!;
725
+ const sourceInside = descendantOwnerByEndpointId.has(original.sourceId);
726
+ const origin = externalPortDummyOf(
727
+ proxyByKind.get(proxyKey(sourceInside ? "output" : "input", edge))!,
728
+ )!;
729
+ let port = initialPorts.find((p) => p.id === own.id);
730
+ if (!port) {
731
+ port = { ...own, layoutOptions: { ...own.layoutOptions } };
732
+ initialPorts.push(port);
733
+ }
734
+ port.layoutOptions = { ...port.layoutOptions, "elk.port.side": origin.side };
735
+ continue;
736
+ }
469
737
  const original = originalHierarchyEndpoints.get(edge)!;
470
- const { sourceId, targetId } = original;
471
- const sourceInside = descendantOwnerByEndpointId.has(sourceId);
472
- const descendantId = sourceInside ? sourceId : targetId;
473
- const rect = relativeRect(descendantId);
474
- if (!rect) continue;
475
- const portId = `__native_hierarchy_port_${String(child.id)}_${String(edge.id)}`;
738
+ const sourceInside = descendantOwnerByEndpointId.has(original.sourceId);
739
+ const proxy = proxyByKind.get(proxyKey(sourceInside ? "output" : "input", edge))!;
740
+ const origin = externalPortDummyOf(proxy)!;
741
+ const portId = `${String(proxy.id)}:parent`;
476
742
  syntheticPortIds.add(portId);
477
- const direction = getDirection(layoutOptions);
478
- const outgoing = sourceInside;
479
- const flowForward = direction === "right" || direction === "down";
480
- const useFarSide = outgoing === flowForward;
481
- ports.push({
482
- id: portId,
483
- width: 0,
484
- height: 0,
485
- x:
486
- direction === "right" || direction === "left"
487
- ? (rect.x ?? 0) + (useFarSide ? (rect.width ?? 0) : 0)
488
- : (rect.x ?? 0) + (rect.width ?? 0) / 2,
489
- y:
490
- direction === "down" || direction === "up"
491
- ? (rect.y ?? 0) + (useFarSide ? (rect.height ?? 0) : 0)
492
- : (rect.y ?? 0) + (rect.height ?? 0) / 2,
493
- });
494
- if (!hierarchyRestorations.some((restoration) => restoration.edge === edge)) {
743
+ if (!initialPorts.some((port) => port.id === portId))
744
+ initialPorts.push({
745
+ id: portId,
746
+ width: origin.externalSize.width,
747
+ height: origin.externalSize.height,
748
+ x: 0,
749
+ y: 0,
750
+ layoutOptions: {
751
+ "elk.port.side": origin.side,
752
+ "elk.port.borderOffset": origin.borderOffset,
753
+ },
754
+ });
755
+ if (!hierarchyRestorations.some((restoration) => restoration.edge === edge))
495
756
  hierarchyRestorations.push({ edge, ...original });
496
- }
497
757
  if (sourceInside) edge.sources = [portId];
498
758
  else edge.targets = [portId];
759
+ const rank = scopeSegmentOrder.get(originalEdgeId.get(edge) ?? String(edge.id));
760
+ if (rank !== undefined) hierarchySegmentRank.set(edge, rank);
499
761
  edge.source = undefined;
500
762
  edge.target = undefined;
501
763
  }
502
- child.ports = ports;
503
- child.layoutOptions = { ...child.layoutOptions, "elk.portConstraints": "FIXED_POS" };
504
- const insideLoopCount = (graph.edges ?? []).filter((edge) =>
505
- isInsideSelfLoop(graph, edge, String(child.id)),
506
- ).length;
507
- if (insideLoopCount > 0) {
508
- const baseHeight = child.height ?? 0;
509
- insideSelfLoopBaseHeightByNodeId.set(String(child.id), baseHeight);
510
- child.height = baseHeight + insideLoopCount * 11;
764
+ for (const { port, proxy } of inactiveAuthoredPorts) {
765
+ const actual = initialPorts.find((p) => p.id === port.id)!;
766
+ actual.layoutOptions = {
767
+ ...actual.layoutOptions,
768
+ "elk.port.side": externalPortDummyOf(proxy)!.side,
769
+ };
511
770
  }
771
+ for (const edge of outsideNodeEdges) {
772
+ const original = originalHierarchyEndpoints.get(edge)!;
773
+ const source = original.sourceId === String(child.id);
774
+ const proxy = proxyFor(source ? "output" : "input", edge),
775
+ own = ownPortFor(edge)!;
776
+ const origin = externalPortDummyOf(proxy)!;
777
+ if (!initialPorts.some((p) => p.id === own.id))
778
+ initialPorts.push({
779
+ ...own,
780
+ layoutOptions: { ...own.layoutOptions, "elk.port.side": origin.side },
781
+ });
782
+ if (!hierarchyRestorations.some((r) => r.edge === edge))
783
+ hierarchyRestorations.push({ edge, ...original });
784
+ if (source) edge.sources = [own.id!];
785
+ else edge.targets = [own.id!];
786
+ edge.source = undefined;
787
+ edge.target = undefined;
788
+ }
789
+ child.ports = initialPorts;
790
+ child.layoutOptions = {
791
+ ...child.layoutOptions,
792
+ "elk.portConstraints":
793
+ // ELK fixes physical boundary sides after introducing hierarchy dummies.
794
+ // Authored constraints are restored when finishing the public graph.
795
+ crossingEdges.length > 0 || outsideNodeEdges.length > 0
796
+ ? "FIXED_SIDE"
797
+ : (getOption(authoredOptionsByCompound.get(child) ?? {}, "portConstraints") ??
798
+ "FREE"),
799
+ };
800
+ finishChildren.push(async () => {
801
+ await preparedChild.finish(activeOrders);
802
+ const childPadding = parsePadding(
803
+ getOption({ ...layoutOptions, ...child.layoutOptions }, "padding"),
804
+ 12,
805
+ );
806
+ for (const internalEdge of internalEdges) {
807
+ const temporary = temporaryEdges.find(
808
+ (candidate) => String(candidate.id) === String(internalEdge.id),
809
+ );
810
+ if (temporary?.sections) {
811
+ internalEdge.sections = temporary.sections;
812
+ // A nested scope may own the route (an edge from a compound's
813
+ // own port to its child); keep that coordinate frame.
814
+ internalEdge.container = (temporary as { container?: string }).container ?? child.id;
815
+ }
816
+ }
817
+ const childDirection = getDirection({ ...layoutOptions, ...child.layoutOptions });
818
+ const horizontalChild = childDirection === "right" || childDirection === "left";
819
+ // External dummy positions retain any routing corridor at the boundary.
820
+ const restoredPerpendicular = [...proxyByKind.values()].some(isPerpendicularPortRestored);
821
+ child.width = restoredPerpendicular
822
+ ? temporaryChild.width
823
+ : horizontalChild
824
+ ? Math.max(
825
+ 0,
826
+ ...(temporaryChild.children ?? []).map(
827
+ (node) => (node.x ?? 0) + (node.width ?? 0),
828
+ ),
829
+ ) + childPadding.right
830
+ : temporaryChild.width;
831
+ child.height = restoredPerpendicular
832
+ ? temporaryChild.height
833
+ : horizontalChild
834
+ ? temporaryChild.height
835
+ : Math.max(
836
+ 0,
837
+ ...(temporaryChild.children ?? []).map(
838
+ (node) => (node.y ?? 0) + (node.height ?? 0),
839
+ ),
840
+ ) + childPadding.bottom;
841
+
842
+ const relativeRect = (id: string): ElkShape | undefined => {
843
+ const owner = descendantOwnerByEndpointId.get(id);
844
+ if (!owner) return undefined;
845
+ const path: ElkNode[] = [];
846
+ const visit = (parent: ElkNode): boolean => {
847
+ for (const candidate of parent.children ?? []) {
848
+ path.push(candidate);
849
+ if (candidate === owner || visit(candidate)) return true;
850
+ path.pop();
851
+ }
852
+ return false;
853
+ };
854
+ if (!visit(child)) return undefined;
855
+ const port = owner.ports?.find((candidate) => String(candidate.id) === id);
856
+ const ownerX = path.reduce((sum, node) => sum + (node.x ?? 0), 0);
857
+ const ownerY = path.reduce((sum, node) => sum + (node.y ?? 0), 0);
858
+ if (port) {
859
+ return {
860
+ x: ownerX + (port.x ?? 0) + (port.width ?? 0) / 2,
861
+ y: ownerY + (port.y ?? 0) + (port.height ?? 0) / 2,
862
+ width: 0,
863
+ height: 0,
864
+ };
865
+ }
866
+ return {
867
+ x: ownerX,
868
+ y: ownerY,
869
+ width: path.at(-1)?.width ?? 0,
870
+ height: path.at(-1)?.height ?? 0,
871
+ };
872
+ };
873
+ const ports = (authoredPortsByCompound.get(child) ?? []).map(
874
+ (original) => child.ports?.find((port) => port.id === original.id) ?? original,
875
+ );
876
+ for (const edge of crossingEdges) {
877
+ const original = originalHierarchyEndpoints.get(edge)!;
878
+ const { sourceId, targetId } = original;
879
+ const sourceInside = descendantOwnerByEndpointId.has(sourceId);
880
+ const descendantId = sourceInside ? sourceId : targetId;
881
+ const rect = relativeRect(descendantId);
882
+ if (!rect) continue;
883
+ const internalEdge = temporaryEdges.find(
884
+ (candidate) =>
885
+ String(candidate.id) ===
886
+ `__native_hierarchy_edge_${String(child.id)}_${String(edge.id)}`,
887
+ );
888
+ const internalRoute = internalEdge?.sections;
889
+ const kind = sourceInside ? "output" : "input";
890
+ const proxy = proxyByKind.get(proxyKey(kind, edge))!;
891
+ const origin = externalPortDummyOf(proxy)!;
892
+ const endpoint = sourceInside
893
+ ? internalRoute?.at(-1)?.endPoint
894
+ : internalRoute?.[0]?.startPoint;
895
+ if (!endpoint)
896
+ throw new Error(`Missing compound boundary route for ${String(edge.id)}`);
897
+ const transferred = transferExternalPort({
898
+ contentSize: {
899
+ width: child.width! - childPadding.left - childPadding.right,
900
+ height: child.height! - childPadding.top - childPadding.bottom,
901
+ },
902
+ padding: childPadding,
903
+ offset: { x: 0, y: 0 },
904
+ dummy: {
905
+ x: endpoint.x - childPadding.left - (ownPortFor(edge) ? origin.port.x : 0),
906
+ y: endpoint.y - childPadding.top - (ownPortFor(edge) ? origin.port.y : 0),
907
+ width: origin.width,
908
+ height: origin.height,
909
+ },
910
+ side: origin.side,
911
+ borderOffset: origin.borderOffset,
912
+ portSize: origin.externalSize,
913
+ });
914
+ const points = internalRoute!
915
+ .flatMap((section, index) => [
916
+ ...(index === 0 ? [section.startPoint] : []),
917
+ ...(section.bendPoints ?? []),
918
+ section.endPoint,
919
+ ])
920
+ .map((point) => ({ ...point }));
921
+ const boundary = sourceInside ? points.at(-1)! : points[0]!;
922
+ boundary.x = transferred.dummy.x + childPadding.left;
923
+ boundary.y = transferred.dummy.y + childPadding.top;
924
+ const own = ownPortFor(edge);
925
+ if (own) {
926
+ if (implicitOwnPorts.has(edge) && !ports.some((p) => p.id === own.id))
927
+ ports.push(own);
928
+ own.x = transferred.port.x;
929
+ own.y = transferred.port.y;
930
+ const anchor = (getElementLayeredSettings(own.layoutOptions ?? {})["port.anchor"] as
931
+ | ElkPoint
932
+ | undefined) ?? {
933
+ x:
934
+ origin.side === "EAST"
935
+ ? (own.width ?? 0)
936
+ : origin.side === "WEST"
937
+ ? 0
938
+ : (own.width ?? 0) / 2,
939
+ y:
940
+ origin.side === "SOUTH"
941
+ ? (own.height ?? 0)
942
+ : origin.side === "NORTH"
943
+ ? 0
944
+ : (own.height ?? 0) / 2,
945
+ };
946
+ boundary.x = own.x + anchor.x;
947
+ boundary.y = own.y + anchor.y;
948
+ edge.sections = [
949
+ {
950
+ id: `${String(edge.id)}_s0`,
951
+ startPoint: points[0]!,
952
+ endPoint: points.at(-1)!,
953
+ ...(points.length > 2 ? { bendPoints: points.slice(1, -1) } : {}),
954
+ incomingShape: sourceId,
955
+ outgoingShape: targetId,
956
+ },
957
+ ];
958
+ if (internalEdge?.junctionPoints?.length)
959
+ edge.junctionPoints = internalEdge.junctionPoints.map((point) => ({ ...point }));
960
+ else delete edge.junctionPoints;
961
+ edge.container = child.id;
962
+ continue;
963
+ }
964
+ const routes = boundaryRoutes.get(edge) ?? {};
965
+ routes[sourceInside ? "source" : "target"] = {
966
+ owner: child,
967
+ points,
968
+ junctionPoints: internalEdge?.junctionPoints,
969
+ };
970
+ boundaryRoutes.set(edge, routes);
971
+ const portId = `${String(proxy.id)}:parent`;
972
+ syntheticPortIds.add(portId);
973
+ if (!ports.some((port) => port.id === portId))
974
+ ports.push({
975
+ id: portId,
976
+ width: origin.externalSize.width,
977
+ height: origin.externalSize.height,
978
+ x: transferred.port.x,
979
+ y: transferred.port.y,
980
+ layoutOptions: {
981
+ "elk.port.side": origin.side,
982
+ "elk.port.borderOffset": origin.borderOffset,
983
+ },
984
+ });
985
+ if (!hierarchyRestorations.some((restoration) => restoration.edge === edge)) {
986
+ hierarchyRestorations.push({ edge, ...original });
987
+ }
988
+ if (sourceInside) edge.sources = [portId];
989
+ else edge.targets = [portId];
990
+ edge.source = undefined;
991
+ edge.target = undefined;
992
+ }
993
+ for (const { port, proxy } of inactiveAuthoredPorts) {
994
+ const origin = externalPortDummyOf(proxy)!;
995
+ const actual = ports.find((p) => p.id === port.id)!;
996
+ const transferred = transferExternalPort({
997
+ contentSize: {
998
+ width: child.width! - childPadding.left - childPadding.right,
999
+ height: child.height! - childPadding.top - childPadding.bottom,
1000
+ },
1001
+ padding: childPadding,
1002
+ offset: { x: 0, y: 0 },
1003
+ dummy: {
1004
+ x: (proxy.x ?? 0) - childPadding.left,
1005
+ y: (proxy.y ?? 0) - childPadding.top,
1006
+ width: origin.width,
1007
+ height: origin.height,
1008
+ },
1009
+ side: origin.side,
1010
+ borderOffset: origin.borderOffset,
1011
+ portSize: origin.externalSize,
1012
+ });
1013
+ actual.x = transferred.port.x;
1014
+ actual.y = transferred.port.y;
1015
+ }
1016
+ for (const edge of outsideNodeEdges) {
1017
+ const original = originalHierarchyEndpoints.get(edge)!;
1018
+ const proxy = proxyFor(
1019
+ original.sourceId === String(child.id) ? "output" : "input",
1020
+ edge,
1021
+ ),
1022
+ origin = externalPortDummyOf(proxy)!,
1023
+ own = ownPortFor(edge)!;
1024
+ const transferred = transferExternalPort({
1025
+ contentSize: {
1026
+ width: child.width! - childPadding.left - childPadding.right,
1027
+ height: child.height! - childPadding.top - childPadding.bottom,
1028
+ },
1029
+ padding: childPadding,
1030
+ offset: { x: 0, y: 0 },
1031
+ dummy: {
1032
+ x: (proxy.x ?? 0) - childPadding.left,
1033
+ y: (proxy.y ?? 0) - childPadding.top,
1034
+ width: origin.width,
1035
+ height: origin.height,
1036
+ },
1037
+ side: origin.side,
1038
+ borderOffset: origin.borderOffset,
1039
+ portSize: origin.externalSize,
1040
+ });
1041
+ own.x = transferred.port.x;
1042
+ own.y = transferred.port.y;
1043
+ if (!ports.some((p) => p.id === own.id)) ports.push(own);
1044
+ }
1045
+ child.ports = ports;
1046
+ child.layoutOptions = { ...child.layoutOptions, "elk.portConstraints": "FIXED_POS" };
1047
+ const insideLoopCount = (graph.edges ?? []).filter((edge) =>
1048
+ isInsideSelfLoop(graph, edge, String(child.id)),
1049
+ ).length;
1050
+ if (insideLoopCount > 0) {
1051
+ const baseHeight = child.height ?? 0;
1052
+ insideSelfLoopBaseHeightByNodeId.set(String(child.id), baseHeight);
1053
+ child.height = baseHeight + insideLoopCount * 11;
1054
+ }
1055
+ });
512
1056
  }
513
1057
  }
514
1058
  applyNodeMicroLayout(graph, layoutOptions);
515
1059
  const graph_ = toGraph(graph, layoutOptions);
1060
+ const hierarchyProxyIds = new Set(
1061
+ (graph.children ?? [])
1062
+ .filter((node) => String(node.id).startsWith("__native_hierarchy_"))
1063
+ .map((node) => String(node.id)),
1064
+ );
516
1065
  const layerConstraintByNodeId = new Map(
517
1066
  (graph.children ?? []).map((node) => [
518
1067
  String(node.id),
@@ -527,6 +1076,10 @@ export default class ELK {
527
1076
  algorithm === "layered" &&
528
1077
  graph_.edges.some((edge) => {
529
1078
  if (edge.sourceId === edge.targetId) return false;
1079
+ // A boundary proxy for an edge from the enclosing compound sits in its own
1080
+ // separate layer ahead of the scope, so it never conflicts with a first node.
1081
+ if (hierarchyProxyIds.has(edge.sourceId) || hierarchyProxyIds.has(edge.targetId))
1082
+ return false;
530
1083
  const sourceConstraint = layerConstraintByNodeId.get(edge.sourceId);
531
1084
  const targetConstraint = layerConstraintByNodeId.get(edge.targetId);
532
1085
  return (
@@ -543,307 +1096,469 @@ export default class ELK {
543
1096
  getOption(layoutOptions, "padding"),
544
1097
  algorithm === "layered" ? 12 : 0,
545
1098
  );
546
- const laidOut = await (algorithm === "sporeCompaction"
547
- ? executeElkjs0111Layout({
548
- algorithm: "sporeCompaction",
549
- graph: graph_,
550
- options: {
551
- padding,
552
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
553
- },
554
- })
555
- : algorithm === "sporeOverlap"
1099
+ const options: LayeredLayoutOptions = {
1100
+ direction: getDirection(layoutOptions),
1101
+ spacing: {
1102
+ node:
1103
+ getNumberOption(layoutOptions, "spacing.nodeNode") ??
1104
+ getNumberOption(layoutOptions, "layered.spacing.baseValue"),
1105
+ layer:
1106
+ getNumberOption(layoutOptions, "layered.spacing.nodeNodeBetweenLayers") ??
1107
+ getNumberOption(layoutOptions, "layered.spacing.baseValue") ??
1108
+ 20,
1109
+ },
1110
+ padding,
1111
+ constraints: {
1112
+ layer: () => undefined,
1113
+ },
1114
+ settings: {
1115
+ ...getLayeredSettings(layoutOptions),
1116
+ ...((arguments_ as ModelArguments)[crossingModel]
1117
+ ? { [CROSSING_MODEL]: (arguments_ as ModelArguments)[crossingModel] }
1118
+ : {}),
1119
+ ...(compoundLayout || (hasHierarchy && !separateHierarchy && !topdownLayout)
1120
+ ? { separateConnectedComponents: false }
1121
+ : {}),
1122
+ ...(hierarchyHandling === "INCLUDE_CHILDREN"
1123
+ ? {
1124
+ "crossingMinimization.greedySwitch.type": String(
1125
+ getOption(
1126
+ layoutOptions,
1127
+ "layered.crossingMinimization.greedySwitchHierarchical.type",
1128
+ ) ?? "TWO_SIDED",
1129
+ ) as LayeredAdvancedOptions["crossingMinimization.greedySwitch.type"],
1130
+ }
1131
+ : {}),
1132
+ },
1133
+ nodeSettings: (node) => {
1134
+ const child = findById(graph.children, node.id)?.item;
1135
+ return getElementLayeredSettings(child?.layoutOptions ?? {});
1136
+ },
1137
+ edgeSettings: (edge) => {
1138
+ const elkEdge = findById(graph.edges, edge.id)?.item;
1139
+ return {
1140
+ ...getElementLayeredSettings(elkEdge?.layoutOptions ?? {}),
1141
+ ...getElementLayeredSettings(elkEdge?.labels?.[0]?.layoutOptions ?? {}),
1142
+ // ELK appends hierarchy segments to port edge lists in creation order.
1143
+ "edge.hierarchyRank": elkEdge && hierarchySegmentRank.get(elkEdge),
1144
+ };
1145
+ },
1146
+ portSettings: (port, node) => {
1147
+ const child = findById(graph.children, node.id)?.item;
1148
+ const found = findById(child?.ports, port.name);
1149
+ const elkPort = found?.item;
1150
+ return {
1151
+ ...getElementLayeredSettings(elkPort?.layoutOptions ?? {}),
1152
+ // ELK imports ports in authored order and sorts sides after dummy insertion.
1153
+ "port.authoredIndex": found?.position,
1154
+ "port.labelWidth": Math.max(
1155
+ 0,
1156
+ ...(elkPort?.labels ?? []).map((label) => label.width ?? 0),
1157
+ ),
1158
+ "port.labelHeight": Math.max(
1159
+ 0,
1160
+ ...(elkPort?.labels ?? []).map((label) => label.height ?? 0),
1161
+ ),
1162
+ } as ElkLayeredOptionValueByName;
1163
+ },
1164
+ };
1165
+ const pipeline =
1166
+ algorithm === "layered" ? createLayeredScopePipeline(graph_, options) : undefined;
1167
+ const initial = pipeline?.next();
1168
+ const phase = initial && !initial.done ? initial.value : undefined;
1169
+ let activeOrders: ReadonlyMap<PreparedElkScope, LayerOrder> | undefined;
1170
+ let finished: Promise<ElkNode> | undefined;
1171
+ const prepared: PreparedElkScope = {
1172
+ graph,
1173
+ native: graph_,
1174
+ phase,
1175
+ options,
1176
+ children: preparedChildren,
1177
+ finish: (orders) => {
1178
+ if (finished) return finished;
1179
+ activeOrders = orders;
1180
+ finished = finish();
1181
+ return finished;
1182
+ },
1183
+ };
1184
+ const finishLayered = (): VisualGraph => {
1185
+ if (!pipeline || !initial) throw new Error("Missing layered pipeline");
1186
+ if (initial.done) return initial.value;
1187
+ const completed = pipeline.next(
1188
+ activeOrders?.has(prepared)
1189
+ ? initial.value.finishOrder(activeOrders.get(prepared)!)
1190
+ : initial.value.minimize(),
1191
+ );
1192
+ if (!completed.done) throw new Error("Layered scope yielded more than one crossing phase");
1193
+ return completed.value;
1194
+ };
1195
+ const finish = async (): Promise<ElkNode> => {
1196
+ for (const complete of finishChildren) await complete();
1197
+ for (const nativeGraph of new Set(
1198
+ [graph_, phase?.input.graph].filter((value) => value !== undefined),
1199
+ )) {
1200
+ for (const child of graph.children ?? []) {
1201
+ const native = nativeGraph.nodes.find((node) => node.id === String(child.id));
1202
+ if (!native || !preparedChildren.has(String(child.id))) continue;
1203
+ phase?.updateNodeSize(native.id, { width: child.width ?? 0, height: child.height ?? 0 });
1204
+ Object.assign(native, { width: child.width, height: child.height });
1205
+ for (const port of native.ports ?? []) {
1206
+ const actual = child.ports?.find((candidate) => String(candidate.id) === port.name);
1207
+ if (actual)
1208
+ Object.assign(port, {
1209
+ x: actual.x,
1210
+ y: actual.y,
1211
+ width: actual.width,
1212
+ height: actual.height,
1213
+ });
1214
+ }
1215
+ }
1216
+ }
1217
+ const laidOut = await (algorithm === "sporeCompaction"
556
1218
  ? executeElkjs0111Layout({
557
- algorithm: "sporeOverlap",
1219
+ algorithm: "sporeCompaction",
558
1220
  graph: graph_,
559
1221
  options: {
560
1222
  padding,
561
1223
  spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
562
1224
  },
563
1225
  })
564
- : algorithm === "rectpacking"
1226
+ : algorithm === "sporeOverlap"
565
1227
  ? executeElkjs0111Layout({
566
- algorithm: "rectpacking",
1228
+ algorithm: "sporeOverlap",
567
1229
  graph: graph_,
568
1230
  options: {
569
- padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
570
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode") ?? 15,
1231
+ padding,
1232
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
571
1233
  },
572
1234
  })
573
- : algorithm === "random"
1235
+ : algorithm === "rectpacking"
574
1236
  ? executeElkjs0111Layout({
575
- algorithm: "random",
1237
+ algorithm: "rectpacking",
576
1238
  graph: graph_,
577
1239
  options: {
578
1240
  padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
579
- spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
580
- aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
581
- seed: getNumberOption(layoutOptions, "randomSeed"),
1241
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode") ?? 15,
582
1242
  },
583
1243
  })
584
- : algorithm === "box"
1244
+ : algorithm === "random"
585
1245
  ? executeElkjs0111Layout({
586
- algorithm: "box",
1246
+ algorithm: "random",
587
1247
  graph: graph_,
588
1248
  options: {
589
1249
  padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
590
1250
  spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
591
1251
  aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
592
- interactive: getBooleanOption(layoutOptions, "interactive"),
593
- expandNodes: getBooleanOption(layoutOptions, "expandNodes"),
594
- priority: (node) => {
595
- const child = graph.children?.find(
596
- (candidate) => String(candidate.id) === node.id,
597
- );
598
- return getNumberOption(child?.layoutOptions ?? {}, "priority");
599
- },
1252
+ seed: getNumberOption(layoutOptions, "randomSeed"),
600
1253
  },
601
1254
  })
602
- : algorithm === "fixed"
1255
+ : algorithm === "box"
603
1256
  ? executeElkjs0111Layout({
604
- algorithm: "fixed",
605
- graph: graph_,
606
- options: { direction: getDirection(layoutOptions) },
607
- })
608
- : executeElkjs0111Layout({
609
- algorithm: "layered",
1257
+ algorithm: "box",
610
1258
  graph: graph_,
611
1259
  options: {
612
- direction: getDirection(layoutOptions),
613
- spacing: {
614
- node:
615
- getNumberOption(layoutOptions, "spacing.nodeNode") ??
616
- getNumberOption(layoutOptions, "layered.spacing.baseValue"),
617
- layer:
618
- (getNumberOption(
619
- layoutOptions,
620
- "layered.spacing.nodeNodeBetweenLayers",
621
- ) ??
622
- getNumberOption(layoutOptions, "layered.spacing.baseValue") ??
623
- 20) +
624
- (hasHierarchyCrossingEdges
625
- ? 5 * Math.max(...hierarchyBoundaryCountByEdge.values())
626
- : 0),
627
- },
628
- padding,
629
- constraints: {
630
- layer: () => undefined,
631
- },
632
- settings: {
633
- ...getLayeredSettings(layoutOptions),
634
- ...(hierarchyHandling === "INCLUDE_CHILDREN" &&
635
- getOption(
636
- layoutOptions,
637
- "layered.crossingMinimization.greedySwitchHierarchical.type",
638
- ) !== undefined
639
- ? {
640
- "crossingMinimization.greedySwitch.type": String(
641
- getOption(
642
- layoutOptions,
643
- "layered.crossingMinimization.greedySwitchHierarchical.type",
644
- ),
645
- ) as LayeredAdvancedOptions["crossingMinimization.greedySwitch.type"],
646
- }
647
- : {}),
648
- },
649
- nodeSettings: (node) => {
1260
+ padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
1261
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
1262
+ aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
1263
+ interactive: getBooleanOption(layoutOptions, "interactive"),
1264
+ expandNodes: getBooleanOption(layoutOptions, "expandNodes"),
1265
+ priority: (node) => {
650
1266
  const child = graph.children?.find(
651
1267
  (candidate) => String(candidate.id) === node.id,
652
1268
  );
653
- return getElementLayeredSettings(child?.layoutOptions ?? {});
654
- },
655
- edgeSettings: (edge) => {
656
- const elkEdge = graph.edges?.find(
657
- (candidate) => String(candidate.id) === edge.id,
658
- );
659
- return {
660
- ...getElementLayeredSettings(elkEdge?.layoutOptions ?? {}),
661
- ...getElementLayeredSettings(elkEdge?.labels?.[0]?.layoutOptions ?? {}),
662
- };
663
- },
664
- portSettings: (port, node) => {
665
- const child = graph.children?.find(
666
- (candidate) => String(candidate.id) === node.id,
667
- );
668
- const elkPort = child?.ports?.find(
669
- (candidate) => String(candidate.id) === port.name,
670
- );
671
- return {
672
- ...getElementLayeredSettings(elkPort?.layoutOptions ?? {}),
673
- "port.labelWidth": Math.max(
674
- 0,
675
- ...(elkPort?.labels ?? []).map((label) => label.width ?? 0),
676
- ),
677
- "port.labelHeight": Math.max(
678
- 0,
679
- ...(elkPort?.labels ?? []).map((label) => label.height ?? 0),
680
- ),
681
- } as ElkLayeredOptionValueByName;
1269
+ return getNumberOption(child?.layoutOptions ?? {}, "priority");
682
1270
  },
683
1271
  },
684
- }));
685
- if (hierarchyRestorations.length > 0) {
686
- const direction = getDirection(layoutOptions);
687
- const horizontal = direction === "right" || direction === "left";
688
- const cross = (point: ElkPoint): number => (horizontal ? point.y : point.x);
689
- const nodeSpacing = getNumberOption(layoutOptions, "spacing.nodeNode") ?? 20;
690
- const shiftsByOutsideId = new Map<string, number[]>();
691
- for (const restoration of hierarchyRestorations) {
692
- const route = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id))?.points;
693
- if (!route || route.length < 2) continue;
694
- const originalSourceId = String(restoration.sources?.[0] ?? restoration.source);
695
- const originalTargetId = String(restoration.targets?.[0] ?? restoration.target);
696
- const sourceInside = !laidOut.nodes.some((node) => node.id === originalSourceId);
697
- const outsideId = sourceInside ? originalTargetId : originalSourceId;
698
- const delta = sourceInside
699
- ? cross(route[0]!) - cross(route.at(-1)!)
700
- : cross(route.at(-1)!) - cross(route[0]!);
701
- const candidates = shiftsByOutsideId.get(outsideId) ?? [];
702
- candidates.push(delta);
703
- shiftsByOutsideId.set(outsideId, candidates);
704
- }
705
- for (const [outsideId, candidates] of shiftsByOutsideId) {
706
- const delta = [...candidates].sort((left, right) => Math.abs(left) - Math.abs(right))[0]!;
707
- const node = laidOut.nodes.find((candidate) => candidate.id === outsideId);
708
- if (!node || Math.abs(delta) < 1e-9 || Math.abs(delta) > nodeSpacing) continue;
709
- if (horizontal) node.y = (node.y ?? 0) + delta;
710
- else node.x = (node.x ?? 0) + delta;
711
- for (const edge of laidOut.edges) {
712
- const points = edge.points;
713
- if (!points || points.length === 0) continue;
714
- if (edge.sourceId === outsideId) {
715
- if (horizontal) points[0]!.y += delta;
716
- else points[0]!.x += delta;
717
- }
718
- if (edge.targetId === outsideId) {
719
- if (horizontal) points.at(-1)!.y += delta;
720
- else points.at(-1)!.x += delta;
721
- }
1272
+ })
1273
+ : algorithm === "fixed"
1274
+ ? executeElkjs0111Layout({
1275
+ algorithm: "fixed",
1276
+ graph: graph_,
1277
+ options: { direction: getDirection(layoutOptions) },
1278
+ })
1279
+ : finishLayered());
1280
+ const resultPolicy = (
1281
+ laidOut as typeof laidOut & {
1282
+ [elkjs0111ResultPolicy]?: Elkjs0111ResultPolicy;
722
1283
  }
1284
+ )[elkjs0111ResultPolicy];
1285
+ applyLayout(graph, laidOut, padding, layoutOptions, resultPolicy);
1286
+ if (resultPolicy?.providerBounds) {
1287
+ graph.width = resultPolicy.providerBounds.width;
1288
+ graph.height = resultPolicy.providerBounds.height;
723
1289
  }
724
- const modelOrder = new Map(
725
- (graph.children ?? []).map((node, index) => [String(node.id), index]),
726
- );
727
- const flowLayers = new Map<number, (typeof laidOut.nodes)[number][]>();
728
- for (const node of laidOut.nodes) {
729
- const flow = horizontal ? (node.x ?? 0) : (node.y ?? 0);
730
- const layer = flowLayers.get(flow) ?? [];
731
- layer.push(node);
732
- flowLayers.set(flow, layer);
733
- }
734
- for (const layer of flowLayers.values()) {
735
- layer.sort(
736
- (left, right) =>
737
- (modelOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
738
- (modelOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER),
739
- );
740
- let crossEnd = Number.NEGATIVE_INFINITY;
741
- for (const node of layer) {
742
- const authoredCross = horizontal ? (node.y ?? 0) : (node.x ?? 0);
743
- const compactedCross = Math.max(
744
- authoredCross,
745
- crossEnd === Number.NEGATIVE_INFINITY ? authoredCross : crossEnd + nodeSpacing,
1290
+ for (const restoration of hierarchyRestorations) {
1291
+ const splits = boundaryRoutes.get(restoration.edge);
1292
+ const sections = restoration.edge.sections;
1293
+ if (splits && sections?.length) {
1294
+ const parentPoints = sections.flatMap((section, index) => [
1295
+ ...(index === 0 ? [section.startPoint] : []),
1296
+ ...(section.bendPoints ?? []),
1297
+ section.endPoint,
1298
+ ]);
1299
+ const segments = [
1300
+ ...(splits.source
1301
+ ? [
1302
+ {
1303
+ points: splits.source.points,
1304
+ offset: { x: splits.source.owner.x ?? 0, y: splits.source.owner.y ?? 0 },
1305
+ },
1306
+ ]
1307
+ : []),
1308
+ { points: parentPoints, offset: { x: 0, y: 0 } },
1309
+ ...(splits.target
1310
+ ? [
1311
+ {
1312
+ points: splits.target.points,
1313
+ offset: { x: splits.target.owner.x ?? 0, y: splits.target.owner.y ?? 0 },
1314
+ },
1315
+ ]
1316
+ : []),
1317
+ ];
1318
+ const points = joinCompoundRouteSegments(
1319
+ segments,
1320
+ getBooleanOption(layoutOptions, "unnecessaryBendpoints") === true,
746
1321
  );
747
- const delta = compactedCross - authoredCross;
748
- if (horizontal) node.y = compactedCross;
749
- else node.x = compactedCross;
750
- if (Math.abs(delta) > 1e-9) {
751
- for (const edge of laidOut.edges) {
752
- const points = edge.points;
753
- if (!points || points.length === 0) continue;
754
- if (edge.sourceId === node.id) {
755
- if (horizontal) points[0]!.y += delta;
756
- else points[0]!.x += delta;
757
- }
758
- if (edge.targetId === node.id) {
759
- if (horizontal) points.at(-1)!.y += delta;
760
- else points.at(-1)!.x += delta;
761
- }
762
- }
1322
+ const childJunctions = (split: typeof splits.source) =>
1323
+ (split?.junctionPoints ?? []).map((point) => ({
1324
+ x: point.x + (split?.owner.x ?? 0),
1325
+ y: point.y + (split?.owner.y ?? 0),
1326
+ }));
1327
+ const junctions = [
1328
+ ...childJunctions(splits.source),
1329
+ ...(restoration.edge.junctionPoints ?? []),
1330
+ ...childJunctions(splits.target),
1331
+ ];
1332
+ if (junctions.length) restoration.edge.junctionPoints = junctions;
1333
+ else delete restoration.edge.junctionPoints;
1334
+ restoration.edge.sections = [
1335
+ {
1336
+ ...sections[0]!,
1337
+ startPoint: points[0]!,
1338
+ endPoint: points.at(-1)!,
1339
+ bendPoints: points.slice(1, -1),
1340
+ },
1341
+ ];
1342
+ }
1343
+ restoration.edge.sources = restoration.sources;
1344
+ restoration.edge.targets = restoration.targets;
1345
+ restoration.edge.source = restoration.source;
1346
+ restoration.edge.target = restoration.target;
1347
+ for (const section of restoration.edge.sections ?? []) {
1348
+ if (
1349
+ section.incomingShape != null &&
1350
+ syntheticPortIds.has(String(section.incomingShape))
1351
+ ) {
1352
+ section.incomingShape = restoration.sources?.[0] ?? restoration.source;
1353
+ }
1354
+ if (
1355
+ section.outgoingShape != null &&
1356
+ syntheticPortIds.has(String(section.outgoingShape))
1357
+ ) {
1358
+ section.outgoingShape = restoration.targets?.[0] ?? restoration.target;
763
1359
  }
764
- crossEnd = compactedCross + (horizontal ? node.height : node.width);
765
1360
  }
766
1361
  }
767
- const edgeNodeSpacing = getNumberOption(layoutOptions, "spacing.edgeNodeBetweenLayers") ?? 10;
768
- for (const restoration of hierarchyRestorations) {
769
- const nativeEdge = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id));
770
- const route = nativeEdge?.points;
771
- if (!route || route.length < 2) continue;
772
- const start = route[0]!;
773
- const end = route.at(-1)!;
774
- // Decomposed ancestor/descendant edges are native self-loops. Preserve
775
- // their reserved exterior route and label placement instead of treating
776
- // equal endpoint coordinates as a straight inter-node connection.
777
- if (nativeEdge.sourceId === nativeEdge.targetId) continue;
778
- if (Math.abs(cross(start) - cross(end)) < 1e-9) {
779
- route.splice(1, route.length - 2);
780
- continue;
1362
+ for (const [compound, ports] of authoredPortsByCompound) {
1363
+ for (const port of ports ?? []) {
1364
+ const actual = compound.ports?.find((p) => p.id === port.id);
1365
+ if (actual)
1366
+ Object.assign(port, {
1367
+ x: actual.x,
1368
+ y: actual.y,
1369
+ width: actual.width,
1370
+ height: actual.height,
1371
+ });
781
1372
  }
782
- const originalSourceId = String(restoration.sources?.[0] ?? restoration.source);
783
- const sourceInside = !laidOut.nodes.some((node) => node.id === originalSourceId);
784
- const flowForward = direction === "right" || direction === "down";
785
- const track = sourceInside
786
- ? (horizontal ? end.x : end.y) - (flowForward ? edgeNodeSpacing : -edgeNodeSpacing)
787
- : (horizontal ? start.x : start.y) + (flowForward ? edgeNodeSpacing : -edgeNodeSpacing);
788
- route.splice(
789
- 1,
790
- route.length - 2,
791
- horizontal ? { x: track, y: start.y } : { x: start.x, y: track },
792
- horizontal ? { x: track, y: end.y } : { x: end.x, y: track },
793
- );
1373
+ compound.ports = ports;
794
1374
  }
795
- }
796
- const resultPolicy = (
797
- laidOut as typeof laidOut & {
798
- [elkjs0111ResultPolicy]?: Elkjs0111ResultPolicy;
799
- }
800
- )[elkjs0111ResultPolicy];
801
- applyLayout(graph, laidOut, padding, layoutOptions, resultPolicy);
802
- if (resultPolicy?.providerBounds) {
803
- graph.width = resultPolicy.providerBounds.width;
804
- graph.height = resultPolicy.providerBounds.height;
805
- }
806
- for (const restoration of hierarchyRestorations) {
807
- restoration.edge.sources = restoration.sources;
808
- restoration.edge.targets = restoration.targets;
809
- restoration.edge.source = restoration.source;
810
- restoration.edge.target = restoration.target;
811
- for (const section of restoration.edge.sections ?? []) {
812
- if (section.incomingShape != null && syntheticPortIds.has(String(section.incomingShape))) {
813
- section.incomingShape = restoration.sources?.[0] ?? restoration.source;
814
- }
815
- if (section.outgoingShape != null && syntheticPortIds.has(String(section.outgoingShape))) {
816
- section.outgoingShape = restoration.targets?.[0] ?? restoration.target;
1375
+ for (const [compound, options_] of authoredOptionsByCompound)
1376
+ compound.layoutOptions = options_;
1377
+ if (hasHierarchy && topdownLayout) {
1378
+ for (const child of graph.children ?? []) {
1379
+ if ((child.children?.length ?? 0) === 0) continue;
1380
+ const position = { x: child.x, y: child.y };
1381
+ await this.#layout(child, {
1382
+ ...arguments_,
1383
+ logging: false,
1384
+ measureExecutionTime: false,
1385
+ });
1386
+ child.x = position.x;
1387
+ child.y = position.y;
817
1388
  }
818
1389
  }
819
- }
820
- for (const [compound, ports] of authoredPortsByCompound) compound.ports = ports;
821
- for (const [compound, options_] of authoredOptionsByCompound) compound.layoutOptions = options_;
822
- if (hasHierarchy && topdownLayout) {
823
- for (const child of graph.children ?? []) {
824
- if ((child.children?.length ?? 0) === 0) continue;
825
- const position = { x: child.x, y: child.y };
826
- await this.#layout(child, {
827
- ...arguments_,
828
- logging: false,
829
- measureExecutionTime: false,
830
- });
831
- child.x = position.x;
832
- child.y = position.y;
1390
+ applyInsideSelfLoops(graph, insideSelfLoopBaseHeightByNodeId);
1391
+ if (arguments_.logging || arguments_.measureExecutionTime) {
1392
+ graph.logging = {
1393
+ name: "Native TypeScript layout",
1394
+ children: [{ name: String(algorithm) }],
1395
+ ...(arguments_.measureExecutionTime
1396
+ ? { executionTime: (performance.now() - startedAt) / 1_000 }
1397
+ : {}),
1398
+ };
833
1399
  }
834
- }
835
- applyInsideSelfLoops(graph, insideSelfLoopBaseHeightByNodeId);
836
- if (arguments_.logging || arguments_.measureExecutionTime) {
837
- graph.logging = {
838
- name: "Native TypeScript layout",
839
- children: [{ name: String(algorithm) }],
840
- ...(arguments_.measureExecutionTime
841
- ? { executionTime: (performance.now() - startedAt) / 1_000 }
842
- : {}),
1400
+ return graph;
1401
+ };
1402
+ return prepared;
1403
+ }
1404
+ }
1405
+
1406
+ /** Move opposite-direction track sharing apart on the finished layout. */
1407
+ function separated<T extends ElkNode>(graph: T, options: Readonly<Record<string, unknown>>): T {
1408
+ separateElkOpposingTracks(graph, { ...options, ...graph.layoutOptions });
1409
+ return graph;
1410
+ }
1411
+
1412
+ function coordinatePreparedScopes(
1413
+ root: PreparedElkScope,
1414
+ ): ReadonlyMap<PreparedElkScope, LayerOrder> | undefined {
1415
+ if (!root.phase || root.children.size === 0) return undefined;
1416
+ const prepared = [root];
1417
+ for (let i = 0; i < prepared.length; i++) prepared.push(...prepared[i]!.children.values());
1418
+ if (
1419
+ prepared.some(
1420
+ (scope) =>
1421
+ !scope.phase ||
1422
+ !["LAYER_SWEEP", "MEDIAN_LAYER_SWEEP"].includes(
1423
+ scope.options?.settings?.["crossingMinimization.strategy"] ?? "LAYER_SWEEP",
1424
+ ),
1425
+ )
1426
+ )
1427
+ return undefined;
1428
+ const scopes = new Map<PreparedElkScope, HierarchyCrossingScope>();
1429
+ for (const scope of prepared) {
1430
+ const phase = scope.phase!;
1431
+ scopes.set(scope, {
1432
+ session: createLayerSweepSession(
1433
+ phase.input,
1434
+ phase.orientation,
1435
+ phase.assignment,
1436
+ scope.options?.settings?.["crossingMinimization.strategy"] === "MEDIAN_LAYER_SWEEP"
1437
+ ? "median"
1438
+ : "mean",
1439
+ 7,
1440
+ { resetSeed: scope === root },
1441
+ ),
1442
+ childrenByNodeId: new Map(),
1443
+ useBottomUp: true,
1444
+ independentRandom: scope !== root,
1445
+ });
1446
+ }
1447
+ for (const parent of prepared) {
1448
+ const parentScope = scopes.get(parent)!;
1449
+ for (const [id, child] of parent.children) {
1450
+ const nativeParent = parent.phase!.input.graph.nodes.find((node) => node.id === id)!;
1451
+ const parentPortOrderFixed = ["FIXED_ORDER", "FIXED_POS", "FIXED_RATIO"].includes(
1452
+ parent.phase!.input.nodeSettings?.(nativeParent)?.portConstraints ?? "FREE",
1453
+ );
1454
+ const ports = nativeParent.ports ?? [];
1455
+ const childPhase = child.phase!;
1456
+ const childSession = scopes.get(child)!.session;
1457
+ const childNodeById = new Map<string, (typeof childPhase.input.graph.nodes)[number]>();
1458
+ for (const node of childPhase.input.graph.nodes)
1459
+ if (!childNodeById.has(node.id)) childNodeById.set(node.id, node);
1460
+ const parentEdgeById = new Map<string, (typeof childPhase.input.graph.edges)[number]>();
1461
+ for (const edge of parent.phase!.input.graph.edges)
1462
+ if (!parentEdgeById.has(edge.id)) parentEdgeById.set(edge.id, edge);
1463
+ const flowSides = hierarchicalPortSides(parent.phase!.input);
1464
+ const inputPorts = ports.filter(
1465
+ (port) =>
1466
+ parent.phase!.input.portSettings?.(port, nativeParent)?.["port.side"] ===
1467
+ flowSides.before,
1468
+ ).length;
1469
+ const outputPorts = ports.filter(
1470
+ (port) =>
1471
+ parent.phase!.input.portSettings?.(port, nativeParent)?.["port.side"] === flowSides.after,
1472
+ ).length;
1473
+ const childScope: HierarchyCrossingScope = {
1474
+ ...scopes.get(child)!,
1475
+ useBottomUp: useBottomUpHierarchySweep(childPhase.input, childSession.crossingGraph(), {
1476
+ portOrderFixed: parentPortOrderFixed,
1477
+ inputPorts,
1478
+ outputPorts,
1479
+ deterministic:
1480
+ child.options?.settings?.["crossingMinimization.strategy"] === "MEDIAN_LAYER_SWEEP",
1481
+ }),
1482
+ alignBoundary: (forward) => {
1483
+ const childLayers = childSession.currentLayers();
1484
+ const layerIndex = forward ? 0 : childLayers.length - 1;
1485
+ const layer = childLayers[layerIndex]!;
1486
+ if (!layer.every((id) => externalPortDummyOf(childNodeById.get(id)!))) return false;
1487
+ // Parent port positions are physical; feedback reversal changes
1488
+ // edge direction without changing the boundary port's identity.
1489
+ const physical = parentScope.session.physicalPortOrder(id);
1490
+ const dummyByParentPort = new Map(
1491
+ childPhase.input.graph.nodes
1492
+ .filter((n) => externalPortDummyOf(n))
1493
+ .map((n) => [externalPortDummyOf(n)!.parentPortId ?? `${n.id}:parent`, n.id]),
1494
+ );
1495
+ const ordered = physical.flatMap((portId) => {
1496
+ const [nodeId, portName] = JSON.parse(portId) as [string, string];
1497
+ const dummy = nodeId === id ? dummyByParentPort.get(portName) : undefined;
1498
+ return dummy && layer.includes(dummy) ? [dummy] : [];
1499
+ });
1500
+ if (forward) ordered.reverse();
1501
+ const unique = [...new Set(ordered)];
1502
+ if (unique.length !== layer.length)
1503
+ throw new Error(`Incomplete hierarchical port order for ${id}`);
1504
+ childSession.replaceLayer(layerIndex, unique);
1505
+ return true;
1506
+ },
1507
+ publishBoundary: (forward) => {
1508
+ const childLayers = childSession.currentLayers();
1509
+ const layer = childLayers[forward ? childLayers.length - 1 : 0]!;
1510
+ if (!layer.every((id) => externalPortDummyOf(childNodeById.get(id)!))) return;
1511
+ const rank = new Map(
1512
+ layer.map((dummy, index) => [
1513
+ externalPortDummyOf(childNodeById.get(dummy)!)!.parentPortId ?? `${dummy}:parent`,
1514
+ index,
1515
+ ]),
1516
+ );
1517
+ const physical = parentScope.session.physicalPortOrder(id);
1518
+ const boundarySlots = physical.flatMap((portId, index) => {
1519
+ const [nodeId, portName] = JSON.parse(portId) as [string, string];
1520
+ return nodeId === id && rank.has(portName) ? [index] : [];
1521
+ });
1522
+ const boundaryPorts = boundarySlots
1523
+ .map((index) => physical[index]!)
1524
+ .sort((a, b) => {
1525
+ const portA = (JSON.parse(a) as [string, string])[1];
1526
+ const portB = (JSON.parse(b) as [string, string])[1];
1527
+ const result = rank.get(portA)! - rank.get(portB)!;
1528
+ return forward ? result : -result;
1529
+ });
1530
+ const updatedPhysical = [...physical];
1531
+ boundarySlots.forEach((slot, index) => {
1532
+ updatedPhysical[slot] = boundaryPorts[index]!;
1533
+ });
1534
+ const edgePortRank = (edgeId: string): number => {
1535
+ const edge = parentEdgeById.get(edgeId)!;
1536
+ const portName = edge.sourceId === id ? edge.sourcePort : edge.targetPort;
1537
+ return updatedPhysical.indexOf(JSON.stringify([id, portName]));
1538
+ };
1539
+ const { incoming, outgoing } = parentScope.session.edgeOrders(id);
1540
+ const byPort = (current: readonly string[]) =>
1541
+ [...current].sort((a, b) => edgePortRank(a) - edgePortRank(b));
1542
+ parentScope.session.reorderNodePorts(
1543
+ id,
1544
+ updatedPhysical,
1545
+ byPort(incoming),
1546
+ byPort(outgoing),
1547
+ );
1548
+ },
843
1549
  };
1550
+ childScope.publishBottomUp = () => {
1551
+ childScope.publishBoundary!(true);
1552
+ childScope.publishBoundary!(false);
1553
+ parentScope.session.lockPortOrder(id);
1554
+ };
1555
+ scopes.set(child, childScope);
1556
+ (parentScope.childrenByNodeId as Map<string, HierarchyCrossingScope>).set(id, childScope);
844
1557
  }
845
- return graph as LaidOutElkNode<T>;
846
1558
  }
1559
+ const orders = minimizeHierarchyCrossings(scopes.get(root)!);
1560
+
1561
+ return new Map(prepared.map((scope) => [scope, orders.get(scopes.get(scope)!)!]));
847
1562
  }
848
1563
 
849
1564
  function parsePadding(value: unknown, fallback = 0) {
@@ -1083,16 +1798,56 @@ function parseMargin(value: unknown): unknown {
1083
1798
  return parsePadding(value, 0);
1084
1799
  }
1085
1800
 
1801
+ // Every accepted spelling of a layered option, with its precedence (lower wins).
1802
+ const layeredOptionAliases = new Map<
1803
+ string,
1804
+ { definition: (typeof elkLayeredOptionDefinitions)[number]; rank: number }
1805
+ >();
1806
+ for (const definition of elkLayeredOptionDefinitions) {
1807
+ const suffix = definition.elkId.replace(/^org\.eclipse\.elk\./, "");
1808
+ [definition.name, suffix, `elk.${suffix}`, definition.elkId].forEach((key, rank) => {
1809
+ const current = layeredOptionAliases.get(key);
1810
+ if (!current || rank < current.rank) layeredOptionAliases.set(key, { definition, rank });
1811
+ });
1812
+ }
1813
+
1814
+ const indexByIdCache = new WeakMap<
1815
+ readonly { id?: ElkId }[],
1816
+ { length: number; index: Map<string, number> }
1817
+ >();
1818
+
1819
+ /** First element whose stringified id matches, like `find`, via a per-array index. */
1820
+ function findById<T extends { id?: ElkId }>(items: readonly T[] | undefined, id: string) {
1821
+ if (!items) return undefined;
1822
+ let cached = indexByIdCache.get(items);
1823
+ if (!cached || cached.length !== items.length) {
1824
+ const index = new Map<string, number>();
1825
+ items.forEach((item, position) => {
1826
+ const key = String(item.id);
1827
+ if (!index.has(key)) index.set(key, position);
1828
+ });
1829
+ indexByIdCache.set(items, (cached = { length: items.length, index }));
1830
+ }
1831
+ const position = cached.index.get(id);
1832
+ return position === undefined ? undefined : { item: items[position]!, position };
1833
+ }
1834
+
1086
1835
  function getElementLayeredSettings(
1087
1836
  options: Readonly<Record<string, unknown>>,
1088
1837
  ): ElkLayeredOptionValueByName {
1089
1838
  const settings: ElkLayeredOptionValueByName = {};
1090
- for (const definition of elkLayeredOptionDefinitions) {
1091
- const suffix = definition.elkId.replace(/^org\.eclipse\.elk\./, "");
1092
- const value = [definition.name, suffix, `elk.${suffix}`, definition.elkId]
1093
- .map((key) => options[key])
1094
- .find((candidate) => candidate !== undefined);
1095
- if (value === undefined) continue;
1839
+ const chosen = new Map<
1840
+ (typeof elkLayeredOptionDefinitions)[number],
1841
+ { rank: number; value: unknown }
1842
+ >();
1843
+ for (const [key, value] of Object.entries(options)) {
1844
+ const alias = layeredOptionAliases.get(key);
1845
+ if (!alias || value === undefined) continue;
1846
+ const current = chosen.get(alias.definition);
1847
+ if (!current || alias.rank < current.rank)
1848
+ chosen.set(alias.definition, { rank: alias.rank, value });
1849
+ }
1850
+ for (const [definition, { value }] of chosen) {
1096
1851
  const vectorMatch =
1097
1852
  definition.name === "port.anchor" && typeof value === "string"
1098
1853
  ? value.match(/^\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*$/)
@@ -1260,7 +2015,16 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1260
2015
  if (String(getOption(child.layoutOptions ?? {}, "portConstraints")) !== "FIXED_SIDE") {
1261
2016
  return ports;
1262
2017
  }
1263
- const side = (port: ElkPort) => String(getOption(port.layoutOptions ?? {}, "port.side"));
2018
+ const canonicalSides: Record<string, string> =
2019
+ getDirection(globalOptions) === "left"
2020
+ ? { NORTH: "NORTH", EAST: "WEST", SOUTH: "SOUTH", WEST: "EAST" }
2021
+ : getDirection(globalOptions) === "down"
2022
+ ? { NORTH: "WEST", EAST: "SOUTH", SOUTH: "EAST", WEST: "NORTH" }
2023
+ : getDirection(globalOptions) === "up"
2024
+ ? { NORTH: "EAST", EAST: "SOUTH", SOUTH: "WEST", WEST: "NORTH" }
2025
+ : { NORTH: "NORTH", EAST: "EAST", SOUTH: "SOUTH", WEST: "WEST" };
2026
+ const side = (port: ElkPort) =>
2027
+ canonicalSides[String(getOption(port.layoutOptions ?? {}, "port.side"))] ?? "UNDEFINED";
1264
2028
  const sideOrder = ["NORTH", "EAST", "SOUTH", "WEST"];
1265
2029
  ports.sort((left, right) => {
1266
2030
  const leftSide = side(left);
@@ -1287,10 +2051,12 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1287
2051
  const usePortModelOrder =
1288
2052
  getBooleanOption(globalOptions, "layered.considerModelOrder.portModelOrder") === true;
1289
2053
  if (modelOrderStrategy !== "NONE" && !usePortModelOrder) {
1290
- const edgeOrderDifference =
1291
- (edgeModelOrderByPortId.get(String(left.id)) ?? Infinity) -
1292
- (edgeModelOrderByPortId.get(String(right.id)) ?? Infinity);
1293
- if (edgeOrderDifference !== 0) return edgeOrderDifference;
2054
+ const leftOrder = edgeModelOrderByPortId.get(String(left.id));
2055
+ const rightOrder = edgeModelOrderByPortId.get(String(right.id));
2056
+ // Ports without edges order last and keep their order among themselves,
2057
+ // unreversed even on the WEST side, as ELK does.
2058
+ if (leftOrder === undefined && rightOrder === undefined) return 0;
2059
+ if (leftOrder !== rightOrder) return (leftOrder ?? Infinity) - (rightOrder ?? Infinity);
1294
2060
  }
1295
2061
  const direction = leftSide === "WEST" ? -1 : 1;
1296
2062
  return direction * ((child.ports?.indexOf(left) ?? 0) - (child.ports?.indexOf(right) ?? 0));
@@ -1298,7 +2064,7 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1298
2064
  return ports;
1299
2065
  };
1300
2066
 
1301
- return createGraph({
2067
+ const native = createGraph({
1302
2068
  id: String(root.id),
1303
2069
  nodes: children.map((child) => ({
1304
2070
  id: String(child.id),
@@ -1353,6 +2119,12 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1353
2119
  : [];
1354
2120
  }),
1355
2121
  });
2122
+ for (const node of native.nodes) {
2123
+ const child = children.find((candidate) => String(candidate.id) === node.id);
2124
+ const origin = child && externalPortDummyOf(child);
2125
+ if (origin) attachExternalPortDummy(node, origin);
2126
+ }
2127
+ return native;
1356
2128
  }
1357
2129
 
1358
2130
  function parsePoints(value: unknown): ElkPoint[] | undefined {
@@ -1431,11 +2203,21 @@ function applyLayout(
1431
2203
  continue;
1432
2204
  }
1433
2205
  const section = getParentEdgeSection(root, edge);
1434
- if (section) edge.sections = [section];
2206
+ if (section) {
2207
+ edge.sections = [section];
2208
+ edge.container = root.id;
2209
+ }
1435
2210
  continue;
1436
2211
  }
2212
+ const nativeJunctions = resultPolicy?.junctionPointsByEdgeId?.get(String(edge.id));
2213
+ if (nativeJunctions) {
2214
+ if (nativeJunctions.length)
2215
+ edge.junctionPoints = nativeJunctions.map((point) => ({ ...point }));
2216
+ else delete edge.junctionPoints;
2217
+ }
1437
2218
  const section = toSection(edge, laidOutEdge.points ?? []);
1438
2219
  edge.sections = section ? [section] : [];
2220
+ if (section) edge.container = root.id;
1439
2221
  const target = root.children?.find(
1440
2222
  (child) => String(child.id) === String(edge.targets?.[0] ?? edge.target),
1441
2223
  );
@@ -1452,6 +2234,8 @@ function applyLayout(
1452
2234
  const midpointX = (laidOutEdge.x ?? 0) + (laidOutEdge.width ?? 0) / 2;
1453
2235
  const edgeLabelSpacing = getNumberOption(layoutOptions, "spacing.edgeLabel") ?? 2;
1454
2236
  const labelLabelSpacing = getNumberOption(layoutOptions, "spacing.labelLabel") ?? 0;
2237
+ // A lone label keeps the native position, which already avoids collisions.
2238
+ const loneLabel = (edge.labels ?? []).filter(isLayoutEdgeLabel).length === 1;
1455
2239
  for (const label of edge.labels ?? []) {
1456
2240
  if (getBooleanOption(label.layoutOptions ?? {}, "noLayout") === true) {
1457
2241
  label.x ??= 0;
@@ -1468,17 +2252,51 @@ function applyLayout(
1468
2252
  );
1469
2253
  const width = label.width ?? 0;
1470
2254
  label.x =
1471
- placement === "TAIL"
1472
- ? firstPoint.x + edgeLabelSpacing
1473
- : placement === "HEAD"
1474
- ? lastPoint.x - width - edgeLabelSpacing
1475
- : midpointX - width / 2;
2255
+ loneLabel && placement !== "CENTER" && laidOutEdge.x !== undefined
2256
+ ? laidOutEdge.x
2257
+ : placement === "TAIL"
2258
+ ? firstPoint.x + edgeLabelSpacing
2259
+ : placement === "HEAD"
2260
+ ? lastPoint.x - width - edgeLabelSpacing
2261
+ : midpointX - width / 2;
1476
2262
  label.y = labelY;
1477
2263
  labelY += (label.height ?? 0) + labelLabelSpacing;
1478
2264
  }
1479
2265
  }
1480
2266
  if (!resultPolicy?.skipBoundsNormalization) {
1481
- normalizeElkGraphBounds(root, padding, layoutOptions);
2267
+ normalizeElkGraphBounds(
2268
+ root,
2269
+ padding,
2270
+ layoutOptions,
2271
+ resultPolicy?.normalizationBounds,
2272
+ resultPolicy?.placementCrossBounds,
2273
+ );
2274
+ }
2275
+ if (
2276
+ !resultPolicy?.preserveEdgeSections &&
2277
+ String(getOption(layoutOptions, "edgeRouting") ?? "ORTHOGONAL") === "ORTHOGONAL"
2278
+ ) {
2279
+ const direction = getDirection(layoutOptions);
2280
+ applyOrthogonalJunctions(
2281
+ root,
2282
+ direction === "right" || direction === "left",
2283
+ direction === "right" || direction === "down",
2284
+ getBooleanOption(layoutOptions, "layered.mergeEdges") === true,
2285
+ new Set(
2286
+ (root.children ?? [])
2287
+ .filter((n) => getBooleanOption(n.layoutOptions ?? {}, "hypernode") === true)
2288
+ .map((n) => String(n.id)),
2289
+ ),
2290
+ new Set(
2291
+ (root.edges ?? [])
2292
+ .filter(
2293
+ (e) =>
2294
+ getBooleanOption(e.layoutOptions ?? {}, "noLayout") === true ||
2295
+ resultPolicy?.junctionPointsByEdgeId?.has(String(e.id)) === true,
2296
+ )
2297
+ .map((e) => String(e.id)),
2298
+ ),
2299
+ );
1482
2300
  }
1483
2301
  }
1484
2302
 
@@ -1486,15 +2304,21 @@ function normalizeElkGraphBounds(
1486
2304
  root: ElkNode,
1487
2305
  padding: { top: number; right: number; bottom: number; left: number },
1488
2306
  layoutOptions: Readonly<Record<string, unknown>> = {},
2307
+ compactionBounds?: Elkjs0111ResultPolicy["normalizationBounds"],
2308
+ placementCrossBounds?: Elkjs0111ResultPolicy["placementCrossBounds"],
1489
2309
  ): void {
2310
+ // Edge coordinates belong to their layout container, not their JSON storage owner.
2311
+ const layoutEdges = (root.edges ?? []).filter(
2312
+ (edge) => edge.container == null || String(edge.container) === String(root.id),
2313
+ );
1490
2314
  const authoredWidth = root.width;
1491
2315
  const authoredHeight = root.height;
1492
2316
  const fixedGraphSize = getBooleanOption(layoutOptions, "nodeSize.fixedGraphSize") === true;
1493
2317
  const layoutChildren = (root.children ?? []).filter(
1494
2318
  (node) => getBooleanOption(node.layoutOptions ?? {}, "noLayout") !== true,
1495
2319
  );
1496
- let minimumX = Number.POSITIVE_INFINITY;
1497
- let minimumY = Number.POSITIVE_INFINITY;
2320
+ let minimumX = compactionBounds?.left ?? Number.POSITIVE_INFINITY;
2321
+ let minimumY = compactionBounds?.top ?? Number.POSITIVE_INFINITY;
1498
2322
  for (const node of layoutChildren) {
1499
2323
  minimumX = Math.min(
1500
2324
  minimumX,
@@ -1515,7 +2339,7 @@ function normalizeElkGraphBounds(
1515
2339
  ),
1516
2340
  );
1517
2341
  }
1518
- for (const edge of root.edges ?? []) {
2342
+ for (const edge of layoutEdges) {
1519
2343
  const laidOutLabels = (edge.labels ?? []).filter(isLayoutEdgeLabel);
1520
2344
  minimumX = Math.min(minimumX, ...laidOutLabels.map((label) => label.x ?? 0));
1521
2345
  minimumY = Math.min(minimumY, ...laidOutLabels.map((label) => label.y ?? 0));
@@ -1536,13 +2360,17 @@ function normalizeElkGraphBounds(
1536
2360
  node.x = (node.x ?? 0) + shiftX;
1537
2361
  node.y = (node.y ?? 0) + shiftY;
1538
2362
  }
1539
- for (const edge of root.edges ?? []) {
2363
+ for (const edge of layoutEdges) {
1540
2364
  for (const section of edge.sections ?? []) {
1541
2365
  for (const point of [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]) {
1542
2366
  point.x += shiftX;
1543
2367
  point.y += shiftY;
1544
2368
  }
1545
2369
  }
2370
+ for (const point of edge.junctionPoints ?? []) {
2371
+ point.x += shiftX;
2372
+ point.y += shiftY;
2373
+ }
1546
2374
  for (const label of (edge.labels ?? []).filter(isLayoutEdgeLabel)) {
1547
2375
  label.x = (label.x ?? 0) + shiftX;
1548
2376
  label.y = (label.y ?? 0) + shiftY;
@@ -1557,7 +2385,7 @@ function normalizeElkGraphBounds(
1557
2385
  0,
1558
2386
  ...layoutChildren.map((node) => (node.y ?? 0) + (node.height ?? 0)),
1559
2387
  );
1560
- const layoutEdgePoints = (root.edges ?? []).flatMap((edge) =>
2388
+ const layoutEdgePoints = layoutEdges.flatMap((edge) =>
1561
2389
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1562
2390
  ? []
1563
2391
  : (edge.sections ?? []).flatMap((section) => [
@@ -1573,7 +2401,7 @@ function normalizeElkGraphBounds(
1573
2401
  for (const child of root.children ?? []) {
1574
2402
  for (const port of child.ports ?? []) childByEndpointId.set(String(port.id), child);
1575
2403
  }
1576
- const wrappedEdgeCount = (root.edges ?? []).filter((edge) => {
2404
+ const wrappedEdgeCount = layoutEdges.filter((edge) => {
1577
2405
  const source = childByEndpointId.get(String(edge.sources?.[0] ?? edge.source));
1578
2406
  const target = childByEndpointId.get(String(edge.targets?.[0] ?? edge.target));
1579
2407
  if (!source || !target) return false;
@@ -1586,24 +2414,44 @@ function normalizeElkGraphBounds(
1586
2414
  : (source.y ?? 0) < (target.y ?? 0);
1587
2415
  }).length;
1588
2416
  const hasWrappedEdge = wrappedEdgeCount > 0;
1589
- const addBoundaryPixel =
2417
+ const retainPlacementBounds =
1590
2418
  wrappingStrategy !== "MULTI_EDGE" &&
1591
2419
  !(wrappingStrategy === "SINGLE_EDGE" && hasWrappedEdge) &&
1592
2420
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") ===
1593
2421
  "NONE" &&
1594
2422
  getBooleanOption(layoutOptions, "layered.feedbackEdges") !== true &&
1595
2423
  String(getOption(layoutOptions, "layered.layering.nodePromotion.strategy") ?? "NONE") !==
1596
- "MODEL_ORDER_LEFT_TO_RIGHT" &&
1597
- !(root.edges ?? []).some((edge) => edge.sources?.[0] === edge.targets?.[0]) &&
2424
+ "MODEL_ORDER_LEFT_TO_RIGHT";
2425
+ // Actual helper extents survive nested scopes. The legacy route-pixel
2426
+ // allowance still excludes compound scopes.
2427
+ const addBoundaryPixel =
2428
+ retainPlacementBounds &&
1598
2429
  !(root.children ?? []).some((child) => (child.children?.length ?? 0) > 0);
2430
+ // Prefer the retained placement extent on the cross axis. Other placers
2431
+ // retain the legacy route-bound allowance until they expose phase bounds.
2432
+ // Loop envelopes own their bounds. Ordinary long-edge dummies still have
2433
+ // a one-pixel cross-axis extent when another edge in the graph is a loop.
2434
+ // Apply that allowance to route points, before unioning labels and margins.
2435
+ const isSelfLoop = (edge: ElkEdge) => {
2436
+ const source = childByEndpointId.get(String(edge.sources?.[0] ?? edge.source));
2437
+ return (
2438
+ source !== undefined &&
2439
+ source === childByEndpointId.get(String(edge.targets?.[0] ?? edge.target))
2440
+ );
2441
+ };
2442
+ const hasSelfLoops = layoutEdges.some(isSelfLoop);
1599
2443
  const edgeBoundsExtraX =
1600
2444
  addBoundaryPixel &&
2445
+ placementCrossBounds?.axis !== "x" &&
2446
+ !(hasSelfLoops && (direction === "right" || direction === "left")) &&
1601
2447
  layoutEdgePoints.length > 0 &&
1602
2448
  Math.max(...layoutEdgePoints.map((point) => point.x)) >= maximumNodeX - 1e-9
1603
2449
  ? 1
1604
2450
  : 0;
1605
2451
  const edgeBoundsExtraY =
1606
2452
  addBoundaryPixel &&
2453
+ placementCrossBounds?.axis !== "y" &&
2454
+ !(hasSelfLoops && (direction === "down" || direction === "up")) &&
1607
2455
  layoutEdgePoints.length > 0 &&
1608
2456
  Math.max(...layoutEdgePoints.map((point) => point.y)) >= maximumNodeY - 1e-9
1609
2457
  ? 1
@@ -1616,6 +2464,7 @@ function normalizeElkGraphBounds(
1616
2464
  ? 1
1617
2465
  : 0;
1618
2466
  const postCompactionBoundsExtraX =
2467
+ !compactionBounds &&
1619
2468
  !addBoundaryPixel &&
1620
2469
  !hasWrappedEdge &&
1621
2470
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") !==
@@ -1625,6 +2474,7 @@ function normalizeElkGraphBounds(
1625
2474
  ? 0.04
1626
2475
  : 0;
1627
2476
  const postCompactionBoundsExtraY =
2477
+ !compactionBounds &&
1628
2478
  !addBoundaryPixel &&
1629
2479
  !hasWrappedEdge &&
1630
2480
  String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") !==
@@ -1635,7 +2485,10 @@ function normalizeElkGraphBounds(
1635
2485
  : 0;
1636
2486
  const calculatedWidth =
1637
2487
  Math.max(
1638
- 0,
2488
+ compactionBounds ? compactionBounds.right + shiftX : 0,
2489
+ retainPlacementBounds && placementCrossBounds?.axis === "x"
2490
+ ? placementCrossBounds.maximum + shiftX
2491
+ : 0,
1639
2492
  ...layoutChildren.flatMap((node) => [
1640
2493
  (node.x ?? 0) + (node.width ?? 0),
1641
2494
  ...(node.labels ?? []).map((label) => (node.x ?? 0) + (label.x ?? 0) + (label.width ?? 0)),
@@ -1646,23 +2499,24 @@ function normalizeElkGraphBounds(
1646
2499
  ),
1647
2500
  ),
1648
2501
  ]),
1649
- ...(root.edges ?? []).flatMap((edge) =>
2502
+ ...layoutEdges.flatMap((edge) =>
1650
2503
  (edge.labels ?? [])
1651
2504
  .filter(isLayoutEdgeLabel)
1652
2505
  .map((label) => (label.x ?? 0) + (label.width ?? 0)),
1653
2506
  ),
1654
- ...(root.edges ?? []).flatMap((edge) =>
2507
+ ...layoutEdges.flatMap((edge) =>
1655
2508
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1656
2509
  ? []
1657
2510
  : (edge.sections ?? []).flatMap((section) => [
1658
- section.startPoint.x,
1659
- ...(section.bendPoints ?? []).map((point) => point.x),
1660
- section.endPoint.x,
2511
+ section.startPoint.x + (isSelfLoop(edge) ? 0 : edgeBoundsExtraX),
2512
+ ...(section.bendPoints ?? []).map(
2513
+ (point) => point.x + (isSelfLoop(edge) ? 0 : edgeBoundsExtraX),
2514
+ ),
2515
+ section.endPoint.x + (isSelfLoop(edge) ? 0 : edgeBoundsExtraX),
1661
2516
  ]),
1662
2517
  ),
1663
2518
  ) +
1664
2519
  padding.right +
1665
- edgeBoundsExtraX +
1666
2520
  postCompactionBoundsExtraX +
1667
2521
  (getBooleanOption(layoutOptions, "layered.feedbackEdges") === true &&
1668
2522
  (getDirection(layoutOptions) === "down" || getDirection(layoutOptions) === "up")
@@ -1670,7 +2524,10 @@ function normalizeElkGraphBounds(
1670
2524
  : 0);
1671
2525
  const calculatedHeight =
1672
2526
  Math.max(
1673
- 0,
2527
+ compactionBounds ? compactionBounds.bottom + shiftY : 0,
2528
+ retainPlacementBounds && placementCrossBounds?.axis === "y"
2529
+ ? placementCrossBounds.maximum + shiftY
2530
+ : 0,
1674
2531
  ...layoutChildren.flatMap((node) => [
1675
2532
  (node.y ?? 0) + (node.height ?? 0),
1676
2533
  ...(node.labels ?? []).map((label) => (node.y ?? 0) + (label.y ?? 0) + (label.height ?? 0)),
@@ -1681,7 +2538,7 @@ function normalizeElkGraphBounds(
1681
2538
  ),
1682
2539
  ),
1683
2540
  ]),
1684
- ...(root.edges ?? []).flatMap((edge) =>
2541
+ ...layoutEdges.flatMap((edge) =>
1685
2542
  (edge.labels ?? [])
1686
2543
  .filter(isLayoutEdgeLabel)
1687
2544
  .map(
@@ -1689,23 +2546,26 @@ function normalizeElkGraphBounds(
1689
2546
  (label.y ?? 0) +
1690
2547
  (label.height ?? 0) +
1691
2548
  (String(getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER") ===
1692
- "CENTER"
2549
+ "CENTER" &&
2550
+ childByEndpointId.get(String(edge.sources?.[0] ?? edge.source)) !==
2551
+ childByEndpointId.get(String(edge.targets?.[0] ?? edge.target))
1693
2552
  ? 1
1694
2553
  : 0),
1695
2554
  ),
1696
2555
  ),
1697
- ...(root.edges ?? []).flatMap((edge) =>
2556
+ ...layoutEdges.flatMap((edge) =>
1698
2557
  getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1699
2558
  ? []
1700
2559
  : (edge.sections ?? []).flatMap((section) => [
1701
- section.startPoint.y,
1702
- ...(section.bendPoints ?? []).map((point) => point.y),
1703
- section.endPoint.y,
2560
+ section.startPoint.y + (isSelfLoop(edge) ? 0 : edgeBoundsExtraY),
2561
+ ...(section.bendPoints ?? []).map(
2562
+ (point) => point.y + (isSelfLoop(edge) ? 0 : edgeBoundsExtraY),
2563
+ ),
2564
+ section.endPoint.y + (isSelfLoop(edge) ? 0 : edgeBoundsExtraY),
1704
2565
  ]),
1705
2566
  ),
1706
2567
  ) +
1707
2568
  padding.bottom +
1708
- edgeBoundsExtraY +
1709
2569
  singleMultiEdgeCutBoundsExtraY +
1710
2570
  postCompactionBoundsExtraY +
1711
2571
  (getBooleanOption(layoutOptions, "layered.feedbackEdges") === true &&
@@ -1903,3 +2763,170 @@ export type {
1903
2763
 
1904
2764
  export { getElkRoutes } from "./routes";
1905
2765
  export type { ElkRouteOptions } from "./routes";
2766
+
2767
+ /** Edges crossing `group`, in the order ELK's CompoundGraphPreprocessor creates its boundary ports. */
2768
+ function elkBoundaryEdgeOrder(
2769
+ group: ElkNode,
2770
+ scope: ElkNode,
2771
+ originals: ReadonlyMap<ElkEdge, { sourceId: string; targetId: string }>,
2772
+ ): ElkEdge[] {
2773
+ const edges: ElkEdge[] = [];
2774
+ const collect = (node: ElkNode) => {
2775
+ edges.push(...(node.edges ?? []));
2776
+ for (const child of node.children ?? []) collect(child);
2777
+ };
2778
+ collect(scope);
2779
+ const endpoints = (edge: ElkEdge) =>
2780
+ originals.get(edge) ?? {
2781
+ sourceId: String(edge.sources?.[0] ?? edge.source),
2782
+ targetId: String(edge.targets?.[0] ?? edge.target),
2783
+ };
2784
+ const order = (node: ElkNode): ElkEdge[] => {
2785
+ const inside = new Set<string>();
2786
+ const mark = (parent: ElkNode) => {
2787
+ for (const child of parent.children ?? []) {
2788
+ inside.add(String(child.id));
2789
+ for (const port of child.ports ?? []) inside.add(String(port.id));
2790
+ mark(child);
2791
+ }
2792
+ };
2793
+ mark(node);
2794
+ const leaves = (edge: ElkEdge) =>
2795
+ inside.has(endpoints(edge).sourceId) !== inside.has(endpoints(edge).targetId);
2796
+ const result = new Set<ElkEdge>();
2797
+ for (const child of node.children ?? [])
2798
+ if (child.children?.length)
2799
+ for (const edge of order(child)) if (leaves(edge)) result.add(edge);
2800
+ for (const child of node.children ?? []) {
2801
+ for (const port of (child.ports ?? []).map((p) => String(p.id))) {
2802
+ for (const edge of edges)
2803
+ if (endpoints(edge).sourceId === port && leaves(edge)) result.add(edge);
2804
+ for (const edge of edges)
2805
+ if (endpoints(edge).targetId === port && leaves(edge)) result.add(edge);
2806
+ }
2807
+ // Each edge on the node itself gets its own implicit port, in import order.
2808
+ const id = String(child.id);
2809
+ for (const edge of edges) {
2810
+ const { sourceId, targetId } = endpoints(edge);
2811
+ if ((sourceId === id || targetId === id) && leaves(edge)) result.add(edge);
2812
+ }
2813
+ }
2814
+ return [...result];
2815
+ };
2816
+ return order(group);
2817
+ }
2818
+
2819
+ /** Creation order of hierarchy segments in ELK's CompoundGraphPreprocessor. */
2820
+ const hierarchySegmentRank = new WeakMap<ElkEdge, number>();
2821
+ const segmentOrderByScope = new WeakMap<ElkNode, ReadonlyMap<string, number>>();
2822
+ const originalEdgeId = new WeakMap<ElkEdge, string>();
2823
+
2824
+ /**
2825
+ * Segments inside `group`, in creation order: the inner pass connects child
2826
+ * groups' exported ports first, then the outer pass leaves `group` itself.
2827
+ */
2828
+ function elkSegmentOrder(
2829
+ group: ElkNode,
2830
+ scope: ElkNode,
2831
+ originals: ReadonlyMap<ElkEdge, { sourceId: string; targetId: string }>,
2832
+ ownBoundary: boolean,
2833
+ ): Map<string, number> {
2834
+ const order = new Map<string, number>();
2835
+ const add = (edge: ElkEdge) => {
2836
+ const id = originalEdgeId.get(edge) ?? String(edge.id);
2837
+ if (!order.has(id)) order.set(id, order.size);
2838
+ };
2839
+ for (const child of group.children ?? [])
2840
+ if (child.children?.length)
2841
+ for (const edge of elkBoundaryEdgeOrder(child, scope, originals)) add(edge);
2842
+ if (ownBoundary) for (const edge of elkBoundaryEdgeOrder(group, scope, originals)) add(edge);
2843
+ return order;
2844
+ }
2845
+
2846
+ const isPostCompactionKey = (key: string) =>
2847
+ /(^|\.)compaction\.postCompaction\.strategy$/.test(key);
2848
+ function usesPostCompaction(options: Readonly<Record<string, unknown>>, node: ElkNode): boolean {
2849
+ const active = (values: Readonly<Record<string, unknown>> | undefined) =>
2850
+ Object.entries(values ?? {}).some(
2851
+ ([key, value]) => isPostCompactionKey(key) && String(value) !== "NONE",
2852
+ );
2853
+ const visit = (current: ElkNode): boolean =>
2854
+ active(current.layoutOptions) ||
2855
+ active(current.properties as Record<string, unknown> | undefined) ||
2856
+ (current.children ?? []).some(visit);
2857
+ return active(options) || visit(node);
2858
+ }
2859
+
2860
+ function disablePostCompaction<T extends ElkNode>(node: T): T {
2861
+ for (const values of [node.layoutOptions, node.properties as Record<string, unknown> | undefined])
2862
+ for (const key of Object.keys(values ?? {}))
2863
+ if (isPostCompactionKey(key)) (values as Record<string, unknown>)[key] = "NONE";
2864
+ for (const child of node.children ?? []) disablePostCompaction(child);
2865
+ return node;
2866
+ }
2867
+
2868
+ /**
2869
+ * Alternative random seeds worth trying for a compound layout, unless the
2870
+ * graph chooses its hierarchical greedy switch explicitly.
2871
+ */
2872
+ function crossingVariants(
2873
+ options: Readonly<Record<string, unknown>>,
2874
+ graph: ElkNode,
2875
+ ): Array<Record<string, string>> {
2876
+ const own = { ...options, ...graph.properties, ...graph.layoutOptions };
2877
+ const compound = (graph.children ?? []).some((child) => child.children?.length);
2878
+ if (
2879
+ !compound ||
2880
+ getOption(own, "hierarchyHandling") !== "INCLUDE_CHILDREN" ||
2881
+ getOption(own, "layered.crossingMinimization.greedySwitchHierarchical.type") !== undefined
2882
+ )
2883
+ return [];
2884
+ return [{ "elk.randomSeed": "2" }, { "elk.randomSeed": "3" }];
2885
+ }
2886
+
2887
+ /**
2888
+ * Whether two edges between different ends can share a port: an explicit port
2889
+ * carrying two or more of them, or implicit ports that merge because some
2890
+ * scope merges edges or a node is a hypernode.
2891
+ */
2892
+ function mayShareTracks(graph: ElkNode, options: Readonly<Record<string, unknown>>): boolean {
2893
+ const merging = (node: ElkNode, inherited: Readonly<Record<string, unknown>> = {}) => {
2894
+ const settings = getElementLayeredSettings({
2895
+ ...inherited,
2896
+ ...node.properties,
2897
+ ...node.layoutOptions,
2898
+ });
2899
+ return settings.mergeEdges === true || settings.hypernode === true;
2900
+ };
2901
+ const anyMerging = (node: ElkNode): boolean =>
2902
+ merging(node) || (node.children ?? []).some(anyMerging);
2903
+ if (merging(graph, options) || (graph.children ?? []).some(anyMerging)) return true;
2904
+ const ports = new Set<string>();
2905
+ const collectPorts = (node: ElkNode) => {
2906
+ for (const port of node.ports ?? []) ports.add(String(port.id));
2907
+ for (const child of node.children ?? []) collectPorts(child);
2908
+ };
2909
+ collectPorts(graph);
2910
+ if (!ports.size) return false;
2911
+ const seen = new Set<string>();
2912
+ const visit = (node: ElkNode): boolean => {
2913
+ for (const edge of node.edges ?? []) {
2914
+ const source = String(edge.sources?.[0] ?? edge.source),
2915
+ target = String(edge.targets?.[0] ?? edge.target);
2916
+ if (source === target) continue;
2917
+ for (const end of [source, target]) {
2918
+ if (!ports.has(end)) continue;
2919
+ if (seen.has(end)) return true;
2920
+ seen.add(end);
2921
+ }
2922
+ }
2923
+ return (node.children ?? []).some(visit);
2924
+ };
2925
+ return visit(graph);
2926
+ }
2927
+
2928
+ /** Whether the graph asks for more than ELK's default layered thoroughness (7). */
2929
+ function thorough(options: Readonly<Record<string, unknown>>, graph: ElkNode): boolean {
2930
+ const own = { ...options, ...graph.properties, ...graph.layoutOptions };
2931
+ return Number(getOption(own, "layered.thoroughness") ?? 7) > 7;
2932
+ }