@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.
package/README.md CHANGED
@@ -20,7 +20,12 @@ Need a tree to experiment with? `randomTree(nTips, { maxBranchLength, labelPrefi
20
20
  - `nodeLabels` (bool) + `nodeLabelFontSize` — draw text labels (e.g. clade/support values) at internal nodes that have one.
21
21
  - `scaleBar` — `true` for an auto-sized branch-length scale bar, a number for an explicit length in branch-length units, or `{ length, x, y, label }` for full control.
22
22
  - `alignTipLabels` (bool, rect & radial layouts) — align tip labels to a common column/ring, with dashed guide lines back to each tip's true position.
23
- - `onNodeClick(node, event)` — fires when an internal node circle is clicked (requires `internalNodeCircles: true`). Combine with `rotate()` for click-to-rotate: `onNodeClick: (node) => { rotate(tree, node.thisId); redraw(); }`.
23
+ - `onNodeClick(node, event)` — fires when an internal node circle is clicked (requires `internalNodeCircles: true`).
24
+ - `container` (DOM element or CSS selector) + `rotateOnClick` (bool) — the batteries-included version of click-to-rotate: with both set, `drawPhylogeny` mounts the SVG into `container` itself and, on every internal-node click, rotates that clade and redraws back into the same container — no manual render loop needed:
25
+ ```js
26
+ drawPhylogeny(newickString, { layout: "rect", container: "#tree", rotateOnClick: true });
27
+ ```
28
+ `rotateOnClick` auto-enables `internalNodeCircles` unless you set it explicitly. A supplied `onNodeClick` still fires too, before the rotate. Without a `container`, `rotateOnClick` throws — there'd be nowhere to put the redrawn SVG. For finer control (e.g. wrapping the SVG in your own zoom/pan `<g>`, as the demo site does), skip `container`/`rotateOnClick` and drive it yourself with `onNodeClick` + `rotate()` as shown in the demo's own source.
24
29
 
25
30
  ### Acknowledgements
26
31
 
@@ -837,6 +837,20 @@ function unrooted (node) {
837
837
  return data;
838
838
  }
839
839
 
840
+ /**
841
+ * Reverse the child order at one node, flipping which side of the tree
842
+ * that clade's descendants are drawn on. Defaults to the root. Mutates
843
+ * the tree in place.
844
+ */
845
+ function rotate(tree, nodeId = tree.id) {
846
+ const target = preorder(tree).find((n) => n.id === nodeId);
847
+ if (!target) {
848
+ throw new Error(`No node with id ${nodeId} found in tree`);
849
+ }
850
+ target.children.reverse();
851
+ return tree;
852
+ }
853
+
840
854
  /**
841
855
  * Parse a Newick tree string into a doubly-linked list of JS Objects.
842
856
  * Assigns labels, branch lengths, and node IDs (tips before internals if input emits them that way).
@@ -950,9 +964,8 @@ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize
950
964
  // by readTree()/randomTree()). Passing the same parsed tree object back in
951
965
  // across re-renders (e.g. after mutating it with rotate()) keeps node ids
952
966
  // stable, which onNodeClick below relies on.
953
- function drawPhylogeny(
954
- input,
955
- {
967
+ function drawPhylogeny(input, options = {}) {
968
+ const {
956
969
  layout = "rect", // rect/radial/unrooted
957
970
  width = 800,
958
971
  height = 800,
@@ -963,12 +976,13 @@ function drawPhylogeny(
963
976
  tipLabels = true,
964
977
  labelFontSize = 10, // font size (px) for tip labels
965
978
  tipRadius, // px radius of tip dots; defaults to each layout's original size
966
- internalNodeCircles = false, // draw a circle at every internal (non-tip) node
967
979
  internalNodeRadius = 3, // px radius for internal node circles
968
980
  nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
969
981
  nodeLabelFontSize, // defaults to labelFontSize
970
982
  scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
971
983
  alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
984
+ container, // DOM element or CSS selector — if given, drawPhylogeny mounts the SVG itself instead of just returning it
985
+ rotateOnClick = false, // click an internal node circle to rotate its children in place and auto-redraw (requires container)
972
986
  onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
973
987
  showTooltips = true,
974
988
  tooltipFormatter = (d, rtt) =>
@@ -978,8 +992,10 @@ function drawPhylogeny(
978
992
  highlightTips = [], // array of tip labels or ids for static highlight (optional)
979
993
  highlightStroke = "#e63946",
980
994
  highlightWidth = 2.5
981
- } = {}
982
- ) {
995
+ } = options;
996
+ // rotateOnClick needs internal node circles to click on; auto-enable them
997
+ // unless the caller explicitly said otherwise.
998
+ const internalNodeCircles = options.internalNodeCircles ?? rotateOnClick;
983
999
 
984
1000
  // shared helpers
985
1001
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
@@ -987,6 +1003,35 @@ function drawPhylogeny(
987
1003
  const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
988
1004
  ? input
989
1005
  : readTree(input);
1006
+
1007
+ const mountEl = typeof container === "string"
1008
+ ? (typeof document !== "undefined" ? document.querySelector(container) : null)
1009
+ : (container ?? null);
1010
+
1011
+ if (rotateOnClick && !mountEl) {
1012
+ throw new Error(
1013
+ "rotateOnClick requires a `container` (a DOM element or CSS selector) so drawPhylogeny can mount the redrawn SVG after each rotation."
1014
+ );
1015
+ }
1016
+
1017
+ // fires the caller's onNodeClick (if any), then handles the rotate+redraw itself
1018
+ const effectiveOnNodeClick = (onNodeClick || rotateOnClick)
1019
+ ? (node, event) => {
1020
+ if (typeof onNodeClick === "function") onNodeClick(node, event);
1021
+ if (rotateOnClick) {
1022
+ rotate(parsedTree, node.thisId);
1023
+ drawPhylogeny(parsedTree, options);
1024
+ }
1025
+ }
1026
+ : undefined;
1027
+
1028
+ function mount(svgNode) {
1029
+ if (mountEl) {
1030
+ mountEl.innerHTML = "";
1031
+ mountEl.appendChild(svgNode);
1032
+ }
1033
+ return svgNode;
1034
+ }
990
1035
  // Works for both radial (uses `r`) and rect (uses `x1`).
991
1036
  // Falls back to summing branchLength up to the root if neither is present.
992
1037
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1127,10 +1172,10 @@ function drawPhylogeny(
1127
1172
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1128
1173
  }
1129
1174
 
1130
- if (onNodeClick) {
1175
+ if (effectiveOnNodeClick) {
1131
1176
  internalDots
1132
1177
  .style("cursor", "pointer")
1133
- .on("click", (event, d) => onNodeClick(d, event));
1178
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1134
1179
  }
1135
1180
  }
1136
1181
 
@@ -1261,7 +1306,7 @@ function drawPhylogeny(
1261
1306
  });
1262
1307
  }
1263
1308
 
1264
- return svg.node();
1309
+ return mount(svg.node());
1265
1310
  } else if (layout === "radial") {
1266
1311
  // RADIAL LAYOUT
1267
1312
  if (width !== height) {
@@ -1487,10 +1532,10 @@ function drawPhylogeny(
1487
1532
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1488
1533
  }
1489
1534
 
1490
- if (onNodeClick) {
1535
+ if (effectiveOnNodeClick) {
1491
1536
  internalDots
1492
1537
  .style("cursor", "pointer")
1493
- .on("click", (event, d) => onNodeClick(d, event));
1538
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1494
1539
  }
1495
1540
  }
1496
1541
 
@@ -1689,7 +1734,7 @@ function drawPhylogeny(
1689
1734
  });
1690
1735
  }
1691
1736
 
1692
- return svg.node();
1737
+ return mount(svg.node());
1693
1738
  } else if (layout === "unrooted") {
1694
1739
  // UNROOTED LAYOUT
1695
1740
  const unrootedPhylo = unrooted(parsedTree);
@@ -1753,11 +1798,11 @@ function drawPhylogeny(
1753
1798
  .attr("stroke-width", 2)
1754
1799
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1755
1800
 
1756
- if (internalNodeCircles && onNodeClick) {
1801
+ if (internalNodeCircles && effectiveOnNodeClick) {
1757
1802
  nodes
1758
1803
  .filter((d) => !d.isTip)
1759
1804
  .style("cursor", "pointer")
1760
- .on("click", (event, d) => onNodeClick(d, event));
1805
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1761
1806
  }
1762
1807
 
1763
1808
  if (nodeLabels) {
@@ -1922,7 +1967,7 @@ function drawPhylogeny(
1922
1967
  });
1923
1968
  }
1924
1969
 
1925
- return svg.node();
1970
+ return mount(svg.node());
1926
1971
  } else {
1927
1972
  throw new Error(
1928
1973
  "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."