@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.
package/dist/index.js CHANGED
@@ -872,15 +872,42 @@ function subTree (tree, node) {
872
872
  function drawPhylogeny(
873
873
  treeText,
874
874
  {
875
- layout = "rect", // "rect" or "radial"
875
+ layout = "rect", // rect/radial/unrooted
876
876
  width = 800,
877
877
  height = 800,
878
878
  margin = { top: 20, right: 300, bottom: 20, left: 50 },
879
879
  radialMargin = 80,
880
- strokeWidth = 1,
881
- radialMode = "outer" // or "align"
880
+ strokeWidth = 1, // for the phylogeny branches
881
+ radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
882
+ tipLabels = true,
883
+ showTooltips = true,
884
+ tooltipFormatter = (d, rtt) =>
885
+ `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
886
+ hoverStroke = "#1f77b4",
887
+ hoverWidth = 3,
888
+ highlightTips = [], // array of tip labels or ids for static highlight (optional)
889
+ highlightStroke = "#e63946",
890
+ highlightWidth = 2.5
882
891
  } = {}
883
892
  ) {
893
+
894
+ // shared helpers
895
+ const isNumber = (x) => typeof x === "number" && Number.isFinite(x);
896
+ function makeRootToTipGetter(byId) {
897
+ const memo = new Map();
898
+ return function rootToTip(id) {
899
+ if (memo.has(id)) return memo.get(id);
900
+ let cur = byId.get(id);
901
+ let sum = 0;
902
+ while (cur && cur.parentId != null) {
903
+ sum += +cur.branchLength || 0;
904
+ cur = byId.get(cur.parentId);
905
+ }
906
+ memo.set(id, sum);
907
+ return sum;
908
+ };
909
+ }
910
+
884
911
  if (layout === "rect") {
885
912
  // RECTANGULAR LAYOUT
886
913
  const tree_df = rectangleLayout(readTree(treeText));
@@ -888,6 +915,12 @@ function drawPhylogeny(
888
915
  const vertical = tree_df.vertical_lines;
889
916
  const tips = horizontal.filter((d) => d.isTip);
890
917
 
918
+ // indices & root→tip getter
919
+ const byId = new Map(horizontal.map((d) => [d.thisId, d]));
920
+ const tipById = new Map(tips.map((d) => [d.thisId, d]));
921
+ const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
922
+ const rootToTip = makeRootToTipGetter(byId);
923
+
891
924
  const maxY = d3.max(horizontal, (d) => d.y1);
892
925
  const minY = d3.min(horizontal, (d) => d.y1);
893
926
  const maxX = d3.max(horizontal, (d) => d.x1);
@@ -911,6 +944,10 @@ function drawPhylogeny(
911
944
 
912
945
  const group = svg.append("g");
913
946
 
947
+ // layers for highlight/hover
948
+ const staticLayer = svg.append("g").attr("class", "phylo_static_highlight");
949
+ const hoverLayer = svg.append("g").attr("class", "phylo_hover_highlight");
950
+
914
951
  group
915
952
  .selectAll(".hline")
916
953
  .data(horizontal)
@@ -933,7 +970,8 @@ function drawPhylogeny(
933
970
  .attr("stroke", "#555")
934
971
  .attr("stroke-width", strokeWidth);
935
972
 
936
- group
973
+ // tip dots
974
+ const tipDots = group
937
975
  .selectAll(".tip-dot")
938
976
  .data(tips)
939
977
  .join("circle")
@@ -942,16 +980,103 @@ function drawPhylogeny(
942
980
  .attr("r", 2)
943
981
  .attr("fill", "black");
944
982
 
945
- svg
946
- .append("g")
947
- .selectAll("text")
948
- .data(tips)
949
- .join("text")
950
- .attr("x", (d) => xScale(d.x1) + 4)
951
- .attr("y", (d) => yScale(d.y1))
952
- .attr("dy", "0.32em")
953
- .attr("font-size", 10)
954
- .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
983
+ // tooltips for rect dots
984
+ if (showTooltips) {
985
+ tipDots
986
+ .append("title")
987
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
988
+ }
989
+
990
+ // interactive root→tip highlight (rect) on dot hover
991
+ tipDots
992
+ .on("mouseenter", function(_event, d) {
993
+ drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
994
+ d3.select(this).attr("r", 4);
995
+ })
996
+ .on("mouseleave", function() {
997
+ hoverLayer.selectAll("*").remove();
998
+ d3.select(this).attr("r", 2);
999
+ });
1000
+
1001
+ // labels
1002
+ if (tipLabels) {
1003
+ const labels = svg
1004
+ .append("g")
1005
+ .attr("class", "phylo_labels")
1006
+ .selectAll("text")
1007
+ .data(tips)
1008
+ .join("text")
1009
+ .attr("x", (d) => xScale(d.x1) + 4)
1010
+ .attr("y", (d) => yScale(d.y1))
1011
+ .attr("dy", "0.32em")
1012
+ .attr("font-size", 10)
1013
+ .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1014
+
1015
+ if (showTooltips) {
1016
+ labels
1017
+ .append("title")
1018
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1019
+ }
1020
+
1021
+ labels
1022
+ .on("mouseenter", function(_event, d) {
1023
+ drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1024
+ d3.select(this).attr("font-weight", 600);
1025
+ })
1026
+ .on("mouseleave", function() {
1027
+ hoverLayer.selectAll("*").remove();
1028
+ d3.select(this).attr("font-weight", null);
1029
+ });
1030
+ }
1031
+
1032
+ // static highlight by ids/labels
1033
+ if (highlightTips && highlightTips.length) {
1034
+ const chosen = new Set(
1035
+ [
1036
+ ...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
1037
+ ...highlightTips
1038
+ .filter((x) => !isNumber(x))
1039
+ .map((lb) => tipByLabel.get(lb))
1040
+ ].filter(Boolean)
1041
+ );
1042
+ chosen.forEach((tip) => {
1043
+ drawRectPath(tip.thisId, staticLayer, highlightStroke, highlightWidth);
1044
+ });
1045
+ }
1046
+
1047
+ // helper to draw root→tip for rect (both vertical+horizontal)
1048
+ function drawRectPath(tipId, layer, stroke, width) {
1049
+ layer.selectAll("*").remove();
1050
+ let cur = byId.get(tipId);
1051
+ while (cur && cur.parentId != null) {
1052
+ const parent = byId.get(cur.parentId);
1053
+ if (!parent) break;
1054
+
1055
+ // vertical at junction x0 from parent.y to child.y
1056
+ layer
1057
+ .append("line")
1058
+ .attr("x1", xScale(cur.x0))
1059
+ .attr("x2", xScale(cur.x0))
1060
+ .attr("y1", yScale(parent.y0))
1061
+ .attr("y2", yScale(cur.y0))
1062
+ .attr("stroke", stroke)
1063
+ .attr("stroke-width", width)
1064
+ .attr("stroke-linecap", "round");
1065
+
1066
+ // horizontal along child's y, from junction x0 to x1
1067
+ layer
1068
+ .append("line")
1069
+ .attr("x1", xScale(cur.x0))
1070
+ .attr("x2", xScale(cur.x1))
1071
+ .attr("y1", yScale(cur.y0))
1072
+ .attr("y2", yScale(cur.y1))
1073
+ .attr("stroke", stroke)
1074
+ .attr("stroke-width", width)
1075
+ .attr("stroke-linecap", "round");
1076
+
1077
+ cur = parent;
1078
+ }
1079
+ }
955
1080
 
956
1081
  return svg.node();
957
1082
  } else if (layout === "radial") {
@@ -959,7 +1084,7 @@ function drawPhylogeny(
959
1084
  const rad = radialLayout(parsedTree);
960
1085
 
961
1086
  // ===== MODE =====
962
- const TIP_MODE = radialMode; // "align" (shorten to original tips) or "outer" (project to one circle)
1087
+ const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
963
1088
  const isOuter = TIP_MODE === "outer";
964
1089
 
965
1090
  // visuals (0 = let spokes reach the dots)
@@ -988,6 +1113,9 @@ function drawPhylogeny(
988
1113
  const byId = new Map(rad.data.map((d) => [d.thisId, d]));
989
1114
  const tips = rad.data.filter((d) => d.isTip);
990
1115
  const tipMaxR = tips.length ? d3.max(tips, (d) => d.r) : 0;
1116
+ const rootToTip = makeRootToTipGetter(byId);
1117
+ const tipById = new Map(tips.map((d) => [d.thisId, d])); // HILITE:
1118
+ const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d])); // HILITE:
991
1119
 
992
1120
  // Robust child-id extractor (handles multiple shapes)
993
1121
  function childIdOf(spoke) {
@@ -1018,6 +1146,12 @@ function drawPhylogeny(
1018
1146
 
1019
1147
  const group = svg.append("g");
1020
1148
 
1149
+ // overlay groups (drawn on top)
1150
+ const staticLines = svg.append("g").attr("class", "phylo_static_lines"); // HILITE:
1151
+ const staticArcs = svg.append("g").attr("class", "phylo_static_arcs"); // HILITE:
1152
+ const hoverLines = svg.append("g").attr("class", "phylo_hover_lines"); // HILITE:
1153
+ const hoverArcs = svg.append("g").attr("class", "phylo_hover_arcs"); // HILITE:
1154
+
1021
1155
  // ===== ARCS (parent circles) =====
1022
1156
  group
1023
1157
  .append("g")
@@ -1045,7 +1179,7 @@ function drawPhylogeny(
1045
1179
  .selectAll("line")
1046
1180
  .data(rad.radii)
1047
1181
  .join("line")
1048
- .each(function(s, _) {
1182
+ .each(function(s, _i) {
1049
1183
  // parent end (data space)
1050
1184
  const x0 = s.x0,
1051
1185
  y0 = s.y0;
@@ -1078,13 +1212,13 @@ function drawPhylogeny(
1078
1212
  });
1079
1213
 
1080
1214
  // ===== TIP DOTS =====
1081
- group
1215
+ const tipDots = group
1082
1216
  .append("g")
1083
1217
  .attr("class", "phylo_tip_dots")
1084
1218
  .selectAll("circle")
1085
1219
  .data(tips)
1086
1220
  .join("circle")
1087
- .each(function(d, _) {
1221
+ .each(function(d, _i) {
1088
1222
  // dot at original tip (align) or projected circle (outer)
1089
1223
  const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
1090
1224
  const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
@@ -1098,45 +1232,174 @@ function drawPhylogeny(
1098
1232
  .attr("stroke-width", 1.5);
1099
1233
  });
1100
1234
 
1101
- // ===== LABELS (unchanged) =====
1235
+ if (showTooltips) {
1236
+ tipDots
1237
+ .append("title")
1238
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1239
+ }
1240
+
1241
+ // maps for fast lookup on hover (childId → spoke / arc)
1242
+ const spokeByChild = new Map(rad.radii.map((s) => [childIdOf(s), s]));
1243
+ const arcByChild = new Map(rad.child_arcs.map((a) => [a.childId, a]));
1244
+
1245
+ // ===== LABELS =====
1102
1246
  // Labels — make them follow the tip position used by the current mode
1103
- group
1104
- .append("g")
1105
- .attr("class", "phylo_labels")
1106
- .selectAll("g.label")
1107
- .data(tips) // <— bind only tip nodes
1108
- .join("g")
1109
- .attr("class", "label")
1110
- .attr("transform", (d) => {
1111
- // same tip position rule as dots/spokes:
1112
- // - "outer": snap to common ring (tipMaxR)
1113
- // - otherwise (e.g. "align"/"phylo"): true tip radius
1114
- const r = isOuter ? tipMaxR : d.r;
1115
- const x = r * Math.cos(d.angle);
1116
- const y = r * Math.sin(d.angle);
1117
- return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
1118
- })
1119
- .each(function(d) {
1120
- // rotate so text reads outward; flip when on the left side
1121
- let angle = (-d.angle * 180) / Math.PI;
1122
- let xoff = 10; // radial padding for text (px)
1123
- let anchor = "start";
1124
- if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
1125
- angle += 180;
1126
- xoff *= -1;
1127
- anchor = "end";
1247
+ if (tipLabels) {
1248
+ const labels = group
1249
+ .append("g")
1250
+ .attr("class", "phylo_labels")
1251
+ .selectAll("g.label")
1252
+ .data(tips)
1253
+ .join("g")
1254
+ .attr("class", "label")
1255
+ .attr("transform", (d) => {
1256
+ // same tip position rule as dots/spokes:
1257
+ // - "outer": snap to common ring (tipMaxR)
1258
+ // - otherwise (e.g. "align"/"phylo"): true tip radius
1259
+ const r = isOuter ? tipMaxR : d.r;
1260
+ const x = r * Math.cos(d.angle);
1261
+ const y = r * Math.sin(d.angle);
1262
+ return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
1263
+ })
1264
+ .each(function(d) {
1265
+ // rotate so text reads outward; flip when on the left side
1266
+ let angle = (-d.angle * 180) / Math.PI;
1267
+ let xoff = 10; // radial padding for text (px)
1268
+ let anchor = "start";
1269
+ if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
1270
+ angle += 180;
1271
+ xoff *= -1;
1272
+ anchor = "end";
1273
+ }
1274
+ d3.select(this)
1275
+ .append("g")
1276
+ .attr("transform", `rotate(${angle})`)
1277
+ .append("text")
1278
+ .attr("x", xoff)
1279
+ .attr("alignment-baseline", "middle")
1280
+ .attr("text-anchor", anchor)
1281
+ .attr("font-size", 10)
1282
+ .attr("fill", "black")
1283
+ .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1284
+ });
1285
+
1286
+ if (showTooltips) {
1287
+ labels
1288
+ .append("title")
1289
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1290
+ }
1291
+
1292
+ // label hover
1293
+ labels
1294
+ .on("mouseenter", function(event, d) {
1295
+ drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1296
+ d3.select(this).select("text").attr("font-weight", 600);
1297
+ })
1298
+ .on("mouseleave", function() {
1299
+ hoverLines.selectAll("*").remove();
1300
+ hoverArcs.selectAll("*").remove();
1301
+ d3.select(this).select("text").attr("font-weight", null);
1302
+ });
1303
+ }
1304
+
1305
+ // draw (overlay) the root→tip path: spokes + arcs (half-arc per child)
1306
+ function drawRadialPath(
1307
+ target,
1308
+ lineLayer,
1309
+ arcLayer,
1310
+ stroke = "#1f77b4",
1311
+ width = 3
1312
+ ) {
1313
+ // target may be a tip node *or* a numeric tip id
1314
+ lineLayer.selectAll("*").remove();
1315
+ arcLayer.selectAll("*").remove();
1316
+
1317
+ let cur = typeof target === "number" ? byId.get(target) : target;
1318
+ if (!cur) return;
1319
+
1320
+ let first = true;
1321
+ while (cur && cur.parentId != null) {
1322
+ // ----- spoke (parent → child) -----
1323
+ const s = spokeByChild.get(cur.thisId);
1324
+ if (s) {
1325
+ const px = s.x0,
1326
+ py = s.y0;
1327
+ let cx = s.x1,
1328
+ cy = s.y1;
1329
+ if (isOuter && first && cur.isTip) {
1330
+ const r = tipMaxR;
1331
+ cx = r * Math.cos(cur.angle);
1332
+ cy = r * Math.sin(cur.angle);
1333
+ }
1334
+ const { X0, Y0, X1s, Y1s } = shortenSpokePx(px, py, cx, cy);
1335
+ lineLayer
1336
+ .append("line")
1337
+ .attr("x1", X0)
1338
+ .attr("y1", Y0)
1339
+ .attr("x2", X1s)
1340
+ .attr("y2", Y1s)
1341
+ .attr("stroke", stroke)
1342
+ .attr("stroke-width", width)
1343
+ .attr("stroke-linecap", "round");
1128
1344
  }
1129
- d3.select(this)
1130
- .append("g")
1131
- .attr("transform", `rotate(${angle})`)
1132
- .append("text")
1133
- .attr("x", xoff)
1134
- .attr("alignment-baseline", "middle")
1135
- .attr("text-anchor", anchor)
1136
- .attr("font-size", 10)
1137
- .attr("fill", "black")
1138
- .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1345
+
1346
+ // ----- half-arc at parent radius (parent.angle → child.angle) -----
1347
+ const a = arcByChild.get(cur.thisId);
1348
+ if (a) {
1349
+ arcLayer
1350
+ .append("path")
1351
+ .attr(
1352
+ "d",
1353
+ describeArc(
1354
+ centerX,
1355
+ centerY,
1356
+ Math.max(0, radiusPx(a.radius)),
1357
+ a.start,
1358
+ a.end
1359
+ )
1360
+ )
1361
+ .attr("fill", "none")
1362
+ .attr("stroke", stroke)
1363
+ .attr("stroke-width", width);
1364
+ }
1365
+
1366
+ first = false;
1367
+ cur = byId.get(cur.parentId);
1368
+ }
1369
+ }
1370
+
1371
+ // tip dot hover
1372
+ tipDots
1373
+ .on("mouseenter", function(_event, d) {
1374
+ drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1375
+ d3.select(this).attr("r", DOT_R + 2);
1376
+ })
1377
+ .on("mouseleave", function() {
1378
+ hoverLines.selectAll("*").remove();
1379
+ hoverArcs.selectAll("*").remove();
1380
+ d3.select(this).attr("r", DOT_R);
1381
+ });
1382
+
1383
+ if (highlightTips && highlightTips.length) {
1384
+ const chosen = new Set(
1385
+ [
1386
+ ...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
1387
+ ...highlightTips
1388
+ .filter((x) => !isNumber(x))
1389
+ .map((lb) => tipByLabel.get(lb))
1390
+ ].filter(Boolean)
1391
+ );
1392
+
1393
+ chosen.forEach((tip) => {
1394
+ drawRadialPath(
1395
+ tip.thisId,
1396
+ staticLines,
1397
+ staticArcs,
1398
+ highlightStroke,
1399
+ highlightWidth
1400
+ );
1139
1401
  });
1402
+ }
1140
1403
 
1141
1404
  return svg.node();
1142
1405
  } else if (layout === "unrooted") {
@@ -1147,23 +1410,17 @@ function drawPhylogeny(
1147
1410
  const w = width;
1148
1411
  const h = height;
1149
1412
 
1150
- // Get spatial extent
1151
1413
  const xExtent = d3.extent(unrootedPhylo.data, (d) => d.x);
1152
1414
  const yExtent = d3.extent(unrootedPhylo.data, (d) => d.y);
1153
-
1154
- // Find maximum absolute distance from center (0,0)
1155
1415
  const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
1156
1416
  const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
1157
1417
  const maxRadius = Math.max(maxX, maxY);
1158
-
1159
- // Add some margin
1160
1418
  const scaleUnroot = maxRadius + 2 * radialMargin;
1161
1419
 
1162
1420
  const xScaleUnroot = d3
1163
1421
  .scaleLinear()
1164
1422
  .domain([-scaleUnroot, scaleUnroot])
1165
1423
  .range([0, w]);
1166
-
1167
1424
  const yScaleUnroot = d3
1168
1425
  .scaleLinear()
1169
1426
  .domain([-scaleUnroot, scaleUnroot])
@@ -1177,8 +1434,9 @@ function drawPhylogeny(
1177
1434
  .attr("font-size", 10);
1178
1435
 
1179
1436
  const group = svg.append("g");
1437
+ const staticLayer = svg.append("g").attr("class", "phylo_static_highlight");
1438
+ const hoverLayer = svg.append("g").attr("class", "phylo_hover_highlight");
1180
1439
 
1181
- // Edges
1182
1440
  group
1183
1441
  .append("g")
1184
1442
  .attr("class", "phylo_lines")
@@ -1192,8 +1450,7 @@ function drawPhylogeny(
1192
1450
  .attr("stroke-width", strokeWidth)
1193
1451
  .attr("stroke", "#777");
1194
1452
 
1195
- // Nodes
1196
- group
1453
+ const nodes = group
1197
1454
  .append("g")
1198
1455
  .attr("class", "phylo_points")
1199
1456
  .selectAll("circle")
@@ -1207,74 +1464,147 @@ function drawPhylogeny(
1207
1464
  .attr("stroke-width", 2)
1208
1465
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1209
1466
 
1210
- // Tip labels
1467
+ const byId = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1468
+ const tipById = new Map(
1469
+ unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisId, d])
1470
+ );
1471
+ const tipByLabel = new Map(
1472
+ unrootedPhylo.data.filter((d) => d.isTip).map((d) => [d.thisLabel, d])
1473
+ );
1474
+ const rootToTip = makeRootToTipGetter(byId);
1475
+
1476
+ if (showTooltips) {
1477
+ nodes
1478
+ .filter((d) => d.isTip)
1479
+ .append("title")
1480
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1481
+ }
1482
+
1211
1483
  const tipEdges = new Map();
1212
1484
  const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1213
-
1214
1485
  unrootedPhylo.edges.forEach((edge) => {
1215
1486
  const tipNode = nodesById.get(edge.id1);
1216
- if (tipNode?.isTip) {
1217
- tipEdges.set(edge.id1, edge);
1218
- }
1487
+ if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
1219
1488
  });
1220
1489
 
1221
- group
1222
- .append("g")
1223
- .attr("class", "phylo_labels")
1224
- .selectAll("g")
1225
- .data(unrootedPhylo.data.filter((d) => d.isTip))
1226
- .join("g")
1227
- .attr("transform", (d) => {
1228
- const x = xScaleUnroot(d.x);
1229
- const y = yScaleUnroot(d.y);
1230
- return `translate(${x},${y})`;
1231
- })
1232
- .each(function(d) {
1233
- const edge = tipEdges.get(d.thisId);
1234
- if (!edge) {
1235
- console.warn(
1236
- "No incoming edge found for tip node:",
1237
- d.thisId,
1238
- d.thisLabel
1239
- );
1240
- return;
1241
- }
1490
+ if (tipLabels) {
1491
+ const tipLabelsSel = group
1492
+ .append("g")
1493
+ .attr("class", "phylo_labels")
1494
+ .selectAll("g")
1495
+ .data(unrootedPhylo.data.filter((d) => d.isTip))
1496
+ .join("g")
1497
+ .attr("transform", (d) => {
1498
+ const x = xScaleUnroot(d.x);
1499
+ const y = yScaleUnroot(d.y);
1500
+ return `translate(${x},${y})`;
1501
+ })
1502
+ .each(function(d) {
1503
+ const edge = tipEdges.get(d.thisId);
1504
+ if (!edge) return;
1505
+
1506
+ const x1 = xScaleUnroot(edge.x1);
1507
+ const y1 = yScaleUnroot(edge.y1);
1508
+ const x2 = xScaleUnroot(edge.x2);
1509
+ const y2 = yScaleUnroot(edge.y2);
1510
+
1511
+ const dx = x2 - x1;
1512
+ const dy = y2 - y1;
1513
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1514
+
1515
+ let xOffset = -10;
1516
+ let anchor = "end";
1517
+ if (angle > 90 || angle < -90) {
1518
+ angle += 180;
1519
+ anchor = "start";
1520
+ xOffset = 10;
1521
+ }
1522
+
1523
+ d3.select(this)
1524
+ .append("g")
1525
+ .attr("transform", `rotate(${angle})`)
1526
+ .append("text")
1527
+ .attr("x", xOffset)
1528
+ .attr("alignment-baseline", "middle")
1529
+ .attr("text-anchor", anchor)
1530
+ .attr("font-size", 10)
1531
+ .attr("fill", "black")
1532
+ .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1533
+ });
1242
1534
 
1243
- // Compute angle of the incoming edge (screen coords)
1244
- const x1 = xScaleUnroot(edge.x1);
1245
- const y1 = yScaleUnroot(edge.y1);
1246
- const x2 = xScaleUnroot(edge.x2);
1247
- const y2 = yScaleUnroot(edge.y2);
1248
-
1249
- const dx = x2 - x1;
1250
- const dy = y2 - y1;
1251
- let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1252
-
1253
- // Flip label if upside down
1254
- let xOffset = -10;
1255
- let anchor = "end";
1256
- if (angle > 90 || angle < -90) {
1257
- angle += 180;
1258
- anchor = "start";
1259
- xOffset = 10;
1260
- }
1535
+ if (showTooltips) {
1536
+ tipLabelsSel
1537
+ .append("title")
1538
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1539
+ }
1261
1540
 
1262
- // Draw label rotated along branch direction
1263
- d3.select(this)
1264
- .append("g")
1265
- .attr("transform", `rotate(${angle})`)
1266
- .append("text")
1267
- .attr("x", xOffset)
1268
- .attr("alignment-baseline", "middle")
1269
- .attr("text-anchor", anchor)
1270
- .attr("font-size", 10)
1271
- .attr("fill", "black")
1272
- .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1541
+ tipLabelsSel
1542
+ .on("mouseenter", function(_event, d) {
1543
+ drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1544
+ d3.select(this).select("text").attr("font-weight", 600);
1545
+ })
1546
+ .on("mouseleave", function() {
1547
+ hoverLayer.selectAll("*").remove();
1548
+ d3.select(this).select("text").attr("font-weight", null);
1549
+ });
1550
+ }
1551
+
1552
+ nodes
1553
+ .filter((d) => d.isTip)
1554
+ .on("mouseenter", function(_event, d) {
1555
+ drawUnrootedPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1556
+ d3.select(this).attr("r", 6);
1557
+ })
1558
+ .on("mouseleave", function() {
1559
+ hoverLayer.selectAll("*").remove();
1560
+ d3.select(this).attr("r", 4);
1561
+ });
1562
+
1563
+ if (highlightTips && highlightTips.length) {
1564
+ const chosen = new Set(
1565
+ [
1566
+ ...highlightTips.filter(isNumber).map((id) => tipById.get(id)),
1567
+ ...highlightTips
1568
+ .filter((x) => !isNumber(x))
1569
+ .map((lb) => tipByLabel.get(lb))
1570
+ ].filter(Boolean)
1571
+ );
1572
+ chosen.forEach((tip) => {
1573
+ drawUnrootedPath(
1574
+ tip.thisId,
1575
+ staticLayer,
1576
+ highlightStroke,
1577
+ highlightWidth
1578
+ );
1273
1579
  });
1580
+ }
1581
+
1582
+ function drawUnrootedPath(tipId, layer, stroke, width) {
1583
+ const edgeFromChild = new Map(unrootedPhylo.edges.map((e) => [e.id1, e]));
1584
+ layer.selectAll("*").remove();
1585
+ let cur = byId.get(tipId);
1586
+ while (cur && cur.parentId != null) {
1587
+ const e = edgeFromChild.get(cur.thisId);
1588
+ if (e) {
1589
+ layer
1590
+ .append("line")
1591
+ .attr("x1", xScaleUnroot(e.x1))
1592
+ .attr("y1", yScaleUnroot(e.y1))
1593
+ .attr("x2", xScaleUnroot(e.x2))
1594
+ .attr("y2", yScaleUnroot(e.y2))
1595
+ .attr("stroke", stroke)
1596
+ .attr("stroke-width", width)
1597
+ .attr("stroke-linecap", "round");
1598
+ }
1599
+ cur = byId.get(cur.parentId);
1600
+ }
1601
+ }
1274
1602
 
1275
1603
  return svg.node();
1276
1604
  } else {
1277
- throw new Error("Unsupported layout type. Use 'rect' or 'radial'.");
1605
+ throw new Error(
1606
+ "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
1607
+ );
1278
1608
  }
1279
1609
  }
1280
1610