@euphrasiologist/lwphylo 1.2.2 → 1.2.4
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 +460 -119
- package/dist/drawPhylogeny.cjs.map +1 -1
- package/dist/drawPhylogeny.esm.js +460 -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 +460 -119
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +460 -119
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -705,15 +705,53 @@ function readTree(text) {
|
|
|
705
705
|
function drawPhylogeny(
|
|
706
706
|
treeText,
|
|
707
707
|
{
|
|
708
|
-
layout = "rect", //
|
|
708
|
+
layout = "rect", // rect/radial/unrooted
|
|
709
709
|
width = 800,
|
|
710
710
|
height = 800,
|
|
711
711
|
margin = { top: 20, right: 300, bottom: 20, left: 50 },
|
|
712
712
|
radialMargin = 80,
|
|
713
|
-
strokeWidth = 1,
|
|
714
|
-
radialMode = "outer" // or "
|
|
713
|
+
strokeWidth = 1, // for the phylogeny branches
|
|
714
|
+
radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
|
|
715
|
+
tipLabels = true,
|
|
716
|
+
showTooltips = true,
|
|
717
|
+
tooltipFormatter = (d, rtt) =>
|
|
718
|
+
`${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
|
|
719
|
+
hoverStroke = "#1f77b4",
|
|
720
|
+
hoverWidth = 3,
|
|
721
|
+
highlightTips = [], // array of tip labels or ids for static highlight (optional)
|
|
722
|
+
highlightStroke = "#e63946",
|
|
723
|
+
highlightWidth = 2.5
|
|
715
724
|
} = {}
|
|
716
725
|
) {
|
|
726
|
+
|
|
727
|
+
// shared helpers
|
|
728
|
+
const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
|
|
729
|
+
// Works for both radial (uses `r`) and rect (uses `x1`).
|
|
730
|
+
// Falls back to summing branchLength up to the root if neither is present.
|
|
731
|
+
function makeRootToTipGetter(byId, { prefer = "auto" } = {}) {
|
|
732
|
+
return function rootToTip(tipId) {
|
|
733
|
+
let n = byId.get(tipId);
|
|
734
|
+
if (!n) return 0;
|
|
735
|
+
|
|
736
|
+
// Prefer explicit cumulative fields if present
|
|
737
|
+
if (prefer === "r" || (prefer === "auto" && "r" in n)) {
|
|
738
|
+
return Number(n.r ?? 0);
|
|
739
|
+
}
|
|
740
|
+
if (prefer === "x1" || (prefer === "auto" && "x1" in n)) {
|
|
741
|
+
return Number(n.x1 ?? 0);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Fallback: sum branchLength up the ancestry
|
|
745
|
+
let sum = 0;
|
|
746
|
+
while (n && n.parentId != null) {
|
|
747
|
+
sum += Number(n.branchLength || 0); // null/undefined → 0
|
|
748
|
+
n = byId.get(n.parentId);
|
|
749
|
+
}
|
|
750
|
+
return sum;
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
|
|
717
755
|
if (layout === "rect") {
|
|
718
756
|
// RECTANGULAR LAYOUT
|
|
719
757
|
const tree_df = rectangleLayout(readTree(treeText));
|
|
@@ -721,6 +759,12 @@ function drawPhylogeny(
|
|
|
721
759
|
const vertical = tree_df.vertical_lines;
|
|
722
760
|
const tips = horizontal.filter((d) => d.isTip);
|
|
723
761
|
|
|
762
|
+
// indices & root→tip getter
|
|
763
|
+
const byId = new Map(horizontal.map((d) => [d.thisId, d]));
|
|
764
|
+
const tipById = new Map(tips.map((d) => [d.thisId, d]));
|
|
765
|
+
const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
|
|
766
|
+
const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
|
|
767
|
+
|
|
724
768
|
const maxY = d3.max(horizontal, (d) => d.y1);
|
|
725
769
|
const minY = d3.min(horizontal, (d) => d.y1);
|
|
726
770
|
const maxX = d3.max(horizontal, (d) => d.x1);
|
|
@@ -744,6 +788,10 @@ function drawPhylogeny(
|
|
|
744
788
|
|
|
745
789
|
const group = svg.append("g");
|
|
746
790
|
|
|
791
|
+
// layers for highlight/hover
|
|
792
|
+
const staticLayer = svg.append("g").attr("class", "phylo_static_highlight");
|
|
793
|
+
const hoverLayer = svg.append("g").attr("class", "phylo_hover_highlight");
|
|
794
|
+
|
|
747
795
|
group
|
|
748
796
|
.selectAll(".hline")
|
|
749
797
|
.data(horizontal)
|
|
@@ -766,7 +814,8 @@ function drawPhylogeny(
|
|
|
766
814
|
.attr("stroke", "#555")
|
|
767
815
|
.attr("stroke-width", strokeWidth);
|
|
768
816
|
|
|
769
|
-
|
|
817
|
+
// tip dots
|
|
818
|
+
const tipDots = group
|
|
770
819
|
.selectAll(".tip-dot")
|
|
771
820
|
.data(tips)
|
|
772
821
|
.join("circle")
|
|
@@ -775,16 +824,103 @@ function drawPhylogeny(
|
|
|
775
824
|
.attr("r", 2)
|
|
776
825
|
.attr("fill", "black");
|
|
777
826
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
.
|
|
827
|
+
// tooltips for rect dots
|
|
828
|
+
if (showTooltips) {
|
|
829
|
+
tipDots
|
|
830
|
+
.append("title")
|
|
831
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// interactive root→tip highlight (rect) on dot hover
|
|
835
|
+
tipDots
|
|
836
|
+
.on("mouseenter", function(_event, d) {
|
|
837
|
+
drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
838
|
+
d3.select(this).attr("r", 4);
|
|
839
|
+
})
|
|
840
|
+
.on("mouseleave", function() {
|
|
841
|
+
hoverLayer.selectAll("*").remove();
|
|
842
|
+
d3.select(this).attr("r", 2);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
// labels
|
|
846
|
+
if (tipLabels) {
|
|
847
|
+
const labels = svg
|
|
848
|
+
.append("g")
|
|
849
|
+
.attr("class", "phylo_labels")
|
|
850
|
+
.selectAll("text")
|
|
851
|
+
.data(tips)
|
|
852
|
+
.join("text")
|
|
853
|
+
.attr("x", (d) => xScale(d.x1) + 4)
|
|
854
|
+
.attr("y", (d) => yScale(d.y1))
|
|
855
|
+
.attr("dy", "0.32em")
|
|
856
|
+
.attr("font-size", 10)
|
|
857
|
+
.text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
858
|
+
|
|
859
|
+
if (showTooltips) {
|
|
860
|
+
labels
|
|
861
|
+
.append("title")
|
|
862
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
labels
|
|
866
|
+
.on("mouseenter", function(_event, d) {
|
|
867
|
+
drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
868
|
+
d3.select(this).attr("font-weight", 600);
|
|
869
|
+
})
|
|
870
|
+
.on("mouseleave", function() {
|
|
871
|
+
hoverLayer.selectAll("*").remove();
|
|
872
|
+
d3.select(this).attr("font-weight", null);
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// static highlight by ids/labels
|
|
877
|
+
if (highlightTips && highlightTips.length) {
|
|
878
|
+
const chosen = new Set(
|
|
879
|
+
[
|
|
880
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
881
|
+
...highlightTips
|
|
882
|
+
.filter((x) => !isNumber(x))
|
|
883
|
+
.map((lb) => tipByLabel.get(lb))
|
|
884
|
+
].filter(Boolean)
|
|
885
|
+
);
|
|
886
|
+
chosen.forEach((tip) => {
|
|
887
|
+
drawRectPath(tip.thisId, staticLayer, highlightStroke, highlightWidth);
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// helper to draw root→tip for rect (both vertical+horizontal)
|
|
892
|
+
function drawRectPath(tipId, layer, stroke, width) {
|
|
893
|
+
layer.selectAll("*").remove();
|
|
894
|
+
let cur = byId.get(tipId);
|
|
895
|
+
while (cur && cur.parentId != null) {
|
|
896
|
+
const parent = byId.get(cur.parentId);
|
|
897
|
+
if (!parent) break;
|
|
898
|
+
|
|
899
|
+
// vertical at junction x0 from parent.y to child.y
|
|
900
|
+
layer
|
|
901
|
+
.append("line")
|
|
902
|
+
.attr("x1", xScale(cur.x0))
|
|
903
|
+
.attr("x2", xScale(cur.x0))
|
|
904
|
+
.attr("y1", yScale(parent.y0))
|
|
905
|
+
.attr("y2", yScale(cur.y0))
|
|
906
|
+
.attr("stroke", stroke)
|
|
907
|
+
.attr("stroke-width", width)
|
|
908
|
+
.attr("stroke-linecap", "round");
|
|
909
|
+
|
|
910
|
+
// horizontal along child's y, from junction x0 to x1
|
|
911
|
+
layer
|
|
912
|
+
.append("line")
|
|
913
|
+
.attr("x1", xScale(cur.x0))
|
|
914
|
+
.attr("x2", xScale(cur.x1))
|
|
915
|
+
.attr("y1", yScale(cur.y0))
|
|
916
|
+
.attr("y2", yScale(cur.y1))
|
|
917
|
+
.attr("stroke", stroke)
|
|
918
|
+
.attr("stroke-width", width)
|
|
919
|
+
.attr("stroke-linecap", "round");
|
|
920
|
+
|
|
921
|
+
cur = parent;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
788
924
|
|
|
789
925
|
return svg.node();
|
|
790
926
|
} else if (layout === "radial") {
|
|
@@ -792,7 +928,7 @@ function drawPhylogeny(
|
|
|
792
928
|
const rad = radialLayout(parsedTree);
|
|
793
929
|
|
|
794
930
|
// ===== MODE =====
|
|
795
|
-
const TIP_MODE = radialMode; // "
|
|
931
|
+
const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
|
|
796
932
|
const isOuter = TIP_MODE === "outer";
|
|
797
933
|
|
|
798
934
|
// visuals (0 = let spokes reach the dots)
|
|
@@ -821,6 +957,9 @@ function drawPhylogeny(
|
|
|
821
957
|
const byId = new Map(rad.data.map((d) => [d.thisId, d]));
|
|
822
958
|
const tips = rad.data.filter((d) => d.isTip);
|
|
823
959
|
const tipMaxR = tips.length ? d3.max(tips, (d) => d.r) : 0;
|
|
960
|
+
const rootToTip = makeRootToTipGetter(byId, { prefer: "r" });
|
|
961
|
+
const tipById = new Map(tips.map((d) => [d.thisId, d])); // HILITE:
|
|
962
|
+
const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d])); // HILITE:
|
|
824
963
|
|
|
825
964
|
// Robust child-id extractor (handles multiple shapes)
|
|
826
965
|
function childIdOf(spoke) {
|
|
@@ -851,6 +990,12 @@ function drawPhylogeny(
|
|
|
851
990
|
|
|
852
991
|
const group = svg.append("g");
|
|
853
992
|
|
|
993
|
+
// overlay groups (drawn on top)
|
|
994
|
+
const staticLines = svg.append("g").attr("class", "phylo_static_lines"); // HILITE:
|
|
995
|
+
const staticArcs = svg.append("g").attr("class", "phylo_static_arcs"); // HILITE:
|
|
996
|
+
const hoverLines = svg.append("g").attr("class", "phylo_hover_lines"); // HILITE:
|
|
997
|
+
const hoverArcs = svg.append("g").attr("class", "phylo_hover_arcs"); // HILITE:
|
|
998
|
+
|
|
854
999
|
// ===== ARCS (parent circles) =====
|
|
855
1000
|
group
|
|
856
1001
|
.append("g")
|
|
@@ -878,7 +1023,7 @@ function drawPhylogeny(
|
|
|
878
1023
|
.selectAll("line")
|
|
879
1024
|
.data(rad.radii)
|
|
880
1025
|
.join("line")
|
|
881
|
-
.each(function(s,
|
|
1026
|
+
.each(function(s, _i) {
|
|
882
1027
|
// parent end (data space)
|
|
883
1028
|
const x0 = s.x0,
|
|
884
1029
|
y0 = s.y0;
|
|
@@ -911,13 +1056,13 @@ function drawPhylogeny(
|
|
|
911
1056
|
});
|
|
912
1057
|
|
|
913
1058
|
// ===== TIP DOTS =====
|
|
914
|
-
group
|
|
1059
|
+
const tipDots = group
|
|
915
1060
|
.append("g")
|
|
916
1061
|
.attr("class", "phylo_tip_dots")
|
|
917
1062
|
.selectAll("circle")
|
|
918
1063
|
.data(tips)
|
|
919
1064
|
.join("circle")
|
|
920
|
-
.each(function(d,
|
|
1065
|
+
.each(function(d, _i) {
|
|
921
1066
|
// dot at original tip (align) or projected circle (outer)
|
|
922
1067
|
const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
|
|
923
1068
|
const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
|
|
@@ -931,46 +1076,175 @@ function drawPhylogeny(
|
|
|
931
1076
|
.attr("stroke-width", 1.5);
|
|
932
1077
|
});
|
|
933
1078
|
|
|
934
|
-
|
|
1079
|
+
if (showTooltips) {
|
|
1080
|
+
tipDots
|
|
1081
|
+
.append("title")
|
|
1082
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// maps for fast lookup on hover (childId → spoke / arc)
|
|
1086
|
+
const spokeByChild = new Map(rad.radii.map((s) => [childIdOf(s), s]));
|
|
1087
|
+
const arcByChild = new Map(rad.child_arcs.map((a) => [a.childId, a]));
|
|
1088
|
+
|
|
1089
|
+
// ===== LABELS =====
|
|
935
1090
|
// Labels — make them follow the tip position used by the current mode
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
angle
|
|
959
|
-
|
|
960
|
-
|
|
1091
|
+
if (tipLabels) {
|
|
1092
|
+
const labels = group
|
|
1093
|
+
.append("g")
|
|
1094
|
+
.attr("class", "phylo_labels")
|
|
1095
|
+
.selectAll("g.label")
|
|
1096
|
+
.data(tips)
|
|
1097
|
+
.join("g")
|
|
1098
|
+
.attr("class", "label")
|
|
1099
|
+
.attr("transform", (d) => {
|
|
1100
|
+
// same tip position rule as dots/spokes:
|
|
1101
|
+
// - "outer": snap to common ring (tipMaxR)
|
|
1102
|
+
// - otherwise (e.g. "align"/"phylo"): true tip radius
|
|
1103
|
+
const r = isOuter ? tipMaxR : d.r;
|
|
1104
|
+
const x = r * Math.cos(d.angle);
|
|
1105
|
+
const y = r * Math.sin(d.angle);
|
|
1106
|
+
return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
|
|
1107
|
+
})
|
|
1108
|
+
.each(function(d) {
|
|
1109
|
+
// rotate so text reads outward; flip when on the left side
|
|
1110
|
+
let angle = (-d.angle * 180) / Math.PI;
|
|
1111
|
+
let xoff = 10; // radial padding for text (px)
|
|
1112
|
+
let anchor = "start";
|
|
1113
|
+
if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
|
|
1114
|
+
angle += 180;
|
|
1115
|
+
xoff *= -1;
|
|
1116
|
+
anchor = "end";
|
|
1117
|
+
}
|
|
1118
|
+
d3.select(this)
|
|
1119
|
+
.append("g")
|
|
1120
|
+
.attr("transform", `rotate(${angle})`)
|
|
1121
|
+
.append("text")
|
|
1122
|
+
.attr("x", xoff)
|
|
1123
|
+
.attr("alignment-baseline", "middle")
|
|
1124
|
+
.attr("text-anchor", anchor)
|
|
1125
|
+
.attr("font-size", 10)
|
|
1126
|
+
.attr("fill", "black")
|
|
1127
|
+
.text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
if (showTooltips) {
|
|
1131
|
+
labels
|
|
1132
|
+
.append("title")
|
|
1133
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// label hover
|
|
1137
|
+
labels
|
|
1138
|
+
.on("mouseenter", function(event, d) {
|
|
1139
|
+
drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
|
|
1140
|
+
d3.select(this).select("text").attr("font-weight", 600);
|
|
1141
|
+
})
|
|
1142
|
+
.on("mouseleave", function() {
|
|
1143
|
+
hoverLines.selectAll("*").remove();
|
|
1144
|
+
hoverArcs.selectAll("*").remove();
|
|
1145
|
+
d3.select(this).select("text").attr("font-weight", null);
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// draw (overlay) the root→tip path: spokes + arcs (half-arc per child)
|
|
1150
|
+
function drawRadialPath(
|
|
1151
|
+
target,
|
|
1152
|
+
lineLayer,
|
|
1153
|
+
arcLayer,
|
|
1154
|
+
stroke = "#1f77b4",
|
|
1155
|
+
width = 3
|
|
1156
|
+
) {
|
|
1157
|
+
// target may be a tip node *or* a numeric tip id
|
|
1158
|
+
lineLayer.selectAll("*").remove();
|
|
1159
|
+
arcLayer.selectAll("*").remove();
|
|
1160
|
+
|
|
1161
|
+
let cur = typeof target === "number" ? byId.get(target) : target;
|
|
1162
|
+
if (!cur) return;
|
|
1163
|
+
|
|
1164
|
+
let first = true;
|
|
1165
|
+
while (cur && cur.parentId != null) {
|
|
1166
|
+
// ----- spoke (parent → child) -----
|
|
1167
|
+
const s = spokeByChild.get(cur.thisId);
|
|
1168
|
+
if (s) {
|
|
1169
|
+
const px = s.x0,
|
|
1170
|
+
py = s.y0;
|
|
1171
|
+
let cx = s.x1,
|
|
1172
|
+
cy = s.y1;
|
|
1173
|
+
if (isOuter && first && cur.isTip) {
|
|
1174
|
+
const r = tipMaxR;
|
|
1175
|
+
cx = r * Math.cos(cur.angle);
|
|
1176
|
+
cy = r * Math.sin(cur.angle);
|
|
1177
|
+
}
|
|
1178
|
+
const { X0, Y0, X1s, Y1s } = shortenSpokePx(px, py, cx, cy);
|
|
1179
|
+
lineLayer
|
|
1180
|
+
.append("line")
|
|
1181
|
+
.attr("x1", X0)
|
|
1182
|
+
.attr("y1", Y0)
|
|
1183
|
+
.attr("x2", X1s)
|
|
1184
|
+
.attr("y2", Y1s)
|
|
1185
|
+
.attr("stroke", stroke)
|
|
1186
|
+
.attr("stroke-width", width)
|
|
1187
|
+
.attr("stroke-linecap", "round");
|
|
961
1188
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1189
|
+
|
|
1190
|
+
// ----- half-arc at parent radius (parent.angle → child.angle) -----
|
|
1191
|
+
const a = arcByChild.get(cur.thisId);
|
|
1192
|
+
if (a) {
|
|
1193
|
+
arcLayer
|
|
1194
|
+
.append("path")
|
|
1195
|
+
.attr(
|
|
1196
|
+
"d",
|
|
1197
|
+
describeArc(
|
|
1198
|
+
centerX,
|
|
1199
|
+
centerY,
|
|
1200
|
+
Math.max(0, radiusPx(a.radius)),
|
|
1201
|
+
a.start,
|
|
1202
|
+
a.end
|
|
1203
|
+
)
|
|
1204
|
+
)
|
|
1205
|
+
.attr("fill", "none")
|
|
1206
|
+
.attr("stroke", stroke)
|
|
1207
|
+
.attr("stroke-width", width);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
first = false;
|
|
1211
|
+
cur = byId.get(cur.parentId);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// tip dot hover
|
|
1216
|
+
tipDots
|
|
1217
|
+
.on("mouseenter", function(_event, d) {
|
|
1218
|
+
drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
|
|
1219
|
+
d3.select(this).attr("r", DOT_R + 2);
|
|
1220
|
+
})
|
|
1221
|
+
.on("mouseleave", function() {
|
|
1222
|
+
hoverLines.selectAll("*").remove();
|
|
1223
|
+
hoverArcs.selectAll("*").remove();
|
|
1224
|
+
d3.select(this).attr("r", DOT_R);
|
|
972
1225
|
});
|
|
973
1226
|
|
|
1227
|
+
if (highlightTips && highlightTips.length) {
|
|
1228
|
+
const chosen = new Set(
|
|
1229
|
+
[
|
|
1230
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
1231
|
+
...highlightTips
|
|
1232
|
+
.filter((x) => !isNumber(x))
|
|
1233
|
+
.map((lb) => tipByLabel.get(lb))
|
|
1234
|
+
].filter(Boolean)
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
chosen.forEach((tip) => {
|
|
1238
|
+
drawRadialPath(
|
|
1239
|
+
tip.thisId,
|
|
1240
|
+
staticLines,
|
|
1241
|
+
staticArcs,
|
|
1242
|
+
highlightStroke,
|
|
1243
|
+
highlightWidth
|
|
1244
|
+
);
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
974
1248
|
return svg.node();
|
|
975
1249
|
} else if (layout === "unrooted") {
|
|
976
1250
|
// UNROOTED LAYOUT
|
|
@@ -980,23 +1254,17 @@ function drawPhylogeny(
|
|
|
980
1254
|
const w = width;
|
|
981
1255
|
const h = height;
|
|
982
1256
|
|
|
983
|
-
// Get spatial extent
|
|
984
1257
|
const xExtent = d3.extent(unrootedPhylo.data, (d) => d.x);
|
|
985
1258
|
const yExtent = d3.extent(unrootedPhylo.data, (d) => d.y);
|
|
986
|
-
|
|
987
|
-
// Find maximum absolute distance from center (0,0)
|
|
988
1259
|
const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
|
|
989
1260
|
const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
|
|
990
1261
|
const maxRadius = Math.max(maxX, maxY);
|
|
991
|
-
|
|
992
|
-
// Add some margin
|
|
993
1262
|
const scaleUnroot = maxRadius + 2 * radialMargin;
|
|
994
1263
|
|
|
995
1264
|
const xScaleUnroot = d3
|
|
996
1265
|
.scaleLinear()
|
|
997
1266
|
.domain([-scaleUnroot, scaleUnroot])
|
|
998
1267
|
.range([0, w]);
|
|
999
|
-
|
|
1000
1268
|
const yScaleUnroot = d3
|
|
1001
1269
|
.scaleLinear()
|
|
1002
1270
|
.domain([-scaleUnroot, scaleUnroot])
|
|
@@ -1010,8 +1278,9 @@ function drawPhylogeny(
|
|
|
1010
1278
|
.attr("font-size", 10);
|
|
1011
1279
|
|
|
1012
1280
|
const group = svg.append("g");
|
|
1281
|
+
const staticLayer = svg.append("g").attr("class", "phylo_static_highlight");
|
|
1282
|
+
const hoverLayer = svg.append("g").attr("class", "phylo_hover_highlight");
|
|
1013
1283
|
|
|
1014
|
-
// Edges
|
|
1015
1284
|
group
|
|
1016
1285
|
.append("g")
|
|
1017
1286
|
.attr("class", "phylo_lines")
|
|
@@ -1025,8 +1294,7 @@ function drawPhylogeny(
|
|
|
1025
1294
|
.attr("stroke-width", strokeWidth)
|
|
1026
1295
|
.attr("stroke", "#777");
|
|
1027
1296
|
|
|
1028
|
-
|
|
1029
|
-
group
|
|
1297
|
+
const nodes = group
|
|
1030
1298
|
.append("g")
|
|
1031
1299
|
.attr("class", "phylo_points")
|
|
1032
1300
|
.selectAll("circle")
|
|
@@ -1040,74 +1308,147 @@ function drawPhylogeny(
|
|
|
1040
1308
|
.attr("stroke-width", 2)
|
|
1041
1309
|
.attr("fill", (d) => (d.isTip ? "black" : "white"));
|
|
1042
1310
|
|
|
1043
|
-
|
|
1311
|
+
const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1312
|
+
const tipById = new Map(
|
|
1313
|
+
unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
|
|
1314
|
+
);
|
|
1315
|
+
const tipByLabel = new Map(
|
|
1316
|
+
unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisLabel, d])
|
|
1317
|
+
);
|
|
1318
|
+
const rootToTip = makeRootToTipGetter(byId, { prefer: "r" });
|
|
1319
|
+
|
|
1320
|
+
if (showTooltips) {
|
|
1321
|
+
nodes
|
|
1322
|
+
.filter((d) => d.isTip)
|
|
1323
|
+
.append("title")
|
|
1324
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1044
1327
|
const tipEdges = new Map();
|
|
1045
1328
|
const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
|
|
1046
|
-
|
|
1047
1329
|
unrootedPhylo.edges.forEach((edge) => {
|
|
1048
1330
|
const tipNode = nodesById.get(edge.id1);
|
|
1049
|
-
if (tipNode?.isTip)
|
|
1050
|
-
tipEdges.set(edge.id1, edge);
|
|
1051
|
-
}
|
|
1331
|
+
if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
|
|
1052
1332
|
});
|
|
1053
1333
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
);
|
|
1073
|
-
|
|
1074
|
-
|
|
1334
|
+
if (tipLabels) {
|
|
1335
|
+
const tipLabelsSel = group
|
|
1336
|
+
.append("g")
|
|
1337
|
+
.attr("class", "phylo_labels")
|
|
1338
|
+
.selectAll("g")
|
|
1339
|
+
.data(unrootedPhylo.data.filter((d) => d.isTip))
|
|
1340
|
+
.join("g")
|
|
1341
|
+
.attr("transform", (d) => {
|
|
1342
|
+
const x = xScaleUnroot(d.x);
|
|
1343
|
+
const y = yScaleUnroot(d.y);
|
|
1344
|
+
return `translate(${x},${y})`;
|
|
1345
|
+
})
|
|
1346
|
+
.each(function(d) {
|
|
1347
|
+
const edge = tipEdges.get(d.thisId);
|
|
1348
|
+
if (!edge) return;
|
|
1349
|
+
|
|
1350
|
+
const x1 = xScaleUnroot(edge.x1);
|
|
1351
|
+
const y1 = yScaleUnroot(edge.y1);
|
|
1352
|
+
const x2 = xScaleUnroot(edge.x2);
|
|
1353
|
+
const y2 = yScaleUnroot(edge.y2);
|
|
1354
|
+
|
|
1355
|
+
const dx = x2 - x1;
|
|
1356
|
+
const dy = y2 - y1;
|
|
1357
|
+
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1358
|
+
|
|
1359
|
+
let xOffset = -10;
|
|
1360
|
+
let anchor = "end";
|
|
1361
|
+
if (angle > 90 || angle < -90) {
|
|
1362
|
+
angle += 180;
|
|
1363
|
+
anchor = "start";
|
|
1364
|
+
xOffset = 10;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
d3.select(this)
|
|
1368
|
+
.append("g")
|
|
1369
|
+
.attr("transform", `rotate(${angle})`)
|
|
1370
|
+
.append("text")
|
|
1371
|
+
.attr("x", xOffset)
|
|
1372
|
+
.attr("alignment-baseline", "middle")
|
|
1373
|
+
.attr("text-anchor", anchor)
|
|
1374
|
+
.attr("font-size", 10)
|
|
1375
|
+
.attr("fill", "black")
|
|
1376
|
+
.text(d.thisLabel?.replace(/_/g, " ") ?? "");
|
|
1377
|
+
});
|
|
1075
1378
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
const dx = x2 - x1;
|
|
1083
|
-
const dy = y2 - y1;
|
|
1084
|
-
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1085
|
-
|
|
1086
|
-
// Flip label if upside down
|
|
1087
|
-
let xOffset = -10;
|
|
1088
|
-
let anchor = "end";
|
|
1089
|
-
if (angle > 90 || angle < -90) {
|
|
1090
|
-
angle += 180;
|
|
1091
|
-
anchor = "start";
|
|
1092
|
-
xOffset = 10;
|
|
1093
|
-
}
|
|
1379
|
+
if (showTooltips) {
|
|
1380
|
+
tipLabelsSel
|
|
1381
|
+
.append("title")
|
|
1382
|
+
.text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
|
|
1383
|
+
}
|
|
1094
1384
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
.
|
|
1098
|
-
.attr("
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
.
|
|
1102
|
-
.attr("
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1385
|
+
tipLabelsSel
|
|
1386
|
+
.on("mouseenter", function(_event, d) {
|
|
1387
|
+
drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1388
|
+
d3.select(this).select("text").attr("font-weight", 600);
|
|
1389
|
+
})
|
|
1390
|
+
.on("mouseleave", function() {
|
|
1391
|
+
hoverLayer.selectAll("*").remove();
|
|
1392
|
+
d3.select(this).select("text").attr("font-weight", null);
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
nodes
|
|
1397
|
+
.filter((d) => d.isTip)
|
|
1398
|
+
.on("mouseenter", function(_event, d) {
|
|
1399
|
+
drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
|
|
1400
|
+
d3.select(this).attr("r", 6);
|
|
1401
|
+
})
|
|
1402
|
+
.on("mouseleave", function() {
|
|
1403
|
+
hoverLayer.selectAll("*").remove();
|
|
1404
|
+
d3.select(this).attr("r", 4);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
if (highlightTips && highlightTips.length) {
|
|
1408
|
+
const chosen = new Set(
|
|
1409
|
+
[
|
|
1410
|
+
...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
|
|
1411
|
+
...highlightTips
|
|
1412
|
+
.filter((x) => !isNumber(x))
|
|
1413
|
+
.map((lb) => tipByLabel.get(lb))
|
|
1414
|
+
].filter(Boolean)
|
|
1415
|
+
);
|
|
1416
|
+
chosen.forEach((tip) => {
|
|
1417
|
+
drawUnrootedPath(
|
|
1418
|
+
tip.thisId,
|
|
1419
|
+
staticLayer,
|
|
1420
|
+
highlightStroke,
|
|
1421
|
+
highlightWidth
|
|
1422
|
+
);
|
|
1106
1423
|
});
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function drawUnrootedPath(tipId, layer, stroke, width) {
|
|
1427
|
+
const edgeFromChild = new Map(unrootedPhylo.edges.map((e) => [e.id1, e]));
|
|
1428
|
+
layer.selectAll("*").remove();
|
|
1429
|
+
let cur = byId.get(tipId);
|
|
1430
|
+
while (cur && cur.parentId != null) {
|
|
1431
|
+
const e = edgeFromChild.get(cur.thisId);
|
|
1432
|
+
if (e) {
|
|
1433
|
+
layer
|
|
1434
|
+
.append("line")
|
|
1435
|
+
.attr("x1", xScaleUnroot(e.x1))
|
|
1436
|
+
.attr("y1", yScaleUnroot(e.y1))
|
|
1437
|
+
.attr("x2", xScaleUnroot(e.x2))
|
|
1438
|
+
.attr("y2", yScaleUnroot(e.y2))
|
|
1439
|
+
.attr("stroke", stroke)
|
|
1440
|
+
.attr("stroke-width", width)
|
|
1441
|
+
.attr("stroke-linecap", "round");
|
|
1442
|
+
}
|
|
1443
|
+
cur = byId.get(cur.parentId);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1107
1446
|
|
|
1108
1447
|
return svg.node();
|
|
1109
1448
|
} else {
|
|
1110
|
-
throw new Error(
|
|
1449
|
+
throw new Error(
|
|
1450
|
+
"Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
|
|
1451
|
+
);
|
|
1111
1452
|
}
|
|
1112
1453
|
}
|
|
1113
1454
|
|