@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.cjs CHANGED
@@ -416,17 +416,50 @@ function getArcs(pd) {
416
416
  return arcs;
417
417
  }
418
418
 
419
+ /**
420
+ * Per-child "half" arcs for radial trees.
421
+ *
422
+ * For each non-root node (child), emit an arc at the PARENT's radius that
423
+ * spans between the parent's angle and the child's angle. This is the arc
424
+ * segment that meets the child's spoke and is ideal for root→tip highlighting.
425
+ *
426
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
427
+ * Output: [{ parentId, childId, radius, start, end }]
428
+ */
429
+ function getChildArcs(pd) {
430
+ const byId = new Map(pd.map(d => [d.thisId, d]));
431
+ const arcs = [];
432
+
433
+ for (const child of pd) {
434
+ if (child.parentId == null) continue; // skip root
435
+ const parent = byId.get(child.parentId);
436
+ if (!parent) continue;
437
+
438
+ arcs.push({
439
+ parentId: parent.thisId,
440
+ childId: child.thisId,
441
+ radius: parent.r, // draw on the parent's circle
442
+ start: parent.angle, // start at parent's angle
443
+ end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
444
+ });
445
+ }
446
+
447
+ return arcs;
448
+ }
449
+
419
450
  /**
420
451
  * Simple wrapper for radial layout:
421
452
  * - data: per-node { angle, r, x, y, ... }
422
453
  * - radii: per-edge radial spokes (parent.r → child.r)
423
- * - arcs: per-internal-node arcs spanning its children at parent radius
454
+ * - arcs: per-parent arcs spanning all children at parent's radius
455
+ * - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
424
456
  */
425
457
  function radialLayout(node) {
426
458
  const data = {};
427
459
  data.data = radialData(node);
428
460
  data.radii = getRadii(node);
429
461
  data.arcs = getArcs(data.data);
462
+ data.child_arcs = getChildArcs(data.data);
430
463
  return data;
431
464
  }
432
465
 
@@ -860,15 +893,42 @@ function subTree (tree, node) {
860
893
  function drawPhylogeny(
861
894
  treeText,
862
895
  {
863
- layout = "rect", // "rect" or "radial"
896
+ layout = "rect", // rect/radial/unrooted
864
897
  width = 800,
865
898
  height = 800,
866
899
  margin = { top: 20, right: 300, bottom: 20, left: 50 },
867
900
  radialMargin = 80,
868
- strokeWidth = 1,
869
- 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
870
912
  } = {}
871
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
+
872
932
  if (layout === "rect") {
873
933
  // RECTANGULAR LAYOUT
874
934
  const tree_df = rectangleLayout(readTree(treeText));
@@ -876,6 +936,12 @@ function drawPhylogeny(
876
936
  const vertical = tree_df.vertical_lines;
877
937
  const tips = horizontal.filter((d) => d.isTip);
878
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
+
879
945
  const maxY = d3__namespace.max(horizontal, (d) => d.y1);
880
946
  const minY = d3__namespace.min(horizontal, (d) => d.y1);
881
947
  const maxX = d3__namespace.max(horizontal, (d) => d.x1);
@@ -899,6 +965,10 @@ function drawPhylogeny(
899
965
 
900
966
  const group = svg.append("g");
901
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
+
902
972
  group
903
973
  .selectAll(".hline")
904
974
  .data(horizontal)
@@ -921,7 +991,8 @@ function drawPhylogeny(
921
991
  .attr("stroke", "#555")
922
992
  .attr("stroke-width", strokeWidth);
923
993
 
924
- group
994
+ // tip dots
995
+ const tipDots = group
925
996
  .selectAll(".tip-dot")
926
997
  .data(tips)
927
998
  .join("circle")
@@ -930,24 +1001,111 @@ function drawPhylogeny(
930
1001
  .attr("r", 2)
931
1002
  .attr("fill", "black");
932
1003
 
933
- svg
934
- .append("g")
935
- .selectAll("text")
936
- .data(tips)
937
- .join("text")
938
- .attr("x", (d) => xScale(d.x1) + 4)
939
- .attr("y", (d) => yScale(d.y1))
940
- .attr("dy", "0.32em")
941
- .attr("font-size", 10)
942
- .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
+ }
943
1101
 
944
1102
  return svg.node();
945
1103
  } else if (layout === "radial") {
946
1104
  const parsedTree = readTree(treeText);
947
1105
  const rad = radialLayout(parsedTree);
948
1106
 
949
- // ===== MODE / DEBUG =====
950
- const TIP_MODE = radialMode; // "align" (shorten to original tips) or "outer" (project to one circle)
1107
+ // ===== MODE =====
1108
+ const TIP_MODE = radialMode; // "phylo" (shorten to original tips) or "outer" (project to one circle)
951
1109
  const isOuter = TIP_MODE === "outer";
952
1110
 
953
1111
  // visuals (0 = let spokes reach the dots)
@@ -976,6 +1134,9 @@ function drawPhylogeny(
976
1134
  const byId = new Map(rad.data.map((d) => [d.thisId, d]));
977
1135
  const tips = rad.data.filter((d) => d.isTip);
978
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:
979
1140
 
980
1141
  // Robust child-id extractor (handles multiple shapes)
981
1142
  function childIdOf(spoke) {
@@ -1006,6 +1167,12 @@ function drawPhylogeny(
1006
1167
 
1007
1168
  const group = svg.append("g");
1008
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
+
1009
1176
  // ===== ARCS (parent circles) =====
1010
1177
  group
1011
1178
  .append("g")
@@ -1033,7 +1200,7 @@ function drawPhylogeny(
1033
1200
  .selectAll("line")
1034
1201
  .data(rad.radii)
1035
1202
  .join("line")
1036
- .each(function(s, _) {
1203
+ .each(function(s, _i) {
1037
1204
  // parent end (data space)
1038
1205
  const x0 = s.x0,
1039
1206
  y0 = s.y0;
@@ -1066,13 +1233,13 @@ function drawPhylogeny(
1066
1233
  });
1067
1234
 
1068
1235
  // ===== TIP DOTS =====
1069
- group
1236
+ const tipDots = group
1070
1237
  .append("g")
1071
1238
  .attr("class", "phylo_tip_dots")
1072
1239
  .selectAll("circle")
1073
1240
  .data(tips)
1074
1241
  .join("circle")
1075
- .each(function(d, _) {
1242
+ .each(function(d, _i) {
1076
1243
  // dot at original tip (align) or projected circle (outer)
1077
1244
  const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
1078
1245
  const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
@@ -1086,45 +1253,174 @@ function drawPhylogeny(
1086
1253
  .attr("stroke-width", 1.5);
1087
1254
  });
1088
1255
 
1089
- // ===== 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 =====
1090
1267
  // Labels — make them follow the tip position used by the current mode
1091
- group
1092
- .append("g")
1093
- .attr("class", "phylo_labels")
1094
- .selectAll("g.label")
1095
- .data(tips) // <— bind only tip nodes
1096
- .join("g")
1097
- .attr("class", "label")
1098
- .attr("transform", (d) => {
1099
- // same tip position rule as dots/spokes:
1100
- // - "outer": snap to common ring (tipMaxR)
1101
- // - otherwise (e.g. "align"/"phylo"): true tip radius
1102
- const r = isOuter ? tipMaxR : d.r;
1103
- const x = r * Math.cos(d.angle);
1104
- const y = r * Math.sin(d.angle);
1105
- return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
1106
- })
1107
- .each(function(d) {
1108
- // rotate so text reads outward; flip when on the left side
1109
- let angle = (-d.angle * 180) / Math.PI;
1110
- let xoff = 10; // radial padding for text (px)
1111
- let anchor = "start";
1112
- if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
1113
- angle += 180;
1114
- xoff *= -1;
1115
- 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");
1116
1365
  }
1117
- d3__namespace.select(this)
1118
- .append("g")
1119
- .attr("transform", `rotate(${angle})`)
1120
- .append("text")
1121
- .attr("x", xoff)
1122
- .attr("alignment-baseline", "middle")
1123
- .attr("text-anchor", anchor)
1124
- .attr("font-size", 10)
1125
- .attr("fill", "black")
1126
- .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
+ );
1127
1422
  });
1423
+ }
1128
1424
 
1129
1425
  return svg.node();
1130
1426
  } else if (layout === "unrooted") {
@@ -1135,23 +1431,17 @@ function drawPhylogeny(
1135
1431
  const w = width;
1136
1432
  const h = height;
1137
1433
 
1138
- // Get spatial extent
1139
1434
  const xExtent = d3__namespace.extent(unrootedPhylo.data, (d) => d.x);
1140
1435
  const yExtent = d3__namespace.extent(unrootedPhylo.data, (d) => d.y);
1141
-
1142
- // Find maximum absolute distance from center (0,0)
1143
1436
  const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
1144
1437
  const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
1145
1438
  const maxRadius = Math.max(maxX, maxY);
1146
-
1147
- // Add some margin
1148
1439
  const scaleUnroot = maxRadius + 2 * radialMargin;
1149
1440
 
1150
1441
  const xScaleUnroot = d3__namespace
1151
1442
  .scaleLinear()
1152
1443
  .domain([-scaleUnroot, scaleUnroot])
1153
1444
  .range([0, w]);
1154
-
1155
1445
  const yScaleUnroot = d3__namespace
1156
1446
  .scaleLinear()
1157
1447
  .domain([-scaleUnroot, scaleUnroot])
@@ -1165,8 +1455,9 @@ function drawPhylogeny(
1165
1455
  .attr("font-size", 10);
1166
1456
 
1167
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");
1168
1460
 
1169
- // Edges
1170
1461
  group
1171
1462
  .append("g")
1172
1463
  .attr("class", "phylo_lines")
@@ -1180,8 +1471,7 @@ function drawPhylogeny(
1180
1471
  .attr("stroke-width", strokeWidth)
1181
1472
  .attr("stroke", "#777");
1182
1473
 
1183
- // Nodes
1184
- group
1474
+ const nodes = group
1185
1475
  .append("g")
1186
1476
  .attr("class", "phylo_points")
1187
1477
  .selectAll("circle")
@@ -1195,74 +1485,147 @@ function drawPhylogeny(
1195
1485
  .attr("stroke-width", 2)
1196
1486
  .attr("fill", (d) => (d.isTip ? "black" : "white"));
1197
1487
 
1198
- // 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
+
1199
1504
  const tipEdges = new Map();
1200
1505
  const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1201
-
1202
1506
  unrootedPhylo.edges.forEach((edge) => {
1203
1507
  const tipNode = nodesById.get(edge.id1);
1204
- if (tipNode?.isTip) {
1205
- tipEdges.set(edge.id1, edge);
1206
- }
1508
+ if (tipNode?.isTip) tipEdges.set(edge.id1, edge);
1207
1509
  });
1208
1510
 
1209
- group
1210
- .append("g")
1211
- .attr("class", "phylo_labels")
1212
- .selectAll("g")
1213
- .data(unrootedPhylo.data.filter((d) => d.isTip))
1214
- .join("g")
1215
- .attr("transform", (d) => {
1216
- const x = xScaleUnroot(d.x);
1217
- const y = yScaleUnroot(d.y);
1218
- return `translate(${x},${y})`;
1219
- })
1220
- .each(function(d) {
1221
- const edge = tipEdges.get(d.thisId);
1222
- if (!edge) {
1223
- console.warn(
1224
- "No incoming edge found for tip node:",
1225
- d.thisId,
1226
- d.thisLabel
1227
- );
1228
- return;
1229
- }
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
+ });
1230
1555
 
1231
- // Compute angle of the incoming edge (screen coords)
1232
- const x1 = xScaleUnroot(edge.x1);
1233
- const y1 = yScaleUnroot(edge.y1);
1234
- const x2 = xScaleUnroot(edge.x2);
1235
- const y2 = yScaleUnroot(edge.y2);
1236
-
1237
- const dx = x2 - x1;
1238
- const dy = y2 - y1;
1239
- let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1240
-
1241
- // Flip label if upside down
1242
- let xOffset = -10;
1243
- let anchor = "end";
1244
- if (angle > 90 || angle < -90) {
1245
- angle += 180;
1246
- anchor = "start";
1247
- xOffset = 10;
1248
- }
1556
+ if (showTooltips) {
1557
+ tipLabelsSel
1558
+ .append("title")
1559
+ .text((d) => tooltipFormatter(d, rootToTip(d.thisId)));
1560
+ }
1249
1561
 
1250
- // Draw label rotated along branch direction
1251
- d3__namespace.select(this)
1252
- .append("g")
1253
- .attr("transform", `rotate(${angle})`)
1254
- .append("text")
1255
- .attr("x", xOffset)
1256
- .attr("alignment-baseline", "middle")
1257
- .attr("text-anchor", anchor)
1258
- .attr("font-size", 10)
1259
- .attr("fill", "black")
1260
- .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
+ );
1261
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
+ }
1262
1623
 
1263
1624
  return svg.node();
1264
1625
  } else {
1265
- throw new Error("Unsupported layout type. Use 'rect' or 'radial'.");
1626
+ throw new Error(
1627
+ "Unsupported layout type. Use 'rect', 'radial', or 'unrooted'."
1628
+ );
1266
1629
  }
1267
1630
  }
1268
1631