@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.cjs
CHANGED
|
@@ -416,17 +416,50 @@ function getArcs(pd) {
|
|
|
416
416
|
return arcs;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Per-child "half" arcs for radial trees.
|
|
421
|
+
*
|
|
422
|
+
* For each non-root node (child), emit an arc at the PARENT's radius that
|
|
423
|
+
* spans between the parent's angle and the child's angle. This is the arc
|
|
424
|
+
* segment that meets the child's spoke and is ideal for root→tip highlighting.
|
|
425
|
+
*
|
|
426
|
+
* Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
|
|
427
|
+
* Output: [{ parentId, childId, radius, start, end }]
|
|
428
|
+
*/
|
|
429
|
+
function getChildArcs(pd) {
|
|
430
|
+
const byId = new Map(pd.map(d => [d.thisId, d]));
|
|
431
|
+
const arcs = [];
|
|
432
|
+
|
|
433
|
+
for (const child of pd) {
|
|
434
|
+
if (child.parentId == null) continue; // skip root
|
|
435
|
+
const parent = byId.get(child.parentId);
|
|
436
|
+
if (!parent) continue;
|
|
437
|
+
|
|
438
|
+
arcs.push({
|
|
439
|
+
parentId: parent.thisId,
|
|
440
|
+
childId: child.thisId,
|
|
441
|
+
radius: parent.r, // draw on the parent's circle
|
|
442
|
+
start: parent.angle, // start at parent's angle
|
|
443
|
+
end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return arcs;
|
|
448
|
+
}
|
|
449
|
+
|
|
419
450
|
/**
|
|
420
451
|
* Simple wrapper for radial layout:
|
|
421
452
|
* - data: per-node { angle, r, x, y, ... }
|
|
422
453
|
* - radii: per-edge radial spokes (parent.r → child.r)
|
|
423
|
-
* - arcs: per-
|
|
454
|
+
* - arcs: per-parent arcs spanning all children at parent's radius
|
|
455
|
+
* - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
|
|
424
456
|
*/
|
|
425
457
|
function radialLayout(node) {
|
|
426
458
|
const data = {};
|
|
427
459
|
data.data = radialData(node);
|
|
428
460
|
data.radii = getRadii(node);
|
|
429
461
|
data.arcs = getArcs(data.data);
|
|
462
|
+
data.child_arcs = getChildArcs(data.data);
|
|
430
463
|
return data;
|
|
431
464
|
}
|
|
432
465
|
|
|
@@ -860,15 +893,42 @@ function subTree (tree, node) {
|
|
|
860
893
|
function drawPhylogeny(
|
|
861
894
|
treeText,
|
|
862
895
|
{
|
|
863
|
-
layout = "rect", //
|
|
896
|
+
layout = "rect", // rect/radial/unrooted
|
|
864
897
|
width = 800,
|
|
865
898
|
height = 800,
|
|
866
899
|
margin = { top: 20, right: 300, bottom: 20, left: 50 },
|
|
867
900
|
radialMargin = 80,
|
|
868
|
-
strokeWidth = 1,
|
|
869
|
-
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
|
|
870
912
|
} = {}
|
|
871
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
|
+
|
|
872
932
|
if (layout === "rect") {
|
|
873
933
|
// RECTANGULAR LAYOUT
|
|
874
934
|
const tree_df = rectangleLayout(readTree(treeText));
|
|
@@ -876,6 +936,12 @@ function drawPhylogeny(
|
|
|
876
936
|
const vertical = tree_df.vertical_lines;
|
|
877
937
|
const tips = horizontal.filter((d) => d.isTip);
|
|
878
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
|
+
|
|
879
945
|
const maxY = d3__namespace.max(horizontal, (d) => d.y1);
|
|
880
946
|
const minY = d3__namespace.min(horizontal, (d) => d.y1);
|
|
881
947
|
const maxX = d3__namespace.max(horizontal, (d) => d.x1);
|
|
@@ -899,6 +965,10 @@ function drawPhylogeny(
|
|
|
899
965
|
|
|
900
966
|
const group = svg.append("g");
|
|
901
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
|
+
|
|
902
972
|
group
|
|
903
973
|
.selectAll(".hline")
|
|
904
974
|
.data(horizontal)
|
|
@@ -921,7 +991,8 @@ function drawPhylogeny(
|
|
|
921
991
|
.attr("stroke", "#555")
|
|
922
992
|
.attr("stroke-width", strokeWidth);
|
|
923
993
|
|
|
924
|
-
|
|
994
|
+
// tip dots
|
|
995
|
+
const tipDots = group
|
|
925
996
|
.selectAll(".tip-dot")
|
|
926
997
|
.data(tips)
|
|
927
998
|
.join("circle")
|
|
@@ -930,24 +1001,111 @@ function drawPhylogeny(
|
|
|
930
1001
|
.attr("r", 2)
|
|
931
1002
|
.attr("fill", "black");
|
|
932
1003
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
.
|
|
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
|
+
}
|
|
943
1101
|
|
|
944
1102
|
return svg.node();
|
|
945
1103
|
} else if (layout === "radial") {
|
|
946
1104
|
const parsedTree = readTree(treeText);
|
|
947
1105
|
const rad = radialLayout(parsedTree);
|
|
948
1106
|
|
|
949
|
-
// ===== MODE
|
|
950
|
-
const TIP_MODE = radialMode; // "
|
|
1107
|
+
// ===== MODE =====
|
|
1108
|
+
const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
|
|
951
1109
|
const isOuter = TIP_MODE === "outer";
|
|
952
1110
|
|
|
953
1111
|
// visuals (0 = let spokes reach the dots)
|
|
@@ -976,6 +1134,9 @@ function drawPhylogeny(
|
|
|
976
1134
|
const byId = new Map(rad.data.map((d) => [d.thisId, d]));
|
|
977
1135
|
const tips = rad.data.filter((d) => d.isTip);
|
|
978
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:
|
|
979
1140
|
|
|
980
1141
|
// Robust child-id extractor (handles multiple shapes)
|
|
981
1142
|
function childIdOf(spoke) {
|
|
@@ -1006,6 +1167,12 @@ function drawPhylogeny(
|
|
|
1006
1167
|
|
|
1007
1168
|
const group = svg.append("g");
|
|
1008
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
|
+
|
|
1009
1176
|
// ===== ARCS (parent circles) =====
|
|
1010
1177
|
group
|
|
1011
1178
|
.append("g")
|
|
@@ -1033,7 +1200,7 @@ function drawPhylogeny(
|
|
|
1033
1200
|
.selectAll("line")
|
|
1034
1201
|
.data(rad.radii)
|
|
1035
1202
|
.join("line")
|
|
1036
|
-
.each(function(s,
|
|
1203
|
+
.each(function(s, _i) {
|
|
1037
1204
|
// parent end (data space)
|
|
1038
1205
|
const x0 = s.x0,
|
|
1039
1206
|
y0 = s.y0;
|
|
@@ -1066,13 +1233,13 @@ function drawPhylogeny(
|
|
|
1066
1233
|
});
|
|
1067
1234
|
|
|
1068
1235
|
// ===== TIP DOTS =====
|
|
1069
|
-
group
|
|
1236
|
+
const tipDots = group
|
|
1070
1237
|
.append("g")
|
|
1071
1238
|
.attr("class", "phylo_tip_dots")
|
|
1072
1239
|
.selectAll("circle")
|
|
1073
1240
|
.data(tips)
|
|
1074
1241
|
.join("circle")
|
|
1075
|
-
.each(function(d,
|
|
1242
|
+
.each(function(d, _i) {
|
|
1076
1243
|
// dot at original tip (align) or projected circle (outer)
|
|
1077
1244
|
const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
|
|
1078
1245
|
const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
|
|
@@ -1086,45 +1253,174 @@ function drawPhylogeny(
|
|
|
1086
1253
|
.attr("stroke-width", 1.5);
|
|
1087
1254
|
});
|
|
1088
1255
|
|
|
1089
|
-
|
|
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 =====
|
|
1090
1267
|
// Labels — make them follow the tip position used by the current mode
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
angle
|
|
1114
|
-
|
|
1115
|
-
|
|
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");
|
|
1116
1365
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
+
);
|
|
1127
1422
|
});
|
|
1423
|
+
}
|
|
1128
1424
|
|
|
1129
1425
|
return svg.node();
|
|
1130
1426
|
} else if (layout === "unrooted") {
|
|
@@ -1135,23 +1431,17 @@ function drawPhylogeny(
|
|
|
1135
1431
|
const w = width;
|
|
1136
1432
|
const h = height;
|
|
1137
1433
|
|
|
1138
|
-
// Get spatial extent
|
|
1139
1434
|
const xExtent = d3__namespace.extent(unrootedPhylo.data, (d) => d.x);
|
|
1140
1435
|
const yExtent = d3__namespace.extent(unrootedPhylo.data, (d) => d.y);
|
|
1141
|
-
|
|
1142
|
-
// Find maximum absolute distance from center (0,0)
|
|
1143
1436
|
const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
|
|
1144
1437
|
const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
|
|
1145
1438
|
const maxRadius = Math.max(maxX, maxY);
|
|
1146
|
-
|
|
1147
|
-
// Add some margin
|
|
1148
1439
|
const scaleUnroot = maxRadius + 2 * radialMargin;
|
|
1149
1440
|
|
|
1150
1441
|
const xScaleUnroot = d3__namespace
|
|
1151
1442
|
.scaleLinear()
|
|
1152
1443
|
.domain([-scaleUnroot, scaleUnroot])
|
|
1153
1444
|
.range([0, w]);
|
|
1154
|
-
|
|
1155
1445
|
const yScaleUnroot = d3__namespace
|
|
1156
1446
|
.scaleLinear()
|
|
1157
1447
|
.domain([-scaleUnroot, scaleUnroot])
|
|
@@ -1165,8 +1455,9 @@ function drawPhylogeny(
|
|
|
1165
1455
|
.attr("font-size", 10);
|
|
1166
1456
|
|
|
1167
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");
|
|
1168
1460
|
|
|
1169
|
-
// Edges
|
|
1170
1461
|
group
|
|
1171
1462
|
.append("g")
|
|
1172
1463
|
.attr("class", "phylo_lines")
|
|
@@ -1180,8 +1471,7 @@ function drawPhylogeny(
|
|
|
1180
1471
|
.attr("stroke-width", strokeWidth)
|
|
1181
1472
|
.attr("stroke", "#777");
|
|
1182
1473
|
|
|
1183
|
-
|
|
1184
|
-
group
|
|
1474
|
+
const nodes = group
|
|
1185
1475
|
.append("g")
|
|
1186
1476
|
.attr("class", "phylo_points")
|
|
1187
1477
|
.selectAll("circle")
|
|
@@ -1195,74 +1485,147 @@ function drawPhylogeny(
|
|
|
1195
1485
|
.attr("stroke-width", 2)
|
|
1196
1486
|
.attr("fill", (d) => (d.isTip ? "black" : "white"));
|
|
1197
1487
|
|
|
1198
|
-
|
|
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
|
+
|
|
1199
1504
|
const tipEdges = new Map();
|
|
1200
1505
|
const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1201
|
-
|
|
1202
1506
|
unrootedPhylo.edges.forEach((edge) => {
|
|
1203
1507
|
const tipNode = nodesById.get(edge.id1);
|
|
1204
|
-
if (tipNode?.isTip)
|
|
1205
|
-
tipEdges.set(edge.id1, edge);
|
|
1206
|
-
}
|
|
1508
|
+
if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
|
|
1207
1509
|
});
|
|
1208
1510
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
);
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
+
});
|
|
1230
1555
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
const dx = x2 - x1;
|
|
1238
|
-
const dy = y2 - y1;
|
|
1239
|
-
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1240
|
-
|
|
1241
|
-
// Flip label if upside down
|
|
1242
|
-
let xOffset = -10;
|
|
1243
|
-
let anchor = "end";
|
|
1244
|
-
if (angle > 90 || angle < -90) {
|
|
1245
|
-
angle += 180;
|
|
1246
|
-
anchor = "start";
|
|
1247
|
-
xOffset = 10;
|
|
1248
|
-
}
|
|
1556
|
+
if (showTooltips) {
|
|
1557
|
+
tipLabelsSel
|
|
1558
|
+
.append("title")
|
|
1559
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1560
|
+
}
|
|
1249
1561
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
.
|
|
1253
|
-
.attr("
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
.
|
|
1257
|
-
.attr("
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
+
);
|
|
1261
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
|
+
}
|
|
1262
1623
|
|
|
1263
1624
|
return svg.node();
|
|
1264
1625
|
} else {
|
|
1265
|
-
throw new Error(
|
|
1626
|
+
throw new Error(
|
|
1627
|
+
"Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
|
|
1628
|
+
);
|
|
1266
1629
|
}
|
|
1267
1630
|
}
|
|
1268
1631
|
|