@euphrasiologist/lwphylo 1.2.24 → 1.3.0

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.cjs CHANGED
@@ -165,8 +165,8 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
165
165
  return `M ${p.x} ${p.y}`; // degenerate span → no arc
166
166
  }
167
167
 
168
- const largeArcFlag = delta > Math.PI ? 1 : 0; // should be 0 for “shortest”, but keep for safety
169
- const sweepFlag = 0; // CCW
168
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
169
+ const sweepFlag = 0; // CCW in our y-flipped coords: math-CCW = decreasing SVG angle = sweepFlag 0
170
170
 
171
171
  const p0 = polarToCartesian(cx, cy, radius, a0);
172
172
  const p1 = polarToCartesian(cx, cy, radius, a1);
@@ -175,15 +175,17 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
175
175
  }
176
176
 
177
177
  // src/radial/describeArcSweep.js
178
- function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
179
- console.log("describeArcSweep input:", {
180
- cx, cy, r,
181
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
182
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
183
- sweep,
184
- largeArcFlag
185
- });
186
-
178
+ // IMPORTANT: angles are in "math space" (increasing = CCW).
179
+ // Because we map y as (cy - r*sin(a)), our math angle t maps to SVG angle -t.
180
+ // Increasing t (math CCW) = decreasing SVG angle = sweepFlag 0 (negative direction).
181
+ // math CCW -> svg sweepFlag = 0
182
+ // math CW -> svg sweepFlag = 1
183
+ function describeArcSweep(
184
+ cx, cy, r,
185
+ a0, a1,
186
+ mathSweep = "ccw", // "ccw" | "cw"
187
+ largeArcFlag = 0
188
+ ) {
187
189
  if (!(r > 0)) return "";
188
190
 
189
191
  const x0 = cx + r * Math.cos(a0);
@@ -191,7 +193,9 @@ function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
191
193
  const x1 = cx + r * Math.cos(a1);
192
194
  const y1 = cy - r * Math.sin(a1);
193
195
 
194
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
196
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
197
+
198
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
195
199
  }
196
200
 
197
201
  /**
@@ -468,16 +472,13 @@ function getArcs(pd) {
468
472
  }
469
473
 
470
474
  /**
471
- * Build APE-like block arcs per internal parent:
472
- * radius = parent.r
473
- * start = first child's angle
474
- * end = last child's angle
475
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
476
- *
477
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
478
- * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
475
+ * APE-like block arcs per internal parent.
476
+ * Draw CCW from first child's angle to last child's angle (wrapping allowed).
479
477
  */
480
478
  function getArcsFan(pd) {
479
+ const TAU = Math.PI * 2;
480
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
481
+
481
482
  const byId = new Map(pd.map(d => [d.thisId, d]));
482
483
  const arcs = [];
483
484
 
@@ -489,9 +490,11 @@ function getArcsFan(pd) {
489
490
  const last = byId.get(c[c.length - 1])?.angle;
490
491
  if (first == null || last == null) continue;
491
492
 
492
- const start = first;
493
- const end = last;
494
- const sweep = end >= start ? 0 : 1; // CW if wrapped
493
+ const start = norm(first);
494
+ const end = norm(last);
495
+
496
+ const deltaCCW = (end - start + TAU) % TAU;
497
+ if (deltaCCW < 1e-9) continue;
495
498
 
496
499
  arcs.push({
497
500
  parentId: p.parentId,
@@ -499,9 +502,11 @@ function getArcsFan(pd) {
499
502
  radius: p.r,
500
503
  start,
501
504
  end,
502
- sweep
505
+ sweep: "ccw", // << math sweep
506
+ largeArc: deltaCCW > Math.PI ? 1 : 0
503
507
  });
504
508
  }
509
+
505
510
  return arcs;
506
511
  }
507
512
 
@@ -587,76 +592,6 @@ function getChildArcs(pd) {
587
592
  return arcs;
588
593
  }
589
594
 
590
- function getChildArcsFan(pd) {
591
- const TAU = Math.PI * 2;
592
- const norm = (t) => ((t % TAU) + TAU) % TAU;
593
-
594
- function midCCW(a, b) {
595
- const d = (b - a + TAU) % TAU;
596
- return norm(a + d / 2);
597
- }
598
-
599
- const key = (x) => (typeof x === "string" ? +x : x);
600
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
601
- const childrenByParent = new Map(
602
- pd.map(d => [
603
- key(d.thisId),
604
- (d.children || [])
605
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
606
- .map(key)
607
- .filter(id => byId.has(id))
608
- ])
609
- );
610
-
611
- const child_arcs = [];
612
-
613
- for (const parentRaw of pd) {
614
- const pid = key(parentRaw.thisId);
615
- const kids = childrenByParent.get(pid) || [];
616
- if (kids.length < 2) continue;
617
-
618
- const A = kids
619
- .map(id => {
620
- const node = byId.get(id);
621
- return node ? { id, a: norm(node.angle) } : null;
622
- })
623
- .filter(Boolean)
624
- .sort((u, v) => u.a - v.a);
625
-
626
- const N = A.length;
627
- if (N < 2) continue;
628
-
629
- const parent = byId.get(pid);
630
- const radius = parent?.r;
631
- if (!(radius > 0)) continue;
632
-
633
- for (let i = 0; i < N; i++) {
634
- const prev = A[(i - 1 + N) % N];
635
- const cur = A[i];
636
- const next = A[(i + 1) % N];
637
-
638
- const start = midCCW(prev.a, cur.a);
639
- const end = midCCW(cur.a, next.a);
640
-
641
- const sweep = 1; // always clockwise
642
- const delta = (end - start + TAU) % TAU;
643
- const largeArc = delta > Math.PI ? 1 : 0;
644
-
645
- child_arcs.push({
646
- parentId: pid,
647
- childId: cur.id,
648
- radius,
649
- start,
650
- end,
651
- sweep,
652
- largeArc
653
- });
654
- }
655
- }
656
-
657
- return child_arcs;
658
- }
659
-
660
595
  /**
661
596
  * radialLayout(node, opts?)
662
597
  * opts:
@@ -700,13 +635,8 @@ function radialLayout(node, opts = {}) {
700
635
  ? getArcsFan(pd)
701
636
  : getArcs(pd);
702
637
 
703
- // per-child arcs for half-arc highlighting if you already use them
704
- let child_arcs = [];
705
- if (arcsStyle === "fan") {
706
- child_arcs = getChildArcsFan(pd);
707
- } else {
708
- child_arcs = getChildArcs(pd);
709
- }
638
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
639
+ const child_arcs = getChildArcs(pd);
710
640
 
711
641
  return { data: pd, radii, arcs, child_arcs };
712
642
  }
@@ -1029,6 +959,85 @@ function parentFisheye(d, data) {
1029
959
  return parent ? { px: parent.fisheye.x, py: parent.fisheye.y } : null;
1030
960
  }
1031
961
 
962
+ /**
963
+ * Generate a random bifurcating tree with `nTips` tips, in the same
964
+ * parent/children node shape produced by readTree().
965
+ *
966
+ * Topology is grown by repeatedly picking a random extant lineage to split
967
+ * (a Yule/coalescent-style process), so internal branching order is random
968
+ * rather than a fixed balanced/caterpillar shape. Branch lengths are drawn
969
+ * uniformly from [0, maxBranchLength).
970
+ */
971
+
972
+ function randomTree(nTips = 10, {
973
+ maxBranchLength = 1,
974
+ labelPrefix = 't',
975
+ seed = null
976
+ } = {}) {
977
+ if (!Number.isInteger(nTips) || nTips < 1) {
978
+ throw new Error("nTips must be a positive integer");
979
+ }
980
+
981
+ const random = seed == null ? Math.random : mulberry32(seed);
982
+
983
+ let nodeId = 0;
984
+ const makeNode = (parent) => ({
985
+ parent,
986
+ children: [],
987
+ id: nodeId++,
988
+ label: '',
989
+ branchLength: null
990
+ });
991
+
992
+ const root = makeNode(null);
993
+
994
+ if (nTips === 1) {
995
+ root.label = `${labelPrefix}1`;
996
+ return root;
997
+ }
998
+
999
+ // start with two lineages hanging off the root
1000
+ let lineages = [makeNode(root), makeNode(root)];
1001
+ root.children.push(...lineages);
1002
+
1003
+ // repeatedly split a random lineage until we have nTips of them
1004
+ while (lineages.length < nTips) {
1005
+ const i = Math.floor(random() * lineages.length);
1006
+ const parent = lineages[i];
1007
+ const left = makeNode(parent);
1008
+ const right = makeNode(parent);
1009
+ parent.children.push(left, right);
1010
+ lineages.splice(i, 1, left, right);
1011
+ }
1012
+
1013
+ // assign branch lengths to every non-root node, and tip labels in
1014
+ // left-to-right order
1015
+ let tipIndex = 0;
1016
+ const assign = (node) => {
1017
+ for (const child of node.children) {
1018
+ child.branchLength = random() * maxBranchLength;
1019
+ assign(child);
1020
+ }
1021
+ if (node.children.length === 0) {
1022
+ node.label = `${labelPrefix}${++tipIndex}`;
1023
+ }
1024
+ };
1025
+ assign(root);
1026
+
1027
+ return root;
1028
+ }
1029
+
1030
+ // small deterministic PRNG so `seed` gives reproducible trees
1031
+ function mulberry32(seed) {
1032
+ let a = seed >>> 0;
1033
+ return function () {
1034
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
1035
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
1036
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
1037
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1038
+ };
1039
+ }
1040
+
1032
1041
  /**
1033
1042
  * Parse a Newick tree string into a doubly-linked list of JS Objects.
1034
1043
  * Assigns labels, branch lengths, and node IDs (tips before internals if input emits them that way).
@@ -1043,21 +1052,29 @@ function readTree(text) {
1043
1052
  text = String(text).replace(/\s+/g, '');
1044
1053
 
1045
1054
  const tokens = text.split(/(;|\(|\)|,)/);
1046
- const root = { parent: null, children: [] };
1047
- let curnode = root;
1048
1055
  let nodeId = 0;
1056
+ const makeNode = (parent) => ({
1057
+ parent,
1058
+ children: [],
1059
+ id: nodeId++,
1060
+ label: '',
1061
+ branchLength: null
1062
+ });
1063
+
1064
+ const root = makeNode(null);
1065
+ let curnode = root;
1049
1066
 
1050
1067
  for (const token of tokens) {
1051
1068
  if (!token || token === ';') continue;
1052
1069
 
1053
1070
  if (token === '(') {
1054
- const child = { parent: curnode, children: [] };
1071
+ const child = makeNode(curnode);
1055
1072
  curnode.children.push(child);
1056
1073
  curnode = child; // descend
1057
1074
  } else if (token === ',') {
1058
1075
  // back to parent, then create sibling
1059
1076
  curnode = curnode.parent;
1060
- const child = { parent: curnode, children: [] };
1077
+ const child = makeNode(curnode);
1061
1078
  curnode.children.push(child);
1062
1079
  curnode = child;
1063
1080
  } else if (token === ')') {
@@ -1066,14 +1083,15 @@ function readTree(text) {
1066
1083
  if (curnode === null) break;
1067
1084
  } else {
1068
1085
  // label/branch-length chunk (e.g., "A:0.01" or "A")
1086
+ // Note: nodes are assigned an id at creation (above), so internal
1087
+ // (clade) nodes that carry neither a label nor a branch length —
1088
+ // e.g. "((A,B),(C,D));" — still get a valid, linkable id here.
1069
1089
  const nodeinfo = token.split(':');
1070
1090
  if (nodeinfo.length === 1) {
1071
1091
  if (token.startsWith(':')) {
1072
- curnode.label = '';
1073
1092
  curnode.branchLength = parseFloat(nodeinfo[0]);
1074
1093
  } else {
1075
1094
  curnode.label = nodeinfo[0];
1076
- curnode.branchLength = null;
1077
1095
  }
1078
1096
  } else if (nodeinfo.length === 2) {
1079
1097
  curnode.label = nodeinfo[0];
@@ -1083,13 +1101,9 @@ function readTree(text) {
1083
1101
  curnode.label = nodeinfo[0] || '';
1084
1102
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
1085
1103
  }
1086
- curnode.id = nodeId++; // assign then increment
1087
1104
  }
1088
1105
  }
1089
1106
 
1090
- // Ensure root has an id if not assigned during parsing
1091
- if (root.id == null) root.id = nodeId;
1092
-
1093
1107
  return root;
1094
1108
  }
1095
1109
 
@@ -1149,6 +1163,7 @@ function drawPhylogeny(
1149
1163
  strokeWidth = 1, // for the phylogeny branches
1150
1164
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1151
1165
  tipLabels = true,
1166
+ labelFontSize = 10, // font size (px) for tip labels
1152
1167
  showTooltips = true,
1153
1168
  tooltipFormatter = (d, rtt) =>
1154
1169
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1196,7 +1211,7 @@ function drawPhylogeny(
1196
1211
  const tips = horizontal.filter((d) => d.isTip);
1197
1212
 
1198
1213
  // indices & root→tip getter
1199
- const byId = new Map(horizontal.map((d) => [d.thisId, d]));
1214
+ const byId = new Map(tree_df.data.map((d) => [d.thisId, d])); // includes root
1200
1215
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1201
1216
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1202
1217
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1270,6 +1285,7 @@ function drawPhylogeny(
1270
1285
  // interactive root→tip highlight (rect) on dot hover
1271
1286
  tipDots
1272
1287
  .on("mouseenter", function(_event, d) {
1288
+ hoverLayer.selectAll("*").remove();
1273
1289
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1274
1290
  d3__namespace.select(this).attr("r", 4);
1275
1291
  })
@@ -1289,7 +1305,7 @@ function drawPhylogeny(
1289
1305
  .attr("x", (d) => xScale(d.x1) + 4)
1290
1306
  .attr("y", (d) => yScale(d.y1))
1291
1307
  .attr("dy", "0.32em")
1292
- .attr("font-size", 10)
1308
+ .attr("font-size", labelFontSize)
1293
1309
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1294
1310
 
1295
1311
  if (showTooltips) {
@@ -1300,6 +1316,7 @@ function drawPhylogeny(
1300
1316
 
1301
1317
  labels
1302
1318
  .on("mouseenter", function(_event, d) {
1319
+ hoverLayer.selectAll("*").remove();
1303
1320
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1304
1321
  d3__namespace.select(this).attr("font-weight", 600);
1305
1322
  })
@@ -1326,7 +1343,6 @@ function drawPhylogeny(
1326
1343
 
1327
1344
  // helper to draw root→tip for rect (both vertical+horizontal)
1328
1345
  function drawRectPath(tipId, layer, stroke, width) {
1329
- layer.selectAll("*").remove();
1330
1346
  let cur = byId.get(tipId);
1331
1347
  while (cur && cur.parentId != null) {
1332
1348
  const parent = byId.get(cur.parentId);
@@ -1383,10 +1399,14 @@ function drawPhylogeny(
1383
1399
  const END_CAP = 0;
1384
1400
 
1385
1401
  // ===== SCALES / BOUNDS =====
1386
- const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1387
- const scaleRadial = maxRadius + 2 * radialMargin;
1388
1402
  const w = width,
1389
1403
  h = height;
1404
+ const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1405
+ // radialMargin is in pixels: tips sit (radialMargin) px from the SVG edge.
1406
+ // Derive the data-space scale so that radiusPx(maxRadius) = w/2 - radialMargin.
1407
+ const scaleRadial = maxRadius > 0
1408
+ ? maxRadius * (w / 2) / (w / 2 - radialMargin)
1409
+ : 1;
1390
1410
  const centerX = w / 2,
1391
1411
  centerY = h / 2;
1392
1412
 
@@ -1466,8 +1486,8 @@ function drawPhylogeny(
1466
1486
  radiusPx(d.radius),
1467
1487
  d.start,
1468
1488
  d.end,
1469
- d.sweep,
1470
- d.largeArc,
1489
+ d.sweep ?? "ccw",
1490
+ d.largeArc ?? 0,
1471
1491
  )
1472
1492
  )
1473
1493
  .attr("fill", "none")
@@ -1582,7 +1602,7 @@ function drawPhylogeny(
1582
1602
  .attr("x", xoff)
1583
1603
  .attr("alignment-baseline", "middle")
1584
1604
  .attr("text-anchor", anchor)
1585
- .attr("font-size", 10)
1605
+ .attr("font-size", labelFontSize)
1586
1606
  .attr("fill", "black")
1587
1607
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1588
1608
  });
@@ -1596,6 +1616,8 @@ function drawPhylogeny(
1596
1616
  // label hover
1597
1617
  labels
1598
1618
  .on("mouseenter", function(_event, d) {
1619
+ hoverLines.selectAll("*").remove();
1620
+ hoverArcs.selectAll("*").remove();
1599
1621
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1600
1622
  d3__namespace.select(this).select("text").attr("font-weight", 600);
1601
1623
  })
@@ -1615,9 +1637,6 @@ function drawPhylogeny(
1615
1637
  width = 3
1616
1638
  ) {
1617
1639
  // target may be a tip node *or* a numeric tip id
1618
- lineLayer.selectAll("*").remove();
1619
- arcLayer.selectAll("*").remove();
1620
-
1621
1640
  let cur = (typeof target === "number" || typeof target === "string")
1622
1641
  ? byId.get(target)
1623
1642
  : target;
@@ -1656,18 +1675,10 @@ function drawPhylogeny(
1656
1675
  const R = radiusPx(rec.radius);
1657
1676
  return rec.sweep == null
1658
1677
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1659
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep, a.largeArc);
1678
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1660
1679
  }
1661
1680
 
1662
1681
  if (a) {
1663
- console.log("Drawing arc:", {
1664
- childId: cur.thisId,
1665
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1666
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1667
- sweep: a.sweep,
1668
- radius: a.radius
1669
- });
1670
-
1671
1682
  arcLayer
1672
1683
  .append("path")
1673
1684
  .attr("d", pathFromArcRecord(a))
@@ -1684,6 +1695,8 @@ function drawPhylogeny(
1684
1695
  // tip dot hover
1685
1696
  tipDots
1686
1697
  .on("mouseenter", function(_event, d) {
1698
+ hoverLines.selectAll("*").remove();
1699
+ hoverArcs.selectAll("*").remove();
1687
1700
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1688
1701
  d3__namespace.select(this).attr("r", DOT_R + 2);
1689
1702
  })
@@ -1840,7 +1853,7 @@ function drawPhylogeny(
1840
1853
  .attr("x", xOffset)
1841
1854
  .attr("alignment-baseline", "middle")
1842
1855
  .attr("text-anchor", anchor)
1843
- .attr("font-size", 10)
1856
+ .attr("font-size", labelFontSize)
1844
1857
  .attr("fill", "black")
1845
1858
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1846
1859
  });
@@ -1928,6 +1941,7 @@ exports.parentFisheye = parentFisheye;
1928
1941
  exports.phisheye = phisheye;
1929
1942
  exports.polarToCartesian = polarToCartesian;
1930
1943
  exports.radialLayout = radialLayout;
1944
+ exports.randomTree = randomTree;
1931
1945
  exports.readTree = readTree;
1932
1946
  exports.rectangleLayout = rectangleLayout;
1933
1947
  exports.subTree = subTree;