@euphrasiologist/lwphylo 1.2.1 → 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.
@@ -282,17 +282,50 @@ function getArcs(pd) {
282
282
  return arcs;
283
283
  }
284
284
 
285
+ /**
286
+ * Per-child "half" arcs for radial trees.
287
+ *
288
+ * For each non-root node (child), emit an arc at the PARENT's radius that
289
+ * spans between the parent's angle and the child's angle. This is the arc
290
+ * segment that meets the child's spoke and is ideal for root→tip highlighting.
291
+ *
292
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
293
+ * Output: [{ parentId, childId, radius, start, end }]
294
+ */
295
+ function getChildArcs(pd) {
296
+ const byId = new Map(pd.map(d => [d.thisId, d]));
297
+ const arcs = [];
298
+
299
+ for (const child of pd) {
300
+ if (child.parentId == null) continue; // skip root
301
+ const parent = byId.get(child.parentId);
302
+ if (!parent) continue;
303
+
304
+ arcs.push({
305
+ parentId: parent.thisId,
306
+ childId: child.thisId,
307
+ radius: parent.r, // draw on the parent's circle
308
+ start: parent.angle, // start at parent's angle
309
+ end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
310
+ });
311
+ }
312
+
313
+ return arcs;
314
+ }
315
+
285
316
  /**
286
317
  * Simple wrapper for radial layout:
287
318
  * - data: per-node { angle, r, x, y, ... }
288
319
  * - radii: per-edge radial spokes (parent.r → child.r)
289
- * - arcs: per-internal-node arcs spanning its children at parent radius
320
+ * - arcs: per-parent arcs spanning all children at parent's radius
321
+ * - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
290
322
  */
291
323
  function radialLayout(node) {
292
324
  const data = {};
293
325
  data.data = radialData(node);
294
326
  data.radii = getRadii(node);
295
327
  data.arcs = getArcs(data.data);
328
+ data.child_arcs = getChildArcs(data.data);
296
329
  return data;
297
330
  }
298
331
 
@@ -672,15 +705,42 @@ function readTree(text) {
672
705
  function drawPhylogeny(
673
706
  treeText,
674
707
  {
675
- layout = "rect", // "rect" or "radial"
708
+ layout = "rect", // rect/radial/unrooted
676
709
  width = 800,
677
710
  height = 800,
678
711
  margin = { top: 20, right: 300, bottom: 20, left: 50 },
679
712
  radialMargin = 80,
680
- strokeWidth = 1,
681
- 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
682
724
  } = {}
683
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
+
684
744
  if (layout === "rect") {
685
745
  // RECTANGULAR LAYOUT
686
746
  const tree_df = rectangleLayout(readTree(treeText));
@@ -688,6 +748,12 @@ function drawPhylogeny(
688
748
  const vertical = tree_df.vertical_lines;
689
749
  const tips = horizontal.filter((d) => d.isTip);
690
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
+
691
757
  const maxY = d3.max(horizontal, (d) => d.y1);
692
758
  const minY = d3.min(horizontal, (d) => d.y1);
693
759
  const maxX = d3.max(horizontal, (d) => d.x1);
@@ -711,6 +777,10 @@ function drawPhylogeny(
711
777
 
712
778
  const group = svg.append("g");
713
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
+
714
784
  group
715
785
  .selectAll(".hline")
716
786
  .data(horizontal)
@@ -733,7 +803,8 @@ function drawPhylogeny(
733
803
  .attr("stroke", "#555")
734
804
  .attr("stroke-width", strokeWidth);
735
805
 
736
- group
806
+ // tip dots
807
+ const tipDots = group
737
808
  .selectAll(".tip-dot")
738
809
  .data(tips)
739
810
  .join("circle")
@@ -742,24 +813,111 @@ function drawPhylogeny(
742
813
  .attr("r", 2)
743
814
  .attr("fill", "black");
744
815
 
745
- svg
746
- .append("g")
747
- .selectAll("text")
748
- .data(tips)
749
- .join("text")
750
- .attr("x", (d) => xScale(d.x1) + 4)
751
- .attr("y", (d) => yScale(d.y1))
752
- .attr("dy", "0.32em")
753
- .attr("font-size", 10)
754
- .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
+ }
755
913
 
756
914
  return svg.node();
757
915
  } else if (layout === "radial") {
758
916
  const parsedTree = readTree(treeText);
759
917
  const rad = radialLayout(parsedTree);
760
918
 
761
- // ===== MODE / DEBUG =====
762
- const TIP_MODE = radialMode; // "align" (shorten to original tips) or "outer" (project to one circle)
919
+ // ===== MODE =====
920
+ const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
763
921
  const isOuter = TIP_MODE === "outer";
764
922
 
765
923
  // visuals (0 = let spokes reach the dots)
@@ -788,6 +946,9 @@ function drawPhylogeny(
788
946
  const byId = new Map(rad.data.map((d) => [d.thisId, d]));
789
947
  const tips = rad.data.filter((d) => d.isTip);
790
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:
791
952
 
792
953
  // Robust child-id extractor (handles multiple shapes)
793
954
  function childIdOf(spoke) {
@@ -818,6 +979,12 @@ function drawPhylogeny(
818
979
 
819
980
  const group = svg.append("g");
820
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
+
821
988
  // ===== ARCS (parent circles) =====
822
989
  group
823
990
  .append("g")
@@ -845,7 +1012,7 @@ function drawPhylogeny(
845
1012
  .selectAll("line")
846
1013
  .data(rad.radii)
847
1014
  .join("line")
848
- .each(function(s, _) {
1015
+ .each(function(s, _i) {
849
1016
  // parent end (data space)
850
1017
  const x0 = s.x0,
851
1018
  y0 = s.y0;
@@ -878,13 +1045,13 @@ function drawPhylogeny(
878
1045
  });
879
1046
 
880
1047
  // ===== TIP DOTS =====
881
- group
1048
+ const tipDots = group
882
1049
  .append("g")
883
1050
  .attr("class", "phylo_tip_dots")
884
1051
  .selectAll("circle")
885
1052
  .data(tips)
886
1053
  .join("circle")
887
- .each(function(d, _) {
1054
+ .each(function(d, _i) {
888
1055
  // dot at original tip (align) or projected circle (outer)
889
1056
  const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
890
1057
  const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
@@ -898,45 +1065,174 @@ function drawPhylogeny(
898
1065
  .attr("stroke-width", 1.5);
899
1066
  });
900
1067
 
901
- // ===== 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 =====
902
1079
  // Labels — make them follow the tip position used by the current mode
903
- group
904
- .append("g")
905
- .attr("class", "phylo_labels")
906
- .selectAll("g.label")
907
- .data(tips) // <— bind only tip nodes
908
- .join("g")
909
- .attr("class", "label")
910
- .attr("transform", (d) => {
911
- // same tip position rule as dots/spokes:
912
- // - "outer": snap to common ring (tipMaxR)
913
- // - otherwise (e.g. "align"/"phylo"): true tip radius
914
- const r = isOuter ? tipMaxR : d.r;
915
- const x = r * Math.cos(d.angle);
916
- const y = r * Math.sin(d.angle);
917
- return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
918
- })
919
- .each(function(d) {
920
- // rotate so text reads outward; flip when on the left side
921
- let angle = (-d.angle * 180) / Math.PI;
922
- let xoff = 10; // radial padding for text (px)
923
- let anchor = "start";
924
- if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
925
- angle += 180;
926
- xoff *= -1;
927
- 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");
928
1177
  }
929
- d3.select(this)
930
- .append("g")
931
- .attr("transform", `rotate(${angle})`)
932
- .append("text")
933
- .attr("x", xoff)
934
- .attr("alignment-baseline", "middle")
935
- .attr("text-anchor", anchor)
936
- .attr("font-size", 10)
937
- .attr("fill", "black")
938
- .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
+ );
939
1234
  });
1235
+ }
940
1236
 
941
1237
  return svg.node();
942
1238
  } else if (layout === "unrooted") {
@@ -947,23 +1243,17 @@ function drawPhylogeny(
947
1243
  const w = width;
948
1244
  const h = height;
949
1245
 
950
- // Get spatial extent
951
1246
  const xExtent = d3.extent(unrootedPhylo.data, (d) => d.x);
952
1247
  const yExtent = d3.extent(unrootedPhylo.data, (d) => d.y);
953
-
954
- // Find maximum absolute distance from center (0,0)
955
1248
  const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
956
1249
  const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
957
1250
  const maxRadius = Math.max(maxX, maxY);
958
-
959
- // Add some margin
960
1251
  const scaleUnroot = maxRadius + 2 * radialMargin;
961
1252
 
962
1253
  const xScaleUnroot = d3
963
1254
  .scaleLinear()
964
1255
  .domain([-scaleUnroot, scaleUnroot])
965
1256
  .range([0, w]);
966
-
967
1257
  const yScaleUnroot = d3
968
1258
  .scaleLinear()
969
1259
  .domain([-scaleUnroot, scaleUnroot])
@@ -977,8 +1267,9 @@ function drawPhylogeny(
977
1267
  .attr("font-size", 10);
978
1268
 
979
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");
980
1272
 
981
- // Edges
982
1273
  group
983
1274
  .append("g")
984
1275
  .attr("class", "phylo_lines")
@@ -992,8 +1283,7 @@ function drawPhylogeny(
992
1283
  .attr("stroke-width", strokeWidth)
993
1284
  .attr("stroke", "#777");
994
1285
 
995
- // Nodes
996
- group
1286
+ const nodes = group
997
1287
  .append("g")
998
1288
  .attr("class", "phylo_points")
999
1289
  .selectAll("circle")
@@ -1007,74 +1297,147 @@ function drawPhylogeny(
1007
1297
  .attr("stroke-width", 2)
1008
1298
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1009
1299
 
1010
- // 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
+
1011
1316
  const tipEdges = new Map();
1012
1317
  const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1013
-
1014
1318
  unrootedPhylo.edges.forEach((edge) => {
1015
1319
  const tipNode = nodesById.get(edge.id1);
1016
- if (tipNode?.isTip) {
1017
- tipEdges.set(edge.id1, edge);
1018
- }
1320
+ if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
1019
1321
  });
1020
1322
 
1021
- group
1022
- .append("g")
1023
- .attr("class", "phylo_labels")
1024
- .selectAll("g")
1025
- .data(unrootedPhylo.data.filter((d) => d.isTip))
1026
- .join("g")
1027
- .attr("transform", (d) => {
1028
- const x = xScaleUnroot(d.x);
1029
- const y = yScaleUnroot(d.y);
1030
- return `translate(${x},${y})`;
1031
- })
1032
- .each(function(d) {
1033
- const edge = tipEdges.get(d.thisId);
1034
- if (!edge) {
1035
- console.warn(
1036
- "No incoming edge found for tip node:",
1037
- d.thisId,
1038
- d.thisLabel
1039
- );
1040
- return;
1041
- }
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
+ });
1042
1367
 
1043
- // Compute angle of the incoming edge (screen coords)
1044
- const x1 = xScaleUnroot(edge.x1);
1045
- const y1 = yScaleUnroot(edge.y1);
1046
- const x2 = xScaleUnroot(edge.x2);
1047
- const y2 = yScaleUnroot(edge.y2);
1048
-
1049
- const dx = x2 - x1;
1050
- const dy = y2 - y1;
1051
- let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1052
-
1053
- // Flip label if upside down
1054
- let xOffset = -10;
1055
- let anchor = "end";
1056
- if (angle > 90 || angle < -90) {
1057
- angle += 180;
1058
- anchor = "start";
1059
- xOffset = 10;
1060
- }
1368
+ if (showTooltips) {
1369
+ tipLabelsSel
1370
+ .append("title")
1371
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1372
+ }
1061
1373
 
1062
- // Draw label rotated along branch direction
1063
- d3.select(this)
1064
- .append("g")
1065
- .attr("transform", `rotate(${angle})`)
1066
- .append("text")
1067
- .attr("x", xOffset)
1068
- .attr("alignment-baseline", "middle")
1069
- .attr("text-anchor", anchor)
1070
- .attr("font-size", 10)
1071
- .attr("fill", "black")
1072
- .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);
1394
+ });
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
+ );
1073
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
+ }
1074
1435
 
1075
1436
  return svg.node();
1076
1437
  } else {
1077
- throw new Error("Unsupported layout type. Use 'rect' or 'radial'.");
1438
+ throw new Error(
1439
+ "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
1440
+ );
1078
1441
  }
1079
1442
  }
1080
1443