@euphrasiologist/lwphylo 1.2.24 → 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,15 +41,17 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
41
41
  }
42
42
 
43
43
  // src/radial/describeArcSweep.js
44
- function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
45
- console.log("describeArcSweep input:", {
46
- cx, cy, r,
47
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
48
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
49
- sweep,
50
- largeArcFlag
51
- });
52
-
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
+ ) {
53
55
  if (!(r > 0)) return "";
54
56
 
55
57
  const x0 = cx + r * Math.cos(a0);
@@ -57,7 +59,9 @@ function describeArcSweep(cx, cy, r, a0, a1, sweep = 1, largeArcFlag = 0) {
57
59
  const x1 = cx + r * Math.cos(a1);
58
60
  const y1 = cy - r * Math.sin(a1);
59
61
 
60
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
62
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
63
+
64
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
61
65
  }
62
66
 
63
67
  /**
@@ -334,16 +338,13 @@ function getArcs(pd) {
334
338
  }
335
339
 
336
340
  /**
337
- * Build APE-like block arcs per internal parent:
338
- * radius = parent.r
339
- * start = first child's angle
340
- * end = last child's angle
341
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
342
- *
343
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
344
- * @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).
345
343
  */
346
344
  function getArcsFan(pd) {
345
+ const TAU = Math.PI * 2;
346
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
347
+
347
348
  const byId = new Map(pd.map(d => [d.thisId, d]));
348
349
  const arcs = [];
349
350
 
@@ -355,9 +356,11 @@ function getArcsFan(pd) {
355
356
  const last = byId.get(c[c.length - 1])?.angle;
356
357
  if (first == null || last == null) continue;
357
358
 
358
- const start = first;
359
- const end = last;
360
- 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;
361
364
 
362
365
  arcs.push({
363
366
  parentId: p.parentId,
@@ -365,9 +368,11 @@ function getArcsFan(pd) {
365
368
  radius: p.r,
366
369
  start,
367
370
  end,
368
- sweep
371
+ sweep: "ccw", // << math sweep
372
+ largeArc: deltaCCW > Math.PI ? 1 : 0
369
373
  });
370
374
  }
375
+
371
376
  return arcs;
372
377
  }
373
378
 
@@ -453,76 +458,6 @@ function getChildArcs(pd) {
453
458
  return arcs;
454
459
  }
455
460
 
456
- function getChildArcsFan(pd) {
457
- const TAU = Math.PI * 2;
458
- const norm = (t) => ((t % TAU) + TAU) % TAU;
459
-
460
- function midCCW(a, b) {
461
- const d = (b - a + TAU) % TAU;
462
- return norm(a + d / 2);
463
- }
464
-
465
- const key = (x) => (typeof x === "string" ? +x : x);
466
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
467
- const childrenByParent = new Map(
468
- pd.map(d => [
469
- key(d.thisId),
470
- (d.children || [])
471
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
472
- .map(key)
473
- .filter(id => byId.has(id))
474
- ])
475
- );
476
-
477
- const child_arcs = [];
478
-
479
- for (const parentRaw of pd) {
480
- const pid = key(parentRaw.thisId);
481
- const kids = childrenByParent.get(pid) || [];
482
- if (kids.length < 2) continue;
483
-
484
- const A = kids
485
- .map(id => {
486
- const node = byId.get(id);
487
- return node ? { id, a: norm(node.angle) } : null;
488
- })
489
- .filter(Boolean)
490
- .sort((u, v) => u.a - v.a);
491
-
492
- const N = A.length;
493
- if (N < 2) continue;
494
-
495
- const parent = byId.get(pid);
496
- const radius = parent?.r;
497
- if (!(radius > 0)) continue;
498
-
499
- for (let i = 0; i < N; i++) {
500
- const prev = A[(i - 1 + N) % N];
501
- const cur = A[i];
502
- const next = A[(i + 1) % N];
503
-
504
- const start = midCCW(prev.a, cur.a);
505
- const end = midCCW(cur.a, next.a);
506
-
507
- const sweep = 1; // always clockwise
508
- const delta = (end - start + TAU) % TAU;
509
- const largeArc = delta > Math.PI ? 1 : 0;
510
-
511
- child_arcs.push({
512
- parentId: pid,
513
- childId: cur.id,
514
- radius,
515
- start,
516
- end,
517
- sweep,
518
- largeArc
519
- });
520
- }
521
- }
522
-
523
- return child_arcs;
524
- }
525
-
526
461
  /**
527
462
  * radialLayout(node, opts?)
528
463
  * opts:
@@ -566,13 +501,8 @@ function radialLayout(node, opts = {}) {
566
501
  ? getArcsFan(pd)
567
502
  : getArcs(pd);
568
503
 
569
- // per-child arcs for half-arc highlighting if you already use them
570
- let child_arcs = [];
571
- if (arcsStyle === "fan") {
572
- child_arcs = getChildArcsFan(pd);
573
- } else {
574
- child_arcs = getChildArcs(pd);
575
- }
504
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
505
+ const child_arcs = getChildArcs(pd);
576
506
 
577
507
  return { data: pd, radii, arcs, child_arcs };
578
508
  }
@@ -900,21 +830,29 @@ function readTree(text) {
900
830
  text = String(text).replace(/\s+/g, '');
901
831
 
902
832
  const tokens = text.split(/(;|\(|\)|,)/);
903
- const root = { parent: null, children: [] };
904
- let curnode = root;
905
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;
906
844
 
907
845
  for (const token of tokens) {
908
846
  if (!token || token === ';') continue;
909
847
 
910
848
  if (token === '(') {
911
- const child = { parent: curnode, children: [] };
849
+ const child = makeNode(curnode);
912
850
  curnode.children.push(child);
913
851
  curnode = child; // descend
914
852
  } else if (token === ',') {
915
853
  // back to parent, then create sibling
916
854
  curnode = curnode.parent;
917
- const child = { parent: curnode, children: [] };
855
+ const child = makeNode(curnode);
918
856
  curnode.children.push(child);
919
857
  curnode = child;
920
858
  } else if (token === ')') {
@@ -923,14 +861,15 @@ function readTree(text) {
923
861
  if (curnode === null) break;
924
862
  } else {
925
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.
926
867
  const nodeinfo = token.split(':');
927
868
  if (nodeinfo.length === 1) {
928
869
  if (token.startsWith(':')) {
929
- curnode.label = '';
930
870
  curnode.branchLength = parseFloat(nodeinfo[0]);
931
871
  } else {
932
872
  curnode.label = nodeinfo[0];
933
- curnode.branchLength = null;
934
873
  }
935
874
  } else if (nodeinfo.length === 2) {
936
875
  curnode.label = nodeinfo[0];
@@ -940,13 +879,9 @@ function readTree(text) {
940
879
  curnode.label = nodeinfo[0] || '';
941
880
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
942
881
  }
943
- curnode.id = nodeId++; // assign then increment
944
882
  }
945
883
  }
946
884
 
947
- // Ensure root has an id if not assigned during parsing
948
- if (root.id == null) root.id = nodeId;
949
-
950
885
  return root;
951
886
  }
952
887
 
@@ -961,6 +896,7 @@ function drawPhylogeny(
961
896
  strokeWidth = 1, // for the phylogeny branches
962
897
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
963
898
  tipLabels = true,
899
+ labelFontSize = 10, // font size (px) for tip labels
964
900
  showTooltips = true,
965
901
  tooltipFormatter = (d, rtt) =>
966
902
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1008,7 +944,7 @@ function drawPhylogeny(
1008
944
  const tips = horizontal.filter((d) => d.isTip);
1009
945
 
1010
946
  // indices & root→tip getter
1011
- 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
1012
948
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1013
949
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1014
950
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1082,6 +1018,7 @@ function drawPhylogeny(
1082
1018
  // interactive root→tip highlight (rect) on dot hover
1083
1019
  tipDots
1084
1020
  .on("mouseenter", function(_event, d) {
1021
+ hoverLayer.selectAll("*").remove();
1085
1022
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1086
1023
  d3.select(this).attr("r", 4);
1087
1024
  })
@@ -1101,7 +1038,7 @@ function drawPhylogeny(
1101
1038
  .attr("x", (d) => xScale(d.x1) + 4)
1102
1039
  .attr("y", (d) => yScale(d.y1))
1103
1040
  .attr("dy", "0.32em")
1104
- .attr("font-size", 10)
1041
+ .attr("font-size", labelFontSize)
1105
1042
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1106
1043
 
1107
1044
  if (showTooltips) {
@@ -1112,6 +1049,7 @@ function drawPhylogeny(
1112
1049
 
1113
1050
  labels
1114
1051
  .on("mouseenter", function(_event, d) {
1052
+ hoverLayer.selectAll("*").remove();
1115
1053
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1116
1054
  d3.select(this).attr("font-weight", 600);
1117
1055
  })
@@ -1138,7 +1076,6 @@ function drawPhylogeny(
1138
1076
 
1139
1077
  // helper to draw root→tip for rect (both vertical+horizontal)
1140
1078
  function drawRectPath(tipId, layer, stroke, width) {
1141
- layer.selectAll("*").remove();
1142
1079
  let cur = byId.get(tipId);
1143
1080
  while (cur && cur.parentId != null) {
1144
1081
  const parent = byId.get(cur.parentId);
@@ -1195,10 +1132,14 @@ function drawPhylogeny(
1195
1132
  const END_CAP = 0;
1196
1133
 
1197
1134
  // ===== SCALES / BOUNDS =====
1198
- const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
1199
- const scaleRadial = maxRadius + 2 * radialMargin;
1200
1135
  const w = width,
1201
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;
1202
1143
  const centerX = w / 2,
1203
1144
  centerY = h / 2;
1204
1145
 
@@ -1278,8 +1219,8 @@ function drawPhylogeny(
1278
1219
  radiusPx(d.radius),
1279
1220
  d.start,
1280
1221
  d.end,
1281
- d.sweep,
1282
- d.largeArc,
1222
+ d.sweep ?? "ccw",
1223
+ d.largeArc ?? 0,
1283
1224
  )
1284
1225
  )
1285
1226
  .attr("fill", "none")
@@ -1394,7 +1335,7 @@ function drawPhylogeny(
1394
1335
  .attr("x", xoff)
1395
1336
  .attr("alignment-baseline", "middle")
1396
1337
  .attr("text-anchor", anchor)
1397
- .attr("font-size", 10)
1338
+ .attr("font-size", labelFontSize)
1398
1339
  .attr("fill", "black")
1399
1340
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1400
1341
  });
@@ -1408,6 +1349,8 @@ function drawPhylogeny(
1408
1349
  // label hover
1409
1350
  labels
1410
1351
  .on("mouseenter", function(_event, d) {
1352
+ hoverLines.selectAll("*").remove();
1353
+ hoverArcs.selectAll("*").remove();
1411
1354
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1412
1355
  d3.select(this).select("text").attr("font-weight", 600);
1413
1356
  })
@@ -1427,9 +1370,6 @@ function drawPhylogeny(
1427
1370
  width = 3
1428
1371
  ) {
1429
1372
  // target may be a tip node *or* a numeric tip id
1430
- lineLayer.selectAll("*").remove();
1431
- arcLayer.selectAll("*").remove();
1432
-
1433
1373
  let cur = (typeof target === "number" || typeof target === "string")
1434
1374
  ? byId.get(target)
1435
1375
  : target;
@@ -1468,18 +1408,10 @@ function drawPhylogeny(
1468
1408
  const R = radiusPx(rec.radius);
1469
1409
  return rec.sweep == null
1470
1410
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1471
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep, a.largeArc);
1411
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1472
1412
  }
1473
1413
 
1474
1414
  if (a) {
1475
- console.log("Drawing arc:", {
1476
- childId: cur.thisId,
1477
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1478
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1479
- sweep: a.sweep,
1480
- radius: a.radius
1481
- });
1482
-
1483
1415
  arcLayer
1484
1416
  .append("path")
1485
1417
  .attr("d", pathFromArcRecord(a))
@@ -1496,6 +1428,8 @@ function drawPhylogeny(
1496
1428
  // tip dot hover
1497
1429
  tipDots
1498
1430
  .on("mouseenter", function(_event, d) {
1431
+ hoverLines.selectAll("*").remove();
1432
+ hoverArcs.selectAll("*").remove();
1499
1433
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1500
1434
  d3.select(this).attr("r", DOT_R + 2);
1501
1435
  })
@@ -1652,7 +1586,7 @@ function drawPhylogeny(
1652
1586
  .attr("x", xOffset)
1653
1587
  .attr("alignment-baseline", "middle")
1654
1588
  .attr("text-anchor", anchor)
1655
- .attr("font-size", 10)
1589
+ .attr("font-size", labelFontSize)
1656
1590
  .attr("fill", "black")
1657
1591
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1658
1592
  });