archaeopteryx 3.13.0 → 3.15.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.13.0
23
+ // v 3.15.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -3814,6 +3814,411 @@
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
+ forester.labelOccupancy = function (cell) {
3905
+ let size = Math.max(4, cell);
3906
+ let cells = new Map();
3907
+ return {
3908
+ // true when nothing already granted overlaps, and then recorded
3909
+ claim: function (x, y, w, h) {
3910
+ if (!(w > 0) || !(h > 0)) {
3911
+ return true; // reserves no space; recording it would only cost comparisons
3912
+ }
3913
+ let c0 = Math.floor(x / size), c1 = Math.floor((x + w) / size);
3914
+ let r0 = Math.floor(y / size), r1 = Math.floor((y + h) / size);
3915
+ for (let c = c0; c <= c1; ++c) {
3916
+ for (let r = r0; r <= r1; ++r) {
3917
+ let bucket = cells.get(c + ':' + r);
3918
+ if (!bucket) {
3919
+ continue;
3920
+ }
3921
+ for (let i = 0; i < bucket.length; ++i) {
3922
+ let b = bucket[i];
3923
+ // strict: boxes that merely touch do not overlap
3924
+ if (x < (b[0] + b[2]) && (x + w) > b[0]
3925
+ && y < (b[1] + b[3]) && (y + h) > b[1]) {
3926
+ return false;
3927
+ }
3928
+ }
3929
+ }
3930
+ }
3931
+ let box = [x, y, w, h];
3932
+ for (let c = c0; c <= c1; ++c) {
3933
+ for (let r = r0; r <= r1; ++r) {
3934
+ let key = c + ':' + r;
3935
+ let bucket = cells.get(key);
3936
+ if (!bucket) {
3937
+ bucket = [];
3938
+ cells.set(key, bucket);
3939
+ }
3940
+ bucket.push(box);
3941
+ }
3942
+ }
3943
+ return true;
3944
+ }
3945
+ };
3946
+ };
3947
+
3948
+ // Where a protein domain can be read about, as {url, exact} or null.
3949
+ //
3950
+ // An InterPro ENTRY is addressed by ACCESSION and by nothing else --
3951
+ // measured, not assumed: /api/entry/pfam/PF00931/ answers 200 and names
3952
+ // NB-ARC, while /api/entry/pfam/NB-ARC/, /CARD/ and /Death/ are all 404.
3953
+ // phyloXML's <domain id="..."> carries the accession when the annotation
3954
+ // had one, and most files have none (our own apaf.xml names all 166 of its
3955
+ // boxes and gives not one an id), so the fallback is an InterPro text
3956
+ // search.
3957
+ //
3958
+ // The search goes by NAME, never by the id. An id that is not a bare Pfam
3959
+ // accession -- a versioned one from an hmmscan (PF00931.24), or a SMART or
3960
+ // CDD accession -- searches to nothing, while the name is exactly what a
3961
+ // text search is for. A version suffix is stripped for the entry URL
3962
+ // rather than disqualifying it: PF00931.24 IS PF00931.
3963
+ //
3964
+ // Lives here rather than in the viewer so it can be tested: a wrong URL
3965
+ // fails silently in someone else's browser, which is the worst place to
3966
+ // find out.
3967
+ const INTERPRO_ENTRY = 'https://www.ebi.ac.uk/interpro/entry/pfam/';
3968
+ const INTERPRO_SEARCH = 'https://www.ebi.ac.uk/interpro/search/text/';
3969
+
3970
+ forester.domainReference = function (domain) {
3971
+ if (!domain) {
3972
+ return null;
3973
+ }
3974
+ let id = (domain.id || '').trim();
3975
+ let acc = /^(PF\d{5,})(?:\.\d+)?$/i.exec(id);
3976
+ if (acc) {
3977
+ return {url: INTERPRO_ENTRY + acc[1].toUpperCase() + '/', exact: true};
3978
+ }
3979
+ let q = (domain.name || '').trim() || id;
3980
+ return q ? {url: INTERPRO_SEARCH + encodeURIComponent(q) + '/', exact: false} : null;
3981
+ };
3982
+
3983
+ // A domain's E-value for display. It spans many orders of magnitude, so a
3984
+ // small one keeps its exponent; the count of significant digits is the
3985
+ // only thing normalized, because a scan writes 7.2E-117 and 1E-6 and a
3986
+ // readout that mixed those forms would read as two different measures.
3987
+ forester.domainEvalueText = function (e) {
3988
+ if (typeof e !== 'number' || !isFinite(e)) {
3989
+ return null;
3990
+ }
3991
+ if (e === 0) {
3992
+ return '0';
3993
+ }
3994
+ return (Math.abs(e) >= 0.001 && Math.abs(e) < 1e7)
3995
+ ? String(forester.roundNumber(e, 6)) : e.toExponential(2);
3996
+ };
3997
+
3998
+ // Everything the Tree Properties dialog reports, gathered in ONE pass:
3999
+ // the desktop's TreeFacts.scan, minus its histogram (Christian, 2026-09-23:
4000
+ // "we don't need a histogram ... but a more detailed stats would be
4001
+ // useful, min, median, max, n, also type ... also need to include the
4002
+ // properties").
4003
+ //
4004
+ // Side-effect free and DOM-free, so it can be tested in Node -- the whole
4005
+ // reason it lives here rather than in the dialog that renders it, which is
4006
+ // the same split the desktop makes between TreeFacts and
4007
+ // TreePropertiesForm.
4008
+ //
4009
+ // Support values are grouped BY TYPE, because a tree can carry a bootstrap
4010
+ // and a posterior on the same branch and their ranges are not comparable
4011
+ // (0-100 against 0-1); averaging them together would produce a number that
4012
+ // describes neither. MAD values are excluded for the same reason they are
4013
+ // excluded from the Newick support slot: they rate a root position, not a
4014
+ // clade.
4015
+ forester.treeStatistics = function (phyOrNode) {
4016
+ // Callers hand this either a phylogeny wrapper or the root node, and
4017
+ // the difference is one extra internal node, one extra branch and one
4018
+ // more of depth -- small enough to read as plausible. getTreeRoot
4019
+ // normalizes both, as collectBasicTreeProperties does.
4020
+ let root = phyOrNode ? forester.getTreeRoot(phyOrNode) : null;
4021
+ let stats = {
4022
+ tips: 0, internal: 0, nodes: 0, branches: 0,
4023
+ polytomies: 0, maxChildren: 0,
4024
+ depth: 0, height: 0, heightKnown: false,
4025
+ collapsed: 0,
4026
+ branchLengths: null,
4027
+ support: [],
4028
+ ultrametric: false,
4029
+ tipsWithTaxonomy: 0, distinctTaxonomies: 0, tipsWithTaxonomyId: 0,
4030
+ tipsWithSequence: 0, tipsWithMolSeq: 0, tipsWithDomains: 0,
4031
+ nodesWithDate: 0, tipsWithDistribution: 0, namedInternal: 0,
4032
+ duplications: 0, speciations: 0, nodesWithEvents: 0,
4033
+ propertyRefs: []
4034
+ };
4035
+ if (!root) {
4036
+ return stats;
4037
+ }
4038
+ let lengths = [];
4039
+ let zeroLengths = 0;
4040
+ let negativeLengths = 0;
4041
+ let byType = Object.create(null);
4042
+ let taxa = Object.create(null);
4043
+ let refs = Object.create(null);
4044
+ let refOrder = [];
4045
+ let tipDepths = [];
4046
+ let maxDepth = 0;
4047
+
4048
+ (function walk(n, depth, distance) {
4049
+ ++stats.nodes;
4050
+ let kids = n.children || [];
4051
+ if (depth > maxDepth) {
4052
+ maxDepth = depth;
4053
+ }
4054
+ if (n !== root) {
4055
+ ++stats.branches;
4056
+ let b = n.branch_length;
4057
+ if (typeof b === 'number' && isFinite(b)) {
4058
+ lengths.push(b);
4059
+ if (b === 0) {
4060
+ ++zeroLengths;
4061
+ } else if (b < 0) {
4062
+ ++negativeLengths;
4063
+ }
4064
+ distance += b;
4065
+ }
4066
+ }
4067
+ // support, by type, MAD left out
4068
+ if (n.confidences) {
4069
+ for (let i = 0; i < n.confidences.length; ++i) {
4070
+ let c = n.confidences[i];
4071
+ if (c.type === forester.MAD_CONFIDENCE_TYPE) {
4072
+ continue;
4073
+ }
4074
+ if (typeof c.value !== 'number' || !isFinite(c.value)) {
4075
+ continue;
4076
+ }
4077
+ let key = c.type || '';
4078
+ if (!byType[key]) {
4079
+ byType[key] = [];
4080
+ stats.support.push({type: key, values: byType[key]});
4081
+ }
4082
+ byType[key].push(c.value);
4083
+ }
4084
+ }
4085
+ if (n.date && typeof n.date.value === 'number' && isFinite(n.date.value)) {
4086
+ ++stats.nodesWithDate;
4087
+ }
4088
+ if (n.properties) {
4089
+ let seen = Object.create(null);
4090
+ for (let i = 0; i < n.properties.length; ++i) {
4091
+ let ref = n.properties[i] && n.properties[i].ref;
4092
+ if (!ref || seen[ref]) {
4093
+ continue; // a node carrying a ref twice counts once
4094
+ }
4095
+ seen[ref] = true;
4096
+ if (refs[ref] === undefined) {
4097
+ refs[ref] = 0;
4098
+ refOrder.push(ref);
4099
+ }
4100
+ ++refs[ref];
4101
+ }
4102
+ }
4103
+ if (n.events) {
4104
+ ++stats.nodesWithEvents;
4105
+ if (n.events.duplications > 0) {
4106
+ stats.duplications += n.events.duplications;
4107
+ }
4108
+ if (n.events.speciations > 0) {
4109
+ stats.speciations += n.events.speciations;
4110
+ }
4111
+ }
4112
+ if (kids.length > 0) {
4113
+ ++stats.internal;
4114
+ if (kids.length > stats.maxChildren) {
4115
+ stats.maxChildren = kids.length;
4116
+ }
4117
+ if (kids.length > 2) {
4118
+ ++stats.polytomies;
4119
+ }
4120
+ if (n !== root && n.name) {
4121
+ ++stats.namedInternal;
4122
+ }
4123
+ for (let i = 0; i < kids.length; ++i) {
4124
+ walk(kids[i], depth + 1, distance);
4125
+ }
4126
+ return;
4127
+ }
4128
+ ++stats.tips;
4129
+ tipDepths.push(distance);
4130
+ if (n.taxonomies && n.taxonomies.length > 0) {
4131
+ ++stats.tipsWithTaxonomy;
4132
+ let t = n.taxonomies[0];
4133
+ let label = t.scientific_name || t.code || t.common_name || '';
4134
+ if (label) {
4135
+ taxa[label] = true;
4136
+ }
4137
+ if (t.id && t.id.value) {
4138
+ ++stats.tipsWithTaxonomyId;
4139
+ }
4140
+ }
4141
+ if (n.sequences && n.sequences.length > 0) {
4142
+ ++stats.tipsWithSequence;
4143
+ let q = n.sequences[0];
4144
+ if (q.mol_seq && q.mol_seq.value) {
4145
+ ++stats.tipsWithMolSeq;
4146
+ }
4147
+ if (forester.domainArchitectureOf(n)) {
4148
+ ++stats.tipsWithDomains;
4149
+ }
4150
+ }
4151
+ if (n.distributions && n.distributions.length > 0) {
4152
+ ++stats.tipsWithDistribution;
4153
+ }
4154
+ })(root, 0, 0);
4155
+
4156
+ stats.depth = maxDepth;
4157
+ stats.distinctTaxonomies = Object.keys(taxa).length;
4158
+ stats.propertyRefs = refOrder.map(function (r) {
4159
+ return {ref: r, nodes: refs[r]};
4160
+ });
4161
+ stats.branchLengths = forester.describeValues(lengths);
4162
+ if (stats.branchLengths) {
4163
+ stats.branchLengths.zero = zeroLengths;
4164
+ stats.branchLengths.negative = negativeLengths;
4165
+ }
4166
+ stats.support = stats.support.map(function (g) {
4167
+ let d = forester.describeValues(g.values);
4168
+ d.type = g.type;
4169
+ return d;
4170
+ }).filter(function (d) {
4171
+ return d.n > 0;
4172
+ });
4173
+ if (tipDepths.length > 0) {
4174
+ // running, not Math.min.apply: the spread of 150k tip depths
4175
+ // overflows the call stack, as timeAxisInfo found before this
4176
+ let lo = Infinity;
4177
+ let hi = -Infinity;
4178
+ for (let i = 0; i < tipDepths.length; ++i) {
4179
+ if (tipDepths[i] < lo) {
4180
+ lo = tipDepths[i];
4181
+ }
4182
+ if (tipDepths[i] > hi) {
4183
+ hi = tipDepths[i];
4184
+ }
4185
+ }
4186
+ stats.height = hi;
4187
+ stats.heightKnown = lengths.length > 0;
4188
+ // ultrametric: every tip the same distance from the root, to a
4189
+ // tolerance scaled to the tree, since branch lengths are floats
4190
+ stats.ultrametric = stats.heightKnown && (hi - lo) <= (Math.max(hi, 1e-12) * 1e-6);
4191
+ }
4192
+ return stats;
4193
+ };
4194
+
4195
+ // n, minimum, median, maximum, mean and sum of a list of numbers, or null
4196
+ // when there are none. The median is the middle value of the sorted list,
4197
+ // or the mean of the two middle ones -- taken on a COPY, because sorting
4198
+ // the caller's array would reorder data it still owns.
4199
+ forester.describeValues = function (values) {
4200
+ if (!values || values.length === 0) {
4201
+ return null;
4202
+ }
4203
+ let v = values.slice().sort(function (a, b) {
4204
+ return a - b;
4205
+ });
4206
+ let n = v.length;
4207
+ let sum = 0;
4208
+ for (let i = 0; i < n; ++i) {
4209
+ sum += v[i];
4210
+ }
4211
+ let mid = Math.floor(n / 2);
4212
+ return {
4213
+ n: n,
4214
+ min: v[0],
4215
+ max: v[n - 1],
4216
+ median: (n % 2 === 1) ? v[mid] : ((v[mid - 1] + v[mid]) / 2),
4217
+ mean: sum / n,
4218
+ sum: sum
4219
+ };
4220
+ };
4221
+
3817
4222
  forester.calcMaxBranchLength = function (node) {
3818
4223
  let max = 0;
3819
4224
  forester.preOrderTraversalAll(node, function (n) {
@@ -6709,7 +7114,13 @@
6709
7114
  ignored++;
6710
7115
  return;
6711
7116
  }
6712
- domains.push({name: d.name ? String(d.name) : '', from: from, to: to, length: to - from + 1, evalue: e});
7117
+ // `id` is phyloXML's optional domain identifier, which for a Pfam
7118
+ // scan is the accession (PF00931). Carried through because the
7119
+ // hover readout links to the entry with it when it is there, and
7120
+ // has to search by name when it is not -- apaf.xml, our own domain
7121
+ // demo, has names and no ids at all.
7122
+ domains.push({name: d.name ? String(d.name) : '', from: from, to: to,
7123
+ length: to - from + 1, evalue: e, id: d.id ? String(d.id) : ''});
6713
7124
  });
6714
7125
  domains.sort(function (a, b) {
6715
7126
  return a.from - b.from;
@@ -6811,7 +7222,8 @@
6811
7222
  if (!(w > 0) || !isFinite(w)) {
6812
7223
  return;
6813
7224
  }
6814
- boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to, evalue: d.evalue});
7225
+ boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to,
7226
+ evalue: d.evalue, id: d.id});
6815
7227
  });
6816
7228
  return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
6817
7229
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.13.0",
3
+ "version": "3.15.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",