@statelyai/layout 0.5.0 → 0.5.1

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;
@@ -1647,7 +1813,7 @@ function loopEnvelopes(input) {
1647
1813
  };
1648
1814
  const horizontal$1 = 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;
@@ -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,16 @@ 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
+ };
1711
1882
  for (const side of sides$1) {
1712
1883
  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);
1884
+ const canonical = canonicalSides[side];
1885
+ envelope[side] = Math.max(0, ...selected.map((edge) => movableLoopTrack(input, selected, edge, canonical, ordering) + movableLoopLabelExtent(input, edge, canonical)));
1717
1886
  }
1718
1887
  envelopes.set(node.id, envelope);
1719
1888
  }
@@ -2881,7 +3050,7 @@ function compactJoinedGeometryUnchecked(input, placement, routes, orientation) {
2881
3050
  if (point.x >= rect.x + rect.width - 1e-9) return "EAST";
2882
3051
  return point.y <= rect.y ? "NORTH" : "SOUTH";
2883
3052
  };
2884
- const crossSide = (side) => side === "NORTH" || side === "SOUTH";
3053
+ const crossSide$1 = (side) => side === "NORTH" || side === "SOUTH";
2885
3054
  const segments = [];
2886
3055
  for (const [edgeId, points] of canonicalRoutes) {
2887
3056
  const edge = edgeById.get(edgeId);
@@ -2891,8 +3060,8 @@ function compactJoinedGeometryUnchecked(input, placement, routes, orientation) {
2891
3060
  const first = points[index];
2892
3061
  const second = points[index + 1];
2893
3062
  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;
3063
+ if (index === 0 && !crossSide$1(endpointSide(edge.sourceId, edge.sourcePort, first))) continue;
3064
+ if (index === points.length - 2 && !crossSide$1(endpointSide(edge.targetId, edge.targetPort, second))) continue;
2896
3065
  const owner = index === 0 ? nodes.get(edge.sourceId) : index === points.length - 2 ? nodes.get(edge.targetId) : void 0;
2897
3066
  const ownerSide = owner ? endpointSide(index === 0 ? edge.sourceId : edge.targetId, index === 0 ? edge.sourcePort : edge.targetPort, index === 0 ? first : second) : void 0;
2898
3067
  const bend = index === 0 ? second : first;
@@ -3055,7 +3224,7 @@ function compactJoinedGeometryUnchecked(input, placement, routes, orientation) {
3055
3224
  const points = canonicalRoutes.get(edge.id);
3056
3225
  const sourceSide = endpointSide(edge.sourceId, edge.sourcePort, points?.[0]);
3057
3226
  const targetSide = endpointSide(edge.targetId, edge.targetPort, points?.at(-1));
3058
- if (crossSide(sourceSide) && crossSide(targetSide)) continue;
3227
+ if (crossSide$1(sourceSide) && crossSide$1(targetSide)) continue;
3059
3228
  const reversed = orientation?.reversedEdgeIds.has(edge.id) ?? source.x > target.x;
3060
3229
  const from = reversed ? target : source, to = reversed ? source : target;
3061
3230
  constraints.push({
@@ -3149,8 +3318,8 @@ function compactJoinedGeometryUnchecked(input, placement, routes, orientation) {
3149
3318
  const start = pointToCanonical(originals[0]);
3150
3319
  const next = pointToCanonical(originals[1]);
3151
3320
  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;
3321
+ if (!crossSide$1(endpointSide(edge.sourceId, edge.sourcePort, start)) && Math.abs(start.x - next.x) < 1e-9) points[1].x = points[0].x;
3322
+ 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
3323
  }
3155
3324
  }
3156
3325
  const extent$2 = Math.max(...items.map((item) => item.x + item.width), ...[...canonicalRoutes.values()].flatMap((points) => points.map((point) => point.x)));
@@ -6427,13 +6596,13 @@ function routeEdges(style) {
6427
6596
  "WEST"
6428
6597
  ][index % 4] : distribution === "NORTH_SOUTH" && index % 2 === 1 ? "SOUTH" : "NORTH";
6429
6598
  };
6430
- const loopDistance = (loops, edge, side, ordering, canonical = false) => {
6431
- const axis = (side === "NORTH" || side === "SOUTH") === (!canonical || horizontal$1) ? "height" : "width";
6599
+ const loopDistance = (loops, edge, side, ordering) => {
6600
+ const axis = side === "NORTH" || side === "SOUTH" ? "height" : "width";
6432
6601
  const ordered = ordering === "REVERSE_STACKED" ? [...loops].reverse() : loops;
6433
6602
  const index = ordered.indexOf(edge);
6434
6603
  const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
6435
6604
  const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
6436
- const size$1 = (candidate) => canonical || input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true ? candidate[axis] ?? 0 : 0;
6605
+ const size$1 = (candidate) => input.edgeSettings?.(candidate)?.["edgeLabels.inline"] === true ? candidate[axis] ?? 0 : 0;
6437
6606
  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
6607
  };
6439
6608
  const hasFixedLoopPorts = (node) => {
@@ -6488,7 +6657,7 @@ function routeEdges(style) {
6488
6657
  for (const [id, loops] of selfLoopsByNodeId) {
6489
6658
  const rect = mutableRects.get(id);
6490
6659
  const node = nodeById.get(id);
6491
- if (!rect || !node) continue;
6660
+ if (!rect || !node || hasPlacementLoopEnvelope(placement, id)) continue;
6492
6661
  const settings = input.nodeSettings?.(node);
6493
6662
  const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
6494
6663
  const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
@@ -7603,7 +7772,7 @@ function routeEdges(style) {
7603
7772
  outsideFeedbackEdgeIds.add(edge.id);
7604
7773
  continue;
7605
7774
  }
7606
- const movable = hasMovableLoopPorts(input, edge) && hasPlacementLoopEnvelope(placement, source.id) && input.edgeSettings?.(edge)?.["edgeLabels.inline"] !== true;
7775
+ const movable = hasMovableLoopPorts(input, edge) && hasPlacementLoopEnvelope(placement, source.id);
7607
7776
  const loopRect = movable ? {
7608
7777
  x: 0,
7609
7778
  y: 0,
@@ -7643,7 +7812,32 @@ function routeEdges(style) {
7643
7812
  startRatio = (countOnSide + nestingIndex + 1) / denominator;
7644
7813
  endRatio = (countOnSide - nestingIndex) / denominator;
7645
7814
  }
7646
- const distance = loopDistance(loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side), edge, side, ordering, movable);
7815
+ const sideLoops = loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side);
7816
+ if (movable) {
7817
+ const track$1 = movableLoopTrack(input, sideLoops, edge, side, ordering);
7818
+ const sign = side === "NORTH" ? -1 : 1;
7819
+ const base = side === "NORTH" ? 0 : loopRect.height;
7820
+ const [first, last] = [startRatio, endRatio].map((ratio) => movableLoopPortOffset(input, sideLoops, side, loopRect.width, ratio));
7821
+ return [
7822
+ {
7823
+ x: first.along,
7824
+ y: base + sign * first.normal
7825
+ },
7826
+ {
7827
+ x: first.along,
7828
+ y: base + sign * track$1
7829
+ },
7830
+ {
7831
+ x: last.along,
7832
+ y: base + sign * track$1
7833
+ },
7834
+ {
7835
+ x: last.along,
7836
+ y: base + sign * last.normal
7837
+ }
7838
+ ];
7839
+ }
7840
+ const distance = loopDistance(sideLoops, edge, side, ordering);
7647
7841
  const y = side === "NORTH" ? loopRect.y - distance : loopRect.y + loopRect.height + distance;
7648
7842
  const start$2 = {
7649
7843
  x: loopRect.x + loopRect.width * startRatio,
@@ -7673,7 +7867,32 @@ function routeEdges(style) {
7673
7867
  const secondRatio = sequenced ? 1 / denominator : 2 / denominator;
7674
7868
  const startRatio = side === "EAST" ? firstRatio : secondRatio;
7675
7869
  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);
7870
+ const sideLoops = loops.filter((candidate, i) => loopSide(distribution, i, candidate) === side);
7871
+ if (movable) {
7872
+ const track$1 = movableLoopTrack(input, sideLoops, edge, side, ordering);
7873
+ const sign = side === "WEST" ? -1 : 1;
7874
+ const base = side === "WEST" ? 0 : loopRect.width;
7875
+ const [first, last] = [startRatio, endRatio].map((ratio) => movableLoopPortOffset(input, sideLoops, side, loopRect.height, ratio));
7876
+ return [
7877
+ {
7878
+ x: base + sign * first.normal,
7879
+ y: first.along
7880
+ },
7881
+ {
7882
+ x: base + sign * track$1,
7883
+ y: first.along
7884
+ },
7885
+ {
7886
+ x: base + sign * track$1,
7887
+ y: last.along
7888
+ },
7889
+ {
7890
+ x: base + sign * last.normal,
7891
+ y: last.along
7892
+ }
7893
+ ];
7894
+ }
7895
+ const distance = input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true ? loopDistance(sideLoops, edge, side, ordering) : spacing * (indexOnSide + 1);
7677
7896
  const x = side === "EAST" ? loopRect.x + loopRect.width + distance : loopRect.x - distance;
7678
7897
  const start$2 = {
7679
7898
  x: side === "EAST" ? loopRect.x + loopRect.width : loopRect.x,
@@ -18936,6 +19155,8 @@ function* createLayeredScopePipeline(graph, options, context, fallbacks = {
18936
19155
  const separated$1 = runSeparatedComponents(graph, options, context);
18937
19156
  if (separated$1) return separated$1;
18938
19157
  }
19158
+ const hiddenLoopPorts$1 = hideSelfLoopPorts(graph, options);
19159
+ if (hiddenLoopPorts$1) return restoreSelfLoopPorts(graph, yield* createLayeredScopePipeline(hiddenLoopPorts$1.graph, hiddenLoopPorts$1.options, context, fallbacks), hiddenLoopPorts$1.edges);
18939
19160
  const direction = options.direction ?? graph.direction ?? "right";
18940
19161
  const padding = typeof options.padding === "number" ? {
18941
19162
  top: options.padding,
@@ -20017,7 +20238,7 @@ function* createLayeredScopePipeline(graph, options, context, fallbacks = {
20017
20238
  y: (points[1].y + points[2].y - height) / 2
20018
20239
  } : void 0;
20019
20240
  const movableExteriorLoopLabelPosition = (() => {
20020
- if (edgeRouting !== "ORTHOGONAL" || labelPlacement !== "CENTER" || inlineLabel || !hasMovableLoopPorts(input, edge) || !sourceRect || points.length !== 4) return void 0;
20241
+ if (edgeRouting !== "ORTHOGONAL" || labelPlacement !== "CENTER" || !hasMovableLoopPorts(input, edge) || !sourceRect || points.length !== 4) return void 0;
20021
20242
  const first = points[1], last = points[2];
20022
20243
  if (first.y === last.y) {
20023
20244
  const low = first.y < sourceRect.y;
@@ -1,4 +1,4 @@
1
- const require_layered = require('./layered-SB2mlAqt.cjs');
1
+ const require_layered = require('./layered-QzYtokgN.cjs');
2
2
  let _statelyai_graph_layout = require("@statelyai/graph/layout");
3
3
 
4
4
  //#region src/fixed.ts
@@ -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-CUwft7on.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.5.0",
3
+ "version": "0.5.1",
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"
@@ -3142,7 +3155,6 @@ export function* createLayeredScopePipeline<N, E, G, P>(
3142
3155
  if (
3143
3156
  edgeRouting !== "ORTHOGONAL" ||
3144
3157
  labelPlacement !== "CENTER" ||
3145
- inlineLabel ||
3146
3158
  !hasMovableLoopPorts(input, edge) ||
3147
3159
  !sourceRect ||
3148
3160
  points.length !== 4
@@ -8,6 +8,86 @@ import {
8
8
  selfLoopTracks,
9
9
  } from "./fixed-self-loop";
10
10
  import { findNode } from "./node-lookup";
11
+ import { hiddenLoopPorts } from "./self-loop-ports";
12
+
13
+ type CanonicalSide = "NORTH" | "SOUTH" | "EAST" | "WEST";
14
+ const crossSide = (side: CanonicalSide) => side === "NORTH" || side === "SOUTH";
15
+
16
+ /**
17
+ * Extents of a movable loop's hidden ports in the canonical rightward frame:
18
+ * along its side, and protruding from it.
19
+ */
20
+ function loopPortExtents(input: LayeredPhaseInput, edge: GraphEdge, side: CanonicalSide) {
21
+ const ports = hiddenLoopPorts(input.settings, edge.id);
22
+ const horizontal = input.direction === "right" || input.direction === "left";
23
+ const along = (crossSide(side) === horizontal ? "width" : "height") as "width" | "height";
24
+ const normal = along === "width" ? "height" : "width";
25
+ return {
26
+ along: ports ? ports.source[along] + ports.target[along] : 0,
27
+ normal: ports ? Math.max(ports.source[normal], ports.target[normal]) : 0,
28
+ };
29
+ }
30
+
31
+ /** A movable loop's label extent away from its side, in the canonical frame. */
32
+ export function movableLoopLabelExtent(
33
+ input: LayeredPhaseInput,
34
+ edge: GraphEdge,
35
+ side: CanonicalSide,
36
+ ): number {
37
+ const horizontal = input.direction === "right" || input.direction === "left";
38
+ const size = (crossSide(side) === horizontal ? edge.height : edge.width) ?? 0;
39
+ return size > 0 ? size + Number(input.settings["spacing.edgeLabel"] ?? 2) : 0;
40
+ }
41
+
42
+ /**
43
+ * ELK's OrthogonalSelfLoopRouter routing slot of a movable loop, measured
44
+ * from its node side in the canonical frame: past the protruding ports and
45
+ * the self-loop spacing, then one edge spacing plus any label per inner slot.
46
+ */
47
+ export function movableLoopTrack(
48
+ input: LayeredPhaseInput,
49
+ sideLoops: readonly GraphEdge[],
50
+ edge: GraphEdge,
51
+ side: CanonicalSide,
52
+ ordering: string,
53
+ ): number {
54
+ const ordered = ordering === "REVERSE_STACKED" ? [...sideLoops].reverse() : sideLoops;
55
+ const inner = ordering === "SEQUENCED" ? [] : ordered.slice(0, ordered.indexOf(edge));
56
+ const edgeSpacing = Number(input.settings["spacing.edgeEdge"] ?? 10);
57
+ return (
58
+ Math.max(0, ...sideLoops.map((loop) => loopPortExtents(input, loop, side).normal)) +
59
+ Number(input.settings["spacing.nodeSelfLoop"] ?? 10) +
60
+ inner.reduce(
61
+ (sum, loop) =>
62
+ sum + edgeSpacing + (crossSide(side) ? movableLoopLabelExtent(input, loop, side) : 0),
63
+ 0,
64
+ )
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Canonical position of a movable loop port along its side, given its
70
+ * DISTRIBUTED ratio among the side's implicit ports (ELK's port placement
71
+ * with equal gaps between hidden ports of their own size).
72
+ */
73
+ export function movableLoopPortOffset(
74
+ input: LayeredPhaseInput,
75
+ sideLoops: readonly GraphEdge[],
76
+ side: CanonicalSide,
77
+ length: number,
78
+ ratio: number,
79
+ ): { along: number; normal: number } {
80
+ const count = sideLoops.length * 2;
81
+ const extents = sideLoops.map((loop) => loopPortExtents(input, loop, side));
82
+ const total = extents.reduce((sum, extent) => sum + extent.along, 0);
83
+ const size = total / count;
84
+ const index = Math.round(ratio * (count + 1)) - 1;
85
+ const gap = (length - total) / (count + 1);
86
+ return {
87
+ along: gap * (index + 1) + size * index + size / 2,
88
+ normal: Math.max(0, ...extents.map((extent) => extent.normal)),
89
+ };
90
+ }
11
91
 
12
92
  export interface LoopEnvelope {
13
93
  before: number;
@@ -125,18 +205,10 @@ export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, Loo
125
205
  }
126
206
  }
127
207
  const loops = allLoops.filter((edge) => hasMovableLoopPorts(input, edge));
128
- // Inline labels remain owned by the existing labeled-loop phase. Move
129
- // the entire node together when that phase is ported, rather than mixing
130
- // two different loop distributions and reservations on the same node.
131
- if (
132
- loops.length === 0 ||
133
- loops.some((edge) => input.edgeSettings?.(edge)?.["edgeLabels.inline"] === true)
134
- )
135
- continue;
208
+ if (loops.length === 0) continue;
136
209
  const settings = input.nodeSettings?.(node);
137
210
  const distribution = settings?.["edgeRouting.selfLoopDistribution"] ?? "NORTH";
138
211
  const ordering = settings?.["edgeRouting.selfLoopOrdering"] ?? "STACKED";
139
- const spacing = Number(input.settings["spacing.nodeSelfLoop"] ?? 10);
140
212
  const envelope = { before: 0, after: 0, flowBefore: 0, flowAfter: 0 };
141
213
  const sides =
142
214
  distribution === "EQUALLY"
@@ -144,25 +216,23 @@ export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, Loo
144
216
  : distribution === "NORTH_SOUTH"
145
217
  ? (["before", "after"] as const)
146
218
  : (["before"] as const);
219
+ const canonicalSides = {
220
+ before: "NORTH",
221
+ after: "SOUTH",
222
+ flowAfter: "EAST",
223
+ flowBefore: "WEST",
224
+ } as const;
147
225
  for (const side of sides) {
148
226
  const selected = loops.filter((_, index) => sides[index % sides.length] === side);
149
- const horizontal = input.direction === "right" || input.direction === "left";
150
- const crossSize =
151
- side === "before" || side === "after"
152
- ? horizontal
153
- ? "height"
154
- : "width"
155
- : horizontal
156
- ? "width"
157
- : "height";
158
- const labelSpacing = Number(input.settings["spacing.edgeLabel"] ?? 2);
159
- envelope[side] =
160
- spacing * (ordering === "SEQUENCED" ? Math.min(1, selected.length) : selected.length) +
161
- selected.reduce(
162
- (sum, edge) =>
163
- sum + ((edge[crossSize] ?? 0) > 0 ? (edge[crossSize] ?? 0) + labelSpacing : 0),
164
- 0,
165
- );
227
+ const canonical = canonicalSides[side];
228
+ envelope[side] = Math.max(
229
+ 0,
230
+ ...selected.map(
231
+ (edge) =>
232
+ movableLoopTrack(input, selected, edge, canonical, ordering) +
233
+ movableLoopLabelExtent(input, edge, canonical),
234
+ ),
235
+ );
166
236
  }
167
237
  envelopes.set(node.id, envelope);
168
238
  }