@euphrasiologist/lwphylo 1.2.24 → 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,15 +175,17 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
175
175
  }
176
176
 
177
177
  // src/radial/describeArcSweep.js
178
- function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
179
- console.log("describeArcSweep input:", {
180
- cx, cy, r,
181
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
182
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
183
- sweep,
184
- largeArcFlag
185
- });
186
-
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
+ ) {
187
189
  if (!(r > 0)) return "";
188
190
 
189
191
  const x0 = cx + r * Math.cos(a0);
@@ -191,7 +193,9 @@ function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
191
193
  const x1 = cx + r * Math.cos(a1);
192
194
  const y1 = cy - r * Math.sin(a1);
193
195
 
194
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
196
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
197
+
198
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
195
199
  }
196
200
 
197
201
  /**
@@ -468,16 +472,13 @@ function getArcs(pd) {
468
472
  }
469
473
 
470
474
  /**
471
- * Build APE-like block arcs per internal parent:
472
- * radius = parent.r
473
- * start = first child's angle
474
- * end = last child's angle
475
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
476
- *
477
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
478
- * @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).
479
477
  */
480
478
  function getArcsFan(pd) {
479
+ const TAU = Math.PI * 2;
480
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
481
+
481
482
  const byId = new Map(pd.map(d => [d.thisId, d]));
482
483
  const arcs = [];
483
484
 
@@ -489,9 +490,11 @@ function getArcsFan(pd) {
489
490
  const last = byId.get(c[c.length - 1])?.angle;
490
491
  if (first == null || last == null) continue;
491
492
 
492
- const start = first;
493
- const end = last;
494
- 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;
495
498
 
496
499
  arcs.push({
497
500
  parentId: p.parentId,
@@ -499,9 +502,11 @@ function getArcsFan(pd) {
499
502
  radius: p.r,
500
503
  start,
501
504
  end,
502
- sweep
505
+ sweep: "ccw", // << math sweep
506
+ largeArc: deltaCCW > Math.PI ? 1 : 0
503
507
  });
504
508
  }
509
+
505
510
  return arcs;
506
511
  }
507
512
 
@@ -587,76 +592,6 @@ function getChildArcs(pd) {
587
592
  return arcs;
588
593
  }
589
594
 
590
- function getChildArcsFan(pd) {
591
- const TAU = Math.PI * 2;
592
- const norm = (t) => ((t % TAU) + TAU) % TAU;
593
-
594
- function midCCW(a, b) {
595
- const d = (b - a + TAU) % TAU;
596
- return norm(a + d / 2);
597
- }
598
-
599
- const key = (x) => (typeof x === "string" ? +x : x);
600
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
601
- const childrenByParent = new Map(
602
- pd.map(d => [
603
- key(d.thisId),
604
- (d.children || [])
605
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
606
- .map(key)
607
- .filter(id => byId.has(id))
608
- ])
609
- );
610
-
611
- const child_arcs = [];
612
-
613
- for (const parentRaw of pd) {
614
- const pid = key(parentRaw.thisId);
615
- const kids = childrenByParent.get(pid) || [];
616
- if (kids.length < 2) continue;
617
-
618
- const A = kids
619
- .map(id => {
620
- const node = byId.get(id);
621
- return node ? { id, a: norm(node.angle) } : null;
622
- })
623
- .filter(Boolean)
624
- .sort((u, v) => u.a - v.a);
625
-
626
- const N = A.length;
627
- if (N < 2) continue;
628
-
629
- const parent = byId.get(pid);
630
- const radius = parent?.r;
631
- if (!(radius > 0)) continue;
632
-
633
- for (let i = 0; i < N; i++) {
634
- const prev = A[(i - 1 + N) % N];
635
- const cur = A[i];
636
- const next = A[(i + 1) % N];
637
-
638
- const start = midCCW(prev.a, cur.a);
639
- const end = midCCW(cur.a, next.a);
640
-
641
- const sweep = 1; // always clockwise
642
- const delta = (end - start + TAU) % TAU;
643
- const largeArc = delta > Math.PI ? 1 : 0;
644
-
645
- child_arcs.push({
646
- parentId: pid,
647
- childId: cur.id,
648
- radius,
649
- start,
650
- end,
651
- sweep,
652
- largeArc
653
- });
654
- }
655
- }
656
-
657
- return child_arcs;
658
- }
659
-
660
595
  /**
661
596
  * radialLayout(node, opts?)
662
597
  * opts:
@@ -700,13 +635,8 @@ function radialLayout(node, opts = {}) {
700
635
  ? getArcsFan(pd)
701
636
  : getArcs(pd);
702
637
 
703
- // per-child arcs for half-arc highlighting if you already use them
704
- let child_arcs = [];
705
- if (arcsStyle === "fan") {
706
- child_arcs = getChildArcsFan(pd);
707
- } else {
708
- child_arcs = getChildArcs(pd);
709
- }
638
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
639
+ const child_arcs = getChildArcs(pd);
710
640
 
711
641
  return { data: pd, radii, arcs, child_arcs };
712
642
  }
@@ -1043,21 +973,29 @@ function readTree(text) {
1043
973
  text = String(text).replace(/\s+/g, '');
1044
974
 
1045
975
  const tokens = text.split(/(;|\(|\)|,)/);
1046
- const root = { parent: null, children: [] };
1047
- let curnode = root;
1048
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;
1049
987
 
1050
988
  for (const token of tokens) {
1051
989
  if (!token || token === ';') continue;
1052
990
 
1053
991
  if (token === '(') {
1054
- const child = { parent: curnode, children: [] };
992
+ const child = makeNode(curnode);
1055
993
  curnode.children.push(child);
1056
994
  curnode = child; // descend
1057
995
  } else if (token === ',') {
1058
996
  // back to parent, then create sibling
1059
997
  curnode = curnode.parent;
1060
- const child = { parent: curnode, children: [] };
998
+ const child = makeNode(curnode);
1061
999
  curnode.children.push(child);
1062
1000
  curnode = child;
1063
1001
  } else if (token === ')') {
@@ -1066,14 +1004,15 @@ function readTree(text) {
1066
1004
  if (curnode === null) break;
1067
1005
  } else {
1068
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.
1069
1010
  const nodeinfo = token.split(':');
1070
1011
  if (nodeinfo.length === 1) {
1071
1012
  if (token.startsWith(':')) {
1072
- curnode.label = '';
1073
1013
  curnode.branchLength = parseFloat(nodeinfo[0]);
1074
1014
  } else {
1075
1015
  curnode.label = nodeinfo[0];
1076
- curnode.branchLength = null;
1077
1016
  }
1078
1017
  } else if (nodeinfo.length === 2) {
1079
1018
  curnode.label = nodeinfo[0];
@@ -1083,13 +1022,9 @@ function readTree(text) {
1083
1022
  curnode.label = nodeinfo[0] || '';
1084
1023
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
1085
1024
  }
1086
- curnode.id = nodeId++; // assign then increment
1087
1025
  }
1088
1026
  }
1089
1027
 
1090
- // Ensure root has an id if not assigned during parsing
1091
- if (root.id == null) root.id = nodeId;
1092
-
1093
1028
  return root;
1094
1029
  }
1095
1030
 
@@ -1149,6 +1084,7 @@ function drawPhylogeny(
1149
1084
  strokeWidth = 1, // for the phylogeny branches
1150
1085
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1151
1086
  tipLabels = true,
1087
+ labelFontSize = 10, // font size (px) for tip labels
1152
1088
  showTooltips = true,
1153
1089
  tooltipFormatter = (d, rtt) =>
1154
1090
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1196,7 +1132,7 @@ function drawPhylogeny(
1196
1132
  const tips = horizontal.filter((d) => d.isTip);
1197
1133
 
1198
1134
  // indices & root→tip getter
1199
- 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
1200
1136
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1201
1137
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1202
1138
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1270,6 +1206,7 @@ function drawPhylogeny(
1270
1206
  // interactive root→tip highlight (rect) on dot hover
1271
1207
  tipDots
1272
1208
  .on("mouseenter", function(_event, d) {
1209
+ hoverLayer.selectAll("*").remove();
1273
1210
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1274
1211
  d3__namespace.select(this).attr("r", 4);
1275
1212
  })
@@ -1289,7 +1226,7 @@ function drawPhylogeny(
1289
1226
  .attr("x", (d) => xScale(d.x1) + 4)
1290
1227
  .attr("y", (d) => yScale(d.y1))
1291
1228
  .attr("dy", "0.32em")
1292
- .attr("font-size", 10)
1229
+ .attr("font-size", labelFontSize)
1293
1230
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1294
1231
 
1295
1232
  if (showTooltips) {
@@ -1300,6 +1237,7 @@ function drawPhylogeny(
1300
1237
 
1301
1238
  labels
1302
1239
  .on("mouseenter", function(_event, d) {
1240
+ hoverLayer.selectAll("*").remove();
1303
1241
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1304
1242
  d3__namespace.select(this).attr("font-weight", 600);
1305
1243
  })
@@ -1326,7 +1264,6 @@ function drawPhylogeny(
1326
1264
 
1327
1265
  // helper to draw root→tip for rect (both vertical+horizontal)
1328
1266
  function drawRectPath(tipId, layer, stroke, width) {
1329
- layer.selectAll("*").remove();
1330
1267
  let cur = byId.get(tipId);
1331
1268
  while (cur && cur.parentId != null) {
1332
1269
  const parent = byId.get(cur.parentId);
@@ -1383,10 +1320,14 @@ function drawPhylogeny(
1383
1320
  const END_CAP = 0;
1384
1321
 
1385
1322
  // ===== SCALES / BOUNDS =====
1386
- const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1387
- const scaleRadial = maxRadius + 2 * radialMargin;
1388
1323
  const w = width,
1389
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;
1390
1331
  const centerX = w / 2,
1391
1332
  centerY = h / 2;
1392
1333
 
@@ -1466,8 +1407,8 @@ function drawPhylogeny(
1466
1407
  radiusPx(d.radius),
1467
1408
  d.start,
1468
1409
  d.end,
1469
- d.sweep,
1470
- d.largeArc,
1410
+ d.sweep ?? "ccw",
1411
+ d.largeArc ?? 0,
1471
1412
  )
1472
1413
  )
1473
1414
  .attr("fill", "none")
@@ -1582,7 +1523,7 @@ function drawPhylogeny(
1582
1523
  .attr("x", xoff)
1583
1524
  .attr("alignment-baseline", "middle")
1584
1525
  .attr("text-anchor", anchor)
1585
- .attr("font-size", 10)
1526
+ .attr("font-size", labelFontSize)
1586
1527
  .attr("fill", "black")
1587
1528
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1588
1529
  });
@@ -1596,6 +1537,8 @@ function drawPhylogeny(
1596
1537
  // label hover
1597
1538
  labels
1598
1539
  .on("mouseenter", function(_event, d) {
1540
+ hoverLines.selectAll("*").remove();
1541
+ hoverArcs.selectAll("*").remove();
1599
1542
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1600
1543
  d3__namespace.select(this).select("text").attr("font-weight", 600);
1601
1544
  })
@@ -1615,9 +1558,6 @@ function drawPhylogeny(
1615
1558
  width = 3
1616
1559
  ) {
1617
1560
  // target may be a tip node *or* a numeric tip id
1618
- lineLayer.selectAll("*").remove();
1619
- arcLayer.selectAll("*").remove();
1620
-
1621
1561
  let cur = (typeof target === "number" || typeof target === "string")
1622
1562
  ? byId.get(target)
1623
1563
  : target;
@@ -1656,18 +1596,10 @@ function drawPhylogeny(
1656
1596
  const R = radiusPx(rec.radius);
1657
1597
  return rec.sweep == null
1658
1598
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1659
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep, a.largeArc);
1599
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1660
1600
  }
1661
1601
 
1662
1602
  if (a) {
1663
- console.log("Drawing arc:", {
1664
- childId: cur.thisId,
1665
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1666
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1667
- sweep: a.sweep,
1668
- radius: a.radius
1669
- });
1670
-
1671
1603
  arcLayer
1672
1604
  .append("path")
1673
1605
  .attr("d", pathFromArcRecord(a))
@@ -1684,6 +1616,8 @@ function drawPhylogeny(
1684
1616
  // tip dot hover
1685
1617
  tipDots
1686
1618
  .on("mouseenter", function(_event, d) {
1619
+ hoverLines.selectAll("*").remove();
1620
+ hoverArcs.selectAll("*").remove();
1687
1621
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1688
1622
  d3__namespace.select(this).attr("r", DOT_R + 2);
1689
1623
  })
@@ -1840,7 +1774,7 @@ function drawPhylogeny(
1840
1774
  .attr("x", xOffset)
1841
1775
  .attr("alignment-baseline", "middle")
1842
1776
  .attr("text-anchor", anchor)
1843
- .attr("font-size", 10)
1777
+ .attr("font-size", labelFontSize)
1844
1778
  .attr("fill", "black")
1845
1779
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1846
1780
  });