@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.
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,25 +154,27 @@ function describeArc(cx, cy, radius, startAngle, endAngle) {
154
154
  }
155
155
 
156
156
  // src/radial/describeArcSweep.js
157
- const TAU$1 = Math.PI * 2;
158
- const norm$1 = (t) => ((t % TAU$1) + TAU$1) % TAU$1;
159
-
160
- function describeArcSweep(cx, cy, r, a0, a1, sweep /*0=CCW,1=CW*/) {
161
- console.log("describeArcSweep input:", {
162
- cx, cy, r,
163
- a0Deg: (a0 * 180 / Math.PI).toFixed(2),
164
- a1Deg: (a1 * 180 / Math.PI).toFixed(2),
165
- sweep
166
- });
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
+ ) {
168
+ if (!(r > 0)) return "";
167
169
 
168
- const delta = sweep === 0 ? norm$1(a1 - a0) : norm$1(a0 - a1);
169
- if (!(r > 0) || delta < 1e-9) return "";
170
- const largeArcFlag = delta > Math.PI ? 1 : 0;
170
+ const x0 = cx + r * Math.cos(a0);
171
+ const y0 = cy - r * Math.sin(a0);
172
+ const x1 = cx + r * Math.cos(a1);
173
+ const y1 = cy - r * Math.sin(a1);
171
174
 
172
- const x0 = cx + r * Math.cos(a0), y0 = cy - r * Math.sin(a0);
173
- const x1 = cx + r * Math.cos(a1), y1 = cy - r * Math.sin(a1);
175
+ const svgSweepFlag = (mathSweep === "ccw") ? 0 : 1;
174
176
 
175
- return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${sweep} ${x1} ${y1}`;
177
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${largeArcFlag} ${svgSweepFlag} ${x1} ${y1}`;
176
178
  }
177
179
 
178
180
  /**
@@ -449,16 +451,13 @@ function getArcs(pd) {
449
451
  }
450
452
 
451
453
  /**
452
- * Build APE-like block arcs per internal parent:
453
- * radius = parent.r
454
- * start = first child's angle
455
- * end = last child's angle
456
- * sweep = 0 (CCW) if end>=start; 1 (CW) if wrapped across 2π
457
- *
458
- * @param {Array} pd nodes with {thisId,parentId,children,angle,r}
459
- * @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).
460
456
  */
461
457
  function getArcsFan(pd) {
458
+ const TAU = Math.PI * 2;
459
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
460
+
462
461
  const byId = new Map(pd.map(d => [d.thisId, d]));
463
462
  const arcs = [];
464
463
 
@@ -470,9 +469,11 @@ function getArcsFan(pd) {
470
469
  const last = byId.get(c[c.length - 1])?.angle;
471
470
  if (first == null || last == null) continue;
472
471
 
473
- const start = first;
474
- const end = last;
475
- 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;
476
477
 
477
478
  arcs.push({
478
479
  parentId: p.parentId,
@@ -480,9 +481,11 @@ function getArcsFan(pd) {
480
481
  radius: p.r,
481
482
  start,
482
483
  end,
483
- sweep
484
+ sweep: "ccw", // << math sweep
485
+ largeArc: deltaCCW > Math.PI ? 1 : 0
484
486
  });
485
487
  }
488
+
486
489
  return arcs;
487
490
  }
488
491
 
@@ -568,80 +571,6 @@ function getChildArcs(pd) {
568
571
  return arcs;
569
572
  }
570
573
 
571
- function getChildArcsFan(pd) {
572
- const TAU = Math.PI * 2;
573
- const norm = (t) => ((t % TAU) + TAU) % TAU;
574
-
575
- // Circular midpoint that travels CCW from a -> b by half the CCW span
576
- function midCCW(a, b) {
577
- const d = (b - a + TAU) % TAU; // CCW delta in [0, 2π)
578
- return norm(a + d / 2);
579
- }
580
-
581
- const key = (x) => (typeof x === "string" ? +x : x);
582
-
583
- const byId = new Map(pd.map(d => [key(d.thisId), d]));
584
- const childrenByParent = new Map(
585
- pd.map(d => [
586
- key(d.thisId),
587
- // normalize children to numeric IDs; drop anything we can't resolve
588
- (d.children || [])
589
- .map(ch => (typeof ch === "object" ? ch.thisId : ch))
590
- .map(key)
591
- .filter(id => byId.has(id))
592
- ])
593
- );
594
-
595
- const child_arcs = [];
596
-
597
- for (const parentRaw of pd) {
598
- const pid = key(parentRaw.thisId);
599
- const kids = childrenByParent.get(pid) || [];
600
- if (kids.length < 2) continue;
601
-
602
- // Sort children by angle (normalized) around the circle
603
- const A = kids
604
- .map(id => {
605
- const node = byId.get(id);
606
- return node ? { id, a: norm(node.angle) } : null;
607
- })
608
- .filter(Boolean)
609
- .sort((u, v) => u.a - v.a);
610
-
611
- const N = A.length;
612
- if (N < 2) continue;
613
-
614
- const parent = byId.get(pid);
615
- const radius = parent?.r;
616
- if (!(radius > 0)) continue;
617
-
618
- for (let i = 0; i < N; i++) {
619
- const prev = A[(i - 1 + N) % N];
620
- const cur = A[i];
621
- const next = A[(i + 1) % N];
622
-
623
- const start = midCCW(prev.a, cur.a);
624
- const end = midCCW(cur.a, next.a);
625
-
626
- // Use SVG-conforming sweep logic
627
- const delta = (end - start + TAU) % TAU;
628
- const sweep = delta > Math.PI ? 0 : 1;
629
-
630
- child_arcs.push({
631
- parentId: pid,
632
- childId: cur.id,
633
- radius,
634
- start,
635
- end,
636
- sweep
637
- });
638
- }
639
-
640
- }
641
-
642
- return child_arcs;
643
- }
644
-
645
574
  /**
646
575
  * radialLayout(node, opts?)
647
576
  * opts:
@@ -685,13 +614,8 @@ function radialLayout(node, opts = {}) {
685
614
  ? getArcsFan(pd)
686
615
  : getArcs(pd);
687
616
 
688
- // per-child arcs for half-arc highlighting if you already use them
689
- let child_arcs = [];
690
- if (arcsStyle === "fan") {
691
- child_arcs = getChildArcsFan(pd);
692
- } else {
693
- child_arcs = getChildArcs(pd);
694
- }
617
+ // per-child arcs for path highlighting: always parent.angle child.angle at parent.r
618
+ const child_arcs = getChildArcs(pd);
695
619
 
696
620
  return { data: pd, radii, arcs, child_arcs };
697
621
  }
@@ -1028,21 +952,29 @@ function readTree(text) {
1028
952
  text = String(text).replace(/\s+/g, '');
1029
953
 
1030
954
  const tokens = text.split(/(;|\(|\)|,)/);
1031
- const root = { parent: null, children: [] };
1032
- let curnode = root;
1033
955
  let nodeId = 0;
956
+ const makeNode = (parent) => ({
957
+ parent,
958
+ children: [],
959
+ id: nodeId++,
960
+ label: '',
961
+ branchLength: null
962
+ });
963
+
964
+ const root = makeNode(null);
965
+ let curnode = root;
1034
966
 
1035
967
  for (const token of tokens) {
1036
968
  if (!token || token === ';') continue;
1037
969
 
1038
970
  if (token === '(') {
1039
- const child = { parent: curnode, children: [] };
971
+ const child = makeNode(curnode);
1040
972
  curnode.children.push(child);
1041
973
  curnode = child; // descend
1042
974
  } else if (token === ',') {
1043
975
  // back to parent, then create sibling
1044
976
  curnode = curnode.parent;
1045
- const child = { parent: curnode, children: [] };
977
+ const child = makeNode(curnode);
1046
978
  curnode.children.push(child);
1047
979
  curnode = child;
1048
980
  } else if (token === ')') {
@@ -1051,14 +983,15 @@ function readTree(text) {
1051
983
  if (curnode === null) break;
1052
984
  } else {
1053
985
  // label/branch-length chunk (e.g., "A:0.01" or "A")
986
+ // Note: nodes are assigned an id at creation (above), so internal
987
+ // (clade) nodes that carry neither a label nor a branch length —
988
+ // e.g. "((A,B),(C,D));" — still get a valid, linkable id here.
1054
989
  const nodeinfo = token.split(':');
1055
990
  if (nodeinfo.length === 1) {
1056
991
  if (token.startsWith(':')) {
1057
- curnode.label = '';
1058
992
  curnode.branchLength = parseFloat(nodeinfo[0]);
1059
993
  } else {
1060
994
  curnode.label = nodeinfo[0];
1061
- curnode.branchLength = null;
1062
995
  }
1063
996
  } else if (nodeinfo.length === 2) {
1064
997
  curnode.label = nodeinfo[0];
@@ -1068,13 +1001,9 @@ function readTree(text) {
1068
1001
  curnode.label = nodeinfo[0] || '';
1069
1002
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
1070
1003
  }
1071
- curnode.id = nodeId++; // assign then increment
1072
1004
  }
1073
1005
  }
1074
1006
 
1075
- // Ensure root has an id if not assigned during parsing
1076
- if (root.id == null) root.id = nodeId;
1077
-
1078
1007
  return root;
1079
1008
  }
1080
1009
 
@@ -1134,6 +1063,7 @@ function drawPhylogeny(
1134
1063
  strokeWidth = 1, // for the phylogeny branches
1135
1064
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1136
1065
  tipLabels = true,
1066
+ labelFontSize = 10, // font size (px) for tip labels
1137
1067
  showTooltips = true,
1138
1068
  tooltipFormatter = (d, rtt) =>
1139
1069
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1181,7 +1111,7 @@ function drawPhylogeny(
1181
1111
  const tips = horizontal.filter((d) => d.isTip);
1182
1112
 
1183
1113
  // indices & root→tip getter
1184
- const byId = new Map(horizontal.map((d) => [d.thisId, d]));
1114
+ const byId = new Map(tree_df.data.map((d) => [d.thisId, d])); // includes root
1185
1115
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1186
1116
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1187
1117
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1255,6 +1185,7 @@ function drawPhylogeny(
1255
1185
  // interactive root→tip highlight (rect) on dot hover
1256
1186
  tipDots
1257
1187
  .on("mouseenter", function(_event, d) {
1188
+ hoverLayer.selectAll("*").remove();
1258
1189
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1259
1190
  d3.select(this).attr("r", 4);
1260
1191
  })
@@ -1274,7 +1205,7 @@ function drawPhylogeny(
1274
1205
  .attr("x", (d) => xScale(d.x1) + 4)
1275
1206
  .attr("y", (d) => yScale(d.y1))
1276
1207
  .attr("dy", "0.32em")
1277
- .attr("font-size", 10)
1208
+ .attr("font-size", labelFontSize)
1278
1209
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1279
1210
 
1280
1211
  if (showTooltips) {
@@ -1285,6 +1216,7 @@ function drawPhylogeny(
1285
1216
 
1286
1217
  labels
1287
1218
  .on("mouseenter", function(_event, d) {
1219
+ hoverLayer.selectAll("*").remove();
1288
1220
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1289
1221
  d3.select(this).attr("font-weight", 600);
1290
1222
  })
@@ -1311,7 +1243,6 @@ function drawPhylogeny(
1311
1243
 
1312
1244
  // helper to draw root→tip for rect (both vertical+horizontal)
1313
1245
  function drawRectPath(tipId, layer, stroke, width) {
1314
- layer.selectAll("*").remove();
1315
1246
  let cur = byId.get(tipId);
1316
1247
  while (cur && cur.parentId != null) {
1317
1248
  const parent = byId.get(cur.parentId);
@@ -1368,10 +1299,14 @@ function drawPhylogeny(
1368
1299
  const END_CAP = 0;
1369
1300
 
1370
1301
  // ===== SCALES / BOUNDS =====
1371
- const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
1372
- const scaleRadial = maxRadius + 2 * radialMargin;
1373
1302
  const w = width,
1374
1303
  h = height;
1304
+ const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
1305
+ // radialMargin is in pixels: tips sit (radialMargin) px from the SVG edge.
1306
+ // Derive the data-space scale so that radiusPx(maxRadius) = w/2 - radialMargin.
1307
+ const scaleRadial = maxRadius > 0
1308
+ ? maxRadius * (w / 2) / (w / 2 - radialMargin)
1309
+ : 1;
1375
1310
  const centerX = w / 2,
1376
1311
  centerY = h / 2;
1377
1312
 
@@ -1451,7 +1386,8 @@ function drawPhylogeny(
1451
1386
  radiusPx(d.radius),
1452
1387
  d.start,
1453
1388
  d.end,
1454
- d.sweep
1389
+ d.sweep ?? "ccw",
1390
+ d.largeArc ?? 0,
1455
1391
  )
1456
1392
  )
1457
1393
  .attr("fill", "none")
@@ -1566,7 +1502,7 @@ function drawPhylogeny(
1566
1502
  .attr("x", xoff)
1567
1503
  .attr("alignment-baseline", "middle")
1568
1504
  .attr("text-anchor", anchor)
1569
- .attr("font-size", 10)
1505
+ .attr("font-size", labelFontSize)
1570
1506
  .attr("fill", "black")
1571
1507
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1572
1508
  });
@@ -1580,6 +1516,8 @@ function drawPhylogeny(
1580
1516
  // label hover
1581
1517
  labels
1582
1518
  .on("mouseenter", function(_event, d) {
1519
+ hoverLines.selectAll("*").remove();
1520
+ hoverArcs.selectAll("*").remove();
1583
1521
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1584
1522
  d3.select(this).select("text").attr("font-weight", 600);
1585
1523
  })
@@ -1599,9 +1537,6 @@ function drawPhylogeny(
1599
1537
  width = 3
1600
1538
  ) {
1601
1539
  // target may be a tip node *or* a numeric tip id
1602
- lineLayer.selectAll("*").remove();
1603
- arcLayer.selectAll("*").remove();
1604
-
1605
1540
  let cur = (typeof target === "number" || typeof target === "string")
1606
1541
  ? byId.get(target)
1607
1542
  : target;
@@ -1640,18 +1575,10 @@ function drawPhylogeny(
1640
1575
  const R = radiusPx(rec.radius);
1641
1576
  return rec.sweep == null
1642
1577
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1643
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep);
1578
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1644
1579
  }
1645
1580
 
1646
1581
  if (a) {
1647
- console.log("Drawing arc:", {
1648
- childId: cur.thisId,
1649
- startDeg: (a.start * 180 / Math.PI).toFixed(2),
1650
- endDeg: (a.end * 180 / Math.PI).toFixed(2),
1651
- sweep: a.sweep,
1652
- radius: a.radius
1653
- });
1654
-
1655
1582
  arcLayer
1656
1583
  .append("path")
1657
1584
  .attr("d", pathFromArcRecord(a))
@@ -1668,6 +1595,8 @@ function drawPhylogeny(
1668
1595
  // tip dot hover
1669
1596
  tipDots
1670
1597
  .on("mouseenter", function(_event, d) {
1598
+ hoverLines.selectAll("*").remove();
1599
+ hoverArcs.selectAll("*").remove();
1671
1600
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1672
1601
  d3.select(this).attr("r", DOT_R + 2);
1673
1602
  })
@@ -1824,7 +1753,7 @@ function drawPhylogeny(
1824
1753
  .attr("x", xOffset)
1825
1754
  .attr("alignment-baseline", "middle")
1826
1755
  .attr("text-anchor", anchor)
1827
- .attr("font-size", 10)
1756
+ .attr("font-size", labelFontSize)
1828
1757
  .attr("fill", "black")
1829
1758
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1830
1759
  });