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