@statelyai/layout 0.0.3 → 0.0.5

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
@@ -9,12 +9,15 @@ return `VisualGraph`; positions remain node fields and routes remain
9
9
 
10
10
  ## Status
11
11
 
12
+ <!-- layered compatibility coverage from test/oracle*.test.ts -->
13
+
12
14
  The native layered implementation covers the complete 152-option elkjs 0.11.1
13
15
  layered inventory with one simplified typed name per ELK option. Flat and
14
16
  compound graphs, cross-hierarchy edges, ports, labels, self-loops, wrapping,
15
17
  four directions, constraints, and replaceable phases are differential-tested
16
- against elkjs. Partial, incremental, and route-only layout remain explicit
17
- unimplemented capabilities.
18
+ against elkjs, including a curated complex state-machine corpus in both primary
19
+ layout orientations. Partial, incremental, and route-only layout remain
20
+ explicit unimplemented capabilities.
18
21
 
19
22
  ## Install
20
23
 
@@ -1,8 +1,11 @@
1
- import { o as elkLayeredOptionDefinitions, t as getLayeredLayout } from "../layered-f_3mmGHr.mjs";
2
- import { a as getRandomLayout, f as getFixedLayout, n as getSporeOverlapRemovalLayout, s as getRectanglePackingLayout, t as getSporeCompactionLayout, u as getBoxLayout } from "../spore-BfTduMDc.mjs";
1
+ import { o as elkLayeredOptionDefinitions, t as getLayeredLayout } from "../layered-BYhTymAk.mjs";
2
+ import { a as getRandomLayout, f as getFixedLayout, n as getSporeOverlapRemovalLayout, s as getRectanglePackingLayout, t as getSporeCompactionLayout, u as getBoxLayout } from "../spore-B504wOFR.mjs";
3
3
  import { createGraph } from "@statelyai/graph";
4
4
 
5
5
  //#region src/elkjs/index.ts
6
+ function isLayoutEdgeLabel(label) {
7
+ return getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true && (Boolean(label.text) || label.width !== void 0 || label.height !== void 0);
8
+ }
6
9
  var ELK = class {
7
10
  #options;
8
11
  #algorithmIds;
@@ -897,7 +900,7 @@ function toGraph(root, globalOptions = {}) {
897
900
  if (isInsideSelfLoop(root, edge)) return [];
898
901
  const source = endpoint(edge.sources?.[0] ?? edge.source, portOwnerById);
899
902
  const target = endpoint(edge.targets?.[0] ?? edge.target, portOwnerById);
900
- const labels = (edge.labels ?? []).filter((label) => Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true);
903
+ const labels = (edge.labels ?? []).filter(isLayoutEdgeLabel);
901
904
  const labelLabelSpacing = getNumberOption(globalOptions, "spacing.labelLabel") ?? 0;
902
905
  const labelWidth = Math.max(0, ...labels.map((label) => label.width ?? 0));
903
906
  const labelHeight = labels.reduce((sum, label) => sum + (label.height ?? 0), 0) + Math.max(0, labels.length - 1) * labelLabelSpacing;
@@ -1000,7 +1003,7 @@ function applyLayout(root, graph, padding, layoutOptions) {
1000
1003
  label.y ??= 0;
1001
1004
  continue;
1002
1005
  }
1003
- if (!label.text) {
1006
+ if (!isLayoutEdgeLabel(label)) {
1004
1007
  label.x ??= 0;
1005
1008
  label.y ??= 0;
1006
1009
  continue;
@@ -1026,7 +1029,7 @@ function normalizeElkGraphBounds(root, padding, layoutOptions = {}) {
1026
1029
  minimumY = Math.min(minimumY, node.y ?? 0, ...(node.labels ?? []).map((label) => (node.y ?? 0) + (label.y ?? 0)), ...(node.ports ?? []).map((port) => (node.y ?? 0) + (port.y ?? 0)), ...(node.ports ?? []).flatMap((port) => (port.labels ?? []).map((label) => (node.y ?? 0) + (port.y ?? 0) + (label.y ?? 0))));
1027
1030
  }
1028
1031
  for (const edge of root.edges ?? []) {
1029
- const laidOutLabels = (edge.labels ?? []).filter((label) => Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true);
1032
+ const laidOutLabels = (edge.labels ?? []).filter(isLayoutEdgeLabel);
1030
1033
  minimumX = Math.min(minimumX, ...laidOutLabels.map((label) => label.x ?? 0));
1031
1034
  minimumY = Math.min(minimumY, ...laidOutLabels.map((label) => label.y ?? 0));
1032
1035
  if (getBooleanOption(edge.layoutOptions ?? {}, "noLayout") !== true) {
@@ -1055,7 +1058,7 @@ function normalizeElkGraphBounds(root, padding, layoutOptions = {}) {
1055
1058
  point.x += shiftX;
1056
1059
  point.y += shiftY;
1057
1060
  }
1058
- for (const label of (edge.labels ?? []).filter((candidate) => Boolean(candidate.text))) {
1061
+ for (const label of (edge.labels ?? []).filter(isLayoutEdgeLabel)) {
1059
1062
  label.x = (label.x ?? 0) + shiftX;
1060
1063
  label.y = (label.y ?? 0) + shiftY;
1061
1064
  }
@@ -1091,7 +1094,7 @@ function normalizeElkGraphBounds(root, padding, layoutOptions = {}) {
1091
1094
  ...(node.labels ?? []).map((label) => (node.x ?? 0) + (label.x ?? 0) + (label.width ?? 0)),
1092
1095
  ...(node.ports ?? []).map((port) => (node.x ?? 0) + (port.x ?? 0) + (port.width ?? 0)),
1093
1096
  ...(node.ports ?? []).flatMap((port) => (port.labels ?? []).map((label) => (node.x ?? 0) + (port.x ?? 0) + (label.x ?? 0) + (label.width ?? 0)))
1094
- ]), ...(root.edges ?? []).flatMap((edge) => (edge.labels ?? []).filter((label) => Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true).map((label) => (label.x ?? 0) + (label.width ?? 0))), ...(root.edges ?? []).flatMap((edge) => getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true ? [] : (edge.sections ?? []).flatMap((section) => [
1097
+ ]), ...(root.edges ?? []).flatMap((edge) => (edge.labels ?? []).filter(isLayoutEdgeLabel).map((label) => (label.x ?? 0) + (label.width ?? 0))), ...(root.edges ?? []).flatMap((edge) => getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true ? [] : (edge.sections ?? []).flatMap((section) => [
1095
1098
  section.startPoint.x,
1096
1099
  ...(section.bendPoints ?? []).map((point) => point.x),
1097
1100
  section.endPoint.x
@@ -1101,7 +1104,7 @@ function normalizeElkGraphBounds(root, padding, layoutOptions = {}) {
1101
1104
  ...(node.labels ?? []).map((label) => (node.y ?? 0) + (label.y ?? 0) + (label.height ?? 0)),
1102
1105
  ...(node.ports ?? []).map((port) => (node.y ?? 0) + (port.y ?? 0) + (port.height ?? 0)),
1103
1106
  ...(node.ports ?? []).flatMap((port) => (port.labels ?? []).map((label) => (node.y ?? 0) + (port.y ?? 0) + (label.y ?? 0) + (label.height ?? 0)))
1104
- ]), ...(root.edges ?? []).flatMap((edge) => (edge.labels ?? []).filter((label) => Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true).map((label) => (label.y ?? 0) + (label.height ?? 0) + (String(getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER") === "CENTER" ? 1 : 0))), ...(root.edges ?? []).flatMap((edge) => getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true ? [] : (edge.sections ?? []).flatMap((section) => [
1107
+ ]), ...(root.edges ?? []).flatMap((edge) => (edge.labels ?? []).filter(isLayoutEdgeLabel).map((label) => (label.y ?? 0) + (label.height ?? 0) + (String(getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER") === "CENTER" ? 1 : 0))), ...(root.edges ?? []).flatMap((edge) => getBooleanOption(edge.layoutOptions ?? {}, "noLayout") === true ? [] : (edge.sections ?? []).flatMap((section) => [
1105
1108
  section.startPoint.y,
1106
1109
  ...(section.bendPoints ?? []).map((point) => point.y),
1107
1110
  section.endPoint.y
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-f_3mmGHr.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-BfTduMDc.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-BYhTymAk.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-B504wOFR.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-f_3mmGHr.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-BYhTymAk.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 };
@@ -3699,6 +3699,77 @@ function getPolylineMidpoint(points) {
3699
3699
  };
3700
3700
  }
3701
3701
 
3702
+ //#endregion
3703
+ //#region src/layered/separate-exterior-labels.ts
3704
+ /** Separates movable inline labels and their exterior orthogonal route tracks. */
3705
+ function separateExteriorLabels({ edges, nodeRects, direction, spacing, settings }) {
3706
+ const horizontalFlow = direction === "left" || direction === "right";
3707
+ const nodeCrossStart = Math.min(...nodeRects.map((rect) => horizontalFlow ? rect.y : rect.x));
3708
+ const nodeCrossEnd = Math.max(...nodeRects.map((rect) => horizontalFlow ? rect.y + rect.height : rect.x + rect.width));
3709
+ const flowStart = (rect) => horizontalFlow ? rect.x : rect.y;
3710
+ const flowEnd = (rect) => horizontalFlow ? rect.x + rect.width : rect.y + rect.height;
3711
+ const crossStart = (rect) => horizontalFlow ? rect.y : rect.x;
3712
+ const crossEnd = (rect) => horizontalFlow ? rect.y + rect.height : rect.x + rect.width;
3713
+ const overlaps = (left, right) => flowStart(left) < flowEnd(right) && flowEnd(left) > flowStart(right) && crossStart(left) < crossEnd(right) && crossEnd(left) > crossStart(right);
3714
+ const labelRectByEdgeId = new Map(edges.filter((edge) => edge.width > 0 && edge.height > 0).map((edge) => [String(edge.id), {
3715
+ x: edge.x,
3716
+ y: edge.y,
3717
+ width: edge.width,
3718
+ height: edge.height
3719
+ }]));
3720
+ const movableLabels = edges.flatMap((edge) => {
3721
+ const labelRect = labelRectByEdgeId.get(String(edge.id));
3722
+ const labelSettings = settings(edge);
3723
+ if (!labelRect || !labelSettings.inline || labelSettings.placement !== "CENTER") return [];
3724
+ const exteriorTrack = edge.points.flatMap((point, index) => {
3725
+ const next = edge.points[index + 1];
3726
+ if (!next) return [];
3727
+ const isFlowSegment = horizontalFlow ? point.y === next.y && point.x !== next.x : point.x === next.x && point.y !== next.y;
3728
+ const trackCross = horizontalFlow ? point.y : point.x;
3729
+ return isFlowSegment && (trackCross < nodeCrossStart || trackCross > nodeCrossEnd) ? [{
3730
+ cross: trackCross,
3731
+ length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y)
3732
+ }] : [];
3733
+ }).sort((left, right) => right.length - left.length)[0];
3734
+ if (!exteriorTrack) return [];
3735
+ return [{
3736
+ edge,
3737
+ exteriorTrack,
3738
+ labelRect,
3739
+ lowSide: exteriorTrack.cross < (nodeCrossStart + nodeCrossEnd) / 2
3740
+ }];
3741
+ });
3742
+ movableLabels.sort((left, right) => {
3743
+ if (left.lowSide !== right.lowSide) return left.lowSide ? -1 : 1;
3744
+ 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);
3745
+ });
3746
+ for (const { edge, exteriorTrack, labelRect, lowSide } of movableLabels) {
3747
+ const obstacles = [...nodeRects, ...[...labelRectByEdgeId.entries()].flatMap(([edgeId, rect]) => edgeId === String(edge.id) ? [] : [rect])];
3748
+ let totalDelta = 0;
3749
+ while (true) {
3750
+ const blockers = obstacles.filter((rect) => overlaps(labelRect, rect));
3751
+ if (blockers.length === 0) break;
3752
+ const delta = (lowSide ? Math.min(...blockers.map(crossStart)) - spacing - (horizontalFlow ? edge.height : edge.width) : Math.max(...blockers.map(crossEnd)) + spacing) - crossStart(labelRect);
3753
+ totalDelta += delta;
3754
+ if (horizontalFlow) labelRect.y += delta;
3755
+ else labelRect.x += delta;
3756
+ }
3757
+ if (totalDelta === 0) continue;
3758
+ if (horizontalFlow) edge.y += totalDelta;
3759
+ else edge.x += totalDelta;
3760
+ edge.points = edge.points.map((point) => {
3761
+ if ((horizontalFlow ? point.y : point.x) !== exteriorTrack.cross) return point;
3762
+ return horizontalFlow ? {
3763
+ ...point,
3764
+ y: point.y + totalDelta
3765
+ } : {
3766
+ ...point,
3767
+ x: point.x + totalDelta
3768
+ };
3769
+ });
3770
+ }
3771
+ }
3772
+
3702
3773
  //#endregion
3703
3774
  //#region src/layered/network-simplex.ts
3704
3775
  function connectedEdges(node) {
@@ -8745,6 +8816,7 @@ function runLayeredPipeline(graph, options, context) {
8745
8816
  let expandedRoutes = measure("edge-routing", () => edgeRouter(expanded.input, expanded.orientation, placement));
8746
8817
  measure("post-compaction", () => applyPostCompaction(expanded.input, placement, expandedRoutes));
8747
8818
  const antiparallelLabelPositions = /* @__PURE__ */ new Map();
8819
+ const outerAntiparallelLabelIds = /* @__PURE__ */ new Set();
8748
8820
  const parallelLabelPositions = /* @__PURE__ */ new Map();
8749
8821
  const postCompactionNodeCrossDeltas = /* @__PURE__ */ new Map();
8750
8822
  const horizontalAntiparallelFlow = direction === "left" || direction === "right";
@@ -8834,12 +8906,13 @@ function runLayeredPipeline(graph, options, context) {
8834
8906
  const secondRect = placement.rectByNodeId.get(pair[0].targetId);
8835
8907
  if (!firstNode || !secondNode || !firstRect || !secondRect) continue;
8836
8908
  const nonSelfNeighbors = (nodeId) => new Set(graph.edges.flatMap((edge) => edge.sourceId === edge.targetId ? [] : edge.sourceId === nodeId ? [edge.targetId] : edge.targetId === nodeId ? [edge.sourceId] : []));
8837
- if (nonSelfNeighbors(firstNode.id).size !== 1 || nonSelfNeighbors(secondNode.id).size !== 1) continue;
8909
+ const isolatedAntiparallelPair = nonSelfNeighbors(firstNode.id).size === 1 && nonSelfNeighbors(secondNode.id).size === 1;
8838
8910
  const hasFlexiblePorts = (node) => {
8839
8911
  const constraints = options.nodeSettings?.(node)?.portConstraints;
8840
8912
  return constraints === void 0 || constraints === "UNDEFINED" || constraints === "FREE";
8841
8913
  };
8842
- if (!hasFlexiblePorts(firstNode) || !hasFlexiblePorts(secondNode)) continue;
8914
+ const hasFlexiblePairPorts = hasFlexiblePorts(firstNode) && hasFlexiblePorts(secondNode);
8915
+ if (isolatedAntiparallelPair && !hasFlexiblePairPorts) continue;
8843
8916
  const firstBeforeSecond = horizontal ? firstRect.x + firstRect.width / 2 < secondRect.x + secondRect.width / 2 : firstRect.y + firstRect.height / 2 < secondRect.y + secondRect.height / 2;
8844
8917
  const beforeRect = firstBeforeSecond ? firstRect : secondRect;
8845
8918
  const afterRect = firstBeforeSecond ? secondRect : firstRect;
@@ -8850,13 +8923,20 @@ function runLayeredPipeline(graph, options, context) {
8850
8923
  for (const edge of pair) {
8851
8924
  const width = edge.width ?? 0;
8852
8925
  const height = edge.height ?? 0;
8853
- antiparallelLabelPositions.set(edge.id, horizontal ? {
8926
+ antiparallelLabelPositions.set(edge.id, isolatedAntiparallelPair ? horizontal ? {
8854
8927
  x: (beforeRect.x + beforeRect.width + afterRect.x - width) / 2,
8855
8928
  y: cross
8856
8929
  } : {
8857
8930
  x: cross,
8858
8931
  y: (beforeRect.y + beforeRect.height + afterRect.y - height) / 2
8932
+ } : horizontal ? {
8933
+ x: (beforeRect.x + beforeRect.width + afterRect.x - width) / 2,
8934
+ y: edge === forward ? (firstRect.y + firstRect.height / 2 + secondRect.y + secondRect.height / 2) / 2 - height / 2 : Math.max(firstRect.y + firstRect.height, secondRect.y + secondRect.height) + edgeSpacing
8935
+ } : {
8936
+ x: edge === forward ? (firstRect.x + firstRect.width / 2 + secondRect.x + secondRect.width / 2) / 2 - width / 2 : Math.max(firstRect.x + firstRect.width, secondRect.x + secondRect.width) + edgeSpacing,
8937
+ y: (beforeRect.y + beforeRect.height + afterRect.y - height) / 2
8859
8938
  });
8939
+ if (!isolatedAntiparallelPair && edge !== forward) outerAntiparallelLabelIds.add(edge.id);
8860
8940
  cross += (horizontal ? height : width) + edgeSpacing;
8861
8941
  }
8862
8942
  const forwardLabel = antiparallelLabelPositions.get(forward.id);
@@ -8881,8 +8961,10 @@ function runLayeredPipeline(graph, options, context) {
8881
8961
  x: nextCross
8882
8962
  });
8883
8963
  };
8884
- alignPort(nodesById.get(forward.sourceId), forward.sourcePort);
8885
- alignPort(nodesById.get(forward.targetId), forward.targetPort);
8964
+ if (isolatedAntiparallelPair) {
8965
+ alignPort(nodesById.get(forward.sourceId), forward.sourcePort);
8966
+ alignPort(nodesById.get(forward.targetId), forward.targetPort);
8967
+ }
8886
8968
  }
8887
8969
  }
8888
8970
  if (postCompactionNodeCrossDeltas.size > 0) {
@@ -9239,7 +9321,33 @@ function runLayeredPipeline(graph, options, context) {
9239
9321
  };
9240
9322
  const flexibleFeedbackLabel = labelPlacement === "CENTER" && inlineLabel && expanded.orientation.reversedEdgeIds.has(edge.id) && hasFlexiblePorts(sourceNode) && hasFlexiblePorts(targetNode) && sourceRect !== void 0 && targetRect !== void 0;
9241
9323
  const [beforeFlowRect, afterFlowRect] = sourceRect && targetRect ? horizontal ? sourceRect.x <= targetRect.x ? [sourceRect, targetRect] : [targetRect, sourceRect] : sourceRect.y <= targetRect.y ? [sourceRect, targetRect] : [targetRect, sourceRect] : [void 0, void 0];
9242
- const explicitLabelPosition = antiparallelLabelPositions.get(edge.id) ?? parallelLabelPositions.get(edge.id);
9324
+ const antiparallelLabelPosition = antiparallelLabelPositions.get(edge.id);
9325
+ const explicitLabelPosition = (() => {
9326
+ if (!outerAntiparallelLabelIds.has(edge.id)) return void 0;
9327
+ const edgeNodeSpacing$1 = Number(options.settings?.["spacing.edgeNode"] ?? 10);
9328
+ if (horizontal) {
9329
+ const x$1 = (beforeFlowRect.x + beforeFlowRect.width + afterFlowRect.x - width) / 2;
9330
+ const blockers$1 = [
9331
+ sourceRect,
9332
+ targetRect,
9333
+ ...[...placement.rectByNodeId.values()].filter((rect) => rect.x < x$1 + width && rect.x + rect.width > x$1)
9334
+ ];
9335
+ return {
9336
+ x: x$1,
9337
+ y: Math.max(...blockers$1.map((rect) => rect.y + rect.height)) + edgeNodeSpacing$1
9338
+ };
9339
+ }
9340
+ const y$1 = (beforeFlowRect.y + beforeFlowRect.height + afterFlowRect.y - height) / 2;
9341
+ const blockers = [
9342
+ sourceRect,
9343
+ targetRect,
9344
+ ...[...placement.rectByNodeId.values()].filter((rect) => rect.y < y$1 + height && rect.y + rect.height > y$1)
9345
+ ];
9346
+ return {
9347
+ x: Math.max(...blockers.map((rect) => rect.x + rect.width)) + edgeNodeSpacing$1,
9348
+ y: y$1
9349
+ };
9350
+ })() ?? antiparallelLabelPosition ?? parallelLabelPositions.get(edge.id);
9243
9351
  const x = explicitLabelPosition ? explicitLabelPosition.x : flexibleFeedbackLabel ? horizontal ? (beforeFlowRect.x + beforeFlowRect.width + afterFlowRect.x - width) / 2 : targetRect.x + (targetRect.width - width) / 2 : labelPlacement === "CENTER" && horizontal && labelDummyRect ? labelDummyRect.x : routeX;
9244
9352
  const y = explicitLabelPosition ? explicitLabelPosition.y : flexibleFeedbackLabel ? horizontal ? targetRect.y + (targetRect.height - height) / 2 : (beforeFlowRect.y + beforeFlowRect.height + afterFlowRect.y - height) / 2 : labelPlacement === "CENTER" && !horizontal && labelDummyRect ? labelDummyRect.y : routeY;
9245
9353
  return {
@@ -9252,6 +9360,19 @@ function runLayeredPipeline(graph, options, context) {
9252
9360
  routing: edgeRouting === "POLYLINE" ? "polyline" : edgeRouting === "SPLINES" ? "splines" : "orthogonal"
9253
9361
  };
9254
9362
  });
9363
+ if (edgeRouting === "ORTHOGONAL") separateExteriorLabels({
9364
+ edges,
9365
+ nodeRects: feedbackNodeRects,
9366
+ direction,
9367
+ spacing: Number(options.settings?.["spacing.edgeEdge"] ?? 10),
9368
+ settings: (edge) => {
9369
+ const edgeSettings = options.edgeSettings?.(edge);
9370
+ return {
9371
+ inline: edgeSettings?.["edgeLabels.inline"] === true,
9372
+ placement: edgeSettings?.["edgeLabels.placement"] ?? "CENTER"
9373
+ };
9374
+ }
9375
+ });
9255
9376
  return {
9256
9377
  ...graph,
9257
9378
  direction,
@@ -1,4 +1,4 @@
1
- import { F as placePorts, O as getPolylineMidpoint, z as JavaRandom } from "./layered-f_3mmGHr.mjs";
1
+ import { F as placePorts, O as getPolylineMidpoint, z as JavaRandom } from "./layered-BYhTymAk.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.0.3",
3
+ "version": "0.0.5",
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": {
@@ -47,6 +47,7 @@
47
47
  "@changesets/cli": "3.0.1",
48
48
  "@codemirror/lang-javascript": "^6.2.5",
49
49
  "@codemirror/lint": "^6.9.7",
50
+ "@statelyai/layout-pre-exterior-label-separation": "npm:@statelyai/layout@0.0.4",
50
51
  "@types/node": "^25.0.3",
51
52
  "@types/react": "^19.2.18",
52
53
  "@types/react-dom": "^19.2.4",
@@ -14,6 +14,7 @@ import type {
14
14
  ElkConstructorArguments,
15
15
  ElkLayoutAlgorithmDescription,
16
16
  ElkEdge,
17
+ ElkLabel,
17
18
  ElkLayoutArguments,
18
19
  ElkLayoutCategoryDescription,
19
20
  ElkLayoutOptionDescription,
@@ -25,6 +26,13 @@ import type {
25
26
  LaidOutElkNode,
26
27
  } from "./types";
27
28
 
29
+ function isLayoutEdgeLabel(label: ElkLabel): boolean {
30
+ return (
31
+ getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true &&
32
+ (Boolean(label.text) || label.width !== undefined || label.height !== undefined)
33
+ );
34
+ }
35
+
28
36
  export type {
29
37
  ElkConstructorArguments,
30
38
  ElkCommonDescription,
@@ -1264,10 +1272,7 @@ function toGraph(root: ElkNode, globalOptions: Readonly<Record<string, unknown>>
1264
1272
  if (isInsideSelfLoop(root, edge)) return [];
1265
1273
  const source = endpoint(edge.sources?.[0] ?? edge.source, portOwnerById);
1266
1274
  const target = endpoint(edge.targets?.[0] ?? edge.target, portOwnerById);
1267
- const labels = (edge.labels ?? []).filter(
1268
- (label) =>
1269
- Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1270
- );
1275
+ const labels = (edge.labels ?? []).filter(isLayoutEdgeLabel);
1271
1276
  const labelLabelSpacing = getNumberOption(globalOptions, "spacing.labelLabel") ?? 0;
1272
1277
  const labelWidth = Math.max(0, ...labels.map((label) => label.width ?? 0));
1273
1278
  const labelHeight =
@@ -1387,7 +1392,7 @@ function applyLayout(
1387
1392
  label.y ??= 0;
1388
1393
  continue;
1389
1394
  }
1390
- if (!label.text) {
1395
+ if (!isLayoutEdgeLabel(label)) {
1391
1396
  label.x ??= 0;
1392
1397
  label.y ??= 0;
1393
1398
  continue;
@@ -1443,10 +1448,7 @@ function normalizeElkGraphBounds(
1443
1448
  );
1444
1449
  }
1445
1450
  for (const edge of root.edges ?? []) {
1446
- const laidOutLabels = (edge.labels ?? []).filter(
1447
- (label) =>
1448
- Boolean(label.text) && getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1449
- );
1451
+ const laidOutLabels = (edge.labels ?? []).filter(isLayoutEdgeLabel);
1450
1452
  minimumX = Math.min(minimumX, ...laidOutLabels.map((label) => label.x ?? 0));
1451
1453
  minimumY = Math.min(minimumY, ...laidOutLabels.map((label) => label.y ?? 0));
1452
1454
  if (getBooleanOption(edge.layoutOptions ?? {}, "noLayout") !== true) {
@@ -1473,7 +1475,7 @@ function normalizeElkGraphBounds(
1473
1475
  point.y += shiftY;
1474
1476
  }
1475
1477
  }
1476
- for (const label of (edge.labels ?? []).filter((candidate) => Boolean(candidate.text))) {
1478
+ for (const label of (edge.labels ?? []).filter(isLayoutEdgeLabel)) {
1477
1479
  label.x = (label.x ?? 0) + shiftX;
1478
1480
  label.y = (label.y ?? 0) + shiftY;
1479
1481
  }
@@ -1578,11 +1580,7 @@ function normalizeElkGraphBounds(
1578
1580
  ]),
1579
1581
  ...(root.edges ?? []).flatMap((edge) =>
1580
1582
  (edge.labels ?? [])
1581
- .filter(
1582
- (label) =>
1583
- Boolean(label.text) &&
1584
- getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1585
- )
1583
+ .filter(isLayoutEdgeLabel)
1586
1584
  .map((label) => (label.x ?? 0) + (label.width ?? 0)),
1587
1585
  ),
1588
1586
  ...(root.edges ?? []).flatMap((edge) =>
@@ -1617,11 +1615,7 @@ function normalizeElkGraphBounds(
1617
1615
  ]),
1618
1616
  ...(root.edges ?? []).flatMap((edge) =>
1619
1617
  (edge.labels ?? [])
1620
- .filter(
1621
- (label) =>
1622
- Boolean(label.text) &&
1623
- getBooleanOption(label.layoutOptions ?? {}, "noLayout") !== true,
1624
- )
1618
+ .filter(isLayoutEdgeLabel)
1625
1619
  .map(
1626
1620
  (label) =>
1627
1621
  (label.y ?? 0) +
@@ -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,
@@ -1760,6 +1761,7 @@ function runLayeredPipeline<N, E, G, P>(
1760
1761
  );
1761
1762
  measure("post-compaction", () => applyPostCompaction(expanded.input, placement, expandedRoutes));
1762
1763
  const antiparallelLabelPositions = new Map<string, Point>();
1764
+ const outerAntiparallelLabelIds = new Set<string>();
1763
1765
  const parallelLabelPositions = new Map<string, Point>();
1764
1766
  const postCompactionNodeCrossDeltas = new Map<string, number>();
1765
1767
  const horizontalAntiparallelFlow = direction === "left" || direction === "right";
@@ -1898,14 +1900,14 @@ function runLayeredPipeline<N, E, G, P>(
1898
1900
  : [],
1899
1901
  ),
1900
1902
  );
1901
- if (nonSelfNeighbors(firstNode.id).size !== 1 || nonSelfNeighbors(secondNode.id).size !== 1) {
1902
- continue;
1903
- }
1903
+ const isolatedAntiparallelPair =
1904
+ nonSelfNeighbors(firstNode.id).size === 1 && nonSelfNeighbors(secondNode.id).size === 1;
1904
1905
  const hasFlexiblePorts = (node: GraphNode): boolean => {
1905
1906
  const constraints = options.nodeSettings?.(node)?.portConstraints;
1906
1907
  return constraints === undefined || constraints === "UNDEFINED" || constraints === "FREE";
1907
1908
  };
1908
- if (!hasFlexiblePorts(firstNode) || !hasFlexiblePorts(secondNode)) continue;
1909
+ const hasFlexiblePairPorts = hasFlexiblePorts(firstNode) && hasFlexiblePorts(secondNode);
1910
+ if (isolatedAntiparallelPair && !hasFlexiblePairPorts) continue;
1909
1911
 
1910
1912
  const firstBeforeSecond = horizontal
1911
1913
  ? firstRect.x + firstRect.width / 2 < secondRect.x + secondRect.width / 2
@@ -1929,16 +1931,44 @@ function runLayeredPipeline<N, E, G, P>(
1929
1931
  const height = edge.height ?? 0;
1930
1932
  antiparallelLabelPositions.set(
1931
1933
  edge.id,
1932
- horizontal
1933
- ? {
1934
- x: (beforeRect.x + beforeRect.width + afterRect.x - width) / 2,
1935
- y: cross,
1936
- }
1937
- : {
1938
- x: cross,
1939
- y: (beforeRect.y + beforeRect.height + afterRect.y - height) / 2,
1940
- },
1934
+ isolatedAntiparallelPair
1935
+ ? horizontal
1936
+ ? {
1937
+ x: (beforeRect.x + beforeRect.width + afterRect.x - width) / 2,
1938
+ y: cross,
1939
+ }
1940
+ : {
1941
+ x: cross,
1942
+ y: (beforeRect.y + beforeRect.height + afterRect.y - height) / 2,
1943
+ }
1944
+ : horizontal
1945
+ ? {
1946
+ x: (beforeRect.x + beforeRect.width + afterRect.x - width) / 2,
1947
+ y:
1948
+ edge === forward
1949
+ ? (firstRect.y +
1950
+ firstRect.height / 2 +
1951
+ secondRect.y +
1952
+ secondRect.height / 2) /
1953
+ 2 -
1954
+ height / 2
1955
+ : Math.max(firstRect.y + firstRect.height, secondRect.y + secondRect.height) +
1956
+ edgeSpacing,
1957
+ }
1958
+ : {
1959
+ x:
1960
+ edge === forward
1961
+ ? (firstRect.x + firstRect.width / 2 + secondRect.x + secondRect.width / 2) /
1962
+ 2 -
1963
+ width / 2
1964
+ : Math.max(firstRect.x + firstRect.width, secondRect.x + secondRect.width) +
1965
+ edgeSpacing,
1966
+ y: (beforeRect.y + beforeRect.height + afterRect.y - height) / 2,
1967
+ },
1941
1968
  );
1969
+ if (!isolatedAntiparallelPair && edge !== forward) {
1970
+ outerAntiparallelLabelIds.add(edge.id);
1971
+ }
1942
1972
  cross += (horizontal ? height : width) + edgeSpacing;
1943
1973
  }
1944
1974
 
@@ -1974,8 +2004,10 @@ function runLayeredPipeline<N, E, G, P>(
1974
2004
  horizontal ? { ...rect, y: nextCross } : { ...rect, x: nextCross },
1975
2005
  );
1976
2006
  };
1977
- alignPort(nodesById.get(forward.sourceId)!, forward.sourcePort);
1978
- alignPort(nodesById.get(forward.targetId)!, forward.targetPort);
2007
+ if (isolatedAntiparallelPair) {
2008
+ alignPort(nodesById.get(forward.sourceId)!, forward.sourcePort);
2009
+ alignPort(nodesById.get(forward.targetId)!, forward.targetPort);
2010
+ }
1979
2011
  }
1980
2012
  }
1981
2013
  if (postCompactionNodeCrossDeltas.size > 0) {
@@ -2482,8 +2514,41 @@ function runLayeredPipeline<N, E, G, P>(
2482
2514
  ? [sourceRect, targetRect]
2483
2515
  : [targetRect, sourceRect]
2484
2516
  : [undefined, undefined];
2517
+ const antiparallelLabelPosition = antiparallelLabelPositions.get(edge.id);
2518
+ const outerAntiparallelLabelPosition = (() => {
2519
+ if (!outerAntiparallelLabelIds.has(edge.id)) return undefined;
2520
+ const edgeNodeSpacing = Number(options.settings?.["spacing.edgeNode"] ?? 10);
2521
+ if (horizontal) {
2522
+ const x = (beforeFlowRect!.x + beforeFlowRect!.width + afterFlowRect!.x - width) / 2;
2523
+ const blockers = [
2524
+ sourceRect!,
2525
+ targetRect!,
2526
+ ...[...placement.rectByNodeId.values()].filter(
2527
+ (rect) => rect.x < x + width && rect.x + rect.width > x,
2528
+ ),
2529
+ ];
2530
+ return {
2531
+ x,
2532
+ y: Math.max(...blockers.map((rect) => rect.y + rect.height)) + edgeNodeSpacing,
2533
+ };
2534
+ }
2535
+ const y = (beforeFlowRect!.y + beforeFlowRect!.height + afterFlowRect!.y - height) / 2;
2536
+ const blockers = [
2537
+ sourceRect!,
2538
+ targetRect!,
2539
+ ...[...placement.rectByNodeId.values()].filter(
2540
+ (rect) => rect.y < y + height && rect.y + rect.height > y,
2541
+ ),
2542
+ ];
2543
+ return {
2544
+ x: Math.max(...blockers.map((rect) => rect.x + rect.width)) + edgeNodeSpacing,
2545
+ y,
2546
+ };
2547
+ })();
2485
2548
  const explicitLabelPosition =
2486
- antiparallelLabelPositions.get(edge.id) ?? parallelLabelPositions.get(edge.id);
2549
+ outerAntiparallelLabelPosition ??
2550
+ antiparallelLabelPosition ??
2551
+ parallelLabelPositions.get(edge.id);
2487
2552
  const x = explicitLabelPosition
2488
2553
  ? explicitLabelPosition.x
2489
2554
  : flexibleFeedbackLabel
@@ -2518,6 +2583,22 @@ function runLayeredPipeline<N, E, G, P>(
2518
2583
  };
2519
2584
  });
2520
2585
 
2586
+ if (edgeRouting === "ORTHOGONAL") {
2587
+ separateExteriorLabels({
2588
+ edges,
2589
+ nodeRects: feedbackNodeRects,
2590
+ direction,
2591
+ spacing: Number(options.settings?.["spacing.edgeEdge"] ?? 10),
2592
+ settings: (edge) => {
2593
+ const edgeSettings = options.edgeSettings?.(edge);
2594
+ return {
2595
+ inline: edgeSettings?.["edgeLabels.inline"] === true,
2596
+ placement: edgeSettings?.["edgeLabels.placement"] ?? "CENTER",
2597
+ };
2598
+ },
2599
+ });
2600
+ }
2601
+
2521
2602
  return {
2522
2603
  ...graph,
2523
2604
  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
+ }