archaeopteryx 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +116 -21
  2. package/archaeopteryx.js +1652 -579
  3. package/forester.js +572 -86
  4. package/package.json +1 -1
package/forester.js CHANGED
@@ -20,8 +20,8 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.0.0
24
- // 2026-09-04
23
+ // v 3.2.0
24
+ // 2026-09-10
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
27
27
  //
@@ -649,6 +649,21 @@
649
649
  const VIS_MAX_NUMERIC_UNIQUE_NUM = 9; // distinct/covered <= 0.9,
650
650
  const VIS_MAX_NUMERIC_UNIQUE_DEN = 10; // integer-exact as well
651
651
  const VIS_EXCLUDED_REF_PREFIX = 'style:';
652
+ // Refs that are never a visualization, however their values distribute.
653
+ // A taxon identifier repeats like a category and passes every statistical
654
+ // test above, yet says nothing a colour could carry that the species name
655
+ // beside it does not -- and reads as "11320" in a legend. Matched on the
656
+ // ref's local name with case and separators ignored, so vipr:NCBI_Taxon_Id,
657
+ // ncbi_taxid, taxon_id and taxonomy_id all count.
658
+ const VIS_EXCLUDED_LOCAL_NAME_RE = /(taxonomy|taxon|tax)id$/;
659
+
660
+ function visExcludedRef(ref) {
661
+ if (ref.indexOf(VIS_EXCLUDED_REF_PREFIX) === 0) {
662
+ return true;
663
+ }
664
+ let local = ref.substring(ref.indexOf(':') + 1).toLowerCase().replace(/[^a-z0-9]/g, '');
665
+ return VIS_EXCLUDED_LOCAL_NAME_RE.test(local);
666
+ }
652
667
 
653
668
  // ---- display normalization --------------------------------------------
654
669
  //
@@ -796,6 +811,14 @@
796
811
  .join(' ');
797
812
  }
798
813
 
814
+ // The display name for a property ref, as the Color-by menu and legends
815
+ // show it: namespace dropped, then prettified. Exported so the node-data
816
+ // dialog names a property the same way the rest of the viewer does.
817
+ forester.propertyDisplayName = function (ref) {
818
+ let local = ref.indexOf(':') >= 0 ? ref.substring(ref.indexOf(':') + 1) : ref;
819
+ return prettifyVisLabel(local);
820
+ };
821
+
799
822
  forester.visualizationCandidates = function (tree) {
800
823
  let total = 0;
801
824
  let stats = Object.create(null); // id -> {kind, ref, label, nodes, values:Set, multi}; null-proto: ids embed file refs
@@ -832,8 +855,7 @@
832
855
  if (n.properties) {
833
856
  for (let i = 0; i < n.properties.length; ++i) {
834
857
  let p = n.properties[i];
835
- if (p.ref && p.applies_to === 'node'
836
- && p.ref.indexOf(VIS_EXCLUDED_REF_PREFIX) !== 0) {
858
+ if (p.ref && p.applies_to === 'node' && !visExcludedRef(p.ref)) {
837
859
  add('prop:' + p.ref, 'property', p.ref, null, p.value);
838
860
  }
839
861
  }
@@ -1137,18 +1159,33 @@
1137
1159
  return style;
1138
1160
  };
1139
1161
 
1140
- // The boring part of every tip name. When the displayed names all share
1141
- // a long prefix ("Influenza A virus ..."), a shortener that keeps the
1142
- // first characters keeps exactly the characters that carry no
1143
- // information. This returns the longest common prefix of the displayed
1144
- // external names -- the label property's value where one is in effect,
1145
- // the node name otherwise -- cut back to the last separator so no word
1146
- // is split, and only when it is long enough to matter (>= 6 characters).
1147
- // The Short Names rendering strips it before truncating, so what
1148
- // survives is the part that tells the tips apart. The comparison is
1149
- // case-insensitive -- "Influenza A virus" and "Influenza A Virus" are
1150
- // the same boring prefix -- so callers must strip by LENGTH, comparing
1151
- // case-insensitively, not by exact match.
1162
+ // The share of tips that must carry a prefix for it to count as boilerplate.
1163
+ // A strict longest-common-prefix (1.0) lets a handful of oddly-named tips
1164
+ // veto the strip for everyone else: on the BV-BRC influenza tree 13,096 of
1165
+ // 13,246 tips share a 51-character prefix, but the other 0.8% drag the LCP
1166
+ // down to "A" and nothing is stripped at all. 0.95 fixes that with real
1167
+ // margin, and still refuses splits that would leave two groups of tips
1168
+ // incomparable -- at 0.67 the same corpus strips the country code from 78%
1169
+ // of tips while 22% keep their full names. DESIGNED JOINTLY WITH THE
1170
+ // DESKTOP, which uses the identical value: this rule and its output are
1171
+ // byte-identical across both programs, so the threshold is not ours alone
1172
+ // to change.
1173
+ const COMMON_PREFIX_QUANTILE = 0.95;
1174
+
1175
+ // The boring part of every tip name. When most displayed names share a
1176
+ // long prefix ("Influenza A virus ..."), a shortener that keeps the first
1177
+ // characters keeps exactly the characters that carry no information. This
1178
+ // returns the longest prefix shared by at least COMMON_PREFIX_QUANTILE of
1179
+ // the displayed external names -- the label property's value where one is
1180
+ // in effect, the node name otherwise -- cut back to the last separator so
1181
+ // no word is split, and only when it is long enough to matter
1182
+ // (>= 6 characters). The Short Names rendering strips it before
1183
+ // truncating, so what survives is the part that tells the tips apart. The
1184
+ // comparison is case-insensitive -- "Influenza A virus" and "Influenza A
1185
+ // Virus" are the same boring prefix -- so callers must strip by LENGTH,
1186
+ // comparing case-insensitively, not by exact match. Tips that do NOT
1187
+ // carry the prefix keep their full names; the caller's startsWith test
1188
+ // handles that without needing to know about the quantile.
1152
1189
  forester.commonNamePrefix = function (tree, labelProperty) {
1153
1190
  let names = [];
1154
1191
  let slot = labelProperty ? {kind: 'property', ref: labelProperty} : null;
@@ -1170,29 +1207,72 @@
1170
1207
  if (names.length < 2) {
1171
1208
  return '';
1172
1209
  }
1173
- let prefix = names[0];
1174
- for (let k = 1; k < names.length && prefix.length > 0; ++k) {
1175
- let a = prefix.toLowerCase();
1176
- let b = names[k].toLowerCase();
1210
+ // Any k names sharing a prefix are CONTIGUOUS once sorted, so
1211
+ // comparing each sorted name with the one k-1 places later finds the
1212
+ // longest prefix shared by k of them exactly -- no approximation, and
1213
+ // O(n log n + n*L) rather than the quadratic scan the obvious reading
1214
+ // suggests. At q = 1.0, k = n and this reduces to the strict LCP.
1215
+ let lower = names.map(function (s) {
1216
+ return s.toLowerCase();
1217
+ }).sort();
1218
+ let n = lower.length;
1219
+ let k = Math.ceil(COMMON_PREFIX_QUANTILE * n);
1220
+ if (k < 2) {
1221
+ k = 2;
1222
+ }
1223
+ if (k > n) {
1224
+ k = n;
1225
+ }
1226
+ let bestLen = 0;
1227
+ let bestIdx = -1;
1228
+ for (let i = 0; i + k - 1 < n; ++i) {
1229
+ let a = lower[i];
1230
+ let b = lower[i + k - 1];
1177
1231
  let max = Math.min(a.length, b.length);
1178
- let i = 0;
1179
- while (i < max && a.charCodeAt(i) === b.charCodeAt(i)) {
1180
- ++i;
1232
+ let j = 0;
1233
+ while (j < max && a.charCodeAt(j) === b.charCodeAt(j)) {
1234
+ ++j;
1181
1235
  }
1182
- if (i < prefix.length) {
1183
- prefix = prefix.substring(0, i);
1236
+ if (j > bestLen) {
1237
+ bestLen = j;
1238
+ bestIdx = i;
1184
1239
  }
1185
1240
  }
1186
- if (prefix.length === 0) {
1241
+ if (bestLen === 0) {
1242
+ return '';
1243
+ }
1244
+ let lowerPrefix = lower[bestIdx].substring(0, bestLen);
1245
+ // Casing comes from the first name in TRAVERSAL order that carries the
1246
+ // prefix. At q = 1.0 that is names[0], which is exactly what the
1247
+ // strict-LCP version returned, so the two agree character for
1248
+ // character -- and the desktop mirrors this traversal order
1249
+ // deliberately, so the two programs do too.
1250
+ let prefix = null;
1251
+ let carriers = [];
1252
+ for (let ci = 0; ci < names.length; ++ci) {
1253
+ let name = names[ci];
1254
+ if (name.length >= bestLen
1255
+ && name.substring(0, bestLen).toLowerCase() === lowerPrefix) {
1256
+ if (prefix === null) {
1257
+ prefix = name.substring(0, bestLen);
1258
+ }
1259
+ carriers.push(name);
1260
+ }
1261
+ }
1262
+ if (prefix === null) {
1187
1263
  return '';
1188
1264
  }
1189
1265
  // Trim back to the last separator ONLY when the prefix actually
1190
1266
  // splits a word -- "ABC_ho" against "ABC_house"/"ABC_horse" does,
1191
1267
  // "Influenza A virus" against "...virus A/x" and "...virus(A/y)"
1192
- // does not, whatever character each name continues with.
1268
+ // does not, whatever character each name continues with. Only the
1269
+ // names that CARRY the prefix get a say: one that does not share it
1270
+ // says nothing about whether the prefix splits a word, and letting it
1271
+ // vote throws the prefix away -- 19 tips of "ABCDEFG_..."/"ABCDEFG-..."
1272
+ // plus one unrelated longer tip yields "" instead of "ABCDEFG".
1193
1273
  let alnum = /[A-Za-z0-9]/;
1194
1274
  let splitsWord = alnum.test(prefix.charAt(prefix.length - 1))
1195
- && names.some(function (name) {
1275
+ && carriers.some(function (name) {
1196
1276
  return name.length > prefix.length && alnum.test(name.charAt(prefix.length));
1197
1277
  });
1198
1278
  if (splitsWord) {
@@ -1302,6 +1382,17 @@
1302
1382
  // Whether a tree is worth drawing to scale is a question about the
1303
1383
  // majority of its branches, not about whether any branch has a length.
1304
1384
  properties.branchesWithPositiveLength = 0;
1385
+ // Branches that carry a length AT ALL -- an explicit zero is a real
1386
+ // measurement, not a missing one, so these are counted separately from
1387
+ // the "positive" tally above. Split internal-vs-all because a missing
1388
+ // length means different things in the two places: an unmeasured TIP
1389
+ // still draws correctly (at its parent), while an unmeasured INTERNAL
1390
+ // branch destroys the scale. All four counts exclude the root, which
1391
+ // has no branch above it.
1392
+ properties.branchCount = 0;
1393
+ properties.branchesWithLength = 0;
1394
+ properties.internalBranchCount = 0;
1395
+ properties.internalBranchesWithLength = 0;
1305
1396
  properties.averageBranchLength = 0;
1306
1397
  let bl_counter = 0;
1307
1398
  let bl_sum = 0;
@@ -1309,8 +1400,23 @@
1309
1400
  // exist -- skewing the branch-length fraction the viewer uses to choose
1310
1401
  // between a phylogram and a cladogram -- and from phyloXML would take
1311
1402
  // the tree's own name for the longest node name.
1312
- forester.preOrderTraversalAll(realRootOf(tree), function (n) {
1403
+ let rootNode = realRootOf(tree);
1404
+ forester.preOrderTraversalAll(rootNode, function (n) {
1313
1405
  properties.nodeCount += 1;
1406
+ if (n !== rootNode) {
1407
+ let internal = !!(n.children || n._children);
1408
+ let measured = typeof n.branch_length === 'number' && isFinite(n.branch_length);
1409
+ properties.branchCount += 1;
1410
+ if (measured) {
1411
+ properties.branchesWithLength += 1;
1412
+ }
1413
+ if (internal) {
1414
+ properties.internalBranchCount += 1;
1415
+ if (measured) {
1416
+ properties.internalBranchesWithLength += 1;
1417
+ }
1418
+ }
1419
+ }
1314
1420
  if (n.name && n.name.length > 0) {
1315
1421
  properties.nodeNames = true;
1316
1422
  if (n.name.length > properties.longestNodeName) {
@@ -1744,6 +1850,35 @@
1744
1850
  return (lo !== null && hi !== null) ? [lo, hi] : null;
1745
1851
  }
1746
1852
 
1853
+ // Nexus/Newick quoting: a quoted token is wrapped in a matching pair, and a
1854
+ // literal quote INSIDE it is written twice. Reading one back therefore
1855
+ // means removing one matching outer pair and un-doubling what is inside --
1856
+ // 'Seba''s bat' is the single label "Seba's bat", not "Sebas bat", which is
1857
+ // what stripping every quote gave. The doubling is per quote, so a token
1858
+ // holding two escapes in a row un-doubles to two literal quotes; a pass
1859
+ // that removes quotes wholesale loses both.
1860
+ //
1861
+ // A token that is NOT well-formed -- an odd number of quotes, no closing
1862
+ // quote, a quote in the middle of a bare word -- is not a quoted token at
1863
+ // all. Those keep the old lenient behaviour of dropping stray quotes rather
1864
+ // than throwing: a viewer that refuses to open a file teaches the user
1865
+ // nothing, and Nexus in the wild is written by many programs. Strict on
1866
+ // output, lenient on input. Matches the desktop (0.11.141+).
1867
+ function unquoteLabel(s) {
1868
+ if (s === null || s === undefined) {
1869
+ return s;
1870
+ }
1871
+ let t = String(s).trim();
1872
+ let len = t.length;
1873
+ if (len > 1) {
1874
+ let q = t.charAt(0);
1875
+ if ((q === "'" || q === '"') && t.charAt(len - 1) === q) {
1876
+ return t.substring(1, len - 1).split(q + q).join(q);
1877
+ }
1878
+ }
1879
+ return t.replace(/['"]+/g, '');
1880
+ }
1881
+
1747
1882
  function stripValueQuotes(v) {
1748
1883
  if (v.length >= 2
1749
1884
  && ((v.charAt(0) === '"' && v.charAt(v.length - 1) === '"')
@@ -1916,9 +2051,12 @@
1916
2051
  if (confidenceValuesAsInternalNames === undefined) {
1917
2052
  confidenceValuesAsInternalNames = false;
1918
2053
  }
1919
- if ((confidenceValuesInBrackets === true) && (confidenceValuesAsInternalNames === true)) {
1920
- throw ("confidence values cannot be both in brackets and as internal node names");
1921
- }
2054
+ // The two options used to be mutually exclusive and throwing. They are
2055
+ // not: brackets are consumed by the tokenizer, bare labels are handled
2056
+ // afterwards, so a file mixing the two dialects is read correctly with
2057
+ // both on. forester-Java never had this guard, so it was never part of
2058
+ // the shared contract -- and throwing on a combination a user
2059
+ // plausibly wants is what forced callers into workarounds.
1922
2060
 
1923
2061
  let ancs = [];
1924
2062
  let x = {};
@@ -1935,6 +2073,19 @@
1935
2073
  let in_double_q = false;
1936
2074
  let in_single_q = false;
1937
2075
  let buffer = '';
2076
+ // In a quoted label a literal quote is DOUBLED. The tokenizer splits
2077
+ // on quotes, so a doubled one arrives as two quote elements with only
2078
+ // empty strings between them -- that is the signature. Seeing it means
2079
+ // 'emit one quote and stay inside the run', not 'close the run': the
2080
+ // run continuing is what keeps the label in one piece, and emitting
2081
+ // the character is what stops the apostrophe being swallowed.
2082
+ let doubledQuoteEnd = function (at, qch) {
2083
+ let j = at + 1;
2084
+ while (j < ssl && ss[j] === '') {
2085
+ ++j;
2086
+ }
2087
+ return (j < ssl && ss[j] === qch) ? j : -1;
2088
+ };
1938
2089
  for (let i = 0; i < ssl; ++i) {
1939
2090
  let element = ss[i].replace(/\s+/g, '');
1940
2091
 
@@ -1942,25 +2093,37 @@
1942
2093
  if (!in_double_q) {
1943
2094
  in_double_q = true;
1944
2095
  } else {
1945
- in_double_q = false;
1946
- if (x.name && x.name.length > 0) {
1947
- x.name = x.name + buffer;
2096
+ let dq = doubledQuoteEnd(i, '"');
2097
+ if (dq > -1) {
2098
+ buffer += '"';
2099
+ i = dq;
1948
2100
  } else {
1949
- x.name = buffer;
2101
+ in_double_q = false;
2102
+ if (x.name && x.name.length > 0) {
2103
+ x.name = x.name + buffer;
2104
+ } else {
2105
+ x.name = buffer;
2106
+ }
2107
+ buffer = '';
1950
2108
  }
1951
- buffer = '';
1952
2109
  }
1953
2110
  } else if (element === "'" && !in_double_q) {
1954
2111
  if (!in_single_q) {
1955
2112
  in_single_q = true;
1956
2113
  } else {
1957
- in_single_q = false;
1958
- if (x.name && x.name.length > 0) {
1959
- x.name = x.name + buffer;
2114
+ let dq = doubledQuoteEnd(i, "'");
2115
+ if (dq > -1) {
2116
+ buffer += "'";
2117
+ i = dq;
1960
2118
  } else {
1961
- x.name = buffer;
2119
+ in_single_q = false;
2120
+ if (x.name && x.name.length > 0) {
2121
+ x.name = x.name + buffer;
2122
+ } else {
2123
+ x.name = buffer;
2124
+ }
2125
+ buffer = '';
1962
2126
  }
1963
- buffer = '';
1964
2127
  }
1965
2128
  } else {
1966
2129
  if (in_double_q || in_single_q) {
@@ -2233,10 +2396,14 @@
2233
2396
  throw new Error(NEXUS_FORMAT_ERR + 'ill-formatted translate table entry: "'
2234
2397
  + pair.trim() + '" -- is the Translate sub-command terminated with a ";"?');
2235
2398
  }
2236
- let value = m[2].replace(/['"]+/g, '').trim();
2399
+ // The sub-command terminator comes off BEFORE unquoting, or a
2400
+ // well-formed quoted value followed by ';' would not look
2401
+ // well-formed and would fall to the lenient path.
2402
+ let value = m[2].trim();
2237
2403
  if (value.endsWith(';')) {
2238
- value = value.slice(0, -1);
2404
+ value = value.slice(0, -1).trim();
2239
2405
  }
2406
+ value = unquoteLabel(value);
2240
2407
  translateMap[m[1]] = value;
2241
2408
  });
2242
2409
  }
@@ -2320,14 +2487,38 @@
2320
2487
  for (let id in seqs) {
2321
2488
  seqsByKey[joinKey(id)] = seqs[id];
2322
2489
  }
2323
- forester.getAllExternalNodes(phy).forEach(function (node) {
2490
+ let externals = forester.getAllExternalNodes(phy);
2491
+ // A bare integer tip name counts as a TAXLABELS index only when
2492
+ // the WHOLE tree reads as index references: every tip a bare
2493
+ // integer AND every one of them in range. All-or-nothing, because
2494
+ // deciding it per tip fails silently and plausibly -- against six
2495
+ // labels, ((a,b,c),(1,2,3)) renamed just the three integers and
2496
+ // handed back a tree whose every tip was DUPLICATED, which still
2497
+ // parses and still renders; and a single out-of-range index left a
2498
+ // half-renamed tree behind for the same reason. Both are reachable
2499
+ // through our own writer: save as Nexus, reopen. So if any tip is
2500
+ // not an index, none of them are. Matches the desktop (0.11.140+).
2501
+ // A TRANSLATE entry still wins wherever it applies -- it is the
2502
+ // explicit mechanism, this is only the heuristic.
2503
+ let indexed = taxlabels.length > 0 && externals.every(function (node) {
2504
+ if (node.name && translateMap[node.name] !== undefined) {
2505
+ return true;
2506
+ }
2507
+ if (!node.name || !/^\d+$/.test(node.name)) {
2508
+ return false;
2509
+ }
2510
+ let i = parseInt(node.name, 10);
2511
+ return i > 0 && i <= taxlabels.length;
2512
+ });
2513
+ externals.forEach(function (node) {
2324
2514
  if (node.name && translateMap[node.name] !== undefined) {
2325
2515
  node.name = translateMap[node.name];
2326
- } else if (taxlabels.length > 0 && node.name && /^\d+$/.test(node.name)) {
2327
- let i = parseInt(node.name, 10);
2328
- if (i > 0 && i <= taxlabels.length) {
2329
- node.name = taxlabels[i - 1].replace(/['"]+/g, '');
2330
- }
2516
+ } else if (indexed) {
2517
+ // The TAXLABELS tokenizer has already removed the outer
2518
+ // quotes and un-doubled what was inside, so the label is
2519
+ // used as-is: stripping quotes again here would undo the
2520
+ // un-doubling and drop the apostrophe a second time.
2521
+ node.name = taxlabels[parseInt(node.name, 10) - 1];
2331
2522
  }
2332
2523
  if (node.name) {
2333
2524
  let s = seqsByKey[joinKey(node.name)];
@@ -2406,7 +2597,7 @@
2406
2597
  inTree = true;
2407
2598
  let nm = TREE_NAME_RE.exec(line);
2408
2599
  if (nm) {
2409
- name = nm[1].replace(/['"]+/g, '');
2600
+ name = unquoteLabel(nm[1]);
2410
2601
  }
2411
2602
  let rm = ROOTEDNESS_RE.exec(line);
2412
2603
  if (rm) {
@@ -2438,24 +2629,56 @@
2438
2629
  // silently sheared such labels apart and shifted every
2439
2630
  // numeric tip onto the wrong name. ';' (unquoted) ends
2440
2631
  // the sub-command.
2632
+ //
2633
+ // A quote OPENS a run only at a TOKEN BOUNDARY. Inside a
2634
+ // word it is just a character -- an unquoted O'Neil must
2635
+ // not open a run and swallow the rest of the line, the
2636
+ // terminating ';' included, which is what the first
2637
+ // version of this tokenizer did: it merged every remaining
2638
+ // label into one and left the other tips as bare numbers.
2441
2639
  let tok = '';
2442
2640
  let q = null;
2641
+ let closed = false; // this token already held a quoted run
2443
2642
  let push = function () {
2444
2643
  if (tok.length > 0 && tok.toLowerCase() !== 'taxlabels') {
2445
2644
  taxlabels.push(tok);
2446
2645
  }
2447
2646
  tok = '';
2647
+ closed = false;
2448
2648
  };
2449
2649
  for (let ci = 0; ci < line.length; ++ci) {
2450
2650
  let ch = line.charAt(ci);
2451
2651
  if (q) {
2452
2652
  if (ch === q) {
2453
2653
  q = null;
2654
+ closed = true;
2454
2655
  } else {
2455
2656
  tok += ch;
2456
2657
  }
2457
2658
  } else if (ch === "'" || ch === '"') {
2458
- q = ch;
2659
+ if (tok.length === 0 && !closed) {
2660
+ q = ch;
2661
+ } else if (closed) {
2662
+ // A quote directly after a closing one is the
2663
+ // doubled Nexus escape: emit ONE literal quote
2664
+ // and let the run CONTINUE. The continuing is
2665
+ // what keeps a quoted label holding an
2666
+ // apostrophe in one piece instead of splitting
2667
+ // it at the space (that was N1); emitting the
2668
+ // character is the un-doubling half, deferred
2669
+ // until both programs could land it together.
2670
+ tok += ch;
2671
+ q = ch;
2672
+ } else {
2673
+ // A quote in the middle of a BARE word is not
2674
+ // an escape and not a delimiter -- an unquoted
2675
+ // token may not legally hold one at all. It is
2676
+ // dropped rather than kept, which is the older
2677
+ // lenient behaviour and is SHARED with the
2678
+ // desktop; keeping it here would be a new
2679
+ // divergence, not a fix.
2680
+ void ch;
2681
+ }
2459
2682
  } else if (ch === ' ') {
2460
2683
  push();
2461
2684
  } else if (ch === ';') {
@@ -2892,16 +3115,55 @@
2892
3115
  };
2893
3116
 
2894
3117
 
3118
+ // How a label is written into Newick or Nexus, ported from the desktop's
3119
+ // ForesterUtil.santitizeStringForNH so both programs emit the same token
3120
+ // for the same name. Quoting rather than transliterating is what makes a
3121
+ // save-and-reopen lossless: the previous rule mapped every quote, comma,
3122
+ // paren and space to '_', which no reader can undo, so a tip named
3123
+ // "Cooper's Hawk" came back "Cooper_s_Hawk".
3124
+ //
3125
+ // The one case that still loses information is a name carrying BOTH quote
3126
+ // styles: there is no quote character left to wrap it in, so the
3127
+ // apostrophes become backticks. The desktop does the same, deliberately;
3128
+ // that case is a JOINT open item and is NOT to be fixed on one side.
3129
+ //
3130
+ // Note the asymmetry with the READER: a doubled '' is how a quote is
3131
+ // escaped INSIDE a quoted token, and our reader un-doubles it, but neither
3132
+ // writer produces that form -- both sidestep it by switching quote style.
3133
+ // Reading a form you do not write is intentional here.
3134
+ function sanitizeLabelForNH(s) {
3135
+ let t = String(s).replace(/\s+/g, ' ').trim();
3136
+ let hasSingle = t.indexOf("'") > -1;
3137
+ let hasDouble = t.indexOf('"') > -1;
3138
+ if (hasSingle && hasDouble) {
3139
+ return "'" + t.replace(/'/g, '`') + "'";
3140
+ }
3141
+ if (hasSingle) {
3142
+ return '"' + t + '"';
3143
+ }
3144
+ if (hasDouble || /[\s,():;[\]]/.test(t)) {
3145
+ return "'" + t + "'";
3146
+ }
3147
+ return t;
3148
+ }
3149
+
2895
3150
  /**
2896
3151
  * To convert a phylogentic tree object to a New Hampshire (Newick) formatted string.
2897
3152
  *
2898
3153
  * @param phy - A phylogentic tree object.
2899
3154
  * @param decPointsMax - Maximal number of decimal points for branch lengths (optional)
2900
- * @param replaceChars - To replace illegal characters (),:;"' instead of surrounding with quotation marks
3155
+ * @param replaceChars - RETIRED 2026-09-10 and ignored. It used to map
3156
+ * every space, comma, paren, colon, semicolon, bracket and quote to
3157
+ * '_', which no reader can undo: a tip named "Cooper's Hawk" was
3158
+ * written Cooper_s_Hawk and came back that way. Labels are now
3159
+ * always quoted instead, by the same rule the desktop uses, so a
3160
+ * save-and-reopen keeps the name. The parameter is still accepted
3161
+ * so positional callers keep working.
2901
3162
  * @param writeConfidences - to write confidence values in brackets
2902
3163
  * @returns {*} - a New Hampshire (Newick) formatted string.
2903
3164
  */
2904
3165
  forester.toNewHampshire = function (phy, decPointsMax, replaceChars, writeConfidences) {
3166
+ void replaceChars; // retired: see the note above; labels are always quoted now
2905
3167
  let nh = "";
2906
3168
  if (phy.children && phy.children.length === 1) {
2907
3169
  toNewHampshireHelper(phy.children[0], true);
@@ -2928,22 +3190,7 @@
2928
3190
  nh += ")";
2929
3191
  }
2930
3192
  if (node.name && node.name.length > 0) {
2931
- if (replaceChars === true) {
2932
- nh += replaceUnsafeChars(node.name);
2933
- } else {
2934
- let myName = node.name.replace(/\s+/g, ' ');
2935
- if (/[\s,():;'"[\]]/.test(myName)) {
2936
- if ((myName.indexOf('"') > -1) && (myName.indexOf("'") > -1)) {
2937
- nh += '"' + myName.replace(/"/g, "'") + '"';
2938
- } else if (myName.indexOf('"') > -1) {
2939
- nh += "'" + myName + "'";
2940
- } else {
2941
- nh += '"' + myName + '"';
2942
- }
2943
- } else {
2944
- nh += myName;
2945
- }
2946
- }
3193
+ nh += sanitizeLabelForNH(node.name);
2947
3194
  }
2948
3195
  if (node.branch_length !== undefined && node.branch_length !== null) {
2949
3196
  if (decPointsMax && decPointsMax > 0) {
@@ -2964,9 +3211,6 @@
2964
3211
  }
2965
3212
  }
2966
3213
 
2967
- function replaceUnsafeChars(str) {
2968
- return str.replace(/[\s,():;'"[\]]+/g, '_');
2969
- }
2970
3214
  };
2971
3215
 
2972
3216
  // Writes a phylogeny as a Nexus-formatted string, ported from the
@@ -2978,11 +3222,16 @@
2978
3222
  // carrying the tree and its alignment in one file is the point of Nexus,
2979
3223
  // and parseNexus reads the alignment back onto the tips.
2980
3224
  forester.toNexus = function (phy, decPointsMax, writeConfidences) {
2981
- // the same replacement toNewHampshire applies, so the TaxLabels and
2982
- // Matrix labels match the tree's tip tokens exactly
2983
- function safeLabel(s) {
2984
- return s.replace(/[\s,():;'"[\]]+/g, '_');
2985
- }
3225
+ // The TaxLabels tokens, the Matrix row labels and the tree's tip
3226
+ // tokens must be byte-identical or nothing can join them back up, so
3227
+ // all three go through sanitizeLabelForNH -- the same helper
3228
+ // toNewHampshire writes the tree with.
3229
+ //
3230
+ // nexusLabel returns the label UNQUOTED, because it is also assigned
3231
+ // to node.name for a nameless tip and toNewHampshire quotes it again
3232
+ // on the way out. The old '_' substitution was idempotent so applying
3233
+ // it twice was harmless; quoting is not, and would emit "'a b'"
3234
+ // wrapped in quotes a second time.
2986
3235
 
2987
3236
  // label preference as on the desktop: name, then taxonomy
2988
3237
  // (code/scientific/common), then sequence (name/symbol/gene)
@@ -3000,7 +3249,7 @@
3000
3249
  if (!s) {
3001
3250
  s = 'node' + (i + 1); // an empty TaxLabels token would not parse back
3002
3251
  }
3003
- return safeLabel(s);
3252
+ return s;
3004
3253
  }
3005
3254
 
3006
3255
  let ext = forester.getAllExternalNodes(phy).reverse();
@@ -3020,7 +3269,7 @@
3020
3269
  s += ' Dimensions NTax=' + ext.length + ';\n';
3021
3270
  s += ' TaxLabels';
3022
3271
  ext.forEach(function (node, i) {
3023
- s += ' ' + nexusLabel(node, i);
3272
+ s += ' ' + sanitizeLabelForNH(nexusLabel(node, i));
3024
3273
  });
3025
3274
  s += ';\n';
3026
3275
  s += 'End;\n';
@@ -3035,7 +3284,7 @@
3035
3284
  for (let j = 0; j < node.sequences.length; ++j) {
3036
3285
  let q = node.sequences[j];
3037
3286
  if (q.mol_seq && q.mol_seq.is_aligned && q.mol_seq.value) {
3038
- rows.push({label: nexusLabel(node, i), value: q.mol_seq.value});
3287
+ rows.push({label: sanitizeLabelForNH(nexusLabel(node, i)), value: q.mol_seq.value});
3039
3288
  nchar = Math.max(nchar, q.mol_seq.value.length);
3040
3289
  if (!datatype && (q.type === 'protein' || q.type === 'dna' || q.type === 'rna')) {
3041
3290
  datatype = q.type;
@@ -3070,8 +3319,10 @@
3070
3319
  }
3071
3320
 
3072
3321
  s += 'Begin Trees;\n';
3073
- let treeName = phy.name ? String(phy.name).replace(/['"]+/g, '').trim() : '';
3074
- s += ' Tree ' + (treeName ? ("'" + treeName + "'") : 'tree1') + '=';
3322
+ // the tree name was stripped of its quotes for the same reason the tip
3323
+ // names were transliterated, and loses an apostrophe the same way
3324
+ let treeName = phy.name ? String(phy.name).trim() : '';
3325
+ s += ' Tree ' + (treeName ? sanitizeLabelForNH(treeName) : 'tree1') + '=';
3075
3326
  s += (phy.rooted === false) ? '[&U]' : '[&R]';
3076
3327
  let nh = forester.toNewHampshire(phy, decPointsMax, true, writeConfidences);
3077
3328
  renamed.forEach(function (node) {
@@ -3560,6 +3811,117 @@
3560
3811
  {name: 'Miaolingian', young: 497, old: 506.5, color: '#A6CF86'},
3561
3812
  {name: 'Series 2', young: 506.5, old: 521, color: '#99C078'},
3562
3813
  {name: 'Terreneuvian', young: 521, old: 538.8, color: '#8CB06C'}
3814
+ ],
3815
+ // The 101 ratified Phanerozoic stages, plus the Pridoli: a Series with
3816
+ // no stages of its own, standing in the stage row for its own span
3817
+ // (419.62-422.7 Ma) as on the printed ICS chart -- without it the row
3818
+ // would have a hole there. Same source as every rank above
3819
+ // (Macrostrat international timescale, id 11), which the ranks above
3820
+ // match byte for byte; the desktop generated its table from the same
3821
+ // query, so the two programs band from identical data.
3822
+ age: [
3823
+ {name: 'Meghalayan', young: 0, old: 0.0042, color: '#FEF2E0'},
3824
+ {name: 'Northgrippian', young: 0.0042, old: 0.0082, color: '#FEF2E0'},
3825
+ {name: 'Greenlandian', young: 0.0082, old: 0.0117, color: '#FEF2E0'},
3826
+ {name: 'Late Pleistocene', young: 0.0117, old: 0.129, color: '#FFF2C7'},
3827
+ {name: 'Chibanian', young: 0.129, old: 0.774, color: '#FFF2C7'},
3828
+ {name: 'Calabrian', young: 0.774, old: 1.8, color: '#FFF2C7'},
3829
+ {name: 'Gelasian', young: 1.8, old: 2.58, color: '#FFEDB3'},
3830
+ {name: 'Piacenzian', young: 2.58, old: 3.6, color: '#FFFFBF'},
3831
+ {name: 'Zanclean', young: 3.6, old: 5.333, color: '#FFFFB3'},
3832
+ {name: 'Messinian', young: 5.333, old: 7.246, color: '#FFFF73'},
3833
+ {name: 'Tortonian', young: 7.246, old: 11.63, color: '#FFFF66'},
3834
+ {name: 'Serravallian', young: 11.63, old: 13.82, color: '#FFFF59'},
3835
+ {name: 'Langhian', young: 13.82, old: 15.98, color: '#FFFF4D'},
3836
+ {name: 'Burdigalian', young: 15.98, old: 20.45, color: '#FFFF41'},
3837
+ {name: 'Aquitanian', young: 20.45, old: 23.04, color: '#FFFF33'},
3838
+ {name: 'Chattian', young: 23.04, old: 27.3, color: '#FEE6AA'},
3839
+ {name: 'Rupelian', young: 27.3, old: 33.9, color: '#FED99A'},
3840
+ {name: 'Priabonian', young: 33.9, old: 37.71, color: '#FDCDA1'},
3841
+ {name: 'Bartonian', young: 37.71, old: 41.03, color: '#FDC091'},
3842
+ {name: 'Lutetian', young: 41.03, old: 48.07, color: '#FCB482'},
3843
+ {name: 'Ypresian', young: 48.07, old: 56, color: '#FCA773'},
3844
+ {name: 'Thanetian', young: 56, old: 59.24, color: '#FDBF6F'},
3845
+ {name: 'Selandian', young: 59.24, old: 61.66, color: '#FEBF65'},
3846
+ {name: 'Danian', young: 61.66, old: 66, color: '#FDB462'},
3847
+ {name: 'Maastrichtian', young: 66, old: 72.2, color: '#F2FA8C'},
3848
+ {name: 'Campanian', young: 72.2, old: 83.6, color: '#E6F47F'},
3849
+ {name: 'Santonian', young: 83.6, old: 85.7, color: '#D9EF74'},
3850
+ {name: 'Coniacian', young: 85.7, old: 89.8, color: '#CCE968'},
3851
+ {name: 'Turonian', young: 89.8, old: 93.9, color: '#BFE35D'},
3852
+ {name: 'Cenomanian', young: 93.9, old: 100.5, color: '#B3DE53'},
3853
+ {name: 'Albian', young: 100.5, old: 113.2, color: '#CCEA97'},
3854
+ {name: 'Aptian', young: 113.2, old: 121.4, color: '#BFE48A'},
3855
+ {name: 'Barremian', young: 121.4, old: 125.77, color: '#B3DF7F'},
3856
+ {name: 'Hauterivian', young: 125.77, old: 132.6, color: '#A6D975'},
3857
+ {name: 'Valanginian', young: 132.6, old: 137.05, color: '#99D36A'},
3858
+ {name: 'Berriasian', young: 137.05, old: 143.1, color: '#8CCD60'},
3859
+ {name: 'Tithonian', young: 143.1, old: 149.2, color: '#D9F1F7'},
3860
+ {name: 'Kimmeridgian', young: 149.2, old: 154.8, color: '#CCECF4'},
3861
+ {name: 'Oxfordian', young: 154.8, old: 161.5, color: '#BFE7F1'},
3862
+ {name: 'Callovian', young: 161.5, old: 165.3, color: '#BFE7E5'},
3863
+ {name: 'Bathonian', young: 165.3, old: 168.2, color: '#B3E2E3'},
3864
+ {name: 'Bajocian', young: 168.2, old: 170.9, color: '#A6DDE0'},
3865
+ {name: 'Aalenian', young: 170.9, old: 174.7, color: '#9AD9DD'},
3866
+ {name: 'Toarcian', young: 174.7, old: 184.2, color: '#99CEE3'},
3867
+ {name: 'Pliensbachian', young: 184.2, old: 192.9, color: '#80C5DD'},
3868
+ {name: 'Sinemurian', young: 192.9, old: 199.5, color: '#67BCD8'},
3869
+ {name: 'Hettangian', young: 199.5, old: 201.4, color: '#4EB3D3'},
3870
+ {name: 'Rhaetian', young: 201.4, old: 205.7, color: '#E3B9DB'},
3871
+ {name: 'Norian', young: 205.7, old: 227.3, color: '#D6AAD3'},
3872
+ {name: 'Carnian', young: 227.3, old: 237, color: '#C99BCB'},
3873
+ {name: 'Ladinian', young: 237, old: 241.464, color: '#C983BF'},
3874
+ {name: 'Anisian', young: 241.464, old: 246.7, color: '#BC75B7'},
3875
+ {name: 'Olenekian', young: 246.7, old: 249.9, color: '#B051A5'},
3876
+ {name: 'Induan', young: 249.9, old: 251.902, color: '#A4469F'},
3877
+ {name: 'Changhsingian', young: 251.902, old: 254.14, color: '#FCC0B2'},
3878
+ {name: 'Wuchiapingian', young: 254.14, old: 259.51, color: '#FCB4A2'},
3879
+ {name: 'Capitanian', young: 259.51, old: 264.28, color: '#FB9A85'},
3880
+ {name: 'Wordian', young: 264.28, old: 266.9, color: '#FB8D76'},
3881
+ {name: 'Roadian', young: 266.9, old: 274.4, color: '#FB8069'},
3882
+ {name: 'Kungurian', young: 274.4, old: 283.3, color: '#E38776'},
3883
+ {name: 'Artinskian', young: 283.3, old: 290.1, color: '#E37B68'},
3884
+ {name: 'Sakmarian', young: 290.1, old: 293.52, color: '#E36F5C'},
3885
+ {name: 'Asselian', young: 293.52, old: 298.9, color: '#E36350'},
3886
+ {name: 'Gzhelian', young: 298.9, old: 303.7, color: '#CCD4C7'},
3887
+ {name: 'Kasimovian', young: 303.7, old: 307, color: '#BFD0C5'},
3888
+ {name: 'Moscovian', young: 307, old: 315.2, color: '#C7CBB9'},
3889
+ {name: 'Bashkirian', young: 315.2, old: 323.4, color: '#99C2B5'},
3890
+ {name: 'Serpukhovian', young: 323.4, old: 330.3, color: '#BFC26B'},
3891
+ {name: 'Visean', young: 330.3, old: 346.7, color: '#A6B96C'},
3892
+ {name: 'Tournaisian', young: 346.7, old: 358.86, color: '#8CB06C'},
3893
+ {name: 'Famennian', young: 358.86, old: 372.15, color: '#F2EDC5'},
3894
+ {name: 'Frasnian', young: 372.15, old: 382.31, color: '#F2EDAD'},
3895
+ {name: 'Givetian', young: 382.31, old: 387.95, color: '#F1E185'},
3896
+ {name: 'Eifelian', young: 387.95, old: 393.47, color: '#F1D576'},
3897
+ {name: 'Emsian', young: 393.47, old: 410.62, color: '#E5D075'},
3898
+ {name: 'Pragian', young: 410.62, old: 413.02, color: '#E5C468'},
3899
+ {name: 'Lochkovian', young: 413.02, old: 419.62, color: '#E5B75A'},
3900
+ {name: 'Pridoli', young: 419.62, old: 422.7, color: '#E6F5E1'},
3901
+ {name: 'Ludfordian', young: 422.7, old: 425, color: '#D9F0DF'},
3902
+ {name: 'Gorstian', young: 425, old: 426.7, color: '#CCECDD'},
3903
+ {name: 'Homerian', young: 426.7, old: 430.6, color: '#CCEBD1'},
3904
+ {name: 'Sheinwoodian', young: 430.6, old: 432.9, color: '#BFE6C3'},
3905
+ {name: 'Telychian', young: 432.9, old: 438.6, color: '#BFE6CF'},
3906
+ {name: 'Aeronian', young: 438.6, old: 440.5, color: '#B3E1C2'},
3907
+ {name: 'Rhuddanian', young: 440.5, old: 443.1, color: '#A6DCB5'},
3908
+ {name: 'Hirnantian', young: 443.1, old: 445.2, color: '#A6DBAB'},
3909
+ {name: 'Katian', young: 445.2, old: 452.8, color: '#99D69F'},
3910
+ {name: 'Sandbian', young: 452.8, old: 458.2, color: '#8CD094'},
3911
+ {name: 'Darriwilian', young: 458.2, old: 469.4, color: '#74C69C'},
3912
+ {name: 'Dapingian', young: 469.4, old: 471.3, color: '#66C092'},
3913
+ {name: 'Floian', young: 471.3, old: 477.1, color: '#41B087'},
3914
+ {name: 'Tremadocian', young: 477.1, old: 486.85, color: '#33A97E'},
3915
+ {name: 'Stage 10', young: 486.85, old: 491, color: '#E6F5C9'},
3916
+ {name: 'Jiangshanian', young: 491, old: 494.2, color: '#D9F0BB'},
3917
+ {name: 'Paibian', young: 494.2, old: 497, color: '#CCEBAE'},
3918
+ {name: 'Guzhangian', young: 497, old: 500.5, color: '#CCDFAA'},
3919
+ {name: 'Drumian', young: 500.5, old: 504.5, color: '#BFD99D'},
3920
+ {name: 'Wuliuan', young: 504.5, old: 506.5, color: '#B3D492'},
3921
+ {name: 'Stage 4', young: 506.5, old: 514.5, color: '#B3CA8E'},
3922
+ {name: 'Stage 3', young: 514.5, old: 521, color: '#A6C583'},
3923
+ {name: 'Stage 2', young: 521, old: 529, color: '#A6BA80'},
3924
+ {name: 'Fortunian', young: 529, old: 538.8, color: '#99B575'}
3563
3925
  ]
3564
3926
  };
3565
3927
 
@@ -3573,10 +3935,26 @@
3573
3935
  return ivs.length > 0 ? ivs[ivs.length - 1].old : 0;
3574
3936
  };
3575
3937
 
3576
- // The [coarse, fine] rank pair for a tree reaching back oldMa: always the
3577
- // finest pair that still fully covers the range, so a deep tree never
3578
- // shows blank band segments.
3579
- forester.geoBandRanks = function (oldMa) {
3938
+ // The [coarse, fine] rank pair for the span [youngMa, oldMa] a tree
3939
+ // actually occupies -- the youngest tip to the root, not 0 to the root,
3940
+ // so a fossil-only clade bands on its own window rather than on the
3941
+ // whole stretch back from the present.
3942
+ //
3943
+ // It adapts FINER as well as coarser. A narrow Phanerozoic window --
3944
+ // one or two Series -- bands Series over STAGE (the ICS ages), which is
3945
+ // what a 66-100 Ma dinosaur tree needs to say anything at all: over
3946
+ // Period/Epoch it would read "Cretaceous / Late Cretaceous" and nothing
3947
+ // more. Wider than two Series the stages would be slivers, so the
3948
+ // existing ladder takes over: the finest pair that still fully covers the
3949
+ // range, so a deep tree never shows blank band segments. Shared rule with
3950
+ // the desktop's GeologicTimeScale.bandRanks -- change both or neither.
3951
+ forester.geoBandRanks = function (youngMa, oldMa) {
3952
+ if (oldMa <= forester.geoCoverage('age')) { // stages are Phanerozoic-only
3953
+ let series = forester.geoOverlapping('epoch', youngMa, oldMa).length;
3954
+ if (series > 0 && series <= 2) {
3955
+ return ['epoch', 'age'];
3956
+ }
3957
+ }
3580
3958
  if (oldMa <= forester.geoCoverage('epoch')) {
3581
3959
  return ['period', 'epoch'];
3582
3960
  }
@@ -4075,6 +4453,114 @@
4075
4453
  // extractors: {name, taxonomy, sequence}, each a function(externalNode)
4076
4454
  // returning the text that field would contribute to the node's label, or
4077
4455
  // null / '' when it contributes nothing.
4456
+ /**
4457
+ * Reads bare numeric internal node labels as confidence values -- the
4458
+ * dialect nearly every tree writer emits (RAxML, IQ-TREE, FastTree, MEGA,
4459
+ * BV-BRC) writes branch support as `)100:0.05`, which the New Hampshire
4460
+ * grammar makes an internal node NAME.
4461
+ *
4462
+ * The rule is shared with the desktop Java Archaeopteryx; change it only
4463
+ * together with that implementation.
4464
+ *
4465
+ * mode 'auto' (the default) is ALL-OR-NOTHING: every candidate label must
4466
+ * look like support, so a tree of real clade names is never touched.
4467
+ * mode 'confidence' is PER-NODE and unbounded: every numeric label is
4468
+ * promoted whatever its value, which is how a user forces a mixed tree
4469
+ * (some clade names, some support) or an out-of-range scale. mode 'label'
4470
+ * does nothing -- the labels stay names.
4471
+ *
4472
+ * The ROOT is never promoted in any mode: a confidence belongs to the
4473
+ * branch ABOVE a node and the root has none, so a numeric label there is
4474
+ * almost always the tree's name -- `)99;` would otherwise lose it while
4475
+ * `)MyTree;` kept it.
4476
+ *
4477
+ * Promotion MOVES: the label is cleared as the confidence is added, or the
4478
+ * value would be drawn twice.
4479
+ *
4480
+ * Pure apart from the mutation it is asked to make, and returns the number
4481
+ * of labels promoted so the CALLER can react (the viewer switches its
4482
+ * confidence display on, so support values appear rather than names
4483
+ * silently vanishing). Deliberately does not touch display state itself.
4484
+ *
4485
+ * @param phy - the phylogeny
4486
+ * @param mode - 'auto' | 'confidence' | 'label'
4487
+ * @returns {number} how many internal labels became confidences
4488
+ */
4489
+ forester.promoteInternalLabelsToConfidence = function (phy, mode) {
4490
+ if (mode === 'label') {
4491
+ return 0;
4492
+ }
4493
+ if (mode !== 'confidence') {
4494
+ mode = 'auto';
4495
+ }
4496
+ let root = forester.getTreeRoot(phy);
4497
+ let candidates = [];
4498
+ let anyConfidence = false;
4499
+ forester.preOrderTraversalAll(root, function (n) {
4500
+ if (n === root || !(n.children || n._children)) {
4501
+ return;
4502
+ }
4503
+ if (n.confidences && n.confidences.length > 0) {
4504
+ anyConfidence = true;
4505
+ }
4506
+ if (typeof n.name === 'string' && n.name.length > 0) {
4507
+ candidates.push(n);
4508
+ }
4509
+ });
4510
+ if (candidates.length < 1) {
4511
+ return 0;
4512
+ }
4513
+ let numeric = candidates.map(function (n) {
4514
+ let v = Number(n.name);
4515
+ return (n.name.trim().length > 0 && isFinite(v)) ? v : null;
4516
+ });
4517
+ let chosen;
4518
+ if (mode === 'confidence') {
4519
+ // Per node: promote what parses, leave the rest alone.
4520
+ chosen = candidates.filter(function (n, i) {
4521
+ return numeric[i] !== null;
4522
+ });
4523
+ } else {
4524
+ // A file mixing `)[100]:` and `)95:` leaves the bracketed nodes
4525
+ // with no label at all, so one bare numeric label beside real
4526
+ // confidences is corroborated rather than lonely. Without this a
4527
+ // mixed-dialect tree ships some confidences and one stray name.
4528
+ let need = anyConfidence ? 1 : 2;
4529
+ if (candidates.length < need) {
4530
+ return 0;
4531
+ }
4532
+ if (numeric.some(function (v) {
4533
+ // All-or-nothing: one real clade name and the tree is names.
4534
+ // The range covers every scale in use -- 0-1 posteriors,
4535
+ // 0-100 bootstrap, 0-1000 MrBayes -- and excludes identifiers
4536
+ // such as NCBI taxids that would read as "support 9606".
4537
+ return v === null || v < 0 || v > 1000;
4538
+ })) {
4539
+ return 0;
4540
+ }
4541
+ // Clade numbering is overwhelmingly 1..N, each exactly once. A
4542
+ // real support tree containing every integer 1..N once and nothing
4543
+ // else is essentially impossible at n >= 3, and without this guard
4544
+ // a clade-numbered tree silently becomes "1%, 2%, 3% support".
4545
+ let distinct = new Set(numeric);
4546
+ let min = Math.min.apply(null, numeric);
4547
+ let max = Math.max.apply(null, numeric);
4548
+ if (distinct.size === numeric.length && min === 1 && max === numeric.length) {
4549
+ return 0;
4550
+ }
4551
+ chosen = candidates;
4552
+ }
4553
+ chosen.forEach(function (n) {
4554
+ let v = Number(n.name);
4555
+ // Type deliberately left unset: the value cannot distinguish
4556
+ // bootstrap from posterior from aLRT from TBE, and a wrong type
4557
+ // printed beside a number in a published figure is worse than none.
4558
+ pushConfidence(n, v, '');
4559
+ n.name = '';
4560
+ });
4561
+ return chosen.length;
4562
+ };
4563
+
4078
4564
  forester.suggestLabelFields = function (root, extractors) {
4079
4565
  const FIELDS = ['name', 'taxonomy', 'sequence'];
4080
4566
  const CONTAINMENT_MIN = 0.9;