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