@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/dist/index.cjs CHANGED
@@ -950,6 +950,23 @@ function unrooted (node) {
950
950
  return data;
951
951
  }
952
952
 
953
+ /**
954
+ * Reorder every node's children by descendant tip count, so the tree
955
+ * "ladders" from smallest to largest clade at each branching point
956
+ * (or the reverse, with ascending: false). Mutates the tree in place.
957
+ */
958
+ function ladderize(tree, { ascending = true } = {}) {
959
+ const sign = ascending ? 1 : -1;
960
+
961
+ (function sort(node) {
962
+ if (node.children.length === 0) return;
963
+ node.children.forEach(sort);
964
+ node.children.sort((a, b) => sign * (numTips(a) - numTips(b)));
965
+ })(tree);
966
+
967
+ return tree;
968
+ }
969
+
953
970
  function makeIndexById(rows, key = "thisId") {
954
971
  return new Map(rows.map(d => [d[key], d]));
955
972
  }
@@ -959,6 +976,20 @@ function parentFisheye(d, data) {
959
976
  return parent ? { px: parent.fisheye.x, py: parent.fisheye.y } : null;
960
977
  }
961
978
 
979
+ /**
980
+ * Reverse the child order at one node, flipping which side of the tree
981
+ * that clade's descendants are drawn on. Defaults to the root. Mutates
982
+ * the tree in place.
983
+ */
984
+ function rotate(tree, nodeId = tree.id) {
985
+ const target = preorder(tree).find((n) => n.id === nodeId);
986
+ if (!target) {
987
+ throw new Error(`No node with id ${nodeId} found in tree`);
988
+ }
989
+ target.children.reverse();
990
+ return tree;
991
+ }
992
+
962
993
  /**
963
994
  * Generate a random bifurcating tree with `nTips` tips, in the same
964
995
  * parent/children node shape produced by readTree().
@@ -1173,8 +1204,52 @@ function serialize(node) {
1173
1204
  return `(${children})${label}${branch}`;
1174
1205
  }
1175
1206
 
1207
+ // Round a raw data-space length to a "nice" 1/2/5-of-a-power-of-ten value,
1208
+ // so an auto-sized scale bar doesn't show an ugly number like "0.347".
1209
+ function niceScaleLength(target) {
1210
+ if (!(target > 0)) return 1;
1211
+ const exp = Math.floor(Math.log10(target));
1212
+ const base = Math.pow(10, exp);
1213
+ const residual = target / base;
1214
+ const niceResidual = residual < 1.5 ? 1 : residual < 3.5 ? 2 : residual < 7.5 ? 5 : 10;
1215
+ return niceResidual * base;
1216
+ }
1217
+
1218
+ // scaleBar: true | number (explicit data-units length) | { length, x, y, label }
1219
+ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize }) {
1220
+ const opts = (scaleBar === true || typeof scaleBar === "number") ? {} : scaleBar;
1221
+ const length = typeof scaleBar === "number" ? scaleBar : (opts.length ?? niceScaleLength(basis / 5));
1222
+ const x = opts.x ?? defaultX;
1223
+ const y = opts.y ?? defaultY;
1224
+ const barPx = scale(length) - scale(0);
1225
+
1226
+ const g = svg.append("g").attr("class", "phylo_scale_bar");
1227
+ g.append("line")
1228
+ .attr("x1", x).attr("x2", x + barPx)
1229
+ .attr("y1", y).attr("y2", y)
1230
+ .attr("stroke", "#000")
1231
+ .attr("stroke-width", 1);
1232
+ [x, x + barPx].forEach((tx) => {
1233
+ g.append("line")
1234
+ .attr("x1", tx).attr("x2", tx)
1235
+ .attr("y1", y - 4).attr("y2", y + 4)
1236
+ .attr("stroke", "#000")
1237
+ .attr("stroke-width", 1);
1238
+ });
1239
+ g.append("text")
1240
+ .attr("x", x + barPx / 2)
1241
+ .attr("y", y - 6)
1242
+ .attr("text-anchor", "middle")
1243
+ .attr("font-size", fontSize)
1244
+ .text(opts.label ?? String(length));
1245
+ }
1246
+
1247
+ // input: a Newick string, or an already-parsed tree (the node shape returned
1248
+ // by readTree()/randomTree()). Passing the same parsed tree object back in
1249
+ // across re-renders (e.g. after mutating it with rotate()) keeps node ids
1250
+ // stable, which onNodeClick below relies on.
1176
1251
  function drawPhylogeny(
1177
- treeText,
1252
+ input,
1178
1253
  {
1179
1254
  layout = "rect", // rect/radial/unrooted
1180
1255
  width = 800,
@@ -1185,6 +1260,14 @@ function drawPhylogeny(
1185
1260
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1186
1261
  tipLabels = true,
1187
1262
  labelFontSize = 10, // font size (px) for tip labels
1263
+ tipRadius, // px radius of tip dots; defaults to each layout's original size
1264
+ internalNodeCircles = false, // draw a circle at every internal (non-tip) node
1265
+ internalNodeRadius = 3, // px radius for internal node circles
1266
+ nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
1267
+ nodeLabelFontSize, // defaults to labelFontSize
1268
+ scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
1269
+ alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
1270
+ onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
1188
1271
  showTooltips = true,
1189
1272
  tooltipFormatter = (d, rtt) =>
1190
1273
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1198,6 +1281,10 @@ function drawPhylogeny(
1198
1281
 
1199
1282
  // shared helpers
1200
1283
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
1284
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1285
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
1286
+ ? input
1287
+ : readTree(input);
1201
1288
  // Works for both radial (uses `r`) and rect (uses `x1`).
1202
1289
  // Falls back to summing branchLength up to the root if neither is present.
1203
1290
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1226,7 +1313,7 @@ function drawPhylogeny(
1226
1313
 
1227
1314
  if (layout === "rect") {
1228
1315
  // RECTANGULAR LAYOUT
1229
- const tree_df = rectangleLayout(readTree(treeText));
1316
+ const tree_df = rectangleLayout(parsedTree);
1230
1317
  const horizontal = tree_df.horizontal_lines;
1231
1318
  const vertical = tree_df.vertical_lines;
1232
1319
  const tips = horizontal.filter((d) => d.isTip);
@@ -1236,6 +1323,7 @@ function drawPhylogeny(
1236
1323
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1237
1324
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1238
1325
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1326
+ const R_TIP = tipRadius ?? 2;
1239
1327
 
1240
1328
  const maxY = d3__namespace.max(horizontal, (d) => d.y1);
1241
1329
  const minY = d3__namespace.min(horizontal, (d) => d.y1);
@@ -1293,7 +1381,7 @@ function drawPhylogeny(
1293
1381
  .join("circle")
1294
1382
  .attr("cx", (d) => xScale(d.x1))
1295
1383
  .attr("cy", (d) => yScale(d.y1))
1296
- .attr("r", 2)
1384
+ .attr("r", R_TIP)
1297
1385
  .attr("fill", "black");
1298
1386
 
1299
1387
  // tooltips for rect dots
@@ -1308,13 +1396,78 @@ function drawPhylogeny(
1308
1396
  .on("mouseenter", function(_event, d) {
1309
1397
  hoverLayer.selectAll("*").remove();
1310
1398
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1311
- d3__namespace.select(this).attr("r", 4);
1399
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1312
1400
  })
1313
1401
  .on("mouseleave", function() {
1314
1402
  hoverLayer.selectAll("*").remove();
1315
- d3__namespace.select(this).attr("r", 2);
1403
+ d3__namespace.select(this).attr("r", R_TIP);
1316
1404
  });
1317
1405
 
1406
+ // internal node circles (optional)
1407
+ if (internalNodeCircles) {
1408
+ const internalNodes = tree_df.data.filter((d) => !d.isTip);
1409
+ const internalDots = group
1410
+ .append("g")
1411
+ .attr("class", "phylo_internal_dots")
1412
+ .selectAll("circle")
1413
+ .data(internalNodes)
1414
+ .join("circle")
1415
+ .attr("cx", (d) => xScale(d.x1))
1416
+ .attr("cy", (d) => yScale(d.y1))
1417
+ .attr("r", internalNodeRadius)
1418
+ .attr("fill", "white")
1419
+ .attr("stroke", "#555")
1420
+ .attr("stroke-width", 1);
1421
+
1422
+ if (showTooltips) {
1423
+ internalDots
1424
+ .append("title")
1425
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1426
+ }
1427
+
1428
+ if (onNodeClick) {
1429
+ internalDots
1430
+ .style("cursor", "pointer")
1431
+ .on("click", (event, d) => onNodeClick(d, event));
1432
+ }
1433
+ }
1434
+
1435
+ // internal node labels (optional)
1436
+ if (nodeLabels) {
1437
+ const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
1438
+ svg
1439
+ .append("g")
1440
+ .attr("class", "phylo_node_labels")
1441
+ .selectAll("text")
1442
+ .data(labeledInternalNodes)
1443
+ .join("text")
1444
+ .attr("x", (d) => xScale(d.x1) - 4)
1445
+ .attr("y", (d) => yScale(d.y1) - 4)
1446
+ .attr("text-anchor", "end")
1447
+ .attr("font-size", nodeLabelSize)
1448
+ .text((d) => d.thisLabel);
1449
+ }
1450
+
1451
+ // column that tip labels align to when alignTipLabels is set
1452
+ const alignX = xScale(maxX);
1453
+
1454
+ // dashed guide lines from each tip's true branch end to the aligned label column
1455
+ if (tipLabels && alignTipLabels) {
1456
+ group
1457
+ .append("g")
1458
+ .attr("class", "phylo_align_guides")
1459
+ .selectAll("line")
1460
+ .data(tips)
1461
+ .join("line")
1462
+ .attr("x1", (d) => xScale(d.x1))
1463
+ .attr("x2", alignX)
1464
+ .attr("y1", (d) => yScale(d.y1))
1465
+ .attr("y2", (d) => yScale(d.y1))
1466
+ .attr("stroke", "#999")
1467
+ .attr("stroke-width", 1)
1468
+ .attr("stroke-dasharray", "2,2");
1469
+ }
1470
+
1318
1471
  // labels
1319
1472
  if (tipLabels) {
1320
1473
  const labels = svg
@@ -1323,7 +1476,7 @@ function drawPhylogeny(
1323
1476
  .selectAll("text")
1324
1477
  .data(tips)
1325
1478
  .join("text")
1326
- .attr("x", (d) => xScale(d.x1) + 4)
1479
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1327
1480
  .attr("y", (d) => yScale(d.y1))
1328
1481
  .attr("dy", "0.32em")
1329
1482
  .attr("font-size", labelFontSize)
@@ -1395,13 +1548,23 @@ function drawPhylogeny(
1395
1548
  }
1396
1549
  }
1397
1550
 
1551
+ if (scaleBar) {
1552
+ addScaleBar(svg, {
1553
+ scale: xScale,
1554
+ basis: maxX,
1555
+ defaultX: margin.left,
1556
+ defaultY: height - margin.bottom / 2,
1557
+ scaleBar,
1558
+ fontSize: labelFontSize
1559
+ });
1560
+ }
1561
+
1398
1562
  return svg.node();
1399
1563
  } else if (layout === "radial") {
1400
1564
  // RADIAL LAYOUT
1401
1565
  if (width !== height) {
1402
1566
  throw new Error("width and height must be the same for radial layout");
1403
1567
  }
1404
- const parsedTree = readTree(treeText);
1405
1568
  const rad = radialLayout(parsedTree, {
1406
1569
  angleStrategy: "fan",
1407
1570
  arcsStyle: "fan"
@@ -1416,7 +1579,7 @@ function drawPhylogeny(
1416
1579
 
1417
1580
 
1418
1581
  // visuals (0 = let spokes reach the dots)
1419
- const DOT_R = 3;
1582
+ const DOT_R = tipRadius ?? 3;
1420
1583
  const END_CAP = 0;
1421
1584
 
1422
1585
  // ===== SCALES / BOUNDS =====
@@ -1554,6 +1717,25 @@ function drawPhylogeny(
1554
1717
  .attr("stroke-width", strokeWidth);
1555
1718
  });
1556
1719
 
1720
+ // ===== ALIGN GUIDES (optional) =====
1721
+ // dashed lines from each tip's true position to the common label ring,
1722
+ // for radialMode "phylo" (true terminals) where tips aren't already co-circular
1723
+ if (tipLabels && alignTipLabels && !isOuter) {
1724
+ group
1725
+ .append("g")
1726
+ .attr("class", "phylo_align_guides")
1727
+ .selectAll("line")
1728
+ .data(tips)
1729
+ .join("line")
1730
+ .attr("x1", (d) => xScaleRadial(d.x))
1731
+ .attr("y1", (d) => yScaleRadial(d.y))
1732
+ .attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
1733
+ .attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
1734
+ .attr("stroke", "#999")
1735
+ .attr("stroke-width", 1)
1736
+ .attr("stroke-dasharray", "2,2");
1737
+ }
1738
+
1557
1739
  // ===== TIP DOTS =====
1558
1740
  const tipDots = group
1559
1741
  .append("g")
@@ -1581,6 +1763,51 @@ function drawPhylogeny(
1581
1763
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1582
1764
  }
1583
1765
 
1766
+ // ===== INTERNAL NODE CIRCLES (optional) =====
1767
+ if (internalNodeCircles) {
1768
+ const internalNodes = rad.data.filter((d) => !d.isTip);
1769
+ const internalDots = group
1770
+ .append("g")
1771
+ .attr("class", "phylo_internal_dots")
1772
+ .selectAll("circle")
1773
+ .data(internalNodes)
1774
+ .join("circle")
1775
+ .attr("cx", (d) => xScaleRadial(d.x))
1776
+ .attr("cy", (d) => yScaleRadial(d.y))
1777
+ .attr("r", internalNodeRadius)
1778
+ .attr("fill", "white")
1779
+ .attr("stroke", "#555")
1780
+ .attr("stroke-width", 1);
1781
+
1782
+ if (showTooltips) {
1783
+ internalDots
1784
+ .append("title")
1785
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1786
+ }
1787
+
1788
+ if (onNodeClick) {
1789
+ internalDots
1790
+ .style("cursor", "pointer")
1791
+ .on("click", (event, d) => onNodeClick(d, event));
1792
+ }
1793
+ }
1794
+
1795
+ // ===== INTERNAL NODE LABELS (optional) =====
1796
+ if (nodeLabels) {
1797
+ const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
1798
+ group
1799
+ .append("g")
1800
+ .attr("class", "phylo_node_labels")
1801
+ .selectAll("text")
1802
+ .data(labeledInternalNodes)
1803
+ .join("text")
1804
+ .attr("x", (d) => xScaleRadial(d.x) + 4)
1805
+ .attr("y", (d) => yScaleRadial(d.y) - 4)
1806
+ .attr("font-size", nodeLabelSize)
1807
+ .attr("fill", "black")
1808
+ .text((d) => d.thisLabel);
1809
+ }
1810
+
1584
1811
  // maps for fast lookup on hover (childId → spoke / arc)
1585
1812
  const key = (x) => (typeof x === "string" ? +x : x);
1586
1813
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1601,7 +1828,8 @@ function drawPhylogeny(
1601
1828
  // same tip position rule as dots/spokes:
1602
1829
  // - "outer": snap to common ring (tipMaxR)
1603
1830
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1604
- const r = isOuter ? tipMaxR : d.r;
1831
+ // - alignTipLabels forces the common ring regardless of mode
1832
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1605
1833
  const x = r * Math.cos(d.angle);
1606
1834
  const y = r * Math.sin(d.angle);
1607
1835
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1748,10 +1976,20 @@ function drawPhylogeny(
1748
1976
  });
1749
1977
  }
1750
1978
 
1979
+ if (scaleBar) {
1980
+ addScaleBar(svg, {
1981
+ scale: xScaleRadial,
1982
+ basis: maxRadius,
1983
+ defaultX: 20,
1984
+ defaultY: h - 20,
1985
+ scaleBar,
1986
+ fontSize: labelFontSize
1987
+ });
1988
+ }
1989
+
1751
1990
  return svg.node();
1752
1991
  } else if (layout === "unrooted") {
1753
1992
  // UNROOTED LAYOUT
1754
- const parsedTree = readTree(treeText);
1755
1993
  const unrootedPhylo = unrooted(parsedTree);
1756
1994
 
1757
1995
  const w = width;
@@ -1797,6 +2035,8 @@ function drawPhylogeny(
1797
2035
  .attr("stroke-width", strokeWidth)
1798
2036
  .attr("stroke", "#777");
1799
2037
 
2038
+ const R_TIP = tipRadius ?? 4;
2039
+
1800
2040
  const nodes = group
1801
2041
  .append("g")
1802
2042
  .attr("class", "phylo_points")
@@ -1804,13 +2044,35 @@ function drawPhylogeny(
1804
2044
  .data(unrootedPhylo.data)
1805
2045
  .join("circle")
1806
2046
  .attr("class", "dot")
1807
- .attr("r", (d) => (d.isTip ? 4 : 0))
2047
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1808
2048
  .attr("cx", (d) => xScaleUnroot(d.x))
1809
2049
  .attr("cy", (d) => yScaleUnroot(d.y))
1810
2050
  .attr("stroke", "black")
1811
2051
  .attr("stroke-width", 2)
1812
2052
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1813
2053
 
2054
+ if (internalNodeCircles && onNodeClick) {
2055
+ nodes
2056
+ .filter((d) => !d.isTip)
2057
+ .style("cursor", "pointer")
2058
+ .on("click", (event, d) => onNodeClick(d, event));
2059
+ }
2060
+
2061
+ if (nodeLabels) {
2062
+ const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
2063
+ group
2064
+ .append("g")
2065
+ .attr("class", "phylo_node_labels")
2066
+ .selectAll("text")
2067
+ .data(labeledInternalNodes)
2068
+ .join("text")
2069
+ .attr("x", (d) => xScaleUnroot(d.x) + 4)
2070
+ .attr("y", (d) => yScaleUnroot(d.y) - 4)
2071
+ .attr("font-size", nodeLabelSize)
2072
+ .attr("fill", "black")
2073
+ .text((d) => d.thisLabel);
2074
+ }
2075
+
1814
2076
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1815
2077
  const tipById = new Map(
1816
2078
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1900,11 +2162,11 @@ function drawPhylogeny(
1900
2162
  .filter((d) => d.isTip)
1901
2163
  .on("mouseenter", function(_event, d) {
1902
2164
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1903
- d3__namespace.select(this).attr("r", 6);
2165
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1904
2166
  })
1905
2167
  .on("mouseleave", function() {
1906
2168
  hoverLayer.selectAll("*").remove();
1907
- d3__namespace.select(this).attr("r", 4);
2169
+ d3__namespace.select(this).attr("r", R_TIP);
1908
2170
  });
1909
2171
 
1910
2172
  if (highlightTips && highlightTips.length) {
@@ -1947,6 +2209,17 @@ function drawPhylogeny(
1947
2209
  }
1948
2210
  }
1949
2211
 
2212
+ if (scaleBar) {
2213
+ addScaleBar(svg, {
2214
+ scale: xScaleUnroot,
2215
+ basis: maxRadius,
2216
+ defaultX: 20,
2217
+ defaultY: h - 20,
2218
+ scaleBar,
2219
+ fontSize: labelFontSize
2220
+ });
2221
+ }
2222
+
1950
2223
  return svg.node();
1951
2224
  } else {
1952
2225
  throw new Error(
@@ -1958,6 +2231,7 @@ function drawPhylogeny(
1958
2231
  exports.describeArc = describeArc;
1959
2232
  exports.describeArcSweep = describeArcSweep;
1960
2233
  exports.drawPhylogeny = drawPhylogeny;
2234
+ exports.ladderize = ladderize;
1961
2235
  exports.parentFisheye = parentFisheye;
1962
2236
  exports.phisheye = phisheye;
1963
2237
  exports.polarToCartesian = polarToCartesian;
@@ -1965,6 +2239,7 @@ exports.radialLayout = radialLayout;
1965
2239
  exports.randomTree = randomTree;
1966
2240
  exports.readTree = readTree;
1967
2241
  exports.rectangleLayout = rectangleLayout;
2242
+ exports.rotate = rotate;
1968
2243
  exports.subTree = subTree;
1969
2244
  exports.toNewick = toNewick;
1970
2245
  exports.unrooted = unrooted;