archaeopteryx 3.2.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -18
- package/archaeopteryx.d.ts +100 -10
- package/archaeopteryx.js +3089 -384
- package/forester.js +1180 -436
- 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.4.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,111 @@
|
|
|
1341
1577
|
return null;
|
|
1342
1578
|
};
|
|
1343
1579
|
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
+
// `root` is a node, walked in preorder, or an array of the tips to
|
|
1606
|
+
// describe (the viewer passes what is on screen, which a collapsed clade
|
|
1607
|
+
// shortens).
|
|
1608
|
+
forester.visualizationSummary = function (candidate, root) {
|
|
1609
|
+
let counts = Object.create(null);
|
|
1610
|
+
let total = 0;
|
|
1611
|
+
let coverage = 0;
|
|
1612
|
+
let tips = root;
|
|
1613
|
+
if (!Array.isArray(root)) {
|
|
1614
|
+
tips = [];
|
|
1615
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
1616
|
+
if (!n.children) {
|
|
1617
|
+
tips.push(n);
|
|
1357
1618
|
}
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
tips.forEach(function (n) {
|
|
1622
|
+
if (n.children) {
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
total++;
|
|
1626
|
+
let v = forester.visualizationNodeValue(n, candidate);
|
|
1627
|
+
if (v !== null) {
|
|
1628
|
+
coverage++;
|
|
1629
|
+
counts[v] = (counts[v] || 0) + 1;
|
|
1358
1630
|
}
|
|
1359
1631
|
});
|
|
1360
|
-
|
|
1632
|
+
let values = Object.keys(counts);
|
|
1633
|
+
if (candidate.numeric) {
|
|
1634
|
+
values.sort(function (a, b) {
|
|
1635
|
+
return Number(a) - Number(b);
|
|
1636
|
+
});
|
|
1637
|
+
} else {
|
|
1638
|
+
values.sort();
|
|
1639
|
+
}
|
|
1640
|
+
let summary = {values: values, counts: counts, coverage: coverage, total: total, distinct: values.length};
|
|
1641
|
+
if (candidate.numeric) {
|
|
1642
|
+
let modes = visNumericModes(values.length);
|
|
1643
|
+
summary.colorMode = modes.colorMode;
|
|
1644
|
+
summary.switchable = modes.switchable;
|
|
1645
|
+
}
|
|
1646
|
+
return summary;
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
// The candidates of a tree the user has EDITED (a subtree deleted), with
|
|
1650
|
+
// the fields they had chosen KEPT as long as those still carry a value
|
|
1651
|
+
// somewhere in what remains. The refusal rules decide what is offered,
|
|
1652
|
+
// never what is already chosen: colouring by Host and deleting every
|
|
1653
|
+
// clade but one must not silently uncolour the tree because one host is
|
|
1654
|
+
// "not a category". A kept field is appended after the offered ones and
|
|
1655
|
+
// flagged `kept`, so the menu holds it exactly as long as the user does;
|
|
1656
|
+
// the next edit drops it unless it is still chosen. `chosen` is the
|
|
1657
|
+
// previous candidate objects -- their grouping travels with them.
|
|
1658
|
+
forester.visualizationCandidatesKeeping = function (tree, chosen) {
|
|
1659
|
+
let candidates = forester.visualizationCandidates(tree);
|
|
1660
|
+
let ids = Object.create(null);
|
|
1661
|
+
candidates.forEach(function (c) {
|
|
1662
|
+
ids[c.id] = true;
|
|
1663
|
+
});
|
|
1664
|
+
(chosen || []).forEach(function (c) {
|
|
1665
|
+
if (!c || ids[c.id]) {
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
let s = forester.visualizationSummary(c, tree);
|
|
1669
|
+
if (s.coverage === 0) {
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
c.values = s.values;
|
|
1673
|
+
c.counts = s.counts;
|
|
1674
|
+
c.coverage = s.coverage;
|
|
1675
|
+
c.total = s.total;
|
|
1676
|
+
if (c.numeric) {
|
|
1677
|
+
c.colorMode = s.colorMode;
|
|
1678
|
+
c.switchable = s.switchable;
|
|
1679
|
+
}
|
|
1680
|
+
c.kept = true;
|
|
1681
|
+
ids[c.id] = true;
|
|
1682
|
+
candidates.push(c);
|
|
1683
|
+
});
|
|
1684
|
+
return candidates;
|
|
1361
1685
|
};
|
|
1362
1686
|
|
|
1363
1687
|
forester.collectBasicTreeProperties = function (tree) {
|
|
@@ -1376,12 +1700,12 @@
|
|
|
1376
1700
|
properties.taxonomies = false;
|
|
1377
1701
|
properties.alignedMolSeqs = true;
|
|
1378
1702
|
properties.maxMolSeqLength = 0;
|
|
1703
|
+
// protein domain architectures on the tips: whether any tip carries
|
|
1704
|
+
// one, and the longest (Lmax, the scale every track shares)
|
|
1705
|
+
properties.domainArchitectures = false;
|
|
1706
|
+
properties.maxDomainArchitectureLength = 0;
|
|
1379
1707
|
properties.externalNodesCount = 0;
|
|
1380
1708
|
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
1709
|
// Branches that carry a length AT ALL -- an explicit zero is a real
|
|
1386
1710
|
// measurement, not a missing one, so these are counted separately from
|
|
1387
1711
|
// the "positive" tally above. Split internal-vs-all because a missing
|
|
@@ -1394,6 +1718,14 @@
|
|
|
1394
1718
|
properties.internalBranchCount = 0;
|
|
1395
1719
|
properties.internalBranchesWithLength = 0;
|
|
1396
1720
|
properties.averageBranchLength = 0;
|
|
1721
|
+
// Positive lengths only, and the root included: these feed
|
|
1722
|
+
// averageBranchLength and nothing else. They are deliberately NOT the
|
|
1723
|
+
// same population as branchCount / branchesWithLength below, which
|
|
1724
|
+
// exclude the root and count an explicit zero as the measurement it is.
|
|
1725
|
+
// A `branchesWithPositiveLength` property built from this counter was
|
|
1726
|
+
// removed on 2026-09-11 -- nothing read it, and having two tallies over
|
|
1727
|
+
// two different node sets invited exactly the comparison that would be
|
|
1728
|
+
// wrong.
|
|
1397
1729
|
let bl_counter = 0;
|
|
1398
1730
|
let bl_sum = 0;
|
|
1399
1731
|
// Counting the super-root would add a node and a branch that do not
|
|
@@ -1404,7 +1736,7 @@
|
|
|
1404
1736
|
forester.preOrderTraversalAll(rootNode, function (n) {
|
|
1405
1737
|
properties.nodeCount += 1;
|
|
1406
1738
|
if (n !== rootNode) {
|
|
1407
|
-
let internal = !!(n.children
|
|
1739
|
+
let internal = !!(n.children);
|
|
1408
1740
|
let measured = typeof n.branch_length === 'number' && isFinite(n.branch_length);
|
|
1409
1741
|
properties.branchCount += 1;
|
|
1410
1742
|
if (measured) {
|
|
@@ -1422,11 +1754,11 @@
|
|
|
1422
1754
|
if (n.name.length > properties.longestNodeName) {
|
|
1423
1755
|
properties.longestNodeName = n.name.length;
|
|
1424
1756
|
}
|
|
1425
|
-
if ((n.children
|
|
1757
|
+
if ((n.children) && (n.parent)) {
|
|
1426
1758
|
properties.internalNodeData = true;
|
|
1427
1759
|
}
|
|
1428
1760
|
}
|
|
1429
|
-
if (!(n.children
|
|
1761
|
+
if (!(n.children)) {
|
|
1430
1762
|
properties.externalNodesCount += 1;
|
|
1431
1763
|
}
|
|
1432
1764
|
if (n.branch_length && n.branch_length > 0) {
|
|
@@ -1443,7 +1775,7 @@
|
|
|
1443
1775
|
if (n.sequences && n.sequences.length > 0) {
|
|
1444
1776
|
properties.sequences = true;
|
|
1445
1777
|
|
|
1446
|
-
if (n.children
|
|
1778
|
+
if (n.children) {
|
|
1447
1779
|
properties.internalNodeData = true;
|
|
1448
1780
|
} else {
|
|
1449
1781
|
let s = n.sequences[0];
|
|
@@ -1455,11 +1787,18 @@
|
|
|
1455
1787
|
properties.alignedMolSeqs = false;
|
|
1456
1788
|
}
|
|
1457
1789
|
}
|
|
1790
|
+
let da = forester.domainArchitectureOf(n);
|
|
1791
|
+
if (da) {
|
|
1792
|
+
properties.domainArchitectures = true;
|
|
1793
|
+
if (Number(da.length) > properties.maxDomainArchitectureLength) {
|
|
1794
|
+
properties.maxDomainArchitectureLength = Number(da.length);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1458
1797
|
}
|
|
1459
1798
|
}
|
|
1460
1799
|
if (n.taxonomies && n.taxonomies.length > 0) {
|
|
1461
1800
|
properties.taxonomies = true;
|
|
1462
|
-
if (n.children
|
|
1801
|
+
if (n.children) {
|
|
1463
1802
|
properties.internalNodeData = true;
|
|
1464
1803
|
}
|
|
1465
1804
|
}
|
|
@@ -1483,7 +1822,6 @@
|
|
|
1483
1822
|
|
|
1484
1823
|
});
|
|
1485
1824
|
|
|
1486
|
-
properties.branchesWithPositiveLength = bl_counter;
|
|
1487
1825
|
|
|
1488
1826
|
if (bl_counter > 0) {
|
|
1489
1827
|
properties.averageBranchLength = bl_sum / bl_counter;
|
|
@@ -1522,15 +1860,14 @@
|
|
|
1522
1860
|
forester.calcSumOfAllExternalDescendants = function (node) {
|
|
1523
1861
|
let nodes = 0;
|
|
1524
1862
|
forester.preOrderTraversalAll(node, function (n) {
|
|
1525
|
-
if (!(n.children
|
|
1863
|
+
if (!(n.children)) {
|
|
1526
1864
|
++nodes;
|
|
1527
1865
|
}
|
|
1528
1866
|
});
|
|
1529
1867
|
return nodes;
|
|
1530
1868
|
};
|
|
1531
1869
|
|
|
1532
|
-
// Ladderize: at every node, order the
|
|
1533
|
-
// collapsed node's hidden _children are left untouched) by clade size --
|
|
1870
|
+
// Ladderize: at every node, order the children by clade size --
|
|
1534
1871
|
// largest first when largestFirst, smallest first when not. Works at ANY
|
|
1535
1872
|
// child count, not just 2, so a polytomy (common on a phylodynamic tree,
|
|
1536
1873
|
// e.g. an Auspice build, where every internal node may carry 3+ children)
|
|
@@ -1580,7 +1917,7 @@
|
|
|
1580
1917
|
forester.getAllExternalNodes = function (node) {
|
|
1581
1918
|
let nodes = [];
|
|
1582
1919
|
forester.preOrderTraversalAll(node, function (n) {
|
|
1583
|
-
if (!n.children
|
|
1920
|
+
if (!n.children) {
|
|
1584
1921
|
nodes.push(n);
|
|
1585
1922
|
}
|
|
1586
1923
|
});
|
|
@@ -1595,19 +1932,6 @@
|
|
|
1595
1932
|
return nodes;
|
|
1596
1933
|
};
|
|
1597
1934
|
|
|
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
1935
|
forester.calcDepth = function (node) {
|
|
1612
1936
|
|
|
1613
1937
|
let steps = 0;
|
|
@@ -1619,31 +1943,6 @@
|
|
|
1619
1943
|
};
|
|
1620
1944
|
|
|
1621
1945
|
|
|
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
1946
|
forester.calcMaxBranchLength = function (node) {
|
|
1648
1947
|
let max = 0;
|
|
1649
1948
|
forester.preOrderTraversalAll(node, function (n) {
|
|
@@ -1655,33 +1954,6 @@
|
|
|
1655
1954
|
};
|
|
1656
1955
|
|
|
1657
1956
|
|
|
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
1957
|
/**
|
|
1686
1958
|
* To parse a New Hampshire (Newick) formatted tree.
|
|
1687
1959
|
*
|
|
@@ -2058,6 +2330,17 @@
|
|
|
2058
2330
|
// the shared contract -- and throwing on a combination a user
|
|
2059
2331
|
// plausibly wants is what forced callers into workarounds.
|
|
2060
2332
|
|
|
2333
|
+
// A text holding several trees (one per ';') reads as its FIRST;
|
|
2334
|
+
// parseNewHampshireTrees reads them all. Before this the statements
|
|
2335
|
+
// ran together and the LAST tree came back, the others silently
|
|
2336
|
+
// dropped.
|
|
2337
|
+
{
|
|
2338
|
+
let statements = forester.splitNewHampshire(nhStr);
|
|
2339
|
+
if (statements.length > 1) {
|
|
2340
|
+
nhStr = statements[0];
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2061
2344
|
let ancs = [];
|
|
2062
2345
|
let x = {};
|
|
2063
2346
|
|
|
@@ -2150,7 +2433,16 @@
|
|
|
2150
2433
|
// the separator before a branch length: the length itself
|
|
2151
2434
|
// is read by the branch below, so there is nothing to do here
|
|
2152
2435
|
} else {
|
|
2153
|
-
|
|
2436
|
+
// What came before decides what this element is: a name
|
|
2437
|
+
// follows an opening bracket, a comma or a close, and a
|
|
2438
|
+
// branch length follows a colon.
|
|
2439
|
+
//
|
|
2440
|
+
// At i === 0 there IS no previous token, and the string
|
|
2441
|
+
// beginning with a label is the one-node tree "a;" --
|
|
2442
|
+
// valid Newick, and for want of this line its name was
|
|
2443
|
+
// read as nothing at all and written back as "". The
|
|
2444
|
+
// start of input opens the tree, so it counts as '('.
|
|
2445
|
+
let e = (i === 0) ? '(' : ss[i - 1];
|
|
2154
2446
|
if (e) {
|
|
2155
2447
|
e = e.trim();
|
|
2156
2448
|
// re-attach any annotation blobs riding on this
|
|
@@ -2280,7 +2572,7 @@
|
|
|
2280
2572
|
|
|
2281
2573
|
function moveInternalNodeNamesToConfidenceValues(node) {
|
|
2282
2574
|
forester.preOrderTraversalAll(node, function (n) {
|
|
2283
|
-
if (n.children
|
|
2575
|
+
if (n.children) {
|
|
2284
2576
|
if (n.name) {
|
|
2285
2577
|
let s = n.name;
|
|
2286
2578
|
if (NUMBERS_ONLY_PATTERN.test(s)) {
|
|
@@ -2300,6 +2592,66 @@
|
|
|
2300
2592
|
}
|
|
2301
2593
|
};
|
|
2302
2594
|
|
|
2595
|
+
// Splits a New Hampshire text into its tree statements, one per ';'
|
|
2596
|
+
// outside quotes and [...] comments (a ';' inside a quoted label or a
|
|
2597
|
+
// [&...] annotation is data), each trimmed; blank statements are
|
|
2598
|
+
// dropped. A text without a terminating ';' is one statement.
|
|
2599
|
+
forester.splitNewHampshire = function (nhStr) {
|
|
2600
|
+
let s = String(nhStr);
|
|
2601
|
+
let parts = [];
|
|
2602
|
+
let start = 0;
|
|
2603
|
+
let inSingle = false;
|
|
2604
|
+
let inDouble = false;
|
|
2605
|
+
let depth = 0;
|
|
2606
|
+
for (let i = 0, n = s.length; i < n; ++i) {
|
|
2607
|
+
let c = s.charAt(i);
|
|
2608
|
+
if (inSingle) {
|
|
2609
|
+
if (c === "'") {
|
|
2610
|
+
inSingle = false;
|
|
2611
|
+
}
|
|
2612
|
+
} else if (inDouble) {
|
|
2613
|
+
if (c === '"') {
|
|
2614
|
+
inDouble = false;
|
|
2615
|
+
}
|
|
2616
|
+
} else if (depth > 0) {
|
|
2617
|
+
if (c === ']') {
|
|
2618
|
+
--depth;
|
|
2619
|
+
} else if (c === '[') {
|
|
2620
|
+
++depth;
|
|
2621
|
+
}
|
|
2622
|
+
} else if (c === "'") {
|
|
2623
|
+
inSingle = true;
|
|
2624
|
+
} else if (c === '"') {
|
|
2625
|
+
inDouble = true;
|
|
2626
|
+
} else if (c === '[') {
|
|
2627
|
+
depth = 1;
|
|
2628
|
+
} else if (c === ';') {
|
|
2629
|
+
parts.push(s.substring(start, i + 1));
|
|
2630
|
+
start = i + 1;
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
parts.push(s.substring(start));
|
|
2634
|
+
return parts.map(function (p) {
|
|
2635
|
+
return p.trim();
|
|
2636
|
+
}).filter(function (p) {
|
|
2637
|
+
return p.length > 0;
|
|
2638
|
+
});
|
|
2639
|
+
};
|
|
2640
|
+
|
|
2641
|
+
// Every tree in a New Hampshire text (a file can hold many, one per
|
|
2642
|
+
// ';'), each parsed as parseNewHampshire does, in file order. A text
|
|
2643
|
+
// with no statement at all is handed to parseNewHampshire whole, so it
|
|
2644
|
+
// fails the way an empty tree always did.
|
|
2645
|
+
forester.parseNewHampshireTrees = function (nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
|
|
2646
|
+
let statements = forester.splitNewHampshire(nhStr);
|
|
2647
|
+
if (statements.length === 0) {
|
|
2648
|
+
return [forester.parseNewHampshire(nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames)];
|
|
2649
|
+
}
|
|
2650
|
+
return statements.map(function (statement) {
|
|
2651
|
+
return forester.parseNewHampshire(statement, confidenceValuesInBrackets, confidenceValuesAsInternalNames);
|
|
2652
|
+
});
|
|
2653
|
+
};
|
|
2654
|
+
|
|
2303
2655
|
// Parses a Nexus-formatted string and returns an ARRAY of tree objects,
|
|
2304
2656
|
// each in the same shape parseNewHampshire produces (a Nexus file can
|
|
2305
2657
|
// hold any number of trees). Ported from the desktop's
|
|
@@ -2998,7 +3350,7 @@
|
|
|
2998
3350
|
let v = metricOf(node);
|
|
2999
3351
|
node.branch_length = (parentValue !== null && v !== null)
|
|
3000
3352
|
? Math.max(0, v - parentValue) : 0;
|
|
3001
|
-
let children = node.children
|
|
3353
|
+
let children = node.children;
|
|
3002
3354
|
if (children) {
|
|
3003
3355
|
for (let i = 0; i < children.length; ++i) {
|
|
3004
3356
|
setDeltaBranchLengths(children[i], v, metricOf);
|
|
@@ -3028,93 +3380,19 @@
|
|
|
3028
3380
|
return auspiceHasAnyDate(root) && auspiceHasAnyDiv(root);
|
|
3029
3381
|
};
|
|
3030
3382
|
|
|
3383
|
+
// A number that is not NaN. It used to test only for null, undefined and
|
|
3384
|
+
// NaN, and so answered TRUE for "hello", "", {}, [] and true -- which no
|
|
3385
|
+
// caller was hurt by, since all of them pass the result of parseFloat, but
|
|
3386
|
+
// the name promised a check it did not make.
|
|
3387
|
+
//
|
|
3388
|
+
// Infinity is deliberately still accepted, so that this stays a rename in
|
|
3389
|
+
// behaviour as well as in intent: parseFloat('1e999') is Infinity, and
|
|
3390
|
+
// whether a branch length of Infinity should be refused is a separate
|
|
3391
|
+
// question from whether a string is a number.
|
|
3031
3392
|
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;
|
|
3393
|
+
return typeof v === 'number' && v === v;
|
|
3115
3394
|
};
|
|
3116
3395
|
|
|
3117
|
-
|
|
3118
3396
|
// How a label is written into Newick or Nexus, ported from the desktop's
|
|
3119
3397
|
// ForesterUtil.santitizeStringForNH so both programs emit the same token
|
|
3120
3398
|
// for the same name. Quoting rather than transliterating is what makes a
|
|
@@ -3181,13 +3459,6 @@
|
|
|
3181
3459
|
toNewHampshireHelper(node.children[i], i === l - 1);
|
|
3182
3460
|
}
|
|
3183
3461
|
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
3462
|
}
|
|
3192
3463
|
if (node.name && node.name.length > 0) {
|
|
3193
3464
|
nh += sanitizeLabelForNH(node.name);
|
|
@@ -3378,6 +3649,436 @@
|
|
|
3378
3649
|
};
|
|
3379
3650
|
|
|
3380
3651
|
|
|
3652
|
+
// --------------------------------------------------------------
|
|
3653
|
+
// Protein domain architectures
|
|
3654
|
+
// --------------------------------------------------------------
|
|
3655
|
+
// The pure half of drawing <domain_architecture> beside the tips, ported
|
|
3656
|
+
// from desktop Archaeopteryx (RenderableDomainArchitecture, TreePanel and
|
|
3657
|
+
// AptxUtil at forester 416c705b; the spec is section D1 of the repo's
|
|
3658
|
+
// TODO.md): which domains a threshold admits, where each box sits along
|
|
3659
|
+
// the backbone, the Tableau palette and how names take their colours,
|
|
3660
|
+
// the legend rows and the E-value readout. Everything a fixture can pin
|
|
3661
|
+
// lives here and test/domain_test.js checks it against the numbers the
|
|
3662
|
+
// desktop computed by running its own classes on apaf.xml. The SVG and
|
|
3663
|
+
// the controls are archaeopteryx.js's.
|
|
3664
|
+
|
|
3665
|
+
const DOMAIN_PALETTE = ['#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
|
|
3666
|
+
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC']; // Tableau 10
|
|
3667
|
+
forester.DOMAIN_UNNAMED_COLOR = '#808080'; // a domain with no name
|
|
3668
|
+
forester.DOMAIN_EVALUE_EXPONENT_DEFAULT = -3;
|
|
3669
|
+
forester.DOMAIN_EVALUE_EXPONENT_MIN = -20;
|
|
3670
|
+
forester.DOMAIN_EVALUE_EXPONENT_MAX = 3;
|
|
3671
|
+
|
|
3672
|
+
function hexToRgb(hex) {
|
|
3673
|
+
let n = parseInt(hex.substring(1), 16);
|
|
3674
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
function rgbToHex(rgb) {
|
|
3678
|
+
return '#' + rgb.map(function (c) {
|
|
3679
|
+
let s = Math.max(0, Math.min(255, Math.round(c))).toString(16);
|
|
3680
|
+
return s.length < 2 ? '0' + s : s;
|
|
3681
|
+
}).join('');
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
// Colour i of the qualitative sequence: Tableau 10 for the first ten,
|
|
3685
|
+
// then the same ten shifted toward white (odd cycles) or black (even
|
|
3686
|
+
// cycles), further with each cycle, capped at 0.55.
|
|
3687
|
+
forester.domainQualitativeColor = function (i) {
|
|
3688
|
+
let base = hexToRgb(DOMAIN_PALETTE[i % DOMAIN_PALETTE.length]);
|
|
3689
|
+
let cycle = Math.floor(i / DOMAIN_PALETTE.length);
|
|
3690
|
+
if (cycle === 0) {
|
|
3691
|
+
return rgbToHex(base);
|
|
3692
|
+
}
|
|
3693
|
+
let t = Math.min(0.55, 0.2 * cycle);
|
|
3694
|
+
let toward = (cycle % 2 === 1) ? 255 : 0;
|
|
3695
|
+
return rgbToHex(base.map(function (c) {
|
|
3696
|
+
return Math.round(c + t * (toward - c));
|
|
3697
|
+
}));
|
|
3698
|
+
};
|
|
3699
|
+
|
|
3700
|
+
forester.domainLighten = function (hex, t) {
|
|
3701
|
+
return rgbToHex(hexToRgb(hex).map(function (c) {
|
|
3702
|
+
return c + Math.round((255 - c) * t);
|
|
3703
|
+
}));
|
|
3704
|
+
};
|
|
3705
|
+
|
|
3706
|
+
forester.domainDarken = function (hex, t) {
|
|
3707
|
+
return rgbToHex(hexToRgb(hex).map(function (c) {
|
|
3708
|
+
return Math.round(c * (1 - t));
|
|
3709
|
+
}));
|
|
3710
|
+
};
|
|
3711
|
+
|
|
3712
|
+
// The ink a name is written in on its box: near-black on a light base,
|
|
3713
|
+
// white on a dark one.
|
|
3714
|
+
forester.domainLabelInk = function (hex) {
|
|
3715
|
+
let c = hexToRgb(hex);
|
|
3716
|
+
let lum = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255;
|
|
3717
|
+
return lum > 0.55 ? '#141a1d' : '#ffffff';
|
|
3718
|
+
};
|
|
3719
|
+
|
|
3720
|
+
forester.domainEvalueThreshold = function (exponent) {
|
|
3721
|
+
return Math.pow(10, exponent);
|
|
3722
|
+
};
|
|
3723
|
+
|
|
3724
|
+
// "10" with the exponent in superscript digits: 10⁻³, 10⁰, 10³.
|
|
3725
|
+
const SUPERSCRIPT_DIGITS = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
|
|
3726
|
+
forester.domainEvalueLabel = function (exponent) {
|
|
3727
|
+
let digits = String(Math.abs(exponent)).split('').map(function (d) {
|
|
3728
|
+
return SUPERSCRIPT_DIGITS[+d];
|
|
3729
|
+
}).join('');
|
|
3730
|
+
return '10' + (exponent < 0 ? '⁻' : '') + digits;
|
|
3731
|
+
};
|
|
3732
|
+
|
|
3733
|
+
function domainNumber(v) {
|
|
3734
|
+
return (v === null || v === undefined || v === '') ? NaN : Number(v);
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
// The architecture a node carries: the first of its sequences that has
|
|
3738
|
+
// one, provided its length is a positive integer -- without a length
|
|
3739
|
+
// there is no backbone to draw on. The desktop refuses the whole file in
|
|
3740
|
+
// that case; refusing input over a drawing attribute is the one kind of
|
|
3741
|
+
// strictness this library does not copy, so the architecture is simply
|
|
3742
|
+
// not drawn.
|
|
3743
|
+
forester.domainArchitectureOf = function (node) {
|
|
3744
|
+
if (!node.sequences) {
|
|
3745
|
+
return null;
|
|
3746
|
+
}
|
|
3747
|
+
for (let i = 0; i < node.sequences.length; ++i) {
|
|
3748
|
+
let da = node.sequences[i].domain_architecture;
|
|
3749
|
+
if (da) {
|
|
3750
|
+
let L = domainNumber(da.length);
|
|
3751
|
+
return (Number.isInteger(L) && L > 0) ? da : null;
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
return null;
|
|
3755
|
+
};
|
|
3756
|
+
|
|
3757
|
+
// The drawable domains of an architecture, in from order (equal froms
|
|
3758
|
+
// keep file order). A malformed domain -- a coordinate missing or not an
|
|
3759
|
+
// integer, to <= from, no E-value -- is left out and counted, never
|
|
3760
|
+
// fatal; the caller reports the count once.
|
|
3761
|
+
forester.domainArchitectureDomains = function (da) {
|
|
3762
|
+
let domains = [];
|
|
3763
|
+
let ignored = 0;
|
|
3764
|
+
(da.domains || []).forEach(function (d) {
|
|
3765
|
+
let from = domainNumber(d.from);
|
|
3766
|
+
let to = domainNumber(d.to);
|
|
3767
|
+
let e = domainNumber(d.confidence);
|
|
3768
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || to <= from || !isFinite(e)) {
|
|
3769
|
+
ignored++;
|
|
3770
|
+
return;
|
|
3771
|
+
}
|
|
3772
|
+
domains.push({name: d.name ? String(d.name) : '', from: from, to: to, length: to - from + 1, evalue: e});
|
|
3773
|
+
});
|
|
3774
|
+
domains.sort(function (a, b) {
|
|
3775
|
+
return a.from - b.from;
|
|
3776
|
+
});
|
|
3777
|
+
return {domains: domains, ignored: ignored};
|
|
3778
|
+
};
|
|
3779
|
+
|
|
3780
|
+
// The tree's domain facts: tips carrying an architecture, the longest
|
|
3781
|
+
// architecture (Lmax -- it sets one scale for every track and, counting
|
|
3782
|
+
// every domain whatever its E-value, never moves with the threshold),
|
|
3783
|
+
// the drawable domain count and the malformed count.
|
|
3784
|
+
forester.domainArchitectureStats = function (tree) {
|
|
3785
|
+
let stats = {tips: 0, maxLength: 0, domains: 0, ignored: 0};
|
|
3786
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
3787
|
+
if (n.children) {
|
|
3788
|
+
return;
|
|
3789
|
+
}
|
|
3790
|
+
let da = forester.domainArchitectureOf(n);
|
|
3791
|
+
if (!da) {
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
stats.tips++;
|
|
3795
|
+
let L = Number(da.length);
|
|
3796
|
+
if (L > stats.maxLength) {
|
|
3797
|
+
stats.maxLength = L;
|
|
3798
|
+
}
|
|
3799
|
+
let dd = forester.domainArchitectureDomains(da);
|
|
3800
|
+
stats.domains += dd.domains.length;
|
|
3801
|
+
stats.ignored += dd.ignored;
|
|
3802
|
+
});
|
|
3803
|
+
return stats;
|
|
3804
|
+
};
|
|
3805
|
+
|
|
3806
|
+
// What a threshold admits over the tips: the distinct drawn names sorted
|
|
3807
|
+
// by UTF-16 code unit (the palette's order -- Java's string order,
|
|
3808
|
+
// uppercase before lowercase, so DED sorts before Death), the drawn box
|
|
3809
|
+
// count, the legend rows in first-appearance order (tips in the order
|
|
3810
|
+
// given, domains in from order) each with its count of drawn boxes, and
|
|
3811
|
+
// how many drawn boxes have no name. `tips` is a root, walked in
|
|
3812
|
+
// preorder, or an array of tips in DISPLAY order -- the legend reads
|
|
3813
|
+
// the way the eye goes down the tree, which a ladderized display does
|
|
3814
|
+
// not do in data order.
|
|
3815
|
+
forester.domainSummary = function (tips, exponent) {
|
|
3816
|
+
let T = forester.domainEvalueThreshold(exponent);
|
|
3817
|
+
let counts = Object.create(null);
|
|
3818
|
+
let legend = [];
|
|
3819
|
+
let boxes = 0;
|
|
3820
|
+
let unnamed = 0;
|
|
3821
|
+
let nodes = tips;
|
|
3822
|
+
if (!Array.isArray(tips)) {
|
|
3823
|
+
nodes = [];
|
|
3824
|
+
forester.preOrderTraversalAll(tips, function (n) {
|
|
3825
|
+
if (!n.children) {
|
|
3826
|
+
nodes.push(n);
|
|
3827
|
+
}
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
nodes.forEach(function (n) {
|
|
3831
|
+
let da = forester.domainArchitectureOf(n);
|
|
3832
|
+
if (!da) {
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
forester.domainArchitectureDomains(da).domains.forEach(function (d) {
|
|
3836
|
+
if (d.evalue > T) {
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
boxes++;
|
|
3840
|
+
if (d.name.length === 0) {
|
|
3841
|
+
unnamed++;
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
if (!(d.name in counts)) {
|
|
3845
|
+
counts[d.name] = 0;
|
|
3846
|
+
legend.push({name: d.name, count: 0});
|
|
3847
|
+
}
|
|
3848
|
+
counts[d.name]++;
|
|
3849
|
+
});
|
|
3850
|
+
});
|
|
3851
|
+
legend.forEach(function (row) {
|
|
3852
|
+
row.count = counts[row.name];
|
|
3853
|
+
});
|
|
3854
|
+
return {names: Object.keys(counts).sort(), boxes: boxes, legend: legend, unnamed: unnamed};
|
|
3855
|
+
};
|
|
3856
|
+
|
|
3857
|
+
// One architecture's geometry: the backbone and the boxes the threshold
|
|
3858
|
+
// admits, given its start x and the px per residue f. Residue r covers
|
|
3859
|
+
// [(r-1) f, r f], so a domain from..to spans [start + (from-1) f,
|
|
3860
|
+
// start + to f] and a domain 1..L is exactly the backbone (decided with
|
|
3861
|
+
// the desktop 2026-09-12; until its 416c705b it placed at from f, one
|
|
3862
|
+
// residue to the right). A box with no width is skipped.
|
|
3863
|
+
forester.domainBoxes = function (da, start, f, exponent) {
|
|
3864
|
+
let T = forester.domainEvalueThreshold(exponent);
|
|
3865
|
+
let boxes = [];
|
|
3866
|
+
forester.domainArchitectureDomains(da).domains.forEach(function (d) {
|
|
3867
|
+
if (d.evalue > T) {
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3870
|
+
let w = d.length * f;
|
|
3871
|
+
if (!(w > 0) || !isFinite(w)) {
|
|
3872
|
+
return;
|
|
3873
|
+
}
|
|
3874
|
+
boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to, evalue: d.evalue});
|
|
3875
|
+
});
|
|
3876
|
+
return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
|
|
3877
|
+
};
|
|
3878
|
+
|
|
3879
|
+
// --------------------------------------------------------------
|
|
3880
|
+
// Scale bar
|
|
3881
|
+
// --------------------------------------------------------------
|
|
3882
|
+
// A phylogram's scale bar spans a round number of branch-length units
|
|
3883
|
+
// -- 1, 2 or 5 times a power of ten -- chosen so the bar comes out about
|
|
3884
|
+
// targetPx long at pxPerUnit pixels per unit. Returns {length, label,
|
|
3885
|
+
// px}, or null when the scale is unusable (zero, negative or infinite).
|
|
3886
|
+
forester.scaleBarLength = function (pxPerUnit, targetPx) {
|
|
3887
|
+
if (!(pxPerUnit > 0) || !isFinite(pxPerUnit)) {
|
|
3888
|
+
return null;
|
|
3889
|
+
}
|
|
3890
|
+
let raw = (targetPx || 100) / pxPerUnit;
|
|
3891
|
+
let k = Math.floor(Math.log10(raw));
|
|
3892
|
+
let base = raw / Math.pow(10, k);
|
|
3893
|
+
let nice = base < 1.5 ? 1 : (base < 3.5 ? 2 : (base < 7.5 ? 5 : 10));
|
|
3894
|
+
let length = Number((nice * Math.pow(10, k)).toPrecision(2));
|
|
3895
|
+
return {length: length, label: String(length), px: length * pxPerUnit};
|
|
3896
|
+
};
|
|
3897
|
+
|
|
3898
|
+
// --------------------------------------------------------------
|
|
3899
|
+
// Metadata tables
|
|
3900
|
+
// --------------------------------------------------------------
|
|
3901
|
+
// A table beside the tree -- TSV or CSV with a header row, the first
|
|
3902
|
+
// column naming the tip -- joined onto the tips as node properties, so
|
|
3903
|
+
// that everything downstream of a property sees the columns as if the
|
|
3904
|
+
// file had carried them: the automatic Color-by and Shape candidates,
|
|
3905
|
+
// the legends, the search fields, the node-data dialog, the phyloXML
|
|
3906
|
+
// writer. Every other browser viewer takes such a table; it was the one
|
|
3907
|
+
// input this library lacked (field review, 2026-09-13).
|
|
3908
|
+
|
|
3909
|
+
forester.METADATA_NAMESPACE = 'meta';
|
|
3910
|
+
|
|
3911
|
+
function splitDelimitedLine(line, delimiter) {
|
|
3912
|
+
let out = [];
|
|
3913
|
+
let cur = '';
|
|
3914
|
+
let quoted = false;
|
|
3915
|
+
for (let i = 0; i < line.length; ++i) {
|
|
3916
|
+
let c = line.charAt(i);
|
|
3917
|
+
if (quoted) {
|
|
3918
|
+
if (c === '"') {
|
|
3919
|
+
if (line.charAt(i + 1) === '"') { // a doubled quote is a literal one
|
|
3920
|
+
cur += '"';
|
|
3921
|
+
i++;
|
|
3922
|
+
} else {
|
|
3923
|
+
quoted = false;
|
|
3924
|
+
}
|
|
3925
|
+
} else {
|
|
3926
|
+
cur += c;
|
|
3927
|
+
}
|
|
3928
|
+
} else if (c === '"') {
|
|
3929
|
+
quoted = true;
|
|
3930
|
+
} else if (c === delimiter) {
|
|
3931
|
+
out.push(cur);
|
|
3932
|
+
cur = '';
|
|
3933
|
+
} else {
|
|
3934
|
+
cur += c;
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
out.push(cur);
|
|
3938
|
+
return out.map(function (s) {
|
|
3939
|
+
return s.trim();
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
// Splits delimited text into its header and rows. The delimiter is
|
|
3944
|
+
// whichever of tab, comma and semicolon occurs most in the header line
|
|
3945
|
+
// (tab when none does); fields may be double-quoted; blank lines and
|
|
3946
|
+
// lines starting with '#' are skipped; Windows line ends are fine.
|
|
3947
|
+
// Header names and cells come back trimmed. Throws when there is no
|
|
3948
|
+
// header or fewer than two columns.
|
|
3949
|
+
forester.parseDelimitedTable = function (text) {
|
|
3950
|
+
let lines = String(text || '').split(/\r?\n/).filter(function (l) {
|
|
3951
|
+
return l.trim().length > 0 && l.charAt(0) !== '#';
|
|
3952
|
+
});
|
|
3953
|
+
if (lines.length === 0) {
|
|
3954
|
+
throw new Error('the table is empty');
|
|
3955
|
+
}
|
|
3956
|
+
let delimiter = '\t';
|
|
3957
|
+
let best = -1;
|
|
3958
|
+
['\t', ',', ';'].forEach(function (d) {
|
|
3959
|
+
let n = lines[0].split(d).length - 1;
|
|
3960
|
+
if (n > best) {
|
|
3961
|
+
best = n;
|
|
3962
|
+
delimiter = d;
|
|
3963
|
+
}
|
|
3964
|
+
});
|
|
3965
|
+
let columns = splitDelimitedLine(lines[0], delimiter);
|
|
3966
|
+
if (columns.length < 2) {
|
|
3967
|
+
throw new Error('the table needs a header row with at least two columns: the tip name, then the data');
|
|
3968
|
+
}
|
|
3969
|
+
let rows = [];
|
|
3970
|
+
for (let i = 1; i < lines.length; ++i) {
|
|
3971
|
+
rows.push(splitDelimitedLine(lines[i], delimiter));
|
|
3972
|
+
}
|
|
3973
|
+
return {columns: columns, rows: rows, delimiter: delimiter};
|
|
3974
|
+
};
|
|
3975
|
+
|
|
3976
|
+
// The property ref for a column. A header that already reads as a
|
|
3977
|
+
// phyloXML ref (ns:local, no whitespace) is kept as it is; any other
|
|
3978
|
+
// becomes "meta:" plus the header with its whitespace as '_' -- the
|
|
3979
|
+
// display name prettifies that back to spaces, so "Collection Date"
|
|
3980
|
+
// stays "Collection Date" in every menu.
|
|
3981
|
+
forester.metadataColumnRef = function (header, index) {
|
|
3982
|
+
let h = String(header || '').trim();
|
|
3983
|
+
if (h.length === 0) {
|
|
3984
|
+
h = 'column_' + (index + 1);
|
|
3985
|
+
}
|
|
3986
|
+
if (/^[A-Za-z0-9_]+:\S+$/.test(h)) {
|
|
3987
|
+
return h;
|
|
3988
|
+
}
|
|
3989
|
+
return forester.METADATA_NAMESPACE + ':' + h.replace(/\s+/g, '_');
|
|
3990
|
+
};
|
|
3991
|
+
|
|
3992
|
+
// Joins a table onto the tree's tips. The first column is the key,
|
|
3993
|
+
// matched to the tip's name exactly, then case-insensitively. Every
|
|
3994
|
+
// other column becomes one property per matched tip with a non-empty
|
|
3995
|
+
// cell (applies_to node; the datatype is xsd:integer or xsd:double when
|
|
3996
|
+
// every filled cell of the column is such a number, xsd:string
|
|
3997
|
+
// otherwise). A property the tip already carries under the same ref is
|
|
3998
|
+
// replaced -- the table wins. Returns what happened:
|
|
3999
|
+
// {columns: [{header, ref, datatype, filled}], tips, matchedTips,
|
|
4000
|
+
// unmatchedTips: [names], unmatchedRows: [keys], properties}
|
|
4001
|
+
forester.joinMetadataTable = function (tree, text) {
|
|
4002
|
+
let table = forester.parseDelimitedTable(text);
|
|
4003
|
+
let tips = forester.getAllExternalNodes(tree);
|
|
4004
|
+
let byName = Object.create(null);
|
|
4005
|
+
let byLower = Object.create(null);
|
|
4006
|
+
tips.forEach(function (n) {
|
|
4007
|
+
if (n.name) {
|
|
4008
|
+
byName[n.name] = n;
|
|
4009
|
+
let lower = n.name.toLowerCase();
|
|
4010
|
+
if (!byLower[lower]) {
|
|
4011
|
+
byLower[lower] = n;
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
});
|
|
4015
|
+
let columns = [];
|
|
4016
|
+
for (let j = 1; j < table.columns.length; ++j) {
|
|
4017
|
+
let filled = table.rows.map(function (r) {
|
|
4018
|
+
return r[j] === undefined ? '' : r[j];
|
|
4019
|
+
}).filter(function (v) {
|
|
4020
|
+
return v.length > 0;
|
|
4021
|
+
});
|
|
4022
|
+
let datatype = 'xsd:string';
|
|
4023
|
+
if (filled.length > 0) {
|
|
4024
|
+
if (filled.every(function (v) { return /^[+-]?\d+$/.test(v); })) {
|
|
4025
|
+
datatype = 'xsd:integer';
|
|
4026
|
+
} else if (filled.every(function (v) { return VIS_NUMERIC_RE.test(v); })) {
|
|
4027
|
+
datatype = 'xsd:double';
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
columns.push({header: table.columns[j], ref: forester.metadataColumnRef(table.columns[j], j),
|
|
4031
|
+
datatype: datatype, filled: 0});
|
|
4032
|
+
}
|
|
4033
|
+
let matched = new Set();
|
|
4034
|
+
let unmatchedRows = [];
|
|
4035
|
+
let properties = 0;
|
|
4036
|
+
table.rows.forEach(function (r) {
|
|
4037
|
+
let key = r[0] === undefined ? '' : r[0];
|
|
4038
|
+
if (key.length === 0) {
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
let tip = byName[key] || byLower[key.toLowerCase()];
|
|
4042
|
+
if (!tip) {
|
|
4043
|
+
unmatchedRows.push(key);
|
|
4044
|
+
return;
|
|
4045
|
+
}
|
|
4046
|
+
matched.add(tip);
|
|
4047
|
+
columns.forEach(function (col, k) {
|
|
4048
|
+
let v = r[k + 1] === undefined ? '' : r[k + 1];
|
|
4049
|
+
if (v.length === 0) {
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
if (!tip.properties) {
|
|
4053
|
+
tip.properties = [];
|
|
4054
|
+
}
|
|
4055
|
+
let existing = null;
|
|
4056
|
+
for (let i = 0; i < tip.properties.length; ++i) {
|
|
4057
|
+
if (tip.properties[i].ref === col.ref) {
|
|
4058
|
+
existing = tip.properties[i];
|
|
4059
|
+
break;
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
if (existing) {
|
|
4063
|
+
existing.value = v;
|
|
4064
|
+
existing.datatype = col.datatype;
|
|
4065
|
+
existing.applies_to = 'node';
|
|
4066
|
+
} else {
|
|
4067
|
+
tip.properties.push({ref: col.ref, value: v, datatype: col.datatype, applies_to: 'node'});
|
|
4068
|
+
}
|
|
4069
|
+
col.filled++;
|
|
4070
|
+
properties++;
|
|
4071
|
+
});
|
|
4072
|
+
});
|
|
4073
|
+
let unmatchedTips = tips.filter(function (n) {
|
|
4074
|
+
return !matched.has(n);
|
|
4075
|
+
}).map(function (n) {
|
|
4076
|
+
return n.name || '';
|
|
4077
|
+
});
|
|
4078
|
+
return {columns: columns, tips: tips.length, matchedTips: matched.size,
|
|
4079
|
+
unmatchedTips: unmatchedTips, unmatchedRows: unmatchedRows, properties: properties};
|
|
4080
|
+
};
|
|
4081
|
+
|
|
3381
4082
|
// --------------------------------------------------------------
|
|
3382
4083
|
// Search engine
|
|
3383
4084
|
// --------------------------------------------------------------
|
|
@@ -3390,16 +4091,6 @@
|
|
|
3390
4091
|
// and ',' = OR / '+' = AND inside a text value. Used by archaeopteryx.js;
|
|
3391
4092
|
// pure tree logic, no DOM -- tested by test/search_test.js.
|
|
3392
4093
|
|
|
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
4094
|
const SEARCH_NUMERIC_DATATYPES = new Set(['decimal', 'double', 'float', 'integer', 'int', 'long', 'short',
|
|
3404
4095
|
'byte', 'unsignedint', 'unsignedlong', 'unsignedshort', 'unsignedbyte', 'nonnegativeinteger',
|
|
3405
4096
|
'nonpositiveinteger', 'negativeinteger', 'positiveinteger']);
|
|
@@ -3407,24 +4098,124 @@
|
|
|
3407
4098
|
function searchTaxa(n) { return (n.taxonomies && n.taxonomies.length) ? n.taxonomies : []; }
|
|
3408
4099
|
function searchSeqs(n) { return (n.sequences && n.sequences.length) ? n.sequences : []; }
|
|
3409
4100
|
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
4101
|
+
// A search field is an object -- {label, numeric, extract(node, root)}
|
|
4102
|
+
// and a few flags -- and nothing more: the field menu lists them, a spec
|
|
4103
|
+
// carries one, the engine calls its extract. There are no field ids.
|
|
4104
|
+
// Until 2026-09-12 every field was named by a two-letter code (NN, TS,
|
|
4105
|
+
// SA, ...): the suffixes of the 2.x search syntax ("foo:NN"). 3.0.0
|
|
4106
|
+
// replaced that syntax with the field menu, and the alphabet outlived it
|
|
4107
|
+
// as ids until Christian spotted it. A field is multi-valued; any value
|
|
4108
|
+
// matching is a match; root is needed only by Node Type.
|
|
4109
|
+
//
|
|
4110
|
+
// Flags: `anyText` -- "Any Text" ORs the field in (the molecular
|
|
4111
|
+
// sequence and the domains stay out, as on the desktop); `always` -- in
|
|
4112
|
+
// the menu even when no node carries it; `suggest: false` -- the value
|
|
4113
|
+
// box does not offer its values as type-ahead (near-unique, huge);
|
|
4114
|
+
// `metrics` -- needs the per-node depth / distance / clade size first.
|
|
4115
|
+
function textField(label, extract, flags) {
|
|
4116
|
+
return Object.assign({label: label, numeric: false, extract: extract}, flags || {});
|
|
4117
|
+
}
|
|
4118
|
+
function numericField(label, extract, flags) {
|
|
4119
|
+
return Object.assign({label: label, numeric: true, extract: extract}, flags || {});
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
// The text fields of a node; the labels are the desktop's, verbatim.
|
|
4123
|
+
const SEARCH_NODE_NAME = textField('Node Name', n => (n.name ? [n.name] : []), {always: true, anyText: true});
|
|
4124
|
+
const SEARCH_TAXONOMY_SCIENTIFIC_NAME = textField('Taxonomy Scientific', n => searchTaxa(n).map(t => t.scientific_name).filter(Boolean), {anyText: true});
|
|
4125
|
+
const SEARCH_TAXONOMY_COMMON_NAME = textField('Taxonomy Common', n => searchTaxa(n).map(t => t.common_name).filter(Boolean), {anyText: true});
|
|
4126
|
+
const SEARCH_TAXONOMY_CODE = textField('Taxonomy Code', n => searchTaxa(n).map(t => t.code).filter(Boolean), {anyText: true});
|
|
4127
|
+
const SEARCH_TAXONOMY_ID = textField('Taxonomy Identifier', n => searchTaxa(n).map(t => t.id && t.id.value).filter(Boolean), {anyText: true});
|
|
4128
|
+
const SEARCH_TAXONOMY_SYNONYM = textField('Taxonomy Synonym', n => searchTaxa(n).reduce((a, t) => a.concat(t.synonyms || []), []).filter(Boolean), {anyText: true});
|
|
4129
|
+
const SEARCH_TAXONOMY_LINEAGE = textField('Taxonomy Lineage', n => searchTaxa(n).reduce((a, t) => a.concat(t.lineage || []), []).filter(Boolean), {anyText: true});
|
|
4130
|
+
const SEARCH_SEQUENCE_NAME = textField('Seq Name', n => searchSeqs(n).map(s => s.name).filter(Boolean), {anyText: true});
|
|
4131
|
+
const SEARCH_GENE_NAME = textField('Gene Name', n => searchSeqs(n).map(s => s.gene_name).filter(Boolean), {anyText: true});
|
|
4132
|
+
// phyloXML <sequence><symbol>: the gene symbol
|
|
4133
|
+
const SEARCH_SEQUENCE_SYMBOL = textField('Gene Symbol', n => searchSeqs(n).map(s => s.symbol).filter(Boolean), {anyText: true});
|
|
4134
|
+
const SEARCH_SEQUENCE_ACCESSION = textField('Seq Accession', n => searchSeqs(n).map(s => s.accession && s.accession.value).filter(Boolean), {anyText: true});
|
|
4135
|
+
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));
|
|
4136
|
+
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});
|
|
4137
|
+
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});
|
|
4138
|
+
const SEARCH_MOLECULAR_SEQUENCE = textField('Molecular Sequence', n => searchSeqs(n).map(s => s.mol_seq).filter(Boolean), {suggest: false});
|
|
4139
|
+
// in menu order
|
|
4140
|
+
const SEARCH_TEXT_FIELDS = [
|
|
4141
|
+
SEARCH_NODE_NAME, SEARCH_TAXONOMY_SCIENTIFIC_NAME, SEARCH_TAXONOMY_COMMON_NAME, SEARCH_TAXONOMY_CODE,
|
|
4142
|
+
SEARCH_TAXONOMY_ID, SEARCH_TAXONOMY_SYNONYM, SEARCH_TAXONOMY_LINEAGE, SEARCH_SEQUENCE_NAME, SEARCH_GENE_NAME,
|
|
4143
|
+
SEARCH_SEQUENCE_SYMBOL, SEARCH_SEQUENCE_ACCESSION, SEARCH_DOMAIN, SEARCH_ANNOTATION, SEARCH_CROSS_REFERENCE,
|
|
4144
|
+
SEARCH_MOLECULAR_SEQUENCE
|
|
4145
|
+
];
|
|
4146
|
+
|
|
4147
|
+
// "Any Text": every anyText field above plus every custom property.
|
|
4148
|
+
const SEARCH_ANY_TEXT = textField('Any Text', function (node) {
|
|
4149
|
+
let out = [];
|
|
4150
|
+
SEARCH_TEXT_FIELDS.forEach(function (f) {
|
|
4151
|
+
if (f.anyText) out = out.concat(f.extract(node));
|
|
4152
|
+
});
|
|
4153
|
+
if (node.properties) {
|
|
4154
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
4155
|
+
let p = node.properties[i];
|
|
4156
|
+
if (!isInternalPropRef(p.ref) && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
return out;
|
|
4160
|
+
}, {suggest: false});
|
|
4161
|
+
|
|
4162
|
+
const SEARCH_BRANCH_LENGTH = numericField('Branch Length', n => (typeof n.branch_length === 'number') ? [n.branch_length] : []);
|
|
4163
|
+
const SEARCH_CONFIDENCE = numericField('Confidence', n => n.confidences ? n.confidences.map(c => c.value).filter(v => typeof v === 'number') : []);
|
|
4164
|
+
const SEARCH_CLADE_SIZE = numericField('Clade Size (tips)', n => [n._srchClade], {metrics: true});
|
|
4165
|
+
const SEARCH_CHILD_COUNT = numericField('Number of Children', n => [n.children ? n.children.length : 0]);
|
|
4166
|
+
const SEARCH_DEPTH = numericField('Depth from Root', n => [n._srchDepth], {metrics: true});
|
|
4167
|
+
const SEARCH_DISTANCE_FROM_ROOT = numericField('Distance from Root', n => [n._srchDist], {metrics: true});
|
|
4168
|
+
const SEARCH_NODE_TYPE = textField('Node Type', function (node, root) {
|
|
4169
|
+
let kids = node.children;
|
|
4170
|
+
let isLeaf = !kids || kids.length === 0;
|
|
4171
|
+
return [isLeaf ? 'leaf' : (node === root ? 'root' : 'internal')];
|
|
4172
|
+
});
|
|
4173
|
+
|
|
4174
|
+
// The built-in fields by name, for a caller that asks "does this tree
|
|
4175
|
+
// carry X?" -- the viewer's label presets test whether a field object is
|
|
4176
|
+
// among availableSearchFields(tree), by identity. Property fields have
|
|
4177
|
+
// no entry here: there is one per ref, made per tree.
|
|
4178
|
+
forester.searchFields = {
|
|
4179
|
+
anyText: SEARCH_ANY_TEXT,
|
|
4180
|
+
nodeName: SEARCH_NODE_NAME,
|
|
4181
|
+
taxonomyScientificName: SEARCH_TAXONOMY_SCIENTIFIC_NAME,
|
|
4182
|
+
taxonomyCommonName: SEARCH_TAXONOMY_COMMON_NAME,
|
|
4183
|
+
taxonomyCode: SEARCH_TAXONOMY_CODE,
|
|
4184
|
+
taxonomyId: SEARCH_TAXONOMY_ID,
|
|
4185
|
+
taxonomySynonym: SEARCH_TAXONOMY_SYNONYM,
|
|
4186
|
+
taxonomyLineage: SEARCH_TAXONOMY_LINEAGE,
|
|
4187
|
+
sequenceName: SEARCH_SEQUENCE_NAME,
|
|
4188
|
+
geneName: SEARCH_GENE_NAME,
|
|
4189
|
+
sequenceSymbol: SEARCH_SEQUENCE_SYMBOL,
|
|
4190
|
+
sequenceAccession: SEARCH_SEQUENCE_ACCESSION,
|
|
4191
|
+
domain: SEARCH_DOMAIN,
|
|
4192
|
+
annotation: SEARCH_ANNOTATION,
|
|
4193
|
+
crossReference: SEARCH_CROSS_REFERENCE,
|
|
4194
|
+
molecularSequence: SEARCH_MOLECULAR_SEQUENCE,
|
|
4195
|
+
branchLength: SEARCH_BRANCH_LENGTH,
|
|
4196
|
+
confidence: SEARCH_CONFIDENCE,
|
|
4197
|
+
cladeSize: SEARCH_CLADE_SIZE,
|
|
4198
|
+
childCount: SEARCH_CHILD_COUNT,
|
|
4199
|
+
depth: SEARCH_DEPTH,
|
|
4200
|
+
distanceFromRoot: SEARCH_DISTANCE_FROM_ROOT,
|
|
4201
|
+
nodeType: SEARCH_NODE_TYPE
|
|
3426
4202
|
};
|
|
3427
4203
|
|
|
4204
|
+
// One field per custom property ref; numeric when its declared datatype
|
|
4205
|
+
// or, failing that, every one of its values says so.
|
|
4206
|
+
function propertyField(ref, numeric) {
|
|
4207
|
+
return {label: ref, numeric: numeric, extract: function (node) {
|
|
4208
|
+
let out = [];
|
|
4209
|
+
if (node.properties) {
|
|
4210
|
+
for (let i = 0; i < node.properties.length; ++i) {
|
|
4211
|
+
let p = node.properties[i];
|
|
4212
|
+
if (p.ref === ref && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
return out;
|
|
4216
|
+
}};
|
|
4217
|
+
}
|
|
4218
|
+
|
|
3428
4219
|
function isInternalPropRef(ref) { return !ref || ref.indexOf('aptx:') === 0; }
|
|
3429
4220
|
|
|
3430
4221
|
function datatypeIsNumeric(dt) {
|
|
@@ -3492,19 +4283,19 @@
|
|
|
3492
4283
|
// dropdowns). Always exposes Any Text + Node Name; adds the text, numeric
|
|
3493
4284
|
// and custom-property fields that are present, then structure fields.
|
|
3494
4285
|
forester.availableSearchFields = function (root) {
|
|
3495
|
-
let fields = [];
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
4286
|
+
let fields = [SEARCH_ANY_TEXT];
|
|
4287
|
+
if (!root) {
|
|
4288
|
+
SEARCH_TEXT_FIELDS.forEach(function (f) { if (f.always) fields.push(f); });
|
|
4289
|
+
return fields;
|
|
4290
|
+
}
|
|
3499
4291
|
|
|
3500
|
-
let present =
|
|
4292
|
+
let present = new Set();
|
|
3501
4293
|
let hasBL = false, hasConf = false;
|
|
3502
4294
|
let propRefs = {}; // ref -> { num, tot, dtNum, dtStr }
|
|
3503
4295
|
forester.preOrderTraversalAll(root, function (n) {
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
}
|
|
4296
|
+
SEARCH_TEXT_FIELDS.forEach(function (f) {
|
|
4297
|
+
if (!f.always && !present.has(f) && f.extract(n).length > 0) present.add(f);
|
|
4298
|
+
});
|
|
3508
4299
|
if (!hasBL && typeof n.branch_length === 'number' && n.branch_length >= 0) hasBL = true;
|
|
3509
4300
|
if (!hasConf && n.confidences) {
|
|
3510
4301
|
for (let i = 0; i < n.confidences.length; ++i) {
|
|
@@ -3523,23 +4314,20 @@
|
|
|
3523
4314
|
}
|
|
3524
4315
|
});
|
|
3525
4316
|
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
if (
|
|
3531
|
-
if (hasConf) fields.push({ key: 'CO', label: 'Confidence', numeric: true });
|
|
4317
|
+
SEARCH_TEXT_FIELDS.forEach(function (f) {
|
|
4318
|
+
if (f.always || present.has(f)) fields.push(f);
|
|
4319
|
+
});
|
|
4320
|
+
if (hasBL) fields.push(SEARCH_BRANCH_LENGTH);
|
|
4321
|
+
if (hasConf) fields.push(SEARCH_CONFIDENCE);
|
|
3532
4322
|
let refs = Object.keys(propRefs).sort();
|
|
3533
4323
|
for (let i = 0; i < refs.length; ++i) {
|
|
3534
4324
|
let r = propRefs[refs[i]];
|
|
3535
4325
|
let numeric = r.dtStr ? false : (r.dtNum ? true : (r.tot > 0 && r.num === r.tot));
|
|
3536
|
-
fields.push(
|
|
4326
|
+
fields.push(propertyField(refs[i], numeric));
|
|
3537
4327
|
}
|
|
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 });
|
|
4328
|
+
fields.push(SEARCH_CLADE_SIZE, SEARCH_CHILD_COUNT, SEARCH_DEPTH);
|
|
4329
|
+
if (hasBL) fields.push(SEARCH_DISTANCE_FROM_ROOT);
|
|
4330
|
+
fields.push(SEARCH_NODE_TYPE);
|
|
3543
4331
|
return fields;
|
|
3544
4332
|
};
|
|
3545
4333
|
|
|
@@ -3550,11 +4338,11 @@
|
|
|
3550
4338
|
n._srchDepth = depth;
|
|
3551
4339
|
let d = dist + (typeof n.branch_length === 'number' && n.branch_length > 0 ? n.branch_length : 0);
|
|
3552
4340
|
n._srchDist = d;
|
|
3553
|
-
let kids = n.children
|
|
4341
|
+
let kids = n.children;
|
|
3554
4342
|
if (kids) for (let i = 0; i < kids.length; ++i) pre(kids[i], depth + 1, d);
|
|
3555
4343
|
})(root, 0, 0);
|
|
3556
4344
|
forester.postOrderTraversalAll(root, function (n) {
|
|
3557
|
-
let kids = n.children
|
|
4345
|
+
let kids = n.children;
|
|
3558
4346
|
if (!kids || kids.length === 0) { n._srchClade = 1; return; }
|
|
3559
4347
|
let s = 0;
|
|
3560
4348
|
for (let i = 0; i < kids.length; ++i) s += kids[i]._srchClade;
|
|
@@ -3562,51 +4350,6 @@
|
|
|
3562
4350
|
});
|
|
3563
4351
|
}
|
|
3564
4352
|
|
|
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
4353
|
// Run one search spec { field, mode, value, value2, caseSensitive, inverse }
|
|
3611
4354
|
// over the tree and return the Set of matching nodes.
|
|
3612
4355
|
// A parsed tree is anchored on a SUPER-ROOT: a synthetic node whose single
|
|
@@ -3633,7 +4376,7 @@
|
|
|
3633
4376
|
// values are unchanged; only the set of nodes considered is narrowed.
|
|
3634
4377
|
let nodes = realRootOf(root);
|
|
3635
4378
|
let field = spec.field;
|
|
3636
|
-
if (field.
|
|
4379
|
+
if (field.metrics) computeSearchMetrics(root);
|
|
3637
4380
|
|
|
3638
4381
|
let v = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
|
|
3639
4382
|
v = v.replace(/\s+/g, ' ').trim();
|
|
@@ -3646,7 +4389,7 @@
|
|
|
3646
4389
|
let lo = (b !== null) ? Math.min(a, b) : a;
|
|
3647
4390
|
let hi = (b !== null) ? Math.max(a, b) : a;
|
|
3648
4391
|
test = function (n) {
|
|
3649
|
-
let vals =
|
|
4392
|
+
let vals = field.extract(n, root);
|
|
3650
4393
|
for (let i = 0; i < vals.length; ++i) {
|
|
3651
4394
|
let x = (typeof vals[i] === 'number') ? vals[i] : forester.parseFiniteDouble(vals[i]);
|
|
3652
4395
|
if (x !== null && numMatches(x, spec.mode, a, lo, hi)) return true;
|
|
@@ -3675,7 +4418,7 @@
|
|
|
3675
4418
|
}
|
|
3676
4419
|
if (!compiled.length) return result;
|
|
3677
4420
|
test = function (n) {
|
|
3678
|
-
let vals =
|
|
4421
|
+
let vals = field.extract(n, root);
|
|
3679
4422
|
for (let oi = 0; oi < compiled.length; ++oi) {
|
|
3680
4423
|
let ands = compiled[oi], ok = true;
|
|
3681
4424
|
for (let ai = 0; ai < ands.length; ++ai) {
|
|
@@ -3695,7 +4438,7 @@
|
|
|
3695
4438
|
// Complement, scoped to nodes that actually carry this field.
|
|
3696
4439
|
let inv = new Set();
|
|
3697
4440
|
forester.preOrderTraversalAll(nodes, function (n) {
|
|
3698
|
-
if (!result.has(n) &&
|
|
4441
|
+
if (!result.has(n) && field.extract(n, root).length > 0) inv.add(n);
|
|
3699
4442
|
});
|
|
3700
4443
|
return inv;
|
|
3701
4444
|
}
|
|
@@ -3703,13 +4446,14 @@
|
|
|
3703
4446
|
};
|
|
3704
4447
|
|
|
3705
4448
|
// Distinct, trimmed, sorted values of a specific text field across the tree,
|
|
3706
|
-
// for the value-box autocomplete. Empty for numeric
|
|
3707
|
-
//
|
|
4449
|
+
// for the value-box autocomplete. Empty for a numeric field and for the
|
|
4450
|
+
// fields flagged suggest: false (Any Text; the molecular sequence, which
|
|
4451
|
+
// is near-unique and huge). cap limits the list length (optional).
|
|
3708
4452
|
forester.distinctSearchValues = function (root, field, cap) {
|
|
3709
|
-
if (!root || !field || field.numeric || field.
|
|
4453
|
+
if (!root || !field || field.numeric || field.suggest === false) return [];
|
|
3710
4454
|
let set = new Set();
|
|
3711
4455
|
forester.preOrderTraversalAll(root, function (n) {
|
|
3712
|
-
let vals =
|
|
4456
|
+
let vals = field.extract(n, root);
|
|
3713
4457
|
for (let i = 0; i < vals.length; ++i) {
|
|
3714
4458
|
if (vals[i] !== null && vals[i] !== undefined) {
|
|
3715
4459
|
let v = String(vals[i]).trim();
|
|
@@ -4021,7 +4765,7 @@
|
|
|
4021
4765
|
let hasInternalIntervals = false;
|
|
4022
4766
|
let hasExternalIntervals = false;
|
|
4023
4767
|
forester.preOrderTraversalAll(root, function (n) {
|
|
4024
|
-
let isExt = !n.children
|
|
4768
|
+
let isExt = !n.children;
|
|
4025
4769
|
if (isExt) {
|
|
4026
4770
|
++external;
|
|
4027
4771
|
} else {
|
|
@@ -4497,7 +5241,7 @@
|
|
|
4497
5241
|
let candidates = [];
|
|
4498
5242
|
let anyConfidence = false;
|
|
4499
5243
|
forester.preOrderTraversalAll(root, function (n) {
|
|
4500
|
-
if (n === root || !(n.children
|
|
5244
|
+
if (n === root || !(n.children)) {
|
|
4501
5245
|
return;
|
|
4502
5246
|
}
|
|
4503
5247
|
if (n.confidences && n.confidences.length > 0) {
|