archaeopteryx 3.11.0 → 3.13.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.11.0
23
+ // v 3.13.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -5010,6 +5010,40 @@
5010
5010
  });
5011
5011
  };
5012
5012
 
5013
+ // Residue normalization for a Nexus character matrix, ported from the
5014
+ // desktop's BasicSequence.createAaSequence / createDnaSequence /
5015
+ // createRnaSequence, which is what its Nexus reader builds a sequence
5016
+ // with. Three steps, in this order:
5017
+ // 1. uppercase;
5018
+ // 2. '.' becomes the gap '-' (before step 3, or it would become X/N);
5019
+ // 3. anything outside the type's alphabet becomes the unspecified
5020
+ // residue, 'X' for protein and 'N' for nucleotides.
5021
+ // So the Nexus missing symbol '?' arrives as X or N, which is what
5022
+ // Christian decided on 2026-09-23 ("normalize to X, same as desktop").
5023
+ // '?' is simply one character outside the alphabet; doing it alone would
5024
+ // have left us differing on '.' and on stray letters instead.
5025
+ //
5026
+ // This is the NEXUS MATRIX reader only. A phyloXML <mol_seq> is kept
5027
+ // exactly as written by both programs -- measured, not assumed: the
5028
+ // desktop reads "MK??AL.N" out of phyloXML unchanged.
5029
+ const AA_NOT_ALPHABET = /[^ARNDBCQEZGHILKMFPSTWYVXUO\-*]/g;
5030
+ const DNA_NOT_ALPHABET = /[^ACGTRYMKWSN\-*]/g;
5031
+ const RNA_NOT_ALPHABET = /[^ACGURYMKWSN\-*]/g;
5032
+
5033
+ function normalizeMatrixResidues(block, datatype) {
5034
+ let out = block.toUpperCase().replace(/\./g, '-');
5035
+ if (datatype === 'protein') {
5036
+ return out.replace(AA_NOT_ALPHABET, 'X');
5037
+ }
5038
+ if (datatype === 'dna') {
5039
+ return out.replace(DNA_NOT_ALPHABET, 'N');
5040
+ }
5041
+ if (datatype === 'rna') {
5042
+ return out.replace(RNA_NOT_ALPHABET, 'N');
5043
+ }
5044
+ return out;
5045
+ }
5046
+
5013
5047
  // Parses a Nexus-formatted string and returns an ARRAY of tree objects,
5014
5048
  // each in the same shape parseNewHampshire produces (a Nexus file can
5015
5049
  // hold any number of trees). Ported from the desktop's
@@ -5031,6 +5065,10 @@
5031
5065
  const RESIDUES_RE = /^[A-Za-z\-_*?.]+$/;
5032
5066
  const DATATYPE_RE = /datatype\s*=\s*([a-z]+)/;
5033
5067
  const MATCHCHAR_RE = /matchchar\s*=\s*['"]?(\S)/;
5068
+ // the desktop's patterns, character for character: one optional space
5069
+ // either side of the '=', then the first non-space as the symbol
5070
+ const MISSING_RE = /missing\s?.\s?(\S)/;
5071
+ const GAP_RE = /gap\s?.\s?(\S)/;
5034
5072
 
5035
5073
  let trees = [];
5036
5074
  let taxlabels = [];
@@ -5058,6 +5096,22 @@
5058
5096
  let isRooted = false;
5059
5097
  let matchchar = null;
5060
5098
  let matrixReferenceId = null;
5099
+ // The symbols a block DECLARES for missing data and for a gap. Nexus
5100
+ // lets a file choose them, so assuming '?' and '-' reads an all-missing
5101
+ // row of a "Missing=N" file as real residues -- the invented-sequence
5102
+ // bug the residue scan exists to prevent. Defaults are the conventions.
5103
+ let missingChar = '?';
5104
+ let gapChar = '-';
5105
+ // Matrix ids whose row carried at least one real residue. A row of
5106
+ // nothing but the missing and gap symbols states that the taxon has NO
5107
+ // data, and must not become a sequence: our own writer emits exactly
5108
+ // such rows to keep the matrix rectangular, so without this a save and
5109
+ // reopen invents a sequence of question marks for every tip that never
5110
+ // had one. Tracked here rather than tested on the finished sequence
5111
+ // because the desktop turns '?' into 'X' when it builds an AA
5112
+ // sequence, after which a missing row is indistinguishable from a
5113
+ // genuinely ambiguous one.
5114
+ let seqIdsWithResidues = Object.create(null);
5061
5115
 
5062
5116
  // Nexus treats '_' and ' ' as equivalent, labels may be quoted, and a
5063
5117
  // matrix often capitalizes taxon names differently from the tree -- so
@@ -5168,6 +5222,19 @@
5168
5222
  }
5169
5223
  block = resolved;
5170
5224
  }
5225
+ // '*' is NOT absence: it is a residue, the stop codon of a
5226
+ // translated alignment. What counts as absence is what the block
5227
+ // DECLARED, plus '.', which is the matchchar and gap convention.
5228
+ for (let j = 0; j < block.length; ++j) {
5229
+ let c = block.charAt(j).toLowerCase();
5230
+ if (c !== missingChar.toLowerCase() && c !== gapChar.toLowerCase() && c !== '.') {
5231
+ seqIdsWithResidues[id] = true;
5232
+ break;
5233
+ }
5234
+ }
5235
+ // ... and only THEN normalize: afterwards '?' has become X or N
5236
+ // and a row of nothing but missing data would read as residues.
5237
+ block = normalizeMatrixResidues(block, datatype);
5171
5238
  seqs[id] = {
5172
5239
  value: seqs[id] ? (seqs[id].value + block) : block,
5173
5240
  type: datatype
@@ -5200,7 +5267,9 @@
5200
5267
  }
5201
5268
  let seqsByKey = Object.create(null);
5202
5269
  for (let id in seqs) {
5203
- seqsByKey[joinKey(id)] = seqs[id];
5270
+ if (seqIdsWithResidues[id]) {
5271
+ seqsByKey[joinKey(id)] = seqs[id];
5272
+ }
5204
5273
  }
5205
5274
  let externals = forester.getAllExternalNodes(phy);
5206
5275
  let annotationNs = null;
@@ -5331,6 +5400,9 @@
5331
5400
  // scope the rows to THIS matrix block, so a later block
5332
5401
  // cannot cross-contaminate an earlier one
5333
5402
  seqs = Object.create(null);
5403
+ seqIdsWithResidues = Object.create(null);
5404
+ missingChar = '?';
5405
+ gapChar = '-';
5334
5406
  } else if (inTreesBlock) {
5335
5407
  if (lc.startsWith('title')) {
5336
5408
  let tm = TITLE_RE.exec(line);
@@ -5522,6 +5594,14 @@
5522
5594
  if (mm) {
5523
5595
  matchchar = mm[1];
5524
5596
  }
5597
+ let miss = MISSING_RE.exec(dlc);
5598
+ if (miss) {
5599
+ missingChar = miss[1];
5600
+ }
5601
+ let gp = GAP_RE.exec(dlc);
5602
+ if (gp) {
5603
+ gapChar = gp[1];
5604
+ }
5525
5605
  if (dlc === 'matrix' || dlc.startsWith('matrix ')) {
5526
5606
  inMatrix = true;
5527
5607
  let after = line.substring(6).trim();
@@ -6087,9 +6167,90 @@
6087
6167
  * @param writeConfidences - to write confidence values in brackets
6088
6168
  * @returns {*} - a New Hampshire (Newick) formatted string.
6089
6169
  */
6170
+ // The label a node is written under, in New Hampshire and in Nexus alike:
6171
+ // name, then taxonomy (code / scientific / common), then the sequence's
6172
+ // name / symbol / gene name, then its ACCESSION -- each step tried in turn,
6173
+ // so a taxonomy element that is present but empty does not stop the search.
6174
+ // An EXTERNAL node left with nothing gets a 'node<N>' placeholder, N being
6175
+ // its 1-based position in tip order; an empty label would not parse back
6176
+ // out of TaxLabels, and in Newick it names nothing at all. An INTERNAL node
6177
+ // does NOT get one: a placeholder there would invent a name for an
6178
+ // ancestor, and an unlabeled internal node is perfectly ordinary.
6179
+ //
6180
+ // ONE chain for both writers. A Nexus file whose TaxLabels, matrix rows and
6181
+ // trees block disagree about a taxon cannot be joined back up, and a tree
6182
+ // saved as Newick and as Nexus should name its tips the same way -- ours
6183
+ // did not, until 2026-09-23: toNexus applied this chain and toNewHampshire
6184
+ // wrote node.name and nothing else, so a nameless tip was HUMAN in one file
6185
+ // and empty in the other. It is the desktop's chain
6186
+ // (PhylogenyNode.toNewHampshire), adopted on Christian's word, and the two
6187
+ // programs' output is compared byte for byte in the tests.
6188
+ function nhNodeLabel(node, placeholder) {
6189
+ let s = node.name || '';
6190
+ if (!s && node.taxonomies && node.taxonomies.length > 0) {
6191
+ let t = node.taxonomies[0];
6192
+ s = t.code || t.scientific_name || t.common_name || '';
6193
+ }
6194
+ if (!s && node.sequences && node.sequences.length > 0) {
6195
+ let q = node.sequences[0];
6196
+ s = q.name || q.symbol || q.gene_name || '';
6197
+ }
6198
+ if (!s && node.sequences && node.sequences.length > 0) {
6199
+ let a = node.sequences[0].accession;
6200
+ s = (a && a.value) ? a.value : '';
6201
+ }
6202
+ if (!s && placeholder) {
6203
+ s = placeholder;
6204
+ }
6205
+ return s;
6206
+ }
6207
+
6208
+ // A placeholder label for every external node, by tip index, in the order
6209
+ // the tips are written (getAllExternalNodes collects them in pre-order from
6210
+ // the far side, so reversing it gives left-to-right). Used only for a node
6211
+ // that nothing else names.
6212
+ //
6213
+ // A tip may literally be CALLED "node2". Minting that same token for a
6214
+ // different tip gives two taxa one label: illegal Nexus, and our own reader
6215
+ // takes the repeated row for an interleaved continuation and hands both
6216
+ // tips the two sequences joined together. So the labels the tree already
6217
+ // produces are collected first and stepped over.
6218
+ function tipPlaceholders(phy) {
6219
+ let m = new Map();
6220
+ // An EMPTY tree has no tips to number. Its single top node looks like
6221
+ // an external node -- no children -- and numbering it would turn the
6222
+ // empty string, which parseNewHampshire accepts and toNewHampshire has
6223
+ // always written back as the empty string, into "node1;". (The desktop
6224
+ // throws on an empty string rather than parsing it, so there is no
6225
+ // joint behaviour to match here, only ours to keep.)
6226
+ if (!phy.children || phy.children.length !== 1 || !phy.children[0].children) {
6227
+ return m;
6228
+ }
6229
+ let ext = forester.getAllExternalNodes(phy).reverse();
6230
+ let taken = Object.create(null);
6231
+ ext.forEach(function (n) {
6232
+ let label = nhNodeLabel(n, null);
6233
+ if (label.length > 0) {
6234
+ taken[label] = true;
6235
+ }
6236
+ });
6237
+ let i = 1;
6238
+ let spare = ext.length + 1;
6239
+ ext.forEach(function (n) {
6240
+ let candidate = 'node' + (i++);
6241
+ while (taken[candidate]) {
6242
+ candidate = 'node' + (spare++);
6243
+ }
6244
+ taken[candidate] = true;
6245
+ m.set(n, candidate);
6246
+ });
6247
+ return m;
6248
+ }
6249
+
6090
6250
  forester.toNewHampshire = function (phy, decPointsMax, replaceChars, writeConfidences) {
6091
6251
  void replaceChars; // retired: see the note above; labels are always quoted now
6092
6252
  let nh = "";
6253
+ let tips = tipPlaceholders(phy);
6093
6254
  if (phy.children && phy.children.length === 1) {
6094
6255
  toNewHampshireHelper(phy.children[0], true);
6095
6256
  }
@@ -6107,8 +6268,9 @@
6107
6268
  }
6108
6269
  nh += ")";
6109
6270
  }
6110
- if (node.name && node.name.length > 0) {
6111
- nh += sanitizeLabelForNH(node.name);
6271
+ let label = nhNodeLabel(node, tips.get(node));
6272
+ if (label.length > 0) {
6273
+ nh += sanitizeLabelForNH(label);
6112
6274
  }
6113
6275
  if (node.branch_length !== undefined && node.branch_length !== null) {
6114
6276
  if (decPointsMax && decPointsMax > 0) {
@@ -6120,10 +6282,21 @@
6120
6282
  // the support slot holds support: a MAD value (madRoot) never goes
6121
6283
  // there -- it would read as support, and would crowd out the
6122
6284
  // bootstrap on a branch carrying both. phyloXML keeps it, typed.
6285
+ //
6286
+ // Newick has ONE slot and phyloXML allows many, so a node carrying
6287
+ // both a bootstrap and a posterior has to lose one of them. It
6288
+ // writes the FIRST that is not MAD, which is what the desktop's
6289
+ // BranchData.getSupportConfidence returns and therefore what its
6290
+ // writer emits -- verified by running it: a node with
6291
+ // bootstrap=95 and posterior=0.99 comes out as "ab:0.3[95]".
6292
+ // This used to require EXACTLY one and so wrote nothing at all,
6293
+ // which lost the bootstrap too, on real files: the repo's own
6294
+ // phyloWithConfidences.xml has a node carrying bootstrap and
6295
+ // likelihood together.
6123
6296
  let support = writeConfidences && node.confidences
6124
6297
  ? node.confidences.filter(function (c) { return c.type !== forester.MAD_CONFIDENCE_TYPE; })
6125
6298
  : [];
6126
- if (support.length === 1 && support[0].value !== undefined && support[0].value !== null) {
6299
+ if (support.length > 0 && support[0].value !== undefined && support[0].value !== null) {
6127
6300
  if (decPointsMax && decPointsMax > 0) {
6128
6301
  nh += "[" + forester.roundNumber(support[0].value, decPointsMax) + "]";
6129
6302
  } else {
@@ -6137,6 +6310,70 @@
6137
6310
 
6138
6311
  };
6139
6312
 
6313
+ // A node's molecular sequence for the Nexus matrix: the first one carrying
6314
+ // residues, or null. is_aligned is deliberately NOT consulted -- the
6315
+ // desktop's writer does not have that flag and decides on the lengths
6316
+ // instead, and equal lengths are what a character matrix actually
6317
+ // requires. (A JS node may hold several sequences where a desktop node
6318
+ // holds one; the first with residues is the one written.)
6319
+ function molSeqOfNode(node) {
6320
+ if (!node.sequences) {
6321
+ return null;
6322
+ }
6323
+ for (let j = 0; j < node.sequences.length; ++j) {
6324
+ let q = node.sequences[j];
6325
+ if (q.mol_seq && q.mol_seq.value) {
6326
+ return q.mol_seq.value;
6327
+ }
6328
+ }
6329
+ return null;
6330
+ }
6331
+
6332
+ // ForesterUtil.guessMolecularSequenceType, ported verbatim so that the two
6333
+ // programs declare the same DataType for the same residues: the letters a
6334
+ // nucleotide sequence cannot contain, then T for DNA and U for RNA, and
6335
+ // null when the sequence says nothing either way (all gaps, or A/C/G
6336
+ // alone). Note that it reads the residues and ignores any DECLARED type,
6337
+ // which is the desktop's rule and therefore ours -- and the right one:
6338
+ // bunya_glyco.xml declares type="protein" over 121 sequences of pure ACGT.
6339
+ //
6340
+ // F, P and V were added 2026-09-23 on Christian's word, jointly with the
6341
+ // desktop, because the test was missing most of the protein-exclusive
6342
+ // alphabet: under forester's own alphabets that is BDEFHILOPQVXZ and only
6343
+ // DEHILQ were tested. A protein built solely from nucleotide letters
6344
+ // therefore guessed DNA -- MKATSWNP has exactly one protein-exclusive
6345
+ // residue and it was P -- and a matrix wrongly declared DNA comes back
6346
+ // with every non-nucleotide residue replaced by N. Measured against
6347
+ // UniProt residue frequencies, the chance a protein carries none of the
6348
+ // tested letters falls from 12.5% to 3.3% at length 5 and from 1.6% to
6349
+ // 0.11% at length 10; a real alignment of a hundred columns was never at
6350
+ // risk either way. O, X and Z are protein-exclusive too and deliberately
6351
+ // left out: rare enough to buy almost nothing, and every letter added is
6352
+ // one both programs must add.
6353
+ //
6354
+ // V (and B, D, H) are nucleotide ambiguity codes in full IUPAC but not in
6355
+ // forester's DNA alphabet, which maps them to N, so they cannot survive in
6356
+ // a DNA sequence here and testing them for protein is consistent.
6357
+ //
6358
+ // NEITHER PROGRAM RETUNES THIS ALONE. A letter added on one side types the
6359
+ // same file two ways, which is worse than a blind spot they share;
6360
+ // testNexusMatrixDatatype pins the alphabet letter for letter.
6361
+ function guessMolSeqType(v) {
6362
+ let s = v.toUpperCase();
6363
+ if (s.indexOf('L') >= 0 || s.indexOf('I') >= 0 || s.indexOf('E') >= 0
6364
+ || s.indexOf('H') >= 0 || s.indexOf('D') >= 0 || s.indexOf('Q') >= 0
6365
+ || s.indexOf('F') >= 0 || s.indexOf('P') >= 0 || s.indexOf('V') >= 0) {
6366
+ return 'Protein';
6367
+ }
6368
+ if (s.indexOf('T') >= 0) {
6369
+ return 'DNA';
6370
+ }
6371
+ if (s.indexOf('U') >= 0) {
6372
+ return 'RNA';
6373
+ }
6374
+ return null;
6375
+ }
6376
+
6140
6377
  // Writes a phylogeny as a Nexus-formatted string, ported from the
6141
6378
  // desktop's PhylogenyWriter: a TAXA block (Dimensions, TaxLabels) and a
6142
6379
  // TREES block (the tree under its name, [&R]/[&U] rootedness, the same
@@ -6151,95 +6388,148 @@
6151
6388
  // all three go through sanitizeLabelForNH -- the same helper
6152
6389
  // toNewHampshire writes the tree with.
6153
6390
  //
6154
- // nexusLabel returns the label UNQUOTED, because it is also assigned
6155
- // to node.name for a nameless tip and toNewHampshire quotes it again
6156
- // on the way out. The old '_' substitution was idempotent so applying
6157
- // it twice was harmless; quoting is not, and would emit "'a b'"
6158
- // wrapped in quotes a second time.
6159
-
6160
- // label preference as on the desktop: name, then taxonomy
6161
- // (code/scientific/common), then sequence (name/symbol/gene)
6162
- function nexusLabel(node, i) {
6163
- let s = '';
6164
- if (node.name) {
6165
- s = node.name;
6166
- } else if (node.taxonomies && node.taxonomies.length > 0) {
6167
- let t = node.taxonomies[0];
6168
- s = t.code || t.scientific_name || t.common_name || '';
6169
- } else if (node.sequences && node.sequences.length > 0) {
6170
- let q = node.sequences[0];
6171
- s = q.name || q.symbol || q.gene_name || '';
6172
- }
6173
- if (!s) {
6174
- s = 'node' + (i + 1); // an empty TaxLabels token would not parse back
6175
- }
6176
- return s;
6391
+ // The chain lives in nhNodeLabel, shared with toNewHampshire, and it
6392
+ // returns the label UNQUOTED because every use here quotes it through
6393
+ // sanitizeLabelForNH -- the same helper the tree string goes through.
6394
+ //
6395
+ // No tip is renamed on the way past any more. This used to assign the
6396
+ // computed label to node.name for nameless tips so that toNewHampshire,
6397
+ // which knew only node.name, would write the same token; the caller's
6398
+ // tree was mutated and restored around the call. Now both writers ask
6399
+ // the same function, so there is nothing to patch up.
6400
+ let ext = forester.getAllExternalNodes(phy).reverse();
6401
+ let tips = tipPlaceholders(phy);
6402
+
6403
+ function nexusLabel(node) {
6404
+ return nhNodeLabel(node, tips.get(node));
6177
6405
  }
6178
6406
 
6179
- let ext = forester.getAllExternalNodes(phy).reverse();
6180
- // a nameless tip gets its taxa-block label in the TREE as well --
6181
- // TaxLabels, the Matrix and the Newick must agree on every taxon or
6182
- // nothing can join them back up (restored before returning, so the
6183
- // caller's tree is never mutated)
6184
- let renamed = [];
6185
- ext.forEach(function (node, i) {
6186
- if (!node.name) {
6187
- node.name = nexusLabel(node, i);
6188
- renamed.push(node);
6189
- }
6190
- });
6191
6407
  let s = '#NEXUS\n';
6192
6408
  s += 'Begin Taxa;\n';
6193
6409
  s += ' Dimensions NTax=' + ext.length + ';\n';
6194
6410
  s += ' TaxLabels';
6195
- ext.forEach(function (node, i) {
6196
- s += ' ' + sanitizeLabelForNH(nexusLabel(node, i));
6411
+ ext.forEach(function (node) {
6412
+ s += ' ' + sanitizeLabelForNH(nexusLabel(node));
6197
6413
  });
6198
6414
  s += ';\n';
6199
6415
  s += 'End;\n';
6200
6416
 
6201
- let rows = [];
6202
- let nchar = 0;
6203
- let datatype = null;
6417
+ // Every tip's label and, where there is one, its molecular sequence.
6418
+ // Written to the desktop's rules (PhylogenyWriter.writeNexusCharactersBlock),
6419
+ // because a Nexus file is a joint artifact: whichever program wrote it,
6420
+ // the other has to read the same bytes back.
6421
+ let labels = ext.map(function (node) {
6422
+ return sanitizeLabelForNH(nexusLabel(node));
6423
+ });
6424
+ let withSeq = [];
6204
6425
  ext.forEach(function (node, i) {
6205
- if (!node.sequences) {
6206
- return;
6426
+ let v = molSeqOfNode(node);
6427
+ if (v !== null) {
6428
+ withSeq.push({i: i, value: v});
6429
+ }
6430
+ });
6431
+ if (withSeq.length > 0) {
6432
+ // A matrix is keyed on the taxon label, so two tips sharing one
6433
+ // cannot be told apart: our reader takes the second row for an
6434
+ // interleaved continuation and hands BOTH tips the two sequences
6435
+ // joined together. The Taxa and Trees blocks have always written
6436
+ // such a tree -- invalid Nexus, but only cosmetically; a matrix
6437
+ // would make it corrupting, so it is not written.
6438
+ let seen = Object.create(null);
6439
+ let duplicate = null;
6440
+ for (let k = 0; k < labels.length; ++k) {
6441
+ if (seen[labels[k]]) {
6442
+ duplicate = labels[k];
6443
+ break;
6444
+ }
6445
+ seen[labels[k]] = true;
6446
+ }
6447
+ if (duplicate !== null) {
6448
+ s += '[ Molecular sequences were not written: two or more tips share the taxon label '
6449
+ + duplicate + ', and a character matrix keyed on an ambiguous label cannot be'
6450
+ + ' read back. ]\n';
6451
+ withSeq = [];
6452
+ }
6453
+ }
6454
+ if (withSeq.length > 0) {
6455
+ let nchar = withSeq[0].value.length;
6456
+ let ragged = -1;
6457
+ for (let k = 1; k < withSeq.length; ++k) {
6458
+ if (withSeq[k].value.length !== nchar) {
6459
+ ragged = withSeq[k].value.length;
6460
+ break;
6461
+ }
6207
6462
  }
6208
- for (let j = 0; j < node.sequences.length; ++j) {
6209
- let q = node.sequences[j];
6210
- if (q.mol_seq && q.mol_seq.is_aligned && q.mol_seq.value) {
6211
- rows.push({label: sanitizeLabelForNH(nexusLabel(node, i)), value: q.mol_seq.value});
6212
- nchar = Math.max(nchar, q.mol_seq.value.length);
6213
- if (!datatype && (q.type === 'protein' || q.type === 'dna' || q.type === 'rna')) {
6214
- datatype = q.type;
6463
+ if (ragged >= 0) {
6464
+ // A Nexus matrix is rectangular, so sequences of unequal length
6465
+ // cannot be one: padding them would state an alignment that
6466
+ // does not exist. Say so in the file rather than leaving the
6467
+ // reader to wonder where the data went.
6468
+ s += '[ Molecular sequences were not written: they are of unequal length ('
6469
+ + nchar + ' vs ' + ragged + '), so they are not an alignment and cannot'
6470
+ + ' form a Nexus character matrix. ]\n';
6471
+ } else {
6472
+ // The datatype is a property of the whole MATRIX, so every
6473
+ // sequence decides it, not the first one that guesses non-null.
6474
+ // guessMolSeqType looks for residues only protein has, so a
6475
+ // short protein made of nucleotide letters guesses DNA -- and a
6476
+ // matrix wrongly declared DNA is read back with every
6477
+ // non-nucleotide residue replaced by N (MKATSWNP came back
6478
+ // MKATSWNN). Protein therefore wins any disagreement: calling a
6479
+ // nucleotide alignment protein leaves the residues readable,
6480
+ // the reverse destroys them. Gaps and missing symbols are
6481
+ // stripped first so they cannot sway the guess.
6482
+ let datatype = 'Protein';
6483
+ let sawAa = false;
6484
+ let sawNt = false;
6485
+ let isRna = false;
6486
+ for (let k = 0; k < withSeq.length; ++k) {
6487
+ let bare = withSeq[k].value.replace(/[-.?*]/g, '');
6488
+ if (bare.length < 1) {
6489
+ continue;
6490
+ }
6491
+ let t = guessMolSeqType(bare);
6492
+ if (t === 'DNA') {
6493
+ sawNt = true;
6494
+ } else if (t === 'RNA') {
6495
+ sawNt = true;
6496
+ isRna = true;
6497
+ } else if (t !== null) {
6498
+ sawAa = true;
6215
6499
  }
6216
- return;
6217
6500
  }
6501
+ if (sawNt && !sawAa) {
6502
+ datatype = isRna ? 'RNA' : 'DNA';
6503
+ }
6504
+ let width = 0;
6505
+ labels.forEach(function (l) {
6506
+ width = Math.max(width, l.length);
6507
+ });
6508
+ ++width;
6509
+ let missing = '?'.repeat(nchar);
6510
+ let byTip = Object.create(null);
6511
+ withSeq.forEach(function (r) {
6512
+ byTip[r.i] = r.value;
6513
+ });
6514
+ s += 'Begin Characters;\n';
6515
+ // NChar ONLY: the Nexus standard allows NTax in a CHARACTERS
6516
+ // block's DIMENSIONS solely alongside NEWTAXA (the taxa are the
6517
+ // TAXA block's), and strict readers -- jebl, and so AliView --
6518
+ // reject the file over it
6519
+ s += ' Dimensions NChar=' + nchar + ';\n';
6520
+ s += ' Format DataType=' + datatype + ' Interleave=No Gap=- Missing=?;\n';
6521
+ s += ' Matrix\n';
6522
+ // A row per TAXON, not per sequence: a tip carrying none gets a
6523
+ // row of the missing symbol, so the matrix covers every taxon
6524
+ // the Taxa block declares. parseNexus reads such a row back as
6525
+ // absence of data, never as a sequence of question marks.
6526
+ labels.forEach(function (label, i) {
6527
+ s += ' ' + label + ' '.repeat(width - label.length) + ' '
6528
+ + (byTip[i] === undefined ? missing : byTip[i]) + '\n';
6529
+ });
6530
+ s += ' ;\n';
6531
+ s += 'End;\n';
6218
6532
  }
6219
- });
6220
- if (rows.length > 0) {
6221
- if (!datatype) {
6222
- // no declared type (e.g. the tree came from Newick plus a
6223
- // fasta): judge on the residues themselves
6224
- datatype = forester.msaIsNucleotide(rows[0].value) ? 'dna' : 'protein';
6225
- }
6226
- let width = 0;
6227
- rows.forEach(function (r) {
6228
- width = Math.max(width, r.label.length);
6229
- });
6230
- s += 'Begin Characters;\n';
6231
- // NChar ONLY: the Nexus standard allows NTax in a CHARACTERS
6232
- // block's DIMENSIONS solely alongside NEWTAXA (the taxa are the
6233
- // TAXA block's), and strict readers -- jebl, and so AliView --
6234
- // reject the file over it
6235
- s += ' Dimensions NChar=' + nchar + ';\n';
6236
- s += ' Format DataType=' + datatype + ' Missing=? Gap=-;\n';
6237
- s += ' Matrix\n';
6238
- rows.forEach(function (r) {
6239
- s += ' ' + r.label + ' '.repeat(width - r.label.length + 1) + r.value + '\n';
6240
- });
6241
- s += ' ;\n';
6242
- s += 'End;\n';
6243
6533
  }
6244
6534
 
6245
6535
  s += 'Begin Trees;\n';
@@ -6249,9 +6539,6 @@
6249
6539
  s += ' Tree ' + (treeName ? sanitizeLabelForNH(treeName) : 'tree1') + '=';
6250
6540
  s += (phy.rooted === false) ? '[&U]' : '[&R]';
6251
6541
  let nh = forester.toNewHampshire(phy, decPointsMax, true, writeConfidences);
6252
- renamed.forEach(function (node) {
6253
- delete node.name;
6254
- });
6255
6542
  if (nh.length === 0) {
6256
6543
  // an empty tree would otherwise write "Tree tree1=[&R]" with no
6257
6544
  // tree and no terminating ';' -- a syntactically invalid file
@@ -8440,6 +8727,70 @@
8440
8727
  return {scores: scores, consensus: consensus};
8441
8728
  };
8442
8729
 
8730
+ // Per-column sequence logo over the given rows -- the stack of letters
8731
+ // whose total height is the column's information content in BITS and whose
8732
+ // shares are the residue frequencies (Schneider & Stephens 1990), which is
8733
+ // the display the MEME Suite and WebLogo draw.
8734
+ //
8735
+ // No small-sample correction. Schneider's e_n = (K-1)/(2 ln2 n) is meant
8736
+ // for a motif sampled from many sequences; here the rows are the tips
8737
+ // currently on screen, so n is routinely 3 or 4 -- and for protein at n=3
8738
+ // the correction (4.57 bits) EXCEEDS the maximum (4.32), so a perfectly
8739
+ // conserved column of a small clade would draw nothing at all, exactly
8740
+ // when the reader has asked about that clade. The caller names n instead.
8741
+ //
8742
+ // Gaps are not a letter: frequencies are taken over the non-gap residues,
8743
+ // and the stack is then scaled by the column's occupancy, so a column held
8744
+ // up by two residues out of fifty is short rather than perfectly conserved.
8745
+ // `bits` and `occupancy` are returned separately so a caller can say which
8746
+ // of the two made a column short.
8747
+ //
8748
+ // Letters come back most frequent FIRST, ties broken alphabetically, so a
8749
+ // figure is reproducible rather than at the mercy of key order.
8750
+ forester.msaLogo = function (rows, length, nucleotide) {
8751
+ let maxBits = Math.log(nucleotide ? 4 : 20) / Math.LN2;
8752
+ if (!(length >= 0) || !isFinite(length)) {
8753
+ return {columns: [], maxBits: maxBits};
8754
+ }
8755
+ length = Math.floor(length);
8756
+ let n = rows.length;
8757
+ let columns = new Array(length);
8758
+ for (let c = 0; c < length; ++c) {
8759
+ let counts = {};
8760
+ let nonGap = 0;
8761
+ for (let r = 0; r < n; ++r) {
8762
+ let row = rows[r];
8763
+ let ch = (row && c < row.length) ? row.charAt(c) : '-';
8764
+ if (forester.isMsaGap(ch)) {
8765
+ continue;
8766
+ }
8767
+ ch = ch.toUpperCase();
8768
+ ++nonGap;
8769
+ counts[ch] = (counts[ch] || 0) + 1;
8770
+ }
8771
+ if (n < 1 || nonGap < 1) {
8772
+ columns[c] = {bits: 0, occupancy: 0, height: 0, letters: []};
8773
+ continue;
8774
+ }
8775
+ let H = 0;
8776
+ Object.keys(counts).forEach(function (ch) {
8777
+ let p = counts[ch] / nonGap;
8778
+ H -= p * (Math.log(p) / Math.LN2);
8779
+ });
8780
+ let bits = Math.max(0, maxBits - H);
8781
+ let occupancy = nonGap / n;
8782
+ let height = bits * occupancy;
8783
+ let letters = Object.keys(counts).sort().sort(function (a, b) {
8784
+ return counts[b] - counts[a]; // count first, alphabetical within a tie
8785
+ }).map(function (ch) {
8786
+ let p = counts[ch] / nonGap;
8787
+ return {ch: ch, p: p, h: p * height};
8788
+ });
8789
+ columns[c] = {bits: bits, occupancy: occupancy, height: height, letters: letters};
8790
+ }
8791
+ return {columns: columns, maxBits: maxBits};
8792
+ };
8793
+
8443
8794
  // The hover readout's description of one residue: full name, class (amino
8444
8795
  // acids), Kyte-Doolittle hydropathy. Returns null for a gap.
8445
8796
  forester.msaResidueInfo = function (ch, nucleotide) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.11.0",
3
+ "version": "3.13.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",
@@ -32,8 +32,8 @@
32
32
  "url": "git+https://github.com/cmzmasek/archaeopteryx-js.git"
33
33
  },
34
34
  "dependencies": {
35
- "phyloxml": "^1.1.0",
36
- "d3": "^7.9.0"
35
+ "d3": "^7.9.0",
36
+ "phyloxml": "^1.1.1"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@eslint/js": "^9.13.0",