@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/README.md +8 -2
- package/dist/drawPhylogeny.cjs +82 -12
- package/dist/drawPhylogeny.cjs.map +1 -1
- package/dist/drawPhylogeny.esm.js +82 -12
- package/dist/drawPhylogeny.esm.js.map +1 -1
- package/dist/drawPhylogeny.umd.js +1 -1
- package/dist/drawPhylogeny.umd.js.map +1 -1
- package/dist/index.cjs +68 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +68 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,16 +10,22 @@ Visit https://euphrasiologist.github.io/lwPhylo/ to see examples and live render
|
|
|
10
10
|
|
|
11
11
|
Newick trees can be parsed using the `readTree()` function. This object can then be wrapped in three main functions; `rectangleLayout()` to produce a "regular" phylogenetic tree, `radialLayout()` to produce a circular phylogeny, and `unrooted()` to produce an unrooted tree via the equal angle layout algorithm.
|
|
12
12
|
|
|
13
|
-
Need a tree to experiment with? `randomTree(nTips, { maxBranchLength, labelPrefix, seed })` generates a random bifurcating tree in the same node shape as `readTree()`, ready to pass straight into any of the layout functions. `toNewick(tree)` serializes one of these parsed tree objects back to a Newick string
|
|
13
|
+
Need a tree to experiment with? `randomTree(nTips, { maxBranchLength, labelPrefix, seed })` generates a random bifurcating tree in the same node shape as `readTree()`, ready to pass straight into any of the layout functions. `toNewick(tree)` serializes one of these parsed tree objects back to a Newick string.
|
|
14
14
|
|
|
15
15
|
`ladderize(tree, { ascending })` and `rotate(tree, nodeId)` change tip order by reordering a node's children in place — ladderize sorts every clade by descendant tip count (smallest first by default), rotate flips the child order at one node (the root, if no id is given).
|
|
16
16
|
|
|
17
|
-
`drawPhylogeny(
|
|
17
|
+
`drawPhylogeny(input, options)` accepts either a Newick string or an already-parsed tree object (from `readTree()`/`randomTree()`) as `input`. Passing the same parsed tree object back in across re-renders — e.g. after mutating it with `rotate()` — keeps node ids stable, which `onNodeClick` (below) relies on. Options, in addition to `layout`/`width`/`height`/`tipLabels`/`labelFontSize`/`highlightTips`:
|
|
18
18
|
- `tipRadius` — px radius of tip circles.
|
|
19
19
|
- `internalNodeCircles` (bool) + `internalNodeRadius` — draw a circle at every internal node.
|
|
20
20
|
- `nodeLabels` (bool) + `nodeLabelFontSize` — draw text labels (e.g. clade/support values) at internal nodes that have one.
|
|
21
21
|
- `scaleBar` — `true` for an auto-sized branch-length scale bar, a number for an explicit length in branch-length units, or `{ length, x, y, label }` for full control.
|
|
22
22
|
- `alignTipLabels` (bool, rect & radial layouts) — align tip labels to a common column/ring, with dashed guide lines back to each tip's true position.
|
|
23
|
+
- `onNodeClick(node, event)` — fires when an internal node circle is clicked (requires `internalNodeCircles: true`).
|
|
24
|
+
- `container` (DOM element or CSS selector) + `rotateOnClick` (bool) — the batteries-included version of click-to-rotate: with both set, `drawPhylogeny` mounts the SVG into `container` itself and, on every internal-node click, rotates that clade and redraws back into the same container — no manual render loop needed:
|
|
25
|
+
```js
|
|
26
|
+
drawPhylogeny(newickString, { layout: "rect", container: "#tree", rotateOnClick: true });
|
|
27
|
+
```
|
|
28
|
+
`rotateOnClick` auto-enables `internalNodeCircles` unless you set it explicitly. A supplied `onNodeClick` still fires too, before the rotate. Without a `container`, `rotateOnClick` throws — there'd be nowhere to put the redrawn SVG. For finer control (e.g. wrapping the SVG in your own zoom/pan `<g>`, as the demo site does), skip `container`/`rotateOnClick` and drive it yourself with `onNodeClick` + `rotate()` as shown in the demo's own source.
|
|
23
29
|
|
|
24
30
|
### Acknowledgements
|
|
25
31
|
|
package/dist/drawPhylogeny.cjs
CHANGED
|
@@ -837,6 +837,20 @@ function unrooted (node) {
|
|
|
837
837
|
return data;
|
|
838
838
|
}
|
|
839
839
|
|
|
840
|
+
/**
|
|
841
|
+
* Reverse the child order at one node, flipping which side of the tree
|
|
842
|
+
* that clade's descendants are drawn on. Defaults to the root. Mutates
|
|
843
|
+
* the tree in place.
|
|
844
|
+
*/
|
|
845
|
+
function rotate(tree, nodeId = tree.id) {
|
|
846
|
+
const target = preorder(tree).find((n) => n.id === nodeId);
|
|
847
|
+
if (!target) {
|
|
848
|
+
throw new Error(`No node with id ${nodeId} found in tree`);
|
|
849
|
+
}
|
|
850
|
+
target.children.reverse();
|
|
851
|
+
return tree;
|
|
852
|
+
}
|
|
853
|
+
|
|
840
854
|
/**
|
|
841
855
|
* Parse a Newick tree string into a doubly-linked list of JS Objects.
|
|
842
856
|
* Assigns labels, branch lengths, and node IDs (tips before internals if input emits them that way).
|
|
@@ -946,9 +960,12 @@ function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize
|
|
|
946
960
|
.text(opts.label ?? String(length));
|
|
947
961
|
}
|
|
948
962
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
963
|
+
// input: a Newick string, or an already-parsed tree (the node shape returned
|
|
964
|
+
// by readTree()/randomTree()). Passing the same parsed tree object back in
|
|
965
|
+
// across re-renders (e.g. after mutating it with rotate()) keeps node ids
|
|
966
|
+
// stable, which onNodeClick below relies on.
|
|
967
|
+
function drawPhylogeny(input, options = {}) {
|
|
968
|
+
const {
|
|
952
969
|
layout = "rect", // rect/radial/unrooted
|
|
953
970
|
width = 800,
|
|
954
971
|
height = 800,
|
|
@@ -959,12 +976,14 @@ function drawPhylogeny(
|
|
|
959
976
|
tipLabels = true,
|
|
960
977
|
labelFontSize = 10, // font size (px) for tip labels
|
|
961
978
|
tipRadius, // px radius of tip dots; defaults to each layout's original size
|
|
962
|
-
internalNodeCircles = false, // draw a circle at every internal (non-tip) node
|
|
963
979
|
internalNodeRadius = 3, // px radius for internal node circles
|
|
964
980
|
nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
|
|
965
981
|
nodeLabelFontSize, // defaults to labelFontSize
|
|
966
982
|
scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
|
|
967
983
|
alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
|
|
984
|
+
container, // DOM element or CSS selector — if given, drawPhylogeny mounts the SVG itself instead of just returning it
|
|
985
|
+
rotateOnClick = false, // click an internal node circle to rotate its children in place and auto-redraw (requires container)
|
|
986
|
+
onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
|
|
968
987
|
showTooltips = true,
|
|
969
988
|
tooltipFormatter = (d, rtt) =>
|
|
970
989
|
`${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
|
|
@@ -973,12 +992,46 @@ function drawPhylogeny(
|
|
|
973
992
|
highlightTips = [], // array of tip labels or ids for static highlight (optional)
|
|
974
993
|
highlightStroke = "#e63946",
|
|
975
994
|
highlightWidth = 2.5
|
|
976
|
-
} =
|
|
977
|
-
|
|
995
|
+
} = options;
|
|
996
|
+
// rotateOnClick needs internal node circles to click on; auto-enable them
|
|
997
|
+
// unless the caller explicitly said otherwise.
|
|
998
|
+
const internalNodeCircles = options.internalNodeCircles ?? rotateOnClick;
|
|
978
999
|
|
|
979
1000
|
// shared helpers
|
|
980
1001
|
const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
|
|
981
1002
|
const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
|
|
1003
|
+
const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
|
|
1004
|
+
? input
|
|
1005
|
+
: readTree(input);
|
|
1006
|
+
|
|
1007
|
+
const mountEl = typeof container === "string"
|
|
1008
|
+
? (typeof document !== "undefined" ? document.querySelector(container) : null)
|
|
1009
|
+
: (container ?? null);
|
|
1010
|
+
|
|
1011
|
+
if (rotateOnClick && !mountEl) {
|
|
1012
|
+
throw new Error(
|
|
1013
|
+
"rotateOnClick requires a `container` (a DOM element or CSS selector) so drawPhylogeny can mount the redrawn SVG after each rotation."
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// fires the caller's onNodeClick (if any), then handles the rotate+redraw itself
|
|
1018
|
+
const effectiveOnNodeClick = (onNodeClick || rotateOnClick)
|
|
1019
|
+
? (node, event) => {
|
|
1020
|
+
if (typeof onNodeClick === "function") onNodeClick(node, event);
|
|
1021
|
+
if (rotateOnClick) {
|
|
1022
|
+
rotate(parsedTree, node.thisId);
|
|
1023
|
+
drawPhylogeny(parsedTree, options);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
: undefined;
|
|
1027
|
+
|
|
1028
|
+
function mount(svgNode) {
|
|
1029
|
+
if (mountEl) {
|
|
1030
|
+
mountEl.innerHTML = "";
|
|
1031
|
+
mountEl.appendChild(svgNode);
|
|
1032
|
+
}
|
|
1033
|
+
return svgNode;
|
|
1034
|
+
}
|
|
982
1035
|
// Works for both radial (uses `r`) and rect (uses `x1`).
|
|
983
1036
|
// Falls back to summing branchLength up to the root if neither is present.
|
|
984
1037
|
function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
|
|
@@ -1007,7 +1060,7 @@ function drawPhylogeny(
|
|
|
1007
1060
|
|
|
1008
1061
|
if (layout === "rect") {
|
|
1009
1062
|
// RECTANGULAR LAYOUT
|
|
1010
|
-
const tree_df = rectangleLayout(
|
|
1063
|
+
const tree_df = rectangleLayout(parsedTree);
|
|
1011
1064
|
const horizontal = tree_df.horizontal_lines;
|
|
1012
1065
|
const vertical = tree_df.vertical_lines;
|
|
1013
1066
|
const tips = horizontal.filter((d) => d.isTip);
|
|
@@ -1118,6 +1171,12 @@ function drawPhylogeny(
|
|
|
1118
1171
|
.append("title")
|
|
1119
1172
|
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1120
1173
|
}
|
|
1174
|
+
|
|
1175
|
+
if (effectiveOnNodeClick) {
|
|
1176
|
+
internalDots
|
|
1177
|
+
.style("cursor", "pointer")
|
|
1178
|
+
.on("click", (event, d) => effectiveOnNodeClick(d, event));
|
|
1179
|
+
}
|
|
1121
1180
|
}
|
|
1122
1181
|
|
|
1123
1182
|
// internal node labels (optional)
|
|
@@ -1247,13 +1306,12 @@ function drawPhylogeny(
|
|
|
1247
1306
|
});
|
|
1248
1307
|
}
|
|
1249
1308
|
|
|
1250
|
-
return svg.node();
|
|
1309
|
+
return mount(svg.node());
|
|
1251
1310
|
} else if (layout === "radial") {
|
|
1252
1311
|
// RADIAL LAYOUT
|
|
1253
1312
|
if (width !== height) {
|
|
1254
1313
|
throw new Error("width and height must be the same for radial layout");
|
|
1255
1314
|
}
|
|
1256
|
-
const parsedTree = readTree(treeText);
|
|
1257
1315
|
const rad = radialLayout(parsedTree, {
|
|
1258
1316
|
angleStrategy: "fan",
|
|
1259
1317
|
arcsStyle: "fan"
|
|
@@ -1473,6 +1531,12 @@ function drawPhylogeny(
|
|
|
1473
1531
|
.append("title")
|
|
1474
1532
|
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1475
1533
|
}
|
|
1534
|
+
|
|
1535
|
+
if (effectiveOnNodeClick) {
|
|
1536
|
+
internalDots
|
|
1537
|
+
.style("cursor", "pointer")
|
|
1538
|
+
.on("click", (event, d) => effectiveOnNodeClick(d, event));
|
|
1539
|
+
}
|
|
1476
1540
|
}
|
|
1477
1541
|
|
|
1478
1542
|
// ===== INTERNAL NODE LABELS (optional) =====
|
|
@@ -1670,10 +1734,9 @@ function drawPhylogeny(
|
|
|
1670
1734
|
});
|
|
1671
1735
|
}
|
|
1672
1736
|
|
|
1673
|
-
return svg.node();
|
|
1737
|
+
return mount(svg.node());
|
|
1674
1738
|
} else if (layout === "unrooted") {
|
|
1675
1739
|
// UNROOTED LAYOUT
|
|
1676
|
-
const parsedTree = readTree(treeText);
|
|
1677
1740
|
const unrootedPhylo = unrooted(parsedTree);
|
|
1678
1741
|
|
|
1679
1742
|
const w = width;
|
|
@@ -1735,6 +1798,13 @@ function drawPhylogeny(
|
|
|
1735
1798
|
.attr("stroke-width", 2)
|
|
1736
1799
|
.attr("fill", (d) => (d.isTip ? "black" : "white"));
|
|
1737
1800
|
|
|
1801
|
+
if (internalNodeCircles && effectiveOnNodeClick) {
|
|
1802
|
+
nodes
|
|
1803
|
+
.filter((d) => !d.isTip)
|
|
1804
|
+
.style("cursor", "pointer")
|
|
1805
|
+
.on("click", (event, d) => effectiveOnNodeClick(d, event));
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1738
1808
|
if (nodeLabels) {
|
|
1739
1809
|
const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
|
|
1740
1810
|
group
|
|
@@ -1897,7 +1967,7 @@ function drawPhylogeny(
|
|
|
1897
1967
|
});
|
|
1898
1968
|
}
|
|
1899
1969
|
|
|
1900
|
-
return svg.node();
|
|
1970
|
+
return mount(svg.node());
|
|
1901
1971
|
} else {
|
|
1902
1972
|
throw new Error(
|
|
1903
1973
|
"Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
|