@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.
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
  }
@@ -1022,21 +952,29 @@ function readTree(text) {
1022
952
  text = String(text).replace(/\s+/g, '');
1023
953
 
1024
954
  const tokens = text.split(/(;|\(|\)|,)/);
1025
- const root = { parent: null, children: [] };
1026
- let curnode = root;
1027
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;
1028
966
 
1029
967
  for (const token of tokens) {
1030
968
  if (!token || token === ';') continue;
1031
969
 
1032
970
  if (token === '(') {
1033
- const child = { parent: curnode, children: [] };
971
+ const child = makeNode(curnode);
1034
972
  curnode.children.push(child);
1035
973
  curnode = child; // descend
1036
974
  } else if (token === ',') {
1037
975
  // back to parent, then create sibling
1038
976
  curnode = curnode.parent;
1039
- const child = { parent: curnode, children: [] };
977
+ const child = makeNode(curnode);
1040
978
  curnode.children.push(child);
1041
979
  curnode = child;
1042
980
  } else if (token === ')') {
@@ -1045,14 +983,15 @@ function readTree(text) {
1045
983
  if (curnode === null) break;
1046
984
  } else {
1047
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.
1048
989
  const nodeinfo = token.split(':');
1049
990
  if (nodeinfo.length === 1) {
1050
991
  if (token.startsWith(':')) {
1051
- curnode.label = '';
1052
992
  curnode.branchLength = parseFloat(nodeinfo[0]);
1053
993
  } else {
1054
994
  curnode.label = nodeinfo[0];
1055
- curnode.branchLength = null;
1056
995
  }
1057
996
  } else if (nodeinfo.length === 2) {
1058
997
  curnode.label = nodeinfo[0];
@@ -1062,13 +1001,9 @@ function readTree(text) {
1062
1001
  curnode.label = nodeinfo[0] || '';
1063
1002
  curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
1064
1003
  }
1065
- curnode.id = nodeId++; // assign then increment
1066
1004
  }
1067
1005
  }
1068
1006
 
1069
- // Ensure root has an id if not assigned during parsing
1070
- if (root.id == null) root.id = nodeId;
1071
-
1072
1007
  return root;
1073
1008
  }
1074
1009
 
@@ -1128,6 +1063,7 @@ function drawPhylogeny(
1128
1063
  strokeWidth = 1, // for the phylogeny branches
1129
1064
  radialMode = "outer", // "outer" (co-circular tips) or "phylo" (true terminals)
1130
1065
  tipLabels = true,
1066
+ labelFontSize = 10, // font size (px) for tip labels
1131
1067
  showTooltips = true,
1132
1068
  tooltipFormatter = (d, rtt) =>
1133
1069
  `${d.thisLabel ?? "(unnamed)"}\nroot→tip: ${(+rtt).toFixed(4)}`,
@@ -1175,7 +1111,7 @@ function drawPhylogeny(
1175
1111
  const tips = horizontal.filter((d) => d.isTip);
1176
1112
 
1177
1113
  // indices & root→tip getter
1178
- 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
1179
1115
  const tipById = new Map(tips.map((d) => [d.thisId, d]));
1180
1116
  const tipByLabel = new Map(tips.map((d) => [d.thisLabel, d]));
1181
1117
  const rootToTip = makeRootToTipGetter(byId, { prefer: "x1" });
@@ -1249,6 +1185,7 @@ function drawPhylogeny(
1249
1185
  // interactive root→tip highlight (rect) on dot hover
1250
1186
  tipDots
1251
1187
  .on("mouseenter", function(_event, d) {
1188
+ hoverLayer.selectAll("*").remove();
1252
1189
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1253
1190
  d3.select(this).attr("r", 4);
1254
1191
  })
@@ -1268,7 +1205,7 @@ function drawPhylogeny(
1268
1205
  .attr("x", (d) => xScale(d.x1) + 4)
1269
1206
  .attr("y", (d) => yScale(d.y1))
1270
1207
  .attr("dy", "0.32em")
1271
- .attr("font-size", 10)
1208
+ .attr("font-size", labelFontSize)
1272
1209
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1273
1210
 
1274
1211
  if (showTooltips) {
@@ -1279,6 +1216,7 @@ function drawPhylogeny(
1279
1216
 
1280
1217
  labels
1281
1218
  .on("mouseenter", function(_event, d) {
1219
+ hoverLayer.selectAll("*").remove();
1282
1220
  drawRectPath(d.thisId, hoverLayer, hoverStroke, hoverWidth);
1283
1221
  d3.select(this).attr("font-weight", 600);
1284
1222
  })
@@ -1305,7 +1243,6 @@ function drawPhylogeny(
1305
1243
 
1306
1244
  // helper to draw root→tip for rect (both vertical+horizontal)
1307
1245
  function drawRectPath(tipId, layer, stroke, width) {
1308
- layer.selectAll("*").remove();
1309
1246
  let cur = byId.get(tipId);
1310
1247
  while (cur && cur.parentId != null) {
1311
1248
  const parent = byId.get(cur.parentId);
@@ -1362,10 +1299,14 @@ function drawPhylogeny(
1362
1299
  const END_CAP = 0;
1363
1300
 
1364
1301
  // ===== SCALES / BOUNDS =====
1365
- const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
1366
- const scaleRadial = maxRadius + 2 * radialMargin;
1367
1302
  const w = width,
1368
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;
1369
1310
  const centerX = w / 2,
1370
1311
  centerY = h / 2;
1371
1312
 
@@ -1445,8 +1386,8 @@ function drawPhylogeny(
1445
1386
  radiusPx(d.radius),
1446
1387
  d.start,
1447
1388
  d.end,
1448
- d.sweep,
1449
- d.largeArc,
1389
+ d.sweep ?? "ccw",
1390
+ d.largeArc ?? 0,
1450
1391
  )
1451
1392
  )
1452
1393
  .attr("fill", "none")
@@ -1561,7 +1502,7 @@ function drawPhylogeny(
1561
1502
  .attr("x", xoff)
1562
1503
  .attr("alignment-baseline", "middle")
1563
1504
  .attr("text-anchor", anchor)
1564
- .attr("font-size", 10)
1505
+ .attr("font-size", labelFontSize)
1565
1506
  .attr("fill", "black")
1566
1507
  .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1567
1508
  });
@@ -1575,6 +1516,8 @@ function drawPhylogeny(
1575
1516
  // label hover
1576
1517
  labels
1577
1518
  .on("mouseenter", function(_event, d) {
1519
+ hoverLines.selectAll("*").remove();
1520
+ hoverArcs.selectAll("*").remove();
1578
1521
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1579
1522
  d3.select(this).select("text").attr("font-weight", 600);
1580
1523
  })
@@ -1594,9 +1537,6 @@ function drawPhylogeny(
1594
1537
  width = 3
1595
1538
  ) {
1596
1539
  // target may be a tip node *or* a numeric tip id
1597
- lineLayer.selectAll("*").remove();
1598
- arcLayer.selectAll("*").remove();
1599
-
1600
1540
  let cur = (typeof target === "number" || typeof target === "string")
1601
1541
  ? byId.get(target)
1602
1542
  : target;
@@ -1635,18 +1575,10 @@ function drawPhylogeny(
1635
1575
  const R = radiusPx(rec.radius);
1636
1576
  return rec.sweep == null
1637
1577
  ? describeArc(centerX, centerY, R, rec.start, rec.end)
1638
- : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep, a.largeArc);
1578
+ : describeArcSweep(centerX, centerY, R, rec.start, rec.end, rec.sweep ?? "ccw", rec.largeArc ?? 0);
1639
1579
  }
1640
1580
 
1641
1581
  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
1582
  arcLayer
1651
1583
  .append("path")
1652
1584
  .attr("d", pathFromArcRecord(a))
@@ -1663,6 +1595,8 @@ function drawPhylogeny(
1663
1595
  // tip dot hover
1664
1596
  tipDots
1665
1597
  .on("mouseenter", function(_event, d) {
1598
+ hoverLines.selectAll("*").remove();
1599
+ hoverArcs.selectAll("*").remove();
1666
1600
  drawRadialPath(d, hoverLines, hoverArcs, hoverStroke, hoverWidth);
1667
1601
  d3.select(this).attr("r", DOT_R + 2);
1668
1602
  })
@@ -1819,7 +1753,7 @@ function drawPhylogeny(
1819
1753
  .attr("x", xOffset)
1820
1754
  .attr("alignment-baseline", "middle")
1821
1755
  .attr("text-anchor", anchor)
1822
- .attr("font-size", 10)
1756
+ .attr("font-size", labelFontSize)
1823
1757
  .attr("fill", "black")
1824
1758
  .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1825
1759
  });