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.
- package/LICENSE +160 -499
- package/README.md +1205 -99
- package/archaeopteryx.d.ts +141 -0
- package/archaeopteryx.js +7984 -6469
- package/forester.js +3283 -535
- 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
|
|
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,
|
|
19
|
-
*
|
|
18
|
+
* License along with this library; if not, see
|
|
19
|
+
* <https://www.gnu.org/licenses/>.
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v
|
|
24
|
-
// 2026-04
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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.
|
|
682
|
-
let
|
|
683
|
-
let
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if (
|
|
727
|
-
|
|
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
|
-
|
|
730
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
let
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1613
|
+
forester.collapse = function (node) {
|
|
1614
|
+
if (node.children) {
|
|
1615
|
+
node._children = node.children;
|
|
1616
|
+
node.children = null;
|
|
1133
1617
|
}
|
|
1618
|
+
};
|
|
1134
1619
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
|
1550
|
-
let
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
-
|
|
1620
|
-
|
|
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,():;'"
|
|
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,():;'"
|
|
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
|
// --------------------------------------------------------------
|