@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/README.md CHANGED
@@ -2,16 +2,15 @@
2
2
 
3
3
  A lightweight, low level javascript library to plot phylogenies from a Newick file. It uses no dependencies on any other package, but is designed to be given to the D3 library for visualisation.
4
4
 
5
- ### Functionality
5
+ ## Website
6
6
 
7
- Newick trees can be parsed using the `readTree()` function. This object can then be wrapped in three main functions; `rectangleLayout()` to produce a "regular" phylogenetic tree, `radialLayout()` to produce a circular phylogeny, and `unrooted()` to produce an unrooted tree via the equal angle layout algorithm.
7
+ Visit https://euphrasiologist.github.io/lwPhylo/ to see examples and live rendering of trees. Can even paste your own in.
8
8
 
9
- ### Examples
9
+ ### Functionality
10
10
 
11
- A quick tutorial is now available to see on Observable: https://observablehq.com/@euphrasiologist/lwphylo-tutorial \
12
- It goes over the three tree layout functions, and hopefully is all quite straightforward.
11
+ Newick trees can be parsed using the `readTree()` function. This object can then be wrapped in three main functions; `rectangleLayout()` to produce a "regular" phylogenetic tree, `radialLayout()` to produce a circular phylogeny, and `unrooted()` to produce an unrooted tree via the equal angle layout algorithm.
13
12
 
14
- Stay tuned for examples in the browser.
13
+ Need a tree to experiment with? `randomTree(nTips, { maxBranchLength, labelPrefix, seed })` generates a random bifurcating tree in the same node shape as `readTree()`, ready to pass straight into any of the layout functions.
15
14
 
16
15
  ### Acknowledgements
17
16
 
@@ -52,8 +52,8 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
52
52
  return `M ${p.x} ${p.y}`; // degenerate span → no arc
53
53
  }
54
54
 
55
- const largeArcFlag = delta > Math.PI ? 1 : 0; // should be 0 for “shortest”, but keep for safety
56
- const sweepFlag = 0; // CCW
55
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
56
+ const sweepFlag = 0; // CCW in our y-flipped coords: math-CCW = decreasing SVG angle = sweepFlag 0
57
57
 
58
58
  const p0 = polarToCartesian(cx, cy, radius, a0);
59
59
  const p1 = polarToCartesian(cx, cy, radius, a1);
@@ -62,15 +62,17 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
62
62
  }
63
63
 
64
64
  // src/radial/describeArcSweep.js
65
- function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
66
- console.log("describeArcSweep input:", {
67
- cx, cy, r,
68
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
69
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
70
- sweep,
71
- largeArcFlag
72
- });
73
-
65
+ // IMPORTANT: angles are in "math space" (increasing = CCW).
66
+ // Because we map y as (cy - r*sin(a)), our math angle t maps to SVG angle -t.
67
+ // Increasing t (math CCW) = decreasing SVG angle = sweepFlag 0 (negative direction).
68
+ // math CCW -> svg sweepFlag = 0
69
+ // math CW -> svg sweepFlag = 1
70
+ function describeArcSweep(
71
+ cx, cy, r,
72
+ a0, a1,
73
+ mathSweep = "ccw", // "ccw" | "cw"
74
+ largeArcFlag = 0
75
+ ) {
74
76
  if (!(r > 0)) return "";
75
77
 
76
78
  const x0 = cx + r * Math.cos(a0);
@@ -78,7 +80,9 @@ function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
78
80
  const x1 = cx + r * Math.cos(a1);
79
81
  const y1 = cy - r * Math.sin(a1);
80
82
 
81
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
83
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
84
+
85
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
82
86
  }
83
87
 
84
88
  /**
@@ -355,16 +359,13 @@ function getArcs(pd) {
355
359
  }
356
360
 
357
361
  /**
358
- * Build APE-like block arcs per internal parent:
359
- * radius = parent.r
360
- * start = first child's angle
361
- * end = last child's angle
362
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
363
- *
364
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
365
- * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
362
+ * APE-like block arcs per internal parent.
363
+ * Draw CCW from first child's angle to last child's angle (wrapping allowed).
366
364
  */
367
365
  function getArcsFan(pd) {
366
+ const TAU = Math.PI * 2;
367
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
368
+
368
369
  const byId = new Map(pd.map(d => [d.thisId, d]));
369
370
  const arcs = [];
370
371
 
@@ -376,9 +377,11 @@ function getArcsFan(pd) {
376
377
  const last = byId.get(c[c.length - 1])?.angle;
377
378
  if (first == null || last == null) continue;
378
379
 
379
- const start = first;
380
- const end = last;
381
- const sweep = end >= start ? 0 : 1; // CW if wrapped
380
+ const start = norm(first);
381
+ const end = norm(last);
382
+
383
+ const deltaCCW = (end - start + TAU) % TAU;
384
+ if (deltaCCW < 1e-9) continue;
382
385
 
383
386
  arcs.push({
384
387
  parentId: p.parentId,
@@ -386,9 +389,11 @@ function getArcsFan(pd) {
386
389
  radius: p.r,
387
390
  start,
388
391
  end,
389
- sweep
392
+ sweep: "ccw", // << math sweep
393
+ largeArc: deltaCCW > Math.PI ? 1 : 0
390
394
  });
391
395
  }
396
+
392
397
  return arcs;
393
398
  }
394
399
 
@@ -474,76 +479,6 @@ function getChildArcs(pd) {
474
479
  return arcs;
475
480
  }
476
481
 
477
- function getChildArcsFan(pd) {
478
- const TAU = Math.PI * 2;
479
- const norm = (t) => ((t % TAU) + TAU) % TAU;
480
-
481
- function midCCW(a, b) {
482
- const d = (b - a + TAU) % TAU;
483
- return norm(a + d / 2);
484
- }
485
-
486
- const key = (x) => (typeof x === "string" ? +x : x);
487
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
488
- const childrenByParent = new Map(
489
- pd.map(d => [
490
- key(d.thisId),
491
- (d.children || [])
492
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
493
- .map(key)
494
- .filter(id => byId.has(id))
495
- ])
496
- );
497
-
498
- const child_arcs = [];
499
-
500
- for (const parentRaw of pd) {
501
- const pid = key(parentRaw.thisId);
502
- const kids = childrenByParent.get(pid) || [];
503
- if (kids.length < 2) continue;
504
-
505
- const A = kids
506
- .map(id => {
507
- const node = byId.get(id);
508
- return node ? { id, a: norm(node.angle) } : null;
509
- })
510
- .filter(Boolean)
511
- .sort((u, v) => u.a - v.a);
512
-
513
- const N = A.length;
514
- if (N < 2) continue;
515
-
516
- const parent = byId.get(pid);
517
- const radius = parent?.r;
518
- if (!(radius > 0)) continue;
519
-
520
- for (let i = 0; i < N; i++) {
521
- const prev = A[(i - 1 + N) % N];
522
- const cur = A[i];
523
- const next = A[(i + 1) % N];
524
-
525
- const start = midCCW(prev.a, cur.a);
526
- const end = midCCW(cur.a, next.a);
527
-
528
- const sweep = 1; // always clockwise
529
- const delta = (end - start + TAU) % TAU;
530
- const largeArc = delta > Math.PI ? 1 : 0;
531
-
532
- child_arcs.push({
533
- parentId: pid,
534
- childId: cur.id,
535
- radius,
536
- start,
537
- end,
538
- sweep,
539
- largeArc
540
- });
541
- }
542
- }
543
-
544
- return child_arcs;
545
- }
546
-
547
482
  /**
548
483
  * radialLayout(node, opts?)
549
484
  * opts:
@@ -587,13 +522,8 @@ function radialLayout(node, opts = {}) {
587
522
  ? getArcsFan(pd)
588
523
  : getArcs(pd);
589
524
 
590
- // per-child arcs for half-arc highlighting if you already use them
591
- let child_arcs = [];
592
- if (arcsStyle === "fan") {
593
- child_arcs = getChildArcsFan(pd);
594
- } else {
595
- child_arcs = getChildArcs(pd);
596
- }
525
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
526
+ const child_arcs = getChildArcs(pd);
597
527
 
598
528
  return { data: pd, radii, arcs, child_arcs };
599
529
  }
@@ -921,21 +851,29 @@ function readTree(text) {
921
851
  text = String(text).replace(/\s+/g, '');
922
852
 
923
853
  const tokens = text.split(/(;|\(|\)|,)/);
924
- const root = { parent: null, children: [] };
925
- let curnode = root;
926
854
  let nodeId = 0;
855
+ const makeNode = (parent) => ({
856
+ parent,
857
+ children: [],
858
+ id: nodeId++,
859
+ label: '',
860
+ branchLength: null
861
+ });
862
+
863
+ const root = makeNode(null);
864
+ let curnode = root;
927
865
 
928
866
  for (const token of tokens) {
929
867
  if (!token || token === ';') continue;
930
868
 
931
869
  if (token === '(') {
932
- const child = { parent: curnode, children: [] };
870
+ const child = makeNode(curnode);
933
871
  curnode.children.push(child);
934
872
  curnode = child; // descend
935
873
  } else if (token === ',') {
936
874
  // back to parent, then create sibling
937
875
  curnode = curnode.parent;
938
- const child = { parent: curnode, children: [] };
876
+ const child = makeNode(curnode);
939
877
  curnode.children.push(child);
940
878
  curnode = child;
941
879
  } else if (token === ')') {
@@ -944,14 +882,15 @@ function readTree(text) {
944
882
  if (curnode === null) break;
945
883
  } else {
946
884
  // label/branch-length chunk (e.g., "A:0.01" or "A")
885
+ // Note: nodes are assigned an id at creation (above), so internal
886
+ // (clade) nodes that carry neither a label nor a branch length —
887
+ // e.g. "((A,B),(C,D));" — still get a valid, linkable id here.
947
888
  const nodeinfo = token.split(':');
948
889
  if (nodeinfo.length === 1) {
949
890
  if (token.startsWith(':')) {
950
- curnode.label = '';
951
891
  curnode.branchLength = parseFloat(nodeinfo[0]);
952
892
  } else {
953
893
  curnode.label = nodeinfo[0];
954
- curnode.branchLength = null;
955
894
  }
956
895
  } else if (nodeinfo.length === 2) {
957
896
  curnode.label = nodeinfo[0];
@@ -961,13 +900,9 @@ function readTree(text) {
961
900
  curnode.label = nodeinfo[0] || '';
962
901
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
963
902
  }
964
- curnode.id = nodeId++; // assign then increment
965
903
  }
966
904
  }
967
905
 
968
- // Ensure root has an id if not assigned during parsing
969
- if (root.id == null) root.id = nodeId;
970
-
971
906
  return root;
972
907
  }
973
908
 
@@ -982,6 +917,7 @@ function drawPhylogeny(
982
917
  strokeWidth = 1, // for the phylogeny branches
983
918
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
984
919
  tipLabels = true,
920
+ labelFontSize = 10, // font size (px) for tip labels
985
921
  showTooltips = true,
986
922
  tooltipFormatter = (d, rtt) =>
987
923
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1029,7 +965,7 @@ function drawPhylogeny(
1029
965
  const tips = horizontal.filter((d) => d.isTip);
1030
966
 
1031
967
  // indices & root→tip getter
1032
- const byId = new Map(horizontal.map((d) => [d.thisId, d]));
968
+ const byId = new Map(tree_df.data.map((d) => [d.thisId, d])); // includes root
1033
969
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1034
970
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1035
971
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1103,6 +1039,7 @@ function drawPhylogeny(
1103
1039
  // interactive root→tip highlight (rect) on dot hover
1104
1040
  tipDots
1105
1041
  .on("mouseenter", function(_event, d) {
1042
+ hoverLayer.selectAll("*").remove();
1106
1043
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1107
1044
  d3__namespace.select(this).attr("r", 4);
1108
1045
  })
@@ -1122,7 +1059,7 @@ function drawPhylogeny(
1122
1059
  .attr("x", (d) => xScale(d.x1) + 4)
1123
1060
  .attr("y", (d) => yScale(d.y1))
1124
1061
  .attr("dy", "0.32em")
1125
- .attr("font-size", 10)
1062
+ .attr("font-size", labelFontSize)
1126
1063
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1127
1064
 
1128
1065
  if (showTooltips) {
@@ -1133,6 +1070,7 @@ function drawPhylogeny(
1133
1070
 
1134
1071
  labels
1135
1072
  .on("mouseenter", function(_event, d) {
1073
+ hoverLayer.selectAll("*").remove();
1136
1074
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1137
1075
  d3__namespace.select(this).attr("font-weight", 600);
1138
1076
  })
@@ -1159,7 +1097,6 @@ function drawPhylogeny(
1159
1097
 
1160
1098
  // helper to draw root→tip for rect (both vertical+horizontal)
1161
1099
  function drawRectPath(tipId, layer, stroke, width) {
1162
- layer.selectAll("*").remove();
1163
1100
  let cur = byId.get(tipId);
1164
1101
  while (cur && cur.parentId != null) {
1165
1102
  const parent = byId.get(cur.parentId);
@@ -1216,10 +1153,14 @@ function drawPhylogeny(
1216
1153
  const END_CAP = 0;
1217
1154
 
1218
1155
  // ===== SCALES / BOUNDS =====
1219
- const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1220
- const scaleRadial = maxRadius + 2 * radialMargin;
1221
1156
  const w = width,
1222
1157
  h = height;
1158
+ const maxRadius = d3__namespace.max(rad.data, (d) => d.r) ?? 0;
1159
+ // radialMargin is in pixels: tips sit (radialMargin) px from the SVG edge.
1160
+ // Derive the data-space scale so that radiusPx(maxRadius) = w/2 - radialMargin.
1161
+ const scaleRadial = maxRadius > 0
1162
+ ? maxRadius * (w / 2) / (w / 2 - radialMargin)
1163
+ : 1;
1223
1164
  const centerX = w / 2,
1224
1165
  centerY = h / 2;
1225
1166
 
@@ -1299,8 +1240,8 @@ function drawPhylogeny(
1299
1240
  radiusPx(d.radius),
1300
1241
  d.start,
1301
1242
  d.end,
1302
- d.sweep,
1303
- d.largeArc,
1243
+ d.sweep ?? "ccw",
1244
+ d.largeArc ?? 0,
1304
1245
  )
1305
1246
  )
1306
1247
  .attr("fill", "none")
@@ -1415,7 +1356,7 @@ function drawPhylogeny(
1415
1356
  .attr("x", xoff)
1416
1357
  .attr("alignment-baseline", "middle")
1417
1358
  .attr("text-anchor", anchor)
1418
- .attr("font-size", 10)
1359
+ .attr("font-size", labelFontSize)
1419
1360
  .attr("fill", "black")
1420
1361
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1421
1362
  });
@@ -1429,6 +1370,8 @@ function drawPhylogeny(
1429
1370
  // label hover
1430
1371
  labels
1431
1372
  .on("mouseenter", function(_event, d) {
1373
+ hoverLines.selectAll("*").remove();
1374
+ hoverArcs.selectAll("*").remove();
1432
1375
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1433
1376
  d3__namespace.select(this).select("text").attr("font-weight", 600);
1434
1377
  })
@@ -1448,9 +1391,6 @@ function drawPhylogeny(
1448
1391
  width = 3
1449
1392
  ) {
1450
1393
  // target may be a tip node *or* a numeric tip id
1451
- lineLayer.selectAll("*").remove();
1452
- arcLayer.selectAll("*").remove();
1453
-
1454
1394
  let cur = (typeof target === "number" || typeof target === "string")
1455
1395
  ? byId.get(target)
1456
1396
  : target;
@@ -1489,18 +1429,10 @@ function drawPhylogeny(
1489
1429
  const R = radiusPx(rec.radius);
1490
1430
  return rec.sweep == null
1491
1431
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1492
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep, a.largeArc);
1432
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1493
1433
  }
1494
1434
 
1495
1435
  if (a) {
1496
- console.log("Drawing arc:", {
1497
- childId: cur.thisId,
1498
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1499
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1500
- sweep: a.sweep,
1501
- radius: a.radius
1502
- });
1503
-
1504
1436
  arcLayer
1505
1437
  .append("path")
1506
1438
  .attr("d", pathFromArcRecord(a))
@@ -1517,6 +1449,8 @@ function drawPhylogeny(
1517
1449
  // tip dot hover
1518
1450
  tipDots
1519
1451
  .on("mouseenter", function(_event, d) {
1452
+ hoverLines.selectAll("*").remove();
1453
+ hoverArcs.selectAll("*").remove();
1520
1454
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1521
1455
  d3__namespace.select(this).attr("r", DOT_R + 2);
1522
1456
  })
@@ -1673,7 +1607,7 @@ function drawPhylogeny(
1673
1607
  .attr("x", xOffset)
1674
1608
  .attr("alignment-baseline", "middle")
1675
1609
  .attr("text-anchor", anchor)
1676
- .attr("font-size", 10)
1610
+ .attr("font-size", labelFontSize)
1677
1611
  .attr("fill", "black")
1678
1612
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1679
1613
  });