@euphrasiologist/lwphylo 1.2.7 → 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,16 +174,19 @@ 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
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
+
178
181
  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 "";
182
+ const delta = sweep === 0 ? norm$1(a1 - a0) : norm$1(a0 - a1);
183
+ if (!(r > 0) || delta < 1e-9) return "";
183
184
  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}`;
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}`;
187
190
  }
188
191
 
189
192
  /**
@@ -363,6 +366,37 @@ function getRadii(node) {
363
366
  return segments;
364
367
  }
365
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
+
366
400
  /**
367
401
  * Build arc descriptors for each internal parent:
368
402
  * - One arc per internal node at radius = parent.r
@@ -429,41 +463,48 @@ function getArcs(pd) {
429
463
  }
430
464
 
431
465
  /**
432
- * 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π
433
471
  *
434
- * For each non-root node (child), emit an arc at the PARENT's radius that
435
- * spans between the parent's angle and the child's angle. This is the arc
436
- * segment that meets the child's spoke and is ideal for root→tip highlighting.
437
- *
438
- * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
439
- * 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}]
440
474
  */
441
- function getChildArcs(pd) {
475
+ function getArcsFan(pd) {
442
476
  const byId = new Map(pd.map(d => [d.thisId, d]));
443
477
  const arcs = [];
444
478
 
445
- for (const child of pd) {
446
- if (child.parentId == null) continue; // skip root
447
- const parent = byId.get(child.parentId);
448
- 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
449
490
 
450
491
  arcs.push({
451
- parentId: parent.thisId,
452
- childId: child.thisId,
453
- radius: parent.r, // draw on the parent's circle
454
- start: parent.angle, // start at parent's angle
455
- 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
456
498
  });
457
499
  }
458
-
459
500
  return arcs;
460
501
  }
461
502
 
462
- // fanAngles.js
503
+ // APE-like "fan" angles: tips evenly spaced with open-angle gap & rotation,
504
+ // internal nodes = arithmetic mean of unwrapped child angles.
463
505
  const TAU = Math.PI * 2;
464
506
  const norm = (t) => ((t % TAU) + TAU) % TAU;
465
507
 
466
- // Unwrap angles around a reference so they sit within [ref-π, ref+π]
467
508
  function unwrapAround(ref, a) {
468
509
  let x = a;
469
510
  while (x < ref - Math.PI) x += TAU;
@@ -471,26 +512,17 @@ function unwrapAround(ref, a) {
471
512
  return x;
472
513
  }
473
514
 
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
515
  function fanAngles(pd, opts = {}) {
485
516
  const { openAngleDeg = 0, rotateDeg = 0 } = opts;
486
517
  const gap = (openAngleDeg / 360) * TAU;
487
- const rotate = (rotateDeg / 360) * TAU;
518
+ const rot = (rotateDeg / 360) * TAU;
488
519
 
489
- // Find root and collect tips in cladewise/DFS order
520
+ // root + children index
490
521
  let root = null;
491
- const kids = new Map(pd.map(d => [d.thisId, d.children || []]));
522
+ const kids = new Map(pd.map((d) => [d.thisId, d.children || []]));
492
523
  for (const d of pd) if (d.parentId == null) { root = d.thisId; break; }
493
524
 
525
+ // tip order (DFS left→right like your fortify)
494
526
  const tipIds = [];
495
527
  (function dfs(id) {
496
528
  const c = kids.get(id) || [];
@@ -499,103 +531,105 @@ function fanAngles(pd, opts = {}) {
499
531
  })(root);
500
532
 
501
533
  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;
534
+ const maxA = TAU * (1 - 1 / N) - gap; // note: no last-step overlap
504
535
  const step = N > 1 ? maxA / (N - 1) : 0;
505
536
 
506
537
  const angle = new Map();
507
- tipIds.forEach((id, i) => {
508
- angle.set(id, norm(i * step + rotate));
509
- });
538
+ tipIds.forEach((id, i) => angle.set(id, norm(i * step + rot)));
510
539
 
511
- // Internal nodes: arithmetic mean of child angles (unwrapped)
540
+ // internal nodes: arithmetic mean of child angles (unwrapped)
512
541
  (function setInternal(id) {
513
542
  const c = kids.get(id) || [];
514
543
  for (const ch of c) setInternal(ch);
515
- if (c.length > 0) {
516
- // unwrap child angles around the first child's angle
544
+ if (c.length) {
517
545
  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));
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));
521
548
  }
522
549
  })(root);
523
550
 
524
551
  return angle;
525
552
  }
526
553
 
527
- // getArcsFan.js
528
-
529
554
  /**
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.
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.
534
560
  *
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).
561
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
562
+ * Output: [{ parentId, childId, radius, start, end }]
539
563
  */
540
- function getArcsFan(pd) {
564
+ function getChildArcs(pd) {
541
565
  const byId = new Map(pd.map(d => [d.thisId, d]));
542
566
  const arcs = [];
543
567
 
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;
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;
557
572
 
558
573
  arcs.push({
559
- parentId: p.parentId,
560
- thisId: p.thisId,
561
- radius: p.r,
562
- 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)
563
579
  });
564
580
  }
581
+
565
582
  return arcs;
566
583
  }
567
584
 
585
+ // src/radial/radialLayout.js
586
+
568
587
  /**
569
- * Simple wrapper for radial layout:
570
- * - data: per-node { angle, r, x, y, ... }
571
- * - radii: per-edge radial spokes (parent.r child.r)
572
- * - arcs: per-parent arcs spanning all children at parent's radius
573
- * - 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)
574
594
  */
575
- function radialLayout(node, opts = {}) {
576
- const data = {};
577
- data.data = radialData(node);
578
- data.radii = getRadii(node);
579
- data.arcs = getArcs(data.data);
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);
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
+ }
593
617
  }
594
618
 
595
- data.data_pd = pd;
596
- 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);
597
623
 
598
- 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 };
599
633
  }
600
634
 
601
635
  /**