@euphrasiologist/lwphylo 1.2.2 → 1.2.4
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/drawPhylogeny.cjs +460 -119
- package/dist/drawPhylogeny.cjs.map +1 -1
- package/dist/drawPhylogeny.esm.js +460 -119
- 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 +460 -119
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +460 -119
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -872,15 +872,53 @@ function subTree (tree, node) {
|
|
|
872
872
|
function drawPhylogeny(
|
|
873
873
|
treeText,
|
|
874
874
|
{
|
|
875
|
-
layout = "rect", //
|
|
875
|
+
layout = "rect", // rect/radial/unrooted
|
|
876
876
|
width = 800,
|
|
877
877
|
height = 800,
|
|
878
878
|
margin = { top: 20, right: 300, bottom: 20, left: 50 },
|
|
879
879
|
radialMargin = 80,
|
|
880
|
-
strokeWidth = 1,
|
|
881
|
-
radialMode = "outer" // or "
|
|
880
|
+
strokeWidth = 1, // for the phylogeny branches
|
|
881
|
+
radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
|
|
882
|
+
tipLabels = true,
|
|
883
|
+
showTooltips = true,
|
|
884
|
+
tooltipFormatter = (d, rtt) =>
|
|
885
|
+
`${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
|
|
886
|
+
hoverStroke = "#1f77b4",
|
|
887
|
+
hoverWidth = 3,
|
|
888
|
+
highlightTips = [], // array of tip labels or ids for static highlight (optional)
|
|
889
|
+
highlightStroke = "#e63946",
|
|
890
|
+
highlightWidth = 2.5
|
|
882
891
|
} = {}
|
|
883
892
|
) {
|
|
893
|
+
|
|
894
|
+
// shared helpers
|
|
895
|
+
const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
|
|
896
|
+
// Works for both radial (uses `r`) and rect (uses `x1`).
|
|
897
|
+
// Falls back to summing branchLength up to the root if neither is present.
|
|
898
|
+
function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
|
|
899
|
+
return function rootToTip(tipId) {
|
|
900
|
+
let n = byId.get(tipId);
|
|
901
|
+
if (!n) return 0;
|
|
902
|
+
|
|
903
|
+
// Prefer explicit cumulative fields if present
|
|
904
|
+
if (prefer === "r" || (prefer === "auto" && "r" in n)) {
|
|
905
|
+
return Number(n.r ?? 0);
|
|
906
|
+
}
|
|
907
|
+
if (prefer === "x1" || (prefer === "auto" && "x1" in n)) {
|
|
908
|
+
return Number(n.x1 ?? 0);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Fallback: sum branchLength up the ancestry
|
|
912
|
+
let sum = 0;
|
|
913
|
+
while (n && n.parentId != null) {
|
|
914
|
+
sum += Number(n.branchLength || 0); // null/undefined → 0
|
|
915
|
+
n = byId.get(n.parentId);
|
|
916
|
+
}
|
|
917
|
+
return sum;
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
|
|
884
922
|
if (layout === "rect") {
|
|
885
923
|
// RECTANGULAR LAYOUT
|
|
886
924
|
const tree_df = rectangleLayout(readTree(treeText));
|
|
@@ -888,6 +926,12 @@ function drawPhylogeny(
|
|
|
888
926
|
const vertical = tree_df.vertical_lines;
|
|
889
927
|
const tips = horizontal.filter((d) => d.isTip);
|
|
890
928
|
|
|
929
|
+
// indices & root→tip getter
|
|
930
|
+
const byId = new Map(horizontal.map((d) => [d.thisId, d]));
|
|
931
|
+
const tipById = new Map(tips.map((d) => [d.thisId, d]));
|
|
932
|
+
const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
|
|
933
|
+
const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
|
|
934
|
+
|
|
891
935
|
const maxY = d3.max(horizontal, (d) => d.y1);
|
|
892
936
|
const minY = d3.min(horizontal, (d) => d.y1);
|
|
893
937
|
const maxX = d3.max(horizontal, (d) => d.x1);
|
|
@@ -911,6 +955,10 @@ function drawPhylogeny(
|
|
|
911
955
|
|
|
912
956
|
const group = svg.append("g");
|
|
913
957
|
|
|
958
|
+
// layers for highlight/hover
|
|
959
|
+
const staticLayer = svg.append("g").attr("class", "phylo_static_highlight");
|
|
960
|
+
const hoverLayer = svg.append("g").attr("class", "phylo_hover_highlight");
|
|
961
|
+
|
|
914
962
|
group
|
|
915
963
|
.selectAll(".hline")
|
|
916
964
|
.data(horizontal)
|
|
@@ -933,7 +981,8 @@ function drawPhylogeny(
|
|
|
933
981
|
.attr("stroke", "#555")
|
|
934
982
|
.attr("stroke-width", strokeWidth);
|
|
935
983
|
|
|
936
|
-
|
|
984
|
+
// tip dots
|
|
985
|
+
const tipDots = group
|
|
937
986
|
.selectAll(".tip-dot")
|
|
938
987
|
.data(tips)
|
|
939
988
|
.join("circle")
|
|
@@ -942,16 +991,103 @@ function drawPhylogeny(
|
|
|
942
991
|
.attr("r", 2)
|
|
943
992
|
.attr("fill", "black");
|
|
944
993
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
.
|
|
994
|
+
// tooltips for rect dots
|
|
995
|
+
if (showTooltips) {
|
|
996
|
+
tipDots
|
|
997
|
+
.append("title")
|
|
998
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// interactive root→tip highlight (rect) on dot hover
|
|
1002
|
+
tipDots
|
|
1003
|
+
.on("mouseenter", function(_event, d) {
|
|
1004
|
+
drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1005
|
+
d3.select(this).attr("r", 4);
|
|
1006
|
+
})
|
|
1007
|
+
.on("mouseleave", function() {
|
|
1008
|
+
hoverLayer.selectAll("*").remove();
|
|
1009
|
+
d3.select(this).attr("r", 2);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// labels
|
|
1013
|
+
if (tipLabels) {
|
|
1014
|
+
const labels = svg
|
|
1015
|
+
.append("g")
|
|
1016
|
+
.attr("class", "phylo_labels")
|
|
1017
|
+
.selectAll("text")
|
|
1018
|
+
.data(tips)
|
|
1019
|
+
.join("text")
|
|
1020
|
+
.attr("x", (d) => xScale(d.x1) + 4)
|
|
1021
|
+
.attr("y", (d) => yScale(d.y1))
|
|
1022
|
+
.attr("dy", "0.32em")
|
|
1023
|
+
.attr("font-size", 10)
|
|
1024
|
+
.text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
1025
|
+
|
|
1026
|
+
if (showTooltips) {
|
|
1027
|
+
labels
|
|
1028
|
+
.append("title")
|
|
1029
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
labels
|
|
1033
|
+
.on("mouseenter", function(_event, d) {
|
|
1034
|
+
drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1035
|
+
d3.select(this).attr("font-weight", 600);
|
|
1036
|
+
})
|
|
1037
|
+
.on("mouseleave", function() {
|
|
1038
|
+
hoverLayer.selectAll("*").remove();
|
|
1039
|
+
d3.select(this).attr("font-weight", null);
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// static highlight by ids/labels
|
|
1044
|
+
if (highlightTips && highlightTips.length) {
|
|
1045
|
+
const chosen = new Set(
|
|
1046
|
+
[
|
|
1047
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
1048
|
+
...highlightTips
|
|
1049
|
+
.filter((x) => !isNumber(x))
|
|
1050
|
+
.map((lb) => tipByLabel.get(lb))
|
|
1051
|
+
].filter(Boolean)
|
|
1052
|
+
);
|
|
1053
|
+
chosen.forEach((tip) => {
|
|
1054
|
+
drawRectPath(tip.thisId, staticLayer, highlightStroke, highlightWidth);
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// helper to draw root→tip for rect (both vertical+horizontal)
|
|
1059
|
+
function drawRectPath(tipId, layer, stroke, width) {
|
|
1060
|
+
layer.selectAll("*").remove();
|
|
1061
|
+
let cur = byId.get(tipId);
|
|
1062
|
+
while (cur && cur.parentId != null) {
|
|
1063
|
+
const parent = byId.get(cur.parentId);
|
|
1064
|
+
if (!parent) break;
|
|
1065
|
+
|
|
1066
|
+
// vertical at junction x0 from parent.y to child.y
|
|
1067
|
+
layer
|
|
1068
|
+
.append("line")
|
|
1069
|
+
.attr("x1", xScale(cur.x0))
|
|
1070
|
+
.attr("x2", xScale(cur.x0))
|
|
1071
|
+
.attr("y1", yScale(parent.y0))
|
|
1072
|
+
.attr("y2", yScale(cur.y0))
|
|
1073
|
+
.attr("stroke", stroke)
|
|
1074
|
+
.attr("stroke-width", width)
|
|
1075
|
+
.attr("stroke-linecap", "round");
|
|
1076
|
+
|
|
1077
|
+
// horizontal along child's y, from junction x0 to x1
|
|
1078
|
+
layer
|
|
1079
|
+
.append("line")
|
|
1080
|
+
.attr("x1", xScale(cur.x0))
|
|
1081
|
+
.attr("x2", xScale(cur.x1))
|
|
1082
|
+
.attr("y1", yScale(cur.y0))
|
|
1083
|
+
.attr("y2", yScale(cur.y1))
|
|
1084
|
+
.attr("stroke", stroke)
|
|
1085
|
+
.attr("stroke-width", width)
|
|
1086
|
+
.attr("stroke-linecap", "round");
|
|
1087
|
+
|
|
1088
|
+
cur = parent;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
955
1091
|
|
|
956
1092
|
return svg.node();
|
|
957
1093
|
} else if (layout === "radial") {
|
|
@@ -959,7 +1095,7 @@ function drawPhylogeny(
|
|
|
959
1095
|
const rad = radialLayout(parsedTree);
|
|
960
1096
|
|
|
961
1097
|
// ===== MODE =====
|
|
962
|
-
const TIP_MODE = radialMode; // "
|
|
1098
|
+
const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
|
|
963
1099
|
const isOuter = TIP_MODE === "outer";
|
|
964
1100
|
|
|
965
1101
|
// visuals (0 = let spokes reach the dots)
|
|
@@ -988,6 +1124,9 @@ function drawPhylogeny(
|
|
|
988
1124
|
const byId = new Map(rad.data.map((d) => [d.thisId, d]));
|
|
989
1125
|
const tips = rad.data.filter((d) => d.isTip);
|
|
990
1126
|
const tipMaxR = tips.length ? d3.max(tips, (d) => d.r) : 0;
|
|
1127
|
+
const rootToTip = makeRootToTipGetter(byId, { prefer: "r" });
|
|
1128
|
+
const tipById = new Map(tips.map((d) => [d.thisId, d])); // HILITE:
|
|
1129
|
+
const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d])); // HILITE:
|
|
991
1130
|
|
|
992
1131
|
// Robust child-id extractor (handles multiple shapes)
|
|
993
1132
|
function childIdOf(spoke) {
|
|
@@ -1018,6 +1157,12 @@ function drawPhylogeny(
|
|
|
1018
1157
|
|
|
1019
1158
|
const group = svg.append("g");
|
|
1020
1159
|
|
|
1160
|
+
// overlay groups (drawn on top)
|
|
1161
|
+
const staticLines = svg.append("g").attr("class", "phylo_static_lines"); // HILITE:
|
|
1162
|
+
const staticArcs = svg.append("g").attr("class", "phylo_static_arcs"); // HILITE:
|
|
1163
|
+
const hoverLines = svg.append("g").attr("class", "phylo_hover_lines"); // HILITE:
|
|
1164
|
+
const hoverArcs = svg.append("g").attr("class", "phylo_hover_arcs"); // HILITE:
|
|
1165
|
+
|
|
1021
1166
|
// ===== ARCS (parent circles) =====
|
|
1022
1167
|
group
|
|
1023
1168
|
.append("g")
|
|
@@ -1045,7 +1190,7 @@ function drawPhylogeny(
|
|
|
1045
1190
|
.selectAll("line")
|
|
1046
1191
|
.data(rad.radii)
|
|
1047
1192
|
.join("line")
|
|
1048
|
-
.each(function(s,
|
|
1193
|
+
.each(function(s, _i) {
|
|
1049
1194
|
// parent end (data space)
|
|
1050
1195
|
const x0 = s.x0,
|
|
1051
1196
|
y0 = s.y0;
|
|
@@ -1078,13 +1223,13 @@ function drawPhylogeny(
|
|
|
1078
1223
|
});
|
|
1079
1224
|
|
|
1080
1225
|
// ===== TIP DOTS =====
|
|
1081
|
-
group
|
|
1226
|
+
const tipDots = group
|
|
1082
1227
|
.append("g")
|
|
1083
1228
|
.attr("class", "phylo_tip_dots")
|
|
1084
1229
|
.selectAll("circle")
|
|
1085
1230
|
.data(tips)
|
|
1086
1231
|
.join("circle")
|
|
1087
|
-
.each(function(d,
|
|
1232
|
+
.each(function(d, _i) {
|
|
1088
1233
|
// dot at original tip (align) or projected circle (outer)
|
|
1089
1234
|
const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
|
|
1090
1235
|
const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
|
|
@@ -1098,46 +1243,175 @@ function drawPhylogeny(
|
|
|
1098
1243
|
.attr("stroke-width", 1.5);
|
|
1099
1244
|
});
|
|
1100
1245
|
|
|
1101
|
-
|
|
1246
|
+
if (showTooltips) {
|
|
1247
|
+
tipDots
|
|
1248
|
+
.append("title")
|
|
1249
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// maps for fast lookup on hover (childId → spoke / arc)
|
|
1253
|
+
const spokeByChild = new Map(rad.radii.map((s) => [childIdOf(s), s]));
|
|
1254
|
+
const arcByChild = new Map(rad.child_arcs.map((a) => [a.childId, a]));
|
|
1255
|
+
|
|
1256
|
+
// ===== LABELS =====
|
|
1102
1257
|
// Labels — make them follow the tip position used by the current mode
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
angle
|
|
1126
|
-
|
|
1127
|
-
|
|
1258
|
+
if (tipLabels) {
|
|
1259
|
+
const labels = group
|
|
1260
|
+
.append("g")
|
|
1261
|
+
.attr("class", "phylo_labels")
|
|
1262
|
+
.selectAll("g.label")
|
|
1263
|
+
.data(tips)
|
|
1264
|
+
.join("g")
|
|
1265
|
+
.attr("class", "label")
|
|
1266
|
+
.attr("transform", (d) => {
|
|
1267
|
+
// same tip position rule as dots/spokes:
|
|
1268
|
+
// - "outer": snap to common ring (tipMaxR)
|
|
1269
|
+
// - otherwise (e.g. "align"/"phylo"): true tip radius
|
|
1270
|
+
const r = isOuter ? tipMaxR : d.r;
|
|
1271
|
+
const x = r * Math.cos(d.angle);
|
|
1272
|
+
const y = r * Math.sin(d.angle);
|
|
1273
|
+
return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
|
|
1274
|
+
})
|
|
1275
|
+
.each(function(d) {
|
|
1276
|
+
// rotate so text reads outward; flip when on the left side
|
|
1277
|
+
let angle = (-d.angle * 180) / Math.PI;
|
|
1278
|
+
let xoff = 10; // radial padding for text (px)
|
|
1279
|
+
let anchor = "start";
|
|
1280
|
+
if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
|
|
1281
|
+
angle += 180;
|
|
1282
|
+
xoff *= -1;
|
|
1283
|
+
anchor = "end";
|
|
1284
|
+
}
|
|
1285
|
+
d3.select(this)
|
|
1286
|
+
.append("g")
|
|
1287
|
+
.attr("transform", `rotate(${angle})`)
|
|
1288
|
+
.append("text")
|
|
1289
|
+
.attr("x", xoff)
|
|
1290
|
+
.attr("alignment-baseline", "middle")
|
|
1291
|
+
.attr("text-anchor", anchor)
|
|
1292
|
+
.attr("font-size", 10)
|
|
1293
|
+
.attr("fill", "black")
|
|
1294
|
+
.text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
if (showTooltips) {
|
|
1298
|
+
labels
|
|
1299
|
+
.append("title")
|
|
1300
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// label hover
|
|
1304
|
+
labels
|
|
1305
|
+
.on("mouseenter", function(event, d) {
|
|
1306
|
+
drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
|
|
1307
|
+
d3.select(this).select("text").attr("font-weight", 600);
|
|
1308
|
+
})
|
|
1309
|
+
.on("mouseleave", function() {
|
|
1310
|
+
hoverLines.selectAll("*").remove();
|
|
1311
|
+
hoverArcs.selectAll("*").remove();
|
|
1312
|
+
d3.select(this).select("text").attr("font-weight", null);
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// draw (overlay) the root→tip path: spokes + arcs (half-arc per child)
|
|
1317
|
+
function drawRadialPath(
|
|
1318
|
+
target,
|
|
1319
|
+
lineLayer,
|
|
1320
|
+
arcLayer,
|
|
1321
|
+
stroke = "#1f77b4",
|
|
1322
|
+
width = 3
|
|
1323
|
+
) {
|
|
1324
|
+
// target may be a tip node *or* a numeric tip id
|
|
1325
|
+
lineLayer.selectAll("*").remove();
|
|
1326
|
+
arcLayer.selectAll("*").remove();
|
|
1327
|
+
|
|
1328
|
+
let cur = typeof target === "number" ? byId.get(target) : target;
|
|
1329
|
+
if (!cur) return;
|
|
1330
|
+
|
|
1331
|
+
let first = true;
|
|
1332
|
+
while (cur && cur.parentId != null) {
|
|
1333
|
+
// ----- spoke (parent → child) -----
|
|
1334
|
+
const s = spokeByChild.get(cur.thisId);
|
|
1335
|
+
if (s) {
|
|
1336
|
+
const px = s.x0,
|
|
1337
|
+
py = s.y0;
|
|
1338
|
+
let cx = s.x1,
|
|
1339
|
+
cy = s.y1;
|
|
1340
|
+
if (isOuter && first && cur.isTip) {
|
|
1341
|
+
const r = tipMaxR;
|
|
1342
|
+
cx = r * Math.cos(cur.angle);
|
|
1343
|
+
cy = r * Math.sin(cur.angle);
|
|
1344
|
+
}
|
|
1345
|
+
const { X0, Y0, X1s, Y1s } = shortenSpokePx(px, py, cx, cy);
|
|
1346
|
+
lineLayer
|
|
1347
|
+
.append("line")
|
|
1348
|
+
.attr("x1", X0)
|
|
1349
|
+
.attr("y1", Y0)
|
|
1350
|
+
.attr("x2", X1s)
|
|
1351
|
+
.attr("y2", Y1s)
|
|
1352
|
+
.attr("stroke", stroke)
|
|
1353
|
+
.attr("stroke-width", width)
|
|
1354
|
+
.attr("stroke-linecap", "round");
|
|
1128
1355
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1356
|
+
|
|
1357
|
+
// ----- half-arc at parent radius (parent.angle → child.angle) -----
|
|
1358
|
+
const a = arcByChild.get(cur.thisId);
|
|
1359
|
+
if (a) {
|
|
1360
|
+
arcLayer
|
|
1361
|
+
.append("path")
|
|
1362
|
+
.attr(
|
|
1363
|
+
"d",
|
|
1364
|
+
describeArc(
|
|
1365
|
+
centerX,
|
|
1366
|
+
centerY,
|
|
1367
|
+
Math.max(0, radiusPx(a.radius)),
|
|
1368
|
+
a.start,
|
|
1369
|
+
a.end
|
|
1370
|
+
)
|
|
1371
|
+
)
|
|
1372
|
+
.attr("fill", "none")
|
|
1373
|
+
.attr("stroke", stroke)
|
|
1374
|
+
.attr("stroke-width", width);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
first = false;
|
|
1378
|
+
cur = byId.get(cur.parentId);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// tip dot hover
|
|
1383
|
+
tipDots
|
|
1384
|
+
.on("mouseenter", function(_event, d) {
|
|
1385
|
+
drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
|
|
1386
|
+
d3.select(this).attr("r", DOT_R + 2);
|
|
1387
|
+
})
|
|
1388
|
+
.on("mouseleave", function() {
|
|
1389
|
+
hoverLines.selectAll("*").remove();
|
|
1390
|
+
hoverArcs.selectAll("*").remove();
|
|
1391
|
+
d3.select(this).attr("r", DOT_R);
|
|
1139
1392
|
});
|
|
1140
1393
|
|
|
1394
|
+
if (highlightTips && highlightTips.length) {
|
|
1395
|
+
const chosen = new Set(
|
|
1396
|
+
[
|
|
1397
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
1398
|
+
...highlightTips
|
|
1399
|
+
.filter((x) => !isNumber(x))
|
|
1400
|
+
.map((lb) => tipByLabel.get(lb))
|
|
1401
|
+
].filter(Boolean)
|
|
1402
|
+
);
|
|
1403
|
+
|
|
1404
|
+
chosen.forEach((tip) => {
|
|
1405
|
+
drawRadialPath(
|
|
1406
|
+
tip.thisId,
|
|
1407
|
+
staticLines,
|
|
1408
|
+
staticArcs,
|
|
1409
|
+
highlightStroke,
|
|
1410
|
+
highlightWidth
|
|
1411
|
+
);
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1141
1415
|
return svg.node();
|
|
1142
1416
|
} else if (layout === "unrooted") {
|
|
1143
1417
|
// UNROOTED LAYOUT
|
|
@@ -1147,23 +1421,17 @@ function drawPhylogeny(
|
|
|
1147
1421
|
const w = width;
|
|
1148
1422
|
const h = height;
|
|
1149
1423
|
|
|
1150
|
-
// Get spatial extent
|
|
1151
1424
|
const xExtent = d3.extent(unrootedPhylo.data, (d) => d.x);
|
|
1152
1425
|
const yExtent = d3.extent(unrootedPhylo.data, (d) => d.y);
|
|
1153
|
-
|
|
1154
|
-
// Find maximum absolute distance from center (0,0)
|
|
1155
1426
|
const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
|
|
1156
1427
|
const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
|
|
1157
1428
|
const maxRadius = Math.max(maxX, maxY);
|
|
1158
|
-
|
|
1159
|
-
// Add some margin
|
|
1160
1429
|
const scaleUnroot = maxRadius + 2 * radialMargin;
|
|
1161
1430
|
|
|
1162
1431
|
const xScaleUnroot = d3
|
|
1163
1432
|
.scaleLinear()
|
|
1164
1433
|
.domain([-scaleUnroot, scaleUnroot])
|
|
1165
1434
|
.range([0, w]);
|
|
1166
|
-
|
|
1167
1435
|
const yScaleUnroot = d3
|
|
1168
1436
|
.scaleLinear()
|
|
1169
1437
|
.domain([-scaleUnroot, scaleUnroot])
|
|
@@ -1177,8 +1445,9 @@ function drawPhylogeny(
|
|
|
1177
1445
|
.attr("font-size", 10);
|
|
1178
1446
|
|
|
1179
1447
|
const group = svg.append("g");
|
|
1448
|
+
const staticLayer = svg.append("g").attr("class", "phylo_static_highlight");
|
|
1449
|
+
const hoverLayer = svg.append("g").attr("class", "phylo_hover_highlight");
|
|
1180
1450
|
|
|
1181
|
-
// Edges
|
|
1182
1451
|
group
|
|
1183
1452
|
.append("g")
|
|
1184
1453
|
.attr("class", "phylo_lines")
|
|
@@ -1192,8 +1461,7 @@ function drawPhylogeny(
|
|
|
1192
1461
|
.attr("stroke-width", strokeWidth)
|
|
1193
1462
|
.attr("stroke", "#777");
|
|
1194
1463
|
|
|
1195
|
-
|
|
1196
|
-
group
|
|
1464
|
+
const nodes = group
|
|
1197
1465
|
.append("g")
|
|
1198
1466
|
.attr("class", "phylo_points")
|
|
1199
1467
|
.selectAll("circle")
|
|
@@ -1207,74 +1475,147 @@ function drawPhylogeny(
|
|
|
1207
1475
|
.attr("stroke-width", 2)
|
|
1208
1476
|
.attr("fill", (d) => (d.isTip ? "black" : "white"));
|
|
1209
1477
|
|
|
1210
|
-
|
|
1478
|
+
const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1479
|
+
const tipById = new Map(
|
|
1480
|
+
unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
|
|
1481
|
+
);
|
|
1482
|
+
const tipByLabel = new Map(
|
|
1483
|
+
unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisLabel, d])
|
|
1484
|
+
);
|
|
1485
|
+
const rootToTip = makeRootToTipGetter(byId, { prefer: "r" });
|
|
1486
|
+
|
|
1487
|
+
if (showTooltips) {
|
|
1488
|
+
nodes
|
|
1489
|
+
.filter((d) => d.isTip)
|
|
1490
|
+
.append("title")
|
|
1491
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1211
1494
|
const tipEdges = new Map();
|
|
1212
1495
|
const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1213
|
-
|
|
1214
1496
|
unrootedPhylo.edges.forEach((edge) => {
|
|
1215
1497
|
const tipNode = nodesById.get(edge.id1);
|
|
1216
|
-
if (tipNode?.isTip)
|
|
1217
|
-
tipEdges.set(edge.id1, edge);
|
|
1218
|
-
}
|
|
1498
|
+
if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
|
|
1219
1499
|
});
|
|
1220
1500
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
);
|
|
1240
|
-
|
|
1241
|
-
|
|
1501
|
+
if (tipLabels) {
|
|
1502
|
+
const tipLabelsSel = group
|
|
1503
|
+
.append("g")
|
|
1504
|
+
.attr("class", "phylo_labels")
|
|
1505
|
+
.selectAll("g")
|
|
1506
|
+
.data(unrootedPhylo.data.filter((d) => d.isTip))
|
|
1507
|
+
.join("g")
|
|
1508
|
+
.attr("transform", (d) => {
|
|
1509
|
+
const x = xScaleUnroot(d.x);
|
|
1510
|
+
const y = yScaleUnroot(d.y);
|
|
1511
|
+
return `translate(${x},${y})`;
|
|
1512
|
+
})
|
|
1513
|
+
.each(function(d) {
|
|
1514
|
+
const edge = tipEdges.get(d.thisId);
|
|
1515
|
+
if (!edge) return;
|
|
1516
|
+
|
|
1517
|
+
const x1 = xScaleUnroot(edge.x1);
|
|
1518
|
+
const y1 = yScaleUnroot(edge.y1);
|
|
1519
|
+
const x2 = xScaleUnroot(edge.x2);
|
|
1520
|
+
const y2 = yScaleUnroot(edge.y2);
|
|
1521
|
+
|
|
1522
|
+
const dx = x2 - x1;
|
|
1523
|
+
const dy = y2 - y1;
|
|
1524
|
+
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1525
|
+
|
|
1526
|
+
let xOffset = -10;
|
|
1527
|
+
let anchor = "end";
|
|
1528
|
+
if (angle > 90 || angle < -90) {
|
|
1529
|
+
angle += 180;
|
|
1530
|
+
anchor = "start";
|
|
1531
|
+
xOffset = 10;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
d3.select(this)
|
|
1535
|
+
.append("g")
|
|
1536
|
+
.attr("transform", `rotate(${angle})`)
|
|
1537
|
+
.append("text")
|
|
1538
|
+
.attr("x", xOffset)
|
|
1539
|
+
.attr("alignment-baseline", "middle")
|
|
1540
|
+
.attr("text-anchor", anchor)
|
|
1541
|
+
.attr("font-size", 10)
|
|
1542
|
+
.attr("fill", "black")
|
|
1543
|
+
.text(d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
1544
|
+
});
|
|
1242
1545
|
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const dx = x2 - x1;
|
|
1250
|
-
const dy = y2 - y1;
|
|
1251
|
-
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1252
|
-
|
|
1253
|
-
// Flip label if upside down
|
|
1254
|
-
let xOffset = -10;
|
|
1255
|
-
let anchor = "end";
|
|
1256
|
-
if (angle > 90 || angle < -90) {
|
|
1257
|
-
angle += 180;
|
|
1258
|
-
anchor = "start";
|
|
1259
|
-
xOffset = 10;
|
|
1260
|
-
}
|
|
1546
|
+
if (showTooltips) {
|
|
1547
|
+
tipLabelsSel
|
|
1548
|
+
.append("title")
|
|
1549
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1550
|
+
}
|
|
1261
1551
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
.
|
|
1265
|
-
.attr("
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
.
|
|
1269
|
-
.attr("
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1552
|
+
tipLabelsSel
|
|
1553
|
+
.on("mouseenter", function(_event, d) {
|
|
1554
|
+
drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1555
|
+
d3.select(this).select("text").attr("font-weight", 600);
|
|
1556
|
+
})
|
|
1557
|
+
.on("mouseleave", function() {
|
|
1558
|
+
hoverLayer.selectAll("*").remove();
|
|
1559
|
+
d3.select(this).select("text").attr("font-weight", null);
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
nodes
|
|
1564
|
+
.filter((d) => d.isTip)
|
|
1565
|
+
.on("mouseenter", function(_event, d) {
|
|
1566
|
+
drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1567
|
+
d3.select(this).attr("r", 6);
|
|
1568
|
+
})
|
|
1569
|
+
.on("mouseleave", function() {
|
|
1570
|
+
hoverLayer.selectAll("*").remove();
|
|
1571
|
+
d3.select(this).attr("r", 4);
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
if (highlightTips && highlightTips.length) {
|
|
1575
|
+
const chosen = new Set(
|
|
1576
|
+
[
|
|
1577
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
1578
|
+
...highlightTips
|
|
1579
|
+
.filter((x) => !isNumber(x))
|
|
1580
|
+
.map((lb) => tipByLabel.get(lb))
|
|
1581
|
+
].filter(Boolean)
|
|
1582
|
+
);
|
|
1583
|
+
chosen.forEach((tip) => {
|
|
1584
|
+
drawUnrootedPath(
|
|
1585
|
+
tip.thisId,
|
|
1586
|
+
staticLayer,
|
|
1587
|
+
highlightStroke,
|
|
1588
|
+
highlightWidth
|
|
1589
|
+
);
|
|
1273
1590
|
});
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function drawUnrootedPath(tipId, layer, stroke, width) {
|
|
1594
|
+
const edgeFromChild = new Map(unrootedPhylo.edges.map((e) => [e.id1, e]));
|
|
1595
|
+
layer.selectAll("*").remove();
|
|
1596
|
+
let cur = byId.get(tipId);
|
|
1597
|
+
while (cur && cur.parentId != null) {
|
|
1598
|
+
const e = edgeFromChild.get(cur.thisId);
|
|
1599
|
+
if (e) {
|
|
1600
|
+
layer
|
|
1601
|
+
.append("line")
|
|
1602
|
+
.attr("x1", xScaleUnroot(e.x1))
|
|
1603
|
+
.attr("y1", yScaleUnroot(e.y1))
|
|
1604
|
+
.attr("x2", xScaleUnroot(e.x2))
|
|
1605
|
+
.attr("y2", yScaleUnroot(e.y2))
|
|
1606
|
+
.attr("stroke", stroke)
|
|
1607
|
+
.attr("stroke-width", width)
|
|
1608
|
+
.attr("stroke-linecap", "round");
|
|
1609
|
+
}
|
|
1610
|
+
cur = byId.get(cur.parentId);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1274
1613
|
|
|
1275
1614
|
return svg.node();
|
|
1276
1615
|
} else {
|
|
1277
|
-
throw new Error(
|
|
1616
|
+
throw new Error(
|
|
1617
|
+
"Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
|
|
1618
|
+
);
|
|
1278
1619
|
}
|
|
1279
1620
|
}
|
|
1280
1621
|
|