archaeopteryx 3.2.1 → 3.3.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/README.md +96 -8
- package/archaeopteryx.js +1106 -183
- package/forester.js +895 -437
- package/package.json +3 -3
package/forester.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.3.0
|
|
24
24
|
// 2026-09-10
|
|
25
25
|
//
|
|
26
26
|
// forester.js is a general suite for dealing with phylogenetic trees.
|
|
@@ -35,6 +35,34 @@
|
|
|
35
35
|
//
|
|
36
36
|
// Dependencies: none
|
|
37
37
|
//
|
|
38
|
+
// REMOVED FROM THE PUBLIC API (2026-09-11). Each was exported, called by
|
|
39
|
+
// nothing -- not this library, not Archaeopteryx.js, not the test suite --
|
|
40
|
+
// and is recorded here so that a later "forester used to have X" can be
|
|
41
|
+
// matched to a decision instead of investigated from scratch:
|
|
42
|
+
//
|
|
43
|
+
// collapse, unCollapse the subtree-collapse feature they served was
|
|
44
|
+
// removed from the viewer; they were the only
|
|
45
|
+
// writers of node._children, whose handling
|
|
46
|
+
// went with them
|
|
47
|
+
// getChildren returned _children in preference to children,
|
|
48
|
+
// so it only ever meant anything while collapse
|
|
49
|
+
// existed
|
|
50
|
+
// findByTaxonomyCode superseded by the search machinery
|
|
51
|
+
// findByTaxonomyScientificName (searchWithSpec and friends)
|
|
52
|
+
// calcAverageTreeHeight never used by any caller
|
|
53
|
+
// calcMaxDepth
|
|
54
|
+
// calcBranchLengthSimpleStatistics
|
|
55
|
+
// collectPropertyRefs superseded by visualizationCandidates
|
|
56
|
+
// isHasNodeData
|
|
57
|
+
// removeMaxBranchLength
|
|
58
|
+
// getOneDistinctTaxonomy
|
|
59
|
+
//
|
|
60
|
+
// forester.js ships inside the archaeopteryx npm package, so an outside caller
|
|
61
|
+
// could in principle have used any of these. That is the cost that was weighed
|
|
62
|
+
// and accepted: 12 of 80 exports earned nothing here, and dead code is not free
|
|
63
|
+
// -- it has to keep working, keep linting clean, and be considered in every
|
|
64
|
+
// refactor.
|
|
65
|
+
//
|
|
38
66
|
//
|
|
39
67
|
// In the following is a basic example shows how to parse a New Hampshire formatted String
|
|
40
68
|
// into to a object representing a phylogenetic tree.
|
|
@@ -75,7 +103,6 @@
|
|
|
75
103
|
const NUMBERS_ONLY_PATTERN = /^[-+]?[0-9\\.]+$/;
|
|
76
104
|
|
|
77
105
|
|
|
78
|
-
|
|
79
106
|
/**
|
|
80
107
|
* Sets links to parent nodes for all nodes in a
|
|
81
108
|
* phyloXML-based tree object
|
|
@@ -134,17 +161,12 @@
|
|
|
134
161
|
* @param node - The root of the subtree to traverse.
|
|
135
162
|
* @param fn - The function to apply.
|
|
136
163
|
*/
|
|
164
|
+
// Kept as a distinct name because callers use both, but it IS
|
|
165
|
+
// preOrderTraversal now: the two differed only in that this one also
|
|
166
|
+
// descended into a collapsed node's hidden _children, and nothing can
|
|
167
|
+
// collapse a node any more. See the removed-API note at the top.
|
|
137
168
|
forester.preOrderTraversalAll = function (node, fn) {
|
|
138
|
-
|
|
139
|
-
if (node.children) {
|
|
140
|
-
for (let i = node.children.length - 1; i >= 0; --i) {
|
|
141
|
-
forester.preOrderTraversalAll(node.children[i], fn);
|
|
142
|
-
}
|
|
143
|
-
} else if (node._children) {
|
|
144
|
-
for (let ii = node._children.length - 1; ii >= 0; --ii) {
|
|
145
|
-
forester.preOrderTraversalAll(node._children[ii], fn);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
169
|
+
forester.preOrderTraversal(node, fn);
|
|
148
170
|
};
|
|
149
171
|
|
|
150
172
|
forester.postOrderTraversalAll = function (node, fn) {
|
|
@@ -153,11 +175,6 @@
|
|
|
153
175
|
for (let i = 0; i < l; ++i) {
|
|
154
176
|
forester.postOrderTraversalAll(node.children[i], fn);
|
|
155
177
|
}
|
|
156
|
-
} else if (node._children) {
|
|
157
|
-
let ll = node._children.length;
|
|
158
|
-
for (let ii = 0; ii < ll; ++ii) {
|
|
159
|
-
forester.postOrderTraversalAll(node._children[ii], fn);
|
|
160
|
-
}
|
|
161
178
|
}
|
|
162
179
|
fn(node);
|
|
163
180
|
};
|
|
@@ -173,27 +190,6 @@
|
|
|
173
190
|
return found;
|
|
174
191
|
};
|
|
175
192
|
|
|
176
|
-
forester.findByTaxonomyCode = function (node, code) {
|
|
177
|
-
let found = [];
|
|
178
|
-
forester.preOrderTraversalAll(node, function (n) {
|
|
179
|
-
if (n.taxonomies && n.taxonomies.length > 0 && n.taxonomies[0].code === code) {
|
|
180
|
-
found.push(n);
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
return found;
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
forester.findByTaxonomyScientificName = function (node, scientificName) {
|
|
187
|
-
let found = [];
|
|
188
|
-
forester.preOrderTraversalAll(node, function (n) {
|
|
189
|
-
if (n.taxonomies && n.taxonomies.length > 0 && n.taxonomies[0].scientific_name === scientificName) {
|
|
190
|
-
found.push(n);
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
return found;
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
|
|
197
193
|
/**
|
|
198
194
|
* To delete a sub-tree or external node.
|
|
199
195
|
*
|
|
@@ -222,13 +218,6 @@
|
|
|
222
218
|
p.children.splice(i, 1);
|
|
223
219
|
}
|
|
224
220
|
}
|
|
225
|
-
if ((p._children) && (p._children.length > 1)) {
|
|
226
|
-
let ii = p._children.indexOf(nodeToDelete);
|
|
227
|
-
if (ii !== -1) {
|
|
228
|
-
p._children.splice(ii, 1);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
221
|
if (p.children.length === 1) {
|
|
233
222
|
let pp = p.parent;
|
|
234
223
|
let cni = forester.getChildNodeIndex(pp, p);
|
|
@@ -563,28 +552,6 @@
|
|
|
563
552
|
};
|
|
564
553
|
|
|
565
554
|
|
|
566
|
-
forester.getChildren = function (node) {
|
|
567
|
-
return node._children ? node._children : (node.children ? node.children : []);
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
forester.calcAverageTreeHeight = function (node, externalDescendants) {
|
|
572
|
-
let c = externalDescendants ? externalDescendants : forester.getAllExternalNodes(node);
|
|
573
|
-
let l = c.length;
|
|
574
|
-
let s = 0;
|
|
575
|
-
for (let i = 0; i < l; ++i) {
|
|
576
|
-
let cc = c[i];
|
|
577
|
-
while (cc !== node) {
|
|
578
|
-
if (cc.branch_length > 0) {
|
|
579
|
-
s += cc.branch_length;
|
|
580
|
-
}
|
|
581
|
-
cc = cc.parent;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
return s / l;
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
|
|
588
555
|
// ------------------------------------------------------------------
|
|
589
556
|
// Automatic visualization candidates
|
|
590
557
|
// ------------------------------------------------------------------
|
|
@@ -594,50 +561,70 @@
|
|
|
594
561
|
// caller-supplied "nodeVisualizations" configuration: the tree is the
|
|
595
562
|
// only input.
|
|
596
563
|
//
|
|
597
|
-
// Only external nodes are considered
|
|
598
|
-
//
|
|
599
|
-
//
|
|
564
|
+
// Only external nodes are considered.
|
|
565
|
+
//
|
|
566
|
+
// Candidacy is decided on the TREE, once: at launch, and again only after
|
|
567
|
+
// the user edits it (deletes a subtree). A VIEW -- the subtree the user
|
|
568
|
+
// switches into -- never re-decides it; it only re-summarizes each
|
|
569
|
+
// candidate over the tips on screen (visualizationSummary). A clade is by
|
|
570
|
+
// nature a set of tips sharing a value, and one value is refused, so
|
|
571
|
+
// re-classifying per view would (and until 2026-09-12 did) drop the
|
|
572
|
+
// chosen colouring in most clades. The desktop works the same way.
|
|
600
573
|
//
|
|
601
574
|
// Candidates: taxonomy code / scientific name / common name, sequence
|
|
602
|
-
// name / symbol / gene name, and node properties
|
|
603
|
-
// The "style:" namespace is
|
|
604
|
-
// for per-node rendering
|
|
605
|
-
//
|
|
575
|
+
// name / symbol / gene name, and node properties whose applies_to is
|
|
576
|
+
// "node" or "clade" (isNodeScopedProperty). The "style:" namespace is
|
|
577
|
+
// never a candidate -- the desktop reserves it for per-node rendering
|
|
578
|
+
// instructions (font_color, node_shape, ...), so treating it as data
|
|
579
|
+
// would mean colouring by a colour. Nor are record-keeping fields, by
|
|
580
|
+
// NAME (VIS_EXCLUDED_WORD_RES below): authors, sets, data-use terms,
|
|
581
|
+
// ids, accessions, identifiers, taxon ids.
|
|
606
582
|
//
|
|
607
583
|
// The rules, tuned against the real ViPR / BV-BRC trees in docs/data
|
|
608
|
-
// (which test/visualization_test.js holds as executable fixtures)
|
|
584
|
+
// (which test/visualization_test.js holds as executable fixtures) and
|
|
585
|
+
// pinned for the desktop by test/fixtures/vis-contract.tsv (names) and
|
|
586
|
+
// test/fixtures/vis-trees.tsv (data):
|
|
609
587
|
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
// field on 9% of nodes (state_province) says nothing.
|
|
614
|
-
// Nodes without a value simply keep the default look.
|
|
588
|
+
// multi-value a ref carried more than once by any external node is
|
|
589
|
+
// not a candidate: a node cannot be two colours, and
|
|
590
|
+
// picking one silently is worse than not offering it.
|
|
615
591
|
// repetition at least 2 distinct values (1 paints the whole tree
|
|
616
|
-
// alike)
|
|
617
|
-
//
|
|
592
|
+
// alike). A CATEGORICAL field with as many distinct
|
|
593
|
+
// values as the tree has tips is an identifier and is
|
|
594
|
+
// refused; a NUMERIC one is kept, because a measurement
|
|
595
|
+
// is naturally one value per sample.
|
|
596
|
+
// coverage a field on fewer than 2/3 of the tips is SPARSE: offered,
|
|
597
|
+
// ranked after everything dense, never opening a tree that
|
|
598
|
+
// has anything denser. Database exports are always patchy,
|
|
599
|
+
// and a half-annotated field is often the interesting one.
|
|
618
600
|
// categorical <= 20 distinct values -> Color. Above ~12 the reader
|
|
619
601
|
// leans on the legend, but the real trees cluster at
|
|
620
602
|
// 15-17 (host names, countries, taxonomy codes).
|
|
621
|
-
// wide 21+ distinct values are still offered --
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
// distinct/covered <=
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
// numeric every value
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
603
|
+
// wide 21+ distinct values are still offered -- every value
|
|
604
|
+
// gets a colour and the LEGEND caps the display -- but
|
|
605
|
+
// never open a tree. If they repeat reasonably
|
|
606
|
+
// (distinct/covered <= 3/5) they rank after the numerics;
|
|
607
|
+
// if they barely repeat they are NEAR-UNIQUE and rank at
|
|
608
|
+
// the very bottom (strains, species names, dates).
|
|
609
|
+
// numeric every value matches VIS_NUMERIC_RE, a decimal grammar
|
|
610
|
+
// pinned below; spellings of one number ("1", "1.0") fold
|
|
611
|
+
// to one value. Up to 10 distinct values default to
|
|
612
|
+
// individual colours -- numbers that few are usually codes
|
|
613
|
+
// (HA/NA subtypes), and ten is what the palette's strong
|
|
614
|
+
// first half holds -- 11 to 20 default to a Color-range,
|
|
615
|
+
// and both of those may be switched in the legend; above
|
|
616
|
+
// 20 it is a range with no switch. The band is computed
|
|
617
|
+
// per VIEW. No uniqueness test for numbers.
|
|
635
618
|
// shape <= 7 distinct values (d3 v7 has exactly 7 distinct
|
|
636
619
|
// fill symbols), numeric or not -- two years as two
|
|
637
620
|
// shapes is genuinely useful.
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
621
|
+
// in/out-group offered, ranked after the numerics: a fact about the
|
|
622
|
+
// analysis the person who rooted the tree already knows.
|
|
623
|
+
//
|
|
624
|
+
// Ranking, best first (tierOf below): 0 clean categorical, 1 numeric,
|
|
625
|
+
// 2 wide, 3 in/out-group, 4 sparse, 5 near-unique; within a tier by
|
|
626
|
+
// coverage x balance. The tree OPENS with the first candidate that is
|
|
627
|
+
// not wide (openingVisualization).
|
|
641
628
|
//
|
|
642
629
|
const VIS_MIN_COVERAGE_NUM = 2; // coverage >= 2/3, held as a
|
|
643
630
|
const VIS_MIN_COVERAGE_DEN = 3; // fraction so the test is integer-exact
|
|
@@ -646,9 +633,15 @@
|
|
|
646
633
|
const VIS_NUMERIC_CATEGORY_MAX = 10; // <= this many distinct numbers -> colours by default
|
|
647
634
|
const VIS_WIDE_REPEAT_NUM = 3; // wide categorical: distinct/covered <= 0.6,
|
|
648
635
|
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
636
|
const VIS_EXCLUDED_REF_PREFIX = 'style:';
|
|
637
|
+
// What "numeric" means, spelled out: an optional sign, decimal digits with
|
|
638
|
+
// an optional fraction, an optional exponent. PINNED as a grammar because
|
|
639
|
+
// the host language's own idea of a number is not portable: JavaScript's
|
|
640
|
+
// Number() accepts "0x1A" and "0b101", Java's parseDouble accepts
|
|
641
|
+
// "Infinity" and "NaN", and a field of either would be a gradient in one
|
|
642
|
+
// program and a category in the other. Values are trimmed before this
|
|
643
|
+
// sees them.
|
|
644
|
+
const VIS_NUMERIC_RE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
|
652
645
|
// Refs that are never a visualization, however their values distribute.
|
|
653
646
|
// A taxon identifier repeats like a category and passes every statistical
|
|
654
647
|
// test above, yet says nothing a colour could carry that the species name
|
|
@@ -657,12 +650,95 @@
|
|
|
657
650
|
// ncbi_taxid, taxon_id and taxonomy_id all count.
|
|
658
651
|
const VIS_EXCLUDED_LOCAL_NAME_RE = /(taxonomy|taxon|tax)id$/;
|
|
659
652
|
|
|
653
|
+
// The rest are matched on THE NAME THE MENU SHOWS, split into words.
|
|
654
|
+
//
|
|
655
|
+
// Matching the displayed name rather than the raw ref is the point, not a
|
|
656
|
+
// convenience: `prettifyVisLabel` splits camelCase, so the ref
|
|
657
|
+
// `dataUseTerms` reaches the user as "Data Use Terms". A first version of
|
|
658
|
+
// this rule matched the raw ref, and so read that as one word and offered
|
|
659
|
+
// it -- the menu said "Data Use Terms" while the rule saw "datauseterms".
|
|
660
|
+
// Going through the same function means the rule and the label cannot
|
|
661
|
+
// drift apart again.
|
|
662
|
+
//
|
|
663
|
+
// On top of that, every run of non-alphanumerics -- whitespace, '-', '_',
|
|
664
|
+
// punctuation -- is one word break. Separator-aware on purpose, and that
|
|
665
|
+
// is the whole difference from the rule above: "Authority" and "Dataset"
|
|
666
|
+
// are ordinary properties and must survive, while "Abbr Authors" and
|
|
667
|
+
// "Region Set" must not. Reading punctuation as a break also makes the
|
|
668
|
+
// literal column name "Author(s)" come out as the words "author s".
|
|
669
|
+
//
|
|
670
|
+
// What these have in common is that they describe the RECORD rather than
|
|
671
|
+
// the organism: who deposited it, which collection it belongs to, what may
|
|
672
|
+
// be done with it and until when, and what to call it in a database. They repeat like
|
|
673
|
+
// categories and so pass every statistical test, but a colour spent on one
|
|
674
|
+
// says nothing about the tree.
|
|
675
|
+
const VIS_EXCLUDED_WORD_RES = [
|
|
676
|
+
/(^| )authors?( |$)/, // Author, Authors, Author(s), Abbr Authors
|
|
677
|
+
/(^| )set( |$)/, // Region Set -- but not Dataset or Subset
|
|
678
|
+
/(^| )data use( |$)/, // Data use, Data-Use Terms
|
|
679
|
+
/(^| )restricted ?until( |$)/, // Restricted Until, restricted_until, restrictedUntil: a data-use embargo date
|
|
680
|
+
/(^| )ids?( |$)/, // genome_id, patric_id, GenomeID, Feature_ID
|
|
681
|
+
/accessions?$/, // ...Accession, ...Accessions
|
|
682
|
+
/identifiers?$/ // ...Identifier, ...Identifiers
|
|
683
|
+
];
|
|
684
|
+
// A note on why "id" is a WORD rule and accession/identifier are suffix
|
|
685
|
+
// rules, since the inconsistency is deliberate. "accession" and
|
|
686
|
+
// "identifier" are long enough that a name ending in those letters is one:
|
|
687
|
+
// GBAccession is an accession. "id" is two letters and ends a great many
|
|
688
|
+
// ordinary words -- Plasmid, Hybrid, Nucleic Acid, Lipid, Steroid, Orchid,
|
|
689
|
+
// Centroid, Grid, Rapid -- any of which is a plausible property on a
|
|
690
|
+
// biological tree. Matched as a word it catches genome_id, patric_id,
|
|
691
|
+
// GenomeID, genomeId and Feature_ID while leaving every one of those
|
|
692
|
+
// alone.
|
|
693
|
+
|
|
694
|
+
// Offered, but never the tree's OPENING visualization unless it is the
|
|
695
|
+
// only thing on offer. "In-Group" and "Out-Group" say which tips were the
|
|
696
|
+
// study set and which were there to root it -- a fact about the ANALYSIS,
|
|
697
|
+
// and one the person who rooted the tree already knows. They are also
|
|
698
|
+
// typically an even two-value split with full coverage, which is exactly
|
|
699
|
+
// the shape that wins the automatic pick, so without this they open trees
|
|
700
|
+
// coloured by the least surprising thing in them.
|
|
701
|
+
//
|
|
702
|
+
// Both hyphenations and both one-word spellings, since the rule is about a
|
|
703
|
+
// name rather than a punctuation style: In-Group, InGroup, In Group,
|
|
704
|
+
// in_group, ingroup, and the same six for out. Plural too.
|
|
705
|
+
//
|
|
706
|
+
// Anchored as WORDS, which is not decoration: "Within Group" contains the
|
|
707
|
+
// substring "in group" and must keep leading.
|
|
708
|
+
const VIS_DEPRIORITIZED_WORD_RES = [
|
|
709
|
+
/(^| )(in|out) groups?( |$)/,
|
|
710
|
+
/(^| )(in|out)groups?( |$)/
|
|
711
|
+
];
|
|
712
|
+
|
|
713
|
+
// The name as the MENU shows it, split into words -- see the note on
|
|
714
|
+
// VIS_EXCLUDED_WORD_RES for why the displayed name is the right input.
|
|
715
|
+
function visNameWords(ref) {
|
|
716
|
+
let local = ref.indexOf(':') >= 0 ? ref.substring(ref.indexOf(':') + 1) : ref;
|
|
717
|
+
return prettifyVisLabel(local).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function matchesAny(res, words) {
|
|
721
|
+
for (let i = 0, l = res.length; i !== l; ++i) {
|
|
722
|
+
if (res[i].test(words)) {
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
|
|
660
729
|
function visExcludedRef(ref) {
|
|
661
730
|
if (ref.indexOf(VIS_EXCLUDED_REF_PREFIX) === 0) {
|
|
662
731
|
return true;
|
|
663
732
|
}
|
|
664
|
-
let local = ref.substring(ref.indexOf(':') + 1)
|
|
665
|
-
|
|
733
|
+
let local = ref.substring(ref.indexOf(':') + 1);
|
|
734
|
+
if (VIS_EXCLUDED_LOCAL_NAME_RE.test(local.toLowerCase().replace(/[^a-z0-9]/g, ''))) {
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
return matchesAny(VIS_EXCLUDED_WORD_RES, visNameWords(ref));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function visDeprioritizedRef(ref) {
|
|
741
|
+
return matchesAny(VIS_DEPRIORITIZED_WORD_RES, visNameWords(ref));
|
|
666
742
|
}
|
|
667
743
|
|
|
668
744
|
// ---- display normalization --------------------------------------------
|
|
@@ -683,6 +759,12 @@
|
|
|
683
759
|
// "Saimiri boliviensis (squirrel monkey; voucher: X)" reads as
|
|
684
760
|
// "Saimiri boliviensis" rather than dangling.
|
|
685
761
|
//
|
|
762
|
+
// A value that normalizes to NOTHING -- "_", "___", a host that is only
|
|
763
|
+
// its ";" qualifier -- is no value at all, everywhere: the tip is not
|
|
764
|
+
// covered, no group is made, the legend shows no row for it, and it does
|
|
765
|
+
// not count as the ref being carried twice. (Decided 2026-09-12; the
|
|
766
|
+
// desktop already read it that way.)
|
|
767
|
+
//
|
|
686
768
|
// Matching is WHOLE-VALUE only (after a trailing parenthetical is tried
|
|
687
769
|
// stripped: "Bos taurus (cattle)" looks up "bos taurus") -- never by
|
|
688
770
|
// substring, so "ferret badger" (a Melogale, not a ferret) and
|
|
@@ -779,6 +861,19 @@
|
|
|
779
861
|
return hit || s;
|
|
780
862
|
}
|
|
781
863
|
|
|
864
|
+
// How a numeric field draws by default, from how many distinct values it
|
|
865
|
+
// shows: up to VIS_NUMERIC_CATEGORY_MAX as individual colours (numbers
|
|
866
|
+
// that few are usually codes), up to VIS_MAX_COLOR_CATEGORIES as a
|
|
867
|
+
// gradient the legend can switch back to colours, above that a gradient
|
|
868
|
+
// only. Computed per VIEW: ten distinct years inside a clade draw better
|
|
869
|
+
// as ten colours than as a slice of the whole tree's gradient.
|
|
870
|
+
function visNumericModes(distinct) {
|
|
871
|
+
return {
|
|
872
|
+
colorMode: distinct <= VIS_NUMERIC_CATEGORY_MAX ? 'category' : 'range',
|
|
873
|
+
switchable: distinct <= VIS_MAX_COLOR_CATEGORIES
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
782
877
|
// Fixed candidate slots for the phyloXML elements (properties use their
|
|
783
878
|
// ref). CROSS-IMPLEMENTATION CONTRACT with desktop Archaeopteryx: the
|
|
784
879
|
// ids here (tax:code, seq:name, ...) and the rule that taxonomy/sequence
|
|
@@ -819,12 +914,26 @@
|
|
|
819
914
|
return prettifyVisLabel(local);
|
|
820
915
|
};
|
|
821
916
|
|
|
917
|
+
// Is this property about the node itself? phyloXML's applies_to has six
|
|
918
|
+
// values and two of them describe the node's own data at a tip: 'node',
|
|
919
|
+
// and 'clade' -- the clade rooted at an external node IS that node. Tools
|
|
920
|
+
// differ on which they write: BV-BRC/ViPR exports say node, the repseq
|
|
921
|
+
// pipeline says clade for every field, and until 2026-09-12 this library
|
|
922
|
+
// accepted only node, so a repseq tree with country, host, subtype and
|
|
923
|
+
// year on every tip offered NOTHING to colour by -- four of the thirteen
|
|
924
|
+
// trees in test_trees, silently. The desktop applies no filter at all.
|
|
925
|
+
// 'parent_branch' is deliberately still out: that is the branch above the
|
|
926
|
+
// node, and colouring a node by its branch's property would be wrong.
|
|
927
|
+
forester.isNodeScopedProperty = function (p) {
|
|
928
|
+
return p.applies_to === 'node' || p.applies_to === 'clade';
|
|
929
|
+
};
|
|
930
|
+
|
|
822
931
|
forester.visualizationCandidates = function (tree) {
|
|
823
932
|
let total = 0;
|
|
824
933
|
let stats = Object.create(null); // id -> {kind, ref, label, nodes, values:Set, multi}; null-proto: ids embed file refs
|
|
825
934
|
|
|
826
935
|
forester.preOrderTraversalAll(tree, function (n) {
|
|
827
|
-
if (n.children
|
|
936
|
+
if (n.children) {
|
|
828
937
|
return;
|
|
829
938
|
}
|
|
830
939
|
total++;
|
|
@@ -855,37 +964,50 @@
|
|
|
855
964
|
if (n.properties) {
|
|
856
965
|
for (let i = 0; i < n.properties.length; ++i) {
|
|
857
966
|
let p = n.properties[i];
|
|
858
|
-
if (p.ref && p
|
|
967
|
+
if (p.ref && forester.isNodeScopedProperty(p) && !visExcludedRef(p.ref)) {
|
|
859
968
|
add('prop:' + p.ref, 'property', p.ref, null, p.value);
|
|
860
969
|
}
|
|
861
970
|
}
|
|
862
971
|
}
|
|
863
972
|
Object.keys(perNode).forEach(function (id) {
|
|
864
973
|
let g = perNode[id];
|
|
974
|
+
let cut = g.kind === 'property' ? visQualifierCut(g.ref) : null;
|
|
975
|
+
// properties group under their normalized display form;
|
|
976
|
+
// taxonomy / sequence elements are curated text, verbatim.
|
|
977
|
+
// A value that folds to NOTHING ("_", a host that is only its
|
|
978
|
+
// ";" qualifier) is NO VALUE, exactly like the empty string
|
|
979
|
+
// dropped above: it does not cover the tip, makes no group,
|
|
980
|
+
// and does not count as "carried twice". Until 2026-09-12 the
|
|
981
|
+
// tip was counted covered here and the legend then showed a
|
|
982
|
+
// row with an empty label (Christian: fix; the desktop had it
|
|
983
|
+
// right).
|
|
984
|
+
let displays = [];
|
|
985
|
+
for (let i = 0; i < g.values.length; ++i) {
|
|
986
|
+
let display = g.kind === 'property' ? visDisplayLabel(g.values[i], cut) : g.values[i];
|
|
987
|
+
if (display.length > 0) {
|
|
988
|
+
displays.push(display);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (displays.length === 0) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
865
994
|
if (!stats[id]) {
|
|
866
|
-
stats[id] = {kind: g.kind, ref: g.ref, label: g.label,
|
|
867
|
-
cut: g.kind === 'property' ? visQualifierCut(g.ref) : null,
|
|
995
|
+
stats[id] = {kind: g.kind, ref: g.ref, label: g.label, cut: cut,
|
|
868
996
|
nodes: 0, keys: Object.create(null), multi: false};
|
|
869
997
|
}
|
|
870
998
|
let s = stats[id];
|
|
871
999
|
s.nodes++;
|
|
872
|
-
if (
|
|
1000
|
+
if (displays.length > 1) {
|
|
873
1001
|
s.multi = true;
|
|
874
1002
|
}
|
|
875
|
-
|
|
876
|
-
// properties group under their normalized display form;
|
|
877
|
-
// taxonomy / sequence elements are curated text, verbatim
|
|
878
|
-
let display = g.kind === 'property' ? visDisplayLabel(g.values[i], s.cut) : g.values[i];
|
|
879
|
-
if (display.length === 0) {
|
|
880
|
-
continue;
|
|
881
|
-
}
|
|
1003
|
+
displays.forEach(function (display) {
|
|
882
1004
|
let key = g.kind === 'property' ? display.toLowerCase() : display;
|
|
883
1005
|
if (!s.keys[key]) {
|
|
884
1006
|
s.keys[key] = {count: 0, spellings: Object.create(null)};
|
|
885
1007
|
}
|
|
886
1008
|
s.keys[key].count++;
|
|
887
1009
|
s.keys[key].spellings[display] = (s.keys[key].spellings[display] || 0) + 1;
|
|
888
|
-
}
|
|
1010
|
+
});
|
|
889
1011
|
});
|
|
890
1012
|
});
|
|
891
1013
|
|
|
@@ -918,38 +1040,121 @@
|
|
|
918
1040
|
canon[key] = rep;
|
|
919
1041
|
counts[rep] = group.count;
|
|
920
1042
|
});
|
|
921
|
-
let distinct = Object.keys(canon).length;
|
|
922
|
-
if (covered * VIS_MIN_COVERAGE_DEN < total * VIS_MIN_COVERAGE_NUM) {
|
|
923
|
-
return;
|
|
924
|
-
}
|
|
925
|
-
if (distinct < 2) {
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
1043
|
let values = Object.keys(counts);
|
|
929
1044
|
let numeric = values.every(function (v) {
|
|
930
|
-
return
|
|
1045
|
+
return VIS_NUMERIC_RE.test(v);
|
|
931
1046
|
});
|
|
1047
|
+
if (numeric) {
|
|
1048
|
+
// "1", "1.0" and "+1" are one value. Group the spellings by
|
|
1049
|
+
// the number they denote; the representative is the SHORTEST
|
|
1050
|
+
// spelling, ties alphabetically (a number has no preferred
|
|
1051
|
+
// spelling, so frequency would only pick the exporter's
|
|
1052
|
+
// habit). canon then maps every raw spelling to it, so
|
|
1053
|
+
// visualizationNodeValue folds a node's "1.0" the same way.
|
|
1054
|
+
let byNumber = Object.create(null);
|
|
1055
|
+
values.forEach(function (v) {
|
|
1056
|
+
let k = String(Number(v));
|
|
1057
|
+
let g = byNumber[k];
|
|
1058
|
+
if (!g) {
|
|
1059
|
+
byNumber[k] = {rep: v, count: counts[v]};
|
|
1060
|
+
} else {
|
|
1061
|
+
g.count += counts[v];
|
|
1062
|
+
if (v.length < g.rep.length || (v.length === g.rep.length && v < g.rep)) {
|
|
1063
|
+
g.rep = v;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
let folded = Object.create(null);
|
|
1068
|
+
Object.keys(byNumber).forEach(function (k) {
|
|
1069
|
+
folded[byNumber[k].rep] = byNumber[k].count;
|
|
1070
|
+
});
|
|
1071
|
+
Object.keys(canon).forEach(function (key) {
|
|
1072
|
+
canon[key] = byNumber[String(Number(canon[key]))].rep;
|
|
1073
|
+
});
|
|
1074
|
+
counts = folded;
|
|
1075
|
+
values = Object.keys(counts);
|
|
1076
|
+
}
|
|
1077
|
+
let distinct = values.length;
|
|
1078
|
+
// Sparse fields are RANKED LAST, not refused. A half-annotated
|
|
1079
|
+
// field is often the most interesting thing in the tree -- someone
|
|
1080
|
+
// hand-annotates a subset precisely because it is worth marking --
|
|
1081
|
+
// it just should not be what opens the tree.
|
|
1082
|
+
//
|
|
1083
|
+
// This was a hard refusal, and it cost real data: on the 13,246-tip
|
|
1084
|
+
// BV-BRC flu tree, Country (8,031 covered, 28 values) and Region
|
|
1085
|
+
// (8,030, 13 values) both sit at 61% and were dropped from the menu
|
|
1086
|
+
// outright, while Isolation_Source cleared the bar by twelve tips.
|
|
1087
|
+
// The two fields a phylogeography user reaches for first were
|
|
1088
|
+
// missing from our own flagship demo, invisibly, because a refused
|
|
1089
|
+
// field leaves no trace in the UI. The desktop ranked instead of
|
|
1090
|
+
// refusing from 0.11.133 and was right; this matches it.
|
|
1091
|
+
//
|
|
1092
|
+
// The legend already carries the honest part: a "no value" row
|
|
1093
|
+
// with the uncovered count, at reduced opacity.
|
|
1094
|
+
let sparse = covered * VIS_MIN_COVERAGE_DEN < total * VIS_MIN_COVERAGE_NUM;
|
|
1095
|
+
if (distinct < 2) {
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
932
1098
|
let colorMode;
|
|
933
1099
|
let switchable = false;
|
|
934
1100
|
let wide = false;
|
|
1101
|
+
let nearUnique = false;
|
|
935
1102
|
if (numeric) {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1103
|
+
// No uniqueness refusal for numbers. There used to be one
|
|
1104
|
+
// (distinct/covered > 9/10 -> refused) meant to catch numeric
|
|
1105
|
+
// identifiers, and it could not tell an identifier from a
|
|
1106
|
+
// MEASUREMENT: a read count, a viral load, an expression level
|
|
1107
|
+
// or a year is naturally one value per sample, which is what a
|
|
1108
|
+
// measurement is. Run over the desktop's gallery it refused
|
|
1109
|
+
// exactly those -- read_count, viral_load, expression_tpm, a
|
|
1110
|
+
// 1930-2010 year field -- on nine trees and left one opening
|
|
1111
|
+
// uncoloured. Run over ours it refused three things, every one
|
|
1112
|
+
// an identifier the NAME rules catch anyway (genome_id twice,
|
|
1113
|
+
// BVBRC_Accession). Identifiers are a fact about a name, not a
|
|
1114
|
+
// distribution, and the name rules are the right instrument.
|
|
1115
|
+
// Removed 2026-09-12, jointly with the desktop.
|
|
1116
|
+
let modes = visNumericModes(distinct);
|
|
1117
|
+
colorMode = modes.colorMode;
|
|
1118
|
+
switchable = modes.switchable;
|
|
941
1119
|
values.sort(function (a, b) {
|
|
942
1120
|
return Number(a) - Number(b);
|
|
943
1121
|
});
|
|
944
1122
|
} else {
|
|
1123
|
+
// KNOWN EDGE, left alone BY DECISION (Christian, 2026-09-12):
|
|
1124
|
+
// this counts against TOTAL, so a field unique across its
|
|
1125
|
+
// annotated subset -- strain on a 1,170-tip flu tree, 1,168
|
|
1126
|
+
// distinct over the 1,168 tips that carry it -- is offered
|
|
1127
|
+
// rather than refused as the identifier it is. It lands as
|
|
1128
|
+
// near-unique, tier 5, the bottom of the menu, and never
|
|
1129
|
+
// opens a tree. Counting against COVERED instead closes that
|
|
1130
|
+
// (five corpus fields, four of them strain/collection date)
|
|
1131
|
+
// but also refuses a field carried by two tips with two
|
|
1132
|
+
// values, where all-unique is a sample of two, not evidence.
|
|
1133
|
+
// Both were put to him with the numbers; he chose to keep
|
|
1134
|
+
// this until a user complains. Also decided then: strain and
|
|
1135
|
+
// genome name are NOT excluded by name -- both are grouping
|
|
1136
|
+
// fields wherever an isolate contributes several sequences
|
|
1137
|
+
// (eight segment records per strain in bvbrc+flu_seg4), and
|
|
1138
|
+
// the data rules already sort the identifier case to the
|
|
1139
|
+
// bottom. Do not "fix" either without asking him.
|
|
945
1140
|
if (distinct >= total) {
|
|
946
1141
|
return;
|
|
947
1142
|
}
|
|
948
1143
|
if (distinct > VIS_MAX_COLOR_CATEGORIES) {
|
|
1144
|
+
// More than 20 values. If they repeat reasonably (distinct
|
|
1145
|
+
// no more than 3/5 of covered) this is a WIDE category:
|
|
1146
|
+
// offered, never leading. If they barely repeat -- but DO
|
|
1147
|
+
// repeat, else the all-distinct test above would have
|
|
1148
|
+
// refused it -- it goes to the very bottom of the menu.
|
|
1149
|
+
// Christian, 2026-09-12: an all-distinct categorical is an
|
|
1150
|
+
// identifier and stays refused; a barely-repeating one is
|
|
1151
|
+
// nearly one, so it is offered last rather than hidden,
|
|
1152
|
+
// because a refusal cannot be seen failing and a rank can.
|
|
1153
|
+
// It is wide as well, so it never opens a tree.
|
|
1154
|
+
wide = true;
|
|
949
1155
|
if (distinct * VIS_WIDE_REPEAT_DEN > covered * VIS_WIDE_REPEAT_NUM) {
|
|
950
|
-
|
|
1156
|
+
nearUnique = true;
|
|
951
1157
|
}
|
|
952
|
-
wide = true;
|
|
953
1158
|
}
|
|
954
1159
|
colorMode = 'category';
|
|
955
1160
|
values.sort();
|
|
@@ -978,12 +1183,15 @@
|
|
|
978
1183
|
total: total,
|
|
979
1184
|
values: values,
|
|
980
1185
|
counts: counts,
|
|
981
|
-
canon: s.kind === 'property' ? canon : null,
|
|
1186
|
+
canon: (s.kind === 'property' || numeric) ? canon : null,
|
|
982
1187
|
cut: s.cut,
|
|
983
1188
|
score: (covered / total) * balance,
|
|
984
1189
|
colorMode: colorMode,
|
|
985
1190
|
switchable: switchable,
|
|
986
1191
|
wide: wide,
|
|
1192
|
+
nearUnique: nearUnique,
|
|
1193
|
+
sparse: sparse,
|
|
1194
|
+
deprioritized: visDeprioritizedRef(s.ref || id),
|
|
987
1195
|
shape: distinct <= VIS_MAX_SHAPE_CATEGORIES
|
|
988
1196
|
});
|
|
989
1197
|
});
|
|
@@ -998,15 +1206,36 @@
|
|
|
998
1206
|
}
|
|
999
1207
|
});
|
|
1000
1208
|
|
|
1001
|
-
// Best first: clean categorical fields, then numeric
|
|
1002
|
-
// wide categoricals (offered, never leading)
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1209
|
+
// Best first: clean categorical fields, then EVERY numeric field, then
|
|
1210
|
+
// the wide categoricals (offered, never leading), then the
|
|
1211
|
+
// deprioritized ones, then the sparse, and last the barely-repeating
|
|
1212
|
+
// wide ones -- within each tier by score, ties alphabetically. What
|
|
1213
|
+
// OPENS the tree is openingVisualization below: the first entry that
|
|
1214
|
+
// is not wide. So tiers 0, 1, 3 and 4 can open a tree, each only when
|
|
1215
|
+
// nothing above it exists, and tiers 2 and 5 never do -- a wide field
|
|
1216
|
+
// above an In-Group does not stop the In-Group from opening the tree,
|
|
1217
|
+
// it just precedes it in the menu.
|
|
1005
1218
|
function tierOf(c) {
|
|
1006
|
-
if (c.
|
|
1007
|
-
return
|
|
1219
|
+
if (c.nearUnique) {
|
|
1220
|
+
return 5;
|
|
1008
1221
|
}
|
|
1009
|
-
|
|
1222
|
+
if (c.sparse) {
|
|
1223
|
+
return 4;
|
|
1224
|
+
}
|
|
1225
|
+
if (c.deprioritized) {
|
|
1226
|
+
return 3;
|
|
1227
|
+
}
|
|
1228
|
+
// Every numeric field is tier 1, including one with few enough
|
|
1229
|
+
// values to draw as discrete colours. Tiering on colour MODE put
|
|
1230
|
+
// a small measurement in tier 0 beside the real categories, where
|
|
1231
|
+
// an evenly spread one outscores them on entropy -- so with the
|
|
1232
|
+
// uniqueness refusal gone, Viral Load would open a tree instead of
|
|
1233
|
+
// Segment, and Year instead of Clade. A measurement is always
|
|
1234
|
+
// offered; a category, when there is one, still opens the tree.
|
|
1235
|
+
if (c.numeric) {
|
|
1236
|
+
return 1;
|
|
1237
|
+
}
|
|
1238
|
+
return c.wide ? 2 : 0;
|
|
1010
1239
|
}
|
|
1011
1240
|
candidates.sort(function (a, b) {
|
|
1012
1241
|
let ta = tierOf(a);
|
|
@@ -1047,7 +1276,7 @@
|
|
|
1047
1276
|
let idLike = 0;
|
|
1048
1277
|
let refs = {}; // ref -> {covered, values:Set, wordy}
|
|
1049
1278
|
forester.preOrderTraversalAll(tree, function (n) {
|
|
1050
|
-
if (n.children
|
|
1279
|
+
if (n.children) {
|
|
1051
1280
|
return;
|
|
1052
1281
|
}
|
|
1053
1282
|
total++;
|
|
@@ -1062,7 +1291,7 @@
|
|
|
1062
1291
|
let seen = {};
|
|
1063
1292
|
for (let i = 0; i < n.properties.length; ++i) {
|
|
1064
1293
|
let p = n.properties[i];
|
|
1065
|
-
if (!p.ref || p
|
|
1294
|
+
if (!p.ref || !forester.isNodeScopedProperty(p) || seen[p.ref]
|
|
1066
1295
|
|| p.ref.indexOf(VIS_EXCLUDED_REF_PREFIX) === 0) {
|
|
1067
1296
|
continue;
|
|
1068
1297
|
}
|
|
@@ -1127,7 +1356,7 @@
|
|
|
1127
1356
|
}
|
|
1128
1357
|
for (let i = 0; i < node.properties.length; ++i) {
|
|
1129
1358
|
let p = node.properties[i];
|
|
1130
|
-
if (!p.ref || p
|
|
1359
|
+
if (!p.ref || !forester.isNodeScopedProperty(p) || p.value === undefined || p.value === null) {
|
|
1131
1360
|
continue;
|
|
1132
1361
|
}
|
|
1133
1362
|
let v = String(p.value).trim();
|
|
@@ -1190,7 +1419,7 @@
|
|
|
1190
1419
|
let names = [];
|
|
1191
1420
|
let slot = labelProperty ? {kind: 'property', ref: labelProperty} : null;
|
|
1192
1421
|
forester.preOrderTraversalAll(tree, function (n) {
|
|
1193
|
-
if (n.children
|
|
1422
|
+
if (n.children) {
|
|
1194
1423
|
return;
|
|
1195
1424
|
}
|
|
1196
1425
|
let name = slot ? forester.visualizationNodeValue(n, slot) : null;
|
|
@@ -1306,7 +1535,7 @@
|
|
|
1306
1535
|
if (node.properties) {
|
|
1307
1536
|
for (let i = 0; i < node.properties.length; ++i) {
|
|
1308
1537
|
let p = node.properties[i];
|
|
1309
|
-
if (p.ref === candidate.ref && p
|
|
1538
|
+
if (p.ref === candidate.ref && forester.isNodeScopedProperty(p)) {
|
|
1310
1539
|
let v = clean(p.value);
|
|
1311
1540
|
if (v !== null) {
|
|
1312
1541
|
// a classifier-built candidate folds the value the
|
|
@@ -1314,6 +1543,11 @@
|
|
|
1314
1543
|
// {kind, ref} probe (labels, prefixes) reads raw
|
|
1315
1544
|
if (candidate.canon) {
|
|
1316
1545
|
let display = visDisplayLabel(v, candidate.cut || null);
|
|
1546
|
+
if (display.length === 0) {
|
|
1547
|
+
// folds to nothing: no value, as the
|
|
1548
|
+
// classifier counted it -- keep looking
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1317
1551
|
return candidate.canon[display.toLowerCase()] || display;
|
|
1318
1552
|
}
|
|
1319
1553
|
return v;
|
|
@@ -1332,7 +1566,9 @@
|
|
|
1332
1566
|
for (let j = 0; j < list.length; ++j) {
|
|
1333
1567
|
let v = clean(VIS_ELEMENT_SLOTS[i].get(list[j]));
|
|
1334
1568
|
if (v !== null) {
|
|
1335
|
-
|
|
1569
|
+
// verbatim, except that a numeric slot folds its
|
|
1570
|
+
// spellings exactly as the classifier grouped them
|
|
1571
|
+
return candidate.canon ? (candidate.canon[v] || v) : v;
|
|
1336
1572
|
}
|
|
1337
1573
|
}
|
|
1338
1574
|
return null;
|
|
@@ -1341,23 +1577,99 @@
|
|
|
1341
1577
|
return null;
|
|
1342
1578
|
};
|
|
1343
1579
|
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1580
|
+
// The visualization a tree OPENS with: the first candidate that is not
|
|
1581
|
+
// wide (21+ values are offered, never imposed), or null for none. One
|
|
1582
|
+
// definition, used by the viewer and by the fixture generator, so the
|
|
1583
|
+
// rule the desktop ports is the rule the viewer runs.
|
|
1584
|
+
forester.openingVisualization = function (candidates) {
|
|
1585
|
+
for (let i = 0; i < candidates.length; ++i) {
|
|
1586
|
+
if (!candidates[i].wide) {
|
|
1587
|
+
return candidates[i];
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return null;
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
// What a VIEW shows of a candidate: its values, counts and coverage over
|
|
1594
|
+
// the tips under `root` -- the subtree the user switched into, or the
|
|
1595
|
+
// whole tree. Candidacy is decided ONCE, on the tree
|
|
1596
|
+
// (visualizationCandidates); a view never re-decides it, it only
|
|
1597
|
+
// re-summarizes. Until 2026-09-12 the viewer re-ran the classifier on
|
|
1598
|
+
// every subtree, and since a clade is by nature a set of tips sharing a
|
|
1599
|
+
// value, and one value is refused, entering a clade dropped the colouring
|
|
1600
|
+
// in 61% of the corpus's clades (4,192 of 6,857) and did not bring it back
|
|
1601
|
+
// on return. Values are read exactly as the classifier grouped them, so
|
|
1602
|
+
// everything found here is in the candidate's domain and keeps its
|
|
1603
|
+
// colour. A numeric candidate also gets the view's colour-mode band
|
|
1604
|
+
// (see visNumericModes); a category keeps its mode.
|
|
1605
|
+
forester.visualizationSummary = function (candidate, root) {
|
|
1606
|
+
let counts = Object.create(null);
|
|
1607
|
+
let total = 0;
|
|
1608
|
+
let coverage = 0;
|
|
1609
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
1610
|
+
if (n.children) {
|
|
1611
|
+
return;
|
|
1358
1612
|
}
|
|
1613
|
+
total++;
|
|
1614
|
+
let v = forester.visualizationNodeValue(n, candidate);
|
|
1615
|
+
if (v !== null) {
|
|
1616
|
+
coverage++;
|
|
1617
|
+
counts[v] = (counts[v] || 0) + 1;
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
let values = Object.keys(counts);
|
|
1621
|
+
if (candidate.numeric) {
|
|
1622
|
+
values.sort(function (a, b) {
|
|
1623
|
+
return Number(a) - Number(b);
|
|
1624
|
+
});
|
|
1625
|
+
} else {
|
|
1626
|
+
values.sort();
|
|
1627
|
+
}
|
|
1628
|
+
let summary = {values: values, counts: counts, coverage: coverage, total: total, distinct: values.length};
|
|
1629
|
+
if (candidate.numeric) {
|
|
1630
|
+
let modes = visNumericModes(values.length);
|
|
1631
|
+
summary.colorMode = modes.colorMode;
|
|
1632
|
+
summary.switchable = modes.switchable;
|
|
1633
|
+
}
|
|
1634
|
+
return summary;
|
|
1635
|
+
};
|
|
1636
|
+
|
|
1637
|
+
// The candidates of a tree the user has EDITED (a subtree deleted), with
|
|
1638
|
+
// the fields they had chosen KEPT as long as those still carry a value
|
|
1639
|
+
// somewhere in what remains. The refusal rules decide what is offered,
|
|
1640
|
+
// never what is already chosen: colouring by Host and deleting every
|
|
1641
|
+
// clade but one must not silently uncolour the tree because one host is
|
|
1642
|
+
// "not a category". A kept field is appended after the offered ones and
|
|
1643
|
+
// flagged `kept`, so the menu holds it exactly as long as the user does;
|
|
1644
|
+
// the next edit drops it unless it is still chosen. `chosen` is the
|
|
1645
|
+
// previous candidate objects -- their grouping travels with them.
|
|
1646
|
+
forester.visualizationCandidatesKeeping = function (tree, chosen) {
|
|
1647
|
+
let candidates = forester.visualizationCandidates(tree);
|
|
1648
|
+
let ids = Object.create(null);
|
|
1649
|
+
candidates.forEach(function (c) {
|
|
1650
|
+
ids[c.id] = true;
|
|
1359
1651
|
});
|
|
1360
|
-
|
|
1652
|
+
(chosen || []).forEach(function (c) {
|
|
1653
|
+
if (!c || ids[c.id]) {
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
let s = forester.visualizationSummary(c, tree);
|
|
1657
|
+
if (s.coverage === 0) {
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
c.values = s.values;
|
|
1661
|
+
c.counts = s.counts;
|
|
1662
|
+
c.coverage = s.coverage;
|
|
1663
|
+
c.total = s.total;
|
|
1664
|
+
if (c.numeric) {
|
|
1665
|
+
c.colorMode = s.colorMode;
|
|
1666
|
+
c.switchable = s.switchable;
|
|
1667
|
+
}
|
|
1668
|
+
c.kept = true;
|
|
1669
|
+
ids[c.id] = true;
|
|
1670
|
+
candidates.push(c);
|
|
1671
|
+
});
|
|
1672
|
+
return candidates;
|
|
1361
1673
|
};
|
|
1362
1674
|
|
|
1363
1675
|
forester.collectBasicTreeProperties = function (tree) {
|
|
@@ -1376,12 +1688,12 @@
|
|
|
1376
1688
|
properties.taxonomies = false;
|
|
1377
1689
|
properties.alignedMolSeqs = true;
|
|
1378
1690
|
properties.maxMolSeqLength = 0;
|
|
1691
|
+
// protein domain architectures on the tips: whether any tip carries
|
|
1692
|
+
// one, and the longest (Lmax, the scale every track shares)
|
|
1693
|
+
properties.domainArchitectures = false;
|
|
1694
|
+
properties.maxDomainArchitectureLength = 0;
|
|
1379
1695
|
properties.externalNodesCount = 0;
|
|
1380
1696
|
properties.nodeCount = 0;
|
|
1381
|
-
// How many of the tree's branches actually carry a positive length.
|
|
1382
|
-
// Whether a tree is worth drawing to scale is a question about the
|
|
1383
|
-
// majority of its branches, not about whether any branch has a length.
|
|
1384
|
-
properties.branchesWithPositiveLength = 0;
|
|
1385
1697
|
// Branches that carry a length AT ALL -- an explicit zero is a real
|
|
1386
1698
|
// measurement, not a missing one, so these are counted separately from
|
|
1387
1699
|
// the "positive" tally above. Split internal-vs-all because a missing
|
|
@@ -1394,6 +1706,14 @@
|
|
|
1394
1706
|
properties.internalBranchCount = 0;
|
|
1395
1707
|
properties.internalBranchesWithLength = 0;
|
|
1396
1708
|
properties.averageBranchLength = 0;
|
|
1709
|
+
// Positive lengths only, and the root included: these feed
|
|
1710
|
+
// averageBranchLength and nothing else. They are deliberately NOT the
|
|
1711
|
+
// same population as branchCount / branchesWithLength below, which
|
|
1712
|
+
// exclude the root and count an explicit zero as the measurement it is.
|
|
1713
|
+
// A `branchesWithPositiveLength` property built from this counter was
|
|
1714
|
+
// removed on 2026-09-11 -- nothing read it, and having two tallies over
|
|
1715
|
+
// two different node sets invited exactly the comparison that would be
|
|
1716
|
+
// wrong.
|
|
1397
1717
|
let bl_counter = 0;
|
|
1398
1718
|
let bl_sum = 0;
|
|
1399
1719
|
// Counting the super-root would add a node and a branch that do not
|
|
@@ -1404,7 +1724,7 @@
|
|
|
1404
1724
|
forester.preOrderTraversalAll(rootNode, function (n) {
|
|
1405
1725
|
properties.nodeCount += 1;
|
|
1406
1726
|
if (n !== rootNode) {
|
|
1407
|
-
let internal = !!(n.children
|
|
1727
|
+
let internal = !!(n.children);
|
|
1408
1728
|
let measured = typeof n.branch_length === 'number' && isFinite(n.branch_length);
|
|
1409
1729
|
properties.branchCount += 1;
|
|
1410
1730
|
if (measured) {
|
|
@@ -1422,11 +1742,11 @@
|
|
|
1422
1742
|
if (n.name.length > properties.longestNodeName) {
|
|
1423
1743
|
properties.longestNodeName = n.name.length;
|
|
1424
1744
|
}
|
|
1425
|
-
if ((n.children
|
|
1745
|
+
if ((n.children) && (n.parent)) {
|
|
1426
1746
|
properties.internalNodeData = true;
|
|
1427
1747
|
}
|
|
1428
1748
|
}
|
|
1429
|
-
if (!(n.children
|
|
1749
|
+
if (!(n.children)) {
|
|
1430
1750
|
properties.externalNodesCount += 1;
|
|
1431
1751
|
}
|
|
1432
1752
|
if (n.branch_length && n.branch_length > 0) {
|
|
@@ -1443,7 +1763,7 @@
|
|
|
1443
1763
|
if (n.sequences && n.sequences.length > 0) {
|
|
1444
1764
|
properties.sequences = true;
|
|
1445
1765
|
|
|
1446
|
-
if (n.children
|
|
1766
|
+
if (n.children) {
|
|
1447
1767
|
properties.internalNodeData = true;
|
|
1448
1768
|
} else {
|
|
1449
1769
|
let s = n.sequences[0];
|
|
@@ -1455,11 +1775,18 @@
|
|
|
1455
1775
|
properties.alignedMolSeqs = false;
|
|
1456
1776
|
}
|
|
1457
1777
|
}
|
|
1778
|
+
let da = forester.domainArchitectureOf(n);
|
|
1779
|
+
if (da) {
|
|
1780
|
+
properties.domainArchitectures = true;
|
|
1781
|
+
if (Number(da.length) > properties.maxDomainArchitectureLength) {
|
|
1782
|
+
properties.maxDomainArchitectureLength = Number(da.length);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1458
1785
|
}
|
|
1459
1786
|
}
|
|
1460
1787
|
if (n.taxonomies && n.taxonomies.length > 0) {
|
|
1461
1788
|
properties.taxonomies = true;
|
|
1462
|
-
if (n.children
|
|
1789
|
+
if (n.children) {
|
|
1463
1790
|
properties.internalNodeData = true;
|
|
1464
1791
|
}
|
|
1465
1792
|
}
|
|
@@ -1483,7 +1810,6 @@
|
|
|
1483
1810
|
|
|
1484
1811
|
});
|
|
1485
1812
|
|
|
1486
|
-
properties.branchesWithPositiveLength = bl_counter;
|
|
1487
1813
|
|
|
1488
1814
|
if (bl_counter > 0) {
|
|
1489
1815
|
properties.averageBranchLength = bl_sum / bl_counter;
|
|
@@ -1522,15 +1848,14 @@
|
|
|
1522
1848
|
forester.calcSumOfAllExternalDescendants = function (node) {
|
|
1523
1849
|
let nodes = 0;
|
|
1524
1850
|
forester.preOrderTraversalAll(node, function (n) {
|
|
1525
|
-
if (!(n.children
|
|
1851
|
+
if (!(n.children)) {
|
|
1526
1852
|
++nodes;
|
|
1527
1853
|
}
|
|
1528
1854
|
});
|
|
1529
1855
|
return nodes;
|
|
1530
1856
|
};
|
|
1531
1857
|
|
|
1532
|
-
// Ladderize: at every node, order the
|
|
1533
|
-
// collapsed node's hidden _children are left untouched) by clade size --
|
|
1858
|
+
// Ladderize: at every node, order the children by clade size --
|
|
1534
1859
|
// largest first when largestFirst, smallest first when not. Works at ANY
|
|
1535
1860
|
// child count, not just 2, so a polytomy (common on a phylodynamic tree,
|
|
1536
1861
|
// e.g. an Auspice build, where every internal node may carry 3+ children)
|
|
@@ -1580,7 +1905,7 @@
|
|
|
1580
1905
|
forester.getAllExternalNodes = function (node) {
|
|
1581
1906
|
let nodes = [];
|
|
1582
1907
|
forester.preOrderTraversalAll(node, function (n) {
|
|
1583
|
-
if (!n.children
|
|
1908
|
+
if (!n.children) {
|
|
1584
1909
|
nodes.push(n);
|
|
1585
1910
|
}
|
|
1586
1911
|
});
|
|
@@ -1595,19 +1920,6 @@
|
|
|
1595
1920
|
return nodes;
|
|
1596
1921
|
};
|
|
1597
1922
|
|
|
1598
|
-
forester.calcMaxDepth = function (node) {
|
|
1599
|
-
let max = 0;
|
|
1600
|
-
forester.preOrderTraversalAll(node, function (n) {
|
|
1601
|
-
if (!n.children && !n._children) {
|
|
1602
|
-
let steps = forester.calcDepth(n);
|
|
1603
|
-
if (steps > max) {
|
|
1604
|
-
max = steps;
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
});
|
|
1608
|
-
return max;
|
|
1609
|
-
};
|
|
1610
|
-
|
|
1611
1923
|
forester.calcDepth = function (node) {
|
|
1612
1924
|
|
|
1613
1925
|
let steps = 0;
|
|
@@ -1619,31 +1931,6 @@
|
|
|
1619
1931
|
};
|
|
1620
1932
|
|
|
1621
1933
|
|
|
1622
|
-
forester.calcBranchLengthSimpleStatistics = function (node) {
|
|
1623
|
-
let stats = {};
|
|
1624
|
-
stats.mean = 0;
|
|
1625
|
-
stats.min = Number.MAX_VALUE;
|
|
1626
|
-
stats.max = 0;
|
|
1627
|
-
stats.n = 0;
|
|
1628
|
-
let sum = 0;
|
|
1629
|
-
forester.preOrderTraversalAll(node, function (n) {
|
|
1630
|
-
if (n !== node && n.branch_length && n.branch_length >= 0) {
|
|
1631
|
-
++stats.n;
|
|
1632
|
-
sum += n.branch_length;
|
|
1633
|
-
if (n.branch_length < stats.min) {
|
|
1634
|
-
stats.min = n.branch_length;
|
|
1635
|
-
}
|
|
1636
|
-
if (n.branch_length > stats.max) {
|
|
1637
|
-
stats.max = n.branch_length;
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
});
|
|
1641
|
-
if (stats.n > 0) {
|
|
1642
|
-
stats.mean = sum / stats.n;
|
|
1643
|
-
}
|
|
1644
|
-
return stats;
|
|
1645
|
-
};
|
|
1646
|
-
|
|
1647
1934
|
forester.calcMaxBranchLength = function (node) {
|
|
1648
1935
|
let max = 0;
|
|
1649
1936
|
forester.preOrderTraversalAll(node, function (n) {
|
|
@@ -1655,33 +1942,6 @@
|
|
|
1655
1942
|
};
|
|
1656
1943
|
|
|
1657
1944
|
|
|
1658
|
-
forester.isHasNodeData = function (node) {
|
|
1659
|
-
return ((node.name && node.name.length > 0) || (node.taxonomies && node.taxonomies.length > 0) || (node.sequences && node.sequences.length > 0) || (node.properties && node.properties.length > 0));
|
|
1660
|
-
};
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
forester.removeMaxBranchLength = function (node) {
|
|
1664
|
-
forester.preOrderTraversalAll(node, function (n) {
|
|
1665
|
-
if (n.max) {
|
|
1666
|
-
n.max = undefined;
|
|
1667
|
-
}
|
|
1668
|
-
});
|
|
1669
|
-
};
|
|
1670
|
-
|
|
1671
|
-
forester.collapse = function (node) {
|
|
1672
|
-
if (node.children) {
|
|
1673
|
-
node._children = node.children;
|
|
1674
|
-
node.children = null;
|
|
1675
|
-
}
|
|
1676
|
-
};
|
|
1677
|
-
|
|
1678
|
-
forester.unCollapse = function (node) {
|
|
1679
|
-
if (node._children) {
|
|
1680
|
-
node.children = node._children;
|
|
1681
|
-
node._children = null;
|
|
1682
|
-
}
|
|
1683
|
-
};
|
|
1684
|
-
|
|
1685
1945
|
/**
|
|
1686
1946
|
* To parse a New Hampshire (Newick) formatted tree.
|
|
1687
1947
|
*
|
|
@@ -2150,7 +2410,16 @@
|
|
|
2150
2410
|
// the separator before a branch length: the length itself
|
|
2151
2411
|
// is read by the branch below, so there is nothing to do here
|
|
2152
2412
|
} else {
|
|
2153
|
-
|
|
2413
|
+
// What came before decides what this element is: a name
|
|
2414
|
+
// follows an opening bracket, a comma or a close, and a
|
|
2415
|
+
// branch length follows a colon.
|
|
2416
|
+
//
|
|
2417
|
+
// At i === 0 there IS no previous token, and the string
|
|
2418
|
+
// beginning with a label is the one-node tree "a;" --
|
|
2419
|
+
// valid Newick, and for want of this line its name was
|
|
2420
|
+
// read as nothing at all and written back as "". The
|
|
2421
|
+
// start of input opens the tree, so it counts as '('.
|
|
2422
|
+
let e = (i === 0) ? '(' : ss[i - 1];
|
|
2154
2423
|
if (e) {
|
|
2155
2424
|
e = e.trim();
|
|
2156
2425
|
// re-attach any annotation blobs riding on this
|
|
@@ -2280,7 +2549,7 @@
|
|
|
2280
2549
|
|
|
2281
2550
|
function moveInternalNodeNamesToConfidenceValues(node) {
|
|
2282
2551
|
forester.preOrderTraversalAll(node, function (n) {
|
|
2283
|
-
if (n.children
|
|
2552
|
+
if (n.children) {
|
|
2284
2553
|
if (n.name) {
|
|
2285
2554
|
let s = n.name;
|
|
2286
2555
|
if (NUMBERS_ONLY_PATTERN.test(s)) {
|
|
@@ -2998,7 +3267,7 @@
|
|
|
2998
3267
|
let v = metricOf(node);
|
|
2999
3268
|
node.branch_length = (parentValue !== null && v !== null)
|
|
3000
3269
|
? Math.max(0, v - parentValue) : 0;
|
|
3001
|
-
let children = node.children
|
|
3270
|
+
let children = node.children;
|
|
3002
3271
|
if (children) {
|
|
3003
3272
|
for (let i = 0; i < children.length; ++i) {
|
|
3004
3273
|
setDeltaBranchLengths(children[i], v, metricOf);
|
|
@@ -3028,93 +3297,19 @@
|
|
|
3028
3297
|
return auspiceHasAnyDate(root) && auspiceHasAnyDiv(root);
|
|
3029
3298
|
};
|
|
3030
3299
|
|
|
3300
|
+
// A number that is not NaN. It used to test only for null, undefined and
|
|
3301
|
+
// NaN, and so answered TRUE for "hello", "", {}, [] and true -- which no
|
|
3302
|
+
// caller was hurt by, since all of them pass the result of parseFloat, but
|
|
3303
|
+
// the name promised a check it did not make.
|
|
3304
|
+
//
|
|
3305
|
+
// Infinity is deliberately still accepted, so that this stays a rename in
|
|
3306
|
+
// behaviour as well as in intent: parseFloat('1e999') is Infinity, and
|
|
3307
|
+
// whether a branch length of Infinity should be refused is a separate
|
|
3308
|
+
// question from whether a string is a number.
|
|
3031
3309
|
forester.isNumber = function (v) {
|
|
3032
|
-
|
|
3033
|
-
return false;
|
|
3034
|
-
}
|
|
3035
|
-
if (v != v) {
|
|
3036
|
-
// This can only be true if the v is NaN
|
|
3037
|
-
return false;
|
|
3038
|
-
}
|
|
3039
|
-
return true;
|
|
3040
|
-
};
|
|
3041
|
-
|
|
3042
|
-
forester.getOneDistinctTaxonomy = function (node) {
|
|
3043
|
-
let id = null;
|
|
3044
|
-
let code = null;
|
|
3045
|
-
let sn = null;
|
|
3046
|
-
let cn = null;
|
|
3047
|
-
let result = true;
|
|
3048
|
-
let sawTax = false;
|
|
3049
|
-
forester.preOrderTraversalAll(node, function (n) {
|
|
3050
|
-
if (n.taxonomies && n.taxonomies.length === 1) {
|
|
3051
|
-
let tax = n.taxonomies[0];
|
|
3052
|
-
if (tax.code && tax.code.length > 0) {
|
|
3053
|
-
sawTax = true;
|
|
3054
|
-
if (code === null) {
|
|
3055
|
-
code = tax.code;
|
|
3056
|
-
} else if (code !== tax.code) {
|
|
3057
|
-
result = false;
|
|
3058
|
-
return;
|
|
3059
|
-
}
|
|
3060
|
-
}
|
|
3061
|
-
if (tax.scientific_name && tax.scientific_name.length > 0) {
|
|
3062
|
-
sawTax = true;
|
|
3063
|
-
if (sn === null) {
|
|
3064
|
-
sn = tax.scientific_name;
|
|
3065
|
-
} else if (sn !== tax.scientific_name) {
|
|
3066
|
-
result = false;
|
|
3067
|
-
return;
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
if (tax.common_name && tax.common_name.length > 0) {
|
|
3071
|
-
sawTax = true;
|
|
3072
|
-
if (cn === null) {
|
|
3073
|
-
cn = tax.common_name;
|
|
3074
|
-
} else if (cn !== tax.common_name) {
|
|
3075
|
-
result = false;
|
|
3076
|
-
return;
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
if (tax.id && tax.id.value && tax.id.value.length > 0) {
|
|
3080
|
-
sawTax = true;
|
|
3081
|
-
let myid;
|
|
3082
|
-
if (tax.id.provider && tax.id.provider.length > 0) {
|
|
3083
|
-
myid = tax.id.provider + ':' + tax.id.value;
|
|
3084
|
-
} else {
|
|
3085
|
-
myid = tax.id.value;
|
|
3086
|
-
}
|
|
3087
|
-
if (id === null) {
|
|
3088
|
-
id = myid;
|
|
3089
|
-
} else if (id !== myid) {
|
|
3090
|
-
result = false;
|
|
3091
|
-
|
|
3092
|
-
}
|
|
3093
|
-
}
|
|
3094
|
-
} else if (!n.children && !n._children) {
|
|
3095
|
-
// If an external node lacks taxonomy, return false.
|
|
3096
|
-
result = false;
|
|
3097
|
-
}
|
|
3098
|
-
});
|
|
3099
|
-
if (!sawTax) {
|
|
3100
|
-
return null;
|
|
3101
|
-
}
|
|
3102
|
-
if (result === true) {
|
|
3103
|
-
|
|
3104
|
-
if (sn) {
|
|
3105
|
-
return sn;
|
|
3106
|
-
} else if (code) {
|
|
3107
|
-
return code;
|
|
3108
|
-
} else if (cn) {
|
|
3109
|
-
return cn;
|
|
3110
|
-
} else if (id) {
|
|
3111
|
-
return id;
|
|
3112
|
-
}
|
|
3113
|
-
}
|
|
3114
|
-
return null;
|
|
3310
|
+
return typeof v === 'number' && v === v;
|
|
3115
3311
|
};
|
|
3116
3312
|
|
|
3117
|
-
|
|
3118
3313
|
// How a label is written into Newick or Nexus, ported from the desktop's
|
|
3119
3314
|
// ForesterUtil.santitizeStringForNH so both programs emit the same token
|
|
3120
3315
|
// for the same name. Quoting rather than transliterating is what makes a
|
|
@@ -3181,13 +3376,6 @@
|
|
|
3181
3376
|
toNewHampshireHelper(node.children[i], i === l - 1);
|
|
3182
3377
|
}
|
|
3183
3378
|
nh += ")";
|
|
3184
|
-
} else if (node._children) {
|
|
3185
|
-
let ll = node._children.length;
|
|
3186
|
-
nh += "(";
|
|
3187
|
-
for (let ii = 0; ii < ll; ++ii) {
|
|
3188
|
-
toNewHampshireHelper(node._children[ii], ii === ll - 1);
|
|
3189
|
-
}
|
|
3190
|
-
nh += ")";
|
|
3191
3379
|
}
|
|
3192
3380
|
if (node.name && node.name.length > 0) {
|
|
3193
3381
|
nh += sanitizeLabelForNH(node.name);
|
|
@@ -3378,6 +3566,233 @@
|
|
|
3378
3566
|
};
|
|
3379
3567
|
|
|
3380
3568
|
|
|
3569
|
+
// --------------------------------------------------------------
|
|
3570
|
+
// Protein domain architectures
|
|
3571
|
+
// --------------------------------------------------------------
|
|
3572
|
+
// The pure half of drawing <domain_architecture> beside the tips, ported
|
|
3573
|
+
// from desktop Archaeopteryx (RenderableDomainArchitecture, TreePanel and
|
|
3574
|
+
// AptxUtil at forester 416c705b; the spec is section D1 of the repo's
|
|
3575
|
+
// TODO.md): which domains a threshold admits, where each box sits along
|
|
3576
|
+
// the backbone, the Tableau palette and how names take their colours,
|
|
3577
|
+
// the legend rows and the E-value readout. Everything a fixture can pin
|
|
3578
|
+
// lives here and test/domain_test.js checks it against the numbers the
|
|
3579
|
+
// desktop computed by running its own classes on apaf.xml. The SVG and
|
|
3580
|
+
// the controls are archaeopteryx.js's.
|
|
3581
|
+
|
|
3582
|
+
const DOMAIN_PALETTE = ['#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
|
|
3583
|
+
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC']; // Tableau 10
|
|
3584
|
+
forester.DOMAIN_UNNAMED_COLOR = '#808080'; // a domain with no name
|
|
3585
|
+
forester.DOMAIN_EVALUE_EXPONENT_DEFAULT = -3;
|
|
3586
|
+
forester.DOMAIN_EVALUE_EXPONENT_MIN = -20;
|
|
3587
|
+
forester.DOMAIN_EVALUE_EXPONENT_MAX = 3;
|
|
3588
|
+
|
|
3589
|
+
function hexToRgb(hex) {
|
|
3590
|
+
let n = parseInt(hex.substring(1), 16);
|
|
3591
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
function rgbToHex(rgb) {
|
|
3595
|
+
return '#' + rgb.map(function (c) {
|
|
3596
|
+
let s = Math.max(0, Math.min(255, Math.round(c))).toString(16);
|
|
3597
|
+
return s.length < 2 ? '0' + s : s;
|
|
3598
|
+
}).join('');
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
// Colour i of the qualitative sequence: Tableau 10 for the first ten,
|
|
3602
|
+
// then the same ten shifted toward white (odd cycles) or black (even
|
|
3603
|
+
// cycles), further with each cycle, capped at 0.55.
|
|
3604
|
+
forester.domainQualitativeColor = function (i) {
|
|
3605
|
+
let base = hexToRgb(DOMAIN_PALETTE[i % DOMAIN_PALETTE.length]);
|
|
3606
|
+
let cycle = Math.floor(i / DOMAIN_PALETTE.length);
|
|
3607
|
+
if (cycle === 0) {
|
|
3608
|
+
return rgbToHex(base);
|
|
3609
|
+
}
|
|
3610
|
+
let t = Math.min(0.55, 0.2 * cycle);
|
|
3611
|
+
let toward = (cycle % 2 === 1) ? 255 : 0;
|
|
3612
|
+
return rgbToHex(base.map(function (c) {
|
|
3613
|
+
return Math.round(c + t * (toward - c));
|
|
3614
|
+
}));
|
|
3615
|
+
};
|
|
3616
|
+
|
|
3617
|
+
forester.domainLighten = function (hex, t) {
|
|
3618
|
+
return rgbToHex(hexToRgb(hex).map(function (c) {
|
|
3619
|
+
return c + Math.round((255 - c) * t);
|
|
3620
|
+
}));
|
|
3621
|
+
};
|
|
3622
|
+
|
|
3623
|
+
forester.domainDarken = function (hex, t) {
|
|
3624
|
+
return rgbToHex(hexToRgb(hex).map(function (c) {
|
|
3625
|
+
return Math.round(c * (1 - t));
|
|
3626
|
+
}));
|
|
3627
|
+
};
|
|
3628
|
+
|
|
3629
|
+
// The ink a name is written in on its box: near-black on a light base,
|
|
3630
|
+
// white on a dark one.
|
|
3631
|
+
forester.domainLabelInk = function (hex) {
|
|
3632
|
+
let c = hexToRgb(hex);
|
|
3633
|
+
let lum = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255;
|
|
3634
|
+
return lum > 0.55 ? '#141a1d' : '#ffffff';
|
|
3635
|
+
};
|
|
3636
|
+
|
|
3637
|
+
forester.domainEvalueThreshold = function (exponent) {
|
|
3638
|
+
return Math.pow(10, exponent);
|
|
3639
|
+
};
|
|
3640
|
+
|
|
3641
|
+
// "10" with the exponent in superscript digits: 10⁻³, 10⁰, 10³.
|
|
3642
|
+
const SUPERSCRIPT_DIGITS = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
|
|
3643
|
+
forester.domainEvalueLabel = function (exponent) {
|
|
3644
|
+
let digits = String(Math.abs(exponent)).split('').map(function (d) {
|
|
3645
|
+
return SUPERSCRIPT_DIGITS[+d];
|
|
3646
|
+
}).join('');
|
|
3647
|
+
return '10' + (exponent < 0 ? '⁻' : '') + digits;
|
|
3648
|
+
};
|
|
3649
|
+
|
|
3650
|
+
function domainNumber(v) {
|
|
3651
|
+
return (v === null || v === undefined || v === '') ? NaN : Number(v);
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
// The architecture a node carries: the first of its sequences that has
|
|
3655
|
+
// one, provided its length is a positive integer -- without a length
|
|
3656
|
+
// there is no backbone to draw on. The desktop refuses the whole file in
|
|
3657
|
+
// that case; refusing input over a drawing attribute is the one kind of
|
|
3658
|
+
// strictness this library does not copy, so the architecture is simply
|
|
3659
|
+
// not drawn.
|
|
3660
|
+
forester.domainArchitectureOf = function (node) {
|
|
3661
|
+
if (!node.sequences) {
|
|
3662
|
+
return null;
|
|
3663
|
+
}
|
|
3664
|
+
for (let i = 0; i < node.sequences.length; ++i) {
|
|
3665
|
+
let da = node.sequences[i].domain_architecture;
|
|
3666
|
+
if (da) {
|
|
3667
|
+
let L = domainNumber(da.length);
|
|
3668
|
+
return (Number.isInteger(L) && L > 0) ? da : null;
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
return null;
|
|
3672
|
+
};
|
|
3673
|
+
|
|
3674
|
+
// The drawable domains of an architecture, in from order (equal froms
|
|
3675
|
+
// keep file order). A malformed domain -- a coordinate missing or not an
|
|
3676
|
+
// integer, to <= from, no E-value -- is left out and counted, never
|
|
3677
|
+
// fatal; the caller reports the count once.
|
|
3678
|
+
forester.domainArchitectureDomains = function (da) {
|
|
3679
|
+
let domains = [];
|
|
3680
|
+
let ignored = 0;
|
|
3681
|
+
(da.domains || []).forEach(function (d) {
|
|
3682
|
+
let from = domainNumber(d.from);
|
|
3683
|
+
let to = domainNumber(d.to);
|
|
3684
|
+
let e = domainNumber(d.confidence);
|
|
3685
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || to <= from || !isFinite(e)) {
|
|
3686
|
+
ignored++;
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
domains.push({name: d.name ? String(d.name) : '', from: from, to: to, length: to - from + 1, evalue: e});
|
|
3690
|
+
});
|
|
3691
|
+
domains.sort(function (a, b) {
|
|
3692
|
+
return a.from - b.from;
|
|
3693
|
+
});
|
|
3694
|
+
return {domains: domains, ignored: ignored};
|
|
3695
|
+
};
|
|
3696
|
+
|
|
3697
|
+
// The tree's domain facts: tips carrying an architecture, the longest
|
|
3698
|
+
// architecture (Lmax -- it sets one scale for every track and, counting
|
|
3699
|
+
// every domain whatever its E-value, never moves with the threshold),
|
|
3700
|
+
// the drawable domain count and the malformed count.
|
|
3701
|
+
forester.domainArchitectureStats = function (tree) {
|
|
3702
|
+
let stats = {tips: 0, maxLength: 0, domains: 0, ignored: 0};
|
|
3703
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
3704
|
+
if (n.children) {
|
|
3705
|
+
return;
|
|
3706
|
+
}
|
|
3707
|
+
let da = forester.domainArchitectureOf(n);
|
|
3708
|
+
if (!da) {
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
stats.tips++;
|
|
3712
|
+
let L = Number(da.length);
|
|
3713
|
+
if (L > stats.maxLength) {
|
|
3714
|
+
stats.maxLength = L;
|
|
3715
|
+
}
|
|
3716
|
+
let dd = forester.domainArchitectureDomains(da);
|
|
3717
|
+
stats.domains += dd.domains.length;
|
|
3718
|
+
stats.ignored += dd.ignored;
|
|
3719
|
+
});
|
|
3720
|
+
return stats;
|
|
3721
|
+
};
|
|
3722
|
+
|
|
3723
|
+
// What a threshold admits over the tips: the distinct drawn names sorted
|
|
3724
|
+
// by UTF-16 code unit (the palette's order -- Java's string order,
|
|
3725
|
+
// uppercase before lowercase, so DED sorts before Death), the drawn box
|
|
3726
|
+
// count, the legend rows in first-appearance order (tips in the order
|
|
3727
|
+
// given, domains in from order) each with its count of drawn boxes, and
|
|
3728
|
+
// how many drawn boxes have no name. `tips` is a root, walked in
|
|
3729
|
+
// preorder, or an array of tips in DISPLAY order -- the legend reads
|
|
3730
|
+
// the way the eye goes down the tree, which a ladderized display does
|
|
3731
|
+
// not do in data order.
|
|
3732
|
+
forester.domainSummary = function (tips, exponent) {
|
|
3733
|
+
let T = forester.domainEvalueThreshold(exponent);
|
|
3734
|
+
let counts = Object.create(null);
|
|
3735
|
+
let legend = [];
|
|
3736
|
+
let boxes = 0;
|
|
3737
|
+
let unnamed = 0;
|
|
3738
|
+
let nodes = tips;
|
|
3739
|
+
if (!Array.isArray(tips)) {
|
|
3740
|
+
nodes = [];
|
|
3741
|
+
forester.preOrderTraversalAll(tips, function (n) {
|
|
3742
|
+
if (!n.children) {
|
|
3743
|
+
nodes.push(n);
|
|
3744
|
+
}
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3747
|
+
nodes.forEach(function (n) {
|
|
3748
|
+
let da = forester.domainArchitectureOf(n);
|
|
3749
|
+
if (!da) {
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
forester.domainArchitectureDomains(da).domains.forEach(function (d) {
|
|
3753
|
+
if (d.evalue > T) {
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
boxes++;
|
|
3757
|
+
if (d.name.length === 0) {
|
|
3758
|
+
unnamed++;
|
|
3759
|
+
return;
|
|
3760
|
+
}
|
|
3761
|
+
if (!(d.name in counts)) {
|
|
3762
|
+
counts[d.name] = 0;
|
|
3763
|
+
legend.push({name: d.name, count: 0});
|
|
3764
|
+
}
|
|
3765
|
+
counts[d.name]++;
|
|
3766
|
+
});
|
|
3767
|
+
});
|
|
3768
|
+
legend.forEach(function (row) {
|
|
3769
|
+
row.count = counts[row.name];
|
|
3770
|
+
});
|
|
3771
|
+
return {names: Object.keys(counts).sort(), boxes: boxes, legend: legend, unnamed: unnamed};
|
|
3772
|
+
};
|
|
3773
|
+
|
|
3774
|
+
// One architecture's geometry: the backbone and the boxes the threshold
|
|
3775
|
+
// admits, given its start x and the px per residue f. Residue r covers
|
|
3776
|
+
// [(r-1) f, r f], so a domain from..to spans [start + (from-1) f,
|
|
3777
|
+
// start + to f] and a domain 1..L is exactly the backbone (decided with
|
|
3778
|
+
// the desktop 2026-09-12; until its 416c705b it placed at from f, one
|
|
3779
|
+
// residue to the right). A box with no width is skipped.
|
|
3780
|
+
forester.domainBoxes = function (da, start, f, exponent) {
|
|
3781
|
+
let T = forester.domainEvalueThreshold(exponent);
|
|
3782
|
+
let boxes = [];
|
|
3783
|
+
forester.domainArchitectureDomains(da).domains.forEach(function (d) {
|
|
3784
|
+
if (d.evalue > T) {
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
let w = d.length * f;
|
|
3788
|
+
if (!(w > 0) || !isFinite(w)) {
|
|
3789
|
+
return;
|
|
3790
|
+
}
|
|
3791
|
+
boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to, evalue: d.evalue});
|
|
3792
|
+
});
|
|
3793
|
+
return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
|
|
3794
|
+
};
|
|
3795
|
+
|
|
3381
3796
|
// --------------------------------------------------------------
|
|
3382
3797
|
// Search engine
|
|
3383
3798
|
// --------------------------------------------------------------
|
|
@@ -3390,16 +3805,6 @@
|
|
|
3390
3805
|
// and ',' = OR / '+' = AND inside a text value. Used by archaeopteryx.js;
|
|
3391
3806
|
// pure tree logic, no DOM -- tested by test/search_test.js.
|
|
3392
3807
|
|
|
3393
|
-
const SEARCH_FIELD_LABELS = {
|
|
3394
|
-
NN: 'Node Name',
|
|
3395
|
-
TS: 'Taxonomy Scientific', TN: 'Taxonomy Common', TC: 'Taxonomy Code',
|
|
3396
|
-
TI: 'Taxonomy Identifier', SY: 'Taxonomy Synonym', LN: 'Taxonomy Lineage',
|
|
3397
|
-
SN: 'Seq Name', GN: 'Gene Name', SS: 'Gene Symbol', SA: 'Seq Accession',
|
|
3398
|
-
MS: 'Molecular Sequence', DO: 'Domain', AN: 'Annotation', XR: 'Cross-Reference'
|
|
3399
|
-
};
|
|
3400
|
-
const SEARCH_TEXT_ORDER = ['TS', 'TN', 'TC', 'TI', 'SY', 'LN', 'SN', 'GN', 'SS', 'SA', 'DO', 'AN', 'XR', 'MS'];
|
|
3401
|
-
// Fields folded into the "Any Text" umbrella (desktop omits MS + DO there).
|
|
3402
|
-
const SEARCH_ANY_TEXT_KEYS = ['NN', 'TS', 'TN', 'TC', 'TI', 'SY', 'LN', 'SN', 'GN', 'SS', 'SA', 'AN', 'XR'];
|
|
3403
3808
|
const SEARCH_NUMERIC_DATATYPES = new Set(['decimal', 'double', 'float', 'integer', 'int', 'long', 'short',
|
|
3404
3809
|
'byte', 'unsignedint', 'unsignedlong', 'unsignedshort', 'unsignedbyte', 'nonnegativeinteger',
|
|
3405
3810
|
'nonpositiveinteger', 'negativeinteger', 'positiveinteger']);
|
|
@@ -3407,24 +3812,124 @@
|
|
|
3407
3812
|
function searchTaxa(n) { return (n.taxonomies && n.taxonomies.length) ? n.taxonomies : []; }
|
|
3408
3813
|
function searchSeqs(n) { return (n.sequences && n.sequences.length) ? n.sequences : []; }
|
|
3409
3814
|
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3815
|
+
// A search field is an object -- {label, numeric, extract(node, root)}
|
|
3816
|
+
// and a few flags -- and nothing more: the field menu lists them, a spec
|
|
3817
|
+
// carries one, the engine calls its extract. There are no field ids.
|
|
3818
|
+
// Until 2026-09-12 every field was named by a two-letter code (NN, TS,
|
|
3819
|
+
// SA, ...): the suffixes of the 2.x search syntax ("foo:NN"). 3.0.0
|
|
3820
|
+
// replaced that syntax with the field menu, and the alphabet outlived it
|
|
3821
|
+
// as ids until Christian spotted it. A field is multi-valued; any value
|
|
3822
|
+
// matching is a match; root is needed only by Node Type.
|
|
3823
|
+
//
|
|
3824
|
+
// Flags: `anyText` -- "Any Text" ORs the field in (the molecular
|
|
3825
|
+
// sequence and the domains stay out, as on the desktop); `always` -- in
|
|
3826
|
+
// the menu even when no node carries it; `suggest: false` -- the value
|
|
3827
|
+
// box does not offer its values as type-ahead (near-unique, huge);
|
|
3828
|
+
// `metrics` -- needs the per-node depth / distance / clade size first.
|
|
3829
|
+
function textField(label, extract, flags) {
|
|
3830
|
+
return Object.assign({label: label, numeric: false, extract: extract}, flags || {});
|
|
3831
|
+
}
|
|
3832
|
+
function numericField(label, extract, flags) {
|
|
3833
|
+
return Object.assign({label: label, numeric: true, extract: extract}, flags || {});
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
// The text fields of a node; the labels are the desktop's, verbatim.
|
|
3837
|
+
const SEARCH_NODE_NAME = textField('Node Name', n => (n.name ? [n.name] : []), {always: true, anyText: true});
|
|
3838
|
+
const SEARCH_TAXONOMY_SCIENTIFIC_NAME = textField('Taxonomy Scientific', n => searchTaxa(n).map(t => t.scientific_name).filter(Boolean), {anyText: true});
|
|
3839
|
+
const SEARCH_TAXONOMY_COMMON_NAME = textField('Taxonomy Common', n => searchTaxa(n).map(t => t.common_name).filter(Boolean), {anyText: true});
|
|
3840
|
+
const SEARCH_TAXONOMY_CODE = textField('Taxonomy Code', n => searchTaxa(n).map(t => t.code).filter(Boolean), {anyText: true});
|
|
3841
|
+
const SEARCH_TAXONOMY_ID = textField('Taxonomy Identifier', n => searchTaxa(n).map(t => t.id && t.id.value).filter(Boolean), {anyText: true});
|
|
3842
|
+
const SEARCH_TAXONOMY_SYNONYM = textField('Taxonomy Synonym', n => searchTaxa(n).reduce((a, t) => a.concat(t.synonyms || []), []).filter(Boolean), {anyText: true});
|
|
3843
|
+
const SEARCH_TAXONOMY_LINEAGE = textField('Taxonomy Lineage', n => searchTaxa(n).reduce((a, t) => a.concat(t.lineage || []), []).filter(Boolean), {anyText: true});
|
|
3844
|
+
const SEARCH_SEQUENCE_NAME = textField('Seq Name', n => searchSeqs(n).map(s => s.name).filter(Boolean), {anyText: true});
|
|
3845
|
+
const SEARCH_GENE_NAME = textField('Gene Name', n => searchSeqs(n).map(s => s.gene_name).filter(Boolean), {anyText: true});
|
|
3846
|
+
// phyloXML <sequence><symbol>: the gene symbol
|
|
3847
|
+
const SEARCH_SEQUENCE_SYMBOL = textField('Gene Symbol', n => searchSeqs(n).map(s => s.symbol).filter(Boolean), {anyText: true});
|
|
3848
|
+
const SEARCH_SEQUENCE_ACCESSION = textField('Seq Accession', n => searchSeqs(n).map(s => s.accession && s.accession.value).filter(Boolean), {anyText: true});
|
|
3849
|
+
const SEARCH_DOMAIN = textField('Domain', n => searchSeqs(n).reduce((a, s) => a.concat((s.domain_architecture && s.domain_architecture.domains) ? s.domain_architecture.domains.map(d => d.name) : []), []).filter(Boolean));
|
|
3850
|
+
const SEARCH_ANNOTATION = textField('Annotation', n => searchSeqs(n).reduce((a, s) => a.concat((s.annotations || []).reduce((b, an) => b.concat([an.desc, an.ref]), [])), []).filter(Boolean), {anyText: true});
|
|
3851
|
+
const SEARCH_CROSS_REFERENCE = textField('Cross-Reference', n => searchSeqs(n).reduce((a, s) => a.concat((s.cross_references || []).reduce((b, x) => b.concat([x.value, x.source, x.comment]), [])), []).filter(Boolean), {anyText: true});
|
|
3852
|
+
const SEARCH_MOLECULAR_SEQUENCE = textField('Molecular Sequence', n => searchSeqs(n).map(s => s.mol_seq).filter(Boolean), {suggest: false});
|
|
3853
|
+
// in menu order
|
|
3854
|
+
const SEARCH_TEXT_FIELDS = [
|
|
3855
|
+
SEARCH_NODE_NAME, SEARCH_TAXONOMY_SCIENTIFIC_NAME, SEARCH_TAXONOMY_COMMON_NAME, SEARCH_TAXONOMY_CODE,
|
|
3856
|
+
SEARCH_TAXONOMY_ID, SEARCH_TAXONOMY_SYNONYM, SEARCH_TAXONOMY_LINEAGE, SEARCH_SEQUENCE_NAME, SEARCH_GENE_NAME,
|
|
3857
|
+
SEARCH_SEQUENCE_SYMBOL, SEARCH_SEQUENCE_ACCESSION, SEARCH_DOMAIN, SEARCH_ANNOTATION, SEARCH_CROSS_REFERENCE,
|
|
3858
|
+
SEARCH_MOLECULAR_SEQUENCE
|
|
3859
|
+
];
|
|
3860
|
+
|
|
3861
|
+
// "Any Text": every anyText field above plus every custom property.
|
|
3862
|
+
const SEARCH_ANY_TEXT = textField('Any Text', function (node) {
|
|
3863
|
+
let out = [];
|
|
3864
|
+
SEARCH_TEXT_FIELDS.forEach(function (f) {
|
|
3865
|
+
if (f.anyText) out = out.concat(f.extract(node));
|
|
3866
|
+
});
|
|
3867
|
+
if (node.properties) {
|
|
3868
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
3869
|
+
let p = node.properties[i];
|
|
3870
|
+
if (!isInternalPropRef(p.ref) && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
return out;
|
|
3874
|
+
}, {suggest: false});
|
|
3875
|
+
|
|
3876
|
+
const SEARCH_BRANCH_LENGTH = numericField('Branch Length', n => (typeof n.branch_length === 'number') ? [n.branch_length] : []);
|
|
3877
|
+
const SEARCH_CONFIDENCE = numericField('Confidence', n => n.confidences ? n.confidences.map(c => c.value).filter(v => typeof v === 'number') : []);
|
|
3878
|
+
const SEARCH_CLADE_SIZE = numericField('Clade Size (tips)', n => [n._srchClade], {metrics: true});
|
|
3879
|
+
const SEARCH_CHILD_COUNT = numericField('Number of Children', n => [n.children ? n.children.length : 0]);
|
|
3880
|
+
const SEARCH_DEPTH = numericField('Depth from Root', n => [n._srchDepth], {metrics: true});
|
|
3881
|
+
const SEARCH_DISTANCE_FROM_ROOT = numericField('Distance from Root', n => [n._srchDist], {metrics: true});
|
|
3882
|
+
const SEARCH_NODE_TYPE = textField('Node Type', function (node, root) {
|
|
3883
|
+
let kids = node.children;
|
|
3884
|
+
let isLeaf = !kids || kids.length === 0;
|
|
3885
|
+
return [isLeaf ? 'leaf' : (node === root ? 'root' : 'internal')];
|
|
3886
|
+
});
|
|
3887
|
+
|
|
3888
|
+
// The built-in fields by name, for a caller that asks "does this tree
|
|
3889
|
+
// carry X?" -- the viewer's label presets test whether a field object is
|
|
3890
|
+
// among availableSearchFields(tree), by identity. Property fields have
|
|
3891
|
+
// no entry here: there is one per ref, made per tree.
|
|
3892
|
+
forester.searchFields = {
|
|
3893
|
+
anyText: SEARCH_ANY_TEXT,
|
|
3894
|
+
nodeName: SEARCH_NODE_NAME,
|
|
3895
|
+
taxonomyScientificName: SEARCH_TAXONOMY_SCIENTIFIC_NAME,
|
|
3896
|
+
taxonomyCommonName: SEARCH_TAXONOMY_COMMON_NAME,
|
|
3897
|
+
taxonomyCode: SEARCH_TAXONOMY_CODE,
|
|
3898
|
+
taxonomyId: SEARCH_TAXONOMY_ID,
|
|
3899
|
+
taxonomySynonym: SEARCH_TAXONOMY_SYNONYM,
|
|
3900
|
+
taxonomyLineage: SEARCH_TAXONOMY_LINEAGE,
|
|
3901
|
+
sequenceName: SEARCH_SEQUENCE_NAME,
|
|
3902
|
+
geneName: SEARCH_GENE_NAME,
|
|
3903
|
+
sequenceSymbol: SEARCH_SEQUENCE_SYMBOL,
|
|
3904
|
+
sequenceAccession: SEARCH_SEQUENCE_ACCESSION,
|
|
3905
|
+
domain: SEARCH_DOMAIN,
|
|
3906
|
+
annotation: SEARCH_ANNOTATION,
|
|
3907
|
+
crossReference: SEARCH_CROSS_REFERENCE,
|
|
3908
|
+
molecularSequence: SEARCH_MOLECULAR_SEQUENCE,
|
|
3909
|
+
branchLength: SEARCH_BRANCH_LENGTH,
|
|
3910
|
+
confidence: SEARCH_CONFIDENCE,
|
|
3911
|
+
cladeSize: SEARCH_CLADE_SIZE,
|
|
3912
|
+
childCount: SEARCH_CHILD_COUNT,
|
|
3913
|
+
depth: SEARCH_DEPTH,
|
|
3914
|
+
distanceFromRoot: SEARCH_DISTANCE_FROM_ROOT,
|
|
3915
|
+
nodeType: SEARCH_NODE_TYPE
|
|
3426
3916
|
};
|
|
3427
3917
|
|
|
3918
|
+
// One field per custom property ref; numeric when its declared datatype
|
|
3919
|
+
// or, failing that, every one of its values says so.
|
|
3920
|
+
function propertyField(ref, numeric) {
|
|
3921
|
+
return {label: ref, numeric: numeric, extract: function (node) {
|
|
3922
|
+
let out = [];
|
|
3923
|
+
if (node.properties) {
|
|
3924
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
3925
|
+
let p = node.properties[i];
|
|
3926
|
+
if (p.ref === ref && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
return out;
|
|
3930
|
+
}};
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3428
3933
|
function isInternalPropRef(ref) { return !ref || ref.indexOf('aptx:') === 0; }
|
|
3429
3934
|
|
|
3430
3935
|
function datatypeIsNumeric(dt) {
|
|
@@ -3492,19 +3997,19 @@
|
|
|
3492
3997
|
// dropdowns). Always exposes Any Text + Node Name; adds the text, numeric
|
|
3493
3998
|
// and custom-property fields that are present, then structure fields.
|
|
3494
3999
|
forester.availableSearchFields = function (root) {
|
|
3495
|
-
let fields = [];
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
4000
|
+
let fields = [SEARCH_ANY_TEXT];
|
|
4001
|
+
if (!root) {
|
|
4002
|
+
SEARCH_TEXT_FIELDS.forEach(function (f) { if (f.always) fields.push(f); });
|
|
4003
|
+
return fields;
|
|
4004
|
+
}
|
|
3499
4005
|
|
|
3500
|
-
let present =
|
|
4006
|
+
let present = new Set();
|
|
3501
4007
|
let hasBL = false, hasConf = false;
|
|
3502
4008
|
let propRefs = {}; // ref -> { num, tot, dtNum, dtStr }
|
|
3503
4009
|
forester.preOrderTraversalAll(root, function (n) {
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
}
|
|
4010
|
+
SEARCH_TEXT_FIELDS.forEach(function (f) {
|
|
4011
|
+
if (!f.always && !present.has(f) && f.extract(n).length > 0) present.add(f);
|
|
4012
|
+
});
|
|
3508
4013
|
if (!hasBL && typeof n.branch_length === 'number' && n.branch_length >= 0) hasBL = true;
|
|
3509
4014
|
if (!hasConf && n.confidences) {
|
|
3510
4015
|
for (let i = 0; i < n.confidences.length; ++i) {
|
|
@@ -3523,23 +4028,20 @@
|
|
|
3523
4028
|
}
|
|
3524
4029
|
});
|
|
3525
4030
|
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
if (
|
|
3531
|
-
if (hasConf) fields.push({ key: 'CO', label: 'Confidence', numeric: true });
|
|
4031
|
+
SEARCH_TEXT_FIELDS.forEach(function (f) {
|
|
4032
|
+
if (f.always || present.has(f)) fields.push(f);
|
|
4033
|
+
});
|
|
4034
|
+
if (hasBL) fields.push(SEARCH_BRANCH_LENGTH);
|
|
4035
|
+
if (hasConf) fields.push(SEARCH_CONFIDENCE);
|
|
3532
4036
|
let refs = Object.keys(propRefs).sort();
|
|
3533
4037
|
for (let i = 0; i < refs.length; ++i) {
|
|
3534
4038
|
let r = propRefs[refs[i]];
|
|
3535
4039
|
let numeric = r.dtStr ? false : (r.dtNum ? true : (r.tot > 0 && r.num === r.tot));
|
|
3536
|
-
fields.push(
|
|
4040
|
+
fields.push(propertyField(refs[i], numeric));
|
|
3537
4041
|
}
|
|
3538
|
-
fields.push(
|
|
3539
|
-
fields.push(
|
|
3540
|
-
fields.push(
|
|
3541
|
-
if (hasBL) fields.push({ key: 'DR', label: 'Distance from Root', numeric: true });
|
|
3542
|
-
fields.push({ key: 'NT', label: 'Node Type', numeric: false });
|
|
4042
|
+
fields.push(SEARCH_CLADE_SIZE, SEARCH_CHILD_COUNT, SEARCH_DEPTH);
|
|
4043
|
+
if (hasBL) fields.push(SEARCH_DISTANCE_FROM_ROOT);
|
|
4044
|
+
fields.push(SEARCH_NODE_TYPE);
|
|
3543
4045
|
return fields;
|
|
3544
4046
|
};
|
|
3545
4047
|
|
|
@@ -3550,11 +4052,11 @@
|
|
|
3550
4052
|
n._srchDepth = depth;
|
|
3551
4053
|
let d = dist + (typeof n.branch_length === 'number' && n.branch_length > 0 ? n.branch_length : 0);
|
|
3552
4054
|
n._srchDist = d;
|
|
3553
|
-
let kids = n.children
|
|
4055
|
+
let kids = n.children;
|
|
3554
4056
|
if (kids) for (let i = 0; i < kids.length; ++i) pre(kids[i], depth + 1, d);
|
|
3555
4057
|
})(root, 0, 0);
|
|
3556
4058
|
forester.postOrderTraversalAll(root, function (n) {
|
|
3557
|
-
let kids = n.children
|
|
4059
|
+
let kids = n.children;
|
|
3558
4060
|
if (!kids || kids.length === 0) { n._srchClade = 1; return; }
|
|
3559
4061
|
let s = 0;
|
|
3560
4062
|
for (let i = 0; i < kids.length; ++i) s += kids[i]._srchClade;
|
|
@@ -3562,51 +4064,6 @@
|
|
|
3562
4064
|
});
|
|
3563
4065
|
}
|
|
3564
4066
|
|
|
3565
|
-
// Extract the value(s) of a field from a node (strings for text fields,
|
|
3566
|
-
// numbers for the numeric ones). A field is multi-valued; any value matching
|
|
3567
|
-
// is a match. root is needed only for the Node Type field.
|
|
3568
|
-
forester.extractSearchValues = function (node, field, root) {
|
|
3569
|
-
let key = field.key;
|
|
3570
|
-
if (key === 'ANY') {
|
|
3571
|
-
let out = [];
|
|
3572
|
-
for (let i = 0; i < SEARCH_ANY_TEXT_KEYS.length; ++i) {
|
|
3573
|
-
out = out.concat(SEARCH_TEXT_EXTRACTORS[SEARCH_ANY_TEXT_KEYS[i]](node));
|
|
3574
|
-
}
|
|
3575
|
-
if (node.properties) {
|
|
3576
|
-
for (let i = 0; i < node.properties.length; ++i) {
|
|
3577
|
-
let p = node.properties[i];
|
|
3578
|
-
if (!isInternalPropRef(p.ref) && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
|
|
3579
|
-
}
|
|
3580
|
-
}
|
|
3581
|
-
return out;
|
|
3582
|
-
}
|
|
3583
|
-
if (key === 'NT') {
|
|
3584
|
-
let kids = node.children || node._children;
|
|
3585
|
-
let isLeaf = !kids || kids.length === 0;
|
|
3586
|
-
return [isLeaf ? 'leaf' : (node === root ? 'root' : 'internal')];
|
|
3587
|
-
}
|
|
3588
|
-
if (key.indexOf('PROP:') === 0) {
|
|
3589
|
-
let out = [];
|
|
3590
|
-
if (node.properties) {
|
|
3591
|
-
for (let i = 0; i < node.properties.length; ++i) {
|
|
3592
|
-
let p = node.properties[i];
|
|
3593
|
-
if (p.ref === field.propRef && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
|
|
3594
|
-
}
|
|
3595
|
-
}
|
|
3596
|
-
return out;
|
|
3597
|
-
}
|
|
3598
|
-
if (SEARCH_TEXT_EXTRACTORS[key]) return SEARCH_TEXT_EXTRACTORS[key](node);
|
|
3599
|
-
switch (key) {
|
|
3600
|
-
case 'BL': return (typeof node.branch_length === 'number') ? [node.branch_length] : [];
|
|
3601
|
-
case 'CO': return node.confidences ? node.confidences.map(c => c.value).filter(v => typeof v === 'number') : [];
|
|
3602
|
-
case 'CS': return [node._srchClade];
|
|
3603
|
-
case 'NC': { let kids = node.children || node._children; return [kids ? kids.length : 0]; }
|
|
3604
|
-
case 'DE': return [node._srchDepth];
|
|
3605
|
-
case 'DR': return [node._srchDist];
|
|
3606
|
-
default: return [];
|
|
3607
|
-
}
|
|
3608
|
-
};
|
|
3609
|
-
|
|
3610
4067
|
// Run one search spec { field, mode, value, value2, caseSensitive, inverse }
|
|
3611
4068
|
// over the tree and return the Set of matching nodes.
|
|
3612
4069
|
// A parsed tree is anchored on a SUPER-ROOT: a synthetic node whose single
|
|
@@ -3633,7 +4090,7 @@
|
|
|
3633
4090
|
// values are unchanged; only the set of nodes considered is narrowed.
|
|
3634
4091
|
let nodes = realRootOf(root);
|
|
3635
4092
|
let field = spec.field;
|
|
3636
|
-
if (field.
|
|
4093
|
+
if (field.metrics) computeSearchMetrics(root);
|
|
3637
4094
|
|
|
3638
4095
|
let v = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
|
|
3639
4096
|
v = v.replace(/\s+/g, ' ').trim();
|
|
@@ -3646,7 +4103,7 @@
|
|
|
3646
4103
|
let lo = (b !== null) ? Math.min(a, b) : a;
|
|
3647
4104
|
let hi = (b !== null) ? Math.max(a, b) : a;
|
|
3648
4105
|
test = function (n) {
|
|
3649
|
-
let vals =
|
|
4106
|
+
let vals = field.extract(n, root);
|
|
3650
4107
|
for (let i = 0; i < vals.length; ++i) {
|
|
3651
4108
|
let x = (typeof vals[i] === 'number') ? vals[i] : forester.parseFiniteDouble(vals[i]);
|
|
3652
4109
|
if (x !== null && numMatches(x, spec.mode, a, lo, hi)) return true;
|
|
@@ -3675,7 +4132,7 @@
|
|
|
3675
4132
|
}
|
|
3676
4133
|
if (!compiled.length) return result;
|
|
3677
4134
|
test = function (n) {
|
|
3678
|
-
let vals =
|
|
4135
|
+
let vals = field.extract(n, root);
|
|
3679
4136
|
for (let oi = 0; oi < compiled.length; ++oi) {
|
|
3680
4137
|
let ands = compiled[oi], ok = true;
|
|
3681
4138
|
for (let ai = 0; ai < ands.length; ++ai) {
|
|
@@ -3695,7 +4152,7 @@
|
|
|
3695
4152
|
// Complement, scoped to nodes that actually carry this field.
|
|
3696
4153
|
let inv = new Set();
|
|
3697
4154
|
forester.preOrderTraversalAll(nodes, function (n) {
|
|
3698
|
-
if (!result.has(n) &&
|
|
4155
|
+
if (!result.has(n) && field.extract(n, root).length > 0) inv.add(n);
|
|
3699
4156
|
});
|
|
3700
4157
|
return inv;
|
|
3701
4158
|
}
|
|
@@ -3703,13 +4160,14 @@
|
|
|
3703
4160
|
};
|
|
3704
4161
|
|
|
3705
4162
|
// Distinct, trimmed, sorted values of a specific text field across the tree,
|
|
3706
|
-
// for the value-box autocomplete. Empty for numeric
|
|
3707
|
-
//
|
|
4163
|
+
// for the value-box autocomplete. Empty for a numeric field and for the
|
|
4164
|
+
// fields flagged suggest: false (Any Text; the molecular sequence, which
|
|
4165
|
+
// is near-unique and huge). cap limits the list length (optional).
|
|
3708
4166
|
forester.distinctSearchValues = function (root, field, cap) {
|
|
3709
|
-
if (!root || !field || field.numeric || field.
|
|
4167
|
+
if (!root || !field || field.numeric || field.suggest === false) return [];
|
|
3710
4168
|
let set = new Set();
|
|
3711
4169
|
forester.preOrderTraversalAll(root, function (n) {
|
|
3712
|
-
let vals =
|
|
4170
|
+
let vals = field.extract(n, root);
|
|
3713
4171
|
for (let i = 0; i < vals.length; ++i) {
|
|
3714
4172
|
if (vals[i] !== null && vals[i] !== undefined) {
|
|
3715
4173
|
let v = String(vals[i]).trim();
|
|
@@ -4021,7 +4479,7 @@
|
|
|
4021
4479
|
let hasInternalIntervals = false;
|
|
4022
4480
|
let hasExternalIntervals = false;
|
|
4023
4481
|
forester.preOrderTraversalAll(root, function (n) {
|
|
4024
|
-
let isExt = !n.children
|
|
4482
|
+
let isExt = !n.children;
|
|
4025
4483
|
if (isExt) {
|
|
4026
4484
|
++external;
|
|
4027
4485
|
} else {
|
|
@@ -4497,7 +4955,7 @@
|
|
|
4497
4955
|
let candidates = [];
|
|
4498
4956
|
let anyConfidence = false;
|
|
4499
4957
|
forester.preOrderTraversalAll(root, function (n) {
|
|
4500
|
-
if (n === root || !(n.children
|
|
4958
|
+
if (n === root || !(n.children)) {
|
|
4501
4959
|
return;
|
|
4502
4960
|
}
|
|
4503
4961
|
if (n.confidences && n.confidences.length > 0) {
|