@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.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,6 +1183,46 @@ 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
+
1155
1226
  function drawPhylogeny(
1156
1227
  treeText,
1157
1228
  {
@@ -1164,6 +1235,13 @@ function drawPhylogeny(
1164
1235
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1165
1236
  tipLabels = true,
1166
1237
  labelFontSize = 10, // font size (px) for tip labels
1238
+ tipRadius, // px radius of tip dots; defaults to each layout's original size
1239
+ internalNodeCircles = false, // draw a circle at every internal (non-tip) node
1240
+ internalNodeRadius = 3, // px radius for internal node circles
1241
+ nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
1242
+ nodeLabelFontSize, // defaults to labelFontSize
1243
+ scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
1244
+ alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
1167
1245
  showTooltips = true,
1168
1246
  tooltipFormatter = (d, rtt) =>
1169
1247
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1177,6 +1255,7 @@ function drawPhylogeny(
1177
1255
 
1178
1256
  // shared helpers
1179
1257
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
1258
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1180
1259
  // Works for both radial (uses `r`) and rect (uses `x1`).
1181
1260
  // Falls back to summing branchLength up to the root if neither is present.
1182
1261
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1215,6 +1294,7 @@ function drawPhylogeny(
1215
1294
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1216
1295
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1217
1296
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1297
+ const R_TIP = tipRadius ?? 2;
1218
1298
 
1219
1299
  const maxY = d3.max(horizontal, (d) => d.y1);
1220
1300
  const minY = d3.min(horizontal, (d) => d.y1);
@@ -1272,7 +1352,7 @@ function drawPhylogeny(
1272
1352
  .join("circle")
1273
1353
  .attr("cx", (d) => xScale(d.x1))
1274
1354
  .attr("cy", (d) => yScale(d.y1))
1275
- .attr("r", 2)
1355
+ .attr("r", R_TIP)
1276
1356
  .attr("fill", "black");
1277
1357
 
1278
1358
  // tooltips for rect dots
@@ -1287,13 +1367,72 @@ function drawPhylogeny(
1287
1367
  .on("mouseenter", function(_event, d) {
1288
1368
  hoverLayer.selectAll("*").remove();
1289
1369
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1290
- d3.select(this).attr("r", 4);
1370
+ d3.select(this).attr("r", R_TIP + 2);
1291
1371
  })
1292
1372
  .on("mouseleave", function() {
1293
1373
  hoverLayer.selectAll("*").remove();
1294
- d3.select(this).attr("r", 2);
1374
+ d3.select(this).attr("r", R_TIP);
1295
1375
  });
1296
1376
 
1377
+ // internal node circles (optional)
1378
+ if (internalNodeCircles) {
1379
+ const internalNodes = tree_df.data.filter((d) => !d.isTip);
1380
+ const internalDots = group
1381
+ .append("g")
1382
+ .attr("class", "phylo_internal_dots")
1383
+ .selectAll("circle")
1384
+ .data(internalNodes)
1385
+ .join("circle")
1386
+ .attr("cx", (d) => xScale(d.x1))
1387
+ .attr("cy", (d) => yScale(d.y1))
1388
+ .attr("r", internalNodeRadius)
1389
+ .attr("fill", "white")
1390
+ .attr("stroke", "#555")
1391
+ .attr("stroke-width", 1);
1392
+
1393
+ if (showTooltips) {
1394
+ internalDots
1395
+ .append("title")
1396
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1397
+ }
1398
+ }
1399
+
1400
+ // internal node labels (optional)
1401
+ if (nodeLabels) {
1402
+ const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
1403
+ svg
1404
+ .append("g")
1405
+ .attr("class", "phylo_node_labels")
1406
+ .selectAll("text")
1407
+ .data(labeledInternalNodes)
1408
+ .join("text")
1409
+ .attr("x", (d) => xScale(d.x1) - 4)
1410
+ .attr("y", (d) => yScale(d.y1) - 4)
1411
+ .attr("text-anchor", "end")
1412
+ .attr("font-size", nodeLabelSize)
1413
+ .text((d) => d.thisLabel);
1414
+ }
1415
+
1416
+ // column that tip labels align to when alignTipLabels is set
1417
+ const alignX = xScale(maxX);
1418
+
1419
+ // dashed guide lines from each tip's true branch end to the aligned label column
1420
+ if (tipLabels && alignTipLabels) {
1421
+ group
1422
+ .append("g")
1423
+ .attr("class", "phylo_align_guides")
1424
+ .selectAll("line")
1425
+ .data(tips)
1426
+ .join("line")
1427
+ .attr("x1", (d) => xScale(d.x1))
1428
+ .attr("x2", alignX)
1429
+ .attr("y1", (d) => yScale(d.y1))
1430
+ .attr("y2", (d) => yScale(d.y1))
1431
+ .attr("stroke", "#999")
1432
+ .attr("stroke-width", 1)
1433
+ .attr("stroke-dasharray", "2,2");
1434
+ }
1435
+
1297
1436
  // labels
1298
1437
  if (tipLabels) {
1299
1438
  const labels = svg
@@ -1302,7 +1441,7 @@ function drawPhylogeny(
1302
1441
  .selectAll("text")
1303
1442
  .data(tips)
1304
1443
  .join("text")
1305
- .attr("x", (d) => xScale(d.x1) + 4)
1444
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1306
1445
  .attr("y", (d) => yScale(d.y1))
1307
1446
  .attr("dy", "0.32em")
1308
1447
  .attr("font-size", labelFontSize)
@@ -1374,6 +1513,17 @@ function drawPhylogeny(
1374
1513
  }
1375
1514
  }
1376
1515
 
1516
+ if (scaleBar) {
1517
+ addScaleBar(svg, {
1518
+ scale: xScale,
1519
+ basis: maxX,
1520
+ defaultX: margin.left,
1521
+ defaultY: height - margin.bottom / 2,
1522
+ scaleBar,
1523
+ fontSize: labelFontSize
1524
+ });
1525
+ }
1526
+
1377
1527
  return svg.node();
1378
1528
  } else if (layout === "radial") {
1379
1529
  // RADIAL LAYOUT
@@ -1395,7 +1545,7 @@ function drawPhylogeny(
1395
1545
 
1396
1546
 
1397
1547
  // visuals (0 = let spokes reach the dots)
1398
- const DOT_R = 3;
1548
+ const DOT_R = tipRadius ?? 3;
1399
1549
  const END_CAP = 0;
1400
1550
 
1401
1551
  // ===== SCALES / BOUNDS =====
@@ -1533,6 +1683,25 @@ function drawPhylogeny(
1533
1683
  .attr("stroke-width", strokeWidth);
1534
1684
  });
1535
1685
 
1686
+ // ===== ALIGN GUIDES (optional) =====
1687
+ // dashed lines from each tip's true position to the common label ring,
1688
+ // for radialMode "phylo" (true terminals) where tips aren't already co-circular
1689
+ if (tipLabels && alignTipLabels && !isOuter) {
1690
+ group
1691
+ .append("g")
1692
+ .attr("class", "phylo_align_guides")
1693
+ .selectAll("line")
1694
+ .data(tips)
1695
+ .join("line")
1696
+ .attr("x1", (d) => xScaleRadial(d.x))
1697
+ .attr("y1", (d) => yScaleRadial(d.y))
1698
+ .attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
1699
+ .attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
1700
+ .attr("stroke", "#999")
1701
+ .attr("stroke-width", 1)
1702
+ .attr("stroke-dasharray", "2,2");
1703
+ }
1704
+
1536
1705
  // ===== TIP DOTS =====
1537
1706
  const tipDots = group
1538
1707
  .append("g")
@@ -1560,6 +1729,45 @@ function drawPhylogeny(
1560
1729
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1561
1730
  }
1562
1731
 
1732
+ // ===== INTERNAL NODE CIRCLES (optional) =====
1733
+ if (internalNodeCircles) {
1734
+ const internalNodes = rad.data.filter((d) => !d.isTip);
1735
+ const internalDots = group
1736
+ .append("g")
1737
+ .attr("class", "phylo_internal_dots")
1738
+ .selectAll("circle")
1739
+ .data(internalNodes)
1740
+ .join("circle")
1741
+ .attr("cx", (d) => xScaleRadial(d.x))
1742
+ .attr("cy", (d) => yScaleRadial(d.y))
1743
+ .attr("r", internalNodeRadius)
1744
+ .attr("fill", "white")
1745
+ .attr("stroke", "#555")
1746
+ .attr("stroke-width", 1);
1747
+
1748
+ if (showTooltips) {
1749
+ internalDots
1750
+ .append("title")
1751
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1752
+ }
1753
+ }
1754
+
1755
+ // ===== INTERNAL NODE LABELS (optional) =====
1756
+ if (nodeLabels) {
1757
+ const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
1758
+ group
1759
+ .append("g")
1760
+ .attr("class", "phylo_node_labels")
1761
+ .selectAll("text")
1762
+ .data(labeledInternalNodes)
1763
+ .join("text")
1764
+ .attr("x", (d) => xScaleRadial(d.x) + 4)
1765
+ .attr("y", (d) => yScaleRadial(d.y) - 4)
1766
+ .attr("font-size", nodeLabelSize)
1767
+ .attr("fill", "black")
1768
+ .text((d) => d.thisLabel);
1769
+ }
1770
+
1563
1771
  // maps for fast lookup on hover (childId → spoke / arc)
1564
1772
  const key = (x) => (typeof x === "string" ? +x : x);
1565
1773
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1580,7 +1788,8 @@ function drawPhylogeny(
1580
1788
  // same tip position rule as dots/spokes:
1581
1789
  // - "outer": snap to common ring (tipMaxR)
1582
1790
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1583
- const r = isOuter ? tipMaxR : d.r;
1791
+ // - alignTipLabels forces the common ring regardless of mode
1792
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1584
1793
  const x = r * Math.cos(d.angle);
1585
1794
  const y = r * Math.sin(d.angle);
1586
1795
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1727,6 +1936,17 @@ function drawPhylogeny(
1727
1936
  });
1728
1937
  }
1729
1938
 
1939
+ if (scaleBar) {
1940
+ addScaleBar(svg, {
1941
+ scale: xScaleRadial,
1942
+ basis: maxRadius,
1943
+ defaultX: 20,
1944
+ defaultY: h - 20,
1945
+ scaleBar,
1946
+ fontSize: labelFontSize
1947
+ });
1948
+ }
1949
+
1730
1950
  return svg.node();
1731
1951
  } else if (layout === "unrooted") {
1732
1952
  // UNROOTED LAYOUT
@@ -1776,6 +1996,8 @@ function drawPhylogeny(
1776
1996
  .attr("stroke-width", strokeWidth)
1777
1997
  .attr("stroke", "#777");
1778
1998
 
1999
+ const R_TIP = tipRadius ?? 4;
2000
+
1779
2001
  const nodes = group
1780
2002
  .append("g")
1781
2003
  .attr("class", "phylo_points")
@@ -1783,13 +2005,28 @@ function drawPhylogeny(
1783
2005
  .data(unrootedPhylo.data)
1784
2006
  .join("circle")
1785
2007
  .attr("class", "dot")
1786
- .attr("r", (d) => (d.isTip ? 4 : 0))
2008
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1787
2009
  .attr("cx", (d) => xScaleUnroot(d.x))
1788
2010
  .attr("cy", (d) => yScaleUnroot(d.y))
1789
2011
  .attr("stroke", "black")
1790
2012
  .attr("stroke-width", 2)
1791
2013
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1792
2014
 
2015
+ if (nodeLabels) {
2016
+ const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
2017
+ group
2018
+ .append("g")
2019
+ .attr("class", "phylo_node_labels")
2020
+ .selectAll("text")
2021
+ .data(labeledInternalNodes)
2022
+ .join("text")
2023
+ .attr("x", (d) => xScaleUnroot(d.x) + 4)
2024
+ .attr("y", (d) => yScaleUnroot(d.y) - 4)
2025
+ .attr("font-size", nodeLabelSize)
2026
+ .attr("fill", "black")
2027
+ .text((d) => d.thisLabel);
2028
+ }
2029
+
1793
2030
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1794
2031
  const tipById = new Map(
1795
2032
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1879,11 +2116,11 @@ function drawPhylogeny(
1879
2116
  .filter((d) => d.isTip)
1880
2117
  .on("mouseenter", function(_event, d) {
1881
2118
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1882
- d3.select(this).attr("r", 6);
2119
+ d3.select(this).attr("r", R_TIP + 2);
1883
2120
  })
1884
2121
  .on("mouseleave", function() {
1885
2122
  hoverLayer.selectAll("*").remove();
1886
- d3.select(this).attr("r", 4);
2123
+ d3.select(this).attr("r", R_TIP);
1887
2124
  });
1888
2125
 
1889
2126
  if (highlightTips && highlightTips.length) {
@@ -1926,6 +2163,17 @@ function drawPhylogeny(
1926
2163
  }
1927
2164
  }
1928
2165
 
2166
+ if (scaleBar) {
2167
+ addScaleBar(svg, {
2168
+ scale: xScaleUnroot,
2169
+ basis: maxRadius,
2170
+ defaultX: 20,
2171
+ defaultY: h - 20,
2172
+ scaleBar,
2173
+ fontSize: labelFontSize
2174
+ });
2175
+ }
2176
+
1929
2177
  return svg.node();
1930
2178
  } else {
1931
2179
  throw new Error(
@@ -1934,5 +2182,5 @@ function drawPhylogeny(
1934
2182
  }
1935
2183
  }
1936
2184
 
1937
- export { describeArc, describeArcSweep, drawPhylogeny, parentFisheye, phisheye, polarToCartesian, radialLayout, randomTree, readTree, rectangleLayout, subTree, toNewick, unrooted };
2185
+ export { describeArc, describeArcSweep, drawPhylogeny, ladderize, parentFisheye, phisheye, polarToCartesian, radialLayout, randomTree, readTree, rectangleLayout, rotate, subTree, toNewick, unrooted };
1938
2186
  //# sourceMappingURL=index.js.map