@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.js CHANGED
@@ -929,6 +929,23 @@ function unrooted (node) {
929
929
  return data;
930
930
  }
931
931
 
932
+ /**
933
+ * Reorder every node's children by descendant tip count, so the tree
934
+ * "ladders" from smallest to largest clade at each branching point
935
+ * (or the reverse, with ascending: false). Mutates the tree in place.
936
+ */
937
+ function ladderize(tree, { ascending = true } = {}) {
938
+ const sign = ascending ? 1 : -1;
939
+
940
+ (function sort(node) {
941
+ if (node.children.length === 0) return;
942
+ node.children.forEach(sort);
943
+ node.children.sort((a, b) => sign * (numTips(a) - numTips(b)));
944
+ })(tree);
945
+
946
+ return tree;
947
+ }
948
+
932
949
  function makeIndexById(rows, key = "thisId") {
933
950
  return new Map(rows.map(d => [d[key], d]));
934
951
  }
@@ -938,6 +955,20 @@ function parentFisheye(d, data) {
938
955
  return parent ? { px: parent.fisheye.x, py: parent.fisheye.y } : null;
939
956
  }
940
957
 
958
+ /**
959
+ * Reverse the child order at one node, flipping which side of the tree
960
+ * that clade's descendants are drawn on. Defaults to the root. Mutates
961
+ * the tree in place.
962
+ */
963
+ function rotate(tree, nodeId = tree.id) {
964
+ const target = preorder(tree).find((n) => n.id === nodeId);
965
+ if (!target) {
966
+ throw new Error(`No node with id ${nodeId} found in tree`);
967
+ }
968
+ target.children.reverse();
969
+ return tree;
970
+ }
971
+
941
972
  /**
942
973
  * Generate a random bifurcating tree with `nTips` tips, in the same
943
974
  * parent/children node shape produced by readTree().
@@ -1152,8 +1183,52 @@ function serialize(node) {
1152
1183
  return `(${children})${label}${branch}`;
1153
1184
  }
1154
1185
 
1186
+ // Round a raw data-space length to a "nice" 1/2/5-of-a-power-of-ten value,
1187
+ // so an auto-sized scale bar doesn't show an ugly number like "0.347".
1188
+ function niceScaleLength(target) {
1189
+ if (!(target > 0)) return 1;
1190
+ const exp = Math.floor(Math.log10(target));
1191
+ const base = Math.pow(10, exp);
1192
+ const residual = target / base;
1193
+ const niceResidual = residual < 1.5 ? 1 : residual < 3.5 ? 2 : residual < 7.5 ? 5 : 10;
1194
+ return niceResidual * base;
1195
+ }
1196
+
1197
+ // scaleBar: true | number (explicit data-units length) | { length, x, y, label }
1198
+ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize }) {
1199
+ const opts = (scaleBar === true || typeof scaleBar === "number") ? {} : scaleBar;
1200
+ const length = typeof scaleBar === "number" ? scaleBar : (opts.length ?? niceScaleLength(basis / 5));
1201
+ const x = opts.x ?? defaultX;
1202
+ const y = opts.y ?? defaultY;
1203
+ const barPx = scale(length) - scale(0);
1204
+
1205
+ const g = svg.append("g").attr("class", "phylo_scale_bar");
1206
+ g.append("line")
1207
+ .attr("x1", x).attr("x2", x + barPx)
1208
+ .attr("y1", y).attr("y2", y)
1209
+ .attr("stroke", "#000")
1210
+ .attr("stroke-width", 1);
1211
+ [x, x + barPx].forEach((tx) => {
1212
+ g.append("line")
1213
+ .attr("x1", tx).attr("x2", tx)
1214
+ .attr("y1", y - 4).attr("y2", y + 4)
1215
+ .attr("stroke", "#000")
1216
+ .attr("stroke-width", 1);
1217
+ });
1218
+ g.append("text")
1219
+ .attr("x", x + barPx / 2)
1220
+ .attr("y", y - 6)
1221
+ .attr("text-anchor", "middle")
1222
+ .attr("font-size", fontSize)
1223
+ .text(opts.label ?? String(length));
1224
+ }
1225
+
1226
+ // input: a Newick string, or an already-parsed tree (the node shape returned
1227
+ // by readTree()/randomTree()). Passing the same parsed tree object back in
1228
+ // across re-renders (e.g. after mutating it with rotate()) keeps node ids
1229
+ // stable, which onNodeClick below relies on.
1155
1230
  function drawPhylogeny(
1156
- treeText,
1231
+ input,
1157
1232
  {
1158
1233
  layout = "rect", // rect/radial/unrooted
1159
1234
  width = 800,
@@ -1164,6 +1239,14 @@ function drawPhylogeny(
1164
1239
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1165
1240
  tipLabels = true,
1166
1241
  labelFontSize = 10, // font size (px) for tip labels
1242
+ tipRadius, // px radius of tip dots; defaults to each layout's original size
1243
+ internalNodeCircles = false, // draw a circle at every internal (non-tip) node
1244
+ internalNodeRadius = 3, // px radius for internal node circles
1245
+ nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
1246
+ nodeLabelFontSize, // defaults to labelFontSize
1247
+ scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
1248
+ alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
1249
+ onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
1167
1250
  showTooltips = true,
1168
1251
  tooltipFormatter = (d, rtt) =>
1169
1252
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1177,6 +1260,10 @@ function drawPhylogeny(
1177
1260
 
1178
1261
  // shared helpers
1179
1262
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
1263
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1264
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
1265
+ ? input
1266
+ : readTree(input);
1180
1267
  // Works for both radial (uses `r`) and rect (uses `x1`).
1181
1268
  // Falls back to summing branchLength up to the root if neither is present.
1182
1269
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1205,7 +1292,7 @@ function drawPhylogeny(
1205
1292
 
1206
1293
  if (layout === "rect") {
1207
1294
  // RECTANGULAR LAYOUT
1208
- const tree_df = rectangleLayout(readTree(treeText));
1295
+ const tree_df = rectangleLayout(parsedTree);
1209
1296
  const horizontal = tree_df.horizontal_lines;
1210
1297
  const vertical = tree_df.vertical_lines;
1211
1298
  const tips = horizontal.filter((d) => d.isTip);
@@ -1215,6 +1302,7 @@ function drawPhylogeny(
1215
1302
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1216
1303
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1217
1304
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1305
+ const R_TIP = tipRadius ?? 2;
1218
1306
 
1219
1307
  const maxY = d3.max(horizontal, (d) => d.y1);
1220
1308
  const minY = d3.min(horizontal, (d) => d.y1);
@@ -1272,7 +1360,7 @@ function drawPhylogeny(
1272
1360
  .join("circle")
1273
1361
  .attr("cx", (d) => xScale(d.x1))
1274
1362
  .attr("cy", (d) => yScale(d.y1))
1275
- .attr("r", 2)
1363
+ .attr("r", R_TIP)
1276
1364
  .attr("fill", "black");
1277
1365
 
1278
1366
  // tooltips for rect dots
@@ -1287,13 +1375,78 @@ function drawPhylogeny(
1287
1375
  .on("mouseenter", function(_event, d) {
1288
1376
  hoverLayer.selectAll("*").remove();
1289
1377
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1290
- d3.select(this).attr("r", 4);
1378
+ d3.select(this).attr("r", R_TIP + 2);
1291
1379
  })
1292
1380
  .on("mouseleave", function() {
1293
1381
  hoverLayer.selectAll("*").remove();
1294
- d3.select(this).attr("r", 2);
1382
+ d3.select(this).attr("r", R_TIP);
1295
1383
  });
1296
1384
 
1385
+ // internal node circles (optional)
1386
+ if (internalNodeCircles) {
1387
+ const internalNodes = tree_df.data.filter((d) => !d.isTip);
1388
+ const internalDots = group
1389
+ .append("g")
1390
+ .attr("class", "phylo_internal_dots")
1391
+ .selectAll("circle")
1392
+ .data(internalNodes)
1393
+ .join("circle")
1394
+ .attr("cx", (d) => xScale(d.x1))
1395
+ .attr("cy", (d) => yScale(d.y1))
1396
+ .attr("r", internalNodeRadius)
1397
+ .attr("fill", "white")
1398
+ .attr("stroke", "#555")
1399
+ .attr("stroke-width", 1);
1400
+
1401
+ if (showTooltips) {
1402
+ internalDots
1403
+ .append("title")
1404
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1405
+ }
1406
+
1407
+ if (onNodeClick) {
1408
+ internalDots
1409
+ .style("cursor", "pointer")
1410
+ .on("click", (event, d) => onNodeClick(d, event));
1411
+ }
1412
+ }
1413
+
1414
+ // internal node labels (optional)
1415
+ if (nodeLabels) {
1416
+ const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
1417
+ svg
1418
+ .append("g")
1419
+ .attr("class", "phylo_node_labels")
1420
+ .selectAll("text")
1421
+ .data(labeledInternalNodes)
1422
+ .join("text")
1423
+ .attr("x", (d) => xScale(d.x1) - 4)
1424
+ .attr("y", (d) => yScale(d.y1) - 4)
1425
+ .attr("text-anchor", "end")
1426
+ .attr("font-size", nodeLabelSize)
1427
+ .text((d) => d.thisLabel);
1428
+ }
1429
+
1430
+ // column that tip labels align to when alignTipLabels is set
1431
+ const alignX = xScale(maxX);
1432
+
1433
+ // dashed guide lines from each tip's true branch end to the aligned label column
1434
+ if (tipLabels && alignTipLabels) {
1435
+ group
1436
+ .append("g")
1437
+ .attr("class", "phylo_align_guides")
1438
+ .selectAll("line")
1439
+ .data(tips)
1440
+ .join("line")
1441
+ .attr("x1", (d) => xScale(d.x1))
1442
+ .attr("x2", alignX)
1443
+ .attr("y1", (d) => yScale(d.y1))
1444
+ .attr("y2", (d) => yScale(d.y1))
1445
+ .attr("stroke", "#999")
1446
+ .attr("stroke-width", 1)
1447
+ .attr("stroke-dasharray", "2,2");
1448
+ }
1449
+
1297
1450
  // labels
1298
1451
  if (tipLabels) {
1299
1452
  const labels = svg
@@ -1302,7 +1455,7 @@ function drawPhylogeny(
1302
1455
  .selectAll("text")
1303
1456
  .data(tips)
1304
1457
  .join("text")
1305
- .attr("x", (d) => xScale(d.x1) + 4)
1458
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1306
1459
  .attr("y", (d) => yScale(d.y1))
1307
1460
  .attr("dy", "0.32em")
1308
1461
  .attr("font-size", labelFontSize)
@@ -1374,13 +1527,23 @@ function drawPhylogeny(
1374
1527
  }
1375
1528
  }
1376
1529
 
1530
+ if (scaleBar) {
1531
+ addScaleBar(svg, {
1532
+ scale: xScale,
1533
+ basis: maxX,
1534
+ defaultX: margin.left,
1535
+ defaultY: height - margin.bottom / 2,
1536
+ scaleBar,
1537
+ fontSize: labelFontSize
1538
+ });
1539
+ }
1540
+
1377
1541
  return svg.node();
1378
1542
  } else if (layout === "radial") {
1379
1543
  // RADIAL LAYOUT
1380
1544
  if (width !== height) {
1381
1545
  throw new Error("width and height must be the same for radial layout");
1382
1546
  }
1383
- const parsedTree = readTree(treeText);
1384
1547
  const rad = radialLayout(parsedTree, {
1385
1548
  angleStrategy: "fan",
1386
1549
  arcsStyle: "fan"
@@ -1395,7 +1558,7 @@ function drawPhylogeny(
1395
1558
 
1396
1559
 
1397
1560
  // visuals (0 = let spokes reach the dots)
1398
- const DOT_R = 3;
1561
+ const DOT_R = tipRadius ?? 3;
1399
1562
  const END_CAP = 0;
1400
1563
 
1401
1564
  // ===== SCALES / BOUNDS =====
@@ -1533,6 +1696,25 @@ function drawPhylogeny(
1533
1696
  .attr("stroke-width", strokeWidth);
1534
1697
  });
1535
1698
 
1699
+ // ===== ALIGN GUIDES (optional) =====
1700
+ // dashed lines from each tip's true position to the common label ring,
1701
+ // for radialMode "phylo" (true terminals) where tips aren't already co-circular
1702
+ if (tipLabels && alignTipLabels && !isOuter) {
1703
+ group
1704
+ .append("g")
1705
+ .attr("class", "phylo_align_guides")
1706
+ .selectAll("line")
1707
+ .data(tips)
1708
+ .join("line")
1709
+ .attr("x1", (d) => xScaleRadial(d.x))
1710
+ .attr("y1", (d) => yScaleRadial(d.y))
1711
+ .attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
1712
+ .attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
1713
+ .attr("stroke", "#999")
1714
+ .attr("stroke-width", 1)
1715
+ .attr("stroke-dasharray", "2,2");
1716
+ }
1717
+
1536
1718
  // ===== TIP DOTS =====
1537
1719
  const tipDots = group
1538
1720
  .append("g")
@@ -1560,6 +1742,51 @@ function drawPhylogeny(
1560
1742
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1561
1743
  }
1562
1744
 
1745
+ // ===== INTERNAL NODE CIRCLES (optional) =====
1746
+ if (internalNodeCircles) {
1747
+ const internalNodes = rad.data.filter((d) => !d.isTip);
1748
+ const internalDots = group
1749
+ .append("g")
1750
+ .attr("class", "phylo_internal_dots")
1751
+ .selectAll("circle")
1752
+ .data(internalNodes)
1753
+ .join("circle")
1754
+ .attr("cx", (d) => xScaleRadial(d.x))
1755
+ .attr("cy", (d) => yScaleRadial(d.y))
1756
+ .attr("r", internalNodeRadius)
1757
+ .attr("fill", "white")
1758
+ .attr("stroke", "#555")
1759
+ .attr("stroke-width", 1);
1760
+
1761
+ if (showTooltips) {
1762
+ internalDots
1763
+ .append("title")
1764
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1765
+ }
1766
+
1767
+ if (onNodeClick) {
1768
+ internalDots
1769
+ .style("cursor", "pointer")
1770
+ .on("click", (event, d) => onNodeClick(d, event));
1771
+ }
1772
+ }
1773
+
1774
+ // ===== INTERNAL NODE LABELS (optional) =====
1775
+ if (nodeLabels) {
1776
+ const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
1777
+ group
1778
+ .append("g")
1779
+ .attr("class", "phylo_node_labels")
1780
+ .selectAll("text")
1781
+ .data(labeledInternalNodes)
1782
+ .join("text")
1783
+ .attr("x", (d) => xScaleRadial(d.x) + 4)
1784
+ .attr("y", (d) => yScaleRadial(d.y) - 4)
1785
+ .attr("font-size", nodeLabelSize)
1786
+ .attr("fill", "black")
1787
+ .text((d) => d.thisLabel);
1788
+ }
1789
+
1563
1790
  // maps for fast lookup on hover (childId → spoke / arc)
1564
1791
  const key = (x) => (typeof x === "string" ? +x : x);
1565
1792
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1580,7 +1807,8 @@ function drawPhylogeny(
1580
1807
  // same tip position rule as dots/spokes:
1581
1808
  // - "outer": snap to common ring (tipMaxR)
1582
1809
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1583
- const r = isOuter ? tipMaxR : d.r;
1810
+ // - alignTipLabels forces the common ring regardless of mode
1811
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1584
1812
  const x = r * Math.cos(d.angle);
1585
1813
  const y = r * Math.sin(d.angle);
1586
1814
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1727,10 +1955,20 @@ function drawPhylogeny(
1727
1955
  });
1728
1956
  }
1729
1957
 
1958
+ if (scaleBar) {
1959
+ addScaleBar(svg, {
1960
+ scale: xScaleRadial,
1961
+ basis: maxRadius,
1962
+ defaultX: 20,
1963
+ defaultY: h - 20,
1964
+ scaleBar,
1965
+ fontSize: labelFontSize
1966
+ });
1967
+ }
1968
+
1730
1969
  return svg.node();
1731
1970
  } else if (layout === "unrooted") {
1732
1971
  // UNROOTED LAYOUT
1733
- const parsedTree = readTree(treeText);
1734
1972
  const unrootedPhylo = unrooted(parsedTree);
1735
1973
 
1736
1974
  const w = width;
@@ -1776,6 +2014,8 @@ function drawPhylogeny(
1776
2014
  .attr("stroke-width", strokeWidth)
1777
2015
  .attr("stroke", "#777");
1778
2016
 
2017
+ const R_TIP = tipRadius ?? 4;
2018
+
1779
2019
  const nodes = group
1780
2020
  .append("g")
1781
2021
  .attr("class", "phylo_points")
@@ -1783,13 +2023,35 @@ function drawPhylogeny(
1783
2023
  .data(unrootedPhylo.data)
1784
2024
  .join("circle")
1785
2025
  .attr("class", "dot")
1786
- .attr("r", (d) => (d.isTip ? 4 : 0))
2026
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1787
2027
  .attr("cx", (d) => xScaleUnroot(d.x))
1788
2028
  .attr("cy", (d) => yScaleUnroot(d.y))
1789
2029
  .attr("stroke", "black")
1790
2030
  .attr("stroke-width", 2)
1791
2031
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1792
2032
 
2033
+ if (internalNodeCircles && onNodeClick) {
2034
+ nodes
2035
+ .filter((d) => !d.isTip)
2036
+ .style("cursor", "pointer")
2037
+ .on("click", (event, d) => onNodeClick(d, event));
2038
+ }
2039
+
2040
+ if (nodeLabels) {
2041
+ const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
2042
+ group
2043
+ .append("g")
2044
+ .attr("class", "phylo_node_labels")
2045
+ .selectAll("text")
2046
+ .data(labeledInternalNodes)
2047
+ .join("text")
2048
+ .attr("x", (d) => xScaleUnroot(d.x) + 4)
2049
+ .attr("y", (d) => yScaleUnroot(d.y) - 4)
2050
+ .attr("font-size", nodeLabelSize)
2051
+ .attr("fill", "black")
2052
+ .text((d) => d.thisLabel);
2053
+ }
2054
+
1793
2055
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1794
2056
  const tipById = new Map(
1795
2057
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1879,11 +2141,11 @@ function drawPhylogeny(
1879
2141
  .filter((d) => d.isTip)
1880
2142
  .on("mouseenter", function(_event, d) {
1881
2143
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1882
- d3.select(this).attr("r", 6);
2144
+ d3.select(this).attr("r", R_TIP + 2);
1883
2145
  })
1884
2146
  .on("mouseleave", function() {
1885
2147
  hoverLayer.selectAll("*").remove();
1886
- d3.select(this).attr("r", 4);
2148
+ d3.select(this).attr("r", R_TIP);
1887
2149
  });
1888
2150
 
1889
2151
  if (highlightTips && highlightTips.length) {
@@ -1926,6 +2188,17 @@ function drawPhylogeny(
1926
2188
  }
1927
2189
  }
1928
2190
 
2191
+ if (scaleBar) {
2192
+ addScaleBar(svg, {
2193
+ scale: xScaleUnroot,
2194
+ basis: maxRadius,
2195
+ defaultX: 20,
2196
+ defaultY: h - 20,
2197
+ scaleBar,
2198
+ fontSize: labelFontSize
2199
+ });
2200
+ }
2201
+
1929
2202
  return svg.node();
1930
2203
  } else {
1931
2204
  throw new Error(
@@ -1934,5 +2207,5 @@ function drawPhylogeny(
1934
2207
  }
1935
2208
  }
1936
2209
 
1937
- export { describeArc, describeArcSweep, drawPhylogeny, parentFisheye, phisheye, polarToCartesian, radialLayout, randomTree, readTree, rectangleLayout, subTree, toNewick, unrooted };
2210
+ export { describeArc, describeArcSweep, drawPhylogeny, ladderize, parentFisheye, phisheye, polarToCartesian, radialLayout, randomTree, readTree, rectangleLayout, rotate, subTree, toNewick, unrooted };
1938
2211
  //# sourceMappingURL=index.js.map