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