@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/drawPhylogeny.cjs +129 -98
- package/dist/drawPhylogeny.cjs.map +1 -1
- package/dist/drawPhylogeny.esm.js +129 -98
- package/dist/drawPhylogeny.esm.js.map +1 -1
- package/dist/drawPhylogeny.umd.js +1 -1
- package/dist/drawPhylogeny.umd.js.map +1 -1
- package/dist/index.cjs +145 -98
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +145 -99
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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 2π
|
|
291
322
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
323
|
+
* @param {Array} pd nodes with {thisId,parentId,children,angle,r}
|
|
324
|
+
* @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
|
|
294
325
|
*/
|
|
295
|
-
function
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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:
|
|
306
|
-
|
|
307
|
-
radius:
|
|
308
|
-
start
|
|
309
|
-
end
|
|
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
|
-
//
|
|
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
|
|
369
|
+
const rot = (rotateDeg / 360) * TAU;
|
|
342
370
|
|
|
343
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
370
|
-
// unwrap child angles around the first child's angle
|
|
395
|
+
if (c.length) {
|
|
371
396
|
const a0 = angle.get(c[0]);
|
|
372
|
-
const
|
|
373
|
-
|
|
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
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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
|
|
390
|
-
*
|
|
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
|
|
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
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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:
|
|
414
|
-
|
|
415
|
-
radius:
|
|
416
|
-
start
|
|
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
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
450
|
-
|
|
470
|
+
// Build spokes using the angles currently on pd
|
|
471
|
+
const radii = (angleStrategy === "fan")
|
|
472
|
+
? getRadiiFromPd(pd)
|
|
473
|
+
: getRadii(node);
|
|
451
474
|
|
|
452
|
-
|
|
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
|
/**
|