@statelyai/layout 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1608,8 +1608,174 @@ function findNode(nodes, id) {
1608
1608
  return position !== void 0 && nodes[position]?.id === id ? nodes[position] : nodes.find((node) => node.id === id);
1609
1609
  }
1610
1610
 
1611
+ //#endregion
1612
+ //#region src/layered/self-loop-ports.ts
1613
+ const hiddenBySettings = /* @__PURE__ */ new WeakMap();
1614
+ /** Sizes of the ports SelfLoopPreProcessor hid from a loop, if any. */
1615
+ function hiddenLoopPorts(settings, edgeId) {
1616
+ return hiddenBySettings.get(settings)?.get(edgeId);
1617
+ }
1618
+ /**
1619
+ * ELK's SelfLoopPreProcessor removes ports that carry only self loops from
1620
+ * nodes whose port order is not fixed, and SelfLoopPortRestorer later puts
1621
+ * them on the loop's side. The layered phases then see implicit loop ports;
1622
+ * {@link restoreSelfLoopPorts} places the hidden ports at the loop ends.
1623
+ */
1624
+ function hideSelfLoopPorts(graph, options) {
1625
+ const uses = /* @__PURE__ */ new Map();
1626
+ const nonLoop = /* @__PURE__ */ new Set();
1627
+ for (const edge of graph.edges) for (const [node, port] of [[edge.sourceId, edge.sourcePort], [edge.targetId, edge.targetPort]]) {
1628
+ if (port === void 0) continue;
1629
+ const key = `${node}\0${port}`;
1630
+ uses.set(key, (uses.get(key) ?? 0) + 1);
1631
+ if (edge.sourceId !== edge.targetId) nonLoop.add(key);
1632
+ }
1633
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
1634
+ const hidden = /* @__PURE__ */ new Map();
1635
+ const edges = /* @__PURE__ */ new Map();
1636
+ const hiddenPorts = /* @__PURE__ */ new Set();
1637
+ for (const edge of graph.edges) {
1638
+ if (edge.sourceId !== edge.targetId || edge.sourcePort === void 0 || edge.targetPort === void 0 || edge.sourcePort === edge.targetPort) continue;
1639
+ const node = nodeById.get(edge.sourceId);
1640
+ const constraints = node && options.nodeSettings?.(node)?.portConstraints;
1641
+ if (!node || constraints !== void 0 && constraints !== "FREE" && constraints !== "UNDEFINED") continue;
1642
+ const ports = [edge.sourcePort, edge.targetPort].map((name) => node.ports?.find((port) => port.name === name));
1643
+ if (ports.some((port) => !port || uses.get(`${node.id}\0${port.name}`) !== 1 || nonLoop.has(`${node.id}\0${port.name}`))) continue;
1644
+ const size$1 = (port) => ({
1645
+ width: port.width ?? 0,
1646
+ height: port.height ?? 0
1647
+ });
1648
+ hidden.set(edge.id, {
1649
+ source: size$1(ports[0]),
1650
+ target: size$1(ports[1])
1651
+ });
1652
+ edges.set(edge.id, edge);
1653
+ for (const port of ports) hiddenPorts.add(`${node.id}\0${port.name}`);
1654
+ }
1655
+ if (hidden.size === 0) return void 0;
1656
+ const settings = { ...options.settings };
1657
+ hiddenBySettings.set(settings, hidden);
1658
+ return {
1659
+ graph: {
1660
+ ...graph,
1661
+ nodes: graph.nodes.map((node) => node.ports?.some((port) => hiddenPorts.has(`${node.id}\0${port.name}`)) ? {
1662
+ ...node,
1663
+ ports: node.ports.filter((port) => !hiddenPorts.has(`${node.id}\0${port.name}`))
1664
+ } : node),
1665
+ edges: graph.edges.map((edge) => hidden.has(edge.id) ? {
1666
+ ...edge,
1667
+ sourcePort: void 0,
1668
+ targetPort: void 0
1669
+ } : edge)
1670
+ },
1671
+ options: {
1672
+ ...options,
1673
+ settings
1674
+ },
1675
+ edges
1676
+ };
1677
+ }
1678
+ /** Put hidden loop ports back, anchored on the side their loop leaves from. */
1679
+ function restoreSelfLoopPorts(graph, result, edges) {
1680
+ const anchors = /* @__PURE__ */ new Map();
1681
+ for (const edge of result.edges) {
1682
+ const original = edges.get(edge.id);
1683
+ const points = edge.points;
1684
+ if (!original || !points?.length) continue;
1685
+ anchors.set(`${edge.sourceId}\0${original.sourcePort}`, points[0]);
1686
+ anchors.set(`${edge.targetId}\0${original.targetPort}`, points.at(-1));
1687
+ }
1688
+ const originalById = new Map(graph.nodes.map((node) => [node.id, node]));
1689
+ return {
1690
+ ...result,
1691
+ nodes: result.nodes.map((node) => {
1692
+ const original = originalById.get(node.id)?.ports;
1693
+ if (!original?.some((port) => anchors.has(`${node.id}\0${port.name}`))) return node;
1694
+ const placed = new Map(node.ports?.map((port) => [port.name, port]));
1695
+ return {
1696
+ ...node,
1697
+ ports: original.map((port) => {
1698
+ const anchor = anchors.get(`${node.id}\0${port.name}`);
1699
+ const width = port.width ?? 0, height = port.height ?? 0;
1700
+ if (!anchor) return placed.get(port.name) ?? {
1701
+ ...port,
1702
+ width,
1703
+ height,
1704
+ x: 0,
1705
+ y: 0
1706
+ };
1707
+ const x = anchor.x - node.x, y = anchor.y - node.y;
1708
+ return {
1709
+ ...port,
1710
+ width,
1711
+ height,
1712
+ x: x <= 0 ? x : x >= node.width ? x - width : x - width / 2,
1713
+ y: x <= 0 || x >= node.width ? y - height / 2 : y <= 0 ? y : y - height
1714
+ };
1715
+ })
1716
+ };
1717
+ }),
1718
+ edges: result.edges.map((edge) => {
1719
+ const original = edges.get(edge.id);
1720
+ return original ? {
1721
+ ...edge,
1722
+ sourcePort: original.sourcePort,
1723
+ targetPort: original.targetPort
1724
+ } : edge;
1725
+ })
1726
+ };
1727
+ }
1728
+
1611
1729
  //#endregion
1612
1730
  //#region src/layered/loop-envelopes.ts
1731
+ const crossSide = (side) => side === "NORTH" || side === "SOUTH";
1732
+ /**
1733
+ * Extents of a movable loop's hidden ports in the canonical rightward frame:
1734
+ * along its side, and protruding from it.
1735
+ */
1736
+ function loopPortExtents(input, edge, side) {
1737
+ const ports = hiddenLoopPorts(input.settings, edge.id);
1738
+ const horizontal$1 = input.direction === "right" || input.direction === "left";
1739
+ const along = crossSide(side) === horizontal$1 ? "width" : "height";
1740
+ const normal = along === "width" ? "height" : "width";
1741
+ return {
1742
+ along: ports ? ports.source[along] + ports.target[along] : 0,
1743
+ normal: ports ? Math.max(ports.source[normal], ports.target[normal]) : 0
1744
+ };
1745
+ }
1746
+ /** A movable loop's label extent away from its side, in the canonical frame. */
1747
+ function movableLoopLabelExtent(input, edge, side) {
1748
+ const horizontal$1 = input.direction === "right" || input.direction === "left";
1749
+ const size$1 = (crossSide(side) === horizontal$1 ? edge.height : edge.width) ?? 0;
1750
+ return size$1 > 0 ? size$1 + Number(input.settings["spacing.edgeLabel"] ?? 2) : 0;
1751
+ }
1752
+ /**
1753
+ * ELK's OrthogonalSelfLoopRouter routing slot of a movable loop, measured
1754
+ * from its node side in the canonical frame: past the protruding ports and
1755
+ * the self-loop spacing, then one edge spacing plus any label per inner slot.
1756
+ */
1757
+ function movableLoopTrack(input, sideLoops, edge, side, ordering) {
1758
+ const ordered = ordering === "REVERSE_STACKED" ? [...sideLoops].reverse() : sideLoops;
1759
+ const inner = ordering === "SEQUENCED" ? [] : ordered.slice(0, ordered.indexOf(edge));
1760
+ const edgeSpacing = Number(input.settings["spacing.edgeEdge"] ?? 10);
1761
+ return Math.max(0, ...sideLoops.map((loop) => loopPortExtents(input, loop, side).normal)) + Number(input.settings["spacing.nodeSelfLoop"] ?? 10) + inner.reduce((sum, loop) => sum + edgeSpacing + (crossSide(side) ? movableLoopLabelExtent(input, loop, side) : 0), 0);
1762
+ }
1763
+ /**
1764
+ * Canonical position of a movable loop port along its side, given its
1765
+ * DISTRIBUTED ratio among the side's implicit ports (ELK's port placement
1766
+ * with equal gaps between hidden ports of their own size).
1767
+ */
1768
+ function movableLoopPortOffset(input, sideLoops, side, length, ratio) {
1769
+ const count = sideLoops.length * 2;
1770
+ const extents = sideLoops.map((loop) => loopPortExtents(input, loop, side));
1771
+ const total = extents.reduce((sum, extent$2) => sum + extent$2.along, 0);
1772
+ const size$1 = total / count;
1773
+ const index = Math.round(ratio * (count + 1)) - 1;
1774
+ return {
1775
+ along: (length - total) / (count + 1) * (index + 1) + size$1 * index + size$1 / 2,
1776
+ normal: Math.max(0, ...extents.map((extent$2) => extent$2.normal))
1777
+ };
1778
+ }
1613
1779
  /** Unconstrained implicit loop ports are restored on loop faces after the sweep. */
1614
1780
  function hasMovableLoopPorts(input, edge) {
1615
1781
  if (edge.sourceId !== edge.targetId || edge.sourcePort !== void 0 || edge.targetPort !== void 0) return false;
@@ -1630,14 +1796,14 @@ function loopEnvelopes(input) {
1630
1796
  const allLoops = loopsByNodeId.get(node.id)?.slice() ?? [];
1631
1797
  const constraints = input.nodeSettings?.(node)?.portConstraints;
1632
1798
  if (allLoops.length && constraints && constraints !== "FREE" && constraints !== "UNDEFINED" && allLoops.every((edge) => edge.sourcePort !== void 0 && edge.targetPort !== void 0 && input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true && ((edge.width ?? 0) === 0 && (edge.height ?? 0) === 0 || (input.edgeSettings?.(edge)?.["edgeLabels.placement"] ?? "CENTER") === "CENTER"))) {
1633
- const size$1 = input.sizes.get(node.id) ?? {
1799
+ const size$2 = input.sizes.get(node.id) ?? {
1634
1800
  width: 0,
1635
1801
  height: 0
1636
1802
  };
1637
1803
  const rect = {
1638
1804
  x: 0,
1639
1805
  y: 0,
1640
- ...size$1
1806
+ ...size$2
1641
1807
  };
1642
1808
  const envelope$1 = {
1643
1809
  before: 0,
@@ -1645,9 +1811,9 @@ function loopEnvelopes(input) {
1645
1811
  flowBefore: 0,
1646
1812
  flowAfter: 0
1647
1813
  };
1648
- const horizontal$1 = input.direction === "right" || input.direction === "left";
1814
+ const horizontal$2 = input.direction === "right" || input.direction === "left";
1649
1815
  const reverse = input.direction === "left" || input.direction === "up";
1650
- const spacing$1 = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
1816
+ const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
1651
1817
  let supported = true;
1652
1818
  const tracks = selfLoopTracks(allLoops);
1653
1819
  for (const edge of allLoops) {
@@ -1666,7 +1832,7 @@ function loopEnvelopes(input) {
1666
1832
  }, input.direction, input), getPortPoint(node, edge.targetPort, rect, {
1667
1833
  x: 0,
1668
1834
  y: 0
1669
- }, input.direction, input), sourceSide, targetSide, spacing$1 * (tracks.get(edge.id) + 1), input.direction);
1835
+ }, input.direction, input), sourceSide, targetSide, spacing * (tracks.get(edge.id) + 1), input.direction);
1670
1836
  if (labeled) {
1671
1837
  const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
1672
1838
  const { width = 0, height = 0 } = edge;
@@ -1676,13 +1842,13 @@ function loopEnvelopes(input) {
1676
1842
  });
1677
1843
  }
1678
1844
  for (const point of route) {
1679
- const cross = horizontal$1 ? point.y : point.x;
1680
- const flow = horizontal$1 ? point.x : point.y;
1681
- const flowSize = horizontal$1 ? size$1.width : size$1.height;
1845
+ const cross = horizontal$2 ? point.y : point.x;
1846
+ const flow = horizontal$2 ? point.x : point.y;
1847
+ const flowSize$1 = horizontal$2 ? size$2.width : size$2.height;
1682
1848
  envelope$1.before = Math.max(envelope$1.before, -cross);
1683
- envelope$1.after = Math.max(envelope$1.after, cross - (horizontal$1 ? size$1.height : size$1.width));
1684
- envelope$1.flowBefore = Math.max(envelope$1.flowBefore, reverse ? flow - flowSize : -flow);
1685
- envelope$1.flowAfter = Math.max(envelope$1.flowAfter, reverse ? -flow : flow - flowSize);
1849
+ envelope$1.after = Math.max(envelope$1.after, cross - (horizontal$2 ? size$2.height : size$2.width));
1850
+ envelope$1.flowBefore = Math.max(envelope$1.flowBefore, reverse ? flow - flowSize$1 : -flow);
1851
+ envelope$1.flowAfter = Math.max(envelope$1.flowAfter, reverse ? -flow : flow - flowSize$1);
1686
1852
  }
1687
1853
  }
1688
1854
  if (supported) {
@@ -1691,11 +1857,10 @@ function loopEnvelopes(input) {
1691
1857
  }
1692
1858
  }
1693
1859
  const loops = allLoops.filter((edge) => hasMovableLoopPorts(input, edge));
1694
- if (loops.length === 0 || loops.some((edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)) continue;
1860
+ if (loops.length === 0) continue;
1695
1861
  const settings = input.nodeSettings?.(node);
1696
1862
  const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
1697
1863
  const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
1698
- const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
1699
1864
  const envelope = {
1700
1865
  before: 0,
1701
1866
  after: 0,
@@ -1708,12 +1873,28 @@ function loopEnvelopes(input) {
1708
1873
  "flowAfter",
1709
1874
  "flowBefore"
1710
1875
  ] : distribution === "NORTH_SOUTH" ? ["before", "after"] : ["before"];
1876
+ const canonicalSides = {
1877
+ before: "NORTH",
1878
+ after: "SOUTH",
1879
+ flowAfter: "EAST",
1880
+ flowBefore: "WEST"
1881
+ };
1882
+ const horizontal$1 = input.direction === "right" || input.direction === "left";
1883
+ const size$1 = input.sizes.get(node.id) ?? {
1884
+ width: 0,
1885
+ height: 0
1886
+ };
1887
+ const flowSize = horizontal$1 ? size$1.width : size$1.height;
1711
1888
  for (const side of sides$1) {
1712
1889
  const selected = loops.filter((_, index) => sides$1[index % sides$1.length] === side);
1713
- const horizontal$1 = input.direction === "right" || input.direction === "left";
1714
- const crossSize$3 = side === "before" || side === "after" ? horizontal$1 ? "height" : "width" : horizontal$1 ? "width" : "height";
1715
- const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
1716
- envelope[side] = spacing * (ordering === "SEQUENCED" ? Math.min(1, selected.length) : selected.length) + selected.reduce((sum, edge) => sum + ((edge[crossSize$3] ?? 0) > 0 ? (edge[crossSize$3] ?? 0) + labelSpacing : 0), 0);
1890
+ const canonical = canonicalSides[side];
1891
+ envelope[side] = Math.max(envelope[side], ...selected.map((edge) => movableLoopTrack(input, selected, edge, canonical, ordering) + movableLoopLabelExtent(input, edge, canonical)));
1892
+ if (!crossSide(canonical)) continue;
1893
+ for (const edge of selected) {
1894
+ const overhang = ((horizontal$1 ? edge.width : edge.height) ?? 0) - flowSize;
1895
+ envelope.flowBefore = Math.max(envelope.flowBefore, overhang / 2);
1896
+ envelope.flowAfter = Math.max(envelope.flowAfter, overhang / 2);
1897
+ }
1717
1898
  }
1718
1899
  envelopes.set(node.id, envelope);
1719
1900
  }
@@ -2881,7 +3062,7 @@ function compactJoinedGeometryUnchecked(input, placement, routes, orientation) {
2881
3062
  if (point.x >= rect.x + rect.width - 1e-9) return "EAST";
2882
3063
  return point.y <= rect.y ? "NORTH" : "SOUTH";
2883
3064
  };
2884
- const crossSide = (side) => side === "NORTH" || side === "SOUTH";
3065
+ const crossSide$1 = (side) => side === "NORTH" || side === "SOUTH";
2885
3066
  const segments = [];
2886
3067
  for (const [edgeId, points] of canonicalRoutes) {
2887
3068
  const edge = edgeById.get(edgeId);
@@ -2891,8 +3072,8 @@ function compactJoinedGeometryUnchecked(input, placement, routes, orientation) {
2891
3072
  const first = points[index];
2892
3073
  const second = points[index + 1];
2893
3074
  if (Math.abs(first.x - second.x) > 1e-9 || Math.abs(first.y - second.y) < 1e-9) continue;
2894
- if (index === 0 && !crossSide(endpointSide(edge.sourceId, edge.sourcePort, first))) continue;
2895
- if (index === points.length - 2 && !crossSide(endpointSide(edge.targetId, edge.targetPort, second))) continue;
3075
+ if (index === 0 && !crossSide$1(endpointSide(edge.sourceId, edge.sourcePort, first))) continue;
3076
+ if (index === points.length - 2 && !crossSide$1(endpointSide(edge.targetId, edge.targetPort, second))) continue;
2896
3077
  const owner = index === 0 ? nodes.get(edge.sourceId) : index === points.length - 2 ? nodes.get(edge.targetId) : void 0;
2897
3078
  const ownerSide = owner ? endpointSide(index === 0 ? edge.sourceId : edge.targetId, index === 0 ? edge.sourcePort : edge.targetPort, index === 0 ? first : second) : void 0;
2898
3079
  const bend = index === 0 ? second : first;
@@ -3055,7 +3236,7 @@ function compactJoinedGeometryUnchecked(input, placement, routes, orientation) {
3055
3236
  const points = canonicalRoutes.get(edge.id);
3056
3237
  const sourceSide = endpointSide(edge.sourceId, edge.sourcePort, points?.[0]);
3057
3238
  const targetSide = endpointSide(edge.targetId, edge.targetPort, points?.at(-1));
3058
- if (crossSide(sourceSide) && crossSide(targetSide)) continue;
3239
+ if (crossSide$1(sourceSide) && crossSide$1(targetSide)) continue;
3059
3240
  const reversed = orientation?.reversedEdgeIds.has(edge.id) ?? source.x > target.x;
3060
3241
  const from = reversed ? target : source, to = reversed ? source : target;
3061
3242
  constraints.push({
@@ -3149,8 +3330,8 @@ function compactJoinedGeometryUnchecked(input, placement, routes, orientation) {
3149
3330
  const start = pointToCanonical(originals[0]);
3150
3331
  const next = pointToCanonical(originals[1]);
3151
3332
  const previous = pointToCanonical(originals.at(-2));
3152
- if (!crossSide(endpointSide(edge.sourceId, edge.sourcePort, start)) && Math.abs(start.x - next.x) < 1e-9) points[1].x = points[0].x;
3153
- if (!crossSide(endpointSide(edge.targetId, edge.targetPort, end)) && Math.abs(end.x - previous.x) < 1e-9) points[points.length - 2].x = points.at(-1).x;
3333
+ if (!crossSide$1(endpointSide(edge.sourceId, edge.sourcePort, start)) && Math.abs(start.x - next.x) < 1e-9) points[1].x = points[0].x;
3334
+ if (!crossSide$1(endpointSide(edge.targetId, edge.targetPort, end)) && Math.abs(end.x - previous.x) < 1e-9) points[points.length - 2].x = points.at(-1).x;
3154
3335
  }
3155
3336
  }
3156
3337
  const extent$2 = Math.max(...items.map((item) => item.x + item.width), ...[...canonicalRoutes.values()].flatMap((points) => points.map((point) => point.x)));
@@ -6427,13 +6608,13 @@ function routeEdges(style) {
6427
6608
  "WEST"
6428
6609
  ][index % 4] : distribution === "NORTH_SOUTH" && index % 2 === 1 ? "SOUTH" : "NORTH";
6429
6610
  };
6430
- const loopDistance = (loops, edge, side, ordering, canonical = false) => {
6431
- const axis = (side === "NORTH" || side === "SOUTH") === (!canonical || horizontal$1) ? "height" : "width";
6611
+ const loopDistance = (loops, edge, side, ordering) => {
6612
+ const axis = side === "NORTH" || side === "SOUTH" ? "height" : "width";
6432
6613
  const ordered = ordering === "REVERSE_STACKED" ? [...loops].reverse() : loops;
6433
6614
  const index = ordered.indexOf(edge);
6434
6615
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
6435
6616
  const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
6436
- const size$1 = (candidate) => canonical || input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true ? candidate[axis] ?? 0 : 0;
6617
+ const size$1 = (candidate) => input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true ? candidate[axis] ?? 0 : 0;
6437
6618
  return spacing * (ordering === "SEQUENCED" ? 1 : index + 1) + ordered.slice(0, index).reduce((extent$2, candidate) => extent$2 + (size$1(candidate) > 0 ? size$1(candidate) + labelSpacing : 0), 0) + (size$1(edge) > 0 && input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true ? size$1(edge) / 2 + labelSpacing : 0);
6438
6619
  };
6439
6620
  const hasFixedLoopPorts = (node) => {
@@ -6488,7 +6669,7 @@ function routeEdges(style) {
6488
6669
  for (const [id, loops] of selfLoopsByNodeId) {
6489
6670
  const rect = mutableRects.get(id);
6490
6671
  const node = nodeById.get(id);
6491
- if (!rect || !node) continue;
6672
+ if (!rect || !node || hasPlacementLoopEnvelope(placement, id)) continue;
6492
6673
  const settings = input.nodeSettings?.(node);
6493
6674
  const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
6494
6675
  const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
@@ -7603,7 +7784,7 @@ function routeEdges(style) {
7603
7784
  outsideFeedbackEdgeIds.add(edge.id);
7604
7785
  continue;
7605
7786
  }
7606
- const movable = hasMovableLoopPorts(input, edge) && hasPlacementLoopEnvelope(placement, source.id) && input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true;
7787
+ const movable = hasMovableLoopPorts(input, edge) && hasPlacementLoopEnvelope(placement, source.id);
7607
7788
  const loopRect = movable ? {
7608
7789
  x: 0,
7609
7790
  y: 0,
@@ -7643,7 +7824,32 @@ function routeEdges(style) {
7643
7824
  startRatio = (countOnSide + nestingIndex + 1) / denominator;
7644
7825
  endRatio = (countOnSide - nestingIndex) / denominator;
7645
7826
  }
7646
- const distance = loopDistance(loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side), edge, side, ordering, movable);
7827
+ const sideLoops = loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side);
7828
+ if (movable) {
7829
+ const track$1 = movableLoopTrack(input, sideLoops, edge, side, ordering);
7830
+ const sign = side === "NORTH" ? -1 : 1;
7831
+ const base = side === "NORTH" ? 0 : loopRect.height;
7832
+ const [first, last] = [startRatio, endRatio].map((ratio) => movableLoopPortOffset(input, sideLoops, side, loopRect.width, ratio));
7833
+ return [
7834
+ {
7835
+ x: first.along,
7836
+ y: base + sign * first.normal
7837
+ },
7838
+ {
7839
+ x: first.along,
7840
+ y: base + sign * track$1
7841
+ },
7842
+ {
7843
+ x: last.along,
7844
+ y: base + sign * track$1
7845
+ },
7846
+ {
7847
+ x: last.along,
7848
+ y: base + sign * last.normal
7849
+ }
7850
+ ];
7851
+ }
7852
+ const distance = loopDistance(sideLoops, edge, side, ordering);
7647
7853
  const y = side === "NORTH" ? loopRect.y - distance : loopRect.y + loopRect.height + distance;
7648
7854
  const start$2 = {
7649
7855
  x: loopRect.x + loopRect.width * startRatio,
@@ -7673,7 +7879,32 @@ function routeEdges(style) {
7673
7879
  const secondRatio = sequenced ? 1 / denominator : 2 / denominator;
7674
7880
  const startRatio = side === "EAST" ? firstRatio : secondRatio;
7675
7881
  const endRatio = side === "EAST" ? secondRatio : firstRatio;
7676
- const distance = movable || input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true ? loopDistance(loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side), edge, side, ordering, movable) : spacing * (indexOnSide + 1);
7882
+ const sideLoops = loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side);
7883
+ if (movable) {
7884
+ const track$1 = movableLoopTrack(input, sideLoops, edge, side, ordering);
7885
+ const sign = side === "WEST" ? -1 : 1;
7886
+ const base = side === "WEST" ? 0 : loopRect.width;
7887
+ const [first, last] = [startRatio, endRatio].map((ratio) => movableLoopPortOffset(input, sideLoops, side, loopRect.height, ratio));
7888
+ return [
7889
+ {
7890
+ x: base + sign * first.normal,
7891
+ y: first.along
7892
+ },
7893
+ {
7894
+ x: base + sign * track$1,
7895
+ y: first.along
7896
+ },
7897
+ {
7898
+ x: base + sign * track$1,
7899
+ y: last.along
7900
+ },
7901
+ {
7902
+ x: base + sign * last.normal,
7903
+ y: last.along
7904
+ }
7905
+ ];
7906
+ }
7907
+ const distance = input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true ? loopDistance(sideLoops, edge, side, ordering) : spacing * (indexOnSide + 1);
7677
7908
  const x = side === "EAST" ? loopRect.x + loopRect.width + distance : loopRect.x - distance;
7678
7909
  const start$2 = {
7679
7910
  x: side === "EAST" ? loopRect.x + loopRect.width : loopRect.x,
@@ -18936,6 +19167,8 @@ function* createLayeredScopePipeline(graph, options, context, fallbacks = {
18936
19167
  const separated$1 = runSeparatedComponents(graph, options, context);
18937
19168
  if (separated$1) return separated$1;
18938
19169
  }
19170
+ const hiddenLoopPorts$1 = hideSelfLoopPorts(graph, options);
19171
+ if (hiddenLoopPorts$1) return restoreSelfLoopPorts(graph, yield* createLayeredScopePipeline(hiddenLoopPorts$1.graph, hiddenLoopPorts$1.options, context, fallbacks), hiddenLoopPorts$1.edges);
18939
19172
  const direction = options.direction ?? graph.direction ?? "right";
18940
19173
  const padding = typeof options.padding === "number" ? {
18941
19174
  top: options.padding,
@@ -19609,16 +19842,20 @@ function* createLayeredScopePipeline(graph, options, context, fallbacks = {
19609
19842
  const forwardDummyRect = forwardDummy && placement.rectByNodeId.get(forwardDummy.id);
19610
19843
  const forwardDummyPort = forwardDummy?.ports?.[0];
19611
19844
  const forwardCross = forwardDummyRect && forwardDummyPort ? horizontal$1 ? forwardDummyRect.y + (forwardDummyPort.y ?? 0) : forwardDummyRect.x + (forwardDummyPort.x ?? 0) : horizontal$1 ? Math.round(forwardLabel.y + (forward.height ?? 0) / 2) : Math.round(forwardLabel.x + (forward.width ?? 0) / 2);
19612
- const alignPort = (node, portName) => {
19845
+ const alignedCross = (node, portName) => {
19613
19846
  const rect = placement.rectByNodeId.get(node.id);
19614
19847
  const port = node.ports?.find((candidate) => candidate.name === portName);
19615
- if (!rect || !port) return;
19848
+ if (!rect || !port) return void 0;
19616
19849
  const placedPort = placePorts(node.ports, rect, direction, (candidate) => input.portSettings?.(candidate, node), {
19617
19850
  ...options.settings,
19618
19851
  ...options.nodeSettings?.(node)
19619
19852
  }, (candidate) => getOrientedPortDirection(expanded.input, expanded.orientation, node, candidate))?.find((candidate) => candidate.name === portName);
19620
- if (!placedPort) return;
19621
- const nextCross = forwardCross - (horizontal$1 ? (placedPort.y ?? 0) + (placedPort.height ?? 0) / 2 : (placedPort.x ?? 0) + (placedPort.width ?? 0) / 2);
19853
+ if (!placedPort) return void 0;
19854
+ return forwardCross - (horizontal$1 ? (placedPort.y ?? 0) + (placedPort.height ?? 0) / 2 : (placedPort.x ?? 0) + (placedPort.width ?? 0) / 2);
19855
+ };
19856
+ const alignPort = (node, nextCross) => {
19857
+ const rect = placement.rectByNodeId.get(node.id);
19858
+ if (!rect || nextCross === void 0) return;
19622
19859
  const currentCross = horizontal$1 ? rect.y : rect.x;
19623
19860
  postCompactionNodeCrossDeltas.set(node.id, (postCompactionNodeCrossDeltas.get(node.id) ?? 0) + nextCross - currentCross);
19624
19861
  mutableRects.set(node.id, horizontal$1 ? {
@@ -19630,8 +19867,12 @@ function* createLayeredScopePipeline(graph, options, context, fallbacks = {
19630
19867
  });
19631
19868
  };
19632
19869
  if (isolatedAntiparallelPair) {
19633
- alignPort(nodesById.get(forward.sourceId), forward.sourcePort);
19634
- alignPort(nodesById.get(forward.targetId), forward.targetPort);
19870
+ const ends = [[nodesById.get(forward.sourceId), forward.sourcePort], [nodesById.get(forward.targetId), forward.targetPort]];
19871
+ const crosses$1 = ends.map(([node, port]) => alignedCross(node, port));
19872
+ if (!ends.some(([node], index) => {
19873
+ const rect = placement.rectByNodeId.get(node.id);
19874
+ return rect !== void 0 && crosses$1[index] !== void 0 && Math.abs(crosses$1[index] - (horizontal$1 ? rect.y : rect.x)) > 1e-9 && graph.edges.some((edge) => edge.sourceId === node.id && edge.targetId === node.id);
19875
+ })) ends.forEach(([node], index) => alignPort(node, crosses$1[index]));
19635
19876
  }
19636
19877
  }
19637
19878
  }
@@ -20017,7 +20258,7 @@ function* createLayeredScopePipeline(graph, options, context, fallbacks = {
20017
20258
  y: (points[1].y + points[2].y - height) / 2
20018
20259
  } : void 0;
20019
20260
  const movableExteriorLoopLabelPosition = (() => {
20020
- if (edgeRouting !== "ORTHOGONAL" || labelPlacement !== "CENTER" || inlineLabel || !hasMovableLoopPorts(input, edge) || !sourceRect || points.length !== 4) return void 0;
20261
+ if (edgeRouting !== "ORTHOGONAL" || labelPlacement !== "CENTER" || !hasMovableLoopPorts(input, edge) || !sourceRect || points.length !== 4) return void 0;
20021
20262
  const first = points[1], last = points[2];
20022
20263
  if (first.y === last.y) {
20023
20264
  const low = first.y < sourceRect.y;
@@ -1,4 +1,4 @@
1
- import { K as placePorts, X as JavaRandom, i as layeredAlgorithm, n as elkjs0111LayeredAlgorithm, x as setElkjs0111ResultPolicy, z as getPolylineMidpoint } from "./layered-CmL7oynw.mjs";
1
+ import { K as placePorts, X as JavaRandom, i as layeredAlgorithm, n as elkjs0111LayeredAlgorithm, x as setElkjs0111ResultPolicy, z as getPolylineMidpoint } from "./layered-BtnmL9_b.mjs";
2
2
  import { getNodeSize } from "@statelyai/graph/layout";
3
3
 
4
4
  //#region src/fixed.ts
@@ -1,4 +1,4 @@
1
- const require_layered = require('./layered-SB2mlAqt.cjs');
1
+ const require_layered = require('./layered-B1AE7Zpr.cjs');
2
2
  let _statelyai_graph_layout = require("@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.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Extensible, graph-native layout algorithms for @statelyai/graph",
5
5
  "license": "EPL-2.0 OR GPL-3.0-or-later",
6
6
  "repository": {
@@ -8,6 +8,7 @@ import { countAllCrossings } from "./crossing-counter";
8
8
  import { crossingGraph } from "./crossing-graph";
9
9
  import { sortInitialModelOrder, markInitialModelOrderPrepared } from "./initial-model-order";
10
10
  import { hasMovableLoopPorts } from "./loop-envelopes";
11
+ import { hideSelfLoopPorts, restoreSelfLoopPorts } from "./self-loop-ports";
11
12
  import { fixedSelfLoopLabels, fixedSelfLoopSide } from "./fixed-self-loop";
12
13
  import { compactionBounds, placementCrossBounds } from "./compaction-bounds";
13
14
  import { setElkjs0111ResultPolicy } from "../internal/elkjs-compatibility";
@@ -1424,6 +1425,18 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1424
1425
  const separated = runSeparatedComponents(graph, options, context);
1425
1426
  if (separated) return separated;
1426
1427
  }
1428
+ const hiddenLoopPorts = hideSelfLoopPorts(graph as Graph<N, E, G, P>, options);
1429
+ if (hiddenLoopPorts)
1430
+ return restoreSelfLoopPorts(
1431
+ graph as Graph<N, E, G, P>,
1432
+ yield* createLayeredScopePipeline(
1433
+ hiddenLoopPorts.graph,
1434
+ hiddenLoopPorts.options,
1435
+ context,
1436
+ fallbacks,
1437
+ ),
1438
+ hiddenLoopPorts.edges,
1439
+ );
1427
1440
  const direction = options.direction ?? graph.direction ?? "right";
1428
1441
  const padding =
1429
1442
  typeof options.padding === "number"
@@ -2507,10 +2520,10 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2507
2520
  : horizontal
2508
2521
  ? Math.round(forwardLabel.y + (forward.height ?? 0) / 2)
2509
2522
  : Math.round(forwardLabel.x + (forward.width ?? 0) / 2);
2510
- const alignPort = (node: GraphNode, portName: string): void => {
2523
+ const alignedCross = (node: GraphNode, portName: string): number | undefined => {
2511
2524
  const rect = placement.rectByNodeId.get(node.id);
2512
2525
  const port = node.ports?.find((candidate) => candidate.name === portName);
2513
- if (!rect || !port) return;
2526
+ if (!rect || !port) return undefined;
2514
2527
  const placedPort = placePorts(
2515
2528
  node.ports,
2516
2529
  rect,
@@ -2520,11 +2533,15 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2520
2533
  (candidate) =>
2521
2534
  getOrientedPortDirection(expanded.input, expanded.orientation, node, candidate),
2522
2535
  )?.find((candidate) => candidate.name === portName);
2523
- if (!placedPort) return;
2536
+ if (!placedPort) return undefined;
2524
2537
  const localCross = horizontal
2525
2538
  ? (placedPort.y ?? 0) + (placedPort.height ?? 0) / 2
2526
2539
  : (placedPort.x ?? 0) + (placedPort.width ?? 0) / 2;
2527
- const nextCross = forwardCross - localCross;
2540
+ return forwardCross - localCross;
2541
+ };
2542
+ const alignPort = (node: GraphNode, nextCross: number | undefined): void => {
2543
+ const rect = placement.rectByNodeId.get(node.id);
2544
+ if (!rect || nextCross === undefined) return;
2528
2545
  const currentCross = horizontal ? rect.y : rect.x;
2529
2546
  postCompactionNodeCrossDeltas.set(
2530
2547
  node.id,
@@ -2536,8 +2553,23 @@ export function* createLayeredScopePipeline<N, E, G, P>(
2536
2553
  );
2537
2554
  };
2538
2555
  if (isolatedAntiparallelPair) {
2539
- alignPort(nodesById.get(forward.sourceId)!, forward.sourcePort);
2540
- alignPort(nodesById.get(forward.targetId)!, forward.targetPort);
2556
+ const ends = [
2557
+ [nodesById.get(forward.sourceId)!, forward.sourcePort],
2558
+ [nodesById.get(forward.targetId)!, forward.targetPort],
2559
+ ] as const;
2560
+ const crosses = ends.map(([node, port]) => alignedCross(node, port));
2561
+ // ELK keeps a loop owner where BK placed it, and moving it here would
2562
+ // leave its loop and label behind, so such a pair stays as placed.
2563
+ const movesLoopOwner = ends.some(([node], index) => {
2564
+ const rect = placement.rectByNodeId.get(node.id);
2565
+ return (
2566
+ rect !== undefined &&
2567
+ crosses[index] !== undefined &&
2568
+ Math.abs(crosses[index]! - (horizontal ? rect.y : rect.x)) > 1e-9 &&
2569
+ graph.edges.some((edge) => edge.sourceId === node.id && edge.targetId === node.id)
2570
+ );
2571
+ });
2572
+ if (!movesLoopOwner) ends.forEach(([node], index) => alignPort(node, crosses[index]));
2541
2573
  }
2542
2574
  }
2543
2575
  }
@@ -3142,7 +3174,6 @@ export function* createLayeredScopePipeline<N, E, G, P>(
3142
3174
  if (
3143
3175
  edgeRouting !== "ORTHOGONAL" ||
3144
3176
  labelPlacement !== "CENTER" ||
3145
- inlineLabel ||
3146
3177
  !hasMovableLoopPorts(input, edge) ||
3147
3178
  !sourceRect ||
3148
3179
  points.length !== 4