@euphrasiologist/lwphylo 1.2.23 → 1.2.26

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.
@@ -31,8 +31,8 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
31
31
  return `M ${p.x} ${p.y}`; // degenerate span → no arc
32
32
  }
33
33
 
34
- const largeArcFlag = delta > Math.PI ? 1 : 0; // should be 0 for “shortest”, but keep for safety
35
- const sweepFlag = 0; // CCW
34
+ const largeArcFlag = delta > Math.PI ? 1 : 0;
35
+ const sweepFlag = 0; // CCW in our y-flipped coords: math-CCW = decreasing SVG angle = sweepFlag 0
36
36
 
37
37
  const p0 = polarToCartesian(cx, cy, radius, a0);
38
38
  const p1 = polarToCartesian(cx, cy, radius, a1);
@@ -41,25 +41,27 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
41
41
  }
42
42
 
43
43
  // src/radial/describeArcSweep.js
44
- const TAU$1 = Math.PI * 2;
45
- const norm$1 = (t) => ((t % TAU$1) + TAU$1) % TAU$1;
46
-
47
- function describeArcSweep(cx, cy, r, a0, a1, sweep /*0=CCW,1=CW*/) {
48
- console.log("describeArcSweep input:", {
49
- cx, cy, r,
50
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
51
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
52
- sweep
53
- });
44
+ // IMPORTANT: angles are in "math space" (increasing = CCW).
45
+ // Because we map y as (cy - r*sin(a)), our math angle t maps to SVG angle -t.
46
+ // Increasing t (math CCW) = decreasing SVG angle = sweepFlag 0 (negative direction).
47
+ // math CCW -> svg sweepFlag = 0
48
+ // math CW -> svg sweepFlag = 1
49
+ function describeArcSweep(
50
+ cx, cy, r,
51
+ a0, a1,
52
+ mathSweep = "ccw", // "ccw" | "cw"
53
+ largeArcFlag = 0
54
+ ) {
55
+ if (!(r > 0)) return "";
54
56
 
55
- const delta = sweep === 0 ? norm$1(a1 - a0) : norm$1(a0 - a1);
56
- if (!(r > 0) || delta < 1e-9) return "";
57
- const largeArcFlag = delta > Math.PI ? 1 : 0;
57
+ const x0 = cx + r * Math.cos(a0);
58
+ const y0 = cy - r * Math.sin(a0);
59
+ const x1 = cx + r * Math.cos(a1);
60
+ const y1 = cy - r * Math.sin(a1);
58
61
 
59
- const x0 = cx + r * Math.cos(a0), y0 = cy - r * Math.sin(a0);
60
- const x1 = cx + r * Math.cos(a1), y1 = cy - r * Math.sin(a1);
62
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
61
63
 
62
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
64
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
63
65
  }
64
66
 
65
67
  /**
@@ -336,16 +338,13 @@ function getArcs(pd) {
336
338
  }
337
339
 
338
340
  /**
339
- * Build APE-like block arcs per internal parent:
340
- * radius = parent.r
341
- * start = first child's angle
342
- * end = last child's angle
343
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
344
- *
345
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
346
- * @returns {Array} [{parentId,thisId,radius,start,end,sweep}]
341
+ * APE-like block arcs per internal parent.
342
+ * Draw CCW from first child's angle to last child's angle (wrapping allowed).
347
343
  */
348
344
  function getArcsFan(pd) {
345
+ const TAU = Math.PI * 2;
346
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
347
+
349
348
  const byId = new Map(pd.map(d => [d.thisId, d]));
350
349
  const arcs = [];
351
350
 
@@ -357,9 +356,11 @@ function getArcsFan(pd) {
357
356
  const last = byId.get(c[c.length - 1])?.angle;
358
357
  if (first == null || last == null) continue;
359
358
 
360
- const start = first;
361
- const end = last;
362
- const sweep = end >= start ? 0 : 1; // CW if wrapped
359
+ const start = norm(first);
360
+ const end = norm(last);
361
+
362
+ const deltaCCW = (end - start + TAU) % TAU;
363
+ if (deltaCCW < 1e-9) continue;
363
364
 
364
365
  arcs.push({
365
366
  parentId: p.parentId,
@@ -367,9 +368,11 @@ function getArcsFan(pd) {
367
368
  radius: p.r,
368
369
  start,
369
370
  end,
370
- sweep
371
+ sweep: "ccw", // << math sweep
372
+ largeArc: deltaCCW > Math.PI ? 1 : 0
371
373
  });
372
374
  }
375
+
373
376
  return arcs;
374
377
  }
375
378
 
@@ -455,80 +458,6 @@ function getChildArcs(pd) {
455
458
  return arcs;
456
459
  }
457
460
 
458
- function getChildArcsFan(pd) {
459
- const TAU = Math.PI * 2;
460
- const norm = (t) => ((t % TAU) + TAU) % TAU;
461
-
462
- // Circular midpoint that travels CCW from a -> b by half the CCW span
463
- function midCCW(a, b) {
464
- const d = (b - a + TAU) % TAU; // CCW delta in [0, 2π)
465
- return norm(a + d / 2);
466
- }
467
-
468
- const key = (x) => (typeof x === "string" ? +x : x);
469
-
470
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
471
- const childrenByParent = new Map(
472
- pd.map(d => [
473
- key(d.thisId),
474
- // normalize children to numeric IDs; drop anything we can't resolve
475
- (d.children || [])
476
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
477
- .map(key)
478
- .filter(id => byId.has(id))
479
- ])
480
- );
481
-
482
- const child_arcs = [];
483
-
484
- for (const parentRaw of pd) {
485
- const pid = key(parentRaw.thisId);
486
- const kids = childrenByParent.get(pid) || [];
487
- if (kids.length < 2) continue;
488
-
489
- // Sort children by angle (normalized) around the circle
490
- const A = kids
491
- .map(id => {
492
- const node = byId.get(id);
493
- return node ? { id, a: norm(node.angle) } : null;
494
- })
495
- .filter(Boolean)
496
- .sort((u, v) => u.a - v.a);
497
-
498
- const N = A.length;
499
- if (N < 2) continue;
500
-
501
- const parent = byId.get(pid);
502
- const radius = parent?.r;
503
- if (!(radius > 0)) continue;
504
-
505
- for (let i = 0; i < N; i++) {
506
- const prev = A[(i - 1 + N) % N];
507
- const cur = A[i];
508
- const next = A[(i + 1) % N];
509
-
510
- const start = midCCW(prev.a, cur.a);
511
- const end = midCCW(cur.a, next.a);
512
-
513
- // Use SVG-conforming sweep logic
514
- const delta = (end - start + TAU) % TAU;
515
- const sweep = delta > Math.PI ? 0 : 1;
516
-
517
- child_arcs.push({
518
- parentId: pid,
519
- childId: cur.id,
520
- radius,
521
- start,
522
- end,
523
- sweep
524
- });
525
- }
526
-
527
- }
528
-
529
- return child_arcs;
530
- }
531
-
532
461
  /**
533
462
  * radialLayout(node, opts?)
534
463
  * opts:
@@ -572,13 +501,8 @@ function radialLayout(node, opts = {}) {
572
501
  ? getArcsFan(pd)
573
502
  : getArcs(pd);
574
503
 
575
- // per-child arcs for half-arc highlighting if you already use them
576
- let child_arcs = [];
577
- if (arcsStyle === "fan") {
578
- child_arcs = getChildArcsFan(pd);
579
- } else {
580
- child_arcs = getChildArcs(pd);
581
- }
504
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
505
+ const child_arcs = getChildArcs(pd);
582
506
 
583
507
  return { data: pd, radii, arcs, child_arcs };
584
508
  }
@@ -906,21 +830,29 @@ function readTree(text) {
906
830
  text = String(text).replace(/\s+/g, '');
907
831
 
908
832
  const tokens = text.split(/(;|\(|\)|,)/);
909
- const root = { parent: null, children: [] };
910
- let curnode = root;
911
833
  let nodeId = 0;
834
+ const makeNode = (parent) => ({
835
+ parent,
836
+ children: [],
837
+ id: nodeId++,
838
+ label: '',
839
+ branchLength: null
840
+ });
841
+
842
+ const root = makeNode(null);
843
+ let curnode = root;
912
844
 
913
845
  for (const token of tokens) {
914
846
  if (!token || token === ';') continue;
915
847
 
916
848
  if (token === '(') {
917
- const child = { parent: curnode, children: [] };
849
+ const child = makeNode(curnode);
918
850
  curnode.children.push(child);
919
851
  curnode = child; // descend
920
852
  } else if (token === ',') {
921
853
  // back to parent, then create sibling
922
854
  curnode = curnode.parent;
923
- const child = { parent: curnode, children: [] };
855
+ const child = makeNode(curnode);
924
856
  curnode.children.push(child);
925
857
  curnode = child;
926
858
  } else if (token === ')') {
@@ -929,14 +861,15 @@ function readTree(text) {
929
861
  if (curnode === null) break;
930
862
  } else {
931
863
  // label/branch-length chunk (e.g., "A:0.01" or "A")
864
+ // Note: nodes are assigned an id at creation (above), so internal
865
+ // (clade) nodes that carry neither a label nor a branch length —
866
+ // e.g. "((A,B),(C,D));" — still get a valid, linkable id here.
932
867
  const nodeinfo = token.split(':');
933
868
  if (nodeinfo.length === 1) {
934
869
  if (token.startsWith(':')) {
935
- curnode.label = '';
936
870
  curnode.branchLength = parseFloat(nodeinfo[0]);
937
871
  } else {
938
872
  curnode.label = nodeinfo[0];
939
- curnode.branchLength = null;
940
873
  }
941
874
  } else if (nodeinfo.length === 2) {
942
875
  curnode.label = nodeinfo[0];
@@ -946,13 +879,9 @@ function readTree(text) {
946
879
  curnode.label = nodeinfo[0] || '';
947
880
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
948
881
  }
949
- curnode.id = nodeId++; // assign then increment
950
882
  }
951
883
  }
952
884
 
953
- // Ensure root has an id if not assigned during parsing
954
- if (root.id == null) root.id = nodeId;
955
-
956
885
  return root;
957
886
  }
958
887
 
@@ -967,6 +896,7 @@ function drawPhylogeny(
967
896
  strokeWidth = 1, // for the phylogeny branches
968
897
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
969
898
  tipLabels = true,
899
+ labelFontSize = 10, // font size (px) for tip labels
970
900
  showTooltips = true,
971
901
  tooltipFormatter = (d, rtt) =>
972
902
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1014,7 +944,7 @@ function drawPhylogeny(
1014
944
  const tips = horizontal.filter((d) => d.isTip);
1015
945
 
1016
946
  // indices & root→tip getter
1017
- const byId = new Map(horizontal.map((d) => [d.thisId, d]));
947
+ const byId = new Map(tree_df.data.map((d) => [d.thisId, d])); // includes root
1018
948
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1019
949
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1020
950
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1088,6 +1018,7 @@ function drawPhylogeny(
1088
1018
  // interactive root→tip highlight (rect) on dot hover
1089
1019
  tipDots
1090
1020
  .on("mouseenter", function(_event, d) {
1021
+ hoverLayer.selectAll("*").remove();
1091
1022
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1092
1023
  d3.select(this).attr("r", 4);
1093
1024
  })
@@ -1107,7 +1038,7 @@ function drawPhylogeny(
1107
1038
  .attr("x", (d) => xScale(d.x1) + 4)
1108
1039
  .attr("y", (d) => yScale(d.y1))
1109
1040
  .attr("dy", "0.32em")
1110
- .attr("font-size", 10)
1041
+ .attr("font-size", labelFontSize)
1111
1042
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1112
1043
 
1113
1044
  if (showTooltips) {
@@ -1118,6 +1049,7 @@ function drawPhylogeny(
1118
1049
 
1119
1050
  labels
1120
1051
  .on("mouseenter", function(_event, d) {
1052
+ hoverLayer.selectAll("*").remove();
1121
1053
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1122
1054
  d3.select(this).attr("font-weight", 600);
1123
1055
  })
@@ -1144,7 +1076,6 @@ function drawPhylogeny(
1144
1076
 
1145
1077
  // helper to draw root→tip for rect (both vertical+horizontal)
1146
1078
  function drawRectPath(tipId, layer, stroke, width) {
1147
- layer.selectAll("*").remove();
1148
1079
  let cur = byId.get(tipId);
1149
1080
  while (cur && cur.parentId != null) {
1150
1081
  const parent = byId.get(cur.parentId);
@@ -1201,10 +1132,14 @@ function drawPhylogeny(
1201
1132
  const END_CAP = 0;
1202
1133
 
1203
1134
  // ===== SCALES / BOUNDS =====
1204
- const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
1205
- const scaleRadial = maxRadius + 2 * radialMargin;
1206
1135
  const w = width,
1207
1136
  h = height;
1137
+ const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
1138
+ // radialMargin is in pixels: tips sit (radialMargin) px from the SVG edge.
1139
+ // Derive the data-space scale so that radiusPx(maxRadius) = w/2 - radialMargin.
1140
+ const scaleRadial = maxRadius > 0
1141
+ ? maxRadius * (w / 2) / (w / 2 - radialMargin)
1142
+ : 1;
1208
1143
  const centerX = w / 2,
1209
1144
  centerY = h / 2;
1210
1145
 
@@ -1284,7 +1219,8 @@ function drawPhylogeny(
1284
1219
  radiusPx(d.radius),
1285
1220
  d.start,
1286
1221
  d.end,
1287
- d.sweep
1222
+ d.sweep ?? "ccw",
1223
+ d.largeArc ?? 0,
1288
1224
  )
1289
1225
  )
1290
1226
  .attr("fill", "none")
@@ -1399,7 +1335,7 @@ function drawPhylogeny(
1399
1335
  .attr("x", xoff)
1400
1336
  .attr("alignment-baseline", "middle")
1401
1337
  .attr("text-anchor", anchor)
1402
- .attr("font-size", 10)
1338
+ .attr("font-size", labelFontSize)
1403
1339
  .attr("fill", "black")
1404
1340
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1405
1341
  });
@@ -1413,6 +1349,8 @@ function drawPhylogeny(
1413
1349
  // label hover
1414
1350
  labels
1415
1351
  .on("mouseenter", function(_event, d) {
1352
+ hoverLines.selectAll("*").remove();
1353
+ hoverArcs.selectAll("*").remove();
1416
1354
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1417
1355
  d3.select(this).select("text").attr("font-weight", 600);
1418
1356
  })
@@ -1432,9 +1370,6 @@ function drawPhylogeny(
1432
1370
  width = 3
1433
1371
  ) {
1434
1372
  // target may be a tip node *or* a numeric tip id
1435
- lineLayer.selectAll("*").remove();
1436
- arcLayer.selectAll("*").remove();
1437
-
1438
1373
  let cur = (typeof target === "number" || typeof target === "string")
1439
1374
  ? byId.get(target)
1440
1375
  : target;
@@ -1473,18 +1408,10 @@ function drawPhylogeny(
1473
1408
  const R = radiusPx(rec.radius);
1474
1409
  return rec.sweep == null
1475
1410
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1476
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep);
1411
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1477
1412
  }
1478
1413
 
1479
1414
  if (a) {
1480
- console.log("Drawing arc:", {
1481
- childId: cur.thisId,
1482
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1483
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1484
- sweep: a.sweep,
1485
- radius: a.radius
1486
- });
1487
-
1488
1415
  arcLayer
1489
1416
  .append("path")
1490
1417
  .attr("d", pathFromArcRecord(a))
@@ -1501,6 +1428,8 @@ function drawPhylogeny(
1501
1428
  // tip dot hover
1502
1429
  tipDots
1503
1430
  .on("mouseenter", function(_event, d) {
1431
+ hoverLines.selectAll("*").remove();
1432
+ hoverArcs.selectAll("*").remove();
1504
1433
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1505
1434
  d3.select(this).attr("r", DOT_R + 2);
1506
1435
  })
@@ -1657,7 +1586,7 @@ function drawPhylogeny(
1657
1586
  .attr("x", xOffset)
1658
1587
  .attr("alignment-baseline", "middle")
1659
1588
  .attr("text-anchor", anchor)
1660
- .attr("font-size", 10)
1589
+ .attr("font-size", labelFontSize)
1661
1590
  .attr("fill", "black")
1662
1591
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1663
1592
  });