@euphrasiologist/lwphylo 1.2.7 → 1.2.9

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.
@@ -238,6 +238,37 @@ function getRadii(node) {
238
238
  return segments;
239
239
  }
240
240
 
241
+ // src/radial/getRadiiFromPd.js
242
+ /**
243
+ * Build per-edge spokes using the *current* pd angles/r.
244
+ * Output: [{ parentId, childId, x0,y0,x1,y1, isTip }]
245
+ */
246
+ function getRadiiFromPd(pd) {
247
+ const byId = new Map(pd.map(d => [d.thisId, d]));
248
+ const root = pd.find(d => d.parentId == null)?.thisId;
249
+
250
+ const segments = [];
251
+ for (const d of pd) {
252
+ if (d.thisId === root) continue;
253
+ const parent = byId.get(d.parentId);
254
+ if (!parent) continue;
255
+
256
+ const theta = d.angle;
257
+ const r0 = parent.r, r1 = d.r;
258
+
259
+ segments.push({
260
+ parentId: parent.thisId,
261
+ childId: d.thisId,
262
+ x0: r0 * Math.cos(theta),
263
+ y0: r0 * Math.sin(theta),
264
+ x1: r1 * Math.cos(theta),
265
+ y1: r1 * Math.sin(theta),
266
+ isTip: !!d.isTip
267
+ });
268
+ }
269
+ return segments;
270
+ }
271
+
241
272
  /**
242
273
  * Build arc descriptors for each internal parent:
243
274
  * - One arc per internal node at radius = parent.r
@@ -304,41 +335,48 @@ function getArcs(pd) {
304
335
  }
305
336
 
306
337
  /**
307
- * Per-child "half" arcs for radial trees.
308
- *
309
- * For each non-root node (child), emit an arc at the PARENT's radius that
310
- * spans between the parent's angle and the child's angle. This is the arc
311
- * segment that meets the child's spoke and is ideal for root→tip highlighting.
338
+ * Build APE-like block arcs per internal parent:
339
+ * radius = parent.r
340
+ * start = first child's angle
341
+ * end = last child's angle
342
+ * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across
312
343
  *
313
- * Input: pd the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
314
- * Output: [{ parentId, childId, radius, start, end }]
344
+ * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
345
+ * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
315
346
  */
316
- function getChildArcs(pd) {
347
+ function getArcsFan(pd) {
317
348
  const byId = new Map(pd.map(d => [d.thisId, d]));
318
349
  const arcs = [];
319
350
 
320
- for (const child of pd) {
321
- if (child.parentId == null) continue; // skip root
322
- const parent = byId.get(child.parentId);
323
- if (!parent) continue;
351
+ for (const p of pd) {
352
+ const c = p.children || [];
353
+ if (c.length < 2 || !(p.r > 0)) continue;
354
+
355
+ const first = byId.get(c[0])?.angle;
356
+ const last = byId.get(c[c.length - 1])?.angle;
357
+ if (first == null || last == null) continue;
358
+
359
+ const start = first;
360
+ const end = last;
361
+ const sweep = end >= start ? 0 : 1; // CW if wrapped
324
362
 
325
363
  arcs.push({
326
- parentId: parent.thisId,
327
- childId: child.thisId,
328
- radius: parent.r, // draw on the parent's circle
329
- start: parent.angle, // start at parent's angle
330
- end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
364
+ parentId: p.parentId,
365
+ thisId: p.thisId,
366
+ radius: p.r,
367
+ start,
368
+ end,
369
+ sweep
331
370
  });
332
371
  }
333
-
334
372
  return arcs;
335
373
  }
336
374
 
337
- // fanAngles.js
375
+ // APE-like "fan" angles: tips evenly spaced with open-angle gap & rotation,
376
+ // internal nodes = arithmetic mean of unwrapped child angles.
338
377
  const TAU = Math.PI * 2;
339
378
  const norm = (t) => ((t % TAU) + TAU) % TAU;
340
379
 
341
- // Unwrap angles around a reference so they sit within [ref-π, ref+π]
342
380
  function unwrapAround(ref, a) {
343
381
  let x = a;
344
382
  while (x < ref - Math.PI) x += TAU;
@@ -346,26 +384,17 @@ function unwrapAround(ref, a) {
346
384
  return x;
347
385
  }
348
386
 
349
- /**
350
- * Compute APE "fan" compatible angles:
351
- * - Tips evenly spaced over [0, span] where span = 2π*(1 - 1/Ntip) - gap
352
- * - Then + rotate (radians)
353
- * - Internal nodes = arithmetic mean of child angles (unwrapped)
354
- *
355
- * pd: fortified nodes array (has thisId, parentId, children[])
356
- * opts: { openAngleDeg=0, rotateDeg=0 }
357
- * returns: Map(nodeId -> angle)
358
- */
359
387
  function fanAngles(pd, opts = {}) {
360
388
  const { openAngleDeg = 0, rotateDeg = 0 } = opts;
361
389
  const gap = (openAngleDeg / 360) * TAU;
362
- const rotate = (rotateDeg / 360) * TAU;
390
+ const rot = (rotateDeg / 360) * TAU;
363
391
 
364
- // Find root and collect tips in cladewise/DFS order
392
+ // root + children index
365
393
  let root = null;
366
- const kids = new Map(pd.map(d => [d.thisId, d.children || []]));
394
+ const kids = new Map(pd.map((d) => [d.thisId, d.children || []]));
367
395
  for (const d of pd) if (d.parentId == null) { root = d.thisId; break; }
368
396
 
397
+ // tip order (DFS left→right like your fortify)
369
398
  const tipIds = [];
370
399
  (function dfs(id) {
371
400
  const c = kids.get(id) || [];
@@ -374,103 +403,106 @@ function fanAngles(pd, opts = {}) {
374
403
  })(root);
375
404
 
376
405
  const N = Math.max(1, tipIds.length);
377
- // APE: 0 .. 2π*(1 - 1/N) - gap, length.out=N (no last step overlap)
378
- const maxA = TAU * (1 - 1 / N) - gap;
406
+ const maxA = TAU * (1 - 1 / N) - gap; // note: no last-step overlap
379
407
  const step = N > 1 ? maxA / (N - 1) : 0;
380
408
 
381
409
  const angle = new Map();
382
- tipIds.forEach((id, i) => {
383
- angle.set(id, norm(i * step + rotate));
384
- });
410
+ tipIds.forEach((id, i) => angle.set(id, norm(i * step + rot)));
385
411
 
386
- // Internal nodes: arithmetic mean of child angles (unwrapped)
412
+ // internal nodes: arithmetic mean of child angles (unwrapped)
387
413
  (function setInternal(id) {
388
414
  const c = kids.get(id) || [];
389
415
  for (const ch of c) setInternal(ch);
390
- if (c.length > 0) {
391
- // unwrap child angles around the first child's angle
416
+ if (c.length) {
392
417
  const a0 = angle.get(c[0]);
393
- const unwrapped = c.map(ch => unwrapAround(a0, angle.get(ch)));
394
- const mean = unwrapped.reduce((s, v) => s + v, 0) / unwrapped.length;
395
- angle.set(id, norm(mean));
418
+ const arr = c.map((ch) => unwrapAround(a0, angle.get(ch)));
419
+ angle.set(id, norm(arr.reduce((s, v) => s + v, 0) / arr.length));
396
420
  }
397
421
  })(root);
398
422
 
399
423
  return angle;
400
424
  }
401
425
 
402
- // getArcsFan.js
403
-
404
426
  /**
405
- * Build arcs like APE's circular.plot:
406
- * For each internal parent, draw a single arc at radius=parent.r
407
- * going from first child's angle to last child's angle in child order.
408
- * If last < first (wrap), we draw CW (decreasing) to stay on the block.
427
+ * Per-child "half" arcs for radial trees.
428
+ *
429
+ * For each non-root node (child), emit an arc at the PARENT's radius that
430
+ * spans between the parent's angle and the child's angle. This is the arc
431
+ * segment that meets the child's spoke and is ideal for root→tip highlighting.
409
432
  *
410
- * pd: array with { thisId, parentId, r, children[], angle }
411
- * returns: [{ parentId, thisId, radius, start, end, sweep }]
412
- * where sweep=0 means CCW (start→end increasing),
413
- * sweep=1 means CW (start→end decreasing across wrap).
433
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
434
+ * Output: [{ parentId, childId, radius, start, end }]
414
435
  */
415
- function getArcsFan(pd) {
436
+ function getChildArcs(pd) {
416
437
  const byId = new Map(pd.map(d => [d.thisId, d]));
417
438
  const arcs = [];
418
439
 
419
- for (const p of pd) {
420
- const c = p.children || [];
421
- if (c.length < 2) continue;
422
- const A = c.map(id => byId.get(id)?.angle).filter(a => a != null);
423
- if (A.length < 2 || !isFinite(p.r) || p.r <= 0) continue;
424
-
425
- // Children are contiguous in tip order; take first and last
426
- let start = A[0];
427
- let end = A[A.length - 1];
428
-
429
- // Decide direction like APE’s seq(start, end):
430
- // if end >= start → CCW; else CW across wrap.
431
- const sweep = end >= start ? 0 : 1;
440
+ for (const child of pd) {
441
+ if (child.parentId == null) continue; // skip root
442
+ const parent = byId.get(child.parentId);
443
+ if (!parent) continue;
432
444
 
433
445
  arcs.push({
434
- parentId: p.parentId,
435
- thisId: p.thisId,
436
- radius: p.r,
437
- start, end, sweep
446
+ parentId: parent.thisId,
447
+ childId: child.thisId,
448
+ radius: parent.r, // draw on the parent's circle
449
+ start: parent.angle, // start at parent's angle
450
+ end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
438
451
  });
439
452
  }
453
+
440
454
  return arcs;
441
455
  }
442
456
 
457
+ // src/radial/radialLayout.js
458
+
443
459
  /**
444
- * Simple wrapper for radial layout:
445
- * - data: per-node { angle, r, x, y, ... }
446
- * - radii: per-edge radial spokes (parent.r child.r)
447
- * - arcs: per-parent arcs spanning all children at parent's radius
448
- * - child_arcs: per-child half-arcs (parent.angle child.angle) at parent's radius
460
+ * radialLayout(node, opts?)
461
+ * opts:
462
+ * - angleStrategy: "cmean" (default, your current) | "fan" (APE-like)
463
+ * - arcsStyle: "shortest" (default) | "fan" (block arcs, supports wrap)
464
+ * - openAngleDeg: number (gap wedge for "fan" angles)
465
+ * - rotateDeg: number (rotation for "fan" angles)
449
466
  */
450
- function radialLayout(node, opts = {}) {
451
- const data = {};
452
- data.data = radialData(node);
453
- data.radii = getRadii(node);
454
- data.arcs = getArcs(data.data);
455
- data.child_arcs = getChildArcs(data.data);
456
-
457
- const pd = fortify(node, true);
458
- const angleMap = fanAngles(pd, {
459
- openAngleDeg: opts.openAngleDeg ?? 0,
460
- rotateDeg: opts.rotateDeg ?? 0
461
- });
462
-
463
- // stamp angles + x,y back onto pd (r stays your cumulative edge length)
464
- for (const d of pd) {
465
- d.angle = angleMap.get(d.thisId) ?? 0;
466
- d.x = d.r * Math.cos(d.angle);
467
- d.y = d.r * Math.sin(d.angle);
467
+ function radialLayout(node, opts = {}) {
468
+ const {
469
+ // default fan
470
+ angleStrategy = "fan",
471
+ arcsStyle = "fan",
472
+ openAngleDeg = 0,
473
+ rotateDeg = 0
474
+ } = opts;
475
+
476
+ // Start with your current enriched nodes (angle + r from radialData)
477
+ const pd = radialData(node);
478
+
479
+ if (angleStrategy === "fan") {
480
+ // overwrite angles with APE-like fan angles; keep radii r as-is
481
+ const angleMap = fanAngles(pd, { openAngleDeg, rotateDeg });
482
+ for (const d of pd) {
483
+ const a = angleMap.get(d.thisId);
484
+ if (a != null) {
485
+ d.angle = a;
486
+ d.x = d.r * Math.cos(a);
487
+ d.y = d.r * Math.sin(a);
488
+ }
489
+ }
468
490
  }
469
491
 
470
- data.data_pd = pd;
471
- data.arcs_fan = getArcsFan(pd);
492
+ // Build spokes using the angles currently on pd
493
+ const radii = (angleStrategy === "fan")
494
+ ? getRadiiFromPd(pd)
495
+ : getRadii(node);
472
496
 
473
- return data;
497
+ // Choose arc builder
498
+ const arcs = (arcsStyle === "fan")
499
+ ? getArcsFan(pd)
500
+ : getArcs(pd);
501
+
502
+ // per-child arcs for half-arc highlighting if you already use them
503
+ const child_arcs = getChildArcs(pd);
504
+
505
+ return { data: pd, radii, arcs, child_arcs };
474
506
  }
475
507
 
476
508
  /**