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