archaeopteryx 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +96 -8
  2. package/archaeopteryx.js +1106 -183
  3. package/forester.js +895 -437
  4. package/package.json +3 -3
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.2.0
23
+ // v 3.3.0
24
24
  // 2026-09-10
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -35,6 +35,34 @@
35
35
  //
36
36
  // Dependencies: none
37
37
  //
38
+ // REMOVED FROM THE PUBLIC API (2026-09-11). Each was exported, called by
39
+ // nothing -- not this library, not Archaeopteryx.js, not the test suite --
40
+ // and is recorded here so that a later "forester used to have X" can be
41
+ // matched to a decision instead of investigated from scratch:
42
+ //
43
+ // collapse, unCollapse the subtree-collapse feature they served was
44
+ // removed from the viewer; they were the only
45
+ // writers of node._children, whose handling
46
+ // went with them
47
+ // getChildren returned _children in preference to children,
48
+ // so it only ever meant anything while collapse
49
+ // existed
50
+ // findByTaxonomyCode superseded by the search machinery
51
+ // findByTaxonomyScientificName (searchWithSpec and friends)
52
+ // calcAverageTreeHeight never used by any caller
53
+ // calcMaxDepth
54
+ // calcBranchLengthSimpleStatistics
55
+ // collectPropertyRefs superseded by visualizationCandidates
56
+ // isHasNodeData
57
+ // removeMaxBranchLength
58
+ // getOneDistinctTaxonomy
59
+ //
60
+ // forester.js ships inside the archaeopteryx npm package, so an outside caller
61
+ // could in principle have used any of these. That is the cost that was weighed
62
+ // and accepted: 12 of 80 exports earned nothing here, and dead code is not free
63
+ // -- it has to keep working, keep linting clean, and be considered in every
64
+ // refactor.
65
+ //
38
66
  //
39
67
  // In the following is a basic example shows how to parse a New Hampshire formatted String
40
68
  // into to a object representing a phylogenetic tree.
@@ -75,7 +103,6 @@
75
103
  const NUMBERS_ONLY_PATTERN = /^[-+]?[0-9\\.]+$/;
76
104
 
77
105
 
78
-
79
106
  /**
80
107
  * Sets links to parent nodes for all nodes in a
81
108
  * phyloXML-based tree object
@@ -134,17 +161,12 @@
134
161
  * @param node - The root of the subtree to traverse.
135
162
  * @param fn - The function to apply.
136
163
  */
164
+ // Kept as a distinct name because callers use both, but it IS
165
+ // preOrderTraversal now: the two differed only in that this one also
166
+ // descended into a collapsed node's hidden _children, and nothing can
167
+ // collapse a node any more. See the removed-API note at the top.
137
168
  forester.preOrderTraversalAll = function (node, fn) {
138
- fn(node);
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; the domains always come from the
598
- // COMPLETE tree, so a value keeps its colour inside a subtree view even
599
- // when the subtree does not contain it.
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 (applies_to "node").
603
- // The "style:" namespace is never a candidate -- the desktop reserves it
604
- // for per-node rendering instructions (font_color, node_shape, ...), so
605
- // treating it as data would mean colouring by a colour.
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
- // coverage present on >= 2/3 of the external nodes. Database
611
- // exports are always patchy -- demanding 100% would
612
- // reject nearly every field of the BV-BRC trees while a
613
- // field on 9% of nodes (state_province) says nothing.
614
- // Nodes without a value simply keep the default look.
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), and fewer distinct values than external nodes
617
- // (all-unique means identifiers).
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 -- as the desktop
622
- // does, every value gets a colour and the LEGEND caps the
623
- // display -- but only when values genuinely repeat:
624
- // distinct/covered <= 0.6, or near-unique fields (strains,
625
- // species names, dates) would flood the menus. Wide fields
626
- // rank after everything else and are never auto-applied.
627
- // numeric every value parses as a finite number. Up to 10 distinct
628
- // values default to individual colours -- numbers that few
629
- // are usually codes (HA/NA subtypes), and ten is what the
630
- // palette's strong first half holds -- 11 to 20 default to
631
- // a Color-range, and both of those may be switched in the
632
- // legend; above 20 it is a range with no switch. Guard:
633
- // distinct/covered <= 0.9, or "numeric" identifiers
634
- // (genome ids) would become ramps.
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
- // multi-value a ref carried more than once by any external node is
639
- // not a candidate: a node cannot be two colours, and
640
- // picking one silently is worse than not offering it.
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).toLowerCase().replace(/[^a-z0-9]/g, '');
665
- return VIS_EXCLUDED_LOCAL_NAME_RE.test(local);
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 || 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.applies_to === 'node' && !visExcludedRef(p.ref)) {
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 (g.values.length > 1) {
1000
+ if (displays.length > 1) {
873
1001
  s.multi = true;
874
1002
  }
875
- for (let i = 0; i < g.values.length; ++i) {
876
- // properties group under their normalized display form;
877
- // taxonomy / sequence elements are curated text, verbatim
878
- let display = g.kind === 'property' ? visDisplayLabel(g.values[i], s.cut) : g.values[i];
879
- if (display.length === 0) {
880
- continue;
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 Number.isFinite(Number(v));
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
- if (distinct * VIS_MAX_NUMERIC_UNIQUE_DEN > covered * VIS_MAX_NUMERIC_UNIQUE_NUM) {
937
- return;
938
- }
939
- colorMode = distinct <= VIS_NUMERIC_CATEGORY_MAX ? 'category' : 'range';
940
- switchable = distinct <= VIS_MAX_COLOR_CATEGORIES;
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
- return;
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 ranges, then the
1002
- // wide categoricals (offered, never leading) -- within each tier by
1003
- // score, ties alphabetically. The first entry is what the viewer
1004
- // applies on load.
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.colorMode === 'category') {
1007
- return c.wide ? 2 : 0;
1219
+ if (c.nearUnique) {
1220
+ return 5;
1008
1221
  }
1009
- return 1;
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 || 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.applies_to !== 'node' || seen[p.ref]
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.applies_to !== 'node' || p.value === undefined || p.value === null) {
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 || 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.applies_to === 'node') {
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
- return v;
1569
+ // verbatim, except that a numeric slot folds its
1570
+ // spellings exactly as the classifier grouped them
1571
+ return candidate.canon ? (candidate.canon[v] || v) : v;
1336
1572
  }
1337
1573
  }
1338
1574
  return null;
@@ -1341,23 +1577,99 @@
1341
1577
  return null;
1342
1578
  };
1343
1579
 
1344
- forester.collectPropertyRefs = function (phy, appliesTo, externalOnly) {
1345
- let propertyRefs = new Set();
1346
- forester.preOrderTraversalAll(phy, function (n) {
1347
-
1348
- if (!externalOnly || externalOnly !== true || (!n.children && !n._children)) {
1349
- if (n.properties && n.properties.length > 0) {
1350
- let propertiesLength = n.properties.length;
1351
- for (let i = 0; i < propertiesLength; ++i) {
1352
- let property = n.properties[i];
1353
- if (property.ref && property.value && property.datatype && property.applies_to && property.applies_to === appliesTo) {
1354
- propertyRefs.add(property.ref);
1355
- }
1356
- }
1357
- }
1580
+ // The visualization a tree OPENS with: the first candidate that is not
1581
+ // wide (21+ values are offered, never imposed), or null for none. One
1582
+ // definition, used by the viewer and by the fixture generator, so the
1583
+ // rule the desktop ports is the rule the viewer runs.
1584
+ forester.openingVisualization = function (candidates) {
1585
+ for (let i = 0; i < candidates.length; ++i) {
1586
+ if (!candidates[i].wide) {
1587
+ return candidates[i];
1588
+ }
1589
+ }
1590
+ return null;
1591
+ };
1592
+
1593
+ // What a VIEW shows of a candidate: its values, counts and coverage over
1594
+ // the tips under `root` -- the subtree the user switched into, or the
1595
+ // whole tree. Candidacy is decided ONCE, on the tree
1596
+ // (visualizationCandidates); a view never re-decides it, it only
1597
+ // re-summarizes. Until 2026-09-12 the viewer re-ran the classifier on
1598
+ // every subtree, and since a clade is by nature a set of tips sharing a
1599
+ // value, and one value is refused, entering a clade dropped the colouring
1600
+ // in 61% of the corpus's clades (4,192 of 6,857) and did not bring it back
1601
+ // on return. Values are read exactly as the classifier grouped them, so
1602
+ // everything found here is in the candidate's domain and keeps its
1603
+ // colour. A numeric candidate also gets the view's colour-mode band
1604
+ // (see visNumericModes); a category keeps its mode.
1605
+ forester.visualizationSummary = function (candidate, root) {
1606
+ let counts = Object.create(null);
1607
+ let total = 0;
1608
+ let coverage = 0;
1609
+ forester.preOrderTraversalAll(root, function (n) {
1610
+ if (n.children) {
1611
+ return;
1358
1612
  }
1613
+ total++;
1614
+ let v = forester.visualizationNodeValue(n, candidate);
1615
+ if (v !== null) {
1616
+ coverage++;
1617
+ counts[v] = (counts[v] || 0) + 1;
1618
+ }
1619
+ });
1620
+ let values = Object.keys(counts);
1621
+ if (candidate.numeric) {
1622
+ values.sort(function (a, b) {
1623
+ return Number(a) - Number(b);
1624
+ });
1625
+ } else {
1626
+ values.sort();
1627
+ }
1628
+ let summary = {values: values, counts: counts, coverage: coverage, total: total, distinct: values.length};
1629
+ if (candidate.numeric) {
1630
+ let modes = visNumericModes(values.length);
1631
+ summary.colorMode = modes.colorMode;
1632
+ summary.switchable = modes.switchable;
1633
+ }
1634
+ return summary;
1635
+ };
1636
+
1637
+ // The candidates of a tree the user has EDITED (a subtree deleted), with
1638
+ // the fields they had chosen KEPT as long as those still carry a value
1639
+ // somewhere in what remains. The refusal rules decide what is offered,
1640
+ // never what is already chosen: colouring by Host and deleting every
1641
+ // clade but one must not silently uncolour the tree because one host is
1642
+ // "not a category". A kept field is appended after the offered ones and
1643
+ // flagged `kept`, so the menu holds it exactly as long as the user does;
1644
+ // the next edit drops it unless it is still chosen. `chosen` is the
1645
+ // previous candidate objects -- their grouping travels with them.
1646
+ forester.visualizationCandidatesKeeping = function (tree, chosen) {
1647
+ let candidates = forester.visualizationCandidates(tree);
1648
+ let ids = Object.create(null);
1649
+ candidates.forEach(function (c) {
1650
+ ids[c.id] = true;
1359
1651
  });
1360
- return propertyRefs;
1652
+ (chosen || []).forEach(function (c) {
1653
+ if (!c || ids[c.id]) {
1654
+ return;
1655
+ }
1656
+ let s = forester.visualizationSummary(c, tree);
1657
+ if (s.coverage === 0) {
1658
+ return;
1659
+ }
1660
+ c.values = s.values;
1661
+ c.counts = s.counts;
1662
+ c.coverage = s.coverage;
1663
+ c.total = s.total;
1664
+ if (c.numeric) {
1665
+ c.colorMode = s.colorMode;
1666
+ c.switchable = s.switchable;
1667
+ }
1668
+ c.kept = true;
1669
+ ids[c.id] = true;
1670
+ candidates.push(c);
1671
+ });
1672
+ return candidates;
1361
1673
  };
1362
1674
 
1363
1675
  forester.collectBasicTreeProperties = function (tree) {
@@ -1376,12 +1688,12 @@
1376
1688
  properties.taxonomies = false;
1377
1689
  properties.alignedMolSeqs = true;
1378
1690
  properties.maxMolSeqLength = 0;
1691
+ // protein domain architectures on the tips: whether any tip carries
1692
+ // one, and the longest (Lmax, the scale every track shares)
1693
+ properties.domainArchitectures = false;
1694
+ properties.maxDomainArchitectureLength = 0;
1379
1695
  properties.externalNodesCount = 0;
1380
1696
  properties.nodeCount = 0;
1381
- // How many of the tree's branches actually carry a positive length.
1382
- // Whether a tree is worth drawing to scale is a question about the
1383
- // majority of its branches, not about whether any branch has a length.
1384
- properties.branchesWithPositiveLength = 0;
1385
1697
  // Branches that carry a length AT ALL -- an explicit zero is a real
1386
1698
  // measurement, not a missing one, so these are counted separately from
1387
1699
  // the "positive" tally above. Split internal-vs-all because a missing
@@ -1394,6 +1706,14 @@
1394
1706
  properties.internalBranchCount = 0;
1395
1707
  properties.internalBranchesWithLength = 0;
1396
1708
  properties.averageBranchLength = 0;
1709
+ // Positive lengths only, and the root included: these feed
1710
+ // averageBranchLength and nothing else. They are deliberately NOT the
1711
+ // same population as branchCount / branchesWithLength below, which
1712
+ // exclude the root and count an explicit zero as the measurement it is.
1713
+ // A `branchesWithPositiveLength` property built from this counter was
1714
+ // removed on 2026-09-11 -- nothing read it, and having two tallies over
1715
+ // two different node sets invited exactly the comparison that would be
1716
+ // wrong.
1397
1717
  let bl_counter = 0;
1398
1718
  let bl_sum = 0;
1399
1719
  // Counting the super-root would add a node and a branch that do not
@@ -1404,7 +1724,7 @@
1404
1724
  forester.preOrderTraversalAll(rootNode, function (n) {
1405
1725
  properties.nodeCount += 1;
1406
1726
  if (n !== rootNode) {
1407
- let internal = !!(n.children || n._children);
1727
+ let internal = !!(n.children);
1408
1728
  let measured = typeof n.branch_length === 'number' && isFinite(n.branch_length);
1409
1729
  properties.branchCount += 1;
1410
1730
  if (measured) {
@@ -1422,11 +1742,11 @@
1422
1742
  if (n.name.length > properties.longestNodeName) {
1423
1743
  properties.longestNodeName = n.name.length;
1424
1744
  }
1425
- if ((n.children || n._children) && (n.parent)) {
1745
+ if ((n.children) && (n.parent)) {
1426
1746
  properties.internalNodeData = true;
1427
1747
  }
1428
1748
  }
1429
- if (!(n.children || n._children)) {
1749
+ if (!(n.children)) {
1430
1750
  properties.externalNodesCount += 1;
1431
1751
  }
1432
1752
  if (n.branch_length && n.branch_length > 0) {
@@ -1443,7 +1763,7 @@
1443
1763
  if (n.sequences && n.sequences.length > 0) {
1444
1764
  properties.sequences = true;
1445
1765
 
1446
- if (n.children || n._children) {
1766
+ if (n.children) {
1447
1767
  properties.internalNodeData = true;
1448
1768
  } else {
1449
1769
  let s = n.sequences[0];
@@ -1455,11 +1775,18 @@
1455
1775
  properties.alignedMolSeqs = false;
1456
1776
  }
1457
1777
  }
1778
+ let da = forester.domainArchitectureOf(n);
1779
+ if (da) {
1780
+ properties.domainArchitectures = true;
1781
+ if (Number(da.length) > properties.maxDomainArchitectureLength) {
1782
+ properties.maxDomainArchitectureLength = Number(da.length);
1783
+ }
1784
+ }
1458
1785
  }
1459
1786
  }
1460
1787
  if (n.taxonomies && n.taxonomies.length > 0) {
1461
1788
  properties.taxonomies = true;
1462
- if (n.children || n._children) {
1789
+ if (n.children) {
1463
1790
  properties.internalNodeData = true;
1464
1791
  }
1465
1792
  }
@@ -1483,7 +1810,6 @@
1483
1810
 
1484
1811
  });
1485
1812
 
1486
- properties.branchesWithPositiveLength = bl_counter;
1487
1813
 
1488
1814
  if (bl_counter > 0) {
1489
1815
  properties.averageBranchLength = bl_sum / bl_counter;
@@ -1522,15 +1848,14 @@
1522
1848
  forester.calcSumOfAllExternalDescendants = function (node) {
1523
1849
  let nodes = 0;
1524
1850
  forester.preOrderTraversalAll(node, function (n) {
1525
- if (!(n.children || n._children)) {
1851
+ if (!(n.children)) {
1526
1852
  ++nodes;
1527
1853
  }
1528
1854
  });
1529
1855
  return nodes;
1530
1856
  };
1531
1857
 
1532
- // Ladderize: at every node, order the VISIBLE children (n.children; a
1533
- // collapsed node's hidden _children are left untouched) by clade size --
1858
+ // Ladderize: at every node, order the children by clade size --
1534
1859
  // largest first when largestFirst, smallest first when not. Works at ANY
1535
1860
  // child count, not just 2, so a polytomy (common on a phylodynamic tree,
1536
1861
  // e.g. an Auspice build, where every internal node may carry 3+ children)
@@ -1580,7 +1905,7 @@
1580
1905
  forester.getAllExternalNodes = function (node) {
1581
1906
  let nodes = [];
1582
1907
  forester.preOrderTraversalAll(node, function (n) {
1583
- if (!n.children && !n._children) {
1908
+ if (!n.children) {
1584
1909
  nodes.push(n);
1585
1910
  }
1586
1911
  });
@@ -1595,19 +1920,6 @@
1595
1920
  return nodes;
1596
1921
  };
1597
1922
 
1598
- forester.calcMaxDepth = function (node) {
1599
- let max = 0;
1600
- forester.preOrderTraversalAll(node, function (n) {
1601
- if (!n.children && !n._children) {
1602
- let steps = forester.calcDepth(n);
1603
- if (steps > max) {
1604
- max = steps;
1605
- }
1606
- }
1607
- });
1608
- return max;
1609
- };
1610
-
1611
1923
  forester.calcDepth = function (node) {
1612
1924
 
1613
1925
  let steps = 0;
@@ -1619,31 +1931,6 @@
1619
1931
  };
1620
1932
 
1621
1933
 
1622
- forester.calcBranchLengthSimpleStatistics = function (node) {
1623
- let stats = {};
1624
- stats.mean = 0;
1625
- stats.min = Number.MAX_VALUE;
1626
- stats.max = 0;
1627
- stats.n = 0;
1628
- let sum = 0;
1629
- forester.preOrderTraversalAll(node, function (n) {
1630
- if (n !== node && n.branch_length && n.branch_length >= 0) {
1631
- ++stats.n;
1632
- sum += n.branch_length;
1633
- if (n.branch_length < stats.min) {
1634
- stats.min = n.branch_length;
1635
- }
1636
- if (n.branch_length > stats.max) {
1637
- stats.max = n.branch_length;
1638
- }
1639
- }
1640
- });
1641
- if (stats.n > 0) {
1642
- stats.mean = sum / stats.n;
1643
- }
1644
- return stats;
1645
- };
1646
-
1647
1934
  forester.calcMaxBranchLength = function (node) {
1648
1935
  let max = 0;
1649
1936
  forester.preOrderTraversalAll(node, function (n) {
@@ -1655,33 +1942,6 @@
1655
1942
  };
1656
1943
 
1657
1944
 
1658
- forester.isHasNodeData = function (node) {
1659
- return ((node.name && node.name.length > 0) || (node.taxonomies && node.taxonomies.length > 0) || (node.sequences && node.sequences.length > 0) || (node.properties && node.properties.length > 0));
1660
- };
1661
-
1662
-
1663
- forester.removeMaxBranchLength = function (node) {
1664
- forester.preOrderTraversalAll(node, function (n) {
1665
- if (n.max) {
1666
- n.max = undefined;
1667
- }
1668
- });
1669
- };
1670
-
1671
- forester.collapse = function (node) {
1672
- if (node.children) {
1673
- node._children = node.children;
1674
- node.children = null;
1675
- }
1676
- };
1677
-
1678
- forester.unCollapse = function (node) {
1679
- if (node._children) {
1680
- node.children = node._children;
1681
- node._children = null;
1682
- }
1683
- };
1684
-
1685
1945
  /**
1686
1946
  * To parse a New Hampshire (Newick) formatted tree.
1687
1947
  *
@@ -2150,7 +2410,16 @@
2150
2410
  // the separator before a branch length: the length itself
2151
2411
  // is read by the branch below, so there is nothing to do here
2152
2412
  } else {
2153
- let e = ss[i - 1];
2413
+ // What came before decides what this element is: a name
2414
+ // follows an opening bracket, a comma or a close, and a
2415
+ // branch length follows a colon.
2416
+ //
2417
+ // At i === 0 there IS no previous token, and the string
2418
+ // beginning with a label is the one-node tree "a;" --
2419
+ // valid Newick, and for want of this line its name was
2420
+ // read as nothing at all and written back as "". The
2421
+ // start of input opens the tree, so it counts as '('.
2422
+ let e = (i === 0) ? '(' : ss[i - 1];
2154
2423
  if (e) {
2155
2424
  e = e.trim();
2156
2425
  // re-attach any annotation blobs riding on this
@@ -2280,7 +2549,7 @@
2280
2549
 
2281
2550
  function moveInternalNodeNamesToConfidenceValues(node) {
2282
2551
  forester.preOrderTraversalAll(node, function (n) {
2283
- if (n.children || n._children) {
2552
+ if (n.children) {
2284
2553
  if (n.name) {
2285
2554
  let s = n.name;
2286
2555
  if (NUMBERS_ONLY_PATTERN.test(s)) {
@@ -2998,7 +3267,7 @@
2998
3267
  let v = metricOf(node);
2999
3268
  node.branch_length = (parentValue !== null && v !== null)
3000
3269
  ? Math.max(0, v - parentValue) : 0;
3001
- let children = node.children || node._children;
3270
+ let children = node.children;
3002
3271
  if (children) {
3003
3272
  for (let i = 0; i < children.length; ++i) {
3004
3273
  setDeltaBranchLengths(children[i], v, metricOf);
@@ -3028,93 +3297,19 @@
3028
3297
  return auspiceHasAnyDate(root) && auspiceHasAnyDiv(root);
3029
3298
  };
3030
3299
 
3300
+ // A number that is not NaN. It used to test only for null, undefined and
3301
+ // NaN, and so answered TRUE for "hello", "", {}, [] and true -- which no
3302
+ // caller was hurt by, since all of them pass the result of parseFloat, but
3303
+ // the name promised a check it did not make.
3304
+ //
3305
+ // Infinity is deliberately still accepted, so that this stays a rename in
3306
+ // behaviour as well as in intent: parseFloat('1e999') is Infinity, and
3307
+ // whether a branch length of Infinity should be refused is a separate
3308
+ // question from whether a string is a number.
3031
3309
  forester.isNumber = function (v) {
3032
- if (v === undefined || v === null) {
3033
- return false;
3034
- }
3035
- if (v != v) {
3036
- // This can only be true if the v is NaN
3037
- return false;
3038
- }
3039
- return true;
3040
- };
3041
-
3042
- forester.getOneDistinctTaxonomy = function (node) {
3043
- let id = null;
3044
- let code = null;
3045
- let sn = null;
3046
- let cn = null;
3047
- let result = true;
3048
- let sawTax = false;
3049
- forester.preOrderTraversalAll(node, function (n) {
3050
- if (n.taxonomies && n.taxonomies.length === 1) {
3051
- let tax = n.taxonomies[0];
3052
- if (tax.code && tax.code.length > 0) {
3053
- sawTax = true;
3054
- if (code === null) {
3055
- code = tax.code;
3056
- } else if (code !== tax.code) {
3057
- result = false;
3058
- return;
3059
- }
3060
- }
3061
- if (tax.scientific_name && tax.scientific_name.length > 0) {
3062
- sawTax = true;
3063
- if (sn === null) {
3064
- sn = tax.scientific_name;
3065
- } else if (sn !== tax.scientific_name) {
3066
- result = false;
3067
- return;
3068
- }
3069
- }
3070
- if (tax.common_name && tax.common_name.length > 0) {
3071
- sawTax = true;
3072
- if (cn === null) {
3073
- cn = tax.common_name;
3074
- } else if (cn !== tax.common_name) {
3075
- result = false;
3076
- return;
3077
- }
3078
- }
3079
- if (tax.id && tax.id.value && tax.id.value.length > 0) {
3080
- sawTax = true;
3081
- let myid;
3082
- if (tax.id.provider && tax.id.provider.length > 0) {
3083
- myid = tax.id.provider + ':' + tax.id.value;
3084
- } else {
3085
- myid = tax.id.value;
3086
- }
3087
- if (id === null) {
3088
- id = myid;
3089
- } else if (id !== myid) {
3090
- result = false;
3091
-
3092
- }
3093
- }
3094
- } else if (!n.children && !n._children) {
3095
- // If an external node lacks taxonomy, return false.
3096
- result = false;
3097
- }
3098
- });
3099
- if (!sawTax) {
3100
- return null;
3101
- }
3102
- if (result === true) {
3103
-
3104
- if (sn) {
3105
- return sn;
3106
- } else if (code) {
3107
- return code;
3108
- } else if (cn) {
3109
- return cn;
3110
- } else if (id) {
3111
- return id;
3112
- }
3113
- }
3114
- return null;
3310
+ return typeof v === 'number' && v === v;
3115
3311
  };
3116
3312
 
3117
-
3118
3313
  // How a label is written into Newick or Nexus, ported from the desktop's
3119
3314
  // ForesterUtil.santitizeStringForNH so both programs emit the same token
3120
3315
  // for the same name. Quoting rather than transliterating is what makes a
@@ -3181,13 +3376,6 @@
3181
3376
  toNewHampshireHelper(node.children[i], i === l - 1);
3182
3377
  }
3183
3378
  nh += ")";
3184
- } else if (node._children) {
3185
- let ll = node._children.length;
3186
- nh += "(";
3187
- for (let ii = 0; ii < ll; ++ii) {
3188
- toNewHampshireHelper(node._children[ii], ii === ll - 1);
3189
- }
3190
- nh += ")";
3191
3379
  }
3192
3380
  if (node.name && node.name.length > 0) {
3193
3381
  nh += sanitizeLabelForNH(node.name);
@@ -3378,6 +3566,233 @@
3378
3566
  };
3379
3567
 
3380
3568
 
3569
+ // --------------------------------------------------------------
3570
+ // Protein domain architectures
3571
+ // --------------------------------------------------------------
3572
+ // The pure half of drawing <domain_architecture> beside the tips, ported
3573
+ // from desktop Archaeopteryx (RenderableDomainArchitecture, TreePanel and
3574
+ // AptxUtil at forester 416c705b; the spec is section D1 of the repo's
3575
+ // TODO.md): which domains a threshold admits, where each box sits along
3576
+ // the backbone, the Tableau palette and how names take their colours,
3577
+ // the legend rows and the E-value readout. Everything a fixture can pin
3578
+ // lives here and test/domain_test.js checks it against the numbers the
3579
+ // desktop computed by running its own classes on apaf.xml. The SVG and
3580
+ // the controls are archaeopteryx.js's.
3581
+
3582
+ const DOMAIN_PALETTE = ['#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
3583
+ '#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC']; // Tableau 10
3584
+ forester.DOMAIN_UNNAMED_COLOR = '#808080'; // a domain with no name
3585
+ forester.DOMAIN_EVALUE_EXPONENT_DEFAULT = -3;
3586
+ forester.DOMAIN_EVALUE_EXPONENT_MIN = -20;
3587
+ forester.DOMAIN_EVALUE_EXPONENT_MAX = 3;
3588
+
3589
+ function hexToRgb(hex) {
3590
+ let n = parseInt(hex.substring(1), 16);
3591
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
3592
+ }
3593
+
3594
+ function rgbToHex(rgb) {
3595
+ return '#' + rgb.map(function (c) {
3596
+ let s = Math.max(0, Math.min(255, Math.round(c))).toString(16);
3597
+ return s.length < 2 ? '0' + s : s;
3598
+ }).join('');
3599
+ }
3600
+
3601
+ // Colour i of the qualitative sequence: Tableau 10 for the first ten,
3602
+ // then the same ten shifted toward white (odd cycles) or black (even
3603
+ // cycles), further with each cycle, capped at 0.55.
3604
+ forester.domainQualitativeColor = function (i) {
3605
+ let base = hexToRgb(DOMAIN_PALETTE[i % DOMAIN_PALETTE.length]);
3606
+ let cycle = Math.floor(i / DOMAIN_PALETTE.length);
3607
+ if (cycle === 0) {
3608
+ return rgbToHex(base);
3609
+ }
3610
+ let t = Math.min(0.55, 0.2 * cycle);
3611
+ let toward = (cycle % 2 === 1) ? 255 : 0;
3612
+ return rgbToHex(base.map(function (c) {
3613
+ return Math.round(c + t * (toward - c));
3614
+ }));
3615
+ };
3616
+
3617
+ forester.domainLighten = function (hex, t) {
3618
+ return rgbToHex(hexToRgb(hex).map(function (c) {
3619
+ return c + Math.round((255 - c) * t);
3620
+ }));
3621
+ };
3622
+
3623
+ forester.domainDarken = function (hex, t) {
3624
+ return rgbToHex(hexToRgb(hex).map(function (c) {
3625
+ return Math.round(c * (1 - t));
3626
+ }));
3627
+ };
3628
+
3629
+ // The ink a name is written in on its box: near-black on a light base,
3630
+ // white on a dark one.
3631
+ forester.domainLabelInk = function (hex) {
3632
+ let c = hexToRgb(hex);
3633
+ let lum = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255;
3634
+ return lum > 0.55 ? '#141a1d' : '#ffffff';
3635
+ };
3636
+
3637
+ forester.domainEvalueThreshold = function (exponent) {
3638
+ return Math.pow(10, exponent);
3639
+ };
3640
+
3641
+ // "10" with the exponent in superscript digits: 10⁻³, 10⁰, 10³.
3642
+ const SUPERSCRIPT_DIGITS = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
3643
+ forester.domainEvalueLabel = function (exponent) {
3644
+ let digits = String(Math.abs(exponent)).split('').map(function (d) {
3645
+ return SUPERSCRIPT_DIGITS[+d];
3646
+ }).join('');
3647
+ return '10' + (exponent < 0 ? '⁻' : '') + digits;
3648
+ };
3649
+
3650
+ function domainNumber(v) {
3651
+ return (v === null || v === undefined || v === '') ? NaN : Number(v);
3652
+ }
3653
+
3654
+ // The architecture a node carries: the first of its sequences that has
3655
+ // one, provided its length is a positive integer -- without a length
3656
+ // there is no backbone to draw on. The desktop refuses the whole file in
3657
+ // that case; refusing input over a drawing attribute is the one kind of
3658
+ // strictness this library does not copy, so the architecture is simply
3659
+ // not drawn.
3660
+ forester.domainArchitectureOf = function (node) {
3661
+ if (!node.sequences) {
3662
+ return null;
3663
+ }
3664
+ for (let i = 0; i < node.sequences.length; ++i) {
3665
+ let da = node.sequences[i].domain_architecture;
3666
+ if (da) {
3667
+ let L = domainNumber(da.length);
3668
+ return (Number.isInteger(L) && L > 0) ? da : null;
3669
+ }
3670
+ }
3671
+ return null;
3672
+ };
3673
+
3674
+ // The drawable domains of an architecture, in from order (equal froms
3675
+ // keep file order). A malformed domain -- a coordinate missing or not an
3676
+ // integer, to <= from, no E-value -- is left out and counted, never
3677
+ // fatal; the caller reports the count once.
3678
+ forester.domainArchitectureDomains = function (da) {
3679
+ let domains = [];
3680
+ let ignored = 0;
3681
+ (da.domains || []).forEach(function (d) {
3682
+ let from = domainNumber(d.from);
3683
+ let to = domainNumber(d.to);
3684
+ let e = domainNumber(d.confidence);
3685
+ if (!Number.isInteger(from) || !Number.isInteger(to) || to <= from || !isFinite(e)) {
3686
+ ignored++;
3687
+ return;
3688
+ }
3689
+ domains.push({name: d.name ? String(d.name) : '', from: from, to: to, length: to - from + 1, evalue: e});
3690
+ });
3691
+ domains.sort(function (a, b) {
3692
+ return a.from - b.from;
3693
+ });
3694
+ return {domains: domains, ignored: ignored};
3695
+ };
3696
+
3697
+ // The tree's domain facts: tips carrying an architecture, the longest
3698
+ // architecture (Lmax -- it sets one scale for every track and, counting
3699
+ // every domain whatever its E-value, never moves with the threshold),
3700
+ // the drawable domain count and the malformed count.
3701
+ forester.domainArchitectureStats = function (tree) {
3702
+ let stats = {tips: 0, maxLength: 0, domains: 0, ignored: 0};
3703
+ forester.preOrderTraversalAll(tree, function (n) {
3704
+ if (n.children) {
3705
+ return;
3706
+ }
3707
+ let da = forester.domainArchitectureOf(n);
3708
+ if (!da) {
3709
+ return;
3710
+ }
3711
+ stats.tips++;
3712
+ let L = Number(da.length);
3713
+ if (L > stats.maxLength) {
3714
+ stats.maxLength = L;
3715
+ }
3716
+ let dd = forester.domainArchitectureDomains(da);
3717
+ stats.domains += dd.domains.length;
3718
+ stats.ignored += dd.ignored;
3719
+ });
3720
+ return stats;
3721
+ };
3722
+
3723
+ // What a threshold admits over the tips: the distinct drawn names sorted
3724
+ // by UTF-16 code unit (the palette's order -- Java's string order,
3725
+ // uppercase before lowercase, so DED sorts before Death), the drawn box
3726
+ // count, the legend rows in first-appearance order (tips in the order
3727
+ // given, domains in from order) each with its count of drawn boxes, and
3728
+ // how many drawn boxes have no name. `tips` is a root, walked in
3729
+ // preorder, or an array of tips in DISPLAY order -- the legend reads
3730
+ // the way the eye goes down the tree, which a ladderized display does
3731
+ // not do in data order.
3732
+ forester.domainSummary = function (tips, exponent) {
3733
+ let T = forester.domainEvalueThreshold(exponent);
3734
+ let counts = Object.create(null);
3735
+ let legend = [];
3736
+ let boxes = 0;
3737
+ let unnamed = 0;
3738
+ let nodes = tips;
3739
+ if (!Array.isArray(tips)) {
3740
+ nodes = [];
3741
+ forester.preOrderTraversalAll(tips, function (n) {
3742
+ if (!n.children) {
3743
+ nodes.push(n);
3744
+ }
3745
+ });
3746
+ }
3747
+ nodes.forEach(function (n) {
3748
+ let da = forester.domainArchitectureOf(n);
3749
+ if (!da) {
3750
+ return;
3751
+ }
3752
+ forester.domainArchitectureDomains(da).domains.forEach(function (d) {
3753
+ if (d.evalue > T) {
3754
+ return;
3755
+ }
3756
+ boxes++;
3757
+ if (d.name.length === 0) {
3758
+ unnamed++;
3759
+ return;
3760
+ }
3761
+ if (!(d.name in counts)) {
3762
+ counts[d.name] = 0;
3763
+ legend.push({name: d.name, count: 0});
3764
+ }
3765
+ counts[d.name]++;
3766
+ });
3767
+ });
3768
+ legend.forEach(function (row) {
3769
+ row.count = counts[row.name];
3770
+ });
3771
+ return {names: Object.keys(counts).sort(), boxes: boxes, legend: legend, unnamed: unnamed};
3772
+ };
3773
+
3774
+ // One architecture's geometry: the backbone and the boxes the threshold
3775
+ // admits, given its start x and the px per residue f. Residue r covers
3776
+ // [(r-1) f, r f], so a domain from..to spans [start + (from-1) f,
3777
+ // start + to f] and a domain 1..L is exactly the backbone (decided with
3778
+ // the desktop 2026-09-12; until its 416c705b it placed at from f, one
3779
+ // residue to the right). A box with no width is skipped.
3780
+ forester.domainBoxes = function (da, start, f, exponent) {
3781
+ let T = forester.domainEvalueThreshold(exponent);
3782
+ let boxes = [];
3783
+ forester.domainArchitectureDomains(da).domains.forEach(function (d) {
3784
+ if (d.evalue > T) {
3785
+ return;
3786
+ }
3787
+ let w = d.length * f;
3788
+ if (!(w > 0) || !isFinite(w)) {
3789
+ return;
3790
+ }
3791
+ boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to, evalue: d.evalue});
3792
+ });
3793
+ return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
3794
+ };
3795
+
3381
3796
  // --------------------------------------------------------------
3382
3797
  // Search engine
3383
3798
  // --------------------------------------------------------------
@@ -3390,16 +3805,6 @@
3390
3805
  // and ',' = OR / '+' = AND inside a text value. Used by archaeopteryx.js;
3391
3806
  // pure tree logic, no DOM -- tested by test/search_test.js.
3392
3807
 
3393
- const SEARCH_FIELD_LABELS = {
3394
- NN: 'Node Name',
3395
- TS: 'Taxonomy Scientific', TN: 'Taxonomy Common', TC: 'Taxonomy Code',
3396
- TI: 'Taxonomy Identifier', SY: 'Taxonomy Synonym', LN: 'Taxonomy Lineage',
3397
- SN: 'Seq Name', GN: 'Gene Name', SS: 'Gene Symbol', SA: 'Seq Accession',
3398
- MS: 'Molecular Sequence', DO: 'Domain', AN: 'Annotation', XR: 'Cross-Reference'
3399
- };
3400
- const SEARCH_TEXT_ORDER = ['TS', 'TN', 'TC', 'TI', 'SY', 'LN', 'SN', 'GN', 'SS', 'SA', 'DO', 'AN', 'XR', 'MS'];
3401
- // Fields folded into the "Any Text" umbrella (desktop omits MS + DO there).
3402
- const SEARCH_ANY_TEXT_KEYS = ['NN', 'TS', 'TN', 'TC', 'TI', 'SY', 'LN', 'SN', 'GN', 'SS', 'SA', 'AN', 'XR'];
3403
3808
  const SEARCH_NUMERIC_DATATYPES = new Set(['decimal', 'double', 'float', 'integer', 'int', 'long', 'short',
3404
3809
  'byte', 'unsignedint', 'unsignedlong', 'unsignedshort', 'unsignedbyte', 'nonnegativeinteger',
3405
3810
  'nonpositiveinteger', 'negativeinteger', 'positiveinteger']);
@@ -3407,24 +3812,124 @@
3407
3812
  function searchTaxa(n) { return (n.taxonomies && n.taxonomies.length) ? n.taxonomies : []; }
3408
3813
  function searchSeqs(n) { return (n.sequences && n.sequences.length) ? n.sequences : []; }
3409
3814
 
3410
- const SEARCH_TEXT_EXTRACTORS = {
3411
- NN: n => (n.name ? [n.name] : []),
3412
- TS: n => searchTaxa(n).map(t => t.scientific_name).filter(Boolean),
3413
- TN: n => searchTaxa(n).map(t => t.common_name).filter(Boolean),
3414
- TC: n => searchTaxa(n).map(t => t.code).filter(Boolean),
3415
- TI: n => searchTaxa(n).map(t => t.id && t.id.value).filter(Boolean),
3416
- SY: n => searchTaxa(n).reduce((a, t) => a.concat(t.synonyms || []), []).filter(Boolean),
3417
- LN: n => searchTaxa(n).reduce((a, t) => a.concat(t.lineage || []), []).filter(Boolean),
3418
- SN: n => searchSeqs(n).map(s => s.name).filter(Boolean),
3419
- GN: n => searchSeqs(n).map(s => s.gene_name).filter(Boolean),
3420
- SS: n => searchSeqs(n).map(s => s.symbol).filter(Boolean),
3421
- SA: n => searchSeqs(n).map(s => s.accession && s.accession.value).filter(Boolean),
3422
- MS: n => searchSeqs(n).map(s => s.mol_seq).filter(Boolean),
3423
- DO: n => searchSeqs(n).reduce((a, s) => a.concat((s.domain_architecture && s.domain_architecture.domains) ? s.domain_architecture.domains.map(d => d.name) : []), []).filter(Boolean),
3424
- AN: n => searchSeqs(n).reduce((a, s) => a.concat((s.annotations || []).reduce((b, an) => b.concat([an.desc, an.ref]), [])), []).filter(Boolean),
3425
- XR: n => searchSeqs(n).reduce((a, s) => a.concat((s.cross_references || []).reduce((b, x) => b.concat([x.value, x.source, x.comment]), [])), []).filter(Boolean)
3815
+ // A search field is an object -- {label, numeric, extract(node, root)}
3816
+ // and a few flags -- and nothing more: the field menu lists them, a spec
3817
+ // carries one, the engine calls its extract. There are no field ids.
3818
+ // Until 2026-09-12 every field was named by a two-letter code (NN, TS,
3819
+ // SA, ...): the suffixes of the 2.x search syntax ("foo:NN"). 3.0.0
3820
+ // replaced that syntax with the field menu, and the alphabet outlived it
3821
+ // as ids until Christian spotted it. A field is multi-valued; any value
3822
+ // matching is a match; root is needed only by Node Type.
3823
+ //
3824
+ // Flags: `anyText` -- "Any Text" ORs the field in (the molecular
3825
+ // sequence and the domains stay out, as on the desktop); `always` -- in
3826
+ // the menu even when no node carries it; `suggest: false` -- the value
3827
+ // box does not offer its values as type-ahead (near-unique, huge);
3828
+ // `metrics` -- needs the per-node depth / distance / clade size first.
3829
+ function textField(label, extract, flags) {
3830
+ return Object.assign({label: label, numeric: false, extract: extract}, flags || {});
3831
+ }
3832
+ function numericField(label, extract, flags) {
3833
+ return Object.assign({label: label, numeric: true, extract: extract}, flags || {});
3834
+ }
3835
+
3836
+ // The text fields of a node; the labels are the desktop's, verbatim.
3837
+ const SEARCH_NODE_NAME = textField('Node Name', n => (n.name ? [n.name] : []), {always: true, anyText: true});
3838
+ const SEARCH_TAXONOMY_SCIENTIFIC_NAME = textField('Taxonomy Scientific', n => searchTaxa(n).map(t => t.scientific_name).filter(Boolean), {anyText: true});
3839
+ const SEARCH_TAXONOMY_COMMON_NAME = textField('Taxonomy Common', n => searchTaxa(n).map(t => t.common_name).filter(Boolean), {anyText: true});
3840
+ const SEARCH_TAXONOMY_CODE = textField('Taxonomy Code', n => searchTaxa(n).map(t => t.code).filter(Boolean), {anyText: true});
3841
+ const SEARCH_TAXONOMY_ID = textField('Taxonomy Identifier', n => searchTaxa(n).map(t => t.id && t.id.value).filter(Boolean), {anyText: true});
3842
+ const SEARCH_TAXONOMY_SYNONYM = textField('Taxonomy Synonym', n => searchTaxa(n).reduce((a, t) => a.concat(t.synonyms || []), []).filter(Boolean), {anyText: true});
3843
+ const SEARCH_TAXONOMY_LINEAGE = textField('Taxonomy Lineage', n => searchTaxa(n).reduce((a, t) => a.concat(t.lineage || []), []).filter(Boolean), {anyText: true});
3844
+ const SEARCH_SEQUENCE_NAME = textField('Seq Name', n => searchSeqs(n).map(s => s.name).filter(Boolean), {anyText: true});
3845
+ const SEARCH_GENE_NAME = textField('Gene Name', n => searchSeqs(n).map(s => s.gene_name).filter(Boolean), {anyText: true});
3846
+ // phyloXML <sequence><symbol>: the gene symbol
3847
+ const SEARCH_SEQUENCE_SYMBOL = textField('Gene Symbol', n => searchSeqs(n).map(s => s.symbol).filter(Boolean), {anyText: true});
3848
+ const SEARCH_SEQUENCE_ACCESSION = textField('Seq Accession', n => searchSeqs(n).map(s => s.accession && s.accession.value).filter(Boolean), {anyText: true});
3849
+ const SEARCH_DOMAIN = textField('Domain', n => searchSeqs(n).reduce((a, s) => a.concat((s.domain_architecture && s.domain_architecture.domains) ? s.domain_architecture.domains.map(d => d.name) : []), []).filter(Boolean));
3850
+ const SEARCH_ANNOTATION = textField('Annotation', n => searchSeqs(n).reduce((a, s) => a.concat((s.annotations || []).reduce((b, an) => b.concat([an.desc, an.ref]), [])), []).filter(Boolean), {anyText: true});
3851
+ const SEARCH_CROSS_REFERENCE = textField('Cross-Reference', n => searchSeqs(n).reduce((a, s) => a.concat((s.cross_references || []).reduce((b, x) => b.concat([x.value, x.source, x.comment]), [])), []).filter(Boolean), {anyText: true});
3852
+ const SEARCH_MOLECULAR_SEQUENCE = textField('Molecular Sequence', n => searchSeqs(n).map(s => s.mol_seq).filter(Boolean), {suggest: false});
3853
+ // in menu order
3854
+ const SEARCH_TEXT_FIELDS = [
3855
+ SEARCH_NODE_NAME, SEARCH_TAXONOMY_SCIENTIFIC_NAME, SEARCH_TAXONOMY_COMMON_NAME, SEARCH_TAXONOMY_CODE,
3856
+ SEARCH_TAXONOMY_ID, SEARCH_TAXONOMY_SYNONYM, SEARCH_TAXONOMY_LINEAGE, SEARCH_SEQUENCE_NAME, SEARCH_GENE_NAME,
3857
+ SEARCH_SEQUENCE_SYMBOL, SEARCH_SEQUENCE_ACCESSION, SEARCH_DOMAIN, SEARCH_ANNOTATION, SEARCH_CROSS_REFERENCE,
3858
+ SEARCH_MOLECULAR_SEQUENCE
3859
+ ];
3860
+
3861
+ // "Any Text": every anyText field above plus every custom property.
3862
+ const SEARCH_ANY_TEXT = textField('Any Text', function (node) {
3863
+ let out = [];
3864
+ SEARCH_TEXT_FIELDS.forEach(function (f) {
3865
+ if (f.anyText) out = out.concat(f.extract(node));
3866
+ });
3867
+ if (node.properties) {
3868
+ for (let i = 0; i < node.properties.length; ++i) {
3869
+ let p = node.properties[i];
3870
+ if (!isInternalPropRef(p.ref) && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
3871
+ }
3872
+ }
3873
+ return out;
3874
+ }, {suggest: false});
3875
+
3876
+ const SEARCH_BRANCH_LENGTH = numericField('Branch Length', n => (typeof n.branch_length === 'number') ? [n.branch_length] : []);
3877
+ const SEARCH_CONFIDENCE = numericField('Confidence', n => n.confidences ? n.confidences.map(c => c.value).filter(v => typeof v === 'number') : []);
3878
+ const SEARCH_CLADE_SIZE = numericField('Clade Size (tips)', n => [n._srchClade], {metrics: true});
3879
+ const SEARCH_CHILD_COUNT = numericField('Number of Children', n => [n.children ? n.children.length : 0]);
3880
+ const SEARCH_DEPTH = numericField('Depth from Root', n => [n._srchDepth], {metrics: true});
3881
+ const SEARCH_DISTANCE_FROM_ROOT = numericField('Distance from Root', n => [n._srchDist], {metrics: true});
3882
+ const SEARCH_NODE_TYPE = textField('Node Type', function (node, root) {
3883
+ let kids = node.children;
3884
+ let isLeaf = !kids || kids.length === 0;
3885
+ return [isLeaf ? 'leaf' : (node === root ? 'root' : 'internal')];
3886
+ });
3887
+
3888
+ // The built-in fields by name, for a caller that asks "does this tree
3889
+ // carry X?" -- the viewer's label presets test whether a field object is
3890
+ // among availableSearchFields(tree), by identity. Property fields have
3891
+ // no entry here: there is one per ref, made per tree.
3892
+ forester.searchFields = {
3893
+ anyText: SEARCH_ANY_TEXT,
3894
+ nodeName: SEARCH_NODE_NAME,
3895
+ taxonomyScientificName: SEARCH_TAXONOMY_SCIENTIFIC_NAME,
3896
+ taxonomyCommonName: SEARCH_TAXONOMY_COMMON_NAME,
3897
+ taxonomyCode: SEARCH_TAXONOMY_CODE,
3898
+ taxonomyId: SEARCH_TAXONOMY_ID,
3899
+ taxonomySynonym: SEARCH_TAXONOMY_SYNONYM,
3900
+ taxonomyLineage: SEARCH_TAXONOMY_LINEAGE,
3901
+ sequenceName: SEARCH_SEQUENCE_NAME,
3902
+ geneName: SEARCH_GENE_NAME,
3903
+ sequenceSymbol: SEARCH_SEQUENCE_SYMBOL,
3904
+ sequenceAccession: SEARCH_SEQUENCE_ACCESSION,
3905
+ domain: SEARCH_DOMAIN,
3906
+ annotation: SEARCH_ANNOTATION,
3907
+ crossReference: SEARCH_CROSS_REFERENCE,
3908
+ molecularSequence: SEARCH_MOLECULAR_SEQUENCE,
3909
+ branchLength: SEARCH_BRANCH_LENGTH,
3910
+ confidence: SEARCH_CONFIDENCE,
3911
+ cladeSize: SEARCH_CLADE_SIZE,
3912
+ childCount: SEARCH_CHILD_COUNT,
3913
+ depth: SEARCH_DEPTH,
3914
+ distanceFromRoot: SEARCH_DISTANCE_FROM_ROOT,
3915
+ nodeType: SEARCH_NODE_TYPE
3426
3916
  };
3427
3917
 
3918
+ // One field per custom property ref; numeric when its declared datatype
3919
+ // or, failing that, every one of its values says so.
3920
+ function propertyField(ref, numeric) {
3921
+ return {label: ref, numeric: numeric, extract: function (node) {
3922
+ let out = [];
3923
+ if (node.properties) {
3924
+ for (let i = 0; i < node.properties.length; ++i) {
3925
+ let p = node.properties[i];
3926
+ if (p.ref === ref && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
3927
+ }
3928
+ }
3929
+ return out;
3930
+ }};
3931
+ }
3932
+
3428
3933
  function isInternalPropRef(ref) { return !ref || ref.indexOf('aptx:') === 0; }
3429
3934
 
3430
3935
  function datatypeIsNumeric(dt) {
@@ -3492,19 +3997,19 @@
3492
3997
  // dropdowns). Always exposes Any Text + Node Name; adds the text, numeric
3493
3998
  // and custom-property fields that are present, then structure fields.
3494
3999
  forester.availableSearchFields = function (root) {
3495
- let fields = [];
3496
- fields.push({ key: 'ANY', label: 'Any Text', numeric: false });
3497
- fields.push({ key: 'NN', label: SEARCH_FIELD_LABELS.NN, numeric: false });
3498
- if (!root) return fields;
4000
+ let fields = [SEARCH_ANY_TEXT];
4001
+ if (!root) {
4002
+ SEARCH_TEXT_FIELDS.forEach(function (f) { if (f.always) fields.push(f); });
4003
+ return fields;
4004
+ }
3499
4005
 
3500
- let present = {};
4006
+ let present = new Set();
3501
4007
  let hasBL = false, hasConf = false;
3502
4008
  let propRefs = {}; // ref -> { num, tot, dtNum, dtStr }
3503
4009
  forester.preOrderTraversalAll(root, function (n) {
3504
- for (let k = 0; k < SEARCH_TEXT_ORDER.length; ++k) {
3505
- let key = SEARCH_TEXT_ORDER[k];
3506
- if (!present[key] && SEARCH_TEXT_EXTRACTORS[key](n).length > 0) present[key] = true;
3507
- }
4010
+ SEARCH_TEXT_FIELDS.forEach(function (f) {
4011
+ if (!f.always && !present.has(f) && f.extract(n).length > 0) present.add(f);
4012
+ });
3508
4013
  if (!hasBL && typeof n.branch_length === 'number' && n.branch_length >= 0) hasBL = true;
3509
4014
  if (!hasConf && n.confidences) {
3510
4015
  for (let i = 0; i < n.confidences.length; ++i) {
@@ -3523,23 +4028,20 @@
3523
4028
  }
3524
4029
  });
3525
4030
 
3526
- for (let k = 0; k < SEARCH_TEXT_ORDER.length; ++k) {
3527
- let key = SEARCH_TEXT_ORDER[k];
3528
- if (present[key]) fields.push({ key: key, label: SEARCH_FIELD_LABELS[key], numeric: false });
3529
- }
3530
- if (hasBL) fields.push({ key: 'BL', label: 'Branch Length', numeric: true });
3531
- if (hasConf) fields.push({ key: 'CO', label: 'Confidence', numeric: true });
4031
+ SEARCH_TEXT_FIELDS.forEach(function (f) {
4032
+ if (f.always || present.has(f)) fields.push(f);
4033
+ });
4034
+ if (hasBL) fields.push(SEARCH_BRANCH_LENGTH);
4035
+ if (hasConf) fields.push(SEARCH_CONFIDENCE);
3532
4036
  let refs = Object.keys(propRefs).sort();
3533
4037
  for (let i = 0; i < refs.length; ++i) {
3534
4038
  let r = propRefs[refs[i]];
3535
4039
  let numeric = r.dtStr ? false : (r.dtNum ? true : (r.tot > 0 && r.num === r.tot));
3536
- fields.push({ key: 'PROP:' + refs[i], label: refs[i], numeric: numeric, propRef: refs[i] });
4040
+ fields.push(propertyField(refs[i], numeric));
3537
4041
  }
3538
- fields.push({ key: 'CS', label: 'Clade Size (tips)', numeric: true });
3539
- fields.push({ key: 'NC', label: 'Number of Children', numeric: true });
3540
- fields.push({ key: 'DE', label: 'Depth from Root', numeric: true });
3541
- if (hasBL) fields.push({ key: 'DR', label: 'Distance from Root', numeric: true });
3542
- fields.push({ key: 'NT', label: 'Node Type', numeric: false });
4042
+ fields.push(SEARCH_CLADE_SIZE, SEARCH_CHILD_COUNT, SEARCH_DEPTH);
4043
+ if (hasBL) fields.push(SEARCH_DISTANCE_FROM_ROOT);
4044
+ fields.push(SEARCH_NODE_TYPE);
3543
4045
  return fields;
3544
4046
  };
3545
4047
 
@@ -3550,11 +4052,11 @@
3550
4052
  n._srchDepth = depth;
3551
4053
  let d = dist + (typeof n.branch_length === 'number' && n.branch_length > 0 ? n.branch_length : 0);
3552
4054
  n._srchDist = d;
3553
- let kids = n.children || n._children;
4055
+ let kids = n.children;
3554
4056
  if (kids) for (let i = 0; i < kids.length; ++i) pre(kids[i], depth + 1, d);
3555
4057
  })(root, 0, 0);
3556
4058
  forester.postOrderTraversalAll(root, function (n) {
3557
- let kids = n.children || n._children;
4059
+ let kids = n.children;
3558
4060
  if (!kids || kids.length === 0) { n._srchClade = 1; return; }
3559
4061
  let s = 0;
3560
4062
  for (let i = 0; i < kids.length; ++i) s += kids[i]._srchClade;
@@ -3562,51 +4064,6 @@
3562
4064
  });
3563
4065
  }
3564
4066
 
3565
- // Extract the value(s) of a field from a node (strings for text fields,
3566
- // numbers for the numeric ones). A field is multi-valued; any value matching
3567
- // is a match. root is needed only for the Node Type field.
3568
- forester.extractSearchValues = function (node, field, root) {
3569
- let key = field.key;
3570
- if (key === 'ANY') {
3571
- let out = [];
3572
- for (let i = 0; i < SEARCH_ANY_TEXT_KEYS.length; ++i) {
3573
- out = out.concat(SEARCH_TEXT_EXTRACTORS[SEARCH_ANY_TEXT_KEYS[i]](node));
3574
- }
3575
- if (node.properties) {
3576
- for (let i = 0; i < node.properties.length; ++i) {
3577
- let p = node.properties[i];
3578
- if (!isInternalPropRef(p.ref) && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
3579
- }
3580
- }
3581
- return out;
3582
- }
3583
- if (key === 'NT') {
3584
- let kids = node.children || node._children;
3585
- let isLeaf = !kids || kids.length === 0;
3586
- return [isLeaf ? 'leaf' : (node === root ? 'root' : 'internal')];
3587
- }
3588
- if (key.indexOf('PROP:') === 0) {
3589
- let out = [];
3590
- if (node.properties) {
3591
- for (let i = 0; i < node.properties.length; ++i) {
3592
- let p = node.properties[i];
3593
- if (p.ref === field.propRef && p.value !== null && p.value !== undefined && p.value !== '') out.push(p.value);
3594
- }
3595
- }
3596
- return out;
3597
- }
3598
- if (SEARCH_TEXT_EXTRACTORS[key]) return SEARCH_TEXT_EXTRACTORS[key](node);
3599
- switch (key) {
3600
- case 'BL': return (typeof node.branch_length === 'number') ? [node.branch_length] : [];
3601
- case 'CO': return node.confidences ? node.confidences.map(c => c.value).filter(v => typeof v === 'number') : [];
3602
- case 'CS': return [node._srchClade];
3603
- case 'NC': { let kids = node.children || node._children; return [kids ? kids.length : 0]; }
3604
- case 'DE': return [node._srchDepth];
3605
- case 'DR': return [node._srchDist];
3606
- default: return [];
3607
- }
3608
- };
3609
-
3610
4067
  // Run one search spec { field, mode, value, value2, caseSensitive, inverse }
3611
4068
  // over the tree and return the Set of matching nodes.
3612
4069
  // A parsed tree is anchored on a SUPER-ROOT: a synthetic node whose single
@@ -3633,7 +4090,7 @@
3633
4090
  // values are unchanged; only the set of nodes considered is narrowed.
3634
4091
  let nodes = realRootOf(root);
3635
4092
  let field = spec.field;
3636
- if (field.key === 'CS' || field.key === 'DE' || field.key === 'DR' || field.key === 'NC') computeSearchMetrics(root);
4093
+ if (field.metrics) computeSearchMetrics(root);
3637
4094
 
3638
4095
  let v = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
3639
4096
  v = v.replace(/\s+/g, ' ').trim();
@@ -3646,7 +4103,7 @@
3646
4103
  let lo = (b !== null) ? Math.min(a, b) : a;
3647
4104
  let hi = (b !== null) ? Math.max(a, b) : a;
3648
4105
  test = function (n) {
3649
- let vals = forester.extractSearchValues(n, field, root);
4106
+ let vals = field.extract(n, root);
3650
4107
  for (let i = 0; i < vals.length; ++i) {
3651
4108
  let x = (typeof vals[i] === 'number') ? vals[i] : forester.parseFiniteDouble(vals[i]);
3652
4109
  if (x !== null && numMatches(x, spec.mode, a, lo, hi)) return true;
@@ -3675,7 +4132,7 @@
3675
4132
  }
3676
4133
  if (!compiled.length) return result;
3677
4134
  test = function (n) {
3678
- let vals = forester.extractSearchValues(n, field, root);
4135
+ let vals = field.extract(n, root);
3679
4136
  for (let oi = 0; oi < compiled.length; ++oi) {
3680
4137
  let ands = compiled[oi], ok = true;
3681
4138
  for (let ai = 0; ai < ands.length; ++ai) {
@@ -3695,7 +4152,7 @@
3695
4152
  // Complement, scoped to nodes that actually carry this field.
3696
4153
  let inv = new Set();
3697
4154
  forester.preOrderTraversalAll(nodes, function (n) {
3698
- if (!result.has(n) && forester.extractSearchValues(n, field, root).length > 0) inv.add(n);
4155
+ if (!result.has(n) && field.extract(n, root).length > 0) inv.add(n);
3699
4156
  });
3700
4157
  return inv;
3701
4158
  }
@@ -3703,13 +4160,14 @@
3703
4160
  };
3704
4161
 
3705
4162
  // Distinct, trimmed, sorted values of a specific text field across the tree,
3706
- // for the value-box autocomplete. Empty for numeric, Any Text, or Molecular
3707
- // Sequence (near-unique / huge). cap limits the list length (optional).
4163
+ // for the value-box autocomplete. Empty for a numeric field and for the
4164
+ // fields flagged suggest: false (Any Text; the molecular sequence, which
4165
+ // is near-unique and huge). cap limits the list length (optional).
3708
4166
  forester.distinctSearchValues = function (root, field, cap) {
3709
- if (!root || !field || field.numeric || field.key === 'ANY' || field.key === 'MS') return [];
4167
+ if (!root || !field || field.numeric || field.suggest === false) return [];
3710
4168
  let set = new Set();
3711
4169
  forester.preOrderTraversalAll(root, function (n) {
3712
- let vals = forester.extractSearchValues(n, field, root);
4170
+ let vals = field.extract(n, root);
3713
4171
  for (let i = 0; i < vals.length; ++i) {
3714
4172
  if (vals[i] !== null && vals[i] !== undefined) {
3715
4173
  let v = String(vals[i]).trim();
@@ -4021,7 +4479,7 @@
4021
4479
  let hasInternalIntervals = false;
4022
4480
  let hasExternalIntervals = false;
4023
4481
  forester.preOrderTraversalAll(root, function (n) {
4024
- let isExt = !n.children && !n._children;
4482
+ let isExt = !n.children;
4025
4483
  if (isExt) {
4026
4484
  ++external;
4027
4485
  } else {
@@ -4497,7 +4955,7 @@
4497
4955
  let candidates = [];
4498
4956
  let anyConfidence = false;
4499
4957
  forester.preOrderTraversalAll(root, function (n) {
4500
- if (n === root || !(n.children || n._children)) {
4958
+ if (n === root || !(n.children)) {
4501
4959
  return;
4502
4960
  }
4503
4961
  if (n.confidences && n.confidences.length > 0) {