@euphrasiologist/lwphylo 1.3.1 → 1.5.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/README.md +11 -1
- package/dist/drawPhylogeny.cjs +255 -13
- package/dist/drawPhylogeny.cjs.map +1 -1
- package/dist/drawPhylogeny.esm.js +255 -13
- 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 +288 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +287 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,17 @@ 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.
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
- `tipRadius` — px radius of tip circles.
|
|
19
|
+
- `internalNodeCircles` (bool) + `internalNodeRadius` — draw a circle at every internal node.
|
|
20
|
+
- `nodeLabels` (bool) + `nodeLabelFontSize` — draw text labels (e.g. clade/support values) at internal nodes that have one.
|
|
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
|
+
- `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`). Combine with `rotate()` for click-to-rotate: `onNodeClick: (node) => { rotate(tree, node.thisId); redraw(); }`.
|
|
14
24
|
|
|
15
25
|
### Acknowledgements
|
|
16
26
|
|
package/dist/drawPhylogeny.cjs
CHANGED
|
@@ -906,8 +906,52 @@ function readTree(text) {
|
|
|
906
906
|
return root;
|
|
907
907
|
}
|
|
908
908
|
|
|
909
|
+
// Round a raw data-space length to a "nice" 1/2/5-of-a-power-of-ten value,
|
|
910
|
+
// so an auto-sized scale bar doesn't show an ugly number like "0.347".
|
|
911
|
+
function niceScaleLength(target) {
|
|
912
|
+
if (!(target > 0)) return 1;
|
|
913
|
+
const exp = Math.floor(Math.log10(target));
|
|
914
|
+
const base = Math.pow(10, exp);
|
|
915
|
+
const residual = target / base;
|
|
916
|
+
const niceResidual = residual < 1.5 ? 1 : residual < 3.5 ? 2 : residual < 7.5 ? 5 : 10;
|
|
917
|
+
return niceResidual * base;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// scaleBar: true | number (explicit data-units length) | { length, x, y, label }
|
|
921
|
+
function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize }) {
|
|
922
|
+
const opts = (scaleBar === true || typeof scaleBar === "number") ? {} : scaleBar;
|
|
923
|
+
const length = typeof scaleBar === "number" ? scaleBar : (opts.length ?? niceScaleLength(basis / 5));
|
|
924
|
+
const x = opts.x ?? defaultX;
|
|
925
|
+
const y = opts.y ?? defaultY;
|
|
926
|
+
const barPx = scale(length) - scale(0);
|
|
927
|
+
|
|
928
|
+
const g = svg.append("g").attr("class", "phylo_scale_bar");
|
|
929
|
+
g.append("line")
|
|
930
|
+
.attr("x1", x).attr("x2", x + barPx)
|
|
931
|
+
.attr("y1", y).attr("y2", y)
|
|
932
|
+
.attr("stroke", "#000")
|
|
933
|
+
.attr("stroke-width", 1);
|
|
934
|
+
[x, x + barPx].forEach((tx) => {
|
|
935
|
+
g.append("line")
|
|
936
|
+
.attr("x1", tx).attr("x2", tx)
|
|
937
|
+
.attr("y1", y - 4).attr("y2", y + 4)
|
|
938
|
+
.attr("stroke", "#000")
|
|
939
|
+
.attr("stroke-width", 1);
|
|
940
|
+
});
|
|
941
|
+
g.append("text")
|
|
942
|
+
.attr("x", x + barPx / 2)
|
|
943
|
+
.attr("y", y - 6)
|
|
944
|
+
.attr("text-anchor", "middle")
|
|
945
|
+
.attr("font-size", fontSize)
|
|
946
|
+
.text(opts.label ?? String(length));
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// input: a Newick string, or an already-parsed tree (the node shape returned
|
|
950
|
+
// by readTree()/randomTree()). Passing the same parsed tree object back in
|
|
951
|
+
// across re-renders (e.g. after mutating it with rotate()) keeps node ids
|
|
952
|
+
// stable, which onNodeClick below relies on.
|
|
909
953
|
function drawPhylogeny(
|
|
910
|
-
|
|
954
|
+
input,
|
|
911
955
|
{
|
|
912
956
|
layout = "rect", // rect/radial/unrooted
|
|
913
957
|
width = 800,
|
|
@@ -918,6 +962,14 @@ function drawPhylogeny(
|
|
|
918
962
|
radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
|
|
919
963
|
tipLabels = true,
|
|
920
964
|
labelFontSize = 10, // font size (px) for tip labels
|
|
965
|
+
tipRadius, // px radius of tip dots; defaults to each layout's original size
|
|
966
|
+
internalNodeCircles = false, // draw a circle at every internal (non-tip) node
|
|
967
|
+
internalNodeRadius = 3, // px radius for internal node circles
|
|
968
|
+
nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
|
|
969
|
+
nodeLabelFontSize, // defaults to labelFontSize
|
|
970
|
+
scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
|
|
971
|
+
alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
|
|
972
|
+
onNodeClick, // (node, event) => void — fires when an internal node circle is clicked (requires internalNodeCircles: true)
|
|
921
973
|
showTooltips = true,
|
|
922
974
|
tooltipFormatter = (d, rtt) =>
|
|
923
975
|
`${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
|
|
@@ -931,6 +983,10 @@ function drawPhylogeny(
|
|
|
931
983
|
|
|
932
984
|
// shared helpers
|
|
933
985
|
const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
|
|
986
|
+
const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
|
|
987
|
+
const parsedTree = (input && typeof input === "object" && Array.isArray(input.children))
|
|
988
|
+
? input
|
|
989
|
+
: readTree(input);
|
|
934
990
|
// Works for both radial (uses `r`) and rect (uses `x1`).
|
|
935
991
|
// Falls back to summing branchLength up to the root if neither is present.
|
|
936
992
|
function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
|
|
@@ -959,7 +1015,7 @@ function drawPhylogeny(
|
|
|
959
1015
|
|
|
960
1016
|
if (layout === "rect") {
|
|
961
1017
|
// RECTANGULAR LAYOUT
|
|
962
|
-
const tree_df = rectangleLayout(
|
|
1018
|
+
const tree_df = rectangleLayout(parsedTree);
|
|
963
1019
|
const horizontal = tree_df.horizontal_lines;
|
|
964
1020
|
const vertical = tree_df.vertical_lines;
|
|
965
1021
|
const tips = horizontal.filter((d) => d.isTip);
|
|
@@ -969,6 +1025,7 @@ function drawPhylogeny(
|
|
|
969
1025
|
const tipById = new Map(tips.map((d) => [d.thisId, d]));
|
|
970
1026
|
const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
|
|
971
1027
|
const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
|
|
1028
|
+
const R_TIP = tipRadius ?? 2;
|
|
972
1029
|
|
|
973
1030
|
const maxY = d3__namespace.max(horizontal, (d) => d.y1);
|
|
974
1031
|
const minY = d3__namespace.min(horizontal, (d) => d.y1);
|
|
@@ -1026,7 +1083,7 @@ function drawPhylogeny(
|
|
|
1026
1083
|
.join("circle")
|
|
1027
1084
|
.attr("cx", (d) => xScale(d.x1))
|
|
1028
1085
|
.attr("cy", (d) => yScale(d.y1))
|
|
1029
|
-
.attr("r",
|
|
1086
|
+
.attr("r", R_TIP)
|
|
1030
1087
|
.attr("fill", "black");
|
|
1031
1088
|
|
|
1032
1089
|
// tooltips for rect dots
|
|
@@ -1041,13 +1098,78 @@ function drawPhylogeny(
|
|
|
1041
1098
|
.on("mouseenter", function(_event, d) {
|
|
1042
1099
|
hoverLayer.selectAll("*").remove();
|
|
1043
1100
|
drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1044
|
-
d3__namespace.select(this).attr("r",
|
|
1101
|
+
d3__namespace.select(this).attr("r", R_TIP + 2);
|
|
1045
1102
|
})
|
|
1046
1103
|
.on("mouseleave", function() {
|
|
1047
1104
|
hoverLayer.selectAll("*").remove();
|
|
1048
|
-
d3__namespace.select(this).attr("r",
|
|
1105
|
+
d3__namespace.select(this).attr("r", R_TIP);
|
|
1049
1106
|
});
|
|
1050
1107
|
|
|
1108
|
+
// internal node circles (optional)
|
|
1109
|
+
if (internalNodeCircles) {
|
|
1110
|
+
const internalNodes = tree_df.data.filter((d) => !d.isTip);
|
|
1111
|
+
const internalDots = group
|
|
1112
|
+
.append("g")
|
|
1113
|
+
.attr("class", "phylo_internal_dots")
|
|
1114
|
+
.selectAll("circle")
|
|
1115
|
+
.data(internalNodes)
|
|
1116
|
+
.join("circle")
|
|
1117
|
+
.attr("cx", (d) => xScale(d.x1))
|
|
1118
|
+
.attr("cy", (d) => yScale(d.y1))
|
|
1119
|
+
.attr("r", internalNodeRadius)
|
|
1120
|
+
.attr("fill", "white")
|
|
1121
|
+
.attr("stroke", "#555")
|
|
1122
|
+
.attr("stroke-width", 1);
|
|
1123
|
+
|
|
1124
|
+
if (showTooltips) {
|
|
1125
|
+
internalDots
|
|
1126
|
+
.append("title")
|
|
1127
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (onNodeClick) {
|
|
1131
|
+
internalDots
|
|
1132
|
+
.style("cursor", "pointer")
|
|
1133
|
+
.on("click", (event, d) => onNodeClick(d, event));
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// internal node labels (optional)
|
|
1138
|
+
if (nodeLabels) {
|
|
1139
|
+
const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
|
|
1140
|
+
svg
|
|
1141
|
+
.append("g")
|
|
1142
|
+
.attr("class", "phylo_node_labels")
|
|
1143
|
+
.selectAll("text")
|
|
1144
|
+
.data(labeledInternalNodes)
|
|
1145
|
+
.join("text")
|
|
1146
|
+
.attr("x", (d) => xScale(d.x1) - 4)
|
|
1147
|
+
.attr("y", (d) => yScale(d.y1) - 4)
|
|
1148
|
+
.attr("text-anchor", "end")
|
|
1149
|
+
.attr("font-size", nodeLabelSize)
|
|
1150
|
+
.text((d) => d.thisLabel);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// column that tip labels align to when alignTipLabels is set
|
|
1154
|
+
const alignX = xScale(maxX);
|
|
1155
|
+
|
|
1156
|
+
// dashed guide lines from each tip's true branch end to the aligned label column
|
|
1157
|
+
if (tipLabels && alignTipLabels) {
|
|
1158
|
+
group
|
|
1159
|
+
.append("g")
|
|
1160
|
+
.attr("class", "phylo_align_guides")
|
|
1161
|
+
.selectAll("line")
|
|
1162
|
+
.data(tips)
|
|
1163
|
+
.join("line")
|
|
1164
|
+
.attr("x1", (d) => xScale(d.x1))
|
|
1165
|
+
.attr("x2", alignX)
|
|
1166
|
+
.attr("y1", (d) => yScale(d.y1))
|
|
1167
|
+
.attr("y2", (d) => yScale(d.y1))
|
|
1168
|
+
.attr("stroke", "#999")
|
|
1169
|
+
.attr("stroke-width", 1)
|
|
1170
|
+
.attr("stroke-dasharray", "2,2");
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1051
1173
|
// labels
|
|
1052
1174
|
if (tipLabels) {
|
|
1053
1175
|
const labels = svg
|
|
@@ -1056,7 +1178,7 @@ function drawPhylogeny(
|
|
|
1056
1178
|
.selectAll("text")
|
|
1057
1179
|
.data(tips)
|
|
1058
1180
|
.join("text")
|
|
1059
|
-
.attr("x", (d) => xScale(d.x1) + 4)
|
|
1181
|
+
.attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
|
|
1060
1182
|
.attr("y", (d) => yScale(d.y1))
|
|
1061
1183
|
.attr("dy", "0.32em")
|
|
1062
1184
|
.attr("font-size", labelFontSize)
|
|
@@ -1128,13 +1250,23 @@ function drawPhylogeny(
|
|
|
1128
1250
|
}
|
|
1129
1251
|
}
|
|
1130
1252
|
|
|
1253
|
+
if (scaleBar) {
|
|
1254
|
+
addScaleBar(svg, {
|
|
1255
|
+
scale: xScale,
|
|
1256
|
+
basis: maxX,
|
|
1257
|
+
defaultX: margin.left,
|
|
1258
|
+
defaultY: height - margin.bottom / 2,
|
|
1259
|
+
scaleBar,
|
|
1260
|
+
fontSize: labelFontSize
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1131
1264
|
return svg.node();
|
|
1132
1265
|
} else if (layout === "radial") {
|
|
1133
1266
|
// RADIAL LAYOUT
|
|
1134
1267
|
if (width !== height) {
|
|
1135
1268
|
throw new Error("width and height must be the same for radial layout");
|
|
1136
1269
|
}
|
|
1137
|
-
const parsedTree = readTree(treeText);
|
|
1138
1270
|
const rad = radialLayout(parsedTree, {
|
|
1139
1271
|
angleStrategy: "fan",
|
|
1140
1272
|
arcsStyle: "fan"
|
|
@@ -1149,7 +1281,7 @@ function drawPhylogeny(
|
|
|
1149
1281
|
|
|
1150
1282
|
|
|
1151
1283
|
// visuals (0 = let spokes reach the dots)
|
|
1152
|
-
const DOT_R = 3;
|
|
1284
|
+
const DOT_R = tipRadius ?? 3;
|
|
1153
1285
|
const END_CAP = 0;
|
|
1154
1286
|
|
|
1155
1287
|
// ===== SCALES / BOUNDS =====
|
|
@@ -1287,6 +1419,25 @@ function drawPhylogeny(
|
|
|
1287
1419
|
.attr("stroke-width", strokeWidth);
|
|
1288
1420
|
});
|
|
1289
1421
|
|
|
1422
|
+
// ===== ALIGN GUIDES (optional) =====
|
|
1423
|
+
// dashed lines from each tip's true position to the common label ring,
|
|
1424
|
+
// for radialMode "phylo" (true terminals) where tips aren't already co-circular
|
|
1425
|
+
if (tipLabels && alignTipLabels && !isOuter) {
|
|
1426
|
+
group
|
|
1427
|
+
.append("g")
|
|
1428
|
+
.attr("class", "phylo_align_guides")
|
|
1429
|
+
.selectAll("line")
|
|
1430
|
+
.data(tips)
|
|
1431
|
+
.join("line")
|
|
1432
|
+
.attr("x1", (d) => xScaleRadial(d.x))
|
|
1433
|
+
.attr("y1", (d) => yScaleRadial(d.y))
|
|
1434
|
+
.attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
|
|
1435
|
+
.attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
|
|
1436
|
+
.attr("stroke", "#999")
|
|
1437
|
+
.attr("stroke-width", 1)
|
|
1438
|
+
.attr("stroke-dasharray", "2,2");
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1290
1441
|
// ===== TIP DOTS =====
|
|
1291
1442
|
const tipDots = group
|
|
1292
1443
|
.append("g")
|
|
@@ -1314,6 +1465,51 @@ function drawPhylogeny(
|
|
|
1314
1465
|
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1315
1466
|
}
|
|
1316
1467
|
|
|
1468
|
+
// ===== INTERNAL NODE CIRCLES (optional) =====
|
|
1469
|
+
if (internalNodeCircles) {
|
|
1470
|
+
const internalNodes = rad.data.filter((d) => !d.isTip);
|
|
1471
|
+
const internalDots = group
|
|
1472
|
+
.append("g")
|
|
1473
|
+
.attr("class", "phylo_internal_dots")
|
|
1474
|
+
.selectAll("circle")
|
|
1475
|
+
.data(internalNodes)
|
|
1476
|
+
.join("circle")
|
|
1477
|
+
.attr("cx", (d) => xScaleRadial(d.x))
|
|
1478
|
+
.attr("cy", (d) => yScaleRadial(d.y))
|
|
1479
|
+
.attr("r", internalNodeRadius)
|
|
1480
|
+
.attr("fill", "white")
|
|
1481
|
+
.attr("stroke", "#555")
|
|
1482
|
+
.attr("stroke-width", 1);
|
|
1483
|
+
|
|
1484
|
+
if (showTooltips) {
|
|
1485
|
+
internalDots
|
|
1486
|
+
.append("title")
|
|
1487
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
if (onNodeClick) {
|
|
1491
|
+
internalDots
|
|
1492
|
+
.style("cursor", "pointer")
|
|
1493
|
+
.on("click", (event, d) => onNodeClick(d, event));
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// ===== INTERNAL NODE LABELS (optional) =====
|
|
1498
|
+
if (nodeLabels) {
|
|
1499
|
+
const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
|
|
1500
|
+
group
|
|
1501
|
+
.append("g")
|
|
1502
|
+
.attr("class", "phylo_node_labels")
|
|
1503
|
+
.selectAll("text")
|
|
1504
|
+
.data(labeledInternalNodes)
|
|
1505
|
+
.join("text")
|
|
1506
|
+
.attr("x", (d) => xScaleRadial(d.x) + 4)
|
|
1507
|
+
.attr("y", (d) => yScaleRadial(d.y) - 4)
|
|
1508
|
+
.attr("font-size", nodeLabelSize)
|
|
1509
|
+
.attr("fill", "black")
|
|
1510
|
+
.text((d) => d.thisLabel);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1317
1513
|
// maps for fast lookup on hover (childId → spoke / arc)
|
|
1318
1514
|
const key = (x) => (typeof x === "string" ? +x : x);
|
|
1319
1515
|
const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
|
|
@@ -1334,7 +1530,8 @@ function drawPhylogeny(
|
|
|
1334
1530
|
// same tip position rule as dots/spokes:
|
|
1335
1531
|
// - "outer": snap to common ring (tipMaxR)
|
|
1336
1532
|
// - otherwise (e.g. "align"/"phylo"): true tip radius
|
|
1337
|
-
|
|
1533
|
+
// - alignTipLabels forces the common ring regardless of mode
|
|
1534
|
+
const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
|
|
1338
1535
|
const x = r * Math.cos(d.angle);
|
|
1339
1536
|
const y = r * Math.sin(d.angle);
|
|
1340
1537
|
return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
|
|
@@ -1481,10 +1678,20 @@ function drawPhylogeny(
|
|
|
1481
1678
|
});
|
|
1482
1679
|
}
|
|
1483
1680
|
|
|
1681
|
+
if (scaleBar) {
|
|
1682
|
+
addScaleBar(svg, {
|
|
1683
|
+
scale: xScaleRadial,
|
|
1684
|
+
basis: maxRadius,
|
|
1685
|
+
defaultX: 20,
|
|
1686
|
+
defaultY: h - 20,
|
|
1687
|
+
scaleBar,
|
|
1688
|
+
fontSize: labelFontSize
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1484
1692
|
return svg.node();
|
|
1485
1693
|
} else if (layout === "unrooted") {
|
|
1486
1694
|
// UNROOTED LAYOUT
|
|
1487
|
-
const parsedTree = readTree(treeText);
|
|
1488
1695
|
const unrootedPhylo = unrooted(parsedTree);
|
|
1489
1696
|
|
|
1490
1697
|
const w = width;
|
|
@@ -1530,6 +1737,8 @@ function drawPhylogeny(
|
|
|
1530
1737
|
.attr("stroke-width", strokeWidth)
|
|
1531
1738
|
.attr("stroke", "#777");
|
|
1532
1739
|
|
|
1740
|
+
const R_TIP = tipRadius ?? 4;
|
|
1741
|
+
|
|
1533
1742
|
const nodes = group
|
|
1534
1743
|
.append("g")
|
|
1535
1744
|
.attr("class", "phylo_points")
|
|
@@ -1537,13 +1746,35 @@ function drawPhylogeny(
|
|
|
1537
1746
|
.data(unrootedPhylo.data)
|
|
1538
1747
|
.join("circle")
|
|
1539
1748
|
.attr("class", "dot")
|
|
1540
|
-
.attr("r", (d) => (d.isTip ?
|
|
1749
|
+
.attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
|
|
1541
1750
|
.attr("cx", (d) => xScaleUnroot(d.x))
|
|
1542
1751
|
.attr("cy", (d) => yScaleUnroot(d.y))
|
|
1543
1752
|
.attr("stroke", "black")
|
|
1544
1753
|
.attr("stroke-width", 2)
|
|
1545
1754
|
.attr("fill", (d) => (d.isTip ? "black" : "white"));
|
|
1546
1755
|
|
|
1756
|
+
if (internalNodeCircles && onNodeClick) {
|
|
1757
|
+
nodes
|
|
1758
|
+
.filter((d) => !d.isTip)
|
|
1759
|
+
.style("cursor", "pointer")
|
|
1760
|
+
.on("click", (event, d) => onNodeClick(d, event));
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
if (nodeLabels) {
|
|
1764
|
+
const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
|
|
1765
|
+
group
|
|
1766
|
+
.append("g")
|
|
1767
|
+
.attr("class", "phylo_node_labels")
|
|
1768
|
+
.selectAll("text")
|
|
1769
|
+
.data(labeledInternalNodes)
|
|
1770
|
+
.join("text")
|
|
1771
|
+
.attr("x", (d) => xScaleUnroot(d.x) + 4)
|
|
1772
|
+
.attr("y", (d) => yScaleUnroot(d.y) - 4)
|
|
1773
|
+
.attr("font-size", nodeLabelSize)
|
|
1774
|
+
.attr("fill", "black")
|
|
1775
|
+
.text((d) => d.thisLabel);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1547
1778
|
const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1548
1779
|
const tipById = new Map(
|
|
1549
1780
|
unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
|
|
@@ -1633,11 +1864,11 @@ function drawPhylogeny(
|
|
|
1633
1864
|
.filter((d) => d.isTip)
|
|
1634
1865
|
.on("mouseenter", function(_event, d) {
|
|
1635
1866
|
drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1636
|
-
d3__namespace.select(this).attr("r",
|
|
1867
|
+
d3__namespace.select(this).attr("r", R_TIP + 2);
|
|
1637
1868
|
})
|
|
1638
1869
|
.on("mouseleave", function() {
|
|
1639
1870
|
hoverLayer.selectAll("*").remove();
|
|
1640
|
-
d3__namespace.select(this).attr("r",
|
|
1871
|
+
d3__namespace.select(this).attr("r", R_TIP);
|
|
1641
1872
|
});
|
|
1642
1873
|
|
|
1643
1874
|
if (highlightTips && highlightTips.length) {
|
|
@@ -1680,6 +1911,17 @@ function drawPhylogeny(
|
|
|
1680
1911
|
}
|
|
1681
1912
|
}
|
|
1682
1913
|
|
|
1914
|
+
if (scaleBar) {
|
|
1915
|
+
addScaleBar(svg, {
|
|
1916
|
+
scale: xScaleUnroot,
|
|
1917
|
+
basis: maxRadius,
|
|
1918
|
+
defaultX: 20,
|
|
1919
|
+
defaultY: h - 20,
|
|
1920
|
+
scaleBar,
|
|
1921
|
+
fontSize: labelFontSize
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1683
1925
|
return svg.node();
|
|
1684
1926
|
} else {
|
|
1685
1927
|
throw new Error(
|