@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.
- package/README.md +71 -2
- package/dist/elkjs/index.cjs +3 -2
- package/dist/elkjs/index.d.cts +11 -2
- package/dist/elkjs/index.d.mts +11 -2
- package/dist/elkjs/index.mjs +3 -3
- package/dist/elkjs/main.cjs +2 -2
- package/dist/elkjs/main.d.cts +2 -2
- package/dist/elkjs/main.d.mts +2 -2
- package/dist/elkjs/main.mjs +2 -2
- package/dist/elkjs/worker-api.cjs +1 -1
- package/dist/elkjs/worker-api.d.cts +2 -2
- package/dist/elkjs/worker-api.d.mts +2 -2
- package/dist/elkjs/worker-api.mjs +1 -1
- package/dist/elkjs/worker.cjs +2 -2
- package/dist/elkjs/worker.mjs +2 -2
- package/dist/{elkjs-CFlfkEBz.mjs → elkjs-DaHMISnQ.mjs} +152 -3
- package/dist/{elkjs-BVnDwnBQ.cjs → elkjs-dMghiiWW.cjs} +157 -2
- package/dist/index-BEA_pzeT.d.cts +72 -0
- package/dist/{index-BSH2bloA.d.mts → index-D9dhxrRl.d.cts} +35 -3
- package/dist/index-DNTLRsDl.d.mts +72 -0
- package/dist/{index-CG712jzV.d.cts → index-DNixtE3h.d.mts} +35 -3
- package/dist/index.cjs +36 -9
- package/dist/index.d.cts +4 -3
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +4 -3
- package/dist/layered/index.cjs +1 -1
- package/dist/layered/index.d.cts +3 -2
- package/dist/layered/index.d.mts +3 -2
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-Cf9Rn8l7.cjs → layered-B2buJqMf.cjs} +417 -469
- package/dist/{layered-Bz2nWmVp.mjs → layered-DGFTeQSl.mjs} +391 -431
- package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-Cs5tatA6.mjs} +1 -1
- package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-Dn1PiFwP.cjs} +1 -1
- package/dist/routing/index.cjs +28 -0
- package/dist/routing/index.d.cts +3 -0
- package/dist/routing/index.d.mts +3 -0
- package/dist/routing/index.mjs +3 -0
- package/dist/routing-A2hrK9MP.mjs +2396 -0
- package/dist/routing-ujEP7Qh6.cjs +2606 -0
- package/dist/types-CT2akdK0.d.mts +133 -0
- package/dist/types-uxPlGRof.d.cts +133 -0
- package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
- package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
- package/package.json +7 -2
- package/src/authoring/coordinates.ts +10 -2
- package/src/elkjs/index.ts +5 -3
- package/src/elkjs/routes.ts +147 -0
- package/src/index.ts +6 -0
- package/src/layered/compound.ts +417 -0
- package/src/layered/index.ts +27 -102
- package/src/layered/types.ts +21 -0
- package/src/routing/adapters.ts +111 -0
- package/src/routing/algorithms.ts +676 -0
- package/src/routing/coordination.ts +86 -0
- package/src/routing/engine.ts +473 -0
- package/src/routing/index.ts +68 -0
- package/src/routing/layout-cache.ts +65 -0
- package/src/routing/model.ts +218 -0
- package/src/routing/path.ts +324 -0
- package/src/routing/persistent.ts +117 -0
- package/src/routing/search.ts +276 -0
- package/src/routing/spatial.ts +78 -0
- package/src/routing/types.ts +146 -0
- /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
- /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
- /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
- /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
|
+
}
|
package/src/layered/index.ts
CHANGED
|
@@ -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
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
):
|
|
2675
|
-
|
|
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> = {
|
package/src/layered/types.ts
CHANGED
|
@@ -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;
|