@euphrasiologist/lwphylo 1.2.6 → 1.2.8

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,21 @@ 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
+ // 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
+ const delta = sweep === 0 ? norm$1(a1 - a0) : norm$1(a0 - a1);
183
+ if (!(r > 0) || delta < 1e-9) return "";
184
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
185
+
186
+ const x0 = cx + r * Math.cos(a0), y0 = cy - r * Math.sin(a0);
187
+ const x1 = cx + r * Math.cos(a1), y1 = cy - r * Math.sin(a1);
188
+
189
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
190
+ }
191
+
177
192
  /**
178
193
  * Recursive function for pre-order traversal of tree (returns array)
179
194
  */
@@ -351,6 +366,37 @@ function getRadii(node) {
351
366
  return segments;
352
367
  }
353
368
 
369
+ // src/radial/getRadiiFromPd.js
370
+ /**
371
+ * Build per-edge spokes using the *current* pd angles/r.
372
+ * Output: [{ parentId, childId, x0,y0,x1,y1, isTip }]
373
+ */
374
+ function getRadiiFromPd(pd) {
375
+ const byId = new Map(pd.map(d => [d.thisId, d]));
376
+ const root = pd.find(d => d.parentId == null)?.thisId;
377
+
378
+ const segments = [];
379
+ for (const d of pd) {
380
+ if (d.thisId === root) continue;
381
+ const parent = byId.get(d.parentId);
382
+ if (!parent) continue;
383
+
384
+ const theta = d.angle;
385
+ const r0 = parent.r, r1 = d.r;
386
+
387
+ segments.push({
388
+ parentId: parent.thisId,
389
+ childId: d.thisId,
390
+ x0: r0 * Math.cos(theta),
391
+ y0: r0 * Math.sin(theta),
392
+ x1: r1 * Math.cos(theta),
393
+ y1: r1 * Math.sin(theta),
394
+ isTip: !!d.isTip
395
+ });
396
+ }
397
+ return segments;
398
+ }
399
+
354
400
  /**
355
401
  * Build arc descriptors for each internal parent:
356
402
  * - One arc per internal node at radius = parent.r
@@ -417,41 +463,48 @@ function getArcs(pd) {
417
463
  }
418
464
 
419
465
  /**
420
- * Per-child "half" arcs for radial trees.
466
+ * Build APE-like block arcs per internal parent:
467
+ * radius = parent.r
468
+ * start = first child's angle
469
+ * end = last child's angle
470
+ * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
421
471
  *
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 }]
472
+ * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
473
+ * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
428
474
  */
429
- function getChildArcs(pd) {
475
+ function getArcsFan(pd) {
430
476
  const byId = new Map(pd.map(d => [d.thisId, d]));
431
477
  const arcs = [];
432
478
 
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;
479
+ for (const p of pd) {
480
+ const c = p.children || [];
481
+ if (c.length < 2 || !(p.r > 0)) continue;
482
+
483
+ const first = byId.get(c[0])?.angle;
484
+ const last = byId.get(c[c.length - 1])?.angle;
485
+ if (first == null || last == null) continue;
486
+
487
+ const start = first;
488
+ const end = last;
489
+ const sweep = end >= start ? 0 : 1; // CW if wrapped
437
490
 
438
491
  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)
492
+ parentId: p.parentId,
493
+ thisId: p.thisId,
494
+ radius: p.r,
495
+ start,
496
+ end,
497
+ sweep
444
498
  });
445
499
  }
446
-
447
500
  return arcs;
448
501
  }
449
502
 
450
- // fanAngles.js
503
+ // APE-like "fan" angles: tips evenly spaced with open-angle gap & rotation,
504
+ // internal nodes = arithmetic mean of unwrapped child angles.
451
505
  const TAU = Math.PI * 2;
452
506
  const norm = (t) => ((t % TAU) + TAU) % TAU;
453
507
 
454
- // Unwrap angles around a reference so they sit within [ref-π, ref+π]
455
508
  function unwrapAround(ref, a) {
456
509
  let x = a;
457
510
  while (x < ref - Math.PI) x += TAU;
@@ -459,26 +512,17 @@ function unwrapAround(ref, a) {
459
512
  return x;
460
513
  }
461
514
 
462
- /**
463
- * Compute APE "fan" compatible angles:
464
- * - Tips evenly spaced over [0, span] where span = 2π*(1 - 1/Ntip) - gap
465
- * - Then + rotate (radians)
466
- * - Internal nodes = arithmetic mean of child angles (unwrapped)
467
- *
468
- * pd: fortified nodes array (has thisId, parentId, children[])
469
- * opts: { openAngleDeg=0, rotateDeg=0 }
470
- * returns: Map(nodeId -> angle)
471
- */
472
515
  function fanAngles(pd, opts = {}) {
473
516
  const { openAngleDeg = 0, rotateDeg = 0 } = opts;
474
517
  const gap = (openAngleDeg / 360) * TAU;
475
- const rotate = (rotateDeg / 360) * TAU;
518
+ const rot = (rotateDeg / 360) * TAU;
476
519
 
477
- // Find root and collect tips in cladewise/DFS order
520
+ // root + children index
478
521
  let root = null;
479
- const kids = new Map(pd.map(d => [d.thisId, d.children || []]));
522
+ const kids = new Map(pd.map((d) => [d.thisId, d.children || []]));
480
523
  for (const d of pd) if (d.parentId == null) { root = d.thisId; break; }
481
524
 
525
+ // tip order (DFS left→right like your fortify)
482
526
  const tipIds = [];
483
527
  (function dfs(id) {
484
528
  const c = kids.get(id) || [];
@@ -487,103 +531,105 @@ function fanAngles(pd, opts = {}) {
487
531
  })(root);
488
532
 
489
533
  const N = Math.max(1, tipIds.length);
490
- // APE: 0 .. 2π*(1 - 1/N) - gap, length.out=N (no last step overlap)
491
- const maxA = TAU * (1 - 1 / N) - gap;
534
+ const maxA = TAU * (1 - 1 / N) - gap; // note: no last-step overlap
492
535
  const step = N > 1 ? maxA / (N - 1) : 0;
493
536
 
494
537
  const angle = new Map();
495
- tipIds.forEach((id, i) => {
496
- angle.set(id, norm(i * step + rotate));
497
- });
538
+ tipIds.forEach((id, i) => angle.set(id, norm(i * step + rot)));
498
539
 
499
- // Internal nodes: arithmetic mean of child angles (unwrapped)
540
+ // internal nodes: arithmetic mean of child angles (unwrapped)
500
541
  (function setInternal(id) {
501
542
  const c = kids.get(id) || [];
502
543
  for (const ch of c) setInternal(ch);
503
- if (c.length > 0) {
504
- // unwrap child angles around the first child's angle
544
+ if (c.length) {
505
545
  const a0 = angle.get(c[0]);
506
- const unwrapped = c.map(ch => unwrapAround(a0, angle.get(ch)));
507
- const mean = unwrapped.reduce((s, v) => s + v, 0) / unwrapped.length;
508
- angle.set(id, norm(mean));
546
+ const arr = c.map((ch) => unwrapAround(a0, angle.get(ch)));
547
+ angle.set(id, norm(arr.reduce((s, v) => s + v, 0) / arr.length));
509
548
  }
510
549
  })(root);
511
550
 
512
551
  return angle;
513
552
  }
514
553
 
515
- // getArcsFan.js
516
-
517
554
  /**
518
- * Build arcs like APE's circular.plot:
519
- * For each internal parent, draw a single arc at radius=parent.r
520
- * going from first child's angle to last child's angle in child order.
521
- * If last < first (wrap), we draw CW (decreasing) to stay on the block.
555
+ * Per-child "half" arcs for radial trees.
556
+ *
557
+ * For each non-root node (child), emit an arc at the PARENT's radius that
558
+ * spans between the parent's angle and the child's angle. This is the arc
559
+ * segment that meets the child's spoke and is ideal for root→tip highlighting.
522
560
  *
523
- * pd: array with { thisId, parentId, r, children[], angle }
524
- * returns: [{ parentId, thisId, radius, start, end, sweep }]
525
- * where sweep=0 means CCW (start→end increasing),
526
- * sweep=1 means CW (start→end decreasing across wrap).
561
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
562
+ * Output: [{ parentId, childId, radius, start, end }]
527
563
  */
528
- function getArcsFan(pd) {
564
+ function getChildArcs(pd) {
529
565
  const byId = new Map(pd.map(d => [d.thisId, d]));
530
566
  const arcs = [];
531
567
 
532
- for (const p of pd) {
533
- const c = p.children || [];
534
- if (c.length < 2) continue;
535
- const A = c.map(id => byId.get(id)?.angle).filter(a => a != null);
536
- if (A.length < 2 || !isFinite(p.r) || p.r <= 0) continue;
537
-
538
- // Children are contiguous in tip order; take first and last
539
- let start = A[0];
540
- let end = A[A.length - 1];
541
-
542
- // Decide direction like APE’s seq(start, end):
543
- // if end >= start → CCW; else CW across wrap.
544
- const sweep = end >= start ? 0 : 1;
568
+ for (const child of pd) {
569
+ if (child.parentId == null) continue; // skip root
570
+ const parent = byId.get(child.parentId);
571
+ if (!parent) continue;
545
572
 
546
573
  arcs.push({
547
- parentId: p.parentId,
548
- thisId: p.thisId,
549
- radius: p.r,
550
- start, end, sweep
574
+ parentId: parent.thisId,
575
+ childId: child.thisId,
576
+ radius: parent.r, // draw on the parent's circle
577
+ start: parent.angle, // start at parent's angle
578
+ end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
551
579
  });
552
580
  }
581
+
553
582
  return arcs;
554
583
  }
555
584
 
585
+ // src/radial/radialLayout.js
586
+
556
587
  /**
557
- * Simple wrapper for radial layout:
558
- * - data: per-node { angle, r, x, y, ... }
559
- * - radii: per-edge radial spokes (parent.r child.r)
560
- * - arcs: per-parent arcs spanning all children at parent's radius
561
- * - child_arcs: per-child half-arcs (parent.angle child.angle) at parent's radius
588
+ * radialLayout(node, opts?)
589
+ * opts:
590
+ * - angleStrategy: "cmean" (default, your current) | "fan" (APE-like)
591
+ * - arcsStyle: "shortest" (default) | "fan" (block arcs, supports wrap)
592
+ * - openAngleDeg: number (gap wedge for "fan" angles)
593
+ * - rotateDeg: number (rotation for "fan" angles)
562
594
  */
563
- function radialLayout(node, opts = {}) {
564
- const data = {};
565
- data.data = radialData(node);
566
- data.radii = getRadii(node);
567
- data.arcs = getArcs(data.data);
568
- data.child_arcs = getChildArcs(data.data);
569
-
570
- const pd = fortify(node, true);
571
- const angleMap = fanAngles(pd, {
572
- openAngleDeg: opts.openAngleDeg ?? 0,
573
- rotateDeg: opts.rotateDeg ?? 0
574
- });
575
-
576
- // stamp angles + x,y back onto pd (r stays your cumulative edge length)
577
- for (const d of pd) {
578
- d.angle = angleMap.get(d.thisId) ?? 0;
579
- d.x = d.r * Math.cos(d.angle);
580
- d.y = d.r * Math.sin(d.angle);
595
+ function radialLayout(node, opts = {}) {
596
+ const {
597
+ angleStrategy = "cmean",
598
+ arcsStyle = "shortest",
599
+ openAngleDeg = 0,
600
+ rotateDeg = 0
601
+ } = opts;
602
+
603
+ // Start with your current enriched nodes (angle + r from radialData)
604
+ const pd = radialData(node);
605
+
606
+ if (angleStrategy === "fan") {
607
+ // overwrite angles with APE-like fan angles; keep radii r as-is
608
+ const angleMap = fanAngles(pd, { openAngleDeg, rotateDeg });
609
+ for (const d of pd) {
610
+ const a = angleMap.get(d.thisId);
611
+ if (a != null) {
612
+ d.angle = a;
613
+ d.x = d.r * Math.cos(a);
614
+ d.y = d.r * Math.sin(a);
615
+ }
616
+ }
581
617
  }
582
618
 
583
- data.data_pd = pd;
584
- data.arcs_fan = getArcsFan(pd);
619
+ // Build spokes using the angles currently on pd
620
+ const radii = (angleStrategy === "fan")
621
+ ? getRadiiFromPd(pd)
622
+ : getRadii(node);
585
623
 
586
- return data;
624
+ // Choose arc builder
625
+ const arcs = (arcsStyle === "fan")
626
+ ? getArcsFan(pd)
627
+ : getArcs(pd);
628
+
629
+ // per-child arcs for half-arc highlighting if you already use them
630
+ const child_arcs = getChildArcs(pd);
631
+
632
+ return { data: pd, radii, arcs, child_arcs };
587
633
  }
588
634
 
589
635
  /**
@@ -1764,6 +1810,7 @@ function drawPhylogeny(
1764
1810
  }
1765
1811
 
1766
1812
  exports.describeArc = describeArc;
1813
+ exports.describeArcSweep = describeArcSweep;
1767
1814
  exports.drawPhylogeny = drawPhylogeny;
1768
1815
  exports.parentFisheye = parentFisheye;
1769
1816
  exports.phisheye = phisheye;