@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.
package/README.md CHANGED
@@ -10,16 +10,22 @@ Visit https://euphrasiologist.github.io/lwPhylo/ to see examples and live render
10
10
 
11
11
  Newick trees can be parsed using the `readTree()` function. This object can then be wrapped in three main functions; `rectangleLayout()` to produce a "regular" phylogenetic tree, `radialLayout()` to produce a circular phylogeny, and `unrooted()` to produce an unrooted tree via the equal angle layout algorithm.
12
12
 
13
- Need a tree to experiment with? `randomTree(nTips, { maxBranchLength, labelPrefix, seed })` generates a random bifurcating tree in the same node shape as `readTree()`, ready to pass straight into any of the layout functions. `toNewick(tree)` serializes one of these parsed tree objects back to a Newick string, so it can be handed to `drawPhylogeny()` (which expects Newick text): `drawPhylogeny(toNewick(randomTree(20)))`.
13
+ Need a tree to experiment with? `randomTree(nTips, { maxBranchLength, labelPrefix, seed })` generates a random bifurcating tree in the same node shape as `readTree()`, ready to pass straight into any of the layout functions. `toNewick(tree)` serializes one of these parsed tree objects back to a Newick string.
14
14
 
15
15
  `ladderize(tree, { ascending })` and `rotate(tree, nodeId)` change tip order by reordering a node's children in place — ladderize sorts every clade by descendant tip count (smallest first by default), rotate flips the child order at one node (the root, if no id is given).
16
16
 
17
- `drawPhylogeny(newick, options)` accepts, in addition to `layout`/`width`/`height`/`tipLabels`/`labelFontSize`/`highlightTips`:
17
+ `drawPhylogeny(input, options)` accepts either a Newick string or an already-parsed tree object (from `readTree()`/`randomTree()`) as `input`. Passing the same parsed tree object back in across re-renders — e.g. after mutating it with `rotate()` — keeps node ids stable, which `onNodeClick` (below) relies on. Options, in addition to `layout`/`width`/`height`/`tipLabels`/`labelFontSize`/`highlightTips`:
18
18
  - `tipRadius` — px radius of tip circles.
19
19
  - `internalNodeCircles` (bool) + `internalNodeRadius` — draw a circle at every internal node.
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`).
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.
23
29
 
24
30
  ### Acknowledgements
25
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).
@@ -946,9 +960,12 @@ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize
946
960
  .text(opts.label ?? String(length));
947
961
  }
948
962
 
949
- function drawPhylogeny(
950
- treeText,
951
- {
963
+ // input: a Newick string, or an already-parsed tree (the node shape returned
964
+ // by readTree()/randomTree()). Passing the same parsed tree object back in
965
+ // across re-renders (e.g. after mutating it with rotate()) keeps node ids
966
+ // stable, which onNodeClick below relies on.
967
+ function drawPhylogeny(input, options = {}) {
968
+ const {
952
969
  layout = "rect", // rect/radial/unrooted
953
970
  width = 800,
954
971
  height = 800,
@@ -959,12 +976,14 @@ function drawPhylogeny(
959
976
  tipLabels = true,
960
977
  labelFontSize = 10, // font size (px) for tip labels
961
978
  tipRadius, // px radius of tip dots; defaults to each layout's original size
962
- internalNodeCircles = false, // draw a circle at every internal (non-tip) node
963
979
  internalNodeRadius = 3, // px radius for internal node circles
964
980
  nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
965
981
  nodeLabelFontSize, // defaults to labelFontSize
966
982
  scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
967
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)
986
+ onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
968
987
  showTooltips = true,
969
988
  tooltipFormatter = (d, rtt) =>
970
989
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -973,12 +992,46 @@ function drawPhylogeny(
973
992
  highlightTips = [], // array of tip labels or ids for static highlight (optional)
974
993
  highlightStroke = "#e63946",
975
994
  highlightWidth = 2.5
976
- } = {}
977
- ) {
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;
978
999
 
979
1000
  // shared helpers
980
1001
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
981
1002
  const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1003
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
1004
+ ? input
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
+ }
982
1035
  // Works for both radial (uses `r`) and rect (uses `x1`).
983
1036
  // Falls back to summing branchLength up to the root if neither is present.
984
1037
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1007,7 +1060,7 @@ function drawPhylogeny(
1007
1060
 
1008
1061
  if (layout === "rect") {
1009
1062
  // RECTANGULAR LAYOUT
1010
- const tree_df = rectangleLayout(readTree(treeText));
1063
+ const tree_df = rectangleLayout(parsedTree);
1011
1064
  const horizontal = tree_df.horizontal_lines;
1012
1065
  const vertical = tree_df.vertical_lines;
1013
1066
  const tips = horizontal.filter((d) => d.isTip);
@@ -1118,6 +1171,12 @@ function drawPhylogeny(
1118
1171
  .append("title")
1119
1172
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1120
1173
  }
1174
+
1175
+ if (effectiveOnNodeClick) {
1176
+ internalDots
1177
+ .style("cursor", "pointer")
1178
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1179
+ }
1121
1180
  }
1122
1181
 
1123
1182
  // internal node labels (optional)
@@ -1247,13 +1306,12 @@ function drawPhylogeny(
1247
1306
  });
1248
1307
  }
1249
1308
 
1250
- return svg.node();
1309
+ return mount(svg.node());
1251
1310
  } else if (layout === "radial") {
1252
1311
  // RADIAL LAYOUT
1253
1312
  if (width !== height) {
1254
1313
  throw new Error("width and height must be the same for radial layout");
1255
1314
  }
1256
- const parsedTree = readTree(treeText);
1257
1315
  const rad = radialLayout(parsedTree, {
1258
1316
  angleStrategy: "fan",
1259
1317
  arcsStyle: "fan"
@@ -1473,6 +1531,12 @@ function drawPhylogeny(
1473
1531
  .append("title")
1474
1532
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1475
1533
  }
1534
+
1535
+ if (effectiveOnNodeClick) {
1536
+ internalDots
1537
+ .style("cursor", "pointer")
1538
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1539
+ }
1476
1540
  }
1477
1541
 
1478
1542
  // ===== INTERNAL NODE LABELS (optional) =====
@@ -1670,10 +1734,9 @@ function drawPhylogeny(
1670
1734
  });
1671
1735
  }
1672
1736
 
1673
- return svg.node();
1737
+ return mount(svg.node());
1674
1738
  } else if (layout === "unrooted") {
1675
1739
  // UNROOTED LAYOUT
1676
- const parsedTree = readTree(treeText);
1677
1740
  const unrootedPhylo = unrooted(parsedTree);
1678
1741
 
1679
1742
  const w = width;
@@ -1735,6 +1798,13 @@ function drawPhylogeny(
1735
1798
  .attr("stroke-width", 2)
1736
1799
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1737
1800
 
1801
+ if (internalNodeCircles && effectiveOnNodeClick) {
1802
+ nodes
1803
+ .filter((d) => !d.isTip)
1804
+ .style("cursor", "pointer")
1805
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1806
+ }
1807
+
1738
1808
  if (nodeLabels) {
1739
1809
  const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
1740
1810
  group
@@ -1897,7 +1967,7 @@ function drawPhylogeny(
1897
1967
  });
1898
1968
  }
1899
1969
 
1900
- return svg.node();
1970
+ return mount(svg.node());
1901
1971
  } else {
1902
1972
  throw new Error(
1903
1973
  "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."