@euphrasiologist/lwphylo 1.2.1 → 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
@@ -395,17 +395,50 @@ function getArcs(pd) {
395
395
  return arcs;
396
396
  }
397
397
 
398
+ /**
399
+ * Per-child "half" arcs for radial trees.
400
+ *
401
+ * For each non-root node (child), emit an arc at the PARENT's radius that
402
+ * spans between the parent's angle and the child's angle. This is the arc
403
+ * segment that meets the child's spoke and is ideal for root→tip highlighting.
404
+ *
405
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
406
+ * Output: [{ parentId, childId, radius, start, end }]
407
+ */
408
+ function getChildArcs(pd) {
409
+ const byId = new Map(pd.map(d => [d.thisId, d]));
410
+ const arcs = [];
411
+
412
+ for (const child of pd) {
413
+ if (child.parentId == null) continue; // skip root
414
+ const parent = byId.get(child.parentId);
415
+ if (!parent) continue;
416
+
417
+ arcs.push({
418
+ parentId: parent.thisId,
419
+ childId: child.thisId,
420
+ radius: parent.r, // draw on the parent's circle
421
+ start: parent.angle, // start at parent's angle
422
+ end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
423
+ });
424
+ }
425
+
426
+ return arcs;
427
+ }
428
+
398
429
  /**
399
430
  * Simple wrapper for radial layout:
400
431
  * - data: per-node { angle, r, x, y, ... }
401
432
  * - radii: per-edge radial spokes (parent.r → child.r)
402
- * - arcs: per-internal-node arcs spanning its children at parent radius
433
+ * - arcs: per-parent arcs spanning all children at parent's radius
434
+ * - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
403
435
  */
404
436
  function radialLayout(node) {
405
437
  const data = {};
406
438
  data.data = radialData(node);
407
439
  data.radii = getRadii(node);
408
440
  data.arcs = getArcs(data.data);
441
+ data.child_arcs = getChildArcs(data.data);
409
442
  return data;
410
443
  }
411
444
 
@@ -839,15 +872,42 @@ function subTree (tree, node) {
839
872
  function drawPhylogeny(
840
873
  treeText,
841
874
  {
842
- layout = "rect", // "rect" or "radial"
875
+ layout = "rect", // rect/radial/unrooted
843
876
  width = 800,
844
877
  height = 800,
845
878
  margin = { top: 20, right: 300, bottom: 20, left: 50 },
846
879
  radialMargin = 80,
847
- strokeWidth = 1,
848
- 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
849
891
  } = {}
850
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
+
851
911
  if (layout === "rect") {
852
912
  // RECTANGULAR LAYOUT
853
913
  const tree_df = rectangleLayout(readTree(treeText));
@@ -855,6 +915,12 @@ function drawPhylogeny(
855
915
  const vertical = tree_df.vertical_lines;
856
916
  const tips = horizontal.filter((d) => d.isTip);
857
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
+
858
924
  const maxY = d3.max(horizontal, (d) => d.y1);
859
925
  const minY = d3.min(horizontal, (d) => d.y1);
860
926
  const maxX = d3.max(horizontal, (d) => d.x1);
@@ -878,6 +944,10 @@ function drawPhylogeny(
878
944
 
879
945
  const group = svg.append("g");
880
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
+
881
951
  group
882
952
  .selectAll(".hline")
883
953
  .data(horizontal)
@@ -900,7 +970,8 @@ function drawPhylogeny(
900
970
  .attr("stroke", "#555")
901
971
  .attr("stroke-width", strokeWidth);
902
972
 
903
- group
973
+ // tip dots
974
+ const tipDots = group
904
975
  .selectAll(".tip-dot")
905
976
  .data(tips)
906
977
  .join("circle")
@@ -909,24 +980,111 @@ function drawPhylogeny(
909
980
  .attr("r", 2)
910
981
  .attr("fill", "black");
911
982
 
912
- svg
913
- .append("g")
914
- .selectAll("text")
915
- .data(tips)
916
- .join("text")
917
- .attr("x", (d) => xScale(d.x1) + 4)
918
- .attr("y", (d) => yScale(d.y1))
919
- .attr("dy", "0.32em")
920
- .attr("font-size", 10)
921
- .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
+ }
922
1080
 
923
1081
  return svg.node();
924
1082
  } else if (layout === "radial") {
925
1083
  const parsedTree = readTree(treeText);
926
1084
  const rad = radialLayout(parsedTree);
927
1085
 
928
- // ===== MODE / DEBUG =====
929
- const TIP_MODE = radialMode; // "align" (shorten to original tips) or "outer" (project to one circle)
1086
+ // ===== MODE =====
1087
+ const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
930
1088
  const isOuter = TIP_MODE === "outer";
931
1089
 
932
1090
  // visuals (0 = let spokes reach the dots)
@@ -955,6 +1113,9 @@ function drawPhylogeny(
955
1113
  const byId = new Map(rad.data.map((d) => [d.thisId, d]));
956
1114
  const tips = rad.data.filter((d) => d.isTip);
957
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:
958
1119
 
959
1120
  // Robust child-id extractor (handles multiple shapes)
960
1121
  function childIdOf(spoke) {
@@ -985,6 +1146,12 @@ function drawPhylogeny(
985
1146
 
986
1147
  const group = svg.append("g");
987
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
+
988
1155
  // ===== ARCS (parent circles) =====
989
1156
  group
990
1157
  .append("g")
@@ -1012,7 +1179,7 @@ function drawPhylogeny(
1012
1179
  .selectAll("line")
1013
1180
  .data(rad.radii)
1014
1181
  .join("line")
1015
- .each(function(s, _) {
1182
+ .each(function(s, _i) {
1016
1183
  // parent end (data space)
1017
1184
  const x0 = s.x0,
1018
1185
  y0 = s.y0;
@@ -1045,13 +1212,13 @@ function drawPhylogeny(
1045
1212
  });
1046
1213
 
1047
1214
  // ===== TIP DOTS =====
1048
- group
1215
+ const tipDots = group
1049
1216
  .append("g")
1050
1217
  .attr("class", "phylo_tip_dots")
1051
1218
  .selectAll("circle")
1052
1219
  .data(tips)
1053
1220
  .join("circle")
1054
- .each(function(d, _) {
1221
+ .each(function(d, _i) {
1055
1222
  // dot at original tip (align) or projected circle (outer)
1056
1223
  const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
1057
1224
  const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
@@ -1065,45 +1232,174 @@ function drawPhylogeny(
1065
1232
  .attr("stroke-width", 1.5);
1066
1233
  });
1067
1234
 
1068
- // ===== 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 =====
1069
1246
  // Labels — make them follow the tip position used by the current mode
1070
- group
1071
- .append("g")
1072
- .attr("class", "phylo_labels")
1073
- .selectAll("g.label")
1074
- .data(tips) // <— bind only tip nodes
1075
- .join("g")
1076
- .attr("class", "label")
1077
- .attr("transform", (d) => {
1078
- // same tip position rule as dots/spokes:
1079
- // - "outer": snap to common ring (tipMaxR)
1080
- // - otherwise (e.g. "align"/"phylo"): true tip radius
1081
- const r = isOuter ? tipMaxR : d.r;
1082
- const x = r * Math.cos(d.angle);
1083
- const y = r * Math.sin(d.angle);
1084
- return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
1085
- })
1086
- .each(function(d) {
1087
- // rotate so text reads outward; flip when on the left side
1088
- let angle = (-d.angle * 180) / Math.PI;
1089
- let xoff = 10; // radial padding for text (px)
1090
- let anchor = "start";
1091
- if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
1092
- angle += 180;
1093
- xoff *= -1;
1094
- 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");
1095
1344
  }
1096
- d3.select(this)
1097
- .append("g")
1098
- .attr("transform", `rotate(${angle})`)
1099
- .append("text")
1100
- .attr("x", xoff)
1101
- .attr("alignment-baseline", "middle")
1102
- .attr("text-anchor", anchor)
1103
- .attr("font-size", 10)
1104
- .attr("fill", "black")
1105
- .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
+ );
1106
1401
  });
1402
+ }
1107
1403
 
1108
1404
  return svg.node();
1109
1405
  } else if (layout === "unrooted") {
@@ -1114,23 +1410,17 @@ function drawPhylogeny(
1114
1410
  const w = width;
1115
1411
  const h = height;
1116
1412
 
1117
- // Get spatial extent
1118
1413
  const xExtent = d3.extent(unrootedPhylo.data, (d) => d.x);
1119
1414
  const yExtent = d3.extent(unrootedPhylo.data, (d) => d.y);
1120
-
1121
- // Find maximum absolute distance from center (0,0)
1122
1415
  const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
1123
1416
  const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
1124
1417
  const maxRadius = Math.max(maxX, maxY);
1125
-
1126
- // Add some margin
1127
1418
  const scaleUnroot = maxRadius + 2 * radialMargin;
1128
1419
 
1129
1420
  const xScaleUnroot = d3
1130
1421
  .scaleLinear()
1131
1422
  .domain([-scaleUnroot, scaleUnroot])
1132
1423
  .range([0, w]);
1133
-
1134
1424
  const yScaleUnroot = d3
1135
1425
  .scaleLinear()
1136
1426
  .domain([-scaleUnroot, scaleUnroot])
@@ -1144,8 +1434,9 @@ function drawPhylogeny(
1144
1434
  .attr("font-size", 10);
1145
1435
 
1146
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");
1147
1439
 
1148
- // Edges
1149
1440
  group
1150
1441
  .append("g")
1151
1442
  .attr("class", "phylo_lines")
@@ -1159,8 +1450,7 @@ function drawPhylogeny(
1159
1450
  .attr("stroke-width", strokeWidth)
1160
1451
  .attr("stroke", "#777");
1161
1452
 
1162
- // Nodes
1163
- group
1453
+ const nodes = group
1164
1454
  .append("g")
1165
1455
  .attr("class", "phylo_points")
1166
1456
  .selectAll("circle")
@@ -1174,74 +1464,147 @@ function drawPhylogeny(
1174
1464
  .attr("stroke-width", 2)
1175
1465
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1176
1466
 
1177
- // 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
+
1178
1483
  const tipEdges = new Map();
1179
1484
  const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1180
-
1181
1485
  unrootedPhylo.edges.forEach((edge) => {
1182
1486
  const tipNode = nodesById.get(edge.id1);
1183
- if (tipNode?.isTip) {
1184
- tipEdges.set(edge.id1, edge);
1185
- }
1487
+ if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
1186
1488
  });
1187
1489
 
1188
- group
1189
- .append("g")
1190
- .attr("class", "phylo_labels")
1191
- .selectAll("g")
1192
- .data(unrootedPhylo.data.filter((d) => d.isTip))
1193
- .join("g")
1194
- .attr("transform", (d) => {
1195
- const x = xScaleUnroot(d.x);
1196
- const y = yScaleUnroot(d.y);
1197
- return `translate(${x},${y})`;
1198
- })
1199
- .each(function(d) {
1200
- const edge = tipEdges.get(d.thisId);
1201
- if (!edge) {
1202
- console.warn(
1203
- "No incoming edge found for tip node:",
1204
- d.thisId,
1205
- d.thisLabel
1206
- );
1207
- return;
1208
- }
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
+ });
1209
1534
 
1210
- // Compute angle of the incoming edge (screen coords)
1211
- const x1 = xScaleUnroot(edge.x1);
1212
- const y1 = yScaleUnroot(edge.y1);
1213
- const x2 = xScaleUnroot(edge.x2);
1214
- const y2 = yScaleUnroot(edge.y2);
1215
-
1216
- const dx = x2 - x1;
1217
- const dy = y2 - y1;
1218
- let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1219
-
1220
- // Flip label if upside down
1221
- let xOffset = -10;
1222
- let anchor = "end";
1223
- if (angle > 90 || angle < -90) {
1224
- angle += 180;
1225
- anchor = "start";
1226
- xOffset = 10;
1227
- }
1535
+ if (showTooltips) {
1536
+ tipLabelsSel
1537
+ .append("title")
1538
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1539
+ }
1228
1540
 
1229
- // Draw label rotated along branch direction
1230
- d3.select(this)
1231
- .append("g")
1232
- .attr("transform", `rotate(${angle})`)
1233
- .append("text")
1234
- .attr("x", xOffset)
1235
- .attr("alignment-baseline", "middle")
1236
- .attr("text-anchor", anchor)
1237
- .attr("font-size", 10)
1238
- .attr("fill", "black")
1239
- .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
+ );
1240
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
+ }
1241
1602
 
1242
1603
  return svg.node();
1243
1604
  } else {
1244
- throw new Error("Unsupported layout type. Use 'rect' or 'radial'.");
1605
+ throw new Error(
1606
+ "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
1607
+ );
1245
1608
  }
1246
1609
  }
1247
1610