@euphrasiologist/lwphylo 1.3.1 → 1.4.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,16 @@ 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, so it can be handed to `drawPhylogeny()` (which expects Newick text): `drawPhylogeny(toNewick(randomTree(20)))`.
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(newick, options)` accepts, 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.
14
23
 
15
24
  ### Acknowledgements
16
25
 
@@ -906,6 +906,46 @@ 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
+
909
949
  function drawPhylogeny(
910
950
  treeText,
911
951
  {
@@ -918,6 +958,13 @@ function drawPhylogeny(
918
958
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
919
959
  tipLabels = true,
920
960
  labelFontSize = 10, // font size (px) for tip labels
961
+ 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
+ internalNodeRadius = 3, // px radius for internal node circles
964
+ nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
965
+ nodeLabelFontSize, // defaults to labelFontSize
966
+ scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
967
+ alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
921
968
  showTooltips = true,
922
969
  tooltipFormatter = (d, rtt) =>
923
970
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -931,6 +978,7 @@ function drawPhylogeny(
931
978
 
932
979
  // shared helpers
933
980
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
981
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
934
982
  // Works for both radial (uses `r`) and rect (uses `x1`).
935
983
  // Falls back to summing branchLength up to the root if neither is present.
936
984
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -969,6 +1017,7 @@ function drawPhylogeny(
969
1017
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
970
1018
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
971
1019
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1020
+ const R_TIP = tipRadius ?? 2;
972
1021
 
973
1022
  const maxY = d3__namespace.max(horizontal, (d) => d.y1);
974
1023
  const minY = d3__namespace.min(horizontal, (d) => d.y1);
@@ -1026,7 +1075,7 @@ function drawPhylogeny(
1026
1075
  .join("circle")
1027
1076
  .attr("cx", (d) => xScale(d.x1))
1028
1077
  .attr("cy", (d) => yScale(d.y1))
1029
- .attr("r", 2)
1078
+ .attr("r", R_TIP)
1030
1079
  .attr("fill", "black");
1031
1080
 
1032
1081
  // tooltips for rect dots
@@ -1041,13 +1090,72 @@ function drawPhylogeny(
1041
1090
  .on("mouseenter", function(_event, d) {
1042
1091
  hoverLayer.selectAll("*").remove();
1043
1092
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1044
- d3__namespace.select(this).attr("r", 4);
1093
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1045
1094
  })
1046
1095
  .on("mouseleave", function() {
1047
1096
  hoverLayer.selectAll("*").remove();
1048
- d3__namespace.select(this).attr("r", 2);
1097
+ d3__namespace.select(this).attr("r", R_TIP);
1049
1098
  });
1050
1099
 
1100
+ // internal node circles (optional)
1101
+ if (internalNodeCircles) {
1102
+ const internalNodes = tree_df.data.filter((d) => !d.isTip);
1103
+ const internalDots = group
1104
+ .append("g")
1105
+ .attr("class", "phylo_internal_dots")
1106
+ .selectAll("circle")
1107
+ .data(internalNodes)
1108
+ .join("circle")
1109
+ .attr("cx", (d) => xScale(d.x1))
1110
+ .attr("cy", (d) => yScale(d.y1))
1111
+ .attr("r", internalNodeRadius)
1112
+ .attr("fill", "white")
1113
+ .attr("stroke", "#555")
1114
+ .attr("stroke-width", 1);
1115
+
1116
+ if (showTooltips) {
1117
+ internalDots
1118
+ .append("title")
1119
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1120
+ }
1121
+ }
1122
+
1123
+ // internal node labels (optional)
1124
+ if (nodeLabels) {
1125
+ const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
1126
+ svg
1127
+ .append("g")
1128
+ .attr("class", "phylo_node_labels")
1129
+ .selectAll("text")
1130
+ .data(labeledInternalNodes)
1131
+ .join("text")
1132
+ .attr("x", (d) => xScale(d.x1) - 4)
1133
+ .attr("y", (d) => yScale(d.y1) - 4)
1134
+ .attr("text-anchor", "end")
1135
+ .attr("font-size", nodeLabelSize)
1136
+ .text((d) => d.thisLabel);
1137
+ }
1138
+
1139
+ // column that tip labels align to when alignTipLabels is set
1140
+ const alignX = xScale(maxX);
1141
+
1142
+ // dashed guide lines from each tip's true branch end to the aligned label column
1143
+ if (tipLabels && alignTipLabels) {
1144
+ group
1145
+ .append("g")
1146
+ .attr("class", "phylo_align_guides")
1147
+ .selectAll("line")
1148
+ .data(tips)
1149
+ .join("line")
1150
+ .attr("x1", (d) => xScale(d.x1))
1151
+ .attr("x2", alignX)
1152
+ .attr("y1", (d) => yScale(d.y1))
1153
+ .attr("y2", (d) => yScale(d.y1))
1154
+ .attr("stroke", "#999")
1155
+ .attr("stroke-width", 1)
1156
+ .attr("stroke-dasharray", "2,2");
1157
+ }
1158
+
1051
1159
  // labels
1052
1160
  if (tipLabels) {
1053
1161
  const labels = svg
@@ -1056,7 +1164,7 @@ function drawPhylogeny(
1056
1164
  .selectAll("text")
1057
1165
  .data(tips)
1058
1166
  .join("text")
1059
- .attr("x", (d) => xScale(d.x1) + 4)
1167
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1060
1168
  .attr("y", (d) => yScale(d.y1))
1061
1169
  .attr("dy", "0.32em")
1062
1170
  .attr("font-size", labelFontSize)
@@ -1128,6 +1236,17 @@ function drawPhylogeny(
1128
1236
  }
1129
1237
  }
1130
1238
 
1239
+ if (scaleBar) {
1240
+ addScaleBar(svg, {
1241
+ scale: xScale,
1242
+ basis: maxX,
1243
+ defaultX: margin.left,
1244
+ defaultY: height - margin.bottom / 2,
1245
+ scaleBar,
1246
+ fontSize: labelFontSize
1247
+ });
1248
+ }
1249
+
1131
1250
  return svg.node();
1132
1251
  } else if (layout === "radial") {
1133
1252
  // RADIAL LAYOUT
@@ -1149,7 +1268,7 @@ function drawPhylogeny(
1149
1268
 
1150
1269
 
1151
1270
  // visuals (0 = let spokes reach the dots)
1152
- const DOT_R = 3;
1271
+ const DOT_R = tipRadius ?? 3;
1153
1272
  const END_CAP = 0;
1154
1273
 
1155
1274
  // ===== SCALES / BOUNDS =====
@@ -1287,6 +1406,25 @@ function drawPhylogeny(
1287
1406
  .attr("stroke-width", strokeWidth);
1288
1407
  });
1289
1408
 
1409
+ // ===== ALIGN GUIDES (optional) =====
1410
+ // dashed lines from each tip's true position to the common label ring,
1411
+ // for radialMode "phylo" (true terminals) where tips aren't already co-circular
1412
+ if (tipLabels && alignTipLabels && !isOuter) {
1413
+ group
1414
+ .append("g")
1415
+ .attr("class", "phylo_align_guides")
1416
+ .selectAll("line")
1417
+ .data(tips)
1418
+ .join("line")
1419
+ .attr("x1", (d) => xScaleRadial(d.x))
1420
+ .attr("y1", (d) => yScaleRadial(d.y))
1421
+ .attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
1422
+ .attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
1423
+ .attr("stroke", "#999")
1424
+ .attr("stroke-width", 1)
1425
+ .attr("stroke-dasharray", "2,2");
1426
+ }
1427
+
1290
1428
  // ===== TIP DOTS =====
1291
1429
  const tipDots = group
1292
1430
  .append("g")
@@ -1314,6 +1452,45 @@ function drawPhylogeny(
1314
1452
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1315
1453
  }
1316
1454
 
1455
+ // ===== INTERNAL NODE CIRCLES (optional) =====
1456
+ if (internalNodeCircles) {
1457
+ const internalNodes = rad.data.filter((d) => !d.isTip);
1458
+ const internalDots = group
1459
+ .append("g")
1460
+ .attr("class", "phylo_internal_dots")
1461
+ .selectAll("circle")
1462
+ .data(internalNodes)
1463
+ .join("circle")
1464
+ .attr("cx", (d) => xScaleRadial(d.x))
1465
+ .attr("cy", (d) => yScaleRadial(d.y))
1466
+ .attr("r", internalNodeRadius)
1467
+ .attr("fill", "white")
1468
+ .attr("stroke", "#555")
1469
+ .attr("stroke-width", 1);
1470
+
1471
+ if (showTooltips) {
1472
+ internalDots
1473
+ .append("title")
1474
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1475
+ }
1476
+ }
1477
+
1478
+ // ===== INTERNAL NODE LABELS (optional) =====
1479
+ if (nodeLabels) {
1480
+ const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
1481
+ group
1482
+ .append("g")
1483
+ .attr("class", "phylo_node_labels")
1484
+ .selectAll("text")
1485
+ .data(labeledInternalNodes)
1486
+ .join("text")
1487
+ .attr("x", (d) => xScaleRadial(d.x) + 4)
1488
+ .attr("y", (d) => yScaleRadial(d.y) - 4)
1489
+ .attr("font-size", nodeLabelSize)
1490
+ .attr("fill", "black")
1491
+ .text((d) => d.thisLabel);
1492
+ }
1493
+
1317
1494
  // maps for fast lookup on hover (childId → spoke / arc)
1318
1495
  const key = (x) => (typeof x === "string" ? +x : x);
1319
1496
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1334,7 +1511,8 @@ function drawPhylogeny(
1334
1511
  // same tip position rule as dots/spokes:
1335
1512
  // - "outer": snap to common ring (tipMaxR)
1336
1513
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1337
- const r = isOuter ? tipMaxR : d.r;
1514
+ // - alignTipLabels forces the common ring regardless of mode
1515
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1338
1516
  const x = r * Math.cos(d.angle);
1339
1517
  const y = r * Math.sin(d.angle);
1340
1518
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1481,6 +1659,17 @@ function drawPhylogeny(
1481
1659
  });
1482
1660
  }
1483
1661
 
1662
+ if (scaleBar) {
1663
+ addScaleBar(svg, {
1664
+ scale: xScaleRadial,
1665
+ basis: maxRadius,
1666
+ defaultX: 20,
1667
+ defaultY: h - 20,
1668
+ scaleBar,
1669
+ fontSize: labelFontSize
1670
+ });
1671
+ }
1672
+
1484
1673
  return svg.node();
1485
1674
  } else if (layout === "unrooted") {
1486
1675
  // UNROOTED LAYOUT
@@ -1530,6 +1719,8 @@ function drawPhylogeny(
1530
1719
  .attr("stroke-width", strokeWidth)
1531
1720
  .attr("stroke", "#777");
1532
1721
 
1722
+ const R_TIP = tipRadius ?? 4;
1723
+
1533
1724
  const nodes = group
1534
1725
  .append("g")
1535
1726
  .attr("class", "phylo_points")
@@ -1537,13 +1728,28 @@ function drawPhylogeny(
1537
1728
  .data(unrootedPhylo.data)
1538
1729
  .join("circle")
1539
1730
  .attr("class", "dot")
1540
- .attr("r", (d) => (d.isTip ? 4 : 0))
1731
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1541
1732
  .attr("cx", (d) => xScaleUnroot(d.x))
1542
1733
  .attr("cy", (d) => yScaleUnroot(d.y))
1543
1734
  .attr("stroke", "black")
1544
1735
  .attr("stroke-width", 2)
1545
1736
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1546
1737
 
1738
+ if (nodeLabels) {
1739
+ const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
1740
+ group
1741
+ .append("g")
1742
+ .attr("class", "phylo_node_labels")
1743
+ .selectAll("text")
1744
+ .data(labeledInternalNodes)
1745
+ .join("text")
1746
+ .attr("x", (d) => xScaleUnroot(d.x) + 4)
1747
+ .attr("y", (d) => yScaleUnroot(d.y) - 4)
1748
+ .attr("font-size", nodeLabelSize)
1749
+ .attr("fill", "black")
1750
+ .text((d) => d.thisLabel);
1751
+ }
1752
+
1547
1753
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1548
1754
  const tipById = new Map(
1549
1755
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1633,11 +1839,11 @@ function drawPhylogeny(
1633
1839
  .filter((d) => d.isTip)
1634
1840
  .on("mouseenter", function(_event, d) {
1635
1841
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1636
- d3__namespace.select(this).attr("r", 6);
1842
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1637
1843
  })
1638
1844
  .on("mouseleave", function() {
1639
1845
  hoverLayer.selectAll("*").remove();
1640
- d3__namespace.select(this).attr("r", 4);
1846
+ d3__namespace.select(this).attr("r", R_TIP);
1641
1847
  });
1642
1848
 
1643
1849
  if (highlightTips && highlightTips.length) {
@@ -1680,6 +1886,17 @@ function drawPhylogeny(
1680
1886
  }
1681
1887
  }
1682
1888
 
1889
+ if (scaleBar) {
1890
+ addScaleBar(svg, {
1891
+ scale: xScaleUnroot,
1892
+ basis: maxRadius,
1893
+ defaultX: 20,
1894
+ defaultY: h - 20,
1895
+ scaleBar,
1896
+ fontSize: labelFontSize
1897
+ });
1898
+ }
1899
+
1683
1900
  return svg.node();
1684
1901
  } else {
1685
1902
  throw new Error(