@euphrasiologist/lwphylo 1.4.0 → 1.5.0

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,17 @@ 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`). Combine with `rotate()` for click-to-rotate: `onNodeClick: (node) => { rotate(tree, node.thisId); redraw(); }`.
23
24
 
24
25
  ### Acknowledgements
25
26
 
@@ -946,8 +946,12 @@ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize
946
946
  .text(opts.label ?? String(length));
947
947
  }
948
948
 
949
+ // input: a Newick string, or an already-parsed tree (the node shape returned
950
+ // by readTree()/randomTree()). Passing the same parsed tree object back in
951
+ // across re-renders (e.g. after mutating it with rotate()) keeps node ids
952
+ // stable, which onNodeClick below relies on.
949
953
  function drawPhylogeny(
950
- treeText,
954
+ input,
951
955
  {
952
956
  layout = "rect", // rect/radial/unrooted
953
957
  width = 800,
@@ -965,6 +969,7 @@ function drawPhylogeny(
965
969
  nodeLabelFontSize, // defaults to labelFontSize
966
970
  scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
967
971
  alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
972
+ onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
968
973
  showTooltips = true,
969
974
  tooltipFormatter = (d, rtt) =>
970
975
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -979,6 +984,9 @@ function drawPhylogeny(
979
984
  // shared helpers
980
985
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
981
986
  const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
987
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
988
+ ? input
989
+ : readTree(input);
982
990
  // Works for both radial (uses `r`) and rect (uses `x1`).
983
991
  // Falls back to summing branchLength up to the root if neither is present.
984
992
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1007,7 +1015,7 @@ function drawPhylogeny(
1007
1015
 
1008
1016
  if (layout === "rect") {
1009
1017
  // RECTANGULAR LAYOUT
1010
- const tree_df = rectangleLayout(readTree(treeText));
1018
+ const tree_df = rectangleLayout(parsedTree);
1011
1019
  const horizontal = tree_df.horizontal_lines;
1012
1020
  const vertical = tree_df.vertical_lines;
1013
1021
  const tips = horizontal.filter((d) => d.isTip);
@@ -1118,6 +1126,12 @@ function drawPhylogeny(
1118
1126
  .append("title")
1119
1127
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1120
1128
  }
1129
+
1130
+ if (onNodeClick) {
1131
+ internalDots
1132
+ .style("cursor", "pointer")
1133
+ .on("click", (event, d) => onNodeClick(d, event));
1134
+ }
1121
1135
  }
1122
1136
 
1123
1137
  // internal node labels (optional)
@@ -1253,7 +1267,6 @@ function drawPhylogeny(
1253
1267
  if (width !== height) {
1254
1268
  throw new Error("width and height must be the same for radial layout");
1255
1269
  }
1256
- const parsedTree = readTree(treeText);
1257
1270
  const rad = radialLayout(parsedTree, {
1258
1271
  angleStrategy: "fan",
1259
1272
  arcsStyle: "fan"
@@ -1473,6 +1486,12 @@ function drawPhylogeny(
1473
1486
  .append("title")
1474
1487
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1475
1488
  }
1489
+
1490
+ if (onNodeClick) {
1491
+ internalDots
1492
+ .style("cursor", "pointer")
1493
+ .on("click", (event, d) => onNodeClick(d, event));
1494
+ }
1476
1495
  }
1477
1496
 
1478
1497
  // ===== INTERNAL NODE LABELS (optional) =====
@@ -1673,7 +1692,6 @@ function drawPhylogeny(
1673
1692
  return svg.node();
1674
1693
  } else if (layout === "unrooted") {
1675
1694
  // UNROOTED LAYOUT
1676
- const parsedTree = readTree(treeText);
1677
1695
  const unrootedPhylo = unrooted(parsedTree);
1678
1696
 
1679
1697
  const w = width;
@@ -1735,6 +1753,13 @@ function drawPhylogeny(
1735
1753
  .attr("stroke-width", 2)
1736
1754
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1737
1755
 
1756
+ if (internalNodeCircles && onNodeClick) {
1757
+ nodes
1758
+ .filter((d) => !d.isTip)
1759
+ .style("cursor", "pointer")
1760
+ .on("click", (event, d) => onNodeClick(d, event));
1761
+ }
1762
+
1738
1763
  if (nodeLabels) {
1739
1764
  const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
1740
1765
  group