archaeopteryx 2.3.2 → 3.1.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.
Files changed (6) hide show
  1. package/LICENSE +160 -499
  2. package/README.md +1205 -99
  3. package/archaeopteryx.d.ts +141 -0
  4. package/archaeopteryx.js +7984 -6469
  5. package/forester.js +3283 -535
  6. package/package.json +42 -12
package/forester.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * This library is free software; you can redistribute it and/or
8
8
  * modify it under the terms of the GNU Lesser General Public
9
9
  * License as published by the Free Software Foundation; either
10
- * version 2.1 of the License, or (at your option) any later version.
10
+ * version 3 of the License, or (at your option) any later version.
11
11
  *
12
12
  * This library is distributed in the hope that it will be useful,
13
13
  * but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -15,13 +15,13 @@
15
15
  * Lesser General Public License for more details.
16
16
  *
17
17
  * You should have received a copy of the GNU Lesser General Public
18
- * License along with this library; if not, write to the Free Software
19
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
18
+ * License along with this library; if not, see
19
+ * <https://www.gnu.org/licenses/>.
20
20
  *
21
21
  */
22
22
 
23
- // v 2.3.2
24
- // 2026-04-22
23
+ // v 3.1.0
24
+ // 2026-09-04
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
27
27
  //
@@ -74,34 +74,6 @@
74
74
 
75
75
  const NUMBERS_ONLY_PATTERN = /^[-+]?[0-9\\.]+$/;
76
76
 
77
- const MSA_RESIDUE_SORT_MAP = new Map();
78
- MSA_RESIDUE_SORT_MAP.set('A', 0);
79
- MSA_RESIDUE_SORT_MAP.set('C', 1);
80
- MSA_RESIDUE_SORT_MAP.set('D', 2);
81
- MSA_RESIDUE_SORT_MAP.set('E', 3);
82
- MSA_RESIDUE_SORT_MAP.set('F', 4);
83
- MSA_RESIDUE_SORT_MAP.set('G', 5);
84
- MSA_RESIDUE_SORT_MAP.set('H', 6);
85
- MSA_RESIDUE_SORT_MAP.set('I', 7);
86
- MSA_RESIDUE_SORT_MAP.set('K', 8);
87
- MSA_RESIDUE_SORT_MAP.set('L', 9);
88
- MSA_RESIDUE_SORT_MAP.set('M', 10);
89
- MSA_RESIDUE_SORT_MAP.set('N', 11);
90
- MSA_RESIDUE_SORT_MAP.set('P', 12);
91
- MSA_RESIDUE_SORT_MAP.set('Q', 13);
92
- MSA_RESIDUE_SORT_MAP.set('R', 14);
93
- MSA_RESIDUE_SORT_MAP.set('S', 15);
94
- MSA_RESIDUE_SORT_MAP.set('T', 16);
95
- MSA_RESIDUE_SORT_MAP.set('U', 17);// Uracil
96
- MSA_RESIDUE_SORT_MAP.set('V', 18);
97
- MSA_RESIDUE_SORT_MAP.set('W', 19);
98
- MSA_RESIDUE_SORT_MAP.set('Y', 20);
99
- MSA_RESIDUE_SORT_MAP.set('B', 21);// Asparagine or aspartic acid
100
- MSA_RESIDUE_SORT_MAP.set('Z', 22);// Glutamine or glutamic acid
101
- MSA_RESIDUE_SORT_MAP.set('X', 23);
102
- MSA_RESIDUE_SORT_MAP.set('?', 24);
103
- MSA_RESIDUE_SORT_MAP.set('-', 25);
104
- MSA_RESIDUE_SORT_MAP.set('.', 26);
105
77
 
106
78
 
107
79
  /**
@@ -222,43 +194,6 @@
222
194
  };
223
195
 
224
196
 
225
- forester.filterByNodeProperty = function (positive, phy, propertyMap) {
226
- if (!phy) {
227
- throw ("cannot delete null tree");
228
- }
229
- if (!propertyMap) {
230
- throw ("property list is null");
231
- }
232
- const toDelete = [];
233
- forester.preOrderTraversalAll(phy, function (n) {
234
- if (!n.children && !n._children) {
235
- if (n.properties && n.properties.length > 0) {
236
- const propertiesLength = n.properties.length;
237
- for (let i = 0; i < propertiesLength; ++i) {
238
- const property = n.properties[i];
239
- if (property.ref && property.value && property.applies_to === 'node') {
240
- if (positive) {
241
- if (property.ref in propertyMap && !propertyMap[property.ref].includes(property.value)) {
242
- toDelete.push(n);
243
- }
244
- } else {
245
- if (property.ref in propertyMap && propertyMap[property.ref].includes(property.value)) {
246
- toDelete.push(n);
247
- }
248
- }
249
- }
250
- }
251
- }
252
- }
253
- });
254
- const l = toDelete.length;
255
- console.log(toDelete);
256
- for (let i = 0; i < l; ++i) {
257
- forester.deleteSubtree(phy, toDelete[i]);
258
- }
259
- };
260
-
261
-
262
197
  /**
263
198
  * To delete a sub-tree or external node.
264
199
  *
@@ -607,15 +542,6 @@
607
542
  }
608
543
  };
609
544
 
610
- forester.unCollapseAll = function (node) {
611
- forester.preOrderTraversal(node, function (d) {
612
- if (d._children) {
613
- d.children = d._children;
614
- d._children = null;
615
- }
616
- });
617
- };
618
-
619
545
  forester.copyBranchData = function (nodeFrom, nodeTo) {
620
546
  nodeTo.width = nodeFrom.width;
621
547
  nodeTo.color = nodeFrom.color;
@@ -658,216 +584,722 @@
658
584
  return s / l;
659
585
  };
660
586
 
661
- forester.setToArray = function (set) {
662
- let array = [];
663
- if (set) {
664
- set.forEach(function (e) {
665
- array.push(e);
666
- });
587
+
588
+ // ------------------------------------------------------------------
589
+ // Automatic visualization candidates
590
+ // ------------------------------------------------------------------
591
+ //
592
+ // Decides, from the tree alone, which of its elements are worth offering
593
+ // as a Color, Color-range, or Shape visualization. This replaces the old
594
+ // caller-supplied "nodeVisualizations" configuration: the tree is the
595
+ // only input.
596
+ //
597
+ // Only external nodes are considered; the domains always come from the
598
+ // COMPLETE tree, so a value keeps its colour inside a subtree view even
599
+ // when the subtree does not contain it.
600
+ //
601
+ // Candidates: taxonomy code / scientific name / common name, sequence
602
+ // name / symbol / gene name, and node properties (applies_to "node").
603
+ // The "style:" namespace is never a candidate -- the desktop reserves it
604
+ // for per-node rendering instructions (font_color, node_shape, ...), so
605
+ // treating it as data would mean colouring by a colour.
606
+ //
607
+ // The rules, tuned against the real ViPR / BV-BRC trees in docs/data
608
+ // (which test/visualization_test.js holds as executable fixtures):
609
+ //
610
+ // coverage present on >= 2/3 of the external nodes. Database
611
+ // exports are always patchy -- demanding 100% would
612
+ // reject nearly every field of the BV-BRC trees while a
613
+ // field on 9% of nodes (state_province) says nothing.
614
+ // Nodes without a value simply keep the default look.
615
+ // repetition at least 2 distinct values (1 paints the whole tree
616
+ // alike), and fewer distinct values than external nodes
617
+ // (all-unique means identifiers).
618
+ // categorical <= 20 distinct values -> Color. Above ~12 the reader
619
+ // leans on the legend, but the real trees cluster at
620
+ // 15-17 (host names, countries, taxonomy codes).
621
+ // wide 21+ distinct values are still offered -- as the desktop
622
+ // does, every value gets a colour and the LEGEND caps the
623
+ // display -- but only when values genuinely repeat:
624
+ // distinct/covered <= 0.6, or near-unique fields (strains,
625
+ // species names, dates) would flood the menus. Wide fields
626
+ // rank after everything else and are never auto-applied.
627
+ // numeric every value parses as a finite number. Up to 10 distinct
628
+ // values default to individual colours -- numbers that few
629
+ // are usually codes (HA/NA subtypes), and ten is what the
630
+ // palette's strong first half holds -- 11 to 20 default to
631
+ // a Color-range, and both of those may be switched in the
632
+ // legend; above 20 it is a range with no switch. Guard:
633
+ // distinct/covered <= 0.9, or "numeric" identifiers
634
+ // (genome ids) would become ramps.
635
+ // shape <= 7 distinct values (d3 v7 has exactly 7 distinct
636
+ // fill symbols), numeric or not -- two years as two
637
+ // shapes is genuinely useful.
638
+ // multi-value a ref carried more than once by any external node is
639
+ // not a candidate: a node cannot be two colours, and
640
+ // picking one silently is worse than not offering it.
641
+ //
642
+ const VIS_MIN_COVERAGE_NUM = 2; // coverage >= 2/3, held as a
643
+ const VIS_MIN_COVERAGE_DEN = 3; // fraction so the test is integer-exact
644
+ const VIS_MAX_COLOR_CATEGORIES = 20;
645
+ const VIS_MAX_SHAPE_CATEGORIES = 7;
646
+ const VIS_NUMERIC_CATEGORY_MAX = 10; // <= this many distinct numbers -> colours by default
647
+ const VIS_WIDE_REPEAT_NUM = 3; // wide categorical: distinct/covered <= 0.6,
648
+ const VIS_WIDE_REPEAT_DEN = 5; // held integer-exact
649
+ const VIS_MAX_NUMERIC_UNIQUE_NUM = 9; // distinct/covered <= 0.9,
650
+ const VIS_MAX_NUMERIC_UNIQUE_DEN = 10; // integer-exact as well
651
+ const VIS_EXCLUDED_REF_PREFIX = 'style:';
652
+ // Refs that are never a visualization, however their values distribute.
653
+ // A taxon identifier repeats like a category and passes every statistical
654
+ // test above, yet says nothing a colour could carry that the species name
655
+ // beside it does not -- and reads as "11320" in a legend. Matched on the
656
+ // ref's local name with case and separators ignored, so vipr:NCBI_Taxon_Id,
657
+ // ncbi_taxid, taxon_id and taxonomy_id all count.
658
+ const VIS_EXCLUDED_LOCAL_NAME_RE = /(taxonomy|taxon|tax)id$/;
659
+
660
+ function visExcludedRef(ref) {
661
+ if (ref.indexOf(VIS_EXCLUDED_REF_PREFIX) === 0) {
662
+ return true;
667
663
  }
668
- return array;
664
+ let local = ref.substring(ref.indexOf(':') + 1).toLowerCase().replace(/[^a-z0-9]/g, '');
665
+ return VIS_EXCLUDED_LOCAL_NAME_RE.test(local);
666
+ }
667
+
668
+ // ---- display normalization --------------------------------------------
669
+ //
670
+ // Property values are grouped for colouring after a normalization pass,
671
+ // following the desktop's PropertyColorScheme with two deliberate
672
+ // extensions. The desktop's part: values are trimmed, underscores read as
673
+ // spaces, whitespace runs collapse, grouping is case-insensitive, and for
674
+ // refs literally named "host" / "country" a trailing qualifier is cut
675
+ // (everything from the first ';' / ':' -- "USA:CA" groups as "USA",
676
+ // "Homo sapiens; sex: M" as "Homo sapiens"). Our extensions, chosen for a
677
+ // VISUALIZATION tool that should look good on the data it is given: a
678
+ // small dictionary of common-animal synonyms folds scientific names and
679
+ // spelling variants into one capitalized common name ("bovine", "calf",
680
+ // "cattle" and "Bos taurus" are all Cow -- including "Human", where the
681
+ // desktop folds the other way); and a qualifier cut that would leave an
682
+ // unclosed "(" behind is trimmed back to before it, so
683
+ // "Saimiri boliviensis (squirrel monkey; voucher: X)" reads as
684
+ // "Saimiri boliviensis" rather than dangling.
685
+ //
686
+ // Matching is WHOLE-VALUE only (after a trailing parenthetical is tried
687
+ // stripped: "Bos taurus (cattle)" looks up "bos taurus") -- never by
688
+ // substring, so "ferret badger" (a Melogale, not a ferret) and
689
+ // "42-day-old pig" keep their own rows. The dictionary applies to
690
+ // property fields only; taxonomy and sequence elements are curated text
691
+ // and stay verbatim. Node names, exports, search, and the node-data
692
+ // dialog always show the raw values -- this is display grouping, nothing
693
+ // more.
694
+ // CROSS-IMPLEMENTATION CONTRACT with desktop Archaeopteryx: this
695
+ // dictionary is carried verbatim on both sides (as agreed with the
696
+ // desktop's Color-by parity work), so a value groups the same way in
697
+ // both viewers. Extend BOTH implementations together, never just one.
698
+ const VIS_SYNONYMS = {
699
+ 'Human': ['humans', 'homo sapiens', 'h. sapiens'],
700
+ 'Cow': ['bovine', 'calf', 'cattle', 'bull', 'heifer', 'bos taurus', 'b. taurus'],
701
+ 'Chicken': ['broiler chicken', 'broiler', 'hen', 'rooster', 'gallus gallus', 'g. gallus', 'gallus gallus domesticus'],
702
+ 'Mouse': ['house mouse', 'murine', 'mus musculus', 'm. musculus'],
703
+ 'Rat': ['brown rat', 'norway rat', 'black rat', 'rattus norvegicus', 'r. norvegicus', 'rattus rattus'],
704
+ 'Ferret': ['domestic ferret', 'mustela putorius furo', 'mustela furo', 'm. putorius furo'],
705
+ 'Guinea pig': ['cavy', 'domestic guinea pig', 'cavia porcellus', 'c. porcellus'],
706
+ 'Rhesus monkey': ['rhesus macaque', 'macaca mulatta', 'm. mulatta'],
707
+ 'Rabbit': ['european rabbit', 'oryctolagus cuniculus', 'o. cuniculus'],
708
+ 'Dog': ['canine', 'canis familiaris', 'canis lupus familiaris', 'c. familiaris'],
709
+ 'Cat': ['feline', 'domestic cat', 'felis catus', 'f. catus', 'felis silvestris catus'],
710
+ 'Duck': ['mallard', 'mallard duck', 'domestic duck', 'anas platyrhynchos', 'a. platyrhynchos'],
711
+ 'Pig': ['swine', 'porcine', 'hog', 'piglet', 'sus scrofa', 's. scrofa', 'sus scrofa domesticus'],
712
+ 'Horse': ['equine', 'mare', 'stallion', 'equus caballus', 'e. caballus'],
713
+ 'Sheep': ['ovine', 'lamb', 'ewe', 'ovis aries', 'o. aries'],
714
+ 'Goat': ['caprine', 'capra hircus', 'c. hircus'],
715
+ 'Camel': ['dromedary', 'bactrian camel', 'camelus dromedarius', 'camelus bactrianus', 'c. dromedarius']
669
716
  };
717
+ // null prototype: the lookup is keyed by FILE values, and a value named
718
+ // "toString" / "__proto__" must miss, not return an inherited member
719
+ // (that crashed visualizationCandidates -- and so launch -- outright)
720
+ const VIS_SYNONYM_LOOKUP = Object.create(null);
721
+ Object.keys(VIS_SYNONYMS).forEach(function (canon) {
722
+ VIS_SYNONYM_LOOKUP[canon.toLowerCase()] = canon;
723
+ VIS_SYNONYMS[canon].forEach(function (syn) {
724
+ VIS_SYNONYM_LOOKUP[syn] = canon;
725
+ });
726
+ });
727
+
728
+ // ';' for host fields, ':' for country fields, null otherwise -- matched
729
+ // on the ref's local name EXACTLY, so host_group and isolation_country
730
+ // keep their full values.
731
+ function visQualifierCut(ref) {
732
+ let i = ref.lastIndexOf(':');
733
+ let local = (i >= 0 ? ref.substring(i + 1) : ref).toLowerCase();
734
+ if (local === 'country') {
735
+ return ':';
736
+ }
737
+ if (local === 'host') {
738
+ return ';';
739
+ }
740
+ return null;
741
+ }
670
742
 
671
- forester.setToSortedArray = function (set) {
672
- let array = [];
673
- if (set) {
674
- set.forEach(function (e) {
675
- array.push(e);
676
- });
743
+ // The display form a raw property value is grouped under (case is
744
+ // preserved here; grouping lowercases it).
745
+ function visDisplayLabel(value, cut) {
746
+ let s = value;
747
+ if (cut) {
748
+ let at = s.indexOf(cut);
749
+ if (at >= 0) {
750
+ s = s.substring(0, at);
751
+ // the cut may land inside a parenthetical; trim back to
752
+ // before the first unclosed '('
753
+ let open = [];
754
+ for (let i = 0; i < s.length; ++i) {
755
+ if (s.charAt(i) === '(') {
756
+ open.push(i);
757
+ } else if (s.charAt(i) === ')') {
758
+ open.pop();
759
+ }
760
+ }
761
+ if (open.length > 0) {
762
+ s = s.substring(0, open[0]);
763
+ }
764
+ }
677
765
  }
678
- return array.sort();
766
+ // the trailing trim matters: without it, a value that is ALL
767
+ // underscores (e.g. "_") folds to a single space rather than empty,
768
+ // so it survives the caller's empty-string drop; "_cat_" folds to
769
+ // " cat " and never joins the "cat" group. (The leading trim alone
770
+ // only catches whitespace that was already there before folding.)
771
+ s = s.trim().replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
772
+ let hit = VIS_SYNONYM_LOOKUP[s.toLowerCase()];
773
+ if (!hit) {
774
+ let stripped = s.replace(/\s*\([^()]*\)\s*$/, '');
775
+ if (stripped !== s && stripped.length > 0) {
776
+ hit = VIS_SYNONYM_LOOKUP[stripped.toLowerCase()];
777
+ }
778
+ }
779
+ return hit || s;
780
+ }
781
+
782
+ // Fixed candidate slots for the phyloXML elements (properties use their
783
+ // ref). CROSS-IMPLEMENTATION CONTRACT with desktop Archaeopteryx: the
784
+ // ids here (tax:code, seq:name, ...) and the rule that taxonomy/sequence
785
+ // elements stay VERBATIM (never folded/grouped like property values,
786
+ // see visDisplayLabel above) are pinned on both sides. Extend BOTH
787
+ // implementations together, never just one.
788
+ const VIS_ELEMENT_SLOTS = [
789
+ {id: 'tax:code', kind: 'taxonomy', label: 'Taxonomy Code', get: function (t) { return t.code; }},
790
+ {id: 'tax:scientific_name', kind: 'taxonomy', label: 'Scientific Name', get: function (t) { return t.scientific_name; }},
791
+ {id: 'tax:common_name', kind: 'taxonomy', label: 'Common Name', get: function (t) { return t.common_name; }},
792
+ {id: 'seq:name', kind: 'sequence', label: 'Sequence Name', get: function (s) { return s.name; }},
793
+ {id: 'seq:symbol', kind: 'sequence', label: 'Sequence Symbol', get: function (s) { return s.symbol; }},
794
+ {id: 'seq:gene_name', kind: 'sequence', label: 'Gene Name', get: function (s) { return s.gene_name; }}
795
+ ];
796
+
797
+ // "geographic_group" reads like a database column; a menu should say
798
+ // "Geographic Group". Underscores become spaces, camelCase is split
799
+ // (FluSeason -> Flu Season, GlobalH1Clade -> Global H1 Clade), and
800
+ // all-lowercase words are capitalized. Words that already carry capitals
801
+ // (PANGO, HA, H5N1) are left exactly as written.
802
+ function prettifyVisLabel(name) {
803
+ return name
804
+ .replace(/_/g, ' ')
805
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
806
+ .replace(/([0-9])([A-Z][a-z])/g, '$1 $2')
807
+ .split(' ')
808
+ .map(function (w) {
809
+ return /^[a-z]/.test(w) ? w.charAt(0).toUpperCase() + w.substring(1) : w;
810
+ })
811
+ .join(' ');
812
+ }
813
+
814
+ // The display name for a property ref, as the Color-by menu and legends
815
+ // show it: namespace dropped, then prettified. Exported so the node-data
816
+ // dialog names a property the same way the rest of the viewer does.
817
+ forester.propertyDisplayName = function (ref) {
818
+ let local = ref.indexOf(':') >= 0 ? ref.substring(ref.indexOf(':') + 1) : ref;
819
+ return prettifyVisLabel(local);
679
820
  };
680
821
 
681
- forester.calcMinMaxInSet = function (set) {
682
- let array = [];
683
- let first = true;
684
- let min = 0;
685
- let max = 0;
686
- if (set) {
687
- set.forEach(function (e) {
688
- e = parseFloat(e);
689
- if (first) {
690
- first = false;
691
- min = e;
692
- max = e;
693
- } else {
694
- if (e < min) {
695
- min = e;
822
+ forester.visualizationCandidates = function (tree) {
823
+ let total = 0;
824
+ let stats = Object.create(null); // id -> {kind, ref, label, nodes, values:Set, multi}; null-proto: ids embed file refs
825
+
826
+ forester.preOrderTraversalAll(tree, function (n) {
827
+ if (n.children || n._children) {
828
+ return;
829
+ }
830
+ total++;
831
+ // gather this node's values per candidate id first, so carrying
832
+ // the same ref twice is visible as such
833
+ let perNode = Object.create(null);
834
+ function add(id, kind, ref, label, value) {
835
+ if (value === undefined || value === null) {
836
+ return;
837
+ }
838
+ let v = String(value).trim();
839
+ if (v.length === 0) {
840
+ return;
841
+ }
842
+ if (!perNode[id]) {
843
+ perNode[id] = {kind: kind, ref: ref, label: label, values: []};
844
+ }
845
+ perNode[id].values.push(v);
846
+ }
847
+ VIS_ELEMENT_SLOTS.forEach(function (slot) {
848
+ let list = slot.kind === 'taxonomy' ? n.taxonomies : n.sequences;
849
+ if (list) {
850
+ for (let i = 0; i < list.length; ++i) {
851
+ add(slot.id, slot.kind, null, slot.label, slot.get(list[i]));
852
+ }
853
+ }
854
+ });
855
+ if (n.properties) {
856
+ for (let i = 0; i < n.properties.length; ++i) {
857
+ let p = n.properties[i];
858
+ if (p.ref && p.applies_to === 'node' && !visExcludedRef(p.ref)) {
859
+ add('prop:' + p.ref, 'property', p.ref, null, p.value);
860
+ }
861
+ }
862
+ }
863
+ Object.keys(perNode).forEach(function (id) {
864
+ let g = perNode[id];
865
+ if (!stats[id]) {
866
+ stats[id] = {kind: g.kind, ref: g.ref, label: g.label,
867
+ cut: g.kind === 'property' ? visQualifierCut(g.ref) : null,
868
+ nodes: 0, keys: Object.create(null), multi: false};
869
+ }
870
+ let s = stats[id];
871
+ s.nodes++;
872
+ if (g.values.length > 1) {
873
+ s.multi = true;
874
+ }
875
+ for (let i = 0; i < g.values.length; ++i) {
876
+ // properties group under their normalized display form;
877
+ // taxonomy / sequence elements are curated text, verbatim
878
+ let display = g.kind === 'property' ? visDisplayLabel(g.values[i], s.cut) : g.values[i];
879
+ if (display.length === 0) {
880
+ continue;
696
881
  }
697
- if (e > max) {
698
- max = e;
882
+ let key = g.kind === 'property' ? display.toLowerCase() : display;
883
+ if (!s.keys[key]) {
884
+ s.keys[key] = {count: 0, spellings: Object.create(null)};
699
885
  }
886
+ s.keys[key].count++;
887
+ s.keys[key].spellings[display] = (s.keys[key].spellings[display] || 0) + 1;
700
888
  }
701
889
  });
702
- }
703
- array[0] = min;
704
- array[1] = max;
705
- return array;
706
- };
890
+ });
707
891
 
708
- forester.calcMinMeanMaxInSet = function (set) {
709
- let array = [];
710
- let first = true;
711
- let min = 0;
712
- let max = 0;
713
- let mean = 0;
714
- let sum = 0;
715
- let n = 0;
716
- if (set) {
717
- set.forEach(function (e) {
718
- e = parseFloat(e);
719
- ++n;
720
- sum += e;
721
- if (first) {
722
- first = false;
723
- min = e;
724
- max = e;
725
- } else {
726
- if (e < min) {
727
- min = e;
892
+ let candidates = [];
893
+ Object.keys(stats).forEach(function (id) {
894
+ let s = stats[id];
895
+ if (s.multi) {
896
+ return;
897
+ }
898
+ let covered = s.nodes;
899
+ // one legend row per group: the dictionary canonical where one
900
+ // applied (it is then the only recorded spelling), otherwise the
901
+ // most frequent raw spelling (ties alphabetically), capitalized
902
+ let canon = Object.create(null);
903
+ let counts = Object.create(null);
904
+ Object.keys(s.keys).forEach(function (key) {
905
+ let group = s.keys[key];
906
+ let rep = null;
907
+ let best = -1;
908
+ Object.keys(group.spellings).forEach(function (spelling) {
909
+ let n = group.spellings[spelling];
910
+ if (n > best || (n === best && spelling < rep)) {
911
+ rep = spelling;
912
+ best = n;
728
913
  }
729
- if (e > max) {
730
- max = e;
914
+ });
915
+ if (s.kind === 'property') {
916
+ rep = rep.charAt(0).toUpperCase() + rep.substring(1);
917
+ }
918
+ canon[key] = rep;
919
+ counts[rep] = group.count;
920
+ });
921
+ let distinct = Object.keys(canon).length;
922
+ if (covered * VIS_MIN_COVERAGE_DEN < total * VIS_MIN_COVERAGE_NUM) {
923
+ return;
924
+ }
925
+ if (distinct < 2) {
926
+ return;
927
+ }
928
+ let values = Object.keys(counts);
929
+ let numeric = values.every(function (v) {
930
+ return Number.isFinite(Number(v));
931
+ });
932
+ let colorMode;
933
+ let switchable = false;
934
+ let wide = false;
935
+ if (numeric) {
936
+ if (distinct * VIS_MAX_NUMERIC_UNIQUE_DEN > covered * VIS_MAX_NUMERIC_UNIQUE_NUM) {
937
+ return;
938
+ }
939
+ colorMode = distinct <= VIS_NUMERIC_CATEGORY_MAX ? 'category' : 'range';
940
+ switchable = distinct <= VIS_MAX_COLOR_CATEGORIES;
941
+ values.sort(function (a, b) {
942
+ return Number(a) - Number(b);
943
+ });
944
+ } else {
945
+ if (distinct >= total) {
946
+ return;
947
+ }
948
+ if (distinct > VIS_MAX_COLOR_CATEGORIES) {
949
+ if (distinct * VIS_WIDE_REPEAT_DEN > covered * VIS_WIDE_REPEAT_NUM) {
950
+ return;
731
951
  }
952
+ wide = true;
732
953
  }
954
+ colorMode = 'category';
955
+ values.sort();
956
+ }
957
+ // Rank by how much a visualization would actually show: coverage
958
+ // times balance, where balance is the normalized entropy of the
959
+ // value distribution. A field that is one value on 92% of nodes
960
+ // scores low even with full coverage; an even 4-way split on 90%
961
+ // of nodes scores high.
962
+ let entropy = 0;
963
+ values.forEach(function (v) {
964
+ let p = counts[v] / covered;
965
+ entropy -= p * Math.log(p);
733
966
  });
734
- }
735
- if (n > 0) {
736
- mean = sum / n;
737
- }
738
- array[0] = min;
739
- array[1] = mean;
740
- array[2] = max;
741
- return array;
742
- };
967
+ let balance = entropy / Math.log(distinct);
968
+ candidates.push({
969
+ id: id,
970
+ kind: s.kind,
971
+ ref: s.ref,
972
+ // property labels drop the namespace prefix and are prettified;
973
+ // a cross-namespace collision is resolved below by restoring
974
+ // the full ref verbatim
975
+ label: s.label || prettifyVisLabel(s.ref.indexOf(':') >= 0 ? s.ref.substring(s.ref.indexOf(':') + 1) : s.ref),
976
+ numeric: numeric,
977
+ coverage: covered,
978
+ total: total,
979
+ values: values,
980
+ counts: counts,
981
+ canon: s.kind === 'property' ? canon : null,
982
+ cut: s.cut,
983
+ score: (covered / total) * balance,
984
+ colorMode: colorMode,
985
+ switchable: switchable,
986
+ wide: wide,
987
+ shape: distinct <= VIS_MAX_SHAPE_CATEGORIES
988
+ });
989
+ });
743
990
 
991
+ let labelCount = Object.create(null);
992
+ candidates.forEach(function (c) {
993
+ labelCount[c.label] = (labelCount[c.label] || 0) + 1;
994
+ });
995
+ candidates.forEach(function (c) {
996
+ if (labelCount[c.label] > 1 && c.ref) {
997
+ c.label = c.ref;
998
+ }
999
+ });
744
1000
 
745
- /**
746
- * This collects all properties in a tree
747
- * and returns them as dictionary of Sets mapping
748
- * keys to values.
749
- * It only collects properly formed properties
750
- * (as per phyloXML standard), which means
751
- * that 'applies_to' and 'datatype' have to be present.
752
- *
753
- *
754
- * @param phy - A phyloXML-based tree object or node.
755
- * @param appliesTo - 'phylogeny', 'clade', 'node', 'annotation', 'parent_branch', or 'other'.
756
- * @param externalOnly - To collect from external nodes only.
757
- * @returns {{}}
758
- */
759
- forester.collectProperties = function (phy, appliesTo, externalOnly) {
760
- let props = {};
761
- forester.preOrderTraversalAll(phy, function (n) {
1001
+ // Best first: clean categorical fields, then numeric ranges, then the
1002
+ // wide categoricals (offered, never leading) -- within each tier by
1003
+ // score, ties alphabetically. The first entry is what the viewer
1004
+ // applies on load.
1005
+ function tierOf(c) {
1006
+ if (c.colorMode === 'category') {
1007
+ return c.wide ? 2 : 0;
1008
+ }
1009
+ return 1;
1010
+ }
1011
+ candidates.sort(function (a, b) {
1012
+ let ta = tierOf(a);
1013
+ let tb = tierOf(b);
1014
+ if (ta !== tb) {
1015
+ return ta - tb;
1016
+ }
1017
+ if (a.score !== b.score) {
1018
+ return b.score - a.score;
1019
+ }
1020
+ let la = a.label.toLowerCase();
1021
+ let lb = b.label.toLowerCase();
1022
+ return la < lb ? -1 : (la > lb ? 1 : (a.id < b.id ? -1 : 1));
1023
+ });
1024
+ return candidates;
1025
+ };
762
1026
 
763
- if (!externalOnly || externalOnly !== true || (!n.children && !n._children)) {
764
- if (n.properties && n.properties.length > 0) {
765
- let propertiesLength = n.properties.length;
766
- for (let i = 0; i < propertiesLength; ++i) {
767
- let property = n.properties[i];
768
- if (property.ref && property.value && property.datatype && property.applies_to && property.applies_to === appliesTo) {
769
- let ref = property.ref;
770
- if (!props[ref]) {
771
- props[ref] = new Set();
772
- }
773
- props[ref].add(property.value);
774
- }
1027
+ // Decides whether a property should REPLACE the node names as the
1028
+ // displayed tip label, and which one. Database exports often name their
1029
+ // tips with identifiers (PATRIC.10334.249.FJ478159..., 11320.305060)
1030
+ // while carrying the readable name in a property such as
1031
+ // BVBRC:genome_name. All of the following must hold, or the answer is
1032
+ // null and the names stand:
1033
+ //
1034
+ // - at least 80% of the named external nodes have identifier-like
1035
+ // names (no spaces, at least one digit) -- readable names are never
1036
+ // overridden;
1037
+ // - the property's local name ends in "name" (genome_name,
1038
+ // sample_name, ...): only fields that say they are names qualify;
1039
+ // - it covers at least 90% of the external nodes, is mostly distinct
1040
+ // (>= 50%), and is mostly wordy (>= 50% of values contain a space)
1041
+ // -- which is what separates genome_name from strain codes.
1042
+ //
1043
+ // Of several qualifiers, the best-covered wins, ties alphabetically.
1044
+ forester.nodeLabelProperty = function (tree) {
1045
+ let total = 0;
1046
+ let named = 0;
1047
+ let idLike = 0;
1048
+ let refs = {}; // ref -> {covered, values:Set, wordy}
1049
+ forester.preOrderTraversalAll(tree, function (n) {
1050
+ if (n.children || n._children) {
1051
+ return;
1052
+ }
1053
+ total++;
1054
+ if (n.name && String(n.name).trim().length > 0) {
1055
+ named++;
1056
+ let name = String(n.name).trim();
1057
+ if (!/\s/.test(name) && /\d/.test(name)) {
1058
+ idLike++;
1059
+ }
1060
+ }
1061
+ if (n.properties) {
1062
+ let seen = {};
1063
+ for (let i = 0; i < n.properties.length; ++i) {
1064
+ let p = n.properties[i];
1065
+ if (!p.ref || p.applies_to !== 'node' || seen[p.ref]
1066
+ || p.ref.indexOf(VIS_EXCLUDED_REF_PREFIX) === 0) {
1067
+ continue;
1068
+ }
1069
+ let local = p.ref.indexOf(':') >= 0 ? p.ref.substring(p.ref.indexOf(':') + 1) : p.ref;
1070
+ if (!/name$/i.test(local)) {
1071
+ continue;
1072
+ }
1073
+ let v = (p.value === undefined || p.value === null) ? '' : String(p.value).trim();
1074
+ if (v.length === 0) {
1075
+ continue;
1076
+ }
1077
+ seen[p.ref] = true;
1078
+ if (!refs[p.ref]) {
1079
+ refs[p.ref] = {covered: 0, values: new Set(), wordy: 0};
1080
+ }
1081
+ refs[p.ref].covered++;
1082
+ refs[p.ref].values.add(v);
1083
+ if (v.indexOf(' ') >= 0) {
1084
+ refs[p.ref].wordy++;
775
1085
  }
776
1086
  }
777
1087
  }
778
1088
  });
779
- return props;
1089
+ if (named === 0 || idLike * 10 < named * 8) {
1090
+ return null;
1091
+ }
1092
+ let best = null;
1093
+ Object.keys(refs).sort().forEach(function (ref) {
1094
+ let r = refs[ref];
1095
+ if (r.covered * 10 < total * 9) {
1096
+ return;
1097
+ }
1098
+ if (r.values.size * 2 < r.covered) {
1099
+ return;
1100
+ }
1101
+ if (r.wordy * 2 < r.covered) {
1102
+ return;
1103
+ }
1104
+ if (!best || r.covered > refs[best].covered) {
1105
+ best = ref;
1106
+ }
1107
+ });
1108
+ return best;
780
1109
  };
781
1110
 
1111
+ // The desktop's reserved "style:" namespace, read back as the rendering
1112
+ // instruction it is (NodeVisualData on the desktop): per-node font colour,
1113
+ // node colour, node shape, font size and font style. The rest of the
1114
+ // vocabulary (font name, node_size, node_transparency, node_fill_type) is
1115
+ // not honoured by this viewer yet. Returns null when the node carries
1116
+ // none of the five.
1117
+ forester.nodeVisualStyle = function (node) {
1118
+ if (!node.properties) {
1119
+ return null;
1120
+ }
1121
+ let style = null;
1122
+ function put(key, value) {
1123
+ if (style === null) {
1124
+ style = {};
1125
+ }
1126
+ style[key] = value;
1127
+ }
1128
+ for (let i = 0; i < node.properties.length; ++i) {
1129
+ let p = node.properties[i];
1130
+ if (!p.ref || p.applies_to !== 'node' || p.value === undefined || p.value === null) {
1131
+ continue;
1132
+ }
1133
+ let v = String(p.value).trim();
1134
+ if (v.length === 0) {
1135
+ continue;
1136
+ }
1137
+ if (p.ref === 'style:font_color') {
1138
+ put('fontColor', v);
1139
+ } else if (p.ref === 'style:node_color') {
1140
+ put('nodeColor', v);
1141
+ } else if (p.ref === 'style:node_shape') {
1142
+ // the desktop's shape names; rectangle renders as our square
1143
+ if (v === 'rectangle') {
1144
+ put('shape', 'square');
1145
+ } else if (v === 'circle' || v === 'diamond') {
1146
+ put('shape', v);
1147
+ }
1148
+ } else if (p.ref === 'style:font_size') {
1149
+ let n = Number(v);
1150
+ if (Number.isFinite(n) && n > 0) {
1151
+ put('fontSize', Math.min(48, Math.max(4, n)));
1152
+ }
1153
+ } else if (p.ref === 'style:font_style') {
1154
+ if (v === 'italic' || v === 'bold' || v === 'bold_italic' || v === 'plain') {
1155
+ put('fontStyle', v);
1156
+ }
1157
+ }
1158
+ }
1159
+ return style;
1160
+ };
782
1161
 
783
- /**
784
- *
785
- * Special method for IRD database.
786
- * Returns true if at least one 'ird:Host' property with 'Avian' found
787
- *
788
- * @param phy
789
- * @param targetValue
790
- * @param fromRef
791
- * @param toRef
792
- * @returns {boolean}
793
- */
794
- forester.splitProperty = function (phy, targetValue, fromRef, toRef) {
795
- let found = false;
796
- let targetValue_ = targetValue + ' ';
797
- forester.preOrderTraversalAll(phy, function (n) {
798
- if (n.properties && n.properties.length > 0) {
799
- let propertiesLength = n.properties.length;
800
- for (let i = 0; i < propertiesLength; ++i) {
801
- let property = n.properties[i];
802
- if (property.ref === fromRef && property.value) {
803
- let newValue = '';
804
- if (property.value.startsWith(targetValue_)) {
805
- newValue = targetValue;
806
- found = true;
807
- } else {
808
- newValue = property.value;
809
- }
810
- let newproperty = {};
811
- newproperty.ref = toRef;
812
- newproperty.value = newValue;
813
- newproperty.datatype = 'xsd:string';
814
- newproperty.applies_to = 'node';
815
- n.properties.push(newproperty);
816
- }
1162
+ // The boring part of every tip name. When the displayed names all share
1163
+ // a long prefix ("Influenza A virus ..."), a shortener that keeps the
1164
+ // first characters keeps exactly the characters that carry no
1165
+ // information. This returns the longest common prefix of the displayed
1166
+ // external names -- the label property's value where one is in effect,
1167
+ // the node name otherwise -- cut back to the last separator so no word
1168
+ // is split, and only when it is long enough to matter (>= 6 characters).
1169
+ // The Short Names rendering strips it before truncating, so what
1170
+ // survives is the part that tells the tips apart. The comparison is
1171
+ // case-insensitive -- "Influenza A virus" and "Influenza A Virus" are
1172
+ // the same boring prefix -- so callers must strip by LENGTH, comparing
1173
+ // case-insensitively, not by exact match.
1174
+ forester.commonNamePrefix = function (tree, labelProperty) {
1175
+ let names = [];
1176
+ let slot = labelProperty ? {kind: 'property', ref: labelProperty} : null;
1177
+ forester.preOrderTraversalAll(tree, function (n) {
1178
+ if (n.children || n._children) {
1179
+ return;
1180
+ }
1181
+ let name = slot ? forester.visualizationNodeValue(n, slot) : null;
1182
+ if (name === null && n.name !== undefined && n.name !== null) {
1183
+ let s = String(n.name).trim();
1184
+ if (s.length > 0) {
1185
+ name = s;
817
1186
  }
818
1187
  }
1188
+ if (name !== null) {
1189
+ names.push(name);
1190
+ }
819
1191
  });
820
- return found;
1192
+ if (names.length < 2) {
1193
+ return '';
1194
+ }
1195
+ let prefix = names[0];
1196
+ for (let k = 1; k < names.length && prefix.length > 0; ++k) {
1197
+ let a = prefix.toLowerCase();
1198
+ let b = names[k].toLowerCase();
1199
+ let max = Math.min(a.length, b.length);
1200
+ let i = 0;
1201
+ while (i < max && a.charCodeAt(i) === b.charCodeAt(i)) {
1202
+ ++i;
1203
+ }
1204
+ if (i < prefix.length) {
1205
+ prefix = prefix.substring(0, i);
1206
+ }
1207
+ }
1208
+ if (prefix.length === 0) {
1209
+ return '';
1210
+ }
1211
+ // Trim back to the last separator ONLY when the prefix actually
1212
+ // splits a word -- "ABC_ho" against "ABC_house"/"ABC_horse" does,
1213
+ // "Influenza A virus" against "...virus A/x" and "...virus(A/y)"
1214
+ // does not, whatever character each name continues with.
1215
+ let alnum = /[A-Za-z0-9]/;
1216
+ let splitsWord = alnum.test(prefix.charAt(prefix.length - 1))
1217
+ && names.some(function (name) {
1218
+ return name.length > prefix.length && alnum.test(name.charAt(prefix.length));
1219
+ });
1220
+ if (splitsWord) {
1221
+ let cut = -1;
1222
+ for (let i = prefix.length - 1; i >= 0; --i) {
1223
+ if (' /|_.-:'.indexOf(prefix.charAt(i)) >= 0) {
1224
+ cut = i;
1225
+ break;
1226
+ }
1227
+ }
1228
+ prefix = cut >= 0 ? prefix.substring(0, cut + 1) : '';
1229
+ }
1230
+ return prefix.length >= 6 ? prefix : '';
821
1231
  };
822
1232
 
823
- forester.collectPropertyRefs = function (phy, appliesTo, externalOnly) {
824
- let propertyRefs = new Set();
825
- forester.preOrderTraversalAll(phy, function (n) {
826
-
827
- if (!externalOnly || externalOnly !== true || (!n.children && !n._children)) {
828
- if (n.properties && n.properties.length > 0) {
829
- let propertiesLength = n.properties.length;
830
- for (let i = 0; i < propertiesLength; ++i) {
831
- let property = n.properties[i];
832
- if (property.ref && property.value && property.datatype && property.applies_to && property.applies_to === appliesTo) {
833
- propertyRefs.add(property.ref);
1233
+ // Reads a node's value for one candidate, exactly as the classifier read
1234
+ // it when it built the candidate -- the two must never drift, or a node
1235
+ // could carry a value that maps to no colour. Returns the trimmed value,
1236
+ // or null when the node has none (the node then keeps the default look).
1237
+ // Multi-valued refs never become candidates, so "the first value" is
1238
+ // "the only value".
1239
+ forester.visualizationNodeValue = function (node, candidate) {
1240
+ function clean(v) {
1241
+ if (v === undefined || v === null) {
1242
+ return null;
1243
+ }
1244
+ let s = String(v).trim();
1245
+ return s.length > 0 ? s : null;
1246
+ }
1247
+ if (candidate.kind === 'property') {
1248
+ if (node.properties) {
1249
+ for (let i = 0; i < node.properties.length; ++i) {
1250
+ let p = node.properties[i];
1251
+ if (p.ref === candidate.ref && p.applies_to === 'node') {
1252
+ let v = clean(p.value);
1253
+ if (v !== null) {
1254
+ // a classifier-built candidate folds the value the
1255
+ // same way its groups were built; a bare
1256
+ // {kind, ref} probe (labels, prefixes) reads raw
1257
+ if (candidate.canon) {
1258
+ let display = visDisplayLabel(v, candidate.cut || null);
1259
+ return candidate.canon[display.toLowerCase()] || display;
1260
+ }
1261
+ return v;
834
1262
  }
835
1263
  }
836
1264
  }
837
1265
  }
838
- });
839
- return propertyRefs;
1266
+ return null;
1267
+ }
1268
+ let list = candidate.kind === 'taxonomy' ? node.taxonomies : node.sequences;
1269
+ if (!list) {
1270
+ return null;
1271
+ }
1272
+ for (let i = 0; i < VIS_ELEMENT_SLOTS.length; ++i) {
1273
+ if (VIS_ELEMENT_SLOTS[i].id === candidate.id) {
1274
+ for (let j = 0; j < list.length; ++j) {
1275
+ let v = clean(VIS_ELEMENT_SLOTS[i].get(list[j]));
1276
+ if (v !== null) {
1277
+ return v;
1278
+ }
1279
+ }
1280
+ return null;
1281
+ }
1282
+ }
1283
+ return null;
840
1284
  };
841
1285
 
842
-
843
- forester.shortenProperties = function (phy, appliesTo, externalOnly, sourceRef, targetRef) {
1286
+ forester.collectPropertyRefs = function (phy, appliesTo, externalOnly) {
1287
+ let propertyRefs = new Set();
844
1288
  forester.preOrderTraversalAll(phy, function (n) {
1289
+
845
1290
  if (!externalOnly || externalOnly !== true || (!n.children && !n._children)) {
846
1291
  if (n.properties && n.properties.length > 0) {
847
1292
  let propertiesLength = n.properties.length;
848
1293
  for (let i = 0; i < propertiesLength; ++i) {
849
1294
  let property = n.properties[i];
850
1295
  if (property.ref && property.value && property.datatype && property.applies_to && property.applies_to === appliesTo) {
851
- if (property.ref === sourceRef) {
852
- let s = property.value.trim().split(/\s+/);
853
- if (s && s.length > 1) {
854
- let newProp = {};
855
- newProp.ref = targetRef;
856
- if (s.length === 2) {
857
- newProp.value = s[0];
858
- } else {
859
- newProp.value = s[0] + ' ' + s[1];
860
- }
861
- newProp.datatype = property.datatype;
862
- newProp.applies_to = property.applies_to;
863
- n.properties.push(newProp);
864
- }
865
- }
1296
+ propertyRefs.add(property.ref);
866
1297
  }
867
1298
  }
868
1299
  }
869
1300
  }
870
1301
  });
1302
+ return propertyRefs;
871
1303
  };
872
1304
 
873
1305
  forester.collectBasicTreeProperties = function (tree) {
@@ -877,18 +1309,56 @@
877
1309
  properties.longestNodeName = 0;
878
1310
  properties.branchLengths = false;
879
1311
  properties.confidences = false;
1312
+ // the largest confidence value seen -- how a caller tells a
1313
+ // posterior-probability tree (max <= 1) from a bootstrap tree
1314
+ properties.maxConfidence = 0;
880
1315
  properties.nodeEvents = false;
1316
+ properties.branchColors = false;
881
1317
  properties.sequences = false;
882
1318
  properties.taxonomies = false;
883
1319
  properties.alignedMolSeqs = true;
884
1320
  properties.maxMolSeqLength = 0;
885
1321
  properties.externalNodesCount = 0;
886
- properties.molSeqResiduesPerPosition = null;
1322
+ properties.nodeCount = 0;
1323
+ // How many of the tree's branches actually carry a positive length.
1324
+ // Whether a tree is worth drawing to scale is a question about the
1325
+ // majority of its branches, not about whether any branch has a length.
1326
+ properties.branchesWithPositiveLength = 0;
1327
+ // Branches that carry a length AT ALL -- an explicit zero is a real
1328
+ // measurement, not a missing one, so these are counted separately from
1329
+ // the "positive" tally above. Split internal-vs-all because a missing
1330
+ // length means different things in the two places: an unmeasured TIP
1331
+ // still draws correctly (at its parent), while an unmeasured INTERNAL
1332
+ // branch destroys the scale. All four counts exclude the root, which
1333
+ // has no branch above it.
1334
+ properties.branchCount = 0;
1335
+ properties.branchesWithLength = 0;
1336
+ properties.internalBranchCount = 0;
1337
+ properties.internalBranchesWithLength = 0;
887
1338
  properties.averageBranchLength = 0;
888
1339
  let bl_counter = 0;
889
1340
  let bl_sum = 0;
890
- let molSeqs = [];
891
- forester.preOrderTraversalAll(tree, function (n) {
1341
+ // Counting the super-root would add a node and a branch that do not
1342
+ // exist -- skewing the branch-length fraction the viewer uses to choose
1343
+ // between a phylogram and a cladogram -- and from phyloXML would take
1344
+ // the tree's own name for the longest node name.
1345
+ let rootNode = realRootOf(tree);
1346
+ forester.preOrderTraversalAll(rootNode, function (n) {
1347
+ properties.nodeCount += 1;
1348
+ if (n !== rootNode) {
1349
+ let internal = !!(n.children || n._children);
1350
+ let measured = typeof n.branch_length === 'number' && isFinite(n.branch_length);
1351
+ properties.branchCount += 1;
1352
+ if (measured) {
1353
+ properties.branchesWithLength += 1;
1354
+ }
1355
+ if (internal) {
1356
+ properties.internalBranchCount += 1;
1357
+ if (measured) {
1358
+ properties.internalBranchesWithLength += 1;
1359
+ }
1360
+ }
1361
+ }
892
1362
  if (n.name && n.name.length > 0) {
893
1363
  properties.nodeNames = true;
894
1364
  if (n.name.length > properties.longestNodeName) {
@@ -909,6 +1379,9 @@
909
1379
  if (n.events) {
910
1380
  properties.nodeEvents = true;
911
1381
  }
1382
+ if (n.color) {
1383
+ properties.branchColors = true;
1384
+ }
912
1385
  if (n.sequences && n.sequences.length > 0) {
913
1386
  properties.sequences = true;
914
1387
 
@@ -922,8 +1395,6 @@
922
1395
  }
923
1396
  if (!s.mol_seq.is_aligned) {
924
1397
  properties.alignedMolSeqs = false;
925
- } else {
926
- molSeqs.push(s.mol_seq.value);
927
1398
  }
928
1399
  }
929
1400
  }
@@ -936,6 +1407,12 @@
936
1407
  }
937
1408
  if (n.confidences && n.confidences.length > 0) {
938
1409
  properties.confidences = true;
1410
+ for (let ci = 0; ci < n.confidences.length; ++ci) {
1411
+ let cv = n.confidences[ci].value;
1412
+ if (typeof cv === 'number' && isFinite(cv) && cv > properties.maxConfidence) {
1413
+ properties.maxConfidence = cv;
1414
+ }
1415
+ }
939
1416
  }
940
1417
  if (n.properties && n.properties.length > 0) {
941
1418
  let l = n.properties.length;
@@ -948,29 +1425,7 @@
948
1425
 
949
1426
  });
950
1427
 
951
- if (properties.alignedMolSeqs) {
952
- properties.molSeqResiduesPerPosition = [];
953
- for (let p = 0, maxLen = properties.maxMolSeqLength; p < maxLen; ++p) {
954
- let mySet = new Set();
955
- for (let i = 0, seqsLen = molSeqs.length; i < seqsLen; ++i) {
956
- let molSeq = molSeqs[i];
957
- let c = molSeq[p];
958
- if (c) {
959
- c = c.toUpperCase();
960
- mySet.add(c);
961
- if (!MSA_RESIDUE_SORT_MAP.has(c)) {
962
- throw ("Unknown MSA residue '" + c + "'");
963
- }
964
- }
965
-
966
- }
967
- let myArray = forester.setToArray(mySet);
968
- myArray.sort(function (a, b) {
969
- return MSA_RESIDUE_SORT_MAP.get(a) - MSA_RESIDUE_SORT_MAP.get(b);
970
- });
971
- properties.molSeqResiduesPerPosition.push(myArray);
972
- }
973
- }
1428
+ properties.branchesWithPositiveLength = bl_counter;
974
1429
 
975
1430
  if (bl_counter > 0) {
976
1431
  properties.averageBranchLength = bl_sum / bl_counter;
@@ -1016,23 +1471,52 @@
1016
1471
  return nodes;
1017
1472
  };
1018
1473
 
1019
- /**
1020
- * Returns true if at least one of the child nodes
1021
- * of node is collapsed.
1022
- *
1023
- * @param node - A node.
1024
- * @returns {boolean} - true if at least one of the child nodes is
1025
- * collapsed
1026
- */
1027
- forester.isHasCollapsedNodes = function (node) {
1028
- let collapsed = false;
1029
- forester.preOrderTraversalAll(node, function (n) {
1030
- if (n._children) {
1031
- collapsed = true;
1032
-
1474
+ // Ladderize: at every node, order the VISIBLE children (n.children; a
1475
+ // collapsed node's hidden _children are left untouched) by clade size --
1476
+ // largest first when largestFirst, smallest first when not. Works at ANY
1477
+ // child count, not just 2, so a polytomy (common on a phylodynamic tree,
1478
+ // e.g. an Auspice build, where every internal node may carry 3+ children)
1479
+ // is sorted exactly like a bifurcation. The sort is STABLE (ties keep
1480
+ // their existing relative order), so a node that already reads correctly
1481
+ // is never needlessly disturbed. Mutates the tree in place; returns
1482
+ // whether anything actually changed.
1483
+ forester.ladderize = function (node, largestFirst) {
1484
+ let changed = false;
1485
+ ord(node);
1486
+ return changed;
1487
+
1488
+ function ord(n) {
1489
+ if (!n.children) {
1490
+ return;
1033
1491
  }
1034
- });
1035
- return collapsed;
1492
+ let c = n.children;
1493
+ let l = c.length;
1494
+ if (l >= 2) {
1495
+ let counts = c.map(function (child) {
1496
+ return forester.calcSumOfAllExternalDescendants(child);
1497
+ });
1498
+ let order = c.map(function (child, i) {
1499
+ return i;
1500
+ });
1501
+ order.sort(function (i, j) {
1502
+ if (counts[i] === counts[j]) {
1503
+ return i - j;
1504
+ }
1505
+ return largestFirst ? (counts[j] - counts[i]) : (counts[i] - counts[j]);
1506
+ });
1507
+ if (order.some(function (idx, i) {
1508
+ return idx !== i;
1509
+ })) {
1510
+ changed = true;
1511
+ n.children = order.map(function (idx) {
1512
+ return c[idx];
1513
+ });
1514
+ }
1515
+ }
1516
+ for (let i = 0; i < n.children.length; ++i) {
1517
+ ord(n.children[i]);
1518
+ }
1519
+ }
1036
1520
  };
1037
1521
 
1038
1522
  forester.getAllExternalNodes = function (node) {
@@ -1126,77 +1610,349 @@
1126
1610
  });
1127
1611
  };
1128
1612
 
1129
-
1130
- forester.collapseToBranchLength = function (root, branchLength) {
1131
- if (root.children && root.children.length === 1) {
1132
- collapseToBranchLengthHelper(root.children[0], branchLength);
1613
+ forester.collapse = function (node) {
1614
+ if (node.children) {
1615
+ node._children = node.children;
1616
+ node.children = null;
1133
1617
  }
1618
+ };
1134
1619
 
1135
- function collapseToBranchLengthHelper(n, branchLength) {
1136
- if (!(n.children || n._children)) {
1137
- return;
1138
- }
1139
-
1140
- if (!n.max) {
1141
- n.max = forester.calcMaxBranchLength(n);
1142
- }
1143
- let max = n.max;
1144
- if (max < branchLength) {
1145
- forester.collapse(n);
1146
- } else {
1147
- forester.unCollapse(n);
1148
- for (let i = n.children.length - 1; i >= 0; i--) {
1149
- collapseToBranchLengthHelper(n.children[i], branchLength);
1150
- }
1151
- }
1620
+ forester.unCollapse = function (node) {
1621
+ if (node._children) {
1622
+ node.children = node._children;
1623
+ node._children = null;
1152
1624
  }
1153
1625
  };
1154
1626
 
1155
- forester.collapseToDepth = function (root, depth) {
1156
- if (root.children && root.children.length === 1) {
1157
- collapseToDepthHelper(root.children[0], 0, depth);
1627
+ /**
1628
+ * To parse a New Hampshire (Newick) formatted tree.
1629
+ *
1630
+ * @param nhStr - A New Hampshire (Newick) formatted string.
1631
+ * @param confidenceValuesInBrackets - Set to true if confidence values are in brackets (default: true)
1632
+ * Format is: name:distance[confidence]
1633
+ * Example: "bcl2:0.000393[95]"
1634
+ * @param confidenceValuesAsInternalNames - Set to true if confidence values are represented by internal names (default: false).
1635
+ * @returns {{}} - A phylogenetic tree object.
1636
+ */
1637
+ // ---------------------------------------------------------------
1638
+ // Extended Newick annotations: BEAST-style [&key=value,...] and
1639
+ // NHX [&&NHX:tag=value:...]
1640
+ // ---------------------------------------------------------------
1641
+ //
1642
+ // ALWAYS parsed (the desktop keeps this behind a
1643
+ // setParseBeastStyleExtendedTags option; here the blobs were simply
1644
+ // discarded before, so ingesting them can regress nothing). Ported from
1645
+ // the desktop's BeastAnnotationParser + the NHXParser tag loop.
1646
+
1647
+ // Pre-tokenization pass: pull every [&...] annotation out of the Newick
1648
+ // string and leave a [@N] marker in its place -- the blob's commas,
1649
+ // colons and quotes must never reach the Newick tokenizer. A bracket NOT
1650
+ // starting with '&' (a [95] confidence) is left untouched, as is any
1651
+ // bracket inside a quoted label. Quotes and nested brackets inside an
1652
+ // annotation are honoured when finding its end.
1653
+ function extractBracketAnnotations(str) {
1654
+ if (str.indexOf('[') < 0) {
1655
+ return {text: str, blobs: []};
1656
+ }
1657
+ let out = '';
1658
+ let blobs = [];
1659
+ let inSq = false;
1660
+ let inDq = false;
1661
+ for (let i = 0; i < str.length; ++i) {
1662
+ let c = str.charAt(i);
1663
+ if (inSq || inDq) {
1664
+ out += c;
1665
+ if ((inSq && c === "'") || (inDq && c === '"')) {
1666
+ inSq = false;
1667
+ inDq = false;
1668
+ }
1669
+ } else if (c === "'") {
1670
+ inSq = true;
1671
+ out += c;
1672
+ } else if (c === '"') {
1673
+ inDq = true;
1674
+ out += c;
1675
+ } else if (c === '[') {
1676
+ let j = i + 1;
1677
+ let depth = 1;
1678
+ let q = null;
1679
+ while (j < str.length && depth > 0) {
1680
+ let cj = str.charAt(j);
1681
+ if (q) {
1682
+ if (cj === q) {
1683
+ q = null;
1684
+ }
1685
+ } else if (cj === "'" || cj === '"') {
1686
+ q = cj;
1687
+ } else if (cj === '[') {
1688
+ ++depth;
1689
+ } else if (cj === ']') {
1690
+ --depth;
1691
+ }
1692
+ if (depth > 0) {
1693
+ ++j;
1694
+ }
1695
+ }
1696
+ let content = str.substring(i + 1, j);
1697
+ if (/^\s*&/.test(content)) {
1698
+ out += '[@' + blobs.length + ']';
1699
+ blobs.push(content.trim());
1700
+ } else {
1701
+ out += str.substring(i, Math.min(j + 1, str.length));
1702
+ }
1703
+ i = j;
1704
+ } else {
1705
+ out += c;
1706
+ }
1158
1707
  }
1708
+ return {text: out, blobs: blobs};
1709
+ }
1159
1710
 
1160
- function collapseToDepthHelper(n, d, depth) {
1161
- if (!n.children && !n._children) {
1711
+ function pushConfidence(node, value, type, stddev) {
1712
+ if (!node.confidences) {
1713
+ node.confidences = [];
1714
+ }
1715
+ let c = {type: type, value: value};
1716
+ if (stddev !== undefined && stddev !== null) {
1717
+ c.stddev = stddev;
1718
+ }
1719
+ node.confidences.push(c);
1720
+ }
1721
+
1722
+ function nodeTaxonomy0(node) {
1723
+ if (!node.taxonomies) {
1724
+ node.taxonomies = [{}];
1725
+ }
1726
+ return node.taxonomies[0];
1727
+ }
1728
+
1729
+ function nodeSequence0(node) {
1730
+ if (!node.sequences) {
1731
+ node.sequences = [{}];
1732
+ }
1733
+ return node.sequences[0];
1734
+ }
1735
+
1736
+ // Split on TOP-LEVEL commas only: a comma inside {...}/[...] sets or
1737
+ // inside quotes is data, not a separator (height_95%_HPD={1.4,1.5} must
1738
+ // stay one token).
1739
+ function splitTopLevelCommas(s) {
1740
+ let out = [];
1741
+ let depth = 0;
1742
+ let q = null;
1743
+ let cur = '';
1744
+ for (let i = 0; i < s.length; ++i) {
1745
+ let c = s.charAt(i);
1746
+ if (q) {
1747
+ if (c === q) {
1748
+ q = null;
1749
+ }
1750
+ cur += c;
1751
+ } else if (c === "'" || c === '"') {
1752
+ q = c;
1753
+ cur += c;
1754
+ } else if (c === '{' || c === '[') {
1755
+ ++depth;
1756
+ cur += c;
1757
+ } else if (c === '}' || c === ']') {
1758
+ if (depth > 0) {
1759
+ --depth;
1760
+ }
1761
+ cur += c;
1762
+ } else if (c === ',' && depth === 0) {
1763
+ out.push(cur);
1764
+ cur = '';
1765
+ } else {
1766
+ cur += c;
1767
+ }
1768
+ }
1769
+ if (cur.length > 0) {
1770
+ out.push(cur);
1771
+ }
1772
+ return out;
1773
+ }
1774
+
1775
+ function parseBeastNumber(v) {
1776
+ let d = parseFloat(v);
1777
+ return (isFinite(d) && isFinite(Number(v))) ? d : null;
1778
+ }
1779
+
1780
+ // A two-value BEAST set {lo,hi} (or [lo,hi]) as [lo,hi] numbers, or null.
1781
+ function parseBeastInterval(v) {
1782
+ let s = v.trim();
1783
+ if (s.length < 3 || (s.charAt(0) !== '{' && s.charAt(0) !== '[')) {
1784
+ return null;
1785
+ }
1786
+ let parts = splitTopLevelCommas(s.substring(1, s.length - 1));
1787
+ if (parts.length !== 2) {
1788
+ return null;
1789
+ }
1790
+ let lo = parseBeastNumber(parts[0].trim());
1791
+ let hi = parseBeastNumber(parts[1].trim());
1792
+ return (lo !== null && hi !== null) ? [lo, hi] : null;
1793
+ }
1794
+
1795
+ function stripValueQuotes(v) {
1796
+ if (v.length >= 2
1797
+ && ((v.charAt(0) === '"' && v.charAt(v.length - 1) === '"')
1798
+ || (v.charAt(0) === "'" && v.charAt(v.length - 1) === "'"))) {
1799
+ return v.substring(1, v.length - 1);
1800
+ }
1801
+ return v;
1802
+ }
1803
+
1804
+ // A property-ref-safe rendering of a BEAST key: keep letters/digits,
1805
+ // collapse every other run to one underscore, drop a trailing one
1806
+ // (rate_95%_HPD -> a clean beast:rate_95_HPD ref).
1807
+ function beastRefKey(key) {
1808
+ return key.replace(/[^A-Za-z0-9]+/g, '_').replace(/_$/, '');
1809
+ }
1810
+
1811
+ // A BEAST / BEAST X / TreeAnnotator / FigTree / MrBayes [&...] blob onto
1812
+ // one node, each field mapped to the phyloXML structure the existing
1813
+ // display features consume:
1814
+ // - posterior -> a confidence of type "posterior"; MrBayes prob (+
1815
+ // prob_stddev) -> "posterior probability"; bootstrap -> "bootstrap";
1816
+ // - node age height/height_mean/height_median + height_95%_HPD (or
1817
+ // height_range) + date -> node.date value/min/max/desc (the node-age
1818
+ // HPD bars draw the interval);
1819
+ // - FigTree !color=#rrggbb -> the branch color;
1820
+ // - every other field (rate, length_*, traits, location, ...) -> a
1821
+ // beast:<key> node property (numeric -> xsd:decimal, so Color-by
1822
+ // picks it up).
1823
+ // The branch length lives on the Newick ":length" and is left untouched.
1824
+ // A malformed field is skipped, never aborting the parse.
1825
+ function applyBeastAnnotations(node, blob) {
1826
+ let heightMedian = null;
1827
+ let heightMean = null;
1828
+ let height = null;
1829
+ let hpd = null;
1830
+ let range = null;
1831
+ let dateDesc = null;
1832
+ let prob = null;
1833
+ let probSd = null;
1834
+ splitTopLevelCommas(blob).forEach(function (token) {
1835
+ let eq = token.indexOf('=');
1836
+ if (eq <= 0) {
1162
1837
  return;
1163
1838
  }
1164
- if (d >= depth) {
1165
- forester.collapse(n);
1166
- } else {
1167
- forester.unCollapse(n);
1168
- ++d;
1169
- for (let i = n.children.length - 1; i >= 0; i--) {
1170
- collapseToDepthHelper(n.children[i], d, depth);
1839
+ let key = token.substring(0, eq).trim();
1840
+ let value = stripValueQuotes(token.substring(eq + 1).trim());
1841
+ if (key.length === 0 || value.length === 0) {
1842
+ return;
1843
+ }
1844
+ let kl = key.toLowerCase();
1845
+ if (kl === 'posterior') {
1846
+ let d = parseBeastNumber(value);
1847
+ if (d !== null) {
1848
+ pushConfidence(node, d, 'posterior');
1171
1849
  }
1850
+ } else if (kl === 'prob') {
1851
+ prob = parseBeastNumber(value);
1852
+ } else if (kl === 'prob_stddev') {
1853
+ probSd = parseBeastNumber(value);
1854
+ } else if (kl === 'bootstrap') {
1855
+ let b = parseBeastNumber(value);
1856
+ if (b !== null) {
1857
+ pushConfidence(node, b, 'bootstrap');
1858
+ }
1859
+ } else if ((kl === '!color' || kl === '!colour')
1860
+ && /^#[0-9a-f]{6}$/i.test(value)) {
1861
+ node.color = {
1862
+ red: parseInt(value.substring(1, 3), 16),
1863
+ green: parseInt(value.substring(3, 5), 16),
1864
+ blue: parseInt(value.substring(5, 7), 16)
1865
+ };
1866
+ } else if (kl === 'height_median') {
1867
+ heightMedian = value;
1868
+ } else if (kl === 'height_mean') {
1869
+ heightMean = value;
1870
+ } else if (kl === 'height') {
1871
+ height = value;
1872
+ } else if (kl === 'height_95%_hpd') {
1873
+ hpd = parseBeastInterval(value);
1874
+ } else if (kl === 'height_range') {
1875
+ range = parseBeastInterval(value);
1876
+ } else if (kl === 'date') {
1877
+ dateDesc = value;
1878
+ } else {
1879
+ addNodeProperty(node, 'beast:' + beastRefKey(key), value);
1172
1880
  }
1881
+ });
1882
+ if (prob !== null) {
1883
+ pushConfidence(node, prob, 'posterior probability', probSd);
1173
1884
  }
1174
- };
1175
-
1176
- forester.collapse = function (node) {
1177
- if (node.children) {
1178
- node._children = node.children;
1179
- node.children = null;
1885
+ // age preference: median, then mean, then height -- and each piece
1886
+ // parsed independently, so an unparseable point value never discards
1887
+ // a valid {lo,hi} interval
1888
+ let v = heightMedian !== null ? heightMedian
1889
+ : (heightMean !== null ? heightMean : height);
1890
+ let dv = (v !== null) ? parseBeastNumber(v) : null;
1891
+ let interval = hpd || range;
1892
+ if (dv === null && !interval && dateDesc === null) {
1893
+ return;
1180
1894
  }
1181
- };
1895
+ let date = {};
1896
+ if (dv !== null) {
1897
+ date.value = dv;
1898
+ }
1899
+ if (interval) {
1900
+ date.minimum = interval[0];
1901
+ date.maximum = interval[1];
1902
+ }
1903
+ if (dateDesc !== null) {
1904
+ date.desc = dateDesc;
1905
+ }
1906
+ node.date = date;
1907
+ }
1182
1908
 
1183
- forester.unCollapse = function (node) {
1184
- if (node._children) {
1185
- node.children = node._children;
1186
- node._children = null;
1909
+ // The classic NHX tag set, as the desktop maps it: S= taxonomy
1910
+ // scientific name, T= taxonomy id, B= support confidence, D= a
1911
+ // duplication (Y/T) / speciation (N/F) / undecided (?) event, GN=
1912
+ // sequence name, AC= sequence accession, C= an nh:comment property.
1913
+ // Unknown tags (and DS= domain structures) are ignored.
1914
+ function applyNhxTags(node, content) {
1915
+ content.split(':').forEach(function (tag) {
1916
+ let t = tag.trim();
1917
+ if (t.length < 3) {
1918
+ return;
1919
+ }
1920
+ if (t.startsWith('S=')) {
1921
+ nodeTaxonomy0(node).scientific_name = t.substring(2);
1922
+ } else if (t.startsWith('T=')) {
1923
+ nodeTaxonomy0(node).id = {value: t.substring(2)};
1924
+ } else if (t.startsWith('B=')) {
1925
+ let b = parseBeastNumber(t.substring(2));
1926
+ if (b !== null) {
1927
+ pushConfidence(node, b, 'bootstrap');
1928
+ }
1929
+ } else if (t.startsWith('D=')) {
1930
+ let c = t.charAt(2);
1931
+ if (c === 'Y' || c === 'T') {
1932
+ node.events = {duplications: 1};
1933
+ } else if (c === 'N' || c === 'F') {
1934
+ node.events = {speciations: 1};
1935
+ } else if (c === '?') {
1936
+ node.events = {type: 'speciation_or_duplication'};
1937
+ }
1938
+ } else if (t.startsWith('GN=')) {
1939
+ nodeSequence0(node).name = t.substring(3);
1940
+ } else if (t.startsWith('AC=')) {
1941
+ nodeSequence0(node).accession = {value: t.substring(3), source: '?'};
1942
+ } else if (t.startsWith('C=')) {
1943
+ addNodeProperty(node, 'nh:comment', t.substring(2));
1944
+ }
1945
+ });
1946
+ }
1947
+
1948
+ function applyExtendedAnnotations(node, blob) {
1949
+ if (/^&&NHX:/i.test(blob)) {
1950
+ applyNhxTags(node, blob.substring(6));
1951
+ } else {
1952
+ applyBeastAnnotations(node, blob.replace(/^&/, ''));
1187
1953
  }
1188
- };
1954
+ }
1189
1955
 
1190
- /**
1191
- * To parse a New Hampshire (Newick) formatted tree.
1192
- *
1193
- * @param nhStr - A New Hampshire (Newick) formatted string.
1194
- * @param confidenceValuesInBrackets - Set to true if confidence values are in brackets (default: true)
1195
- * Format is: name:distance[confidence]
1196
- * Example: "bcl2:0.000393[95]"
1197
- * @param confidenceValuesAsInternalNames - Set to true if confidence values are represented by internal names (default: false).
1198
- * @returns {{}} - A phylogenetic tree object.
1199
- */
1200
1956
  forester.parseNewHampshire = function (nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
1201
1957
 
1202
1958
  let NH_FORMAT_ERR_OPEN_PARENS = NH_FORMAT_ERR + 'likely cause: number of open parentheses is larger than number of close parentheses';
@@ -1208,14 +1964,22 @@
1208
1964
  if (confidenceValuesAsInternalNames === undefined) {
1209
1965
  confidenceValuesAsInternalNames = false;
1210
1966
  }
1211
- if ((confidenceValuesInBrackets === true) && (confidenceValuesAsInternalNames === true)) {
1212
- throw ("confidence values cannot be both in brackets and as internal node names");
1213
- }
1967
+ // The two options used to be mutually exclusive and throwing. They are
1968
+ // not: brackets are consumed by the tokenizer, bare labels are handled
1969
+ // afterwards, so a file mixing the two dialects is read correctly with
1970
+ // both on. forester-Java never had this guard, so it was never part of
1971
+ // the shared contract -- and throwing on a combination a user
1972
+ // plausibly wants is what forced callers into workarounds.
1214
1973
 
1215
1974
  let ancs = [];
1216
1975
  let x = {};
1217
1976
 
1218
- let sss = nhStr.replace(/\[\s*&.+?\]/g, '');
1977
+ // [&...] annotation blobs (BEAST-style key=value, NHX) are pulled
1978
+ // out BEFORE tokenizing -- their commas/colons/quotes are data --
1979
+ // and re-attached to their node via the [@N] markers below
1980
+ let extracted = extractBracketAnnotations(nhStr);
1981
+ let sss = extracted.text;
1982
+ let annotations = extracted.blobs;
1219
1983
 
1220
1984
  let ss = sss.split(/(;|\(|\)|,|:|"|')/);
1221
1985
  let ssl = ss.length;
@@ -1271,10 +2035,24 @@
1271
2035
  } else if (element === ')') {
1272
2036
  x = ancs.pop();
1273
2037
  } else if (element === ':') {
2038
+ // the separator before a branch length: the length itself
2039
+ // is read by the branch below, so there is nothing to do here
1274
2040
  } else {
1275
2041
  let e = ss[i - 1];
1276
2042
  if (e) {
1277
2043
  e = e.trim();
2044
+ // re-attach any annotation blobs riding on this
2045
+ // element (name, branch length, or standalone) to
2046
+ // the current node, and drop the markers
2047
+ if (annotations.length > 0 && element.indexOf('[@') > -1) {
2048
+ element = element.replace(/\[@(\d+)\]/g, function (m, k) {
2049
+ let blob = annotations[+k];
2050
+ if (blob !== undefined) {
2051
+ applyExtendedAnnotations(x, blob);
2052
+ }
2053
+ return '';
2054
+ });
2055
+ }
1278
2056
  if ((e === ')') || (e === '(') || (e === ',')) {
1279
2057
  if (element && element.length > 0) {
1280
2058
  if (element.charAt(element.length - 1) === "]") {
@@ -1410,221 +2188,762 @@
1410
2188
  }
1411
2189
  };
1412
2190
 
1413
- forester.isNumber = function (v) {
1414
- if (v === undefined || v === null) {
1415
- return false;
1416
- }
1417
- if (v != v) {
1418
- // This can only be true if the v is NaN
1419
- return false;
2191
+ // Parses a Nexus-formatted string and returns an ARRAY of tree objects,
2192
+ // each in the same shape parseNewHampshire produces (a Nexus file can
2193
+ // hold any number of trees). Ported from the desktop's
2194
+ // NexusPhylogeniesParser: reads TAXLABELS, the TREES block (TRANSLATE
2195
+ // tables, TREE/UTREE statements, [&R]/[&U] rootedness, tree names and
2196
+ // titles) and CHARACTERS/DATA blocks -- a protein/dna/rna MATRIX
2197
+ // (sequential or interleaved, MATCHCHAR resolved, quoted labels,
2198
+ // comments stripped) becomes per-tip aligned molecular sequences in the
2199
+ // phyloXML shape (sequences[i].mol_seq.{is_aligned,value}), so a tree
2200
+ // read from Nexus shows its alignment track exactly like one read from
2201
+ // phyloXML. The two confidence options are handed through to
2202
+ // parseNewHampshire for each tree statement.
2203
+ forester.parseNexus = function (nexStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
2204
+ const NEXUS_FORMAT_ERR = 'Nexus format error: ';
2205
+ const TITLE_RE = /^title.?\s+([^;]+)/i;
2206
+ const TREE_NAME_RE = /^\s*.?tree\s+(.+?)\s*=/i;
2207
+ const ROOTEDNESS_RE = /=\s*\[&([RU])\]/i;
2208
+ const TRANSLATE_PAIR_RE = /([0-9A-Za-z]+)\s+(.+)/;
2209
+ const RESIDUES_RE = /^[A-Za-z\-_*?.]+$/;
2210
+ const DATATYPE_RE = /datatype\s*=\s*([a-z]+)/;
2211
+ const MATCHCHAR_RE = /matchchar\s*=\s*['"]?(\S)/;
2212
+
2213
+ let trees = [];
2214
+ let taxlabels = [];
2215
+ // null-prototype maps: a taxon named "__proto__" must stay data
2216
+ let translateMap = Object.create(null);
2217
+ let seqs = Object.create(null);
2218
+ let translateBuf = '';
2219
+ let nh = '';
2220
+ let name = '';
2221
+ let title = '';
2222
+ let inTreesBlock = false;
2223
+ let inTaxalabels = false;
2224
+ let inTranslate = false;
2225
+ let inTree = false;
2226
+ let inDataBlock = false;
2227
+ let inMatrix = false;
2228
+ let inDataComment = false;
2229
+ let datatype = null;
2230
+ let rootedInfoPresent = false;
2231
+ let isRooted = false;
2232
+ let matchchar = null;
2233
+ let matrixReferenceId = null;
2234
+
2235
+ // Nexus treats '_' and ' ' as equivalent, labels may be quoted, and a
2236
+ // matrix often capitalizes taxon names differently from the tree -- so
2237
+ // a matrix row joins its tree tip through this canonical key.
2238
+ function joinKey(s) {
2239
+ return s.replace(/_/g, ' ').replace(/['"]+/g, '').trim().toLowerCase();
1420
2240
  }
1421
- return true;
1422
- };
1423
-
1424
- forester.getOneDistinctTaxonomy = function (node) {
1425
- let id = null;
1426
- let code = null;
1427
- let sn = null;
1428
- let cn = null;
1429
- let result = true;
1430
- let sawTax = false;
1431
- forester.preOrderTraversalAll(node, function (n) {
1432
- if (n.taxonomies && n.taxonomies.length === 1) {
1433
- let tax = n.taxonomies[0];
1434
- if (tax.code && tax.code.length > 0) {
1435
- sawTax = true;
1436
- if (code === null) {
1437
- code = tax.code;
1438
- } else if (code !== tax.code) {
1439
- result = false;
1440
- return;
1441
- }
1442
- }
1443
- if (tax.scientific_name && tax.scientific_name.length > 0) {
1444
- sawTax = true;
1445
- if (sn === null) {
1446
- sn = tax.scientific_name;
1447
- } else if (sn !== tax.scientific_name) {
1448
- result = false;
1449
- return;
1450
- }
1451
- }
1452
- if (tax.common_name && tax.common_name.length > 0) {
1453
- sawTax = true;
1454
- if (cn === null) {
1455
- cn = tax.common_name;
1456
- } else if (cn !== tax.common_name) {
1457
- result = false;
1458
- return;
1459
- }
1460
- }
1461
- if (tax.id && tax.id.value && tax.id.value.length > 0) {
1462
- sawTax = true;
1463
- let myid;
1464
- if (tax.id.provider && tax.id.provider.length > 0) {
1465
- myid = tax.id.provider + ':' + tax.id.value;
1466
- } else {
1467
- myid = tax.id.value;
1468
- }
1469
- if (id === null) {
1470
- id = myid;
1471
- } else if (id !== myid) {
1472
- result = false;
1473
2241
 
2242
+ // Strip Nexus [ ... ] comments, tracking an OPEN comment across lines
2243
+ // so a multi-line comment inside the matrix cannot leak prose as a
2244
+ // spurious taxon row. Called only inside the data block -- the trees
2245
+ // block keeps [&R]/[&...], which are semantic there.
2246
+ function stripDataComments(s) {
2247
+ if (!inDataComment && s.indexOf('[') < 0) {
2248
+ return s;
2249
+ }
2250
+ let out = '';
2251
+ for (let i = 0; i < s.length; ++i) {
2252
+ let c = s.charAt(i);
2253
+ if (inDataComment) {
2254
+ if (c === ']') {
2255
+ inDataComment = false;
1474
2256
  }
2257
+ } else if (c === '[') {
2258
+ inDataComment = true;
2259
+ } else {
2260
+ out += c;
1475
2261
  }
1476
- } else if (!n.children && !n._children) {
1477
- // If an external node lacks taxonomy, return false.
1478
- result = false;
1479
2262
  }
1480
- });
1481
- if (!sawTax) {
1482
- return null;
2263
+ return out.trim();
1483
2264
  }
1484
- if (result === true) {
1485
2265
 
1486
- if (sn) {
1487
- return sn;
1488
- } else if (code) {
1489
- return code;
1490
- } else if (cn) {
1491
- return cn;
1492
- } else if (id) {
1493
- return id;
2266
+ function setTranslatePairs(buf) {
2267
+ let s = buf.trim();
2268
+ if (s.endsWith(';')) {
2269
+ s = s.slice(0, -1).trim();
1494
2270
  }
2271
+ // splitTopLevelCommas, not a plain split: a quoted label may
2272
+ // itself contain a comma ('Korea, Republic of' -- the very case
2273
+ // the Auspice writer quotes against elsewhere in this file)
2274
+ splitTopLevelCommas(s).forEach(function (pair) {
2275
+ let ti = pair.toLowerCase().indexOf('translate');
2276
+ if (ti > -1) {
2277
+ pair = pair.substring(ti + 9);
2278
+ }
2279
+ if (pair.trim().length === 0) {
2280
+ return; // a trailing comma before the ';' is tolerated
2281
+ }
2282
+ let m = TRANSLATE_PAIR_RE.exec(pair);
2283
+ if (!m) {
2284
+ throw new Error(NEXUS_FORMAT_ERR + 'ill-formatted translate table entry: "'
2285
+ + pair.trim() + '" -- is the Translate sub-command terminated with a ";"?');
2286
+ }
2287
+ let value = m[2].replace(/['"]+/g, '').trim();
2288
+ if (value.endsWith(';')) {
2289
+ value = value.slice(0, -1);
2290
+ }
2291
+ translateMap[m[1]] = value;
2292
+ });
1495
2293
  }
1496
- return null;
1497
- };
1498
2294
 
1499
- forester.getOneDistinctNodePropertyValue = function (node, propertyRef) {
1500
- let propValue = null;
1501
- let result = true;
1502
- forester.preOrderTraversalAll(node, function (n) {
1503
- if (n.properties && n.properties.length > 0) {
1504
- let propertiesLength = n.properties.length;
1505
- let gotIt = false;
1506
- for (let i = 0; i < propertiesLength; ++i) {
1507
- let property = n.properties[i];
1508
- if (property.ref && property.value && (property.applies_to === 'node') && (property.ref === propertyRef) && (property.value.length > 0)) {
1509
- if (propValue === null) {
1510
- propValue = property.value;
1511
- } else if (propValue !== property.value) {
1512
- result = false;
1513
- return;
1514
- }
1515
- gotIt = true;
1516
- }
2295
+ // One MATRIX row ("taxon residues..."): the id is the first token (a
2296
+ // quoted label may contain spaces), the residues are the rest with all
2297
+ // internal whitespace removed. Only protein/dna/rna matrices become
2298
+ // sequences. In an interleaved matrix each id reappears in a later
2299
+ // block, so a repeated id is CONCATENATED onto its row. MATCHCHAR
2300
+ // (e.g. '.') means "same as the first taxon at this position" and is
2301
+ // resolved against that reference row at the same absolute positions.
2302
+ function addMatrixRow(row) {
2303
+ if (datatype !== 'protein' && datatype !== 'dna' && datatype !== 'rna') {
2304
+ return;
2305
+ }
2306
+ let id;
2307
+ let rest;
2308
+ let c0 = row.charAt(0);
2309
+ if (c0 === "'" || c0 === '"') {
2310
+ let close = row.indexOf(c0, 1);
2311
+ if (close < 1) {
2312
+ return;
1517
2313
  }
1518
- if (!gotIt && !n.children && !n._children) {
1519
- // If an external node lacks propertyRef, return false.
1520
- result = false;
1521
-
2314
+ id = row.substring(0, close + 1);
2315
+ rest = row.substring(close + 1);
2316
+ } else {
2317
+ let sp = row.indexOf(' ');
2318
+ if (sp < 1) {
2319
+ return;
1522
2320
  }
2321
+ id = row.substring(0, sp);
2322
+ rest = row.substring(sp + 1);
2323
+ }
2324
+ let block = rest.replace(/\s+/g, '');
2325
+ if (block.length === 0 || !RESIDUES_RE.test(block)) {
2326
+ return;
2327
+ }
2328
+ if (matchchar && (matrixReferenceId !== null) && (id !== matrixReferenceId)
2329
+ && seqs[matrixReferenceId]) {
2330
+ let ref = seqs[matrixReferenceId].value;
2331
+ let offset = seqs[id] ? seqs[id].value.length : 0;
2332
+ let resolved = '';
2333
+ for (let j = 0; j < block.length; ++j) {
2334
+ let c = block.charAt(j);
2335
+ resolved += (c === matchchar && (offset + j) < ref.length)
2336
+ ? ref.charAt(offset + j) : c;
2337
+ }
2338
+ block = resolved;
2339
+ }
2340
+ seqs[id] = {
2341
+ value: seqs[id] ? (seqs[id].value + block) : block,
2342
+ type: datatype
2343
+ };
2344
+ if (matrixReferenceId === null) {
2345
+ matrixReferenceId = id;
1523
2346
  }
1524
- });
1525
- if (propValue === null) {
1526
- return null;
1527
- }
1528
- if (result === true) {
1529
- return propValue;
1530
- } else {
1531
- return null;
1532
2347
  }
1533
- };
1534
2348
 
1535
- /**
1536
- * To be deprecated!
1537
- *
1538
- * @param phy
1539
- * @returns {{}}
1540
- */
1541
- forester.moveSimpleCharacteristicsToProperties = function (phy) {
1542
- let apptype;
1543
- if (phy.desc) {
1544
- apptype = 'ird:'
1545
- } else {
1546
- apptype = 'vipr:'
2349
+ // A complete tree statement has accumulated in nh: parse it and carry
2350
+ // over the block's translate table / taxlabels / matrix sequences.
2351
+ function finishTree() {
2352
+ if (nh.length === 0) {
2353
+ return;
2354
+ }
2355
+ let phy = forester.parseNewHampshire(nh, confidenceValuesInBrackets, confidenceValuesAsInternalNames);
2356
+ let myname = '';
2357
+ if (title && name) {
2358
+ myname = title.replace(/_/g, ' ').trim() + ' (' + name.trim() + ')';
2359
+ } else if (title) {
2360
+ myname = title.replace(/_/g, ' ').trim();
2361
+ } else if (name) {
2362
+ myname = name.trim();
2363
+ }
2364
+ if (myname) {
2365
+ phy.name = myname;
2366
+ }
2367
+ if (rootedInfoPresent) {
2368
+ phy.rooted = isRooted;
2369
+ }
2370
+ let seqsByKey = Object.create(null);
2371
+ for (let id in seqs) {
2372
+ seqsByKey[joinKey(id)] = seqs[id];
2373
+ }
2374
+ forester.getAllExternalNodes(phy).forEach(function (node) {
2375
+ if (node.name && translateMap[node.name] !== undefined) {
2376
+ node.name = translateMap[node.name];
2377
+ } else if (taxlabels.length > 0 && node.name && /^\d+$/.test(node.name)) {
2378
+ let i = parseInt(node.name, 10);
2379
+ if (i > 0 && i <= taxlabels.length) {
2380
+ node.name = taxlabels[i - 1].replace(/['"]+/g, '');
2381
+ }
2382
+ }
2383
+ if (node.name) {
2384
+ let s = seqsByKey[joinKey(node.name)];
2385
+ if (s) {
2386
+ if (!node.sequences) {
2387
+ node.sequences = [];
2388
+ }
2389
+ node.sequences.push({
2390
+ type: s.type,
2391
+ mol_seq: {is_aligned: true, value: s.value}
2392
+ });
2393
+ }
2394
+ }
2395
+ });
2396
+ trees.push(phy);
2397
+ nh = '';
2398
+ name = '';
2399
+ rootedInfoPresent = false;
2400
+ isRooted = false;
1547
2401
  }
1548
2402
 
1549
- let HOST = apptype + 'Host';
1550
- let COUNTRY = apptype + 'Country';
1551
- let YEAR = apptype + 'Year';
1552
- let HA = apptype + 'HA';
1553
- let NA = apptype + 'NA';
1554
- let NODE = 'node';
1555
- let STRING = 'xsd:string';
1556
- let INT = 'xsd:integer';
1557
-
1558
- forester.preOrderTraversalAll(phy, function (n) {
1559
- if (n.simple_characteristics) {
1560
- let sc = n.simple_characteristics;
1561
- let props;
1562
- if (sc.country && sc.country.length > 0) {
1563
- props = {};
1564
- props.ref = COUNTRY;
1565
- props.datatype = STRING;
1566
- props.applies_to = NODE;
1567
- props.value = sc.country;
1568
- addProperties(n, props);
1569
- }
1570
- if (sc.host && sc.host.length > 0) {
1571
- props = {};
1572
- props.ref = HOST;
1573
- props.datatype = STRING;
1574
- props.applies_to = NODE;
1575
- props.value = sc.host;
1576
- addProperties(n, props);
1577
- }
1578
- if (sc.year && sc.year.length > 0) {
1579
- props = {};
1580
- props.ref = YEAR;
1581
- props.datatype = INT;
1582
- props.applies_to = NODE;
1583
- props.value = parseInt(sc.year);
1584
- addProperties(n, props);
1585
- }
1586
- if (sc.ha && sc.ha.length > 0) {
1587
- props = {};
1588
- props.ref = HA;
1589
- props.datatype = INT;
1590
- props.applies_to = NODE;
1591
- props.value = parseInt(sc.ha);
1592
- addProperties(n, props);
1593
- }
1594
- if (sc.na && sc.na.length > 0) {
1595
- props = {};
1596
- props.ref = NA;
1597
- props.datatype = INT;
1598
- props.applies_to = NODE;
1599
- props.value = parseInt(sc.na);
1600
- addProperties(n, props);
1601
- }
1602
- n.simple_characteristics = undefined;
2403
+ let lines = String(nexStr).split(/\r\n|\r|\n/);
2404
+ for (let k = 0; k < lines.length; ++k) {
2405
+ let line = lines[k].trim();
2406
+ if (line.length === 0 || line.charAt(0) === '#' || line.charAt(0) === '>') {
2407
+ continue;
1603
2408
  }
1604
- });
1605
-
1606
- function addProperties(n, props) {
1607
- if (props) {
1608
- if (!n.properties) {
1609
- n.properties = [];
2409
+ line = line.replace(/\s+/g, ' ').replace(/\s+;/g, ';');
2410
+ let lc = line.toLowerCase();
2411
+ if (/^begin\s+trees\b/.test(lc)) {
2412
+ inTreesBlock = true;
2413
+ inTaxalabels = false;
2414
+ inTranslate = false;
2415
+ inDataBlock = false;
2416
+ datatype = null;
2417
+ title = '';
2418
+ } else if (lc.startsWith('taxlabels')) {
2419
+ inTreesBlock = false;
2420
+ inTaxalabels = true;
2421
+ inTranslate = false;
2422
+ inDataBlock = false;
2423
+ datatype = null;
2424
+ } else if (lc.startsWith('translate')) {
2425
+ translateBuf = '';
2426
+ inTaxalabels = false;
2427
+ inTranslate = true;
2428
+ inDataBlock = false;
2429
+ datatype = null;
2430
+ } else if (/^begin\s+(characters|data)\b/.test(lc)) {
2431
+ inTaxalabels = false;
2432
+ inTreesBlock = false;
2433
+ inTranslate = false;
2434
+ inDataBlock = true;
2435
+ inMatrix = false;
2436
+ inDataComment = false;
2437
+ datatype = null;
2438
+ matchchar = null;
2439
+ matrixReferenceId = null;
2440
+ // scope the rows to THIS matrix block, so a later block
2441
+ // cannot cross-contaminate an earlier one
2442
+ seqs = Object.create(null);
2443
+ } else if (inTreesBlock) {
2444
+ if (lc.startsWith('title')) {
2445
+ let tm = TITLE_RE.exec(line);
2446
+ if (tm) {
2447
+ title = tm[1];
2448
+ }
2449
+ } else if (lc.startsWith('link')) {
2450
+ // a LINK sub-command (e.g. "LINK TAXA=...") -- ignored
2451
+ } else if (lc.startsWith('end;') || lc.startsWith('endblock')) {
2452
+ inTreesBlock = false;
2453
+ inTree = false;
2454
+ finishTree();
2455
+ } else if (lc.startsWith('tree ') || lc.startsWith('utree ')) {
2456
+ finishTree(); // a previous statement still pending
2457
+ inTree = true;
2458
+ let nm = TREE_NAME_RE.exec(line);
2459
+ if (nm) {
2460
+ name = nm[1].replace(/['"]+/g, '');
2461
+ }
2462
+ let rm = ROOTEDNESS_RE.exec(line);
2463
+ if (rm) {
2464
+ rootedInfoPresent = true;
2465
+ isRooted = rm[1].toUpperCase() === 'R';
2466
+ }
2467
+ // parseNewHampshire handles the remaining [&...] hot
2468
+ // comments itself: BEAST-style annotations are parsed
2469
+ // onto the nodes, a leading [&R]/[&U] is dropped
2470
+ nh = line.substring(line.indexOf('=') + 1).trim();
2471
+ if (lc.endsWith(';')) {
2472
+ inTree = false;
2473
+ finishTree();
2474
+ }
2475
+ } else if (inTree) {
2476
+ nh += line;
2477
+ if (lc.endsWith(';')) {
2478
+ inTree = false;
2479
+ finishTree();
2480
+ }
1610
2481
  }
1611
- let alreadyHave = false;
1612
- let l = n.properties.length;
1613
- for (let i = 0; i < l; ++i) {
1614
- if (n.properties[i].ref === props.ref) {
1615
- alreadyHave = true;
1616
- break;
2482
+ }
2483
+ if (inTaxalabels) {
2484
+ if (lc.startsWith('end;') || lc.startsWith('endblock')) {
2485
+ inTaxalabels = false;
2486
+ } else {
2487
+ // QUOTE-AWARE tokenization: 'Homo sapiens' is ONE label
2488
+ // (that is what the quotes are for) -- a plain space split
2489
+ // silently sheared such labels apart and shifted every
2490
+ // numeric tip onto the wrong name. ';' (unquoted) ends
2491
+ // the sub-command.
2492
+ let tok = '';
2493
+ let q = null;
2494
+ let push = function () {
2495
+ if (tok.length > 0 && tok.toLowerCase() !== 'taxlabels') {
2496
+ taxlabels.push(tok);
2497
+ }
2498
+ tok = '';
2499
+ };
2500
+ for (let ci = 0; ci < line.length; ++ci) {
2501
+ let ch = line.charAt(ci);
2502
+ if (q) {
2503
+ if (ch === q) {
2504
+ q = null;
2505
+ } else {
2506
+ tok += ch;
2507
+ }
2508
+ } else if (ch === "'" || ch === '"') {
2509
+ q = ch;
2510
+ } else if (ch === ' ') {
2511
+ push();
2512
+ } else if (ch === ';') {
2513
+ inTaxalabels = false;
2514
+ push();
2515
+ break;
2516
+ } else {
2517
+ tok += ch;
2518
+ }
2519
+ }
2520
+ push();
2521
+ }
2522
+ }
2523
+ if (inTranslate) {
2524
+ if (lc.startsWith('end;') || lc.startsWith('endblock')) {
2525
+ inTranslate = false;
2526
+ } else {
2527
+ translateBuf += ' ' + line;
2528
+ if (line.endsWith(';')) {
2529
+ inTranslate = false;
2530
+ setTranslatePairs(translateBuf);
1617
2531
  }
1618
2532
  }
1619
- if (!alreadyHave) {
1620
- n.properties.push(props);
2533
+ }
2534
+ if (inDataBlock) {
2535
+ line = stripDataComments(line);
2536
+ let dlc = line.toLowerCase();
2537
+ if (line.length === 0) {
2538
+ // comment-only (or now-empty) line
2539
+ } else if (dlc.startsWith('end;') || dlc.startsWith('endblock')) {
2540
+ inDataBlock = false;
2541
+ inMatrix = false;
2542
+ datatype = null;
2543
+ } else if (dlc.startsWith('link ')) {
2544
+ // ignored; the trailing space keeps a taxon row whose
2545
+ // name starts with "link" out of this branch
2546
+ } else if (!inMatrix) {
2547
+ // block header: DIMENSIONS / FORMAT / CHARLABELS / ... --
2548
+ // read DATATYPE and MATCHCHAR off FORMAT, enter the matrix
2549
+ // on the MATRIX keyword, ignore the rest (a sub-command
2550
+ // ending in ';' must NOT be mistaken for the block's end)
2551
+ let dm = DATATYPE_RE.exec(dlc);
2552
+ if (dm) {
2553
+ datatype = dm[1];
2554
+ }
2555
+ let mm = MATCHCHAR_RE.exec(dlc);
2556
+ if (mm) {
2557
+ matchchar = mm[1];
2558
+ }
2559
+ if (dlc === 'matrix' || dlc.startsWith('matrix ')) {
2560
+ inMatrix = true;
2561
+ let after = line.substring(6).trim();
2562
+ let matrixEnds = false;
2563
+ if (after.endsWith(';')) {
2564
+ matrixEnds = true;
2565
+ after = after.slice(0, -1).trim();
2566
+ }
2567
+ if (after.length > 0) {
2568
+ addMatrixRow(after);
2569
+ }
2570
+ if (matrixEnds) {
2571
+ inMatrix = false;
2572
+ inDataBlock = false;
2573
+ datatype = null;
2574
+ }
2575
+ }
2576
+ } else {
2577
+ // inside the MATRIX: one taxon row per line until ';'
2578
+ let matrixEnds = false;
2579
+ if (line.endsWith(';')) {
2580
+ matrixEnds = true;
2581
+ line = line.slice(0, -1).trim();
2582
+ }
2583
+ if (line.length > 0) {
2584
+ addMatrixRow(line);
2585
+ }
2586
+ if (matrixEnds) {
2587
+ inMatrix = false;
2588
+ inDataBlock = false;
2589
+ datatype = null;
2590
+ }
1621
2591
  }
1622
2592
  }
1623
2593
  }
2594
+ finishTree(); // EOF with a tree still pending (no closing "End;")
2595
+ return trees;
1624
2596
  };
1625
2597
 
1626
-
1627
- /**
2598
+ // ---------------------------------------------------------------
2599
+ // Auspice / Nextstrain
2600
+ // ---------------------------------------------------------------
2601
+
2602
+ // Namespace for the node properties the Auspice parser writes, so
2603
+ // Nextstrain traits are colour-able/searchable and clearly distinguished
2604
+ // from BEAST's "beast:" namespace. Same prefix as the desktop.
2605
+ const NEXTSTRAIN_PREFIX = 'nextstrain:';
2606
+
2607
+ // A compact string for a JSON number: a whole value drops the ".0" (a
2608
+ // clean categorical/integer property), otherwise the plain decimal
2609
+ // WITHOUT scientific notation (a small divergence like 1e-4 must read
2610
+ // as "0.0001" in the node-data dialog / as a searchable value).
2611
+ function plainNumberString(d) {
2612
+ if (Number.isInteger(d) && Math.abs(d) < 1e15) {
2613
+ return String(d);
2614
+ }
2615
+ let s = String(d);
2616
+ if (s.indexOf('e') < 0 && s.indexOf('E') < 0) {
2617
+ return s;
2618
+ }
2619
+ return d.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
2620
+ }
2621
+
2622
+ function addNodeProperty(node, ref, value) {
2623
+ if (value === undefined || value === null || String(value).length === 0) {
2624
+ return;
2625
+ }
2626
+ if (!node.properties) {
2627
+ node.properties = [];
2628
+ }
2629
+ let v = String(value);
2630
+ node.properties.push({
2631
+ ref: ref,
2632
+ value: v,
2633
+ datatype: isFinite(parseFloat(v)) && isFinite(Number(v)) ? 'xsd:decimal' : 'xsd:string',
2634
+ applies_to: 'node'
2635
+ });
2636
+ }
2637
+
2638
+ // Parses an Auspice / Nextstrain v2 dataset.json (string or already-parsed
2639
+ // object) into ONE tree object, mapping its per-node data onto the native
2640
+ // phyloXML shape so the existing overlays light it up -- ported from the
2641
+ // desktop's AuspiceJsonParser:
2642
+ // - node_attrs.num_date.value -> node.date value (decimal year) -> the
2643
+ // calendar time axis; its .confidence [lo,hi] -> date minimum/maximum
2644
+ // -> the node-age (HPD) bars;
2645
+ // - node_attrs.div -> a nextstrain:div property (the divergence measure,
2646
+ // kept for a future time<->divergence view);
2647
+ // - every discrete trait (country, clade_membership, host, ...) -> a
2648
+ // nextstrain:<key> node property (Color-by / search / node dialog);
2649
+ // its .confidence {state:prob} -> nextstrain:<key>_set + _set_prob
2650
+ // brace-list pair (the desktop's ancestral-state-pie encoding);
2651
+ // - branch_attrs.labels.clade -> a nextstrain:clade_label property.
2652
+ // Branch lengths default to TIME (successive num_date differences); a
2653
+ // divergence-only build falls back to div differences. Deliberately NOT
2654
+ // ingested: the map, entropy and frequencies panels.
2655
+ forester.parseAuspiceJson = function (data) {
2656
+ let doc = forester.isString(data) ? JSON.parse(data) : data;
2657
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
2658
+ throw new Error('not an Auspice dataset (the JSON root is not an object)');
2659
+ }
2660
+ if (doc.version !== 'v2' || !doc.tree || typeof doc.tree !== 'object'
2661
+ || Array.isArray(doc.tree)) {
2662
+ throw new Error('not an Auspice v2 dataset (expected "version":"v2" and a "tree" object)');
2663
+ }
2664
+
2665
+ function isScalar(v) {
2666
+ return (typeof v === 'string') || (typeof v === 'number') || (typeof v === 'boolean');
2667
+ }
2668
+
2669
+ function scalarToString(v) {
2670
+ return (typeof v === 'number') ? plainNumberString(v) : String(v);
2671
+ }
2672
+
2673
+ // a discrete trait's posterior distribution as the _set/_set_prob
2674
+ // brace-list pair; state names quoted so a comma/space in one (e.g.
2675
+ // "Korea, Republic of") cannot corrupt the list
2676
+ function applyTraitConfidence(node, trait, conf) {
2677
+ let states = [];
2678
+ let probs = [];
2679
+ Object.keys(conf).forEach(function (state) {
2680
+ let p = conf[state];
2681
+ if (typeof p !== 'number' || !isFinite(p) || state.length === 0) {
2682
+ return;
2683
+ }
2684
+ states.push('"' + state.replace(/"/g, '') + '"');
2685
+ probs.push(plainNumberString(p));
2686
+ });
2687
+ if (states.length > 0) {
2688
+ addNodeProperty(node, NEXTSTRAIN_PREFIX + trait + '_set', '{' + states.join(',') + '}');
2689
+ addNodeProperty(node, NEXTSTRAIN_PREFIX + trait + '_set_prob', '{' + probs.join(',') + '}');
2690
+ }
2691
+ }
2692
+
2693
+ function applyNodeAttrs(node, attrs) {
2694
+ Object.keys(attrs).forEach(function (key) {
2695
+ let val = attrs[key];
2696
+ if (key === 'num_date') {
2697
+ if (val && typeof val === 'object' && typeof val.value === 'number') {
2698
+ let date = {value: val.value, unit: 'year'};
2699
+ if (Array.isArray(val.confidence) && val.confidence.length === 2
2700
+ && typeof val.confidence[0] === 'number'
2701
+ && typeof val.confidence[1] === 'number') {
2702
+ date.minimum = val.confidence[0];
2703
+ date.maximum = val.confidence[1];
2704
+ }
2705
+ node.date = date;
2706
+ // the point date doubles as a numeric property, so the
2707
+ // sampling date can drive Color-by (the classic
2708
+ // Nextstrain colour-by-date view) and search
2709
+ addNodeProperty(node, NEXTSTRAIN_PREFIX + 'num_date', plainNumberString(val.value));
2710
+ }
2711
+ } else if (key === 'div') {
2712
+ if (typeof val === 'number' && isFinite(val)) {
2713
+ addNodeProperty(node, NEXTSTRAIN_PREFIX + 'div', plainNumberString(val));
2714
+ }
2715
+ } else if (val && typeof val === 'object' && !Array.isArray(val)) {
2716
+ // a discrete trait: {value, confidence{state:prob}, entropy}
2717
+ if (isScalar(val.value)) {
2718
+ addNodeProperty(node, NEXTSTRAIN_PREFIX + key, scalarToString(val.value));
2719
+ }
2720
+ if (val.confidence && typeof val.confidence === 'object'
2721
+ && !Array.isArray(val.confidence)) {
2722
+ applyTraitConfidence(node, key, val.confidence);
2723
+ }
2724
+ } else if (isScalar(val)) {
2725
+ addNodeProperty(node, NEXTSTRAIN_PREFIX + key, scalarToString(val)); // bare attr (accession, url, ...)
2726
+ }
2727
+ });
2728
+ }
2729
+
2730
+ function buildNode(jn) {
2731
+ let node = {};
2732
+ if (typeof jn.name === 'string' && jn.name.length > 0) {
2733
+ node.name = jn.name;
2734
+ }
2735
+ if (jn.node_attrs && typeof jn.node_attrs === 'object') {
2736
+ applyNodeAttrs(node, jn.node_attrs);
2737
+ }
2738
+ let labels = jn.branch_attrs && jn.branch_attrs.labels;
2739
+ if (labels && typeof labels.clade === 'string' && labels.clade.length > 0) {
2740
+ addNodeProperty(node, NEXTSTRAIN_PREFIX + 'clade_label', labels.clade);
2741
+ }
2742
+ if (Array.isArray(jn.children) && jn.children.length > 0) {
2743
+ node.children = [];
2744
+ jn.children.forEach(function (c) {
2745
+ if (c && typeof c === 'object') {
2746
+ node.children.push(buildNode(c));
2747
+ }
2748
+ });
2749
+ if (node.children.length === 0) {
2750
+ delete node.children;
2751
+ }
2752
+ }
2753
+ return node;
2754
+ }
2755
+
2756
+ let root = buildNode(doc.tree);
2757
+ let phy = {rooted: true, children: [root]};
2758
+ let title = doc.meta && doc.meta.title;
2759
+ if (typeof title === 'string' && title.trim().length > 0) {
2760
+ phy.name = title.trim();
2761
+ }
2762
+ if (auspiceHasAnyDate(root)) {
2763
+ setDeltaBranchLengths(root, null, auspiceNodeDate); // default view = time
2764
+ } else {
2765
+ // a divergence-only build carries no num_date anywhere; div deltas
2766
+ // keep the layout meaningful instead of a cladogram
2767
+ setDeltaBranchLengths(root, null, auspiceNodeDiv);
2768
+ }
2769
+ // A TIP is a dated sample: keep its point date (the calendar axis)
2770
+ // but drop the date INTERVAL -- the divergence-time uncertainty (the
2771
+ // node-age bars) belongs to the INTERNAL nodes, and a tip interval
2772
+ // would read as a fossil-style observed range on a viral tree.
2773
+ forester.preOrderTraversalAll(root, function (n) {
2774
+ if (!n.children && n.date
2775
+ && (n.date.minimum !== undefined || n.date.maximum !== undefined)) {
2776
+ n.date = {value: n.date.value, unit: n.date.unit};
2777
+ }
2778
+ });
2779
+ forester.addParents(phy);
2780
+ return phy;
2781
+ };
2782
+
2783
+ function auspiceNodeDate(node) {
2784
+ return (node.date && typeof node.date.value === 'number' && isFinite(node.date.value))
2785
+ ? node.date.value : null;
2786
+ }
2787
+
2788
+ function auspiceNodeDiv(node) {
2789
+ if (node.properties) {
2790
+ for (let i = 0; i < node.properties.length; ++i) {
2791
+ if (node.properties[i].ref === NEXTSTRAIN_PREFIX + 'div') {
2792
+ let d = parseFloat(node.properties[i].value);
2793
+ return isFinite(d) ? d : null;
2794
+ }
2795
+ }
2796
+ }
2797
+ return null;
2798
+ }
2799
+
2800
+ function auspiceHasAnyDate(node) {
2801
+ let found = false;
2802
+ forester.preOrderTraversalAll(node, function (n) {
2803
+ if (auspiceNodeDate(n) !== null) {
2804
+ found = true;
2805
+ }
2806
+ });
2807
+ return found;
2808
+ }
2809
+
2810
+ function auspiceHasAnyDiv(node) {
2811
+ let found = false;
2812
+ forester.preOrderTraversalAll(node, function (n) {
2813
+ if (auspiceNodeDiv(n) !== null) {
2814
+ found = true;
2815
+ }
2816
+ });
2817
+ return found;
2818
+ }
2819
+
2820
+ // Branch lengths = successive differences of a cumulative per-node metric
2821
+ // (num_date -> the time view; nextstrain:div -> the divergence view).
2822
+ // The root's length is 0, and a node missing the metric (or whose parent
2823
+ // misses it) gets 0 -- so a time<->divergence toggle can never leave a
2824
+ // stale cross-scale length behind. A (spurious) negative delta clamps to 0.
2825
+ function setDeltaBranchLengths(node, parentValue, metricOf) {
2826
+ let v = metricOf(node);
2827
+ node.branch_length = (parentValue !== null && v !== null)
2828
+ ? Math.max(0, v - parentValue) : 0;
2829
+ let children = node.children || node._children;
2830
+ if (children) {
2831
+ for (let i = 0; i < children.length; ++i) {
2832
+ setDeltaBranchLengths(children[i], v, metricOf);
2833
+ }
2834
+ }
2835
+ }
2836
+
2837
+ // The time<->divergence plumbing: both metrics are RETAINED on a parsed
2838
+ // Auspice tree (the date values + the nextstrain:div properties), so a
2839
+ // future display toggle can rewrite the branch lengths from EITHER at any
2840
+ // time -- lossless and reversible, and reusing the exact recompute the
2841
+ // parser itself used, so the toggle can never drift from the loaded view.
2842
+
2843
+ forester.applyTimeBranchLengths = function (phy) {
2844
+ setDeltaBranchLengths(forester.getTreeRoot(phy), null, auspiceNodeDate);
2845
+ };
2846
+
2847
+ forester.applyDivergenceBranchLengths = function (phy) {
2848
+ setDeltaBranchLengths(forester.getTreeRoot(phy), null, auspiceNodeDiv);
2849
+ };
2850
+
2851
+ // True when the tree carries BOTH a time signal (a dated node) AND a
2852
+ // divergence signal (a nextstrain:div property), so the toggle is
2853
+ // meaningful at all.
2854
+ forester.hasTimeAndDivergence = function (phy) {
2855
+ let root = forester.getTreeRoot(phy);
2856
+ return auspiceHasAnyDate(root) && auspiceHasAnyDiv(root);
2857
+ };
2858
+
2859
+ forester.isNumber = function (v) {
2860
+ if (v === undefined || v === null) {
2861
+ return false;
2862
+ }
2863
+ if (v != v) {
2864
+ // This can only be true if the v is NaN
2865
+ return false;
2866
+ }
2867
+ return true;
2868
+ };
2869
+
2870
+ forester.getOneDistinctTaxonomy = function (node) {
2871
+ let id = null;
2872
+ let code = null;
2873
+ let sn = null;
2874
+ let cn = null;
2875
+ let result = true;
2876
+ let sawTax = false;
2877
+ forester.preOrderTraversalAll(node, function (n) {
2878
+ if (n.taxonomies && n.taxonomies.length === 1) {
2879
+ let tax = n.taxonomies[0];
2880
+ if (tax.code && tax.code.length > 0) {
2881
+ sawTax = true;
2882
+ if (code === null) {
2883
+ code = tax.code;
2884
+ } else if (code !== tax.code) {
2885
+ result = false;
2886
+ return;
2887
+ }
2888
+ }
2889
+ if (tax.scientific_name && tax.scientific_name.length > 0) {
2890
+ sawTax = true;
2891
+ if (sn === null) {
2892
+ sn = tax.scientific_name;
2893
+ } else if (sn !== tax.scientific_name) {
2894
+ result = false;
2895
+ return;
2896
+ }
2897
+ }
2898
+ if (tax.common_name && tax.common_name.length > 0) {
2899
+ sawTax = true;
2900
+ if (cn === null) {
2901
+ cn = tax.common_name;
2902
+ } else if (cn !== tax.common_name) {
2903
+ result = false;
2904
+ return;
2905
+ }
2906
+ }
2907
+ if (tax.id && tax.id.value && tax.id.value.length > 0) {
2908
+ sawTax = true;
2909
+ let myid;
2910
+ if (tax.id.provider && tax.id.provider.length > 0) {
2911
+ myid = tax.id.provider + ':' + tax.id.value;
2912
+ } else {
2913
+ myid = tax.id.value;
2914
+ }
2915
+ if (id === null) {
2916
+ id = myid;
2917
+ } else if (id !== myid) {
2918
+ result = false;
2919
+
2920
+ }
2921
+ }
2922
+ } else if (!n.children && !n._children) {
2923
+ // If an external node lacks taxonomy, return false.
2924
+ result = false;
2925
+ }
2926
+ });
2927
+ if (!sawTax) {
2928
+ return null;
2929
+ }
2930
+ if (result === true) {
2931
+
2932
+ if (sn) {
2933
+ return sn;
2934
+ } else if (code) {
2935
+ return code;
2936
+ } else if (cn) {
2937
+ return cn;
2938
+ } else if (id) {
2939
+ return id;
2940
+ }
2941
+ }
2942
+ return null;
2943
+ };
2944
+
2945
+
2946
+ /**
1628
2947
  * To convert a phylogentic tree object to a New Hampshire (Newick) formatted string.
1629
2948
  *
1630
2949
  * @param phy - A phylogentic tree object.
@@ -1664,7 +2983,7 @@
1664
2983
  nh += replaceUnsafeChars(node.name);
1665
2984
  } else {
1666
2985
  let myName = node.name.replace(/\s+/g, ' ');
1667
- if (/[\s,():;'"\[\]]/.test(myName)) {
2986
+ if (/[\s,():;'"[\]]/.test(myName)) {
1668
2987
  if ((myName.indexOf('"') > -1) && (myName.indexOf("'") > -1)) {
1669
2988
  nh += '"' + myName.replace(/"/g, "'") + '"';
1670
2989
  } else if (myName.indexOf('"') > -1) {
@@ -1697,8 +3016,126 @@
1697
3016
  }
1698
3017
 
1699
3018
  function replaceUnsafeChars(str) {
1700
- return str.replace(/[\s,():;'"\[\]]+/g, '_');
3019
+ return str.replace(/[\s,():;'"[\]]+/g, '_');
3020
+ }
3021
+ };
3022
+
3023
+ // Writes a phylogeny as a Nexus-formatted string, ported from the
3024
+ // desktop's PhylogenyWriter: a TAXA block (Dimensions, TaxLabels) and a
3025
+ // TREES block (the tree under its name, [&R]/[&U] rootedness, the same
3026
+ // safe-character Newick toNewHampshire writes). Beyond the desktop
3027
+ // template, tips carrying ALIGNED molecular sequences also get a
3028
+ // CHARACTERS block (Dimensions, Format with the datatype, Matrix) --
3029
+ // carrying the tree and its alignment in one file is the point of Nexus,
3030
+ // and parseNexus reads the alignment back onto the tips.
3031
+ forester.toNexus = function (phy, decPointsMax, writeConfidences) {
3032
+ // the same replacement toNewHampshire applies, so the TaxLabels and
3033
+ // Matrix labels match the tree's tip tokens exactly
3034
+ function safeLabel(s) {
3035
+ return s.replace(/[\s,():;'"[\]]+/g, '_');
3036
+ }
3037
+
3038
+ // label preference as on the desktop: name, then taxonomy
3039
+ // (code/scientific/common), then sequence (name/symbol/gene)
3040
+ function nexusLabel(node, i) {
3041
+ let s = '';
3042
+ if (node.name) {
3043
+ s = node.name;
3044
+ } else if (node.taxonomies && node.taxonomies.length > 0) {
3045
+ let t = node.taxonomies[0];
3046
+ s = t.code || t.scientific_name || t.common_name || '';
3047
+ } else if (node.sequences && node.sequences.length > 0) {
3048
+ let q = node.sequences[0];
3049
+ s = q.name || q.symbol || q.gene_name || '';
3050
+ }
3051
+ if (!s) {
3052
+ s = 'node' + (i + 1); // an empty TaxLabels token would not parse back
3053
+ }
3054
+ return safeLabel(s);
3055
+ }
3056
+
3057
+ let ext = forester.getAllExternalNodes(phy).reverse();
3058
+ // a nameless tip gets its taxa-block label in the TREE as well --
3059
+ // TaxLabels, the Matrix and the Newick must agree on every taxon or
3060
+ // nothing can join them back up (restored before returning, so the
3061
+ // caller's tree is never mutated)
3062
+ let renamed = [];
3063
+ ext.forEach(function (node, i) {
3064
+ if (!node.name) {
3065
+ node.name = nexusLabel(node, i);
3066
+ renamed.push(node);
3067
+ }
3068
+ });
3069
+ let s = '#NEXUS\n';
3070
+ s += 'Begin Taxa;\n';
3071
+ s += ' Dimensions NTax=' + ext.length + ';\n';
3072
+ s += ' TaxLabels';
3073
+ ext.forEach(function (node, i) {
3074
+ s += ' ' + nexusLabel(node, i);
3075
+ });
3076
+ s += ';\n';
3077
+ s += 'End;\n';
3078
+
3079
+ let rows = [];
3080
+ let nchar = 0;
3081
+ let datatype = null;
3082
+ ext.forEach(function (node, i) {
3083
+ if (!node.sequences) {
3084
+ return;
3085
+ }
3086
+ for (let j = 0; j < node.sequences.length; ++j) {
3087
+ let q = node.sequences[j];
3088
+ if (q.mol_seq && q.mol_seq.is_aligned && q.mol_seq.value) {
3089
+ rows.push({label: nexusLabel(node, i), value: q.mol_seq.value});
3090
+ nchar = Math.max(nchar, q.mol_seq.value.length);
3091
+ if (!datatype && (q.type === 'protein' || q.type === 'dna' || q.type === 'rna')) {
3092
+ datatype = q.type;
3093
+ }
3094
+ return;
3095
+ }
3096
+ }
3097
+ });
3098
+ if (rows.length > 0) {
3099
+ if (!datatype) {
3100
+ // no declared type (e.g. the tree came from Newick plus a
3101
+ // fasta): judge on the residues themselves
3102
+ datatype = forester.msaIsNucleotide(rows[0].value) ? 'dna' : 'protein';
3103
+ }
3104
+ let width = 0;
3105
+ rows.forEach(function (r) {
3106
+ width = Math.max(width, r.label.length);
3107
+ });
3108
+ s += 'Begin Characters;\n';
3109
+ // NChar ONLY: the Nexus standard allows NTax in a CHARACTERS
3110
+ // block's DIMENSIONS solely alongside NEWTAXA (the taxa are the
3111
+ // TAXA block's), and strict readers -- jebl, and so AliView --
3112
+ // reject the file over it
3113
+ s += ' Dimensions NChar=' + nchar + ';\n';
3114
+ s += ' Format DataType=' + datatype + ' Missing=? Gap=-;\n';
3115
+ s += ' Matrix\n';
3116
+ rows.forEach(function (r) {
3117
+ s += ' ' + r.label + ' '.repeat(width - r.label.length + 1) + r.value + '\n';
3118
+ });
3119
+ s += ' ;\n';
3120
+ s += 'End;\n';
1701
3121
  }
3122
+
3123
+ s += 'Begin Trees;\n';
3124
+ let treeName = phy.name ? String(phy.name).replace(/['"]+/g, '').trim() : '';
3125
+ s += ' Tree ' + (treeName ? ("'" + treeName + "'") : 'tree1') + '=';
3126
+ s += (phy.rooted === false) ? '[&U]' : '[&R]';
3127
+ let nh = forester.toNewHampshire(phy, decPointsMax, true, writeConfidences);
3128
+ renamed.forEach(function (node) {
3129
+ delete node.name;
3130
+ });
3131
+ if (nh.length === 0) {
3132
+ // an empty tree would otherwise write "Tree tree1=[&R]" with no
3133
+ // tree and no terminating ';' -- a syntactically invalid file
3134
+ throw new Error('toNexus: the tree is empty (nothing to write)');
3135
+ }
3136
+ s += nh + '\n';
3137
+ s += 'End;\n';
3138
+ return s;
1702
3139
  };
1703
3140
 
1704
3141
  forester.getMolecularSequencesAsFasta = function (node, sep) {
@@ -1741,6 +3178,1317 @@
1741
3178
  };
1742
3179
 
1743
3180
 
3181
+ // --------------------------------------------------------------
3182
+ // Search engine
3183
+ // --------------------------------------------------------------
3184
+ // Field-and-mode search over a phylogeny (mirrors the desktop Archaeopteryx
3185
+ // redesign). A search is described by a spec:
3186
+ // { field, mode, value, value2, caseSensitive, inverse }
3187
+ // where field comes from forester.availableSearchFields(root), mode is one of
3188
+ // the string modes ('contains', 'starts_with', 'ends_with', 'whole_word',
3189
+ // 'regex') or numeric modes ('eq', 'ne', 'lt', 'le', 'gt', 'ge', 'range'),
3190
+ // and ',' = OR / '+' = AND inside a text value. Used by archaeopteryx.js;
3191
+ // pure tree logic, no DOM -- tested by test/search_test.js.
3192
+
3193
+ const SEARCH_FIELD_LABELS = {
3194
+ NN: 'Node Name',
3195
+ TS: 'Taxonomy Scientific', TN: 'Taxonomy Common', TC: 'Taxonomy Code',
3196
+ TI: 'Taxonomy Identifier', SY: 'Taxonomy Synonym', LN: 'Taxonomy Lineage',
3197
+ SN: 'Seq Name', GN: 'Gene Name', SS: 'Gene Symbol', SA: 'Seq Accession',
3198
+ MS: 'Molecular Sequence', DO: 'Domain', AN: 'Annotation', XR: 'Cross-Reference'
3199
+ };
3200
+ const SEARCH_TEXT_ORDER = ['TS', 'TN', 'TC', 'TI', 'SY', 'LN', 'SN', 'GN', 'SS', 'SA', 'DO', 'AN', 'XR', 'MS'];
3201
+ // Fields folded into the "Any Text" umbrella (desktop omits MS + DO there).
3202
+ const SEARCH_ANY_TEXT_KEYS = ['NN', 'TS', 'TN', 'TC', 'TI', 'SY', 'LN', 'SN', 'GN', 'SS', 'SA', 'AN', 'XR'];
3203
+ const SEARCH_NUMERIC_DATATYPES = new Set(['decimal', 'double', 'float', 'integer', 'int', 'long', 'short',
3204
+ 'byte', 'unsignedint', 'unsignedlong', 'unsignedshort', 'unsignedbyte', 'nonnegativeinteger',
3205
+ 'nonpositiveinteger', 'negativeinteger', 'positiveinteger']);
3206
+
3207
+ function searchTaxa(n) { return (n.taxonomies && n.taxonomies.length) ? n.taxonomies : []; }
3208
+ function searchSeqs(n) { return (n.sequences && n.sequences.length) ? n.sequences : []; }
3209
+
3210
+ const SEARCH_TEXT_EXTRACTORS = {
3211
+ NN: n => (n.name ? [n.name] : []),
3212
+ TS: n => searchTaxa(n).map(t => t.scientific_name).filter(Boolean),
3213
+ TN: n => searchTaxa(n).map(t => t.common_name).filter(Boolean),
3214
+ TC: n => searchTaxa(n).map(t => t.code).filter(Boolean),
3215
+ TI: n => searchTaxa(n).map(t => t.id && t.id.value).filter(Boolean),
3216
+ SY: n => searchTaxa(n).reduce((a, t) => a.concat(t.synonyms || []), []).filter(Boolean),
3217
+ LN: n => searchTaxa(n).reduce((a, t) => a.concat(t.lineage || []), []).filter(Boolean),
3218
+ SN: n => searchSeqs(n).map(s => s.name).filter(Boolean),
3219
+ GN: n => searchSeqs(n).map(s => s.gene_name).filter(Boolean),
3220
+ SS: n => searchSeqs(n).map(s => s.symbol).filter(Boolean),
3221
+ SA: n => searchSeqs(n).map(s => s.accession && s.accession.value).filter(Boolean),
3222
+ MS: n => searchSeqs(n).map(s => s.mol_seq).filter(Boolean),
3223
+ DO: 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),
3224
+ AN: n => searchSeqs(n).reduce((a, s) => a.concat((s.annotations || []).reduce((b, an) => b.concat([an.desc, an.ref]), [])), []).filter(Boolean),
3225
+ XR: n => searchSeqs(n).reduce((a, s) => a.concat((s.cross_references || []).reduce((b, x) => b.concat([x.value, x.source, x.comment]), [])), []).filter(Boolean)
3226
+ };
3227
+
3228
+ function isInternalPropRef(ref) { return !ref || ref.indexOf('aptx:') === 0; }
3229
+
3230
+ function datatypeIsNumeric(dt) {
3231
+ if (!dt) return false;
3232
+ let local = String(dt).toLowerCase();
3233
+ let c = local.lastIndexOf(':');
3234
+ if (c >= 0) local = local.substring(c + 1);
3235
+ return SEARCH_NUMERIC_DATATYPES.has(local);
3236
+ }
3237
+
3238
+ function escapeSearchRegExp(str) {
3239
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3240
+ }
3241
+
3242
+ // Accept comma as decimal separator when unambiguous (one comma, no period,
3243
+ // and not the US thousands pattern comma+exactly-3-digits). Returns null if
3244
+ // not a finite number.
3245
+ forester.parseFiniteDouble = function (s) {
3246
+ if (s === null || s === undefined) return null;
3247
+ s = String(s).trim();
3248
+ if (s.length === 0) return null;
3249
+ if (s.indexOf('.') < 0 && (s.split(',').length - 1) === 1 && !/,\d{3}$/.test(s)) {
3250
+ s = s.replace(',', '.');
3251
+ }
3252
+ let n = Number(s);
3253
+ return isFinite(n) ? n : null;
3254
+ };
3255
+
3256
+ // Build a predicate value -> bool for one text term. Returns null for an
3257
+ // invalid regex (caller treats that as "never matches").
3258
+ forester.makeSearchStringTest = function (term, mode, caseSensitive) {
3259
+ if (mode === 'regex' || mode === 'whole_word') {
3260
+ let src = (mode === 'whole_word')
3261
+ ? ('(?<![\\p{L}\\p{N}])' + escapeSearchRegExp(term) + '(?![\\p{L}\\p{N}])')
3262
+ : term;
3263
+ let re;
3264
+ try { re = new RegExp(src, caseSensitive ? 'u' : 'iu'); }
3265
+ catch { return null; }
3266
+ return s => (s !== null && s !== undefined && re.test(String(s)));
3267
+ }
3268
+ let t = caseSensitive ? term : term.toLowerCase();
3269
+ return function (s) {
3270
+ if (s === null || s === undefined) return false;
3271
+ let str = caseSensitive ? String(s) : String(s).toLowerCase();
3272
+ if (mode === 'starts_with') return str.indexOf(t) === 0;
3273
+ if (mode === 'ends_with') return str.length >= t.length && str.lastIndexOf(t) === str.length - t.length;
3274
+ return str.indexOf(t) >= 0; // contains
3275
+ };
3276
+ };
3277
+
3278
+ function numMatches(x, mode, a, lo, hi) {
3279
+ switch (mode) {
3280
+ case 'eq': return Math.abs(x - a) <= 1e-9 * Math.max(1, Math.abs(a));
3281
+ case 'ne': return Math.abs(x - a) > 1e-9 * Math.max(1, Math.abs(a));
3282
+ case 'lt': return x < a;
3283
+ case 'le': return x <= a;
3284
+ case 'gt': return x > a;
3285
+ case 'ge': return x >= a;
3286
+ case 'range': return x >= lo && x <= hi;
3287
+ default: return false;
3288
+ }
3289
+ }
3290
+
3291
+ // The list of fields the given tree actually offers (drives the Field
3292
+ // dropdowns). Always exposes Any Text + Node Name; adds the text, numeric
3293
+ // and custom-property fields that are present, then structure fields.
3294
+ forester.availableSearchFields = function (root) {
3295
+ let fields = [];
3296
+ fields.push({ key: 'ANY', label: 'Any Text', numeric: false });
3297
+ fields.push({ key: 'NN', label: SEARCH_FIELD_LABELS.NN, numeric: false });
3298
+ if (!root) return fields;
3299
+
3300
+ let present = {};
3301
+ let hasBL = false, hasConf = false;
3302
+ let propRefs = {}; // ref -> { num, tot, dtNum, dtStr }
3303
+ forester.preOrderTraversalAll(root, function (n) {
3304
+ for (let k = 0; k < SEARCH_TEXT_ORDER.length; ++k) {
3305
+ let key = SEARCH_TEXT_ORDER[k];
3306
+ if (!present[key] && SEARCH_TEXT_EXTRACTORS[key](n).length > 0) present[key] = true;
3307
+ }
3308
+ if (!hasBL && typeof n.branch_length === 'number' && n.branch_length >= 0) hasBL = true;
3309
+ if (!hasConf && n.confidences) {
3310
+ for (let i = 0; i < n.confidences.length; ++i) {
3311
+ if (typeof n.confidences[i].value === 'number') { hasConf = true; break; }
3312
+ }
3313
+ }
3314
+ if (n.properties) {
3315
+ for (let i = 0; i < n.properties.length; ++i) {
3316
+ let p = n.properties[i];
3317
+ if (isInternalPropRef(p.ref)) continue;
3318
+ let r = propRefs[p.ref] || (propRefs[p.ref] = { num: 0, tot: 0, dtNum: false, dtStr: false });
3319
+ r.tot++;
3320
+ if (forester.parseFiniteDouble(p.value) !== null) r.num++;
3321
+ if (p.datatype) { if (datatypeIsNumeric(p.datatype)) r.dtNum = true; else r.dtStr = true; }
3322
+ }
3323
+ }
3324
+ });
3325
+
3326
+ for (let k = 0; k < SEARCH_TEXT_ORDER.length; ++k) {
3327
+ let key = SEARCH_TEXT_ORDER[k];
3328
+ if (present[key]) fields.push({ key: key, label: SEARCH_FIELD_LABELS[key], numeric: false });
3329
+ }
3330
+ if (hasBL) fields.push({ key: 'BL', label: 'Branch Length', numeric: true });
3331
+ if (hasConf) fields.push({ key: 'CO', label: 'Confidence', numeric: true });
3332
+ let refs = Object.keys(propRefs).sort();
3333
+ for (let i = 0; i < refs.length; ++i) {
3334
+ let r = propRefs[refs[i]];
3335
+ let numeric = r.dtStr ? false : (r.dtNum ? true : (r.tot > 0 && r.num === r.tot));
3336
+ fields.push({ key: 'PROP:' + refs[i], label: refs[i], numeric: numeric, propRef: refs[i] });
3337
+ }
3338
+ fields.push({ key: 'CS', label: 'Clade Size (tips)', numeric: true });
3339
+ fields.push({ key: 'NC', label: 'Number of Children', numeric: true });
3340
+ fields.push({ key: 'DE', label: 'Depth from Root', numeric: true });
3341
+ if (hasBL) fields.push({ key: 'DR', label: 'Distance from Root', numeric: true });
3342
+ fields.push({ key: 'NT', label: 'Node Type', numeric: false });
3343
+ return fields;
3344
+ };
3345
+
3346
+ // Per-node depth / distance-to-root / clade size, computed on demand for the
3347
+ // structure search fields (cheap O(n), avoids staleness after tree edits).
3348
+ function computeSearchMetrics(root) {
3349
+ (function pre(n, depth, dist) {
3350
+ n._srchDepth = depth;
3351
+ let d = dist + (typeof n.branch_length === 'number' && n.branch_length > 0 ? n.branch_length : 0);
3352
+ n._srchDist = d;
3353
+ let kids = n.children || n._children;
3354
+ if (kids) for (let i = 0; i < kids.length; ++i) pre(kids[i], depth + 1, d);
3355
+ })(root, 0, 0);
3356
+ forester.postOrderTraversalAll(root, function (n) {
3357
+ let kids = n.children || n._children;
3358
+ if (!kids || kids.length === 0) { n._srchClade = 1; return; }
3359
+ let s = 0;
3360
+ for (let i = 0; i < kids.length; ++i) s += kids[i]._srchClade;
3361
+ n._srchClade = s;
3362
+ });
3363
+ }
3364
+
3365
+ // Extract the value(s) of a field from a node (strings for text fields,
3366
+ // numbers for the numeric ones). A field is multi-valued; any value matching
3367
+ // is a match. root is needed only for the Node Type field.
3368
+ forester.extractSearchValues = function (node, field, root) {
3369
+ let key = field.key;
3370
+ if (key === 'ANY') {
3371
+ let out = [];
3372
+ for (let i = 0; i < SEARCH_ANY_TEXT_KEYS.length; ++i) {
3373
+ out = out.concat(SEARCH_TEXT_EXTRACTORS[SEARCH_ANY_TEXT_KEYS[i]](node));
3374
+ }
3375
+ if (node.properties) {
3376
+ for (let i = 0; i < node.properties.length; ++i) {
3377
+ let p = node.properties[i];
3378
+ if (!isInternalPropRef(p.ref) && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
3379
+ }
3380
+ }
3381
+ return out;
3382
+ }
3383
+ if (key === 'NT') {
3384
+ let kids = node.children || node._children;
3385
+ let isLeaf = !kids || kids.length === 0;
3386
+ return [isLeaf ? 'leaf' : (node === root ? 'root' : 'internal')];
3387
+ }
3388
+ if (key.indexOf('PROP:') === 0) {
3389
+ let out = [];
3390
+ if (node.properties) {
3391
+ for (let i = 0; i < node.properties.length; ++i) {
3392
+ let p = node.properties[i];
3393
+ if (p.ref === field.propRef && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
3394
+ }
3395
+ }
3396
+ return out;
3397
+ }
3398
+ if (SEARCH_TEXT_EXTRACTORS[key]) return SEARCH_TEXT_EXTRACTORS[key](node);
3399
+ switch (key) {
3400
+ case 'BL': return (typeof node.branch_length === 'number') ? [node.branch_length] : [];
3401
+ case 'CO': return node.confidences ? node.confidences.map(c => c.value).filter(v => typeof v === 'number') : [];
3402
+ case 'CS': return [node._srchClade];
3403
+ case 'NC': { let kids = node.children || node._children; return [kids ? kids.length : 0]; }
3404
+ case 'DE': return [node._srchDepth];
3405
+ case 'DR': return [node._srchDist];
3406
+ default: return [];
3407
+ }
3408
+ };
3409
+
3410
+ // Run one search spec { field, mode, value, value2, caseSensitive, inverse }
3411
+ // over the tree and return the Set of matching nodes.
3412
+ // A parsed tree is anchored on a SUPER-ROOT: a synthetic node whose single
3413
+ // child is the tree's actual root. It is not a node of the phylogeny -- it
3414
+ // exists to give the root a parent slot, which is what lets reRoot() move
3415
+ // the root around and what toNewHampshire() writes from. Both formats have
3416
+ // one; from phyloXML it also carries the <phylogeny> element's own name and
3417
+ // description, so it looks like a node with the TREE's name on it.
3418
+ //
3419
+ // Anything that reasons about the phylogeny's own nodes has to step over it.
3420
+ // This is the test getTreeRoot uses, minus that function's walk UP the tree,
3421
+ // which would escape a subtree a caller had deliberately scoped to.
3422
+ function realRootOf(root) {
3423
+ if (!root.parent && root.children && root.children.length === 1) {
3424
+ return root.children[0];
3425
+ }
3426
+ return root;
3427
+ }
3428
+
3429
+ forester.searchWithSpec = function (root, spec) {
3430
+ let result = new Set();
3431
+ if (!root || !spec || !spec.field) return result;
3432
+ // Metrics stay relative to what the caller passed, so depth and distance
3433
+ // values are unchanged; only the set of nodes considered is narrowed.
3434
+ let nodes = realRootOf(root);
3435
+ let field = spec.field;
3436
+ if (field.key === 'CS' || field.key === 'DE' || field.key === 'DR' || field.key === 'NC') computeSearchMetrics(root);
3437
+
3438
+ let v = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
3439
+ v = v.replace(/\s+/g, ' ').trim();
3440
+
3441
+ let test = null;
3442
+ if (field.numeric) {
3443
+ let a = forester.parseFiniteDouble(v);
3444
+ let b = (spec.mode === 'range') ? forester.parseFiniteDouble(spec.value2) : null;
3445
+ if (a === null || (spec.mode === 'range' && b === null)) return result; // invalid -> reset
3446
+ let lo = (b !== null) ? Math.min(a, b) : a;
3447
+ let hi = (b !== null) ? Math.max(a, b) : a;
3448
+ test = function (n) {
3449
+ let vals = forester.extractSearchValues(n, field, root);
3450
+ for (let i = 0; i < vals.length; ++i) {
3451
+ let x = (typeof vals[i] === 'number') ? vals[i] : forester.parseFiniteDouble(vals[i]);
3452
+ if (x !== null && numMatches(x, spec.mode, a, lo, hi)) return true;
3453
+ }
3454
+ return false;
3455
+ };
3456
+ } else {
3457
+ if (v.length < 1) return result;
3458
+ let splittable = spec.mode !== 'regex';
3459
+ let orTerms = (splittable && v.indexOf(',') >= 0) ? v.split(/,+/) : [v];
3460
+ let compiled = [];
3461
+ for (let oi = 0; oi < orTerms.length; ++oi) {
3462
+ let ot = orTerms[oi].trim();
3463
+ if (!ot) continue;
3464
+ let ands = (splittable && ot.indexOf('+') > 0) ? ot.split(/\++/) : [ot];
3465
+ let tests = [];
3466
+ let bad = false;
3467
+ for (let ai = 0; ai < ands.length; ++ai) {
3468
+ let term = ands[ai].trim();
3469
+ if (!term) continue;
3470
+ let t = forester.makeSearchStringTest(term, spec.mode, spec.caseSensitive);
3471
+ if (t === null) { bad = true; break; } // invalid regex
3472
+ tests.push(t);
3473
+ }
3474
+ if (!bad && tests.length) compiled.push(tests);
3475
+ }
3476
+ if (!compiled.length) return result;
3477
+ test = function (n) {
3478
+ let vals = forester.extractSearchValues(n, field, root);
3479
+ for (let oi = 0; oi < compiled.length; ++oi) {
3480
+ let ands = compiled[oi], ok = true;
3481
+ for (let ai = 0; ai < ands.length; ++ai) {
3482
+ let hit = false;
3483
+ for (let vi = 0; vi < vals.length; ++vi) { if (ands[ai](vals[vi])) { hit = true; break; } }
3484
+ if (!hit) { ok = false; break; }
3485
+ }
3486
+ if (ok) return true;
3487
+ }
3488
+ return false;
3489
+ };
3490
+ }
3491
+
3492
+ forester.preOrderTraversalAll(nodes, function (n) { if (test(n)) result.add(n); });
3493
+
3494
+ if (spec.inverse) {
3495
+ // Complement, scoped to nodes that actually carry this field.
3496
+ let inv = new Set();
3497
+ forester.preOrderTraversalAll(nodes, function (n) {
3498
+ if (!result.has(n) && forester.extractSearchValues(n, field, root).length > 0) inv.add(n);
3499
+ });
3500
+ return inv;
3501
+ }
3502
+ return result;
3503
+ };
3504
+
3505
+ // Distinct, trimmed, sorted values of a specific text field across the tree,
3506
+ // for the value-box autocomplete. Empty for numeric, Any Text, or Molecular
3507
+ // Sequence (near-unique / huge). cap limits the list length (optional).
3508
+ forester.distinctSearchValues = function (root, field, cap) {
3509
+ if (!root || !field || field.numeric || field.key === 'ANY' || field.key === 'MS') return [];
3510
+ let set = new Set();
3511
+ forester.preOrderTraversalAll(root, function (n) {
3512
+ let vals = forester.extractSearchValues(n, field, root);
3513
+ for (let i = 0; i < vals.length; ++i) {
3514
+ if (vals[i] !== null && vals[i] !== undefined) {
3515
+ let v = String(vals[i]).trim();
3516
+ if (v.length > 0) set.add(v);
3517
+ }
3518
+ }
3519
+ });
3520
+ let arr = Array.from(set).sort(function (a, b) { return a.localeCompare(b); });
3521
+ if (cap && arr.length > cap) arr = arr.slice(0, cap);
3522
+ return arr;
3523
+ };
3524
+
3525
+
3526
+ // --------------------------------------------------------------
3527
+ // Geologic time scale + time-tree detection (the desktop's time axes)
3528
+ // --------------------------------------------------------------
3529
+ // The ICS International Chronostratigraphic Chart as the desktop embeds
3530
+ // it (GeologicTimeScale.java): {name, young, old, color}, ages in Ma,
3531
+ // colours the official ICS ones (kept even in monochrome exports -- the
3532
+ // timescale IS a colour key). Reference: Cohen, K.M., Harper, D.A.T.,
3533
+ // Gibbard, P.L. & Car, N. (2025, updated), The ICS International
3534
+ // Chronostratigraphic Chart this decade, Episodes 48: 105-115;
3535
+ // International Commission on Stratigraphy, www.stratigraphy.org.
3536
+
3537
+ const GEO_SCALE = {
3538
+ eon: [
3539
+ {name: 'Phanerozoic', young: 0, old: 538.8, color: '#9AD9DD'},
3540
+ {name: 'Proterozoic', young: 538.8, old: 2500, color: '#FF70B8'},
3541
+ {name: 'Archean', young: 2500, old: 4031, color: '#FF3399'}
3542
+ ],
3543
+ era: [
3544
+ {name: 'Cenozoic', young: 0, old: 66, color: '#F2F91D'},
3545
+ {name: 'Mesozoic', young: 66, old: 251.902, color: '#67C5CA'},
3546
+ {name: 'Paleozoic', young: 251.902, old: 538.8, color: '#99C08D'},
3547
+ {name: 'Neoproterozoic', young: 538.8, old: 1000, color: '#FF9BCD'},
3548
+ {name: 'Mesoproterozoic', young: 1000, old: 1600, color: '#FF7EBF'},
3549
+ {name: 'Paleoproterozoic', young: 1600, old: 2500, color: '#E665A6'},
3550
+ {name: 'Neoarchean', young: 2500, old: 2800, color: '#FF5CAD'},
3551
+ {name: 'Mesoarchean', young: 2800, old: 3200, color: '#E62E8A'},
3552
+ {name: 'Paleoarchean', young: 3200, old: 3600, color: '#CC297A'},
3553
+ {name: 'Eoarchean', young: 3600, old: 4031, color: '#B2246B'}
3554
+ ],
3555
+ period: [
3556
+ {name: 'Quaternary', young: 0, old: 2.58, color: '#F9F97F'},
3557
+ {name: 'Neogene', young: 2.58, old: 23.04, color: '#FFE619'},
3558
+ {name: 'Paleogene', young: 23.04, old: 66, color: '#FD9A52'},
3559
+ {name: 'Cretaceous', young: 66, old: 143.1, color: '#7FC64E'},
3560
+ {name: 'Jurassic', young: 143.1, old: 201.4, color: '#34B2C9'},
3561
+ {name: 'Triassic', young: 201.4, old: 251.902, color: '#812B92'},
3562
+ {name: 'Permian', young: 251.902, old: 298.9, color: '#F04028'},
3563
+ {name: 'Carboniferous', young: 298.9, old: 358.86, color: '#67A599'},
3564
+ {name: 'Devonian', young: 358.86, old: 419.62, color: '#CB8C37'},
3565
+ {name: 'Silurian', young: 419.62, old: 443.1, color: '#B3E1B6'},
3566
+ {name: 'Ordovician', young: 443.1, old: 486.85, color: '#009270'},
3567
+ {name: 'Cambrian', young: 486.85, old: 538.8, color: '#7FA056'},
3568
+ {name: 'Ediacaran', young: 538.8, old: 635, color: '#FFC3E1'},
3569
+ {name: 'Cryogenian', young: 635, old: 720, color: '#FFAFD7'},
3570
+ {name: 'Tonian', young: 720, old: 1000, color: '#FFA5D2'},
3571
+ {name: 'Stenian', young: 1000, old: 1200, color: '#FFA5D2'},
3572
+ {name: 'Ectasian', young: 1200, old: 1400, color: '#FF98CC'},
3573
+ {name: 'Calymmian', young: 1400, old: 1600, color: '#FF8BC5'},
3574
+ {name: 'Statherian', young: 1600, old: 1800, color: '#EE93C1'},
3575
+ {name: 'Orosirian', young: 1800, old: 2050, color: '#E874AF'},
3576
+ {name: 'Rhyacian', young: 2050, old: 2300, color: '#EB84B8'},
3577
+ {name: 'Siderian', young: 2300, old: 2500, color: '#E874AF'}
3578
+ ],
3579
+ epoch: [
3580
+ {name: 'Holocene', young: 0, old: 0.0117, color: '#FEF2E0'},
3581
+ {name: 'Pleistocene', young: 0.0117, old: 2.58, color: '#FFF2AE'},
3582
+ {name: 'Pliocene', young: 2.58, old: 5.333, color: '#FFFF99'},
3583
+ {name: 'Miocene', young: 5.333, old: 23.04, color: '#FFFF00'},
3584
+ {name: 'Oligocene', young: 23.04, old: 33.9, color: '#FDC07A'},
3585
+ {name: 'Eocene', young: 33.9, old: 56, color: '#FDB46C'},
3586
+ {name: 'Paleocene', young: 56, old: 66, color: '#FDA75F'},
3587
+ {name: 'Late Cretaceous', young: 66, old: 100.5, color: '#A6D84A'},
3588
+ {name: 'Early Cretaceous', young: 100.5, old: 143.1, color: '#8CCD57'},
3589
+ {name: 'Late Jurassic', young: 143.1, old: 161.5, color: '#B3E3EE'},
3590
+ {name: 'Middle Jurassic', young: 161.5, old: 174.7, color: '#80CFD8'},
3591
+ {name: 'Early Jurassic', young: 174.7, old: 201.4, color: '#42AED0'},
3592
+ {name: 'Late Triassic', young: 201.4, old: 237, color: '#BD8CC3'},
3593
+ {name: 'Middle Triassic', young: 237, old: 246.7, color: '#B168B1'},
3594
+ {name: 'Early Triassic', young: 246.7, old: 251.902, color: '#983999'},
3595
+ {name: 'Lopingian', young: 251.902, old: 259.51, color: '#FBA794'},
3596
+ {name: 'Guadalupian', young: 259.51, old: 274.4, color: '#FB745C'},
3597
+ {name: 'Cisuralian', young: 274.4, old: 298.9, color: '#EF5845'},
3598
+ {name: 'Pennsylvanian', young: 298.9, old: 323.4, color: '#99C2B5'},
3599
+ {name: 'Mississippian', young: 323.4, old: 358.86, color: '#678F66'},
3600
+ {name: 'Late Devonian', young: 358.86, old: 382.31, color: '#F1E19D'},
3601
+ {name: 'Middle Devonian', young: 382.31, old: 393.47, color: '#F1C868'},
3602
+ {name: 'Early Devonian', young: 393.47, old: 419.62, color: '#E5AC4D'},
3603
+ {name: 'Pridoli', young: 419.62, old: 422.7, color: '#E6F5E1'},
3604
+ {name: 'Ludlow', young: 422.7, old: 426.7, color: '#BFE6CF'},
3605
+ {name: 'Wenlock', young: 426.7, old: 432.9, color: '#B3E1C2'},
3606
+ {name: 'Llandovery', young: 432.9, old: 443.1, color: '#99D7B3'},
3607
+ {name: 'Late Ordovician', young: 443.1, old: 458.2, color: '#7FCA93'},
3608
+ {name: 'Middle Ordovician', young: 458.2, old: 471.3, color: '#4DB47E'},
3609
+ {name: 'Early Ordovician', young: 471.3, old: 486.85, color: '#1A9D6F'},
3610
+ {name: 'Furongian', young: 486.85, old: 497, color: '#B3E095'},
3611
+ {name: 'Miaolingian', young: 497, old: 506.5, color: '#A6CF86'},
3612
+ {name: 'Series 2', young: 506.5, old: 521, color: '#99C078'},
3613
+ {name: 'Terreneuvian', young: 521, old: 538.8, color: '#8CB06C'}
3614
+ ],
3615
+ // The 101 ratified Phanerozoic stages, plus the Pridoli: a Series with
3616
+ // no stages of its own, standing in the stage row for its own span
3617
+ // (419.62-422.7 Ma) as on the printed ICS chart -- without it the row
3618
+ // would have a hole there. Same source as every rank above
3619
+ // (Macrostrat international timescale, id 11), which the ranks above
3620
+ // match byte for byte; the desktop generated its table from the same
3621
+ // query, so the two programs band from identical data.
3622
+ age: [
3623
+ {name: 'Meghalayan', young: 0, old: 0.0042, color: '#FEF2E0'},
3624
+ {name: 'Northgrippian', young: 0.0042, old: 0.0082, color: '#FEF2E0'},
3625
+ {name: 'Greenlandian', young: 0.0082, old: 0.0117, color: '#FEF2E0'},
3626
+ {name: 'Late Pleistocene', young: 0.0117, old: 0.129, color: '#FFF2C7'},
3627
+ {name: 'Chibanian', young: 0.129, old: 0.774, color: '#FFF2C7'},
3628
+ {name: 'Calabrian', young: 0.774, old: 1.8, color: '#FFF2C7'},
3629
+ {name: 'Gelasian', young: 1.8, old: 2.58, color: '#FFEDB3'},
3630
+ {name: 'Piacenzian', young: 2.58, old: 3.6, color: '#FFFFBF'},
3631
+ {name: 'Zanclean', young: 3.6, old: 5.333, color: '#FFFFB3'},
3632
+ {name: 'Messinian', young: 5.333, old: 7.246, color: '#FFFF73'},
3633
+ {name: 'Tortonian', young: 7.246, old: 11.63, color: '#FFFF66'},
3634
+ {name: 'Serravallian', young: 11.63, old: 13.82, color: '#FFFF59'},
3635
+ {name: 'Langhian', young: 13.82, old: 15.98, color: '#FFFF4D'},
3636
+ {name: 'Burdigalian', young: 15.98, old: 20.45, color: '#FFFF41'},
3637
+ {name: 'Aquitanian', young: 20.45, old: 23.04, color: '#FFFF33'},
3638
+ {name: 'Chattian', young: 23.04, old: 27.3, color: '#FEE6AA'},
3639
+ {name: 'Rupelian', young: 27.3, old: 33.9, color: '#FED99A'},
3640
+ {name: 'Priabonian', young: 33.9, old: 37.71, color: '#FDCDA1'},
3641
+ {name: 'Bartonian', young: 37.71, old: 41.03, color: '#FDC091'},
3642
+ {name: 'Lutetian', young: 41.03, old: 48.07, color: '#FCB482'},
3643
+ {name: 'Ypresian', young: 48.07, old: 56, color: '#FCA773'},
3644
+ {name: 'Thanetian', young: 56, old: 59.24, color: '#FDBF6F'},
3645
+ {name: 'Selandian', young: 59.24, old: 61.66, color: '#FEBF65'},
3646
+ {name: 'Danian', young: 61.66, old: 66, color: '#FDB462'},
3647
+ {name: 'Maastrichtian', young: 66, old: 72.2, color: '#F2FA8C'},
3648
+ {name: 'Campanian', young: 72.2, old: 83.6, color: '#E6F47F'},
3649
+ {name: 'Santonian', young: 83.6, old: 85.7, color: '#D9EF74'},
3650
+ {name: 'Coniacian', young: 85.7, old: 89.8, color: '#CCE968'},
3651
+ {name: 'Turonian', young: 89.8, old: 93.9, color: '#BFE35D'},
3652
+ {name: 'Cenomanian', young: 93.9, old: 100.5, color: '#B3DE53'},
3653
+ {name: 'Albian', young: 100.5, old: 113.2, color: '#CCEA97'},
3654
+ {name: 'Aptian', young: 113.2, old: 121.4, color: '#BFE48A'},
3655
+ {name: 'Barremian', young: 121.4, old: 125.77, color: '#B3DF7F'},
3656
+ {name: 'Hauterivian', young: 125.77, old: 132.6, color: '#A6D975'},
3657
+ {name: 'Valanginian', young: 132.6, old: 137.05, color: '#99D36A'},
3658
+ {name: 'Berriasian', young: 137.05, old: 143.1, color: '#8CCD60'},
3659
+ {name: 'Tithonian', young: 143.1, old: 149.2, color: '#D9F1F7'},
3660
+ {name: 'Kimmeridgian', young: 149.2, old: 154.8, color: '#CCECF4'},
3661
+ {name: 'Oxfordian', young: 154.8, old: 161.5, color: '#BFE7F1'},
3662
+ {name: 'Callovian', young: 161.5, old: 165.3, color: '#BFE7E5'},
3663
+ {name: 'Bathonian', young: 165.3, old: 168.2, color: '#B3E2E3'},
3664
+ {name: 'Bajocian', young: 168.2, old: 170.9, color: '#A6DDE0'},
3665
+ {name: 'Aalenian', young: 170.9, old: 174.7, color: '#9AD9DD'},
3666
+ {name: 'Toarcian', young: 174.7, old: 184.2, color: '#99CEE3'},
3667
+ {name: 'Pliensbachian', young: 184.2, old: 192.9, color: '#80C5DD'},
3668
+ {name: 'Sinemurian', young: 192.9, old: 199.5, color: '#67BCD8'},
3669
+ {name: 'Hettangian', young: 199.5, old: 201.4, color: '#4EB3D3'},
3670
+ {name: 'Rhaetian', young: 201.4, old: 205.7, color: '#E3B9DB'},
3671
+ {name: 'Norian', young: 205.7, old: 227.3, color: '#D6AAD3'},
3672
+ {name: 'Carnian', young: 227.3, old: 237, color: '#C99BCB'},
3673
+ {name: 'Ladinian', young: 237, old: 241.464, color: '#C983BF'},
3674
+ {name: 'Anisian', young: 241.464, old: 246.7, color: '#BC75B7'},
3675
+ {name: 'Olenekian', young: 246.7, old: 249.9, color: '#B051A5'},
3676
+ {name: 'Induan', young: 249.9, old: 251.902, color: '#A4469F'},
3677
+ {name: 'Changhsingian', young: 251.902, old: 254.14, color: '#FCC0B2'},
3678
+ {name: 'Wuchiapingian', young: 254.14, old: 259.51, color: '#FCB4A2'},
3679
+ {name: 'Capitanian', young: 259.51, old: 264.28, color: '#FB9A85'},
3680
+ {name: 'Wordian', young: 264.28, old: 266.9, color: '#FB8D76'},
3681
+ {name: 'Roadian', young: 266.9, old: 274.4, color: '#FB8069'},
3682
+ {name: 'Kungurian', young: 274.4, old: 283.3, color: '#E38776'},
3683
+ {name: 'Artinskian', young: 283.3, old: 290.1, color: '#E37B68'},
3684
+ {name: 'Sakmarian', young: 290.1, old: 293.52, color: '#E36F5C'},
3685
+ {name: 'Asselian', young: 293.52, old: 298.9, color: '#E36350'},
3686
+ {name: 'Gzhelian', young: 298.9, old: 303.7, color: '#CCD4C7'},
3687
+ {name: 'Kasimovian', young: 303.7, old: 307, color: '#BFD0C5'},
3688
+ {name: 'Moscovian', young: 307, old: 315.2, color: '#C7CBB9'},
3689
+ {name: 'Bashkirian', young: 315.2, old: 323.4, color: '#99C2B5'},
3690
+ {name: 'Serpukhovian', young: 323.4, old: 330.3, color: '#BFC26B'},
3691
+ {name: 'Visean', young: 330.3, old: 346.7, color: '#A6B96C'},
3692
+ {name: 'Tournaisian', young: 346.7, old: 358.86, color: '#8CB06C'},
3693
+ {name: 'Famennian', young: 358.86, old: 372.15, color: '#F2EDC5'},
3694
+ {name: 'Frasnian', young: 372.15, old: 382.31, color: '#F2EDAD'},
3695
+ {name: 'Givetian', young: 382.31, old: 387.95, color: '#F1E185'},
3696
+ {name: 'Eifelian', young: 387.95, old: 393.47, color: '#F1D576'},
3697
+ {name: 'Emsian', young: 393.47, old: 410.62, color: '#E5D075'},
3698
+ {name: 'Pragian', young: 410.62, old: 413.02, color: '#E5C468'},
3699
+ {name: 'Lochkovian', young: 413.02, old: 419.62, color: '#E5B75A'},
3700
+ {name: 'Pridoli', young: 419.62, old: 422.7, color: '#E6F5E1'},
3701
+ {name: 'Ludfordian', young: 422.7, old: 425, color: '#D9F0DF'},
3702
+ {name: 'Gorstian', young: 425, old: 426.7, color: '#CCECDD'},
3703
+ {name: 'Homerian', young: 426.7, old: 430.6, color: '#CCEBD1'},
3704
+ {name: 'Sheinwoodian', young: 430.6, old: 432.9, color: '#BFE6C3'},
3705
+ {name: 'Telychian', young: 432.9, old: 438.6, color: '#BFE6CF'},
3706
+ {name: 'Aeronian', young: 438.6, old: 440.5, color: '#B3E1C2'},
3707
+ {name: 'Rhuddanian', young: 440.5, old: 443.1, color: '#A6DCB5'},
3708
+ {name: 'Hirnantian', young: 443.1, old: 445.2, color: '#A6DBAB'},
3709
+ {name: 'Katian', young: 445.2, old: 452.8, color: '#99D69F'},
3710
+ {name: 'Sandbian', young: 452.8, old: 458.2, color: '#8CD094'},
3711
+ {name: 'Darriwilian', young: 458.2, old: 469.4, color: '#74C69C'},
3712
+ {name: 'Dapingian', young: 469.4, old: 471.3, color: '#66C092'},
3713
+ {name: 'Floian', young: 471.3, old: 477.1, color: '#41B087'},
3714
+ {name: 'Tremadocian', young: 477.1, old: 486.85, color: '#33A97E'},
3715
+ {name: 'Stage 10', young: 486.85, old: 491, color: '#E6F5C9'},
3716
+ {name: 'Jiangshanian', young: 491, old: 494.2, color: '#D9F0BB'},
3717
+ {name: 'Paibian', young: 494.2, old: 497, color: '#CCEBAE'},
3718
+ {name: 'Guzhangian', young: 497, old: 500.5, color: '#CCDFAA'},
3719
+ {name: 'Drumian', young: 500.5, old: 504.5, color: '#BFD99D'},
3720
+ {name: 'Wuliuan', young: 504.5, old: 506.5, color: '#B3D492'},
3721
+ {name: 'Stage 4', young: 506.5, old: 514.5, color: '#B3CA8E'},
3722
+ {name: 'Stage 3', young: 514.5, old: 521, color: '#A6C583'},
3723
+ {name: 'Stage 2', young: 521, old: 529, color: '#A6BA80'},
3724
+ {name: 'Fortunian', young: 529, old: 538.8, color: '#99B575'}
3725
+ ]
3726
+ };
3727
+
3728
+ forester.geoIntervals = function (rank) {
3729
+ return GEO_SCALE[rank] || [];
3730
+ };
3731
+
3732
+ // How far back a rank's intervals reach (its oldest boundary, Ma).
3733
+ forester.geoCoverage = function (rank) {
3734
+ let ivs = forester.geoIntervals(rank);
3735
+ return ivs.length > 0 ? ivs[ivs.length - 1].old : 0;
3736
+ };
3737
+
3738
+ // The [coarse, fine] rank pair for the span [youngMa, oldMa] a tree
3739
+ // actually occupies -- the youngest tip to the root, not 0 to the root,
3740
+ // so a fossil-only clade bands on its own window rather than on the
3741
+ // whole stretch back from the present.
3742
+ //
3743
+ // It adapts FINER as well as coarser. A narrow Phanerozoic window --
3744
+ // one or two Series -- bands Series over STAGE (the ICS ages), which is
3745
+ // what a 66-100 Ma dinosaur tree needs to say anything at all: over
3746
+ // Period/Epoch it would read "Cretaceous / Late Cretaceous" and nothing
3747
+ // more. Wider than two Series the stages would be slivers, so the
3748
+ // existing ladder takes over: the finest pair that still fully covers the
3749
+ // range, so a deep tree never shows blank band segments. Shared rule with
3750
+ // the desktop's GeologicTimeScale.bandRanks -- change both or neither.
3751
+ forester.geoBandRanks = function (youngMa, oldMa) {
3752
+ if (oldMa <= forester.geoCoverage('age')) { // stages are Phanerozoic-only
3753
+ let series = forester.geoOverlapping('epoch', youngMa, oldMa).length;
3754
+ if (series > 0 && series <= 2) {
3755
+ return ['epoch', 'age'];
3756
+ }
3757
+ }
3758
+ if (oldMa <= forester.geoCoverage('epoch')) {
3759
+ return ['period', 'epoch'];
3760
+ }
3761
+ if (oldMa <= forester.geoCoverage('period')) {
3762
+ return ['era', 'period'];
3763
+ }
3764
+ return ['eon', 'era'];
3765
+ };
3766
+
3767
+ // The rank's intervals overlapping [youngMa, oldMa] (a zero-width window
3768
+ // becomes a point query).
3769
+ forester.geoOverlapping = function (rank, youngMa, oldMa) {
3770
+ let lo = Math.min(youngMa, oldMa);
3771
+ let hi = Math.max(youngMa, oldMa);
3772
+ return forester.geoIntervals(rank).filter(function (iv) {
3773
+ if (hi === lo) {
3774
+ return iv.young <= lo && lo < iv.old;
3775
+ }
3776
+ return iv.old > lo && iv.young < hi;
3777
+ });
3778
+ };
3779
+
3780
+ // The rank's interval containing ageMa (young <= age < old), or null.
3781
+ forester.geoAt = function (rank, ageMa) {
3782
+ let ivs = forester.geoIntervals(rank);
3783
+ for (let i = 0; i < ivs.length; ++i) {
3784
+ if (ivs[i].young <= ageMa && ageMa < ivs[i].old) {
3785
+ return ivs[i];
3786
+ }
3787
+ }
3788
+ return null;
3789
+ };
3790
+
3791
+ // ---- time-tree detection -------------------------------------------
3792
+ // Two date conventions: GEOLOGIC ages (Ma before present, decreasing
3793
+ // toward the tips) and CALENDAR years (increasing toward the tips).
3794
+ // Decided from the <date> unit attributes, with a magnitude fallback for
3795
+ // unitless dates: values mostly in [1500, 2200] read as years; values
3796
+ // spanning from large down toward ~0 read as ages.
3797
+ const GEO_DATE_UNITS = {
3798
+ mya: 1, ma: 1, myr: 1, myrs: 1, my: 1, ga: 1, gya: 1, bya: 1, kya: 1,
3799
+ 'million years': 1, 'billion years': 1
3800
+ };
3801
+ const CAL_DATE_UNITS = {
3802
+ year: 1, years: 1, yr: 1, yrs: 1, cal: 1, ce: 1, ad: 1, calendar: 1,
3803
+ 'calendar year': 1, 'calendar years': 1
3804
+ };
3805
+
3806
+ // Everything the viewer needs to decide about and draw a time axis:
3807
+ // {type: 'geologic'|'calendar'|null, rootAge, presentDate, dated,
3808
+ // hasInternalIntervals, hasExternalIntervals}. rootAge (geologic) and
3809
+ // presentDate (calendar) are both the LARGEST date value -- the oldest
3810
+ // node for ages, the most recent tip for years.
3811
+ forester.timeAxisInfo = function (root) {
3812
+ let values = [];
3813
+ let maxVal = -Infinity; // running, not Math.max.apply: 150k dated tips overflow the call stack
3814
+ let minVal = Infinity;
3815
+ let geoUnits = 0;
3816
+ let calUnits = 0;
3817
+ let internal = 0;
3818
+ let external = 0;
3819
+ let datedInternal = 0;
3820
+ let datedExternal = 0;
3821
+ let hasInternalIntervals = false;
3822
+ let hasExternalIntervals = false;
3823
+ forester.preOrderTraversalAll(root, function (n) {
3824
+ let isExt = !n.children && !n._children;
3825
+ if (isExt) {
3826
+ ++external;
3827
+ } else {
3828
+ ++internal;
3829
+ }
3830
+ let d = n.date;
3831
+ if (!d) {
3832
+ return;
3833
+ }
3834
+ let interval = (typeof d.minimum === 'number') && (typeof d.maximum === 'number');
3835
+ if (interval) {
3836
+ if (isExt) {
3837
+ hasExternalIntervals = true;
3838
+ } else {
3839
+ hasInternalIntervals = true;
3840
+ }
3841
+ }
3842
+ if (typeof d.value !== 'number' || !isFinite(d.value)) {
3843
+ return; // 1e400 parses to Infinity and must never reach the tick loops
3844
+ }
3845
+ values.push(d.value);
3846
+ if (d.value > maxVal) {
3847
+ maxVal = d.value;
3848
+ }
3849
+ if (d.value < minVal) {
3850
+ minVal = d.value;
3851
+ }
3852
+ if (isExt) {
3853
+ ++datedExternal;
3854
+ } else {
3855
+ ++datedInternal;
3856
+ }
3857
+ if (d.unit) {
3858
+ let u = String(d.unit).trim().toLowerCase();
3859
+ if (GEO_DATE_UNITS[u]) {
3860
+ ++geoUnits;
3861
+ } else if (CAL_DATE_UNITS[u]) {
3862
+ ++calUnits;
3863
+ }
3864
+ }
3865
+ });
3866
+ let type = null;
3867
+ if (values.length > 0) {
3868
+ if (geoUnits > 0 && geoUnits >= calUnits) {
3869
+ type = 'geologic';
3870
+ } else if (calUnits > 0) {
3871
+ type = 'calendar';
3872
+ } else {
3873
+ let calendarish = values.filter(function (v) {
3874
+ return v >= 1500 && v <= 2200;
3875
+ }).length;
3876
+ if (calendarish * 2 > values.length) {
3877
+ type = 'calendar';
3878
+ } else if (maxVal > 10 && minVal <= maxVal * 0.05) {
3879
+ type = 'geologic';
3880
+ }
3881
+ }
3882
+ }
3883
+ let dated = (datedInternal >= 2 && (datedInternal * 2) > internal)
3884
+ || (datedExternal >= 2 && (datedExternal * 2) > external);
3885
+ let maxValue = values.length > 0 ? maxVal : 0;
3886
+ return {
3887
+ type: type,
3888
+ rootAge: type === 'geologic' ? maxValue : 0,
3889
+ presentDate: type === 'calendar' ? maxValue : 0,
3890
+ dated: dated,
3891
+ hasInternalIntervals: hasInternalIntervals,
3892
+ hasExternalIntervals: hasExternalIntervals
3893
+ };
3894
+ };
3895
+
3896
+ // ---- axis tick mathematics -----------------------------------------
3897
+ // The smallest 1/2/5 x 10^k (k may be negative) step >= target.
3898
+ forester.niceAxisStep = function (target) {
3899
+ if (!(target > 0) || !isFinite(target)) {
3900
+ return 1;
3901
+ }
3902
+ let mag = Math.pow(10, Math.floor(Math.log(target) / Math.LN10));
3903
+ let candidates = [1, 2, 5, 10];
3904
+ for (let i = 0; i < candidates.length; ++i) {
3905
+ let s = candidates[i] * mag;
3906
+ if (s >= target - 1e-12) {
3907
+ return s;
3908
+ }
3909
+ }
3910
+ return 10 * mag;
3911
+ };
3912
+
3913
+ // Tick ages for a "Ma before present" ruler: ~8 nice steps from 0 back
3914
+ // to the root age.
3915
+ forester.maAxisTickValues = function (rootAge) {
3916
+ if (!(rootAge > 0) || !isFinite(rootAge)) {
3917
+ return [];
3918
+ }
3919
+ let step = forester.niceAxisStep(rootAge / 8);
3920
+ let vals = [];
3921
+ for (let v = 0; v <= rootAge + 1e-9; v += step) {
3922
+ vals.push(Math.round(v * 1e6) / 1e6);
3923
+ }
3924
+ return vals;
3925
+ };
3926
+
3927
+ // Whole-year calendar ticks over [from, to]: nice 1/2/5 x 10^k integer
3928
+ // steps, ~7 ticks.
3929
+ forester.calendarTickYears = function (from, to) {
3930
+ let span = to - from;
3931
+ if (!(span > 0) || !isFinite(span) || !isFinite(from)) {
3932
+ return [];
3933
+ }
3934
+ let step = Math.max(1, Math.round(forester.niceAxisStep(span / 7)));
3935
+ let vals = [];
3936
+ for (let y = Math.ceil(from / step) * step; y <= to + 1e-9; y += step) {
3937
+ vals.push(y);
3938
+ }
3939
+ return vals;
3940
+ };
3941
+
3942
+
3943
+ // --------------------------------------------------------------
3944
+ // Multiple sequence alignment (the desktop's alignment track)
3945
+ // --------------------------------------------------------------
3946
+ // Residue palettes, gap handling, conservation scoring and the hover
3947
+ // readout data -- all pure, shared by the viewer's alignment track.
3948
+ // Palettes are the desktop's (MsaColors.java): a Zappo-style 7-class
3949
+ // physico-chemical scheme for amino acids, one colour per base for
3950
+ // nucleotides, muted grey for ambiguity codes, and NO fill for a gap.
3951
+
3952
+ const MSA_GAP_CHARS = {'-': true, '.': true, ' ': true, '~': true};
3953
+
3954
+ // [r, g, b] triples so the letter-ink contrast rule can read them.
3955
+ const MSA_AA_CLASSES = [
3956
+ {residues: 'ILVAM', clazz: 'aliphatic (hydrophobic)', rgb: [240, 170, 170]},
3957
+ {residues: 'FWY', clazz: 'aromatic', rgb: [240, 190, 90]},
3958
+ {residues: 'KRH', clazz: 'positively charged', rgb: [120, 130, 240]},
3959
+ {residues: 'DE', clazz: 'negatively charged', rgb: [230, 100, 100]},
3960
+ {residues: 'STNQ', clazz: 'polar (hydrophilic)', rgb: [120, 200, 120]},
3961
+ {residues: 'PG', clazz: 'conformationally special', rgb: [220, 130, 220]},
3962
+ {residues: 'C', clazz: 'cysteine', rgb: [235, 220, 110]}
3963
+ ];
3964
+ const MSA_NT_RGB = {
3965
+ A: [120, 200, 120],
3966
+ C: [120, 130, 240],
3967
+ G: [230, 185, 80],
3968
+ T: [230, 110, 110],
3969
+ U: [230, 110, 110]
3970
+ };
3971
+ const MSA_UNKNOWN_RGB = [205, 205, 205];
3972
+
3973
+ const MSA_AA_RGB = {};
3974
+ const MSA_AA_CLASS = {};
3975
+ MSA_AA_CLASSES.forEach(function (c) {
3976
+ for (let i = 0; i < c.residues.length; ++i) {
3977
+ MSA_AA_RGB[c.residues.charAt(i)] = c.rgb;
3978
+ MSA_AA_CLASS[c.residues.charAt(i)] = c.clazz;
3979
+ }
3980
+ });
3981
+ // The palette triples and the geologic table are handed out by reference;
3982
+ // freezing them keeps one careless caller mutation from permanently
3983
+ // recolouring a whole residue class (or renaming the Cretaceous).
3984
+ MSA_AA_CLASSES.forEach(function (c) {
3985
+ Object.freeze(c.rgb);
3986
+ });
3987
+ Object.keys(MSA_NT_RGB).forEach(function (k) {
3988
+ Object.freeze(MSA_NT_RGB[k]);
3989
+ });
3990
+ Object.freeze(MSA_UNKNOWN_RGB);
3991
+ Object.keys(GEO_SCALE).forEach(function (rank) {
3992
+ GEO_SCALE[rank].forEach(Object.freeze);
3993
+ Object.freeze(GEO_SCALE[rank]);
3994
+ });
3995
+
3996
+ // Kyte-Doolittle hydropathy and full residue names, for the hover readout.
3997
+ const MSA_HYDROPATHY = {
3998
+ I: 4.5, V: 4.2, L: 3.8, F: 2.8, C: 2.5, M: 1.9, A: 1.8, G: -0.4,
3999
+ T: -0.7, S: -0.8, W: -0.9, Y: -1.3, P: -1.6, H: -3.2, E: -3.5,
4000
+ Q: -3.5, D: -3.5, N: -3.5, K: -3.9, R: -4.5
4001
+ };
4002
+ const MSA_AA_NAMES = {
4003
+ A: 'Alanine', R: 'Arginine', N: 'Asparagine', D: 'Aspartic acid',
4004
+ C: 'Cysteine', E: 'Glutamic acid', Q: 'Glutamine', G: 'Glycine',
4005
+ H: 'Histidine', I: 'Isoleucine', L: 'Leucine', K: 'Lysine',
4006
+ M: 'Methionine', F: 'Phenylalanine', P: 'Proline', S: 'Serine',
4007
+ T: 'Threonine', W: 'Tryptophan', Y: 'Tyrosine', V: 'Valine',
4008
+ U: 'Selenocysteine', O: 'Pyrrolysine',
4009
+ B: 'Asparagine or aspartic acid', Z: 'Glutamine or glutamic acid',
4010
+ X: 'Any amino acid', '*': 'stop'
4011
+ };
4012
+ const MSA_NT_NAMES = {
4013
+ A: 'Adenine', C: 'Cytosine', G: 'Guanine', T: 'Thymine',
4014
+ U: 'Uracil', N: 'any base'
4015
+ };
4016
+ const MSA_NT_CLASS = {
4017
+ A: 'purine', G: 'purine', C: 'pyrimidine', T: 'pyrimidine', U: 'pyrimidine'
4018
+ };
4019
+
4020
+ forester.isMsaGap = function (ch) {
4021
+ return MSA_GAP_CHARS[ch] === true;
4022
+ };
4023
+
4024
+ // The residue's fill as an [r,g,b] triple, or null for a gap (drawn as a
4025
+ // faint dash, not a filled cell).
4026
+ forester.msaResidueRgb = function (ch, nucleotide) {
4027
+ if (ch === undefined || ch === null || forester.isMsaGap(ch)) {
4028
+ return null;
4029
+ }
4030
+ let u = ch.toUpperCase();
4031
+ let rgb = nucleotide ? MSA_NT_RGB[u] : MSA_AA_RGB[u];
4032
+ return rgb ? rgb : MSA_UNKNOWN_RGB;
4033
+ };
4034
+
4035
+ // Black or white letter ink over the given cell colour, by luminance --
4036
+ // the same rule the desktop uses.
4037
+ forester.msaLetterInk = function (rgb) {
4038
+ if (!rgb) {
4039
+ return '#404040'; // over a gap / unfilled cell, as on the desktop
4040
+ }
4041
+ let luminance = (0.299 * rgb[0]) + (0.587 * rgb[1]) + (0.114 * rgb[2]);
4042
+ return luminance < 140 ? '#ffffff' : '#000000';
4043
+ };
4044
+
4045
+ // Amino acid or nucleotide? Judged on the actual residues, never on any
4046
+ // declared type: the fraction of non-gap characters that are plausible
4047
+ // bases (ACGTUN) decides.
4048
+ forester.msaIsNucleotide = function (seq) {
4049
+ if (!seq) {
4050
+ return false;
4051
+ }
4052
+ let bases = 0;
4053
+ let residues = 0;
4054
+ for (let i = 0; i < seq.length; ++i) {
4055
+ let ch = seq.charAt(i);
4056
+ if (forester.isMsaGap(ch)) {
4057
+ continue;
4058
+ }
4059
+ ++residues;
4060
+ if ('ACGTUNacgtun'.indexOf(ch) >= 0) {
4061
+ ++bases;
4062
+ }
4063
+ }
4064
+ return residues > 0 && (bases / residues) > 0.9;
4065
+ };
4066
+
4067
+ // Per-column conservation over the given rows (gapped strings; a short
4068
+ // row's missing tail counts as gaps). Two measures, both in [0,1]:
4069
+ // 'identity' -- the fraction of ROWS carrying the column's most common
4070
+ // residue (gaps stay in the denominator); 'information' -- the Schneider
4071
+ // & Stephens sequence-logo information content, normalized by log2(K)
4072
+ // (K = 4 or 20) and scaled by the column's non-gap fraction. Consensus is
4073
+ // the most common NON-gap residue, ties broken alphabetically so figures
4074
+ // are reproducible.
4075
+ forester.msaConservation = function (rows, length, measure, nucleotide) {
4076
+ if (!(length >= 0) || !isFinite(length)) {
4077
+ return {scores: [], consensus: []};
4078
+ }
4079
+ length = Math.floor(length);
4080
+ let n = rows.length;
4081
+ let scores = new Array(length);
4082
+ let consensus = new Array(length);
4083
+ let K = nucleotide ? 4 : 20;
4084
+ let log2K = Math.log(K) / Math.LN2;
4085
+ for (let c = 0; c < length; ++c) {
4086
+ let counts = {};
4087
+ let nonGap = 0;
4088
+ for (let r = 0; r < n; ++r) {
4089
+ let row = rows[r];
4090
+ let ch = (row && c < row.length) ? row.charAt(c) : '-';
4091
+ if (forester.isMsaGap(ch)) {
4092
+ continue;
4093
+ }
4094
+ ch = ch.toUpperCase();
4095
+ ++nonGap;
4096
+ counts[ch] = (counts[ch] || 0) + 1;
4097
+ }
4098
+ let best = null;
4099
+ let bestCount = 0;
4100
+ Object.keys(counts).sort().forEach(function (ch) {
4101
+ if (counts[ch] > bestCount) {
4102
+ bestCount = counts[ch];
4103
+ best = ch;
4104
+ }
4105
+ });
4106
+ consensus[c] = best;
4107
+ if (n < 1 || nonGap < 1) {
4108
+ scores[c] = 0;
4109
+ } else if (measure === 'information') {
4110
+ let H = 0;
4111
+ Object.keys(counts).forEach(function (ch) {
4112
+ let p = counts[ch] / nonGap;
4113
+ H -= p * (Math.log(p) / Math.LN2);
4114
+ });
4115
+ let info = (log2K - H) / log2K;
4116
+ scores[c] = Math.max(0, info) * (nonGap / n);
4117
+ } else {
4118
+ scores[c] = bestCount / n;
4119
+ }
4120
+ }
4121
+ return {scores: scores, consensus: consensus};
4122
+ };
4123
+
4124
+ // The hover readout's description of one residue: full name, class (amino
4125
+ // acids), Kyte-Doolittle hydropathy. Returns null for a gap.
4126
+ forester.msaResidueInfo = function (ch, nucleotide) {
4127
+ if (ch === undefined || ch === null || forester.isMsaGap(ch)) {
4128
+ return null;
4129
+ }
4130
+ let u = ch.toUpperCase();
4131
+ if (nucleotide) {
4132
+ return {
4133
+ name: MSA_NT_NAMES[u] || 'ambiguity code',
4134
+ clazz: MSA_NT_CLASS[u] || null,
4135
+ hydropathy: null
4136
+ };
4137
+ }
4138
+ let clazz = MSA_AA_CLASS[u] || 'non-standard / ambiguity code';
4139
+ if (u === 'C') {
4140
+ clazz = 'cysteine (disulphide-forming)';
4141
+ }
4142
+ return {
4143
+ name: MSA_AA_NAMES[u] || 'ambiguity code',
4144
+ clazz: clazz,
4145
+ hydropathy: (MSA_HYDROPATHY[u] !== undefined) ? MSA_HYDROPATHY[u] : null
4146
+ };
4147
+ };
4148
+
4149
+ // The residue's 1-based position within its own UNGAPPED sequence -- the
4150
+ // coordinate that maps back onto the real molecule -- or null on a gap.
4151
+ forester.msaUngappedPosition = function (row, col) {
4152
+ if (!row || col < 0 || col >= row.length || forester.isMsaGap(row.charAt(col))) {
4153
+ return null;
4154
+ }
4155
+ let pos = 0;
4156
+ for (let i = 0; i <= col; ++i) {
4157
+ if (!forester.isMsaGap(row.charAt(i))) {
4158
+ ++pos;
4159
+ }
4160
+ }
4161
+ return pos;
4162
+ };
4163
+
4164
+
4165
+ // --------------------------------------------------------------
4166
+ // Unrooted (equal-angle) layout
4167
+ // --------------------------------------------------------------
4168
+ // The desktop's unrooted display: Meacham's equal-angle algorithm, one
4169
+ // pass, no daylight iterations. The root sits at (0,0) and owns the full
4170
+ // circle [startAngle, startAngle + 2*pi); each child receives a wedge of
4171
+ // its parent's proportional to the external nodes it encloses and sits at
4172
+ // its wedge's mid-angle, at the distance lengthOf(child) gives (branch
4173
+ // length times the caller's scale factor, or a constant for a cladogram).
4174
+ // Angles are ABSOLUTE screen angles (radians, y down), inherited and
4175
+ // subdivided -- never re-referenced to the incoming branch, whose
4176
+ // direction is implicitly each wedge's midpoint.
4177
+ //
4178
+ // Writes ux, uy (position) and uangle (the incoming spoke's screen angle)
4179
+ // onto every node; returns {maxRad}, the largest distance from the root.
4180
+ forester.equalAngleLayout = function (root, startAngle, lengthOf) {
4181
+ let counts = new Map();
4182
+ let countExt = function (n) {
4183
+ let c;
4184
+ if (!n.children || n.children.length < 1) {
4185
+ c = 1;
4186
+ } else {
4187
+ c = 0;
4188
+ for (let i = 0; i < n.children.length; ++i) {
4189
+ c += countExt(n.children[i]);
4190
+ }
4191
+ }
4192
+ counts.set(n, c);
4193
+ return c;
4194
+ };
4195
+ countExt(root);
4196
+ let maxRad = 0;
4197
+ root.ux = 0;
4198
+ root.uy = 0;
4199
+ root.uangle = startAngle;
4200
+ let recurse = function (n, low, high) {
4201
+ if (!n.children || n.children.length < 1) {
4202
+ return;
4203
+ }
4204
+ let total = counts.get(n);
4205
+ let current = low;
4206
+ for (let i = 0; i < n.children.length; ++i) {
4207
+ let desc = n.children[i];
4208
+ let arc = (counts.get(desc) / total) * (high - low);
4209
+ let mid = current + (arc / 2);
4210
+ let len = lengthOf(desc);
4211
+ desc.ux = n.ux + (Math.cos(mid) * len);
4212
+ desc.uy = n.uy + (Math.sin(mid) * len);
4213
+ desc.uangle = mid;
4214
+ let r = Math.sqrt((desc.ux * desc.ux) + (desc.uy * desc.uy));
4215
+ if (r > maxRad) {
4216
+ maxRad = r;
4217
+ }
4218
+ recurse(desc, current, current + arc);
4219
+ current += arc;
4220
+ }
4221
+ };
4222
+ recurse(root, startAngle, startAngle + (2 * Math.PI));
4223
+ return {maxRad: maxRad};
4224
+ };
4225
+
4226
+
4227
+ // --------------------------------------------------------------
4228
+ // Label-field suggestion
4229
+ // --------------------------------------------------------------
4230
+ // Decides which of the three label groups (node name, taxonomy, sequence)
4231
+ // should START checked when a tree is loaded. Two rules, judged on the
4232
+ // external nodes' actual label text (supplied by the caller's extractors,
4233
+ // so the decision is made on exactly what would be printed):
4234
+ //
4235
+ // 1. Redundancy: a field whose text is CONTAINED in another checked
4236
+ // field's text (case-insensitively, ignoring spaces and underscores)
4237
+ // for at least 90% of the nodes carrying both adds nothing and starts
4238
+ // unchecked. On mutual containment the earlier field in the priority
4239
+ // order name > taxonomy > sequence is kept.
4240
+ //
4241
+ // 2. Length budget: if the median combined label of the remaining fields
4242
+ // still exceeds 50 characters, only the single most IDENTIFYING field
4243
+ // stays: the one with the highest ratio of distinct values to ALL
4244
+ // external nodes (a label's job is telling nodes apart -- and a field
4245
+ // carried by only a few tips cannot identify the rest, however unique
4246
+ // its few values are). Within a tie (0.05) the priority order wins,
4247
+ // unless a later field is SUBSTANTIALLY more economical (median length
4248
+ // under 60% of the leader's).
4249
+ //
4250
+ // A field with no values at all is never checked. This only decides the
4251
+ // INITIAL state; the caller's UI stays free to override.
4252
+ //
4253
+ // extractors: {name, taxonomy, sequence}, each a function(externalNode)
4254
+ // returning the text that field would contribute to the node's label, or
4255
+ // null / '' when it contributes nothing.
4256
+ /**
4257
+ * Reads bare numeric internal node labels as confidence values -- the
4258
+ * dialect nearly every tree writer emits (RAxML, IQ-TREE, FastTree, MEGA,
4259
+ * BV-BRC) writes branch support as `)100:0.05`, which the New Hampshire
4260
+ * grammar makes an internal node NAME.
4261
+ *
4262
+ * The rule is shared with the desktop Java Archaeopteryx; change it only
4263
+ * together with that implementation.
4264
+ *
4265
+ * mode 'auto' (the default) is ALL-OR-NOTHING: every candidate label must
4266
+ * look like support, so a tree of real clade names is never touched.
4267
+ * mode 'confidence' is PER-NODE and unbounded: every numeric label is
4268
+ * promoted whatever its value, which is how a user forces a mixed tree
4269
+ * (some clade names, some support) or an out-of-range scale. mode 'label'
4270
+ * does nothing -- the labels stay names.
4271
+ *
4272
+ * The ROOT is never promoted in any mode: a confidence belongs to the
4273
+ * branch ABOVE a node and the root has none, so a numeric label there is
4274
+ * almost always the tree's name -- `)99;` would otherwise lose it while
4275
+ * `)MyTree;` kept it.
4276
+ *
4277
+ * Promotion MOVES: the label is cleared as the confidence is added, or the
4278
+ * value would be drawn twice.
4279
+ *
4280
+ * Pure apart from the mutation it is asked to make, and returns the number
4281
+ * of labels promoted so the CALLER can react (the viewer switches its
4282
+ * confidence display on, so support values appear rather than names
4283
+ * silently vanishing). Deliberately does not touch display state itself.
4284
+ *
4285
+ * @param phy - the phylogeny
4286
+ * @param mode - 'auto' | 'confidence' | 'label'
4287
+ * @returns {number} how many internal labels became confidences
4288
+ */
4289
+ forester.promoteInternalLabelsToConfidence = function (phy, mode) {
4290
+ if (mode === 'label') {
4291
+ return 0;
4292
+ }
4293
+ if (mode !== 'confidence') {
4294
+ mode = 'auto';
4295
+ }
4296
+ let root = forester.getTreeRoot(phy);
4297
+ let candidates = [];
4298
+ let anyConfidence = false;
4299
+ forester.preOrderTraversalAll(root, function (n) {
4300
+ if (n === root || !(n.children || n._children)) {
4301
+ return;
4302
+ }
4303
+ if (n.confidences && n.confidences.length > 0) {
4304
+ anyConfidence = true;
4305
+ }
4306
+ if (typeof n.name === 'string' && n.name.length > 0) {
4307
+ candidates.push(n);
4308
+ }
4309
+ });
4310
+ if (candidates.length < 1) {
4311
+ return 0;
4312
+ }
4313
+ let numeric = candidates.map(function (n) {
4314
+ let v = Number(n.name);
4315
+ return (n.name.trim().length > 0 && isFinite(v)) ? v : null;
4316
+ });
4317
+ let chosen;
4318
+ if (mode === 'confidence') {
4319
+ // Per node: promote what parses, leave the rest alone.
4320
+ chosen = candidates.filter(function (n, i) {
4321
+ return numeric[i] !== null;
4322
+ });
4323
+ } else {
4324
+ // A file mixing `)[100]:` and `)95:` leaves the bracketed nodes
4325
+ // with no label at all, so one bare numeric label beside real
4326
+ // confidences is corroborated rather than lonely. Without this a
4327
+ // mixed-dialect tree ships some confidences and one stray name.
4328
+ let need = anyConfidence ? 1 : 2;
4329
+ if (candidates.length < need) {
4330
+ return 0;
4331
+ }
4332
+ if (numeric.some(function (v) {
4333
+ // All-or-nothing: one real clade name and the tree is names.
4334
+ // The range covers every scale in use -- 0-1 posteriors,
4335
+ // 0-100 bootstrap, 0-1000 MrBayes -- and excludes identifiers
4336
+ // such as NCBI taxids that would read as "support 9606".
4337
+ return v === null || v < 0 || v > 1000;
4338
+ })) {
4339
+ return 0;
4340
+ }
4341
+ // Clade numbering is overwhelmingly 1..N, each exactly once. A
4342
+ // real support tree containing every integer 1..N once and nothing
4343
+ // else is essentially impossible at n >= 3, and without this guard
4344
+ // a clade-numbered tree silently becomes "1%, 2%, 3% support".
4345
+ let distinct = new Set(numeric);
4346
+ let min = Math.min.apply(null, numeric);
4347
+ let max = Math.max.apply(null, numeric);
4348
+ if (distinct.size === numeric.length && min === 1 && max === numeric.length) {
4349
+ return 0;
4350
+ }
4351
+ chosen = candidates;
4352
+ }
4353
+ chosen.forEach(function (n) {
4354
+ let v = Number(n.name);
4355
+ // Type deliberately left unset: the value cannot distinguish
4356
+ // bootstrap from posterior from aLRT from TBE, and a wrong type
4357
+ // printed beside a number in a published figure is worse than none.
4358
+ pushConfidence(n, v, '');
4359
+ n.name = '';
4360
+ });
4361
+ return chosen.length;
4362
+ };
4363
+
4364
+ forester.suggestLabelFields = function (root, extractors) {
4365
+ const FIELDS = ['name', 'taxonomy', 'sequence'];
4366
+ const CONTAINMENT_MIN = 0.9;
4367
+ const BUDGET_CHARS = 50;
4368
+ const MIN_PAIRS = 2;
4369
+ const TIE_EPSILON = 0.05;
4370
+ const LENGTH_ADVANTAGE = 0.6;
4371
+ let ext = forester.getAllExternalNodes(root);
4372
+ let norm = function (s) {
4373
+ return s.toLowerCase().replace(/[\s_]+/g, '');
4374
+ };
4375
+ let median = function (arr) {
4376
+ if (arr.length < 1) {
4377
+ return 0;
4378
+ }
4379
+ let a = arr.slice().sort(function (x, y) {
4380
+ return x - y;
4381
+ });
4382
+ let m = a.length >> 1;
4383
+ return (a.length % 2) ? a[m] : (a[m - 1] + a[m]) / 2;
4384
+ };
4385
+ let vals = {};
4386
+ FIELDS.forEach(function (f) {
4387
+ let get = extractors ? extractors[f] : null;
4388
+ vals[f] = ext.map(function (n) {
4389
+ let v = get ? get(n) : null;
4390
+ v = (v === undefined || v === null) ? '' : String(v).trim();
4391
+ return v.length > 0 ? v : null;
4392
+ });
4393
+ });
4394
+ let stats = {};
4395
+ FIELDS.forEach(function (f) {
4396
+ let present = vals[f].filter(Boolean);
4397
+ stats[f] = {
4398
+ count: present.length,
4399
+ medianLength: median(present.map(function (s) {
4400
+ return s.length;
4401
+ })),
4402
+ distinctRatio: (present.length > 0 && ext.length > 0)
4403
+ ? (new Set(present.map(norm)).size / ext.length) : 0
4404
+ };
4405
+ });
4406
+ let checked = {};
4407
+ FIELDS.forEach(function (f) {
4408
+ checked[f] = stats[f].count > 0;
4409
+ });
4410
+
4411
+ let containedFrac = function (inner, outer) {
4412
+ let both = 0;
4413
+ let contained = 0;
4414
+ for (let i = 0; i < ext.length; ++i) {
4415
+ let a = vals[outer][i];
4416
+ let b = vals[inner][i];
4417
+ if (a && b) {
4418
+ ++both;
4419
+ if (norm(a).indexOf(norm(b)) >= 0) {
4420
+ ++contained;
4421
+ }
4422
+ }
4423
+ }
4424
+ return both >= MIN_PAIRS ? contained / both : 0;
4425
+ };
4426
+ for (let i = 0; i < FIELDS.length; ++i) {
4427
+ for (let j = i + 1; j < FIELDS.length; ++j) {
4428
+ let a = FIELDS[i];
4429
+ let b = FIELDS[j];
4430
+ if (!checked[a] || !checked[b]) {
4431
+ continue;
4432
+ }
4433
+ // on mutual containment this still drops b: a is earlier in
4434
+ // the keep-priority order
4435
+ if (containedFrac(b, a) >= CONTAINMENT_MIN) {
4436
+ checked[b] = false;
4437
+ } else if (containedFrac(a, b) >= CONTAINMENT_MIN) {
4438
+ checked[a] = false;
4439
+ }
4440
+ }
4441
+ }
4442
+
4443
+ let combined = [];
4444
+ for (let i = 0; i < ext.length; ++i) {
4445
+ let parts = [];
4446
+ FIELDS.forEach(function (f) {
4447
+ if (checked[f] && vals[f][i]) {
4448
+ parts.push(vals[f][i]);
4449
+ }
4450
+ });
4451
+ if (parts.length > 0) {
4452
+ combined.push(parts.join(' | ').length);
4453
+ }
4454
+ }
4455
+ let medianCombined = median(combined);
4456
+ let kept = FIELDS.filter(function (f) {
4457
+ return checked[f];
4458
+ });
4459
+ if (medianCombined > BUDGET_CHARS && kept.length > 1) {
4460
+ // top distinct-ratio first (a greedy pairwise chain is not
4461
+ // transitive within the tie window); everything within the tie
4462
+ // window then competes by priority order, with the economy rule
4463
+ // as the only override
4464
+ let maxDr = 0;
4465
+ kept.forEach(function (f) {
4466
+ if (stats[f].distinctRatio > maxDr) {
4467
+ maxDr = stats[f].distinctRatio;
4468
+ }
4469
+ });
4470
+ let contenders = kept.filter(function (f) {
4471
+ return stats[f].distinctRatio >= maxDr - TIE_EPSILON;
4472
+ });
4473
+ let best = contenders[0];
4474
+ for (let k = 1; k < contenders.length; ++k) {
4475
+ if (stats[contenders[k]].medianLength < LENGTH_ADVANTAGE * stats[best].medianLength) {
4476
+ best = contenders[k];
4477
+ }
4478
+ }
4479
+ FIELDS.forEach(function (f) {
4480
+ checked[f] = (f === best);
4481
+ });
4482
+ }
4483
+ return {
4484
+ showNodeName: checked.name,
4485
+ showTaxonomy: checked.taxonomy,
4486
+ showSequence: checked.sequence,
4487
+ stats: {fields: stats, medianCombinedLength: medianCombined}
4488
+ };
4489
+ };
4490
+
4491
+
1744
4492
  // --------------------------------------------------------------
1745
4493
  // For exporting
1746
4494
  // --------------------------------------------------------------