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