@statelyai/layout 0.0.5 → 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-BYhTymAk.mjs → layered-CZs9YWVx.mjs} +120 -28
- package/dist/{spore-B504wOFR.mjs → spore-9N0W9dZn.mjs} +1 -1
- package/package.json +1 -1
- package/src/elkjs/index.ts +15 -1
- package/src/elkjs/statechart.ts +338 -0
- package/src/layered/index.ts +82 -28
- 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
|
@@ -1664,6 +1664,13 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
1664
1664
|
}
|
|
1665
1665
|
{
|
|
1666
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
|
+
}
|
|
1667
1674
|
for (const edge of graph.edges) {
|
|
1668
1675
|
if (edge.sourcePort === undefined) continue;
|
|
1669
1676
|
const source = graph.nodes.find((node) => node.id === edge.sourceId);
|
|
@@ -1672,27 +1679,35 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
1672
1679
|
const port = source?.ports?.find((candidate) => candidate.name === edge.sourcePort);
|
|
1673
1680
|
if (!source || !sourceRect || !targetRect || !port) continue;
|
|
1674
1681
|
const side = input.portSettings?.(port, source)?.["port.side"];
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
mutableRects.
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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);
|
|
1696
1711
|
}
|
|
1697
1712
|
}
|
|
1698
1713
|
measure("port-margin-normalization", () =>
|
|
@@ -1759,7 +1774,9 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
1759
1774
|
let expandedRoutes = measure("edge-routing", () =>
|
|
1760
1775
|
edgeRouter(expanded.input, expanded.orientation, placement),
|
|
1761
1776
|
);
|
|
1762
|
-
measure("post-compaction", () =>
|
|
1777
|
+
measure("post-compaction", () =>
|
|
1778
|
+
applyPostCompaction(expanded.input, placement, expandedRoutes, graph.edges),
|
|
1779
|
+
);
|
|
1763
1780
|
const antiparallelLabelPositions = new Map<string, Point>();
|
|
1764
1781
|
const outerAntiparallelLabelIds = new Set<string>();
|
|
1765
1782
|
const parallelLabelPositions = new Map<string, Point>();
|
|
@@ -2499,6 +2516,7 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
2499
2516
|
const flexibleFeedbackLabel =
|
|
2500
2517
|
labelPlacement === "CENTER" &&
|
|
2501
2518
|
inlineLabel &&
|
|
2519
|
+
edge.sourceId !== edge.targetId &&
|
|
2502
2520
|
expanded.orientation.reversedEdgeIds.has(edge.id) &&
|
|
2503
2521
|
hasFlexiblePorts(sourceNode) &&
|
|
2504
2522
|
hasFlexiblePorts(targetNode) &&
|
|
@@ -2545,7 +2563,39 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
2545
2563
|
y,
|
|
2546
2564
|
};
|
|
2547
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;
|
|
2548
2597
|
const explicitLabelPosition =
|
|
2598
|
+
selfLoopLabelPosition ??
|
|
2549
2599
|
outerAntiparallelLabelPosition ??
|
|
2550
2600
|
antiparallelLabelPosition ??
|
|
2551
2601
|
parallelLabelPositions.get(edge.id);
|
|
@@ -2555,18 +2605,22 @@ function runLayeredPipeline<N, E, G, P>(
|
|
|
2555
2605
|
? horizontal
|
|
2556
2606
|
? (beforeFlowRect!.x + beforeFlowRect!.width + afterFlowRect!.x - width) / 2
|
|
2557
2607
|
: targetRect.x + (targetRect.width - width) / 2
|
|
2558
|
-
:
|
|
2559
|
-
?
|
|
2560
|
-
:
|
|
2608
|
+
: centerFlowPosition !== undefined && horizontal
|
|
2609
|
+
? centerFlowPosition
|
|
2610
|
+
: labelPlacement === "CENTER" && horizontal && labelDummyRect
|
|
2611
|
+
? labelDummyRect.x
|
|
2612
|
+
: routeX;
|
|
2561
2613
|
const y = explicitLabelPosition
|
|
2562
2614
|
? explicitLabelPosition.y
|
|
2563
2615
|
: flexibleFeedbackLabel
|
|
2564
2616
|
? horizontal
|
|
2565
2617
|
? targetRect.y + (targetRect.height - height) / 2
|
|
2566
2618
|
: (beforeFlowRect!.y + beforeFlowRect!.height + afterFlowRect!.y - height) / 2
|
|
2567
|
-
:
|
|
2568
|
-
?
|
|
2569
|
-
:
|
|
2619
|
+
: centerFlowPosition !== undefined && !horizontal
|
|
2620
|
+
? centerFlowPosition
|
|
2621
|
+
: labelPlacement === "CENTER" && !horizontal && labelDummyRect
|
|
2622
|
+
? labelDummyRect.y
|
|
2623
|
+
: routeY;
|
|
2570
2624
|
return {
|
|
2571
2625
|
...edge,
|
|
2572
2626
|
x,
|