@euphrasiologist/lwphylo 1.5.0 → 1.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.
@@ -816,6 +816,20 @@ function unrooted (node) {
816
816
  return data;
817
817
  }
818
818
 
819
+ /**
820
+ * Reverse the child order at one node, flipping which side of the tree
821
+ * that clade's descendants are drawn on. Defaults to the root. Mutates
822
+ * the tree in place.
823
+ */
824
+ function rotate(tree, nodeId = tree.id) {
825
+ const target = preorder(tree).find((n) => n.id === nodeId);
826
+ if (!target) {
827
+ throw new Error(`No node with id ${nodeId} found in tree`);
828
+ }
829
+ target.children.reverse();
830
+ return tree;
831
+ }
832
+
819
833
  /**
820
834
  * Parse a Newick tree string into a doubly-linked list of JS Objects.
821
835
  * Assigns labels, branch lengths, and node IDs (tips before internals if input emits them that way).
@@ -929,9 +943,8 @@ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize
929
943
  // by readTree()/randomTree()). Passing the same parsed tree object back in
930
944
  // across re-renders (e.g. after mutating it with rotate()) keeps node ids
931
945
  // stable, which onNodeClick below relies on.
932
- function drawPhylogeny(
933
- input,
934
- {
946
+ function drawPhylogeny(input, options = {}) {
947
+ const {
935
948
  layout = "rect", // rect/radial/unrooted
936
949
  width = 800,
937
950
  height = 800,
@@ -942,12 +955,13 @@ function drawPhylogeny(
942
955
  tipLabels = true,
943
956
  labelFontSize = 10, // font size (px) for tip labels
944
957
  tipRadius, // px radius of tip dots; defaults to each layout's original size
945
- internalNodeCircles = false, // draw a circle at every internal (non-tip) node
946
958
  internalNodeRadius = 3, // px radius for internal node circles
947
959
  nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
948
960
  nodeLabelFontSize, // defaults to labelFontSize
949
961
  scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
950
962
  alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
963
+ container, // DOM element or CSS selector — if given, drawPhylogeny mounts the SVG itself instead of just returning it
964
+ rotateOnClick = false, // click an internal node circle to rotate its children in place and auto-redraw (requires container)
951
965
  onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
952
966
  showTooltips = true,
953
967
  tooltipFormatter = (d, rtt) =>
@@ -957,8 +971,10 @@ function drawPhylogeny(
957
971
  highlightTips = [], // array of tip labels or ids for static highlight (optional)
958
972
  highlightStroke = "#e63946",
959
973
  highlightWidth = 2.5
960
- } = {}
961
- ) {
974
+ } = options;
975
+ // rotateOnClick needs internal node circles to click on; auto-enable them
976
+ // unless the caller explicitly said otherwise.
977
+ const internalNodeCircles = options.internalNodeCircles ?? rotateOnClick;
962
978
 
963
979
  // shared helpers
964
980
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
@@ -966,6 +982,35 @@ function drawPhylogeny(
966
982
  const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
967
983
  ? input
968
984
  : readTree(input);
985
+
986
+ const mountEl = typeof container === "string"
987
+ ? (typeof document !== "undefined" ? document.querySelector(container) : null)
988
+ : (container ?? null);
989
+
990
+ if (rotateOnClick && !mountEl) {
991
+ throw new Error(
992
+ "rotateOnClick requires a `container` (a DOM element or CSS selector) so drawPhylogeny can mount the redrawn SVG after each rotation."
993
+ );
994
+ }
995
+
996
+ // fires the caller's onNodeClick (if any), then handles the rotate+redraw itself
997
+ const effectiveOnNodeClick = (onNodeClick || rotateOnClick)
998
+ ? (node, event) => {
999
+ if (typeof onNodeClick === "function") onNodeClick(node, event);
1000
+ if (rotateOnClick) {
1001
+ rotate(parsedTree, node.thisId);
1002
+ drawPhylogeny(parsedTree, options);
1003
+ }
1004
+ }
1005
+ : undefined;
1006
+
1007
+ function mount(svgNode) {
1008
+ if (mountEl) {
1009
+ mountEl.innerHTML = "";
1010
+ mountEl.appendChild(svgNode);
1011
+ }
1012
+ return svgNode;
1013
+ }
969
1014
  // Works for both radial (uses `r`) and rect (uses `x1`).
970
1015
  // Falls back to summing branchLength up to the root if neither is present.
971
1016
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1106,10 +1151,10 @@ function drawPhylogeny(
1106
1151
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1107
1152
  }
1108
1153
 
1109
- if (onNodeClick) {
1154
+ if (effectiveOnNodeClick) {
1110
1155
  internalDots
1111
1156
  .style("cursor", "pointer")
1112
- .on("click", (event, d) => onNodeClick(d, event));
1157
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1113
1158
  }
1114
1159
  }
1115
1160
 
@@ -1240,7 +1285,7 @@ function drawPhylogeny(
1240
1285
  });
1241
1286
  }
1242
1287
 
1243
- return svg.node();
1288
+ return mount(svg.node());
1244
1289
  } else if (layout === "radial") {
1245
1290
  // RADIAL LAYOUT
1246
1291
  if (width !== height) {
@@ -1466,10 +1511,10 @@ function drawPhylogeny(
1466
1511
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1467
1512
  }
1468
1513
 
1469
- if (onNodeClick) {
1514
+ if (effectiveOnNodeClick) {
1470
1515
  internalDots
1471
1516
  .style("cursor", "pointer")
1472
- .on("click", (event, d) => onNodeClick(d, event));
1517
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1473
1518
  }
1474
1519
  }
1475
1520
 
@@ -1668,7 +1713,7 @@ function drawPhylogeny(
1668
1713
  });
1669
1714
  }
1670
1715
 
1671
- return svg.node();
1716
+ return mount(svg.node());
1672
1717
  } else if (layout === "unrooted") {
1673
1718
  // UNROOTED LAYOUT
1674
1719
  const unrootedPhylo = unrooted(parsedTree);
@@ -1732,11 +1777,11 @@ function drawPhylogeny(
1732
1777
  .attr("stroke-width", 2)
1733
1778
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1734
1779
 
1735
- if (internalNodeCircles && onNodeClick) {
1780
+ if (internalNodeCircles && effectiveOnNodeClick) {
1736
1781
  nodes
1737
1782
  .filter((d) => !d.isTip)
1738
1783
  .style("cursor", "pointer")
1739
- .on("click", (event, d) => onNodeClick(d, event));
1784
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1740
1785
  }
1741
1786
 
1742
1787
  if (nodeLabels) {
@@ -1901,7 +1946,7 @@ function drawPhylogeny(
1901
1946
  });
1902
1947
  }
1903
1948
 
1904
- return svg.node();
1949
+ return mount(svg.node());
1905
1950
  } else {
1906
1951
  throw new Error(
1907
1952
  "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."