@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.
@@ -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
+ }
@@ -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
- if (horizontal && side === "NORTH") {
1675
- mutableRects.set(edge.targetId, {
1676
- ...targetRect,
1677
- y: sourceRect.y - (port.height ?? 0) - targetRect.height,
1678
- });
1679
- } else if (horizontal && side === "SOUTH") {
1680
- mutableRects.set(edge.targetId, {
1681
- ...targetRect,
1682
- y: sourceRect.y + sourceRect.height + (port.height ?? 0),
1683
- });
1684
- } else if (!horizontal && side === "WEST") {
1685
- mutableRects.set(edge.targetId, {
1686
- ...targetRect,
1687
- x: sourceRect.x - (port.width ?? 0) - targetRect.width,
1688
- });
1689
- } else if (!horizontal && side === "EAST") {
1690
- mutableRects.set(edge.targetId, {
1691
- ...targetRect,
1692
- x: sourceRect.x + sourceRect.width + (port.width ?? 0),
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", () => applyPostCompaction(expanded.input, placement, expandedRoutes));
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
- : labelPlacement === "CENTER" && horizontal && labelDummyRect
2558
- ? labelDummyRect.x
2559
- : routeX;
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
- : labelPlacement === "CENTER" && !horizontal && labelDummyRect
2567
- ? labelDummyRect.y
2568
- : routeY;
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
+ }