@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.
@@ -705,15 +705,53 @@ 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
+ // 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
- group
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
- 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, " ") ?? "");
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; // "align" (shorten to original tips) or "outer" (project to one circle)
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
- // ===== LABELS (unchanged) =====
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
- 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";
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
- 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, " ") ?? "");
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
- // Nodes
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
- // Tip labels
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
- 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
- }
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
- // 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
- }
1379
+ if (showTooltips) {
1380
+ tipLabelsSel
1381
+ .append("title")
1382
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1383
+ }
1094
1384
 
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, " ") ?? "");
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("Unsupported layout type. Use 'rect' or 'radial'.");
1449
+ throw new Error(
1450
+ "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
1451
+ );
1111
1452
  }
1112
1453
  }
1113
1454