archaeopteryx 3.14.0 → 3.16.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/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.14.0
23
+ // v 3.16.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -3814,6 +3814,349 @@
3814
3814
  };
3815
3815
 
3816
3816
 
3817
+ // The nodes of `list` in PREORDER -- root first, then each subtree -- which
3818
+ // is the claim order the crowded-data rule needs and is NOT the order the
3819
+ // layout hands out. d3's descendants() is BREADTH-first (r,a,b,a1,a2,b1),
3820
+ // and the viewer reverses it for drawing, which puts the root LAST. Taking
3821
+ // that order for preorder inverts the whole rule: the leaf-most mark wins
3822
+ // and the one nearer the root is dropped, which is the opposite of what
3823
+ // the desktop does. Measured, after a review caught it: "descendants()
3824
+ // r,a,b,a1,a2,b1" and "reversed b1,a2,a1,b,a,r".
3825
+ //
3826
+ // Derived from the node objects rather than from any layout, so it cannot
3827
+ // drift with one: the root is the member whose parent is not itself in the
3828
+ // list, and children are visited in their own order. A node whose parent
3829
+ // is absent (a subtree view) roots its own walk, and anything unreachable
3830
+ // is appended so no mark is silently skipped.
3831
+ forester.preorderOf = function (list) {
3832
+ let present = new Set(list);
3833
+ let out = [];
3834
+ let seen = new Set();
3835
+ function walk(n) {
3836
+ if (seen.has(n)) {
3837
+ return;
3838
+ }
3839
+ seen.add(n);
3840
+ out.push(n);
3841
+ let kids = n.children;
3842
+ if (kids) {
3843
+ for (let i = 0; i < kids.length; ++i) {
3844
+ if (present.has(kids[i])) {
3845
+ walk(kids[i]);
3846
+ }
3847
+ }
3848
+ }
3849
+ }
3850
+ for (let i = 0; i < list.length; ++i) {
3851
+ let n = list[i];
3852
+ if (!n.parent || !present.has(n.parent)) {
3853
+ walk(n);
3854
+ }
3855
+ }
3856
+ for (let i = 0; i < list.length; ++i) {
3857
+ if (!seen.has(list[i])) {
3858
+ out.push(list[i]); // a cycle or a detached node: never dropped
3859
+ }
3860
+ }
3861
+ return out;
3862
+ };
3863
+
3864
+ // The box to claim for a mark that RIDES ITS BRANCH -- the circular and
3865
+ // unrooted layouts, where a label is turned to the branch's direction
3866
+ // instead of standing along a row. The axis-aligned bounds of the turned
3867
+ // rectangle, centred on (cx, cy):
3868
+ //
3869
+ // rot_w = |w*cos(a)| + |h*sin(a)|
3870
+ // rot_h = |w*sin(a)| + |h*cos(a)|
3871
+ //
3872
+ // The desktop's formula, verbatim (TreePanel.paintBranchDataRadial), and
3873
+ // the second half of the joint rule below. It carries between the two
3874
+ // programs because both measure it in real SCREEN space -- the layout's
3875
+ // own units do not survive the trip, which is why this was parked as a
3876
+ // divergence until forester-71 sent the arithmetic.
3877
+ //
3878
+ // Deliberately conservative: a diagonal label's bounds are wider than its
3879
+ // ink, so a crowded number is dropped rather than overprinted. Exact at
3880
+ // the right angles, worst at 45 degrees. Insensitive to the sign of the
3881
+ // angle and to a half turn, which is what lets the 180-degree flip that
3882
+ // keeps the far half of a fan upright leave the box alone.
3883
+ forester.rotatedLabelBox = function (cx, cy, w, h, angle) {
3884
+ let cos = Math.abs(Math.cos(angle));
3885
+ let sin = Math.abs(Math.sin(angle));
3886
+ let rw = (w * cos) + (h * sin);
3887
+ let rh = (w * sin) + (h * cos);
3888
+ return [cx - (rw / 2), cy - (rh / 2), rw, rh];
3889
+ };
3890
+
3891
+ // The occupancy map behind "auto-hide crowded branch data": a mark is
3892
+ // drawn only when its box overlaps nothing already granted in the same
3893
+ // pass. Ported from the desktop's LabelOccupancy (0.11.161) as a JOINT
3894
+ // rule on Christian's word, 2026-09-25, so neither side retunes it alone.
3895
+ //
3896
+ // Here rather than in the viewer because it is a pure algorithm and the
3897
+ // suite can reach it: the parts that MUST match the desktop -- first claim
3898
+ // wins, a refused claim records nothing, a zero-size box is granted and
3899
+ // not recorded, and boxes that merely touch do not overlap -- are exactly
3900
+ // the parts no screenshot would show going wrong.
3901
+ // A uniform grid rather than all-pairs: a 13,246-tip tree with branch
3902
+ // lengths on offers 18,511 boxes, and 171 million comparisons per redraw
3903
+ // is not a redraw.
3904
+ /**
3905
+ * The same first-come space-keeping as labelOccupancy, but for boxes that
3906
+ * are TURNED: each claim is a quadrilateral, and two of them overlap only
3907
+ * if their own outlines do.
3908
+ *
3909
+ * A turned label cannot use the axis-aligned map. The bounds of a rotated
3910
+ * rectangle grow with its length, so a long name lying near its spoke
3911
+ * reserves several times its own area, and the map refuses neighbours that
3912
+ * a reader can see are clear of it. Measured on a 48-label circular ring:
3913
+ * 28 pairs "overlapping" by the bounds where the outlines gave 0, and a
3914
+ * first-come pass over the same labels in the same order kept 48 by
3915
+ * outline against 32 by bounds -- sixteen names dropped for nothing.
3916
+ *
3917
+ * The grid is still used, on each quad's bounds, to find which claims are
3918
+ * worth testing; only the test itself is exact.
3919
+ *
3920
+ * @param cell - grid size, in the same units as the quads
3921
+ * @returns an object with claim(quad) and occupy(quad); a quad is four
3922
+ * [x, y] corners in order round the shape
3923
+ */
3924
+ forester.orientedOccupancy = function (cell) {
3925
+ let size = Math.max(4, cell);
3926
+ let cells = new Map();
3927
+ let boundsOf = function (quad) {
3928
+ let x0 = quad[0][0], x1 = quad[0][0], y0 = quad[0][1], y1 = quad[0][1];
3929
+ for (let i = 1; i < quad.length; ++i) {
3930
+ x0 = Math.min(x0, quad[i][0]);
3931
+ x1 = Math.max(x1, quad[i][0]);
3932
+ y0 = Math.min(y0, quad[i][1]);
3933
+ y1 = Math.max(y1, quad[i][1]);
3934
+ }
3935
+ return [x0, y0, x1, y1];
3936
+ };
3937
+ // Separating-axis test. A rectangle's own two edge normals are enough
3938
+ // for itself, but BOTH shapes' normals are needed: with only one
3939
+ // shape's, a box that clears another along the OTHER's axis is
3940
+ // reported as overlapping. Strict, so shapes that merely touch do not
3941
+ // overlap -- as in labelOccupancy.
3942
+ let overlaps = function (P, Q) {
3943
+ for (let s = 0; s < 2; ++s) {
3944
+ let R = s === 0 ? P : Q;
3945
+ for (let k = 0; k < 2; ++k) {
3946
+ let ax = R[k + 1][1] - R[k][1];
3947
+ let ay = R[k][0] - R[k + 1][0];
3948
+ let p0 = Infinity, p1 = -Infinity, q0 = Infinity, q1 = -Infinity;
3949
+ for (let i = 0; i < P.length; ++i) {
3950
+ let d = (P[i][0] * ax) + (P[i][1] * ay);
3951
+ p0 = Math.min(p0, d);
3952
+ p1 = Math.max(p1, d);
3953
+ }
3954
+ for (let i = 0; i < Q.length; ++i) {
3955
+ let d = (Q[i][0] * ax) + (Q[i][1] * ay);
3956
+ q0 = Math.min(q0, d);
3957
+ q1 = Math.max(q1, d);
3958
+ }
3959
+ if (p1 <= q0 || q1 <= p0) {
3960
+ return false;
3961
+ }
3962
+ }
3963
+ }
3964
+ return true;
3965
+ };
3966
+ let record = function (quad, b) {
3967
+ let c1 = Math.floor(b[2] / size);
3968
+ let r1 = Math.floor(b[3] / size);
3969
+ for (let c = Math.floor(b[0] / size); c <= c1; ++c) {
3970
+ for (let r = Math.floor(b[1] / size); r <= r1; ++r) {
3971
+ let key = c + ':' + r;
3972
+ let bucket = cells.get(key);
3973
+ if (!bucket) {
3974
+ bucket = [];
3975
+ cells.set(key, bucket);
3976
+ }
3977
+ bucket.push(quad);
3978
+ }
3979
+ }
3980
+ };
3981
+ let free = function (quad, b) {
3982
+ let seen = null;
3983
+ let c1 = Math.floor(b[2] / size);
3984
+ let r1 = Math.floor(b[3] / size);
3985
+ for (let c = Math.floor(b[0] / size); c <= c1; ++c) {
3986
+ for (let r = Math.floor(b[1] / size); r <= r1; ++r) {
3987
+ let bucket = cells.get(c + ':' + r);
3988
+ if (!bucket) {
3989
+ continue;
3990
+ }
3991
+ for (let i = 0; i < bucket.length; ++i) {
3992
+ // a quad spans several cells, so the same one comes up
3993
+ // more than once; the test is the expensive part
3994
+ if (seen === null) {
3995
+ seen = new Set();
3996
+ }
3997
+ if (seen.has(bucket[i])) {
3998
+ continue;
3999
+ }
4000
+ seen.add(bucket[i]);
4001
+ if (overlaps(quad, bucket[i])) {
4002
+ return false;
4003
+ }
4004
+ }
4005
+ }
4006
+ }
4007
+ return true;
4008
+ };
4009
+ let degenerate = function (quad) {
4010
+ if (!quad || quad.length !== 4) {
4011
+ return true;
4012
+ }
4013
+ let b = boundsOf(quad);
4014
+ return !((b[2] - b[0]) > 0) || !((b[3] - b[1]) > 0);
4015
+ };
4016
+ return {
4017
+ claim: function (quad) {
4018
+ if (degenerate(quad)) {
4019
+ return true; // reserves nothing, as a zero-size box does
4020
+ }
4021
+ let b = boundsOf(quad);
4022
+ if (!free(quad, b)) {
4023
+ return false;
4024
+ }
4025
+ record(quad, b);
4026
+ return true;
4027
+ },
4028
+ // record without asking, for what is drawn whatever else is there
4029
+ occupy: function (quad) {
4030
+ if (degenerate(quad)) {
4031
+ return true;
4032
+ }
4033
+ let b = boundsOf(quad);
4034
+ let clear = free(quad, b);
4035
+ record(quad, b);
4036
+ return clear;
4037
+ }
4038
+ };
4039
+ };
4040
+
4041
+ forester.labelOccupancy = function (cell) {
4042
+ let size = Math.max(4, cell);
4043
+ let cells = new Map();
4044
+ let record = function (x, y, w, h) {
4045
+ let box = [x, y, w, h];
4046
+ let c1 = Math.floor((x + w) / size);
4047
+ let r1 = Math.floor((y + h) / size);
4048
+ for (let c = Math.floor(x / size); c <= c1; ++c) {
4049
+ for (let r = Math.floor(y / size); r <= r1; ++r) {
4050
+ let key = c + ':' + r;
4051
+ let bucket = cells.get(key);
4052
+ if (!bucket) {
4053
+ bucket = [];
4054
+ cells.set(key, bucket);
4055
+ }
4056
+ bucket.push(box);
4057
+ }
4058
+ }
4059
+ };
4060
+ let free = function (x, y, w, h) {
4061
+ let c1 = Math.floor((x + w) / size);
4062
+ let r1 = Math.floor((y + h) / size);
4063
+ for (let c = Math.floor(x / size); c <= c1; ++c) {
4064
+ for (let r = Math.floor(y / size); r <= r1; ++r) {
4065
+ let bucket = cells.get(c + ':' + r);
4066
+ if (!bucket) {
4067
+ continue;
4068
+ }
4069
+ for (let i = 0; i < bucket.length; ++i) {
4070
+ let b = bucket[i];
4071
+ // strict: boxes that merely touch do not overlap
4072
+ if (x < (b[0] + b[2]) && (x + w) > b[0]
4073
+ && y < (b[1] + b[3]) && (y + h) > b[1]) {
4074
+ return false;
4075
+ }
4076
+ }
4077
+ }
4078
+ }
4079
+ return true;
4080
+ };
4081
+ return {
4082
+ // true when nothing already granted overlaps, and then recorded
4083
+ claim: function (x, y, w, h) {
4084
+ if (!(w > 0) || !(h > 0)) {
4085
+ return true; // reserves no space; recording it would only cost comparisons
4086
+ }
4087
+ if (!free(x, y, w, h)) {
4088
+ return false;
4089
+ }
4090
+ record(x, y, w, h);
4091
+ return true;
4092
+ },
4093
+ // Record without asking, for what is drawn whatever else is there.
4094
+ // A refused CLAIM records nothing, so a label put through claim()
4095
+ // and refused would leave the space open and let a number print
4096
+ // straight through the name it lost to -- the opposite of the
4097
+ // point. Returns whether the space was clear, for measurement;
4098
+ // the box is recorded either way.
4099
+ occupy: function (x, y, w, h) {
4100
+ if (!(w > 0) || !(h > 0)) {
4101
+ return true;
4102
+ }
4103
+ let clear = free(x, y, w, h);
4104
+ record(x, y, w, h);
4105
+ return clear;
4106
+ }
4107
+ };
4108
+ };
4109
+
4110
+ // Where a protein domain can be read about, as {url, exact} or null.
4111
+ //
4112
+ // An InterPro ENTRY is addressed by ACCESSION and by nothing else --
4113
+ // measured, not assumed: /api/entry/pfam/PF00931/ answers 200 and names
4114
+ // NB-ARC, while /api/entry/pfam/NB-ARC/, /CARD/ and /Death/ are all 404.
4115
+ // phyloXML's <domain id="..."> carries the accession when the annotation
4116
+ // had one, and most files have none (our own apaf.xml names all 166 of its
4117
+ // boxes and gives not one an id), so the fallback is an InterPro text
4118
+ // search.
4119
+ //
4120
+ // The search goes by NAME, never by the id. An id that is not a bare Pfam
4121
+ // accession -- a versioned one from an hmmscan (PF00931.24), or a SMART or
4122
+ // CDD accession -- searches to nothing, while the name is exactly what a
4123
+ // text search is for. A version suffix is stripped for the entry URL
4124
+ // rather than disqualifying it: PF00931.24 IS PF00931.
4125
+ //
4126
+ // Lives here rather than in the viewer so it can be tested: a wrong URL
4127
+ // fails silently in someone else's browser, which is the worst place to
4128
+ // find out.
4129
+ const INTERPRO_ENTRY = 'https://www.ebi.ac.uk/interpro/entry/pfam/';
4130
+ const INTERPRO_SEARCH = 'https://www.ebi.ac.uk/interpro/search/text/';
4131
+
4132
+ forester.domainReference = function (domain) {
4133
+ if (!domain) {
4134
+ return null;
4135
+ }
4136
+ let id = (domain.id || '').trim();
4137
+ let acc = /^(PF\d{5,})(?:\.\d+)?$/i.exec(id);
4138
+ if (acc) {
4139
+ return {url: INTERPRO_ENTRY + acc[1].toUpperCase() + '/', exact: true};
4140
+ }
4141
+ let q = (domain.name || '').trim() || id;
4142
+ return q ? {url: INTERPRO_SEARCH + encodeURIComponent(q) + '/', exact: false} : null;
4143
+ };
4144
+
4145
+ // A domain's E-value for display. It spans many orders of magnitude, so a
4146
+ // small one keeps its exponent; the count of significant digits is the
4147
+ // only thing normalized, because a scan writes 7.2E-117 and 1E-6 and a
4148
+ // readout that mixed those forms would read as two different measures.
4149
+ forester.domainEvalueText = function (e) {
4150
+ if (typeof e !== 'number' || !isFinite(e)) {
4151
+ return null;
4152
+ }
4153
+ if (e === 0) {
4154
+ return '0';
4155
+ }
4156
+ return (Math.abs(e) >= 0.001 && Math.abs(e) < 1e7)
4157
+ ? String(forester.roundNumber(e, 6)) : e.toExponential(2);
4158
+ };
4159
+
3817
4160
  // Everything the Tree Properties dialog reports, gathered in ONE pass:
3818
4161
  // the desktop's TreeFacts.scan, minus its histogram (Christian, 2026-09-23:
3819
4162
  // "we don't need a histogram ... but a more detailed stats would be
@@ -6933,7 +7276,13 @@
6933
7276
  ignored++;
6934
7277
  return;
6935
7278
  }
6936
- domains.push({name: d.name ? String(d.name) : '', from: from, to: to, length: to - from + 1, evalue: e});
7279
+ // `id` is phyloXML's optional domain identifier, which for a Pfam
7280
+ // scan is the accession (PF00931). Carried through because the
7281
+ // hover readout links to the entry with it when it is there, and
7282
+ // has to search by name when it is not -- apaf.xml, our own domain
7283
+ // demo, has names and no ids at all.
7284
+ domains.push({name: d.name ? String(d.name) : '', from: from, to: to,
7285
+ length: to - from + 1, evalue: e, id: d.id ? String(d.id) : ''});
6937
7286
  });
6938
7287
  domains.sort(function (a, b) {
6939
7288
  return a.from - b.from;
@@ -7035,7 +7384,8 @@
7035
7384
  if (!(w > 0) || !isFinite(w)) {
7036
7385
  return;
7037
7386
  }
7038
- boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to, evalue: d.evalue});
7387
+ boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to,
7388
+ evalue: d.evalue, id: d.id});
7039
7389
  });
7040
7390
  return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
7041
7391
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.14.0",
3
+ "version": "3.16.0",
4
4
  "description": "Archaeopteryx.js is a software tool for the visualization and analysis of highly annotated phylogenetic trees.",
5
5
  "main": "archaeopteryx.js",
6
6
  "types": "archaeopteryx.d.ts",
@@ -25,7 +25,9 @@
25
25
  "lint:fix": "eslint . --fix",
26
26
  "format": "prettier --write .",
27
27
  "format:check": "prettier --check .",
28
- "docs:sync": "cp archaeopteryx.js forester.js docs/lib/"
28
+ "docs:sync": "cp archaeopteryx.js forester.js docs/lib/",
29
+ "test:browser": "node test/browser/run.js",
30
+ "test:browser:quick": "node test/browser/run.js --quick"
29
31
  },
30
32
  "repository": {
31
33
  "type": "git",