@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/README.md +10 -1
- package/dist/drawPhylogeny.cjs +226 -9
- package/dist/drawPhylogeny.cjs.map +1 -1
- package/dist/drawPhylogeny.esm.js +226 -9
- 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 +281 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +279 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -885,6 +885,46 @@ function readTree(text) {
|
|
|
885
885
|
return root;
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
+
// Round a raw data-space length to a "nice" 1/2/5-of-a-power-of-ten value,
|
|
889
|
+
// so an auto-sized scale bar doesn't show an ugly number like "0.347".
|
|
890
|
+
function niceScaleLength(target) {
|
|
891
|
+
if (!(target > 0)) return 1;
|
|
892
|
+
const exp = Math.floor(Math.log10(target));
|
|
893
|
+
const base = Math.pow(10, exp);
|
|
894
|
+
const residual = target / base;
|
|
895
|
+
const niceResidual = residual < 1.5 ? 1 : residual < 3.5 ? 2 : residual < 7.5 ? 5 : 10;
|
|
896
|
+
return niceResidual * base;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// scaleBar: true | number (explicit data-units length) | { length, x, y, label }
|
|
900
|
+
function addScaleBar(svg, { scale, basis, defaultX, defaultY, scaleBar, fontSize }) {
|
|
901
|
+
const opts = (scaleBar === true || typeof scaleBar === "number") ? {} : scaleBar;
|
|
902
|
+
const length = typeof scaleBar === "number" ? scaleBar : (opts.length ?? niceScaleLength(basis / 5));
|
|
903
|
+
const x = opts.x ?? defaultX;
|
|
904
|
+
const y = opts.y ?? defaultY;
|
|
905
|
+
const barPx = scale(length) - scale(0);
|
|
906
|
+
|
|
907
|
+
const g = svg.append("g").attr("class", "phylo_scale_bar");
|
|
908
|
+
g.append("line")
|
|
909
|
+
.attr("x1", x).attr("x2", x + barPx)
|
|
910
|
+
.attr("y1", y).attr("y2", y)
|
|
911
|
+
.attr("stroke", "#000")
|
|
912
|
+
.attr("stroke-width", 1);
|
|
913
|
+
[x, x + barPx].forEach((tx) => {
|
|
914
|
+
g.append("line")
|
|
915
|
+
.attr("x1", tx).attr("x2", tx)
|
|
916
|
+
.attr("y1", y - 4).attr("y2", y + 4)
|
|
917
|
+
.attr("stroke", "#000")
|
|
918
|
+
.attr("stroke-width", 1);
|
|
919
|
+
});
|
|
920
|
+
g.append("text")
|
|
921
|
+
.attr("x", x + barPx / 2)
|
|
922
|
+
.attr("y", y - 6)
|
|
923
|
+
.attr("text-anchor", "middle")
|
|
924
|
+
.attr("font-size", fontSize)
|
|
925
|
+
.text(opts.label ?? String(length));
|
|
926
|
+
}
|
|
927
|
+
|
|
888
928
|
function drawPhylogeny(
|
|
889
929
|
treeText,
|
|
890
930
|
{
|
|
@@ -897,6 +937,13 @@ function drawPhylogeny(
|
|
|
897
937
|
radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
|
|
898
938
|
tipLabels = true,
|
|
899
939
|
labelFontSize = 10, // font size (px) for tip labels
|
|
940
|
+
tipRadius, // px radius of tip dots; defaults to each layout's original size
|
|
941
|
+
internalNodeCircles = false, // draw a circle at every internal (non-tip) node
|
|
942
|
+
internalNodeRadius = 3, // px radius for internal node circles
|
|
943
|
+
nodeLabels = false, // draw text labels at internal nodes (e.g. clade/support labels)
|
|
944
|
+
nodeLabelFontSize, // defaults to labelFontSize
|
|
945
|
+
scaleBar = false, // false | true | number (branch-length units) | { length, x, y, label }
|
|
946
|
+
alignTipLabels = false, // rect & radial only: align tip labels to a common column/ring, with dashed guide lines back to the true tip position
|
|
900
947
|
showTooltips = true,
|
|
901
948
|
tooltipFormatter = (d, rtt) =>
|
|
902
949
|
`${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
|
|
@@ -910,6 +957,7 @@ function drawPhylogeny(
|
|
|
910
957
|
|
|
911
958
|
// shared helpers
|
|
912
959
|
const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
|
|
960
|
+
const nodeLabelSize = nodeLabelFontSize ?? labelFontSize;
|
|
913
961
|
// Works for both radial (uses `r`) and rect (uses `x1`).
|
|
914
962
|
// Falls back to summing branchLength up to the root if neither is present.
|
|
915
963
|
function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
|
|
@@ -948,6 +996,7 @@ function drawPhylogeny(
|
|
|
948
996
|
const tipById = new Map(tips.map((d) => [d.thisId, d]));
|
|
949
997
|
const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
|
|
950
998
|
const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
|
|
999
|
+
const R_TIP = tipRadius ?? 2;
|
|
951
1000
|
|
|
952
1001
|
const maxY = d3.max(horizontal, (d) => d.y1);
|
|
953
1002
|
const minY = d3.min(horizontal, (d) => d.y1);
|
|
@@ -1005,7 +1054,7 @@ function drawPhylogeny(
|
|
|
1005
1054
|
.join("circle")
|
|
1006
1055
|
.attr("cx", (d) => xScale(d.x1))
|
|
1007
1056
|
.attr("cy", (d) => yScale(d.y1))
|
|
1008
|
-
.attr("r",
|
|
1057
|
+
.attr("r", R_TIP)
|
|
1009
1058
|
.attr("fill", "black");
|
|
1010
1059
|
|
|
1011
1060
|
// tooltips for rect dots
|
|
@@ -1020,13 +1069,72 @@ function drawPhylogeny(
|
|
|
1020
1069
|
.on("mouseenter", function(_event, d) {
|
|
1021
1070
|
hoverLayer.selectAll("*").remove();
|
|
1022
1071
|
drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1023
|
-
d3.select(this).attr("r",
|
|
1072
|
+
d3.select(this).attr("r", R_TIP + 2);
|
|
1024
1073
|
})
|
|
1025
1074
|
.on("mouseleave", function() {
|
|
1026
1075
|
hoverLayer.selectAll("*").remove();
|
|
1027
|
-
d3.select(this).attr("r",
|
|
1076
|
+
d3.select(this).attr("r", R_TIP);
|
|
1028
1077
|
});
|
|
1029
1078
|
|
|
1079
|
+
// internal node circles (optional)
|
|
1080
|
+
if (internalNodeCircles) {
|
|
1081
|
+
const internalNodes = tree_df.data.filter((d) => !d.isTip);
|
|
1082
|
+
const internalDots = group
|
|
1083
|
+
.append("g")
|
|
1084
|
+
.attr("class", "phylo_internal_dots")
|
|
1085
|
+
.selectAll("circle")
|
|
1086
|
+
.data(internalNodes)
|
|
1087
|
+
.join("circle")
|
|
1088
|
+
.attr("cx", (d) => xScale(d.x1))
|
|
1089
|
+
.attr("cy", (d) => yScale(d.y1))
|
|
1090
|
+
.attr("r", internalNodeRadius)
|
|
1091
|
+
.attr("fill", "white")
|
|
1092
|
+
.attr("stroke", "#555")
|
|
1093
|
+
.attr("stroke-width", 1);
|
|
1094
|
+
|
|
1095
|
+
if (showTooltips) {
|
|
1096
|
+
internalDots
|
|
1097
|
+
.append("title")
|
|
1098
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// internal node labels (optional)
|
|
1103
|
+
if (nodeLabels) {
|
|
1104
|
+
const labeledInternalNodes = tree_df.data.filter((d) => !d.isTip && d.thisLabel);
|
|
1105
|
+
svg
|
|
1106
|
+
.append("g")
|
|
1107
|
+
.attr("class", "phylo_node_labels")
|
|
1108
|
+
.selectAll("text")
|
|
1109
|
+
.data(labeledInternalNodes)
|
|
1110
|
+
.join("text")
|
|
1111
|
+
.attr("x", (d) => xScale(d.x1) - 4)
|
|
1112
|
+
.attr("y", (d) => yScale(d.y1) - 4)
|
|
1113
|
+
.attr("text-anchor", "end")
|
|
1114
|
+
.attr("font-size", nodeLabelSize)
|
|
1115
|
+
.text((d) => d.thisLabel);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// column that tip labels align to when alignTipLabels is set
|
|
1119
|
+
const alignX = xScale(maxX);
|
|
1120
|
+
|
|
1121
|
+
// dashed guide lines from each tip's true branch end to the aligned label column
|
|
1122
|
+
if (tipLabels && alignTipLabels) {
|
|
1123
|
+
group
|
|
1124
|
+
.append("g")
|
|
1125
|
+
.attr("class", "phylo_align_guides")
|
|
1126
|
+
.selectAll("line")
|
|
1127
|
+
.data(tips)
|
|
1128
|
+
.join("line")
|
|
1129
|
+
.attr("x1", (d) => xScale(d.x1))
|
|
1130
|
+
.attr("x2", alignX)
|
|
1131
|
+
.attr("y1", (d) => yScale(d.y1))
|
|
1132
|
+
.attr("y2", (d) => yScale(d.y1))
|
|
1133
|
+
.attr("stroke", "#999")
|
|
1134
|
+
.attr("stroke-width", 1)
|
|
1135
|
+
.attr("stroke-dasharray", "2,2");
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1030
1138
|
// labels
|
|
1031
1139
|
if (tipLabels) {
|
|
1032
1140
|
const labels = svg
|
|
@@ -1035,7 +1143,7 @@ function drawPhylogeny(
|
|
|
1035
1143
|
.selectAll("text")
|
|
1036
1144
|
.data(tips)
|
|
1037
1145
|
.join("text")
|
|
1038
|
-
.attr("x", (d) => xScale(d.x1) + 4)
|
|
1146
|
+
.attr("x", (d) => (alignTipLabels ? alignX : xScale(d.x1)) + 4)
|
|
1039
1147
|
.attr("y", (d) => yScale(d.y1))
|
|
1040
1148
|
.attr("dy", "0.32em")
|
|
1041
1149
|
.attr("font-size", labelFontSize)
|
|
@@ -1107,6 +1215,17 @@ function drawPhylogeny(
|
|
|
1107
1215
|
}
|
|
1108
1216
|
}
|
|
1109
1217
|
|
|
1218
|
+
if (scaleBar) {
|
|
1219
|
+
addScaleBar(svg, {
|
|
1220
|
+
scale: xScale,
|
|
1221
|
+
basis: maxX,
|
|
1222
|
+
defaultX: margin.left,
|
|
1223
|
+
defaultY: height - margin.bottom / 2,
|
|
1224
|
+
scaleBar,
|
|
1225
|
+
fontSize: labelFontSize
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1110
1229
|
return svg.node();
|
|
1111
1230
|
} else if (layout === "radial") {
|
|
1112
1231
|
// RADIAL LAYOUT
|
|
@@ -1128,7 +1247,7 @@ function drawPhylogeny(
|
|
|
1128
1247
|
|
|
1129
1248
|
|
|
1130
1249
|
// visuals (0 = let spokes reach the dots)
|
|
1131
|
-
const DOT_R = 3;
|
|
1250
|
+
const DOT_R = tipRadius ?? 3;
|
|
1132
1251
|
const END_CAP = 0;
|
|
1133
1252
|
|
|
1134
1253
|
// ===== SCALES / BOUNDS =====
|
|
@@ -1266,6 +1385,25 @@ function drawPhylogeny(
|
|
|
1266
1385
|
.attr("stroke-width", strokeWidth);
|
|
1267
1386
|
});
|
|
1268
1387
|
|
|
1388
|
+
// ===== ALIGN GUIDES (optional) =====
|
|
1389
|
+
// dashed lines from each tip's true position to the common label ring,
|
|
1390
|
+
// for radialMode "phylo" (true terminals) where tips aren't already co-circular
|
|
1391
|
+
if (tipLabels && alignTipLabels && !isOuter) {
|
|
1392
|
+
group
|
|
1393
|
+
.append("g")
|
|
1394
|
+
.attr("class", "phylo_align_guides")
|
|
1395
|
+
.selectAll("line")
|
|
1396
|
+
.data(tips)
|
|
1397
|
+
.join("line")
|
|
1398
|
+
.attr("x1", (d) => xScaleRadial(d.x))
|
|
1399
|
+
.attr("y1", (d) => yScaleRadial(d.y))
|
|
1400
|
+
.attr("x2", (d) => xScaleRadial(tipMaxR * Math.cos(d.angle)))
|
|
1401
|
+
.attr("y2", (d) => yScaleRadial(tipMaxR * Math.sin(d.angle)))
|
|
1402
|
+
.attr("stroke", "#999")
|
|
1403
|
+
.attr("stroke-width", 1)
|
|
1404
|
+
.attr("stroke-dasharray", "2,2");
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1269
1407
|
// ===== TIP DOTS =====
|
|
1270
1408
|
const tipDots = group
|
|
1271
1409
|
.append("g")
|
|
@@ -1293,6 +1431,45 @@ function drawPhylogeny(
|
|
|
1293
1431
|
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1294
1432
|
}
|
|
1295
1433
|
|
|
1434
|
+
// ===== INTERNAL NODE CIRCLES (optional) =====
|
|
1435
|
+
if (internalNodeCircles) {
|
|
1436
|
+
const internalNodes = rad.data.filter((d) => !d.isTip);
|
|
1437
|
+
const internalDots = group
|
|
1438
|
+
.append("g")
|
|
1439
|
+
.attr("class", "phylo_internal_dots")
|
|
1440
|
+
.selectAll("circle")
|
|
1441
|
+
.data(internalNodes)
|
|
1442
|
+
.join("circle")
|
|
1443
|
+
.attr("cx", (d) => xScaleRadial(d.x))
|
|
1444
|
+
.attr("cy", (d) => yScaleRadial(d.y))
|
|
1445
|
+
.attr("r", internalNodeRadius)
|
|
1446
|
+
.attr("fill", "white")
|
|
1447
|
+
.attr("stroke", "#555")
|
|
1448
|
+
.attr("stroke-width", 1);
|
|
1449
|
+
|
|
1450
|
+
if (showTooltips) {
|
|
1451
|
+
internalDots
|
|
1452
|
+
.append("title")
|
|
1453
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// ===== INTERNAL NODE LABELS (optional) =====
|
|
1458
|
+
if (nodeLabels) {
|
|
1459
|
+
const labeledInternalNodes = rad.data.filter((d) => !d.isTip && d.thisLabel);
|
|
1460
|
+
group
|
|
1461
|
+
.append("g")
|
|
1462
|
+
.attr("class", "phylo_node_labels")
|
|
1463
|
+
.selectAll("text")
|
|
1464
|
+
.data(labeledInternalNodes)
|
|
1465
|
+
.join("text")
|
|
1466
|
+
.attr("x", (d) => xScaleRadial(d.x) + 4)
|
|
1467
|
+
.attr("y", (d) => yScaleRadial(d.y) - 4)
|
|
1468
|
+
.attr("font-size", nodeLabelSize)
|
|
1469
|
+
.attr("fill", "black")
|
|
1470
|
+
.text((d) => d.thisLabel);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1296
1473
|
// maps for fast lookup on hover (childId → spoke / arc)
|
|
1297
1474
|
const key = (x) => (typeof x === "string" ? +x : x);
|
|
1298
1475
|
const spokeByChild = new Map(rad.radii.map(s => [key(s.childId ?? s.thisId ?? s.id1), s]));
|
|
@@ -1313,7 +1490,8 @@ function drawPhylogeny(
|
|
|
1313
1490
|
// same tip position rule as dots/spokes:
|
|
1314
1491
|
// - "outer": snap to common ring (tipMaxR)
|
|
1315
1492
|
// - otherwise (e.g. "align"/"phylo"): true tip radius
|
|
1316
|
-
|
|
1493
|
+
// - alignTipLabels forces the common ring regardless of mode
|
|
1494
|
+
const r = (isOuter || alignTipLabels) ? tipMaxR : d.r;
|
|
1317
1495
|
const x = r * Math.cos(d.angle);
|
|
1318
1496
|
const y = r * Math.sin(d.angle);
|
|
1319
1497
|
return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
|
|
@@ -1460,6 +1638,17 @@ function drawPhylogeny(
|
|
|
1460
1638
|
});
|
|
1461
1639
|
}
|
|
1462
1640
|
|
|
1641
|
+
if (scaleBar) {
|
|
1642
|
+
addScaleBar(svg, {
|
|
1643
|
+
scale: xScaleRadial,
|
|
1644
|
+
basis: maxRadius,
|
|
1645
|
+
defaultX: 20,
|
|
1646
|
+
defaultY: h - 20,
|
|
1647
|
+
scaleBar,
|
|
1648
|
+
fontSize: labelFontSize
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1463
1652
|
return svg.node();
|
|
1464
1653
|
} else if (layout === "unrooted") {
|
|
1465
1654
|
// UNROOTED LAYOUT
|
|
@@ -1509,6 +1698,8 @@ function drawPhylogeny(
|
|
|
1509
1698
|
.attr("stroke-width", strokeWidth)
|
|
1510
1699
|
.attr("stroke", "#777");
|
|
1511
1700
|
|
|
1701
|
+
const R_TIP = tipRadius ?? 4;
|
|
1702
|
+
|
|
1512
1703
|
const nodes = group
|
|
1513
1704
|
.append("g")
|
|
1514
1705
|
.attr("class", "phylo_points")
|
|
@@ -1516,13 +1707,28 @@ function drawPhylogeny(
|
|
|
1516
1707
|
.data(unrootedPhylo.data)
|
|
1517
1708
|
.join("circle")
|
|
1518
1709
|
.attr("class", "dot")
|
|
1519
|
-
.attr("r", (d) => (d.isTip ?
|
|
1710
|
+
.attr("r", (d) => (d.isTip ? R_TIP : (internalNodeCircles ? internalNodeRadius : 0)))
|
|
1520
1711
|
.attr("cx", (d) => xScaleUnroot(d.x))
|
|
1521
1712
|
.attr("cy", (d) => yScaleUnroot(d.y))
|
|
1522
1713
|
.attr("stroke", "black")
|
|
1523
1714
|
.attr("stroke-width", 2)
|
|
1524
1715
|
.attr("fill", (d) => (d.isTip ? "black" : "white"));
|
|
1525
1716
|
|
|
1717
|
+
if (nodeLabels) {
|
|
1718
|
+
const labeledInternalNodes = unrootedPhylo.data.filter((d) => !d.isTip && d.thisLabel);
|
|
1719
|
+
group
|
|
1720
|
+
.append("g")
|
|
1721
|
+
.attr("class", "phylo_node_labels")
|
|
1722
|
+
.selectAll("text")
|
|
1723
|
+
.data(labeledInternalNodes)
|
|
1724
|
+
.join("text")
|
|
1725
|
+
.attr("x", (d) => xScaleUnroot(d.x) + 4)
|
|
1726
|
+
.attr("y", (d) => yScaleUnroot(d.y) - 4)
|
|
1727
|
+
.attr("font-size", nodeLabelSize)
|
|
1728
|
+
.attr("fill", "black")
|
|
1729
|
+
.text((d) => d.thisLabel);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1526
1732
|
const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1527
1733
|
const tipById = new Map(
|
|
1528
1734
|
unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
|
|
@@ -1612,11 +1818,11 @@ function drawPhylogeny(
|
|
|
1612
1818
|
.filter((d) => d.isTip)
|
|
1613
1819
|
.on("mouseenter", function(_event, d) {
|
|
1614
1820
|
drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1615
|
-
d3.select(this).attr("r",
|
|
1821
|
+
d3.select(this).attr("r", R_TIP + 2);
|
|
1616
1822
|
})
|
|
1617
1823
|
.on("mouseleave", function() {
|
|
1618
1824
|
hoverLayer.selectAll("*").remove();
|
|
1619
|
-
d3.select(this).attr("r",
|
|
1825
|
+
d3.select(this).attr("r", R_TIP);
|
|
1620
1826
|
});
|
|
1621
1827
|
|
|
1622
1828
|
if (highlightTips && highlightTips.length) {
|
|
@@ -1659,6 +1865,17 @@ function drawPhylogeny(
|
|
|
1659
1865
|
}
|
|
1660
1866
|
}
|
|
1661
1867
|
|
|
1868
|
+
if (scaleBar) {
|
|
1869
|
+
addScaleBar(svg, {
|
|
1870
|
+
scale: xScaleUnroot,
|
|
1871
|
+
basis: maxRadius,
|
|
1872
|
+
defaultX: 20,
|
|
1873
|
+
defaultY: h - 20,
|
|
1874
|
+
scaleBar,
|
|
1875
|
+
fontSize: labelFontSize
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1662
1879
|
return svg.node();
|
|
1663
1880
|
} else {
|
|
1664
1881
|
throw new Error(
|