@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/drawPhylogeny.cjs
CHANGED
|
@@ -303,17 +303,50 @@ function getArcs(pd) {
|
|
|
303
303
|
return arcs;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Per-child "half" arcs for radial trees.
|
|
308
|
+
*
|
|
309
|
+
* For each non-root node (child), emit an arc at the PARENT's radius that
|
|
310
|
+
* spans between the parent's angle and the child's angle. This is the arc
|
|
311
|
+
* segment that meets the child's spoke and is ideal for root→tip highlighting.
|
|
312
|
+
*
|
|
313
|
+
* Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
|
|
314
|
+
* Output: [{ parentId, childId, radius, start, end }]
|
|
315
|
+
*/
|
|
316
|
+
function getChildArcs(pd) {
|
|
317
|
+
const byId = new Map(pd.map(d => [d.thisId, d]));
|
|
318
|
+
const arcs = [];
|
|
319
|
+
|
|
320
|
+
for (const child of pd) {
|
|
321
|
+
if (child.parentId == null) continue; // skip root
|
|
322
|
+
const parent = byId.get(child.parentId);
|
|
323
|
+
if (!parent) continue;
|
|
324
|
+
|
|
325
|
+
arcs.push({
|
|
326
|
+
parentId: parent.thisId,
|
|
327
|
+
childId: child.thisId,
|
|
328
|
+
radius: parent.r, // draw on the parent's circle
|
|
329
|
+
start: parent.angle, // start at parent's angle
|
|
330
|
+
end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return arcs;
|
|
335
|
+
}
|
|
336
|
+
|
|
306
337
|
/**
|
|
307
338
|
* Simple wrapper for radial layout:
|
|
308
339
|
* - data: per-node { angle, r, x, y, ... }
|
|
309
340
|
* - radii: per-edge radial spokes (parent.r → child.r)
|
|
310
|
-
* - arcs: per-
|
|
341
|
+
* - arcs: per-parent arcs spanning all children at parent's radius
|
|
342
|
+
* - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
|
|
311
343
|
*/
|
|
312
344
|
function radialLayout(node) {
|
|
313
345
|
const data = {};
|
|
314
346
|
data.data = radialData(node);
|
|
315
347
|
data.radii = getRadii(node);
|
|
316
348
|
data.arcs = getArcs(data.data);
|
|
349
|
+
data.child_arcs = getChildArcs(data.data);
|
|
317
350
|
return data;
|
|
318
351
|
}
|
|
319
352
|
|
|
@@ -693,15 +726,42 @@ function readTree(text) {
|
|
|
693
726
|
function drawPhylogeny(
|
|
694
727
|
treeText,
|
|
695
728
|
{
|
|
696
|
-
layout = "rect", //
|
|
729
|
+
layout = "rect", // rect/radial/unrooted
|
|
697
730
|
width = 800,
|
|
698
731
|
height = 800,
|
|
699
732
|
margin = { top: 20, right: 300, bottom: 20, left: 50 },
|
|
700
733
|
radialMargin = 80,
|
|
701
|
-
strokeWidth = 1,
|
|
702
|
-
radialMode = "outer" // or "
|
|
734
|
+
strokeWidth = 1, // for the phylogeny branches
|
|
735
|
+
radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
|
|
736
|
+
tipLabels = true,
|
|
737
|
+
showTooltips = true,
|
|
738
|
+
tooltipFormatter = (d, rtt) =>
|
|
739
|
+
`${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
|
|
740
|
+
hoverStroke = "#1f77b4",
|
|
741
|
+
hoverWidth = 3,
|
|
742
|
+
highlightTips = [], // array of tip labels or ids for static highlight (optional)
|
|
743
|
+
highlightStroke = "#e63946",
|
|
744
|
+
highlightWidth = 2.5
|
|
703
745
|
} = {}
|
|
704
746
|
) {
|
|
747
|
+
|
|
748
|
+
// shared helpers
|
|
749
|
+
const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
|
|
750
|
+
function makeRootToTipGetter(byId) {
|
|
751
|
+
const memo = new Map();
|
|
752
|
+
return function rootToTip(id) {
|
|
753
|
+
if (memo.has(id)) return memo.get(id);
|
|
754
|
+
let cur = byId.get(id);
|
|
755
|
+
let sum = 0;
|
|
756
|
+
while (cur && cur.parentId != null) {
|
|
757
|
+
sum += +cur.branchLength || 0;
|
|
758
|
+
cur = byId.get(cur.parentId);
|
|
759
|
+
}
|
|
760
|
+
memo.set(id, sum);
|
|
761
|
+
return sum;
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
705
765
|
if (layout === "rect") {
|
|
706
766
|
// RECTANGULAR LAYOUT
|
|
707
767
|
const tree_df = rectangleLayout(readTree(treeText));
|
|
@@ -709,6 +769,12 @@ function drawPhylogeny(
|
|
|
709
769
|
const vertical = tree_df.vertical_lines;
|
|
710
770
|
const tips = horizontal.filter((d) => d.isTip);
|
|
711
771
|
|
|
772
|
+
// indices & root→tip getter
|
|
773
|
+
const byId = new Map(horizontal.map((d) => [d.thisId, d]));
|
|
774
|
+
const tipById = new Map(tips.map((d) => [d.thisId, d]));
|
|
775
|
+
const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
|
|
776
|
+
const rootToTip = makeRootToTipGetter(byId);
|
|
777
|
+
|
|
712
778
|
const maxY = d3__namespace.max(horizontal, (d) => d.y1);
|
|
713
779
|
const minY = d3__namespace.min(horizontal, (d) => d.y1);
|
|
714
780
|
const maxX = d3__namespace.max(horizontal, (d) => d.x1);
|
|
@@ -732,6 +798,10 @@ function drawPhylogeny(
|
|
|
732
798
|
|
|
733
799
|
const group = svg.append("g");
|
|
734
800
|
|
|
801
|
+
// layers for highlight/hover
|
|
802
|
+
const staticLayer = svg.append("g").attr("class", "phylo_static_highlight");
|
|
803
|
+
const hoverLayer = svg.append("g").attr("class", "phylo_hover_highlight");
|
|
804
|
+
|
|
735
805
|
group
|
|
736
806
|
.selectAll(".hline")
|
|
737
807
|
.data(horizontal)
|
|
@@ -754,7 +824,8 @@ function drawPhylogeny(
|
|
|
754
824
|
.attr("stroke", "#555")
|
|
755
825
|
.attr("stroke-width", strokeWidth);
|
|
756
826
|
|
|
757
|
-
|
|
827
|
+
// tip dots
|
|
828
|
+
const tipDots = group
|
|
758
829
|
.selectAll(".tip-dot")
|
|
759
830
|
.data(tips)
|
|
760
831
|
.join("circle")
|
|
@@ -763,24 +834,111 @@ function drawPhylogeny(
|
|
|
763
834
|
.attr("r", 2)
|
|
764
835
|
.attr("fill", "black");
|
|
765
836
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
.
|
|
837
|
+
// tooltips for rect dots
|
|
838
|
+
if (showTooltips) {
|
|
839
|
+
tipDots
|
|
840
|
+
.append("title")
|
|
841
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// interactive root→tip highlight (rect) on dot hover
|
|
845
|
+
tipDots
|
|
846
|
+
.on("mouseenter", function(_event, d) {
|
|
847
|
+
drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
848
|
+
d3__namespace.select(this).attr("r", 4);
|
|
849
|
+
})
|
|
850
|
+
.on("mouseleave", function() {
|
|
851
|
+
hoverLayer.selectAll("*").remove();
|
|
852
|
+
d3__namespace.select(this).attr("r", 2);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// labels
|
|
856
|
+
if (tipLabels) {
|
|
857
|
+
const labels = svg
|
|
858
|
+
.append("g")
|
|
859
|
+
.attr("class", "phylo_labels")
|
|
860
|
+
.selectAll("text")
|
|
861
|
+
.data(tips)
|
|
862
|
+
.join("text")
|
|
863
|
+
.attr("x", (d) => xScale(d.x1) + 4)
|
|
864
|
+
.attr("y", (d) => yScale(d.y1))
|
|
865
|
+
.attr("dy", "0.32em")
|
|
866
|
+
.attr("font-size", 10)
|
|
867
|
+
.text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
868
|
+
|
|
869
|
+
if (showTooltips) {
|
|
870
|
+
labels
|
|
871
|
+
.append("title")
|
|
872
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
labels
|
|
876
|
+
.on("mouseenter", function(_event, d) {
|
|
877
|
+
drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
878
|
+
d3__namespace.select(this).attr("font-weight", 600);
|
|
879
|
+
})
|
|
880
|
+
.on("mouseleave", function() {
|
|
881
|
+
hoverLayer.selectAll("*").remove();
|
|
882
|
+
d3__namespace.select(this).attr("font-weight", null);
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// static highlight by ids/labels
|
|
887
|
+
if (highlightTips && highlightTips.length) {
|
|
888
|
+
const chosen = new Set(
|
|
889
|
+
[
|
|
890
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
891
|
+
...highlightTips
|
|
892
|
+
.filter((x) => !isNumber(x))
|
|
893
|
+
.map((lb) => tipByLabel.get(lb))
|
|
894
|
+
].filter(Boolean)
|
|
895
|
+
);
|
|
896
|
+
chosen.forEach((tip) => {
|
|
897
|
+
drawRectPath(tip.thisId, staticLayer, highlightStroke, highlightWidth);
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// helper to draw root→tip for rect (both vertical+horizontal)
|
|
902
|
+
function drawRectPath(tipId, layer, stroke, width) {
|
|
903
|
+
layer.selectAll("*").remove();
|
|
904
|
+
let cur = byId.get(tipId);
|
|
905
|
+
while (cur && cur.parentId != null) {
|
|
906
|
+
const parent = byId.get(cur.parentId);
|
|
907
|
+
if (!parent) break;
|
|
908
|
+
|
|
909
|
+
// vertical at junction x0 from parent.y to child.y
|
|
910
|
+
layer
|
|
911
|
+
.append("line")
|
|
912
|
+
.attr("x1", xScale(cur.x0))
|
|
913
|
+
.attr("x2", xScale(cur.x0))
|
|
914
|
+
.attr("y1", yScale(parent.y0))
|
|
915
|
+
.attr("y2", yScale(cur.y0))
|
|
916
|
+
.attr("stroke", stroke)
|
|
917
|
+
.attr("stroke-width", width)
|
|
918
|
+
.attr("stroke-linecap", "round");
|
|
919
|
+
|
|
920
|
+
// horizontal along child's y, from junction x0 to x1
|
|
921
|
+
layer
|
|
922
|
+
.append("line")
|
|
923
|
+
.attr("x1", xScale(cur.x0))
|
|
924
|
+
.attr("x2", xScale(cur.x1))
|
|
925
|
+
.attr("y1", yScale(cur.y0))
|
|
926
|
+
.attr("y2", yScale(cur.y1))
|
|
927
|
+
.attr("stroke", stroke)
|
|
928
|
+
.attr("stroke-width", width)
|
|
929
|
+
.attr("stroke-linecap", "round");
|
|
930
|
+
|
|
931
|
+
cur = parent;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
776
934
|
|
|
777
935
|
return svg.node();
|
|
778
936
|
} else if (layout === "radial") {
|
|
779
937
|
const parsedTree = readTree(treeText);
|
|
780
938
|
const rad = radialLayout(parsedTree);
|
|
781
939
|
|
|
782
|
-
// ===== MODE
|
|
783
|
-
const TIP_MODE = radialMode; // "
|
|
940
|
+
// ===== MODE =====
|
|
941
|
+
const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
|
|
784
942
|
const isOuter = TIP_MODE === "outer";
|
|
785
943
|
|
|
786
944
|
// visuals (0 = let spokes reach the dots)
|
|
@@ -809,6 +967,9 @@ function drawPhylogeny(
|
|
|
809
967
|
const byId = new Map(rad.data.map((d) => [d.thisId, d]));
|
|
810
968
|
const tips = rad.data.filter((d) => d.isTip);
|
|
811
969
|
const tipMaxR = tips.length ? d3__namespace.max(tips, (d) => d.r) : 0;
|
|
970
|
+
const rootToTip = makeRootToTipGetter(byId);
|
|
971
|
+
const tipById = new Map(tips.map((d) => [d.thisId, d])); // HILITE:
|
|
972
|
+
const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d])); // HILITE:
|
|
812
973
|
|
|
813
974
|
// Robust child-id extractor (handles multiple shapes)
|
|
814
975
|
function childIdOf(spoke) {
|
|
@@ -839,6 +1000,12 @@ function drawPhylogeny(
|
|
|
839
1000
|
|
|
840
1001
|
const group = svg.append("g");
|
|
841
1002
|
|
|
1003
|
+
// overlay groups (drawn on top)
|
|
1004
|
+
const staticLines = svg.append("g").attr("class", "phylo_static_lines"); // HILITE:
|
|
1005
|
+
const staticArcs = svg.append("g").attr("class", "phylo_static_arcs"); // HILITE:
|
|
1006
|
+
const hoverLines = svg.append("g").attr("class", "phylo_hover_lines"); // HILITE:
|
|
1007
|
+
const hoverArcs = svg.append("g").attr("class", "phylo_hover_arcs"); // HILITE:
|
|
1008
|
+
|
|
842
1009
|
// ===== ARCS (parent circles) =====
|
|
843
1010
|
group
|
|
844
1011
|
.append("g")
|
|
@@ -866,7 +1033,7 @@ function drawPhylogeny(
|
|
|
866
1033
|
.selectAll("line")
|
|
867
1034
|
.data(rad.radii)
|
|
868
1035
|
.join("line")
|
|
869
|
-
.each(function(s,
|
|
1036
|
+
.each(function(s, _i) {
|
|
870
1037
|
// parent end (data space)
|
|
871
1038
|
const x0 = s.x0,
|
|
872
1039
|
y0 = s.y0;
|
|
@@ -899,13 +1066,13 @@ function drawPhylogeny(
|
|
|
899
1066
|
});
|
|
900
1067
|
|
|
901
1068
|
// ===== TIP DOTS =====
|
|
902
|
-
group
|
|
1069
|
+
const tipDots = group
|
|
903
1070
|
.append("g")
|
|
904
1071
|
.attr("class", "phylo_tip_dots")
|
|
905
1072
|
.selectAll("circle")
|
|
906
1073
|
.data(tips)
|
|
907
1074
|
.join("circle")
|
|
908
|
-
.each(function(d,
|
|
1075
|
+
.each(function(d, _i) {
|
|
909
1076
|
// dot at original tip (align) or projected circle (outer)
|
|
910
1077
|
const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
|
|
911
1078
|
const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
|
|
@@ -919,45 +1086,174 @@ function drawPhylogeny(
|
|
|
919
1086
|
.attr("stroke-width", 1.5);
|
|
920
1087
|
});
|
|
921
1088
|
|
|
922
|
-
|
|
1089
|
+
if (showTooltips) {
|
|
1090
|
+
tipDots
|
|
1091
|
+
.append("title")
|
|
1092
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// maps for fast lookup on hover (childId → spoke / arc)
|
|
1096
|
+
const spokeByChild = new Map(rad.radii.map((s) => [childIdOf(s), s]));
|
|
1097
|
+
const arcByChild = new Map(rad.child_arcs.map((a) => [a.childId, a]));
|
|
1098
|
+
|
|
1099
|
+
// ===== LABELS =====
|
|
923
1100
|
// Labels — make them follow the tip position used by the current mode
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
angle
|
|
947
|
-
|
|
948
|
-
|
|
1101
|
+
if (tipLabels) {
|
|
1102
|
+
const labels = group
|
|
1103
|
+
.append("g")
|
|
1104
|
+
.attr("class", "phylo_labels")
|
|
1105
|
+
.selectAll("g.label")
|
|
1106
|
+
.data(tips)
|
|
1107
|
+
.join("g")
|
|
1108
|
+
.attr("class", "label")
|
|
1109
|
+
.attr("transform", (d) => {
|
|
1110
|
+
// same tip position rule as dots/spokes:
|
|
1111
|
+
// - "outer": snap to common ring (tipMaxR)
|
|
1112
|
+
// - otherwise (e.g. "align"/"phylo"): true tip radius
|
|
1113
|
+
const r = isOuter ? tipMaxR : d.r;
|
|
1114
|
+
const x = r * Math.cos(d.angle);
|
|
1115
|
+
const y = r * Math.sin(d.angle);
|
|
1116
|
+
return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
|
|
1117
|
+
})
|
|
1118
|
+
.each(function(d) {
|
|
1119
|
+
// rotate so text reads outward; flip when on the left side
|
|
1120
|
+
let angle = (-d.angle * 180) / Math.PI;
|
|
1121
|
+
let xoff = 10; // radial padding for text (px)
|
|
1122
|
+
let anchor = "start";
|
|
1123
|
+
if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
|
|
1124
|
+
angle += 180;
|
|
1125
|
+
xoff *= -1;
|
|
1126
|
+
anchor = "end";
|
|
1127
|
+
}
|
|
1128
|
+
d3__namespace.select(this)
|
|
1129
|
+
.append("g")
|
|
1130
|
+
.attr("transform", `rotate(${angle})`)
|
|
1131
|
+
.append("text")
|
|
1132
|
+
.attr("x", xoff)
|
|
1133
|
+
.attr("alignment-baseline", "middle")
|
|
1134
|
+
.attr("text-anchor", anchor)
|
|
1135
|
+
.attr("font-size", 10)
|
|
1136
|
+
.attr("fill", "black")
|
|
1137
|
+
.text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
if (showTooltips) {
|
|
1141
|
+
labels
|
|
1142
|
+
.append("title")
|
|
1143
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// label hover
|
|
1147
|
+
labels
|
|
1148
|
+
.on("mouseenter", function(event, d) {
|
|
1149
|
+
drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
|
|
1150
|
+
d3__namespace.select(this).select("text").attr("font-weight", 600);
|
|
1151
|
+
})
|
|
1152
|
+
.on("mouseleave", function() {
|
|
1153
|
+
hoverLines.selectAll("*").remove();
|
|
1154
|
+
hoverArcs.selectAll("*").remove();
|
|
1155
|
+
d3__namespace.select(this).select("text").attr("font-weight", null);
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// draw (overlay) the root→tip path: spokes + arcs (half-arc per child)
|
|
1160
|
+
function drawRadialPath(
|
|
1161
|
+
target,
|
|
1162
|
+
lineLayer,
|
|
1163
|
+
arcLayer,
|
|
1164
|
+
stroke = "#1f77b4",
|
|
1165
|
+
width = 3
|
|
1166
|
+
) {
|
|
1167
|
+
// target may be a tip node *or* a numeric tip id
|
|
1168
|
+
lineLayer.selectAll("*").remove();
|
|
1169
|
+
arcLayer.selectAll("*").remove();
|
|
1170
|
+
|
|
1171
|
+
let cur = typeof target === "number" ? byId.get(target) : target;
|
|
1172
|
+
if (!cur) return;
|
|
1173
|
+
|
|
1174
|
+
let first = true;
|
|
1175
|
+
while (cur && cur.parentId != null) {
|
|
1176
|
+
// ----- spoke (parent → child) -----
|
|
1177
|
+
const s = spokeByChild.get(cur.thisId);
|
|
1178
|
+
if (s) {
|
|
1179
|
+
const px = s.x0,
|
|
1180
|
+
py = s.y0;
|
|
1181
|
+
let cx = s.x1,
|
|
1182
|
+
cy = s.y1;
|
|
1183
|
+
if (isOuter && first && cur.isTip) {
|
|
1184
|
+
const r = tipMaxR;
|
|
1185
|
+
cx = r * Math.cos(cur.angle);
|
|
1186
|
+
cy = r * Math.sin(cur.angle);
|
|
1187
|
+
}
|
|
1188
|
+
const { X0, Y0, X1s, Y1s } = shortenSpokePx(px, py, cx, cy);
|
|
1189
|
+
lineLayer
|
|
1190
|
+
.append("line")
|
|
1191
|
+
.attr("x1", X0)
|
|
1192
|
+
.attr("y1", Y0)
|
|
1193
|
+
.attr("x2", X1s)
|
|
1194
|
+
.attr("y2", Y1s)
|
|
1195
|
+
.attr("stroke", stroke)
|
|
1196
|
+
.attr("stroke-width", width)
|
|
1197
|
+
.attr("stroke-linecap", "round");
|
|
949
1198
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1199
|
+
|
|
1200
|
+
// ----- half-arc at parent radius (parent.angle → child.angle) -----
|
|
1201
|
+
const a = arcByChild.get(cur.thisId);
|
|
1202
|
+
if (a) {
|
|
1203
|
+
arcLayer
|
|
1204
|
+
.append("path")
|
|
1205
|
+
.attr(
|
|
1206
|
+
"d",
|
|
1207
|
+
describeArc(
|
|
1208
|
+
centerX,
|
|
1209
|
+
centerY,
|
|
1210
|
+
Math.max(0, radiusPx(a.radius)),
|
|
1211
|
+
a.start,
|
|
1212
|
+
a.end
|
|
1213
|
+
)
|
|
1214
|
+
)
|
|
1215
|
+
.attr("fill", "none")
|
|
1216
|
+
.attr("stroke", stroke)
|
|
1217
|
+
.attr("stroke-width", width);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
first = false;
|
|
1221
|
+
cur = byId.get(cur.parentId);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// tip dot hover
|
|
1226
|
+
tipDots
|
|
1227
|
+
.on("mouseenter", function(_event, d) {
|
|
1228
|
+
drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
|
|
1229
|
+
d3__namespace.select(this).attr("r", DOT_R + 2);
|
|
1230
|
+
})
|
|
1231
|
+
.on("mouseleave", function() {
|
|
1232
|
+
hoverLines.selectAll("*").remove();
|
|
1233
|
+
hoverArcs.selectAll("*").remove();
|
|
1234
|
+
d3__namespace.select(this).attr("r", DOT_R);
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
if (highlightTips && highlightTips.length) {
|
|
1238
|
+
const chosen = new Set(
|
|
1239
|
+
[
|
|
1240
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
1241
|
+
...highlightTips
|
|
1242
|
+
.filter((x) => !isNumber(x))
|
|
1243
|
+
.map((lb) => tipByLabel.get(lb))
|
|
1244
|
+
].filter(Boolean)
|
|
1245
|
+
);
|
|
1246
|
+
|
|
1247
|
+
chosen.forEach((tip) => {
|
|
1248
|
+
drawRadialPath(
|
|
1249
|
+
tip.thisId,
|
|
1250
|
+
staticLines,
|
|
1251
|
+
staticArcs,
|
|
1252
|
+
highlightStroke,
|
|
1253
|
+
highlightWidth
|
|
1254
|
+
);
|
|
960
1255
|
});
|
|
1256
|
+
}
|
|
961
1257
|
|
|
962
1258
|
return svg.node();
|
|
963
1259
|
} else if (layout === "unrooted") {
|
|
@@ -968,23 +1264,17 @@ function drawPhylogeny(
|
|
|
968
1264
|
const w = width;
|
|
969
1265
|
const h = height;
|
|
970
1266
|
|
|
971
|
-
// Get spatial extent
|
|
972
1267
|
const xExtent = d3__namespace.extent(unrootedPhylo.data, (d) => d.x);
|
|
973
1268
|
const yExtent = d3__namespace.extent(unrootedPhylo.data, (d) => d.y);
|
|
974
|
-
|
|
975
|
-
// Find maximum absolute distance from center (0,0)
|
|
976
1269
|
const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
|
|
977
1270
|
const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
|
|
978
1271
|
const maxRadius = Math.max(maxX, maxY);
|
|
979
|
-
|
|
980
|
-
// Add some margin
|
|
981
1272
|
const scaleUnroot = maxRadius + 2 * radialMargin;
|
|
982
1273
|
|
|
983
1274
|
const xScaleUnroot = d3__namespace
|
|
984
1275
|
.scaleLinear()
|
|
985
1276
|
.domain([-scaleUnroot, scaleUnroot])
|
|
986
1277
|
.range([0, w]);
|
|
987
|
-
|
|
988
1278
|
const yScaleUnroot = d3__namespace
|
|
989
1279
|
.scaleLinear()
|
|
990
1280
|
.domain([-scaleUnroot, scaleUnroot])
|
|
@@ -998,8 +1288,9 @@ function drawPhylogeny(
|
|
|
998
1288
|
.attr("font-size", 10);
|
|
999
1289
|
|
|
1000
1290
|
const group = svg.append("g");
|
|
1291
|
+
const staticLayer = svg.append("g").attr("class", "phylo_static_highlight");
|
|
1292
|
+
const hoverLayer = svg.append("g").attr("class", "phylo_hover_highlight");
|
|
1001
1293
|
|
|
1002
|
-
// Edges
|
|
1003
1294
|
group
|
|
1004
1295
|
.append("g")
|
|
1005
1296
|
.attr("class", "phylo_lines")
|
|
@@ -1013,8 +1304,7 @@ function drawPhylogeny(
|
|
|
1013
1304
|
.attr("stroke-width", strokeWidth)
|
|
1014
1305
|
.attr("stroke", "#777");
|
|
1015
1306
|
|
|
1016
|
-
|
|
1017
|
-
group
|
|
1307
|
+
const nodes = group
|
|
1018
1308
|
.append("g")
|
|
1019
1309
|
.attr("class", "phylo_points")
|
|
1020
1310
|
.selectAll("circle")
|
|
@@ -1028,74 +1318,147 @@ function drawPhylogeny(
|
|
|
1028
1318
|
.attr("stroke-width", 2)
|
|
1029
1319
|
.attr("fill", (d) => (d.isTip ? "black" : "white"));
|
|
1030
1320
|
|
|
1031
|
-
|
|
1321
|
+
const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1322
|
+
const tipById = new Map(
|
|
1323
|
+
unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
|
|
1324
|
+
);
|
|
1325
|
+
const tipByLabel = new Map(
|
|
1326
|
+
unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisLabel, d])
|
|
1327
|
+
);
|
|
1328
|
+
const rootToTip = makeRootToTipGetter(byId);
|
|
1329
|
+
|
|
1330
|
+
if (showTooltips) {
|
|
1331
|
+
nodes
|
|
1332
|
+
.filter((d) => d.isTip)
|
|
1333
|
+
.append("title")
|
|
1334
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1032
1337
|
const tipEdges = new Map();
|
|
1033
1338
|
const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1034
|
-
|
|
1035
1339
|
unrootedPhylo.edges.forEach((edge) => {
|
|
1036
1340
|
const tipNode = nodesById.get(edge.id1);
|
|
1037
|
-
if (tipNode?.isTip)
|
|
1038
|
-
tipEdges.set(edge.id1, edge);
|
|
1039
|
-
}
|
|
1341
|
+
if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
|
|
1040
1342
|
});
|
|
1041
1343
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
);
|
|
1061
|
-
|
|
1062
|
-
|
|
1344
|
+
if (tipLabels) {
|
|
1345
|
+
const tipLabelsSel = group
|
|
1346
|
+
.append("g")
|
|
1347
|
+
.attr("class", "phylo_labels")
|
|
1348
|
+
.selectAll("g")
|
|
1349
|
+
.data(unrootedPhylo.data.filter((d) => d.isTip))
|
|
1350
|
+
.join("g")
|
|
1351
|
+
.attr("transform", (d) => {
|
|
1352
|
+
const x = xScaleUnroot(d.x);
|
|
1353
|
+
const y = yScaleUnroot(d.y);
|
|
1354
|
+
return `translate(${x},${y})`;
|
|
1355
|
+
})
|
|
1356
|
+
.each(function(d) {
|
|
1357
|
+
const edge = tipEdges.get(d.thisId);
|
|
1358
|
+
if (!edge) return;
|
|
1359
|
+
|
|
1360
|
+
const x1 = xScaleUnroot(edge.x1);
|
|
1361
|
+
const y1 = yScaleUnroot(edge.y1);
|
|
1362
|
+
const x2 = xScaleUnroot(edge.x2);
|
|
1363
|
+
const y2 = yScaleUnroot(edge.y2);
|
|
1364
|
+
|
|
1365
|
+
const dx = x2 - x1;
|
|
1366
|
+
const dy = y2 - y1;
|
|
1367
|
+
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1368
|
+
|
|
1369
|
+
let xOffset = -10;
|
|
1370
|
+
let anchor = "end";
|
|
1371
|
+
if (angle > 90 || angle < -90) {
|
|
1372
|
+
angle += 180;
|
|
1373
|
+
anchor = "start";
|
|
1374
|
+
xOffset = 10;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
d3__namespace.select(this)
|
|
1378
|
+
.append("g")
|
|
1379
|
+
.attr("transform", `rotate(${angle})`)
|
|
1380
|
+
.append("text")
|
|
1381
|
+
.attr("x", xOffset)
|
|
1382
|
+
.attr("alignment-baseline", "middle")
|
|
1383
|
+
.attr("text-anchor", anchor)
|
|
1384
|
+
.attr("font-size", 10)
|
|
1385
|
+
.attr("fill", "black")
|
|
1386
|
+
.text(d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
1387
|
+
});
|
|
1063
1388
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
const dx = x2 - x1;
|
|
1071
|
-
const dy = y2 - y1;
|
|
1072
|
-
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1073
|
-
|
|
1074
|
-
// Flip label if upside down
|
|
1075
|
-
let xOffset = -10;
|
|
1076
|
-
let anchor = "end";
|
|
1077
|
-
if (angle > 90 || angle < -90) {
|
|
1078
|
-
angle += 180;
|
|
1079
|
-
anchor = "start";
|
|
1080
|
-
xOffset = 10;
|
|
1081
|
-
}
|
|
1389
|
+
if (showTooltips) {
|
|
1390
|
+
tipLabelsSel
|
|
1391
|
+
.append("title")
|
|
1392
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1393
|
+
}
|
|
1082
1394
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
.
|
|
1086
|
-
.attr("
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
.
|
|
1090
|
-
.attr("
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1395
|
+
tipLabelsSel
|
|
1396
|
+
.on("mouseenter", function(_event, d) {
|
|
1397
|
+
drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1398
|
+
d3__namespace.select(this).select("text").attr("font-weight", 600);
|
|
1399
|
+
})
|
|
1400
|
+
.on("mouseleave", function() {
|
|
1401
|
+
hoverLayer.selectAll("*").remove();
|
|
1402
|
+
d3__namespace.select(this).select("text").attr("font-weight", null);
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
nodes
|
|
1407
|
+
.filter((d) => d.isTip)
|
|
1408
|
+
.on("mouseenter", function(_event, d) {
|
|
1409
|
+
drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1410
|
+
d3__namespace.select(this).attr("r", 6);
|
|
1411
|
+
})
|
|
1412
|
+
.on("mouseleave", function() {
|
|
1413
|
+
hoverLayer.selectAll("*").remove();
|
|
1414
|
+
d3__namespace.select(this).attr("r", 4);
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
if (highlightTips && highlightTips.length) {
|
|
1418
|
+
const chosen = new Set(
|
|
1419
|
+
[
|
|
1420
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
1421
|
+
...highlightTips
|
|
1422
|
+
.filter((x) => !isNumber(x))
|
|
1423
|
+
.map((lb) => tipByLabel.get(lb))
|
|
1424
|
+
].filter(Boolean)
|
|
1425
|
+
);
|
|
1426
|
+
chosen.forEach((tip) => {
|
|
1427
|
+
drawUnrootedPath(
|
|
1428
|
+
tip.thisId,
|
|
1429
|
+
staticLayer,
|
|
1430
|
+
highlightStroke,
|
|
1431
|
+
highlightWidth
|
|
1432
|
+
);
|
|
1094
1433
|
});
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function drawUnrootedPath(tipId, layer, stroke, width) {
|
|
1437
|
+
const edgeFromChild = new Map(unrootedPhylo.edges.map((e) => [e.id1, e]));
|
|
1438
|
+
layer.selectAll("*").remove();
|
|
1439
|
+
let cur = byId.get(tipId);
|
|
1440
|
+
while (cur && cur.parentId != null) {
|
|
1441
|
+
const e = edgeFromChild.get(cur.thisId);
|
|
1442
|
+
if (e) {
|
|
1443
|
+
layer
|
|
1444
|
+
.append("line")
|
|
1445
|
+
.attr("x1", xScaleUnroot(e.x1))
|
|
1446
|
+
.attr("y1", yScaleUnroot(e.y1))
|
|
1447
|
+
.attr("x2", xScaleUnroot(e.x2))
|
|
1448
|
+
.attr("y2", yScaleUnroot(e.y2))
|
|
1449
|
+
.attr("stroke", stroke)
|
|
1450
|
+
.attr("stroke-width", width)
|
|
1451
|
+
.attr("stroke-linecap", "round");
|
|
1452
|
+
}
|
|
1453
|
+
cur = byId.get(cur.parentId);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1095
1456
|
|
|
1096
1457
|
return svg.node();
|
|
1097
1458
|
} else {
|
|
1098
|
-
throw new Error(
|
|
1459
|
+
throw new Error(
|
|
1460
|
+
"Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
|
|
1461
|
+
);
|
|
1099
1462
|
}
|
|
1100
1463
|
}
|
|
1101
1464
|
|