@statelyai/layout 0.0.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -392,7 +392,8 @@ function addDepthFirstBackEdges(input, initialReversedEdgeIds, targetOrder) {
392
392
  const targetId = reversed ? edge.sourceId : edge.targetId;
393
393
  if (sourceId === targetId) continue;
394
394
  const targetState = state.get(targetId);
395
- if (targetState === "active") reversedEdgeIds.add(edge.id);
395
+ if (targetState === "active") if (reversed) reversedEdgeIds.delete(edge.id);
396
+ else reversedEdgeIds.add(edge.id);
396
397
  else if (targetState === void 0) {
397
398
  state.set(targetId, "active");
398
399
  stack.push({
@@ -664,7 +665,7 @@ function applyLayerConstraintOrientation(input, orientation) {
664
665
  if (reversedEdgeIds.has(edge.id)) reversedEdgeIds.delete(edge.id);
665
666
  else reversedEdgeIds.add(edge.id);
666
667
  }
667
- return { reversedEdgeIds };
668
+ return { reversedEdgeIds: addDepthFirstBackEdges(input, reversedEdgeIds, "model") };
668
669
  }
669
670
  /** Keep activated ELK partitions in ascending, contiguous layer blocks. */
670
671
  function applyPartitions(input, assignment) {
@@ -1730,7 +1731,7 @@ function applyDirectionCongruency(input, order) {
1730
1731
  return { layers: order.layers.map((layer) => [...layer].reverse()) };
1731
1732
  }
1732
1733
  /** ELK post-placement one-dimensional graph compaction. */
1733
- function applyPostCompaction(input, placement, routes) {
1734
+ function applyPostCompaction(input, placement, routes, labelEdges = input.graph.edges) {
1734
1735
  const strategy = input.settings["compaction.postCompaction.strategy"] ?? "NONE";
1735
1736
  input.settings["compaction.postCompaction.constraints"];
1736
1737
  if (strategy === "NONE") return placement;
@@ -1778,7 +1779,15 @@ function applyPostCompaction(input, placement, routes) {
1778
1779
  }
1779
1780
  }
1780
1781
  const verticalSpacing = (left, right) => left.kind === "node" && right.kind === "node" ? input.spacing.node : left.kind === "segment" && right.kind === "segment" && left.edgeId === right.edgeId ? 1 : Number(input.settings[left.kind === "segment" && right.kind === "segment" ? "spacing.edgeEdge" : "spacing.edgeNode"] ?? 10);
1781
- const horizontalSpacing = (left, right) => left.kind === "node" && right.kind === "node" ? input.spacing.node : left.kind === "segment" && right.kind === "segment" && left.edgeId === right.edgeId ? 0 : Number(input.settings[left.kind === "segment" && right.kind === "segment" ? "spacing.edgeEdge" : "spacing.edgeNode"] ?? 10);
1782
+ const inlineLabelSpaceByPair = /* @__PURE__ */ new Map();
1783
+ if (strategy !== "EDGE_LENGTH" && (input.direction === "left" || input.direction === "right")) for (const edge of labelEdges) {
1784
+ const settings = input.edgeSettings?.(edge);
1785
+ if (edge.sourceId === edge.targetId || settings?.["edgeLabels.inline"] !== true || (settings["edgeLabels.placement"] ?? "CENTER") !== "CENTER") continue;
1786
+ const key = [edge.sourceId, edge.targetId].sort().join("\0");
1787
+ inlineLabelSpaceByPair.set(key, Math.max(inlineLabelSpaceByPair.get(key) ?? 0, (edge.width ?? 0) + 2 * input.spacing.layer));
1788
+ }
1789
+ const labelSpace = (left, right) => left.nodeId !== void 0 && right.nodeId !== void 0 ? inlineLabelSpaceByPair.get([left.nodeId, right.nodeId].sort().join("\0")) ?? 0 : 0;
1790
+ const horizontalSpacing = (left, right) => left.kind === "node" && right.kind === "node" ? Math.max(input.spacing.node, labelSpace(left, right)) : left.kind === "segment" && right.kind === "segment" && left.edgeId === right.edgeId ? 0 : Number(input.settings[left.kind === "segment" && right.kind === "segment" ? "spacing.edgeEdge" : "spacing.edgeNode"] ?? 10);
1782
1791
  const centerLabeledEdgeIds = new Set(input.graph.edges.filter((edge) => (edge.width ?? 0) > 0 && (input.edgeSettings?.(edge)?.["edgeLabels.placement"] ?? "CENTER") === "CENTER").map((edge) => edge.id));
1783
1792
  const labelFlowLocked = new Set(compactables.filter((item) => item.kind === "segment" && item.edgeId !== void 0 && centerLabeledEdgeIds.has(item.edgeId)).map((item) => item.id));
1784
1793
  const compact$1 = (direction, locked = /* @__PURE__ */ new Set()) => {
@@ -1794,7 +1803,7 @@ function applyPostCompaction(input, placement, routes) {
1794
1803
  if (left === right) continue;
1795
1804
  const ordered = right.x > left.x || right.x === left.x && left.width < right.width;
1796
1805
  const verticalCollision = right.y + right.height + verticalSpacing(left, right) > left.y + 1e-9 && right.y < left.y + left.height + verticalSpacing(left, right) - 1e-9;
1797
- if (!ordered || !verticalCollision) continue;
1806
+ if (!ordered || !verticalCollision && labelSpace(left, right) === 0) continue;
1798
1807
  constraints.get(left.id).push(right.id);
1799
1808
  incoming.set(right.id, (incoming.get(right.id) ?? 0) + 1);
1800
1809
  }
@@ -1884,7 +1893,7 @@ function applyPostCompaction(input, placement, routes) {
1884
1893
  const itemByNodeId = new Map(compactables.flatMap((item) => item.kind === "node" && item.nodeId ? [[item.nodeId, item]] : []));
1885
1894
  const edgeNodeSpacing = Number(input.settings["spacing.edgeNodeBetweenLayers"] ?? 10);
1886
1895
  for (const edge of input.graph.edges) {
1887
- if (!centerLabeledEdgeIds.has(edge.id)) continue;
1896
+ if (!centerLabeledEdgeIds.has(edge.id) || strategy !== "EDGE_LENGTH" && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true) continue;
1888
1897
  const source = itemByNodeId.get(edge.sourceId);
1889
1898
  const target = itemByNodeId.get(edge.targetId);
1890
1899
  const originalEndpoints = originalEndpointsByEdgeId.get(edge.id);
@@ -1933,7 +1942,13 @@ function applyPostCompaction(input, placement, routes) {
1933
1942
  ...originalEndpoints.end,
1934
1943
  x: originalEndpoints.end.x + (nodeDeltaById.get(edge.targetId) ?? 0)
1935
1944
  };
1936
- if (routes.outsideFeedbackEdgeIds?.has(edgeId)) {
1945
+ if (edge.sourceId === edge.targetId && routes.outsideFeedbackEdgeIds?.has(edgeId)) {
1946
+ const delta = nodeDeltaById.get(edge.sourceId) ?? 0;
1947
+ for (let index = 1; index + 1 < points.length; index++) points[index] = {
1948
+ ...points[index],
1949
+ x: points[index].x + delta
1950
+ };
1951
+ } else if (routes.outsideFeedbackEdgeIds?.has(edgeId)) {
1937
1952
  const horizontal = input.direction === "left" || input.direction === "right";
1938
1953
  const startDelta = points[0].x - originalEndpoints.start.x;
1939
1954
  const endDelta = points.at(-1).x - originalEndpoints.end.x;
@@ -2350,6 +2365,34 @@ function routeEdges(style) {
2350
2365
  });
2351
2366
  }
2352
2367
  }
2368
+ const loopSide = (distribution, index, edge) => {
2369
+ const node = edge ? nodeById.get(edge.sourceId) : void 0;
2370
+ const sourcePort = node?.ports?.find((port) => port.name === edge?.sourcePort);
2371
+ const targetPort = node?.ports?.find((port) => port.name === edge?.targetPort);
2372
+ const sourceSide = node && sourcePort ? input.portSettings?.(sourcePort, node)?.["port.side"] : void 0;
2373
+ const targetSide = node && targetPort ? input.portSettings?.(targetPort, node)?.["port.side"] : void 0;
2374
+ if (sourceSide && sourceSide === targetSide && [
2375
+ "NORTH",
2376
+ "SOUTH",
2377
+ "EAST",
2378
+ "WEST"
2379
+ ].includes(sourceSide)) return sourceSide;
2380
+ return distribution === "EQUALLY" ? [
2381
+ "NORTH",
2382
+ "SOUTH",
2383
+ "EAST",
2384
+ "WEST"
2385
+ ][index % 4] : distribution === "NORTH_SOUTH" && index % 2 === 1 ? "SOUTH" : "NORTH";
2386
+ };
2387
+ const loopDistance = (loops, edge, side, ordering) => {
2388
+ const axis = side === "NORTH" || side === "SOUTH" ? "height" : "width";
2389
+ const ordered = ordering === "REVERSE_STACKED" ? [...loops].reverse() : loops;
2390
+ const index = ordered.indexOf(edge);
2391
+ const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
2392
+ const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
2393
+ const size = (candidate) => input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true ? candidate[axis] ?? 0 : 0;
2394
+ return spacing * (ordering === "SEQUENCED" ? 1 : index + 1) + ordered.slice(0, index).reduce((extent$1, candidate) => extent$1 + (size(candidate) > 0 ? size(candidate) + labelSpacing : 0), 0) + (size(edge) > 0 ? size(edge) / 2 + labelSpacing : 0);
2395
+ };
2353
2396
  const northReserveByLayer = /* @__PURE__ */ new Map();
2354
2397
  for (const [id, loops] of selfLoopsByNodeId) {
2355
2398
  const rect = mutableRects.get(id);
@@ -2379,6 +2422,41 @@ function routeEdges(style) {
2379
2422
  y: candidateRect.y + reserve
2380
2423
  });
2381
2424
  }
2425
+ if (style === "ORTHOGONAL") for (const axis of ["x", "y"]) {
2426
+ const size = axis === "x" ? "width" : "height";
2427
+ const reservations = /* @__PURE__ */ new Map();
2428
+ for (const [id, loops] of selfLoopsByNodeId) {
2429
+ const rect = mutableRects.get(id);
2430
+ const node = nodeById.get(id);
2431
+ if (!rect || !node) continue;
2432
+ const settings = input.nodeSettings?.(node);
2433
+ const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
2434
+ const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
2435
+ for (const [index, edge] of loops.entries()) {
2436
+ if (input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true) continue;
2437
+ const side = loopSide(distribution, index, edge);
2438
+ if (axis === "y" !== (side === "NORTH" || side === "SOUTH")) continue;
2439
+ const extent$1 = loopDistance(loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side), edge, side, ordering) + (edge[size] ?? 0) / 2;
2440
+ const negative = side === "NORTH" || side === "WEST";
2441
+ const boundary = rect[axis] + (negative ? 0 : rect[size]);
2442
+ const neighboringBounds = [...mutableRects.entries()].flatMap(([otherId, other]) => {
2443
+ if (otherId === id) return [];
2444
+ const bound = other[axis] + (negative ? other[size] : 0);
2445
+ return negative ? bound <= boundary ? [bound] : [] : bound >= boundary ? [bound] : [];
2446
+ });
2447
+ const existingGap = neighboringBounds.length === 0 ? 0 : negative ? boundary - Math.max(...neighboringBounds) : Math.min(...neighboringBounds) - boundary;
2448
+ const required = Math.max(0, extent$1 + (neighboringBounds.length > 0 ? input.spacing.node : 0) - existingGap);
2449
+ reservations.set(boundary, Math.max(reservations.get(boundary) ?? 0, required));
2450
+ }
2451
+ }
2452
+ for (const [id, rect] of mutableRects) {
2453
+ const shift = [...reservations].reduce((sum, [boundary, extent$1]) => sum + (rect[axis] >= boundary - 1e-9 ? extent$1 : 0), 0);
2454
+ if (shift > 0) mutableRects.set(id, {
2455
+ ...rect,
2456
+ [axis]: rect[axis] + shift
2457
+ });
2458
+ }
2459
+ }
2382
2460
  const edgeLabelSideSelection = input.settings["edgeLabels.sideSelection"] ?? "SMART_DOWN";
2383
2461
  if (edgeLabelSideSelection === "ALWAYS_UP" || edgeLabelSideSelection === "SMART_UP" || edgeLabelSideSelection === "DIRECTION_UP") {
2384
2462
  let crossShift = 0;
@@ -2671,7 +2749,7 @@ function routeEdges(style) {
2671
2749
  const slots = slotsByGap[layerNo] ?? 0;
2672
2750
  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);
2673
2751
  const preservedGap = (existingGapByLayer[layerNo] ?? input.spacing.layer) - (routesNearTarget ? edgeEdgeSpacing : 0);
2674
- const gapSpacing = slots === 0 ? existingGapByLayer[layerNo] ?? input.spacing.layer : Math.max(preservesNodeFlexibilityGap || (labelExtraByGap[layerNo] ?? 0) > 0 ? preservedGap : input.spacing.layer, 2 * edgeNodeSpacing + Math.max(0, slots - 1) * edgeEdgeSpacing);
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);
2675
2753
  nextStart = bounds.end + gapSpacing;
2676
2754
  }
2677
2755
  implicitEndpoints = implicitEdgeEndpoints(input, placement, orientation);
@@ -2935,7 +3013,7 @@ function routeEdges(style) {
2935
3013
  const constraints = String(input.nodeSettings?.(node)?.portConstraints ?? "UNDEFINED");
2936
3014
  return constraints !== "UNDEFINED" && constraints !== "FREE";
2937
3015
  };
2938
- const fixedSideFeedback = style === "ORTHOGONAL" && !sameSideSelfLoop && (hasFixedPortSide(source) && feedbackSourcePortSide === sourceAwaySide || hasFixedPortSide(target) && feedbackTargetPortSide === targetAwaySide);
3016
+ const fixedSideFeedback = style === "ORTHOGONAL" && !sameSideSelfLoop && (source.id !== target.id || input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true) && (hasFixedPortSide(source) && feedbackSourcePortSide === sourceAwaySide || hasFixedPortSide(target) && feedbackTargetPortSide === targetAwaySide);
2939
3017
  if (input.settings.feedbackEdges === true && reversedEdge || fixedSideFeedback) {
2940
3018
  if (fixedSideFeedback) outsideFeedbackEdgeIds.add(edge.id);
2941
3019
  const crossSpacing = Number(input.settings["spacing.edgeNode"] ?? 10);
@@ -3066,6 +3144,7 @@ function routeEdges(style) {
3066
3144
  continue;
3067
3145
  }
3068
3146
  if (source.id === target.id) {
3147
+ if (style === "ORTHOGONAL" && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true) outsideFeedbackEdgeIds.add(edge.id);
3069
3148
  const loops = selfLoopsByNodeId.get(source.id) ?? [edge];
3070
3149
  const loopIndex = loops.indexOf(edge);
3071
3150
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
@@ -3093,7 +3172,7 @@ function routeEdges(style) {
3093
3172
  });
3094
3173
  const sameSideLoopIndex = sameSideLoops.findIndex((candidate) => candidate.id === edge.id);
3095
3174
  const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
3096
- const trackDistance = spacing + sameSideLoops.slice(0, Math.max(0, sameSideLoopIndex)).reduce((extent$1, candidate) => extent$1 + (side === "EAST" || side === "WEST" ? candidate.width ?? 0 : candidate.height ?? 0) + labelSpacing, 0);
3175
+ const trackDistance = spacing + sameSideLoops.slice(0, Math.max(0, sameSideLoopIndex)).reduce((extent$1, candidate) => extent$1 + (side === "EAST" || side === "WEST" ? candidate.width ?? 0 : candidate.height ?? 0) + labelSpacing, 0) + ((side === "NORTH" || side === "SOUTH") && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true ? (edge.height ?? 0) / 2 + labelSpacing : 0);
3097
3176
  if (side === "EAST" || side === "WEST") {
3098
3177
  const nodeRects = [...placement.rectByNodeId.values()];
3099
3178
  const minimumNodeX = Math.min(...nodeRects.map((rect) => rect.x));
@@ -3146,7 +3225,6 @@ function routeEdges(style) {
3146
3225
  const denominator = countOnSide * 2 + 1;
3147
3226
  const sequenced = ordering === "SEQUENCED";
3148
3227
  const nestingIndex = ordering === "REVERSE_STACKED" ? countOnSide - indexOnSide - 1 : indexOnSide;
3149
- const trackIndex = sequenced ? 1 : nestingIndex + 1;
3150
3228
  let startRatio;
3151
3229
  let endRatio;
3152
3230
  if (sequenced) if (side === "NORTH") {
@@ -3163,7 +3241,8 @@ function routeEdges(style) {
3163
3241
  startRatio = (countOnSide + nestingIndex + 1) / denominator;
3164
3242
  endRatio = (countOnSide - nestingIndex) / denominator;
3165
3243
  }
3166
- const y = side === "NORTH" ? sourceRect.y - spacing * trackIndex : sourceRect.y + sourceRect.height + spacing * trackIndex;
3244
+ const distance = loopDistance(loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side), edge, side, ordering);
3245
+ const y = side === "NORTH" ? sourceRect.y - distance : sourceRect.y + sourceRect.height + distance;
3167
3246
  const start$2 = {
3168
3247
  x: sourceRect.x + sourceRect.width * startRatio,
3169
3248
  y: side === "NORTH" ? sourceRect.y : sourceRect.y + sourceRect.height
@@ -3192,7 +3271,8 @@ function routeEdges(style) {
3192
3271
  const secondRatio = sequenced ? 1 / denominator : 2 / denominator;
3193
3272
  const startRatio = side === "EAST" ? firstRatio : secondRatio;
3194
3273
  const endRatio = side === "EAST" ? secondRatio : firstRatio;
3195
- const x = side === "EAST" ? sourceRect.x + sourceRect.width + spacing * (indexOnSide + 1) : sourceRect.x - spacing * (indexOnSide + 1);
3274
+ const distance = input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true ? loopDistance(loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side), edge, side, ordering) : spacing * (indexOnSide + 1);
3275
+ const x = side === "EAST" ? sourceRect.x + sourceRect.width + distance : sourceRect.x - distance;
3196
3276
  const start$2 = {
3197
3277
  x: side === "EAST" ? sourceRect.x + sourceRect.width : sourceRect.x,
3198
3278
  y: sourceRect.y + sourceRect.height * startRatio
@@ -3699,6 +3779,77 @@ function getPolylineMidpoint(points) {
3699
3779
  };
3700
3780
  }
3701
3781
 
3782
+ //#endregion
3783
+ //#region src/layered/separate-exterior-labels.ts
3784
+ /** Separates movable inline labels and their exterior orthogonal route tracks. */
3785
+ function separateExteriorLabels({ edges, nodeRects, direction, spacing, settings }) {
3786
+ const horizontalFlow = direction === "left" || direction === "right";
3787
+ const nodeCrossStart = Math.min(...nodeRects.map((rect) => horizontalFlow ? rect.y : rect.x));
3788
+ const nodeCrossEnd = Math.max(...nodeRects.map((rect) => horizontalFlow ? rect.y + rect.height : rect.x + rect.width));
3789
+ const flowStart = (rect) => horizontalFlow ? rect.x : rect.y;
3790
+ const flowEnd = (rect) => horizontalFlow ? rect.x + rect.width : rect.y + rect.height;
3791
+ const crossStart = (rect) => horizontalFlow ? rect.y : rect.x;
3792
+ const crossEnd = (rect) => horizontalFlow ? rect.y + rect.height : rect.x + rect.width;
3793
+ const overlaps = (left, right) => flowStart(left) < flowEnd(right) && flowEnd(left) > flowStart(right) && crossStart(left) < crossEnd(right) && crossEnd(left) > crossStart(right);
3794
+ const labelRectByEdgeId = new Map(edges.filter((edge) => edge.width > 0 && edge.height > 0).map((edge) => [String(edge.id), {
3795
+ x: edge.x,
3796
+ y: edge.y,
3797
+ width: edge.width,
3798
+ height: edge.height
3799
+ }]));
3800
+ const movableLabels = edges.flatMap((edge) => {
3801
+ const labelRect = labelRectByEdgeId.get(String(edge.id));
3802
+ const labelSettings = settings(edge);
3803
+ if (!labelRect || !labelSettings.inline || labelSettings.placement !== "CENTER") return [];
3804
+ const exteriorTrack = edge.points.flatMap((point, index) => {
3805
+ const next = edge.points[index + 1];
3806
+ if (!next) return [];
3807
+ const isFlowSegment = horizontalFlow ? point.y === next.y && point.x !== next.x : point.x === next.x && point.y !== next.y;
3808
+ const trackCross = horizontalFlow ? point.y : point.x;
3809
+ return isFlowSegment && (trackCross < nodeCrossStart || trackCross > nodeCrossEnd) ? [{
3810
+ cross: trackCross,
3811
+ length: horizontalFlow ? Math.abs(next.x - point.x) : Math.abs(next.y - point.y)
3812
+ }] : [];
3813
+ }).sort((left, right) => right.length - left.length)[0];
3814
+ if (!exteriorTrack) return [];
3815
+ return [{
3816
+ edge,
3817
+ exteriorTrack,
3818
+ labelRect,
3819
+ lowSide: exteriorTrack.cross < (nodeCrossStart + nodeCrossEnd) / 2
3820
+ }];
3821
+ });
3822
+ movableLabels.sort((left, right) => {
3823
+ if (left.lowSide !== right.lowSide) return left.lowSide ? -1 : 1;
3824
+ 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
+ });
3826
+ for (const { edge, exteriorTrack, labelRect, lowSide } of movableLabels) {
3827
+ const obstacles = [...nodeRects, ...[...labelRectByEdgeId.entries()].flatMap(([edgeId, rect]) => edgeId === String(edge.id) ? [] : [rect])];
3828
+ let totalDelta = 0;
3829
+ while (true) {
3830
+ const blockers = obstacles.filter((rect) => overlaps(labelRect, rect));
3831
+ if (blockers.length === 0) break;
3832
+ const delta = (lowSide ? Math.min(...blockers.map(crossStart)) - spacing - (horizontalFlow ? edge.height : edge.width) : Math.max(...blockers.map(crossEnd)) + spacing) - crossStart(labelRect);
3833
+ totalDelta += delta;
3834
+ if (horizontalFlow) labelRect.y += delta;
3835
+ else labelRect.x += delta;
3836
+ }
3837
+ if (totalDelta === 0) continue;
3838
+ if (horizontalFlow) edge.y += totalDelta;
3839
+ 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 ? {
3843
+ ...point,
3844
+ y: point.y + totalDelta
3845
+ } : {
3846
+ ...point,
3847
+ x: point.x + totalDelta
3848
+ };
3849
+ });
3850
+ }
3851
+ }
3852
+
3702
3853
  //#endregion
3703
3854
  //#region src/layered/network-simplex.ts
3704
3855
  function connectedEdges(node) {
@@ -8691,30 +8842,37 @@ function runLayeredPipeline(graph, options, context) {
8691
8842
  }
8692
8843
  {
8693
8844
  const horizontal = direction === "right" || direction === "left";
8845
+ const targetsBySource = /* @__PURE__ */ new Map();
8846
+ for (const edge of graph.edges) {
8847
+ if (edge.sourceId === edge.targetId) continue;
8848
+ const targets = targetsBySource.get(edge.sourceId) ?? /* @__PURE__ */ new Set();
8849
+ targets.add(edge.targetId);
8850
+ targetsBySource.set(edge.sourceId, targets);
8851
+ }
8694
8852
  for (const edge of graph.edges) {
8695
8853
  if (edge.sourcePort === void 0) continue;
8696
8854
  const source = graph.nodes.find((node) => node.id === edge.sourceId);
8697
8855
  const targetRect = mutableRects.get(edge.targetId);
8698
8856
  const sourceRect = mutableRects.get(edge.sourceId);
8699
- const port = source?.ports?.find((candidate) => candidate.name === edge.sourcePort);
8857
+ const port = source?.ports?.find((candidate$1) => candidate$1.name === edge.sourcePort);
8700
8858
  if (!source || !sourceRect || !targetRect || !port) continue;
8701
8859
  const side = input.portSettings?.(port, source)?.["port.side"];
8702
- if (horizontal && side === "NORTH") mutableRects.set(edge.targetId, {
8860
+ if (edge.sourceId === edge.targetId || (targetsBySource.get(edge.sourceId)?.size ?? 0) > 1) continue;
8861
+ const candidate = horizontal && side === "NORTH" ? {
8703
8862
  ...targetRect,
8704
8863
  y: sourceRect.y - (port.height ?? 0) - targetRect.height
8705
- });
8706
- else if (horizontal && side === "SOUTH") mutableRects.set(edge.targetId, {
8864
+ } : horizontal && side === "SOUTH" ? {
8707
8865
  ...targetRect,
8708
8866
  y: sourceRect.y + sourceRect.height + (port.height ?? 0)
8709
- });
8710
- else if (!horizontal && side === "WEST") mutableRects.set(edge.targetId, {
8867
+ } : !horizontal && side === "WEST" ? {
8711
8868
  ...targetRect,
8712
8869
  x: sourceRect.x - (port.width ?? 0) - targetRect.width
8713
- });
8714
- else if (!horizontal && side === "EAST") mutableRects.set(edge.targetId, {
8870
+ } : !horizontal && side === "EAST" ? {
8715
8871
  ...targetRect,
8716
8872
  x: sourceRect.x + sourceRect.width + (port.width ?? 0)
8717
- });
8873
+ } : void 0;
8874
+ if (!candidate || [...mutableRects].some(([id, other]) => id !== edge.targetId && other.width > 0 && other.height > 0 && candidate.x < other.x + other.width && candidate.x + candidate.width > other.x && candidate.y < other.y + other.height && candidate.y + candidate.height > other.y)) continue;
8875
+ mutableRects.set(edge.targetId, candidate);
8718
8876
  }
8719
8877
  }
8720
8878
  measure("port-margin-normalization", () => normalizePlacementForPortExtents(expanded.input, placement, order, expanded.orientation));
@@ -8743,7 +8901,7 @@ function runLayeredPipeline(graph, options, context) {
8743
8901
  const edgeRouting = options.settings?.edgeRouting ?? "ORTHOGONAL";
8744
8902
  const edgeRouter = options.strategies?.routeEdges ?? (edgeRouting === "POLYLINE" ? routeEdgesWithPolylines : edgeRouting === "SPLINES" ? routeEdgesWithSplines : routeEdgesOrthogonally);
8745
8903
  let expandedRoutes = measure("edge-routing", () => edgeRouter(expanded.input, expanded.orientation, placement));
8746
- measure("post-compaction", () => applyPostCompaction(expanded.input, placement, expandedRoutes));
8904
+ measure("post-compaction", () => applyPostCompaction(expanded.input, placement, expandedRoutes, graph.edges));
8747
8905
  const antiparallelLabelPositions = /* @__PURE__ */ new Map();
8748
8906
  const outerAntiparallelLabelIds = /* @__PURE__ */ new Set();
8749
8907
  const parallelLabelPositions = /* @__PURE__ */ new Map();
@@ -9248,10 +9406,10 @@ function runLayeredPipeline(graph, options, context) {
9248
9406
  const constraints = node ? options.nodeSettings?.(node)?.portConstraints : void 0;
9249
9407
  return constraints === void 0 || constraints === "UNDEFINED" || constraints === "FREE";
9250
9408
  };
9251
- const flexibleFeedbackLabel = labelPlacement === "CENTER" && inlineLabel && expanded.orientation.reversedEdgeIds.has(edge.id) && hasFlexiblePorts(sourceNode) && hasFlexiblePorts(targetNode) && sourceRect !== void 0 && targetRect !== void 0;
9409
+ const flexibleFeedbackLabel = labelPlacement === "CENTER" && inlineLabel && edge.sourceId !== edge.targetId && expanded.orientation.reversedEdgeIds.has(edge.id) && hasFlexiblePorts(sourceNode) && hasFlexiblePorts(targetNode) && sourceRect !== void 0 && targetRect !== void 0;
9252
9410
  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];
9253
9411
  const antiparallelLabelPosition = antiparallelLabelPositions.get(edge.id);
9254
- const explicitLabelPosition = (() => {
9412
+ const outerAntiparallelLabelPosition = (() => {
9255
9413
  if (!outerAntiparallelLabelIds.has(edge.id)) return void 0;
9256
9414
  const edgeNodeSpacing$1 = Number(options.settings?.["spacing.edgeNode"] ?? 10);
9257
9415
  if (horizontal) {
@@ -9276,9 +9434,14 @@ function runLayeredPipeline(graph, options, context) {
9276
9434
  x: Math.max(...blockers.map((rect) => rect.x + rect.width)) + edgeNodeSpacing$1,
9277
9435
  y: y$1
9278
9436
  };
9279
- })() ?? antiparallelLabelPosition ?? parallelLabelPositions.get(edge.id);
9280
- 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;
9281
- 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;
9437
+ })();
9438
+ const centerFlowPosition = options.settings?.["compaction.postCompaction.strategy"] !== "EDGE_LENGTH" && labelDummyRect === void 0 && edgeRouting === "ORTHOGONAL" && inlineLabel && labelPlacement === "CENTER" && edge.sourceId !== edge.targetId && beforeFlowRect && afterFlowRect && (horizontal ? afterFlowRect.x - beforeFlowRect.x - beforeFlowRect.width >= width : afterFlowRect.y - beforeFlowRect.y - beforeFlowRect.height >= height) ? horizontal ? (beforeFlowRect.x + beforeFlowRect.width + afterFlowRect.x - width) / 2 : (beforeFlowRect.y + beforeFlowRect.height + afterFlowRect.y - height) / 2 - edgeThickness / 2 : void 0;
9439
+ const explicitLabelPosition = (edgeRouting === "ORTHOGONAL" && labelPlacement === "CENTER" && inlineLabel && edge.sourceId === edge.targetId && points.length === 4 ? {
9440
+ x: (points[1].x + points[2].x - width) / 2,
9441
+ y: (points[1].y + points[2].y - height) / 2
9442
+ } : void 0) ?? outerAntiparallelLabelPosition ?? antiparallelLabelPosition ?? parallelLabelPositions.get(edge.id);
9443
+ const x = explicitLabelPosition ? explicitLabelPosition.x : flexibleFeedbackLabel ? horizontal ? (beforeFlowRect.x + beforeFlowRect.width + afterFlowRect.x - width) / 2 : targetRect.x + (targetRect.width - width) / 2 : centerFlowPosition !== void 0 && horizontal ? centerFlowPosition : labelPlacement === "CENTER" && horizontal && labelDummyRect ? labelDummyRect.x : routeX;
9444
+ const y = explicitLabelPosition ? explicitLabelPosition.y : flexibleFeedbackLabel ? horizontal ? targetRect.y + (targetRect.height - height) / 2 : (beforeFlowRect.y + beforeFlowRect.height + afterFlowRect.y - height) / 2 : centerFlowPosition !== void 0 && !horizontal ? centerFlowPosition : labelPlacement === "CENTER" && !horizontal && labelDummyRect ? labelDummyRect.y : routeY;
9282
9445
  return {
9283
9446
  ...edge,
9284
9447
  x,
@@ -9289,6 +9452,19 @@ function runLayeredPipeline(graph, options, context) {
9289
9452
  routing: edgeRouting === "POLYLINE" ? "polyline" : edgeRouting === "SPLINES" ? "splines" : "orthogonal"
9290
9453
  };
9291
9454
  });
9455
+ if (edgeRouting === "ORTHOGONAL") separateExteriorLabels({
9456
+ edges,
9457
+ nodeRects: feedbackNodeRects,
9458
+ direction,
9459
+ spacing: Number(options.settings?.["spacing.edgeEdge"] ?? 10),
9460
+ settings: (edge) => {
9461
+ const edgeSettings = options.edgeSettings?.(edge);
9462
+ return {
9463
+ inline: edgeSettings?.["edgeLabels.inline"] === true,
9464
+ placement: edgeSettings?.["edgeLabels.placement"] ?? "CENTER"
9465
+ };
9466
+ }
9467
+ });
9292
9468
  return {
9293
9469
  ...graph,
9294
9470
  direction,
@@ -1,4 +1,4 @@
1
- import { F as placePorts, O as getPolylineMidpoint, z as JavaRandom } from "./layered-Bp8MkxWK.mjs";
1
+ import { F as placePorts, O as getPolylineMidpoint, z as JavaRandom } from "./layered-CZs9YWVx.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.4",
3
+ "version": "0.1.0",
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",
@@ -510,6 +510,7 @@ export default class ELK {
510
510
  if (
511
511
  algorithm === "layered" &&
512
512
  graph_.edges.some((edge) => {
513
+ if (edge.sourceId === edge.targetId) return false;
513
514
  const sourceConstraint = layerConstraintByNodeId.get(edge.sourceId);
514
515
  const targetConstraint = layerConstraintByNodeId.get(edge.targetId);
515
516
  return (
@@ -722,10 +723,15 @@ export default class ELK {
722
723
  }
723
724
  const edgeNodeSpacing = getNumberOption(layoutOptions, "spacing.edgeNodeBetweenLayers") ?? 10;
724
725
  for (const restoration of hierarchyRestorations) {
725
- const route = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id))?.points;
726
+ const nativeEdge = laidOut.edges.find((edge) => edge.id === String(restoration.edge.id));
727
+ const route = nativeEdge?.points;
726
728
  if (!route || route.length < 2) continue;
727
729
  const start = route[0]!;
728
730
  const end = route.at(-1)!;
731
+ // Decomposed ancestor/descendant edges are native self-loops. Preserve
732
+ // their reserved exterior route and label placement instead of treating
733
+ // equal endpoint coordinates as a straight inter-node connection.
734
+ if (nativeEdge.sourceId === nativeEdge.targetId) continue;
729
735
  if (Math.abs(cross(start) - cross(end)) < 1e-9) {
730
736
  route.splice(1, route.length - 2);
731
737
  continue;
@@ -1824,3 +1830,11 @@ function placePortLabels(node: ElkNode, globalOptions: Readonly<Record<string, u
1824
1830
  }
1825
1831
  }
1826
1832
  }
1833
+
1834
+ export { compileStatechartLayout, layoutStatechart, scoreStatechartLayout } from "./statechart";
1835
+ export type {
1836
+ StatechartScope,
1837
+ StatechartLayoutOptions,
1838
+ StatechartLayoutScore,
1839
+ StatechartLayoutPlan,
1840
+ } from "./statechart";