@statelyai/layout 0.1.1 → 0.1.2

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 CHANGED
@@ -73,7 +73,7 @@ Native algorithms never consume ELK JSON directly.
73
73
 
74
74
  <!-- fixed-port placement and inline center-label guarantees from src/layered/index.ts and src/layered/strategies.ts -->
75
75
 
76
- Fixed port sides constrain routes without collapsing fan-out targets onto each other. Port preferences cannot reintroduce cycles before layering. Inline center labels use reserved inter-rank space; dedicated label layers and route-track compaction retain their placement strategies. Inline self-loop labels reserve clearance on their assigned sides from both their owner and neighboring nodes. Hierarchy decomposition preserves native self-loop routes for ancestor-to-descendant edges. FIRST/FIRST_SEPARATE nodes may have self-loops.
76
+ Fixed port sides constrain routes without collapsing fan-out targets onto each other. Port preferences cannot reintroduce cycles before layering. Inline center labels use reserved inter-rank space; orthogonally routed labels use distinct cross-axis lanes that avoid other labels and states. Dedicated label layers and route-track compaction retain their placement strategies. Inline self-loop labels reserve clearance on their assigned sides from both their owner and neighboring nodes. Hierarchy decomposition preserves native self-loop routes for ancestor-to-descendant edges. FIRST/FIRST_SEPARATE nodes may have self-loops.
77
77
 
78
78
  Advanced layered settings use shorter names such as
79
79
  `layering.strategy`, `spacing.edgeNode`, and `nodePlacement.strategy`.
@@ -1,5 +1,5 @@
1
- import { o as elkLayeredOptionDefinitions, t as getLayeredLayout } from "../layered-CZs9YWVx.mjs";
2
- import { a as getRandomLayout, f as getFixedLayout, n as getSporeOverlapRemovalLayout, s as getRectanglePackingLayout, t as getSporeCompactionLayout, u as getBoxLayout } from "../spore-9N0W9dZn.mjs";
1
+ import { o as elkLayeredOptionDefinitions, t as getLayeredLayout } from "../layered-COIyXvwb.mjs";
2
+ import { a as getRandomLayout, f as getFixedLayout, n as getSporeOverlapRemovalLayout, s as getRectanglePackingLayout, t as getSporeCompactionLayout, u as getBoxLayout } from "../spore-BeKUA1Mb.mjs";
3
3
  import { createGraph } from "@statelyai/graph";
4
4
 
5
5
  //#region src/elkjs/statechart.ts
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { A as minimizeCrossingsWithBarycenter, B as LayoutError, C as breakCyclesGreedilyByModelOrder, D as breakCyclesWithModelOrderDepthFirstSearch, E as breakCyclesWithModelOrderBreadthFirstSearch, I as routeEdgesOrthogonally, L as routeEdgesWithPolylines, M as minimizeCrossingsWithModelOrder, N as placeNodesInLayers, P as placeNodesInteractively, R as routeEdgesWithSplines, S as breakCyclesGreedily, T as breakCyclesWithDepthFirstSearch, V as UnsupportedLayoutError, _ as assignLayersInteractively, a as elkLayeredEnumValues, b as breakCyclesByStronglyConnectedConnectivity, d as assignLayersWithMinWidth, f as assignLayersWithNetworkSimplex, g as assignLayersByLongestPathToSink, h as assignLayersByLongestPath, i as toElkLayeredOptions, j as minimizeCrossingsWithMedian, k as minimizeCrossingsInteractively, m as assignLayersByDepthFirstModelOrder, n as layeredAlgorithm, o as elkLayeredOptionDefinitions, p as assignLayersByBreadthFirstModelOrder, r as fromElkLayeredOptionId, t as getLayeredLayout, u as assignLayersWithStretchWidth, v as assignLayersWithCoffmanGraham, w as breakCyclesInteractively, x as breakCyclesByStronglyConnectedNodeType, y as breakCyclesByModelOrder } from "./layered-CZs9YWVx.mjs";
2
- import { a as getRandomLayout, c as rectanglePackingAlgorithm, d as fixedAlgorithm, f as getFixedLayout, i as sporeOverlapRemovalAlgorithm, l as boxAlgorithm, n as getSporeOverlapRemovalLayout, o as randomAlgorithm, r as sporeCompactionAlgorithm, s as getRectanglePackingLayout, t as getSporeCompactionLayout, u as getBoxLayout } from "./spore-9N0W9dZn.mjs";
1
+ import { A as minimizeCrossingsWithBarycenter, B as LayoutError, C as breakCyclesGreedilyByModelOrder, D as breakCyclesWithModelOrderDepthFirstSearch, E as breakCyclesWithModelOrderBreadthFirstSearch, I as routeEdgesOrthogonally, L as routeEdgesWithPolylines, M as minimizeCrossingsWithModelOrder, N as placeNodesInLayers, P as placeNodesInteractively, R as routeEdgesWithSplines, S as breakCyclesGreedily, T as breakCyclesWithDepthFirstSearch, V as UnsupportedLayoutError, _ as assignLayersInteractively, a as elkLayeredEnumValues, b as breakCyclesByStronglyConnectedConnectivity, d as assignLayersWithMinWidth, f as assignLayersWithNetworkSimplex, g as assignLayersByLongestPathToSink, h as assignLayersByLongestPath, i as toElkLayeredOptions, j as minimizeCrossingsWithMedian, k as minimizeCrossingsInteractively, m as assignLayersByDepthFirstModelOrder, n as layeredAlgorithm, o as elkLayeredOptionDefinitions, p as assignLayersByBreadthFirstModelOrder, r as fromElkLayeredOptionId, t as getLayeredLayout, u as assignLayersWithStretchWidth, v as assignLayersWithCoffmanGraham, w as breakCyclesInteractively, x as breakCyclesByStronglyConnectedNodeType, y as breakCyclesByModelOrder } from "./layered-COIyXvwb.mjs";
2
+ import { a as getRandomLayout, c as rectanglePackingAlgorithm, d as fixedAlgorithm, f as getFixedLayout, i as sporeOverlapRemovalAlgorithm, l as boxAlgorithm, n as getSporeOverlapRemovalLayout, o as randomAlgorithm, r as sporeCompactionAlgorithm, s as getRectanglePackingLayout, t as getSporeCompactionLayout, u as getBoxLayout } from "./spore-BeKUA1Mb.mjs";
3
3
  import { getGraphIssues } from "@statelyai/graph";
4
4
 
5
5
  //#region src/layout.ts
@@ -1,3 +1,3 @@
1
- import { A as minimizeCrossingsWithBarycenter, C as breakCyclesGreedilyByModelOrder, D as breakCyclesWithModelOrderDepthFirstSearch, E as breakCyclesWithModelOrderBreadthFirstSearch, I as routeEdgesOrthogonally, L as routeEdgesWithPolylines, M as minimizeCrossingsWithModelOrder, N as placeNodesInLayers, P as placeNodesInteractively, R as routeEdgesWithSplines, S as breakCyclesGreedily, T as breakCyclesWithDepthFirstSearch, _ as assignLayersInteractively, a as elkLayeredEnumValues, b as breakCyclesByStronglyConnectedConnectivity, c as placeNodesWithLinearSegments, d as assignLayersWithMinWidth, f as assignLayersWithNetworkSimplex, g as assignLayersByLongestPathToSink, h as assignLayersByLongestPath, i as toElkLayeredOptions, j as minimizeCrossingsWithMedian, k as minimizeCrossingsInteractively, l as placeNodesWithBrandesKoepf, m as assignLayersByDepthFirstModelOrder, n as layeredAlgorithm, o as elkLayeredOptionDefinitions, p as assignLayersByBreadthFirstModelOrder, r as fromElkLayeredOptionId, s as placeNodesWithNetworkSimplex, t as getLayeredLayout, u as assignLayersWithStretchWidth, v as assignLayersWithCoffmanGraham, w as breakCyclesInteractively, x as breakCyclesByStronglyConnectedNodeType, y as breakCyclesByModelOrder } from "../layered-CZs9YWVx.mjs";
1
+ import { A as minimizeCrossingsWithBarycenter, C as breakCyclesGreedilyByModelOrder, D as breakCyclesWithModelOrderDepthFirstSearch, E as breakCyclesWithModelOrderBreadthFirstSearch, I as routeEdgesOrthogonally, L as routeEdgesWithPolylines, M as minimizeCrossingsWithModelOrder, N as placeNodesInLayers, P as placeNodesInteractively, R as routeEdgesWithSplines, S as breakCyclesGreedily, T as breakCyclesWithDepthFirstSearch, _ as assignLayersInteractively, a as elkLayeredEnumValues, b as breakCyclesByStronglyConnectedConnectivity, c as placeNodesWithLinearSegments, d as assignLayersWithMinWidth, f as assignLayersWithNetworkSimplex, g as assignLayersByLongestPathToSink, h as assignLayersByLongestPath, i as toElkLayeredOptions, j as minimizeCrossingsWithMedian, k as minimizeCrossingsInteractively, l as placeNodesWithBrandesKoepf, m as assignLayersByDepthFirstModelOrder, n as layeredAlgorithm, o as elkLayeredOptionDefinitions, p as assignLayersByBreadthFirstModelOrder, r as fromElkLayeredOptionId, s as placeNodesWithNetworkSimplex, t as getLayeredLayout, u as assignLayersWithStretchWidth, v as assignLayersWithCoffmanGraham, w as breakCyclesInteractively, x as breakCyclesByStronglyConnectedNodeType, y as breakCyclesByModelOrder } from "../layered-COIyXvwb.mjs";
2
2
 
3
3
  export { assignLayersByBreadthFirstModelOrder, assignLayersByDepthFirstModelOrder, assignLayersByLongestPath, assignLayersByLongestPathToSink, assignLayersInteractively, assignLayersWithCoffmanGraham, assignLayersWithMinWidth, assignLayersWithNetworkSimplex, assignLayersWithStretchWidth, breakCyclesByModelOrder, breakCyclesByStronglyConnectedConnectivity, breakCyclesByStronglyConnectedNodeType, breakCyclesGreedily, breakCyclesGreedilyByModelOrder, breakCyclesInteractively, breakCyclesWithDepthFirstSearch, breakCyclesWithModelOrderBreadthFirstSearch, breakCyclesWithModelOrderDepthFirstSearch, elkLayeredEnumValues, elkLayeredOptionDefinitions, fromElkLayeredOptionId, getLayeredLayout, layeredAlgorithm, minimizeCrossingsInteractively, minimizeCrossingsWithBarycenter, minimizeCrossingsWithMedian, minimizeCrossingsWithModelOrder, placeNodesInLayers, placeNodesInteractively, placeNodesWithBrandesKoepf, placeNodesWithLinearSegments, placeNodesWithNetworkSimplex, routeEdgesOrthogonally, routeEdgesWithPolylines, routeEdgesWithSplines, toElkLayeredOptions };
@@ -3781,7 +3781,7 @@ function getPolylineMidpoint(points) {
3781
3781
 
3782
3782
  //#endregion
3783
3783
  //#region src/layered/separate-exterior-labels.ts
3784
- /** Separates movable inline labels and their exterior orthogonal route tracks. */
3784
+ /** Assigns collision-free cross-axis lanes to inline labels and their route tracks. */
3785
3785
  function separateExteriorLabels({ edges, nodeRects, direction, spacing, settings }) {
3786
3786
  const horizontalFlow = direction === "left" || direction === "right";
3787
3787
  const nodeCrossStart = Math.min(...nodeRects.map((rect) => horizontalFlow ? rect.y : rect.x));
@@ -3808,25 +3808,80 @@ function separateExteriorLabels({ edges, nodeRects, direction, spacing, settings
3808
3808
  const trackCross = horizontalFlow ? point.y : point.x;
3809
3809
  return isFlowSegment && (trackCross < nodeCrossStart || trackCross > nodeCrossEnd) ? [{
3810
3810
  cross: trackCross,
3811
- length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y)
3811
+ length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y),
3812
+ flowStart: horizontalFlow ? Math.min(point.x, next.x) : Math.min(point.y, next.y),
3813
+ flowEnd: horizontalFlow ? Math.max(point.x, next.x) : Math.max(point.y, next.y),
3814
+ startIndex: index
3812
3815
  }] : [];
3813
3816
  }).sort((left, right) => right.length - left.length)[0];
3814
- if (!exteriorTrack) return [];
3817
+ const routeTrack = exteriorTrack ?? edge.points.flatMap((point, index) => {
3818
+ const next = edge.points[index + 1];
3819
+ if (!next) return [];
3820
+ if (!(horizontalFlow ? point.y === next.y && point.x !== next.x : point.x === next.x && point.y !== next.y)) return [];
3821
+ return [{
3822
+ cross: horizontalFlow ? point.y : point.x,
3823
+ length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y),
3824
+ flowStart: horizontalFlow ? Math.min(point.x, next.x) : Math.min(point.y, next.y),
3825
+ flowEnd: horizontalFlow ? Math.max(point.x, next.x) : Math.max(point.y, next.y),
3826
+ startIndex: index
3827
+ }];
3828
+ }).sort((left, right) => Math.abs(left.cross - (crossStart(labelRect) + crossEnd(labelRect)) / 2) - Math.abs(right.cross - (crossStart(labelRect) + crossEnd(labelRect)) / 2) || right.length - left.length)[0];
3829
+ if (!routeTrack) return [];
3815
3830
  return [{
3816
3831
  edge,
3817
- exteriorTrack,
3832
+ exteriorTrack: routeTrack,
3833
+ preserveAnchors: exteriorTrack === void 0,
3818
3834
  labelRect,
3819
- lowSide: exteriorTrack.cross < (nodeCrossStart + nodeCrossEnd) / 2
3835
+ lowSide: (crossStart(labelRect) + crossEnd(labelRect)) / 2 < (nodeCrossStart + nodeCrossEnd) / 2
3820
3836
  }];
3821
3837
  });
3822
3838
  movableLabels.sort((left, right) => {
3823
3839
  if (left.lowSide !== right.lowSide) return left.lowSide ? -1 : 1;
3824
3840
  return (left.lowSide ? crossStart(left.labelRect) - crossStart(right.labelRect) : crossStart(right.labelRect) - crossStart(left.labelRect)) || flowStart(left.labelRect) - flowStart(right.labelRect) || (String(left.edge.id) < String(right.edge.id) ? -1 : String(left.edge.id) > String(right.edge.id) ? 1 : 0);
3825
3841
  });
3826
- for (const { edge, exteriorTrack, labelRect, lowSide } of movableLabels) {
3842
+ for (const { edge, exteriorTrack, labelRect, lowSide, preserveAnchors } of movableLabels) {
3827
3843
  const obstacles = [...nodeRects, ...[...labelRectByEdgeId.entries()].flatMap(([edgeId, rect]) => edgeId === String(edge.id) ? [] : [rect])];
3828
3844
  let totalDelta = 0;
3829
- while (true) {
3845
+ if (preserveAnchors) {
3846
+ const labelCrossStart = crossStart(labelRect);
3847
+ const labelCrossSize = crossEnd(labelRect) - labelCrossStart;
3848
+ const trackOffset = exteriorTrack.cross - labelCrossStart;
3849
+ const candidates = new Set([labelCrossStart]);
3850
+ for (const obstacle of obstacles) {
3851
+ candidates.add(crossStart(obstacle) - spacing - labelCrossSize);
3852
+ candidates.add(crossEnd(obstacle) + spacing);
3853
+ candidates.add(crossStart(obstacle) - spacing - trackOffset);
3854
+ candidates.add(crossEnd(obstacle) + spacing - trackOffset);
3855
+ }
3856
+ const orderedCandidates = [...candidates].sort((left, right) => {
3857
+ const leftPreferred = lowSide ? left <= labelCrossStart : left >= labelCrossStart;
3858
+ const rightPreferred = lowSide ? right <= labelCrossStart : right >= labelCrossStart;
3859
+ return Number(rightPreferred) - Number(leftPreferred) || Math.abs(left - labelCrossStart) - Math.abs(right - labelCrossStart) || left - right;
3860
+ });
3861
+ const lineIntersects = (rect, candidateTrackCross, connectorFlow) => {
3862
+ const movedCrossStart = Math.min(exteriorTrack.cross, candidateTrackCross);
3863
+ const movedCrossEnd = Math.max(exteriorTrack.cross, candidateTrackCross);
3864
+ const trackIntersects = exteriorTrack.flowStart < flowEnd(rect) && exteriorTrack.flowEnd > flowStart(rect) && candidateTrackCross > crossStart(rect) && candidateTrackCross < crossEnd(rect);
3865
+ const connectorIntersects = connectorFlow > flowStart(rect) && connectorFlow < flowEnd(rect) && movedCrossStart < crossEnd(rect) && movedCrossEnd > crossStart(rect);
3866
+ return trackIntersects || connectorIntersects;
3867
+ };
3868
+ const candidate = orderedCandidates.find((candidateLabelCross) => {
3869
+ const delta = candidateLabelCross - labelCrossStart;
3870
+ const candidateLabel = horizontalFlow ? {
3871
+ ...labelRect,
3872
+ y: labelRect.y + delta
3873
+ } : {
3874
+ ...labelRect,
3875
+ x: labelRect.x + delta
3876
+ };
3877
+ const candidateTrackCross = exteriorTrack.cross + delta;
3878
+ return obstacles.every((obstacle) => !overlaps(candidateLabel, obstacle)) && nodeRects.every((nodeRect) => !lineIntersects(nodeRect, candidateTrackCross, exteriorTrack.flowStart) && !lineIntersects(nodeRect, candidateTrackCross, exteriorTrack.flowEnd));
3879
+ });
3880
+ if (candidate === void 0) continue;
3881
+ totalDelta = candidate - labelCrossStart;
3882
+ if (horizontalFlow) labelRect.y += totalDelta;
3883
+ else labelRect.x += totalDelta;
3884
+ } else while (true) {
3830
3885
  const blockers = obstacles.filter((rect) => overlaps(labelRect, rect));
3831
3886
  if (blockers.length === 0) break;
3832
3887
  const delta = (lowSide ? Math.min(...blockers.map(crossStart)) - spacing - (horizontalFlow ? edge.height : edge.width) : Math.max(...blockers.map(crossEnd)) + spacing) - crossStart(labelRect);
@@ -3837,15 +3892,17 @@ function separateExteriorLabels({ edges, nodeRects, direction, spacing, settings
3837
3892
  if (totalDelta === 0) continue;
3838
3893
  if (horizontalFlow) edge.y += totalDelta;
3839
3894
  else edge.x += totalDelta;
3840
- edge.points = edge.points.map((point) => {
3841
- if ((horizontalFlow ? point.y : point.x) !== exteriorTrack.cross) return point;
3842
- return horizontalFlow ? {
3895
+ if (!exteriorTrack) continue;
3896
+ edge.points = edge.points.flatMap((point, index) => {
3897
+ if (index !== exteriorTrack.startIndex && index !== exteriorTrack.startIndex + 1) return [point];
3898
+ const shifted = horizontalFlow ? {
3843
3899
  ...point,
3844
3900
  y: point.y + totalDelta
3845
3901
  } : {
3846
3902
  ...point,
3847
3903
  x: point.x + totalDelta
3848
3904
  };
3905
+ return preserveAnchors && index === exteriorTrack.startIndex && index === 0 ? [point, shifted] : preserveAnchors && index === exteriorTrack.startIndex + 1 && index === edge.points.length - 1 ? [shifted, point] : [shifted];
3849
3906
  });
3850
3907
  }
3851
3908
  }
@@ -1,4 +1,4 @@
1
- import { F as placePorts, O as getPolylineMidpoint, z as JavaRandom } from "./layered-CZs9YWVx.mjs";
1
+ import { F as placePorts, O as getPolylineMidpoint, z as JavaRandom } from "./layered-COIyXvwb.mjs";
2
2
  import { getNodeSize } from "@statelyai/graph/layout";
3
3
 
4
4
  //#region src/fixed.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statelyai/layout",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Extensible, graph-native layout algorithms for @statelyai/graph",
5
5
  "license": "EPL-2.0 OR GPL-3.0-or-later",
6
6
  "repository": {
@@ -22,7 +22,7 @@ interface SeparateExteriorLabelsInput<E extends ExteriorLabelEdge> {
22
22
  settings: (edge: E) => ExteriorLabelSettings;
23
23
  }
24
24
 
25
- /** Separates movable inline labels and their exterior orthogonal route tracks. */
25
+ /** Assigns collision-free cross-axis lanes to inline labels and their route tracks. */
26
26
  export function separateExteriorLabels<E extends ExteriorLabelEdge>({
27
27
  edges,
28
28
  nodeRects,
@@ -72,18 +72,50 @@ export function separateExteriorLabels<E extends ExteriorLabelEdge>({
72
72
  {
73
73
  cross: trackCross,
74
74
  length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y),
75
+ flowStart: horizontalFlow ? Math.min(point.x, next.x) : Math.min(point.y, next.y),
76
+ flowEnd: horizontalFlow ? Math.max(point.x, next.x) : Math.max(point.y, next.y),
77
+ startIndex: index,
75
78
  },
76
79
  ]
77
80
  : [];
78
81
  })
79
82
  .sort((left, right) => right.length - left.length)[0];
80
- if (!exteriorTrack) return [];
83
+ const routeTrack =
84
+ exteriorTrack ??
85
+ edge.points
86
+ .flatMap((point, index) => {
87
+ const next = edge.points[index + 1];
88
+ if (!next) return [];
89
+ const isFlowSegment = horizontalFlow
90
+ ? point.y === next.y && point.x !== next.x
91
+ : point.x === next.x && point.y !== next.y;
92
+ if (!isFlowSegment) return [];
93
+ const cross = horizontalFlow ? point.y : point.x;
94
+ return [
95
+ {
96
+ cross,
97
+ length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y),
98
+ flowStart: horizontalFlow ? Math.min(point.x, next.x) : Math.min(point.y, next.y),
99
+ flowEnd: horizontalFlow ? Math.max(point.x, next.x) : Math.max(point.y, next.y),
100
+ startIndex: index,
101
+ },
102
+ ];
103
+ })
104
+ .sort(
105
+ (left, right) =>
106
+ Math.abs(left.cross - (crossStart(labelRect) + crossEnd(labelRect)) / 2) -
107
+ Math.abs(right.cross - (crossStart(labelRect) + crossEnd(labelRect)) / 2) ||
108
+ right.length - left.length,
109
+ )[0];
110
+ if (!routeTrack) return [];
81
111
  return [
82
112
  {
83
113
  edge,
84
- exteriorTrack,
114
+ exteriorTrack: routeTrack,
115
+ preserveAnchors: exteriorTrack === undefined,
85
116
  labelRect,
86
- lowSide: exteriorTrack.cross < (nodeCrossStart + nodeCrossEnd) / 2,
117
+ lowSide:
118
+ (crossStart(labelRect) + crossEnd(labelRect)) / 2 < (nodeCrossStart + nodeCrossEnd) / 2,
87
119
  },
88
120
  ];
89
121
  });
@@ -104,7 +136,7 @@ export function separateExteriorLabels<E extends ExteriorLabelEdge>({
104
136
  );
105
137
  });
106
138
 
107
- for (const { edge, exteriorTrack, labelRect, lowSide } of movableLabels) {
139
+ for (const { edge, exteriorTrack, labelRect, lowSide, preserveAnchors } of movableLabels) {
108
140
  const obstacles = [
109
141
  ...nodeRects,
110
142
  ...[...labelRectByEdgeId.entries()].flatMap(([edgeId, rect]) =>
@@ -112,28 +144,97 @@ export function separateExteriorLabels<E extends ExteriorLabelEdge>({
112
144
  ),
113
145
  ];
114
146
  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;
147
+ if (preserveAnchors) {
148
+ const labelCrossStart = crossStart(labelRect);
149
+ const labelCrossSize = crossEnd(labelRect) - labelCrossStart;
150
+ const trackOffset = exteriorTrack.cross - labelCrossStart;
151
+ const candidates = new Set<number>([labelCrossStart]);
152
+ for (const obstacle of obstacles) {
153
+ candidates.add(crossStart(obstacle) - spacing - labelCrossSize);
154
+ candidates.add(crossEnd(obstacle) + spacing);
155
+ candidates.add(crossStart(obstacle) - spacing - trackOffset);
156
+ candidates.add(crossEnd(obstacle) + spacing - trackOffset);
157
+ }
158
+ const orderedCandidates = [...candidates].sort((left, right) => {
159
+ const leftPreferred = lowSide ? left <= labelCrossStart : left >= labelCrossStart;
160
+ const rightPreferred = lowSide ? right <= labelCrossStart : right >= labelCrossStart;
161
+ return (
162
+ Number(rightPreferred) - Number(leftPreferred) ||
163
+ Math.abs(left - labelCrossStart) - Math.abs(right - labelCrossStart) ||
164
+ left - right
165
+ );
166
+ });
167
+ const lineIntersects = (
168
+ rect: EntityRect,
169
+ candidateTrackCross: number,
170
+ connectorFlow: number,
171
+ ): boolean => {
172
+ const movedCrossStart = Math.min(exteriorTrack.cross, candidateTrackCross);
173
+ const movedCrossEnd = Math.max(exteriorTrack.cross, candidateTrackCross);
174
+ const trackIntersects =
175
+ exteriorTrack.flowStart < flowEnd(rect) &&
176
+ exteriorTrack.flowEnd > flowStart(rect) &&
177
+ candidateTrackCross > crossStart(rect) &&
178
+ candidateTrackCross < crossEnd(rect);
179
+ const connectorIntersects =
180
+ connectorFlow > flowStart(rect) &&
181
+ connectorFlow < flowEnd(rect) &&
182
+ movedCrossStart < crossEnd(rect) &&
183
+ movedCrossEnd > crossStart(rect);
184
+ return trackIntersects || connectorIntersects;
185
+ };
186
+ const candidate = orderedCandidates.find((candidateLabelCross) => {
187
+ const delta = candidateLabelCross - labelCrossStart;
188
+ const candidateLabel = horizontalFlow
189
+ ? { ...labelRect, y: labelRect.y + delta }
190
+ : { ...labelRect, x: labelRect.x + delta };
191
+ const candidateTrackCross = exteriorTrack.cross + delta;
192
+ return (
193
+ obstacles.every((obstacle) => !overlaps(candidateLabel, obstacle)) &&
194
+ nodeRects.every(
195
+ (nodeRect) =>
196
+ !lineIntersects(nodeRect, candidateTrackCross, exteriorTrack.flowStart) &&
197
+ !lineIntersects(nodeRect, candidateTrackCross, exteriorTrack.flowEnd),
198
+ )
199
+ );
200
+ });
201
+ if (candidate === undefined) continue;
202
+ totalDelta = candidate - labelCrossStart;
203
+ if (horizontalFlow) labelRect.y += totalDelta;
204
+ else labelRect.x += totalDelta;
205
+ } else {
206
+ while (true) {
207
+ const blockers = obstacles.filter((rect) => overlaps(labelRect, rect));
208
+ if (blockers.length === 0) break;
209
+ const desiredCross = lowSide
210
+ ? Math.min(...blockers.map(crossStart)) -
211
+ spacing -
212
+ (horizontalFlow ? edge.height : edge.width)
213
+ : Math.max(...blockers.map(crossEnd)) + spacing;
214
+ const delta = desiredCross - crossStart(labelRect);
215
+ totalDelta += delta;
216
+ if (horizontalFlow) labelRect.y += delta;
217
+ else labelRect.x += delta;
218
+ }
127
219
  }
128
220
  if (totalDelta === 0) continue;
129
221
  if (horizontalFlow) edge.y += totalDelta;
130
222
  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
223
+ if (!exteriorTrack) continue;
224
+ edge.points = edge.points.flatMap((point, index) => {
225
+ if (index !== exteriorTrack.startIndex && index !== exteriorTrack.startIndex + 1)
226
+ return [point];
227
+ const shifted = horizontalFlow
135
228
  ? { ...point, y: point.y + totalDelta }
136
229
  : { ...point, x: point.x + totalDelta };
230
+ // Keep authored endpoint anchors attached when the chosen track ends there.
231
+ return preserveAnchors && index === exteriorTrack.startIndex && index === 0
232
+ ? [point, shifted]
233
+ : preserveAnchors &&
234
+ index === exteriorTrack.startIndex + 1 &&
235
+ index === edge.points.length - 1
236
+ ? [shifted, point]
237
+ : [shifted];
137
238
  });
138
239
  }
139
240
  }