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.
- package/README.md +116 -21
- package/archaeopteryx.js +1652 -579
- package/forester.js +572 -86
- package/package.json +1 -1
package/forester.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
24
|
-
// 2026-09-
|
|
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
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
|
1179
|
-
while (
|
|
1180
|
-
++
|
|
1232
|
+
let j = 0;
|
|
1233
|
+
while (j < max && a.charCodeAt(j) === b.charCodeAt(j)) {
|
|
1234
|
+
++j;
|
|
1181
1235
|
}
|
|
1182
|
-
if (
|
|
1183
|
-
|
|
1236
|
+
if (j > bestLen) {
|
|
1237
|
+
bestLen = j;
|
|
1238
|
+
bestIdx = i;
|
|
1184
1239
|
}
|
|
1185
1240
|
}
|
|
1186
|
-
if (
|
|
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
|
-
&&
|
|
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
|
-
|
|
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
|
-
|
|
1920
|
-
|
|
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
|
-
|
|
1946
|
-
if (
|
|
1947
|
-
|
|
2096
|
+
let dq = doubledQuoteEnd(i, '"');
|
|
2097
|
+
if (dq > -1) {
|
|
2098
|
+
buffer += '"';
|
|
2099
|
+
i = dq;
|
|
1948
2100
|
} else {
|
|
1949
|
-
|
|
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
|
-
|
|
1958
|
-
if (
|
|
1959
|
-
|
|
2114
|
+
let dq = doubledQuoteEnd(i, "'");
|
|
2115
|
+
if (dq > -1) {
|
|
2116
|
+
buffer += "'";
|
|
2117
|
+
i = dq;
|
|
1960
2118
|
} else {
|
|
1961
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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]
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
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
|
|
2982
|
-
//
|
|
2983
|
-
|
|
2984
|
-
|
|
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
|
|
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
|
-
|
|
3074
|
-
|
|
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
|
|
3577
|
-
//
|
|
3578
|
-
//
|
|
3579
|
-
|
|
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;
|