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