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/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.2.1
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
- 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,111 @@
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
- }
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
- return propertyRefs;
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 || 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 || n._children) && (n.parent)) {
1757
+ if ((n.children) && (n.parent)) {
1426
1758
  properties.internalNodeData = true;
1427
1759
  }
1428
1760
  }
1429
- if (!(n.children || 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 || 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 || 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 || 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 VISIBLE children (n.children; a
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 && !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
- let e = ss[i - 1];
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 || 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 || 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
- 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;
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
- 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)
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
- 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;
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
- 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
- }
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
- 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 });
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({ key: 'PROP:' + refs[i], label: refs[i], numeric: numeric, propRef: refs[i] });
4326
+ fields.push(propertyField(refs[i], numeric));
3537
4327
  }
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 });
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 || 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 || 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.key === 'CS' || field.key === 'DE' || field.key === 'DR' || field.key === 'NC') computeSearchMetrics(root);
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 = forester.extractSearchValues(n, field, root);
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 = forester.extractSearchValues(n, field, root);
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) && forester.extractSearchValues(n, field, root).length > 0) inv.add(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, Any Text, or Molecular
3707
- // Sequence (near-unique / huge). cap limits the list length (optional).
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.key === 'ANY' || field.key === 'MS') return [];
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 = forester.extractSearchValues(n, field, root);
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 && !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 || n._children)) {
5244
+ if (n === root || !(n.children)) {
4501
5245
  return;
4502
5246
  }
4503
5247
  if (n.confidences && n.confidences.length > 0) {