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