@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.js CHANGED
@@ -144,8 +144,8 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
144
144
  return `M ${p.x} ${p.y}`; // degenerate span → no arc
145
145
  }
146
146
 
147
- const largeArcFlag = delta > Math.PI ? 1 : 0; // should be 0 for “shortest”, but keep for safety
148
- const sweepFlag = 0; // CCW
147
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
148
+ const sweepFlag = 0; // CCW in our y-flipped coords: math-CCW = decreasing SVG angle = sweepFlag 0
149
149
 
150
150
  const p0 = polarToCartesian(cx, cy, radius, a0);
151
151
  const p1 = polarToCartesian(cx, cy, radius, a1);
@@ -154,15 +154,17 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
154
154
  }
155
155
 
156
156
  // src/radial/describeArcSweep.js
157
- function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
158
- console.log("describeArcSweep input:", {
159
- cx, cy, r,
160
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
161
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
162
- sweep,
163
- largeArcFlag
164
- });
165
-
157
+ // IMPORTANT: angles are in "math space" (increasing = CCW).
158
+ // Because we map y as (cy - r*sin(a)), our math angle t maps to SVG angle -t.
159
+ // Increasing t (math CCW) = decreasing SVG angle = sweepFlag 0 (negative direction).
160
+ // math CCW -> svg sweepFlag = 0
161
+ // math CW -> svg sweepFlag = 1
162
+ function describeArcSweep(
163
+ cx, cy, r,
164
+ a0, a1,
165
+ mathSweep = "ccw", // "ccw" | "cw"
166
+ largeArcFlag = 0
167
+ ) {
166
168
  if (!(r > 0)) return "";
167
169
 
168
170
  const x0 = cx + r * Math.cos(a0);
@@ -170,7 +172,9 @@ function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
170
172
  const x1 = cx + r * Math.cos(a1);
171
173
  const y1 = cy - r * Math.sin(a1);
172
174
 
173
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
175
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
176
+
177
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
174
178
  }
175
179
 
176
180
  /**
@@ -447,16 +451,13 @@ function getArcs(pd) {
447
451
  }
448
452
 
449
453
  /**
450
- * Build APE-like block arcs per internal parent:
451
- * radius = parent.r
452
- * start = first child's angle
453
- * end = last child's angle
454
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
455
- *
456
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
457
- * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
454
+ * APE-like block arcs per internal parent.
455
+ * Draw CCW from first child's angle to last child's angle (wrapping allowed).
458
456
  */
459
457
  function getArcsFan(pd) {
458
+ const TAU = Math.PI * 2;
459
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
460
+
460
461
  const byId = new Map(pd.map(d => [d.thisId, d]));
461
462
  const arcs = [];
462
463
 
@@ -468,9 +469,11 @@ function getArcsFan(pd) {
468
469
  const last = byId.get(c[c.length - 1])?.angle;
469
470
  if (first == null || last == null) continue;
470
471
 
471
- const start = first;
472
- const end = last;
473
- const sweep = end >= start ? 0 : 1; // CW if wrapped
472
+ const start = norm(first);
473
+ const end = norm(last);
474
+
475
+ const deltaCCW = (end - start + TAU) % TAU;
476
+ if (deltaCCW < 1e-9) continue;
474
477
 
475
478
  arcs.push({
476
479
  parentId: p.parentId,
@@ -478,9 +481,11 @@ function getArcsFan(pd) {
478
481
  radius: p.r,
479
482
  start,
480
483
  end,
481
- sweep
484
+ sweep: "ccw", // << math sweep
485
+ largeArc: deltaCCW > Math.PI ? 1 : 0
482
486
  });
483
487
  }
488
+
484
489
  return arcs;
485
490
  }
486
491
 
@@ -566,76 +571,6 @@ function getChildArcs(pd) {
566
571
  return arcs;
567
572
  }
568
573
 
569
- function getChildArcsFan(pd) {
570
- const TAU = Math.PI * 2;
571
- const norm = (t) => ((t % TAU) + TAU) % TAU;
572
-
573
- function midCCW(a, b) {
574
- const d = (b - a + TAU) % TAU;
575
- return norm(a + d / 2);
576
- }
577
-
578
- const key = (x) => (typeof x === "string" ? +x : x);
579
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
580
- const childrenByParent = new Map(
581
- pd.map(d => [
582
- key(d.thisId),
583
- (d.children || [])
584
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
585
- .map(key)
586
- .filter(id => byId.has(id))
587
- ])
588
- );
589
-
590
- const child_arcs = [];
591
-
592
- for (const parentRaw of pd) {
593
- const pid = key(parentRaw.thisId);
594
- const kids = childrenByParent.get(pid) || [];
595
- if (kids.length < 2) continue;
596
-
597
- const A = kids
598
- .map(id => {
599
- const node = byId.get(id);
600
- return node ? { id, a: norm(node.angle) } : null;
601
- })
602
- .filter(Boolean)
603
- .sort((u, v) => u.a - v.a);
604
-
605
- const N = A.length;
606
- if (N < 2) continue;
607
-
608
- const parent = byId.get(pid);
609
- const radius = parent?.r;
610
- if (!(radius > 0)) continue;
611
-
612
- for (let i = 0; i < N; i++) {
613
- const prev = A[(i - 1 + N) % N];
614
- const cur = A[i];
615
- const next = A[(i + 1) % N];
616
-
617
- const start = midCCW(prev.a, cur.a);
618
- const end = midCCW(cur.a, next.a);
619
-
620
- const sweep = 1; // always clockwise
621
- const delta = (end - start + TAU) % TAU;
622
- const largeArc = delta > Math.PI ? 1 : 0;
623
-
624
- child_arcs.push({
625
- parentId: pid,
626
- childId: cur.id,
627
- radius,
628
- start,
629
- end,
630
- sweep,
631
- largeArc
632
- });
633
- }
634
- }
635
-
636
- return child_arcs;
637
- }
638
-
639
574
  /**
640
575
  * radialLayout(node, opts?)
641
576
  * opts:
@@ -679,13 +614,8 @@ function radialLayout(node, opts = {}) {
679
614
  ? getArcsFan(pd)
680
615
  : getArcs(pd);
681
616
 
682
- // per-child arcs for half-arc highlighting if you already use them
683
- let child_arcs = [];
684
- if (arcsStyle === "fan") {
685
- child_arcs = getChildArcsFan(pd);
686
- } else {
687
- child_arcs = getChildArcs(pd);
688
- }
617
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
618
+ const child_arcs = getChildArcs(pd);
689
619
 
690
620
  return { data: pd, radii, arcs, child_arcs };
691
621
  }
@@ -1008,6 +938,85 @@ function parentFisheye(d, data) {
1008
938
  return parent ? { px: parent.fisheye.x, py: parent.fisheye.y } : null;
1009
939
  }
1010
940
 
941
+ /**
942
+ * Generate a random bifurcating tree with `nTips` tips, in the same
943
+ * parent/children node shape produced by readTree().
944
+ *
945
+ * Topology is grown by repeatedly picking a random extant lineage to split
946
+ * (a Yule/coalescent-style process), so internal branching order is random
947
+ * rather than a fixed balanced/caterpillar shape. Branch lengths are drawn
948
+ * uniformly from [0, maxBranchLength).
949
+ */
950
+
951
+ function randomTree(nTips = 10, {
952
+ maxBranchLength = 1,
953
+ labelPrefix = 't',
954
+ seed = null
955
+ } = {}) {
956
+ if (!Number.isInteger(nTips) || nTips < 1) {
957
+ throw new Error("nTips must be a positive integer");
958
+ }
959
+
960
+ const random = seed == null ? Math.random : mulberry32(seed);
961
+
962
+ let nodeId = 0;
963
+ const makeNode = (parent) => ({
964
+ parent,
965
+ children: [],
966
+ id: nodeId++,
967
+ label: '',
968
+ branchLength: null
969
+ });
970
+
971
+ const root = makeNode(null);
972
+
973
+ if (nTips === 1) {
974
+ root.label = `${labelPrefix}1`;
975
+ return root;
976
+ }
977
+
978
+ // start with two lineages hanging off the root
979
+ let lineages = [makeNode(root), makeNode(root)];
980
+ root.children.push(...lineages);
981
+
982
+ // repeatedly split a random lineage until we have nTips of them
983
+ while (lineages.length < nTips) {
984
+ const i = Math.floor(random() * lineages.length);
985
+ const parent = lineages[i];
986
+ const left = makeNode(parent);
987
+ const right = makeNode(parent);
988
+ parent.children.push(left, right);
989
+ lineages.splice(i, 1, left, right);
990
+ }
991
+
992
+ // assign branch lengths to every non-root node, and tip labels in
993
+ // left-to-right order
994
+ let tipIndex = 0;
995
+ const assign = (node) => {
996
+ for (const child of node.children) {
997
+ child.branchLength = random() * maxBranchLength;
998
+ assign(child);
999
+ }
1000
+ if (node.children.length === 0) {
1001
+ node.label = `${labelPrefix}${++tipIndex}`;
1002
+ }
1003
+ };
1004
+ assign(root);
1005
+
1006
+ return root;
1007
+ }
1008
+
1009
+ // small deterministic PRNG so `seed` gives reproducible trees
1010
+ function mulberry32(seed) {
1011
+ let a = seed >>> 0;
1012
+ return function () {
1013
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
1014
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
1015
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
1016
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1017
+ };
1018
+ }
1019
+
1011
1020
  /**
1012
1021
  * Parse a Newick tree string into a doubly-linked list of JS Objects.
1013
1022
  * Assigns labels, branch lengths, and node IDs (tips before internals if input emits them that way).
@@ -1022,21 +1031,29 @@ function readTree(text) {
1022
1031
  text = String(text).replace(/\s+/g, '');
1023
1032
 
1024
1033
  const tokens = text.split(/(;|\(|\)|,)/);
1025
- const root = { parent: null, children: [] };
1026
- let curnode = root;
1027
1034
  let nodeId = 0;
1035
+ const makeNode = (parent) => ({
1036
+ parent,
1037
+ children: [],
1038
+ id: nodeId++,
1039
+ label: '',
1040
+ branchLength: null
1041
+ });
1042
+
1043
+ const root = makeNode(null);
1044
+ let curnode = root;
1028
1045
 
1029
1046
  for (const token of tokens) {
1030
1047
  if (!token || token === ';') continue;
1031
1048
 
1032
1049
  if (token === '(') {
1033
- const child = { parent: curnode, children: [] };
1050
+ const child = makeNode(curnode);
1034
1051
  curnode.children.push(child);
1035
1052
  curnode = child; // descend
1036
1053
  } else if (token === ',') {
1037
1054
  // back to parent, then create sibling
1038
1055
  curnode = curnode.parent;
1039
- const child = { parent: curnode, children: [] };
1056
+ const child = makeNode(curnode);
1040
1057
  curnode.children.push(child);
1041
1058
  curnode = child;
1042
1059
  } else if (token === ')') {
@@ -1045,14 +1062,15 @@ function readTree(text) {
1045
1062
  if (curnode === null) break;
1046
1063
  } else {
1047
1064
  // label/branch-length chunk (e.g., "A:0.01" or "A")
1065
+ // Note: nodes are assigned an id at creation (above), so internal
1066
+ // (clade) nodes that carry neither a label nor a branch length —
1067
+ // e.g. "((A,B),(C,D));" — still get a valid, linkable id here.
1048
1068
  const nodeinfo = token.split(':');
1049
1069
  if (nodeinfo.length === 1) {
1050
1070
  if (token.startsWith(':')) {
1051
- curnode.label = '';
1052
1071
  curnode.branchLength = parseFloat(nodeinfo[0]);
1053
1072
  } else {
1054
1073
  curnode.label = nodeinfo[0];
1055
- curnode.branchLength = null;
1056
1074
  }
1057
1075
  } else if (nodeinfo.length === 2) {
1058
1076
  curnode.label = nodeinfo[0];
@@ -1062,13 +1080,9 @@ function readTree(text) {
1062
1080
  curnode.label = nodeinfo[0] || '';
1063
1081
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
1064
1082
  }
1065
- curnode.id = nodeId++; // assign then increment
1066
1083
  }
1067
1084
  }
1068
1085
 
1069
- // Ensure root has an id if not assigned during parsing
1070
- if (root.id == null) root.id = nodeId;
1071
-
1072
1086
  return root;
1073
1087
  }
1074
1088
 
@@ -1128,6 +1142,7 @@ function drawPhylogeny(
1128
1142
  strokeWidth = 1, // for the phylogeny branches
1129
1143
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1130
1144
  tipLabels = true,
1145
+ labelFontSize = 10, // font size (px) for tip labels
1131
1146
  showTooltips = true,
1132
1147
  tooltipFormatter = (d, rtt) =>
1133
1148
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1175,7 +1190,7 @@ function drawPhylogeny(
1175
1190
  const tips = horizontal.filter((d) => d.isTip);
1176
1191
 
1177
1192
  // indices & root→tip getter
1178
- const byId = new Map(horizontal.map((d) => [d.thisId, d]));
1193
+ const byId = new Map(tree_df.data.map((d) => [d.thisId, d])); // includes root
1179
1194
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1180
1195
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1181
1196
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1249,6 +1264,7 @@ function drawPhylogeny(
1249
1264
  // interactive root→tip highlight (rect) on dot hover
1250
1265
  tipDots
1251
1266
  .on("mouseenter", function(_event, d) {
1267
+ hoverLayer.selectAll("*").remove();
1252
1268
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1253
1269
  d3.select(this).attr("r", 4);
1254
1270
  })
@@ -1268,7 +1284,7 @@ function drawPhylogeny(
1268
1284
  .attr("x", (d) => xScale(d.x1) + 4)
1269
1285
  .attr("y", (d) => yScale(d.y1))
1270
1286
  .attr("dy", "0.32em")
1271
- .attr("font-size", 10)
1287
+ .attr("font-size", labelFontSize)
1272
1288
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1273
1289
 
1274
1290
  if (showTooltips) {
@@ -1279,6 +1295,7 @@ function drawPhylogeny(
1279
1295
 
1280
1296
  labels
1281
1297
  .on("mouseenter", function(_event, d) {
1298
+ hoverLayer.selectAll("*").remove();
1282
1299
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1283
1300
  d3.select(this).attr("font-weight", 600);
1284
1301
  })
@@ -1305,7 +1322,6 @@ function drawPhylogeny(
1305
1322
 
1306
1323
  // helper to draw root→tip for rect (both vertical+horizontal)
1307
1324
  function drawRectPath(tipId, layer, stroke, width) {
1308
- layer.selectAll("*").remove();
1309
1325
  let cur = byId.get(tipId);
1310
1326
  while (cur && cur.parentId != null) {
1311
1327
  const parent = byId.get(cur.parentId);
@@ -1362,10 +1378,14 @@ function drawPhylogeny(
1362
1378
  const END_CAP = 0;
1363
1379
 
1364
1380
  // ===== SCALES / BOUNDS =====
1365
- const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
1366
- const scaleRadial = maxRadius + 2 * radialMargin;
1367
1381
  const w = width,
1368
1382
  h = height;
1383
+ const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
1384
+ // radialMargin is in pixels: tips sit (radialMargin) px from the SVG edge.
1385
+ // Derive the data-space scale so that radiusPx(maxRadius) = w/2 - radialMargin.
1386
+ const scaleRadial = maxRadius > 0
1387
+ ? maxRadius * (w / 2) / (w / 2 - radialMargin)
1388
+ : 1;
1369
1389
  const centerX = w / 2,
1370
1390
  centerY = h / 2;
1371
1391
 
@@ -1445,8 +1465,8 @@ function drawPhylogeny(
1445
1465
  radiusPx(d.radius),
1446
1466
  d.start,
1447
1467
  d.end,
1448
- d.sweep,
1449
- d.largeArc,
1468
+ d.sweep ?? "ccw",
1469
+ d.largeArc ?? 0,
1450
1470
  )
1451
1471
  )
1452
1472
  .attr("fill", "none")
@@ -1561,7 +1581,7 @@ function drawPhylogeny(
1561
1581
  .attr("x", xoff)
1562
1582
  .attr("alignment-baseline", "middle")
1563
1583
  .attr("text-anchor", anchor)
1564
- .attr("font-size", 10)
1584
+ .attr("font-size", labelFontSize)
1565
1585
  .attr("fill", "black")
1566
1586
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1567
1587
  });
@@ -1575,6 +1595,8 @@ function drawPhylogeny(
1575
1595
  // label hover
1576
1596
  labels
1577
1597
  .on("mouseenter", function(_event, d) {
1598
+ hoverLines.selectAll("*").remove();
1599
+ hoverArcs.selectAll("*").remove();
1578
1600
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1579
1601
  d3.select(this).select("text").attr("font-weight", 600);
1580
1602
  })
@@ -1594,9 +1616,6 @@ function drawPhylogeny(
1594
1616
  width = 3
1595
1617
  ) {
1596
1618
  // target may be a tip node *or* a numeric tip id
1597
- lineLayer.selectAll("*").remove();
1598
- arcLayer.selectAll("*").remove();
1599
-
1600
1619
  let cur = (typeof target === "number" || typeof target === "string")
1601
1620
  ? byId.get(target)
1602
1621
  : target;
@@ -1635,18 +1654,10 @@ function drawPhylogeny(
1635
1654
  const R = radiusPx(rec.radius);
1636
1655
  return rec.sweep == null
1637
1656
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1638
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep, a.largeArc);
1657
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1639
1658
  }
1640
1659
 
1641
1660
  if (a) {
1642
- console.log("Drawing arc:", {
1643
- childId: cur.thisId,
1644
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1645
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1646
- sweep: a.sweep,
1647
- radius: a.radius
1648
- });
1649
-
1650
1661
  arcLayer
1651
1662
  .append("path")
1652
1663
  .attr("d", pathFromArcRecord(a))
@@ -1663,6 +1674,8 @@ function drawPhylogeny(
1663
1674
  // tip dot hover
1664
1675
  tipDots
1665
1676
  .on("mouseenter", function(_event, d) {
1677
+ hoverLines.selectAll("*").remove();
1678
+ hoverArcs.selectAll("*").remove();
1666
1679
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1667
1680
  d3.select(this).attr("r", DOT_R + 2);
1668
1681
  })
@@ -1819,7 +1832,7 @@ function drawPhylogeny(
1819
1832
  .attr("x", xOffset)
1820
1833
  .attr("alignment-baseline", "middle")
1821
1834
  .attr("text-anchor", anchor)
1822
- .attr("font-size", 10)
1835
+ .attr("font-size", labelFontSize)
1823
1836
  .attr("fill", "black")
1824
1837
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1825
1838
  });
@@ -1900,5 +1913,5 @@ function drawPhylogeny(
1900
1913
  }
1901
1914
  }
1902
1915
 
1903
- export { describeArc, describeArcSweep, drawPhylogeny, parentFisheye, phisheye, polarToCartesian, radialLayout, readTree, rectangleLayout, subTree, unrooted };
1916
+ export { describeArc, describeArcSweep, drawPhylogeny, parentFisheye, phisheye, polarToCartesian, radialLayout, randomTree, readTree, rectangleLayout, subTree, unrooted };
1904
1917
  //# sourceMappingURL=index.js.map