@euphrasiologist/lwphylo 1.3.1 → 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,7 +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.
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
+
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
+
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
+ - `tipRadius` — px radius of tip circles.
19
+ - `internalNodeCircles` (bool) + `internalNodeRadius` — draw a circle at every internal node.
20
+ - `nodeLabels` (bool) + `nodeLabelFontSize` — draw text labels (e.g. clade/support values) at internal nodes that have one.
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
+ - `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(); }`.
14
24
 
15
25
  ### Acknowledgements
16
26
 
@@ -906,8 +906,52 @@ function readTree(text) {
906
906
  return root;
907
907
  }
908
908
 
909
+ // Round a raw data-space length to a "nice" 1/2/5-of-a-power-of-ten value,
910
+ // so an auto-sized scale bar doesn't show an ugly number like "0.347".
911
+ function niceScaleLength(target) {
912
+ if (!(target > 0)) return 1;
913
+ const exp = Math.floor(Math.log10(target));
914
+ const base = Math.pow(10, exp);
915
+ const residual = target / base;
916
+ const niceResidual = residual < 1.5 ? 1 : residual < 3.5 ? 2 : residual < 7.5 ? 5 : 10;
917
+ return niceResidual * base;
918
+ }
919
+
920
+ // scaleBar: true | number (explicit data-units length) | { length, x, y, label }
921
+ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize }) {
922
+ const opts = (scaleBar === true || typeof scaleBar === "number") ? {} : scaleBar;
923
+ const length = typeof scaleBar === "number" ? scaleBar : (opts.length ?? niceScaleLength(basis / 5));
924
+ const x = opts.x ?? defaultX;
925
+ const y = opts.y ?? defaultY;
926
+ const barPx = scale(length) - scale(0);
927
+
928
+ const g = svg.append("g").attr("class", "phylo_scale_bar");
929
+ g.append("line")
930
+ .attr("x1", x).attr("x2", x + barPx)
931
+ .attr("y1", y).attr("y2", y)
932
+ .attr("stroke", "#000")
933
+ .attr("stroke-width", 1);
934
+ [x, x + barPx].forEach((tx) => {
935
+ g.append("line")
936
+ .attr("x1", tx).attr("x2", tx)
937
+ .attr("y1", y - 4).attr("y2", y + 4)
938
+ .attr("stroke", "#000")
939
+ .attr("stroke-width", 1);
940
+ });
941
+ g.append("text")
942
+ .attr("x", x + barPx / 2)
943
+ .attr("y", y - 6)
944
+ .attr("text-anchor", "middle")
945
+ .attr("font-size", fontSize)
946
+ .text(opts.label ?? String(length));
947
+ }
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.
909
953
  function drawPhylogeny(
910
- treeText,
954
+ input,
911
955
  {
912
956
  layout = "rect", // rect/radial/unrooted
913
957
  width = 800,
@@ -918,6 +962,14 @@ function drawPhylogeny(
918
962
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
919
963
  tipLabels = true,
920
964
  labelFontSize = 10, // font size (px) for tip labels
965
+ 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
+ internalNodeRadius = 3, // px radius for internal node circles
968
+ nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
969
+ nodeLabelFontSize, // defaults to labelFontSize
970
+ scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
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)
921
973
  showTooltips = true,
922
974
  tooltipFormatter = (d, rtt) =>
923
975
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -931,6 +983,10 @@ function drawPhylogeny(
931
983
 
932
984
  // shared helpers
933
985
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
986
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
987
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
988
+ ? input
989
+ : readTree(input);
934
990
  // Works for both radial (uses `r`) and rect (uses `x1`).
935
991
  // Falls back to summing branchLength up to the root if neither is present.
936
992
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -959,7 +1015,7 @@ function drawPhylogeny(
959
1015
 
960
1016
  if (layout === "rect") {
961
1017
  // RECTANGULAR LAYOUT
962
- const tree_df = rectangleLayout(readTree(treeText));
1018
+ const tree_df = rectangleLayout(parsedTree);
963
1019
  const horizontal = tree_df.horizontal_lines;
964
1020
  const vertical = tree_df.vertical_lines;
965
1021
  const tips = horizontal.filter((d) => d.isTip);
@@ -969,6 +1025,7 @@ function drawPhylogeny(
969
1025
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
970
1026
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
971
1027
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1028
+ const R_TIP = tipRadius ?? 2;
972
1029
 
973
1030
  const maxY = d3__namespace.max(horizontal, (d) => d.y1);
974
1031
  const minY = d3__namespace.min(horizontal, (d) => d.y1);
@@ -1026,7 +1083,7 @@ function drawPhylogeny(
1026
1083
  .join("circle")
1027
1084
  .attr("cx", (d) => xScale(d.x1))
1028
1085
  .attr("cy", (d) => yScale(d.y1))
1029
- .attr("r", 2)
1086
+ .attr("r", R_TIP)
1030
1087
  .attr("fill", "black");
1031
1088
 
1032
1089
  // tooltips for rect dots
@@ -1041,13 +1098,78 @@ function drawPhylogeny(
1041
1098
  .on("mouseenter", function(_event, d) {
1042
1099
  hoverLayer.selectAll("*").remove();
1043
1100
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1044
- d3__namespace.select(this).attr("r", 4);
1101
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1045
1102
  })
1046
1103
  .on("mouseleave", function() {
1047
1104
  hoverLayer.selectAll("*").remove();
1048
- d3__namespace.select(this).attr("r", 2);
1105
+ d3__namespace.select(this).attr("r", R_TIP);
1049
1106
  });
1050
1107
 
1108
+ // internal node circles (optional)
1109
+ if (internalNodeCircles) {
1110
+ const internalNodes = tree_df.data.filter((d) => !d.isTip);
1111
+ const internalDots = group
1112
+ .append("g")
1113
+ .attr("class", "phylo_internal_dots")
1114
+ .selectAll("circle")
1115
+ .data(internalNodes)
1116
+ .join("circle")
1117
+ .attr("cx", (d) => xScale(d.x1))
1118
+ .attr("cy", (d) => yScale(d.y1))
1119
+ .attr("r", internalNodeRadius)
1120
+ .attr("fill", "white")
1121
+ .attr("stroke", "#555")
1122
+ .attr("stroke-width", 1);
1123
+
1124
+ if (showTooltips) {
1125
+ internalDots
1126
+ .append("title")
1127
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1128
+ }
1129
+
1130
+ if (onNodeClick) {
1131
+ internalDots
1132
+ .style("cursor", "pointer")
1133
+ .on("click", (event, d) => onNodeClick(d, event));
1134
+ }
1135
+ }
1136
+
1137
+ // internal node labels (optional)
1138
+ if (nodeLabels) {
1139
+ const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
1140
+ svg
1141
+ .append("g")
1142
+ .attr("class", "phylo_node_labels")
1143
+ .selectAll("text")
1144
+ .data(labeledInternalNodes)
1145
+ .join("text")
1146
+ .attr("x", (d) => xScale(d.x1) - 4)
1147
+ .attr("y", (d) => yScale(d.y1) - 4)
1148
+ .attr("text-anchor", "end")
1149
+ .attr("font-size", nodeLabelSize)
1150
+ .text((d) => d.thisLabel);
1151
+ }
1152
+
1153
+ // column that tip labels align to when alignTipLabels is set
1154
+ const alignX = xScale(maxX);
1155
+
1156
+ // dashed guide lines from each tip's true branch end to the aligned label column
1157
+ if (tipLabels && alignTipLabels) {
1158
+ group
1159
+ .append("g")
1160
+ .attr("class", "phylo_align_guides")
1161
+ .selectAll("line")
1162
+ .data(tips)
1163
+ .join("line")
1164
+ .attr("x1", (d) => xScale(d.x1))
1165
+ .attr("x2", alignX)
1166
+ .attr("y1", (d) => yScale(d.y1))
1167
+ .attr("y2", (d) => yScale(d.y1))
1168
+ .attr("stroke", "#999")
1169
+ .attr("stroke-width", 1)
1170
+ .attr("stroke-dasharray", "2,2");
1171
+ }
1172
+
1051
1173
  // labels
1052
1174
  if (tipLabels) {
1053
1175
  const labels = svg
@@ -1056,7 +1178,7 @@ function drawPhylogeny(
1056
1178
  .selectAll("text")
1057
1179
  .data(tips)
1058
1180
  .join("text")
1059
- .attr("x", (d) => xScale(d.x1) + 4)
1181
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1060
1182
  .attr("y", (d) => yScale(d.y1))
1061
1183
  .attr("dy", "0.32em")
1062
1184
  .attr("font-size", labelFontSize)
@@ -1128,13 +1250,23 @@ function drawPhylogeny(
1128
1250
  }
1129
1251
  }
1130
1252
 
1253
+ if (scaleBar) {
1254
+ addScaleBar(svg, {
1255
+ scale: xScale,
1256
+ basis: maxX,
1257
+ defaultX: margin.left,
1258
+ defaultY: height - margin.bottom / 2,
1259
+ scaleBar,
1260
+ fontSize: labelFontSize
1261
+ });
1262
+ }
1263
+
1131
1264
  return svg.node();
1132
1265
  } else if (layout === "radial") {
1133
1266
  // RADIAL LAYOUT
1134
1267
  if (width !== height) {
1135
1268
  throw new Error("width and height must be the same for radial layout");
1136
1269
  }
1137
- const parsedTree = readTree(treeText);
1138
1270
  const rad = radialLayout(parsedTree, {
1139
1271
  angleStrategy: "fan",
1140
1272
  arcsStyle: "fan"
@@ -1149,7 +1281,7 @@ function drawPhylogeny(
1149
1281
 
1150
1282
 
1151
1283
  // visuals (0 = let spokes reach the dots)
1152
- const DOT_R = 3;
1284
+ const DOT_R = tipRadius ?? 3;
1153
1285
  const END_CAP = 0;
1154
1286
 
1155
1287
  // ===== SCALES / BOUNDS =====
@@ -1287,6 +1419,25 @@ function drawPhylogeny(
1287
1419
  .attr("stroke-width", strokeWidth);
1288
1420
  });
1289
1421
 
1422
+ // ===== ALIGN GUIDES (optional) =====
1423
+ // dashed lines from each tip's true position to the common label ring,
1424
+ // for radialMode "phylo" (true terminals) where tips aren't already co-circular
1425
+ if (tipLabels && alignTipLabels && !isOuter) {
1426
+ group
1427
+ .append("g")
1428
+ .attr("class", "phylo_align_guides")
1429
+ .selectAll("line")
1430
+ .data(tips)
1431
+ .join("line")
1432
+ .attr("x1", (d) => xScaleRadial(d.x))
1433
+ .attr("y1", (d) => yScaleRadial(d.y))
1434
+ .attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
1435
+ .attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
1436
+ .attr("stroke", "#999")
1437
+ .attr("stroke-width", 1)
1438
+ .attr("stroke-dasharray", "2,2");
1439
+ }
1440
+
1290
1441
  // ===== TIP DOTS =====
1291
1442
  const tipDots = group
1292
1443
  .append("g")
@@ -1314,6 +1465,51 @@ function drawPhylogeny(
1314
1465
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1315
1466
  }
1316
1467
 
1468
+ // ===== INTERNAL NODE CIRCLES (optional) =====
1469
+ if (internalNodeCircles) {
1470
+ const internalNodes = rad.data.filter((d) => !d.isTip);
1471
+ const internalDots = group
1472
+ .append("g")
1473
+ .attr("class", "phylo_internal_dots")
1474
+ .selectAll("circle")
1475
+ .data(internalNodes)
1476
+ .join("circle")
1477
+ .attr("cx", (d) => xScaleRadial(d.x))
1478
+ .attr("cy", (d) => yScaleRadial(d.y))
1479
+ .attr("r", internalNodeRadius)
1480
+ .attr("fill", "white")
1481
+ .attr("stroke", "#555")
1482
+ .attr("stroke-width", 1);
1483
+
1484
+ if (showTooltips) {
1485
+ internalDots
1486
+ .append("title")
1487
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1488
+ }
1489
+
1490
+ if (onNodeClick) {
1491
+ internalDots
1492
+ .style("cursor", "pointer")
1493
+ .on("click", (event, d) => onNodeClick(d, event));
1494
+ }
1495
+ }
1496
+
1497
+ // ===== INTERNAL NODE LABELS (optional) =====
1498
+ if (nodeLabels) {
1499
+ const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
1500
+ group
1501
+ .append("g")
1502
+ .attr("class", "phylo_node_labels")
1503
+ .selectAll("text")
1504
+ .data(labeledInternalNodes)
1505
+ .join("text")
1506
+ .attr("x", (d) => xScaleRadial(d.x) + 4)
1507
+ .attr("y", (d) => yScaleRadial(d.y) - 4)
1508
+ .attr("font-size", nodeLabelSize)
1509
+ .attr("fill", "black")
1510
+ .text((d) => d.thisLabel);
1511
+ }
1512
+
1317
1513
  // maps for fast lookup on hover (childId → spoke / arc)
1318
1514
  const key = (x) => (typeof x === "string" ? +x : x);
1319
1515
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1334,7 +1530,8 @@ function drawPhylogeny(
1334
1530
  // same tip position rule as dots/spokes:
1335
1531
  // - "outer": snap to common ring (tipMaxR)
1336
1532
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1337
- const r = isOuter ? tipMaxR : d.r;
1533
+ // - alignTipLabels forces the common ring regardless of mode
1534
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1338
1535
  const x = r * Math.cos(d.angle);
1339
1536
  const y = r * Math.sin(d.angle);
1340
1537
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1481,10 +1678,20 @@ function drawPhylogeny(
1481
1678
  });
1482
1679
  }
1483
1680
 
1681
+ if (scaleBar) {
1682
+ addScaleBar(svg, {
1683
+ scale: xScaleRadial,
1684
+ basis: maxRadius,
1685
+ defaultX: 20,
1686
+ defaultY: h - 20,
1687
+ scaleBar,
1688
+ fontSize: labelFontSize
1689
+ });
1690
+ }
1691
+
1484
1692
  return svg.node();
1485
1693
  } else if (layout === "unrooted") {
1486
1694
  // UNROOTED LAYOUT
1487
- const parsedTree = readTree(treeText);
1488
1695
  const unrootedPhylo = unrooted(parsedTree);
1489
1696
 
1490
1697
  const w = width;
@@ -1530,6 +1737,8 @@ function drawPhylogeny(
1530
1737
  .attr("stroke-width", strokeWidth)
1531
1738
  .attr("stroke", "#777");
1532
1739
 
1740
+ const R_TIP = tipRadius ?? 4;
1741
+
1533
1742
  const nodes = group
1534
1743
  .append("g")
1535
1744
  .attr("class", "phylo_points")
@@ -1537,13 +1746,35 @@ function drawPhylogeny(
1537
1746
  .data(unrootedPhylo.data)
1538
1747
  .join("circle")
1539
1748
  .attr("class", "dot")
1540
- .attr("r", (d) => (d.isTip ? 4 : 0))
1749
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1541
1750
  .attr("cx", (d) => xScaleUnroot(d.x))
1542
1751
  .attr("cy", (d) => yScaleUnroot(d.y))
1543
1752
  .attr("stroke", "black")
1544
1753
  .attr("stroke-width", 2)
1545
1754
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1546
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
+
1763
+ if (nodeLabels) {
1764
+ const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
1765
+ group
1766
+ .append("g")
1767
+ .attr("class", "phylo_node_labels")
1768
+ .selectAll("text")
1769
+ .data(labeledInternalNodes)
1770
+ .join("text")
1771
+ .attr("x", (d) => xScaleUnroot(d.x) + 4)
1772
+ .attr("y", (d) => yScaleUnroot(d.y) - 4)
1773
+ .attr("font-size", nodeLabelSize)
1774
+ .attr("fill", "black")
1775
+ .text((d) => d.thisLabel);
1776
+ }
1777
+
1547
1778
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1548
1779
  const tipById = new Map(
1549
1780
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1633,11 +1864,11 @@ function drawPhylogeny(
1633
1864
  .filter((d) => d.isTip)
1634
1865
  .on("mouseenter", function(_event, d) {
1635
1866
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1636
- d3__namespace.select(this).attr("r", 6);
1867
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1637
1868
  })
1638
1869
  .on("mouseleave", function() {
1639
1870
  hoverLayer.selectAll("*").remove();
1640
- d3__namespace.select(this).attr("r", 4);
1871
+ d3__namespace.select(this).attr("r", R_TIP);
1641
1872
  });
1642
1873
 
1643
1874
  if (highlightTips && highlightTips.length) {
@@ -1680,6 +1911,17 @@ function drawPhylogeny(
1680
1911
  }
1681
1912
  }
1682
1913
 
1914
+ if (scaleBar) {
1915
+ addScaleBar(svg, {
1916
+ scale: xScaleUnroot,
1917
+ basis: maxRadius,
1918
+ defaultX: 20,
1919
+ defaultY: h - 20,
1920
+ scaleBar,
1921
+ fontSize: labelFontSize
1922
+ });
1923
+ }
1924
+
1683
1925
  return svg.node();
1684
1926
  } else {
1685
1927
  throw new Error(