@statelyai/layout 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1 -3
  2. package/dist/elkjs/index.mjs +15 -11
  3. package/dist/{index-D2RodsZY.d.mts → index-8xYkohbz.d.mts} +2 -0
  4. package/dist/index.d.mts +1 -1
  5. package/dist/index.mjs +2 -2
  6. package/dist/layered/index.d.mts +1 -1
  7. package/dist/layered/index.mjs +1 -1
  8. package/dist/{layered-Dd868WZY.mjs → layered-f_3mmGHr.mjs} +717 -62
  9. package/dist/{spore-fTgSoRLP.mjs → spore-BfTduMDc.mjs} +1 -1
  10. package/package.json +20 -6
  11. package/src/box.ts +135 -0
  12. package/src/elkjs/index.ts +1832 -0
  13. package/src/elkjs/types.ts +103 -0
  14. package/src/errors.ts +16 -0
  15. package/src/fixed.ts +80 -0
  16. package/src/index.ts +92 -0
  17. package/src/java-random.ts +46 -0
  18. package/src/layered/bk-node-placement.ts +715 -0
  19. package/src/layered/elk-enum-values.ts +171 -0
  20. package/src/layered/elk-options.generated.ts +315 -0
  21. package/src/layered/elk-options.ts +98 -0
  22. package/src/layered/flexible-ports.ts +11 -0
  23. package/src/layered/high-degree.ts +127 -0
  24. package/src/layered/index.ts +2555 -0
  25. package/src/layered/layer-unzipping.ts +217 -0
  26. package/src/layered/linear-segments-node-placement.ts +447 -0
  27. package/src/layered/long-edges.ts +406 -0
  28. package/src/layered/min-width.ts +159 -0
  29. package/src/layered/multi-edge-wrapping.ts +460 -0
  30. package/src/layered/network-simplex-node-placement.ts +500 -0
  31. package/src/layered/network-simplex.ts +346 -0
  32. package/src/layered/node-promotion.ts +197 -0
  33. package/src/layered/spacing.ts +37 -0
  34. package/src/layered/spline-bezier.ts +102 -0
  35. package/src/layered/strategies.ts +5403 -0
  36. package/src/layered/stretch-width.ts +136 -0
  37. package/src/layered/types.ts +111 -0
  38. package/src/layout.ts +202 -0
  39. package/src/packing.ts +74 -0
  40. package/src/random.ts +142 -0
  41. package/src/spore.ts +103 -0
  42. package/src/types.ts +84 -0
@@ -0,0 +1,1832 @@
1
+ import { createGraph, type Graph } from "@statelyai/graph";
2
+ import { getBoxLayout } from "../box";
3
+ import { getFixedLayout } from "../fixed";
4
+ import { getLayeredLayout } from "../layered";
5
+ import {
6
+ elkLayeredOptionDefinitions,
7
+ type ElkLayeredOptionValueByName,
8
+ type LayeredAdvancedOptions,
9
+ } from "../layered/elk-options";
10
+ import { getRectanglePackingLayout } from "../packing";
11
+ import { getRandomLayout } from "../random";
12
+ import { getSporeCompactionLayout, getSporeOverlapRemovalLayout } from "../spore";
13
+ import type {
14
+ ElkConstructorArguments,
15
+ ElkLayoutAlgorithmDescription,
16
+ ElkEdge,
17
+ ElkLayoutArguments,
18
+ ElkLayoutCategoryDescription,
19
+ ElkLayoutOptionDescription,
20
+ ElkId,
21
+ ElkNode,
22
+ ElkPoint,
23
+ ElkPort,
24
+ ElkShape,
25
+ LaidOutElkNode,
26
+ } from "./types";
27
+
28
+ export type {
29
+ ElkConstructorArguments,
30
+ ElkCommonDescription,
31
+ ElkGraphElement,
32
+ ElkId,
33
+ ElkEdge,
34
+ ElkEdgeSection,
35
+ ElkLabel,
36
+ ElkLayoutArguments,
37
+ ElkLayoutAlgorithmDescription,
38
+ ElkLayoutCategoryDescription,
39
+ ElkLayoutOptionDescription,
40
+ ElkLogging,
41
+ ElkNode,
42
+ ElkPoint,
43
+ ElkPort,
44
+ ElkShape,
45
+ LaidOutElkNode,
46
+ } from "./types";
47
+
48
+ export default class ELK {
49
+ readonly #options: ElkConstructorArguments;
50
+ readonly #algorithmIds: ReadonlySet<string>;
51
+
52
+ constructor(options: ElkConstructorArguments = {}) {
53
+ this.#options = options;
54
+ this.#algorithmIds = new Set([
55
+ "box",
56
+ "fixed",
57
+ "random",
58
+ "rectpacking",
59
+ "sporeCompaction",
60
+ "sporeOverlap",
61
+ ...(options.algorithms ?? ["layered"]).filter((id) => id === "layered"),
62
+ ]);
63
+ }
64
+
65
+ async knownLayoutAlgorithms(): Promise<ElkLayoutAlgorithmDescription[]> {
66
+ return [...this.#algorithmIds].map((id) => ({
67
+ id: id === "layered" ? "org.eclipse.elk.layered" : id,
68
+ name: {
69
+ layered: "Layered",
70
+ box: "Box",
71
+ fixed: "Fixed",
72
+ random: "Random",
73
+ rectpacking: "Rectangle Packing",
74
+ sporeCompaction: "SPOrE Compaction",
75
+ sporeOverlap: "SPOrE Overlap Removal",
76
+ }[id],
77
+ category: id === "layered" ? "layered" : "other",
78
+ knownOptions:
79
+ id === "layered"
80
+ ? elkLayeredOptionDefinitions.map((definition) => definition.elkId)
81
+ : id === "box"
82
+ ? ["padding", "spacing.nodeNode", "aspectRatio", "box.packingMode"]
83
+ : id === "random"
84
+ ? ["padding", "spacing.nodeNode", "aspectRatio", "randomSeed"]
85
+ : ["position", "bendPoints"],
86
+ }));
87
+ }
88
+
89
+ async knownLayoutOptions(): Promise<ElkLayoutOptionDescription[]> {
90
+ return [
91
+ { id: "org.eclipse.elk.algorithm", name: "Layout Algorithm", type: "STRING" },
92
+ ...elkLayeredOptionDefinitions.map((definition) => ({
93
+ id: definition.elkId,
94
+ name: definition.name,
95
+ type: definition.type,
96
+ targets: [...definition.targets],
97
+ })),
98
+ ];
99
+ }
100
+
101
+ async knownLayoutCategories(): Promise<ElkLayoutCategoryDescription[]> {
102
+ return [
103
+ {
104
+ id: "layered",
105
+ name: "Layered",
106
+ knownLayouters: this.#algorithmIds.has("layered") ? ["layered"] : [],
107
+ },
108
+ { id: "other", name: "Other", knownLayouters: ["box", "fixed", "random"] },
109
+ ];
110
+ }
111
+
112
+ terminateWorker(): void {}
113
+
114
+ async layout<T extends ElkNode>(
115
+ graph: T,
116
+ arguments_: ElkLayoutArguments = {},
117
+ ): Promise<LaidOutElkNode<T>> {
118
+ const startedAt = performance.now();
119
+ if (graph === undefined || graph === null) {
120
+ throw new TypeError("Missing mandatory parameter: graph");
121
+ }
122
+ if (
123
+ typeof graph.id !== "string" &&
124
+ !(typeof graph.id === "number" && Number.isInteger(graph.id))
125
+ ) {
126
+ throw new TypeError("Graph id must be a string or integer");
127
+ }
128
+ delete graph.logging;
129
+ const layoutOptions = {
130
+ ...this.#options.defaultLayoutOptions,
131
+ ...arguments_.layoutOptions,
132
+ ...graph.properties,
133
+ ...graph.layoutOptions,
134
+ };
135
+ // Programmatic incremental metadata is accepted by ELK but is neither
136
+ // serialized by elkjs nor geometry-affecting during a normal layout.
137
+ void getOption(layoutOptions, "debugMode");
138
+ void getOption(layoutOptions, "interactiveLayout");
139
+ void getOption(layoutOptions, "layered.generatePositionAndLayerIds");
140
+ void getOption(layoutOptions, "topdown.scaleFactor");
141
+ void getOption(layoutOptions, "contentAlignment");
142
+ for (const child of graph.children ?? []) {
143
+ const childOptions = child.layoutOptions ?? {};
144
+ void getOption(childOptions, "layered.layering.layerId");
145
+ void getOption(childOptions, "layered.crossingMinimization.positionId");
146
+ void getOption(childOptions, "layered.layering.layerChoiceConstraint");
147
+ void getOption(childOptions, "layered.crossingMinimization.positionChoiceConstraint");
148
+ void getOption(childOptions, "layered.crossingMinimization.inLayerPredOf");
149
+ void getOption(childOptions, "layered.crossingMinimization.inLayerSuccOf");
150
+ void getOption(childOptions, "topdown.scaleFactor");
151
+ void getOption(childOptions, "layered.considerModelOrder.groupModelOrder.componentGroupId");
152
+ for (const port of child.ports ?? []) {
153
+ void getOption(
154
+ port.layoutOptions ?? {},
155
+ "layered.considerModelOrder.groupModelOrder.componentGroupId",
156
+ );
157
+ }
158
+ }
159
+ for (const edge of graph.edges ?? []) {
160
+ void getOption(
161
+ edge.layoutOptions ?? {},
162
+ "layered.considerModelOrder.groupModelOrder.componentGroupId",
163
+ );
164
+ }
165
+ const requestedAlgorithm = String(getOption(layoutOptions, "algorithm") ?? "layered");
166
+ const algorithm = requestedAlgorithm.replace(/^(?:org\.eclipse\.)?elk\./, "");
167
+ if (
168
+ algorithm !== "layered" &&
169
+ algorithm !== "box" &&
170
+ algorithm !== "fixed" &&
171
+ algorithm !== "random" &&
172
+ algorithm !== "rectpacking" &&
173
+ algorithm !== "sporeCompaction" &&
174
+ algorithm !== "sporeOverlap"
175
+ ) {
176
+ throw new Error(
177
+ `org.eclipse.elk.core.UnsupportedConfigurationException: Layout algorithm '${requestedAlgorithm}' not found`,
178
+ );
179
+ }
180
+ const hasHierarchy = (graph.children ?? []).some((child) => (child.children?.length ?? 0) > 0);
181
+ const insideSelfLoopBaseHeightByNodeId = new Map<string, number>();
182
+ const hierarchyRestorations: Array<{
183
+ edge: ElkEdge;
184
+ sources?: ElkId[];
185
+ targets?: ElkId[];
186
+ source?: ElkId;
187
+ target?: ElkId;
188
+ }> = [];
189
+ const syntheticPortIds = new Set<string>();
190
+ const authoredPortsByCompound = new Map<ElkNode, ElkPort[] | undefined>();
191
+ const authoredOptionsByCompound = new Map<ElkNode, Record<string, unknown> | undefined>();
192
+ const hierarchyBoundaryCountByEdge = new Map<ElkEdge, number>();
193
+ const originalHierarchyEndpoints = new Map(
194
+ (graph.edges ?? []).map((edge) => [
195
+ edge,
196
+ {
197
+ sourceId: String(edge.sources?.[0] ?? edge.source),
198
+ targetId: String(edge.targets?.[0] ?? edge.target),
199
+ sources: edge.sources,
200
+ targets: edge.targets,
201
+ source: edge.source,
202
+ target: edge.target,
203
+ },
204
+ ]),
205
+ );
206
+ let hasHierarchyCrossingEdges = false;
207
+ const hierarchyHandling = getOption(layoutOptions, "hierarchyHandling");
208
+ const topdownLayout = getBooleanOption(layoutOptions, "topdownLayout") === true;
209
+ const separateHierarchy =
210
+ hasHierarchy && hierarchyHandling !== undefined && hierarchyHandling !== "INCLUDE_CHILDREN";
211
+ if (hasHierarchy && topdownLayout && hierarchyHandling === "INCLUDE_CHILDREN") {
212
+ throw new Error(
213
+ "org.eclipse.elk.core.UnsupportedConfigurationException: Topdown layout cannot be used together with hierarchy handling.",
214
+ );
215
+ }
216
+ if (separateHierarchy) {
217
+ const hasCrossHierarchyEdge = (container: ElkNode): boolean => {
218
+ const directEndpointIds = new Set<string>();
219
+ for (const child of container.children ?? []) {
220
+ directEndpointIds.add(String(child.id));
221
+ for (const port of child.ports ?? []) directEndpointIds.add(String(port.id));
222
+ }
223
+ if (
224
+ (container.edges ?? []).some((edge) => {
225
+ const sourceId = String(edge.sources?.[0] ?? edge.source);
226
+ const targetId = String(edge.targets?.[0] ?? edge.target);
227
+ return !directEndpointIds.has(sourceId) || !directEndpointIds.has(targetId);
228
+ })
229
+ ) {
230
+ return true;
231
+ }
232
+ return (container.children ?? []).some((child) => hasCrossHierarchyEdge(child));
233
+ };
234
+ if (hasCrossHierarchyEdge(graph)) {
235
+ throw new Error(
236
+ "org.eclipse.elk.core.UnsupportedGraphException: Hierarchical edges require INCLUDE_CHILDREN",
237
+ );
238
+ }
239
+ for (const child of graph.children ?? []) {
240
+ if ((child.children?.length ?? 0) === 0) continue;
241
+ await this.layout(child, {
242
+ ...arguments_,
243
+ layoutOptions: {
244
+ ...arguments_.layoutOptions,
245
+ ...child.layoutOptions,
246
+ hierarchyHandling,
247
+ },
248
+ logging: false,
249
+ measureExecutionTime: false,
250
+ });
251
+ }
252
+ }
253
+ if (hasHierarchy && topdownLayout) {
254
+ if (getOption(layoutOptions, "topdown.nodeType") === undefined) {
255
+ throw new Error(`${String(graph.id)} has not been assigned a top-down node type.`);
256
+ }
257
+ for (const child of graph.children ?? []) {
258
+ if ((child.children?.length ?? 0) === 0) continue;
259
+ const childOptions = { ...layoutOptions, ...child.layoutOptions };
260
+ const childPadding = parsePadding(getOption(childOptions, "padding"), 12);
261
+ const parallelNode = getOption(childOptions, "topdown.nodeType") === "PARALLEL_NODE";
262
+ const width =
263
+ getNumberOption(childOptions, "topdown.hierarchicalNodeWidth") ??
264
+ (parallelNode ? 150 : 0);
265
+ const aspectRatio =
266
+ getNumberOption(childOptions, "topdown.hierarchicalNodeAspectRatio") ??
267
+ (parallelNode ? 1.414 : 1 / Math.sqrt(2));
268
+ child.width = Math.max(child.width ?? 0, width + childPadding.left + childPadding.right);
269
+ child.height = Math.max(
270
+ child.height ?? 0,
271
+ width / aspectRatio + childPadding.top + childPadding.bottom,
272
+ );
273
+ }
274
+ } else if (hasHierarchy && !separateHierarchy) {
275
+ // The probability only chooses between equivalent top-down and bottom-up
276
+ // sweep schedules. The deterministic proxy decomposition below preserves
277
+ // the resulting exported order for either schedule.
278
+ void getNumberOption(layoutOptions, "layered.crossingMinimization.hierarchicalSweepiness");
279
+ for (const child of graph.children ?? []) {
280
+ if ((child.children?.length ?? 0) === 0) continue;
281
+ const descendantById = new Map<string, ElkNode>();
282
+ const descendantOwnerByEndpointId = new Map<string, ElkNode>();
283
+ const collectDescendants = (node: ElkNode): void => {
284
+ for (const descendant of node.children ?? []) {
285
+ descendantById.set(String(descendant.id), descendant);
286
+ descendantOwnerByEndpointId.set(String(descendant.id), descendant);
287
+ for (const port of descendant.ports ?? []) {
288
+ descendantOwnerByEndpointId.set(String(port.id), descendant);
289
+ }
290
+ collectDescendants(descendant);
291
+ }
292
+ };
293
+ collectDescendants(child);
294
+ const internalEdges = (graph.edges ?? []).filter((edge) => {
295
+ const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
296
+ return (
297
+ descendantOwnerByEndpointId.has(sourceId) && descendantOwnerByEndpointId.has(targetId)
298
+ );
299
+ });
300
+ const crossingEdges = (graph.edges ?? []).filter((edge) => {
301
+ const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
302
+ const sourceInside = descendantOwnerByEndpointId.has(sourceId);
303
+ const targetInside = descendantOwnerByEndpointId.has(targetId);
304
+ return sourceInside !== targetInside;
305
+ });
306
+ for (const edge of crossingEdges) {
307
+ hierarchyBoundaryCountByEdge.set(edge, (hierarchyBoundaryCountByEdge.get(edge) ?? 0) + 1);
308
+ }
309
+ hasHierarchyCrossingEdges ||= crossingEdges.length > 0;
310
+ const mergeHierarchyEdges =
311
+ getBooleanOption(layoutOptions, "layered.mergeHierarchyEdges") !== false;
312
+ // The option changes the number of internal external-port dummies. They
313
+ // are removed before elkjs serialization and do not alter the exported
314
+ // geometry for a shared hierarchy boundary.
315
+ void mergeHierarchyEdges;
316
+ const proxyByKind = new Map<string, ElkNode>();
317
+ const proxyFor = (kind: "input" | "output"): ElkNode => {
318
+ const key = kind;
319
+ let proxy = proxyByKind.get(key);
320
+ if (!proxy) {
321
+ proxy = {
322
+ id: `__native_hierarchy_${String(child.id)}_${key.replace(/[^a-zA-Z0-9]/g, "_")}`,
323
+ width: 0,
324
+ height: 0,
325
+ };
326
+ proxyByKind.set(key, proxy);
327
+ }
328
+ return proxy;
329
+ };
330
+ const temporaryEdges: ElkEdge[] = [
331
+ ...(child.edges ?? []),
332
+ ...internalEdges.filter(
333
+ (edge) =>
334
+ !(child.edges ?? []).some((candidate) => String(candidate.id) === String(edge.id)),
335
+ ),
336
+ ...crossingEdges.map((edge) => {
337
+ const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
338
+ const sourceInside = descendantOwnerByEndpointId.has(sourceId);
339
+ const proxy = proxyFor(sourceInside ? "output" : "input");
340
+ return {
341
+ ...edge,
342
+ id: `__native_hierarchy_edge_${String(child.id)}_${String(edge.id)}`,
343
+ sources: [sourceInside ? sourceId : proxy.id!],
344
+ targets: [sourceInside ? proxy.id! : targetId],
345
+ source: undefined,
346
+ target: undefined,
347
+ sections: undefined,
348
+ };
349
+ }),
350
+ ];
351
+ const temporaryChild: ElkNode = {
352
+ ...child,
353
+ children: [...(child.children ?? []), ...proxyByKind.values()],
354
+ edges: temporaryEdges,
355
+ };
356
+ await this.layout(temporaryChild, {
357
+ ...arguments_,
358
+ layoutOptions: {
359
+ ...arguments_.layoutOptions,
360
+ hierarchyHandling: "INCLUDE_CHILDREN",
361
+ },
362
+ logging: false,
363
+ measureExecutionTime: false,
364
+ });
365
+ const childPadding = parsePadding(
366
+ getOption({ ...layoutOptions, ...child.layoutOptions }, "padding"),
367
+ 12,
368
+ );
369
+ if (proxyByKind.has("input")) {
370
+ const direction = getDirection(layoutOptions);
371
+ const horizontal = direction === "right" || direction === "left";
372
+ const increasing = direction === "right" || direction === "down";
373
+ if (increasing) {
374
+ const minimumFlow = Math.min(
375
+ ...(child.children ?? []).map((node) => (horizontal ? (node.x ?? 0) : (node.y ?? 0))),
376
+ );
377
+ const desiredFlow = horizontal ? childPadding.left : childPadding.top;
378
+ const delta = desiredFlow - minimumFlow;
379
+ for (const node of child.children ?? []) {
380
+ if (horizontal) node.x = (node.x ?? 0) + delta;
381
+ else node.y = (node.y ?? 0) + delta;
382
+ }
383
+ for (const edge of temporaryEdges) {
384
+ for (const section of edge.sections ?? []) {
385
+ for (const point of [
386
+ section.startPoint,
387
+ ...(section.bendPoints ?? []),
388
+ section.endPoint,
389
+ ]) {
390
+ if (horizontal) point.x += delta;
391
+ else point.y += delta;
392
+ }
393
+ }
394
+ }
395
+ }
396
+ }
397
+ for (const internalEdge of internalEdges) {
398
+ const temporary = temporaryEdges.find(
399
+ (candidate) => String(candidate.id) === String(internalEdge.id),
400
+ );
401
+ if (temporary?.sections) internalEdge.sections = temporary.sections;
402
+ }
403
+ child.width =
404
+ Math.max(0, ...(child.children ?? []).map((node) => (node.x ?? 0) + (node.width ?? 0))) +
405
+ childPadding.right;
406
+ child.height =
407
+ Math.max(0, ...(child.children ?? []).map((node) => (node.y ?? 0) + (node.height ?? 0))) +
408
+ childPadding.bottom;
409
+
410
+ const relativeRect = (id: string): ElkShape | undefined => {
411
+ const owner = descendantOwnerByEndpointId.get(id);
412
+ if (!owner) return undefined;
413
+ const path: ElkNode[] = [];
414
+ const visit = (parent: ElkNode): boolean => {
415
+ for (const candidate of parent.children ?? []) {
416
+ path.push(candidate);
417
+ if (candidate === owner || visit(candidate)) return true;
418
+ path.pop();
419
+ }
420
+ return false;
421
+ };
422
+ if (!visit(child)) return undefined;
423
+ const port = owner.ports?.find((candidate) => String(candidate.id) === id);
424
+ const ownerX = path.reduce((sum, node) => sum + (node.x ?? 0), 0);
425
+ const ownerY = path.reduce((sum, node) => sum + (node.y ?? 0), 0);
426
+ if (port) {
427
+ return {
428
+ x: ownerX + (port.x ?? 0) + (port.width ?? 0) / 2,
429
+ y: ownerY + (port.y ?? 0) + (port.height ?? 0) / 2,
430
+ width: 0,
431
+ height: 0,
432
+ };
433
+ }
434
+ return {
435
+ x: ownerX,
436
+ y: ownerY,
437
+ width: path.at(-1)?.width ?? 0,
438
+ height: path.at(-1)?.height ?? 0,
439
+ };
440
+ };
441
+ authoredPortsByCompound.set(child, child.ports);
442
+ authoredOptionsByCompound.set(child, child.layoutOptions);
443
+ const ports = [...(child.ports ?? [])];
444
+ for (const edge of crossingEdges) {
445
+ const original = originalHierarchyEndpoints.get(edge)!;
446
+ const { sourceId, targetId } = original;
447
+ const sourceInside = descendantOwnerByEndpointId.has(sourceId);
448
+ const descendantId = sourceInside ? sourceId : targetId;
449
+ const rect = relativeRect(descendantId);
450
+ if (!rect) continue;
451
+ const portId = `__native_hierarchy_port_${String(child.id)}_${String(edge.id)}`;
452
+ syntheticPortIds.add(portId);
453
+ const direction = getDirection(layoutOptions);
454
+ const outgoing = sourceInside;
455
+ const flowForward = direction === "right" || direction === "down";
456
+ const useFarSide = outgoing === flowForward;
457
+ ports.push({
458
+ id: portId,
459
+ width: 0,
460
+ height: 0,
461
+ x:
462
+ direction === "right" || direction === "left"
463
+ ? (rect.x ?? 0) + (useFarSide ? (rect.width ?? 0) : 0)
464
+ : (rect.x ?? 0) + (rect.width ?? 0) / 2,
465
+ y:
466
+ direction === "down" || direction === "up"
467
+ ? (rect.y ?? 0) + (useFarSide ? (rect.height ?? 0) : 0)
468
+ : (rect.y ?? 0) + (rect.height ?? 0) / 2,
469
+ });
470
+ if (!hierarchyRestorations.some((restoration) => restoration.edge === edge)) {
471
+ hierarchyRestorations.push({ edge, ...original });
472
+ }
473
+ if (sourceInside) edge.sources = [portId];
474
+ else edge.targets = [portId];
475
+ edge.source = undefined;
476
+ edge.target = undefined;
477
+ }
478
+ child.ports = ports;
479
+ child.layoutOptions = { ...child.layoutOptions, "elk.portConstraints": "FIXED_POS" };
480
+ const insideLoopCount = (graph.edges ?? []).filter((edge) =>
481
+ isInsideSelfLoop(graph, edge, String(child.id)),
482
+ ).length;
483
+ if (insideLoopCount > 0) {
484
+ const baseHeight = child.height ?? 0;
485
+ insideSelfLoopBaseHeightByNodeId.set(String(child.id), baseHeight);
486
+ child.height = baseHeight + insideLoopCount * 11;
487
+ }
488
+ }
489
+ }
490
+ applyNodeMicroLayout(graph, layoutOptions);
491
+ const graph_ = toGraph(graph, layoutOptions);
492
+ const layerConstraintByNodeId = new Map(
493
+ (graph.children ?? []).map((node) => [
494
+ String(node.id),
495
+ String(
496
+ getOption(node.layoutOptions ?? {}, "layered.layering.layerConstraint") ??
497
+ getOption(node.layoutOptions ?? {}, "layerConstraint") ??
498
+ "NONE",
499
+ ),
500
+ ]),
501
+ );
502
+ if (
503
+ algorithm === "layered" &&
504
+ graph_.edges.some((edge) => {
505
+ const sourceConstraint = layerConstraintByNodeId.get(edge.sourceId);
506
+ const targetConstraint = layerConstraintByNodeId.get(edge.targetId);
507
+ return (
508
+ (sourceConstraint === "FIRST" || sourceConstraint === "FIRST_SEPARATE") &&
509
+ (targetConstraint === "FIRST" || targetConstraint === "FIRST_SEPARATE")
510
+ );
511
+ })
512
+ ) {
513
+ throw new Error(
514
+ "org.eclipse.elk.core.UnsupportedConfigurationException: Layer constraints conflict",
515
+ );
516
+ }
517
+ const padding = parsePadding(
518
+ getOption(layoutOptions, "padding"),
519
+ algorithm === "layered" ? 12 : 0,
520
+ );
521
+ const laidOut =
522
+ algorithm === "sporeCompaction"
523
+ ? getSporeCompactionLayout(graph_, {
524
+ padding,
525
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
526
+ })
527
+ : algorithm === "sporeOverlap"
528
+ ? getSporeOverlapRemovalLayout(graph_, {
529
+ padding,
530
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
531
+ })
532
+ : algorithm === "rectpacking"
533
+ ? getRectanglePackingLayout(graph_, {
534
+ padding,
535
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
536
+ })
537
+ : algorithm === "random"
538
+ ? getRandomLayout(graph_, {
539
+ padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
540
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
541
+ aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
542
+ seed: getNumberOption(layoutOptions, "randomSeed"),
543
+ })
544
+ : algorithm === "box"
545
+ ? getBoxLayout(graph_, {
546
+ padding: getOption(layoutOptions, "padding") === undefined ? 15 : padding,
547
+ spacing: getNumberOption(layoutOptions, "spacing.nodeNode"),
548
+ aspectRatio: getNumberOption(layoutOptions, "aspectRatio"),
549
+ interactive: getBooleanOption(layoutOptions, "interactive"),
550
+ expandNodes: getBooleanOption(layoutOptions, "expandNodes"),
551
+ priority: (node) => {
552
+ const child = graph.children?.find(
553
+ (candidate) => String(candidate.id) === node.id,
554
+ );
555
+ return getNumberOption(child?.layoutOptions ?? {}, "priority");
556
+ },
557
+ })
558
+ : algorithm === "fixed"
559
+ ? getFixedLayout(graph_, { direction: getDirection(layoutOptions) })
560
+ : getLayeredLayout(graph_, {
561
+ direction: getDirection(layoutOptions),
562
+ spacing: {
563
+ node:
564
+ getNumberOption(layoutOptions, "spacing.nodeNode") ??
565
+ getNumberOption(layoutOptions, "layered.spacing.baseValue"),
566
+ layer:
567
+ (getNumberOption(
568
+ layoutOptions,
569
+ "layered.spacing.nodeNodeBetweenLayers",
570
+ ) ??
571
+ getNumberOption(layoutOptions, "layered.spacing.baseValue") ??
572
+ 20) +
573
+ (hasHierarchyCrossingEdges
574
+ ? 5 * Math.max(...hierarchyBoundaryCountByEdge.values())
575
+ : 0),
576
+ },
577
+ padding,
578
+ constraints: {
579
+ layer: () => undefined,
580
+ },
581
+ settings: {
582
+ ...getLayeredSettings(layoutOptions),
583
+ ...(hierarchyHandling === "INCLUDE_CHILDREN" &&
584
+ getOption(
585
+ layoutOptions,
586
+ "layered.crossingMinimization.greedySwitchHierarchical.type",
587
+ ) !== undefined
588
+ ? {
589
+ "crossingMinimization.greedySwitch.type": String(
590
+ getOption(
591
+ layoutOptions,
592
+ "layered.crossingMinimization.greedySwitchHierarchical.type",
593
+ ),
594
+ ) as LayeredAdvancedOptions["crossingMinimization.greedySwitch.type"],
595
+ }
596
+ : {}),
597
+ },
598
+ nodeSettings: (node) => {
599
+ const child = graph.children?.find(
600
+ (candidate) => String(candidate.id) === node.id,
601
+ );
602
+ return getElementLayeredSettings(child?.layoutOptions ?? {});
603
+ },
604
+ edgeSettings: (edge) => {
605
+ const elkEdge = graph.edges?.find(
606
+ (candidate) => String(candidate.id) === edge.id,
607
+ );
608
+ return {
609
+ ...getElementLayeredSettings(elkEdge?.layoutOptions ?? {}),
610
+ ...getElementLayeredSettings(elkEdge?.labels?.[0]?.layoutOptions ?? {}),
611
+ };
612
+ },
613
+ portSettings: (port, node) => {
614
+ const child = graph.children?.find(
615
+ (candidate) => String(candidate.id) === node.id,
616
+ );
617
+ const elkPort = child?.ports?.find(
618
+ (candidate) => String(candidate.id) === port.name,
619
+ );
620
+ return {
621
+ ...getElementLayeredSettings(elkPort?.layoutOptions ?? {}),
622
+ "port.labelWidth": Math.max(
623
+ 0,
624
+ ...(elkPort?.labels ?? []).map((label) => label.width ?? 0),
625
+ ),
626
+ "port.labelHeight": Math.max(
627
+ 0,
628
+ ...(elkPort?.labels ?? []).map((label) => label.height ?? 0),
629
+ ),
630
+ } as ElkLayeredOptionValueByName;
631
+ },
632
+ });
633
+ if (hierarchyRestorations.length > 0) {
634
+ const direction = getDirection(layoutOptions);
635
+ const horizontal = direction === "right" || direction === "left";
636
+ const cross = (point: ElkPoint): number => (horizontal ? point.y : point.x);
637
+ const nodeSpacing = getNumberOption(layoutOptions, "spacing.nodeNode") ?? 20;
638
+ const shiftsByOutsideId = new Map<string, number[]>();
639
+ for (const restoration of hierarchyRestorations) {
640
+ const route = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id))?.points;
641
+ if (!route || route.length < 2) continue;
642
+ const originalSourceId = String(restoration.sources?.[0] ?? restoration.source);
643
+ const originalTargetId = String(restoration.targets?.[0] ?? restoration.target);
644
+ const sourceInside = !laidOut.nodes.some((node) => node.id === originalSourceId);
645
+ const outsideId = sourceInside ? originalTargetId : originalSourceId;
646
+ const delta = sourceInside
647
+ ? cross(route[0]!) - cross(route.at(-1)!)
648
+ : cross(route.at(-1)!) - cross(route[0]!);
649
+ const candidates = shiftsByOutsideId.get(outsideId) ?? [];
650
+ candidates.push(delta);
651
+ shiftsByOutsideId.set(outsideId, candidates);
652
+ }
653
+ for (const [outsideId, candidates] of shiftsByOutsideId) {
654
+ const delta = [...candidates].sort((left, right) => Math.abs(left) - Math.abs(right))[0]!;
655
+ const node = laidOut.nodes.find((candidate) => candidate.id === outsideId);
656
+ if (!node || Math.abs(delta) < 1e-9 || Math.abs(delta) > nodeSpacing) continue;
657
+ if (horizontal) node.y = (node.y ?? 0) + delta;
658
+ else node.x = (node.x ?? 0) + delta;
659
+ for (const edge of laidOut.edges) {
660
+ const points = edge.points;
661
+ if (!points || points.length === 0) continue;
662
+ if (edge.sourceId === outsideId) {
663
+ if (horizontal) points[0]!.y += delta;
664
+ else points[0]!.x += delta;
665
+ }
666
+ if (edge.targetId === outsideId) {
667
+ if (horizontal) points.at(-1)!.y += delta;
668
+ else points.at(-1)!.x += delta;
669
+ }
670
+ }
671
+ }
672
+ const modelOrder = new Map(
673
+ (graph.children ?? []).map((node, index) => [String(node.id), index]),
674
+ );
675
+ const flowLayers = new Map<number, (typeof laidOut.nodes)[number][]>();
676
+ for (const node of laidOut.nodes) {
677
+ const flow = horizontal ? (node.x ?? 0) : (node.y ?? 0);
678
+ const layer = flowLayers.get(flow) ?? [];
679
+ layer.push(node);
680
+ flowLayers.set(flow, layer);
681
+ }
682
+ for (const layer of flowLayers.values()) {
683
+ layer.sort(
684
+ (left, right) =>
685
+ (modelOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
686
+ (modelOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER),
687
+ );
688
+ let crossEnd = Number.NEGATIVE_INFINITY;
689
+ for (const node of layer) {
690
+ const authoredCross = horizontal ? (node.y ?? 0) : (node.x ?? 0);
691
+ const compactedCross = Math.max(
692
+ authoredCross,
693
+ crossEnd === Number.NEGATIVE_INFINITY ? authoredCross : crossEnd + nodeSpacing,
694
+ );
695
+ const delta = compactedCross - authoredCross;
696
+ if (horizontal) node.y = compactedCross;
697
+ else node.x = compactedCross;
698
+ if (Math.abs(delta) > 1e-9) {
699
+ for (const edge of laidOut.edges) {
700
+ const points = edge.points;
701
+ if (!points || points.length === 0) continue;
702
+ if (edge.sourceId === node.id) {
703
+ if (horizontal) points[0]!.y += delta;
704
+ else points[0]!.x += delta;
705
+ }
706
+ if (edge.targetId === node.id) {
707
+ if (horizontal) points.at(-1)!.y += delta;
708
+ else points.at(-1)!.x += delta;
709
+ }
710
+ }
711
+ }
712
+ crossEnd = compactedCross + (horizontal ? node.height : node.width);
713
+ }
714
+ }
715
+ const edgeNodeSpacing = getNumberOption(layoutOptions, "spacing.edgeNodeBetweenLayers") ?? 10;
716
+ for (const restoration of hierarchyRestorations) {
717
+ const route = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id))?.points;
718
+ if (!route || route.length < 2) continue;
719
+ const start = route[0]!;
720
+ const end = route.at(-1)!;
721
+ if (Math.abs(cross(start) - cross(end)) < 1e-9) {
722
+ route.splice(1, route.length - 2);
723
+ continue;
724
+ }
725
+ const originalSourceId = String(restoration.sources?.[0] ?? restoration.source);
726
+ const sourceInside = !laidOut.nodes.some((node) => node.id === originalSourceId);
727
+ const flowForward = direction === "right" || direction === "down";
728
+ const track = sourceInside
729
+ ? (horizontal ? end.x : end.y) - (flowForward ? edgeNodeSpacing : -edgeNodeSpacing)
730
+ : (horizontal ? start.x : start.y) + (flowForward ? edgeNodeSpacing : -edgeNodeSpacing);
731
+ route.splice(
732
+ 1,
733
+ route.length - 2,
734
+ horizontal ? { x: track, y: start.y } : { x: start.x, y: track },
735
+ horizontal ? { x: track, y: end.y } : { x: end.x, y: track },
736
+ );
737
+ }
738
+ }
739
+ applyLayout(graph, laidOut, padding, layoutOptions);
740
+ for (const restoration of hierarchyRestorations) {
741
+ restoration.edge.sources = restoration.sources;
742
+ restoration.edge.targets = restoration.targets;
743
+ restoration.edge.source = restoration.source;
744
+ restoration.edge.target = restoration.target;
745
+ for (const section of restoration.edge.sections ?? []) {
746
+ if (section.incomingShape != null && syntheticPortIds.has(String(section.incomingShape))) {
747
+ section.incomingShape = restoration.sources?.[0] ?? restoration.source;
748
+ }
749
+ if (section.outgoingShape != null && syntheticPortIds.has(String(section.outgoingShape))) {
750
+ section.outgoingShape = restoration.targets?.[0] ?? restoration.target;
751
+ }
752
+ }
753
+ }
754
+ for (const [compound, ports] of authoredPortsByCompound) compound.ports = ports;
755
+ for (const [compound, options_] of authoredOptionsByCompound) compound.layoutOptions = options_;
756
+ if (hasHierarchy && topdownLayout) {
757
+ for (const child of graph.children ?? []) {
758
+ if ((child.children?.length ?? 0) === 0) continue;
759
+ const position = { x: child.x, y: child.y };
760
+ await this.layout(child, {
761
+ ...arguments_,
762
+ logging: false,
763
+ measureExecutionTime: false,
764
+ });
765
+ child.x = position.x;
766
+ child.y = position.y;
767
+ }
768
+ }
769
+ applyInsideSelfLoops(graph, insideSelfLoopBaseHeightByNodeId);
770
+ if (arguments_.logging || arguments_.measureExecutionTime) {
771
+ graph.logging = {
772
+ name: "Native TypeScript layout",
773
+ children: [{ name: String(algorithm) }],
774
+ ...(arguments_.measureExecutionTime
775
+ ? { executionTime: (performance.now() - startedAt) / 1_000 }
776
+ : {}),
777
+ };
778
+ }
779
+ return graph as LaidOutElkNode<T>;
780
+ }
781
+ }
782
+
783
+ function parsePadding(value: unknown, fallback = 0) {
784
+ if (typeof value === "number") {
785
+ return { top: value, right: value, bottom: value, left: value };
786
+ }
787
+ if (typeof value !== "string") {
788
+ return { top: fallback, right: fallback, bottom: fallback, left: fallback };
789
+ }
790
+ const padding = { top: 0, right: 0, bottom: 0, left: 0 };
791
+ for (const match of value.matchAll(/(top|right|bottom|left)\s*=\s*(-?\d+(?:\.\d+)?)/g)) {
792
+ const side = match[1] as keyof typeof padding;
793
+ padding[side] = Number(match[2]);
794
+ }
795
+ return padding;
796
+ }
797
+
798
+ function parseVector(value: unknown): ElkPoint | undefined {
799
+ if (typeof value === "object" && value !== null && "x" in value && "y" in value) {
800
+ return { x: Number(value.x), y: Number(value.y) };
801
+ }
802
+ if (typeof value !== "string") return undefined;
803
+ const match = value.match(/^\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*$/);
804
+ return match ? { x: Number(match[1]), y: Number(match[2]) } : undefined;
805
+ }
806
+
807
+ function applyNodeMicroLayout(
808
+ graph: ElkNode,
809
+ globalOptions: Readonly<Record<string, unknown>>,
810
+ ): void {
811
+ const labelPadding = parsePadding(getOption(globalOptions, "nodeLabels.padding"), 5);
812
+ for (const node of graph.children ?? []) {
813
+ const constraints = String(getOption(node.layoutOptions ?? {}, "nodeSize.constraints") ?? "");
814
+ if (!constraints) continue;
815
+ const configuredSizeOptions = getOption(node.layoutOptions ?? {}, "nodeSize.options");
816
+ const sizeOptions = new Set(
817
+ configuredSizeOptions === undefined
818
+ ? ["DEFAULT_MINIMUM_SIZE"]
819
+ : String(configuredSizeOptions)
820
+ .split(/[\s,;]+/)
821
+ .filter(Boolean),
822
+ );
823
+ const effectivelyFixedPortLabelSize =
824
+ constraints.includes("PORT_LABELS") &&
825
+ !constraints.includes("NODE_LABELS") &&
826
+ !constraints.includes("MINIMUM_SIZE");
827
+ const preserveComputedCompoundSize = (node.children?.length ?? 0) > 0;
828
+ let width =
829
+ effectivelyFixedPortLabelSize || preserveComputedCompoundSize ? (node.width ?? 0) : 0;
830
+ let height =
831
+ effectivelyFixedPortLabelSize || preserveComputedCompoundSize ? (node.height ?? 0) : 0;
832
+ let insideHorizontalInset = 0;
833
+ let insideVerticalInset = 0;
834
+ const insideLabelCells = new Map<string, { width: number; height: number }>();
835
+ if (constraints.includes("PORTS")) {
836
+ const spacing = getNumberOption(globalOptions, "spacing.portPort") ?? 10;
837
+ const sideCounts = { NORTH: 0, EAST: 0, SOUTH: 0, WEST: 0 };
838
+ for (const port of node.ports ?? []) {
839
+ const side = String(getOption(port.layoutOptions ?? {}, "port.side") ?? "EAST");
840
+ if (side in sideCounts) sideCounts[side as keyof typeof sideCounts]++;
841
+ }
842
+ const nodeId = String(node.id);
843
+ for (const edge of graph.edges ?? []) {
844
+ const source = String(edge.sources?.[0] ?? edge.source);
845
+ const target = String(edge.targets?.[0] ?? edge.target);
846
+ if (source === nodeId) sideCounts.EAST++;
847
+ if (target === nodeId) sideCounts.WEST++;
848
+ }
849
+ width = Math.max(width, (Math.max(sideCounts.NORTH, sideCounts.SOUTH) + 1) * spacing);
850
+ height = Math.max(height, (Math.max(sideCounts.EAST, sideCounts.WEST) + 1) * spacing);
851
+ if (!preserveComputedCompoundSize && sideCounts.NORTH === 0 && sideCounts.SOUTH === 0) {
852
+ width = 0;
853
+ }
854
+ if (!preserveComputedCompoundSize && sideCounts.EAST === 0 && sideCounts.WEST === 0) {
855
+ height = 0;
856
+ }
857
+ }
858
+ if (constraints.includes("NODE_LABELS")) {
859
+ for (const label of node.labels ?? []) {
860
+ if (!label.text) continue;
861
+ const placement = String(
862
+ getOption(
863
+ { ...globalOptions, ...node.layoutOptions, ...label.layoutOptions },
864
+ "nodeLabels.placement",
865
+ ) ?? "",
866
+ );
867
+ if (!placement) continue;
868
+ const labelWidth = label.width ?? 0;
869
+ const labelHeight = label.height ?? 0;
870
+ if (placement.includes("OUTSIDE")) {
871
+ if (!sizeOptions.has("OUTSIDE_NODE_LABELS_OVERHANG")) {
872
+ width = Math.max(width, labelWidth);
873
+ }
874
+ } else {
875
+ width = Math.max(width, labelWidth + labelPadding.left + labelPadding.right);
876
+ const verticalInset =
877
+ labelHeight * (placement.includes("V_CENTER") ? 1 : 2) +
878
+ labelPadding.top +
879
+ labelPadding.bottom;
880
+ height = Math.max(height, verticalInset);
881
+ insideHorizontalInset = Math.max(
882
+ insideHorizontalInset,
883
+ labelPadding.left + labelPadding.right,
884
+ );
885
+ insideVerticalInset = Math.max(insideVerticalInset, verticalInset);
886
+ const row = placement.includes("V_CENTER")
887
+ ? "center"
888
+ : placement.includes("V_BOTTOM")
889
+ ? "bottom"
890
+ : "top";
891
+ const column = placement.includes("H_CENTER")
892
+ ? "center"
893
+ : placement.includes("H_RIGHT")
894
+ ? "right"
895
+ : "left";
896
+ const key = `${row}:${column}`;
897
+ const cell = insideLabelCells.get(key) ?? { width: 0, height: 0 };
898
+ cell.width = Math.max(cell.width, labelWidth);
899
+ cell.height += labelHeight;
900
+ insideLabelCells.set(key, cell);
901
+ }
902
+ }
903
+ if (insideLabelCells.size > 0) {
904
+ const rows = ["top", "center", "bottom"] as const;
905
+ const columns = ["left", "center", "right"] as const;
906
+ const cellWidth = (row: string, column: string) =>
907
+ insideLabelCells.get(`${row}:${column}`)?.width ?? 0;
908
+ const forceTabular = sizeOptions.has("FORCE_TABULAR_NODE_LABELS");
909
+ const asymmetrical = sizeOptions.has("ASYMMETRICAL");
910
+ const globalColumns = columns.map((column) =>
911
+ Math.max(...rows.map((row) => cellWidth(row, column))),
912
+ );
913
+ const labelGridWidth = forceTabular
914
+ ? globalColumns.reduce((sum, value) => sum + value, 0)
915
+ : Math.max(
916
+ ...rows.map((row) => {
917
+ const left = cellWidth(row, "left");
918
+ const center = cellWidth(row, "center");
919
+ const right = cellWidth(row, "right");
920
+ return asymmetrical ? left + center + right : 2 * Math.max(left, right) + center;
921
+ }),
922
+ );
923
+ const labelGridHeight = rows.reduce(
924
+ (sum, row) =>
925
+ sum +
926
+ Math.max(
927
+ ...columns.map((column) => insideLabelCells.get(`${row}:${column}`)?.height ?? 0),
928
+ ),
929
+ 0,
930
+ );
931
+ width = Math.max(width, labelGridWidth + labelPadding.left + labelPadding.right);
932
+ height = Math.max(height, labelGridHeight + labelPadding.top + labelPadding.bottom);
933
+ }
934
+ }
935
+ if (constraints.includes("MINIMUM_SIZE")) {
936
+ const configuredMinimum = parseVector(
937
+ getOption(node.layoutOptions ?? {}, "nodeSize.minimum"),
938
+ );
939
+ const minimum = {
940
+ x:
941
+ configuredMinimum?.x && configuredMinimum.x > 0
942
+ ? configuredMinimum.x
943
+ : sizeOptions.has("DEFAULT_MINIMUM_SIZE")
944
+ ? 20
945
+ : 0,
946
+ y:
947
+ configuredMinimum?.y && configuredMinimum.y > 0
948
+ ? configuredMinimum.y
949
+ : sizeOptions.has("DEFAULT_MINIMUM_SIZE")
950
+ ? 20
951
+ : 0,
952
+ };
953
+ if (sizeOptions.has("MINIMUM_SIZE_ACCOUNTS_FOR_PADDING")) {
954
+ width = Math.max(width, minimum.x + insideHorizontalInset);
955
+ height = Math.max(height, minimum.y + insideVerticalInset);
956
+ } else {
957
+ width = Math.max(width, minimum.x);
958
+ height = Math.max(height, minimum.y);
959
+ }
960
+ }
961
+ // Reading COMPUTE_PADDING is intentional: elkjs does not serialize the computed padding property.
962
+ void sizeOptions.has("COMPUTE_PADDING");
963
+ node.width = width;
964
+ node.height = height;
965
+ }
966
+ }
967
+
968
+ function getOption(options: Readonly<Record<string, unknown>>, suffix: string): unknown {
969
+ const exactKeys = [suffix, `elk.${suffix}`, `org.eclipse.elk.${suffix}`];
970
+ for (const key of exactKeys) {
971
+ if (options[key] !== undefined) return options[key];
972
+ }
973
+ return undefined;
974
+ }
975
+
976
+ const ergonomicallyMappedLayeredSettings = new Set<keyof ElkLayeredOptionValueByName>([
977
+ "direction",
978
+ "padding",
979
+ "spacing.node",
980
+ "spacing.layer",
981
+ ]);
982
+
983
+ function coerceLayeredOptionValue(value: unknown, type: string): unknown {
984
+ if (type === "BOOLEAN") {
985
+ if (typeof value === "boolean") return value;
986
+ if (typeof value === "string") return value.toLowerCase() === "true";
987
+ }
988
+ if (type === "DOUBLE" || type === "INT") {
989
+ if (typeof value === "number") return value;
990
+ if (typeof value === "string" && value.trim() !== "") return Number(value);
991
+ }
992
+ return value;
993
+ }
994
+
995
+ function parseIndividualSpacing(value: unknown): unknown {
996
+ if (typeof value === "object" && value !== null) return value;
997
+ if (typeof value !== "string") return value;
998
+ const result: Record<string, number> = {};
999
+ for (const entry of value.split(/;,;|;/)) {
1000
+ const match = entry.match(
1001
+ /^\s*(?:org\.eclipse\.elk\.)?(?:layered\.)?([^:]+)\s*:\s*(-?\d+(?:\.\d+)?)\s*$/,
1002
+ );
1003
+ if (!match) continue;
1004
+ const sourceName = match[1]!;
1005
+ const definition = elkLayeredOptionDefinitions.find((candidate) => {
1006
+ const suffix = candidate.elkId.replace(/^org\.eclipse\.elk\./, "");
1007
+ return candidate.name === sourceName || suffix === sourceName;
1008
+ });
1009
+ if (definition) result[definition.name] = Number(match[2]);
1010
+ }
1011
+ return result;
1012
+ }
1013
+
1014
+ function parseMargin(value: unknown): unknown {
1015
+ if (typeof value === "object" && value !== null) return value;
1016
+ if (typeof value !== "string") return value;
1017
+ return parsePadding(value, 0);
1018
+ }
1019
+
1020
+ function getElementLayeredSettings(
1021
+ options: Readonly<Record<string, unknown>>,
1022
+ ): ElkLayeredOptionValueByName {
1023
+ const settings: ElkLayeredOptionValueByName = {};
1024
+ for (const definition of elkLayeredOptionDefinitions) {
1025
+ const suffix = definition.elkId.replace(/^org\.eclipse\.elk\./, "");
1026
+ const value = [definition.name, suffix, `elk.${suffix}`, definition.elkId]
1027
+ .map((key) => options[key])
1028
+ .find((candidate) => candidate !== undefined);
1029
+ if (value === undefined) continue;
1030
+ const vectorMatch =
1031
+ definition.name === "port.anchor" && typeof value === "string"
1032
+ ? value.match(/^\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*$/)
1033
+ : undefined;
1034
+ settings[definition.name] = (
1035
+ vectorMatch
1036
+ ? { x: Number(vectorMatch[1]), y: Number(vectorMatch[2]) }
1037
+ : definition.name === "spacing.individual"
1038
+ ? parseIndividualSpacing(value)
1039
+ : definition.name === "spacing.portsSurrounding"
1040
+ ? parseMargin(value)
1041
+ : coerceLayeredOptionValue(value, definition.type)
1042
+ ) as never;
1043
+ }
1044
+ return settings;
1045
+ }
1046
+
1047
+ function getLayeredSettings(options: Readonly<Record<string, unknown>>): LayeredAdvancedOptions {
1048
+ const settings = getElementLayeredSettings(options);
1049
+ const baseValue = settings["spacing.baseValue"];
1050
+ if (baseValue !== undefined) {
1051
+ for (const [name, defaultValue] of [
1052
+ ["spacing.componentComponent", 20],
1053
+ ["spacing.edgeEdge", 10],
1054
+ ["spacing.edgeLabel", 2],
1055
+ ["spacing.edgeNode", 10],
1056
+ ["spacing.labelLabel", 0],
1057
+ ["spacing.labelNode", 5],
1058
+ ["spacing.labelPortHorizontal", 1],
1059
+ ["spacing.labelPortVertical", 1],
1060
+ ["spacing.nodeSelfLoop", 10],
1061
+ ["spacing.portPort", 10],
1062
+ ["spacing.edgeEdgeBetweenLayers", 10],
1063
+ ["spacing.edgeNodeBetweenLayers", 10],
1064
+ ] as const) {
1065
+ settings[name] ??= (baseValue * defaultValue) / 20;
1066
+ }
1067
+ }
1068
+ for (const name of ergonomicallyMappedLayeredSettings) delete settings[name];
1069
+ return settings as LayeredAdvancedOptions;
1070
+ }
1071
+
1072
+ function getNumberOption(
1073
+ options: Readonly<Record<string, unknown>>,
1074
+ suffix: string,
1075
+ ): number | undefined {
1076
+ const value = getOption(options, suffix);
1077
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1078
+ if (typeof value === "string" && value.trim() !== "") {
1079
+ const parsed = Number(value);
1080
+ if (Number.isFinite(parsed)) return parsed;
1081
+ }
1082
+ return undefined;
1083
+ }
1084
+
1085
+ function getBooleanOption(
1086
+ options: Readonly<Record<string, unknown>>,
1087
+ suffix: string,
1088
+ ): boolean | undefined {
1089
+ const value = getOption(options, suffix);
1090
+ if (typeof value === "boolean") return value;
1091
+ if (typeof value === "string") {
1092
+ if (value.toLowerCase() === "true") return true;
1093
+ if (value.toLowerCase() === "false") return false;
1094
+ }
1095
+ return undefined;
1096
+ }
1097
+
1098
+ function getDirection(
1099
+ options: Readonly<Record<string, unknown>>,
1100
+ ): "up" | "down" | "left" | "right" {
1101
+ const direction = String(getOption(options, "direction") ?? "RIGHT").toLowerCase();
1102
+ return direction === "up" || direction === "left" || direction === "down" ? direction : "right";
1103
+ }
1104
+
1105
+ function endpoint(
1106
+ value: unknown,
1107
+ portOwnerById: ReadonlyMap<string, string>,
1108
+ ): { nodeId: string; port?: string } {
1109
+ const id = String(value);
1110
+ const ownerId = portOwnerById.get(id);
1111
+ return ownerId === undefined ? { nodeId: id } : { nodeId: ownerId, port: id };
1112
+ }
1113
+
1114
+ function isInsideSelfLoop(root: ElkNode, edge: ElkEdge, expectedNodeId?: string): boolean {
1115
+ const source = String(edge.sources?.[0] ?? edge.source);
1116
+ const target = String(edge.targets?.[0] ?? edge.target);
1117
+ if (source !== target || (expectedNodeId !== undefined && source !== expectedNodeId))
1118
+ return false;
1119
+ const node = root.children?.find((child) => String(child.id) === source);
1120
+ return (
1121
+ getBooleanOption(node?.layoutOptions ?? {}, "insideSelfLoops.activate") === true &&
1122
+ getBooleanOption(edge.layoutOptions ?? {}, "insideSelfLoops.yo") === true
1123
+ );
1124
+ }
1125
+
1126
+ function applyInsideSelfLoops(
1127
+ root: ElkNode,
1128
+ baseHeightByNodeId: ReadonlyMap<string, number>,
1129
+ ): void {
1130
+ const indexByNodeId = new Map<string, number>();
1131
+ for (const edge of root.edges ?? []) {
1132
+ if (!isInsideSelfLoop(root, edge)) continue;
1133
+ const nodeId = String(edge.sources?.[0] ?? edge.source);
1134
+ const node = root.children?.find((child) => String(child.id) === nodeId);
1135
+ const baseHeight = baseHeightByNodeId.get(nodeId);
1136
+ if (!node || baseHeight === undefined) continue;
1137
+ const index = indexByNodeId.get(nodeId) ?? 0;
1138
+ indexByNodeId.set(nodeId, index + 1);
1139
+ const y = (node.y ?? 0) + baseHeight - 2 + index * 11;
1140
+ edge.sections = [
1141
+ {
1142
+ id: `${String(edge.id)}_s0`,
1143
+ startPoint: { x: node.x ?? 0, y },
1144
+ endPoint: { x: (node.x ?? 0) + (node.width ?? 0), y },
1145
+ incomingShape: edge.sources?.[0] ?? edge.source,
1146
+ outgoingShape: edge.targets?.[0] ?? edge.target,
1147
+ },
1148
+ ];
1149
+ }
1150
+ }
1151
+
1152
+ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>> = {}): Graph {
1153
+ const children = (root.children ?? []).filter(
1154
+ (child) => getBooleanOption(child.layoutOptions ?? {}, "noLayout") !== true,
1155
+ );
1156
+ const nodeIds = new Set(children.map((child) => String(child.id)));
1157
+ const portOwnerById = new Map<string, string>();
1158
+ for (const child of children) {
1159
+ for (const port of child.ports ?? []) {
1160
+ if (port.id !== undefined) portOwnerById.set(String(port.id), String(child.id));
1161
+ }
1162
+ }
1163
+ const sourcePortIds = new Set(
1164
+ (root.edges ?? []).flatMap((edge) =>
1165
+ (edge.sources ?? (edge.source === undefined ? [] : [edge.source])).map(String),
1166
+ ),
1167
+ );
1168
+ const targetPortIds = new Set(
1169
+ (root.edges ?? []).flatMap((edge) =>
1170
+ (edge.targets ?? (edge.target === undefined ? [] : [edge.target])).map(String),
1171
+ ),
1172
+ );
1173
+ const sourcePortDegree = new Map<string, number>();
1174
+ const targetPortDegree = new Map<string, number>();
1175
+ const edgeModelOrderByPortId = new Map<string, number>();
1176
+ for (const [edgeIndex, edge] of (root.edges ?? []).entries()) {
1177
+ for (const source of edge.sources ?? (edge.source === undefined ? [] : [edge.source])) {
1178
+ const id = String(source);
1179
+ sourcePortDegree.set(id, (sourcePortDegree.get(id) ?? 0) + 1);
1180
+ if (portOwnerById.has(id) && !edgeModelOrderByPortId.has(id)) {
1181
+ edgeModelOrderByPortId.set(id, edgeIndex);
1182
+ }
1183
+ }
1184
+ for (const target of edge.targets ?? (edge.target === undefined ? [] : [edge.target])) {
1185
+ const id = String(target);
1186
+ targetPortDegree.set(id, (targetPortDegree.get(id) ?? 0) + 1);
1187
+ if (portOwnerById.has(id) && !edgeModelOrderByPortId.has(id)) {
1188
+ edgeModelOrderByPortId.set(id, edgeIndex);
1189
+ }
1190
+ }
1191
+ }
1192
+ const orderedPorts = (child: ElkNode) => {
1193
+ const ports = [...(child.ports ?? [])];
1194
+ if (String(getOption(child.layoutOptions ?? {}, "portConstraints")) !== "FIXED_SIDE") {
1195
+ return ports;
1196
+ }
1197
+ const side = (port: ElkPort) => String(getOption(port.layoutOptions ?? {}, "port.side"));
1198
+ const sideOrder = ["NORTH", "EAST", "SOUTH", "WEST"];
1199
+ ports.sort((left, right) => {
1200
+ const leftSide = side(left);
1201
+ const rightSide = side(right);
1202
+ const sideDifference = sideOrder.indexOf(leftSide) - sideOrder.indexOf(rightSide);
1203
+ if (sideDifference !== 0) return sideDifference;
1204
+ if (String(getOption(globalOptions, "layered.portSortingStrategy")) === "PORT_DEGREE") {
1205
+ if (leftSide === "EAST") {
1206
+ return (
1207
+ (sourcePortDegree.get(String(right.id)) ?? 0) -
1208
+ (sourcePortDegree.get(String(left.id)) ?? 0)
1209
+ );
1210
+ }
1211
+ if (leftSide === "WEST") {
1212
+ return (
1213
+ (targetPortDegree.get(String(left.id)) ?? 0) -
1214
+ (targetPortDegree.get(String(right.id)) ?? 0)
1215
+ );
1216
+ }
1217
+ }
1218
+ const modelOrderStrategy = String(
1219
+ getOption(globalOptions, "layered.considerModelOrder.strategy") ?? "NONE",
1220
+ );
1221
+ const usePortModelOrder =
1222
+ getBooleanOption(globalOptions, "layered.considerModelOrder.portModelOrder") === true;
1223
+ if (modelOrderStrategy !== "NONE" && !usePortModelOrder) {
1224
+ const edgeOrderDifference =
1225
+ (edgeModelOrderByPortId.get(String(left.id)) ?? Infinity) -
1226
+ (edgeModelOrderByPortId.get(String(right.id)) ?? Infinity);
1227
+ if (edgeOrderDifference !== 0) return edgeOrderDifference;
1228
+ }
1229
+ const direction = leftSide === "WEST" ? -1 : 1;
1230
+ return direction * ((child.ports?.indexOf(left) ?? 0) - (child.ports?.indexOf(right) ?? 0));
1231
+ });
1232
+ return ports;
1233
+ };
1234
+
1235
+ return createGraph({
1236
+ id: String(root.id),
1237
+ nodes: children.map((child) => ({
1238
+ id: String(child.id),
1239
+ x: child.x,
1240
+ y: child.y,
1241
+ ...parsePosition(getOption(child.layoutOptions ?? {}, "position")),
1242
+ width: child.width,
1243
+ height: child.height,
1244
+ label: child.labels?.[0]?.text,
1245
+ ports: orderedPorts(child)
1246
+ .filter((port) => getBooleanOption(port.layoutOptions ?? {}, "noLayout") !== true)
1247
+ .map((port) => ({
1248
+ name: String(port.id),
1249
+ direction: sourcePortIds.has(String(port.id))
1250
+ ? targetPortIds.has(String(port.id))
1251
+ ? ("inout" as const)
1252
+ : ("out" as const)
1253
+ : targetPortIds.has(String(port.id))
1254
+ ? ("in" as const)
1255
+ : ("inout" as const),
1256
+ x: port.x,
1257
+ y: port.y,
1258
+ width: port.width,
1259
+ height: port.height,
1260
+ })),
1261
+ })),
1262
+ edges: (root.edges ?? []).flatMap((edge) => {
1263
+ if (getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true) return [];
1264
+ if (isInsideSelfLoop(root, edge)) return [];
1265
+ const source = endpoint(edge.sources?.[0] ?? edge.source, portOwnerById);
1266
+ const target = endpoint(edge.targets?.[0] ?? edge.target, portOwnerById);
1267
+ const labels = (edge.labels ?? []).filter(
1268
+ (label) =>
1269
+ Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1270
+ );
1271
+ const labelLabelSpacing = getNumberOption(globalOptions, "spacing.labelLabel") ?? 0;
1272
+ const labelWidth = Math.max(0, ...labels.map((label) => label.width ?? 0));
1273
+ const labelHeight =
1274
+ labels.reduce((sum, label) => sum + (label.height ?? 0), 0) +
1275
+ Math.max(0, labels.length - 1) * labelLabelSpacing;
1276
+ return nodeIds.has(source.nodeId) && nodeIds.has(target.nodeId)
1277
+ ? [
1278
+ {
1279
+ id: String(edge.id),
1280
+ sourceId: source.nodeId,
1281
+ targetId: target.nodeId,
1282
+ sourcePort: source.port,
1283
+ targetPort: target.port,
1284
+ label: edge.labels?.[0]?.text,
1285
+ width: labelWidth,
1286
+ height: labelHeight,
1287
+ points: parsePoints(getOption(edge.layoutOptions ?? {}, "bendPoints")),
1288
+ },
1289
+ ]
1290
+ : [];
1291
+ }),
1292
+ });
1293
+ }
1294
+
1295
+ function parsePoints(value: unknown): ElkPoint[] | undefined {
1296
+ if (typeof value !== "string") return undefined;
1297
+ const points = [...value.matchAll(/\{\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\}/g)].map(
1298
+ (match) => ({ x: Number(match[1]), y: Number(match[2]) }),
1299
+ );
1300
+ return points.length > 0 ? points : undefined;
1301
+ }
1302
+
1303
+ function parsePosition(value: unknown): ElkPoint | undefined {
1304
+ if (typeof value !== "string") return undefined;
1305
+ const match = value.match(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/);
1306
+ return match ? { x: Number(match[1]), y: Number(match[2]) } : undefined;
1307
+ }
1308
+
1309
+ function toSection(edge: ElkEdge, points: readonly ElkPoint[]) {
1310
+ const startPoint = points[0];
1311
+ const endPoint = points.at(-1);
1312
+ if (!startPoint || !endPoint) return undefined;
1313
+ return {
1314
+ id: `${String(edge.id)}_s0`,
1315
+ startPoint: { ...startPoint },
1316
+ endPoint: { ...endPoint },
1317
+ incomingShape: edge.sources?.[0] ?? edge.source,
1318
+ outgoingShape: edge.targets?.[0] ?? edge.target,
1319
+ ...(points.length > 2
1320
+ ? { bendPoints: points.slice(1, -1).map((point) => ({ ...point })) }
1321
+ : {}),
1322
+ };
1323
+ }
1324
+
1325
+ function applyLayout(
1326
+ root: ElkNode,
1327
+ graph: ReturnType<typeof getLayeredLayout>,
1328
+ padding: { top: number; right: number; bottom: number; left: number },
1329
+ layoutOptions: Readonly<Record<string, unknown>>,
1330
+ ): void {
1331
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
1332
+ const edgeById = new Map(graph.edges.map((edge) => [edge.id, edge]));
1333
+ for (const child of root.children ?? []) {
1334
+ const node = nodeById.get(String(child.id));
1335
+ if (!node) continue;
1336
+ child.x = node.x;
1337
+ child.y = node.y;
1338
+ child.width = node.width;
1339
+ child.height = node.height;
1340
+ placeNodeLabels(child, layoutOptions);
1341
+ for (const port of child.ports ?? []) {
1342
+ if (getBooleanOption(port.layoutOptions ?? {}, "noLayout") === true) continue;
1343
+ const laidOutPort = node.ports?.find((candidate) => candidate.name === String(port.id));
1344
+ if (!laidOutPort) continue;
1345
+ port.x = laidOutPort.x;
1346
+ port.y = laidOutPort.y;
1347
+ port.width = laidOutPort.width;
1348
+ port.height = laidOutPort.height;
1349
+ }
1350
+ placePortLabels(child, layoutOptions);
1351
+ }
1352
+ for (const edge of root.edges ?? []) {
1353
+ const laidOutEdge = edgeById.get(String(edge.id));
1354
+ if (!laidOutEdge) {
1355
+ if (getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true) {
1356
+ for (const section of edge.sections ?? []) {
1357
+ section.incomingShape ??= edge.sources?.[0] ?? edge.source;
1358
+ section.outgoingShape ??= edge.targets?.[0] ?? edge.target;
1359
+ }
1360
+ continue;
1361
+ }
1362
+ const section = getParentEdgeSection(root, edge);
1363
+ if (section) edge.sections = [section];
1364
+ continue;
1365
+ }
1366
+ const section = toSection(edge, laidOutEdge.points ?? []);
1367
+ edge.sections = section ? [section] : [];
1368
+ const target = root.children?.find(
1369
+ (child) => String(child.id) === String(edge.targets?.[0] ?? edge.target),
1370
+ );
1371
+ if (
1372
+ getBooleanOption(target?.layoutOptions ?? {}, "hypernode") === true &&
1373
+ (section?.bendPoints?.length ?? 0) > 0
1374
+ ) {
1375
+ edge.junctionPoints = [section!.bendPoints!.at(-1)!];
1376
+ }
1377
+ let labelY = laidOutEdge.y ?? 0;
1378
+ const points = laidOutEdge.points ?? [];
1379
+ const firstPoint = points[0] ?? { x: laidOutEdge.x ?? 0, y: laidOutEdge.y ?? 0 };
1380
+ const lastPoint = points.at(-1) ?? firstPoint;
1381
+ const midpointX = (laidOutEdge.x ?? 0) + (laidOutEdge.width ?? 0) / 2;
1382
+ const edgeLabelSpacing = getNumberOption(layoutOptions, "spacing.edgeLabel") ?? 2;
1383
+ const labelLabelSpacing = getNumberOption(layoutOptions, "spacing.labelLabel") ?? 0;
1384
+ for (const label of edge.labels ?? []) {
1385
+ if (getBooleanOption(label.layoutOptions ?? {}, "noLayout") === true) {
1386
+ label.x ??= 0;
1387
+ label.y ??= 0;
1388
+ continue;
1389
+ }
1390
+ if (!label.text) {
1391
+ label.x ??= 0;
1392
+ label.y ??= 0;
1393
+ continue;
1394
+ }
1395
+ const placement = String(
1396
+ getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER",
1397
+ );
1398
+ const width = label.width ?? 0;
1399
+ label.x =
1400
+ placement === "TAIL"
1401
+ ? firstPoint.x + edgeLabelSpacing
1402
+ : placement === "HEAD"
1403
+ ? lastPoint.x - width - edgeLabelSpacing
1404
+ : midpointX - width / 2;
1405
+ label.y = labelY;
1406
+ labelY += (label.height ?? 0) + labelLabelSpacing;
1407
+ }
1408
+ }
1409
+ normalizeElkGraphBounds(root, padding, layoutOptions);
1410
+ }
1411
+
1412
+ function normalizeElkGraphBounds(
1413
+ root: ElkNode,
1414
+ padding: { top: number; right: number; bottom: number; left: number },
1415
+ layoutOptions: Readonly<Record<string, unknown>> = {},
1416
+ ): void {
1417
+ const authoredWidth = root.width;
1418
+ const authoredHeight = root.height;
1419
+ const fixedGraphSize = getBooleanOption(layoutOptions, "nodeSize.fixedGraphSize") === true;
1420
+ const layoutChildren = (root.children ?? []).filter(
1421
+ (node) => getBooleanOption(node.layoutOptions ?? {}, "noLayout") !== true,
1422
+ );
1423
+ let minimumX = Number.POSITIVE_INFINITY;
1424
+ let minimumY = Number.POSITIVE_INFINITY;
1425
+ for (const node of layoutChildren) {
1426
+ minimumX = Math.min(
1427
+ minimumX,
1428
+ node.x ?? 0,
1429
+ ...(node.labels ?? []).map((label) => (node.x ?? 0) + (label.x ?? 0)),
1430
+ ...(node.ports ?? []).map((port) => (node.x ?? 0) + (port.x ?? 0)),
1431
+ ...(node.ports ?? []).flatMap((port) =>
1432
+ (port.labels ?? []).map((label) => (node.x ?? 0) + (port.x ?? 0) + (label.x ?? 0)),
1433
+ ),
1434
+ );
1435
+ minimumY = Math.min(
1436
+ minimumY,
1437
+ node.y ?? 0,
1438
+ ...(node.labels ?? []).map((label) => (node.y ?? 0) + (label.y ?? 0)),
1439
+ ...(node.ports ?? []).map((port) => (node.y ?? 0) + (port.y ?? 0)),
1440
+ ...(node.ports ?? []).flatMap((port) =>
1441
+ (port.labels ?? []).map((label) => (node.y ?? 0) + (port.y ?? 0) + (label.y ?? 0)),
1442
+ ),
1443
+ );
1444
+ }
1445
+ for (const edge of root.edges ?? []) {
1446
+ const laidOutLabels = (edge.labels ?? []).filter(
1447
+ (label) =>
1448
+ Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1449
+ );
1450
+ minimumX = Math.min(minimumX, ...laidOutLabels.map((label) => label.x ?? 0));
1451
+ minimumY = Math.min(minimumY, ...laidOutLabels.map((label) => label.y ?? 0));
1452
+ if (getBooleanOption(edge.layoutOptions ?? {}, "noLayout") !== true) {
1453
+ const points = (edge.sections ?? []).flatMap((section) => [
1454
+ section.startPoint,
1455
+ ...(section.bendPoints ?? []),
1456
+ section.endPoint,
1457
+ ]);
1458
+ minimumX = Math.min(minimumX, ...points.map((point) => point.x));
1459
+ minimumY = Math.min(minimumY, ...points.map((point) => point.y));
1460
+ }
1461
+ }
1462
+ const shiftX = Number.isFinite(minimumX) ? Math.max(0, padding.left - minimumX) : 0;
1463
+ const shiftY = Number.isFinite(minimumY) ? Math.max(0, padding.top - minimumY) : 0;
1464
+ if (shiftX !== 0 || shiftY !== 0) {
1465
+ for (const node of layoutChildren) {
1466
+ node.x = (node.x ?? 0) + shiftX;
1467
+ node.y = (node.y ?? 0) + shiftY;
1468
+ }
1469
+ for (const edge of root.edges ?? []) {
1470
+ for (const section of edge.sections ?? []) {
1471
+ for (const point of [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]) {
1472
+ point.x += shiftX;
1473
+ point.y += shiftY;
1474
+ }
1475
+ }
1476
+ for (const label of (edge.labels ?? []).filter((candidate) => Boolean(candidate.text))) {
1477
+ label.x = (label.x ?? 0) + shiftX;
1478
+ label.y = (label.y ?? 0) + shiftY;
1479
+ }
1480
+ }
1481
+ }
1482
+ const maximumNodeX = Math.max(
1483
+ 0,
1484
+ ...layoutChildren.map((node) => (node.x ?? 0) + (node.width ?? 0)),
1485
+ );
1486
+ const maximumNodeY = Math.max(
1487
+ 0,
1488
+ ...layoutChildren.map((node) => (node.y ?? 0) + (node.height ?? 0)),
1489
+ );
1490
+ const layoutEdgePoints = (root.edges ?? []).flatMap((edge) =>
1491
+ getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1492
+ ? []
1493
+ : (edge.sections ?? []).flatMap((section) => [
1494
+ section.startPoint,
1495
+ ...(section.bendPoints ?? []),
1496
+ section.endPoint,
1497
+ ]),
1498
+ );
1499
+ const wrappingStrategy = String(getOption(layoutOptions, "layered.wrapping.strategy") ?? "OFF");
1500
+ const direction = getDirection(layoutOptions);
1501
+ const laidOutChildById = new Map((root.children ?? []).map((child) => [String(child.id), child]));
1502
+ const childByEndpointId = new Map(laidOutChildById);
1503
+ for (const child of root.children ?? []) {
1504
+ for (const port of child.ports ?? []) childByEndpointId.set(String(port.id), child);
1505
+ }
1506
+ const wrappedEdgeCount = (root.edges ?? []).filter((edge) => {
1507
+ const source = childByEndpointId.get(String(edge.sources?.[0] ?? edge.source));
1508
+ const target = childByEndpointId.get(String(edge.targets?.[0] ?? edge.target));
1509
+ if (!source || !target) return false;
1510
+ return direction === "right"
1511
+ ? (source.x ?? 0) > (target.x ?? 0)
1512
+ : direction === "left"
1513
+ ? (source.x ?? 0) < (target.x ?? 0)
1514
+ : direction === "down"
1515
+ ? (source.y ?? 0) > (target.y ?? 0)
1516
+ : (source.y ?? 0) < (target.y ?? 0);
1517
+ }).length;
1518
+ const hasWrappedEdge = wrappedEdgeCount > 0;
1519
+ const addBoundaryPixel =
1520
+ wrappingStrategy !== "MULTI_EDGE" &&
1521
+ !(wrappingStrategy === "SINGLE_EDGE" && hasWrappedEdge) &&
1522
+ String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") ===
1523
+ "NONE" &&
1524
+ getBooleanOption(layoutOptions, "layered.feedbackEdges") !== true &&
1525
+ String(getOption(layoutOptions, "layered.layering.nodePromotion.strategy") ?? "NONE") !==
1526
+ "MODEL_ORDER_LEFT_TO_RIGHT" &&
1527
+ !(root.edges ?? []).some((edge) => edge.sources?.[0] === edge.targets?.[0]) &&
1528
+ !(root.children ?? []).some((child) => (child.children?.length ?? 0) > 0);
1529
+ const edgeBoundsExtraX =
1530
+ addBoundaryPixel &&
1531
+ layoutEdgePoints.length > 0 &&
1532
+ Math.max(...layoutEdgePoints.map((point) => point.x)) >= maximumNodeX - 1e-9
1533
+ ? 1
1534
+ : 0;
1535
+ const edgeBoundsExtraY =
1536
+ addBoundaryPixel &&
1537
+ layoutEdgePoints.length > 0 &&
1538
+ Math.max(...layoutEdgePoints.map((point) => point.y)) >= maximumNodeY - 1e-9
1539
+ ? 1
1540
+ : 0;
1541
+ const singleMultiEdgeCutBoundsExtraY =
1542
+ wrappingStrategy === "MULTI_EDGE" &&
1543
+ wrappedEdgeCount === 1 &&
1544
+ layoutEdgePoints.length > 0 &&
1545
+ Math.max(...layoutEdgePoints.map((point) => point.y)) >= maximumNodeY - 1e-9
1546
+ ? 1
1547
+ : 0;
1548
+ const postCompactionBoundsExtraX =
1549
+ !addBoundaryPixel &&
1550
+ !hasWrappedEdge &&
1551
+ String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") !==
1552
+ "NONE" &&
1553
+ layoutEdgePoints.length > 0 &&
1554
+ Math.max(...layoutEdgePoints.map((point) => point.x)) > maximumNodeX + 1e-9
1555
+ ? 0.04
1556
+ : 0;
1557
+ const postCompactionBoundsExtraY =
1558
+ !addBoundaryPixel &&
1559
+ !hasWrappedEdge &&
1560
+ String(getOption(layoutOptions, "layered.compaction.postCompaction.strategy") ?? "NONE") !==
1561
+ "NONE" &&
1562
+ layoutEdgePoints.length > 0 &&
1563
+ Math.max(...layoutEdgePoints.map((point) => point.y)) > maximumNodeY + 1e-9
1564
+ ? 0.04
1565
+ : 0;
1566
+ const calculatedWidth =
1567
+ Math.max(
1568
+ 0,
1569
+ ...layoutChildren.flatMap((node) => [
1570
+ (node.x ?? 0) + (node.width ?? 0),
1571
+ ...(node.labels ?? []).map((label) => (node.x ?? 0) + (label.x ?? 0) + (label.width ?? 0)),
1572
+ ...(node.ports ?? []).map((port) => (node.x ?? 0) + (port.x ?? 0) + (port.width ?? 0)),
1573
+ ...(node.ports ?? []).flatMap((port) =>
1574
+ (port.labels ?? []).map(
1575
+ (label) => (node.x ?? 0) + (port.x ?? 0) + (label.x ?? 0) + (label.width ?? 0),
1576
+ ),
1577
+ ),
1578
+ ]),
1579
+ ...(root.edges ?? []).flatMap((edge) =>
1580
+ (edge.labels ?? [])
1581
+ .filter(
1582
+ (label) =>
1583
+ Boolean(label.text) &&
1584
+ getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1585
+ )
1586
+ .map((label) => (label.x ?? 0) + (label.width ?? 0)),
1587
+ ),
1588
+ ...(root.edges ?? []).flatMap((edge) =>
1589
+ getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1590
+ ? []
1591
+ : (edge.sections ?? []).flatMap((section) => [
1592
+ section.startPoint.x,
1593
+ ...(section.bendPoints ?? []).map((point) => point.x),
1594
+ section.endPoint.x,
1595
+ ]),
1596
+ ),
1597
+ ) +
1598
+ padding.right +
1599
+ edgeBoundsExtraX +
1600
+ postCompactionBoundsExtraX +
1601
+ (getBooleanOption(layoutOptions, "layered.feedbackEdges") === true &&
1602
+ (getDirection(layoutOptions) === "down" || getDirection(layoutOptions) === "up")
1603
+ ? 1
1604
+ : 0);
1605
+ const calculatedHeight =
1606
+ Math.max(
1607
+ 0,
1608
+ ...layoutChildren.flatMap((node) => [
1609
+ (node.y ?? 0) + (node.height ?? 0),
1610
+ ...(node.labels ?? []).map((label) => (node.y ?? 0) + (label.y ?? 0) + (label.height ?? 0)),
1611
+ ...(node.ports ?? []).map((port) => (node.y ?? 0) + (port.y ?? 0) + (port.height ?? 0)),
1612
+ ...(node.ports ?? []).flatMap((port) =>
1613
+ (port.labels ?? []).map(
1614
+ (label) => (node.y ?? 0) + (port.y ?? 0) + (label.y ?? 0) + (label.height ?? 0),
1615
+ ),
1616
+ ),
1617
+ ]),
1618
+ ...(root.edges ?? []).flatMap((edge) =>
1619
+ (edge.labels ?? [])
1620
+ .filter(
1621
+ (label) =>
1622
+ Boolean(label.text) &&
1623
+ getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1624
+ )
1625
+ .map(
1626
+ (label) =>
1627
+ (label.y ?? 0) +
1628
+ (label.height ?? 0) +
1629
+ (String(getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER") ===
1630
+ "CENTER"
1631
+ ? 1
1632
+ : 0),
1633
+ ),
1634
+ ),
1635
+ ...(root.edges ?? []).flatMap((edge) =>
1636
+ getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true
1637
+ ? []
1638
+ : (edge.sections ?? []).flatMap((section) => [
1639
+ section.startPoint.y,
1640
+ ...(section.bendPoints ?? []).map((point) => point.y),
1641
+ section.endPoint.y,
1642
+ ]),
1643
+ ),
1644
+ ) +
1645
+ padding.bottom +
1646
+ edgeBoundsExtraY +
1647
+ singleMultiEdgeCutBoundsExtraY +
1648
+ postCompactionBoundsExtraY +
1649
+ (getBooleanOption(layoutOptions, "layered.feedbackEdges") === true &&
1650
+ (getDirection(layoutOptions) === "right" || getDirection(layoutOptions) === "left")
1651
+ ? 1
1652
+ : 0);
1653
+ root.width = fixedGraphSize ? (authoredWidth ?? 0) : calculatedWidth;
1654
+ root.height = fixedGraphSize ? (authoredHeight ?? 0) : calculatedHeight;
1655
+ }
1656
+
1657
+ function getParentEdgeSection(root: ElkNode, edge: ElkEdge) {
1658
+ const sourceId = String(edge.sources?.[0] ?? edge.source);
1659
+ const targetId = String(edge.targets?.[0] ?? edge.target);
1660
+ const rootId = String(root.id);
1661
+ const source = root.children?.find((child) => String(child.id) === sourceId);
1662
+ const target = root.children?.find((child) => String(child.id) === targetId);
1663
+ if (source && targetId === rootId) {
1664
+ const startPoint = {
1665
+ x: (source.x ?? 0) + (source.width ?? 0) / 2,
1666
+ y: (source.y ?? 0) + (source.height ?? 0),
1667
+ };
1668
+ return {
1669
+ id: `${String(edge.id)}_s0`,
1670
+ startPoint,
1671
+ endPoint: { x: startPoint.x, y: 0 },
1672
+ };
1673
+ }
1674
+ if (sourceId === rootId && target) {
1675
+ const endPoint = {
1676
+ x: (target.x ?? 0) + (target.width ?? 0) / 2,
1677
+ y: target.y ?? 0,
1678
+ };
1679
+ return {
1680
+ id: `${String(edge.id)}_s0`,
1681
+ startPoint: { x: endPoint.x, y: 0 },
1682
+ endPoint,
1683
+ };
1684
+ }
1685
+ return undefined;
1686
+ }
1687
+
1688
+ function placeNodeLabels(node: ElkNode, globalOptions: Readonly<Record<string, unknown>>): void {
1689
+ const labelPadding = parsePadding(getOption(globalOptions, "nodeLabels.padding"), 5);
1690
+ for (const label of node.labels ?? []) {
1691
+ if (!label.text) continue;
1692
+ if (getBooleanOption(label.layoutOptions ?? {}, "noLayout") === true) continue;
1693
+ const placement = String(
1694
+ getOption(
1695
+ { ...globalOptions, ...node.layoutOptions, ...label.layoutOptions },
1696
+ "nodeLabels.placement",
1697
+ ) ?? "",
1698
+ );
1699
+ if (!placement) continue;
1700
+ const width = label.width ?? 0;
1701
+ const height = label.height ?? 0;
1702
+ const nodeWidth = node.width ?? 0;
1703
+ const nodeHeight = node.height ?? 0;
1704
+ label.x = placement.includes("H_CENTER")
1705
+ ? (nodeWidth - width + labelPadding.left - labelPadding.right) / 2
1706
+ : placement.includes("H_RIGHT")
1707
+ ? nodeWidth - width - labelPadding.right
1708
+ : labelPadding.left;
1709
+ if (placement.includes("OUTSIDE") && placement.includes("V_TOP")) {
1710
+ label.y = -height - Number(getOption(globalOptions, "spacing.labelNode") ?? 5);
1711
+ } else if (placement.includes("OUTSIDE") && placement.includes("V_BOTTOM")) {
1712
+ label.y = nodeHeight + Number(getOption(globalOptions, "spacing.labelNode") ?? 5);
1713
+ } else if (placement.includes("V_CENTER")) {
1714
+ label.y = (nodeHeight - height + labelPadding.top - labelPadding.bottom) / 2;
1715
+ } else if (placement.includes("V_BOTTOM")) {
1716
+ label.y = nodeHeight - height - labelPadding.bottom;
1717
+ } else {
1718
+ label.y = labelPadding.top;
1719
+ }
1720
+ }
1721
+ }
1722
+
1723
+ function placePortLabels(node: ElkNode, globalOptions: Readonly<Record<string, unknown>>): void {
1724
+ const placement = String(
1725
+ getOption({ ...globalOptions, ...node.layoutOptions }, "portLabels.placement") ?? "OUTSIDE",
1726
+ );
1727
+ const inside = placement.includes("INSIDE");
1728
+ const alwaysOtherSide = placement.includes("ALWAYS_OTHER_SAME_SIDE");
1729
+ const spaceEfficient =
1730
+ placement.includes("SPACE_EFFICIENT") ||
1731
+ String(getOption(node.layoutOptions ?? {}, "nodeSize.options") ?? "").includes(
1732
+ "SPACE_EFFICIENT_PORT_LABELS",
1733
+ );
1734
+ const horizontalSpacing = getNumberOption(globalOptions, "spacing.labelPortHorizontal") ?? 1;
1735
+ const verticalSpacing = getNumberOption(globalOptions, "spacing.labelPortVertical") ?? 1;
1736
+ const labelSpacing = getNumberOption(globalOptions, "spacing.labelLabel") ?? 0;
1737
+ const treatAsGroup =
1738
+ getBooleanOption(node.layoutOptions ?? {}, "portLabels.treatAsGroup") ?? false;
1739
+ const placeNextToPort =
1740
+ placement.includes("NEXT_TO_PORT_IF_POSSIBLE") ||
1741
+ getBooleanOption(node.layoutOptions ?? {}, "portLabels.nextToPortIfPossible") === true;
1742
+ const nodeWidth = node.width ?? 0;
1743
+ for (const port of node.ports ?? []) {
1744
+ if (getBooleanOption(port.layoutOptions ?? {}, "noLayout") === true) continue;
1745
+ const portWidth = port.width ?? 0;
1746
+ const portHeight = port.height ?? 0;
1747
+ const side =
1748
+ (port.x ?? 0) < 0
1749
+ ? "WEST"
1750
+ : (port.x ?? 0) >= nodeWidth
1751
+ ? "EAST"
1752
+ : (port.y ?? 0) < 0
1753
+ ? "NORTH"
1754
+ : "SOUTH";
1755
+ const portsOnSide = (node.ports ?? []).filter((candidate) => {
1756
+ const candidateSide =
1757
+ (candidate.x ?? 0) < 0
1758
+ ? "WEST"
1759
+ : (candidate.x ?? 0) >= nodeWidth
1760
+ ? "EAST"
1761
+ : (candidate.y ?? 0) < 0
1762
+ ? "NORTH"
1763
+ : "SOUTH";
1764
+ return candidateSide === side;
1765
+ });
1766
+ const labels = (port.labels ?? []).filter(
1767
+ (label) =>
1768
+ Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1769
+ );
1770
+ const totalLabelHeight =
1771
+ labels.reduce((sum, label) => sum + (label.height ?? 0), 0) +
1772
+ Math.max(0, labels.length - 1) * labelSpacing;
1773
+ let stackedY =
1774
+ labels.length > 1
1775
+ ? inside || placeNextToPort
1776
+ ? treatAsGroup
1777
+ ? (portHeight - totalLabelHeight) / 2
1778
+ : (portHeight - (labels[0]?.height ?? 0)) / 2
1779
+ : portHeight + verticalSpacing
1780
+ : undefined;
1781
+ for (const label of labels) {
1782
+ if (!label.text) continue;
1783
+ const width = label.width ?? 0;
1784
+ const height = label.height ?? 0;
1785
+ if (side === "EAST") {
1786
+ label.x = inside ? -width - horizontalSpacing : portWidth + horizontalSpacing;
1787
+ label.y =
1788
+ stackedY ??
1789
+ (inside || placeNextToPort
1790
+ ? (portHeight - height) / 2
1791
+ : alwaysOtherSide
1792
+ ? -height - verticalSpacing
1793
+ : portHeight + verticalSpacing);
1794
+ } else if (side === "WEST") {
1795
+ label.x = inside ? portWidth + horizontalSpacing : -width - horizontalSpacing;
1796
+ label.y =
1797
+ stackedY ??
1798
+ (inside || placeNextToPort
1799
+ ? (portHeight - height) / 2
1800
+ : alwaysOtherSide
1801
+ ? -height - verticalSpacing
1802
+ : portHeight + verticalSpacing);
1803
+ } else if (side === "NORTH") {
1804
+ label.x =
1805
+ inside || placeNextToPort
1806
+ ? (portWidth - width) / 2
1807
+ : alwaysOtherSide
1808
+ ? -width - horizontalSpacing
1809
+ : portWidth + horizontalSpacing;
1810
+ label.y = inside ? portHeight + verticalSpacing : -height - verticalSpacing;
1811
+ } else {
1812
+ label.x =
1813
+ inside || placeNextToPort
1814
+ ? (portWidth - width) / 2
1815
+ : alwaysOtherSide
1816
+ ? -width - horizontalSpacing
1817
+ : portWidth + horizontalSpacing;
1818
+ label.y = inside ? -height - verticalSpacing : portHeight + verticalSpacing;
1819
+ }
1820
+ if (
1821
+ spaceEfficient &&
1822
+ portsOnSide[0] === port &&
1823
+ portsOnSide.length >= 2 &&
1824
+ !placeNextToPort
1825
+ ) {
1826
+ if (side === "EAST" || side === "WEST") label.y = -height - verticalSpacing;
1827
+ else label.x = -width - horizontalSpacing;
1828
+ }
1829
+ if (stackedY !== undefined) stackedY += height + labelSpacing;
1830
+ }
1831
+ }
1832
+ }