@statelyai/layout 0.0.4 → 0.1.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 +14 -0
- package/dist/elkjs/index.d.mts +44 -1
- package/dist/elkjs/index.mjs +269 -7
- package/dist/index.mjs +2 -2
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-Bp8MkxWK.mjs → layered-CZs9YWVx.mjs} +204 -28
- package/dist/{spore-ePWz2LtF.mjs → spore-9N0W9dZn.mjs} +1 -1
- package/package.json +2 -1
- package/src/elkjs/index.ts +15 -1
- package/src/elkjs/statechart.ts +338 -0
- package/src/layered/index.ts +99 -28
- package/src/layered/separate-exterior-labels.ts +139 -0
- package/src/layered/strategies.ts +188 -13
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import ELK from "./index";
|
|
2
|
+
import type { ElkEdge, ElkGraphElement, ElkId, ElkNode, ElkPoint, LaidOutElkNode } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface StatechartScope {
|
|
5
|
+
/** Direct child of this container; never inferred from state names. */
|
|
6
|
+
initialNodeId?: ElkId;
|
|
7
|
+
/** Connected direct-child path. Explicit paths resolve branching ambiguity. */
|
|
8
|
+
preferredPath?: readonly ElkId[];
|
|
9
|
+
direction?: "UP" | "DOWN" | "LEFT" | "RIGHT";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StatechartLayoutOptions {
|
|
13
|
+
scopes: Readonly<Record<string, StatechartScope>>;
|
|
14
|
+
/** Total engine calls, including baseline; integer from 1 to 3. Default 3. */
|
|
15
|
+
maxAttempts?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StatechartLayoutScore {
|
|
19
|
+
invalid: number;
|
|
20
|
+
overlaps: number;
|
|
21
|
+
pathOrder: number;
|
|
22
|
+
crossings: number;
|
|
23
|
+
bends: number;
|
|
24
|
+
routeLength: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StatechartLayoutPlan {
|
|
28
|
+
graph: ElkNode;
|
|
29
|
+
paths: Record<string, ElkId[]>;
|
|
30
|
+
commonExits: Record<string, ElkId[]>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function option(node: ElkGraphElement, suffix: string): unknown {
|
|
34
|
+
const settings = { ...node.properties, ...node.layoutOptions };
|
|
35
|
+
return settings[suffix] ?? settings[`elk.${suffix}`] ?? settings[`org.eclipse.elk.${suffix}`];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const endpoint = (edge: ElkEdge, source: boolean) =>
|
|
39
|
+
source ? (edge.sources?.[0] ?? edge.source) : (edge.targets?.[0] ?? edge.target);
|
|
40
|
+
|
|
41
|
+
/** Compile semantic hints into settings implemented by this package's ELK adapter. */
|
|
42
|
+
export function compileStatechartLayout(
|
|
43
|
+
input: ElkNode,
|
|
44
|
+
scopes: StatechartLayoutOptions["scopes"],
|
|
45
|
+
): StatechartLayoutPlan {
|
|
46
|
+
const graph = structuredClone(input);
|
|
47
|
+
const paths: Record<string, ElkId[]> = Object.create(null);
|
|
48
|
+
const commonExits: Record<string, ElkId[]> = Object.create(null);
|
|
49
|
+
const containers = new Map<string, ElkNode>();
|
|
50
|
+
const edges: ElkEdge[] = [];
|
|
51
|
+
const collect = (node: ElkNode) => {
|
|
52
|
+
if (node.id === undefined || containers.has(String(node.id)))
|
|
53
|
+
throw new Error("Statechart nodes require unique IDs");
|
|
54
|
+
containers.set(String(node.id), node);
|
|
55
|
+
edges.push(...(node.edges ?? []));
|
|
56
|
+
node.children?.forEach(collect);
|
|
57
|
+
};
|
|
58
|
+
collect(graph);
|
|
59
|
+
for (const [id, scope] of Object.entries(scopes)) {
|
|
60
|
+
const container = containers.get(id);
|
|
61
|
+
if (!container) throw new Error(`Unknown statechart container: ${id}`);
|
|
62
|
+
const children = container.children ?? [];
|
|
63
|
+
const ids = new Set(children.map((node) => node.id));
|
|
64
|
+
const owner = new Map<ElkId | undefined, ElkId>();
|
|
65
|
+
const own = (node: ElkNode, childId: ElkId) => {
|
|
66
|
+
owner.set(node.id, childId);
|
|
67
|
+
node.ports?.forEach((port) => owner.set(port.id, childId));
|
|
68
|
+
node.children?.forEach((descendant) => own(descendant, childId));
|
|
69
|
+
};
|
|
70
|
+
children.forEach((child) => own(child, child.id!));
|
|
71
|
+
const localEdges = edges.filter(
|
|
72
|
+
(edge) => owner.has(endpoint(edge, true)) && owner.has(endpoint(edge, false)),
|
|
73
|
+
);
|
|
74
|
+
const successors = new Map(children.map((node) => [node.id, new Set<ElkId>()]));
|
|
75
|
+
const predecessors = new Map(children.map((node) => [node.id, new Set<ElkId>()]));
|
|
76
|
+
for (const edge of localEdges) {
|
|
77
|
+
const source = owner.get(endpoint(edge, true))!;
|
|
78
|
+
const target = owner.get(endpoint(edge, false))!;
|
|
79
|
+
if (source === target) continue;
|
|
80
|
+
successors.get(source)!.add(target);
|
|
81
|
+
predecessors.get(target)!.add(source);
|
|
82
|
+
}
|
|
83
|
+
const exits = children
|
|
84
|
+
.filter((node) => successors.get(node.id)!.size === 0 && predecessors.get(node.id)!.size >= 2)
|
|
85
|
+
.map((node) => node.id!);
|
|
86
|
+
commonExits[id] = exits;
|
|
87
|
+
const path = [
|
|
88
|
+
...(scope.preferredPath ?? (scope.initialNodeId === undefined ? [] : [scope.initialNodeId])),
|
|
89
|
+
];
|
|
90
|
+
if (new Set(path).size !== path.length || path.some((nodeId) => !ids.has(nodeId))) {
|
|
91
|
+
throw new Error(`Path must contain distinct direct children of ${id}`);
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
scope.initialNodeId !== undefined &&
|
|
95
|
+
(!ids.has(scope.initialNodeId) || path[0] !== scope.initialNodeId)
|
|
96
|
+
) {
|
|
97
|
+
throw new Error(`Path must start at initial child of ${id}`);
|
|
98
|
+
}
|
|
99
|
+
if (scope.preferredPath) {
|
|
100
|
+
for (let i = 1; i < path.length; i++) {
|
|
101
|
+
if (!successors.get(path[i - 1])?.has(path[i]))
|
|
102
|
+
throw new Error(`Disconnected preferred path in ${id}`);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
while (path.length) {
|
|
106
|
+
const next = [...successors.get(path.at(-1))!].filter(
|
|
107
|
+
(nodeId) => !path.includes(nodeId) && !exits.includes(nodeId),
|
|
108
|
+
);
|
|
109
|
+
if (next.length !== 1) break;
|
|
110
|
+
path.push(next[0]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
paths[id] = path;
|
|
114
|
+
const order = [
|
|
115
|
+
...path,
|
|
116
|
+
...children
|
|
117
|
+
.map((node) => node.id!)
|
|
118
|
+
.filter((nodeId) => !path.includes(nodeId) && !exits.includes(nodeId)),
|
|
119
|
+
...exits.filter((nodeId) => !path.includes(nodeId)),
|
|
120
|
+
];
|
|
121
|
+
container.children = order.map((nodeId) => children.find((node) => node.id === nodeId)!);
|
|
122
|
+
container.layoutOptions = {
|
|
123
|
+
...(path.length > 1 && option(container, "layered.cycleBreaking.strategy") === undefined
|
|
124
|
+
? { "elk.layered.cycleBreaking.strategy": "MODEL_ORDER" }
|
|
125
|
+
: {}),
|
|
126
|
+
...(path.length > 1 && option(container, "layered.feedbackEdges") === undefined
|
|
127
|
+
? { "elk.layered.feedbackEdges": true }
|
|
128
|
+
: {}),
|
|
129
|
+
...container.layoutOptions,
|
|
130
|
+
...(scope.direction ? { direction: scope.direction } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { graph, paths, commonExits };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface Rect {
|
|
137
|
+
x: number;
|
|
138
|
+
y: number;
|
|
139
|
+
width: number;
|
|
140
|
+
height: number;
|
|
141
|
+
}
|
|
142
|
+
const intersects = (a: Rect, b: Rect) =>
|
|
143
|
+
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
144
|
+
const cross = (a: ElkPoint, b: ElkPoint, c: ElkPoint) =>
|
|
145
|
+
(b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
146
|
+
|
|
147
|
+
/** Diagnostic objective, not a proof of geometric validity. Coordinates are container-relative. */
|
|
148
|
+
export function scoreStatechartLayout(
|
|
149
|
+
graph: ElkNode,
|
|
150
|
+
paths: StatechartLayoutPlan["paths"] = {},
|
|
151
|
+
): StatechartLayoutScore {
|
|
152
|
+
const score: StatechartLayoutScore = {
|
|
153
|
+
invalid: 0,
|
|
154
|
+
overlaps: 0,
|
|
155
|
+
pathOrder: 0,
|
|
156
|
+
crossings: 0,
|
|
157
|
+
bends: 0,
|
|
158
|
+
routeLength: 0,
|
|
159
|
+
};
|
|
160
|
+
const segments: Array<{ a: ElkPoint; b: ElkPoint; edge: ElkEdge }> = [];
|
|
161
|
+
const boxes: Array<{ rect: Rect; ancestors: ElkNode[]; node?: ElkNode }> = [];
|
|
162
|
+
const rect = (
|
|
163
|
+
shape: { x?: number; y?: number; width?: number; height?: number },
|
|
164
|
+
x: number,
|
|
165
|
+
y: number,
|
|
166
|
+
): Rect => {
|
|
167
|
+
const values = [shape.x, shape.y, shape.width, shape.height];
|
|
168
|
+
if (
|
|
169
|
+
values.some((value) => value === undefined || !Number.isFinite(value)) ||
|
|
170
|
+
(shape.width ?? 0) < 0 ||
|
|
171
|
+
(shape.height ?? 0) < 0
|
|
172
|
+
)
|
|
173
|
+
score.invalid++;
|
|
174
|
+
return {
|
|
175
|
+
x: x + (shape.x ?? 0),
|
|
176
|
+
y: y + (shape.y ?? 0),
|
|
177
|
+
width: shape.width ?? 0,
|
|
178
|
+
height: shape.height ?? 0,
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
const visit = (node: ElkNode, x: number, y: number, ancestors: ElkNode[]) => {
|
|
182
|
+
const direction = String(option(node, "direction") ?? "RIGHT").toUpperCase();
|
|
183
|
+
const horizontal = direction === "RIGHT" || direction === "LEFT";
|
|
184
|
+
const sign = direction === "LEFT" || direction === "UP" ? -1 : 1;
|
|
185
|
+
const path = paths[String(node.id)] ?? [];
|
|
186
|
+
for (let i = 1; i < path.length; i++) {
|
|
187
|
+
const a = node.children?.find((child) => child.id === path[i - 1]);
|
|
188
|
+
const b = node.children?.find((child) => child.id === path[i]);
|
|
189
|
+
if (!a || !b || sign * (horizontal ? b.x! - a.x! : b.y! - a.y!) <= 0) score.pathOrder++;
|
|
190
|
+
}
|
|
191
|
+
for (const child of node.children ?? []) {
|
|
192
|
+
if (String(option(child, "noLayout")).toLowerCase() === "true") continue;
|
|
193
|
+
const box = rect(child, x, y);
|
|
194
|
+
if (
|
|
195
|
+
(child.x ?? 0) < 0 ||
|
|
196
|
+
(child.y ?? 0) < 0 ||
|
|
197
|
+
(child.x ?? 0) + (child.width ?? 0) > (node.width ?? Infinity) + 0.001 ||
|
|
198
|
+
(child.y ?? 0) + (child.height ?? 0) > (node.height ?? Infinity) + 0.001
|
|
199
|
+
)
|
|
200
|
+
score.invalid++;
|
|
201
|
+
boxes.push({ rect: box, ancestors, node: child });
|
|
202
|
+
visit(child, box.x, box.y, [...ancestors, child]);
|
|
203
|
+
}
|
|
204
|
+
for (const edge of node.edges ?? []) {
|
|
205
|
+
if (String(option(edge, "noLayout")).toLowerCase() === "true") continue;
|
|
206
|
+
if (!edge.sections?.length) score.invalid++;
|
|
207
|
+
for (const label of edge.labels ?? []) {
|
|
208
|
+
if (String(option(label, "noLayout")).toLowerCase() === "true") continue;
|
|
209
|
+
boxes.push({ rect: rect(label, x, y), ancestors });
|
|
210
|
+
}
|
|
211
|
+
for (const section of edge.sections ?? []) {
|
|
212
|
+
const points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint];
|
|
213
|
+
score.bends += section.bendPoints?.length ?? 0;
|
|
214
|
+
for (const point of points)
|
|
215
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) score.invalid++;
|
|
216
|
+
for (let i = 1; i < points.length; i++) {
|
|
217
|
+
const a = { x: x + points[i - 1].x, y: y + points[i - 1].y };
|
|
218
|
+
const b = { x: x + points[i].x, y: y + points[i].y };
|
|
219
|
+
score.routeLength += Math.hypot(b.x - a.x, b.y - a.y);
|
|
220
|
+
segments.push({ a, b, edge });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
visit(graph, 0, 0, [graph]);
|
|
226
|
+
for (let i = 0; i < boxes.length; i++)
|
|
227
|
+
for (let j = i + 1; j < boxes.length; j++) {
|
|
228
|
+
const a = boxes[i],
|
|
229
|
+
b = boxes[j];
|
|
230
|
+
if ((a.node && b.ancestors.includes(a.node)) || (b.node && a.ancestors.includes(b.node)))
|
|
231
|
+
continue;
|
|
232
|
+
if (intersects(a.rect, b.rect)) score.overlaps++;
|
|
233
|
+
}
|
|
234
|
+
for (let i = 0; i < segments.length; i++)
|
|
235
|
+
for (let j = i + 1; j < segments.length; j++) {
|
|
236
|
+
const a = segments[i],
|
|
237
|
+
b = segments[j];
|
|
238
|
+
if (a.edge === b.edge) continue;
|
|
239
|
+
if (
|
|
240
|
+
cross(a.a, a.b, b.a) * cross(a.a, a.b, b.b) < 0 &&
|
|
241
|
+
cross(b.a, b.b, a.a) * cross(b.a, b.b, a.b) < 0
|
|
242
|
+
)
|
|
243
|
+
score.crossings++;
|
|
244
|
+
}
|
|
245
|
+
if (!Number.isFinite(score.routeLength)) score.routeLength = Number.MAX_VALUE;
|
|
246
|
+
return score;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const objectives = [
|
|
250
|
+
"invalid",
|
|
251
|
+
"overlaps",
|
|
252
|
+
"pathOrder",
|
|
253
|
+
"crossings",
|
|
254
|
+
"bends",
|
|
255
|
+
"routeLength",
|
|
256
|
+
] as const;
|
|
257
|
+
function better(a: StatechartLayoutScore, b: StatechartLayoutScore) {
|
|
258
|
+
for (const key of objectives) if (a[key] !== b[key]) return a[key] < b[key];
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Each attempt starts from original input. No post-layout geometry edits. */
|
|
263
|
+
export async function layoutStatechart<T extends ElkNode>(
|
|
264
|
+
input: T,
|
|
265
|
+
options: StatechartLayoutOptions,
|
|
266
|
+
) {
|
|
267
|
+
const maxAttempts = options.maxAttempts ?? 3;
|
|
268
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 3)
|
|
269
|
+
throw new RangeError("maxAttempts must be an integer from 1 to 3");
|
|
270
|
+
const plan = compileStatechartLayout(input, options.scopes);
|
|
271
|
+
const attempts: Array<{ kind: "baseline" | "policy" | "spacing"; score: StatechartLayoutScore }> =
|
|
272
|
+
[];
|
|
273
|
+
let selected:
|
|
274
|
+
| { graph: LaidOutElkNode<T>; score: StatechartLayoutScore; attempt: number }
|
|
275
|
+
| undefined;
|
|
276
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
277
|
+
const candidate = structuredClone(attempt === 0 ? input : plan.graph);
|
|
278
|
+
// Directions are explicit user intent, so apply them to the baseline too.
|
|
279
|
+
const configure = (node: ElkNode) => {
|
|
280
|
+
const scope = Object.hasOwn(options.scopes, String(node.id))
|
|
281
|
+
? options.scopes[String(node.id)]
|
|
282
|
+
: undefined;
|
|
283
|
+
if (scope?.direction)
|
|
284
|
+
node.layoutOptions = { ...node.layoutOptions, direction: scope.direction };
|
|
285
|
+
if (attempt === 2 && scope)
|
|
286
|
+
node.layoutOptions = {
|
|
287
|
+
...node.layoutOptions,
|
|
288
|
+
"spacing.nodeNode": Math.max(40, Number(option(node, "spacing.nodeNode") ?? 20) * 1.5),
|
|
289
|
+
"layered.spacing.nodeNodeBetweenLayers": Math.max(
|
|
290
|
+
60,
|
|
291
|
+
Number(option(node, "layered.spacing.nodeNodeBetweenLayers") ?? 20) * 1.5,
|
|
292
|
+
),
|
|
293
|
+
};
|
|
294
|
+
if (attempt === 2 && scope) {
|
|
295
|
+
// Spread at most three parallel labels over the engine's supported anchors.
|
|
296
|
+
const groups = new Map<string, ElkEdge[]>();
|
|
297
|
+
for (const edge of node.edges ?? []) {
|
|
298
|
+
if (!edge.labels?.length) continue;
|
|
299
|
+
const key = JSON.stringify([endpoint(edge, true), endpoint(edge, false)]);
|
|
300
|
+
const group = groups.get(key) ?? [];
|
|
301
|
+
group.push(edge);
|
|
302
|
+
groups.set(key, group);
|
|
303
|
+
}
|
|
304
|
+
for (const group of groups.values())
|
|
305
|
+
for (let i = 1; i < Math.min(group.length, 3); i++) {
|
|
306
|
+
const edge = group[i];
|
|
307
|
+
if (
|
|
308
|
+
option(edge, "edgeLabels.placement") === undefined &&
|
|
309
|
+
edge.labels!.every((label) => option(label, "edgeLabels.placement") === undefined)
|
|
310
|
+
) {
|
|
311
|
+
const placement = i === 1 ? "HEAD" : "TAIL";
|
|
312
|
+
edge.layoutOptions = {
|
|
313
|
+
...edge.layoutOptions,
|
|
314
|
+
"elk.edgeLabels.placement": placement,
|
|
315
|
+
};
|
|
316
|
+
edge.labels = edge.labels!.map((label) => ({
|
|
317
|
+
...label,
|
|
318
|
+
layoutOptions: {
|
|
319
|
+
...label.layoutOptions,
|
|
320
|
+
"elk.edgeLabels.placement": placement,
|
|
321
|
+
},
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
node.children?.forEach(configure);
|
|
327
|
+
};
|
|
328
|
+
configure(candidate);
|
|
329
|
+
const graph = (await new ELK().layout(candidate)) as LaidOutElkNode<T>;
|
|
330
|
+
const score = scoreStatechartLayout(graph, plan.paths);
|
|
331
|
+
attempts.push({
|
|
332
|
+
kind: attempt === 0 ? "baseline" : attempt === 1 ? "policy" : "spacing",
|
|
333
|
+
score,
|
|
334
|
+
});
|
|
335
|
+
if (!selected || better(score, selected.score)) selected = { graph, score, attempt };
|
|
336
|
+
}
|
|
337
|
+
return { ...selected!, attempts, paths: plan.paths, commonExits: plan.commonExits };
|
|
338
|
+
}
|
package/src/layered/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Graph, GraphEdge, GraphNode, Point, VisualGraph, VisualNode } from "@statelyai/graph";
|
|
2
2
|
import { UnsupportedLayoutError } from "../errors";
|
|
3
3
|
import type { LayoutAlgorithm, LayoutExecutionContext } from "../types";
|
|
4
|
+
import { separateExteriorLabels } from "./separate-exterior-labels";
|
|
4
5
|
import {
|
|
5
6
|
assignLayersByLongestPath,
|
|
6
7
|
assignLayersByLongestPathToSink,
|
|
@@ -1663,6 +1664,13 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
1663
1664
|
}
|
|
1664
1665
|
{
|
|
1665
1666
|
const horizontal = direction === "right" || direction === "left";
|
|
1667
|
+
const targetsBySource = new Map<string, Set<string>>();
|
|
1668
|
+
for (const edge of graph.edges) {
|
|
1669
|
+
if (edge.sourceId === edge.targetId) continue;
|
|
1670
|
+
const targets = targetsBySource.get(edge.sourceId) ?? new Set<string>();
|
|
1671
|
+
targets.add(edge.targetId);
|
|
1672
|
+
targetsBySource.set(edge.sourceId, targets);
|
|
1673
|
+
}
|
|
1666
1674
|
for (const edge of graph.edges) {
|
|
1667
1675
|
if (edge.sourcePort === undefined) continue;
|
|
1668
1676
|
const source = graph.nodes.find((node) => node.id === edge.sourceId);
|
|
@@ -1671,27 +1679,35 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
1671
1679
|
const port = source?.ports?.find((candidate) => candidate.name === edge.sourcePort);
|
|
1672
1680
|
if (!source || !sourceRect || !targetRect || !port) continue;
|
|
1673
1681
|
const side = input.portSettings?.(port, source)?.["port.side"];
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
mutableRects.
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1682
|
+
// A port side constrains its route, not the positions of an entire fan-out.
|
|
1683
|
+
// Align an isolated target only when the placement's separation survives.
|
|
1684
|
+
if (edge.sourceId === edge.targetId || (targetsBySource.get(edge.sourceId)?.size ?? 0) > 1)
|
|
1685
|
+
continue;
|
|
1686
|
+
const candidate =
|
|
1687
|
+
horizontal && side === "NORTH"
|
|
1688
|
+
? { ...targetRect, y: sourceRect.y - (port.height ?? 0) - targetRect.height }
|
|
1689
|
+
: horizontal && side === "SOUTH"
|
|
1690
|
+
? { ...targetRect, y: sourceRect.y + sourceRect.height + (port.height ?? 0) }
|
|
1691
|
+
: !horizontal && side === "WEST"
|
|
1692
|
+
? { ...targetRect, x: sourceRect.x - (port.width ?? 0) - targetRect.width }
|
|
1693
|
+
: !horizontal && side === "EAST"
|
|
1694
|
+
? { ...targetRect, x: sourceRect.x + sourceRect.width + (port.width ?? 0) }
|
|
1695
|
+
: undefined;
|
|
1696
|
+
if (
|
|
1697
|
+
!candidate ||
|
|
1698
|
+
[...mutableRects].some(
|
|
1699
|
+
([id, other]) =>
|
|
1700
|
+
id !== edge.targetId &&
|
|
1701
|
+
other.width > 0 &&
|
|
1702
|
+
other.height > 0 &&
|
|
1703
|
+
candidate.x < other.x + other.width &&
|
|
1704
|
+
candidate.x + candidate.width > other.x &&
|
|
1705
|
+
candidate.y < other.y + other.height &&
|
|
1706
|
+
candidate.y + candidate.height > other.y,
|
|
1707
|
+
)
|
|
1708
|
+
)
|
|
1709
|
+
continue;
|
|
1710
|
+
mutableRects.set(edge.targetId, candidate);
|
|
1695
1711
|
}
|
|
1696
1712
|
}
|
|
1697
1713
|
measure("port-margin-normalization", () =>
|
|
@@ -1758,7 +1774,9 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
1758
1774
|
let expandedRoutes = measure("edge-routing", () =>
|
|
1759
1775
|
edgeRouter(expanded.input, expanded.orientation, placement),
|
|
1760
1776
|
);
|
|
1761
|
-
measure("post-compaction", () =>
|
|
1777
|
+
measure("post-compaction", () =>
|
|
1778
|
+
applyPostCompaction(expanded.input, placement, expandedRoutes, graph.edges),
|
|
1779
|
+
);
|
|
1762
1780
|
const antiparallelLabelPositions = new Map<string, Point>();
|
|
1763
1781
|
const outerAntiparallelLabelIds = new Set<string>();
|
|
1764
1782
|
const parallelLabelPositions = new Map<string, Point>();
|
|
@@ -2498,6 +2516,7 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
2498
2516
|
const flexibleFeedbackLabel =
|
|
2499
2517
|
labelPlacement === "CENTER" &&
|
|
2500
2518
|
inlineLabel &&
|
|
2519
|
+
edge.sourceId !== edge.targetId &&
|
|
2501
2520
|
expanded.orientation.reversedEdgeIds.has(edge.id) &&
|
|
2502
2521
|
hasFlexiblePorts(sourceNode) &&
|
|
2503
2522
|
hasFlexiblePorts(targetNode) &&
|
|
@@ -2544,7 +2563,39 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
2544
2563
|
y,
|
|
2545
2564
|
};
|
|
2546
2565
|
})();
|
|
2566
|
+
// An inline CENTER label occupies the reserved flow interval between its
|
|
2567
|
+
// endpoint ranks. Route midpoints can lie on long cross-axis detours, and
|
|
2568
|
+
// are not a flow-coordinate anchor for that label.
|
|
2569
|
+
const centerFlowPosition =
|
|
2570
|
+
options.settings?.["compaction.postCompaction.strategy"] !== "EDGE_LENGTH" &&
|
|
2571
|
+
labelDummyRect === undefined &&
|
|
2572
|
+
edgeRouting === "ORTHOGONAL" &&
|
|
2573
|
+
inlineLabel &&
|
|
2574
|
+
labelPlacement === "CENTER" &&
|
|
2575
|
+
edge.sourceId !== edge.targetId &&
|
|
2576
|
+
beforeFlowRect &&
|
|
2577
|
+
afterFlowRect &&
|
|
2578
|
+
(horizontal
|
|
2579
|
+
? afterFlowRect.x - beforeFlowRect.x - beforeFlowRect.width >= width
|
|
2580
|
+
: afterFlowRect.y - beforeFlowRect.y - beforeFlowRect.height >= height)
|
|
2581
|
+
? horizontal
|
|
2582
|
+
? (beforeFlowRect.x + beforeFlowRect.width + afterFlowRect.x - width) / 2
|
|
2583
|
+
: (beforeFlowRect.y + beforeFlowRect.height + afterFlowRect.y - height) / 2 -
|
|
2584
|
+
edgeThickness / 2
|
|
2585
|
+
: undefined;
|
|
2586
|
+
const selfLoopLabelPosition =
|
|
2587
|
+
edgeRouting === "ORTHOGONAL" &&
|
|
2588
|
+
labelPlacement === "CENTER" &&
|
|
2589
|
+
inlineLabel &&
|
|
2590
|
+
edge.sourceId === edge.targetId &&
|
|
2591
|
+
points.length === 4
|
|
2592
|
+
? {
|
|
2593
|
+
x: (points[1]!.x + points[2]!.x - width) / 2,
|
|
2594
|
+
y: (points[1]!.y + points[2]!.y - height) / 2,
|
|
2595
|
+
}
|
|
2596
|
+
: undefined;
|
|
2547
2597
|
const explicitLabelPosition =
|
|
2598
|
+
selfLoopLabelPosition ??
|
|
2548
2599
|
outerAntiparallelLabelPosition ??
|
|
2549
2600
|
antiparallelLabelPosition ??
|
|
2550
2601
|
parallelLabelPositions.get(edge.id);
|
|
@@ -2554,18 +2605,22 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
2554
2605
|
? horizontal
|
|
2555
2606
|
? (beforeFlowRect!.x + beforeFlowRect!.width + afterFlowRect!.x - width) / 2
|
|
2556
2607
|
: targetRect.x + (targetRect.width - width) / 2
|
|
2557
|
-
:
|
|
2558
|
-
?
|
|
2559
|
-
:
|
|
2608
|
+
: centerFlowPosition !== undefined && horizontal
|
|
2609
|
+
? centerFlowPosition
|
|
2610
|
+
: labelPlacement === "CENTER" && horizontal && labelDummyRect
|
|
2611
|
+
? labelDummyRect.x
|
|
2612
|
+
: routeX;
|
|
2560
2613
|
const y = explicitLabelPosition
|
|
2561
2614
|
? explicitLabelPosition.y
|
|
2562
2615
|
: flexibleFeedbackLabel
|
|
2563
2616
|
? horizontal
|
|
2564
2617
|
? targetRect.y + (targetRect.height - height) / 2
|
|
2565
2618
|
: (beforeFlowRect!.y + beforeFlowRect!.height + afterFlowRect!.y - height) / 2
|
|
2566
|
-
:
|
|
2567
|
-
?
|
|
2568
|
-
:
|
|
2619
|
+
: centerFlowPosition !== undefined && !horizontal
|
|
2620
|
+
? centerFlowPosition
|
|
2621
|
+
: labelPlacement === "CENTER" && !horizontal && labelDummyRect
|
|
2622
|
+
? labelDummyRect.y
|
|
2623
|
+
: routeY;
|
|
2569
2624
|
return {
|
|
2570
2625
|
...edge,
|
|
2571
2626
|
x,
|
|
@@ -2582,6 +2637,22 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
2582
2637
|
};
|
|
2583
2638
|
});
|
|
2584
2639
|
|
|
2640
|
+
if (edgeRouting === "ORTHOGONAL") {
|
|
2641
|
+
separateExteriorLabels({
|
|
2642
|
+
edges,
|
|
2643
|
+
nodeRects: feedbackNodeRects,
|
|
2644
|
+
direction,
|
|
2645
|
+
spacing: Number(options.settings?.["spacing.edgeEdge"] ?? 10),
|
|
2646
|
+
settings: (edge) => {
|
|
2647
|
+
const edgeSettings = options.edgeSettings?.(edge);
|
|
2648
|
+
return {
|
|
2649
|
+
inline: edgeSettings?.["edgeLabels.inline"] === true,
|
|
2650
|
+
placement: edgeSettings?.["edgeLabels.placement"] ?? "CENTER",
|
|
2651
|
+
};
|
|
2652
|
+
},
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2585
2656
|
return {
|
|
2586
2657
|
...graph,
|
|
2587
2658
|
direction,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { EntityRect, Point } from "@statelyai/graph";
|
|
2
|
+
|
|
3
|
+
export interface ExteriorLabelEdge {
|
|
4
|
+
id: string;
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
points: Point[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ExteriorLabelSettings {
|
|
13
|
+
inline: boolean;
|
|
14
|
+
placement: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SeparateExteriorLabelsInput<E extends ExteriorLabelEdge> {
|
|
18
|
+
edges: E[];
|
|
19
|
+
nodeRects: readonly EntityRect[];
|
|
20
|
+
direction: "up" | "down" | "left" | "right";
|
|
21
|
+
spacing: number;
|
|
22
|
+
settings: (edge: E) => ExteriorLabelSettings;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Separates movable inline labels and their exterior orthogonal route tracks. */
|
|
26
|
+
export function separateExteriorLabels<E extends ExteriorLabelEdge>({
|
|
27
|
+
edges,
|
|
28
|
+
nodeRects,
|
|
29
|
+
direction,
|
|
30
|
+
spacing,
|
|
31
|
+
settings,
|
|
32
|
+
}: SeparateExteriorLabelsInput<E>): void {
|
|
33
|
+
const horizontalFlow = direction === "left" || direction === "right";
|
|
34
|
+
const nodeCrossStart = Math.min(...nodeRects.map((rect) => (horizontalFlow ? rect.y : rect.x)));
|
|
35
|
+
const nodeCrossEnd = Math.max(
|
|
36
|
+
...nodeRects.map((rect) => (horizontalFlow ? rect.y + rect.height : rect.x + rect.width)),
|
|
37
|
+
);
|
|
38
|
+
const flowStart = (rect: EntityRect): number => (horizontalFlow ? rect.x : rect.y);
|
|
39
|
+
const flowEnd = (rect: EntityRect): number =>
|
|
40
|
+
horizontalFlow ? rect.x + rect.width : rect.y + rect.height;
|
|
41
|
+
const crossStart = (rect: EntityRect): number => (horizontalFlow ? rect.y : rect.x);
|
|
42
|
+
const crossEnd = (rect: EntityRect): number =>
|
|
43
|
+
horizontalFlow ? rect.y + rect.height : rect.x + rect.width;
|
|
44
|
+
const overlaps = (left: EntityRect, right: EntityRect): boolean =>
|
|
45
|
+
flowStart(left) < flowEnd(right) &&
|
|
46
|
+
flowEnd(left) > flowStart(right) &&
|
|
47
|
+
crossStart(left) < crossEnd(right) &&
|
|
48
|
+
crossEnd(left) > crossStart(right);
|
|
49
|
+
type MutableRect = { x: number; y: number; width: number; height: number };
|
|
50
|
+
const labelRectByEdgeId = new Map<string, MutableRect>(
|
|
51
|
+
edges
|
|
52
|
+
.filter((edge) => edge.width > 0 && edge.height > 0)
|
|
53
|
+
.map((edge) => [
|
|
54
|
+
String(edge.id),
|
|
55
|
+
{ x: edge.x, y: edge.y, width: edge.width, height: edge.height },
|
|
56
|
+
]),
|
|
57
|
+
);
|
|
58
|
+
const movableLabels = edges.flatMap((edge) => {
|
|
59
|
+
const labelRect = labelRectByEdgeId.get(String(edge.id));
|
|
60
|
+
const labelSettings = settings(edge);
|
|
61
|
+
if (!labelRect || !labelSettings.inline || labelSettings.placement !== "CENTER") return [];
|
|
62
|
+
const exteriorTrack = edge.points
|
|
63
|
+
.flatMap((point, index) => {
|
|
64
|
+
const next = edge.points[index + 1];
|
|
65
|
+
if (!next) return [];
|
|
66
|
+
const isFlowSegment = horizontalFlow
|
|
67
|
+
? point.y === next.y && point.x !== next.x
|
|
68
|
+
: point.x === next.x && point.y !== next.y;
|
|
69
|
+
const trackCross = horizontalFlow ? point.y : point.x;
|
|
70
|
+
return isFlowSegment && (trackCross < nodeCrossStart || trackCross > nodeCrossEnd)
|
|
71
|
+
? [
|
|
72
|
+
{
|
|
73
|
+
cross: trackCross,
|
|
74
|
+
length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y),
|
|
75
|
+
},
|
|
76
|
+
]
|
|
77
|
+
: [];
|
|
78
|
+
})
|
|
79
|
+
.sort((left, right) => right.length - left.length)[0];
|
|
80
|
+
if (!exteriorTrack) return [];
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
edge,
|
|
84
|
+
exteriorTrack,
|
|
85
|
+
labelRect,
|
|
86
|
+
lowSide: exteriorTrack.cross < (nodeCrossStart + nodeCrossEnd) / 2,
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
movableLabels.sort((left, right) => {
|
|
92
|
+
if (left.lowSide !== right.lowSide) return left.lowSide ? -1 : 1;
|
|
93
|
+
const crossDifference = left.lowSide
|
|
94
|
+
? crossStart(left.labelRect) - crossStart(right.labelRect)
|
|
95
|
+
: crossStart(right.labelRect) - crossStart(left.labelRect);
|
|
96
|
+
return (
|
|
97
|
+
crossDifference ||
|
|
98
|
+
flowStart(left.labelRect) - flowStart(right.labelRect) ||
|
|
99
|
+
(String(left.edge.id) < String(right.edge.id)
|
|
100
|
+
? -1
|
|
101
|
+
: String(left.edge.id) > String(right.edge.id)
|
|
102
|
+
? 1
|
|
103
|
+
: 0)
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (const { edge, exteriorTrack, labelRect, lowSide } of movableLabels) {
|
|
108
|
+
const obstacles = [
|
|
109
|
+
...nodeRects,
|
|
110
|
+
...[...labelRectByEdgeId.entries()].flatMap(([edgeId, rect]) =>
|
|
111
|
+
edgeId === String(edge.id) ? [] : [rect],
|
|
112
|
+
),
|
|
113
|
+
];
|
|
114
|
+
let totalDelta = 0;
|
|
115
|
+
while (true) {
|
|
116
|
+
const blockers = obstacles.filter((rect) => overlaps(labelRect, rect));
|
|
117
|
+
if (blockers.length === 0) break;
|
|
118
|
+
const desiredCross = lowSide
|
|
119
|
+
? Math.min(...blockers.map(crossStart)) -
|
|
120
|
+
spacing -
|
|
121
|
+
(horizontalFlow ? edge.height : edge.width)
|
|
122
|
+
: Math.max(...blockers.map(crossEnd)) + spacing;
|
|
123
|
+
const delta = desiredCross - crossStart(labelRect);
|
|
124
|
+
totalDelta += delta;
|
|
125
|
+
if (horizontalFlow) labelRect.y += delta;
|
|
126
|
+
else labelRect.x += delta;
|
|
127
|
+
}
|
|
128
|
+
if (totalDelta === 0) continue;
|
|
129
|
+
if (horizontalFlow) edge.y += totalDelta;
|
|
130
|
+
else edge.x += totalDelta;
|
|
131
|
+
edge.points = edge.points.map((point) => {
|
|
132
|
+
const pointCross = horizontalFlow ? point.y : point.x;
|
|
133
|
+
if (pointCross !== exteriorTrack.cross) return point;
|
|
134
|
+
return horizontalFlow
|
|
135
|
+
? { ...point, y: point.y + totalDelta }
|
|
136
|
+
: { ...point, x: point.x + totalDelta };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|