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