archaeopteryx 3.3.0 → 3.4.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.3.0
23
+ // v 3.4.0
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,209 @@
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
+ forester.metadataColumnRef = function (header, index) {
3982
+ let h = String(header || '').trim();
3983
+ if (h.length === 0) {
3984
+ h = 'column_' + (index + 1);
3985
+ }
3986
+ if (/^[A-Za-z0-9_]+:\S+$/.test(h)) {
3987
+ return h;
3988
+ }
3989
+ return forester.METADATA_NAMESPACE + ':' + h.replace(/\s+/g, '_');
3990
+ };
3991
+
3992
+ // Joins a table onto the tree's tips. The first column is the key,
3993
+ // matched to the tip's name exactly, then case-insensitively. Every
3994
+ // other column becomes one property per matched tip with a non-empty
3995
+ // cell (applies_to node; the datatype is xsd:integer or xsd:double when
3996
+ // every filled cell of the column is such a number, xsd:string
3997
+ // otherwise). A property the tip already carries under the same ref is
3998
+ // replaced -- the table wins. Returns what happened:
3999
+ // {columns: [{header, ref, datatype, filled}], tips, matchedTips,
4000
+ // unmatchedTips: [names], unmatchedRows: [keys], properties}
4001
+ forester.joinMetadataTable = function (tree, text) {
4002
+ let table = forester.parseDelimitedTable(text);
4003
+ let tips = forester.getAllExternalNodes(tree);
4004
+ let byName = Object.create(null);
4005
+ let byLower = Object.create(null);
4006
+ tips.forEach(function (n) {
4007
+ if (n.name) {
4008
+ byName[n.name] = n;
4009
+ let lower = n.name.toLowerCase();
4010
+ if (!byLower[lower]) {
4011
+ byLower[lower] = n;
4012
+ }
4013
+ }
4014
+ });
4015
+ let columns = [];
4016
+ for (let j = 1; j < table.columns.length; ++j) {
4017
+ let filled = table.rows.map(function (r) {
4018
+ return r[j] === undefined ? '' : r[j];
4019
+ }).filter(function (v) {
4020
+ return v.length > 0;
4021
+ });
4022
+ let datatype = 'xsd:string';
4023
+ if (filled.length > 0) {
4024
+ if (filled.every(function (v) { return /^[+-]?\d+$/.test(v); })) {
4025
+ datatype = 'xsd:integer';
4026
+ } else if (filled.every(function (v) { return VIS_NUMERIC_RE.test(v); })) {
4027
+ datatype = 'xsd:double';
4028
+ }
4029
+ }
4030
+ columns.push({header: table.columns[j], ref: forester.metadataColumnRef(table.columns[j], j),
4031
+ datatype: datatype, filled: 0});
4032
+ }
4033
+ let matched = new Set();
4034
+ let unmatchedRows = [];
4035
+ let properties = 0;
4036
+ table.rows.forEach(function (r) {
4037
+ let key = r[0] === undefined ? '' : r[0];
4038
+ if (key.length === 0) {
4039
+ return;
4040
+ }
4041
+ let tip = byName[key] || byLower[key.toLowerCase()];
4042
+ if (!tip) {
4043
+ unmatchedRows.push(key);
4044
+ return;
4045
+ }
4046
+ matched.add(tip);
4047
+ columns.forEach(function (col, k) {
4048
+ let v = r[k + 1] === undefined ? '' : r[k + 1];
4049
+ if (v.length === 0) {
4050
+ return;
4051
+ }
4052
+ if (!tip.properties) {
4053
+ tip.properties = [];
4054
+ }
4055
+ let existing = null;
4056
+ for (let i = 0; i < tip.properties.length; ++i) {
4057
+ if (tip.properties[i].ref === col.ref) {
4058
+ existing = tip.properties[i];
4059
+ break;
4060
+ }
4061
+ }
4062
+ if (existing) {
4063
+ existing.value = v;
4064
+ existing.datatype = col.datatype;
4065
+ existing.applies_to = 'node';
4066
+ } else {
4067
+ tip.properties.push({ref: col.ref, value: v, datatype: col.datatype, applies_to: 'node'});
4068
+ }
4069
+ col.filled++;
4070
+ properties++;
4071
+ });
4072
+ });
4073
+ let unmatchedTips = tips.filter(function (n) {
4074
+ return !matched.has(n);
4075
+ }).map(function (n) {
4076
+ return n.name || '';
4077
+ });
4078
+ return {columns: columns, tips: tips.length, matchedTips: matched.size,
4079
+ unmatchedTips: unmatchedTips, unmatchedRows: unmatchedRows, properties: properties};
4080
+ };
4081
+
3796
4082
  // --------------------------------------------------------------
3797
4083
  // Search engine
3798
4084
  // --------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.3.0",
3
+ "version": "3.4.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",
@@ -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 .",