@statelyai/layout 0.1.1 → 0.1.3

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-CoeH9LRO.mjs";
2
+ import { a as getRandomLayout, f as getFixedLayout, n as getSporeOverlapRemovalLayout, s as getRectanglePackingLayout, t as getSporeCompactionLayout, u as getBoxLayout } from "../spore-BtTSZbb6.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-CoeH9LRO.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-BtTSZbb6.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-CoeH9LRO.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 };
@@ -2494,7 +2494,40 @@ function routeEdges(style) {
2494
2494
  }
2495
2495
  flowLayerByNodeId.set(interval.id, flowLayers.length - 1);
2496
2496
  }
2497
+ const increasing = input.direction === "right" || input.direction === "down";
2498
+ const flowPortProtrusion = (edge, endpoint) => {
2499
+ const node = nodeById.get(endpoint === "source" ? edge.sourceId : edge.targetId);
2500
+ const portName = endpoint === "source" ? edge.sourcePort : edge.targetPort;
2501
+ const port = node?.ports?.find((candidate) => candidate.name === portName);
2502
+ if (!node || !port) return 0;
2503
+ return input.portSettings?.(port, node)?.["port.side"] === (horizontal ? endpoint === "source" ? input.direction === "right" ? "EAST" : "WEST" : input.direction === "right" ? "WEST" : "EAST" : endpoint === "source" ? input.direction === "down" ? "SOUTH" : "NORTH" : input.direction === "down" ? "NORTH" : "SOUTH") ? horizontal ? port.width ?? 0 : port.height ?? 0 : 0;
2504
+ };
2505
+ const adjacentNodeIds = /* @__PURE__ */ new Map();
2506
+ for (const edge of input.graph.edges) {
2507
+ const sourceNeighbors = adjacentNodeIds.get(edge.sourceId) ?? /* @__PURE__ */ new Set();
2508
+ sourceNeighbors.add(edge.targetId);
2509
+ adjacentNodeIds.set(edge.sourceId, sourceNeighbors);
2510
+ const targetNeighbors = adjacentNodeIds.get(edge.targetId) ?? /* @__PURE__ */ new Set();
2511
+ targetNeighbors.add(edge.sourceId);
2512
+ adjacentNodeIds.set(edge.targetId, targetNeighbors);
2513
+ }
2514
+ const feedbackComponentNodeIds = /* @__PURE__ */ new Set();
2515
+ for (const edge of input.graph.edges) {
2516
+ const sourceLayer = flowLayerByNodeId.get(edge.sourceId);
2517
+ const targetLayer = flowLayerByNodeId.get(edge.targetId);
2518
+ if (sourceLayer === void 0 || targetLayer === void 0) continue;
2519
+ if (sourceLayer === targetLayer) continue;
2520
+ if (increasing ? targetLayer > sourceLayer : targetLayer < sourceLayer) continue;
2521
+ const pending = [edge.sourceId, edge.targetId];
2522
+ while (pending.length > 0) {
2523
+ const nodeId = pending.pop();
2524
+ if (feedbackComponentNodeIds.has(nodeId)) continue;
2525
+ feedbackComponentNodeIds.add(nodeId);
2526
+ pending.push(...adjacentNodeIds.get(nodeId) ?? []);
2527
+ }
2528
+ }
2497
2529
  const labelExtraByGap = flowLayers.slice(0, -1).map(() => 0);
2530
+ const compactLabelCorridorGaps = /* @__PURE__ */ new Set();
2498
2531
  for (const edge of input.graph.edges) {
2499
2532
  const labelFlowSize = horizontal ? edge.width ?? 0 : edge.height ?? 0;
2500
2533
  if (labelFlowSize <= 0) continue;
@@ -2502,8 +2535,14 @@ function routeEdges(style) {
2502
2535
  const targetLayer = flowLayerByNodeId.get(edge.targetId);
2503
2536
  if (sourceLayer === void 0 || targetLayer === void 0) continue;
2504
2537
  if (Math.abs(sourceLayer - targetLayer) !== 1) continue;
2505
- const extra = (input.edgeSettings?.(edge)?.["edgeLabels.placement"] ?? "CENTER") === "CENTER" ? labelFlowSize + input.spacing.layer : labelFlowSize + Number(input.settings["spacing.edgeLabel"] ?? 2);
2538
+ const placement$1 = input.edgeSettings?.(edge)?.["edgeLabels.placement"] ?? "CENTER";
2539
+ const edgeNodeSpacing = Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10);
2540
+ const requiredGap = labelFlowSize + Math.max(input.spacing.layer, flowPortProtrusion(edge, "source") + edgeNodeSpacing) + Math.max(input.spacing.layer, flowPortProtrusion(edge, "target") + edgeNodeSpacing);
2506
2541
  const gap = Math.min(sourceLayer, targetLayer);
2542
+ const availableGap = flowLayers[gap + 1].start - flowLayers[gap].end;
2543
+ const compactCorridor = placement$1 === "CENTER" && (increasing ? targetLayer > sourceLayer : targetLayer < sourceLayer) && !feedbackComponentNodeIds.has(edge.sourceId);
2544
+ if (compactCorridor) compactLabelCorridorGaps.add(gap);
2545
+ const extra = compactCorridor ? Math.max(0, requiredGap - availableGap) : placement$1 === "CENTER" ? labelFlowSize + input.spacing.layer : labelFlowSize + Number(input.settings["spacing.edgeLabel"] ?? 2);
2507
2546
  labelExtraByGap[gap] = Math.max(labelExtraByGap[gap] ?? 0, extra);
2508
2547
  }
2509
2548
  let labelShift = 0;
@@ -2749,7 +2788,7 @@ function routeEdges(style) {
2749
2788
  const slots = slotsByGap[layerNo] ?? 0;
2750
2789
  const routesNearTarget = candidatesByGap[layerNo]?.some((candidate) => hasZeroFixedSideTarget(candidate.edge) && candidatesByGap[layerNo].filter(({ edge }) => edge.targetId === candidate.edge.targetId).length > candidatesByGap[layerNo].filter(({ edge }) => edge.sourceId === candidate.edge.sourceId).length);
2751
2790
  const preservedGap = (existingGapByLayer[layerNo] ?? input.spacing.layer) - (routesNearTarget ? edgeEdgeSpacing : 0);
2752
- const gapSpacing = slots === 0 ? existingGapByLayer[layerNo] ?? input.spacing.layer : Math.max(preservesNodeFlexibilityGap || (labelExtraByGap[layerNo] ?? 0) > 0 || [...selfLoopsByNodeId].some(([id, loops]) => (flowLayerByNodeId.get(id) === layerNo || flowLayerByNodeId.get(id) === layerNo + 1) && loops.some((edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)) ? preservedGap : input.spacing.layer, 2 * edgeNodeSpacing + Math.max(0, slots - 1) * edgeEdgeSpacing);
2791
+ const gapSpacing = slots === 0 ? existingGapByLayer[layerNo] ?? input.spacing.layer : Math.max(preservesNodeFlexibilityGap || (labelExtraByGap[layerNo] ?? 0) > 0 || compactLabelCorridorGaps.has(layerNo) || [...selfLoopsByNodeId].some(([id, loops]) => (flowLayerByNodeId.get(id) === layerNo || flowLayerByNodeId.get(id) === layerNo + 1) && loops.some((edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)) ? preservedGap : input.spacing.layer, 2 * edgeNodeSpacing + Math.max(0, slots - 1) * edgeEdgeSpacing);
2753
2792
  nextStart = bounds.end + gapSpacing;
2754
2793
  }
2755
2794
  implicitEndpoints = implicitEdgeEndpoints(input, placement, orientation);
@@ -3781,7 +3820,7 @@ function getPolylineMidpoint(points) {
3781
3820
 
3782
3821
  //#endregion
3783
3822
  //#region src/layered/separate-exterior-labels.ts
3784
- /** Separates movable inline labels and their exterior orthogonal route tracks. */
3823
+ /** Assigns collision-free cross-axis lanes to inline labels and their route tracks. */
3785
3824
  function separateExteriorLabels({ edges, nodeRects, direction, spacing, settings }) {
3786
3825
  const horizontalFlow = direction === "left" || direction === "right";
3787
3826
  const nodeCrossStart = Math.min(...nodeRects.map((rect) => horizontalFlow ? rect.y : rect.x));
@@ -3808,25 +3847,80 @@ function separateExteriorLabels({ edges, nodeRects, direction, spacing, settings
3808
3847
  const trackCross = horizontalFlow ? point.y : point.x;
3809
3848
  return isFlowSegment && (trackCross < nodeCrossStart || trackCross > nodeCrossEnd) ? [{
3810
3849
  cross: trackCross,
3811
- length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y)
3850
+ length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y),
3851
+ flowStart: horizontalFlow ? Math.min(point.x, next.x) : Math.min(point.y, next.y),
3852
+ flowEnd: horizontalFlow ? Math.max(point.x, next.x) : Math.max(point.y, next.y),
3853
+ startIndex: index
3812
3854
  }] : [];
3813
3855
  }).sort((left, right) => right.length - left.length)[0];
3814
- if (!exteriorTrack) return [];
3856
+ const routeTrack = exteriorTrack ?? edge.points.flatMap((point, index) => {
3857
+ const next = edge.points[index + 1];
3858
+ if (!next) return [];
3859
+ if (!(horizontalFlow ? point.y === next.y && point.x !== next.x : point.x === next.x && point.y !== next.y)) return [];
3860
+ return [{
3861
+ cross: horizontalFlow ? point.y : point.x,
3862
+ length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y),
3863
+ flowStart: horizontalFlow ? Math.min(point.x, next.x) : Math.min(point.y, next.y),
3864
+ flowEnd: horizontalFlow ? Math.max(point.x, next.x) : Math.max(point.y, next.y),
3865
+ startIndex: index
3866
+ }];
3867
+ }).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];
3868
+ if (!routeTrack) return [];
3815
3869
  return [{
3816
3870
  edge,
3817
- exteriorTrack,
3871
+ exteriorTrack: routeTrack,
3872
+ preserveAnchors: exteriorTrack === void 0,
3818
3873
  labelRect,
3819
- lowSide: exteriorTrack.cross < (nodeCrossStart + nodeCrossEnd) / 2
3874
+ lowSide: (crossStart(labelRect) + crossEnd(labelRect)) / 2 < (nodeCrossStart + nodeCrossEnd) / 2
3820
3875
  }];
3821
3876
  });
3822
3877
  movableLabels.sort((left, right) => {
3823
3878
  if (left.lowSide !== right.lowSide) return left.lowSide ? -1 : 1;
3824
3879
  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
3880
  });
3826
- for (const { edge, exteriorTrack, labelRect, lowSide } of movableLabels) {
3881
+ for (const { edge, exteriorTrack, labelRect, lowSide, preserveAnchors } of movableLabels) {
3827
3882
  const obstacles = [...nodeRects, ...[...labelRectByEdgeId.entries()].flatMap(([edgeId, rect]) => edgeId === String(edge.id) ? [] : [rect])];
3828
3883
  let totalDelta = 0;
3829
- while (true) {
3884
+ if (preserveAnchors) {
3885
+ const labelCrossStart = crossStart(labelRect);
3886
+ const labelCrossSize = crossEnd(labelRect) - labelCrossStart;
3887
+ const trackOffset = exteriorTrack.cross - labelCrossStart;
3888
+ const candidates = new Set([labelCrossStart]);
3889
+ for (const obstacle of obstacles) {
3890
+ candidates.add(crossStart(obstacle) - spacing - labelCrossSize);
3891
+ candidates.add(crossEnd(obstacle) + spacing);
3892
+ candidates.add(crossStart(obstacle) - spacing - trackOffset);
3893
+ candidates.add(crossEnd(obstacle) + spacing - trackOffset);
3894
+ }
3895
+ const orderedCandidates = [...candidates].sort((left, right) => {
3896
+ const leftPreferred = lowSide ? left <= labelCrossStart : left >= labelCrossStart;
3897
+ const rightPreferred = lowSide ? right <= labelCrossStart : right >= labelCrossStart;
3898
+ return Number(rightPreferred) - Number(leftPreferred) || Math.abs(left - labelCrossStart) - Math.abs(right - labelCrossStart) || left - right;
3899
+ });
3900
+ const lineIntersects = (rect, candidateTrackCross, connectorFlow) => {
3901
+ const movedCrossStart = Math.min(exteriorTrack.cross, candidateTrackCross);
3902
+ const movedCrossEnd = Math.max(exteriorTrack.cross, candidateTrackCross);
3903
+ const trackIntersects = exteriorTrack.flowStart < flowEnd(rect) && exteriorTrack.flowEnd > flowStart(rect) && candidateTrackCross > crossStart(rect) && candidateTrackCross < crossEnd(rect);
3904
+ const connectorIntersects = connectorFlow > flowStart(rect) && connectorFlow < flowEnd(rect) && movedCrossStart < crossEnd(rect) && movedCrossEnd > crossStart(rect);
3905
+ return trackIntersects || connectorIntersects;
3906
+ };
3907
+ const candidate = orderedCandidates.find((candidateLabelCross) => {
3908
+ const delta = candidateLabelCross - labelCrossStart;
3909
+ const candidateLabel = horizontalFlow ? {
3910
+ ...labelRect,
3911
+ y: labelRect.y + delta
3912
+ } : {
3913
+ ...labelRect,
3914
+ x: labelRect.x + delta
3915
+ };
3916
+ const candidateTrackCross = exteriorTrack.cross + delta;
3917
+ return obstacles.every((obstacle) => !overlaps(candidateLabel, obstacle)) && nodeRects.every((nodeRect) => !lineIntersects(nodeRect, candidateTrackCross, exteriorTrack.flowStart) && !lineIntersects(nodeRect, candidateTrackCross, exteriorTrack.flowEnd));
3918
+ });
3919
+ if (candidate === void 0) continue;
3920
+ totalDelta = candidate - labelCrossStart;
3921
+ if (horizontalFlow) labelRect.y += totalDelta;
3922
+ else labelRect.x += totalDelta;
3923
+ } else while (true) {
3830
3924
  const blockers = obstacles.filter((rect) => overlaps(labelRect, rect));
3831
3925
  if (blockers.length === 0) break;
3832
3926
  const delta = (lowSide ? Math.min(...blockers.map(crossStart)) - spacing - (horizontalFlow ? edge.height : edge.width) : Math.max(...blockers.map(crossEnd)) + spacing) - crossStart(labelRect);
@@ -3837,15 +3931,17 @@ function separateExteriorLabels({ edges, nodeRects, direction, spacing, settings
3837
3931
  if (totalDelta === 0) continue;
3838
3932
  if (horizontalFlow) edge.y += totalDelta;
3839
3933
  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 ? {
3934
+ if (!exteriorTrack) continue;
3935
+ edge.points = edge.points.flatMap((point, index) => {
3936
+ if (index !== exteriorTrack.startIndex && index !== exteriorTrack.startIndex + 1) return [point];
3937
+ const shifted = horizontalFlow ? {
3843
3938
  ...point,
3844
3939
  y: point.y + totalDelta
3845
3940
  } : {
3846
3941
  ...point,
3847
3942
  x: point.x + totalDelta
3848
3943
  };
3944
+ return preserveAnchors && index === exteriorTrack.startIndex && index === 0 ? [point, shifted] : preserveAnchors && index === exteriorTrack.startIndex + 1 && index === edge.points.length - 1 ? [shifted, point] : [shifted];
3849
3945
  });
3850
3946
  }
3851
3947
  }
@@ -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-CoeH9LRO.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.3",
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
  }
@@ -3645,7 +3645,57 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3645
3645
  flowLayerByNodeId.set(interval.id, flowLayers.length - 1);
3646
3646
  }
3647
3647
 
3648
+ const increasing = input.direction === "right" || input.direction === "down";
3649
+ const flowPortProtrusion = (edge: GraphEdge, endpoint: "source" | "target"): number => {
3650
+ const node = nodeById.get(endpoint === "source" ? edge.sourceId : edge.targetId);
3651
+ const portName = endpoint === "source" ? edge.sourcePort : edge.targetPort;
3652
+ const port = node?.ports?.find((candidate) => candidate.name === portName);
3653
+ if (!node || !port) return 0;
3654
+ const side = input.portSettings?.(port, node)?.["port.side"];
3655
+ const flowSide = horizontal
3656
+ ? endpoint === "source"
3657
+ ? input.direction === "right"
3658
+ ? "EAST"
3659
+ : "WEST"
3660
+ : input.direction === "right"
3661
+ ? "WEST"
3662
+ : "EAST"
3663
+ : endpoint === "source"
3664
+ ? input.direction === "down"
3665
+ ? "SOUTH"
3666
+ : "NORTH"
3667
+ : input.direction === "down"
3668
+ ? "NORTH"
3669
+ : "SOUTH";
3670
+ return side === flowSide ? (horizontal ? (port.width ?? 0) : (port.height ?? 0)) : 0;
3671
+ };
3672
+ const adjacentNodeIds = new Map<string, Set<string>>();
3673
+ for (const edge of input.graph.edges) {
3674
+ const sourceNeighbors = adjacentNodeIds.get(edge.sourceId) ?? new Set<string>();
3675
+ sourceNeighbors.add(edge.targetId);
3676
+ adjacentNodeIds.set(edge.sourceId, sourceNeighbors);
3677
+ const targetNeighbors = adjacentNodeIds.get(edge.targetId) ?? new Set<string>();
3678
+ targetNeighbors.add(edge.sourceId);
3679
+ adjacentNodeIds.set(edge.targetId, targetNeighbors);
3680
+ }
3681
+ const feedbackComponentNodeIds = new Set<string>();
3682
+ for (const edge of input.graph.edges) {
3683
+ const sourceLayer = flowLayerByNodeId.get(edge.sourceId);
3684
+ const targetLayer = flowLayerByNodeId.get(edge.targetId);
3685
+ if (sourceLayer === undefined || targetLayer === undefined) continue;
3686
+ if (sourceLayer === targetLayer) continue;
3687
+ const forward = increasing ? targetLayer > sourceLayer : targetLayer < sourceLayer;
3688
+ if (forward) continue;
3689
+ const pending = [edge.sourceId, edge.targetId];
3690
+ while (pending.length > 0) {
3691
+ const nodeId = pending.pop()!;
3692
+ if (feedbackComponentNodeIds.has(nodeId)) continue;
3693
+ feedbackComponentNodeIds.add(nodeId);
3694
+ pending.push(...(adjacentNodeIds.get(nodeId) ?? []));
3695
+ }
3696
+ }
3648
3697
  const labelExtraByGap = flowLayers.slice(0, -1).map(() => 0);
3698
+ const compactLabelCorridorGaps = new Set<number>();
3649
3699
  for (const edge of input.graph.edges) {
3650
3700
  const labelFlowSize = horizontal ? (edge.width ?? 0) : (edge.height ?? 0);
3651
3701
  if (labelFlowSize <= 0) continue;
@@ -3654,11 +3704,25 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
3654
3704
  if (sourceLayer === undefined || targetLayer === undefined) continue;
3655
3705
  if (Math.abs(sourceLayer - targetLayer) !== 1) continue;
3656
3706
  const placement = input.edgeSettings?.(edge)?.["edgeLabels.placement"] ?? "CENTER";
3657
- const extra =
3658
- placement === "CENTER"
3707
+ const edgeNodeSpacing = Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10);
3708
+ // ELK models center labels as dummy layers. For an exterior port, using node-node
3709
+ // spacing beyond the port double-counts the corridor. Treat the label as edge
3710
+ // geometry while retaining normal layer spacing when it is the larger clearance.
3711
+ const requiredGap =
3712
+ labelFlowSize +
3713
+ Math.max(input.spacing.layer, flowPortProtrusion(edge, "source") + edgeNodeSpacing) +
3714
+ Math.max(input.spacing.layer, flowPortProtrusion(edge, "target") + edgeNodeSpacing);
3715
+ const gap = Math.min(sourceLayer, targetLayer);
3716
+ const availableGap = flowLayers[gap + 1]!.start - flowLayers[gap]!.end;
3717
+ const forward = increasing ? targetLayer > sourceLayer : targetLayer < sourceLayer;
3718
+ const compactCorridor =
3719
+ placement === "CENTER" && forward && !feedbackComponentNodeIds.has(edge.sourceId);
3720
+ if (compactCorridor) compactLabelCorridorGaps.add(gap);
3721
+ const extra = compactCorridor
3722
+ ? Math.max(0, requiredGap - availableGap)
3723
+ : placement === "CENTER"
3659
3724
  ? labelFlowSize + input.spacing.layer
3660
3725
  : labelFlowSize + Number(input.settings["spacing.edgeLabel"] ?? 2);
3661
- const gap = Math.min(sourceLayer, targetLayer);
3662
3726
  labelExtraByGap[gap] = Math.max(labelExtraByGap[gap] ?? 0, extra);
3663
3727
  }
3664
3728
  let labelShift = 0;
@@ -4049,6 +4113,7 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4049
4113
  : Math.max(
4050
4114
  preservesNodeFlexibilityGap ||
4051
4115
  (labelExtraByGap[layerNo] ?? 0) > 0 ||
4116
+ compactLabelCorridorGaps.has(layerNo) ||
4052
4117
  [...selfLoopsByNodeId].some(
4053
4118
  ([id, loops]) =>
4054
4119
  (flowLayerByNodeId.get(id) === layerNo ||