@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.
@@ -303,17 +303,50 @@ function getArcs(pd) {
303
303
  return arcs;
304
304
  }
305
305
 
306
+ /**
307
+ * Per-child "half" arcs for radial trees.
308
+ *
309
+ * For each non-root node (child), emit an arc at the PARENT's radius that
310
+ * spans between the parent's angle and the child's angle. This is the arc
311
+ * segment that meets the child's spoke and is ideal for root→tip highlighting.
312
+ *
313
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
314
+ * Output: [{ parentId, childId, radius, start, end }]
315
+ */
316
+ function getChildArcs(pd) {
317
+ const byId = new Map(pd.map(d => [d.thisId, d]));
318
+ const arcs = [];
319
+
320
+ for (const child of pd) {
321
+ if (child.parentId == null) continue; // skip root
322
+ const parent = byId.get(child.parentId);
323
+ if (!parent) continue;
324
+
325
+ arcs.push({
326
+ parentId: parent.thisId,
327
+ childId: child.thisId,
328
+ radius: parent.r, // draw on the parent's circle
329
+ start: parent.angle, // start at parent's angle
330
+ end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
331
+ });
332
+ }
333
+
334
+ return arcs;
335
+ }
336
+
306
337
  /**
307
338
  * Simple wrapper for radial layout:
308
339
  * - data: per-node { angle, r, x, y, ... }
309
340
  * - radii: per-edge radial spokes (parent.r → child.r)
310
- * - arcs: per-internal-node arcs spanning its children at parent radius
341
+ * - arcs: per-parent arcs spanning all children at parent's radius
342
+ * - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
311
343
  */
312
344
  function radialLayout(node) {
313
345
  const data = {};
314
346
  data.data = radialData(node);
315
347
  data.radii = getRadii(node);
316
348
  data.arcs = getArcs(data.data);
349
+ data.child_arcs = getChildArcs(data.data);
317
350
  return data;
318
351
  }
319
352
 
@@ -693,15 +726,42 @@ function readTree(text) {
693
726
  function drawPhylogeny(
694
727
  treeText,
695
728
  {
696
- layout = "rect", // "rect" or "radial"
729
+ layout = "rect", // rect/radial/unrooted
697
730
  width = 800,
698
731
  height = 800,
699
732
  margin = { top: 20, right: 300, bottom: 20, left: 50 },
700
733
  radialMargin = 80,
701
- strokeWidth = 1,
702
- 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
703
745
  } = {}
704
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
+
705
765
  if (layout === "rect") {
706
766
  // RECTANGULAR LAYOUT
707
767
  const tree_df = rectangleLayout(readTree(treeText));
@@ -709,6 +769,12 @@ function drawPhylogeny(
709
769
  const vertical = tree_df.vertical_lines;
710
770
  const tips = horizontal.filter((d) => d.isTip);
711
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
+
712
778
  const maxY = d3__namespace.max(horizontal, (d) => d.y1);
713
779
  const minY = d3__namespace.min(horizontal, (d) => d.y1);
714
780
  const maxX = d3__namespace.max(horizontal, (d) => d.x1);
@@ -732,6 +798,10 @@ function drawPhylogeny(
732
798
 
733
799
  const group = svg.append("g");
734
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
+
735
805
  group
736
806
  .selectAll(".hline")
737
807
  .data(horizontal)
@@ -754,7 +824,8 @@ function drawPhylogeny(
754
824
  .attr("stroke", "#555")
755
825
  .attr("stroke-width", strokeWidth);
756
826
 
757
- group
827
+ // tip dots
828
+ const tipDots = group
758
829
  .selectAll(".tip-dot")
759
830
  .data(tips)
760
831
  .join("circle")
@@ -763,24 +834,111 @@ function drawPhylogeny(
763
834
  .attr("r", 2)
764
835
  .attr("fill", "black");
765
836
 
766
- svg
767
- .append("g")
768
- .selectAll("text")
769
- .data(tips)
770
- .join("text")
771
- .attr("x", (d) => xScale(d.x1) + 4)
772
- .attr("y", (d) => yScale(d.y1))
773
- .attr("dy", "0.32em")
774
- .attr("font-size", 10)
775
- .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
+ }
776
934
 
777
935
  return svg.node();
778
936
  } else if (layout === "radial") {
779
937
  const parsedTree = readTree(treeText);
780
938
  const rad = radialLayout(parsedTree);
781
939
 
782
- // ===== MODE / DEBUG =====
783
- const TIP_MODE = radialMode; // "align" (shorten to original tips) or "outer" (project to one circle)
940
+ // ===== MODE =====
941
+ const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
784
942
  const isOuter = TIP_MODE === "outer";
785
943
 
786
944
  // visuals (0 = let spokes reach the dots)
@@ -809,6 +967,9 @@ function drawPhylogeny(
809
967
  const byId = new Map(rad.data.map((d) => [d.thisId, d]));
810
968
  const tips = rad.data.filter((d) => d.isTip);
811
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:
812
973
 
813
974
  // Robust child-id extractor (handles multiple shapes)
814
975
  function childIdOf(spoke) {
@@ -839,6 +1000,12 @@ function drawPhylogeny(
839
1000
 
840
1001
  const group = svg.append("g");
841
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
+
842
1009
  // ===== ARCS (parent circles) =====
843
1010
  group
844
1011
  .append("g")
@@ -866,7 +1033,7 @@ function drawPhylogeny(
866
1033
  .selectAll("line")
867
1034
  .data(rad.radii)
868
1035
  .join("line")
869
- .each(function(s, _) {
1036
+ .each(function(s, _i) {
870
1037
  // parent end (data space)
871
1038
  const x0 = s.x0,
872
1039
  y0 = s.y0;
@@ -899,13 +1066,13 @@ function drawPhylogeny(
899
1066
  });
900
1067
 
901
1068
  // ===== TIP DOTS =====
902
- group
1069
+ const tipDots = group
903
1070
  .append("g")
904
1071
  .attr("class", "phylo_tip_dots")
905
1072
  .selectAll("circle")
906
1073
  .data(tips)
907
1074
  .join("circle")
908
- .each(function(d, _) {
1075
+ .each(function(d, _i) {
909
1076
  // dot at original tip (align) or projected circle (outer)
910
1077
  const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
911
1078
  const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
@@ -919,45 +1086,174 @@ function drawPhylogeny(
919
1086
  .attr("stroke-width", 1.5);
920
1087
  });
921
1088
 
922
- // ===== 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 =====
923
1100
  // Labels — make them follow the tip position used by the current mode
924
- group
925
- .append("g")
926
- .attr("class", "phylo_labels")
927
- .selectAll("g.label")
928
- .data(tips) // <— bind only tip nodes
929
- .join("g")
930
- .attr("class", "label")
931
- .attr("transform", (d) => {
932
- // same tip position rule as dots/spokes:
933
- // - "outer": snap to common ring (tipMaxR)
934
- // - otherwise (e.g. "align"/"phylo"): true tip radius
935
- const r = isOuter ? tipMaxR : d.r;
936
- const x = r * Math.cos(d.angle);
937
- const y = r * Math.sin(d.angle);
938
- return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
939
- })
940
- .each(function(d) {
941
- // rotate so text reads outward; flip when on the left side
942
- let angle = (-d.angle * 180) / Math.PI;
943
- let xoff = 10; // radial padding for text (px)
944
- let anchor = "start";
945
- if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
946
- angle += 180;
947
- xoff *= -1;
948
- 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");
949
1198
  }
950
- d3__namespace.select(this)
951
- .append("g")
952
- .attr("transform", `rotate(${angle})`)
953
- .append("text")
954
- .attr("x", xoff)
955
- .attr("alignment-baseline", "middle")
956
- .attr("text-anchor", anchor)
957
- .attr("font-size", 10)
958
- .attr("fill", "black")
959
- .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
+ );
960
1255
  });
1256
+ }
961
1257
 
962
1258
  return svg.node();
963
1259
  } else if (layout === "unrooted") {
@@ -968,23 +1264,17 @@ function drawPhylogeny(
968
1264
  const w = width;
969
1265
  const h = height;
970
1266
 
971
- // Get spatial extent
972
1267
  const xExtent = d3__namespace.extent(unrootedPhylo.data, (d) => d.x);
973
1268
  const yExtent = d3__namespace.extent(unrootedPhylo.data, (d) => d.y);
974
-
975
- // Find maximum absolute distance from center (0,0)
976
1269
  const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
977
1270
  const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
978
1271
  const maxRadius = Math.max(maxX, maxY);
979
-
980
- // Add some margin
981
1272
  const scaleUnroot = maxRadius + 2 * radialMargin;
982
1273
 
983
1274
  const xScaleUnroot = d3__namespace
984
1275
  .scaleLinear()
985
1276
  .domain([-scaleUnroot, scaleUnroot])
986
1277
  .range([0, w]);
987
-
988
1278
  const yScaleUnroot = d3__namespace
989
1279
  .scaleLinear()
990
1280
  .domain([-scaleUnroot, scaleUnroot])
@@ -998,8 +1288,9 @@ function drawPhylogeny(
998
1288
  .attr("font-size", 10);
999
1289
 
1000
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");
1001
1293
 
1002
- // Edges
1003
1294
  group
1004
1295
  .append("g")
1005
1296
  .attr("class", "phylo_lines")
@@ -1013,8 +1304,7 @@ function drawPhylogeny(
1013
1304
  .attr("stroke-width", strokeWidth)
1014
1305
  .attr("stroke", "#777");
1015
1306
 
1016
- // Nodes
1017
- group
1307
+ const nodes = group
1018
1308
  .append("g")
1019
1309
  .attr("class", "phylo_points")
1020
1310
  .selectAll("circle")
@@ -1028,74 +1318,147 @@ function drawPhylogeny(
1028
1318
  .attr("stroke-width", 2)
1029
1319
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1030
1320
 
1031
- // 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
+
1032
1337
  const tipEdges = new Map();
1033
1338
  const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1034
-
1035
1339
  unrootedPhylo.edges.forEach((edge) => {
1036
1340
  const tipNode = nodesById.get(edge.id1);
1037
- if (tipNode?.isTip) {
1038
- tipEdges.set(edge.id1, edge);
1039
- }
1341
+ if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
1040
1342
  });
1041
1343
 
1042
- group
1043
- .append("g")
1044
- .attr("class", "phylo_labels")
1045
- .selectAll("g")
1046
- .data(unrootedPhylo.data.filter((d) => d.isTip))
1047
- .join("g")
1048
- .attr("transform", (d) => {
1049
- const x = xScaleUnroot(d.x);
1050
- const y = yScaleUnroot(d.y);
1051
- return `translate(${x},${y})`;
1052
- })
1053
- .each(function(d) {
1054
- const edge = tipEdges.get(d.thisId);
1055
- if (!edge) {
1056
- console.warn(
1057
- "No incoming edge found for tip node:",
1058
- d.thisId,
1059
- d.thisLabel
1060
- );
1061
- return;
1062
- }
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
+ });
1063
1388
 
1064
- // Compute angle of the incoming edge (screen coords)
1065
- const x1 = xScaleUnroot(edge.x1);
1066
- const y1 = yScaleUnroot(edge.y1);
1067
- const x2 = xScaleUnroot(edge.x2);
1068
- const y2 = yScaleUnroot(edge.y2);
1069
-
1070
- const dx = x2 - x1;
1071
- const dy = y2 - y1;
1072
- let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1073
-
1074
- // Flip label if upside down
1075
- let xOffset = -10;
1076
- let anchor = "end";
1077
- if (angle > 90 || angle < -90) {
1078
- angle += 180;
1079
- anchor = "start";
1080
- xOffset = 10;
1081
- }
1389
+ if (showTooltips) {
1390
+ tipLabelsSel
1391
+ .append("title")
1392
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1393
+ }
1082
1394
 
1083
- // Draw label rotated along branch direction
1084
- d3__namespace.select(this)
1085
- .append("g")
1086
- .attr("transform", `rotate(${angle})`)
1087
- .append("text")
1088
- .attr("x", xOffset)
1089
- .attr("alignment-baseline", "middle")
1090
- .attr("text-anchor", anchor)
1091
- .attr("font-size", 10)
1092
- .attr("fill", "black")
1093
- .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);
1415
+ });
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
+ );
1094
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
+ }
1095
1456
 
1096
1457
  return svg.node();
1097
1458
  } else {
1098
- throw new Error("Unsupported layout type. Use 'rect' or 'radial'.");
1459
+ throw new Error(
1460
+ "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
1461
+ );
1099
1462
  }
1100
1463
  }
1101
1464