@euphrasiologist/lwphylo 1.2.1 → 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 +484 -121
- package/dist/drawPhylogeny.cjs.map +1 -1
- package/dist/drawPhylogeny.esm.js +484 -121
- 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 +484 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +484 -121
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -395,17 +395,50 @@ function getArcs(pd) {
|
|
|
395
395
|
return arcs;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Per-child "half" arcs for radial trees.
|
|
400
|
+
*
|
|
401
|
+
* For each non-root node (child), emit an arc at the PARENT's radius that
|
|
402
|
+
* spans between the parent's angle and the child's angle. This is the arc
|
|
403
|
+
* segment that meets the child's spoke and is ideal for root→tip highlighting.
|
|
404
|
+
*
|
|
405
|
+
* Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
|
|
406
|
+
* Output: [{ parentId, childId, radius, start, end }]
|
|
407
|
+
*/
|
|
408
|
+
function getChildArcs(pd) {
|
|
409
|
+
const byId = new Map(pd.map(d => [d.thisId, d]));
|
|
410
|
+
const arcs = [];
|
|
411
|
+
|
|
412
|
+
for (const child of pd) {
|
|
413
|
+
if (child.parentId == null) continue; // skip root
|
|
414
|
+
const parent = byId.get(child.parentId);
|
|
415
|
+
if (!parent) continue;
|
|
416
|
+
|
|
417
|
+
arcs.push({
|
|
418
|
+
parentId: parent.thisId,
|
|
419
|
+
childId: child.thisId,
|
|
420
|
+
radius: parent.r, // draw on the parent's circle
|
|
421
|
+
start: parent.angle, // start at parent's angle
|
|
422
|
+
end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return arcs;
|
|
427
|
+
}
|
|
428
|
+
|
|
398
429
|
/**
|
|
399
430
|
* Simple wrapper for radial layout:
|
|
400
431
|
* - data: per-node { angle, r, x, y, ... }
|
|
401
432
|
* - radii: per-edge radial spokes (parent.r → child.r)
|
|
402
|
-
* - arcs: per-
|
|
433
|
+
* - arcs: per-parent arcs spanning all children at parent's radius
|
|
434
|
+
* - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
|
|
403
435
|
*/
|
|
404
436
|
function radialLayout(node) {
|
|
405
437
|
const data = {};
|
|
406
438
|
data.data = radialData(node);
|
|
407
439
|
data.radii = getRadii(node);
|
|
408
440
|
data.arcs = getArcs(data.data);
|
|
441
|
+
data.child_arcs = getChildArcs(data.data);
|
|
409
442
|
return data;
|
|
410
443
|
}
|
|
411
444
|
|
|
@@ -839,15 +872,42 @@ function subTree (tree, node) {
|
|
|
839
872
|
function drawPhylogeny(
|
|
840
873
|
treeText,
|
|
841
874
|
{
|
|
842
|
-
layout = "rect", //
|
|
875
|
+
layout = "rect", // rect/radial/unrooted
|
|
843
876
|
width = 800,
|
|
844
877
|
height = 800,
|
|
845
878
|
margin = { top: 20, right: 300, bottom: 20, left: 50 },
|
|
846
879
|
radialMargin = 80,
|
|
847
|
-
strokeWidth = 1,
|
|
848
|
-
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
|
|
849
891
|
} = {}
|
|
850
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
|
+
|
|
851
911
|
if (layout === "rect") {
|
|
852
912
|
// RECTANGULAR LAYOUT
|
|
853
913
|
const tree_df = rectangleLayout(readTree(treeText));
|
|
@@ -855,6 +915,12 @@ function drawPhylogeny(
|
|
|
855
915
|
const vertical = tree_df.vertical_lines;
|
|
856
916
|
const tips = horizontal.filter((d) => d.isTip);
|
|
857
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
|
+
|
|
858
924
|
const maxY = d3.max(horizontal, (d) => d.y1);
|
|
859
925
|
const minY = d3.min(horizontal, (d) => d.y1);
|
|
860
926
|
const maxX = d3.max(horizontal, (d) => d.x1);
|
|
@@ -878,6 +944,10 @@ function drawPhylogeny(
|
|
|
878
944
|
|
|
879
945
|
const group = svg.append("g");
|
|
880
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
|
+
|
|
881
951
|
group
|
|
882
952
|
.selectAll(".hline")
|
|
883
953
|
.data(horizontal)
|
|
@@ -900,7 +970,8 @@ function drawPhylogeny(
|
|
|
900
970
|
.attr("stroke", "#555")
|
|
901
971
|
.attr("stroke-width", strokeWidth);
|
|
902
972
|
|
|
903
|
-
|
|
973
|
+
// tip dots
|
|
974
|
+
const tipDots = group
|
|
904
975
|
.selectAll(".tip-dot")
|
|
905
976
|
.data(tips)
|
|
906
977
|
.join("circle")
|
|
@@ -909,24 +980,111 @@ function drawPhylogeny(
|
|
|
909
980
|
.attr("r", 2)
|
|
910
981
|
.attr("fill", "black");
|
|
911
982
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
.
|
|
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
|
+
}
|
|
922
1080
|
|
|
923
1081
|
return svg.node();
|
|
924
1082
|
} else if (layout === "radial") {
|
|
925
1083
|
const parsedTree = readTree(treeText);
|
|
926
1084
|
const rad = radialLayout(parsedTree);
|
|
927
1085
|
|
|
928
|
-
// ===== MODE
|
|
929
|
-
const TIP_MODE = radialMode; // "
|
|
1086
|
+
// ===== MODE =====
|
|
1087
|
+
const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
|
|
930
1088
|
const isOuter = TIP_MODE === "outer";
|
|
931
1089
|
|
|
932
1090
|
// visuals (0 = let spokes reach the dots)
|
|
@@ -955,6 +1113,9 @@ function drawPhylogeny(
|
|
|
955
1113
|
const byId = new Map(rad.data.map((d) => [d.thisId, d]));
|
|
956
1114
|
const tips = rad.data.filter((d) => d.isTip);
|
|
957
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:
|
|
958
1119
|
|
|
959
1120
|
// Robust child-id extractor (handles multiple shapes)
|
|
960
1121
|
function childIdOf(spoke) {
|
|
@@ -985,6 +1146,12 @@ function drawPhylogeny(
|
|
|
985
1146
|
|
|
986
1147
|
const group = svg.append("g");
|
|
987
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
|
+
|
|
988
1155
|
// ===== ARCS (parent circles) =====
|
|
989
1156
|
group
|
|
990
1157
|
.append("g")
|
|
@@ -1012,7 +1179,7 @@ function drawPhylogeny(
|
|
|
1012
1179
|
.selectAll("line")
|
|
1013
1180
|
.data(rad.radii)
|
|
1014
1181
|
.join("line")
|
|
1015
|
-
.each(function(s,
|
|
1182
|
+
.each(function(s, _i) {
|
|
1016
1183
|
// parent end (data space)
|
|
1017
1184
|
const x0 = s.x0,
|
|
1018
1185
|
y0 = s.y0;
|
|
@@ -1045,13 +1212,13 @@ function drawPhylogeny(
|
|
|
1045
1212
|
});
|
|
1046
1213
|
|
|
1047
1214
|
// ===== TIP DOTS =====
|
|
1048
|
-
group
|
|
1215
|
+
const tipDots = group
|
|
1049
1216
|
.append("g")
|
|
1050
1217
|
.attr("class", "phylo_tip_dots")
|
|
1051
1218
|
.selectAll("circle")
|
|
1052
1219
|
.data(tips)
|
|
1053
1220
|
.join("circle")
|
|
1054
|
-
.each(function(d,
|
|
1221
|
+
.each(function(d, _i) {
|
|
1055
1222
|
// dot at original tip (align) or projected circle (outer)
|
|
1056
1223
|
const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
|
|
1057
1224
|
const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
|
|
@@ -1065,45 +1232,174 @@ function drawPhylogeny(
|
|
|
1065
1232
|
.attr("stroke-width", 1.5);
|
|
1066
1233
|
});
|
|
1067
1234
|
|
|
1068
|
-
|
|
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 =====
|
|
1069
1246
|
// Labels — make them follow the tip position used by the current mode
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
angle
|
|
1093
|
-
|
|
1094
|
-
|
|
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");
|
|
1095
1344
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
+
);
|
|
1106
1401
|
});
|
|
1402
|
+
}
|
|
1107
1403
|
|
|
1108
1404
|
return svg.node();
|
|
1109
1405
|
} else if (layout === "unrooted") {
|
|
@@ -1114,23 +1410,17 @@ function drawPhylogeny(
|
|
|
1114
1410
|
const w = width;
|
|
1115
1411
|
const h = height;
|
|
1116
1412
|
|
|
1117
|
-
// Get spatial extent
|
|
1118
1413
|
const xExtent = d3.extent(unrootedPhylo.data, (d) => d.x);
|
|
1119
1414
|
const yExtent = d3.extent(unrootedPhylo.data, (d) => d.y);
|
|
1120
|
-
|
|
1121
|
-
// Find maximum absolute distance from center (0,0)
|
|
1122
1415
|
const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
|
|
1123
1416
|
const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
|
|
1124
1417
|
const maxRadius = Math.max(maxX, maxY);
|
|
1125
|
-
|
|
1126
|
-
// Add some margin
|
|
1127
1418
|
const scaleUnroot = maxRadius + 2 * radialMargin;
|
|
1128
1419
|
|
|
1129
1420
|
const xScaleUnroot = d3
|
|
1130
1421
|
.scaleLinear()
|
|
1131
1422
|
.domain([-scaleUnroot, scaleUnroot])
|
|
1132
1423
|
.range([0, w]);
|
|
1133
|
-
|
|
1134
1424
|
const yScaleUnroot = d3
|
|
1135
1425
|
.scaleLinear()
|
|
1136
1426
|
.domain([-scaleUnroot, scaleUnroot])
|
|
@@ -1144,8 +1434,9 @@ function drawPhylogeny(
|
|
|
1144
1434
|
.attr("font-size", 10);
|
|
1145
1435
|
|
|
1146
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");
|
|
1147
1439
|
|
|
1148
|
-
// Edges
|
|
1149
1440
|
group
|
|
1150
1441
|
.append("g")
|
|
1151
1442
|
.attr("class", "phylo_lines")
|
|
@@ -1159,8 +1450,7 @@ function drawPhylogeny(
|
|
|
1159
1450
|
.attr("stroke-width", strokeWidth)
|
|
1160
1451
|
.attr("stroke", "#777");
|
|
1161
1452
|
|
|
1162
|
-
|
|
1163
|
-
group
|
|
1453
|
+
const nodes = group
|
|
1164
1454
|
.append("g")
|
|
1165
1455
|
.attr("class", "phylo_points")
|
|
1166
1456
|
.selectAll("circle")
|
|
@@ -1174,74 +1464,147 @@ function drawPhylogeny(
|
|
|
1174
1464
|
.attr("stroke-width", 2)
|
|
1175
1465
|
.attr("fill", (d) => (d.isTip ? "black" : "white"));
|
|
1176
1466
|
|
|
1177
|
-
|
|
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
|
+
|
|
1178
1483
|
const tipEdges = new Map();
|
|
1179
1484
|
const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1180
|
-
|
|
1181
1485
|
unrootedPhylo.edges.forEach((edge) => {
|
|
1182
1486
|
const tipNode = nodesById.get(edge.id1);
|
|
1183
|
-
if (tipNode?.isTip)
|
|
1184
|
-
tipEdges.set(edge.id1, edge);
|
|
1185
|
-
}
|
|
1487
|
+
if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
|
|
1186
1488
|
});
|
|
1187
1489
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
);
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
+
});
|
|
1209
1534
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
const dx = x2 - x1;
|
|
1217
|
-
const dy = y2 - y1;
|
|
1218
|
-
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1219
|
-
|
|
1220
|
-
// Flip label if upside down
|
|
1221
|
-
let xOffset = -10;
|
|
1222
|
-
let anchor = "end";
|
|
1223
|
-
if (angle > 90 || angle < -90) {
|
|
1224
|
-
angle += 180;
|
|
1225
|
-
anchor = "start";
|
|
1226
|
-
xOffset = 10;
|
|
1227
|
-
}
|
|
1535
|
+
if (showTooltips) {
|
|
1536
|
+
tipLabelsSel
|
|
1537
|
+
.append("title")
|
|
1538
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1539
|
+
}
|
|
1228
1540
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
.
|
|
1232
|
-
.attr("
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
.
|
|
1236
|
-
.attr("
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
+
);
|
|
1240
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
|
+
}
|
|
1241
1602
|
|
|
1242
1603
|
return svg.node();
|
|
1243
1604
|
} else {
|
|
1244
|
-
throw new Error(
|
|
1605
|
+
throw new Error(
|
|
1606
|
+
"Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
|
|
1607
|
+
);
|
|
1245
1608
|
}
|
|
1246
1609
|
}
|
|
1247
1610
|
|