@euphrasiologist/lwphylo 1.4.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).
@@ -925,9 +939,12 @@ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize
925
939
  .text(opts.label ?? String(length));
926
940
  }
927
941
 
928
- function drawPhylogeny(
929
- treeText,
930
- {
942
+ // input: a Newick string, or an already-parsed tree (the node shape returned
943
+ // by readTree()/randomTree()). Passing the same parsed tree object back in
944
+ // across re-renders (e.g. after mutating it with rotate()) keeps node ids
945
+ // stable, which onNodeClick below relies on.
946
+ function drawPhylogeny(input, options = {}) {
947
+ const {
931
948
  layout = "rect", // rect/radial/unrooted
932
949
  width = 800,
933
950
  height = 800,
@@ -938,12 +955,14 @@ function drawPhylogeny(
938
955
  tipLabels = true,
939
956
  labelFontSize = 10, // font size (px) for tip labels
940
957
  tipRadius, // px radius of tip dots; defaults to each layout's original size
941
- internalNodeCircles = false, // draw a circle at every internal (non-tip) node
942
958
  internalNodeRadius = 3, // px radius for internal node circles
943
959
  nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
944
960
  nodeLabelFontSize, // defaults to labelFontSize
945
961
  scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
946
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)
965
+ onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
947
966
  showTooltips = true,
948
967
  tooltipFormatter = (d, rtt) =>
949
968
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -952,12 +971,46 @@ function drawPhylogeny(
952
971
  highlightTips = [], // array of tip labels or ids for static highlight (optional)
953
972
  highlightStroke = "#e63946",
954
973
  highlightWidth = 2.5
955
- } = {}
956
- ) {
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;
957
978
 
958
979
  // shared helpers
959
980
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
960
981
  const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
982
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
983
+ ? input
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
+ }
961
1014
  // Works for both radial (uses `r`) and rect (uses `x1`).
962
1015
  // Falls back to summing branchLength up to the root if neither is present.
963
1016
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -986,7 +1039,7 @@ function drawPhylogeny(
986
1039
 
987
1040
  if (layout === "rect") {
988
1041
  // RECTANGULAR LAYOUT
989
- const tree_df = rectangleLayout(readTree(treeText));
1042
+ const tree_df = rectangleLayout(parsedTree);
990
1043
  const horizontal = tree_df.horizontal_lines;
991
1044
  const vertical = tree_df.vertical_lines;
992
1045
  const tips = horizontal.filter((d) => d.isTip);
@@ -1097,6 +1150,12 @@ function drawPhylogeny(
1097
1150
  .append("title")
1098
1151
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1099
1152
  }
1153
+
1154
+ if (effectiveOnNodeClick) {
1155
+ internalDots
1156
+ .style("cursor", "pointer")
1157
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1158
+ }
1100
1159
  }
1101
1160
 
1102
1161
  // internal node labels (optional)
@@ -1226,13 +1285,12 @@ function drawPhylogeny(
1226
1285
  });
1227
1286
  }
1228
1287
 
1229
- return svg.node();
1288
+ return mount(svg.node());
1230
1289
  } else if (layout === "radial") {
1231
1290
  // RADIAL LAYOUT
1232
1291
  if (width !== height) {
1233
1292
  throw new Error("width and height must be the same for radial layout");
1234
1293
  }
1235
- const parsedTree = readTree(treeText);
1236
1294
  const rad = radialLayout(parsedTree, {
1237
1295
  angleStrategy: "fan",
1238
1296
  arcsStyle: "fan"
@@ -1452,6 +1510,12 @@ function drawPhylogeny(
1452
1510
  .append("title")
1453
1511
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1454
1512
  }
1513
+
1514
+ if (effectiveOnNodeClick) {
1515
+ internalDots
1516
+ .style("cursor", "pointer")
1517
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1518
+ }
1455
1519
  }
1456
1520
 
1457
1521
  // ===== INTERNAL NODE LABELS (optional) =====
@@ -1649,10 +1713,9 @@ function drawPhylogeny(
1649
1713
  });
1650
1714
  }
1651
1715
 
1652
- return svg.node();
1716
+ return mount(svg.node());
1653
1717
  } else if (layout === "unrooted") {
1654
1718
  // UNROOTED LAYOUT
1655
- const parsedTree = readTree(treeText);
1656
1719
  const unrootedPhylo = unrooted(parsedTree);
1657
1720
 
1658
1721
  const w = width;
@@ -1714,6 +1777,13 @@ function drawPhylogeny(
1714
1777
  .attr("stroke-width", 2)
1715
1778
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1716
1779
 
1780
+ if (internalNodeCircles && effectiveOnNodeClick) {
1781
+ nodes
1782
+ .filter((d) => !d.isTip)
1783
+ .style("cursor", "pointer")
1784
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1785
+ }
1786
+
1717
1787
  if (nodeLabels) {
1718
1788
  const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
1719
1789
  group
@@ -1876,7 +1946,7 @@ function drawPhylogeny(
1876
1946
  });
1877
1947
  }
1878
1948
 
1879
- return svg.node();
1949
+ return mount(svg.node());
1880
1950
  } else {
1881
1951
  throw new Error(
1882
1952
  "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."