@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.
@@ -885,8 +885,52 @@ function readTree(text) {
885
885
  return root;
886
886
  }
887
887
 
888
+ // Round a raw data-space length to a "nice" 1/2/5-of-a-power-of-ten value,
889
+ // so an auto-sized scale bar doesn't show an ugly number like "0.347".
890
+ function niceScaleLength(target) {
891
+ if (!(target > 0)) return 1;
892
+ const exp = Math.floor(Math.log10(target));
893
+ const base = Math.pow(10, exp);
894
+ const residual = target / base;
895
+ const niceResidual = residual < 1.5 ? 1 : residual < 3.5 ? 2 : residual < 7.5 ? 5 : 10;
896
+ return niceResidual * base;
897
+ }
898
+
899
+ // scaleBar: true | number (explicit data-units length) | { length, x, y, label }
900
+ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize }) {
901
+ const opts = (scaleBar === true || typeof scaleBar === "number") ? {} : scaleBar;
902
+ const length = typeof scaleBar === "number" ? scaleBar : (opts.length ?? niceScaleLength(basis / 5));
903
+ const x = opts.x ?? defaultX;
904
+ const y = opts.y ?? defaultY;
905
+ const barPx = scale(length) - scale(0);
906
+
907
+ const g = svg.append("g").attr("class", "phylo_scale_bar");
908
+ g.append("line")
909
+ .attr("x1", x).attr("x2", x + barPx)
910
+ .attr("y1", y).attr("y2", y)
911
+ .attr("stroke", "#000")
912
+ .attr("stroke-width", 1);
913
+ [x, x + barPx].forEach((tx) => {
914
+ g.append("line")
915
+ .attr("x1", tx).attr("x2", tx)
916
+ .attr("y1", y - 4).attr("y2", y + 4)
917
+ .attr("stroke", "#000")
918
+ .attr("stroke-width", 1);
919
+ });
920
+ g.append("text")
921
+ .attr("x", x + barPx / 2)
922
+ .attr("y", y - 6)
923
+ .attr("text-anchor", "middle")
924
+ .attr("font-size", fontSize)
925
+ .text(opts.label ?? String(length));
926
+ }
927
+
928
+ // input: a Newick string, or an already-parsed tree (the node shape returned
929
+ // by readTree()/randomTree()). Passing the same parsed tree object back in
930
+ // across re-renders (e.g. after mutating it with rotate()) keeps node ids
931
+ // stable, which onNodeClick below relies on.
888
932
  function drawPhylogeny(
889
- treeText,
933
+ input,
890
934
  {
891
935
  layout = "rect", // rect/radial/unrooted
892
936
  width = 800,
@@ -897,6 +941,14 @@ function drawPhylogeny(
897
941
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
898
942
  tipLabels = true,
899
943
  labelFontSize = 10, // font size (px) for tip labels
944
+ tipRadius, // px radius of tip dots; defaults to each layout's original size
945
+ internalNodeCircles = false, // draw a circle at every internal (non-tip) node
946
+ internalNodeRadius = 3, // px radius for internal node circles
947
+ nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
948
+ nodeLabelFontSize, // defaults to labelFontSize
949
+ scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
950
+ alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
951
+ onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
900
952
  showTooltips = true,
901
953
  tooltipFormatter = (d, rtt) =>
902
954
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -910,6 +962,10 @@ function drawPhylogeny(
910
962
 
911
963
  // shared helpers
912
964
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
965
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
966
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
967
+ ? input
968
+ : readTree(input);
913
969
  // Works for both radial (uses `r`) and rect (uses `x1`).
914
970
  // Falls back to summing branchLength up to the root if neither is present.
915
971
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -938,7 +994,7 @@ function drawPhylogeny(
938
994
 
939
995
  if (layout === "rect") {
940
996
  // RECTANGULAR LAYOUT
941
- const tree_df = rectangleLayout(readTree(treeText));
997
+ const tree_df = rectangleLayout(parsedTree);
942
998
  const horizontal = tree_df.horizontal_lines;
943
999
  const vertical = tree_df.vertical_lines;
944
1000
  const tips = horizontal.filter((d) => d.isTip);
@@ -948,6 +1004,7 @@ function drawPhylogeny(
948
1004
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
949
1005
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
950
1006
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1007
+ const R_TIP = tipRadius ?? 2;
951
1008
 
952
1009
  const maxY = d3.max(horizontal, (d) => d.y1);
953
1010
  const minY = d3.min(horizontal, (d) => d.y1);
@@ -1005,7 +1062,7 @@ function drawPhylogeny(
1005
1062
  .join("circle")
1006
1063
  .attr("cx", (d) => xScale(d.x1))
1007
1064
  .attr("cy", (d) => yScale(d.y1))
1008
- .attr("r", 2)
1065
+ .attr("r", R_TIP)
1009
1066
  .attr("fill", "black");
1010
1067
 
1011
1068
  // tooltips for rect dots
@@ -1020,13 +1077,78 @@ function drawPhylogeny(
1020
1077
  .on("mouseenter", function(_event, d) {
1021
1078
  hoverLayer.selectAll("*").remove();
1022
1079
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1023
- d3.select(this).attr("r", 4);
1080
+ d3.select(this).attr("r", R_TIP + 2);
1024
1081
  })
1025
1082
  .on("mouseleave", function() {
1026
1083
  hoverLayer.selectAll("*").remove();
1027
- d3.select(this).attr("r", 2);
1084
+ d3.select(this).attr("r", R_TIP);
1028
1085
  });
1029
1086
 
1087
+ // internal node circles (optional)
1088
+ if (internalNodeCircles) {
1089
+ const internalNodes = tree_df.data.filter((d) => !d.isTip);
1090
+ const internalDots = group
1091
+ .append("g")
1092
+ .attr("class", "phylo_internal_dots")
1093
+ .selectAll("circle")
1094
+ .data(internalNodes)
1095
+ .join("circle")
1096
+ .attr("cx", (d) => xScale(d.x1))
1097
+ .attr("cy", (d) => yScale(d.y1))
1098
+ .attr("r", internalNodeRadius)
1099
+ .attr("fill", "white")
1100
+ .attr("stroke", "#555")
1101
+ .attr("stroke-width", 1);
1102
+
1103
+ if (showTooltips) {
1104
+ internalDots
1105
+ .append("title")
1106
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1107
+ }
1108
+
1109
+ if (onNodeClick) {
1110
+ internalDots
1111
+ .style("cursor", "pointer")
1112
+ .on("click", (event, d) => onNodeClick(d, event));
1113
+ }
1114
+ }
1115
+
1116
+ // internal node labels (optional)
1117
+ if (nodeLabels) {
1118
+ const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
1119
+ svg
1120
+ .append("g")
1121
+ .attr("class", "phylo_node_labels")
1122
+ .selectAll("text")
1123
+ .data(labeledInternalNodes)
1124
+ .join("text")
1125
+ .attr("x", (d) => xScale(d.x1) - 4)
1126
+ .attr("y", (d) => yScale(d.y1) - 4)
1127
+ .attr("text-anchor", "end")
1128
+ .attr("font-size", nodeLabelSize)
1129
+ .text((d) => d.thisLabel);
1130
+ }
1131
+
1132
+ // column that tip labels align to when alignTipLabels is set
1133
+ const alignX = xScale(maxX);
1134
+
1135
+ // dashed guide lines from each tip's true branch end to the aligned label column
1136
+ if (tipLabels && alignTipLabels) {
1137
+ group
1138
+ .append("g")
1139
+ .attr("class", "phylo_align_guides")
1140
+ .selectAll("line")
1141
+ .data(tips)
1142
+ .join("line")
1143
+ .attr("x1", (d) => xScale(d.x1))
1144
+ .attr("x2", alignX)
1145
+ .attr("y1", (d) => yScale(d.y1))
1146
+ .attr("y2", (d) => yScale(d.y1))
1147
+ .attr("stroke", "#999")
1148
+ .attr("stroke-width", 1)
1149
+ .attr("stroke-dasharray", "2,2");
1150
+ }
1151
+
1030
1152
  // labels
1031
1153
  if (tipLabels) {
1032
1154
  const labels = svg
@@ -1035,7 +1157,7 @@ function drawPhylogeny(
1035
1157
  .selectAll("text")
1036
1158
  .data(tips)
1037
1159
  .join("text")
1038
- .attr("x", (d) => xScale(d.x1) + 4)
1160
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1039
1161
  .attr("y", (d) => yScale(d.y1))
1040
1162
  .attr("dy", "0.32em")
1041
1163
  .attr("font-size", labelFontSize)
@@ -1107,13 +1229,23 @@ function drawPhylogeny(
1107
1229
  }
1108
1230
  }
1109
1231
 
1232
+ if (scaleBar) {
1233
+ addScaleBar(svg, {
1234
+ scale: xScale,
1235
+ basis: maxX,
1236
+ defaultX: margin.left,
1237
+ defaultY: height - margin.bottom / 2,
1238
+ scaleBar,
1239
+ fontSize: labelFontSize
1240
+ });
1241
+ }
1242
+
1110
1243
  return svg.node();
1111
1244
  } else if (layout === "radial") {
1112
1245
  // RADIAL LAYOUT
1113
1246
  if (width !== height) {
1114
1247
  throw new Error("width and height must be the same for radial layout");
1115
1248
  }
1116
- const parsedTree = readTree(treeText);
1117
1249
  const rad = radialLayout(parsedTree, {
1118
1250
  angleStrategy: "fan",
1119
1251
  arcsStyle: "fan"
@@ -1128,7 +1260,7 @@ function drawPhylogeny(
1128
1260
 
1129
1261
 
1130
1262
  // visuals (0 = let spokes reach the dots)
1131
- const DOT_R = 3;
1263
+ const DOT_R = tipRadius ?? 3;
1132
1264
  const END_CAP = 0;
1133
1265
 
1134
1266
  // ===== SCALES / BOUNDS =====
@@ -1266,6 +1398,25 @@ function drawPhylogeny(
1266
1398
  .attr("stroke-width", strokeWidth);
1267
1399
  });
1268
1400
 
1401
+ // ===== ALIGN GUIDES (optional) =====
1402
+ // dashed lines from each tip's true position to the common label ring,
1403
+ // for radialMode "phylo" (true terminals) where tips aren't already co-circular
1404
+ if (tipLabels && alignTipLabels && !isOuter) {
1405
+ group
1406
+ .append("g")
1407
+ .attr("class", "phylo_align_guides")
1408
+ .selectAll("line")
1409
+ .data(tips)
1410
+ .join("line")
1411
+ .attr("x1", (d) => xScaleRadial(d.x))
1412
+ .attr("y1", (d) => yScaleRadial(d.y))
1413
+ .attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
1414
+ .attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
1415
+ .attr("stroke", "#999")
1416
+ .attr("stroke-width", 1)
1417
+ .attr("stroke-dasharray", "2,2");
1418
+ }
1419
+
1269
1420
  // ===== TIP DOTS =====
1270
1421
  const tipDots = group
1271
1422
  .append("g")
@@ -1293,6 +1444,51 @@ function drawPhylogeny(
1293
1444
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1294
1445
  }
1295
1446
 
1447
+ // ===== INTERNAL NODE CIRCLES (optional) =====
1448
+ if (internalNodeCircles) {
1449
+ const internalNodes = rad.data.filter((d) => !d.isTip);
1450
+ const internalDots = group
1451
+ .append("g")
1452
+ .attr("class", "phylo_internal_dots")
1453
+ .selectAll("circle")
1454
+ .data(internalNodes)
1455
+ .join("circle")
1456
+ .attr("cx", (d) => xScaleRadial(d.x))
1457
+ .attr("cy", (d) => yScaleRadial(d.y))
1458
+ .attr("r", internalNodeRadius)
1459
+ .attr("fill", "white")
1460
+ .attr("stroke", "#555")
1461
+ .attr("stroke-width", 1);
1462
+
1463
+ if (showTooltips) {
1464
+ internalDots
1465
+ .append("title")
1466
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1467
+ }
1468
+
1469
+ if (onNodeClick) {
1470
+ internalDots
1471
+ .style("cursor", "pointer")
1472
+ .on("click", (event, d) => onNodeClick(d, event));
1473
+ }
1474
+ }
1475
+
1476
+ // ===== INTERNAL NODE LABELS (optional) =====
1477
+ if (nodeLabels) {
1478
+ const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
1479
+ group
1480
+ .append("g")
1481
+ .attr("class", "phylo_node_labels")
1482
+ .selectAll("text")
1483
+ .data(labeledInternalNodes)
1484
+ .join("text")
1485
+ .attr("x", (d) => xScaleRadial(d.x) + 4)
1486
+ .attr("y", (d) => yScaleRadial(d.y) - 4)
1487
+ .attr("font-size", nodeLabelSize)
1488
+ .attr("fill", "black")
1489
+ .text((d) => d.thisLabel);
1490
+ }
1491
+
1296
1492
  // maps for fast lookup on hover (childId → spoke / arc)
1297
1493
  const key = (x) => (typeof x === "string" ? +x : x);
1298
1494
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1313,7 +1509,8 @@ function drawPhylogeny(
1313
1509
  // same tip position rule as dots/spokes:
1314
1510
  // - "outer": snap to common ring (tipMaxR)
1315
1511
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1316
- const r = isOuter ? tipMaxR : d.r;
1512
+ // - alignTipLabels forces the common ring regardless of mode
1513
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1317
1514
  const x = r * Math.cos(d.angle);
1318
1515
  const y = r * Math.sin(d.angle);
1319
1516
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1460,10 +1657,20 @@ function drawPhylogeny(
1460
1657
  });
1461
1658
  }
1462
1659
 
1660
+ if (scaleBar) {
1661
+ addScaleBar(svg, {
1662
+ scale: xScaleRadial,
1663
+ basis: maxRadius,
1664
+ defaultX: 20,
1665
+ defaultY: h - 20,
1666
+ scaleBar,
1667
+ fontSize: labelFontSize
1668
+ });
1669
+ }
1670
+
1463
1671
  return svg.node();
1464
1672
  } else if (layout === "unrooted") {
1465
1673
  // UNROOTED LAYOUT
1466
- const parsedTree = readTree(treeText);
1467
1674
  const unrootedPhylo = unrooted(parsedTree);
1468
1675
 
1469
1676
  const w = width;
@@ -1509,6 +1716,8 @@ function drawPhylogeny(
1509
1716
  .attr("stroke-width", strokeWidth)
1510
1717
  .attr("stroke", "#777");
1511
1718
 
1719
+ const R_TIP = tipRadius ?? 4;
1720
+
1512
1721
  const nodes = group
1513
1722
  .append("g")
1514
1723
  .attr("class", "phylo_points")
@@ -1516,13 +1725,35 @@ function drawPhylogeny(
1516
1725
  .data(unrootedPhylo.data)
1517
1726
  .join("circle")
1518
1727
  .attr("class", "dot")
1519
- .attr("r", (d) => (d.isTip ? 4 : 0))
1728
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1520
1729
  .attr("cx", (d) => xScaleUnroot(d.x))
1521
1730
  .attr("cy", (d) => yScaleUnroot(d.y))
1522
1731
  .attr("stroke", "black")
1523
1732
  .attr("stroke-width", 2)
1524
1733
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1525
1734
 
1735
+ if (internalNodeCircles && onNodeClick) {
1736
+ nodes
1737
+ .filter((d) => !d.isTip)
1738
+ .style("cursor", "pointer")
1739
+ .on("click", (event, d) => onNodeClick(d, event));
1740
+ }
1741
+
1742
+ if (nodeLabels) {
1743
+ const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
1744
+ group
1745
+ .append("g")
1746
+ .attr("class", "phylo_node_labels")
1747
+ .selectAll("text")
1748
+ .data(labeledInternalNodes)
1749
+ .join("text")
1750
+ .attr("x", (d) => xScaleUnroot(d.x) + 4)
1751
+ .attr("y", (d) => yScaleUnroot(d.y) - 4)
1752
+ .attr("font-size", nodeLabelSize)
1753
+ .attr("fill", "black")
1754
+ .text((d) => d.thisLabel);
1755
+ }
1756
+
1526
1757
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1527
1758
  const tipById = new Map(
1528
1759
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1612,11 +1843,11 @@ function drawPhylogeny(
1612
1843
  .filter((d) => d.isTip)
1613
1844
  .on("mouseenter", function(_event, d) {
1614
1845
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1615
- d3.select(this).attr("r", 6);
1846
+ d3.select(this).attr("r", R_TIP + 2);
1616
1847
  })
1617
1848
  .on("mouseleave", function() {
1618
1849
  hoverLayer.selectAll("*").remove();
1619
- d3.select(this).attr("r", 4);
1850
+ d3.select(this).attr("r", R_TIP);
1620
1851
  });
1621
1852
 
1622
1853
  if (highlightTips && highlightTips.length) {
@@ -1659,6 +1890,17 @@ function drawPhylogeny(
1659
1890
  }
1660
1891
  }
1661
1892
 
1893
+ if (scaleBar) {
1894
+ addScaleBar(svg, {
1895
+ scale: xScaleUnroot,
1896
+ basis: maxRadius,
1897
+ defaultX: 20,
1898
+ defaultY: h - 20,
1899
+ scaleBar,
1900
+ fontSize: labelFontSize
1901
+ });
1902
+ }
1903
+
1662
1904
  return svg.node();
1663
1905
  } else {
1664
1906
  throw new Error(