@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.
package/dist/index.cjs CHANGED
@@ -165,8 +165,8 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
165
165
  return `M ${p.x} ${p.y}`; // degenerate span → no arc
166
166
  }
167
167
 
168
- const largeArcFlag = delta > Math.PI ? 1 : 0; // should be 0 for “shortest”, but keep for safety
169
- const sweepFlag = 0; // CCW
168
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
169
+ const sweepFlag = 0; // CCW in our y-flipped coords: math-CCW = decreasing SVG angle = sweepFlag 0
170
170
 
171
171
  const p0 = polarToCartesian(cx, cy, radius, a0);
172
172
  const p1 = polarToCartesian(cx, cy, radius, a1);
@@ -175,25 +175,27 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
175
175
  }
176
176
 
177
177
  // src/radial/describeArcSweep.js
178
- const TAU$1 = Math.PI * 2;
179
- const norm$1 = (t) => ((t % TAU$1) + TAU$1) % TAU$1;
180
-
181
- function describeArcSweep(cx, cy, r, a0, a1, sweep /*0=CCW,1=CW*/) {
182
- console.log("describeArcSweep input:", {
183
- cx, cy, r,
184
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
185
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
186
- sweep
187
- });
178
+ // IMPORTANT: angles are in "math space" (increasing = CCW).
179
+ // Because we map y as (cy - r*sin(a)), our math angle t maps to SVG angle -t.
180
+ // Increasing t (math CCW) = decreasing SVG angle = sweepFlag 0 (negative direction).
181
+ // math CCW -> svg sweepFlag = 0
182
+ // math CW -> svg sweepFlag = 1
183
+ function describeArcSweep(
184
+ cx, cy, r,
185
+ a0, a1,
186
+ mathSweep = "ccw", // "ccw" | "cw"
187
+ largeArcFlag = 0
188
+ ) {
189
+ if (!(r > 0)) return "";
188
190
 
189
- const delta = sweep === 0 ? norm$1(a1 - a0) : norm$1(a0 - a1);
190
- if (!(r > 0) || delta < 1e-9) return "";
191
- const largeArcFlag = delta > Math.PI ? 1 : 0;
191
+ const x0 = cx + r * Math.cos(a0);
192
+ const y0 = cy - r * Math.sin(a0);
193
+ const x1 = cx + r * Math.cos(a1);
194
+ const y1 = cy - r * Math.sin(a1);
192
195
 
193
- const x0 = cx + r * Math.cos(a0), y0 = cy - r * Math.sin(a0);
194
- const x1 = cx + r * Math.cos(a1), y1 = cy - r * Math.sin(a1);
196
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
195
197
 
196
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
198
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
197
199
  }
198
200
 
199
201
  /**
@@ -470,16 +472,13 @@ function getArcs(pd) {
470
472
  }
471
473
 
472
474
  /**
473
- * Build APE-like block arcs per internal parent:
474
- * radius = parent.r
475
- * start = first child's angle
476
- * end = last child's angle
477
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
478
- *
479
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
480
- * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
475
+ * APE-like block arcs per internal parent.
476
+ * Draw CCW from first child's angle to last child's angle (wrapping allowed).
481
477
  */
482
478
  function getArcsFan(pd) {
479
+ const TAU = Math.PI * 2;
480
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
481
+
483
482
  const byId = new Map(pd.map(d => [d.thisId, d]));
484
483
  const arcs = [];
485
484
 
@@ -491,9 +490,11 @@ function getArcsFan(pd) {
491
490
  const last = byId.get(c[c.length - 1])?.angle;
492
491
  if (first == null || last == null) continue;
493
492
 
494
- const start = first;
495
- const end = last;
496
- const sweep = end >= start ? 0 : 1; // CW if wrapped
493
+ const start = norm(first);
494
+ const end = norm(last);
495
+
496
+ const deltaCCW = (end - start + TAU) % TAU;
497
+ if (deltaCCW < 1e-9) continue;
497
498
 
498
499
  arcs.push({
499
500
  parentId: p.parentId,
@@ -501,9 +502,11 @@ function getArcsFan(pd) {
501
502
  radius: p.r,
502
503
  start,
503
504
  end,
504
- sweep
505
+ sweep: "ccw", // << math sweep
506
+ largeArc: deltaCCW > Math.PI ? 1 : 0
505
507
  });
506
508
  }
509
+
507
510
  return arcs;
508
511
  }
509
512
 
@@ -589,80 +592,6 @@ function getChildArcs(pd) {
589
592
  return arcs;
590
593
  }
591
594
 
592
- function getChildArcsFan(pd) {
593
- const TAU = Math.PI * 2;
594
- const norm = (t) => ((t % TAU) + TAU) % TAU;
595
-
596
- // Circular midpoint that travels CCW from a -> b by half the CCW span
597
- function midCCW(a, b) {
598
- const d = (b - a + TAU) % TAU; // CCW delta in [0, 2π)
599
- return norm(a + d / 2);
600
- }
601
-
602
- const key = (x) => (typeof x === "string" ? +x : x);
603
-
604
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
605
- const childrenByParent = new Map(
606
- pd.map(d => [
607
- key(d.thisId),
608
- // normalize children to numeric IDs; drop anything we can't resolve
609
- (d.children || [])
610
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
611
- .map(key)
612
- .filter(id => byId.has(id))
613
- ])
614
- );
615
-
616
- const child_arcs = [];
617
-
618
- for (const parentRaw of pd) {
619
- const pid = key(parentRaw.thisId);
620
- const kids = childrenByParent.get(pid) || [];
621
- if (kids.length < 2) continue;
622
-
623
- // Sort children by angle (normalized) around the circle
624
- const A = kids
625
- .map(id => {
626
- const node = byId.get(id);
627
- return node ? { id, a: norm(node.angle) } : null;
628
- })
629
- .filter(Boolean)
630
- .sort((u, v) => u.a - v.a);
631
-
632
- const N = A.length;
633
- if (N < 2) continue;
634
-
635
- const parent = byId.get(pid);
636
- const radius = parent?.r;
637
- if (!(radius > 0)) continue;
638
-
639
- for (let i = 0; i < N; i++) {
640
- const prev = A[(i - 1 + N) % N];
641
- const cur = A[i];
642
- const next = A[(i + 1) % N];
643
-
644
- const start = midCCW(prev.a, cur.a);
645
- const end = midCCW(cur.a, next.a);
646
-
647
- // Use SVG-conforming sweep logic
648
- const delta = (end - start + TAU) % TAU;
649
- const sweep = delta > Math.PI ? 0 : 1;
650
-
651
- child_arcs.push({
652
- parentId: pid,
653
- childId: cur.id,
654
- radius,
655
- start,
656
- end,
657
- sweep
658
- });
659
- }
660
-
661
- }
662
-
663
- return child_arcs;
664
- }
665
-
666
595
  /**
667
596
  * radialLayout(node, opts?)
668
597
  * opts:
@@ -706,13 +635,8 @@ function radialLayout(node, opts = {}) {
706
635
  ? getArcsFan(pd)
707
636
  : getArcs(pd);
708
637
 
709
- // per-child arcs for half-arc highlighting if you already use them
710
- let child_arcs = [];
711
- if (arcsStyle === "fan") {
712
- child_arcs = getChildArcsFan(pd);
713
- } else {
714
- child_arcs = getChildArcs(pd);
715
- }
638
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
639
+ const child_arcs = getChildArcs(pd);
716
640
 
717
641
  return { data: pd, radii, arcs, child_arcs };
718
642
  }
@@ -1049,21 +973,29 @@ function readTree(text) {
1049
973
  text = String(text).replace(/\s+/g, '');
1050
974
 
1051
975
  const tokens = text.split(/(;|\(|\)|,)/);
1052
- const root = { parent: null, children: [] };
1053
- let curnode = root;
1054
976
  let nodeId = 0;
977
+ const makeNode = (parent) => ({
978
+ parent,
979
+ children: [],
980
+ id: nodeId++,
981
+ label: '',
982
+ branchLength: null
983
+ });
984
+
985
+ const root = makeNode(null);
986
+ let curnode = root;
1055
987
 
1056
988
  for (const token of tokens) {
1057
989
  if (!token || token === ';') continue;
1058
990
 
1059
991
  if (token === '(') {
1060
- const child = { parent: curnode, children: [] };
992
+ const child = makeNode(curnode);
1061
993
  curnode.children.push(child);
1062
994
  curnode = child; // descend
1063
995
  } else if (token === ',') {
1064
996
  // back to parent, then create sibling
1065
997
  curnode = curnode.parent;
1066
- const child = { parent: curnode, children: [] };
998
+ const child = makeNode(curnode);
1067
999
  curnode.children.push(child);
1068
1000
  curnode = child;
1069
1001
  } else if (token === ')') {
@@ -1072,14 +1004,15 @@ function readTree(text) {
1072
1004
  if (curnode === null) break;
1073
1005
  } else {
1074
1006
  // label/branch-length chunk (e.g., "A:0.01" or "A")
1007
+ // Note: nodes are assigned an id at creation (above), so internal
1008
+ // (clade) nodes that carry neither a label nor a branch length —
1009
+ // e.g. "((A,B),(C,D));" — still get a valid, linkable id here.
1075
1010
  const nodeinfo = token.split(':');
1076
1011
  if (nodeinfo.length === 1) {
1077
1012
  if (token.startsWith(':')) {
1078
- curnode.label = '';
1079
1013
  curnode.branchLength = parseFloat(nodeinfo[0]);
1080
1014
  } else {
1081
1015
  curnode.label = nodeinfo[0];
1082
- curnode.branchLength = null;
1083
1016
  }
1084
1017
  } else if (nodeinfo.length === 2) {
1085
1018
  curnode.label = nodeinfo[0];
@@ -1089,13 +1022,9 @@ function readTree(text) {
1089
1022
  curnode.label = nodeinfo[0] || '';
1090
1023
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
1091
1024
  }
1092
- curnode.id = nodeId++; // assign then increment
1093
1025
  }
1094
1026
  }
1095
1027
 
1096
- // Ensure root has an id if not assigned during parsing
1097
- if (root.id == null) root.id = nodeId;
1098
-
1099
1028
  return root;
1100
1029
  }
1101
1030
 
@@ -1155,6 +1084,7 @@ function drawPhylogeny(
1155
1084
  strokeWidth = 1, // for the phylogeny branches
1156
1085
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1157
1086
  tipLabels = true,
1087
+ labelFontSize = 10, // font size (px) for tip labels
1158
1088
  showTooltips = true,
1159
1089
  tooltipFormatter = (d, rtt) =>
1160
1090
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1202,7 +1132,7 @@ function drawPhylogeny(
1202
1132
  const tips = horizontal.filter((d) => d.isTip);
1203
1133
 
1204
1134
  // indices & root→tip getter
1205
- const byId = new Map(horizontal.map((d) => [d.thisId, d]));
1135
+ const byId = new Map(tree_df.data.map((d) => [d.thisId, d])); // includes root
1206
1136
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1207
1137
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1208
1138
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1276,6 +1206,7 @@ function drawPhylogeny(
1276
1206
  // interactive root→tip highlight (rect) on dot hover
1277
1207
  tipDots
1278
1208
  .on("mouseenter", function(_event, d) {
1209
+ hoverLayer.selectAll("*").remove();
1279
1210
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1280
1211
  d3__namespace.select(this).attr("r", 4);
1281
1212
  })
@@ -1295,7 +1226,7 @@ function drawPhylogeny(
1295
1226
  .attr("x", (d) => xScale(d.x1) + 4)
1296
1227
  .attr("y", (d) => yScale(d.y1))
1297
1228
  .attr("dy", "0.32em")
1298
- .attr("font-size", 10)
1229
+ .attr("font-size", labelFontSize)
1299
1230
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1300
1231
 
1301
1232
  if (showTooltips) {
@@ -1306,6 +1237,7 @@ function drawPhylogeny(
1306
1237
 
1307
1238
  labels
1308
1239
  .on("mouseenter", function(_event, d) {
1240
+ hoverLayer.selectAll("*").remove();
1309
1241
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1310
1242
  d3__namespace.select(this).attr("font-weight", 600);
1311
1243
  })
@@ -1332,7 +1264,6 @@ function drawPhylogeny(
1332
1264
 
1333
1265
  // helper to draw root→tip for rect (both vertical+horizontal)
1334
1266
  function drawRectPath(tipId, layer, stroke, width) {
1335
- layer.selectAll("*").remove();
1336
1267
  let cur = byId.get(tipId);
1337
1268
  while (cur && cur.parentId != null) {
1338
1269
  const parent = byId.get(cur.parentId);
@@ -1389,10 +1320,14 @@ function drawPhylogeny(
1389
1320
  const END_CAP = 0;
1390
1321
 
1391
1322
  // ===== SCALES / BOUNDS =====
1392
- const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1393
- const scaleRadial = maxRadius + 2 * radialMargin;
1394
1323
  const w = width,
1395
1324
  h = height;
1325
+ const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1326
+ // radialMargin is in pixels: tips sit (radialMargin) px from the SVG edge.
1327
+ // Derive the data-space scale so that radiusPx(maxRadius) = w/2 - radialMargin.
1328
+ const scaleRadial = maxRadius > 0
1329
+ ? maxRadius * (w / 2) / (w / 2 - radialMargin)
1330
+ : 1;
1396
1331
  const centerX = w / 2,
1397
1332
  centerY = h / 2;
1398
1333
 
@@ -1472,7 +1407,8 @@ function drawPhylogeny(
1472
1407
  radiusPx(d.radius),
1473
1408
  d.start,
1474
1409
  d.end,
1475
- d.sweep
1410
+ d.sweep ?? "ccw",
1411
+ d.largeArc ?? 0,
1476
1412
  )
1477
1413
  )
1478
1414
  .attr("fill", "none")
@@ -1587,7 +1523,7 @@ function drawPhylogeny(
1587
1523
  .attr("x", xoff)
1588
1524
  .attr("alignment-baseline", "middle")
1589
1525
  .attr("text-anchor", anchor)
1590
- .attr("font-size", 10)
1526
+ .attr("font-size", labelFontSize)
1591
1527
  .attr("fill", "black")
1592
1528
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1593
1529
  });
@@ -1601,6 +1537,8 @@ function drawPhylogeny(
1601
1537
  // label hover
1602
1538
  labels
1603
1539
  .on("mouseenter", function(_event, d) {
1540
+ hoverLines.selectAll("*").remove();
1541
+ hoverArcs.selectAll("*").remove();
1604
1542
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1605
1543
  d3__namespace.select(this).select("text").attr("font-weight", 600);
1606
1544
  })
@@ -1620,9 +1558,6 @@ function drawPhylogeny(
1620
1558
  width = 3
1621
1559
  ) {
1622
1560
  // target may be a tip node *or* a numeric tip id
1623
- lineLayer.selectAll("*").remove();
1624
- arcLayer.selectAll("*").remove();
1625
-
1626
1561
  let cur = (typeof target === "number" || typeof target === "string")
1627
1562
  ? byId.get(target)
1628
1563
  : target;
@@ -1661,18 +1596,10 @@ function drawPhylogeny(
1661
1596
  const R = radiusPx(rec.radius);
1662
1597
  return rec.sweep == null
1663
1598
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1664
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep);
1599
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1665
1600
  }
1666
1601
 
1667
1602
  if (a) {
1668
- console.log("Drawing arc:", {
1669
- childId: cur.thisId,
1670
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1671
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1672
- sweep: a.sweep,
1673
- radius: a.radius
1674
- });
1675
-
1676
1603
  arcLayer
1677
1604
  .append("path")
1678
1605
  .attr("d", pathFromArcRecord(a))
@@ -1689,6 +1616,8 @@ function drawPhylogeny(
1689
1616
  // tip dot hover
1690
1617
  tipDots
1691
1618
  .on("mouseenter", function(_event, d) {
1619
+ hoverLines.selectAll("*").remove();
1620
+ hoverArcs.selectAll("*").remove();
1692
1621
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1693
1622
  d3__namespace.select(this).attr("r", DOT_R + 2);
1694
1623
  })
@@ -1845,7 +1774,7 @@ function drawPhylogeny(
1845
1774
  .attr("x", xOffset)
1846
1775
  .attr("alignment-baseline", "middle")
1847
1776
  .attr("text-anchor", anchor)
1848
- .attr("font-size", 10)
1777
+ .attr("font-size", labelFontSize)
1849
1778
  .attr("fill", "black")
1850
1779
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1851
1780
  });