@statelyai/layout 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +71 -2
  2. package/dist/elkjs/index.cjs +3 -2
  3. package/dist/elkjs/index.d.cts +11 -2
  4. package/dist/elkjs/index.d.mts +11 -2
  5. package/dist/elkjs/index.mjs +3 -3
  6. package/dist/elkjs/main.cjs +2 -2
  7. package/dist/elkjs/main.d.cts +2 -2
  8. package/dist/elkjs/main.d.mts +2 -2
  9. package/dist/elkjs/main.mjs +2 -2
  10. package/dist/elkjs/worker-api.cjs +1 -1
  11. package/dist/elkjs/worker-api.d.cts +2 -2
  12. package/dist/elkjs/worker-api.d.mts +2 -2
  13. package/dist/elkjs/worker-api.mjs +1 -1
  14. package/dist/elkjs/worker.cjs +2 -2
  15. package/dist/elkjs/worker.mjs +2 -2
  16. package/dist/{elkjs-CFlfkEBz.mjs → elkjs-DaHMISnQ.mjs} +152 -3
  17. package/dist/{elkjs-BVnDwnBQ.cjs → elkjs-dMghiiWW.cjs} +157 -2
  18. package/dist/index-BEA_pzeT.d.cts +72 -0
  19. package/dist/{index-BSH2bloA.d.mts → index-D9dhxrRl.d.cts} +35 -3
  20. package/dist/index-DNTLRsDl.d.mts +72 -0
  21. package/dist/{index-CG712jzV.d.cts → index-DNixtE3h.d.mts} +35 -3
  22. package/dist/index.cjs +36 -9
  23. package/dist/index.d.cts +4 -3
  24. package/dist/index.d.mts +4 -3
  25. package/dist/index.mjs +4 -3
  26. package/dist/layered/index.cjs +1 -1
  27. package/dist/layered/index.d.cts +3 -2
  28. package/dist/layered/index.d.mts +3 -2
  29. package/dist/layered/index.mjs +1 -1
  30. package/dist/{layered-Cf9Rn8l7.cjs → layered-B2buJqMf.cjs} +417 -469
  31. package/dist/{layered-Bz2nWmVp.mjs → layered-DGFTeQSl.mjs} +391 -431
  32. package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-Cs5tatA6.mjs} +1 -1
  33. package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-Dn1PiFwP.cjs} +1 -1
  34. package/dist/routing/index.cjs +28 -0
  35. package/dist/routing/index.d.cts +3 -0
  36. package/dist/routing/index.d.mts +3 -0
  37. package/dist/routing/index.mjs +3 -0
  38. package/dist/routing-A2hrK9MP.mjs +2396 -0
  39. package/dist/routing-ujEP7Qh6.cjs +2606 -0
  40. package/dist/types-CT2akdK0.d.mts +133 -0
  41. package/dist/types-uxPlGRof.d.cts +133 -0
  42. package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
  43. package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
  44. package/package.json +7 -2
  45. package/src/authoring/coordinates.ts +10 -2
  46. package/src/elkjs/index.ts +5 -3
  47. package/src/elkjs/routes.ts +147 -0
  48. package/src/index.ts +6 -0
  49. package/src/layered/compound.ts +417 -0
  50. package/src/layered/index.ts +27 -102
  51. package/src/layered/types.ts +21 -0
  52. package/src/routing/adapters.ts +111 -0
  53. package/src/routing/algorithms.ts +676 -0
  54. package/src/routing/coordination.ts +86 -0
  55. package/src/routing/engine.ts +473 -0
  56. package/src/routing/index.ts +68 -0
  57. package/src/routing/layout-cache.ts +65 -0
  58. package/src/routing/model.ts +218 -0
  59. package/src/routing/path.ts +324 -0
  60. package/src/routing/persistent.ts +117 -0
  61. package/src/routing/search.ts +276 -0
  62. package/src/routing/spatial.ts +78 -0
  63. package/src/routing/types.ts +146 -0
  64. /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
  65. /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
  66. /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
  67. /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
@@ -0,0 +1,417 @@
1
+ import { orthogonalRouting, routeToPolylines } from "../routing";
2
+ import { recordRouteGeometry } from "../routing/layout-cache";
3
+ import type { EdgeRoutingSettings, Route } from "../routing/types";
4
+ import type {
5
+ EntityRect,
6
+ Graph,
7
+ GraphEdge,
8
+ Point,
9
+ VisualGraph,
10
+ VisualNode,
11
+ } from "@statelyai/graph";
12
+ import type { CompoundLayoutGeometry, LayeredLayoutOptions, LayoutPadding } from "./types";
13
+
14
+ export type CompoundVisualGraph<N = unknown, E = unknown, G = unknown, P = unknown> = VisualGraph<
15
+ N,
16
+ E,
17
+ G,
18
+ P
19
+ > & {
20
+ /** Nodes remain parent-relative; edge rectangles and points are world-space. */
21
+ readonly edgeCoordinateSpace?: "world";
22
+ readonly compoundGeometry: ReadonlyMap<string, CompoundLayoutGeometry>;
23
+ /** World-space route sections preserve label gaps and fallback diagnostics. */
24
+ readonly compoundRoutes: ReadonlyMap<string, Route>;
25
+ };
26
+
27
+ const paddingOf = (value: number | Partial<LayoutPadding> | undefined): LayoutPadding => ({
28
+ top: typeof value === "number" ? value : (value?.top ?? 12),
29
+ right: typeof value === "number" ? value : (value?.right ?? 12),
30
+ bottom: typeof value === "number" ? value : (value?.bottom ?? 12),
31
+ left: typeof value === "number" ? value : (value?.left ?? 12),
32
+ });
33
+
34
+ /** Solve each scope's occupied envelope bottom-up; never repair returned sibling positions. */
35
+ export function layoutCompounds<N, E, G, P>(
36
+ graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
37
+ options: LayeredLayoutOptions,
38
+ flat: (graph: Graph<N, E, G, P>, options: LayeredLayoutOptions) => VisualGraph<N, E, G, P>,
39
+ ): CompoundVisualGraph<N, E, G, P> {
40
+ const nodes = graph.nodes.map((node) => {
41
+ const size = options.measure?.(node);
42
+ return {
43
+ ...node,
44
+ width: size?.width ?? node.width ?? 0,
45
+ height: size?.height ?? node.height ?? 0,
46
+ };
47
+ }) as VisualNode<N, P>[];
48
+ const byId = new Map(nodes.map((node) => [node.id, node]));
49
+ const ancestry = (id: string): (string | null)[] => {
50
+ const chain: (string | null)[] = [];
51
+ let current: string | null = id;
52
+ while (current != null) {
53
+ if (chain.includes(current)) throw new Error(`Cyclic compound hierarchy at ${current}`);
54
+ chain.push(current);
55
+ current = byId.get(current)?.parentId ?? null;
56
+ }
57
+ return [...chain, null];
58
+ };
59
+ const branch = (id: string, scope: string | null): string => {
60
+ const chain = ancestry(id);
61
+ return chain[chain.indexOf(scope) - 1] as string;
62
+ };
63
+ const owner = (edge: GraphEdge): string | null => {
64
+ const a = ancestry(edge.sourceId),
65
+ b = ancestry(edge.targetId);
66
+ if (edge.sourceId !== edge.targetId && b.includes(edge.sourceId)) return edge.sourceId;
67
+ if (edge.sourceId !== edge.targetId && a.includes(edge.targetId)) return edge.targetId;
68
+ return a.slice(1).find((id) => b.slice(1).includes(id)) ?? null;
69
+ };
70
+ const owned = new Map<string | null, GraphEdge<E>[]>();
71
+ for (const edge of graph.edges) {
72
+ const scope = owner(edge);
73
+ owned.set(scope, [...(owned.get(scope) ?? []), edge]);
74
+ }
75
+ const edges = new Map(
76
+ graph.edges.map((e) => [
77
+ e.id,
78
+ { ...e, x: 0, y: 0, width: e.width ?? 0, height: e.height ?? 0 },
79
+ ]),
80
+ );
81
+ const localLabels = new Map<string, { scope: string | null; rect: EntityRect }>();
82
+ const compoundGeometry = new Map<string, CompoundLayoutGeometry>();
83
+ const childScopes = [
84
+ ...new Set(nodes.flatMap((n) => (n.parentId == null ? [] : [n.parentId]))),
85
+ ].sort((a, b) => ancestry(b).length - ancestry(a).length);
86
+
87
+ const solve = (scope: string | null) => {
88
+ const children = nodes.filter((n) => (n.parentId ?? null) === scope);
89
+ if (!children.length) return;
90
+ const container = scope == null ? undefined : byId.get(scope);
91
+ const config = container && options.compound?.(container);
92
+ const direction = config?.direction ?? options.direction ?? graph.direction ?? "right";
93
+ const horizontal = direction === "left" || direction === "right";
94
+ const padding = paddingOf(config?.padding ?? options.padding);
95
+ const insets = { ...padding };
96
+ const header = config?.header;
97
+ const side = header?.side ?? "top";
98
+ if (header) insets[side] += side === "top" || side === "bottom" ? header.height : header.width;
99
+ const scopeEdges = owned.get(scope) ?? [];
100
+ const boundary = new Map<string, GraphEdge<E>[]>();
101
+ const projected: GraphEdge<E>[] = [];
102
+ for (const edge of scopeEdges) {
103
+ if (edge.sourceId === scope || edge.targetId === scope) {
104
+ const id = branch(edge.sourceId === scope ? edge.targetId : edge.sourceId, scope);
105
+ boundary.set(id, [...(boundary.get(id) ?? []), edge]);
106
+ } else {
107
+ projected.push({
108
+ ...edge,
109
+ sourceId: branch(edge.sourceId, scope),
110
+ targetId: branch(edge.targetId, scope),
111
+ points: undefined,
112
+ });
113
+ }
114
+ }
115
+ const gap = options.settings?.["spacing.labelNode"] ?? options.spacing?.node ?? 20;
116
+ const labelGap = options.settings?.["spacing.labelLabel"] ?? 8;
117
+ const sizes = new Map(
118
+ children.map((n) => {
119
+ const labels = boundary.get(n.id) ?? [];
120
+ const labelWidth = Math.max(0, ...labels.map((e) => e.width ?? 0));
121
+ const labelHeight =
122
+ labels.reduce((sum, e) => sum + (e.height ?? 0), 0) +
123
+ Math.max(0, labels.length - 1) * labelGap;
124
+ return [
125
+ n.id,
126
+ {
127
+ width:
128
+ labels.length && !horizontal
129
+ ? n.width + gap + labelWidth
130
+ : Math.max(n.width, labelWidth),
131
+ height:
132
+ labels.length && horizontal
133
+ ? n.height + gap + labelHeight
134
+ : Math.max(n.height, labelHeight),
135
+ },
136
+ ];
137
+ }),
138
+ );
139
+ // A label is an occupied vertex in the scope's placement problem, including
140
+ // adjacent-layer and feedback edges. It cannot be added after node placement.
141
+ const labels = projected.filter((edge) => (edge.width ?? 0) > 0 && (edge.height ?? 0) > 0);
142
+ const used = new Set([...graph.nodes, ...graph.edges].map((entity) => entity.id));
143
+ const labelIds = new Map<string, string>();
144
+ for (const edge of labels) {
145
+ let id = `__layout_label_${edge.id}`;
146
+ while ([id, `${id}_source`, `${id}_target`].some((key) => used.has(key))) id += "_";
147
+ for (const key of [id, `${id}_source`, `${id}_target`]) used.add(key);
148
+ labelIds.set(edge.id, id);
149
+ sizes.set(id, { width: edge.width!, height: edge.height! });
150
+ }
151
+ const originalEdges = new Map(graph.edges.map((edge) => [edge.id, edge]));
152
+ for (const edge of labels) {
153
+ originalEdges.set(`${labelIds.get(edge.id)}_source`, edges.get(edge.id)!);
154
+ originalEdges.set(`${labelIds.get(edge.id)}_target`, edges.get(edge.id)!);
155
+ }
156
+ // Keep each label between its endpoint representatives in model order.
157
+ // Appending labels would reverse every label->target leg under MODEL_ORDER
158
+ // and collapse a forward chain into one layer of siblings.
159
+ const placementOrder = new Map(children.map((node, index) => [node.id, index]));
160
+ for (const edge of labels)
161
+ placementOrder.set(
162
+ labelIds.get(edge.id)!,
163
+ ((placementOrder.get(edge.sourceId) ?? 0) + (placementOrder.get(edge.targetId) ?? 0)) / 2 +
164
+ (edge.sourceId === edge.targetId ? 0.25 : 0),
165
+ );
166
+ const result = flat(
167
+ {
168
+ ...graph,
169
+ nodes: [
170
+ ...children.map((n) => ({ ...n, parentId: null })),
171
+ ...labels.map((edge) => ({
172
+ type: "node" as const,
173
+ id: labelIds.get(edge.id)!,
174
+ data: undefined as N,
175
+ })),
176
+ ].sort((a, b) => placementOrder.get(a.id)! - placementOrder.get(b.id)!),
177
+ edges: projected.flatMap((edge) => {
178
+ const labelId = labelIds.get(edge.id);
179
+ return labelId
180
+ ? [
181
+ {
182
+ ...edge,
183
+ id: `${labelId}_source`,
184
+ targetId: labelId,
185
+ targetPort: undefined,
186
+ width: 0,
187
+ height: 0,
188
+ },
189
+ {
190
+ ...edge,
191
+ id: `${labelId}_target`,
192
+ sourceId: labelId,
193
+ sourcePort: undefined,
194
+ width: 0,
195
+ height: 0,
196
+ },
197
+ ]
198
+ : [edge];
199
+ }),
200
+ },
201
+ {
202
+ ...options,
203
+ compound: undefined,
204
+ direction,
205
+ padding: insets,
206
+ measure: (node) => sizes.get(node.id)!,
207
+ nodeSettings: (node) => {
208
+ const original = byId.get(node.id);
209
+ return original ? options.nodeSettings?.(original) : undefined;
210
+ },
211
+ edgeSettings: (edge) => options.edgeSettings?.(originalEdges.get(edge.id) ?? edge),
212
+ },
213
+ );
214
+ for (const placed of result.nodes) {
215
+ const node = byId.get(placed.id);
216
+ if (node) Object.assign(node, { x: placed.x, y: placed.y, ports: placed.ports });
217
+ }
218
+ for (const edge of projected) {
219
+ const labelId = labelIds.get(edge.id);
220
+ if (labelId) {
221
+ const label = result.nodes.find((n) => n.id === labelId)!;
222
+ localLabels.set(edge.id, {
223
+ scope,
224
+ rect: { x: label.x, y: label.y, width: label.width, height: label.height },
225
+ });
226
+ }
227
+ }
228
+ for (const [id, labels] of boundary) {
229
+ const node = byId.get(id)!;
230
+ const totalHeight =
231
+ labels.reduce((sum, e) => sum + (e.height ?? 0), 0) +
232
+ Math.max(0, labels.length - 1) * labelGap;
233
+ let cross = horizontal ? 0 : (Math.max(node.height, totalHeight) - totalHeight) / 2;
234
+ for (const edge of labels) {
235
+ const width = edge.width ?? 0,
236
+ height = edge.height ?? 0;
237
+ localLabels.set(edge.id, {
238
+ scope,
239
+ rect: {
240
+ x: node.x + (horizontal ? (Math.max(node.width, width) - width) / 2 : node.width + gap),
241
+ y: node.y + (horizontal ? node.height + gap : 0) + cross,
242
+ width,
243
+ height,
244
+ },
245
+ });
246
+ cross += height + labelGap;
247
+ }
248
+ }
249
+ const boxes: EntityRect[] = [
250
+ ...children.map((n) => ({ x: n.x, y: n.y, ...sizes.get(n.id)! })),
251
+ ...[...localLabels.values()].filter((l) => l.scope === scope).map((l) => l.rect),
252
+ ];
253
+ // One scope-local transform preserves labels relative to their nodes.
254
+ let dx = Math.max(0, insets.left - Math.min(...boxes.map((b) => b.x)));
255
+ let dy = Math.max(0, insets.top - Math.min(...boxes.map((b) => b.y)));
256
+ if (header && (side === "top" || side === "bottom")) {
257
+ const occupiedWidth =
258
+ Math.max(...boxes.map((b) => b.x + b.width)) -
259
+ Math.min(...boxes.map((b) => b.x)) +
260
+ padding.left +
261
+ padding.right;
262
+ dx += Math.max(0, (header.width - occupiedWidth) / 2);
263
+ }
264
+ if (header && (side === "left" || side === "right")) {
265
+ const occupiedHeight =
266
+ Math.max(...boxes.map((b) => b.y + b.height)) -
267
+ Math.min(...boxes.map((b) => b.y)) +
268
+ padding.top +
269
+ padding.bottom;
270
+ dy += Math.max(0, (header.height - occupiedHeight) / 2);
271
+ }
272
+ const right = Math.max(...boxes.map((b) => b.x + dx + b.width));
273
+ const bottom = Math.max(...boxes.map((b) => b.y + dy + b.height));
274
+ for (const n of children) {
275
+ n.x += dx;
276
+ n.y += dy;
277
+ }
278
+ for (const l of localLabels.values())
279
+ if (l.scope === scope) {
280
+ l.rect.x += dx;
281
+ l.rect.y += dy;
282
+ }
283
+ if (container) {
284
+ const width = Math.max(
285
+ header?.width ?? 0,
286
+ config?.minContentSize?.width
287
+ ? config.minContentSize.width + insets.left + insets.right
288
+ : 0,
289
+ right + insets.right,
290
+ );
291
+ const height = Math.max(
292
+ header?.height ?? 0,
293
+ config?.minContentSize?.height
294
+ ? config.minContentSize.height + insets.top + insets.bottom
295
+ : 0,
296
+ bottom + insets.bottom,
297
+ );
298
+ container.width = Math.max(container.width, width);
299
+ container.height = Math.max(container.height, height);
300
+ compoundGeometry.set(container.id, {
301
+ bounds: { x: 0, y: 0, width: container.width, height: container.height },
302
+ content: {
303
+ x: insets.left,
304
+ y: insets.top,
305
+ width: container.width - insets.left - insets.right,
306
+ height: container.height - insets.top - insets.bottom,
307
+ },
308
+ ...(header
309
+ ? {
310
+ header: {
311
+ x: side === "right" ? container.width - header.width : 0,
312
+ y: side === "bottom" ? container.height - header.height : 0,
313
+ width: side === "top" || side === "bottom" ? container.width : header.width,
314
+ height: side === "left" || side === "right" ? container.height : header.height,
315
+ },
316
+ }
317
+ : {}),
318
+ });
319
+ }
320
+ };
321
+ for (const scope of childScopes) solve(scope);
322
+ const roots = nodes.filter((n) => n.parentId == null);
323
+ if (roots.length === 1 && compoundGeometry.has(roots[0]!.id) && !owned.get(null)?.length)
324
+ Object.assign(roots[0]!, { x: 0, y: 0 });
325
+ else solve(null);
326
+ const origin = (id: string | null): Point => {
327
+ if (id == null) return { x: 0, y: 0 };
328
+ return ancestry(id)
329
+ .slice(0, -1)
330
+ .reduce<Point>((p, key) => ({ x: p.x + byId.get(key!)!.x, y: p.y + byId.get(key!)!.y }), {
331
+ x: 0,
332
+ y: 0,
333
+ });
334
+ };
335
+ for (const [id, label] of localLabels) {
336
+ const offset = origin(label.scope);
337
+ Object.assign(edges.get(id)!, {
338
+ ...label.rect,
339
+ x: label.rect.x + offset.x,
340
+ y: label.rect.y + offset.y,
341
+ });
342
+ }
343
+ // Route the original endpoints, not their projected ancestor representatives.
344
+ const attachmentSettings: Record<string, EdgeRoutingSettings> = {};
345
+ for (const edge of edges.values()) {
346
+ const sourceGeometry = compoundGeometry.get(edge.sourceId),
347
+ targetGeometry = compoundGeometry.get(edge.targetId);
348
+ const intent = options.edgeAttachment?.(edge);
349
+ attachmentSettings[edge.id] = {
350
+ ...(sourceGeometry &&
351
+ edge.sourceId !== edge.targetId &&
352
+ ancestry(edge.targetId).includes(edge.sourceId)
353
+ ? {
354
+ sourceAttachment: {
355
+ bounds:
356
+ intent?.source === "outer"
357
+ ? {
358
+ x: 0,
359
+ y: 0,
360
+ width: sourceGeometry.bounds.width,
361
+ height: sourceGeometry.bounds.height,
362
+ }
363
+ : sourceGeometry.content,
364
+ facing: intent?.source === "outer" ? "outward" : "inward",
365
+ },
366
+ }
367
+ : {}),
368
+ ...(targetGeometry && intent?.target === "content"
369
+ ? { targetAttachment: { bounds: targetGeometry.content, facing: "inward" } }
370
+ : {}),
371
+ };
372
+ }
373
+ const worldNodes = nodes.map((n) => ({ ...n, ...origin(n.id) }));
374
+ const usedIds = new Set(nodes.map((n) => n.id));
375
+ const headers = [...compoundGeometry].flatMap(([id, geometry]) => {
376
+ if (!geometry.header) return [];
377
+ const p = origin(id);
378
+ let key = `__layout_header_${id}`;
379
+ while (usedIds.has(key)) key += "_";
380
+ usedIds.add(key);
381
+ return [
382
+ {
383
+ type: "node" as const,
384
+ id: key,
385
+ parentId: id,
386
+ data: undefined as N,
387
+ ...geometry.header,
388
+ x: p.x + geometry.header.x,
389
+ y: p.y + geometry.header.y,
390
+ },
391
+ ];
392
+ });
393
+ const snapshot = orthogonalRouting.route(
394
+ { ...graph, nodes: [...worldNodes, ...headers], edges: [...edges.values()] },
395
+ { coordinateSpace: "world", edges: attachmentSettings },
396
+ );
397
+ for (const [id, route] of snapshot.routes) {
398
+ const polylines = routeToPolylines(route);
399
+ Object.assign(edges.get(id)!, {
400
+ points: polylines.flatMap((p) => p.map((point) => ({ ...point }))),
401
+ routing: "orthogonal",
402
+ });
403
+ }
404
+ for (const [id, geometry] of compoundGeometry)
405
+ geometry.bounds = { ...geometry.bounds, x: byId.get(id)!.x, y: byId.get(id)!.y };
406
+ const result: CompoundVisualGraph<N, E, G, P> = {
407
+ ...graph,
408
+ edgeCoordinateSpace: "world",
409
+ direction: options.direction ?? graph.direction ?? "right",
410
+ nodes,
411
+ edges: graph.edges.map((e) => edges.get(e.id)!) as VisualGraph<N, E, G, P>["edges"],
412
+ compoundGeometry,
413
+ compoundRoutes: snapshot.routes,
414
+ };
415
+ recordRouteGeometry(result, snapshot.routes);
416
+ return result;
417
+ }
@@ -1,3 +1,4 @@
1
+ import { layoutCompounds, type CompoundVisualGraph } from "./compound";
1
2
  import type { Graph, GraphEdge, GraphNode, Point, VisualGraph, VisualNode } from "@statelyai/graph";
2
3
  import { labelReferences, runPartialLayout } from "../authoring/partial";
3
4
  import { UnsupportedLayoutError } from "../errors";
@@ -61,6 +62,8 @@ import {
61
62
  joinFoldedMultiEdgeRoutes,
62
63
  } from "./multi-edge-wrapping";
63
64
 
65
+ export type { CompoundVisualGraph } from "./compound";
66
+
64
67
  export type {
65
68
  AcyclicOrientation,
66
69
  CrossingMinimizer,
@@ -69,6 +72,9 @@ export type {
69
72
  EdgeRoutes,
70
73
  LayerAssigner,
71
74
  LayerAssignment,
75
+ CompoundLayoutOptions,
76
+ CompoundLayoutGeometry,
77
+ CompoundEdgeAttachment,
72
78
  LayeredLayoutOptions,
73
79
  LayeredPhaseInput,
74
80
  LayeredSpacing,
@@ -1182,106 +1188,20 @@ function runCompoundPipeline<N, E, G, P>(
1182
1188
  options: LayeredLayoutOptions,
1183
1189
  context?: LayoutExecutionContext,
1184
1190
  ): VisualGraph<N, E, G, P> {
1185
- const nodes = graph.nodes.map((node) => ({ ...node })) as VisualNode<N, P>[];
1186
- const nodeById = new Map(nodes.map((node) => [node.id, node]));
1187
- const depth = (node: GraphNode): number => {
1188
- let value = 0;
1189
- let parentId = node.parentId;
1190
- const seen = new Set<string>();
1191
- while (parentId != null && !seen.has(parentId)) {
1192
- seen.add(parentId);
1193
- value++;
1194
- parentId = nodeById.get(parentId)?.parentId;
1195
- }
1196
- return value;
1197
- };
1198
- const parentIds = [
1199
- ...new Set(nodes.flatMap((node) => (node.parentId == null ? [] : [node.parentId]))),
1200
- ].sort((left, right) => depth(nodeById.get(right)!) - depth(nodeById.get(left)!));
1201
- const edgeById = new Map(graph.edges.map((edge) => [edge.id, { ...edge }]));
1202
- const padding = typeof options.padding === "number" ? options.padding : 12;
1203
-
1204
- const layoutSiblings = (parentId: string | null): void => {
1205
- const siblings = nodes.filter((node) => (node.parentId ?? null) === parentId);
1206
- if (siblings.length === 0) return;
1207
- const siblingIds = new Set(siblings.map((node) => node.id));
1208
- const siblingEdges = graph.edges.filter(
1209
- (edge) => siblingIds.has(edge.sourceId) && siblingIds.has(edge.targetId),
1210
- );
1211
- const flatNodes = siblings.map((node) => ({ ...node, parentId: null }));
1212
- const flatGraph = {
1213
- ...graph,
1214
- nodes: flatNodes,
1215
- edges: siblingEdges,
1216
- } as Graph<N, E, G, P>;
1217
- const result = runLayeredPipeline(flatGraph, options, context);
1218
- for (const laidOut of result.nodes) {
1219
- const node = nodeById.get(laidOut.id);
1220
- if (!node) continue;
1221
- Object.assign(node, laidOut, { parentId });
1222
- }
1223
- for (const edge of result.edges) edgeById.set(edge.id, edge);
1224
- };
1225
-
1226
- for (const parentId of parentIds) {
1227
- layoutSiblings(parentId);
1228
- const parent = nodeById.get(parentId);
1229
- if (!parent) continue;
1230
- const children = nodes.filter((node) => node.parentId === parentId);
1231
- const right = Math.max(0, ...children.map((node) => (node.x ?? 0) + (node.width ?? 0)));
1232
- const bottom = Math.max(0, ...children.map((node) => (node.y ?? 0) + (node.height ?? 0)));
1233
- parent.width = Math.max(parent.width ?? 0, right + padding);
1234
- parent.height = Math.max(parent.height ?? 0, bottom + padding);
1235
- }
1236
-
1237
- const roots = nodes.filter((node) => node.parentId == null);
1238
- if (roots.length === 1 && parentIds.includes(roots[0]!.id)) {
1239
- Object.assign(roots[0]!, { x: 0, y: 0 });
1240
- } else {
1241
- layoutSiblings(null);
1242
- }
1243
-
1244
- const absoluteRect = (id: string): { x: number; y: number; width: number; height: number } => {
1245
- const node = nodeById.get(id);
1246
- if (!node) return { x: 0, y: 0, width: 0, height: 0 };
1247
- let x = node.x ?? 0;
1248
- let y = node.y ?? 0;
1249
- let parentId = node.parentId;
1250
- const seen = new Set<string>();
1251
- while (parentId != null && !seen.has(parentId)) {
1252
- seen.add(parentId);
1253
- const parent = nodeById.get(parentId);
1254
- if (!parent) break;
1255
- x += parent.x ?? 0;
1256
- y += parent.y ?? 0;
1257
- parentId = parent.parentId;
1258
- }
1259
- return { x, y, width: node.width ?? 0, height: node.height ?? 0 };
1260
- };
1261
- for (const edge of graph.edges) {
1262
- if (edgeById.get(edge.id)?.points !== undefined) continue;
1263
- const source = absoluteRect(edge.sourceId);
1264
- const target = absoluteRect(edge.targetId);
1265
- const start = { x: source.x + source.width, y: source.y + source.height / 2 };
1266
- const end = { x: target.x, y: target.y + target.height / 2 };
1267
- const track = (start.x + end.x) / 2;
1268
- const points = [start, { x: track, y: start.y }, { x: track, y: end.y }, end];
1269
- edgeById.set(edge.id, {
1270
- ...edge,
1271
- points,
1272
- x: track,
1273
- y: (start.y + end.y) / 2,
1274
- width: edge.width ?? 0,
1275
- height: edge.height ?? 0,
1276
- routing: "orthogonal",
1277
- });
1278
- }
1279
-
1280
- return {
1281
- ...graph,
1282
- nodes,
1283
- edges: graph.edges.map((edge) => edgeById.get(edge.id) ?? edge),
1284
- } as VisualGraph<N, E, G, P>;
1191
+ const result = layoutCompounds(graph, options, (scope, local) =>
1192
+ runLayeredPipeline(scope, local, context),
1193
+ );
1194
+ for (const route of result.compoundRoutes.values())
1195
+ for (const diagnostic of route.diagnostics)
1196
+ context?.diagnostics.push({
1197
+ severity: "warning",
1198
+ code: diagnostic.code,
1199
+ message: diagnostic.message,
1200
+ entityIds: [route.edgeId],
1201
+ phase: "compound-routing",
1202
+ geometry: "routes",
1203
+ });
1204
+ return result;
1285
1205
  }
1286
1206
 
1287
1207
  function runLayeredPipeline<N, E, G, P>(
@@ -2671,8 +2591,13 @@ function runLayeredPipeline<N, E, G, P>(
2671
2591
  export function getLayeredLayout<N, E, G, P>(
2672
2592
  graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
2673
2593
  options: LayeredLayoutOptions = {},
2674
- ): VisualGraph<N, E, G, P> {
2675
- return runLayeredPipeline(graph, options);
2594
+ ): CompoundVisualGraph<N, E, G, P> {
2595
+ const result = runLayeredPipeline(graph, options) as CompoundVisualGraph<N, E, G, P>;
2596
+ return {
2597
+ ...result,
2598
+ compoundGeometry: result.compoundGeometry ?? new Map(),
2599
+ compoundRoutes: result.compoundRoutes ?? new Map(),
2600
+ };
2676
2601
  }
2677
2602
 
2678
2603
  export const layeredAlgorithm: LayoutAlgorithm<LayeredLayoutOptions> = {
@@ -92,8 +92,29 @@ export interface LayeredStrategies {
92
92
  routeEdges?: EdgeRouter;
93
93
  }
94
94
 
95
+ /** Geometry reserved while laying out a compound, before its ancestors. */
96
+ export interface CompoundLayoutOptions {
97
+ padding?: number | Partial<LayoutPadding>;
98
+ direction?: LayoutDirection;
99
+ header?: NodeSize & { side?: "top" | "right" | "bottom" | "left" };
100
+ minContentSize?: Partial<NodeSize>;
101
+ }
102
+ export interface CompoundLayoutGeometry {
103
+ /** Parent-relative outer rectangle; header/content are relative to this rectangle. */
104
+ bounds: EntityRect;
105
+ header?: EntityRect;
106
+ content: EntityRect;
107
+ }
108
+ export interface CompoundEdgeAttachment {
109
+ /** Content attachments leave toward the interior; outer attachments leave outward. */
110
+ source?: "content" | "outer";
111
+ target?: "content" | "outer";
112
+ }
113
+
95
114
  export interface LayeredLayoutOptions {
96
115
  direction?: LayoutDirection;
116
+ compound?: (node: GraphNode) => CompoundLayoutOptions | undefined;
117
+ edgeAttachment?: (edge: GraphEdge) => CompoundEdgeAttachment | undefined;
97
118
  spacing?: Partial<LayeredSpacing>;
98
119
  padding?: number | Partial<LayoutPadding>;
99
120
  constraints?: LayoutConstraints;