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