@euphrasiologist/lwphylo 1.2.23 → 1.2.26

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.
@@ -52,8 +52,8 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
52
52
  return `M ${p.x} ${p.y}`; // degenerate span → no arc
53
53
  }
54
54
 
55
- const largeArcFlag = delta > Math.PI ? 1 : 0; // should be 0 for “shortest”, but keep for safety
56
- const sweepFlag = 0; // CCW
55
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
56
+ const sweepFlag = 0; // CCW in our y-flipped coords: math-CCW = decreasing SVG angle = sweepFlag 0
57
57
 
58
58
  const p0 = polarToCartesian(cx, cy, radius, a0);
59
59
  const p1 = polarToCartesian(cx, cy, radius, a1);
@@ -62,25 +62,27 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
62
62
  }
63
63
 
64
64
  // src/radial/describeArcSweep.js
65
- const TAU$1 = Math.PI * 2;
66
- const norm$1 = (t) => ((t % TAU$1) + TAU$1) % TAU$1;
67
-
68
- function describeArcSweep(cx, cy, r, a0, a1, sweep /*0=CCW,1=CW*/) {
69
- console.log("describeArcSweep input:", {
70
- cx, cy, r,
71
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
72
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
73
- sweep
74
- });
65
+ // IMPORTANT: angles are in "math space" (increasing = CCW).
66
+ // Because we map y as (cy - r*sin(a)), our math angle t maps to SVG angle -t.
67
+ // Increasing t (math CCW) = decreasing SVG angle = sweepFlag 0 (negative direction).
68
+ // math CCW -> svg sweepFlag = 0
69
+ // math CW -> svg sweepFlag = 1
70
+ function describeArcSweep(
71
+ cx, cy, r,
72
+ a0, a1,
73
+ mathSweep = "ccw", // "ccw" | "cw"
74
+ largeArcFlag = 0
75
+ ) {
76
+ if (!(r > 0)) return "";
75
77
 
76
- const delta = sweep === 0 ? norm$1(a1 - a0) : norm$1(a0 - a1);
77
- if (!(r > 0) || delta < 1e-9) return "";
78
- const largeArcFlag = delta > Math.PI ? 1 : 0;
78
+ const x0 = cx + r * Math.cos(a0);
79
+ const y0 = cy - r * Math.sin(a0);
80
+ const x1 = cx + r * Math.cos(a1);
81
+ const y1 = cy - r * Math.sin(a1);
79
82
 
80
- const x0 = cx + r * Math.cos(a0), y0 = cy - r * Math.sin(a0);
81
- const x1 = cx + r * Math.cos(a1), y1 = cy - r * Math.sin(a1);
83
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
82
84
 
83
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
85
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
84
86
  }
85
87
 
86
88
  /**
@@ -357,16 +359,13 @@ function getArcs(pd) {
357
359
  }
358
360
 
359
361
  /**
360
- * Build APE-like block arcs per internal parent:
361
- * radius = parent.r
362
- * start = first child's angle
363
- * end = last child's angle
364
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
365
- *
366
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
367
- * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
362
+ * APE-like block arcs per internal parent.
363
+ * Draw CCW from first child's angle to last child's angle (wrapping allowed).
368
364
  */
369
365
  function getArcsFan(pd) {
366
+ const TAU = Math.PI * 2;
367
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
368
+
370
369
  const byId = new Map(pd.map(d => [d.thisId, d]));
371
370
  const arcs = [];
372
371
 
@@ -378,9 +377,11 @@ function getArcsFan(pd) {
378
377
  const last = byId.get(c[c.length - 1])?.angle;
379
378
  if (first == null || last == null) continue;
380
379
 
381
- const start = first;
382
- const end = last;
383
- const sweep = end >= start ? 0 : 1; // CW if wrapped
380
+ const start = norm(first);
381
+ const end = norm(last);
382
+
383
+ const deltaCCW = (end - start + TAU) % TAU;
384
+ if (deltaCCW < 1e-9) continue;
384
385
 
385
386
  arcs.push({
386
387
  parentId: p.parentId,
@@ -388,9 +389,11 @@ function getArcsFan(pd) {
388
389
  radius: p.r,
389
390
  start,
390
391
  end,
391
- sweep
392
+ sweep: "ccw", // << math sweep
393
+ largeArc: deltaCCW > Math.PI ? 1 : 0
392
394
  });
393
395
  }
396
+
394
397
  return arcs;
395
398
  }
396
399
 
@@ -476,80 +479,6 @@ function getChildArcs(pd) {
476
479
  return arcs;
477
480
  }
478
481
 
479
- function getChildArcsFan(pd) {
480
- const TAU = Math.PI * 2;
481
- const norm = (t) => ((t % TAU) + TAU) % TAU;
482
-
483
- // Circular midpoint that travels CCW from a -> b by half the CCW span
484
- function midCCW(a, b) {
485
- const d = (b - a + TAU) % TAU; // CCW delta in [0, 2π)
486
- return norm(a + d / 2);
487
- }
488
-
489
- const key = (x) => (typeof x === "string" ? +x : x);
490
-
491
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
492
- const childrenByParent = new Map(
493
- pd.map(d => [
494
- key(d.thisId),
495
- // normalize children to numeric IDs; drop anything we can't resolve
496
- (d.children || [])
497
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
498
- .map(key)
499
- .filter(id => byId.has(id))
500
- ])
501
- );
502
-
503
- const child_arcs = [];
504
-
505
- for (const parentRaw of pd) {
506
- const pid = key(parentRaw.thisId);
507
- const kids = childrenByParent.get(pid) || [];
508
- if (kids.length < 2) continue;
509
-
510
- // Sort children by angle (normalized) around the circle
511
- const A = kids
512
- .map(id => {
513
- const node = byId.get(id);
514
- return node ? { id, a: norm(node.angle) } : null;
515
- })
516
- .filter(Boolean)
517
- .sort((u, v) => u.a - v.a);
518
-
519
- const N = A.length;
520
- if (N < 2) continue;
521
-
522
- const parent = byId.get(pid);
523
- const radius = parent?.r;
524
- if (!(radius > 0)) continue;
525
-
526
- for (let i = 0; i < N; i++) {
527
- const prev = A[(i - 1 + N) % N];
528
- const cur = A[i];
529
- const next = A[(i + 1) % N];
530
-
531
- const start = midCCW(prev.a, cur.a);
532
- const end = midCCW(cur.a, next.a);
533
-
534
- // Use SVG-conforming sweep logic
535
- const delta = (end - start + TAU) % TAU;
536
- const sweep = delta > Math.PI ? 0 : 1;
537
-
538
- child_arcs.push({
539
- parentId: pid,
540
- childId: cur.id,
541
- radius,
542
- start,
543
- end,
544
- sweep
545
- });
546
- }
547
-
548
- }
549
-
550
- return child_arcs;
551
- }
552
-
553
482
  /**
554
483
  * radialLayout(node, opts?)
555
484
  * opts:
@@ -593,13 +522,8 @@ function radialLayout(node, opts = {}) {
593
522
  ? getArcsFan(pd)
594
523
  : getArcs(pd);
595
524
 
596
- // per-child arcs for half-arc highlighting if you already use them
597
- let child_arcs = [];
598
- if (arcsStyle === "fan") {
599
- child_arcs = getChildArcsFan(pd);
600
- } else {
601
- child_arcs = getChildArcs(pd);
602
- }
525
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
526
+ const child_arcs = getChildArcs(pd);
603
527
 
604
528
  return { data: pd, radii, arcs, child_arcs };
605
529
  }
@@ -927,21 +851,29 @@ function readTree(text) {
927
851
  text = String(text).replace(/\s+/g, '');
928
852
 
929
853
  const tokens = text.split(/(;|\(|\)|,)/);
930
- const root = { parent: null, children: [] };
931
- let curnode = root;
932
854
  let nodeId = 0;
855
+ const makeNode = (parent) => ({
856
+ parent,
857
+ children: [],
858
+ id: nodeId++,
859
+ label: '',
860
+ branchLength: null
861
+ });
862
+
863
+ const root = makeNode(null);
864
+ let curnode = root;
933
865
 
934
866
  for (const token of tokens) {
935
867
  if (!token || token === ';') continue;
936
868
 
937
869
  if (token === '(') {
938
- const child = { parent: curnode, children: [] };
870
+ const child = makeNode(curnode);
939
871
  curnode.children.push(child);
940
872
  curnode = child; // descend
941
873
  } else if (token === ',') {
942
874
  // back to parent, then create sibling
943
875
  curnode = curnode.parent;
944
- const child = { parent: curnode, children: [] };
876
+ const child = makeNode(curnode);
945
877
  curnode.children.push(child);
946
878
  curnode = child;
947
879
  } else if (token === ')') {
@@ -950,14 +882,15 @@ function readTree(text) {
950
882
  if (curnode === null) break;
951
883
  } else {
952
884
  // label/branch-length chunk (e.g., "A:0.01" or "A")
885
+ // Note: nodes are assigned an id at creation (above), so internal
886
+ // (clade) nodes that carry neither a label nor a branch length —
887
+ // e.g. "((A,B),(C,D));" — still get a valid, linkable id here.
953
888
  const nodeinfo = token.split(':');
954
889
  if (nodeinfo.length === 1) {
955
890
  if (token.startsWith(':')) {
956
- curnode.label = '';
957
891
  curnode.branchLength = parseFloat(nodeinfo[0]);
958
892
  } else {
959
893
  curnode.label = nodeinfo[0];
960
- curnode.branchLength = null;
961
894
  }
962
895
  } else if (nodeinfo.length === 2) {
963
896
  curnode.label = nodeinfo[0];
@@ -967,13 +900,9 @@ function readTree(text) {
967
900
  curnode.label = nodeinfo[0] || '';
968
901
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
969
902
  }
970
- curnode.id = nodeId++; // assign then increment
971
903
  }
972
904
  }
973
905
 
974
- // Ensure root has an id if not assigned during parsing
975
- if (root.id == null) root.id = nodeId;
976
-
977
906
  return root;
978
907
  }
979
908
 
@@ -988,6 +917,7 @@ function drawPhylogeny(
988
917
  strokeWidth = 1, // for the phylogeny branches
989
918
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
990
919
  tipLabels = true,
920
+ labelFontSize = 10, // font size (px) for tip labels
991
921
  showTooltips = true,
992
922
  tooltipFormatter = (d, rtt) =>
993
923
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1035,7 +965,7 @@ function drawPhylogeny(
1035
965
  const tips = horizontal.filter((d) => d.isTip);
1036
966
 
1037
967
  // indices & root→tip getter
1038
- const byId = new Map(horizontal.map((d) => [d.thisId, d]));
968
+ const byId = new Map(tree_df.data.map((d) => [d.thisId, d])); // includes root
1039
969
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1040
970
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1041
971
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1109,6 +1039,7 @@ function drawPhylogeny(
1109
1039
  // interactive root→tip highlight (rect) on dot hover
1110
1040
  tipDots
1111
1041
  .on("mouseenter", function(_event, d) {
1042
+ hoverLayer.selectAll("*").remove();
1112
1043
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1113
1044
  d3__namespace.select(this).attr("r", 4);
1114
1045
  })
@@ -1128,7 +1059,7 @@ function drawPhylogeny(
1128
1059
  .attr("x", (d) => xScale(d.x1) + 4)
1129
1060
  .attr("y", (d) => yScale(d.y1))
1130
1061
  .attr("dy", "0.32em")
1131
- .attr("font-size", 10)
1062
+ .attr("font-size", labelFontSize)
1132
1063
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1133
1064
 
1134
1065
  if (showTooltips) {
@@ -1139,6 +1070,7 @@ function drawPhylogeny(
1139
1070
 
1140
1071
  labels
1141
1072
  .on("mouseenter", function(_event, d) {
1073
+ hoverLayer.selectAll("*").remove();
1142
1074
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1143
1075
  d3__namespace.select(this).attr("font-weight", 600);
1144
1076
  })
@@ -1165,7 +1097,6 @@ function drawPhylogeny(
1165
1097
 
1166
1098
  // helper to draw root→tip for rect (both vertical+horizontal)
1167
1099
  function drawRectPath(tipId, layer, stroke, width) {
1168
- layer.selectAll("*").remove();
1169
1100
  let cur = byId.get(tipId);
1170
1101
  while (cur && cur.parentId != null) {
1171
1102
  const parent = byId.get(cur.parentId);
@@ -1222,10 +1153,14 @@ function drawPhylogeny(
1222
1153
  const END_CAP = 0;
1223
1154
 
1224
1155
  // ===== SCALES / BOUNDS =====
1225
- const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1226
- const scaleRadial = maxRadius + 2 * radialMargin;
1227
1156
  const w = width,
1228
1157
  h = height;
1158
+ const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1159
+ // radialMargin is in pixels: tips sit (radialMargin) px from the SVG edge.
1160
+ // Derive the data-space scale so that radiusPx(maxRadius) = w/2 - radialMargin.
1161
+ const scaleRadial = maxRadius > 0
1162
+ ? maxRadius * (w / 2) / (w / 2 - radialMargin)
1163
+ : 1;
1229
1164
  const centerX = w / 2,
1230
1165
  centerY = h / 2;
1231
1166
 
@@ -1305,7 +1240,8 @@ function drawPhylogeny(
1305
1240
  radiusPx(d.radius),
1306
1241
  d.start,
1307
1242
  d.end,
1308
- d.sweep
1243
+ d.sweep ?? "ccw",
1244
+ d.largeArc ?? 0,
1309
1245
  )
1310
1246
  )
1311
1247
  .attr("fill", "none")
@@ -1420,7 +1356,7 @@ function drawPhylogeny(
1420
1356
  .attr("x", xoff)
1421
1357
  .attr("alignment-baseline", "middle")
1422
1358
  .attr("text-anchor", anchor)
1423
- .attr("font-size", 10)
1359
+ .attr("font-size", labelFontSize)
1424
1360
  .attr("fill", "black")
1425
1361
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1426
1362
  });
@@ -1434,6 +1370,8 @@ function drawPhylogeny(
1434
1370
  // label hover
1435
1371
  labels
1436
1372
  .on("mouseenter", function(_event, d) {
1373
+ hoverLines.selectAll("*").remove();
1374
+ hoverArcs.selectAll("*").remove();
1437
1375
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1438
1376
  d3__namespace.select(this).select("text").attr("font-weight", 600);
1439
1377
  })
@@ -1453,9 +1391,6 @@ function drawPhylogeny(
1453
1391
  width = 3
1454
1392
  ) {
1455
1393
  // target may be a tip node *or* a numeric tip id
1456
- lineLayer.selectAll("*").remove();
1457
- arcLayer.selectAll("*").remove();
1458
-
1459
1394
  let cur = (typeof target === "number" || typeof target === "string")
1460
1395
  ? byId.get(target)
1461
1396
  : target;
@@ -1494,18 +1429,10 @@ function drawPhylogeny(
1494
1429
  const R = radiusPx(rec.radius);
1495
1430
  return rec.sweep == null
1496
1431
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1497
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep);
1432
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1498
1433
  }
1499
1434
 
1500
1435
  if (a) {
1501
- console.log("Drawing arc:", {
1502
- childId: cur.thisId,
1503
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1504
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1505
- sweep: a.sweep,
1506
- radius: a.radius
1507
- });
1508
-
1509
1436
  arcLayer
1510
1437
  .append("path")
1511
1438
  .attr("d", pathFromArcRecord(a))
@@ -1522,6 +1449,8 @@ function drawPhylogeny(
1522
1449
  // tip dot hover
1523
1450
  tipDots
1524
1451
  .on("mouseenter", function(_event, d) {
1452
+ hoverLines.selectAll("*").remove();
1453
+ hoverArcs.selectAll("*").remove();
1525
1454
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1526
1455
  d3__namespace.select(this).attr("r", DOT_R + 2);
1527
1456
  })
@@ -1678,7 +1607,7 @@ function drawPhylogeny(
1678
1607
  .attr("x", xOffset)
1679
1608
  .attr("alignment-baseline", "middle")
1680
1609
  .attr("text-anchor", anchor)
1681
- .attr("font-size", 10)
1610
+ .attr("font-size", labelFontSize)
1682
1611
  .attr("fill", "black")
1683
1612
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1684
1613
  });