@euphrasiologist/lwphylo 1.3.0 → 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.
@@ -885,6 +885,46 @@ 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
+
888
928
  function drawPhylogeny(
889
929
  treeText,
890
930
  {
@@ -897,6 +937,13 @@ function drawPhylogeny(
897
937
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
898
938
  tipLabels = true,
899
939
  labelFontSize = 10, // font size (px) for tip labels
940
+ tipRadius, // px radius of tip dots; defaults to each layout's original size
941
+ internalNodeCircles = false, // draw a circle at every internal (non-tip) node
942
+ internalNodeRadius = 3, // px radius for internal node circles
943
+ nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
944
+ nodeLabelFontSize, // defaults to labelFontSize
945
+ scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
946
+ alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
900
947
  showTooltips = true,
901
948
  tooltipFormatter = (d, rtt) =>
902
949
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -910,6 +957,7 @@ function drawPhylogeny(
910
957
 
911
958
  // shared helpers
912
959
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
960
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
913
961
  // Works for both radial (uses `r`) and rect (uses `x1`).
914
962
  // Falls back to summing branchLength up to the root if neither is present.
915
963
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -948,6 +996,7 @@ function drawPhylogeny(
948
996
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
949
997
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
950
998
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
999
+ const R_TIP = tipRadius ?? 2;
951
1000
 
952
1001
  const maxY = d3.max(horizontal, (d) => d.y1);
953
1002
  const minY = d3.min(horizontal, (d) => d.y1);
@@ -1005,7 +1054,7 @@ function drawPhylogeny(
1005
1054
  .join("circle")
1006
1055
  .attr("cx", (d) => xScale(d.x1))
1007
1056
  .attr("cy", (d) => yScale(d.y1))
1008
- .attr("r", 2)
1057
+ .attr("r", R_TIP)
1009
1058
  .attr("fill", "black");
1010
1059
 
1011
1060
  // tooltips for rect dots
@@ -1020,13 +1069,72 @@ function drawPhylogeny(
1020
1069
  .on("mouseenter", function(_event, d) {
1021
1070
  hoverLayer.selectAll("*").remove();
1022
1071
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1023
- d3.select(this).attr("r", 4);
1072
+ d3.select(this).attr("r", R_TIP + 2);
1024
1073
  })
1025
1074
  .on("mouseleave", function() {
1026
1075
  hoverLayer.selectAll("*").remove();
1027
- d3.select(this).attr("r", 2);
1076
+ d3.select(this).attr("r", R_TIP);
1028
1077
  });
1029
1078
 
1079
+ // internal node circles (optional)
1080
+ if (internalNodeCircles) {
1081
+ const internalNodes = tree_df.data.filter((d) => !d.isTip);
1082
+ const internalDots = group
1083
+ .append("g")
1084
+ .attr("class", "phylo_internal_dots")
1085
+ .selectAll("circle")
1086
+ .data(internalNodes)
1087
+ .join("circle")
1088
+ .attr("cx", (d) => xScale(d.x1))
1089
+ .attr("cy", (d) => yScale(d.y1))
1090
+ .attr("r", internalNodeRadius)
1091
+ .attr("fill", "white")
1092
+ .attr("stroke", "#555")
1093
+ .attr("stroke-width", 1);
1094
+
1095
+ if (showTooltips) {
1096
+ internalDots
1097
+ .append("title")
1098
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1099
+ }
1100
+ }
1101
+
1102
+ // internal node labels (optional)
1103
+ if (nodeLabels) {
1104
+ const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
1105
+ svg
1106
+ .append("g")
1107
+ .attr("class", "phylo_node_labels")
1108
+ .selectAll("text")
1109
+ .data(labeledInternalNodes)
1110
+ .join("text")
1111
+ .attr("x", (d) => xScale(d.x1) - 4)
1112
+ .attr("y", (d) => yScale(d.y1) - 4)
1113
+ .attr("text-anchor", "end")
1114
+ .attr("font-size", nodeLabelSize)
1115
+ .text((d) => d.thisLabel);
1116
+ }
1117
+
1118
+ // column that tip labels align to when alignTipLabels is set
1119
+ const alignX = xScale(maxX);
1120
+
1121
+ // dashed guide lines from each tip's true branch end to the aligned label column
1122
+ if (tipLabels && alignTipLabels) {
1123
+ group
1124
+ .append("g")
1125
+ .attr("class", "phylo_align_guides")
1126
+ .selectAll("line")
1127
+ .data(tips)
1128
+ .join("line")
1129
+ .attr("x1", (d) => xScale(d.x1))
1130
+ .attr("x2", alignX)
1131
+ .attr("y1", (d) => yScale(d.y1))
1132
+ .attr("y2", (d) => yScale(d.y1))
1133
+ .attr("stroke", "#999")
1134
+ .attr("stroke-width", 1)
1135
+ .attr("stroke-dasharray", "2,2");
1136
+ }
1137
+
1030
1138
  // labels
1031
1139
  if (tipLabels) {
1032
1140
  const labels = svg
@@ -1035,7 +1143,7 @@ function drawPhylogeny(
1035
1143
  .selectAll("text")
1036
1144
  .data(tips)
1037
1145
  .join("text")
1038
- .attr("x", (d) => xScale(d.x1) + 4)
1146
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1039
1147
  .attr("y", (d) => yScale(d.y1))
1040
1148
  .attr("dy", "0.32em")
1041
1149
  .attr("font-size", labelFontSize)
@@ -1107,6 +1215,17 @@ function drawPhylogeny(
1107
1215
  }
1108
1216
  }
1109
1217
 
1218
+ if (scaleBar) {
1219
+ addScaleBar(svg, {
1220
+ scale: xScale,
1221
+ basis: maxX,
1222
+ defaultX: margin.left,
1223
+ defaultY: height - margin.bottom / 2,
1224
+ scaleBar,
1225
+ fontSize: labelFontSize
1226
+ });
1227
+ }
1228
+
1110
1229
  return svg.node();
1111
1230
  } else if (layout === "radial") {
1112
1231
  // RADIAL LAYOUT
@@ -1128,7 +1247,7 @@ function drawPhylogeny(
1128
1247
 
1129
1248
 
1130
1249
  // visuals (0 = let spokes reach the dots)
1131
- const DOT_R = 3;
1250
+ const DOT_R = tipRadius ?? 3;
1132
1251
  const END_CAP = 0;
1133
1252
 
1134
1253
  // ===== SCALES / BOUNDS =====
@@ -1266,6 +1385,25 @@ function drawPhylogeny(
1266
1385
  .attr("stroke-width", strokeWidth);
1267
1386
  });
1268
1387
 
1388
+ // ===== ALIGN GUIDES (optional) =====
1389
+ // dashed lines from each tip's true position to the common label ring,
1390
+ // for radialMode "phylo" (true terminals) where tips aren't already co-circular
1391
+ if (tipLabels && alignTipLabels && !isOuter) {
1392
+ group
1393
+ .append("g")
1394
+ .attr("class", "phylo_align_guides")
1395
+ .selectAll("line")
1396
+ .data(tips)
1397
+ .join("line")
1398
+ .attr("x1", (d) => xScaleRadial(d.x))
1399
+ .attr("y1", (d) => yScaleRadial(d.y))
1400
+ .attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
1401
+ .attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
1402
+ .attr("stroke", "#999")
1403
+ .attr("stroke-width", 1)
1404
+ .attr("stroke-dasharray", "2,2");
1405
+ }
1406
+
1269
1407
  // ===== TIP DOTS =====
1270
1408
  const tipDots = group
1271
1409
  .append("g")
@@ -1293,6 +1431,45 @@ function drawPhylogeny(
1293
1431
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1294
1432
  }
1295
1433
 
1434
+ // ===== INTERNAL NODE CIRCLES (optional) =====
1435
+ if (internalNodeCircles) {
1436
+ const internalNodes = rad.data.filter((d) => !d.isTip);
1437
+ const internalDots = group
1438
+ .append("g")
1439
+ .attr("class", "phylo_internal_dots")
1440
+ .selectAll("circle")
1441
+ .data(internalNodes)
1442
+ .join("circle")
1443
+ .attr("cx", (d) => xScaleRadial(d.x))
1444
+ .attr("cy", (d) => yScaleRadial(d.y))
1445
+ .attr("r", internalNodeRadius)
1446
+ .attr("fill", "white")
1447
+ .attr("stroke", "#555")
1448
+ .attr("stroke-width", 1);
1449
+
1450
+ if (showTooltips) {
1451
+ internalDots
1452
+ .append("title")
1453
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1454
+ }
1455
+ }
1456
+
1457
+ // ===== INTERNAL NODE LABELS (optional) =====
1458
+ if (nodeLabels) {
1459
+ const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
1460
+ group
1461
+ .append("g")
1462
+ .attr("class", "phylo_node_labels")
1463
+ .selectAll("text")
1464
+ .data(labeledInternalNodes)
1465
+ .join("text")
1466
+ .attr("x", (d) => xScaleRadial(d.x) + 4)
1467
+ .attr("y", (d) => yScaleRadial(d.y) - 4)
1468
+ .attr("font-size", nodeLabelSize)
1469
+ .attr("fill", "black")
1470
+ .text((d) => d.thisLabel);
1471
+ }
1472
+
1296
1473
  // maps for fast lookup on hover (childId → spoke / arc)
1297
1474
  const key = (x) => (typeof x === "string" ? +x : x);
1298
1475
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1313,7 +1490,8 @@ function drawPhylogeny(
1313
1490
  // same tip position rule as dots/spokes:
1314
1491
  // - "outer": snap to common ring (tipMaxR)
1315
1492
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1316
- const r = isOuter ? tipMaxR : d.r;
1493
+ // - alignTipLabels forces the common ring regardless of mode
1494
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1317
1495
  const x = r * Math.cos(d.angle);
1318
1496
  const y = r * Math.sin(d.angle);
1319
1497
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1460,6 +1638,17 @@ function drawPhylogeny(
1460
1638
  });
1461
1639
  }
1462
1640
 
1641
+ if (scaleBar) {
1642
+ addScaleBar(svg, {
1643
+ scale: xScaleRadial,
1644
+ basis: maxRadius,
1645
+ defaultX: 20,
1646
+ defaultY: h - 20,
1647
+ scaleBar,
1648
+ fontSize: labelFontSize
1649
+ });
1650
+ }
1651
+
1463
1652
  return svg.node();
1464
1653
  } else if (layout === "unrooted") {
1465
1654
  // UNROOTED LAYOUT
@@ -1509,6 +1698,8 @@ function drawPhylogeny(
1509
1698
  .attr("stroke-width", strokeWidth)
1510
1699
  .attr("stroke", "#777");
1511
1700
 
1701
+ const R_TIP = tipRadius ?? 4;
1702
+
1512
1703
  const nodes = group
1513
1704
  .append("g")
1514
1705
  .attr("class", "phylo_points")
@@ -1516,13 +1707,28 @@ function drawPhylogeny(
1516
1707
  .data(unrootedPhylo.data)
1517
1708
  .join("circle")
1518
1709
  .attr("class", "dot")
1519
- .attr("r", (d) => (d.isTip ? 4 : 0))
1710
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1520
1711
  .attr("cx", (d) => xScaleUnroot(d.x))
1521
1712
  .attr("cy", (d) => yScaleUnroot(d.y))
1522
1713
  .attr("stroke", "black")
1523
1714
  .attr("stroke-width", 2)
1524
1715
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1525
1716
 
1717
+ if (nodeLabels) {
1718
+ const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
1719
+ group
1720
+ .append("g")
1721
+ .attr("class", "phylo_node_labels")
1722
+ .selectAll("text")
1723
+ .data(labeledInternalNodes)
1724
+ .join("text")
1725
+ .attr("x", (d) => xScaleUnroot(d.x) + 4)
1726
+ .attr("y", (d) => yScaleUnroot(d.y) - 4)
1727
+ .attr("font-size", nodeLabelSize)
1728
+ .attr("fill", "black")
1729
+ .text((d) => d.thisLabel);
1730
+ }
1731
+
1526
1732
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1527
1733
  const tipById = new Map(
1528
1734
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1612,11 +1818,11 @@ function drawPhylogeny(
1612
1818
  .filter((d) => d.isTip)
1613
1819
  .on("mouseenter", function(_event, d) {
1614
1820
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1615
- d3.select(this).attr("r", 6);
1821
+ d3.select(this).attr("r", R_TIP + 2);
1616
1822
  })
1617
1823
  .on("mouseleave", function() {
1618
1824
  hoverLayer.selectAll("*").remove();
1619
- d3.select(this).attr("r", 4);
1825
+ d3.select(this).attr("r", R_TIP);
1620
1826
  });
1621
1827
 
1622
1828
  if (highlightTips && highlightTips.length) {
@@ -1659,6 +1865,17 @@ function drawPhylogeny(
1659
1865
  }
1660
1866
  }
1661
1867
 
1868
+ if (scaleBar) {
1869
+ addScaleBar(svg, {
1870
+ scale: xScaleUnroot,
1871
+ basis: maxRadius,
1872
+ defaultX: 20,
1873
+ defaultY: h - 20,
1874
+ scaleBar,
1875
+ fontSize: labelFontSize
1876
+ });
1877
+ }
1878
+
1662
1879
  return svg.node();
1663
1880
  } else {
1664
1881
  throw new Error(