@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.js CHANGED
@@ -153,6 +153,21 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
153
153
  return `M ${p0.x} ${p0.y} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${p1.x} ${p1.y}`;
154
154
  }
155
155
 
156
+ // src/radial/describeArcSweep.js
157
+ const TAU$1 = Math.PI * 2;
158
+ const norm$1 = (t) => ((t % TAU$1) + TAU$1) % TAU$1;
159
+
160
+ function describeArcSweep(cx, cy, r, a0, a1, sweep /*0=CCW,1=CW*/) {
161
+ const delta = sweep === 0 ? norm$1(a1 - a0) : norm$1(a0 - a1);
162
+ if (!(r > 0) || delta < 1e-9) return "";
163
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
164
+
165
+ const x0 = cx + r * Math.cos(a0), y0 = cy - r * Math.sin(a0);
166
+ const x1 = cx + r * Math.cos(a1), y1 = cy - r * Math.sin(a1);
167
+
168
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
169
+ }
170
+
156
171
  /**
157
172
  * Recursive function for pre-order traversal of tree (returns array)
158
173
  */
@@ -330,6 +345,37 @@ function getRadii(node) {
330
345
  return segments;
331
346
  }
332
347
 
348
+ // src/radial/getRadiiFromPd.js
349
+ /**
350
+ * Build per-edge spokes using the *current* pd angles/r.
351
+ * Output: [{ parentId, childId, x0,y0,x1,y1, isTip }]
352
+ */
353
+ function getRadiiFromPd(pd) {
354
+ const byId = new Map(pd.map(d => [d.thisId, d]));
355
+ const root = pd.find(d => d.parentId == null)?.thisId;
356
+
357
+ const segments = [];
358
+ for (const d of pd) {
359
+ if (d.thisId === root) continue;
360
+ const parent = byId.get(d.parentId);
361
+ if (!parent) continue;
362
+
363
+ const theta = d.angle;
364
+ const r0 = parent.r, r1 = d.r;
365
+
366
+ segments.push({
367
+ parentId: parent.thisId,
368
+ childId: d.thisId,
369
+ x0: r0 * Math.cos(theta),
370
+ y0: r0 * Math.sin(theta),
371
+ x1: r1 * Math.cos(theta),
372
+ y1: r1 * Math.sin(theta),
373
+ isTip: !!d.isTip
374
+ });
375
+ }
376
+ return segments;
377
+ }
378
+
333
379
  /**
334
380
  * Build arc descriptors for each internal parent:
335
381
  * - One arc per internal node at radius = parent.r
@@ -396,41 +442,48 @@ function getArcs(pd) {
396
442
  }
397
443
 
398
444
  /**
399
- * Per-child "half" arcs for radial trees.
445
+ * Build APE-like block arcs per internal parent:
446
+ * radius = parent.r
447
+ * start = first child's angle
448
+ * end = last child's angle
449
+ * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
400
450
  *
401
- * For each non-root node (child), emit an arc at the PARENT's radius that
402
- * spans between the parent's angle and the child's angle. This is the arc
403
- * segment that meets the child's spoke and is ideal for root→tip highlighting.
404
- *
405
- * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
406
- * Output: [{ parentId, childId, radius, start, end }]
451
+ * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
452
+ * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
407
453
  */
408
- function getChildArcs(pd) {
454
+ function getArcsFan(pd) {
409
455
  const byId = new Map(pd.map(d => [d.thisId, d]));
410
456
  const arcs = [];
411
457
 
412
- for (const child of pd) {
413
- if (child.parentId == null) continue; // skip root
414
- const parent = byId.get(child.parentId);
415
- if (!parent) continue;
458
+ for (const p of pd) {
459
+ const c = p.children || [];
460
+ if (c.length < 2 || !(p.r > 0)) continue;
461
+
462
+ const first = byId.get(c[0])?.angle;
463
+ const last = byId.get(c[c.length - 1])?.angle;
464
+ if (first == null || last == null) continue;
465
+
466
+ const start = first;
467
+ const end = last;
468
+ const sweep = end >= start ? 0 : 1; // CW if wrapped
416
469
 
417
470
  arcs.push({
418
- parentId: parent.thisId,
419
- childId: child.thisId,
420
- radius: parent.r, // draw on the parent's circle
421
- start: parent.angle, // start at parent's angle
422
- end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
471
+ parentId: p.parentId,
472
+ thisId: p.thisId,
473
+ radius: p.r,
474
+ start,
475
+ end,
476
+ sweep
423
477
  });
424
478
  }
425
-
426
479
  return arcs;
427
480
  }
428
481
 
429
- // fanAngles.js
482
+ // APE-like "fan" angles: tips evenly spaced with open-angle gap & rotation,
483
+ // internal nodes = arithmetic mean of unwrapped child angles.
430
484
  const TAU = Math.PI * 2;
431
485
  const norm = (t) => ((t % TAU) + TAU) % TAU;
432
486
 
433
- // Unwrap angles around a reference so they sit within [ref-π, ref+π]
434
487
  function unwrapAround(ref, a) {
435
488
  let x = a;
436
489
  while (x < ref - Math.PI) x += TAU;
@@ -438,26 +491,17 @@ function unwrapAround(ref, a) {
438
491
  return x;
439
492
  }
440
493
 
441
- /**
442
- * Compute APE "fan" compatible angles:
443
- * - Tips evenly spaced over [0, span] where span = 2π*(1 - 1/Ntip) - gap
444
- * - Then + rotate (radians)
445
- * - Internal nodes = arithmetic mean of child angles (unwrapped)
446
- *
447
- * pd: fortified nodes array (has thisId, parentId, children[])
448
- * opts: { openAngleDeg=0, rotateDeg=0 }
449
- * returns: Map(nodeId -> angle)
450
- */
451
494
  function fanAngles(pd, opts = {}) {
452
495
  const { openAngleDeg = 0, rotateDeg = 0 } = opts;
453
496
  const gap = (openAngleDeg / 360) * TAU;
454
- const rotate = (rotateDeg / 360) * TAU;
497
+ const rot = (rotateDeg / 360) * TAU;
455
498
 
456
- // Find root and collect tips in cladewise/DFS order
499
+ // root + children index
457
500
  let root = null;
458
- const kids = new Map(pd.map(d => [d.thisId, d.children || []]));
501
+ const kids = new Map(pd.map((d) => [d.thisId, d.children || []]));
459
502
  for (const d of pd) if (d.parentId == null) { root = d.thisId; break; }
460
503
 
504
+ // tip order (DFS left→right like your fortify)
461
505
  const tipIds = [];
462
506
  (function dfs(id) {
463
507
  const c = kids.get(id) || [];
@@ -466,103 +510,105 @@ function fanAngles(pd, opts = {}) {
466
510
  })(root);
467
511
 
468
512
  const N = Math.max(1, tipIds.length);
469
- // APE: 0 .. 2π*(1 - 1/N) - gap, length.out=N (no last step overlap)
470
- const maxA = TAU * (1 - 1 / N) - gap;
513
+ const maxA = TAU * (1 - 1 / N) - gap; // note: no last-step overlap
471
514
  const step = N > 1 ? maxA / (N - 1) : 0;
472
515
 
473
516
  const angle = new Map();
474
- tipIds.forEach((id, i) => {
475
- angle.set(id, norm(i * step + rotate));
476
- });
517
+ tipIds.forEach((id, i) => angle.set(id, norm(i * step + rot)));
477
518
 
478
- // Internal nodes: arithmetic mean of child angles (unwrapped)
519
+ // internal nodes: arithmetic mean of child angles (unwrapped)
479
520
  (function setInternal(id) {
480
521
  const c = kids.get(id) || [];
481
522
  for (const ch of c) setInternal(ch);
482
- if (c.length > 0) {
483
- // unwrap child angles around the first child's angle
523
+ if (c.length) {
484
524
  const a0 = angle.get(c[0]);
485
- const unwrapped = c.map(ch => unwrapAround(a0, angle.get(ch)));
486
- const mean = unwrapped.reduce((s, v) => s + v, 0) / unwrapped.length;
487
- angle.set(id, norm(mean));
525
+ const arr = c.map((ch) => unwrapAround(a0, angle.get(ch)));
526
+ angle.set(id, norm(arr.reduce((s, v) => s + v, 0) / arr.length));
488
527
  }
489
528
  })(root);
490
529
 
491
530
  return angle;
492
531
  }
493
532
 
494
- // getArcsFan.js
495
-
496
533
  /**
497
- * Build arcs like APE's circular.plot:
498
- * For each internal parent, draw a single arc at radius=parent.r
499
- * going from first child's angle to last child's angle in child order.
500
- * If last < first (wrap), we draw CW (decreasing) to stay on the block.
534
+ * Per-child "half" arcs for radial trees.
535
+ *
536
+ * For each non-root node (child), emit an arc at the PARENT's radius that
537
+ * spans between the parent's angle and the child's angle. This is the arc
538
+ * segment that meets the child's spoke and is ideal for root→tip highlighting.
501
539
  *
502
- * pd: array with { thisId, parentId, r, children[], angle }
503
- * returns: [{ parentId, thisId, radius, start, end, sweep }]
504
- * where sweep=0 means CCW (start→end increasing),
505
- * sweep=1 means CW (start→end decreasing across wrap).
540
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
541
+ * Output: [{ parentId, childId, radius, start, end }]
506
542
  */
507
- function getArcsFan(pd) {
543
+ function getChildArcs(pd) {
508
544
  const byId = new Map(pd.map(d => [d.thisId, d]));
509
545
  const arcs = [];
510
546
 
511
- for (const p of pd) {
512
- const c = p.children || [];
513
- if (c.length < 2) continue;
514
- const A = c.map(id => byId.get(id)?.angle).filter(a => a != null);
515
- if (A.length < 2 || !isFinite(p.r) || p.r <= 0) continue;
516
-
517
- // Children are contiguous in tip order; take first and last
518
- let start = A[0];
519
- let end = A[A.length - 1];
520
-
521
- // Decide direction like APE’s seq(start, end):
522
- // if end >= start → CCW; else CW across wrap.
523
- const sweep = end >= start ? 0 : 1;
547
+ for (const child of pd) {
548
+ if (child.parentId == null) continue; // skip root
549
+ const parent = byId.get(child.parentId);
550
+ if (!parent) continue;
524
551
 
525
552
  arcs.push({
526
- parentId: p.parentId,
527
- thisId: p.thisId,
528
- radius: p.r,
529
- start, end, sweep
553
+ parentId: parent.thisId,
554
+ childId: child.thisId,
555
+ radius: parent.r, // draw on the parent's circle
556
+ start: parent.angle, // start at parent's angle
557
+ end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
530
558
  });
531
559
  }
560
+
532
561
  return arcs;
533
562
  }
534
563
 
564
+ // src/radial/radialLayout.js
565
+
535
566
  /**
536
- * Simple wrapper for radial layout:
537
- * - data: per-node { angle, r, x, y, ... }
538
- * - radii: per-edge radial spokes (parent.r child.r)
539
- * - arcs: per-parent arcs spanning all children at parent's radius
540
- * - child_arcs: per-child half-arcs (parent.angle child.angle) at parent's radius
567
+ * radialLayout(node, opts?)
568
+ * opts:
569
+ * - angleStrategy: "cmean" (default, your current) | "fan" (APE-like)
570
+ * - arcsStyle: "shortest" (default) | "fan" (block arcs, supports wrap)
571
+ * - openAngleDeg: number (gap wedge for "fan" angles)
572
+ * - rotateDeg: number (rotation for "fan" angles)
541
573
  */
542
- function radialLayout(node, opts = {}) {
543
- const data = {};
544
- data.data = radialData(node);
545
- data.radii = getRadii(node);
546
- data.arcs = getArcs(data.data);
547
- data.child_arcs = getChildArcs(data.data);
548
-
549
- const pd = fortify(node, true);
550
- const angleMap = fanAngles(pd, {
551
- openAngleDeg: opts.openAngleDeg ?? 0,
552
- rotateDeg: opts.rotateDeg ?? 0
553
- });
554
-
555
- // stamp angles + x,y back onto pd (r stays your cumulative edge length)
556
- for (const d of pd) {
557
- d.angle = angleMap.get(d.thisId) ?? 0;
558
- d.x = d.r * Math.cos(d.angle);
559
- d.y = d.r * Math.sin(d.angle);
574
+ function radialLayout(node, opts = {}) {
575
+ const {
576
+ angleStrategy = "cmean",
577
+ arcsStyle = "shortest",
578
+ openAngleDeg = 0,
579
+ rotateDeg = 0
580
+ } = opts;
581
+
582
+ // Start with your current enriched nodes (angle + r from radialData)
583
+ const pd = radialData(node);
584
+
585
+ if (angleStrategy === "fan") {
586
+ // overwrite angles with APE-like fan angles; keep radii r as-is
587
+ const angleMap = fanAngles(pd, { openAngleDeg, rotateDeg });
588
+ for (const d of pd) {
589
+ const a = angleMap.get(d.thisId);
590
+ if (a != null) {
591
+ d.angle = a;
592
+ d.x = d.r * Math.cos(a);
593
+ d.y = d.r * Math.sin(a);
594
+ }
595
+ }
560
596
  }
561
597
 
562
- data.data_pd = pd;
563
- data.arcs_fan = getArcsFan(pd);
598
+ // Build spokes using the angles currently on pd
599
+ const radii = (angleStrategy === "fan")
600
+ ? getRadiiFromPd(pd)
601
+ : getRadii(node);
564
602
 
565
- return data;
603
+ // Choose arc builder
604
+ const arcs = (arcsStyle === "fan")
605
+ ? getArcsFan(pd)
606
+ : getArcs(pd);
607
+
608
+ // per-child arcs for half-arc highlighting if you already use them
609
+ const child_arcs = getChildArcs(pd);
610
+
611
+ return { data: pd, radii, arcs, child_arcs };
566
612
  }
567
613
 
568
614
  /**
@@ -1742,5 +1788,5 @@ function drawPhylogeny(
1742
1788
  }
1743
1789
  }
1744
1790
 
1745
- export { describeArc, drawPhylogeny, parentFisheye, phisheye, radialLayout, readTree, rectangleLayout, subTree, unrooted };
1791
+ export { describeArc, describeArcSweep, drawPhylogeny, parentFisheye, phisheye, radialLayout, readTree, rectangleLayout, subTree, unrooted };
1746
1792
  //# sourceMappingURL=index.js.map