@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.
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().
@@ -1152,6 +1183,67 @@ function subTree (tree, node) {
1152
1183
  return Object.fromEntries(data.concat(filtered));
1153
1184
  }
1154
1185
 
1186
+ /**
1187
+ * Serialize a parsed tree (the parent/children node shape produced by
1188
+ * readTree() and randomTree()) back into a Newick string.
1189
+ */
1190
+
1191
+ function toNewick(node) {
1192
+ return `${serialize(node)};`;
1193
+ }
1194
+
1195
+ function serialize(node) {
1196
+ const label = node.label || '';
1197
+ const branch = node.branchLength == null ? '' : `:${node.branchLength}`;
1198
+
1199
+ if (node.children.length === 0) {
1200
+ return `${label}${branch}`;
1201
+ }
1202
+
1203
+ const children = node.children.map(serialize).join(',');
1204
+ return `(${children})${label}${branch}`;
1205
+ }
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
+
1155
1247
  function drawPhylogeny(
1156
1248
  treeText,
1157
1249
  {
@@ -1164,6 +1256,13 @@ function drawPhylogeny(
1164
1256
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1165
1257
  tipLabels = true,
1166
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
1167
1266
  showTooltips = true,
1168
1267
  tooltipFormatter = (d, rtt) =>
1169
1268
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1177,6 +1276,7 @@ function drawPhylogeny(
1177
1276
 
1178
1277
  // shared helpers
1179
1278
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
1279
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1180
1280
  // Works for both radial (uses `r`) and rect (uses `x1`).
1181
1281
  // Falls back to summing branchLength up to the root if neither is present.
1182
1282
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1215,6 +1315,7 @@ function drawPhylogeny(
1215
1315
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1216
1316
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1217
1317
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1318
+ const R_TIP = tipRadius ?? 2;
1218
1319
 
1219
1320
  const maxY = d3__namespace.max(horizontal, (d) => d.y1);
1220
1321
  const minY = d3__namespace.min(horizontal, (d) => d.y1);
@@ -1272,7 +1373,7 @@ function drawPhylogeny(
1272
1373
  .join("circle")
1273
1374
  .attr("cx", (d) => xScale(d.x1))
1274
1375
  .attr("cy", (d) => yScale(d.y1))
1275
- .attr("r", 2)
1376
+ .attr("r", R_TIP)
1276
1377
  .attr("fill", "black");
1277
1378
 
1278
1379
  // tooltips for rect dots
@@ -1287,13 +1388,72 @@ function drawPhylogeny(
1287
1388
  .on("mouseenter", function(_event, d) {
1288
1389
  hoverLayer.selectAll("*").remove();
1289
1390
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1290
- d3__namespace.select(this).attr("r", 4);
1391
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1291
1392
  })
1292
1393
  .on("mouseleave", function() {
1293
1394
  hoverLayer.selectAll("*").remove();
1294
- d3__namespace.select(this).attr("r", 2);
1395
+ d3__namespace.select(this).attr("r", R_TIP);
1295
1396
  });
1296
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
+
1297
1457
  // labels
1298
1458
  if (tipLabels) {
1299
1459
  const labels = svg
@@ -1302,7 +1462,7 @@ function drawPhylogeny(
1302
1462
  .selectAll("text")
1303
1463
  .data(tips)
1304
1464
  .join("text")
1305
- .attr("x", (d) => xScale(d.x1) + 4)
1465
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1306
1466
  .attr("y", (d) => yScale(d.y1))
1307
1467
  .attr("dy", "0.32em")
1308
1468
  .attr("font-size", labelFontSize)
@@ -1374,6 +1534,17 @@ function drawPhylogeny(
1374
1534
  }
1375
1535
  }
1376
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
+
1377
1548
  return svg.node();
1378
1549
  } else if (layout === "radial") {
1379
1550
  // RADIAL LAYOUT
@@ -1395,7 +1566,7 @@ function drawPhylogeny(
1395
1566
 
1396
1567
 
1397
1568
  // visuals (0 = let spokes reach the dots)
1398
- const DOT_R = 3;
1569
+ const DOT_R = tipRadius ?? 3;
1399
1570
  const END_CAP = 0;
1400
1571
 
1401
1572
  // ===== SCALES / BOUNDS =====
@@ -1533,6 +1704,25 @@ function drawPhylogeny(
1533
1704
  .attr("stroke-width", strokeWidth);
1534
1705
  });
1535
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
+
1536
1726
  // ===== TIP DOTS =====
1537
1727
  const tipDots = group
1538
1728
  .append("g")
@@ -1560,6 +1750,45 @@ function drawPhylogeny(
1560
1750
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1561
1751
  }
1562
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
+
1563
1792
  // maps for fast lookup on hover (childId → spoke / arc)
1564
1793
  const key = (x) => (typeof x === "string" ? +x : x);
1565
1794
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1580,7 +1809,8 @@ function drawPhylogeny(
1580
1809
  // same tip position rule as dots/spokes:
1581
1810
  // - "outer": snap to common ring (tipMaxR)
1582
1811
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1583
- const r = isOuter ? tipMaxR : d.r;
1812
+ // - alignTipLabels forces the common ring regardless of mode
1813
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1584
1814
  const x = r * Math.cos(d.angle);
1585
1815
  const y = r * Math.sin(d.angle);
1586
1816
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1727,6 +1957,17 @@ function drawPhylogeny(
1727
1957
  });
1728
1958
  }
1729
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
+
1730
1971
  return svg.node();
1731
1972
  } else if (layout === "unrooted") {
1732
1973
  // UNROOTED LAYOUT
@@ -1776,6 +2017,8 @@ function drawPhylogeny(
1776
2017
  .attr("stroke-width", strokeWidth)
1777
2018
  .attr("stroke", "#777");
1778
2019
 
2020
+ const R_TIP = tipRadius ?? 4;
2021
+
1779
2022
  const nodes = group
1780
2023
  .append("g")
1781
2024
  .attr("class", "phylo_points")
@@ -1783,13 +2026,28 @@ function drawPhylogeny(
1783
2026
  .data(unrootedPhylo.data)
1784
2027
  .join("circle")
1785
2028
  .attr("class", "dot")
1786
- .attr("r", (d) => (d.isTip ? 4 : 0))
2029
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1787
2030
  .attr("cx", (d) => xScaleUnroot(d.x))
1788
2031
  .attr("cy", (d) => yScaleUnroot(d.y))
1789
2032
  .attr("stroke", "black")
1790
2033
  .attr("stroke-width", 2)
1791
2034
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1792
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
+
1793
2051
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1794
2052
  const tipById = new Map(
1795
2053
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1879,11 +2137,11 @@ function drawPhylogeny(
1879
2137
  .filter((d) => d.isTip)
1880
2138
  .on("mouseenter", function(_event, d) {
1881
2139
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1882
- d3__namespace.select(this).attr("r", 6);
2140
+ d3__namespace.select(this).attr("r", R_TIP + 2);
1883
2141
  })
1884
2142
  .on("mouseleave", function() {
1885
2143
  hoverLayer.selectAll("*").remove();
1886
- d3__namespace.select(this).attr("r", 4);
2144
+ d3__namespace.select(this).attr("r", R_TIP);
1887
2145
  });
1888
2146
 
1889
2147
  if (highlightTips && highlightTips.length) {
@@ -1926,6 +2184,17 @@ function drawPhylogeny(
1926
2184
  }
1927
2185
  }
1928
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
+
1929
2198
  return svg.node();
1930
2199
  } else {
1931
2200
  throw new Error(
@@ -1937,6 +2206,7 @@ function drawPhylogeny(
1937
2206
  exports.describeArc = describeArc;
1938
2207
  exports.describeArcSweep = describeArcSweep;
1939
2208
  exports.drawPhylogeny = drawPhylogeny;
2209
+ exports.ladderize = ladderize;
1940
2210
  exports.parentFisheye = parentFisheye;
1941
2211
  exports.phisheye = phisheye;
1942
2212
  exports.polarToCartesian = polarToCartesian;
@@ -1944,6 +2214,8 @@ exports.radialLayout = radialLayout;
1944
2214
  exports.randomTree = randomTree;
1945
2215
  exports.readTree = readTree;
1946
2216
  exports.rectangleLayout = rectangleLayout;
2217
+ exports.rotate = rotate;
1947
2218
  exports.subTree = subTree;
2219
+ exports.toNewick = toNewick;
1948
2220
  exports.unrooted = unrooted;
1949
2221
  //# sourceMappingURL=index.cjs.map