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