@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/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,6 +1204,46 @@ 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
+
1176
1247
  function drawPhylogeny(
1177
1248
  treeText,
1178
1249
  {
@@ -1185,6 +1256,13 @@ function drawPhylogeny(
1185
1256
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1186
1257
  tipLabels = true,
1187
1258
  labelFontSize = 10, // font size (px) for tip labels
1259
+ tipRadius, // px radius of tip dots; defaults to each layout's original size
1260
+ internalNodeCircles = false, // draw a circle at every internal (non-tip) node
1261
+ internalNodeRadius = 3, // px radius for internal node circles
1262
+ nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
1263
+ nodeLabelFontSize, // defaults to labelFontSize
1264
+ scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
1265
+ alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
1188
1266
  showTooltips = true,
1189
1267
  tooltipFormatter = (d, rtt) =>
1190
1268
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1198,6 +1276,7 @@ function drawPhylogeny(
1198
1276
 
1199
1277
  // shared helpers
1200
1278
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
1279
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1201
1280
  // Works for both radial (uses `r`) and rect (uses `x1`).
1202
1281
  // Falls back to summing branchLength up to the root if neither is present.
1203
1282
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1236,6 +1315,7 @@ function drawPhylogeny(
1236
1315
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1237
1316
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1238
1317
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1318
+ const R_TIP = tipRadius ?? 2;
1239
1319
 
1240
1320
  const maxY = d3__namespace.max(horizontal, (d) => d.y1);
1241
1321
  const minY = d3__namespace.min(horizontal, (d) => d.y1);
@@ -1293,7 +1373,7 @@ function drawPhylogeny(
1293
1373
  .join("circle")
1294
1374
  .attr("cx", (d) => xScale(d.x1))
1295
1375
  .attr("cy", (d) => yScale(d.y1))
1296
- .attr("r", 2)
1376
+ .attr("r", R_TIP)
1297
1377
  .attr("fill", "black");
1298
1378
 
1299
1379
  // tooltips for rect dots
@@ -1308,13 +1388,72 @@ function drawPhylogeny(
1308
1388
  .on("mouseenter", function(_event, d) {
1309
1389
  hoverLayer.selectAll("*").remove();
1310
1390
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1311
- d3__namespace.select(this).attr("r", 4);
1391
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1312
1392
  })
1313
1393
  .on("mouseleave", function() {
1314
1394
  hoverLayer.selectAll("*").remove();
1315
- d3__namespace.select(this).attr("r", 2);
1395
+ d3__namespace.select(this).attr("r", R_TIP);
1316
1396
  });
1317
1397
 
1398
+ // internal node circles (optional)
1399
+ if (internalNodeCircles) {
1400
+ const internalNodes = tree_df.data.filter((d) => !d.isTip);
1401
+ const internalDots = group
1402
+ .append("g")
1403
+ .attr("class", "phylo_internal_dots")
1404
+ .selectAll("circle")
1405
+ .data(internalNodes)
1406
+ .join("circle")
1407
+ .attr("cx", (d) => xScale(d.x1))
1408
+ .attr("cy", (d) => yScale(d.y1))
1409
+ .attr("r", internalNodeRadius)
1410
+ .attr("fill", "white")
1411
+ .attr("stroke", "#555")
1412
+ .attr("stroke-width", 1);
1413
+
1414
+ if (showTooltips) {
1415
+ internalDots
1416
+ .append("title")
1417
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1418
+ }
1419
+ }
1420
+
1421
+ // internal node labels (optional)
1422
+ if (nodeLabels) {
1423
+ const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
1424
+ svg
1425
+ .append("g")
1426
+ .attr("class", "phylo_node_labels")
1427
+ .selectAll("text")
1428
+ .data(labeledInternalNodes)
1429
+ .join("text")
1430
+ .attr("x", (d) => xScale(d.x1) - 4)
1431
+ .attr("y", (d) => yScale(d.y1) - 4)
1432
+ .attr("text-anchor", "end")
1433
+ .attr("font-size", nodeLabelSize)
1434
+ .text((d) => d.thisLabel);
1435
+ }
1436
+
1437
+ // column that tip labels align to when alignTipLabels is set
1438
+ const alignX = xScale(maxX);
1439
+
1440
+ // dashed guide lines from each tip's true branch end to the aligned label column
1441
+ if (tipLabels && alignTipLabels) {
1442
+ group
1443
+ .append("g")
1444
+ .attr("class", "phylo_align_guides")
1445
+ .selectAll("line")
1446
+ .data(tips)
1447
+ .join("line")
1448
+ .attr("x1", (d) => xScale(d.x1))
1449
+ .attr("x2", alignX)
1450
+ .attr("y1", (d) => yScale(d.y1))
1451
+ .attr("y2", (d) => yScale(d.y1))
1452
+ .attr("stroke", "#999")
1453
+ .attr("stroke-width", 1)
1454
+ .attr("stroke-dasharray", "2,2");
1455
+ }
1456
+
1318
1457
  // labels
1319
1458
  if (tipLabels) {
1320
1459
  const labels = svg
@@ -1323,7 +1462,7 @@ function drawPhylogeny(
1323
1462
  .selectAll("text")
1324
1463
  .data(tips)
1325
1464
  .join("text")
1326
- .attr("x", (d) => xScale(d.x1) + 4)
1465
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1327
1466
  .attr("y", (d) => yScale(d.y1))
1328
1467
  .attr("dy", "0.32em")
1329
1468
  .attr("font-size", labelFontSize)
@@ -1395,6 +1534,17 @@ function drawPhylogeny(
1395
1534
  }
1396
1535
  }
1397
1536
 
1537
+ if (scaleBar) {
1538
+ addScaleBar(svg, {
1539
+ scale: xScale,
1540
+ basis: maxX,
1541
+ defaultX: margin.left,
1542
+ defaultY: height - margin.bottom / 2,
1543
+ scaleBar,
1544
+ fontSize: labelFontSize
1545
+ });
1546
+ }
1547
+
1398
1548
  return svg.node();
1399
1549
  } else if (layout === "radial") {
1400
1550
  // RADIAL LAYOUT
@@ -1416,7 +1566,7 @@ function drawPhylogeny(
1416
1566
 
1417
1567
 
1418
1568
  // visuals (0 = let spokes reach the dots)
1419
- const DOT_R = 3;
1569
+ const DOT_R = tipRadius ?? 3;
1420
1570
  const END_CAP = 0;
1421
1571
 
1422
1572
  // ===== SCALES / BOUNDS =====
@@ -1554,6 +1704,25 @@ function drawPhylogeny(
1554
1704
  .attr("stroke-width", strokeWidth);
1555
1705
  });
1556
1706
 
1707
+ // ===== ALIGN GUIDES (optional) =====
1708
+ // dashed lines from each tip's true position to the common label ring,
1709
+ // for radialMode "phylo" (true terminals) where tips aren't already co-circular
1710
+ if (tipLabels && alignTipLabels && !isOuter) {
1711
+ group
1712
+ .append("g")
1713
+ .attr("class", "phylo_align_guides")
1714
+ .selectAll("line")
1715
+ .data(tips)
1716
+ .join("line")
1717
+ .attr("x1", (d) => xScaleRadial(d.x))
1718
+ .attr("y1", (d) => yScaleRadial(d.y))
1719
+ .attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
1720
+ .attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
1721
+ .attr("stroke", "#999")
1722
+ .attr("stroke-width", 1)
1723
+ .attr("stroke-dasharray", "2,2");
1724
+ }
1725
+
1557
1726
  // ===== TIP DOTS =====
1558
1727
  const tipDots = group
1559
1728
  .append("g")
@@ -1581,6 +1750,45 @@ function drawPhylogeny(
1581
1750
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1582
1751
  }
1583
1752
 
1753
+ // ===== INTERNAL NODE CIRCLES (optional) =====
1754
+ if (internalNodeCircles) {
1755
+ const internalNodes = rad.data.filter((d) => !d.isTip);
1756
+ const internalDots = group
1757
+ .append("g")
1758
+ .attr("class", "phylo_internal_dots")
1759
+ .selectAll("circle")
1760
+ .data(internalNodes)
1761
+ .join("circle")
1762
+ .attr("cx", (d) => xScaleRadial(d.x))
1763
+ .attr("cy", (d) => yScaleRadial(d.y))
1764
+ .attr("r", internalNodeRadius)
1765
+ .attr("fill", "white")
1766
+ .attr("stroke", "#555")
1767
+ .attr("stroke-width", 1);
1768
+
1769
+ if (showTooltips) {
1770
+ internalDots
1771
+ .append("title")
1772
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1773
+ }
1774
+ }
1775
+
1776
+ // ===== INTERNAL NODE LABELS (optional) =====
1777
+ if (nodeLabels) {
1778
+ const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
1779
+ group
1780
+ .append("g")
1781
+ .attr("class", "phylo_node_labels")
1782
+ .selectAll("text")
1783
+ .data(labeledInternalNodes)
1784
+ .join("text")
1785
+ .attr("x", (d) => xScaleRadial(d.x) + 4)
1786
+ .attr("y", (d) => yScaleRadial(d.y) - 4)
1787
+ .attr("font-size", nodeLabelSize)
1788
+ .attr("fill", "black")
1789
+ .text((d) => d.thisLabel);
1790
+ }
1791
+
1584
1792
  // maps for fast lookup on hover (childId → spoke / arc)
1585
1793
  const key = (x) => (typeof x === "string" ? +x : x);
1586
1794
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1601,7 +1809,8 @@ function drawPhylogeny(
1601
1809
  // same tip position rule as dots/spokes:
1602
1810
  // - "outer": snap to common ring (tipMaxR)
1603
1811
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1604
- const r = isOuter ? tipMaxR : d.r;
1812
+ // - alignTipLabels forces the common ring regardless of mode
1813
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1605
1814
  const x = r * Math.cos(d.angle);
1606
1815
  const y = r * Math.sin(d.angle);
1607
1816
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1748,6 +1957,17 @@ function drawPhylogeny(
1748
1957
  });
1749
1958
  }
1750
1959
 
1960
+ if (scaleBar) {
1961
+ addScaleBar(svg, {
1962
+ scale: xScaleRadial,
1963
+ basis: maxRadius,
1964
+ defaultX: 20,
1965
+ defaultY: h - 20,
1966
+ scaleBar,
1967
+ fontSize: labelFontSize
1968
+ });
1969
+ }
1970
+
1751
1971
  return svg.node();
1752
1972
  } else if (layout === "unrooted") {
1753
1973
  // UNROOTED LAYOUT
@@ -1797,6 +2017,8 @@ function drawPhylogeny(
1797
2017
  .attr("stroke-width", strokeWidth)
1798
2018
  .attr("stroke", "#777");
1799
2019
 
2020
+ const R_TIP = tipRadius ?? 4;
2021
+
1800
2022
  const nodes = group
1801
2023
  .append("g")
1802
2024
  .attr("class", "phylo_points")
@@ -1804,13 +2026,28 @@ function drawPhylogeny(
1804
2026
  .data(unrootedPhylo.data)
1805
2027
  .join("circle")
1806
2028
  .attr("class", "dot")
1807
- .attr("r", (d) => (d.isTip ? 4 : 0))
2029
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1808
2030
  .attr("cx", (d) => xScaleUnroot(d.x))
1809
2031
  .attr("cy", (d) => yScaleUnroot(d.y))
1810
2032
  .attr("stroke", "black")
1811
2033
  .attr("stroke-width", 2)
1812
2034
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1813
2035
 
2036
+ if (nodeLabels) {
2037
+ const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
2038
+ group
2039
+ .append("g")
2040
+ .attr("class", "phylo_node_labels")
2041
+ .selectAll("text")
2042
+ .data(labeledInternalNodes)
2043
+ .join("text")
2044
+ .attr("x", (d) => xScaleUnroot(d.x) + 4)
2045
+ .attr("y", (d) => yScaleUnroot(d.y) - 4)
2046
+ .attr("font-size", nodeLabelSize)
2047
+ .attr("fill", "black")
2048
+ .text((d) => d.thisLabel);
2049
+ }
2050
+
1814
2051
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1815
2052
  const tipById = new Map(
1816
2053
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1900,11 +2137,11 @@ function drawPhylogeny(
1900
2137
  .filter((d) => d.isTip)
1901
2138
  .on("mouseenter", function(_event, d) {
1902
2139
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1903
- d3__namespace.select(this).attr("r", 6);
2140
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1904
2141
  })
1905
2142
  .on("mouseleave", function() {
1906
2143
  hoverLayer.selectAll("*").remove();
1907
- d3__namespace.select(this).attr("r", 4);
2144
+ d3__namespace.select(this).attr("r", R_TIP);
1908
2145
  });
1909
2146
 
1910
2147
  if (highlightTips && highlightTips.length) {
@@ -1947,6 +2184,17 @@ function drawPhylogeny(
1947
2184
  }
1948
2185
  }
1949
2186
 
2187
+ if (scaleBar) {
2188
+ addScaleBar(svg, {
2189
+ scale: xScaleUnroot,
2190
+ basis: maxRadius,
2191
+ defaultX: 20,
2192
+ defaultY: h - 20,
2193
+ scaleBar,
2194
+ fontSize: labelFontSize
2195
+ });
2196
+ }
2197
+
1950
2198
  return svg.node();
1951
2199
  } else {
1952
2200
  throw new Error(
@@ -1958,6 +2206,7 @@ function drawPhylogeny(
1958
2206
  exports.describeArc = describeArc;
1959
2207
  exports.describeArcSweep = describeArcSweep;
1960
2208
  exports.drawPhylogeny = drawPhylogeny;
2209
+ exports.ladderize = ladderize;
1961
2210
  exports.parentFisheye = parentFisheye;
1962
2211
  exports.phisheye = phisheye;
1963
2212
  exports.polarToCartesian = polarToCartesian;
@@ -1965,6 +2214,7 @@ exports.radialLayout = radialLayout;
1965
2214
  exports.randomTree = randomTree;
1966
2215
  exports.readTree = readTree;
1967
2216
  exports.rectangleLayout = rectangleLayout;
2217
+ exports.rotate = rotate;
1968
2218
  exports.subTree = subTree;
1969
2219
  exports.toNewick = toNewick;
1970
2220
  exports.unrooted = unrooted;