@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.js CHANGED
@@ -153,16 +153,19 @@ 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
- // describeArcSweep.js
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
+
157
160
  function describeArcSweep(cx, cy, r, a0, a1, sweep /*0=CCW,1=CW*/) {
158
- const TAU = Math.PI * 2;
159
- const norm = (t) => ((t % TAU) + TAU) % TAU;
160
- const delta = sweep === 0 ? norm(a1 - a0) : norm(a0 - a1); // magnitude on chosen sweep
161
- if (delta < 1e-9 || r <= 0) return "";
161
+ const delta = sweep === 0 ? norm$1(a1 - a0) : norm$1(a0 - a1);
162
+ if (!(r > 0) || delta < 1e-9) return "";
162
163
  const largeArcFlag = delta > Math.PI ? 1 : 0;
163
- const p0 = { x: cx + r * Math.cos(a0), y: cy - r * Math.sin(a0) };
164
- const p1 = { x: cx + r * Math.cos(a1), y: cy - r * Math.sin(a1) };
165
- return `M ${p0.x} ${p0.y} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${p1.x} ${p1.y}`;
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}`;
166
169
  }
167
170
 
168
171
  /**
@@ -342,6 +345,37 @@ function getRadii(node) {
342
345
  return segments;
343
346
  }
344
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
+
345
379
  /**
346
380
  * Build arc descriptors for each internal parent:
347
381
  * - One arc per internal node at radius = parent.r
@@ -408,41 +442,48 @@ function getArcs(pd) {
408
442
  }
409
443
 
410
444
  /**
411
- * 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π
412
450
  *
413
- * For each non-root node (child), emit an arc at the PARENT's radius that
414
- * spans between the parent's angle and the child's angle. This is the arc
415
- * segment that meets the child's spoke and is ideal for root→tip highlighting.
416
- *
417
- * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
418
- * 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}]
419
453
  */
420
- function getChildArcs(pd) {
454
+ function getArcsFan(pd) {
421
455
  const byId = new Map(pd.map(d => [d.thisId, d]));
422
456
  const arcs = [];
423
457
 
424
- for (const child of pd) {
425
- if (child.parentId == null) continue; // skip root
426
- const parent = byId.get(child.parentId);
427
- 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
428
469
 
429
470
  arcs.push({
430
- parentId: parent.thisId,
431
- childId: child.thisId,
432
- radius: parent.r, // draw on the parent's circle
433
- start: parent.angle, // start at parent's angle
434
- 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
435
477
  });
436
478
  }
437
-
438
479
  return arcs;
439
480
  }
440
481
 
441
- // fanAngles.js
482
+ // APE-like "fan" angles: tips evenly spaced with open-angle gap & rotation,
483
+ // internal nodes = arithmetic mean of unwrapped child angles.
442
484
  const TAU = Math.PI * 2;
443
485
  const norm = (t) => ((t % TAU) + TAU) % TAU;
444
486
 
445
- // Unwrap angles around a reference so they sit within [ref-π, ref+π]
446
487
  function unwrapAround(ref, a) {
447
488
  let x = a;
448
489
  while (x < ref - Math.PI) x += TAU;
@@ -450,26 +491,17 @@ function unwrapAround(ref, a) {
450
491
  return x;
451
492
  }
452
493
 
453
- /**
454
- * Compute APE "fan" compatible angles:
455
- * - Tips evenly spaced over [0, span] where span = 2π*(1 - 1/Ntip) - gap
456
- * - Then + rotate (radians)
457
- * - Internal nodes = arithmetic mean of child angles (unwrapped)
458
- *
459
- * pd: fortified nodes array (has thisId, parentId, children[])
460
- * opts: { openAngleDeg=0, rotateDeg=0 }
461
- * returns: Map(nodeId -> angle)
462
- */
463
494
  function fanAngles(pd, opts = {}) {
464
495
  const { openAngleDeg = 0, rotateDeg = 0 } = opts;
465
496
  const gap = (openAngleDeg / 360) * TAU;
466
- const rotate = (rotateDeg / 360) * TAU;
497
+ const rot = (rotateDeg / 360) * TAU;
467
498
 
468
- // Find root and collect tips in cladewise/DFS order
499
+ // root + children index
469
500
  let root = null;
470
- const kids = new Map(pd.map(d => [d.thisId, d.children || []]));
501
+ const kids = new Map(pd.map((d) => [d.thisId, d.children || []]));
471
502
  for (const d of pd) if (d.parentId == null) { root = d.thisId; break; }
472
503
 
504
+ // tip order (DFS left→right like your fortify)
473
505
  const tipIds = [];
474
506
  (function dfs(id) {
475
507
  const c = kids.get(id) || [];
@@ -478,103 +510,105 @@ function fanAngles(pd, opts = {}) {
478
510
  })(root);
479
511
 
480
512
  const N = Math.max(1, tipIds.length);
481
- // APE: 0 .. 2π*(1 - 1/N) - gap, length.out=N (no last step overlap)
482
- const maxA = TAU * (1 - 1 / N) - gap;
513
+ const maxA = TAU * (1 - 1 / N) - gap; // note: no last-step overlap
483
514
  const step = N > 1 ? maxA / (N - 1) : 0;
484
515
 
485
516
  const angle = new Map();
486
- tipIds.forEach((id, i) => {
487
- angle.set(id, norm(i * step + rotate));
488
- });
517
+ tipIds.forEach((id, i) => angle.set(id, norm(i * step + rot)));
489
518
 
490
- // Internal nodes: arithmetic mean of child angles (unwrapped)
519
+ // internal nodes: arithmetic mean of child angles (unwrapped)
491
520
  (function setInternal(id) {
492
521
  const c = kids.get(id) || [];
493
522
  for (const ch of c) setInternal(ch);
494
- if (c.length > 0) {
495
- // unwrap child angles around the first child's angle
523
+ if (c.length) {
496
524
  const a0 = angle.get(c[0]);
497
- const unwrapped = c.map(ch => unwrapAround(a0, angle.get(ch)));
498
- const mean = unwrapped.reduce((s, v) => s + v, 0) / unwrapped.length;
499
- 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));
500
527
  }
501
528
  })(root);
502
529
 
503
530
  return angle;
504
531
  }
505
532
 
506
- // getArcsFan.js
507
-
508
533
  /**
509
- * Build arcs like APE's circular.plot:
510
- * For each internal parent, draw a single arc at radius=parent.r
511
- * going from first child's angle to last child's angle in child order.
512
- * 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.
513
539
  *
514
- * pd: array with { thisId, parentId, r, children[], angle }
515
- * returns: [{ parentId, thisId, radius, start, end, sweep }]
516
- * where sweep=0 means CCW (start→end increasing),
517
- * 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 }]
518
542
  */
519
- function getArcsFan(pd) {
543
+ function getChildArcs(pd) {
520
544
  const byId = new Map(pd.map(d => [d.thisId, d]));
521
545
  const arcs = [];
522
546
 
523
- for (const p of pd) {
524
- const c = p.children || [];
525
- if (c.length < 2) continue;
526
- const A = c.map(id => byId.get(id)?.angle).filter(a => a != null);
527
- if (A.length < 2 || !isFinite(p.r) || p.r <= 0) continue;
528
-
529
- // Children are contiguous in tip order; take first and last
530
- let start = A[0];
531
- let end = A[A.length - 1];
532
-
533
- // Decide direction like APE’s seq(start, end):
534
- // if end >= start → CCW; else CW across wrap.
535
- 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;
536
551
 
537
552
  arcs.push({
538
- parentId: p.parentId,
539
- thisId: p.thisId,
540
- radius: p.r,
541
- 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)
542
558
  });
543
559
  }
560
+
544
561
  return arcs;
545
562
  }
546
563
 
564
+ // src/radial/radialLayout.js
565
+
547
566
  /**
548
- * Simple wrapper for radial layout:
549
- * - data: per-node { angle, r, x, y, ... }
550
- * - radii: per-edge radial spokes (parent.r child.r)
551
- * - arcs: per-parent arcs spanning all children at parent's radius
552
- * - 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)
553
573
  */
554
- function radialLayout(node, opts = {}) {
555
- const data = {};
556
- data.data = radialData(node);
557
- data.radii = getRadii(node);
558
- data.arcs = getArcs(data.data);
559
- data.child_arcs = getChildArcs(data.data);
560
-
561
- const pd = fortify(node, true);
562
- const angleMap = fanAngles(pd, {
563
- openAngleDeg: opts.openAngleDeg ?? 0,
564
- rotateDeg: opts.rotateDeg ?? 0
565
- });
566
-
567
- // stamp angles + x,y back onto pd (r stays your cumulative edge length)
568
- for (const d of pd) {
569
- d.angle = angleMap.get(d.thisId) ?? 0;
570
- d.x = d.r * Math.cos(d.angle);
571
- 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
+ }
572
596
  }
573
597
 
574
- data.data_pd = pd;
575
- 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);
576
602
 
577
- 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 };
578
612
  }
579
613
 
580
614
  /**