@euphrasiologist/lwphylo 1.2.5 → 1.2.7

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
@@ -174,6 +174,18 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
174
174
  return `M ${p0.x} ${p0.y} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${p1.x} ${p1.y}`;
175
175
  }
176
176
 
177
+ // describeArcSweep.js
178
+ function describeArcSweep(cx, cy, r, a0, a1, sweep /*0=CCW,1=CW*/) {
179
+ const TAU = Math.PI * 2;
180
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
181
+ const delta = sweep === 0 ? norm(a1 - a0) : norm(a0 - a1); // magnitude on chosen sweep
182
+ if (delta < 1e-9 || r <= 0) return "";
183
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
184
+ const p0 = { x: cx + r * Math.cos(a0), y: cy - r * Math.sin(a0) };
185
+ const p1 = { x: cx + r * Math.cos(a1), y: cy - r * Math.sin(a1) };
186
+ return `M ${p0.x} ${p0.y} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${p1.x} ${p1.y}`;
187
+ }
188
+
177
189
  /**
178
190
  * Recursive function for pre-order traversal of tree (returns array)
179
191
  */
@@ -447,6 +459,112 @@ function getChildArcs(pd) {
447
459
  return arcs;
448
460
  }
449
461
 
462
+ // fanAngles.js
463
+ const TAU = Math.PI * 2;
464
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
465
+
466
+ // Unwrap angles around a reference so they sit within [ref-π, ref+π]
467
+ function unwrapAround(ref, a) {
468
+ let x = a;
469
+ while (x < ref - Math.PI) x += TAU;
470
+ while (x > ref + Math.PI) x -= TAU;
471
+ return x;
472
+ }
473
+
474
+ /**
475
+ * Compute APE "fan" compatible angles:
476
+ * - Tips evenly spaced over [0, span] where span = 2π*(1 - 1/Ntip) - gap
477
+ * - Then + rotate (radians)
478
+ * - Internal nodes = arithmetic mean of child angles (unwrapped)
479
+ *
480
+ * pd: fortified nodes array (has thisId, parentId, children[])
481
+ * opts: { openAngleDeg=0, rotateDeg=0 }
482
+ * returns: Map(nodeId -> angle)
483
+ */
484
+ function fanAngles(pd, opts = {}) {
485
+ const { openAngleDeg = 0, rotateDeg = 0 } = opts;
486
+ const gap = (openAngleDeg / 360) * TAU;
487
+ const rotate = (rotateDeg / 360) * TAU;
488
+
489
+ // Find root and collect tips in cladewise/DFS order
490
+ let root = null;
491
+ const kids = new Map(pd.map(d => [d.thisId, d.children || []]));
492
+ for (const d of pd) if (d.parentId == null) { root = d.thisId; break; }
493
+
494
+ const tipIds = [];
495
+ (function dfs(id) {
496
+ const c = kids.get(id) || [];
497
+ if (!c.length) { tipIds.push(id); return; }
498
+ for (const ch of c) dfs(ch);
499
+ })(root);
500
+
501
+ const N = Math.max(1, tipIds.length);
502
+ // APE: 0 .. 2π*(1 - 1/N) - gap, length.out=N (no last step overlap)
503
+ const maxA = TAU * (1 - 1 / N) - gap;
504
+ const step = N > 1 ? maxA / (N - 1) : 0;
505
+
506
+ const angle = new Map();
507
+ tipIds.forEach((id, i) => {
508
+ angle.set(id, norm(i * step + rotate));
509
+ });
510
+
511
+ // Internal nodes: arithmetic mean of child angles (unwrapped)
512
+ (function setInternal(id) {
513
+ const c = kids.get(id) || [];
514
+ for (const ch of c) setInternal(ch);
515
+ if (c.length > 0) {
516
+ // unwrap child angles around the first child's angle
517
+ const a0 = angle.get(c[0]);
518
+ const unwrapped = c.map(ch => unwrapAround(a0, angle.get(ch)));
519
+ const mean = unwrapped.reduce((s, v) => s + v, 0) / unwrapped.length;
520
+ angle.set(id, norm(mean));
521
+ }
522
+ })(root);
523
+
524
+ return angle;
525
+ }
526
+
527
+ // getArcsFan.js
528
+
529
+ /**
530
+ * Build arcs like APE's circular.plot:
531
+ * For each internal parent, draw a single arc at radius=parent.r
532
+ * going from first child's angle to last child's angle in child order.
533
+ * If last < first (wrap), we draw CW (decreasing) to stay on the block.
534
+ *
535
+ * pd: array with { thisId, parentId, r, children[], angle }
536
+ * returns: [{ parentId, thisId, radius, start, end, sweep }]
537
+ * where sweep=0 means CCW (start→end increasing),
538
+ * sweep=1 means CW (start→end decreasing across wrap).
539
+ */
540
+ function getArcsFan(pd) {
541
+ const byId = new Map(pd.map(d => [d.thisId, d]));
542
+ const arcs = [];
543
+
544
+ for (const p of pd) {
545
+ const c = p.children || [];
546
+ if (c.length < 2) continue;
547
+ const A = c.map(id => byId.get(id)?.angle).filter(a => a != null);
548
+ if (A.length < 2 || !isFinite(p.r) || p.r <= 0) continue;
549
+
550
+ // Children are contiguous in tip order; take first and last
551
+ let start = A[0];
552
+ let end = A[A.length - 1];
553
+
554
+ // Decide direction like APE’s seq(start, end):
555
+ // if end >= start → CCW; else CW across wrap.
556
+ const sweep = end >= start ? 0 : 1;
557
+
558
+ arcs.push({
559
+ parentId: p.parentId,
560
+ thisId: p.thisId,
561
+ radius: p.r,
562
+ start, end, sweep
563
+ });
564
+ }
565
+ return arcs;
566
+ }
567
+
450
568
  /**
451
569
  * Simple wrapper for radial layout:
452
570
  * - data: per-node { angle, r, x, y, ... }
@@ -454,12 +572,29 @@ function getChildArcs(pd) {
454
572
  * - arcs: per-parent arcs spanning all children at parent's radius
455
573
  * - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
456
574
  */
457
- function radialLayout(node) {
575
+ function radialLayout(node, opts = {}) {
458
576
  const data = {};
459
577
  data.data = radialData(node);
460
578
  data.radii = getRadii(node);
461
579
  data.arcs = getArcs(data.data);
462
580
  data.child_arcs = getChildArcs(data.data);
581
+
582
+ const pd = fortify(node, true);
583
+ const angleMap = fanAngles(pd, {
584
+ openAngleDeg: opts.openAngleDeg ?? 0,
585
+ rotateDeg: opts.rotateDeg ?? 0
586
+ });
587
+
588
+ // stamp angles + x,y back onto pd (r stays your cumulative edge length)
589
+ for (const d of pd) {
590
+ d.angle = angleMap.get(d.thisId) ?? 0;
591
+ d.x = d.r * Math.cos(d.angle);
592
+ d.y = d.r * Math.sin(d.angle);
593
+ }
594
+
595
+ data.data_pd = pd;
596
+ data.arcs_fan = getArcsFan(pd);
597
+
463
598
  return data;
464
599
  }
465
600
 
@@ -1641,6 +1776,7 @@ function drawPhylogeny(
1641
1776
  }
1642
1777
 
1643
1778
  exports.describeArc = describeArc;
1779
+ exports.describeArcSweep = describeArcSweep;
1644
1780
  exports.drawPhylogeny = drawPhylogeny;
1645
1781
  exports.parentFisheye = parentFisheye;
1646
1782
  exports.phisheye = phisheye;