@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.
@@ -705,15 +705,42 @@ function readTree(text) {
705
705
  function drawPhylogeny(
706
706
  treeText,
707
707
  {
708
- layout = "rect", // "rect" or "radial"
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 "align"
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
- group
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
- svg
779
- .append("g")
780
- .selectAll("text")
781
- .data(tips)
782
- .join("text")
783
- .attr("x", (d) => xScale(d.x1) + 4)
784
- .attr("y", (d) => yScale(d.y1))
785
- .attr("dy", "0.32em")
786
- .attr("font-size", 10)
787
- .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
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; // "align" (shorten to original tips) or "outer" (project to one circle)
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
- // ===== LABELS (unchanged) =====
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
- group
937
- .append("g")
938
- .attr("class", "phylo_labels")
939
- .selectAll("g.label")
940
- .data(tips) // <— bind only tip nodes
941
- .join("g")
942
- .attr("class", "label")
943
- .attr("transform", (d) => {
944
- // same tip position rule as dots/spokes:
945
- // - "outer": snap to common ring (tipMaxR)
946
- // - otherwise (e.g. "align"/"phylo"): true tip radius
947
- const r = isOuter ? tipMaxR : d.r;
948
- const x = r * Math.cos(d.angle);
949
- const y = r * Math.sin(d.angle);
950
- return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
951
- })
952
- .each(function(d) {
953
- // rotate so text reads outward; flip when on the left side
954
- let angle = (-d.angle * 180) / Math.PI;
955
- let xoff = 10; // radial padding for text (px)
956
- let anchor = "start";
957
- if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
958
- angle += 180;
959
- xoff *= -1;
960
- anchor = "end";
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
- d3.select(this)
963
- .append("g")
964
- .attr("transform", `rotate(${angle})`)
965
- .append("text")
966
- .attr("x", xoff)
967
- .attr("alignment-baseline", "middle")
968
- .attr("text-anchor", anchor)
969
- .attr("font-size", 10)
970
- .attr("fill", "black")
971
- .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
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
- // Nodes
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
- // Tip labels
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
- group
1055
- .append("g")
1056
- .attr("class", "phylo_labels")
1057
- .selectAll("g")
1058
- .data(unrootedPhylo.data.filter((d) => d.isTip))
1059
- .join("g")
1060
- .attr("transform", (d) => {
1061
- const x = xScaleUnroot(d.x);
1062
- const y = yScaleUnroot(d.y);
1063
- return `translate(${x},${y})`;
1064
- })
1065
- .each(function(d) {
1066
- const edge = tipEdges.get(d.thisId);
1067
- if (!edge) {
1068
- console.warn(
1069
- "No incoming edge found for tip node:",
1070
- d.thisId,
1071
- d.thisLabel
1072
- );
1073
- return;
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
- // Compute angle of the incoming edge (screen coords)
1077
- const x1 = xScaleUnroot(edge.x1);
1078
- const y1 = yScaleUnroot(edge.y1);
1079
- const x2 = xScaleUnroot(edge.x2);
1080
- const y2 = yScaleUnroot(edge.y2);
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
- // Draw label rotated along branch direction
1096
- d3.select(this)
1097
- .append("g")
1098
- .attr("transform", `rotate(${angle})`)
1099
- .append("text")
1100
- .attr("x", xOffset)
1101
- .attr("alignment-baseline", "middle")
1102
- .attr("text-anchor", anchor)
1103
- .attr("font-size", 10)
1104
- .attr("fill", "black")
1105
- .text(d.thisLabel?.replace(/_/g, " ") ?? "");
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("Unsupported layout type. Use 'rect' or 'radial'.");
1438
+ throw new Error(
1439
+ "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
1440
+ );
1111
1441
  }
1112
1442
  }
1113
1443