@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.js CHANGED
@@ -1223,9 +1223,12 @@ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize
1223
1223
  .text(opts.label ?? String(length));
1224
1224
  }
1225
1225
 
1226
- function drawPhylogeny(
1227
- treeText,
1228
- {
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.
1230
+ function drawPhylogeny(input, options = {}) {
1231
+ const {
1229
1232
  layout = "rect", // rect/radial/unrooted
1230
1233
  width = 800,
1231
1234
  height = 800,
@@ -1236,12 +1239,14 @@ function drawPhylogeny(
1236
1239
  tipLabels = true,
1237
1240
  labelFontSize = 10, // font size (px) for tip labels
1238
1241
  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
1242
  internalNodeRadius = 3, // px radius for internal node circles
1241
1243
  nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
1242
1244
  nodeLabelFontSize, // defaults to labelFontSize
1243
1245
  scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
1244
1246
  alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
1247
+ container, // DOM element or CSS selector — if given, drawPhylogeny mounts the SVG itself instead of just returning it
1248
+ rotateOnClick = false, // click an internal node circle to rotate its children in place and auto-redraw (requires container)
1249
+ onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
1245
1250
  showTooltips = true,
1246
1251
  tooltipFormatter = (d, rtt) =>
1247
1252
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1250,12 +1255,46 @@ function drawPhylogeny(
1250
1255
  highlightTips = [], // array of tip labels or ids for static highlight (optional)
1251
1256
  highlightStroke = "#e63946",
1252
1257
  highlightWidth = 2.5
1253
- } = {}
1254
- ) {
1258
+ } = options;
1259
+ // rotateOnClick needs internal node circles to click on; auto-enable them
1260
+ // unless the caller explicitly said otherwise.
1261
+ const internalNodeCircles = options.internalNodeCircles ?? rotateOnClick;
1255
1262
 
1256
1263
  // shared helpers
1257
1264
  const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
1258
1265
  const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
1266
+ const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
1267
+ ? input
1268
+ : readTree(input);
1269
+
1270
+ const mountEl = typeof container === "string"
1271
+ ? (typeof document !== "undefined" ? document.querySelector(container) : null)
1272
+ : (container ?? null);
1273
+
1274
+ if (rotateOnClick && !mountEl) {
1275
+ throw new Error(
1276
+ "rotateOnClick requires a `container` (a DOM element or CSS selector) so drawPhylogeny can mount the redrawn SVG after each rotation."
1277
+ );
1278
+ }
1279
+
1280
+ // fires the caller's onNodeClick (if any), then handles the rotate+redraw itself
1281
+ const effectiveOnNodeClick = (onNodeClick || rotateOnClick)
1282
+ ? (node, event) => {
1283
+ if (typeof onNodeClick === "function") onNodeClick(node, event);
1284
+ if (rotateOnClick) {
1285
+ rotate(parsedTree, node.thisId);
1286
+ drawPhylogeny(parsedTree, options);
1287
+ }
1288
+ }
1289
+ : undefined;
1290
+
1291
+ function mount(svgNode) {
1292
+ if (mountEl) {
1293
+ mountEl.innerHTML = "";
1294
+ mountEl.appendChild(svgNode);
1295
+ }
1296
+ return svgNode;
1297
+ }
1259
1298
  // Works for both radial (uses `r`) and rect (uses `x1`).
1260
1299
  // Falls back to summing branchLength up to the root if neither is present.
1261
1300
  function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
@@ -1284,7 +1323,7 @@ function drawPhylogeny(
1284
1323
 
1285
1324
  if (layout === "rect") {
1286
1325
  // RECTANGULAR LAYOUT
1287
- const tree_df = rectangleLayout(readTree(treeText));
1326
+ const tree_df = rectangleLayout(parsedTree);
1288
1327
  const horizontal = tree_df.horizontal_lines;
1289
1328
  const vertical = tree_df.vertical_lines;
1290
1329
  const tips = horizontal.filter((d) => d.isTip);
@@ -1395,6 +1434,12 @@ function drawPhylogeny(
1395
1434
  .append("title")
1396
1435
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1397
1436
  }
1437
+
1438
+ if (effectiveOnNodeClick) {
1439
+ internalDots
1440
+ .style("cursor", "pointer")
1441
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1442
+ }
1398
1443
  }
1399
1444
 
1400
1445
  // internal node labels (optional)
@@ -1524,13 +1569,12 @@ function drawPhylogeny(
1524
1569
  });
1525
1570
  }
1526
1571
 
1527
- return svg.node();
1572
+ return mount(svg.node());
1528
1573
  } else if (layout === "radial") {
1529
1574
  // RADIAL LAYOUT
1530
1575
  if (width !== height) {
1531
1576
  throw new Error("width and height must be the same for radial layout");
1532
1577
  }
1533
- const parsedTree = readTree(treeText);
1534
1578
  const rad = radialLayout(parsedTree, {
1535
1579
  angleStrategy: "fan",
1536
1580
  arcsStyle: "fan"
@@ -1750,6 +1794,12 @@ function drawPhylogeny(
1750
1794
  .append("title")
1751
1795
  .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1752
1796
  }
1797
+
1798
+ if (effectiveOnNodeClick) {
1799
+ internalDots
1800
+ .style("cursor", "pointer")
1801
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
1802
+ }
1753
1803
  }
1754
1804
 
1755
1805
  // ===== INTERNAL NODE LABELS (optional) =====
@@ -1947,10 +1997,9 @@ function drawPhylogeny(
1947
1997
  });
1948
1998
  }
1949
1999
 
1950
- return svg.node();
2000
+ return mount(svg.node());
1951
2001
  } else if (layout === "unrooted") {
1952
2002
  // UNROOTED LAYOUT
1953
- const parsedTree = readTree(treeText);
1954
2003
  const unrootedPhylo = unrooted(parsedTree);
1955
2004
 
1956
2005
  const w = width;
@@ -2012,6 +2061,13 @@ function drawPhylogeny(
2012
2061
  .attr("stroke-width", 2)
2013
2062
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
2014
2063
 
2064
+ if (internalNodeCircles && effectiveOnNodeClick) {
2065
+ nodes
2066
+ .filter((d) => !d.isTip)
2067
+ .style("cursor", "pointer")
2068
+ .on("click", (event, d) => effectiveOnNodeClick(d, event));
2069
+ }
2070
+
2015
2071
  if (nodeLabels) {
2016
2072
  const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
2017
2073
  group
@@ -2174,7 +2230,7 @@ function drawPhylogeny(
2174
2230
  });
2175
2231
  }
2176
2232
 
2177
- return svg.node();
2233
+ return mount(svg.node());
2178
2234
  } else {
2179
2235
  throw new Error(
2180
2236
  "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."