@euphrasiologist/lwphylo 1.4.0 → 1.5.1

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
@@ -1244,9 +1244,12 @@ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize
1244
1244
  .text(opts.label ?? String(length));
1245
1245
  }
1246
1246
 
1247
- function drawPhylogeny(
1248
- treeText,
1249
- {
1247
+ // input: a Newick string, or an already-parsed tree (the node shape returned
1248
+ // by readTree()/randomTree()). Passing the same parsed tree object back in
1249
+ // across re-renders (e.g. after mutating it with rotate()) keeps node ids
1250
+ // stable, which onNodeClick below relies on.
1251
+ function drawPhylogeny(input, options = {}) {
1252
+ const {
1250
1253
  layout = "rect", // rect/radial/unrooted
1251
1254
  width = 800,
1252
1255
  height = 800,
@@ -1257,12 +1260,14 @@ function drawPhylogeny(
1257
1260
  tipLabels = true,
1258
1261
  labelFontSize = 10, // font size (px) for tip labels
1259
1262
  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
1263
  internalNodeRadius = 3, // px radius for internal node circles
1262
1264
  nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
1263
1265
  nodeLabelFontSize, // defaults to labelFontSize
1264
1266
  scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
1265
1267
  alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
1268
+ container, // DOM element or CSS selector — if given, drawPhylogeny mounts the SVG itself instead of just returning it
1269
+ rotateOnClick = false, // click an internal node circle to rotate its children in place and auto-redraw (requires container)
1270
+ onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
1266
1271
  showTooltips = true,
1267
1272
  tooltipFormatter = (d, rtt) =>
1268
1273
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1271,12 +1276,46 @@ function drawPhylogeny(
1271
1276
  highlightTips = [], // array of tip labels or ids for static highlight (optional)
1272
1277
  highlightStroke = "#e63946",
1273
1278
  highlightWidth = 2.5
1274
- } = {}
1275
- ) {
1279
+ } = options;
1280
+ // rotateOnClick needs internal node circles to click on; auto-enable them
1281
+ // unless the caller explicitly said otherwise.
1282
+ const internalNodeCircles = options.internalNodeCircles ?? rotateOnClick;
1276
1283
 
1277
1284
  // shared helpers
1278
1285
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
1279
1286
  const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1287
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
1288
+ ? input
1289
+ : readTree(input);
1290
+
1291
+ const mountEl = typeof container === "string"
1292
+ ? (typeof document !== "undefined" ? document.querySelector(container) : null)
1293
+ : (container ?? null);
1294
+
1295
+ if (rotateOnClick && !mountEl) {
1296
+ throw new Error(
1297
+ "rotateOnClick requires a `container` (a DOM element or CSS selector) so drawPhylogeny can mount the redrawn SVG after each rotation."
1298
+ );
1299
+ }
1300
+
1301
+ // fires the caller's onNodeClick (if any), then handles the rotate+redraw itself
1302
+ const effectiveOnNodeClick = (onNodeClick || rotateOnClick)
1303
+ ? (node, event) => {
1304
+ if (typeof onNodeClick === "function") onNodeClick(node, event);
1305
+ if (rotateOnClick) {
1306
+ rotate(parsedTree, node.thisId);
1307
+ drawPhylogeny(parsedTree, options);
1308
+ }
1309
+ }
1310
+ : undefined;
1311
+
1312
+ function mount(svgNode) {
1313
+ if (mountEl) {
1314
+ mountEl.innerHTML = "";
1315
+ mountEl.appendChild(svgNode);
1316
+ }
1317
+ return svgNode;
1318
+ }
1280
1319
  // Works for both radial (uses `r`) and rect (uses `x1`).
1281
1320
  // Falls back to summing branchLength up to the root if neither is present.
1282
1321
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1305,7 +1344,7 @@ function drawPhylogeny(
1305
1344
 
1306
1345
  if (layout === "rect") {
1307
1346
  // RECTANGULAR LAYOUT
1308
- const tree_df = rectangleLayout(readTree(treeText));
1347
+ const tree_df = rectangleLayout(parsedTree);
1309
1348
  const horizontal = tree_df.horizontal_lines;
1310
1349
  const vertical = tree_df.vertical_lines;
1311
1350
  const tips = horizontal.filter((d) => d.isTip);
@@ -1416,6 +1455,12 @@ function drawPhylogeny(
1416
1455
  .append("title")
1417
1456
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1418
1457
  }
1458
+
1459
+ if (effectiveOnNodeClick) {
1460
+ internalDots
1461
+ .style("cursor", "pointer")
1462
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1463
+ }
1419
1464
  }
1420
1465
 
1421
1466
  // internal node labels (optional)
@@ -1545,13 +1590,12 @@ function drawPhylogeny(
1545
1590
  });
1546
1591
  }
1547
1592
 
1548
- return svg.node();
1593
+ return mount(svg.node());
1549
1594
  } else if (layout === "radial") {
1550
1595
  // RADIAL LAYOUT
1551
1596
  if (width !== height) {
1552
1597
  throw new Error("width and height must be the same for radial layout");
1553
1598
  }
1554
- const parsedTree = readTree(treeText);
1555
1599
  const rad = radialLayout(parsedTree, {
1556
1600
  angleStrategy: "fan",
1557
1601
  arcsStyle: "fan"
@@ -1771,6 +1815,12 @@ function drawPhylogeny(
1771
1815
  .append("title")
1772
1816
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1773
1817
  }
1818
+
1819
+ if (effectiveOnNodeClick) {
1820
+ internalDots
1821
+ .style("cursor", "pointer")
1822
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1823
+ }
1774
1824
  }
1775
1825
 
1776
1826
  // ===== INTERNAL NODE LABELS (optional) =====
@@ -1968,10 +2018,9 @@ function drawPhylogeny(
1968
2018
  });
1969
2019
  }
1970
2020
 
1971
- return svg.node();
2021
+ return mount(svg.node());
1972
2022
  } else if (layout === "unrooted") {
1973
2023
  // UNROOTED LAYOUT
1974
- const parsedTree = readTree(treeText);
1975
2024
  const unrootedPhylo = unrooted(parsedTree);
1976
2025
 
1977
2026
  const w = width;
@@ -2033,6 +2082,13 @@ function drawPhylogeny(
2033
2082
  .attr("stroke-width", 2)
2034
2083
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
2035
2084
 
2085
+ if (internalNodeCircles && effectiveOnNodeClick) {
2086
+ nodes
2087
+ .filter((d) => !d.isTip)
2088
+ .style("cursor", "pointer")
2089
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
2090
+ }
2091
+
2036
2092
  if (nodeLabels) {
2037
2093
  const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
2038
2094
  group
@@ -2195,7 +2251,7 @@ function drawPhylogeny(
2195
2251
  });
2196
2252
  }
2197
2253
 
2198
- return svg.node();
2254
+ return mount(svg.node());
2199
2255
  } else {
2200
2256
  throw new Error(
2201
2257
  "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."