archaeopteryx 2.3.2 → 3.0.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 +1109 -98
- package/archaeopteryx.d.ts +141 -0
- package/archaeopteryx.js +6587 -6145
- package/forester.js +2998 -536
- 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.0.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,700 @@
|
|
|
658
584
|
return s / l;
|
|
659
585
|
};
|
|
660
586
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
+
|
|
653
|
+
// ---- display normalization --------------------------------------------
|
|
654
|
+
//
|
|
655
|
+
// Property values are grouped for colouring after a normalization pass,
|
|
656
|
+
// following the desktop's PropertyColorScheme with two deliberate
|
|
657
|
+
// extensions. The desktop's part: values are trimmed, underscores read as
|
|
658
|
+
// spaces, whitespace runs collapse, grouping is case-insensitive, and for
|
|
659
|
+
// refs literally named "host" / "country" a trailing qualifier is cut
|
|
660
|
+
// (everything from the first ';' / ':' -- "USA:CA" groups as "USA",
|
|
661
|
+
// "Homo sapiens; sex: M" as "Homo sapiens"). Our extensions, chosen for a
|
|
662
|
+
// VISUALIZATION tool that should look good on the data it is given: a
|
|
663
|
+
// small dictionary of common-animal synonyms folds scientific names and
|
|
664
|
+
// spelling variants into one capitalized common name ("bovine", "calf",
|
|
665
|
+
// "cattle" and "Bos taurus" are all Cow -- including "Human", where the
|
|
666
|
+
// desktop folds the other way); and a qualifier cut that would leave an
|
|
667
|
+
// unclosed "(" behind is trimmed back to before it, so
|
|
668
|
+
// "Saimiri boliviensis (squirrel monkey; voucher: X)" reads as
|
|
669
|
+
// "Saimiri boliviensis" rather than dangling.
|
|
670
|
+
//
|
|
671
|
+
// Matching is WHOLE-VALUE only (after a trailing parenthetical is tried
|
|
672
|
+
// stripped: "Bos taurus (cattle)" looks up "bos taurus") -- never by
|
|
673
|
+
// substring, so "ferret badger" (a Melogale, not a ferret) and
|
|
674
|
+
// "42-day-old pig" keep their own rows. The dictionary applies to
|
|
675
|
+
// property fields only; taxonomy and sequence elements are curated text
|
|
676
|
+
// and stay verbatim. Node names, exports, search, and the node-data
|
|
677
|
+
// dialog always show the raw values -- this is display grouping, nothing
|
|
678
|
+
// more.
|
|
679
|
+
// CROSS-IMPLEMENTATION CONTRACT with desktop Archaeopteryx: this
|
|
680
|
+
// dictionary is carried verbatim on both sides (as agreed with the
|
|
681
|
+
// desktop's Color-by parity work), so a value groups the same way in
|
|
682
|
+
// both viewers. Extend BOTH implementations together, never just one.
|
|
683
|
+
const VIS_SYNONYMS = {
|
|
684
|
+
'Human': ['humans', 'homo sapiens', 'h. sapiens'],
|
|
685
|
+
'Cow': ['bovine', 'calf', 'cattle', 'bull', 'heifer', 'bos taurus', 'b. taurus'],
|
|
686
|
+
'Chicken': ['broiler chicken', 'broiler', 'hen', 'rooster', 'gallus gallus', 'g. gallus', 'gallus gallus domesticus'],
|
|
687
|
+
'Mouse': ['house mouse', 'murine', 'mus musculus', 'm. musculus'],
|
|
688
|
+
'Rat': ['brown rat', 'norway rat', 'black rat', 'rattus norvegicus', 'r. norvegicus', 'rattus rattus'],
|
|
689
|
+
'Ferret': ['domestic ferret', 'mustela putorius furo', 'mustela furo', 'm. putorius furo'],
|
|
690
|
+
'Guinea pig': ['cavy', 'domestic guinea pig', 'cavia porcellus', 'c. porcellus'],
|
|
691
|
+
'Rhesus monkey': ['rhesus macaque', 'macaca mulatta', 'm. mulatta'],
|
|
692
|
+
'Rabbit': ['european rabbit', 'oryctolagus cuniculus', 'o. cuniculus'],
|
|
693
|
+
'Dog': ['canine', 'canis familiaris', 'canis lupus familiaris', 'c. familiaris'],
|
|
694
|
+
'Cat': ['feline', 'domestic cat', 'felis catus', 'f. catus', 'felis silvestris catus'],
|
|
695
|
+
'Duck': ['mallard', 'mallard duck', 'domestic duck', 'anas platyrhynchos', 'a. platyrhynchos'],
|
|
696
|
+
'Pig': ['swine', 'porcine', 'hog', 'piglet', 'sus scrofa', 's. scrofa', 'sus scrofa domesticus'],
|
|
697
|
+
'Horse': ['equine', 'mare', 'stallion', 'equus caballus', 'e. caballus'],
|
|
698
|
+
'Sheep': ['ovine', 'lamb', 'ewe', 'ovis aries', 'o. aries'],
|
|
699
|
+
'Goat': ['caprine', 'capra hircus', 'c. hircus'],
|
|
700
|
+
'Camel': ['dromedary', 'bactrian camel', 'camelus dromedarius', 'camelus bactrianus', 'c. dromedarius']
|
|
669
701
|
};
|
|
702
|
+
// null prototype: the lookup is keyed by FILE values, and a value named
|
|
703
|
+
// "toString" / "__proto__" must miss, not return an inherited member
|
|
704
|
+
// (that crashed visualizationCandidates -- and so launch -- outright)
|
|
705
|
+
const VIS_SYNONYM_LOOKUP = Object.create(null);
|
|
706
|
+
Object.keys(VIS_SYNONYMS).forEach(function (canon) {
|
|
707
|
+
VIS_SYNONYM_LOOKUP[canon.toLowerCase()] = canon;
|
|
708
|
+
VIS_SYNONYMS[canon].forEach(function (syn) {
|
|
709
|
+
VIS_SYNONYM_LOOKUP[syn] = canon;
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// ';' for host fields, ':' for country fields, null otherwise -- matched
|
|
714
|
+
// on the ref's local name EXACTLY, so host_group and isolation_country
|
|
715
|
+
// keep their full values.
|
|
716
|
+
function visQualifierCut(ref) {
|
|
717
|
+
let i = ref.lastIndexOf(':');
|
|
718
|
+
let local = (i >= 0 ? ref.substring(i + 1) : ref).toLowerCase();
|
|
719
|
+
if (local === 'country') {
|
|
720
|
+
return ':';
|
|
721
|
+
}
|
|
722
|
+
if (local === 'host') {
|
|
723
|
+
return ';';
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
670
727
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
728
|
+
// The display form a raw property value is grouped under (case is
|
|
729
|
+
// preserved here; grouping lowercases it).
|
|
730
|
+
function visDisplayLabel(value, cut) {
|
|
731
|
+
let s = value;
|
|
732
|
+
if (cut) {
|
|
733
|
+
let at = s.indexOf(cut);
|
|
734
|
+
if (at >= 0) {
|
|
735
|
+
s = s.substring(0, at);
|
|
736
|
+
// the cut may land inside a parenthetical; trim back to
|
|
737
|
+
// before the first unclosed '('
|
|
738
|
+
let open = [];
|
|
739
|
+
for (let i = 0; i < s.length; ++i) {
|
|
740
|
+
if (s.charAt(i) === '(') {
|
|
741
|
+
open.push(i);
|
|
742
|
+
} else if (s.charAt(i) === ')') {
|
|
743
|
+
open.pop();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (open.length > 0) {
|
|
747
|
+
s = s.substring(0, open[0]);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
677
750
|
}
|
|
678
|
-
|
|
679
|
-
|
|
751
|
+
// the trailing trim matters: without it, a value that is ALL
|
|
752
|
+
// underscores (e.g. "_") folds to a single space rather than empty,
|
|
753
|
+
// so it survives the caller's empty-string drop; "_cat_" folds to
|
|
754
|
+
// " cat " and never joins the "cat" group. (The leading trim alone
|
|
755
|
+
// only catches whitespace that was already there before folding.)
|
|
756
|
+
s = s.trim().replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
|
757
|
+
let hit = VIS_SYNONYM_LOOKUP[s.toLowerCase()];
|
|
758
|
+
if (!hit) {
|
|
759
|
+
let stripped = s.replace(/\s*\([^()]*\)\s*$/, '');
|
|
760
|
+
if (stripped !== s && stripped.length > 0) {
|
|
761
|
+
hit = VIS_SYNONYM_LOOKUP[stripped.toLowerCase()];
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return hit || s;
|
|
765
|
+
}
|
|
680
766
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
767
|
+
// Fixed candidate slots for the phyloXML elements (properties use their
|
|
768
|
+
// ref). CROSS-IMPLEMENTATION CONTRACT with desktop Archaeopteryx: the
|
|
769
|
+
// ids here (tax:code, seq:name, ...) and the rule that taxonomy/sequence
|
|
770
|
+
// elements stay VERBATIM (never folded/grouped like property values,
|
|
771
|
+
// see visDisplayLabel above) are pinned on both sides. Extend BOTH
|
|
772
|
+
// implementations together, never just one.
|
|
773
|
+
const VIS_ELEMENT_SLOTS = [
|
|
774
|
+
{id: 'tax:code', kind: 'taxonomy', label: 'Taxonomy Code', get: function (t) { return t.code; }},
|
|
775
|
+
{id: 'tax:scientific_name', kind: 'taxonomy', label: 'Scientific Name', get: function (t) { return t.scientific_name; }},
|
|
776
|
+
{id: 'tax:common_name', kind: 'taxonomy', label: 'Common Name', get: function (t) { return t.common_name; }},
|
|
777
|
+
{id: 'seq:name', kind: 'sequence', label: 'Sequence Name', get: function (s) { return s.name; }},
|
|
778
|
+
{id: 'seq:symbol', kind: 'sequence', label: 'Sequence Symbol', get: function (s) { return s.symbol; }},
|
|
779
|
+
{id: 'seq:gene_name', kind: 'sequence', label: 'Gene Name', get: function (s) { return s.gene_name; }}
|
|
780
|
+
];
|
|
781
|
+
|
|
782
|
+
// "geographic_group" reads like a database column; a menu should say
|
|
783
|
+
// "Geographic Group". Underscores become spaces, camelCase is split
|
|
784
|
+
// (FluSeason -> Flu Season, GlobalH1Clade -> Global H1 Clade), and
|
|
785
|
+
// all-lowercase words are capitalized. Words that already carry capitals
|
|
786
|
+
// (PANGO, HA, H5N1) are left exactly as written.
|
|
787
|
+
function prettifyVisLabel(name) {
|
|
788
|
+
return name
|
|
789
|
+
.replace(/_/g, ' ')
|
|
790
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
791
|
+
.replace(/([0-9])([A-Z][a-z])/g, '$1 $2')
|
|
792
|
+
.split(' ')
|
|
793
|
+
.map(function (w) {
|
|
794
|
+
return /^[a-z]/.test(w) ? w.charAt(0).toUpperCase() + w.substring(1) : w;
|
|
795
|
+
})
|
|
796
|
+
.join(' ');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
forester.visualizationCandidates = function (tree) {
|
|
800
|
+
let total = 0;
|
|
801
|
+
let stats = Object.create(null); // id -> {kind, ref, label, nodes, values:Set, multi}; null-proto: ids embed file refs
|
|
802
|
+
|
|
803
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
804
|
+
if (n.children || n._children) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
total++;
|
|
808
|
+
// gather this node's values per candidate id first, so carrying
|
|
809
|
+
// the same ref twice is visible as such
|
|
810
|
+
let perNode = Object.create(null);
|
|
811
|
+
function add(id, kind, ref, label, value) {
|
|
812
|
+
if (value === undefined || value === null) {
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
let v = String(value).trim();
|
|
816
|
+
if (v.length === 0) {
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (!perNode[id]) {
|
|
820
|
+
perNode[id] = {kind: kind, ref: ref, label: label, values: []};
|
|
821
|
+
}
|
|
822
|
+
perNode[id].values.push(v);
|
|
823
|
+
}
|
|
824
|
+
VIS_ELEMENT_SLOTS.forEach(function (slot) {
|
|
825
|
+
let list = slot.kind === 'taxonomy' ? n.taxonomies : n.sequences;
|
|
826
|
+
if (list) {
|
|
827
|
+
for (let i = 0; i < list.length; ++i) {
|
|
828
|
+
add(slot.id, slot.kind, null, slot.label, slot.get(list[i]));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
if (n.properties) {
|
|
833
|
+
for (let i = 0; i < n.properties.length; ++i) {
|
|
834
|
+
let p = n.properties[i];
|
|
835
|
+
if (p.ref && p.applies_to === 'node'
|
|
836
|
+
&& p.ref.indexOf(VIS_EXCLUDED_REF_PREFIX) !== 0) {
|
|
837
|
+
add('prop:' + p.ref, 'property', p.ref, null, p.value);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
Object.keys(perNode).forEach(function (id) {
|
|
842
|
+
let g = perNode[id];
|
|
843
|
+
if (!stats[id]) {
|
|
844
|
+
stats[id] = {kind: g.kind, ref: g.ref, label: g.label,
|
|
845
|
+
cut: g.kind === 'property' ? visQualifierCut(g.ref) : null,
|
|
846
|
+
nodes: 0, keys: Object.create(null), multi: false};
|
|
847
|
+
}
|
|
848
|
+
let s = stats[id];
|
|
849
|
+
s.nodes++;
|
|
850
|
+
if (g.values.length > 1) {
|
|
851
|
+
s.multi = true;
|
|
852
|
+
}
|
|
853
|
+
for (let i = 0; i < g.values.length; ++i) {
|
|
854
|
+
// properties group under their normalized display form;
|
|
855
|
+
// taxonomy / sequence elements are curated text, verbatim
|
|
856
|
+
let display = g.kind === 'property' ? visDisplayLabel(g.values[i], s.cut) : g.values[i];
|
|
857
|
+
if (display.length === 0) {
|
|
858
|
+
continue;
|
|
696
859
|
}
|
|
697
|
-
|
|
698
|
-
|
|
860
|
+
let key = g.kind === 'property' ? display.toLowerCase() : display;
|
|
861
|
+
if (!s.keys[key]) {
|
|
862
|
+
s.keys[key] = {count: 0, spellings: Object.create(null)};
|
|
699
863
|
}
|
|
864
|
+
s.keys[key].count++;
|
|
865
|
+
s.keys[key].spellings[display] = (s.keys[key].spellings[display] || 0) + 1;
|
|
700
866
|
}
|
|
701
867
|
});
|
|
702
|
-
}
|
|
703
|
-
array[0] = min;
|
|
704
|
-
array[1] = max;
|
|
705
|
-
return array;
|
|
706
|
-
};
|
|
868
|
+
});
|
|
707
869
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if (
|
|
727
|
-
|
|
870
|
+
let candidates = [];
|
|
871
|
+
Object.keys(stats).forEach(function (id) {
|
|
872
|
+
let s = stats[id];
|
|
873
|
+
if (s.multi) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
let covered = s.nodes;
|
|
877
|
+
// one legend row per group: the dictionary canonical where one
|
|
878
|
+
// applied (it is then the only recorded spelling), otherwise the
|
|
879
|
+
// most frequent raw spelling (ties alphabetically), capitalized
|
|
880
|
+
let canon = Object.create(null);
|
|
881
|
+
let counts = Object.create(null);
|
|
882
|
+
Object.keys(s.keys).forEach(function (key) {
|
|
883
|
+
let group = s.keys[key];
|
|
884
|
+
let rep = null;
|
|
885
|
+
let best = -1;
|
|
886
|
+
Object.keys(group.spellings).forEach(function (spelling) {
|
|
887
|
+
let n = group.spellings[spelling];
|
|
888
|
+
if (n > best || (n === best && spelling < rep)) {
|
|
889
|
+
rep = spelling;
|
|
890
|
+
best = n;
|
|
728
891
|
}
|
|
729
|
-
|
|
730
|
-
|
|
892
|
+
});
|
|
893
|
+
if (s.kind === 'property') {
|
|
894
|
+
rep = rep.charAt(0).toUpperCase() + rep.substring(1);
|
|
895
|
+
}
|
|
896
|
+
canon[key] = rep;
|
|
897
|
+
counts[rep] = group.count;
|
|
898
|
+
});
|
|
899
|
+
let distinct = Object.keys(canon).length;
|
|
900
|
+
if (covered * VIS_MIN_COVERAGE_DEN < total * VIS_MIN_COVERAGE_NUM) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (distinct < 2) {
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
let values = Object.keys(counts);
|
|
907
|
+
let numeric = values.every(function (v) {
|
|
908
|
+
return Number.isFinite(Number(v));
|
|
909
|
+
});
|
|
910
|
+
let colorMode;
|
|
911
|
+
let switchable = false;
|
|
912
|
+
let wide = false;
|
|
913
|
+
if (numeric) {
|
|
914
|
+
if (distinct * VIS_MAX_NUMERIC_UNIQUE_DEN > covered * VIS_MAX_NUMERIC_UNIQUE_NUM) {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
colorMode = distinct <= VIS_NUMERIC_CATEGORY_MAX ? 'category' : 'range';
|
|
918
|
+
switchable = distinct <= VIS_MAX_COLOR_CATEGORIES;
|
|
919
|
+
values.sort(function (a, b) {
|
|
920
|
+
return Number(a) - Number(b);
|
|
921
|
+
});
|
|
922
|
+
} else {
|
|
923
|
+
if (distinct >= total) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (distinct > VIS_MAX_COLOR_CATEGORIES) {
|
|
927
|
+
if (distinct * VIS_WIDE_REPEAT_DEN > covered * VIS_WIDE_REPEAT_NUM) {
|
|
928
|
+
return;
|
|
731
929
|
}
|
|
930
|
+
wide = true;
|
|
732
931
|
}
|
|
932
|
+
colorMode = 'category';
|
|
933
|
+
values.sort();
|
|
934
|
+
}
|
|
935
|
+
// Rank by how much a visualization would actually show: coverage
|
|
936
|
+
// times balance, where balance is the normalized entropy of the
|
|
937
|
+
// value distribution. A field that is one value on 92% of nodes
|
|
938
|
+
// scores low even with full coverage; an even 4-way split on 90%
|
|
939
|
+
// of nodes scores high.
|
|
940
|
+
let entropy = 0;
|
|
941
|
+
values.forEach(function (v) {
|
|
942
|
+
let p = counts[v] / covered;
|
|
943
|
+
entropy -= p * Math.log(p);
|
|
733
944
|
});
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
945
|
+
let balance = entropy / Math.log(distinct);
|
|
946
|
+
candidates.push({
|
|
947
|
+
id: id,
|
|
948
|
+
kind: s.kind,
|
|
949
|
+
ref: s.ref,
|
|
950
|
+
// property labels drop the namespace prefix and are prettified;
|
|
951
|
+
// a cross-namespace collision is resolved below by restoring
|
|
952
|
+
// the full ref verbatim
|
|
953
|
+
label: s.label || prettifyVisLabel(s.ref.indexOf(':') >= 0 ? s.ref.substring(s.ref.indexOf(':') + 1) : s.ref),
|
|
954
|
+
numeric: numeric,
|
|
955
|
+
coverage: covered,
|
|
956
|
+
total: total,
|
|
957
|
+
values: values,
|
|
958
|
+
counts: counts,
|
|
959
|
+
canon: s.kind === 'property' ? canon : null,
|
|
960
|
+
cut: s.cut,
|
|
961
|
+
score: (covered / total) * balance,
|
|
962
|
+
colorMode: colorMode,
|
|
963
|
+
switchable: switchable,
|
|
964
|
+
wide: wide,
|
|
965
|
+
shape: distinct <= VIS_MAX_SHAPE_CATEGORIES
|
|
966
|
+
});
|
|
967
|
+
});
|
|
743
968
|
|
|
969
|
+
let labelCount = Object.create(null);
|
|
970
|
+
candidates.forEach(function (c) {
|
|
971
|
+
labelCount[c.label] = (labelCount[c.label] || 0) + 1;
|
|
972
|
+
});
|
|
973
|
+
candidates.forEach(function (c) {
|
|
974
|
+
if (labelCount[c.label] > 1 && c.ref) {
|
|
975
|
+
c.label = c.ref;
|
|
976
|
+
}
|
|
977
|
+
});
|
|
744
978
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
979
|
+
// Best first: clean categorical fields, then numeric ranges, then the
|
|
980
|
+
// wide categoricals (offered, never leading) -- within each tier by
|
|
981
|
+
// score, ties alphabetically. The first entry is what the viewer
|
|
982
|
+
// applies on load.
|
|
983
|
+
function tierOf(c) {
|
|
984
|
+
if (c.colorMode === 'category') {
|
|
985
|
+
return c.wide ? 2 : 0;
|
|
986
|
+
}
|
|
987
|
+
return 1;
|
|
988
|
+
}
|
|
989
|
+
candidates.sort(function (a, b) {
|
|
990
|
+
let ta = tierOf(a);
|
|
991
|
+
let tb = tierOf(b);
|
|
992
|
+
if (ta !== tb) {
|
|
993
|
+
return ta - tb;
|
|
994
|
+
}
|
|
995
|
+
if (a.score !== b.score) {
|
|
996
|
+
return b.score - a.score;
|
|
997
|
+
}
|
|
998
|
+
let la = a.label.toLowerCase();
|
|
999
|
+
let lb = b.label.toLowerCase();
|
|
1000
|
+
return la < lb ? -1 : (la > lb ? 1 : (a.id < b.id ? -1 : 1));
|
|
1001
|
+
});
|
|
1002
|
+
return candidates;
|
|
1003
|
+
};
|
|
762
1004
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1005
|
+
// Decides whether a property should REPLACE the node names as the
|
|
1006
|
+
// displayed tip label, and which one. Database exports often name their
|
|
1007
|
+
// tips with identifiers (PATRIC.10334.249.FJ478159..., 11320.305060)
|
|
1008
|
+
// while carrying the readable name in a property such as
|
|
1009
|
+
// BVBRC:genome_name. All of the following must hold, or the answer is
|
|
1010
|
+
// null and the names stand:
|
|
1011
|
+
//
|
|
1012
|
+
// - at least 80% of the named external nodes have identifier-like
|
|
1013
|
+
// names (no spaces, at least one digit) -- readable names are never
|
|
1014
|
+
// overridden;
|
|
1015
|
+
// - the property's local name ends in "name" (genome_name,
|
|
1016
|
+
// sample_name, ...): only fields that say they are names qualify;
|
|
1017
|
+
// - it covers at least 90% of the external nodes, is mostly distinct
|
|
1018
|
+
// (>= 50%), and is mostly wordy (>= 50% of values contain a space)
|
|
1019
|
+
// -- which is what separates genome_name from strain codes.
|
|
1020
|
+
//
|
|
1021
|
+
// Of several qualifiers, the best-covered wins, ties alphabetically.
|
|
1022
|
+
forester.nodeLabelProperty = function (tree) {
|
|
1023
|
+
let total = 0;
|
|
1024
|
+
let named = 0;
|
|
1025
|
+
let idLike = 0;
|
|
1026
|
+
let refs = {}; // ref -> {covered, values:Set, wordy}
|
|
1027
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
1028
|
+
if (n.children || n._children) {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
total++;
|
|
1032
|
+
if (n.name && String(n.name).trim().length > 0) {
|
|
1033
|
+
named++;
|
|
1034
|
+
let name = String(n.name).trim();
|
|
1035
|
+
if (!/\s/.test(name) && /\d/.test(name)) {
|
|
1036
|
+
idLike++;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (n.properties) {
|
|
1040
|
+
let seen = {};
|
|
1041
|
+
for (let i = 0; i < n.properties.length; ++i) {
|
|
1042
|
+
let p = n.properties[i];
|
|
1043
|
+
if (!p.ref || p.applies_to !== 'node' || seen[p.ref]
|
|
1044
|
+
|| p.ref.indexOf(VIS_EXCLUDED_REF_PREFIX) === 0) {
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
let local = p.ref.indexOf(':') >= 0 ? p.ref.substring(p.ref.indexOf(':') + 1) : p.ref;
|
|
1048
|
+
if (!/name$/i.test(local)) {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
let v = (p.value === undefined || p.value === null) ? '' : String(p.value).trim();
|
|
1052
|
+
if (v.length === 0) {
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
seen[p.ref] = true;
|
|
1056
|
+
if (!refs[p.ref]) {
|
|
1057
|
+
refs[p.ref] = {covered: 0, values: new Set(), wordy: 0};
|
|
1058
|
+
}
|
|
1059
|
+
refs[p.ref].covered++;
|
|
1060
|
+
refs[p.ref].values.add(v);
|
|
1061
|
+
if (v.indexOf(' ') >= 0) {
|
|
1062
|
+
refs[p.ref].wordy++;
|
|
775
1063
|
}
|
|
776
1064
|
}
|
|
777
1065
|
}
|
|
778
1066
|
});
|
|
779
|
-
|
|
1067
|
+
if (named === 0 || idLike * 10 < named * 8) {
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
let best = null;
|
|
1071
|
+
Object.keys(refs).sort().forEach(function (ref) {
|
|
1072
|
+
let r = refs[ref];
|
|
1073
|
+
if (r.covered * 10 < total * 9) {
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (r.values.size * 2 < r.covered) {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (r.wordy * 2 < r.covered) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (!best || r.covered > refs[best].covered) {
|
|
1083
|
+
best = ref;
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
return best;
|
|
780
1087
|
};
|
|
781
1088
|
|
|
1089
|
+
// The desktop's reserved "style:" namespace, read back as the rendering
|
|
1090
|
+
// instruction it is (NodeVisualData on the desktop): per-node font colour,
|
|
1091
|
+
// node colour, node shape, font size and font style. The rest of the
|
|
1092
|
+
// vocabulary (font name, node_size, node_transparency, node_fill_type) is
|
|
1093
|
+
// not honoured by this viewer yet. Returns null when the node carries
|
|
1094
|
+
// none of the five.
|
|
1095
|
+
forester.nodeVisualStyle = function (node) {
|
|
1096
|
+
if (!node.properties) {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
let style = null;
|
|
1100
|
+
function put(key, value) {
|
|
1101
|
+
if (style === null) {
|
|
1102
|
+
style = {};
|
|
1103
|
+
}
|
|
1104
|
+
style[key] = value;
|
|
1105
|
+
}
|
|
1106
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
1107
|
+
let p = node.properties[i];
|
|
1108
|
+
if (!p.ref || p.applies_to !== 'node' || p.value === undefined || p.value === null) {
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
let v = String(p.value).trim();
|
|
1112
|
+
if (v.length === 0) {
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (p.ref === 'style:font_color') {
|
|
1116
|
+
put('fontColor', v);
|
|
1117
|
+
} else if (p.ref === 'style:node_color') {
|
|
1118
|
+
put('nodeColor', v);
|
|
1119
|
+
} else if (p.ref === 'style:node_shape') {
|
|
1120
|
+
// the desktop's shape names; rectangle renders as our square
|
|
1121
|
+
if (v === 'rectangle') {
|
|
1122
|
+
put('shape', 'square');
|
|
1123
|
+
} else if (v === 'circle' || v === 'diamond') {
|
|
1124
|
+
put('shape', v);
|
|
1125
|
+
}
|
|
1126
|
+
} else if (p.ref === 'style:font_size') {
|
|
1127
|
+
let n = Number(v);
|
|
1128
|
+
if (Number.isFinite(n) && n > 0) {
|
|
1129
|
+
put('fontSize', Math.min(48, Math.max(4, n)));
|
|
1130
|
+
}
|
|
1131
|
+
} else if (p.ref === 'style:font_style') {
|
|
1132
|
+
if (v === 'italic' || v === 'bold' || v === 'bold_italic' || v === 'plain') {
|
|
1133
|
+
put('fontStyle', v);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return style;
|
|
1138
|
+
};
|
|
782
1139
|
|
|
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
|
-
}
|
|
1140
|
+
// The boring part of every tip name. When the displayed names all share
|
|
1141
|
+
// a long prefix ("Influenza A virus ..."), a shortener that keeps the
|
|
1142
|
+
// first characters keeps exactly the characters that carry no
|
|
1143
|
+
// information. This returns the longest common prefix of the displayed
|
|
1144
|
+
// external names -- the label property's value where one is in effect,
|
|
1145
|
+
// the node name otherwise -- cut back to the last separator so no word
|
|
1146
|
+
// is split, and only when it is long enough to matter (>= 6 characters).
|
|
1147
|
+
// The Short Names rendering strips it before truncating, so what
|
|
1148
|
+
// survives is the part that tells the tips apart. The comparison is
|
|
1149
|
+
// case-insensitive -- "Influenza A virus" and "Influenza A Virus" are
|
|
1150
|
+
// the same boring prefix -- so callers must strip by LENGTH, comparing
|
|
1151
|
+
// case-insensitively, not by exact match.
|
|
1152
|
+
forester.commonNamePrefix = function (tree, labelProperty) {
|
|
1153
|
+
let names = [];
|
|
1154
|
+
let slot = labelProperty ? {kind: 'property', ref: labelProperty} : null;
|
|
1155
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
1156
|
+
if (n.children || n._children) {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
let name = slot ? forester.visualizationNodeValue(n, slot) : null;
|
|
1160
|
+
if (name === null && n.name !== undefined && n.name !== null) {
|
|
1161
|
+
let s = String(n.name).trim();
|
|
1162
|
+
if (s.length > 0) {
|
|
1163
|
+
name = s;
|
|
817
1164
|
}
|
|
818
1165
|
}
|
|
1166
|
+
if (name !== null) {
|
|
1167
|
+
names.push(name);
|
|
1168
|
+
}
|
|
819
1169
|
});
|
|
820
|
-
|
|
1170
|
+
if (names.length < 2) {
|
|
1171
|
+
return '';
|
|
1172
|
+
}
|
|
1173
|
+
let prefix = names[0];
|
|
1174
|
+
for (let k = 1; k < names.length && prefix.length > 0; ++k) {
|
|
1175
|
+
let a = prefix.toLowerCase();
|
|
1176
|
+
let b = names[k].toLowerCase();
|
|
1177
|
+
let max = Math.min(a.length, b.length);
|
|
1178
|
+
let i = 0;
|
|
1179
|
+
while (i < max && a.charCodeAt(i) === b.charCodeAt(i)) {
|
|
1180
|
+
++i;
|
|
1181
|
+
}
|
|
1182
|
+
if (i < prefix.length) {
|
|
1183
|
+
prefix = prefix.substring(0, i);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (prefix.length === 0) {
|
|
1187
|
+
return '';
|
|
1188
|
+
}
|
|
1189
|
+
// Trim back to the last separator ONLY when the prefix actually
|
|
1190
|
+
// splits a word -- "ABC_ho" against "ABC_house"/"ABC_horse" does,
|
|
1191
|
+
// "Influenza A virus" against "...virus A/x" and "...virus(A/y)"
|
|
1192
|
+
// does not, whatever character each name continues with.
|
|
1193
|
+
let alnum = /[A-Za-z0-9]/;
|
|
1194
|
+
let splitsWord = alnum.test(prefix.charAt(prefix.length - 1))
|
|
1195
|
+
&& names.some(function (name) {
|
|
1196
|
+
return name.length > prefix.length && alnum.test(name.charAt(prefix.length));
|
|
1197
|
+
});
|
|
1198
|
+
if (splitsWord) {
|
|
1199
|
+
let cut = -1;
|
|
1200
|
+
for (let i = prefix.length - 1; i >= 0; --i) {
|
|
1201
|
+
if (' /|_.-:'.indexOf(prefix.charAt(i)) >= 0) {
|
|
1202
|
+
cut = i;
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
prefix = cut >= 0 ? prefix.substring(0, cut + 1) : '';
|
|
1207
|
+
}
|
|
1208
|
+
return prefix.length >= 6 ? prefix : '';
|
|
821
1209
|
};
|
|
822
1210
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1211
|
+
// Reads a node's value for one candidate, exactly as the classifier read
|
|
1212
|
+
// it when it built the candidate -- the two must never drift, or a node
|
|
1213
|
+
// could carry a value that maps to no colour. Returns the trimmed value,
|
|
1214
|
+
// or null when the node has none (the node then keeps the default look).
|
|
1215
|
+
// Multi-valued refs never become candidates, so "the first value" is
|
|
1216
|
+
// "the only value".
|
|
1217
|
+
forester.visualizationNodeValue = function (node, candidate) {
|
|
1218
|
+
function clean(v) {
|
|
1219
|
+
if (v === undefined || v === null) {
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
let s = String(v).trim();
|
|
1223
|
+
return s.length > 0 ? s : null;
|
|
1224
|
+
}
|
|
1225
|
+
if (candidate.kind === 'property') {
|
|
1226
|
+
if (node.properties) {
|
|
1227
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
1228
|
+
let p = node.properties[i];
|
|
1229
|
+
if (p.ref === candidate.ref && p.applies_to === 'node') {
|
|
1230
|
+
let v = clean(p.value);
|
|
1231
|
+
if (v !== null) {
|
|
1232
|
+
// a classifier-built candidate folds the value the
|
|
1233
|
+
// same way its groups were built; a bare
|
|
1234
|
+
// {kind, ref} probe (labels, prefixes) reads raw
|
|
1235
|
+
if (candidate.canon) {
|
|
1236
|
+
let display = visDisplayLabel(v, candidate.cut || null);
|
|
1237
|
+
return candidate.canon[display.toLowerCase()] || display;
|
|
1238
|
+
}
|
|
1239
|
+
return v;
|
|
834
1240
|
}
|
|
835
1241
|
}
|
|
836
1242
|
}
|
|
837
1243
|
}
|
|
838
|
-
|
|
839
|
-
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
let list = candidate.kind === 'taxonomy' ? node.taxonomies : node.sequences;
|
|
1247
|
+
if (!list) {
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
for (let i = 0; i < VIS_ELEMENT_SLOTS.length; ++i) {
|
|
1251
|
+
if (VIS_ELEMENT_SLOTS[i].id === candidate.id) {
|
|
1252
|
+
for (let j = 0; j < list.length; ++j) {
|
|
1253
|
+
let v = clean(VIS_ELEMENT_SLOTS[i].get(list[j]));
|
|
1254
|
+
if (v !== null) {
|
|
1255
|
+
return v;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
return null;
|
|
840
1262
|
};
|
|
841
1263
|
|
|
842
|
-
|
|
843
|
-
|
|
1264
|
+
forester.collectPropertyRefs = function (phy, appliesTo, externalOnly) {
|
|
1265
|
+
let propertyRefs = new Set();
|
|
844
1266
|
forester.preOrderTraversalAll(phy, function (n) {
|
|
1267
|
+
|
|
845
1268
|
if (!externalOnly || externalOnly !== true || (!n.children && !n._children)) {
|
|
846
1269
|
if (n.properties && n.properties.length > 0) {
|
|
847
1270
|
let propertiesLength = n.properties.length;
|
|
848
1271
|
for (let i = 0; i < propertiesLength; ++i) {
|
|
849
1272
|
let property = n.properties[i];
|
|
850
1273
|
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
|
-
}
|
|
1274
|
+
propertyRefs.add(property.ref);
|
|
866
1275
|
}
|
|
867
1276
|
}
|
|
868
1277
|
}
|
|
869
1278
|
}
|
|
870
1279
|
});
|
|
1280
|
+
return propertyRefs;
|
|
871
1281
|
};
|
|
872
1282
|
|
|
873
1283
|
forester.collectBasicTreeProperties = function (tree) {
|
|
@@ -877,18 +1287,30 @@
|
|
|
877
1287
|
properties.longestNodeName = 0;
|
|
878
1288
|
properties.branchLengths = false;
|
|
879
1289
|
properties.confidences = false;
|
|
1290
|
+
// the largest confidence value seen -- how a caller tells a
|
|
1291
|
+
// posterior-probability tree (max <= 1) from a bootstrap tree
|
|
1292
|
+
properties.maxConfidence = 0;
|
|
880
1293
|
properties.nodeEvents = false;
|
|
1294
|
+
properties.branchColors = false;
|
|
881
1295
|
properties.sequences = false;
|
|
882
1296
|
properties.taxonomies = false;
|
|
883
1297
|
properties.alignedMolSeqs = true;
|
|
884
1298
|
properties.maxMolSeqLength = 0;
|
|
885
1299
|
properties.externalNodesCount = 0;
|
|
886
|
-
properties.
|
|
1300
|
+
properties.nodeCount = 0;
|
|
1301
|
+
// How many of the tree's branches actually carry a positive length.
|
|
1302
|
+
// Whether a tree is worth drawing to scale is a question about the
|
|
1303
|
+
// majority of its branches, not about whether any branch has a length.
|
|
1304
|
+
properties.branchesWithPositiveLength = 0;
|
|
887
1305
|
properties.averageBranchLength = 0;
|
|
888
1306
|
let bl_counter = 0;
|
|
889
1307
|
let bl_sum = 0;
|
|
890
|
-
|
|
891
|
-
|
|
1308
|
+
// Counting the super-root would add a node and a branch that do not
|
|
1309
|
+
// exist -- skewing the branch-length fraction the viewer uses to choose
|
|
1310
|
+
// between a phylogram and a cladogram -- and from phyloXML would take
|
|
1311
|
+
// the tree's own name for the longest node name.
|
|
1312
|
+
forester.preOrderTraversalAll(realRootOf(tree), function (n) {
|
|
1313
|
+
properties.nodeCount += 1;
|
|
892
1314
|
if (n.name && n.name.length > 0) {
|
|
893
1315
|
properties.nodeNames = true;
|
|
894
1316
|
if (n.name.length > properties.longestNodeName) {
|
|
@@ -909,6 +1331,9 @@
|
|
|
909
1331
|
if (n.events) {
|
|
910
1332
|
properties.nodeEvents = true;
|
|
911
1333
|
}
|
|
1334
|
+
if (n.color) {
|
|
1335
|
+
properties.branchColors = true;
|
|
1336
|
+
}
|
|
912
1337
|
if (n.sequences && n.sequences.length > 0) {
|
|
913
1338
|
properties.sequences = true;
|
|
914
1339
|
|
|
@@ -922,8 +1347,6 @@
|
|
|
922
1347
|
}
|
|
923
1348
|
if (!s.mol_seq.is_aligned) {
|
|
924
1349
|
properties.alignedMolSeqs = false;
|
|
925
|
-
} else {
|
|
926
|
-
molSeqs.push(s.mol_seq.value);
|
|
927
1350
|
}
|
|
928
1351
|
}
|
|
929
1352
|
}
|
|
@@ -936,6 +1359,12 @@
|
|
|
936
1359
|
}
|
|
937
1360
|
if (n.confidences && n.confidences.length > 0) {
|
|
938
1361
|
properties.confidences = true;
|
|
1362
|
+
for (let ci = 0; ci < n.confidences.length; ++ci) {
|
|
1363
|
+
let cv = n.confidences[ci].value;
|
|
1364
|
+
if (typeof cv === 'number' && isFinite(cv) && cv > properties.maxConfidence) {
|
|
1365
|
+
properties.maxConfidence = cv;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
939
1368
|
}
|
|
940
1369
|
if (n.properties && n.properties.length > 0) {
|
|
941
1370
|
let l = n.properties.length;
|
|
@@ -948,29 +1377,7 @@
|
|
|
948
1377
|
|
|
949
1378
|
});
|
|
950
1379
|
|
|
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
|
-
}
|
|
1380
|
+
properties.branchesWithPositiveLength = bl_counter;
|
|
974
1381
|
|
|
975
1382
|
if (bl_counter > 0) {
|
|
976
1383
|
properties.averageBranchLength = bl_sum / bl_counter;
|
|
@@ -1016,23 +1423,52 @@
|
|
|
1016
1423
|
return nodes;
|
|
1017
1424
|
};
|
|
1018
1425
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1426
|
+
// Ladderize: at every node, order the VISIBLE children (n.children; a
|
|
1427
|
+
// collapsed node's hidden _children are left untouched) by clade size --
|
|
1428
|
+
// largest first when largestFirst, smallest first when not. Works at ANY
|
|
1429
|
+
// child count, not just 2, so a polytomy (common on a phylodynamic tree,
|
|
1430
|
+
// e.g. an Auspice build, where every internal node may carry 3+ children)
|
|
1431
|
+
// is sorted exactly like a bifurcation. The sort is STABLE (ties keep
|
|
1432
|
+
// their existing relative order), so a node that already reads correctly
|
|
1433
|
+
// is never needlessly disturbed. Mutates the tree in place; returns
|
|
1434
|
+
// whether anything actually changed.
|
|
1435
|
+
forester.ladderize = function (node, largestFirst) {
|
|
1436
|
+
let changed = false;
|
|
1437
|
+
ord(node);
|
|
1438
|
+
return changed;
|
|
1439
|
+
|
|
1440
|
+
function ord(n) {
|
|
1441
|
+
if (!n.children) {
|
|
1442
|
+
return;
|
|
1033
1443
|
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1444
|
+
let c = n.children;
|
|
1445
|
+
let l = c.length;
|
|
1446
|
+
if (l >= 2) {
|
|
1447
|
+
let counts = c.map(function (child) {
|
|
1448
|
+
return forester.calcSumOfAllExternalDescendants(child);
|
|
1449
|
+
});
|
|
1450
|
+
let order = c.map(function (child, i) {
|
|
1451
|
+
return i;
|
|
1452
|
+
});
|
|
1453
|
+
order.sort(function (i, j) {
|
|
1454
|
+
if (counts[i] === counts[j]) {
|
|
1455
|
+
return i - j;
|
|
1456
|
+
}
|
|
1457
|
+
return largestFirst ? (counts[j] - counts[i]) : (counts[i] - counts[j]);
|
|
1458
|
+
});
|
|
1459
|
+
if (order.some(function (idx, i) {
|
|
1460
|
+
return idx !== i;
|
|
1461
|
+
})) {
|
|
1462
|
+
changed = true;
|
|
1463
|
+
n.children = order.map(function (idx) {
|
|
1464
|
+
return c[idx];
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
1469
|
+
ord(n.children[i]);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1036
1472
|
};
|
|
1037
1473
|
|
|
1038
1474
|
forester.getAllExternalNodes = function (node) {
|
|
@@ -1126,66 +1562,19 @@
|
|
|
1126
1562
|
});
|
|
1127
1563
|
};
|
|
1128
1564
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
function collapseToBranchLengthHelper(n, branchLength) {
|
|
1136
|
-
if (!(n.children || n._children)) {
|
|
1137
|
-
return;
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
if (!n.max) {
|
|
1141
|
-
n.max = forester.calcMaxBranchLength(n);
|
|
1142
|
-
}
|
|
1143
|
-
let max = n.max;
|
|
1144
|
-
if (max < branchLength) {
|
|
1145
|
-
forester.collapse(n);
|
|
1146
|
-
} else {
|
|
1147
|
-
forester.unCollapse(n);
|
|
1148
|
-
for (let i = n.children.length - 1; i >= 0; i--) {
|
|
1149
|
-
collapseToBranchLengthHelper(n.children[i], branchLength);
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1565
|
+
forester.collapse = function (node) {
|
|
1566
|
+
if (node.children) {
|
|
1567
|
+
node._children = node.children;
|
|
1568
|
+
node.children = null;
|
|
1152
1569
|
}
|
|
1153
1570
|
};
|
|
1154
1571
|
|
|
1155
|
-
forester.
|
|
1156
|
-
if (
|
|
1157
|
-
|
|
1572
|
+
forester.unCollapse = function (node) {
|
|
1573
|
+
if (node._children) {
|
|
1574
|
+
node.children = node._children;
|
|
1575
|
+
node._children = null;
|
|
1158
1576
|
}
|
|
1159
|
-
|
|
1160
|
-
function collapseToDepthHelper(n, d, depth) {
|
|
1161
|
-
if (!n.children && !n._children) {
|
|
1162
|
-
return;
|
|
1163
|
-
}
|
|
1164
|
-
if (d >= depth) {
|
|
1165
|
-
forester.collapse(n);
|
|
1166
|
-
} else {
|
|
1167
|
-
forester.unCollapse(n);
|
|
1168
|
-
++d;
|
|
1169
|
-
for (let i = n.children.length - 1; i >= 0; i--) {
|
|
1170
|
-
collapseToDepthHelper(n.children[i], d, depth);
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
};
|
|
1175
|
-
|
|
1176
|
-
forester.collapse = function (node) {
|
|
1177
|
-
if (node.children) {
|
|
1178
|
-
node._children = node.children;
|
|
1179
|
-
node.children = null;
|
|
1180
|
-
}
|
|
1181
|
-
};
|
|
1182
|
-
|
|
1183
|
-
forester.unCollapse = function (node) {
|
|
1184
|
-
if (node._children) {
|
|
1185
|
-
node.children = node._children;
|
|
1186
|
-
node._children = null;
|
|
1187
|
-
}
|
|
1188
|
-
};
|
|
1577
|
+
};
|
|
1189
1578
|
|
|
1190
1579
|
/**
|
|
1191
1580
|
* To parse a New Hampshire (Newick) formatted tree.
|
|
@@ -1197,6 +1586,325 @@
|
|
|
1197
1586
|
* @param confidenceValuesAsInternalNames - Set to true if confidence values are represented by internal names (default: false).
|
|
1198
1587
|
* @returns {{}} - A phylogenetic tree object.
|
|
1199
1588
|
*/
|
|
1589
|
+
// ---------------------------------------------------------------
|
|
1590
|
+
// Extended Newick annotations: BEAST-style [&key=value,...] and
|
|
1591
|
+
// NHX [&&NHX:tag=value:...]
|
|
1592
|
+
// ---------------------------------------------------------------
|
|
1593
|
+
//
|
|
1594
|
+
// ALWAYS parsed (the desktop keeps this behind a
|
|
1595
|
+
// setParseBeastStyleExtendedTags option; here the blobs were simply
|
|
1596
|
+
// discarded before, so ingesting them can regress nothing). Ported from
|
|
1597
|
+
// the desktop's BeastAnnotationParser + the NHXParser tag loop.
|
|
1598
|
+
|
|
1599
|
+
// Pre-tokenization pass: pull every [&...] annotation out of the Newick
|
|
1600
|
+
// string and leave a [@N] marker in its place -- the blob's commas,
|
|
1601
|
+
// colons and quotes must never reach the Newick tokenizer. A bracket NOT
|
|
1602
|
+
// starting with '&' (a [95] confidence) is left untouched, as is any
|
|
1603
|
+
// bracket inside a quoted label. Quotes and nested brackets inside an
|
|
1604
|
+
// annotation are honoured when finding its end.
|
|
1605
|
+
function extractBracketAnnotations(str) {
|
|
1606
|
+
if (str.indexOf('[') < 0) {
|
|
1607
|
+
return {text: str, blobs: []};
|
|
1608
|
+
}
|
|
1609
|
+
let out = '';
|
|
1610
|
+
let blobs = [];
|
|
1611
|
+
let inSq = false;
|
|
1612
|
+
let inDq = false;
|
|
1613
|
+
for (let i = 0; i < str.length; ++i) {
|
|
1614
|
+
let c = str.charAt(i);
|
|
1615
|
+
if (inSq || inDq) {
|
|
1616
|
+
out += c;
|
|
1617
|
+
if ((inSq && c === "'") || (inDq && c === '"')) {
|
|
1618
|
+
inSq = false;
|
|
1619
|
+
inDq = false;
|
|
1620
|
+
}
|
|
1621
|
+
} else if (c === "'") {
|
|
1622
|
+
inSq = true;
|
|
1623
|
+
out += c;
|
|
1624
|
+
} else if (c === '"') {
|
|
1625
|
+
inDq = true;
|
|
1626
|
+
out += c;
|
|
1627
|
+
} else if (c === '[') {
|
|
1628
|
+
let j = i + 1;
|
|
1629
|
+
let depth = 1;
|
|
1630
|
+
let q = null;
|
|
1631
|
+
while (j < str.length && depth > 0) {
|
|
1632
|
+
let cj = str.charAt(j);
|
|
1633
|
+
if (q) {
|
|
1634
|
+
if (cj === q) {
|
|
1635
|
+
q = null;
|
|
1636
|
+
}
|
|
1637
|
+
} else if (cj === "'" || cj === '"') {
|
|
1638
|
+
q = cj;
|
|
1639
|
+
} else if (cj === '[') {
|
|
1640
|
+
++depth;
|
|
1641
|
+
} else if (cj === ']') {
|
|
1642
|
+
--depth;
|
|
1643
|
+
}
|
|
1644
|
+
if (depth > 0) {
|
|
1645
|
+
++j;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
let content = str.substring(i + 1, j);
|
|
1649
|
+
if (/^\s*&/.test(content)) {
|
|
1650
|
+
out += '[@' + blobs.length + ']';
|
|
1651
|
+
blobs.push(content.trim());
|
|
1652
|
+
} else {
|
|
1653
|
+
out += str.substring(i, Math.min(j + 1, str.length));
|
|
1654
|
+
}
|
|
1655
|
+
i = j;
|
|
1656
|
+
} else {
|
|
1657
|
+
out += c;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return {text: out, blobs: blobs};
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function pushConfidence(node, value, type, stddev) {
|
|
1664
|
+
if (!node.confidences) {
|
|
1665
|
+
node.confidences = [];
|
|
1666
|
+
}
|
|
1667
|
+
let c = {type: type, value: value};
|
|
1668
|
+
if (stddev !== undefined && stddev !== null) {
|
|
1669
|
+
c.stddev = stddev;
|
|
1670
|
+
}
|
|
1671
|
+
node.confidences.push(c);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function nodeTaxonomy0(node) {
|
|
1675
|
+
if (!node.taxonomies) {
|
|
1676
|
+
node.taxonomies = [{}];
|
|
1677
|
+
}
|
|
1678
|
+
return node.taxonomies[0];
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function nodeSequence0(node) {
|
|
1682
|
+
if (!node.sequences) {
|
|
1683
|
+
node.sequences = [{}];
|
|
1684
|
+
}
|
|
1685
|
+
return node.sequences[0];
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Split on TOP-LEVEL commas only: a comma inside {...}/[...] sets or
|
|
1689
|
+
// inside quotes is data, not a separator (height_95%_HPD={1.4,1.5} must
|
|
1690
|
+
// stay one token).
|
|
1691
|
+
function splitTopLevelCommas(s) {
|
|
1692
|
+
let out = [];
|
|
1693
|
+
let depth = 0;
|
|
1694
|
+
let q = null;
|
|
1695
|
+
let cur = '';
|
|
1696
|
+
for (let i = 0; i < s.length; ++i) {
|
|
1697
|
+
let c = s.charAt(i);
|
|
1698
|
+
if (q) {
|
|
1699
|
+
if (c === q) {
|
|
1700
|
+
q = null;
|
|
1701
|
+
}
|
|
1702
|
+
cur += c;
|
|
1703
|
+
} else if (c === "'" || c === '"') {
|
|
1704
|
+
q = c;
|
|
1705
|
+
cur += c;
|
|
1706
|
+
} else if (c === '{' || c === '[') {
|
|
1707
|
+
++depth;
|
|
1708
|
+
cur += c;
|
|
1709
|
+
} else if (c === '}' || c === ']') {
|
|
1710
|
+
if (depth > 0) {
|
|
1711
|
+
--depth;
|
|
1712
|
+
}
|
|
1713
|
+
cur += c;
|
|
1714
|
+
} else if (c === ',' && depth === 0) {
|
|
1715
|
+
out.push(cur);
|
|
1716
|
+
cur = '';
|
|
1717
|
+
} else {
|
|
1718
|
+
cur += c;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
if (cur.length > 0) {
|
|
1722
|
+
out.push(cur);
|
|
1723
|
+
}
|
|
1724
|
+
return out;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function parseBeastNumber(v) {
|
|
1728
|
+
let d = parseFloat(v);
|
|
1729
|
+
return (isFinite(d) && isFinite(Number(v))) ? d : null;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// A two-value BEAST set {lo,hi} (or [lo,hi]) as [lo,hi] numbers, or null.
|
|
1733
|
+
function parseBeastInterval(v) {
|
|
1734
|
+
let s = v.trim();
|
|
1735
|
+
if (s.length < 3 || (s.charAt(0) !== '{' && s.charAt(0) !== '[')) {
|
|
1736
|
+
return null;
|
|
1737
|
+
}
|
|
1738
|
+
let parts = splitTopLevelCommas(s.substring(1, s.length - 1));
|
|
1739
|
+
if (parts.length !== 2) {
|
|
1740
|
+
return null;
|
|
1741
|
+
}
|
|
1742
|
+
let lo = parseBeastNumber(parts[0].trim());
|
|
1743
|
+
let hi = parseBeastNumber(parts[1].trim());
|
|
1744
|
+
return (lo !== null && hi !== null) ? [lo, hi] : null;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function stripValueQuotes(v) {
|
|
1748
|
+
if (v.length >= 2
|
|
1749
|
+
&& ((v.charAt(0) === '"' && v.charAt(v.length - 1) === '"')
|
|
1750
|
+
|| (v.charAt(0) === "'" && v.charAt(v.length - 1) === "'"))) {
|
|
1751
|
+
return v.substring(1, v.length - 1);
|
|
1752
|
+
}
|
|
1753
|
+
return v;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// A property-ref-safe rendering of a BEAST key: keep letters/digits,
|
|
1757
|
+
// collapse every other run to one underscore, drop a trailing one
|
|
1758
|
+
// (rate_95%_HPD -> a clean beast:rate_95_HPD ref).
|
|
1759
|
+
function beastRefKey(key) {
|
|
1760
|
+
return key.replace(/[^A-Za-z0-9]+/g, '_').replace(/_$/, '');
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// A BEAST / BEAST X / TreeAnnotator / FigTree / MrBayes [&...] blob onto
|
|
1764
|
+
// one node, each field mapped to the phyloXML structure the existing
|
|
1765
|
+
// display features consume:
|
|
1766
|
+
// - posterior -> a confidence of type "posterior"; MrBayes prob (+
|
|
1767
|
+
// prob_stddev) -> "posterior probability"; bootstrap -> "bootstrap";
|
|
1768
|
+
// - node age height/height_mean/height_median + height_95%_HPD (or
|
|
1769
|
+
// height_range) + date -> node.date value/min/max/desc (the node-age
|
|
1770
|
+
// HPD bars draw the interval);
|
|
1771
|
+
// - FigTree !color=#rrggbb -> the branch color;
|
|
1772
|
+
// - every other field (rate, length_*, traits, location, ...) -> a
|
|
1773
|
+
// beast:<key> node property (numeric -> xsd:decimal, so Color-by
|
|
1774
|
+
// picks it up).
|
|
1775
|
+
// The branch length lives on the Newick ":length" and is left untouched.
|
|
1776
|
+
// A malformed field is skipped, never aborting the parse.
|
|
1777
|
+
function applyBeastAnnotations(node, blob) {
|
|
1778
|
+
let heightMedian = null;
|
|
1779
|
+
let heightMean = null;
|
|
1780
|
+
let height = null;
|
|
1781
|
+
let hpd = null;
|
|
1782
|
+
let range = null;
|
|
1783
|
+
let dateDesc = null;
|
|
1784
|
+
let prob = null;
|
|
1785
|
+
let probSd = null;
|
|
1786
|
+
splitTopLevelCommas(blob).forEach(function (token) {
|
|
1787
|
+
let eq = token.indexOf('=');
|
|
1788
|
+
if (eq <= 0) {
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
let key = token.substring(0, eq).trim();
|
|
1792
|
+
let value = stripValueQuotes(token.substring(eq + 1).trim());
|
|
1793
|
+
if (key.length === 0 || value.length === 0) {
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
let kl = key.toLowerCase();
|
|
1797
|
+
if (kl === 'posterior') {
|
|
1798
|
+
let d = parseBeastNumber(value);
|
|
1799
|
+
if (d !== null) {
|
|
1800
|
+
pushConfidence(node, d, 'posterior');
|
|
1801
|
+
}
|
|
1802
|
+
} else if (kl === 'prob') {
|
|
1803
|
+
prob = parseBeastNumber(value);
|
|
1804
|
+
} else if (kl === 'prob_stddev') {
|
|
1805
|
+
probSd = parseBeastNumber(value);
|
|
1806
|
+
} else if (kl === 'bootstrap') {
|
|
1807
|
+
let b = parseBeastNumber(value);
|
|
1808
|
+
if (b !== null) {
|
|
1809
|
+
pushConfidence(node, b, 'bootstrap');
|
|
1810
|
+
}
|
|
1811
|
+
} else if ((kl === '!color' || kl === '!colour')
|
|
1812
|
+
&& /^#[0-9a-f]{6}$/i.test(value)) {
|
|
1813
|
+
node.color = {
|
|
1814
|
+
red: parseInt(value.substring(1, 3), 16),
|
|
1815
|
+
green: parseInt(value.substring(3, 5), 16),
|
|
1816
|
+
blue: parseInt(value.substring(5, 7), 16)
|
|
1817
|
+
};
|
|
1818
|
+
} else if (kl === 'height_median') {
|
|
1819
|
+
heightMedian = value;
|
|
1820
|
+
} else if (kl === 'height_mean') {
|
|
1821
|
+
heightMean = value;
|
|
1822
|
+
} else if (kl === 'height') {
|
|
1823
|
+
height = value;
|
|
1824
|
+
} else if (kl === 'height_95%_hpd') {
|
|
1825
|
+
hpd = parseBeastInterval(value);
|
|
1826
|
+
} else if (kl === 'height_range') {
|
|
1827
|
+
range = parseBeastInterval(value);
|
|
1828
|
+
} else if (kl === 'date') {
|
|
1829
|
+
dateDesc = value;
|
|
1830
|
+
} else {
|
|
1831
|
+
addNodeProperty(node, 'beast:' + beastRefKey(key), value);
|
|
1832
|
+
}
|
|
1833
|
+
});
|
|
1834
|
+
if (prob !== null) {
|
|
1835
|
+
pushConfidence(node, prob, 'posterior probability', probSd);
|
|
1836
|
+
}
|
|
1837
|
+
// age preference: median, then mean, then height -- and each piece
|
|
1838
|
+
// parsed independently, so an unparseable point value never discards
|
|
1839
|
+
// a valid {lo,hi} interval
|
|
1840
|
+
let v = heightMedian !== null ? heightMedian
|
|
1841
|
+
: (heightMean !== null ? heightMean : height);
|
|
1842
|
+
let dv = (v !== null) ? parseBeastNumber(v) : null;
|
|
1843
|
+
let interval = hpd || range;
|
|
1844
|
+
if (dv === null && !interval && dateDesc === null) {
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
let date = {};
|
|
1848
|
+
if (dv !== null) {
|
|
1849
|
+
date.value = dv;
|
|
1850
|
+
}
|
|
1851
|
+
if (interval) {
|
|
1852
|
+
date.minimum = interval[0];
|
|
1853
|
+
date.maximum = interval[1];
|
|
1854
|
+
}
|
|
1855
|
+
if (dateDesc !== null) {
|
|
1856
|
+
date.desc = dateDesc;
|
|
1857
|
+
}
|
|
1858
|
+
node.date = date;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// The classic NHX tag set, as the desktop maps it: S= taxonomy
|
|
1862
|
+
// scientific name, T= taxonomy id, B= support confidence, D= a
|
|
1863
|
+
// duplication (Y/T) / speciation (N/F) / undecided (?) event, GN=
|
|
1864
|
+
// sequence name, AC= sequence accession, C= an nh:comment property.
|
|
1865
|
+
// Unknown tags (and DS= domain structures) are ignored.
|
|
1866
|
+
function applyNhxTags(node, content) {
|
|
1867
|
+
content.split(':').forEach(function (tag) {
|
|
1868
|
+
let t = tag.trim();
|
|
1869
|
+
if (t.length < 3) {
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (t.startsWith('S=')) {
|
|
1873
|
+
nodeTaxonomy0(node).scientific_name = t.substring(2);
|
|
1874
|
+
} else if (t.startsWith('T=')) {
|
|
1875
|
+
nodeTaxonomy0(node).id = {value: t.substring(2)};
|
|
1876
|
+
} else if (t.startsWith('B=')) {
|
|
1877
|
+
let b = parseBeastNumber(t.substring(2));
|
|
1878
|
+
if (b !== null) {
|
|
1879
|
+
pushConfidence(node, b, 'bootstrap');
|
|
1880
|
+
}
|
|
1881
|
+
} else if (t.startsWith('D=')) {
|
|
1882
|
+
let c = t.charAt(2);
|
|
1883
|
+
if (c === 'Y' || c === 'T') {
|
|
1884
|
+
node.events = {duplications: 1};
|
|
1885
|
+
} else if (c === 'N' || c === 'F') {
|
|
1886
|
+
node.events = {speciations: 1};
|
|
1887
|
+
} else if (c === '?') {
|
|
1888
|
+
node.events = {type: 'speciation_or_duplication'};
|
|
1889
|
+
}
|
|
1890
|
+
} else if (t.startsWith('GN=')) {
|
|
1891
|
+
nodeSequence0(node).name = t.substring(3);
|
|
1892
|
+
} else if (t.startsWith('AC=')) {
|
|
1893
|
+
nodeSequence0(node).accession = {value: t.substring(3), source: '?'};
|
|
1894
|
+
} else if (t.startsWith('C=')) {
|
|
1895
|
+
addNodeProperty(node, 'nh:comment', t.substring(2));
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function applyExtendedAnnotations(node, blob) {
|
|
1901
|
+
if (/^&&NHX:/i.test(blob)) {
|
|
1902
|
+
applyNhxTags(node, blob.substring(6));
|
|
1903
|
+
} else {
|
|
1904
|
+
applyBeastAnnotations(node, blob.replace(/^&/, ''));
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1200
1908
|
forester.parseNewHampshire = function (nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
|
|
1201
1909
|
|
|
1202
1910
|
let NH_FORMAT_ERR_OPEN_PARENS = NH_FORMAT_ERR + 'likely cause: number of open parentheses is larger than number of close parentheses';
|
|
@@ -1215,7 +1923,12 @@
|
|
|
1215
1923
|
let ancs = [];
|
|
1216
1924
|
let x = {};
|
|
1217
1925
|
|
|
1218
|
-
|
|
1926
|
+
// [&...] annotation blobs (BEAST-style key=value, NHX) are pulled
|
|
1927
|
+
// out BEFORE tokenizing -- their commas/colons/quotes are data --
|
|
1928
|
+
// and re-attached to their node via the [@N] markers below
|
|
1929
|
+
let extracted = extractBracketAnnotations(nhStr);
|
|
1930
|
+
let sss = extracted.text;
|
|
1931
|
+
let annotations = extracted.blobs;
|
|
1219
1932
|
|
|
1220
1933
|
let ss = sss.split(/(;|\(|\)|,|:|"|')/);
|
|
1221
1934
|
let ssl = ss.length;
|
|
@@ -1271,10 +1984,24 @@
|
|
|
1271
1984
|
} else if (element === ')') {
|
|
1272
1985
|
x = ancs.pop();
|
|
1273
1986
|
} else if (element === ':') {
|
|
1987
|
+
// the separator before a branch length: the length itself
|
|
1988
|
+
// is read by the branch below, so there is nothing to do here
|
|
1274
1989
|
} else {
|
|
1275
1990
|
let e = ss[i - 1];
|
|
1276
1991
|
if (e) {
|
|
1277
1992
|
e = e.trim();
|
|
1993
|
+
// re-attach any annotation blobs riding on this
|
|
1994
|
+
// element (name, branch length, or standalone) to
|
|
1995
|
+
// the current node, and drop the markers
|
|
1996
|
+
if (annotations.length > 0 && element.indexOf('[@') > -1) {
|
|
1997
|
+
element = element.replace(/\[@(\d+)\]/g, function (m, k) {
|
|
1998
|
+
let blob = annotations[+k];
|
|
1999
|
+
if (blob !== undefined) {
|
|
2000
|
+
applyExtendedAnnotations(x, blob);
|
|
2001
|
+
}
|
|
2002
|
+
return '';
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
1278
2005
|
if ((e === ')') || (e === '(') || (e === ',')) {
|
|
1279
2006
|
if (element && element.length > 0) {
|
|
1280
2007
|
if (element.charAt(element.length - 1) === "]") {
|
|
@@ -1410,223 +2137,764 @@
|
|
|
1410
2137
|
}
|
|
1411
2138
|
};
|
|
1412
2139
|
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
2140
|
+
// Parses a Nexus-formatted string and returns an ARRAY of tree objects,
|
|
2141
|
+
// each in the same shape parseNewHampshire produces (a Nexus file can
|
|
2142
|
+
// hold any number of trees). Ported from the desktop's
|
|
2143
|
+
// NexusPhylogeniesParser: reads TAXLABELS, the TREES block (TRANSLATE
|
|
2144
|
+
// tables, TREE/UTREE statements, [&R]/[&U] rootedness, tree names and
|
|
2145
|
+
// titles) and CHARACTERS/DATA blocks -- a protein/dna/rna MATRIX
|
|
2146
|
+
// (sequential or interleaved, MATCHCHAR resolved, quoted labels,
|
|
2147
|
+
// comments stripped) becomes per-tip aligned molecular sequences in the
|
|
2148
|
+
// phyloXML shape (sequences[i].mol_seq.{is_aligned,value}), so a tree
|
|
2149
|
+
// read from Nexus shows its alignment track exactly like one read from
|
|
2150
|
+
// phyloXML. The two confidence options are handed through to
|
|
2151
|
+
// parseNewHampshire for each tree statement.
|
|
2152
|
+
forester.parseNexus = function (nexStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
|
|
2153
|
+
const NEXUS_FORMAT_ERR = 'Nexus format error: ';
|
|
2154
|
+
const TITLE_RE = /^title.?\s+([^;]+)/i;
|
|
2155
|
+
const TREE_NAME_RE = /^\s*.?tree\s+(.+?)\s*=/i;
|
|
2156
|
+
const ROOTEDNESS_RE = /=\s*\[&([RU])\]/i;
|
|
2157
|
+
const TRANSLATE_PAIR_RE = /([0-9A-Za-z]+)\s+(.+)/;
|
|
2158
|
+
const RESIDUES_RE = /^[A-Za-z\-_*?.]+$/;
|
|
2159
|
+
const DATATYPE_RE = /datatype\s*=\s*([a-z]+)/;
|
|
2160
|
+
const MATCHCHAR_RE = /matchchar\s*=\s*['"]?(\S)/;
|
|
2161
|
+
|
|
2162
|
+
let trees = [];
|
|
2163
|
+
let taxlabels = [];
|
|
2164
|
+
// null-prototype maps: a taxon named "__proto__" must stay data
|
|
2165
|
+
let translateMap = Object.create(null);
|
|
2166
|
+
let seqs = Object.create(null);
|
|
2167
|
+
let translateBuf = '';
|
|
2168
|
+
let nh = '';
|
|
2169
|
+
let name = '';
|
|
2170
|
+
let title = '';
|
|
2171
|
+
let inTreesBlock = false;
|
|
2172
|
+
let inTaxalabels = false;
|
|
2173
|
+
let inTranslate = false;
|
|
2174
|
+
let inTree = false;
|
|
2175
|
+
let inDataBlock = false;
|
|
2176
|
+
let inMatrix = false;
|
|
2177
|
+
let inDataComment = false;
|
|
2178
|
+
let datatype = null;
|
|
2179
|
+
let rootedInfoPresent = false;
|
|
2180
|
+
let isRooted = false;
|
|
2181
|
+
let matchchar = null;
|
|
2182
|
+
let matrixReferenceId = null;
|
|
2183
|
+
|
|
2184
|
+
// Nexus treats '_' and ' ' as equivalent, labels may be quoted, and a
|
|
2185
|
+
// matrix often capitalizes taxon names differently from the tree -- so
|
|
2186
|
+
// a matrix row joins its tree tip through this canonical key.
|
|
2187
|
+
function joinKey(s) {
|
|
2188
|
+
return s.replace(/_/g, ' ').replace(/['"]+/g, '').trim().toLowerCase();
|
|
1420
2189
|
}
|
|
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
2190
|
|
|
2191
|
+
// Strip Nexus [ ... ] comments, tracking an OPEN comment across lines
|
|
2192
|
+
// so a multi-line comment inside the matrix cannot leak prose as a
|
|
2193
|
+
// spurious taxon row. Called only inside the data block -- the trees
|
|
2194
|
+
// block keeps [&R]/[&...], which are semantic there.
|
|
2195
|
+
function stripDataComments(s) {
|
|
2196
|
+
if (!inDataComment && s.indexOf('[') < 0) {
|
|
2197
|
+
return s;
|
|
2198
|
+
}
|
|
2199
|
+
let out = '';
|
|
2200
|
+
for (let i = 0; i < s.length; ++i) {
|
|
2201
|
+
let c = s.charAt(i);
|
|
2202
|
+
if (inDataComment) {
|
|
2203
|
+
if (c === ']') {
|
|
2204
|
+
inDataComment = false;
|
|
1474
2205
|
}
|
|
2206
|
+
} else if (c === '[') {
|
|
2207
|
+
inDataComment = true;
|
|
2208
|
+
} else {
|
|
2209
|
+
out += c;
|
|
1475
2210
|
}
|
|
1476
|
-
} else if (!n.children && !n._children) {
|
|
1477
|
-
// If an external node lacks taxonomy, return false.
|
|
1478
|
-
result = false;
|
|
1479
2211
|
}
|
|
1480
|
-
|
|
1481
|
-
if (!sawTax) {
|
|
1482
|
-
return null;
|
|
2212
|
+
return out.trim();
|
|
1483
2213
|
}
|
|
1484
|
-
if (result === true) {
|
|
1485
2214
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
} else if (cn) {
|
|
1491
|
-
return cn;
|
|
1492
|
-
} else if (id) {
|
|
1493
|
-
return id;
|
|
2215
|
+
function setTranslatePairs(buf) {
|
|
2216
|
+
let s = buf.trim();
|
|
2217
|
+
if (s.endsWith(';')) {
|
|
2218
|
+
s = s.slice(0, -1).trim();
|
|
1494
2219
|
}
|
|
2220
|
+
// splitTopLevelCommas, not a plain split: a quoted label may
|
|
2221
|
+
// itself contain a comma ('Korea, Republic of' -- the very case
|
|
2222
|
+
// the Auspice writer quotes against elsewhere in this file)
|
|
2223
|
+
splitTopLevelCommas(s).forEach(function (pair) {
|
|
2224
|
+
let ti = pair.toLowerCase().indexOf('translate');
|
|
2225
|
+
if (ti > -1) {
|
|
2226
|
+
pair = pair.substring(ti + 9);
|
|
2227
|
+
}
|
|
2228
|
+
if (pair.trim().length === 0) {
|
|
2229
|
+
return; // a trailing comma before the ';' is tolerated
|
|
2230
|
+
}
|
|
2231
|
+
let m = TRANSLATE_PAIR_RE.exec(pair);
|
|
2232
|
+
if (!m) {
|
|
2233
|
+
throw new Error(NEXUS_FORMAT_ERR + 'ill-formatted translate table entry: "'
|
|
2234
|
+
+ pair.trim() + '" -- is the Translate sub-command terminated with a ";"?');
|
|
2235
|
+
}
|
|
2236
|
+
let value = m[2].replace(/['"]+/g, '').trim();
|
|
2237
|
+
if (value.endsWith(';')) {
|
|
2238
|
+
value = value.slice(0, -1);
|
|
2239
|
+
}
|
|
2240
|
+
translateMap[m[1]] = value;
|
|
2241
|
+
});
|
|
1495
2242
|
}
|
|
1496
|
-
return null;
|
|
1497
|
-
};
|
|
1498
2243
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
2244
|
+
// One MATRIX row ("taxon residues..."): the id is the first token (a
|
|
2245
|
+
// quoted label may contain spaces), the residues are the rest with all
|
|
2246
|
+
// internal whitespace removed. Only protein/dna/rna matrices become
|
|
2247
|
+
// sequences. In an interleaved matrix each id reappears in a later
|
|
2248
|
+
// block, so a repeated id is CONCATENATED onto its row. MATCHCHAR
|
|
2249
|
+
// (e.g. '.') means "same as the first taxon at this position" and is
|
|
2250
|
+
// resolved against that reference row at the same absolute positions.
|
|
2251
|
+
function addMatrixRow(row) {
|
|
2252
|
+
if (datatype !== 'protein' && datatype !== 'dna' && datatype !== 'rna') {
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
let id;
|
|
2256
|
+
let rest;
|
|
2257
|
+
let c0 = row.charAt(0);
|
|
2258
|
+
if (c0 === "'" || c0 === '"') {
|
|
2259
|
+
let close = row.indexOf(c0, 1);
|
|
2260
|
+
if (close < 1) {
|
|
2261
|
+
return;
|
|
1517
2262
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
2263
|
+
id = row.substring(0, close + 1);
|
|
2264
|
+
rest = row.substring(close + 1);
|
|
2265
|
+
} else {
|
|
2266
|
+
let sp = row.indexOf(' ');
|
|
2267
|
+
if (sp < 1) {
|
|
2268
|
+
return;
|
|
1522
2269
|
}
|
|
2270
|
+
id = row.substring(0, sp);
|
|
2271
|
+
rest = row.substring(sp + 1);
|
|
2272
|
+
}
|
|
2273
|
+
let block = rest.replace(/\s+/g, '');
|
|
2274
|
+
if (block.length === 0 || !RESIDUES_RE.test(block)) {
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
if (matchchar && (matrixReferenceId !== null) && (id !== matrixReferenceId)
|
|
2278
|
+
&& seqs[matrixReferenceId]) {
|
|
2279
|
+
let ref = seqs[matrixReferenceId].value;
|
|
2280
|
+
let offset = seqs[id] ? seqs[id].value.length : 0;
|
|
2281
|
+
let resolved = '';
|
|
2282
|
+
for (let j = 0; j < block.length; ++j) {
|
|
2283
|
+
let c = block.charAt(j);
|
|
2284
|
+
resolved += (c === matchchar && (offset + j) < ref.length)
|
|
2285
|
+
? ref.charAt(offset + j) : c;
|
|
2286
|
+
}
|
|
2287
|
+
block = resolved;
|
|
2288
|
+
}
|
|
2289
|
+
seqs[id] = {
|
|
2290
|
+
value: seqs[id] ? (seqs[id].value + block) : block,
|
|
2291
|
+
type: datatype
|
|
2292
|
+
};
|
|
2293
|
+
if (matrixReferenceId === null) {
|
|
2294
|
+
matrixReferenceId = id;
|
|
1523
2295
|
}
|
|
1524
|
-
});
|
|
1525
|
-
if (propValue === null) {
|
|
1526
|
-
return null;
|
|
1527
|
-
}
|
|
1528
|
-
if (result === true) {
|
|
1529
|
-
return propValue;
|
|
1530
|
-
} else {
|
|
1531
|
-
return null;
|
|
1532
2296
|
}
|
|
1533
|
-
};
|
|
1534
2297
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
2298
|
+
// A complete tree statement has accumulated in nh: parse it and carry
|
|
2299
|
+
// over the block's translate table / taxlabels / matrix sequences.
|
|
2300
|
+
function finishTree() {
|
|
2301
|
+
if (nh.length === 0) {
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
let phy = forester.parseNewHampshire(nh, confidenceValuesInBrackets, confidenceValuesAsInternalNames);
|
|
2305
|
+
let myname = '';
|
|
2306
|
+
if (title && name) {
|
|
2307
|
+
myname = title.replace(/_/g, ' ').trim() + ' (' + name.trim() + ')';
|
|
2308
|
+
} else if (title) {
|
|
2309
|
+
myname = title.replace(/_/g, ' ').trim();
|
|
2310
|
+
} else if (name) {
|
|
2311
|
+
myname = name.trim();
|
|
2312
|
+
}
|
|
2313
|
+
if (myname) {
|
|
2314
|
+
phy.name = myname;
|
|
2315
|
+
}
|
|
2316
|
+
if (rootedInfoPresent) {
|
|
2317
|
+
phy.rooted = isRooted;
|
|
2318
|
+
}
|
|
2319
|
+
let seqsByKey = Object.create(null);
|
|
2320
|
+
for (let id in seqs) {
|
|
2321
|
+
seqsByKey[joinKey(id)] = seqs[id];
|
|
2322
|
+
}
|
|
2323
|
+
forester.getAllExternalNodes(phy).forEach(function (node) {
|
|
2324
|
+
if (node.name && translateMap[node.name] !== undefined) {
|
|
2325
|
+
node.name = translateMap[node.name];
|
|
2326
|
+
} else if (taxlabels.length > 0 && node.name && /^\d+$/.test(node.name)) {
|
|
2327
|
+
let i = parseInt(node.name, 10);
|
|
2328
|
+
if (i > 0 && i <= taxlabels.length) {
|
|
2329
|
+
node.name = taxlabels[i - 1].replace(/['"]+/g, '');
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
if (node.name) {
|
|
2333
|
+
let s = seqsByKey[joinKey(node.name)];
|
|
2334
|
+
if (s) {
|
|
2335
|
+
if (!node.sequences) {
|
|
2336
|
+
node.sequences = [];
|
|
2337
|
+
}
|
|
2338
|
+
node.sequences.push({
|
|
2339
|
+
type: s.type,
|
|
2340
|
+
mol_seq: {is_aligned: true, value: s.value}
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
trees.push(phy);
|
|
2346
|
+
nh = '';
|
|
2347
|
+
name = '';
|
|
2348
|
+
rootedInfoPresent = false;
|
|
2349
|
+
isRooted = false;
|
|
1547
2350
|
}
|
|
1548
2351
|
|
|
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;
|
|
2352
|
+
let lines = String(nexStr).split(/\r\n|\r|\n/);
|
|
2353
|
+
for (let k = 0; k < lines.length; ++k) {
|
|
2354
|
+
let line = lines[k].trim();
|
|
2355
|
+
if (line.length === 0 || line.charAt(0) === '#' || line.charAt(0) === '>') {
|
|
2356
|
+
continue;
|
|
1603
2357
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
2358
|
+
line = line.replace(/\s+/g, ' ').replace(/\s+;/g, ';');
|
|
2359
|
+
let lc = line.toLowerCase();
|
|
2360
|
+
if (/^begin\s+trees\b/.test(lc)) {
|
|
2361
|
+
inTreesBlock = true;
|
|
2362
|
+
inTaxalabels = false;
|
|
2363
|
+
inTranslate = false;
|
|
2364
|
+
inDataBlock = false;
|
|
2365
|
+
datatype = null;
|
|
2366
|
+
title = '';
|
|
2367
|
+
} else if (lc.startsWith('taxlabels')) {
|
|
2368
|
+
inTreesBlock = false;
|
|
2369
|
+
inTaxalabels = true;
|
|
2370
|
+
inTranslate = false;
|
|
2371
|
+
inDataBlock = false;
|
|
2372
|
+
datatype = null;
|
|
2373
|
+
} else if (lc.startsWith('translate')) {
|
|
2374
|
+
translateBuf = '';
|
|
2375
|
+
inTaxalabels = false;
|
|
2376
|
+
inTranslate = true;
|
|
2377
|
+
inDataBlock = false;
|
|
2378
|
+
datatype = null;
|
|
2379
|
+
} else if (/^begin\s+(characters|data)\b/.test(lc)) {
|
|
2380
|
+
inTaxalabels = false;
|
|
2381
|
+
inTreesBlock = false;
|
|
2382
|
+
inTranslate = false;
|
|
2383
|
+
inDataBlock = true;
|
|
2384
|
+
inMatrix = false;
|
|
2385
|
+
inDataComment = false;
|
|
2386
|
+
datatype = null;
|
|
2387
|
+
matchchar = null;
|
|
2388
|
+
matrixReferenceId = null;
|
|
2389
|
+
// scope the rows to THIS matrix block, so a later block
|
|
2390
|
+
// cannot cross-contaminate an earlier one
|
|
2391
|
+
seqs = Object.create(null);
|
|
2392
|
+
} else if (inTreesBlock) {
|
|
2393
|
+
if (lc.startsWith('title')) {
|
|
2394
|
+
let tm = TITLE_RE.exec(line);
|
|
2395
|
+
if (tm) {
|
|
2396
|
+
title = tm[1];
|
|
2397
|
+
}
|
|
2398
|
+
} else if (lc.startsWith('link')) {
|
|
2399
|
+
// a LINK sub-command (e.g. "LINK TAXA=...") -- ignored
|
|
2400
|
+
} else if (lc.startsWith('end;') || lc.startsWith('endblock')) {
|
|
2401
|
+
inTreesBlock = false;
|
|
2402
|
+
inTree = false;
|
|
2403
|
+
finishTree();
|
|
2404
|
+
} else if (lc.startsWith('tree ') || lc.startsWith('utree ')) {
|
|
2405
|
+
finishTree(); // a previous statement still pending
|
|
2406
|
+
inTree = true;
|
|
2407
|
+
let nm = TREE_NAME_RE.exec(line);
|
|
2408
|
+
if (nm) {
|
|
2409
|
+
name = nm[1].replace(/['"]+/g, '');
|
|
2410
|
+
}
|
|
2411
|
+
let rm = ROOTEDNESS_RE.exec(line);
|
|
2412
|
+
if (rm) {
|
|
2413
|
+
rootedInfoPresent = true;
|
|
2414
|
+
isRooted = rm[1].toUpperCase() === 'R';
|
|
2415
|
+
}
|
|
2416
|
+
// parseNewHampshire handles the remaining [&...] hot
|
|
2417
|
+
// comments itself: BEAST-style annotations are parsed
|
|
2418
|
+
// onto the nodes, a leading [&R]/[&U] is dropped
|
|
2419
|
+
nh = line.substring(line.indexOf('=') + 1).trim();
|
|
2420
|
+
if (lc.endsWith(';')) {
|
|
2421
|
+
inTree = false;
|
|
2422
|
+
finishTree();
|
|
2423
|
+
}
|
|
2424
|
+
} else if (inTree) {
|
|
2425
|
+
nh += line;
|
|
2426
|
+
if (lc.endsWith(';')) {
|
|
2427
|
+
inTree = false;
|
|
2428
|
+
finishTree();
|
|
2429
|
+
}
|
|
1610
2430
|
}
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
2431
|
+
}
|
|
2432
|
+
if (inTaxalabels) {
|
|
2433
|
+
if (lc.startsWith('end;') || lc.startsWith('endblock')) {
|
|
2434
|
+
inTaxalabels = false;
|
|
2435
|
+
} else {
|
|
2436
|
+
// QUOTE-AWARE tokenization: 'Homo sapiens' is ONE label
|
|
2437
|
+
// (that is what the quotes are for) -- a plain space split
|
|
2438
|
+
// silently sheared such labels apart and shifted every
|
|
2439
|
+
// numeric tip onto the wrong name. ';' (unquoted) ends
|
|
2440
|
+
// the sub-command.
|
|
2441
|
+
let tok = '';
|
|
2442
|
+
let q = null;
|
|
2443
|
+
let push = function () {
|
|
2444
|
+
if (tok.length > 0 && tok.toLowerCase() !== 'taxlabels') {
|
|
2445
|
+
taxlabels.push(tok);
|
|
2446
|
+
}
|
|
2447
|
+
tok = '';
|
|
2448
|
+
};
|
|
2449
|
+
for (let ci = 0; ci < line.length; ++ci) {
|
|
2450
|
+
let ch = line.charAt(ci);
|
|
2451
|
+
if (q) {
|
|
2452
|
+
if (ch === q) {
|
|
2453
|
+
q = null;
|
|
2454
|
+
} else {
|
|
2455
|
+
tok += ch;
|
|
2456
|
+
}
|
|
2457
|
+
} else if (ch === "'" || ch === '"') {
|
|
2458
|
+
q = ch;
|
|
2459
|
+
} else if (ch === ' ') {
|
|
2460
|
+
push();
|
|
2461
|
+
} else if (ch === ';') {
|
|
2462
|
+
inTaxalabels = false;
|
|
2463
|
+
push();
|
|
2464
|
+
break;
|
|
2465
|
+
} else {
|
|
2466
|
+
tok += ch;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
push();
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
if (inTranslate) {
|
|
2473
|
+
if (lc.startsWith('end;') || lc.startsWith('endblock')) {
|
|
2474
|
+
inTranslate = false;
|
|
2475
|
+
} else {
|
|
2476
|
+
translateBuf += ' ' + line;
|
|
2477
|
+
if (line.endsWith(';')) {
|
|
2478
|
+
inTranslate = false;
|
|
2479
|
+
setTranslatePairs(translateBuf);
|
|
1617
2480
|
}
|
|
1618
2481
|
}
|
|
1619
|
-
|
|
1620
|
-
|
|
2482
|
+
}
|
|
2483
|
+
if (inDataBlock) {
|
|
2484
|
+
line = stripDataComments(line);
|
|
2485
|
+
let dlc = line.toLowerCase();
|
|
2486
|
+
if (line.length === 0) {
|
|
2487
|
+
// comment-only (or now-empty) line
|
|
2488
|
+
} else if (dlc.startsWith('end;') || dlc.startsWith('endblock')) {
|
|
2489
|
+
inDataBlock = false;
|
|
2490
|
+
inMatrix = false;
|
|
2491
|
+
datatype = null;
|
|
2492
|
+
} else if (dlc.startsWith('link ')) {
|
|
2493
|
+
// ignored; the trailing space keeps a taxon row whose
|
|
2494
|
+
// name starts with "link" out of this branch
|
|
2495
|
+
} else if (!inMatrix) {
|
|
2496
|
+
// block header: DIMENSIONS / FORMAT / CHARLABELS / ... --
|
|
2497
|
+
// read DATATYPE and MATCHCHAR off FORMAT, enter the matrix
|
|
2498
|
+
// on the MATRIX keyword, ignore the rest (a sub-command
|
|
2499
|
+
// ending in ';' must NOT be mistaken for the block's end)
|
|
2500
|
+
let dm = DATATYPE_RE.exec(dlc);
|
|
2501
|
+
if (dm) {
|
|
2502
|
+
datatype = dm[1];
|
|
2503
|
+
}
|
|
2504
|
+
let mm = MATCHCHAR_RE.exec(dlc);
|
|
2505
|
+
if (mm) {
|
|
2506
|
+
matchchar = mm[1];
|
|
2507
|
+
}
|
|
2508
|
+
if (dlc === 'matrix' || dlc.startsWith('matrix ')) {
|
|
2509
|
+
inMatrix = true;
|
|
2510
|
+
let after = line.substring(6).trim();
|
|
2511
|
+
let matrixEnds = false;
|
|
2512
|
+
if (after.endsWith(';')) {
|
|
2513
|
+
matrixEnds = true;
|
|
2514
|
+
after = after.slice(0, -1).trim();
|
|
2515
|
+
}
|
|
2516
|
+
if (after.length > 0) {
|
|
2517
|
+
addMatrixRow(after);
|
|
2518
|
+
}
|
|
2519
|
+
if (matrixEnds) {
|
|
2520
|
+
inMatrix = false;
|
|
2521
|
+
inDataBlock = false;
|
|
2522
|
+
datatype = null;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
} else {
|
|
2526
|
+
// inside the MATRIX: one taxon row per line until ';'
|
|
2527
|
+
let matrixEnds = false;
|
|
2528
|
+
if (line.endsWith(';')) {
|
|
2529
|
+
matrixEnds = true;
|
|
2530
|
+
line = line.slice(0, -1).trim();
|
|
2531
|
+
}
|
|
2532
|
+
if (line.length > 0) {
|
|
2533
|
+
addMatrixRow(line);
|
|
2534
|
+
}
|
|
2535
|
+
if (matrixEnds) {
|
|
2536
|
+
inMatrix = false;
|
|
2537
|
+
inDataBlock = false;
|
|
2538
|
+
datatype = null;
|
|
2539
|
+
}
|
|
1621
2540
|
}
|
|
1622
2541
|
}
|
|
1623
2542
|
}
|
|
2543
|
+
finishTree(); // EOF with a tree still pending (no closing "End;")
|
|
2544
|
+
return trees;
|
|
1624
2545
|
};
|
|
1625
2546
|
|
|
2547
|
+
// ---------------------------------------------------------------
|
|
2548
|
+
// Auspice / Nextstrain
|
|
2549
|
+
// ---------------------------------------------------------------
|
|
2550
|
+
|
|
2551
|
+
// Namespace for the node properties the Auspice parser writes, so
|
|
2552
|
+
// Nextstrain traits are colour-able/searchable and clearly distinguished
|
|
2553
|
+
// from BEAST's "beast:" namespace. Same prefix as the desktop.
|
|
2554
|
+
const NEXTSTRAIN_PREFIX = 'nextstrain:';
|
|
2555
|
+
|
|
2556
|
+
// A compact string for a JSON number: a whole value drops the ".0" (a
|
|
2557
|
+
// clean categorical/integer property), otherwise the plain decimal
|
|
2558
|
+
// WITHOUT scientific notation (a small divergence like 1e-4 must read
|
|
2559
|
+
// as "0.0001" in the node-data dialog / as a searchable value).
|
|
2560
|
+
function plainNumberString(d) {
|
|
2561
|
+
if (Number.isInteger(d) && Math.abs(d) < 1e15) {
|
|
2562
|
+
return String(d);
|
|
2563
|
+
}
|
|
2564
|
+
let s = String(d);
|
|
2565
|
+
if (s.indexOf('e') < 0 && s.indexOf('E') < 0) {
|
|
2566
|
+
return s;
|
|
2567
|
+
}
|
|
2568
|
+
return d.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
|
|
2569
|
+
}
|
|
1626
2570
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
2571
|
+
function addNodeProperty(node, ref, value) {
|
|
2572
|
+
if (value === undefined || value === null || String(value).length === 0) {
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
if (!node.properties) {
|
|
2576
|
+
node.properties = [];
|
|
2577
|
+
}
|
|
2578
|
+
let v = String(value);
|
|
2579
|
+
node.properties.push({
|
|
2580
|
+
ref: ref,
|
|
2581
|
+
value: v,
|
|
2582
|
+
datatype: isFinite(parseFloat(v)) && isFinite(Number(v)) ? 'xsd:decimal' : 'xsd:string',
|
|
2583
|
+
applies_to: 'node'
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
// Parses an Auspice / Nextstrain v2 dataset.json (string or already-parsed
|
|
2588
|
+
// object) into ONE tree object, mapping its per-node data onto the native
|
|
2589
|
+
// phyloXML shape so the existing overlays light it up -- ported from the
|
|
2590
|
+
// desktop's AuspiceJsonParser:
|
|
2591
|
+
// - node_attrs.num_date.value -> node.date value (decimal year) -> the
|
|
2592
|
+
// calendar time axis; its .confidence [lo,hi] -> date minimum/maximum
|
|
2593
|
+
// -> the node-age (HPD) bars;
|
|
2594
|
+
// - node_attrs.div -> a nextstrain:div property (the divergence measure,
|
|
2595
|
+
// kept for a future time<->divergence view);
|
|
2596
|
+
// - every discrete trait (country, clade_membership, host, ...) -> a
|
|
2597
|
+
// nextstrain:<key> node property (Color-by / search / node dialog);
|
|
2598
|
+
// its .confidence {state:prob} -> nextstrain:<key>_set + _set_prob
|
|
2599
|
+
// brace-list pair (the desktop's ancestral-state-pie encoding);
|
|
2600
|
+
// - branch_attrs.labels.clade -> a nextstrain:clade_label property.
|
|
2601
|
+
// Branch lengths default to TIME (successive num_date differences); a
|
|
2602
|
+
// divergence-only build falls back to div differences. Deliberately NOT
|
|
2603
|
+
// ingested: the map, entropy and frequencies panels.
|
|
2604
|
+
forester.parseAuspiceJson = function (data) {
|
|
2605
|
+
let doc = forester.isString(data) ? JSON.parse(data) : data;
|
|
2606
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
2607
|
+
throw new Error('not an Auspice dataset (the JSON root is not an object)');
|
|
2608
|
+
}
|
|
2609
|
+
if (doc.version !== 'v2' || !doc.tree || typeof doc.tree !== 'object'
|
|
2610
|
+
|| Array.isArray(doc.tree)) {
|
|
2611
|
+
throw new Error('not an Auspice v2 dataset (expected "version":"v2" and a "tree" object)');
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
function isScalar(v) {
|
|
2615
|
+
return (typeof v === 'string') || (typeof v === 'number') || (typeof v === 'boolean');
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
function scalarToString(v) {
|
|
2619
|
+
return (typeof v === 'number') ? plainNumberString(v) : String(v);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// a discrete trait's posterior distribution as the _set/_set_prob
|
|
2623
|
+
// brace-list pair; state names quoted so a comma/space in one (e.g.
|
|
2624
|
+
// "Korea, Republic of") cannot corrupt the list
|
|
2625
|
+
function applyTraitConfidence(node, trait, conf) {
|
|
2626
|
+
let states = [];
|
|
2627
|
+
let probs = [];
|
|
2628
|
+
Object.keys(conf).forEach(function (state) {
|
|
2629
|
+
let p = conf[state];
|
|
2630
|
+
if (typeof p !== 'number' || !isFinite(p) || state.length === 0) {
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
states.push('"' + state.replace(/"/g, '') + '"');
|
|
2634
|
+
probs.push(plainNumberString(p));
|
|
2635
|
+
});
|
|
2636
|
+
if (states.length > 0) {
|
|
2637
|
+
addNodeProperty(node, NEXTSTRAIN_PREFIX + trait + '_set', '{' + states.join(',') + '}');
|
|
2638
|
+
addNodeProperty(node, NEXTSTRAIN_PREFIX + trait + '_set_prob', '{' + probs.join(',') + '}');
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
function applyNodeAttrs(node, attrs) {
|
|
2643
|
+
Object.keys(attrs).forEach(function (key) {
|
|
2644
|
+
let val = attrs[key];
|
|
2645
|
+
if (key === 'num_date') {
|
|
2646
|
+
if (val && typeof val === 'object' && typeof val.value === 'number') {
|
|
2647
|
+
let date = {value: val.value, unit: 'year'};
|
|
2648
|
+
if (Array.isArray(val.confidence) && val.confidence.length === 2
|
|
2649
|
+
&& typeof val.confidence[0] === 'number'
|
|
2650
|
+
&& typeof val.confidence[1] === 'number') {
|
|
2651
|
+
date.minimum = val.confidence[0];
|
|
2652
|
+
date.maximum = val.confidence[1];
|
|
2653
|
+
}
|
|
2654
|
+
node.date = date;
|
|
2655
|
+
// the point date doubles as a numeric property, so the
|
|
2656
|
+
// sampling date can drive Color-by (the classic
|
|
2657
|
+
// Nextstrain colour-by-date view) and search
|
|
2658
|
+
addNodeProperty(node, NEXTSTRAIN_PREFIX + 'num_date', plainNumberString(val.value));
|
|
2659
|
+
}
|
|
2660
|
+
} else if (key === 'div') {
|
|
2661
|
+
if (typeof val === 'number' && isFinite(val)) {
|
|
2662
|
+
addNodeProperty(node, NEXTSTRAIN_PREFIX + 'div', plainNumberString(val));
|
|
2663
|
+
}
|
|
2664
|
+
} else if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
2665
|
+
// a discrete trait: {value, confidence{state:prob}, entropy}
|
|
2666
|
+
if (isScalar(val.value)) {
|
|
2667
|
+
addNodeProperty(node, NEXTSTRAIN_PREFIX + key, scalarToString(val.value));
|
|
2668
|
+
}
|
|
2669
|
+
if (val.confidence && typeof val.confidence === 'object'
|
|
2670
|
+
&& !Array.isArray(val.confidence)) {
|
|
2671
|
+
applyTraitConfidence(node, key, val.confidence);
|
|
2672
|
+
}
|
|
2673
|
+
} else if (isScalar(val)) {
|
|
2674
|
+
addNodeProperty(node, NEXTSTRAIN_PREFIX + key, scalarToString(val)); // bare attr (accession, url, ...)
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function buildNode(jn) {
|
|
2680
|
+
let node = {};
|
|
2681
|
+
if (typeof jn.name === 'string' && jn.name.length > 0) {
|
|
2682
|
+
node.name = jn.name;
|
|
2683
|
+
}
|
|
2684
|
+
if (jn.node_attrs && typeof jn.node_attrs === 'object') {
|
|
2685
|
+
applyNodeAttrs(node, jn.node_attrs);
|
|
2686
|
+
}
|
|
2687
|
+
let labels = jn.branch_attrs && jn.branch_attrs.labels;
|
|
2688
|
+
if (labels && typeof labels.clade === 'string' && labels.clade.length > 0) {
|
|
2689
|
+
addNodeProperty(node, NEXTSTRAIN_PREFIX + 'clade_label', labels.clade);
|
|
2690
|
+
}
|
|
2691
|
+
if (Array.isArray(jn.children) && jn.children.length > 0) {
|
|
2692
|
+
node.children = [];
|
|
2693
|
+
jn.children.forEach(function (c) {
|
|
2694
|
+
if (c && typeof c === 'object') {
|
|
2695
|
+
node.children.push(buildNode(c));
|
|
2696
|
+
}
|
|
2697
|
+
});
|
|
2698
|
+
if (node.children.length === 0) {
|
|
2699
|
+
delete node.children;
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
return node;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
let root = buildNode(doc.tree);
|
|
2706
|
+
let phy = {rooted: true, children: [root]};
|
|
2707
|
+
let title = doc.meta && doc.meta.title;
|
|
2708
|
+
if (typeof title === 'string' && title.trim().length > 0) {
|
|
2709
|
+
phy.name = title.trim();
|
|
2710
|
+
}
|
|
2711
|
+
if (auspiceHasAnyDate(root)) {
|
|
2712
|
+
setDeltaBranchLengths(root, null, auspiceNodeDate); // default view = time
|
|
2713
|
+
} else {
|
|
2714
|
+
// a divergence-only build carries no num_date anywhere; div deltas
|
|
2715
|
+
// keep the layout meaningful instead of a cladogram
|
|
2716
|
+
setDeltaBranchLengths(root, null, auspiceNodeDiv);
|
|
2717
|
+
}
|
|
2718
|
+
// A TIP is a dated sample: keep its point date (the calendar axis)
|
|
2719
|
+
// but drop the date INTERVAL -- the divergence-time uncertainty (the
|
|
2720
|
+
// node-age bars) belongs to the INTERNAL nodes, and a tip interval
|
|
2721
|
+
// would read as a fossil-style observed range on a viral tree.
|
|
2722
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
2723
|
+
if (!n.children && n.date
|
|
2724
|
+
&& (n.date.minimum !== undefined || n.date.maximum !== undefined)) {
|
|
2725
|
+
n.date = {value: n.date.value, unit: n.date.unit};
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
forester.addParents(phy);
|
|
2729
|
+
return phy;
|
|
2730
|
+
};
|
|
2731
|
+
|
|
2732
|
+
function auspiceNodeDate(node) {
|
|
2733
|
+
return (node.date && typeof node.date.value === 'number' && isFinite(node.date.value))
|
|
2734
|
+
? node.date.value : null;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
function auspiceNodeDiv(node) {
|
|
2738
|
+
if (node.properties) {
|
|
2739
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
2740
|
+
if (node.properties[i].ref === NEXTSTRAIN_PREFIX + 'div') {
|
|
2741
|
+
let d = parseFloat(node.properties[i].value);
|
|
2742
|
+
return isFinite(d) ? d : null;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
return null;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
function auspiceHasAnyDate(node) {
|
|
2750
|
+
let found = false;
|
|
2751
|
+
forester.preOrderTraversalAll(node, function (n) {
|
|
2752
|
+
if (auspiceNodeDate(n) !== null) {
|
|
2753
|
+
found = true;
|
|
2754
|
+
}
|
|
2755
|
+
});
|
|
2756
|
+
return found;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
function auspiceHasAnyDiv(node) {
|
|
2760
|
+
let found = false;
|
|
2761
|
+
forester.preOrderTraversalAll(node, function (n) {
|
|
2762
|
+
if (auspiceNodeDiv(n) !== null) {
|
|
2763
|
+
found = true;
|
|
2764
|
+
}
|
|
2765
|
+
});
|
|
2766
|
+
return found;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// Branch lengths = successive differences of a cumulative per-node metric
|
|
2770
|
+
// (num_date -> the time view; nextstrain:div -> the divergence view).
|
|
2771
|
+
// The root's length is 0, and a node missing the metric (or whose parent
|
|
2772
|
+
// misses it) gets 0 -- so a time<->divergence toggle can never leave a
|
|
2773
|
+
// stale cross-scale length behind. A (spurious) negative delta clamps to 0.
|
|
2774
|
+
function setDeltaBranchLengths(node, parentValue, metricOf) {
|
|
2775
|
+
let v = metricOf(node);
|
|
2776
|
+
node.branch_length = (parentValue !== null && v !== null)
|
|
2777
|
+
? Math.max(0, v - parentValue) : 0;
|
|
2778
|
+
let children = node.children || node._children;
|
|
2779
|
+
if (children) {
|
|
2780
|
+
for (let i = 0; i < children.length; ++i) {
|
|
2781
|
+
setDeltaBranchLengths(children[i], v, metricOf);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// The time<->divergence plumbing: both metrics are RETAINED on a parsed
|
|
2787
|
+
// Auspice tree (the date values + the nextstrain:div properties), so a
|
|
2788
|
+
// future display toggle can rewrite the branch lengths from EITHER at any
|
|
2789
|
+
// time -- lossless and reversible, and reusing the exact recompute the
|
|
2790
|
+
// parser itself used, so the toggle can never drift from the loaded view.
|
|
2791
|
+
|
|
2792
|
+
forester.applyTimeBranchLengths = function (phy) {
|
|
2793
|
+
setDeltaBranchLengths(forester.getTreeRoot(phy), null, auspiceNodeDate);
|
|
2794
|
+
};
|
|
2795
|
+
|
|
2796
|
+
forester.applyDivergenceBranchLengths = function (phy) {
|
|
2797
|
+
setDeltaBranchLengths(forester.getTreeRoot(phy), null, auspiceNodeDiv);
|
|
2798
|
+
};
|
|
2799
|
+
|
|
2800
|
+
// True when the tree carries BOTH a time signal (a dated node) AND a
|
|
2801
|
+
// divergence signal (a nextstrain:div property), so the toggle is
|
|
2802
|
+
// meaningful at all.
|
|
2803
|
+
forester.hasTimeAndDivergence = function (phy) {
|
|
2804
|
+
let root = forester.getTreeRoot(phy);
|
|
2805
|
+
return auspiceHasAnyDate(root) && auspiceHasAnyDiv(root);
|
|
2806
|
+
};
|
|
2807
|
+
|
|
2808
|
+
forester.isNumber = function (v) {
|
|
2809
|
+
if (v === undefined || v === null) {
|
|
2810
|
+
return false;
|
|
2811
|
+
}
|
|
2812
|
+
if (v != v) {
|
|
2813
|
+
// This can only be true if the v is NaN
|
|
2814
|
+
return false;
|
|
2815
|
+
}
|
|
2816
|
+
return true;
|
|
2817
|
+
};
|
|
2818
|
+
|
|
2819
|
+
forester.getOneDistinctTaxonomy = function (node) {
|
|
2820
|
+
let id = null;
|
|
2821
|
+
let code = null;
|
|
2822
|
+
let sn = null;
|
|
2823
|
+
let cn = null;
|
|
2824
|
+
let result = true;
|
|
2825
|
+
let sawTax = false;
|
|
2826
|
+
forester.preOrderTraversalAll(node, function (n) {
|
|
2827
|
+
if (n.taxonomies && n.taxonomies.length === 1) {
|
|
2828
|
+
let tax = n.taxonomies[0];
|
|
2829
|
+
if (tax.code && tax.code.length > 0) {
|
|
2830
|
+
sawTax = true;
|
|
2831
|
+
if (code === null) {
|
|
2832
|
+
code = tax.code;
|
|
2833
|
+
} else if (code !== tax.code) {
|
|
2834
|
+
result = false;
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
if (tax.scientific_name && tax.scientific_name.length > 0) {
|
|
2839
|
+
sawTax = true;
|
|
2840
|
+
if (sn === null) {
|
|
2841
|
+
sn = tax.scientific_name;
|
|
2842
|
+
} else if (sn !== tax.scientific_name) {
|
|
2843
|
+
result = false;
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
if (tax.common_name && tax.common_name.length > 0) {
|
|
2848
|
+
sawTax = true;
|
|
2849
|
+
if (cn === null) {
|
|
2850
|
+
cn = tax.common_name;
|
|
2851
|
+
} else if (cn !== tax.common_name) {
|
|
2852
|
+
result = false;
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
if (tax.id && tax.id.value && tax.id.value.length > 0) {
|
|
2857
|
+
sawTax = true;
|
|
2858
|
+
let myid;
|
|
2859
|
+
if (tax.id.provider && tax.id.provider.length > 0) {
|
|
2860
|
+
myid = tax.id.provider + ':' + tax.id.value;
|
|
2861
|
+
} else {
|
|
2862
|
+
myid = tax.id.value;
|
|
2863
|
+
}
|
|
2864
|
+
if (id === null) {
|
|
2865
|
+
id = myid;
|
|
2866
|
+
} else if (id !== myid) {
|
|
2867
|
+
result = false;
|
|
2868
|
+
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
} else if (!n.children && !n._children) {
|
|
2872
|
+
// If an external node lacks taxonomy, return false.
|
|
2873
|
+
result = false;
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
if (!sawTax) {
|
|
2877
|
+
return null;
|
|
2878
|
+
}
|
|
2879
|
+
if (result === true) {
|
|
2880
|
+
|
|
2881
|
+
if (sn) {
|
|
2882
|
+
return sn;
|
|
2883
|
+
} else if (code) {
|
|
2884
|
+
return code;
|
|
2885
|
+
} else if (cn) {
|
|
2886
|
+
return cn;
|
|
2887
|
+
} else if (id) {
|
|
2888
|
+
return id;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
return null;
|
|
2892
|
+
};
|
|
2893
|
+
|
|
2894
|
+
|
|
2895
|
+
/**
|
|
2896
|
+
* To convert a phylogentic tree object to a New Hampshire (Newick) formatted string.
|
|
2897
|
+
*
|
|
1630
2898
|
* @param phy - A phylogentic tree object.
|
|
1631
2899
|
* @param decPointsMax - Maximal number of decimal points for branch lengths (optional)
|
|
1632
2900
|
* @param replaceChars - To replace illegal characters (),:;"' instead of surrounding with quotation marks
|
|
@@ -1664,7 +2932,7 @@
|
|
|
1664
2932
|
nh += replaceUnsafeChars(node.name);
|
|
1665
2933
|
} else {
|
|
1666
2934
|
let myName = node.name.replace(/\s+/g, ' ');
|
|
1667
|
-
if (/[\s,():;'"
|
|
2935
|
+
if (/[\s,():;'"[\]]/.test(myName)) {
|
|
1668
2936
|
if ((myName.indexOf('"') > -1) && (myName.indexOf("'") > -1)) {
|
|
1669
2937
|
nh += '"' + myName.replace(/"/g, "'") + '"';
|
|
1670
2938
|
} else if (myName.indexOf('"') > -1) {
|
|
@@ -1697,10 +2965,128 @@
|
|
|
1697
2965
|
}
|
|
1698
2966
|
|
|
1699
2967
|
function replaceUnsafeChars(str) {
|
|
1700
|
-
return str.replace(/[\s,():;'"
|
|
2968
|
+
return str.replace(/[\s,():;'"[\]]+/g, '_');
|
|
1701
2969
|
}
|
|
1702
2970
|
};
|
|
1703
2971
|
|
|
2972
|
+
// Writes a phylogeny as a Nexus-formatted string, ported from the
|
|
2973
|
+
// desktop's PhylogenyWriter: a TAXA block (Dimensions, TaxLabels) and a
|
|
2974
|
+
// TREES block (the tree under its name, [&R]/[&U] rootedness, the same
|
|
2975
|
+
// safe-character Newick toNewHampshire writes). Beyond the desktop
|
|
2976
|
+
// template, tips carrying ALIGNED molecular sequences also get a
|
|
2977
|
+
// CHARACTERS block (Dimensions, Format with the datatype, Matrix) --
|
|
2978
|
+
// carrying the tree and its alignment in one file is the point of Nexus,
|
|
2979
|
+
// and parseNexus reads the alignment back onto the tips.
|
|
2980
|
+
forester.toNexus = function (phy, decPointsMax, writeConfidences) {
|
|
2981
|
+
// the same replacement toNewHampshire applies, so the TaxLabels and
|
|
2982
|
+
// Matrix labels match the tree's tip tokens exactly
|
|
2983
|
+
function safeLabel(s) {
|
|
2984
|
+
return s.replace(/[\s,():;'"[\]]+/g, '_');
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// label preference as on the desktop: name, then taxonomy
|
|
2988
|
+
// (code/scientific/common), then sequence (name/symbol/gene)
|
|
2989
|
+
function nexusLabel(node, i) {
|
|
2990
|
+
let s = '';
|
|
2991
|
+
if (node.name) {
|
|
2992
|
+
s = node.name;
|
|
2993
|
+
} else if (node.taxonomies && node.taxonomies.length > 0) {
|
|
2994
|
+
let t = node.taxonomies[0];
|
|
2995
|
+
s = t.code || t.scientific_name || t.common_name || '';
|
|
2996
|
+
} else if (node.sequences && node.sequences.length > 0) {
|
|
2997
|
+
let q = node.sequences[0];
|
|
2998
|
+
s = q.name || q.symbol || q.gene_name || '';
|
|
2999
|
+
}
|
|
3000
|
+
if (!s) {
|
|
3001
|
+
s = 'node' + (i + 1); // an empty TaxLabels token would not parse back
|
|
3002
|
+
}
|
|
3003
|
+
return safeLabel(s);
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
let ext = forester.getAllExternalNodes(phy).reverse();
|
|
3007
|
+
// a nameless tip gets its taxa-block label in the TREE as well --
|
|
3008
|
+
// TaxLabels, the Matrix and the Newick must agree on every taxon or
|
|
3009
|
+
// nothing can join them back up (restored before returning, so the
|
|
3010
|
+
// caller's tree is never mutated)
|
|
3011
|
+
let renamed = [];
|
|
3012
|
+
ext.forEach(function (node, i) {
|
|
3013
|
+
if (!node.name) {
|
|
3014
|
+
node.name = nexusLabel(node, i);
|
|
3015
|
+
renamed.push(node);
|
|
3016
|
+
}
|
|
3017
|
+
});
|
|
3018
|
+
let s = '#NEXUS\n';
|
|
3019
|
+
s += 'Begin Taxa;\n';
|
|
3020
|
+
s += ' Dimensions NTax=' + ext.length + ';\n';
|
|
3021
|
+
s += ' TaxLabels';
|
|
3022
|
+
ext.forEach(function (node, i) {
|
|
3023
|
+
s += ' ' + nexusLabel(node, i);
|
|
3024
|
+
});
|
|
3025
|
+
s += ';\n';
|
|
3026
|
+
s += 'End;\n';
|
|
3027
|
+
|
|
3028
|
+
let rows = [];
|
|
3029
|
+
let nchar = 0;
|
|
3030
|
+
let datatype = null;
|
|
3031
|
+
ext.forEach(function (node, i) {
|
|
3032
|
+
if (!node.sequences) {
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
for (let j = 0; j < node.sequences.length; ++j) {
|
|
3036
|
+
let q = node.sequences[j];
|
|
3037
|
+
if (q.mol_seq && q.mol_seq.is_aligned && q.mol_seq.value) {
|
|
3038
|
+
rows.push({label: nexusLabel(node, i), value: q.mol_seq.value});
|
|
3039
|
+
nchar = Math.max(nchar, q.mol_seq.value.length);
|
|
3040
|
+
if (!datatype && (q.type === 'protein' || q.type === 'dna' || q.type === 'rna')) {
|
|
3041
|
+
datatype = q.type;
|
|
3042
|
+
}
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
});
|
|
3047
|
+
if (rows.length > 0) {
|
|
3048
|
+
if (!datatype) {
|
|
3049
|
+
// no declared type (e.g. the tree came from Newick plus a
|
|
3050
|
+
// fasta): judge on the residues themselves
|
|
3051
|
+
datatype = forester.msaIsNucleotide(rows[0].value) ? 'dna' : 'protein';
|
|
3052
|
+
}
|
|
3053
|
+
let width = 0;
|
|
3054
|
+
rows.forEach(function (r) {
|
|
3055
|
+
width = Math.max(width, r.label.length);
|
|
3056
|
+
});
|
|
3057
|
+
s += 'Begin Characters;\n';
|
|
3058
|
+
// NChar ONLY: the Nexus standard allows NTax in a CHARACTERS
|
|
3059
|
+
// block's DIMENSIONS solely alongside NEWTAXA (the taxa are the
|
|
3060
|
+
// TAXA block's), and strict readers -- jebl, and so AliView --
|
|
3061
|
+
// reject the file over it
|
|
3062
|
+
s += ' Dimensions NChar=' + nchar + ';\n';
|
|
3063
|
+
s += ' Format DataType=' + datatype + ' Missing=? Gap=-;\n';
|
|
3064
|
+
s += ' Matrix\n';
|
|
3065
|
+
rows.forEach(function (r) {
|
|
3066
|
+
s += ' ' + r.label + ' '.repeat(width - r.label.length + 1) + r.value + '\n';
|
|
3067
|
+
});
|
|
3068
|
+
s += ' ;\n';
|
|
3069
|
+
s += 'End;\n';
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
s += 'Begin Trees;\n';
|
|
3073
|
+
let treeName = phy.name ? String(phy.name).replace(/['"]+/g, '').trim() : '';
|
|
3074
|
+
s += ' Tree ' + (treeName ? ("'" + treeName + "'") : 'tree1') + '=';
|
|
3075
|
+
s += (phy.rooted === false) ? '[&U]' : '[&R]';
|
|
3076
|
+
let nh = forester.toNewHampshire(phy, decPointsMax, true, writeConfidences);
|
|
3077
|
+
renamed.forEach(function (node) {
|
|
3078
|
+
delete node.name;
|
|
3079
|
+
});
|
|
3080
|
+
if (nh.length === 0) {
|
|
3081
|
+
// an empty tree would otherwise write "Tree tree1=[&R]" with no
|
|
3082
|
+
// tree and no terminating ';' -- a syntactically invalid file
|
|
3083
|
+
throw new Error('toNexus: the tree is empty (nothing to write)');
|
|
3084
|
+
}
|
|
3085
|
+
s += nh + '\n';
|
|
3086
|
+
s += 'End;\n';
|
|
3087
|
+
return s;
|
|
3088
|
+
};
|
|
3089
|
+
|
|
1704
3090
|
forester.getMolecularSequencesAsFasta = function (node, sep) {
|
|
1705
3091
|
let fasta_all = '';
|
|
1706
3092
|
let ext_nodes = forester.getAllExternalNodes(node).reverse();
|
|
@@ -1741,6 +3127,1082 @@
|
|
|
1741
3127
|
};
|
|
1742
3128
|
|
|
1743
3129
|
|
|
3130
|
+
// --------------------------------------------------------------
|
|
3131
|
+
// Search engine
|
|
3132
|
+
// --------------------------------------------------------------
|
|
3133
|
+
// Field-and-mode search over a phylogeny (mirrors the desktop Archaeopteryx
|
|
3134
|
+
// redesign). A search is described by a spec:
|
|
3135
|
+
// { field, mode, value, value2, caseSensitive, inverse }
|
|
3136
|
+
// where field comes from forester.availableSearchFields(root), mode is one of
|
|
3137
|
+
// the string modes ('contains', 'starts_with', 'ends_with', 'whole_word',
|
|
3138
|
+
// 'regex') or numeric modes ('eq', 'ne', 'lt', 'le', 'gt', 'ge', 'range'),
|
|
3139
|
+
// and ',' = OR / '+' = AND inside a text value. Used by archaeopteryx.js;
|
|
3140
|
+
// pure tree logic, no DOM -- tested by test/search_test.js.
|
|
3141
|
+
|
|
3142
|
+
const SEARCH_FIELD_LABELS = {
|
|
3143
|
+
NN: 'Node Name',
|
|
3144
|
+
TS: 'Taxonomy Scientific', TN: 'Taxonomy Common', TC: 'Taxonomy Code',
|
|
3145
|
+
TI: 'Taxonomy Identifier', SY: 'Taxonomy Synonym', LN: 'Taxonomy Lineage',
|
|
3146
|
+
SN: 'Seq Name', GN: 'Gene Name', SS: 'Gene Symbol', SA: 'Seq Accession',
|
|
3147
|
+
MS: 'Molecular Sequence', DO: 'Domain', AN: 'Annotation', XR: 'Cross-Reference'
|
|
3148
|
+
};
|
|
3149
|
+
const SEARCH_TEXT_ORDER = ['TS', 'TN', 'TC', 'TI', 'SY', 'LN', 'SN', 'GN', 'SS', 'SA', 'DO', 'AN', 'XR', 'MS'];
|
|
3150
|
+
// Fields folded into the "Any Text" umbrella (desktop omits MS + DO there).
|
|
3151
|
+
const SEARCH_ANY_TEXT_KEYS = ['NN', 'TS', 'TN', 'TC', 'TI', 'SY', 'LN', 'SN', 'GN', 'SS', 'SA', 'AN', 'XR'];
|
|
3152
|
+
const SEARCH_NUMERIC_DATATYPES = new Set(['decimal', 'double', 'float', 'integer', 'int', 'long', 'short',
|
|
3153
|
+
'byte', 'unsignedint', 'unsignedlong', 'unsignedshort', 'unsignedbyte', 'nonnegativeinteger',
|
|
3154
|
+
'nonpositiveinteger', 'negativeinteger', 'positiveinteger']);
|
|
3155
|
+
|
|
3156
|
+
function searchTaxa(n) { return (n.taxonomies && n.taxonomies.length) ? n.taxonomies : []; }
|
|
3157
|
+
function searchSeqs(n) { return (n.sequences && n.sequences.length) ? n.sequences : []; }
|
|
3158
|
+
|
|
3159
|
+
const SEARCH_TEXT_EXTRACTORS = {
|
|
3160
|
+
NN: n => (n.name ? [n.name] : []),
|
|
3161
|
+
TS: n => searchTaxa(n).map(t => t.scientific_name).filter(Boolean),
|
|
3162
|
+
TN: n => searchTaxa(n).map(t => t.common_name).filter(Boolean),
|
|
3163
|
+
TC: n => searchTaxa(n).map(t => t.code).filter(Boolean),
|
|
3164
|
+
TI: n => searchTaxa(n).map(t => t.id && t.id.value).filter(Boolean),
|
|
3165
|
+
SY: n => searchTaxa(n).reduce((a, t) => a.concat(t.synonyms || []), []).filter(Boolean),
|
|
3166
|
+
LN: n => searchTaxa(n).reduce((a, t) => a.concat(t.lineage || []), []).filter(Boolean),
|
|
3167
|
+
SN: n => searchSeqs(n).map(s => s.name).filter(Boolean),
|
|
3168
|
+
GN: n => searchSeqs(n).map(s => s.gene_name).filter(Boolean),
|
|
3169
|
+
SS: n => searchSeqs(n).map(s => s.symbol).filter(Boolean),
|
|
3170
|
+
SA: n => searchSeqs(n).map(s => s.accession && s.accession.value).filter(Boolean),
|
|
3171
|
+
MS: n => searchSeqs(n).map(s => s.mol_seq).filter(Boolean),
|
|
3172
|
+
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),
|
|
3173
|
+
AN: n => searchSeqs(n).reduce((a, s) => a.concat((s.annotations || []).reduce((b, an) => b.concat([an.desc, an.ref]), [])), []).filter(Boolean),
|
|
3174
|
+
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)
|
|
3175
|
+
};
|
|
3176
|
+
|
|
3177
|
+
function isInternalPropRef(ref) { return !ref || ref.indexOf('aptx:') === 0; }
|
|
3178
|
+
|
|
3179
|
+
function datatypeIsNumeric(dt) {
|
|
3180
|
+
if (!dt) return false;
|
|
3181
|
+
let local = String(dt).toLowerCase();
|
|
3182
|
+
let c = local.lastIndexOf(':');
|
|
3183
|
+
if (c >= 0) local = local.substring(c + 1);
|
|
3184
|
+
return SEARCH_NUMERIC_DATATYPES.has(local);
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
function escapeSearchRegExp(str) {
|
|
3188
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
// Accept comma as decimal separator when unambiguous (one comma, no period,
|
|
3192
|
+
// and not the US thousands pattern comma+exactly-3-digits). Returns null if
|
|
3193
|
+
// not a finite number.
|
|
3194
|
+
forester.parseFiniteDouble = function (s) {
|
|
3195
|
+
if (s === null || s === undefined) return null;
|
|
3196
|
+
s = String(s).trim();
|
|
3197
|
+
if (s.length === 0) return null;
|
|
3198
|
+
if (s.indexOf('.') < 0 && (s.split(',').length - 1) === 1 && !/,\d{3}$/.test(s)) {
|
|
3199
|
+
s = s.replace(',', '.');
|
|
3200
|
+
}
|
|
3201
|
+
let n = Number(s);
|
|
3202
|
+
return isFinite(n) ? n : null;
|
|
3203
|
+
};
|
|
3204
|
+
|
|
3205
|
+
// Build a predicate value -> bool for one text term. Returns null for an
|
|
3206
|
+
// invalid regex (caller treats that as "never matches").
|
|
3207
|
+
forester.makeSearchStringTest = function (term, mode, caseSensitive) {
|
|
3208
|
+
if (mode === 'regex' || mode === 'whole_word') {
|
|
3209
|
+
let src = (mode === 'whole_word')
|
|
3210
|
+
? ('(?<![\\p{L}\\p{N}])' + escapeSearchRegExp(term) + '(?![\\p{L}\\p{N}])')
|
|
3211
|
+
: term;
|
|
3212
|
+
let re;
|
|
3213
|
+
try { re = new RegExp(src, caseSensitive ? 'u' : 'iu'); }
|
|
3214
|
+
catch { return null; }
|
|
3215
|
+
return s => (s !== null && s !== undefined && re.test(String(s)));
|
|
3216
|
+
}
|
|
3217
|
+
let t = caseSensitive ? term : term.toLowerCase();
|
|
3218
|
+
return function (s) {
|
|
3219
|
+
if (s === null || s === undefined) return false;
|
|
3220
|
+
let str = caseSensitive ? String(s) : String(s).toLowerCase();
|
|
3221
|
+
if (mode === 'starts_with') return str.indexOf(t) === 0;
|
|
3222
|
+
if (mode === 'ends_with') return str.length >= t.length && str.lastIndexOf(t) === str.length - t.length;
|
|
3223
|
+
return str.indexOf(t) >= 0; // contains
|
|
3224
|
+
};
|
|
3225
|
+
};
|
|
3226
|
+
|
|
3227
|
+
function numMatches(x, mode, a, lo, hi) {
|
|
3228
|
+
switch (mode) {
|
|
3229
|
+
case 'eq': return Math.abs(x - a) <= 1e-9 * Math.max(1, Math.abs(a));
|
|
3230
|
+
case 'ne': return Math.abs(x - a) > 1e-9 * Math.max(1, Math.abs(a));
|
|
3231
|
+
case 'lt': return x < a;
|
|
3232
|
+
case 'le': return x <= a;
|
|
3233
|
+
case 'gt': return x > a;
|
|
3234
|
+
case 'ge': return x >= a;
|
|
3235
|
+
case 'range': return x >= lo && x <= hi;
|
|
3236
|
+
default: return false;
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// The list of fields the given tree actually offers (drives the Field
|
|
3241
|
+
// dropdowns). Always exposes Any Text + Node Name; adds the text, numeric
|
|
3242
|
+
// and custom-property fields that are present, then structure fields.
|
|
3243
|
+
forester.availableSearchFields = function (root) {
|
|
3244
|
+
let fields = [];
|
|
3245
|
+
fields.push({ key: 'ANY', label: 'Any Text', numeric: false });
|
|
3246
|
+
fields.push({ key: 'NN', label: SEARCH_FIELD_LABELS.NN, numeric: false });
|
|
3247
|
+
if (!root) return fields;
|
|
3248
|
+
|
|
3249
|
+
let present = {};
|
|
3250
|
+
let hasBL = false, hasConf = false;
|
|
3251
|
+
let propRefs = {}; // ref -> { num, tot, dtNum, dtStr }
|
|
3252
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
3253
|
+
for (let k = 0; k < SEARCH_TEXT_ORDER.length; ++k) {
|
|
3254
|
+
let key = SEARCH_TEXT_ORDER[k];
|
|
3255
|
+
if (!present[key] && SEARCH_TEXT_EXTRACTORS[key](n).length > 0) present[key] = true;
|
|
3256
|
+
}
|
|
3257
|
+
if (!hasBL && typeof n.branch_length === 'number' && n.branch_length >= 0) hasBL = true;
|
|
3258
|
+
if (!hasConf && n.confidences) {
|
|
3259
|
+
for (let i = 0; i < n.confidences.length; ++i) {
|
|
3260
|
+
if (typeof n.confidences[i].value === 'number') { hasConf = true; break; }
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
if (n.properties) {
|
|
3264
|
+
for (let i = 0; i < n.properties.length; ++i) {
|
|
3265
|
+
let p = n.properties[i];
|
|
3266
|
+
if (isInternalPropRef(p.ref)) continue;
|
|
3267
|
+
let r = propRefs[p.ref] || (propRefs[p.ref] = { num: 0, tot: 0, dtNum: false, dtStr: false });
|
|
3268
|
+
r.tot++;
|
|
3269
|
+
if (forester.parseFiniteDouble(p.value) !== null) r.num++;
|
|
3270
|
+
if (p.datatype) { if (datatypeIsNumeric(p.datatype)) r.dtNum = true; else r.dtStr = true; }
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
});
|
|
3274
|
+
|
|
3275
|
+
for (let k = 0; k < SEARCH_TEXT_ORDER.length; ++k) {
|
|
3276
|
+
let key = SEARCH_TEXT_ORDER[k];
|
|
3277
|
+
if (present[key]) fields.push({ key: key, label: SEARCH_FIELD_LABELS[key], numeric: false });
|
|
3278
|
+
}
|
|
3279
|
+
if (hasBL) fields.push({ key: 'BL', label: 'Branch Length', numeric: true });
|
|
3280
|
+
if (hasConf) fields.push({ key: 'CO', label: 'Confidence', numeric: true });
|
|
3281
|
+
let refs = Object.keys(propRefs).sort();
|
|
3282
|
+
for (let i = 0; i < refs.length; ++i) {
|
|
3283
|
+
let r = propRefs[refs[i]];
|
|
3284
|
+
let numeric = r.dtStr ? false : (r.dtNum ? true : (r.tot > 0 && r.num === r.tot));
|
|
3285
|
+
fields.push({ key: 'PROP:' + refs[i], label: refs[i], numeric: numeric, propRef: refs[i] });
|
|
3286
|
+
}
|
|
3287
|
+
fields.push({ key: 'CS', label: 'Clade Size (tips)', numeric: true });
|
|
3288
|
+
fields.push({ key: 'NC', label: 'Number of Children', numeric: true });
|
|
3289
|
+
fields.push({ key: 'DE', label: 'Depth from Root', numeric: true });
|
|
3290
|
+
if (hasBL) fields.push({ key: 'DR', label: 'Distance from Root', numeric: true });
|
|
3291
|
+
fields.push({ key: 'NT', label: 'Node Type', numeric: false });
|
|
3292
|
+
return fields;
|
|
3293
|
+
};
|
|
3294
|
+
|
|
3295
|
+
// Per-node depth / distance-to-root / clade size, computed on demand for the
|
|
3296
|
+
// structure search fields (cheap O(n), avoids staleness after tree edits).
|
|
3297
|
+
function computeSearchMetrics(root) {
|
|
3298
|
+
(function pre(n, depth, dist) {
|
|
3299
|
+
n._srchDepth = depth;
|
|
3300
|
+
let d = dist + (typeof n.branch_length === 'number' && n.branch_length > 0 ? n.branch_length : 0);
|
|
3301
|
+
n._srchDist = d;
|
|
3302
|
+
let kids = n.children || n._children;
|
|
3303
|
+
if (kids) for (let i = 0; i < kids.length; ++i) pre(kids[i], depth + 1, d);
|
|
3304
|
+
})(root, 0, 0);
|
|
3305
|
+
forester.postOrderTraversalAll(root, function (n) {
|
|
3306
|
+
let kids = n.children || n._children;
|
|
3307
|
+
if (!kids || kids.length === 0) { n._srchClade = 1; return; }
|
|
3308
|
+
let s = 0;
|
|
3309
|
+
for (let i = 0; i < kids.length; ++i) s += kids[i]._srchClade;
|
|
3310
|
+
n._srchClade = s;
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
// Extract the value(s) of a field from a node (strings for text fields,
|
|
3315
|
+
// numbers for the numeric ones). A field is multi-valued; any value matching
|
|
3316
|
+
// is a match. root is needed only for the Node Type field.
|
|
3317
|
+
forester.extractSearchValues = function (node, field, root) {
|
|
3318
|
+
let key = field.key;
|
|
3319
|
+
if (key === 'ANY') {
|
|
3320
|
+
let out = [];
|
|
3321
|
+
for (let i = 0; i < SEARCH_ANY_TEXT_KEYS.length; ++i) {
|
|
3322
|
+
out = out.concat(SEARCH_TEXT_EXTRACTORS[SEARCH_ANY_TEXT_KEYS[i]](node));
|
|
3323
|
+
}
|
|
3324
|
+
if (node.properties) {
|
|
3325
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
3326
|
+
let p = node.properties[i];
|
|
3327
|
+
if (!isInternalPropRef(p.ref) && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
return out;
|
|
3331
|
+
}
|
|
3332
|
+
if (key === 'NT') {
|
|
3333
|
+
let kids = node.children || node._children;
|
|
3334
|
+
let isLeaf = !kids || kids.length === 0;
|
|
3335
|
+
return [isLeaf ? 'leaf' : (node === root ? 'root' : 'internal')];
|
|
3336
|
+
}
|
|
3337
|
+
if (key.indexOf('PROP:') === 0) {
|
|
3338
|
+
let out = [];
|
|
3339
|
+
if (node.properties) {
|
|
3340
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
3341
|
+
let p = node.properties[i];
|
|
3342
|
+
if (p.ref === field.propRef && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
return out;
|
|
3346
|
+
}
|
|
3347
|
+
if (SEARCH_TEXT_EXTRACTORS[key]) return SEARCH_TEXT_EXTRACTORS[key](node);
|
|
3348
|
+
switch (key) {
|
|
3349
|
+
case 'BL': return (typeof node.branch_length === 'number') ? [node.branch_length] : [];
|
|
3350
|
+
case 'CO': return node.confidences ? node.confidences.map(c => c.value).filter(v => typeof v === 'number') : [];
|
|
3351
|
+
case 'CS': return [node._srchClade];
|
|
3352
|
+
case 'NC': { let kids = node.children || node._children; return [kids ? kids.length : 0]; }
|
|
3353
|
+
case 'DE': return [node._srchDepth];
|
|
3354
|
+
case 'DR': return [node._srchDist];
|
|
3355
|
+
default: return [];
|
|
3356
|
+
}
|
|
3357
|
+
};
|
|
3358
|
+
|
|
3359
|
+
// Run one search spec { field, mode, value, value2, caseSensitive, inverse }
|
|
3360
|
+
// over the tree and return the Set of matching nodes.
|
|
3361
|
+
// A parsed tree is anchored on a SUPER-ROOT: a synthetic node whose single
|
|
3362
|
+
// child is the tree's actual root. It is not a node of the phylogeny -- it
|
|
3363
|
+
// exists to give the root a parent slot, which is what lets reRoot() move
|
|
3364
|
+
// the root around and what toNewHampshire() writes from. Both formats have
|
|
3365
|
+
// one; from phyloXML it also carries the <phylogeny> element's own name and
|
|
3366
|
+
// description, so it looks like a node with the TREE's name on it.
|
|
3367
|
+
//
|
|
3368
|
+
// Anything that reasons about the phylogeny's own nodes has to step over it.
|
|
3369
|
+
// This is the test getTreeRoot uses, minus that function's walk UP the tree,
|
|
3370
|
+
// which would escape a subtree a caller had deliberately scoped to.
|
|
3371
|
+
function realRootOf(root) {
|
|
3372
|
+
if (!root.parent && root.children && root.children.length === 1) {
|
|
3373
|
+
return root.children[0];
|
|
3374
|
+
}
|
|
3375
|
+
return root;
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
forester.searchWithSpec = function (root, spec) {
|
|
3379
|
+
let result = new Set();
|
|
3380
|
+
if (!root || !spec || !spec.field) return result;
|
|
3381
|
+
// Metrics stay relative to what the caller passed, so depth and distance
|
|
3382
|
+
// values are unchanged; only the set of nodes considered is narrowed.
|
|
3383
|
+
let nodes = realRootOf(root);
|
|
3384
|
+
let field = spec.field;
|
|
3385
|
+
if (field.key === 'CS' || field.key === 'DE' || field.key === 'DR' || field.key === 'NC') computeSearchMetrics(root);
|
|
3386
|
+
|
|
3387
|
+
let v = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
|
|
3388
|
+
v = v.replace(/\s+/g, ' ').trim();
|
|
3389
|
+
|
|
3390
|
+
let test = null;
|
|
3391
|
+
if (field.numeric) {
|
|
3392
|
+
let a = forester.parseFiniteDouble(v);
|
|
3393
|
+
let b = (spec.mode === 'range') ? forester.parseFiniteDouble(spec.value2) : null;
|
|
3394
|
+
if (a === null || (spec.mode === 'range' && b === null)) return result; // invalid -> reset
|
|
3395
|
+
let lo = (b !== null) ? Math.min(a, b) : a;
|
|
3396
|
+
let hi = (b !== null) ? Math.max(a, b) : a;
|
|
3397
|
+
test = function (n) {
|
|
3398
|
+
let vals = forester.extractSearchValues(n, field, root);
|
|
3399
|
+
for (let i = 0; i < vals.length; ++i) {
|
|
3400
|
+
let x = (typeof vals[i] === 'number') ? vals[i] : forester.parseFiniteDouble(vals[i]);
|
|
3401
|
+
if (x !== null && numMatches(x, spec.mode, a, lo, hi)) return true;
|
|
3402
|
+
}
|
|
3403
|
+
return false;
|
|
3404
|
+
};
|
|
3405
|
+
} else {
|
|
3406
|
+
if (v.length < 1) return result;
|
|
3407
|
+
let splittable = spec.mode !== 'regex';
|
|
3408
|
+
let orTerms = (splittable && v.indexOf(',') >= 0) ? v.split(/,+/) : [v];
|
|
3409
|
+
let compiled = [];
|
|
3410
|
+
for (let oi = 0; oi < orTerms.length; ++oi) {
|
|
3411
|
+
let ot = orTerms[oi].trim();
|
|
3412
|
+
if (!ot) continue;
|
|
3413
|
+
let ands = (splittable && ot.indexOf('+') > 0) ? ot.split(/\++/) : [ot];
|
|
3414
|
+
let tests = [];
|
|
3415
|
+
let bad = false;
|
|
3416
|
+
for (let ai = 0; ai < ands.length; ++ai) {
|
|
3417
|
+
let term = ands[ai].trim();
|
|
3418
|
+
if (!term) continue;
|
|
3419
|
+
let t = forester.makeSearchStringTest(term, spec.mode, spec.caseSensitive);
|
|
3420
|
+
if (t === null) { bad = true; break; } // invalid regex
|
|
3421
|
+
tests.push(t);
|
|
3422
|
+
}
|
|
3423
|
+
if (!bad && tests.length) compiled.push(tests);
|
|
3424
|
+
}
|
|
3425
|
+
if (!compiled.length) return result;
|
|
3426
|
+
test = function (n) {
|
|
3427
|
+
let vals = forester.extractSearchValues(n, field, root);
|
|
3428
|
+
for (let oi = 0; oi < compiled.length; ++oi) {
|
|
3429
|
+
let ands = compiled[oi], ok = true;
|
|
3430
|
+
for (let ai = 0; ai < ands.length; ++ai) {
|
|
3431
|
+
let hit = false;
|
|
3432
|
+
for (let vi = 0; vi < vals.length; ++vi) { if (ands[ai](vals[vi])) { hit = true; break; } }
|
|
3433
|
+
if (!hit) { ok = false; break; }
|
|
3434
|
+
}
|
|
3435
|
+
if (ok) return true;
|
|
3436
|
+
}
|
|
3437
|
+
return false;
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
forester.preOrderTraversalAll(nodes, function (n) { if (test(n)) result.add(n); });
|
|
3442
|
+
|
|
3443
|
+
if (spec.inverse) {
|
|
3444
|
+
// Complement, scoped to nodes that actually carry this field.
|
|
3445
|
+
let inv = new Set();
|
|
3446
|
+
forester.preOrderTraversalAll(nodes, function (n) {
|
|
3447
|
+
if (!result.has(n) && forester.extractSearchValues(n, field, root).length > 0) inv.add(n);
|
|
3448
|
+
});
|
|
3449
|
+
return inv;
|
|
3450
|
+
}
|
|
3451
|
+
return result;
|
|
3452
|
+
};
|
|
3453
|
+
|
|
3454
|
+
// Distinct, trimmed, sorted values of a specific text field across the tree,
|
|
3455
|
+
// for the value-box autocomplete. Empty for numeric, Any Text, or Molecular
|
|
3456
|
+
// Sequence (near-unique / huge). cap limits the list length (optional).
|
|
3457
|
+
forester.distinctSearchValues = function (root, field, cap) {
|
|
3458
|
+
if (!root || !field || field.numeric || field.key === 'ANY' || field.key === 'MS') return [];
|
|
3459
|
+
let set = new Set();
|
|
3460
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
3461
|
+
let vals = forester.extractSearchValues(n, field, root);
|
|
3462
|
+
for (let i = 0; i < vals.length; ++i) {
|
|
3463
|
+
if (vals[i] !== null && vals[i] !== undefined) {
|
|
3464
|
+
let v = String(vals[i]).trim();
|
|
3465
|
+
if (v.length > 0) set.add(v);
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
});
|
|
3469
|
+
let arr = Array.from(set).sort(function (a, b) { return a.localeCompare(b); });
|
|
3470
|
+
if (cap && arr.length > cap) arr = arr.slice(0, cap);
|
|
3471
|
+
return arr;
|
|
3472
|
+
};
|
|
3473
|
+
|
|
3474
|
+
|
|
3475
|
+
// --------------------------------------------------------------
|
|
3476
|
+
// Geologic time scale + time-tree detection (the desktop's time axes)
|
|
3477
|
+
// --------------------------------------------------------------
|
|
3478
|
+
// The ICS International Chronostratigraphic Chart as the desktop embeds
|
|
3479
|
+
// it (GeologicTimeScale.java): {name, young, old, color}, ages in Ma,
|
|
3480
|
+
// colours the official ICS ones (kept even in monochrome exports -- the
|
|
3481
|
+
// timescale IS a colour key). Reference: Cohen, K.M., Harper, D.A.T.,
|
|
3482
|
+
// Gibbard, P.L. & Car, N. (2025, updated), The ICS International
|
|
3483
|
+
// Chronostratigraphic Chart this decade, Episodes 48: 105-115;
|
|
3484
|
+
// International Commission on Stratigraphy, www.stratigraphy.org.
|
|
3485
|
+
|
|
3486
|
+
const GEO_SCALE = {
|
|
3487
|
+
eon: [
|
|
3488
|
+
{name: 'Phanerozoic', young: 0, old: 538.8, color: '#9AD9DD'},
|
|
3489
|
+
{name: 'Proterozoic', young: 538.8, old: 2500, color: '#FF70B8'},
|
|
3490
|
+
{name: 'Archean', young: 2500, old: 4031, color: '#FF3399'}
|
|
3491
|
+
],
|
|
3492
|
+
era: [
|
|
3493
|
+
{name: 'Cenozoic', young: 0, old: 66, color: '#F2F91D'},
|
|
3494
|
+
{name: 'Mesozoic', young: 66, old: 251.902, color: '#67C5CA'},
|
|
3495
|
+
{name: 'Paleozoic', young: 251.902, old: 538.8, color: '#99C08D'},
|
|
3496
|
+
{name: 'Neoproterozoic', young: 538.8, old: 1000, color: '#FF9BCD'},
|
|
3497
|
+
{name: 'Mesoproterozoic', young: 1000, old: 1600, color: '#FF7EBF'},
|
|
3498
|
+
{name: 'Paleoproterozoic', young: 1600, old: 2500, color: '#E665A6'},
|
|
3499
|
+
{name: 'Neoarchean', young: 2500, old: 2800, color: '#FF5CAD'},
|
|
3500
|
+
{name: 'Mesoarchean', young: 2800, old: 3200, color: '#E62E8A'},
|
|
3501
|
+
{name: 'Paleoarchean', young: 3200, old: 3600, color: '#CC297A'},
|
|
3502
|
+
{name: 'Eoarchean', young: 3600, old: 4031, color: '#B2246B'}
|
|
3503
|
+
],
|
|
3504
|
+
period: [
|
|
3505
|
+
{name: 'Quaternary', young: 0, old: 2.58, color: '#F9F97F'},
|
|
3506
|
+
{name: 'Neogene', young: 2.58, old: 23.04, color: '#FFE619'},
|
|
3507
|
+
{name: 'Paleogene', young: 23.04, old: 66, color: '#FD9A52'},
|
|
3508
|
+
{name: 'Cretaceous', young: 66, old: 143.1, color: '#7FC64E'},
|
|
3509
|
+
{name: 'Jurassic', young: 143.1, old: 201.4, color: '#34B2C9'},
|
|
3510
|
+
{name: 'Triassic', young: 201.4, old: 251.902, color: '#812B92'},
|
|
3511
|
+
{name: 'Permian', young: 251.902, old: 298.9, color: '#F04028'},
|
|
3512
|
+
{name: 'Carboniferous', young: 298.9, old: 358.86, color: '#67A599'},
|
|
3513
|
+
{name: 'Devonian', young: 358.86, old: 419.62, color: '#CB8C37'},
|
|
3514
|
+
{name: 'Silurian', young: 419.62, old: 443.1, color: '#B3E1B6'},
|
|
3515
|
+
{name: 'Ordovician', young: 443.1, old: 486.85, color: '#009270'},
|
|
3516
|
+
{name: 'Cambrian', young: 486.85, old: 538.8, color: '#7FA056'},
|
|
3517
|
+
{name: 'Ediacaran', young: 538.8, old: 635, color: '#FFC3E1'},
|
|
3518
|
+
{name: 'Cryogenian', young: 635, old: 720, color: '#FFAFD7'},
|
|
3519
|
+
{name: 'Tonian', young: 720, old: 1000, color: '#FFA5D2'},
|
|
3520
|
+
{name: 'Stenian', young: 1000, old: 1200, color: '#FFA5D2'},
|
|
3521
|
+
{name: 'Ectasian', young: 1200, old: 1400, color: '#FF98CC'},
|
|
3522
|
+
{name: 'Calymmian', young: 1400, old: 1600, color: '#FF8BC5'},
|
|
3523
|
+
{name: 'Statherian', young: 1600, old: 1800, color: '#EE93C1'},
|
|
3524
|
+
{name: 'Orosirian', young: 1800, old: 2050, color: '#E874AF'},
|
|
3525
|
+
{name: 'Rhyacian', young: 2050, old: 2300, color: '#EB84B8'},
|
|
3526
|
+
{name: 'Siderian', young: 2300, old: 2500, color: '#E874AF'}
|
|
3527
|
+
],
|
|
3528
|
+
epoch: [
|
|
3529
|
+
{name: 'Holocene', young: 0, old: 0.0117, color: '#FEF2E0'},
|
|
3530
|
+
{name: 'Pleistocene', young: 0.0117, old: 2.58, color: '#FFF2AE'},
|
|
3531
|
+
{name: 'Pliocene', young: 2.58, old: 5.333, color: '#FFFF99'},
|
|
3532
|
+
{name: 'Miocene', young: 5.333, old: 23.04, color: '#FFFF00'},
|
|
3533
|
+
{name: 'Oligocene', young: 23.04, old: 33.9, color: '#FDC07A'},
|
|
3534
|
+
{name: 'Eocene', young: 33.9, old: 56, color: '#FDB46C'},
|
|
3535
|
+
{name: 'Paleocene', young: 56, old: 66, color: '#FDA75F'},
|
|
3536
|
+
{name: 'Late Cretaceous', young: 66, old: 100.5, color: '#A6D84A'},
|
|
3537
|
+
{name: 'Early Cretaceous', young: 100.5, old: 143.1, color: '#8CCD57'},
|
|
3538
|
+
{name: 'Late Jurassic', young: 143.1, old: 161.5, color: '#B3E3EE'},
|
|
3539
|
+
{name: 'Middle Jurassic', young: 161.5, old: 174.7, color: '#80CFD8'},
|
|
3540
|
+
{name: 'Early Jurassic', young: 174.7, old: 201.4, color: '#42AED0'},
|
|
3541
|
+
{name: 'Late Triassic', young: 201.4, old: 237, color: '#BD8CC3'},
|
|
3542
|
+
{name: 'Middle Triassic', young: 237, old: 246.7, color: '#B168B1'},
|
|
3543
|
+
{name: 'Early Triassic', young: 246.7, old: 251.902, color: '#983999'},
|
|
3544
|
+
{name: 'Lopingian', young: 251.902, old: 259.51, color: '#FBA794'},
|
|
3545
|
+
{name: 'Guadalupian', young: 259.51, old: 274.4, color: '#FB745C'},
|
|
3546
|
+
{name: 'Cisuralian', young: 274.4, old: 298.9, color: '#EF5845'},
|
|
3547
|
+
{name: 'Pennsylvanian', young: 298.9, old: 323.4, color: '#99C2B5'},
|
|
3548
|
+
{name: 'Mississippian', young: 323.4, old: 358.86, color: '#678F66'},
|
|
3549
|
+
{name: 'Late Devonian', young: 358.86, old: 382.31, color: '#F1E19D'},
|
|
3550
|
+
{name: 'Middle Devonian', young: 382.31, old: 393.47, color: '#F1C868'},
|
|
3551
|
+
{name: 'Early Devonian', young: 393.47, old: 419.62, color: '#E5AC4D'},
|
|
3552
|
+
{name: 'Pridoli', young: 419.62, old: 422.7, color: '#E6F5E1'},
|
|
3553
|
+
{name: 'Ludlow', young: 422.7, old: 426.7, color: '#BFE6CF'},
|
|
3554
|
+
{name: 'Wenlock', young: 426.7, old: 432.9, color: '#B3E1C2'},
|
|
3555
|
+
{name: 'Llandovery', young: 432.9, old: 443.1, color: '#99D7B3'},
|
|
3556
|
+
{name: 'Late Ordovician', young: 443.1, old: 458.2, color: '#7FCA93'},
|
|
3557
|
+
{name: 'Middle Ordovician', young: 458.2, old: 471.3, color: '#4DB47E'},
|
|
3558
|
+
{name: 'Early Ordovician', young: 471.3, old: 486.85, color: '#1A9D6F'},
|
|
3559
|
+
{name: 'Furongian', young: 486.85, old: 497, color: '#B3E095'},
|
|
3560
|
+
{name: 'Miaolingian', young: 497, old: 506.5, color: '#A6CF86'},
|
|
3561
|
+
{name: 'Series 2', young: 506.5, old: 521, color: '#99C078'},
|
|
3562
|
+
{name: 'Terreneuvian', young: 521, old: 538.8, color: '#8CB06C'}
|
|
3563
|
+
]
|
|
3564
|
+
};
|
|
3565
|
+
|
|
3566
|
+
forester.geoIntervals = function (rank) {
|
|
3567
|
+
return GEO_SCALE[rank] || [];
|
|
3568
|
+
};
|
|
3569
|
+
|
|
3570
|
+
// How far back a rank's intervals reach (its oldest boundary, Ma).
|
|
3571
|
+
forester.geoCoverage = function (rank) {
|
|
3572
|
+
let ivs = forester.geoIntervals(rank);
|
|
3573
|
+
return ivs.length > 0 ? ivs[ivs.length - 1].old : 0;
|
|
3574
|
+
};
|
|
3575
|
+
|
|
3576
|
+
// The [coarse, fine] rank pair for a tree reaching back oldMa: always the
|
|
3577
|
+
// finest pair that still fully covers the range, so a deep tree never
|
|
3578
|
+
// shows blank band segments.
|
|
3579
|
+
forester.geoBandRanks = function (oldMa) {
|
|
3580
|
+
if (oldMa <= forester.geoCoverage('epoch')) {
|
|
3581
|
+
return ['period', 'epoch'];
|
|
3582
|
+
}
|
|
3583
|
+
if (oldMa <= forester.geoCoverage('period')) {
|
|
3584
|
+
return ['era', 'period'];
|
|
3585
|
+
}
|
|
3586
|
+
return ['eon', 'era'];
|
|
3587
|
+
};
|
|
3588
|
+
|
|
3589
|
+
// The rank's intervals overlapping [youngMa, oldMa] (a zero-width window
|
|
3590
|
+
// becomes a point query).
|
|
3591
|
+
forester.geoOverlapping = function (rank, youngMa, oldMa) {
|
|
3592
|
+
let lo = Math.min(youngMa, oldMa);
|
|
3593
|
+
let hi = Math.max(youngMa, oldMa);
|
|
3594
|
+
return forester.geoIntervals(rank).filter(function (iv) {
|
|
3595
|
+
if (hi === lo) {
|
|
3596
|
+
return iv.young <= lo && lo < iv.old;
|
|
3597
|
+
}
|
|
3598
|
+
return iv.old > lo && iv.young < hi;
|
|
3599
|
+
});
|
|
3600
|
+
};
|
|
3601
|
+
|
|
3602
|
+
// The rank's interval containing ageMa (young <= age < old), or null.
|
|
3603
|
+
forester.geoAt = function (rank, ageMa) {
|
|
3604
|
+
let ivs = forester.geoIntervals(rank);
|
|
3605
|
+
for (let i = 0; i < ivs.length; ++i) {
|
|
3606
|
+
if (ivs[i].young <= ageMa && ageMa < ivs[i].old) {
|
|
3607
|
+
return ivs[i];
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
return null;
|
|
3611
|
+
};
|
|
3612
|
+
|
|
3613
|
+
// ---- time-tree detection -------------------------------------------
|
|
3614
|
+
// Two date conventions: GEOLOGIC ages (Ma before present, decreasing
|
|
3615
|
+
// toward the tips) and CALENDAR years (increasing toward the tips).
|
|
3616
|
+
// Decided from the <date> unit attributes, with a magnitude fallback for
|
|
3617
|
+
// unitless dates: values mostly in [1500, 2200] read as years; values
|
|
3618
|
+
// spanning from large down toward ~0 read as ages.
|
|
3619
|
+
const GEO_DATE_UNITS = {
|
|
3620
|
+
mya: 1, ma: 1, myr: 1, myrs: 1, my: 1, ga: 1, gya: 1, bya: 1, kya: 1,
|
|
3621
|
+
'million years': 1, 'billion years': 1
|
|
3622
|
+
};
|
|
3623
|
+
const CAL_DATE_UNITS = {
|
|
3624
|
+
year: 1, years: 1, yr: 1, yrs: 1, cal: 1, ce: 1, ad: 1, calendar: 1,
|
|
3625
|
+
'calendar year': 1, 'calendar years': 1
|
|
3626
|
+
};
|
|
3627
|
+
|
|
3628
|
+
// Everything the viewer needs to decide about and draw a time axis:
|
|
3629
|
+
// {type: 'geologic'|'calendar'|null, rootAge, presentDate, dated,
|
|
3630
|
+
// hasInternalIntervals, hasExternalIntervals}. rootAge (geologic) and
|
|
3631
|
+
// presentDate (calendar) are both the LARGEST date value -- the oldest
|
|
3632
|
+
// node for ages, the most recent tip for years.
|
|
3633
|
+
forester.timeAxisInfo = function (root) {
|
|
3634
|
+
let values = [];
|
|
3635
|
+
let maxVal = -Infinity; // running, not Math.max.apply: 150k dated tips overflow the call stack
|
|
3636
|
+
let minVal = Infinity;
|
|
3637
|
+
let geoUnits = 0;
|
|
3638
|
+
let calUnits = 0;
|
|
3639
|
+
let internal = 0;
|
|
3640
|
+
let external = 0;
|
|
3641
|
+
let datedInternal = 0;
|
|
3642
|
+
let datedExternal = 0;
|
|
3643
|
+
let hasInternalIntervals = false;
|
|
3644
|
+
let hasExternalIntervals = false;
|
|
3645
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
3646
|
+
let isExt = !n.children && !n._children;
|
|
3647
|
+
if (isExt) {
|
|
3648
|
+
++external;
|
|
3649
|
+
} else {
|
|
3650
|
+
++internal;
|
|
3651
|
+
}
|
|
3652
|
+
let d = n.date;
|
|
3653
|
+
if (!d) {
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3656
|
+
let interval = (typeof d.minimum === 'number') && (typeof d.maximum === 'number');
|
|
3657
|
+
if (interval) {
|
|
3658
|
+
if (isExt) {
|
|
3659
|
+
hasExternalIntervals = true;
|
|
3660
|
+
} else {
|
|
3661
|
+
hasInternalIntervals = true;
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
if (typeof d.value !== 'number' || !isFinite(d.value)) {
|
|
3665
|
+
return; // 1e400 parses to Infinity and must never reach the tick loops
|
|
3666
|
+
}
|
|
3667
|
+
values.push(d.value);
|
|
3668
|
+
if (d.value > maxVal) {
|
|
3669
|
+
maxVal = d.value;
|
|
3670
|
+
}
|
|
3671
|
+
if (d.value < minVal) {
|
|
3672
|
+
minVal = d.value;
|
|
3673
|
+
}
|
|
3674
|
+
if (isExt) {
|
|
3675
|
+
++datedExternal;
|
|
3676
|
+
} else {
|
|
3677
|
+
++datedInternal;
|
|
3678
|
+
}
|
|
3679
|
+
if (d.unit) {
|
|
3680
|
+
let u = String(d.unit).trim().toLowerCase();
|
|
3681
|
+
if (GEO_DATE_UNITS[u]) {
|
|
3682
|
+
++geoUnits;
|
|
3683
|
+
} else if (CAL_DATE_UNITS[u]) {
|
|
3684
|
+
++calUnits;
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
});
|
|
3688
|
+
let type = null;
|
|
3689
|
+
if (values.length > 0) {
|
|
3690
|
+
if (geoUnits > 0 && geoUnits >= calUnits) {
|
|
3691
|
+
type = 'geologic';
|
|
3692
|
+
} else if (calUnits > 0) {
|
|
3693
|
+
type = 'calendar';
|
|
3694
|
+
} else {
|
|
3695
|
+
let calendarish = values.filter(function (v) {
|
|
3696
|
+
return v >= 1500 && v <= 2200;
|
|
3697
|
+
}).length;
|
|
3698
|
+
if (calendarish * 2 > values.length) {
|
|
3699
|
+
type = 'calendar';
|
|
3700
|
+
} else if (maxVal > 10 && minVal <= maxVal * 0.05) {
|
|
3701
|
+
type = 'geologic';
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
let dated = (datedInternal >= 2 && (datedInternal * 2) > internal)
|
|
3706
|
+
|| (datedExternal >= 2 && (datedExternal * 2) > external);
|
|
3707
|
+
let maxValue = values.length > 0 ? maxVal : 0;
|
|
3708
|
+
return {
|
|
3709
|
+
type: type,
|
|
3710
|
+
rootAge: type === 'geologic' ? maxValue : 0,
|
|
3711
|
+
presentDate: type === 'calendar' ? maxValue : 0,
|
|
3712
|
+
dated: dated,
|
|
3713
|
+
hasInternalIntervals: hasInternalIntervals,
|
|
3714
|
+
hasExternalIntervals: hasExternalIntervals
|
|
3715
|
+
};
|
|
3716
|
+
};
|
|
3717
|
+
|
|
3718
|
+
// ---- axis tick mathematics -----------------------------------------
|
|
3719
|
+
// The smallest 1/2/5 x 10^k (k may be negative) step >= target.
|
|
3720
|
+
forester.niceAxisStep = function (target) {
|
|
3721
|
+
if (!(target > 0) || !isFinite(target)) {
|
|
3722
|
+
return 1;
|
|
3723
|
+
}
|
|
3724
|
+
let mag = Math.pow(10, Math.floor(Math.log(target) / Math.LN10));
|
|
3725
|
+
let candidates = [1, 2, 5, 10];
|
|
3726
|
+
for (let i = 0; i < candidates.length; ++i) {
|
|
3727
|
+
let s = candidates[i] * mag;
|
|
3728
|
+
if (s >= target - 1e-12) {
|
|
3729
|
+
return s;
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
return 10 * mag;
|
|
3733
|
+
};
|
|
3734
|
+
|
|
3735
|
+
// Tick ages for a "Ma before present" ruler: ~8 nice steps from 0 back
|
|
3736
|
+
// to the root age.
|
|
3737
|
+
forester.maAxisTickValues = function (rootAge) {
|
|
3738
|
+
if (!(rootAge > 0) || !isFinite(rootAge)) {
|
|
3739
|
+
return [];
|
|
3740
|
+
}
|
|
3741
|
+
let step = forester.niceAxisStep(rootAge / 8);
|
|
3742
|
+
let vals = [];
|
|
3743
|
+
for (let v = 0; v <= rootAge + 1e-9; v += step) {
|
|
3744
|
+
vals.push(Math.round(v * 1e6) / 1e6);
|
|
3745
|
+
}
|
|
3746
|
+
return vals;
|
|
3747
|
+
};
|
|
3748
|
+
|
|
3749
|
+
// Whole-year calendar ticks over [from, to]: nice 1/2/5 x 10^k integer
|
|
3750
|
+
// steps, ~7 ticks.
|
|
3751
|
+
forester.calendarTickYears = function (from, to) {
|
|
3752
|
+
let span = to - from;
|
|
3753
|
+
if (!(span > 0) || !isFinite(span) || !isFinite(from)) {
|
|
3754
|
+
return [];
|
|
3755
|
+
}
|
|
3756
|
+
let step = Math.max(1, Math.round(forester.niceAxisStep(span / 7)));
|
|
3757
|
+
let vals = [];
|
|
3758
|
+
for (let y = Math.ceil(from / step) * step; y <= to + 1e-9; y += step) {
|
|
3759
|
+
vals.push(y);
|
|
3760
|
+
}
|
|
3761
|
+
return vals;
|
|
3762
|
+
};
|
|
3763
|
+
|
|
3764
|
+
|
|
3765
|
+
// --------------------------------------------------------------
|
|
3766
|
+
// Multiple sequence alignment (the desktop's alignment track)
|
|
3767
|
+
// --------------------------------------------------------------
|
|
3768
|
+
// Residue palettes, gap handling, conservation scoring and the hover
|
|
3769
|
+
// readout data -- all pure, shared by the viewer's alignment track.
|
|
3770
|
+
// Palettes are the desktop's (MsaColors.java): a Zappo-style 7-class
|
|
3771
|
+
// physico-chemical scheme for amino acids, one colour per base for
|
|
3772
|
+
// nucleotides, muted grey for ambiguity codes, and NO fill for a gap.
|
|
3773
|
+
|
|
3774
|
+
const MSA_GAP_CHARS = {'-': true, '.': true, ' ': true, '~': true};
|
|
3775
|
+
|
|
3776
|
+
// [r, g, b] triples so the letter-ink contrast rule can read them.
|
|
3777
|
+
const MSA_AA_CLASSES = [
|
|
3778
|
+
{residues: 'ILVAM', clazz: 'aliphatic (hydrophobic)', rgb: [240, 170, 170]},
|
|
3779
|
+
{residues: 'FWY', clazz: 'aromatic', rgb: [240, 190, 90]},
|
|
3780
|
+
{residues: 'KRH', clazz: 'positively charged', rgb: [120, 130, 240]},
|
|
3781
|
+
{residues: 'DE', clazz: 'negatively charged', rgb: [230, 100, 100]},
|
|
3782
|
+
{residues: 'STNQ', clazz: 'polar (hydrophilic)', rgb: [120, 200, 120]},
|
|
3783
|
+
{residues: 'PG', clazz: 'conformationally special', rgb: [220, 130, 220]},
|
|
3784
|
+
{residues: 'C', clazz: 'cysteine', rgb: [235, 220, 110]}
|
|
3785
|
+
];
|
|
3786
|
+
const MSA_NT_RGB = {
|
|
3787
|
+
A: [120, 200, 120],
|
|
3788
|
+
C: [120, 130, 240],
|
|
3789
|
+
G: [230, 185, 80],
|
|
3790
|
+
T: [230, 110, 110],
|
|
3791
|
+
U: [230, 110, 110]
|
|
3792
|
+
};
|
|
3793
|
+
const MSA_UNKNOWN_RGB = [205, 205, 205];
|
|
3794
|
+
|
|
3795
|
+
const MSA_AA_RGB = {};
|
|
3796
|
+
const MSA_AA_CLASS = {};
|
|
3797
|
+
MSA_AA_CLASSES.forEach(function (c) {
|
|
3798
|
+
for (let i = 0; i < c.residues.length; ++i) {
|
|
3799
|
+
MSA_AA_RGB[c.residues.charAt(i)] = c.rgb;
|
|
3800
|
+
MSA_AA_CLASS[c.residues.charAt(i)] = c.clazz;
|
|
3801
|
+
}
|
|
3802
|
+
});
|
|
3803
|
+
// The palette triples and the geologic table are handed out by reference;
|
|
3804
|
+
// freezing them keeps one careless caller mutation from permanently
|
|
3805
|
+
// recolouring a whole residue class (or renaming the Cretaceous).
|
|
3806
|
+
MSA_AA_CLASSES.forEach(function (c) {
|
|
3807
|
+
Object.freeze(c.rgb);
|
|
3808
|
+
});
|
|
3809
|
+
Object.keys(MSA_NT_RGB).forEach(function (k) {
|
|
3810
|
+
Object.freeze(MSA_NT_RGB[k]);
|
|
3811
|
+
});
|
|
3812
|
+
Object.freeze(MSA_UNKNOWN_RGB);
|
|
3813
|
+
Object.keys(GEO_SCALE).forEach(function (rank) {
|
|
3814
|
+
GEO_SCALE[rank].forEach(Object.freeze);
|
|
3815
|
+
Object.freeze(GEO_SCALE[rank]);
|
|
3816
|
+
});
|
|
3817
|
+
|
|
3818
|
+
// Kyte-Doolittle hydropathy and full residue names, for the hover readout.
|
|
3819
|
+
const MSA_HYDROPATHY = {
|
|
3820
|
+
I: 4.5, V: 4.2, L: 3.8, F: 2.8, C: 2.5, M: 1.9, A: 1.8, G: -0.4,
|
|
3821
|
+
T: -0.7, S: -0.8, W: -0.9, Y: -1.3, P: -1.6, H: -3.2, E: -3.5,
|
|
3822
|
+
Q: -3.5, D: -3.5, N: -3.5, K: -3.9, R: -4.5
|
|
3823
|
+
};
|
|
3824
|
+
const MSA_AA_NAMES = {
|
|
3825
|
+
A: 'Alanine', R: 'Arginine', N: 'Asparagine', D: 'Aspartic acid',
|
|
3826
|
+
C: 'Cysteine', E: 'Glutamic acid', Q: 'Glutamine', G: 'Glycine',
|
|
3827
|
+
H: 'Histidine', I: 'Isoleucine', L: 'Leucine', K: 'Lysine',
|
|
3828
|
+
M: 'Methionine', F: 'Phenylalanine', P: 'Proline', S: 'Serine',
|
|
3829
|
+
T: 'Threonine', W: 'Tryptophan', Y: 'Tyrosine', V: 'Valine',
|
|
3830
|
+
U: 'Selenocysteine', O: 'Pyrrolysine',
|
|
3831
|
+
B: 'Asparagine or aspartic acid', Z: 'Glutamine or glutamic acid',
|
|
3832
|
+
X: 'Any amino acid', '*': 'stop'
|
|
3833
|
+
};
|
|
3834
|
+
const MSA_NT_NAMES = {
|
|
3835
|
+
A: 'Adenine', C: 'Cytosine', G: 'Guanine', T: 'Thymine',
|
|
3836
|
+
U: 'Uracil', N: 'any base'
|
|
3837
|
+
};
|
|
3838
|
+
const MSA_NT_CLASS = {
|
|
3839
|
+
A: 'purine', G: 'purine', C: 'pyrimidine', T: 'pyrimidine', U: 'pyrimidine'
|
|
3840
|
+
};
|
|
3841
|
+
|
|
3842
|
+
forester.isMsaGap = function (ch) {
|
|
3843
|
+
return MSA_GAP_CHARS[ch] === true;
|
|
3844
|
+
};
|
|
3845
|
+
|
|
3846
|
+
// The residue's fill as an [r,g,b] triple, or null for a gap (drawn as a
|
|
3847
|
+
// faint dash, not a filled cell).
|
|
3848
|
+
forester.msaResidueRgb = function (ch, nucleotide) {
|
|
3849
|
+
if (ch === undefined || ch === null || forester.isMsaGap(ch)) {
|
|
3850
|
+
return null;
|
|
3851
|
+
}
|
|
3852
|
+
let u = ch.toUpperCase();
|
|
3853
|
+
let rgb = nucleotide ? MSA_NT_RGB[u] : MSA_AA_RGB[u];
|
|
3854
|
+
return rgb ? rgb : MSA_UNKNOWN_RGB;
|
|
3855
|
+
};
|
|
3856
|
+
|
|
3857
|
+
// Black or white letter ink over the given cell colour, by luminance --
|
|
3858
|
+
// the same rule the desktop uses.
|
|
3859
|
+
forester.msaLetterInk = function (rgb) {
|
|
3860
|
+
if (!rgb) {
|
|
3861
|
+
return '#404040'; // over a gap / unfilled cell, as on the desktop
|
|
3862
|
+
}
|
|
3863
|
+
let luminance = (0.299 * rgb[0]) + (0.587 * rgb[1]) + (0.114 * rgb[2]);
|
|
3864
|
+
return luminance < 140 ? '#ffffff' : '#000000';
|
|
3865
|
+
};
|
|
3866
|
+
|
|
3867
|
+
// Amino acid or nucleotide? Judged on the actual residues, never on any
|
|
3868
|
+
// declared type: the fraction of non-gap characters that are plausible
|
|
3869
|
+
// bases (ACGTUN) decides.
|
|
3870
|
+
forester.msaIsNucleotide = function (seq) {
|
|
3871
|
+
if (!seq) {
|
|
3872
|
+
return false;
|
|
3873
|
+
}
|
|
3874
|
+
let bases = 0;
|
|
3875
|
+
let residues = 0;
|
|
3876
|
+
for (let i = 0; i < seq.length; ++i) {
|
|
3877
|
+
let ch = seq.charAt(i);
|
|
3878
|
+
if (forester.isMsaGap(ch)) {
|
|
3879
|
+
continue;
|
|
3880
|
+
}
|
|
3881
|
+
++residues;
|
|
3882
|
+
if ('ACGTUNacgtun'.indexOf(ch) >= 0) {
|
|
3883
|
+
++bases;
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
return residues > 0 && (bases / residues) > 0.9;
|
|
3887
|
+
};
|
|
3888
|
+
|
|
3889
|
+
// Per-column conservation over the given rows (gapped strings; a short
|
|
3890
|
+
// row's missing tail counts as gaps). Two measures, both in [0,1]:
|
|
3891
|
+
// 'identity' -- the fraction of ROWS carrying the column's most common
|
|
3892
|
+
// residue (gaps stay in the denominator); 'information' -- the Schneider
|
|
3893
|
+
// & Stephens sequence-logo information content, normalized by log2(K)
|
|
3894
|
+
// (K = 4 or 20) and scaled by the column's non-gap fraction. Consensus is
|
|
3895
|
+
// the most common NON-gap residue, ties broken alphabetically so figures
|
|
3896
|
+
// are reproducible.
|
|
3897
|
+
forester.msaConservation = function (rows, length, measure, nucleotide) {
|
|
3898
|
+
if (!(length >= 0) || !isFinite(length)) {
|
|
3899
|
+
return {scores: [], consensus: []};
|
|
3900
|
+
}
|
|
3901
|
+
length = Math.floor(length);
|
|
3902
|
+
let n = rows.length;
|
|
3903
|
+
let scores = new Array(length);
|
|
3904
|
+
let consensus = new Array(length);
|
|
3905
|
+
let K = nucleotide ? 4 : 20;
|
|
3906
|
+
let log2K = Math.log(K) / Math.LN2;
|
|
3907
|
+
for (let c = 0; c < length; ++c) {
|
|
3908
|
+
let counts = {};
|
|
3909
|
+
let nonGap = 0;
|
|
3910
|
+
for (let r = 0; r < n; ++r) {
|
|
3911
|
+
let row = rows[r];
|
|
3912
|
+
let ch = (row && c < row.length) ? row.charAt(c) : '-';
|
|
3913
|
+
if (forester.isMsaGap(ch)) {
|
|
3914
|
+
continue;
|
|
3915
|
+
}
|
|
3916
|
+
ch = ch.toUpperCase();
|
|
3917
|
+
++nonGap;
|
|
3918
|
+
counts[ch] = (counts[ch] || 0) + 1;
|
|
3919
|
+
}
|
|
3920
|
+
let best = null;
|
|
3921
|
+
let bestCount = 0;
|
|
3922
|
+
Object.keys(counts).sort().forEach(function (ch) {
|
|
3923
|
+
if (counts[ch] > bestCount) {
|
|
3924
|
+
bestCount = counts[ch];
|
|
3925
|
+
best = ch;
|
|
3926
|
+
}
|
|
3927
|
+
});
|
|
3928
|
+
consensus[c] = best;
|
|
3929
|
+
if (n < 1 || nonGap < 1) {
|
|
3930
|
+
scores[c] = 0;
|
|
3931
|
+
} else if (measure === 'information') {
|
|
3932
|
+
let H = 0;
|
|
3933
|
+
Object.keys(counts).forEach(function (ch) {
|
|
3934
|
+
let p = counts[ch] / nonGap;
|
|
3935
|
+
H -= p * (Math.log(p) / Math.LN2);
|
|
3936
|
+
});
|
|
3937
|
+
let info = (log2K - H) / log2K;
|
|
3938
|
+
scores[c] = Math.max(0, info) * (nonGap / n);
|
|
3939
|
+
} else {
|
|
3940
|
+
scores[c] = bestCount / n;
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
return {scores: scores, consensus: consensus};
|
|
3944
|
+
};
|
|
3945
|
+
|
|
3946
|
+
// The hover readout's description of one residue: full name, class (amino
|
|
3947
|
+
// acids), Kyte-Doolittle hydropathy. Returns null for a gap.
|
|
3948
|
+
forester.msaResidueInfo = function (ch, nucleotide) {
|
|
3949
|
+
if (ch === undefined || ch === null || forester.isMsaGap(ch)) {
|
|
3950
|
+
return null;
|
|
3951
|
+
}
|
|
3952
|
+
let u = ch.toUpperCase();
|
|
3953
|
+
if (nucleotide) {
|
|
3954
|
+
return {
|
|
3955
|
+
name: MSA_NT_NAMES[u] || 'ambiguity code',
|
|
3956
|
+
clazz: MSA_NT_CLASS[u] || null,
|
|
3957
|
+
hydropathy: null
|
|
3958
|
+
};
|
|
3959
|
+
}
|
|
3960
|
+
let clazz = MSA_AA_CLASS[u] || 'non-standard / ambiguity code';
|
|
3961
|
+
if (u === 'C') {
|
|
3962
|
+
clazz = 'cysteine (disulphide-forming)';
|
|
3963
|
+
}
|
|
3964
|
+
return {
|
|
3965
|
+
name: MSA_AA_NAMES[u] || 'ambiguity code',
|
|
3966
|
+
clazz: clazz,
|
|
3967
|
+
hydropathy: (MSA_HYDROPATHY[u] !== undefined) ? MSA_HYDROPATHY[u] : null
|
|
3968
|
+
};
|
|
3969
|
+
};
|
|
3970
|
+
|
|
3971
|
+
// The residue's 1-based position within its own UNGAPPED sequence -- the
|
|
3972
|
+
// coordinate that maps back onto the real molecule -- or null on a gap.
|
|
3973
|
+
forester.msaUngappedPosition = function (row, col) {
|
|
3974
|
+
if (!row || col < 0 || col >= row.length || forester.isMsaGap(row.charAt(col))) {
|
|
3975
|
+
return null;
|
|
3976
|
+
}
|
|
3977
|
+
let pos = 0;
|
|
3978
|
+
for (let i = 0; i <= col; ++i) {
|
|
3979
|
+
if (!forester.isMsaGap(row.charAt(i))) {
|
|
3980
|
+
++pos;
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
return pos;
|
|
3984
|
+
};
|
|
3985
|
+
|
|
3986
|
+
|
|
3987
|
+
// --------------------------------------------------------------
|
|
3988
|
+
// Unrooted (equal-angle) layout
|
|
3989
|
+
// --------------------------------------------------------------
|
|
3990
|
+
// The desktop's unrooted display: Meacham's equal-angle algorithm, one
|
|
3991
|
+
// pass, no daylight iterations. The root sits at (0,0) and owns the full
|
|
3992
|
+
// circle [startAngle, startAngle + 2*pi); each child receives a wedge of
|
|
3993
|
+
// its parent's proportional to the external nodes it encloses and sits at
|
|
3994
|
+
// its wedge's mid-angle, at the distance lengthOf(child) gives (branch
|
|
3995
|
+
// length times the caller's scale factor, or a constant for a cladogram).
|
|
3996
|
+
// Angles are ABSOLUTE screen angles (radians, y down), inherited and
|
|
3997
|
+
// subdivided -- never re-referenced to the incoming branch, whose
|
|
3998
|
+
// direction is implicitly each wedge's midpoint.
|
|
3999
|
+
//
|
|
4000
|
+
// Writes ux, uy (position) and uangle (the incoming spoke's screen angle)
|
|
4001
|
+
// onto every node; returns {maxRad}, the largest distance from the root.
|
|
4002
|
+
forester.equalAngleLayout = function (root, startAngle, lengthOf) {
|
|
4003
|
+
let counts = new Map();
|
|
4004
|
+
let countExt = function (n) {
|
|
4005
|
+
let c;
|
|
4006
|
+
if (!n.children || n.children.length < 1) {
|
|
4007
|
+
c = 1;
|
|
4008
|
+
} else {
|
|
4009
|
+
c = 0;
|
|
4010
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
4011
|
+
c += countExt(n.children[i]);
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
counts.set(n, c);
|
|
4015
|
+
return c;
|
|
4016
|
+
};
|
|
4017
|
+
countExt(root);
|
|
4018
|
+
let maxRad = 0;
|
|
4019
|
+
root.ux = 0;
|
|
4020
|
+
root.uy = 0;
|
|
4021
|
+
root.uangle = startAngle;
|
|
4022
|
+
let recurse = function (n, low, high) {
|
|
4023
|
+
if (!n.children || n.children.length < 1) {
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
4026
|
+
let total = counts.get(n);
|
|
4027
|
+
let current = low;
|
|
4028
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
4029
|
+
let desc = n.children[i];
|
|
4030
|
+
let arc = (counts.get(desc) / total) * (high - low);
|
|
4031
|
+
let mid = current + (arc / 2);
|
|
4032
|
+
let len = lengthOf(desc);
|
|
4033
|
+
desc.ux = n.ux + (Math.cos(mid) * len);
|
|
4034
|
+
desc.uy = n.uy + (Math.sin(mid) * len);
|
|
4035
|
+
desc.uangle = mid;
|
|
4036
|
+
let r = Math.sqrt((desc.ux * desc.ux) + (desc.uy * desc.uy));
|
|
4037
|
+
if (r > maxRad) {
|
|
4038
|
+
maxRad = r;
|
|
4039
|
+
}
|
|
4040
|
+
recurse(desc, current, current + arc);
|
|
4041
|
+
current += arc;
|
|
4042
|
+
}
|
|
4043
|
+
};
|
|
4044
|
+
recurse(root, startAngle, startAngle + (2 * Math.PI));
|
|
4045
|
+
return {maxRad: maxRad};
|
|
4046
|
+
};
|
|
4047
|
+
|
|
4048
|
+
|
|
4049
|
+
// --------------------------------------------------------------
|
|
4050
|
+
// Label-field suggestion
|
|
4051
|
+
// --------------------------------------------------------------
|
|
4052
|
+
// Decides which of the three label groups (node name, taxonomy, sequence)
|
|
4053
|
+
// should START checked when a tree is loaded. Two rules, judged on the
|
|
4054
|
+
// external nodes' actual label text (supplied by the caller's extractors,
|
|
4055
|
+
// so the decision is made on exactly what would be printed):
|
|
4056
|
+
//
|
|
4057
|
+
// 1. Redundancy: a field whose text is CONTAINED in another checked
|
|
4058
|
+
// field's text (case-insensitively, ignoring spaces and underscores)
|
|
4059
|
+
// for at least 90% of the nodes carrying both adds nothing and starts
|
|
4060
|
+
// unchecked. On mutual containment the earlier field in the priority
|
|
4061
|
+
// order name > taxonomy > sequence is kept.
|
|
4062
|
+
//
|
|
4063
|
+
// 2. Length budget: if the median combined label of the remaining fields
|
|
4064
|
+
// still exceeds 50 characters, only the single most IDENTIFYING field
|
|
4065
|
+
// stays: the one with the highest ratio of distinct values to ALL
|
|
4066
|
+
// external nodes (a label's job is telling nodes apart -- and a field
|
|
4067
|
+
// carried by only a few tips cannot identify the rest, however unique
|
|
4068
|
+
// its few values are). Within a tie (0.05) the priority order wins,
|
|
4069
|
+
// unless a later field is SUBSTANTIALLY more economical (median length
|
|
4070
|
+
// under 60% of the leader's).
|
|
4071
|
+
//
|
|
4072
|
+
// A field with no values at all is never checked. This only decides the
|
|
4073
|
+
// INITIAL state; the caller's UI stays free to override.
|
|
4074
|
+
//
|
|
4075
|
+
// extractors: {name, taxonomy, sequence}, each a function(externalNode)
|
|
4076
|
+
// returning the text that field would contribute to the node's label, or
|
|
4077
|
+
// null / '' when it contributes nothing.
|
|
4078
|
+
forester.suggestLabelFields = function (root, extractors) {
|
|
4079
|
+
const FIELDS = ['name', 'taxonomy', 'sequence'];
|
|
4080
|
+
const CONTAINMENT_MIN = 0.9;
|
|
4081
|
+
const BUDGET_CHARS = 50;
|
|
4082
|
+
const MIN_PAIRS = 2;
|
|
4083
|
+
const TIE_EPSILON = 0.05;
|
|
4084
|
+
const LENGTH_ADVANTAGE = 0.6;
|
|
4085
|
+
let ext = forester.getAllExternalNodes(root);
|
|
4086
|
+
let norm = function (s) {
|
|
4087
|
+
return s.toLowerCase().replace(/[\s_]+/g, '');
|
|
4088
|
+
};
|
|
4089
|
+
let median = function (arr) {
|
|
4090
|
+
if (arr.length < 1) {
|
|
4091
|
+
return 0;
|
|
4092
|
+
}
|
|
4093
|
+
let a = arr.slice().sort(function (x, y) {
|
|
4094
|
+
return x - y;
|
|
4095
|
+
});
|
|
4096
|
+
let m = a.length >> 1;
|
|
4097
|
+
return (a.length % 2) ? a[m] : (a[m - 1] + a[m]) / 2;
|
|
4098
|
+
};
|
|
4099
|
+
let vals = {};
|
|
4100
|
+
FIELDS.forEach(function (f) {
|
|
4101
|
+
let get = extractors ? extractors[f] : null;
|
|
4102
|
+
vals[f] = ext.map(function (n) {
|
|
4103
|
+
let v = get ? get(n) : null;
|
|
4104
|
+
v = (v === undefined || v === null) ? '' : String(v).trim();
|
|
4105
|
+
return v.length > 0 ? v : null;
|
|
4106
|
+
});
|
|
4107
|
+
});
|
|
4108
|
+
let stats = {};
|
|
4109
|
+
FIELDS.forEach(function (f) {
|
|
4110
|
+
let present = vals[f].filter(Boolean);
|
|
4111
|
+
stats[f] = {
|
|
4112
|
+
count: present.length,
|
|
4113
|
+
medianLength: median(present.map(function (s) {
|
|
4114
|
+
return s.length;
|
|
4115
|
+
})),
|
|
4116
|
+
distinctRatio: (present.length > 0 && ext.length > 0)
|
|
4117
|
+
? (new Set(present.map(norm)).size / ext.length) : 0
|
|
4118
|
+
};
|
|
4119
|
+
});
|
|
4120
|
+
let checked = {};
|
|
4121
|
+
FIELDS.forEach(function (f) {
|
|
4122
|
+
checked[f] = stats[f].count > 0;
|
|
4123
|
+
});
|
|
4124
|
+
|
|
4125
|
+
let containedFrac = function (inner, outer) {
|
|
4126
|
+
let both = 0;
|
|
4127
|
+
let contained = 0;
|
|
4128
|
+
for (let i = 0; i < ext.length; ++i) {
|
|
4129
|
+
let a = vals[outer][i];
|
|
4130
|
+
let b = vals[inner][i];
|
|
4131
|
+
if (a && b) {
|
|
4132
|
+
++both;
|
|
4133
|
+
if (norm(a).indexOf(norm(b)) >= 0) {
|
|
4134
|
+
++contained;
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
return both >= MIN_PAIRS ? contained / both : 0;
|
|
4139
|
+
};
|
|
4140
|
+
for (let i = 0; i < FIELDS.length; ++i) {
|
|
4141
|
+
for (let j = i + 1; j < FIELDS.length; ++j) {
|
|
4142
|
+
let a = FIELDS[i];
|
|
4143
|
+
let b = FIELDS[j];
|
|
4144
|
+
if (!checked[a] || !checked[b]) {
|
|
4145
|
+
continue;
|
|
4146
|
+
}
|
|
4147
|
+
// on mutual containment this still drops b: a is earlier in
|
|
4148
|
+
// the keep-priority order
|
|
4149
|
+
if (containedFrac(b, a) >= CONTAINMENT_MIN) {
|
|
4150
|
+
checked[b] = false;
|
|
4151
|
+
} else if (containedFrac(a, b) >= CONTAINMENT_MIN) {
|
|
4152
|
+
checked[a] = false;
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
let combined = [];
|
|
4158
|
+
for (let i = 0; i < ext.length; ++i) {
|
|
4159
|
+
let parts = [];
|
|
4160
|
+
FIELDS.forEach(function (f) {
|
|
4161
|
+
if (checked[f] && vals[f][i]) {
|
|
4162
|
+
parts.push(vals[f][i]);
|
|
4163
|
+
}
|
|
4164
|
+
});
|
|
4165
|
+
if (parts.length > 0) {
|
|
4166
|
+
combined.push(parts.join(' | ').length);
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
let medianCombined = median(combined);
|
|
4170
|
+
let kept = FIELDS.filter(function (f) {
|
|
4171
|
+
return checked[f];
|
|
4172
|
+
});
|
|
4173
|
+
if (medianCombined > BUDGET_CHARS && kept.length > 1) {
|
|
4174
|
+
// top distinct-ratio first (a greedy pairwise chain is not
|
|
4175
|
+
// transitive within the tie window); everything within the tie
|
|
4176
|
+
// window then competes by priority order, with the economy rule
|
|
4177
|
+
// as the only override
|
|
4178
|
+
let maxDr = 0;
|
|
4179
|
+
kept.forEach(function (f) {
|
|
4180
|
+
if (stats[f].distinctRatio > maxDr) {
|
|
4181
|
+
maxDr = stats[f].distinctRatio;
|
|
4182
|
+
}
|
|
4183
|
+
});
|
|
4184
|
+
let contenders = kept.filter(function (f) {
|
|
4185
|
+
return stats[f].distinctRatio >= maxDr - TIE_EPSILON;
|
|
4186
|
+
});
|
|
4187
|
+
let best = contenders[0];
|
|
4188
|
+
for (let k = 1; k < contenders.length; ++k) {
|
|
4189
|
+
if (stats[contenders[k]].medianLength < LENGTH_ADVANTAGE * stats[best].medianLength) {
|
|
4190
|
+
best = contenders[k];
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
FIELDS.forEach(function (f) {
|
|
4194
|
+
checked[f] = (f === best);
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
return {
|
|
4198
|
+
showNodeName: checked.name,
|
|
4199
|
+
showTaxonomy: checked.taxonomy,
|
|
4200
|
+
showSequence: checked.sequence,
|
|
4201
|
+
stats: {fields: stats, medianCombinedLength: medianCombined}
|
|
4202
|
+
};
|
|
4203
|
+
};
|
|
4204
|
+
|
|
4205
|
+
|
|
1744
4206
|
// --------------------------------------------------------------
|
|
1745
4207
|
// For exporting
|
|
1746
4208
|
// --------------------------------------------------------------
|