archaeopteryx 3.3.0 → 3.4.1

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.3.0
23
+ // v 3.4.1
24
24
  // 2026-09-10
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -1602,11 +1602,23 @@
1602
1602
  // everything found here is in the candidate's domain and keeps its
1603
1603
  // colour. A numeric candidate also gets the view's colour-mode band
1604
1604
  // (see visNumericModes); a category keeps its mode.
1605
+ // `root` is a node, walked in preorder, or an array of the tips to
1606
+ // describe (the viewer passes what is on screen, which a collapsed clade
1607
+ // shortens).
1605
1608
  forester.visualizationSummary = function (candidate, root) {
1606
1609
  let counts = Object.create(null);
1607
1610
  let total = 0;
1608
1611
  let coverage = 0;
1609
- forester.preOrderTraversalAll(root, function (n) {
1612
+ let tips = root;
1613
+ if (!Array.isArray(root)) {
1614
+ tips = [];
1615
+ forester.preOrderTraversalAll(root, function (n) {
1616
+ if (!n.children) {
1617
+ tips.push(n);
1618
+ }
1619
+ });
1620
+ }
1621
+ tips.forEach(function (n) {
1610
1622
  if (n.children) {
1611
1623
  return;
1612
1624
  }
@@ -2318,6 +2330,17 @@
2318
2330
  // the shared contract -- and throwing on a combination a user
2319
2331
  // plausibly wants is what forced callers into workarounds.
2320
2332
 
2333
+ // A text holding several trees (one per ';') reads as its FIRST;
2334
+ // parseNewHampshireTrees reads them all. Before this the statements
2335
+ // ran together and the LAST tree came back, the others silently
2336
+ // dropped.
2337
+ {
2338
+ let statements = forester.splitNewHampshire(nhStr);
2339
+ if (statements.length > 1) {
2340
+ nhStr = statements[0];
2341
+ }
2342
+ }
2343
+
2321
2344
  let ancs = [];
2322
2345
  let x = {};
2323
2346
 
@@ -2569,6 +2592,66 @@
2569
2592
  }
2570
2593
  };
2571
2594
 
2595
+ // Splits a New Hampshire text into its tree statements, one per ';'
2596
+ // outside quotes and [...] comments (a ';' inside a quoted label or a
2597
+ // [&...] annotation is data), each trimmed; blank statements are
2598
+ // dropped. A text without a terminating ';' is one statement.
2599
+ forester.splitNewHampshire = function (nhStr) {
2600
+ let s = String(nhStr);
2601
+ let parts = [];
2602
+ let start = 0;
2603
+ let inSingle = false;
2604
+ let inDouble = false;
2605
+ let depth = 0;
2606
+ for (let i = 0, n = s.length; i < n; ++i) {
2607
+ let c = s.charAt(i);
2608
+ if (inSingle) {
2609
+ if (c === "'") {
2610
+ inSingle = false;
2611
+ }
2612
+ } else if (inDouble) {
2613
+ if (c === '"') {
2614
+ inDouble = false;
2615
+ }
2616
+ } else if (depth > 0) {
2617
+ if (c === ']') {
2618
+ --depth;
2619
+ } else if (c === '[') {
2620
+ ++depth;
2621
+ }
2622
+ } else if (c === "'") {
2623
+ inSingle = true;
2624
+ } else if (c === '"') {
2625
+ inDouble = true;
2626
+ } else if (c === '[') {
2627
+ depth = 1;
2628
+ } else if (c === ';') {
2629
+ parts.push(s.substring(start, i + 1));
2630
+ start = i + 1;
2631
+ }
2632
+ }
2633
+ parts.push(s.substring(start));
2634
+ return parts.map(function (p) {
2635
+ return p.trim();
2636
+ }).filter(function (p) {
2637
+ return p.length > 0;
2638
+ });
2639
+ };
2640
+
2641
+ // Every tree in a New Hampshire text (a file can hold many, one per
2642
+ // ';'), each parsed as parseNewHampshire does, in file order. A text
2643
+ // with no statement at all is handed to parseNewHampshire whole, so it
2644
+ // fails the way an empty tree always did.
2645
+ forester.parseNewHampshireTrees = function (nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
2646
+ let statements = forester.splitNewHampshire(nhStr);
2647
+ if (statements.length === 0) {
2648
+ return [forester.parseNewHampshire(nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames)];
2649
+ }
2650
+ return statements.map(function (statement) {
2651
+ return forester.parseNewHampshire(statement, confidenceValuesInBrackets, confidenceValuesAsInternalNames);
2652
+ });
2653
+ };
2654
+
2572
2655
  // Parses a Nexus-formatted string and returns an ARRAY of tree objects,
2573
2656
  // each in the same shape parseNewHampshire produces (a Nexus file can
2574
2657
  // hold any number of trees). Ported from the desktop's
@@ -3793,6 +3876,301 @@
3793
3876
  return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
3794
3877
  };
3795
3878
 
3879
+ // --------------------------------------------------------------
3880
+ // Scale bar
3881
+ // --------------------------------------------------------------
3882
+ // A phylogram's scale bar spans a round number of branch-length units
3883
+ // -- 1, 2 or 5 times a power of ten -- chosen so the bar comes out about
3884
+ // targetPx long at pxPerUnit pixels per unit. Returns {length, label,
3885
+ // px}, or null when the scale is unusable (zero, negative or infinite).
3886
+ forester.scaleBarLength = function (pxPerUnit, targetPx) {
3887
+ if (!(pxPerUnit > 0) || !isFinite(pxPerUnit)) {
3888
+ return null;
3889
+ }
3890
+ let raw = (targetPx || 100) / pxPerUnit;
3891
+ let k = Math.floor(Math.log10(raw));
3892
+ let base = raw / Math.pow(10, k);
3893
+ let nice = base < 1.5 ? 1 : (base < 3.5 ? 2 : (base < 7.5 ? 5 : 10));
3894
+ let length = Number((nice * Math.pow(10, k)).toPrecision(2));
3895
+ return {length: length, label: String(length), px: length * pxPerUnit};
3896
+ };
3897
+
3898
+ // --------------------------------------------------------------
3899
+ // Metadata tables
3900
+ // --------------------------------------------------------------
3901
+ // A table beside the tree -- TSV or CSV with a header row, the first
3902
+ // column naming the tip -- joined onto the tips as node properties, so
3903
+ // that everything downstream of a property sees the columns as if the
3904
+ // file had carried them: the automatic Color-by and Shape candidates,
3905
+ // the legends, the search fields, the node-data dialog, the phyloXML
3906
+ // writer. Every other browser viewer takes such a table; it was the one
3907
+ // input this library lacked (field review, 2026-09-13).
3908
+
3909
+ forester.METADATA_NAMESPACE = 'meta';
3910
+
3911
+ function splitDelimitedLine(line, delimiter) {
3912
+ let out = [];
3913
+ let cur = '';
3914
+ let quoted = false;
3915
+ for (let i = 0; i < line.length; ++i) {
3916
+ let c = line.charAt(i);
3917
+ if (quoted) {
3918
+ if (c === '"') {
3919
+ if (line.charAt(i + 1) === '"') { // a doubled quote is a literal one
3920
+ cur += '"';
3921
+ i++;
3922
+ } else {
3923
+ quoted = false;
3924
+ }
3925
+ } else {
3926
+ cur += c;
3927
+ }
3928
+ } else if (c === '"') {
3929
+ quoted = true;
3930
+ } else if (c === delimiter) {
3931
+ out.push(cur);
3932
+ cur = '';
3933
+ } else {
3934
+ cur += c;
3935
+ }
3936
+ }
3937
+ out.push(cur);
3938
+ return out.map(function (s) {
3939
+ return s.trim();
3940
+ });
3941
+ }
3942
+
3943
+ // Splits delimited text into its header and rows. The delimiter is
3944
+ // whichever of tab, comma and semicolon occurs most in the header line
3945
+ // (tab when none does); fields may be double-quoted; blank lines and
3946
+ // lines starting with '#' are skipped; Windows line ends are fine.
3947
+ // Header names and cells come back trimmed. Throws when there is no
3948
+ // header or fewer than two columns.
3949
+ forester.parseDelimitedTable = function (text) {
3950
+ let lines = String(text || '').split(/\r?\n/).filter(function (l) {
3951
+ return l.trim().length > 0 && l.charAt(0) !== '#';
3952
+ });
3953
+ if (lines.length === 0) {
3954
+ throw new Error('the table is empty');
3955
+ }
3956
+ let delimiter = '\t';
3957
+ let best = -1;
3958
+ ['\t', ',', ';'].forEach(function (d) {
3959
+ let n = lines[0].split(d).length - 1;
3960
+ if (n > best) {
3961
+ best = n;
3962
+ delimiter = d;
3963
+ }
3964
+ });
3965
+ let columns = splitDelimitedLine(lines[0], delimiter);
3966
+ if (columns.length < 2) {
3967
+ throw new Error('the table needs a header row with at least two columns: the tip name, then the data');
3968
+ }
3969
+ let rows = [];
3970
+ for (let i = 1; i < lines.length; ++i) {
3971
+ rows.push(splitDelimitedLine(lines[i], delimiter));
3972
+ }
3973
+ return {columns: columns, rows: rows, delimiter: delimiter};
3974
+ };
3975
+
3976
+ // The property ref for a column. A header that already reads as a
3977
+ // phyloXML ref (ns:local, no whitespace) is kept as it is; any other
3978
+ // becomes "meta:" plus the header with its whitespace as '_' -- the
3979
+ // display name prettifies that back to spaces, so "Collection Date"
3980
+ // stays "Collection Date" in every menu.
3981
+ // The tips' data as a table, one row per tip in the order given, header
3982
+ // first: what the node menu's "Download Ext. Node Data" writes. The
3983
+ // columns and their names are the desktop's (NodeDataExporter.toNodeDataTsv),
3984
+ // so the two programs write the same table: name (always), the first
3985
+ // taxonomy's scientific name, common name, code, id and rank, the first
3986
+ // sequence's name, gene name, symbol, accession and type, the branch
3987
+ // length, then one column per property ref, sorted, holding its first
3988
+ // value. A column no tip has a value for is left out. When the tip names
3989
+ // cannot key the rows (one blank or repeated), a node_id column comes
3990
+ // first, from idOf(tip, index) or the row number. Tabs and line breaks
3991
+ // inside a value become spaces. A property keeps its ref as the header,
3992
+ // so the table joins back onto a tree with joinMetadataTable.
3993
+ forester.externalNodeDataTable = function (tips, idOf) {
3994
+ tips = tips || [];
3995
+ if (tips.length === 0) {
3996
+ return {columns: [], rows: []};
3997
+ }
3998
+ let clean = function (v) {
3999
+ return (v === undefined || v === null) ? '' : String(v).replace(/[\t\n\r]/g, ' ');
4000
+ };
4001
+ let tax = function (n) {
4002
+ return (n.taxonomies && n.taxonomies[0]) || {};
4003
+ };
4004
+ let seq = function (n) {
4005
+ return (n.sequences && n.sequences[0]) || {};
4006
+ };
4007
+ let cols = [];
4008
+ let add = function (name, extract, force) {
4009
+ let vals = tips.map(function (n, i) {
4010
+ return clean(extract(n, i));
4011
+ });
4012
+ if (force || vals.some(function (v) { return v.length > 0; })) {
4013
+ cols.push({name: name, vals: vals});
4014
+ }
4015
+ };
4016
+ let seen = new Set();
4017
+ let unique = tips.every(function (n) {
4018
+ if (!n.name || seen.has(n.name)) {
4019
+ return false;
4020
+ }
4021
+ seen.add(n.name);
4022
+ return true;
4023
+ });
4024
+ if (!unique) {
4025
+ add('node_id', function (n, i) { return idOf ? idOf(n, i) : i + 1; }, true);
4026
+ }
4027
+ add('name', function (n) { return n.name; }, true);
4028
+ add('taxonomy_scientific_name', function (n) { return tax(n).scientific_name; });
4029
+ add('taxonomy_common_name', function (n) { return tax(n).common_name; });
4030
+ add('taxonomy_code', function (n) { return tax(n).code; });
4031
+ add('taxonomy_id', function (n) { return tax(n).id && tax(n).id.value; });
4032
+ add('taxonomy_rank', function (n) { return tax(n).rank; });
4033
+ add('sequence_name', function (n) { return seq(n).name; });
4034
+ add('gene_name', function (n) { return seq(n).gene_name; });
4035
+ add('sequence_symbol', function (n) { return seq(n).symbol; });
4036
+ add('sequence_accession', function (n) { return seq(n).accession && seq(n).accession.value; });
4037
+ add('sequence_type', function (n) { return seq(n).type; });
4038
+ add('branch_length', function (n) { return (typeof n.branch_length === 'number') ? n.branch_length : ''; });
4039
+ let refs = new Set();
4040
+ tips.forEach(function (n) {
4041
+ (n.properties || []).forEach(function (p) {
4042
+ if (p.ref) {
4043
+ refs.add(p.ref);
4044
+ }
4045
+ });
4046
+ });
4047
+ Array.from(refs).sort().forEach(function (ref) {
4048
+ add(ref, function (n) {
4049
+ let p = (n.properties || []).filter(function (q) { return q.ref === ref; })[0];
4050
+ return p ? p.value : '';
4051
+ });
4052
+ });
4053
+ return {
4054
+ columns: cols.map(function (c) { return c.name; }),
4055
+ rows: tips.map(function (n, i) {
4056
+ return cols.map(function (c) { return c.vals[i]; });
4057
+ })
4058
+ };
4059
+ };
4060
+
4061
+ // externalNodeDataTable as tab-separated text, header line first; empty
4062
+ // for no tips.
4063
+ forester.externalNodeDataTsv = function (tips, idOf) {
4064
+ let t = forester.externalNodeDataTable(tips, idOf);
4065
+ if (t.columns.length === 0) {
4066
+ return '';
4067
+ }
4068
+ return [t.columns].concat(t.rows).map(function (r) {
4069
+ return r.join('\t');
4070
+ }).join('\n') + '\n';
4071
+ };
4072
+
4073
+ forester.metadataColumnRef = function (header, index) {
4074
+ let h = String(header || '').trim();
4075
+ if (h.length === 0) {
4076
+ h = 'column_' + (index + 1);
4077
+ }
4078
+ if (/^[A-Za-z0-9_]+:\S+$/.test(h)) {
4079
+ return h;
4080
+ }
4081
+ return forester.METADATA_NAMESPACE + ':' + h.replace(/\s+/g, '_');
4082
+ };
4083
+
4084
+ // Joins a table onto the tree's tips. The first column is the key,
4085
+ // matched to the tip's name exactly, then case-insensitively. Every
4086
+ // other column becomes one property per matched tip with a non-empty
4087
+ // cell (applies_to node; the datatype is xsd:integer or xsd:double when
4088
+ // every filled cell of the column is such a number, xsd:string
4089
+ // otherwise). A property the tip already carries under the same ref is
4090
+ // replaced -- the table wins. Returns what happened:
4091
+ // {columns: [{header, ref, datatype, filled}], tips, matchedTips,
4092
+ // unmatchedTips: [names], unmatchedRows: [keys], properties}
4093
+ forester.joinMetadataTable = function (tree, text) {
4094
+ let table = forester.parseDelimitedTable(text);
4095
+ let tips = forester.getAllExternalNodes(tree);
4096
+ let byName = Object.create(null);
4097
+ let byLower = Object.create(null);
4098
+ tips.forEach(function (n) {
4099
+ if (n.name) {
4100
+ byName[n.name] = n;
4101
+ let lower = n.name.toLowerCase();
4102
+ if (!byLower[lower]) {
4103
+ byLower[lower] = n;
4104
+ }
4105
+ }
4106
+ });
4107
+ let columns = [];
4108
+ for (let j = 1; j < table.columns.length; ++j) {
4109
+ let filled = table.rows.map(function (r) {
4110
+ return r[j] === undefined ? '' : r[j];
4111
+ }).filter(function (v) {
4112
+ return v.length > 0;
4113
+ });
4114
+ let datatype = 'xsd:string';
4115
+ if (filled.length > 0) {
4116
+ if (filled.every(function (v) { return /^[+-]?\d+$/.test(v); })) {
4117
+ datatype = 'xsd:integer';
4118
+ } else if (filled.every(function (v) { return VIS_NUMERIC_RE.test(v); })) {
4119
+ datatype = 'xsd:double';
4120
+ }
4121
+ }
4122
+ columns.push({header: table.columns[j], ref: forester.metadataColumnRef(table.columns[j], j),
4123
+ datatype: datatype, filled: 0});
4124
+ }
4125
+ let matched = new Set();
4126
+ let unmatchedRows = [];
4127
+ let properties = 0;
4128
+ table.rows.forEach(function (r) {
4129
+ let key = r[0] === undefined ? '' : r[0];
4130
+ if (key.length === 0) {
4131
+ return;
4132
+ }
4133
+ let tip = byName[key] || byLower[key.toLowerCase()];
4134
+ if (!tip) {
4135
+ unmatchedRows.push(key);
4136
+ return;
4137
+ }
4138
+ matched.add(tip);
4139
+ columns.forEach(function (col, k) {
4140
+ let v = r[k + 1] === undefined ? '' : r[k + 1];
4141
+ if (v.length === 0) {
4142
+ return;
4143
+ }
4144
+ if (!tip.properties) {
4145
+ tip.properties = [];
4146
+ }
4147
+ let existing = null;
4148
+ for (let i = 0; i < tip.properties.length; ++i) {
4149
+ if (tip.properties[i].ref === col.ref) {
4150
+ existing = tip.properties[i];
4151
+ break;
4152
+ }
4153
+ }
4154
+ if (existing) {
4155
+ existing.value = v;
4156
+ existing.datatype = col.datatype;
4157
+ existing.applies_to = 'node';
4158
+ } else {
4159
+ tip.properties.push({ref: col.ref, value: v, datatype: col.datatype, applies_to: 'node'});
4160
+ }
4161
+ col.filled++;
4162
+ properties++;
4163
+ });
4164
+ });
4165
+ let unmatchedTips = tips.filter(function (n) {
4166
+ return !matched.has(n);
4167
+ }).map(function (n) {
4168
+ return n.name || '';
4169
+ });
4170
+ return {columns: columns, tips: tips.length, matchedTips: matched.size,
4171
+ unmatchedTips: unmatchedTips, unmatchedRows: unmatchedRows, properties: properties};
4172
+ };
4173
+
3796
4174
  // --------------------------------------------------------------
3797
4175
  // Search engine
3798
4176
  // --------------------------------------------------------------
@@ -3849,7 +4227,13 @@
3849
4227
  const SEARCH_DOMAIN = textField('Domain', n => searchSeqs(n).reduce((a, s) => a.concat((s.domain_architecture && s.domain_architecture.domains) ? s.domain_architecture.domains.map(d => d.name) : []), []).filter(Boolean));
3850
4228
  const SEARCH_ANNOTATION = textField('Annotation', n => searchSeqs(n).reduce((a, s) => a.concat((s.annotations || []).reduce((b, an) => b.concat([an.desc, an.ref]), [])), []).filter(Boolean), {anyText: true});
3851
4229
  const SEARCH_CROSS_REFERENCE = textField('Cross-Reference', n => searchSeqs(n).reduce((a, s) => a.concat((s.cross_references || []).reduce((b, x) => b.concat([x.value, x.source, x.comment]), [])), []).filter(Boolean), {anyText: true});
3852
- const SEARCH_MOLECULAR_SEQUENCE = textField('Molecular Sequence', n => searchSeqs(n).map(s => s.mol_seq).filter(Boolean), {suggest: false});
4230
+ // The residues: the phyloXML and Nexus readers give mol_seq as {value,
4231
+ // is_aligned}, and a hand-built tree may carry the plain string. Reading
4232
+ // the object itself compared every query against "[object Object]", so
4233
+ // this field never matched a real file (its test built the string form).
4234
+ const SEARCH_MOLECULAR_SEQUENCE = textField('Molecular Sequence', n => searchSeqs(n).map(function (s) {
4235
+ return (s.mol_seq && typeof s.mol_seq === 'object') ? s.mol_seq.value : s.mol_seq;
4236
+ }).filter(Boolean), {suggest: false});
3853
4237
  // in menu order
3854
4238
  const SEARCH_TEXT_FIELDS = [
3855
4239
  SEARCH_NODE_NAME, SEARCH_TAXONOMY_SCIENTIFIC_NAME, SEARCH_TAXONOMY_COMMON_NAME, SEARCH_TAXONOMY_CODE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
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",
@@ -20,7 +20,7 @@
20
20
  "LICENSE"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node test/forester_test.js && node test/search_test.js && node test/visualization_test.js && node test/domain_test.js",
23
+ "test": "node test/forester_test.js && node test/search_test.js && node test/visualization_test.js && node test/domain_test.js && node test/metadata_test.js",
24
24
  "lint": "eslint .",
25
25
  "lint:fix": "eslint . --fix",
26
26
  "format": "prettier --write .",