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