@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.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().
@@ -1131,6 +1162,67 @@ function subTree (tree, node) {
1131
1162
  return Object.fromEntries(data.concat(filtered));
1132
1163
  }
1133
1164
 
1165
+ /**
1166
+ * Serialize a parsed tree (the parent/children node shape produced by
1167
+ * readTree() and randomTree()) back into a Newick string.
1168
+ */
1169
+
1170
+ function toNewick(node) {
1171
+ return `${serialize(node)};`;
1172
+ }
1173
+
1174
+ function serialize(node) {
1175
+ const label = node.label || '';
1176
+ const branch = node.branchLength == null ? '' : `:${node.branchLength}`;
1177
+
1178
+ if (node.children.length === 0) {
1179
+ return `${label}${branch}`;
1180
+ }
1181
+
1182
+ const children = node.children.map(serialize).join(',');
1183
+ return `(${children})${label}${branch}`;
1184
+ }
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
+
1134
1226
  function drawPhylogeny(
1135
1227
  treeText,
1136
1228
  {
@@ -1143,6 +1235,13 @@ function drawPhylogeny(
1143
1235
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1144
1236
  tipLabels = true,
1145
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
1146
1245
  showTooltips = true,
1147
1246
  tooltipFormatter = (d, rtt) =>
1148
1247
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1156,6 +1255,7 @@ function drawPhylogeny(
1156
1255
 
1157
1256
  // shared helpers
1158
1257
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
1258
+ const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1159
1259
  // Works for both radial (uses `r`) and rect (uses `x1`).
1160
1260
  // Falls back to summing branchLength up to the root if neither is present.
1161
1261
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1194,6 +1294,7 @@ function drawPhylogeny(
1194
1294
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1195
1295
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1196
1296
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
1297
+ const R_TIP = tipRadius ?? 2;
1197
1298
 
1198
1299
  const maxY = d3.max(horizontal, (d) => d.y1);
1199
1300
  const minY = d3.min(horizontal, (d) => d.y1);
@@ -1251,7 +1352,7 @@ function drawPhylogeny(
1251
1352
  .join("circle")
1252
1353
  .attr("cx", (d) => xScale(d.x1))
1253
1354
  .attr("cy", (d) => yScale(d.y1))
1254
- .attr("r", 2)
1355
+ .attr("r", R_TIP)
1255
1356
  .attr("fill", "black");
1256
1357
 
1257
1358
  // tooltips for rect dots
@@ -1266,13 +1367,72 @@ function drawPhylogeny(
1266
1367
  .on("mouseenter", function(_event, d) {
1267
1368
  hoverLayer.selectAll("*").remove();
1268
1369
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1269
- d3.select(this).attr("r", 4);
1370
+ d3.select(this).attr("r", R_TIP + 2);
1270
1371
  })
1271
1372
  .on("mouseleave", function() {
1272
1373
  hoverLayer.selectAll("*").remove();
1273
- d3.select(this).attr("r", 2);
1374
+ d3.select(this).attr("r", R_TIP);
1274
1375
  });
1275
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
+
1276
1436
  // labels
1277
1437
  if (tipLabels) {
1278
1438
  const labels = svg
@@ -1281,7 +1441,7 @@ function drawPhylogeny(
1281
1441
  .selectAll("text")
1282
1442
  .data(tips)
1283
1443
  .join("text")
1284
- .attr("x", (d) => xScale(d.x1) + 4)
1444
+ .attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
1285
1445
  .attr("y", (d) => yScale(d.y1))
1286
1446
  .attr("dy", "0.32em")
1287
1447
  .attr("font-size", labelFontSize)
@@ -1353,6 +1513,17 @@ function drawPhylogeny(
1353
1513
  }
1354
1514
  }
1355
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
+
1356
1527
  return svg.node();
1357
1528
  } else if (layout === "radial") {
1358
1529
  // RADIAL LAYOUT
@@ -1374,7 +1545,7 @@ function drawPhylogeny(
1374
1545
 
1375
1546
 
1376
1547
  // visuals (0 = let spokes reach the dots)
1377
- const DOT_R = 3;
1548
+ const DOT_R = tipRadius ?? 3;
1378
1549
  const END_CAP = 0;
1379
1550
 
1380
1551
  // ===== SCALES / BOUNDS =====
@@ -1512,6 +1683,25 @@ function drawPhylogeny(
1512
1683
  .attr("stroke-width", strokeWidth);
1513
1684
  });
1514
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
+
1515
1705
  // ===== TIP DOTS =====
1516
1706
  const tipDots = group
1517
1707
  .append("g")
@@ -1539,6 +1729,45 @@ function drawPhylogeny(
1539
1729
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1540
1730
  }
1541
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
+
1542
1771
  // maps for fast lookup on hover (childId → spoke / arc)
1543
1772
  const key = (x) => (typeof x === "string" ? +x : x);
1544
1773
  const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
@@ -1559,7 +1788,8 @@ function drawPhylogeny(
1559
1788
  // same tip position rule as dots/spokes:
1560
1789
  // - "outer": snap to common ring (tipMaxR)
1561
1790
  // - otherwise (e.g. "align"/"phylo"): true tip radius
1562
- const r = isOuter ? tipMaxR : d.r;
1791
+ // - alignTipLabels forces the common ring regardless of mode
1792
+ const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
1563
1793
  const x = r * Math.cos(d.angle);
1564
1794
  const y = r * Math.sin(d.angle);
1565
1795
  return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
@@ -1706,6 +1936,17 @@ function drawPhylogeny(
1706
1936
  });
1707
1937
  }
1708
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
+
1709
1950
  return svg.node();
1710
1951
  } else if (layout === "unrooted") {
1711
1952
  // UNROOTED LAYOUT
@@ -1755,6 +1996,8 @@ function drawPhylogeny(
1755
1996
  .attr("stroke-width", strokeWidth)
1756
1997
  .attr("stroke", "#777");
1757
1998
 
1999
+ const R_TIP = tipRadius ?? 4;
2000
+
1758
2001
  const nodes = group
1759
2002
  .append("g")
1760
2003
  .attr("class", "phylo_points")
@@ -1762,13 +2005,28 @@ function drawPhylogeny(
1762
2005
  .data(unrootedPhylo.data)
1763
2006
  .join("circle")
1764
2007
  .attr("class", "dot")
1765
- .attr("r", (d) => (d.isTip ? 4 : 0))
2008
+ .attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
1766
2009
  .attr("cx", (d) => xScaleUnroot(d.x))
1767
2010
  .attr("cy", (d) => yScaleUnroot(d.y))
1768
2011
  .attr("stroke", "black")
1769
2012
  .attr("stroke-width", 2)
1770
2013
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1771
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
+
1772
2030
  const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1773
2031
  const tipById = new Map(
1774
2032
  unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
@@ -1858,11 +2116,11 @@ function drawPhylogeny(
1858
2116
  .filter((d) => d.isTip)
1859
2117
  .on("mouseenter", function(_event, d) {
1860
2118
  drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1861
- d3.select(this).attr("r", 6);
2119
+ d3.select(this).attr("r", R_TIP + 2);
1862
2120
  })
1863
2121
  .on("mouseleave", function() {
1864
2122
  hoverLayer.selectAll("*").remove();
1865
- d3.select(this).attr("r", 4);
2123
+ d3.select(this).attr("r", R_TIP);
1866
2124
  });
1867
2125
 
1868
2126
  if (highlightTips && highlightTips.length) {
@@ -1905,6 +2163,17 @@ function drawPhylogeny(
1905
2163
  }
1906
2164
  }
1907
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
+
1908
2177
  return svg.node();
1909
2178
  } else {
1910
2179
  throw new Error(
@@ -1913,5 +2182,5 @@ function drawPhylogeny(
1913
2182
  }
1914
2183
  }
1915
2184
 
1916
- export { describeArc, describeArcSweep, drawPhylogeny, parentFisheye, phisheye, polarToCartesian, radialLayout, randomTree, readTree, rectangleLayout, subTree, unrooted };
2185
+ export { describeArc, describeArcSweep, drawPhylogeny, ladderize, parentFisheye, phisheye, polarToCartesian, radialLayout, randomTree, readTree, rectangleLayout, rotate, subTree, toNewick, unrooted };
1917
2186
  //# sourceMappingURL=index.js.map