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