archaeopteryx 3.7.0 → 3.9.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 +67 -7
- package/archaeopteryx.d.ts +6 -1
- package/archaeopteryx.js +336 -31
- package/forester.js +1225 -80
- package/package.json +2 -2
package/forester.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
24
|
-
// 2026-09-
|
|
23
|
+
// v 3.9.0
|
|
24
|
+
// 2026-09-17
|
|
25
25
|
//
|
|
26
26
|
// forester.js is a general suite for dealing with phylogenetic trees.
|
|
27
27
|
//
|
|
@@ -3077,6 +3077,57 @@
|
|
|
3077
3077
|
// starting with '&' (a [95] confidence) is left untouched, as is any
|
|
3078
3078
|
// bracket inside a quoted label. Quotes and nested brackets inside an
|
|
3079
3079
|
// annotation are honoured when finding its end.
|
|
3080
|
+
//
|
|
3081
|
+
// A QUOTE CHARACTER INSIDE A BLOB IS DATA unless it opens a quoted VALUE.
|
|
3082
|
+
// Auspice writes values bare -- country=Côte d'Ivoire -- and treating that
|
|
3083
|
+
// apostrophe as the start of a quoted string was a real bug, in both of
|
|
3084
|
+
// its forms: one such tip and the quote never closed, so the file was
|
|
3085
|
+
// refused over its "unbalanced parentheses"; two and the apostrophes
|
|
3086
|
+
// paired up ACROSS the tips, no error at all, the second tip gone and the
|
|
3087
|
+
// first one's country reading "Côte d'Ivoire],B:1[&country=Côte d'Ivoire".
|
|
3088
|
+
// (Real file: nextstrain_chikv_global_timetree.nexus, 16 apostrophes, all
|
|
3089
|
+
// of them that one country.) Matches the desktop's scanner rule.
|
|
3090
|
+
//
|
|
3091
|
+
// So a quote opens a run only where a value can START -- straight after
|
|
3092
|
+
// '=', or after '{', '[' or ',' inside a set -- and only if it is closed
|
|
3093
|
+
// by the same character standing where a value can END: before ',', '}',
|
|
3094
|
+
// ']' or the end. Anything else is a character like any other.
|
|
3095
|
+
function opensBlobQuote(s, p, from) {
|
|
3096
|
+
let m = p - 1;
|
|
3097
|
+
while (m >= from && /\s/.test(s.charAt(m))) {
|
|
3098
|
+
--m;
|
|
3099
|
+
}
|
|
3100
|
+
return m >= from && '={[,'.indexOf(s.charAt(m)) >= 0;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// The index of the quote closing the run opened at p, or -1. `bounded` is
|
|
3104
|
+
// for the extraction pass, which does not yet know where the blob ends:
|
|
3105
|
+
// there the search gives up at a ']' that is followed by Newick structure,
|
|
3106
|
+
// so a bare value that merely BEGINS with an apostrophe ('s-Hertogenbosch)
|
|
3107
|
+
// cannot reach into the next node's blob for its partner.
|
|
3108
|
+
function blobQuoteClose(s, p, bounded) {
|
|
3109
|
+
let q = s.charAt(p);
|
|
3110
|
+
for (let k = p + 1; k < s.length; ++k) {
|
|
3111
|
+
let c = s.charAt(k);
|
|
3112
|
+
if (c !== q && !(bounded && c === ']')) {
|
|
3113
|
+
continue;
|
|
3114
|
+
}
|
|
3115
|
+
let m = k + 1;
|
|
3116
|
+
while (m < s.length && /\s/.test(s.charAt(m))) {
|
|
3117
|
+
++m;
|
|
3118
|
+
}
|
|
3119
|
+
let next = m < s.length ? s.charAt(m) : '';
|
|
3120
|
+
if (c === q) {
|
|
3121
|
+
if (next === '' || ',}]'.indexOf(next) >= 0) {
|
|
3122
|
+
return k;
|
|
3123
|
+
}
|
|
3124
|
+
} else if (next === '' || ',):;(['.indexOf(next) >= 0) {
|
|
3125
|
+
return -1;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
return -1;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3080
3131
|
function extractBracketAnnotations(str) {
|
|
3081
3132
|
if (str.indexOf('[') < 0) {
|
|
3082
3133
|
return {text: str, blobs: []};
|
|
@@ -3102,16 +3153,16 @@
|
|
|
3102
3153
|
} else if (c === '[') {
|
|
3103
3154
|
let j = i + 1;
|
|
3104
3155
|
let depth = 1;
|
|
3105
|
-
let q = null;
|
|
3106
3156
|
while (j < str.length && depth > 0) {
|
|
3107
3157
|
let cj = str.charAt(j);
|
|
3108
|
-
if (
|
|
3109
|
-
|
|
3110
|
-
|
|
3158
|
+
if ((cj === "'" || cj === '"') && opensBlobQuote(str, j, i + 1)) {
|
|
3159
|
+
let close = blobQuoteClose(str, j, true);
|
|
3160
|
+
if (close > -1) {
|
|
3161
|
+
j = close + 1; // a quoted value: its brackets are data
|
|
3162
|
+
continue;
|
|
3111
3163
|
}
|
|
3112
|
-
}
|
|
3113
|
-
|
|
3114
|
-
} else if (cj === '[') {
|
|
3164
|
+
}
|
|
3165
|
+
if (cj === '[') {
|
|
3115
3166
|
++depth;
|
|
3116
3167
|
} else if (cj === ']') {
|
|
3117
3168
|
--depth;
|
|
@@ -3161,21 +3212,37 @@
|
|
|
3161
3212
|
}
|
|
3162
3213
|
|
|
3163
3214
|
// Split on TOP-LEVEL commas only: a comma inside {...}/[...] sets or
|
|
3164
|
-
// inside
|
|
3165
|
-
// stay one token).
|
|
3166
|
-
|
|
3215
|
+
// inside a quoted VALUE is data, not a separator (height_95%_HPD={1.4,1.5}
|
|
3216
|
+
// must stay one token). A quote that does not open a value is itself data
|
|
3217
|
+
// (opensBlobQuote): country=Côte d'Ivoire,region=Africa is two fields.
|
|
3218
|
+
//
|
|
3219
|
+
// TWO quoting rules live here, because two grammars do. In a Nexus
|
|
3220
|
+
// TRANSLATE table the things between the commas are LABELS, and a quote
|
|
3221
|
+
// opens one wherever it stands (1 'Korea, Republic of'). In a [&...] blob
|
|
3222
|
+
// they are key=value fields, and a quote opens only a VALUE. Giving the
|
|
3223
|
+
// table the blob's rule split 'Korea, Republic of' in two, which a test
|
|
3224
|
+
// caught the moment it was tried; `blob` says which grammar this is.
|
|
3225
|
+
function splitTopLevelCommas(s, blob) {
|
|
3167
3226
|
let out = [];
|
|
3168
3227
|
let depth = 0;
|
|
3169
3228
|
let q = null;
|
|
3170
3229
|
let cur = '';
|
|
3171
3230
|
for (let i = 0; i < s.length; ++i) {
|
|
3172
3231
|
let c = s.charAt(i);
|
|
3232
|
+
if (blob && (c === "'" || c === '"') && opensBlobQuote(s, i, 0)) {
|
|
3233
|
+
let close = blobQuoteClose(s, i, false);
|
|
3234
|
+
if (close > -1) {
|
|
3235
|
+
cur += s.substring(i, close + 1); // a quoted value, its commas data
|
|
3236
|
+
i = close;
|
|
3237
|
+
continue;
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3173
3240
|
if (q) {
|
|
3174
3241
|
if (c === q) {
|
|
3175
3242
|
q = null;
|
|
3176
3243
|
}
|
|
3177
3244
|
cur += c;
|
|
3178
|
-
} else if (c === "'" || c === '"') {
|
|
3245
|
+
} else if (!blob && (c === "'" || c === '"')) {
|
|
3179
3246
|
q = c;
|
|
3180
3247
|
cur += c;
|
|
3181
3248
|
} else if (c === '{' || c === '[') {
|
|
@@ -3199,9 +3266,44 @@
|
|
|
3199
3266
|
return out;
|
|
3200
3267
|
}
|
|
3201
3268
|
|
|
3269
|
+
// A NUMBER is a plain decimal with an optional exponent, and nothing else
|
|
3270
|
+
// -- the desktop's grammar (Christian, 2026-09-16). The test used to be
|
|
3271
|
+
// "parseFloat is finite AND Number is finite", and the two read different
|
|
3272
|
+
// languages: Number() understands 0x1A, 0b101 and 0o17, parseFloat() stops
|
|
3273
|
+
// at the letter and answers 0. So a trait that merely LOOKED like a hex
|
|
3274
|
+
// literal was typed numeric, and a height written that way dated its node
|
|
3275
|
+
// at 0 -- a wrong answer where a refusal was due. One pattern now decides,
|
|
3276
|
+
// for a value read as a number and for a property's datatype alike.
|
|
3277
|
+
const PLAIN_DECIMAL_RE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
|
3278
|
+
|
|
3202
3279
|
function parseBeastNumber(v) {
|
|
3203
|
-
let
|
|
3204
|
-
|
|
3280
|
+
let t = String(v).trim();
|
|
3281
|
+
if (!PLAIN_DECIMAL_RE.test(t)) {
|
|
3282
|
+
return null;
|
|
3283
|
+
}
|
|
3284
|
+
let d = parseFloat(t);
|
|
3285
|
+
return isFinite(d) ? d : null; // 1e400 is well-formed and still not a number we can use
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
// FigTree's !color value: #rrggbb, or Java's SIGNED Color.getRGB() int,
|
|
3289
|
+
// which FigTree writes whenever the colour came from AWT (real files: every
|
|
3290
|
+
// tag in test_trees/influenza.tree is #-8381639, never hex). Each '>>>'
|
|
3291
|
+
// coerces to an unsigned 32-bit value first, so the low three bytes come
|
|
3292
|
+
// out as RGB regardless of sign; the alpha byte is discarded. Null when it
|
|
3293
|
+
// is neither.
|
|
3294
|
+
function parseFigTreeColor(value) {
|
|
3295
|
+
if (/^#[0-9a-f]{6}$/i.test(value)) {
|
|
3296
|
+
return {
|
|
3297
|
+
red: parseInt(value.substring(1, 3), 16),
|
|
3298
|
+
green: parseInt(value.substring(3, 5), 16),
|
|
3299
|
+
blue: parseInt(value.substring(5, 7), 16)
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
if (/^#-?[0-9]{1,10}$/.test(value)) {
|
|
3303
|
+
let argb = Number(value.substring(1));
|
|
3304
|
+
return {red: (argb >>> 16) & 0xff, green: (argb >>> 8) & 0xff, blue: argb & 0xff};
|
|
3305
|
+
}
|
|
3306
|
+
return null;
|
|
3205
3307
|
}
|
|
3206
3308
|
|
|
3207
3309
|
// A two-value BEAST set {lo,hi} (or [lo,hi]) as [lo,hi] numbers, or null.
|
|
@@ -3210,7 +3312,7 @@
|
|
|
3210
3312
|
if (s.length < 3 || (s.charAt(0) !== '{' && s.charAt(0) !== '[')) {
|
|
3211
3313
|
return null;
|
|
3212
3314
|
}
|
|
3213
|
-
let parts = splitTopLevelCommas(s.substring(1, s.length - 1));
|
|
3315
|
+
let parts = splitTopLevelCommas(s.substring(1, s.length - 1), true);
|
|
3214
3316
|
if (parts.length !== 2) {
|
|
3215
3317
|
return null;
|
|
3216
3318
|
}
|
|
@@ -3272,6 +3374,15 @@
|
|
|
3272
3374
|
// - node age height/height_mean/height_median + height_95%_HPD (or
|
|
3273
3375
|
// height_range) + date -> node.date value/min/max/desc (the node-age
|
|
3274
3376
|
// HPD bars draw the interval);
|
|
3377
|
+
// - Auspice's "download Nexus" vocabulary lands exactly where
|
|
3378
|
+
// parseAuspiceJson puts the same dataset, so one Nextstrain build opens
|
|
3379
|
+
// the same way whichever format it was saved in: num_date -> the date
|
|
3380
|
+
// VALUE with unit "year", plus a nextstrain:num_date property;
|
|
3381
|
+
// num_date_CI={lo,hi} -> that date's minimum/maximum, on a tip too
|
|
3382
|
+
// (there it is the sampling-date uncertainty); div -> a
|
|
3383
|
+
// nextstrain:div property. A num_date outranks every height* (it is a
|
|
3384
|
+
// calendar year, a height is an age before present), it alone carries
|
|
3385
|
+
// the unit, and it never borrows the height's HPD as its interval;
|
|
3275
3386
|
// - FigTree !color=#rrggbb -> the branch color;
|
|
3276
3387
|
// - every other field (rate, length_*, traits, location, ...) -> a
|
|
3277
3388
|
// beast:<key> node property (numeric -> xsd:decimal, so Color-by
|
|
@@ -3285,9 +3396,14 @@
|
|
|
3285
3396
|
let hpd = null;
|
|
3286
3397
|
let range = null;
|
|
3287
3398
|
let dateDesc = null;
|
|
3399
|
+
let hpdText = null;
|
|
3400
|
+
let rangeText = null;
|
|
3401
|
+
let numDate = null;
|
|
3402
|
+
let numDateCi = null;
|
|
3403
|
+
let numDateCiKey = null;
|
|
3288
3404
|
let prob = null;
|
|
3289
3405
|
let probSd = null;
|
|
3290
|
-
splitTopLevelCommas(blob).forEach(function (token) {
|
|
3406
|
+
splitTopLevelCommas(blob, true).forEach(function (token) {
|
|
3291
3407
|
let eq = token.indexOf('=');
|
|
3292
3408
|
if (eq <= 0) {
|
|
3293
3409
|
return;
|
|
@@ -3312,13 +3428,8 @@
|
|
|
3312
3428
|
if (b !== null) {
|
|
3313
3429
|
pushConfidence(node, b, 'bootstrap');
|
|
3314
3430
|
}
|
|
3315
|
-
} else if ((kl === '!color' || kl === '!colour')
|
|
3316
|
-
|
|
3317
|
-
node.color = {
|
|
3318
|
-
red: parseInt(value.substring(1, 3), 16),
|
|
3319
|
-
green: parseInt(value.substring(3, 5), 16),
|
|
3320
|
-
blue: parseInt(value.substring(5, 7), 16)
|
|
3321
|
-
};
|
|
3431
|
+
} else if ((kl === '!color' || kl === '!colour') && parseFigTreeColor(value) !== null) {
|
|
3432
|
+
node.color = parseFigTreeColor(value); // in the tree string it is the BRANCH colour
|
|
3322
3433
|
} else if (kl === 'height_median') {
|
|
3323
3434
|
heightMedian = value;
|
|
3324
3435
|
} else if (kl === 'height_mean') {
|
|
@@ -3327,10 +3438,37 @@
|
|
|
3327
3438
|
height = value;
|
|
3328
3439
|
} else if (kl === 'height_95%_hpd') {
|
|
3329
3440
|
hpd = parseBeastInterval(value);
|
|
3441
|
+
hpdText = value;
|
|
3330
3442
|
} else if (kl === 'height_range') {
|
|
3331
3443
|
range = parseBeastInterval(value);
|
|
3444
|
+
rangeText = value;
|
|
3332
3445
|
} else if (kl === 'date') {
|
|
3333
3446
|
dateDesc = value;
|
|
3447
|
+
} else if (kl === 'num_date') {
|
|
3448
|
+
numDate = value;
|
|
3449
|
+
} else if (kl === 'num_date_ci') {
|
|
3450
|
+
numDateCi = value;
|
|
3451
|
+
numDateCiKey = beastRefKey(key);
|
|
3452
|
+
} else if (kl === 'div' && parseBeastNumber(value) !== null) {
|
|
3453
|
+
addNodeProperty(node, NEXTSTRAIN_PREFIX + 'div', value);
|
|
3454
|
+
} else if (key.charAt(0) === '!') {
|
|
3455
|
+
// A key that starts with '!' is one of FigTree's display
|
|
3456
|
+
// DIRECTIVES -- !color, !rotate, !collapse, !hilight, !name --
|
|
3457
|
+
// never a measurement, so it is never typed numeric. It
|
|
3458
|
+
// matters for the forms we refuse as a colour: !color=-8381639
|
|
3459
|
+
// (no '#') used to land as beast:_color typed xsd:decimal, and
|
|
3460
|
+
// Color-by offered FigTree's paint as a gradient. It is still
|
|
3461
|
+
// KEPT -- nothing in this reader throws data away -- under the
|
|
3462
|
+
// same ref, as text. A user's own trait called "color" has no
|
|
3463
|
+
// '!' and stays an ordinary trait. (The desktop's rule;
|
|
3464
|
+
// Christian, 2026-09-17: "do the same".)
|
|
3465
|
+
addNodeProperty(node, 'beast:' + beastRefKey(key), value, 'xsd:string');
|
|
3466
|
+
} else if (kl === 'mutations' || kl === 'mcc') {
|
|
3467
|
+
// TEXT, whatever it looks like (the desktop forces the same):
|
|
3468
|
+
// a list of mutations that happens to hold one number, or a
|
|
3469
|
+
// clade label that happens to be "3", is not a measurement,
|
|
3470
|
+
// and typing it decimal offers it to Color-by as a gradient
|
|
3471
|
+
addNodeProperty(node, 'beast:' + beastRefKey(key), value, 'xsd:string');
|
|
3334
3472
|
} else {
|
|
3335
3473
|
addNodeProperty(node, 'beast:' + beastRefKey(key), value);
|
|
3336
3474
|
}
|
|
@@ -3338,28 +3476,60 @@
|
|
|
3338
3476
|
if (prob !== null) {
|
|
3339
3477
|
pushConfidence(node, prob, 'posterior probability', probSd);
|
|
3340
3478
|
}
|
|
3341
|
-
//
|
|
3342
|
-
//
|
|
3343
|
-
//
|
|
3344
|
-
let
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
if (dv === null && !interval && dateDesc === null) {
|
|
3349
|
-
return;
|
|
3479
|
+
// A num_date that parses is the node's date, and nothing about a
|
|
3480
|
+
// height may touch it. One that does not parse is just a field: it and
|
|
3481
|
+
// its interval fall back to plain text, as any unknown key does.
|
|
3482
|
+
let year = (numDate !== null) ? parseBeastNumber(numDate) : null;
|
|
3483
|
+
let yearCi = (numDateCi !== null) ? parseBeastInterval(numDateCi) : null;
|
|
3484
|
+
if (numDate !== null) {
|
|
3485
|
+
addNodeProperty(node, (year !== null ? NEXTSTRAIN_PREFIX : 'beast:') + 'num_date', numDate);
|
|
3350
3486
|
}
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3487
|
+
if (numDateCi !== null && (year === null || yearCi === null)) {
|
|
3488
|
+
// an interval with no date to bracket, or not an interval at all
|
|
3489
|
+
addNodeProperty(node, (yearCi !== null ? NEXTSTRAIN_PREFIX : 'beast:') + numDateCiKey, numDateCi);
|
|
3354
3490
|
}
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
date.
|
|
3491
|
+
let date = {};
|
|
3492
|
+
if (year !== null) {
|
|
3493
|
+
date.value = year;
|
|
3494
|
+
date.unit = 'year';
|
|
3495
|
+
if (yearCi !== null) {
|
|
3496
|
+
date.minimum = yearCi[0];
|
|
3497
|
+
date.maximum = yearCi[1];
|
|
3498
|
+
}
|
|
3499
|
+
// provisional until the whole tree has been read: settleNumDates
|
|
3500
|
+
// decides whether this tree is time-scaled at all
|
|
3501
|
+
node._numDate = {ciKey: numDateCiKey, ciText: yearCi !== null ? numDateCi : null};
|
|
3502
|
+
// the heights it outranked are kept as what they were written as,
|
|
3503
|
+
// rather than dropped: no real file carries both, so nothing here
|
|
3504
|
+
// is lost to a guess
|
|
3505
|
+
[['height_median', heightMedian], ['height_mean', heightMean], ['height', height],
|
|
3506
|
+
['height_95_HPD', hpdText], ['height_range', rangeText]].forEach(function (h) {
|
|
3507
|
+
if (h[1] !== null) {
|
|
3508
|
+
addNodeProperty(node, 'beast:' + h[0], h[1]);
|
|
3509
|
+
}
|
|
3510
|
+
});
|
|
3511
|
+
} else {
|
|
3512
|
+
// age preference: median, then mean, then height -- and each piece
|
|
3513
|
+
// parsed independently, so an unparseable point value never
|
|
3514
|
+
// discards a valid {lo,hi} interval
|
|
3515
|
+
let v = heightMedian !== null ? heightMedian
|
|
3516
|
+
: (heightMean !== null ? heightMean : height);
|
|
3517
|
+
let dv = (v !== null) ? parseBeastNumber(v) : null;
|
|
3518
|
+
let interval = hpd || range;
|
|
3519
|
+
if (dv !== null) {
|
|
3520
|
+
date.value = dv;
|
|
3521
|
+
}
|
|
3522
|
+
if (interval) {
|
|
3523
|
+
date.minimum = interval[0];
|
|
3524
|
+
date.maximum = interval[1];
|
|
3525
|
+
}
|
|
3358
3526
|
}
|
|
3359
3527
|
if (dateDesc !== null) {
|
|
3360
3528
|
date.desc = dateDesc;
|
|
3361
3529
|
}
|
|
3362
|
-
|
|
3530
|
+
if (Object.keys(date).length > 0) {
|
|
3531
|
+
node.date = date;
|
|
3532
|
+
}
|
|
3363
3533
|
}
|
|
3364
3534
|
|
|
3365
3535
|
// The classic NHX tag set, as the desktop maps it: S= taxonomy
|
|
@@ -3367,8 +3537,8 @@
|
|
|
3367
3537
|
// duplication (Y/T) / speciation (N/F) / undecided (?) event, GN=
|
|
3368
3538
|
// sequence name, AC= sequence accession, C= an nh:comment property.
|
|
3369
3539
|
// Unknown tags (and DS= domain structures) are ignored.
|
|
3370
|
-
function applyNhxTags(node,
|
|
3371
|
-
|
|
3540
|
+
function applyNhxTags(node, fields) {
|
|
3541
|
+
fields.forEach(function (tag) {
|
|
3372
3542
|
let t = tag.trim();
|
|
3373
3543
|
if (t.length < 3) {
|
|
3374
3544
|
return;
|
|
@@ -3401,14 +3571,327 @@
|
|
|
3401
3571
|
});
|
|
3402
3572
|
}
|
|
3403
3573
|
|
|
3574
|
+
// Inside a legacy [&&NHX:...] tag the desktop reads by its LABEL rule, and
|
|
3575
|
+
// so do we (Christian, 2026-09-16: "do what desktop does"):
|
|
3576
|
+
// - UNQUOTED whitespace is formatting noise and is squeezed out -- its
|
|
3577
|
+
// Test.testNHXParsingQuotes pins "[\t&\t&\n N\tH\tX:S=mo\tnkey !]" as
|
|
3578
|
+
// S=monkey!, and S=Homo sapiens reads as Homosapiens;
|
|
3579
|
+
// - a QUOTED run, either style, keeps what is inside it -- S="homo sapiens"
|
|
3580
|
+
// is homo sapiens, the one way to put a two-word species into an NHX tag
|
|
3581
|
+
// -- with a run of whitespace collapsed to one space, and may carry the
|
|
3582
|
+
// ':' that would otherwise end the tag (S="a:b c":D=Y);
|
|
3583
|
+
// - the quote characters themselves are never part of the value.
|
|
3584
|
+
// My first version squeezed quotes and ALL whitespace out of the blob, from
|
|
3585
|
+
// an inference off that one pinned case; the desktop then MEASURED its own
|
|
3586
|
+
// behaviour and the quoted forms differed (homosapiens here, homo sapiens
|
|
3587
|
+
// there). The opposite of a single-& blob, where quotes and spaces are
|
|
3588
|
+
// data -- so none of this runs until the blob has shown itself to be NHX.
|
|
3589
|
+
function nhxFields(blob) {
|
|
3590
|
+
let out = [];
|
|
3591
|
+
let cur = '';
|
|
3592
|
+
let q = null;
|
|
3593
|
+
let spaced = false;
|
|
3594
|
+
for (let i = 0; i < blob.length; ++i) {
|
|
3595
|
+
let c = blob.charAt(i);
|
|
3596
|
+
if (q) {
|
|
3597
|
+
if (c === q) {
|
|
3598
|
+
q = null;
|
|
3599
|
+
} else if (/\s/.test(c)) {
|
|
3600
|
+
if (!spaced) {
|
|
3601
|
+
cur += ' ';
|
|
3602
|
+
spaced = true;
|
|
3603
|
+
}
|
|
3604
|
+
} else {
|
|
3605
|
+
cur += c;
|
|
3606
|
+
spaced = false;
|
|
3607
|
+
}
|
|
3608
|
+
} else if (c === "'" || c === '"') {
|
|
3609
|
+
q = c;
|
|
3610
|
+
spaced = false;
|
|
3611
|
+
} else if (c === ':') {
|
|
3612
|
+
out.push(cur);
|
|
3613
|
+
cur = '';
|
|
3614
|
+
} else if (!/\s/.test(c)) {
|
|
3615
|
+
cur += c;
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
out.push(cur);
|
|
3619
|
+
return out;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3404
3622
|
function applyExtendedAnnotations(node, blob) {
|
|
3405
|
-
|
|
3406
|
-
|
|
3623
|
+
let fields = nhxFields(blob);
|
|
3624
|
+
if (/^&&NHX$/i.test(fields[0])) {
|
|
3625
|
+
applyNhxTags(node, fields.slice(1));
|
|
3407
3626
|
} else {
|
|
3408
3627
|
applyBeastAnnotations(node, blob.replace(/^&/, ''));
|
|
3409
3628
|
}
|
|
3410
3629
|
}
|
|
3411
3630
|
|
|
3631
|
+
// ---------------------------------------------------------------
|
|
3632
|
+
// A bare numeric date= as a node date VALUE (TreeTime)
|
|
3633
|
+
// ---------------------------------------------------------------
|
|
3634
|
+
//
|
|
3635
|
+
// applyBeastAnnotations files date= as a date DESC and nothing else, which
|
|
3636
|
+
// is what the desktop's BeastAnnotationParser does and stays that way --
|
|
3637
|
+
// in BEAST output the age lives in height*, and date= is a decoration.
|
|
3638
|
+
//
|
|
3639
|
+
// TreeTime has no height at all: it writes "[&mutations=...,date=2003.84]"
|
|
3640
|
+
// and the decimal year IS the node's position in time. Left as a desc the
|
|
3641
|
+
// tree carries no date value, so isTimeTree is false and the calendar axis
|
|
3642
|
+
// never appears -- a time tree that does not look like one.
|
|
3643
|
+
//
|
|
3644
|
+
// The catch is that TreeTime writes that SAME comment on both trees it
|
|
3645
|
+
// emits: timetree.nexus, whose branch lengths are years, and
|
|
3646
|
+
// divergence_tree.nexus, whose branch lengths are substitutions. No single
|
|
3647
|
+
// annotation says which file it came from, and promoting blindly would put
|
|
3648
|
+
// a calendar axis (which maps one branch-length unit to one year) on a
|
|
3649
|
+
// divergence tree and silently disable re-rooting for it.
|
|
3650
|
+
//
|
|
3651
|
+
// So the TREE is asked rather than the annotation sniffed: a numeric date
|
|
3652
|
+
// becomes a value only where the parent-to-child date differences actually
|
|
3653
|
+
// reproduce the branch lengths. That needs no format detection, it is
|
|
3654
|
+
// self-validating on any input, and it separates TreeTime's two files
|
|
3655
|
+
// exactly. The desc is left in place either way, so nothing is lost.
|
|
3656
|
+
const NUMERIC_DATE_ABS_TOL = 0.02; // date= is written to 2 decimals, so a
|
|
3657
|
+
const NUMERIC_DATE_REL_TOL = 0.01; // difference of two carries ~0.01 error
|
|
3658
|
+
|
|
3659
|
+
function numericDateDesc(n) {
|
|
3660
|
+
if (!n.date || n.date.value !== undefined || typeof n.date.desc !== 'string') {
|
|
3661
|
+
return null;
|
|
3662
|
+
}
|
|
3663
|
+
return parseBeastNumber(n.date.desc);
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
// THE PAIR COUNT, shared by the date= promotion below and by
|
|
3667
|
+
// settleNumDates, so the two can never drift: a PAIR is a dated node, its
|
|
3668
|
+
// dated DIRECT parent and a branch length between them, and it AGREES
|
|
3669
|
+
// when the year difference reproduces that length within the tolerances.
|
|
3670
|
+
//
|
|
3671
|
+
// A pair must also be INFORMATIVE: max(|dYear|, |length|) > the absolute
|
|
3672
|
+
// tolerance. One that is not cannot tell years from substitutions at all
|
|
3673
|
+
// -- both numbers sit inside the tolerance, so it "agrees" whatever the
|
|
3674
|
+
// tree is measured in -- and it is left out of BOTH counts. That is not a
|
|
3675
|
+
// nicety. The 0.02 tolerance is larger than a dense tree's substitution
|
|
3676
|
+
// lengths, so on a densely sampled DIVERGENCE tree such pairs pile up as
|
|
3677
|
+
// agreement. Measured by rebuilding real Auspice time-tree exports as
|
|
3678
|
+
// their divergence trees (length = the div difference, same num_dates):
|
|
3679
|
+
// agreeing, every pair informative pairs only
|
|
3680
|
+
// H5N1 (2 y) 3620 of 9205 (39.3%) 1 of 5586
|
|
3681
|
+
// chikungunya 672 of 2645 (25.4%) 0 of 1973
|
|
3682
|
+
// measles 915 of 5388 (17.0%) 0 of 4473
|
|
3683
|
+
// while every time tree stays at 100% either way (H5N1 5586 of 5586).
|
|
3684
|
+
// 39% is under the majority, so nothing visible changed on those files;
|
|
3685
|
+
// it is headroom -- a denser build would have crossed it and been dated
|
|
3686
|
+
// on substitutions. Found by a review on the desktop, reproduced here to
|
|
3687
|
+
// the pair, and a JOINT RULE (Christian, 2026-09-17, both sessions). The
|
|
3688
|
+
// burden of proof is untouched: with no informative pair at all a date=
|
|
3689
|
+
// is still not promoted and a num_date still stands.
|
|
3690
|
+
function countDatePairs(root, yearOf) {
|
|
3691
|
+
let dated = [];
|
|
3692
|
+
let agree = 0;
|
|
3693
|
+
let pairs = 0;
|
|
3694
|
+
let stack = [[root, null]];
|
|
3695
|
+
while (stack.length > 0) {
|
|
3696
|
+
let top = stack.pop();
|
|
3697
|
+
let n = top[0];
|
|
3698
|
+
let year = yearOf(n);
|
|
3699
|
+
if (year !== null) {
|
|
3700
|
+
dated.push([n, year]);
|
|
3701
|
+
let parentYear = top[1];
|
|
3702
|
+
if (parentYear !== null && typeof n.branch_length === 'number'
|
|
3703
|
+
&& isFinite(n.branch_length)) {
|
|
3704
|
+
let dYear = year - parentYear;
|
|
3705
|
+
if (Math.max(Math.abs(dYear), Math.abs(n.branch_length)) > NUMERIC_DATE_ABS_TOL) {
|
|
3706
|
+
++pairs;
|
|
3707
|
+
let tol = NUMERIC_DATE_ABS_TOL
|
|
3708
|
+
+ NUMERIC_DATE_REL_TOL * Math.abs(n.branch_length);
|
|
3709
|
+
if (Math.abs(dYear - n.branch_length) <= tol) {
|
|
3710
|
+
++agree;
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
if (n.children) {
|
|
3716
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
3717
|
+
stack.push([n.children[i], year]);
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
return {dated: dated, pairs: pairs, agree: agree};
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
function promoteTimeScaledDates(phy) {
|
|
3725
|
+
let root = forester.getTreeRoot(phy);
|
|
3726
|
+
if (!root) {
|
|
3727
|
+
return;
|
|
3728
|
+
}
|
|
3729
|
+
let counted = countDatePairs(root, numericDateDesc);
|
|
3730
|
+
let dated = counted.dated;
|
|
3731
|
+
let pairs = counted.pairs;
|
|
3732
|
+
let agree = counted.agree;
|
|
3733
|
+
if (pairs < 2 || agree * 2 <= pairs) {
|
|
3734
|
+
return;
|
|
3735
|
+
}
|
|
3736
|
+
dated.forEach(function (d) {
|
|
3737
|
+
d[0].date.value = d[1];
|
|
3738
|
+
d[0].date.unit = 'year';
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
// ---------------------------------------------------------------
|
|
3743
|
+
// An Auspice num_date is a date VALUE only on a time-scaled tree
|
|
3744
|
+
// ---------------------------------------------------------------
|
|
3745
|
+
//
|
|
3746
|
+
// Auspice's "download Nexus" offers the SAME annotations on two trees: the
|
|
3747
|
+
// time tree (…_timetree.nexus, branch lengths in years) and the divergence
|
|
3748
|
+
// tree (…_tree.nexus, branch lengths in substitutions). A date value is
|
|
3749
|
+
// what makes a tree a time tree here -- isTimeTree counts them -- and the
|
|
3750
|
+
// calendar axis maps one branch-length unit to one year, so dating the
|
|
3751
|
+
// divergence export would hang that axis on a tree measured in
|
|
3752
|
+
// substitutions and refuse its re-rooting. Measured on real exports, the
|
|
3753
|
+
// parent-to-child num_date differences reproduce the branch lengths on
|
|
3754
|
+
// measles timetree 5388 of 5388 pairs
|
|
3755
|
+
// chikv timetree 2645 of 2645 pairs
|
|
3756
|
+
// lassa_gpc tree 169 of 2295 pairs (7.4%)
|
|
3757
|
+
// so this is the question promoteTimeScaledDates already asks of a
|
|
3758
|
+
// TreeTime date=, with the same pinned tolerances, put to the tree rather
|
|
3759
|
+
// than guessed from a file name. The difference is the burden of proof: a
|
|
3760
|
+
// date= may not be a value at all, so it needs evidence FOR; a num_date is
|
|
3761
|
+
// a date by its very name, so it stands unless there is evidence AGAINST
|
|
3762
|
+
// -- two comparable pairs or more, and no strict majority agreeing. A tree
|
|
3763
|
+
// too small to say anything keeps its dates.
|
|
3764
|
+
//
|
|
3765
|
+
// A JOINT RULE with the desktop (Christian, 2026-09-16), chosen over
|
|
3766
|
+
// keeping it here alone, over dating unconditionally, and over moving the
|
|
3767
|
+
// question up into isTimeTree -- which would have changed that answer for
|
|
3768
|
+
// every dated input, phyloXML and BEAST included. Do not retune it alone.
|
|
3769
|
+
//
|
|
3770
|
+
// Where the tree is not time-scaled nothing is lost: the year stays on
|
|
3771
|
+
// the node as nextstrain:num_date (numeric, so Color-by and search have
|
|
3772
|
+
// it) and its interval as nextstrain:num_date_CI, exactly what an
|
|
3773
|
+
// interval with no date to bracket becomes anyway.
|
|
3774
|
+
function settleNumDates(phy) {
|
|
3775
|
+
let root = forester.getTreeRoot(phy);
|
|
3776
|
+
if (!root) {
|
|
3777
|
+
return;
|
|
3778
|
+
}
|
|
3779
|
+
let counted = countDatePairs(root, function (n) {
|
|
3780
|
+
return n._numDate ? n.date.value : null;
|
|
3781
|
+
});
|
|
3782
|
+
let marked = counted.dated.map(function (d) {
|
|
3783
|
+
return d[0];
|
|
3784
|
+
});
|
|
3785
|
+
let pairs = counted.pairs;
|
|
3786
|
+
let agree = counted.agree;
|
|
3787
|
+
let notTimeScaled = pairs >= 2 && agree * 2 <= pairs;
|
|
3788
|
+
marked.forEach(function (n) {
|
|
3789
|
+
let m = n._numDate;
|
|
3790
|
+
delete n._numDate;
|
|
3791
|
+
if (!notTimeScaled) {
|
|
3792
|
+
// A TIP keeps its interval too. It used to be dropped here
|
|
3793
|
+
// and in parseAuspiceJson, for a DISPLAY reason -- a bar on a
|
|
3794
|
+
// tip read as a fossil range -- and that threw real data away:
|
|
3795
|
+
// a sample dated only to its month or year. Counted on real
|
|
3796
|
+
// exports: dengue 2347 of 3863 tips carry a genuine interval
|
|
3797
|
+
// (median 0.78 y), measles 1390 of 2985, enterovirus 715 of
|
|
3798
|
+
// 1600. The display now tells a sampled tip from a fossil
|
|
3799
|
+
// instead (drawTimeAxis); Christian, 2026-09-17, both programs.
|
|
3800
|
+
return;
|
|
3801
|
+
}
|
|
3802
|
+
delete n.date.value;
|
|
3803
|
+
delete n.date.unit;
|
|
3804
|
+
delete n.date.minimum;
|
|
3805
|
+
delete n.date.maximum;
|
|
3806
|
+
if (Object.keys(n.date).length === 0) {
|
|
3807
|
+
delete n.date;
|
|
3808
|
+
}
|
|
3809
|
+
if (m.ciText !== null) {
|
|
3810
|
+
addNodeProperty(n, NEXTSTRAIN_PREFIX + m.ciKey, m.ciText);
|
|
3811
|
+
}
|
|
3812
|
+
});
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
// ---------------------------------------------------------------
|
|
3816
|
+
// TreeTime's own namespace
|
|
3817
|
+
// ---------------------------------------------------------------
|
|
3818
|
+
//
|
|
3819
|
+
// TreeTime's annotations arrive through the BEAST path, so they were
|
|
3820
|
+
// landing as beast:<key> -- accurate about the syntax, wrong about the
|
|
3821
|
+
// producer, and confusing next to a real BEAST run. Christian asked for a
|
|
3822
|
+
// namespace of their own (2026-09-16).
|
|
3823
|
+
//
|
|
3824
|
+
// The producer is recognised on the TREE, not the file: a TreeTime tree
|
|
3825
|
+
// carries mutations= and no node age at all, where every BEAST/MrBayes
|
|
3826
|
+
// run states an age (height, height_mean, height_median, and the
|
|
3827
|
+
// height_95%_HPD / height_range intervals). So the test is "mutations
|
|
3828
|
+
// present, age absent", which cannot fire on a BEAST file and leaves that
|
|
3829
|
+
// shared contract with the desktop untouched.
|
|
3830
|
+
//
|
|
3831
|
+
// TreeTime's mugration output is a bare user-named trait -- [®ion="x"]
|
|
3832
|
+
// and nothing else -- which no rule could attribute to any producer. It
|
|
3833
|
+
// keeps the generic namespace, correctly.
|
|
3834
|
+
const TREETIME_PREFIX = 'treetime:';
|
|
3835
|
+
const BEAST_PREFIX = 'beast:';
|
|
3836
|
+
|
|
3837
|
+
// Must run BEFORE promoteTimeScaledDates: until then a date VALUE can
|
|
3838
|
+
// only have come from a BEAST height field or an Auspice num_date, and
|
|
3839
|
+
// either one says this is not TreeTime's own Nexus.
|
|
3840
|
+
function renameTreeTimeProperties(phy) {
|
|
3841
|
+
let nodes = forester.getAllNodes(phy);
|
|
3842
|
+
let mutations = false;
|
|
3843
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
3844
|
+
let d = nodes[i].date;
|
|
3845
|
+
if (d && (d.value !== undefined || d.minimum !== undefined
|
|
3846
|
+
|| d.maximum !== undefined)) {
|
|
3847
|
+
return;
|
|
3848
|
+
}
|
|
3849
|
+
if (!mutations && nodes[i].properties) {
|
|
3850
|
+
mutations = nodes[i].properties.some(function (p) {
|
|
3851
|
+
return p.ref === BEAST_PREFIX + 'mutations';
|
|
3852
|
+
});
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
if (!mutations) {
|
|
3856
|
+
return;
|
|
3857
|
+
}
|
|
3858
|
+
nodes.forEach(function (n) {
|
|
3859
|
+
if (n.properties) {
|
|
3860
|
+
n.properties.forEach(function (p) {
|
|
3861
|
+
if (typeof p.ref === 'string' && p.ref.indexOf(BEAST_PREFIX) === 0) {
|
|
3862
|
+
p.ref = TREETIME_PREFIX + p.ref.substring(BEAST_PREFIX.length);
|
|
3863
|
+
}
|
|
3864
|
+
});
|
|
3865
|
+
}
|
|
3866
|
+
});
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
// The namespace this tree's bracket annotations ended up in, for anything
|
|
3870
|
+
// added to it AFTER the pass above has run -- the Nexus reader hangs a
|
|
3871
|
+
// taxon's refused colour on its tip once the tree string is parsed, and
|
|
3872
|
+
// filed it under beast: on a tree the pass had just renamed: one tree, two
|
|
3873
|
+
// namespaces. The rename is all-or-nothing, so the tree already says which
|
|
3874
|
+
// it took; and asking it, rather than running the pass a second time, is
|
|
3875
|
+
// deliberate -- after promoteTimeScaledDates a TreeTime tree HAS date
|
|
3876
|
+
// values, and a second run would read that as "not TreeTime's own".
|
|
3877
|
+
// (Sound only while nothing read from a taxon can sway the decision; we
|
|
3878
|
+
// read !color alone there. The desktop reads the whole blob, and so has to
|
|
3879
|
+
// run its pass after the taxlabels instead.)
|
|
3880
|
+
function annotationPrefix(phy) {
|
|
3881
|
+
let nodes = forester.getAllNodes(phy);
|
|
3882
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
3883
|
+
let props = nodes[i].properties;
|
|
3884
|
+
if (props) {
|
|
3885
|
+
for (let j = 0; j < props.length; ++j) {
|
|
3886
|
+
if (typeof props[j].ref === 'string' && props[j].ref.indexOf(TREETIME_PREFIX) === 0) {
|
|
3887
|
+
return TREETIME_PREFIX;
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
return BEAST_PREFIX;
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3412
3895
|
forester.parseNewHampshire = function (nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
|
|
3413
3896
|
|
|
3414
3897
|
let NH_FORMAT_ERR_OPEN_PARENS = NH_FORMAT_ERR + 'likely cause: number of open parentheses is larger than number of close parentheses';
|
|
@@ -3638,6 +4121,10 @@
|
|
|
3638
4121
|
moveInternalNodeNamesToConfidenceValues(phy);
|
|
3639
4122
|
}
|
|
3640
4123
|
|
|
4124
|
+
renameTreeTimeProperties(phy); // first: a provisional num_date still says "not TreeTime's own"
|
|
4125
|
+
settleNumDates(phy);
|
|
4126
|
+
promoteTimeScaledDates(phy);
|
|
4127
|
+
|
|
3641
4128
|
return phy;
|
|
3642
4129
|
|
|
3643
4130
|
function addConfidence(x, element) {
|
|
@@ -3773,6 +4260,11 @@
|
|
|
3773
4260
|
|
|
3774
4261
|
let trees = [];
|
|
3775
4262
|
let taxlabels = [];
|
|
4263
|
+
let taxlabelColors = Object.create(null); // label -> #rrggbb, from 'name'[&!color=...]
|
|
4264
|
+
let taxlabelColorsByKey = Object.create(null); // the same, under the Nexus join key
|
|
4265
|
+
let taxlabelRefused = Object.create(null); // label -> a !color value we could not read, kept as text
|
|
4266
|
+
let taxlabelRefusedByKey = Object.create(null);
|
|
4267
|
+
let taxlabelKeyCount = Object.create(null); // join key -> how many taxlabels share it, annotated or not
|
|
3776
4268
|
// null-prototype maps: a taxon named "__proto__" must stay data
|
|
3777
4269
|
let translateMap = Object.create(null);
|
|
3778
4270
|
let seqs = Object.create(null);
|
|
@@ -3937,6 +4429,7 @@
|
|
|
3937
4429
|
seqsByKey[joinKey(id)] = seqs[id];
|
|
3938
4430
|
}
|
|
3939
4431
|
let externals = forester.getAllExternalNodes(phy);
|
|
4432
|
+
let annotationNs = null;
|
|
3940
4433
|
// A bare integer tip name counts as a TAXLABELS index only when
|
|
3941
4434
|
// the WHOLE tree reads as index references: every tip a bare
|
|
3942
4435
|
// integer AND every one of them in range. All-or-nothing, because
|
|
@@ -3969,6 +4462,41 @@
|
|
|
3969
4462
|
// un-doubling and drop the apostrophe a second time.
|
|
3970
4463
|
node.name = taxlabels[parseInt(node.name, 10) - 1];
|
|
3971
4464
|
}
|
|
4465
|
+
// A colour FigTree gave the TAXON is the colour of its LABEL
|
|
4466
|
+
// -- FigTree's own meaning, and the desktop's (Christian,
|
|
4467
|
+
// 2026-09-16) -- where a !color in the tree string is the
|
|
4468
|
+
// BRANCH's. It lands as the desktop's style:font_color
|
|
4469
|
+
// property, which is what Visual Styles already draws and what
|
|
4470
|
+
// phyloXML already carries, so nothing downstream is new.
|
|
4471
|
+
// The taxon is found by its exact name, else by the Nexus join
|
|
4472
|
+
// key (case-insensitive, '_' for ' ') -- but by the key ONLY
|
|
4473
|
+
// when it names exactly ONE taxlabel, counting every taxlabel,
|
|
4474
|
+
// annotated or not. Without that, Taxon_A[&!color=red] beside a
|
|
4475
|
+
// plain taxon_a coloured BOTH tips: taxon_a has no annotation
|
|
4476
|
+
// of its own, so it fell through to a key it shares. And a tip
|
|
4477
|
+
// matching two labels by key alone is ambiguous: no colour,
|
|
4478
|
+
// rather than whichever was written last. (Found by a review on
|
|
4479
|
+
// the desktop, which had the same fallback; its rule.)
|
|
4480
|
+
let loneKey = !!node.name && taxlabelKeyCount[joinKey(node.name)] === 1;
|
|
4481
|
+
let labelColor = !node.name ? undefined
|
|
4482
|
+
: (taxlabelColors[node.name] !== undefined ? taxlabelColors[node.name]
|
|
4483
|
+
: (loneKey ? taxlabelColorsByKey[joinKey(node.name)] : undefined));
|
|
4484
|
+
if (labelColor !== undefined) {
|
|
4485
|
+
if (!node.properties) {
|
|
4486
|
+
node.properties = [];
|
|
4487
|
+
}
|
|
4488
|
+
node.properties.push({ref: 'style:font_color', value: labelColor,
|
|
4489
|
+
datatype: 'xsd:token', applies_to: 'node'});
|
|
4490
|
+
}
|
|
4491
|
+
let refusedColor = !node.name ? undefined
|
|
4492
|
+
: (taxlabelRefused[node.name] !== undefined ? taxlabelRefused[node.name]
|
|
4493
|
+
: (loneKey ? taxlabelRefusedByKey[joinKey(node.name)] : undefined));
|
|
4494
|
+
if (refusedColor !== undefined) {
|
|
4495
|
+
if (annotationNs === null) {
|
|
4496
|
+
annotationNs = annotationPrefix(phy); // once per tree, and only if needed
|
|
4497
|
+
}
|
|
4498
|
+
addNodeProperty(node, annotationNs + '_color', refusedColor, 'xsd:string');
|
|
4499
|
+
}
|
|
3972
4500
|
if (node.name) {
|
|
3973
4501
|
let s = seqsByKey[joinKey(node.name)];
|
|
3974
4502
|
if (s) {
|
|
@@ -4091,6 +4619,7 @@
|
|
|
4091
4619
|
let push = function () {
|
|
4092
4620
|
if (tok.length > 0 && tok.toLowerCase() !== 'taxlabels') {
|
|
4093
4621
|
taxlabels.push(tok);
|
|
4622
|
+
taxlabelKeyCount[joinKey(tok)] = (taxlabelKeyCount[joinKey(tok)] || 0) + 1;
|
|
4094
4623
|
}
|
|
4095
4624
|
tok = '';
|
|
4096
4625
|
closed = false;
|
|
@@ -4128,6 +4657,48 @@
|
|
|
4128
4657
|
// divergence, not a fix.
|
|
4129
4658
|
void ch;
|
|
4130
4659
|
}
|
|
4660
|
+
} else if (ch === '[') {
|
|
4661
|
+
// A bracket after a label is not part of it. FigTree
|
|
4662
|
+
// hangs the taxon's colour here --
|
|
4663
|
+
// 'NewYork_454_1999.05'[&!color=#-8381639] -- and it
|
|
4664
|
+
// used to be glued onto the label, which then named
|
|
4665
|
+
// no tip (invisible while a tree spells its tips
|
|
4666
|
+
// out, wrong as soon as it refers to them by number).
|
|
4667
|
+
// A [&...] belongs to the label just read; any other
|
|
4668
|
+
// bracket is an ordinary Nexus comment.
|
|
4669
|
+
//
|
|
4670
|
+
// Only !color is read from it, and that is DECIDED
|
|
4671
|
+
// (Christian, 2026-09-17: "keep it as it is"), a named
|
|
4672
|
+
// difference from the desktop, which keeps every field
|
|
4673
|
+
// of a taxon's annotation. No producer we know writes
|
|
4674
|
+
// anything else here. Widening it is not a small edit:
|
|
4675
|
+
// a posterior, a height or mutations arriving by this
|
|
4676
|
+
// road could sway the per-tree pass, which would then
|
|
4677
|
+
// have to run after the taxlabels (see annotationPrefix).
|
|
4678
|
+
let end = line.indexOf(']', ci);
|
|
4679
|
+
let inside = line.substring(ci + 1, end < 0 ? line.length : end).trim();
|
|
4680
|
+
if (inside.charAt(0) === '&' && tok.length > 0) {
|
|
4681
|
+
splitTopLevelCommas(inside.substring(1), true).forEach(function (field) {
|
|
4682
|
+
let eq = field.indexOf('=');
|
|
4683
|
+
let key = eq > 0 ? field.substring(0, eq).trim().toLowerCase() : '';
|
|
4684
|
+
let isColor = key === '!color' || key === '!colour';
|
|
4685
|
+
let raw = isColor ? stripValueQuotes(field.substring(eq + 1).trim()) : '';
|
|
4686
|
+
let rgb = isColor ? parseFigTreeColor(raw) : null;
|
|
4687
|
+
if (isColor && !rgb && raw.length > 0) {
|
|
4688
|
+
// not a colour we read (no '#', say): kept as
|
|
4689
|
+
// text on the tip, as in the tree string
|
|
4690
|
+
taxlabelRefused[tok] = raw;
|
|
4691
|
+
taxlabelRefusedByKey[joinKey(tok)] = raw;
|
|
4692
|
+
}
|
|
4693
|
+
if (rgb) {
|
|
4694
|
+
taxlabelColors[tok] = '#' + [rgb.red, rgb.green, rgb.blue].map(function (c) {
|
|
4695
|
+
return (c < 16 ? '0' : '') + c.toString(16);
|
|
4696
|
+
}).join('');
|
|
4697
|
+
taxlabelColorsByKey[joinKey(tok)] = taxlabelColors[tok];
|
|
4698
|
+
}
|
|
4699
|
+
});
|
|
4700
|
+
}
|
|
4701
|
+
ci = end < 0 ? line.length : end;
|
|
4131
4702
|
} else if (ch === ' ') {
|
|
4132
4703
|
push();
|
|
4133
4704
|
} else if (ch === ';') {
|
|
@@ -4240,7 +4811,9 @@
|
|
|
4240
4811
|
return d.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
|
|
4241
4812
|
}
|
|
4242
4813
|
|
|
4243
|
-
|
|
4814
|
+
// `datatype` is for the few values whose type is decided by what they ARE
|
|
4815
|
+
// rather than by what they look like (see mutations / mcc).
|
|
4816
|
+
function addNodeProperty(node, ref, value, datatype) {
|
|
4244
4817
|
if (value === undefined || value === null || String(value).length === 0) {
|
|
4245
4818
|
return;
|
|
4246
4819
|
}
|
|
@@ -4251,7 +4824,7 @@
|
|
|
4251
4824
|
node.properties.push({
|
|
4252
4825
|
ref: ref,
|
|
4253
4826
|
value: v,
|
|
4254
|
-
datatype:
|
|
4827
|
+
datatype: datatype || (parseBeastNumber(v) !== null ? 'xsd:decimal' : 'xsd:string'),
|
|
4255
4828
|
applies_to: 'node'
|
|
4256
4829
|
});
|
|
4257
4830
|
}
|
|
@@ -4259,7 +4832,8 @@
|
|
|
4259
4832
|
// Parses an Auspice / Nextstrain v2 dataset.json (string or already-parsed
|
|
4260
4833
|
// object) into ONE tree object, mapping its per-node data onto the native
|
|
4261
4834
|
// phyloXML shape so the existing overlays light it up -- ported from the
|
|
4262
|
-
// desktop's AuspiceJsonParser
|
|
4835
|
+
// desktop's AuspiceJsonParser. TreeTime's own auspice_tree.json is read
|
|
4836
|
+
// here too (see the version check below):
|
|
4263
4837
|
// - node_attrs.num_date.value -> node.date value (decimal year) -> the
|
|
4264
4838
|
// calendar time axis; its .confidence [lo,hi] -> date minimum/maximum
|
|
4265
4839
|
// -> the node-age (HPD) bars;
|
|
@@ -4278,8 +4852,21 @@
|
|
|
4278
4852
|
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
4279
4853
|
throw new Error('not an Auspice dataset (the JSON root is not an object)');
|
|
4280
4854
|
}
|
|
4281
|
-
if (
|
|
4282
|
-
|
|
4855
|
+
if (!doc.tree || typeof doc.tree !== 'object' || Array.isArray(doc.tree)) {
|
|
4856
|
+
throw new Error('not an Auspice v2 dataset (expected "version":"v2" and a "tree" object)');
|
|
4857
|
+
}
|
|
4858
|
+
// The version stamp is taken as PRESENT-OR-IMPLIED: TreeTime writes a
|
|
4859
|
+
// fully valid v2 dataset ({meta, tree} with node_attrs.num_date,
|
|
4860
|
+
// branch_attrs, children) and simply never writes "version":"v2", so
|
|
4861
|
+
// demanding the stamp rejected the richest file TreeTime produces --
|
|
4862
|
+
// the only one carrying full-precision dates (its .nexus rounds them
|
|
4863
|
+
// to two decimals). The shape is still checked, so arbitrary JSON
|
|
4864
|
+
// keeps getting the clear error rather than a confusing parse.
|
|
4865
|
+
if (doc.version !== 'v2'
|
|
4866
|
+
&& !(doc.meta && typeof doc.meta === 'object' && !Array.isArray(doc.meta)
|
|
4867
|
+
&& (typeof doc.tree.name === 'string'
|
|
4868
|
+
|| (doc.tree.node_attrs && typeof doc.tree.node_attrs === 'object')
|
|
4869
|
+
|| Array.isArray(doc.tree.children)))) {
|
|
4283
4870
|
throw new Error('not an Auspice v2 dataset (expected "version":"v2" and a "tree" object)');
|
|
4284
4871
|
}
|
|
4285
4872
|
|
|
@@ -4387,16 +4974,11 @@
|
|
|
4387
4974
|
// keep the layout meaningful instead of a cladogram
|
|
4388
4975
|
setDeltaBranchLengths(root, null, auspiceNodeDiv);
|
|
4389
4976
|
}
|
|
4390
|
-
// A
|
|
4391
|
-
//
|
|
4392
|
-
//
|
|
4393
|
-
//
|
|
4394
|
-
|
|
4395
|
-
if (!n.children && n.date
|
|
4396
|
-
&& (n.date.minimum !== undefined || n.date.maximum !== undefined)) {
|
|
4397
|
-
n.date = {value: n.date.value, unit: n.date.unit};
|
|
4398
|
-
}
|
|
4399
|
-
});
|
|
4977
|
+
// A tip keeps its date INTERVAL: on a Nextstrain build it is the
|
|
4978
|
+
// sampling-date uncertainty of a sample dated only to its month or
|
|
4979
|
+
// year, which is data. (It was dropped here until 2026-09-17 because
|
|
4980
|
+
// the time axis drew every tip interval as a fossil range; the axis
|
|
4981
|
+
// now tells the two apart -- see settleNumDates and drawTimeAxis.)
|
|
4400
4982
|
forester.addParents(phy);
|
|
4401
4983
|
return phy;
|
|
4402
4984
|
};
|
|
@@ -5937,9 +6519,17 @@
|
|
|
5937
6519
|
// ---- time-tree detection -------------------------------------------
|
|
5938
6520
|
// Two date conventions: GEOLOGIC ages (Ma before present, decreasing
|
|
5939
6521
|
// toward the tips) and CALENDAR years (increasing toward the tips).
|
|
5940
|
-
// Decided from the <date> unit attributes
|
|
5941
|
-
//
|
|
5942
|
-
//
|
|
6522
|
+
// Decided from the <date> unit attributes and NOTHING ELSE. A unitless
|
|
6523
|
+
// tree gets no axis: guessing from magnitude read every tip-dated BEAST
|
|
6524
|
+
// tree as geologic, because BEAST states a node's age as a unitless
|
|
6525
|
+
// `height` and an influenza tree spanning 12 years looks exactly like one
|
|
6526
|
+
// spanning 12 Ma. It drew Miocene/Pliocene bands and a "Ma" ruler under
|
|
6527
|
+
// tips labelled 1993..2005 -- a confident false claim across the whole
|
|
6528
|
+
// figure, which is worse than no axis. Measured over every tree we ship:
|
|
6529
|
+
// the guess decided 3 trees and got all 3 wrong, every other axis comes
|
|
6530
|
+
// from a unit, and none relied on the old [1500, 2200] year rule. The
|
|
6531
|
+
// desktop dropped the same rule on 2026-09-10 (their e9ac62ec) on the
|
|
6532
|
+
// same evidence; Christian, 2026-09-17, both programs.
|
|
5943
6533
|
const GEO_DATE_UNITS = {
|
|
5944
6534
|
mya: 1, ma: 1, myr: 1, myrs: 1, my: 1, ga: 1, gya: 1, bya: 1, kya: 1,
|
|
5945
6535
|
'million years': 1, 'billion years': 1
|
|
@@ -5949,6 +6539,40 @@
|
|
|
5949
6539
|
'calendar year': 1, 'calendar years': 1
|
|
5950
6540
|
};
|
|
5951
6541
|
|
|
6542
|
+
// A date's bounds state a real interval only when they differ by more than
|
|
6543
|
+
// floating-point noise. TreeAnnotator writes
|
|
6544
|
+
// height_95%_HPD={9.0,9.000000000000004} on a tip it dated EXACTLY: one
|
|
6545
|
+
// number printed twice through binary floating point, not a width. Read as
|
|
6546
|
+
// a width it put a fossil-range bracket on 686 of influenza.tree's 687
|
|
6547
|
+
// tips. The tolerance is RELATIVE because the noise is: a bound near 2000
|
|
6548
|
+
// carries more of it than one near 0. Nothing real is this narrow -- a
|
|
6549
|
+
// single day is 0.0027 of a calendar year, while 1e-9 of 2000 is about a
|
|
6550
|
+
// minute. Both programs had this gap, on two paths each (an auto-enable
|
|
6551
|
+
// predicate with no tolerance, and a painter with no width test at all).
|
|
6552
|
+
const DATE_INTERVAL_REL_TOL = 1e-9;
|
|
6553
|
+
|
|
6554
|
+
/**
|
|
6555
|
+
* Whether a date's minimum/maximum are a genuine interval rather than one
|
|
6556
|
+
* value printed twice. Every reader of date bounds must ask this, drawing
|
|
6557
|
+
* included: a bar floored at 1px draws noise as a visible bracket.
|
|
6558
|
+
*
|
|
6559
|
+
* @param date a node's date object
|
|
6560
|
+
* @returns {boolean}
|
|
6561
|
+
*/
|
|
6562
|
+
forester.isGenuineDateInterval = function (date) {
|
|
6563
|
+
if (!date || typeof date.minimum !== 'number' || typeof date.maximum !== 'number'
|
|
6564
|
+
|| !isFinite(date.minimum) || !isFinite(date.maximum)) {
|
|
6565
|
+
return false;
|
|
6566
|
+
}
|
|
6567
|
+
let scale = Math.max(Math.abs(date.minimum), Math.abs(date.maximum), 1);
|
|
6568
|
+
// the ABSOLUTE difference: bounds handed over in the wrong order still
|
|
6569
|
+
// state a width, and every painter already normalises the order before
|
|
6570
|
+
// drawing. The desktop's AptxUtil.hasDateIntervalWidth reads it the
|
|
6571
|
+
// same way, down to this constant -- the two programs must not draw
|
|
6572
|
+
// different figures from one file (their message, 2026-09-17).
|
|
6573
|
+
return Math.abs(date.maximum - date.minimum) > (scale * DATE_INTERVAL_REL_TOL);
|
|
6574
|
+
};
|
|
6575
|
+
|
|
5952
6576
|
/**
|
|
5953
6577
|
* Whether the tree's branch lengths are time: most of its internal nodes
|
|
5954
6578
|
* carry a date, and at least two do -- BEAST node heights, Nextstrain
|
|
@@ -5981,9 +6605,8 @@
|
|
|
5981
6605
|
// presentDate (calendar) are both the LARGEST date value -- the oldest
|
|
5982
6606
|
// node for ages, the most recent tip for years.
|
|
5983
6607
|
forester.timeAxisInfo = function (root) {
|
|
5984
|
-
let
|
|
6608
|
+
let valued = 0;
|
|
5985
6609
|
let maxVal = -Infinity; // running, not Math.max.apply: 150k dated tips overflow the call stack
|
|
5986
|
-
let minVal = Infinity;
|
|
5987
6610
|
let geoUnits = 0;
|
|
5988
6611
|
let calUnits = 0;
|
|
5989
6612
|
let internal = 0;
|
|
@@ -6003,7 +6626,7 @@
|
|
|
6003
6626
|
if (!d) {
|
|
6004
6627
|
return;
|
|
6005
6628
|
}
|
|
6006
|
-
let interval =
|
|
6629
|
+
let interval = forester.isGenuineDateInterval(d);
|
|
6007
6630
|
if (interval) {
|
|
6008
6631
|
if (isExt) {
|
|
6009
6632
|
hasExternalIntervals = true;
|
|
@@ -6014,13 +6637,10 @@
|
|
|
6014
6637
|
if (typeof d.value !== 'number' || !isFinite(d.value)) {
|
|
6015
6638
|
return; // 1e400 parses to Infinity and must never reach the tick loops
|
|
6016
6639
|
}
|
|
6017
|
-
|
|
6640
|
+
++valued;
|
|
6018
6641
|
if (d.value > maxVal) {
|
|
6019
6642
|
maxVal = d.value;
|
|
6020
6643
|
}
|
|
6021
|
-
if (d.value < minVal) {
|
|
6022
|
-
minVal = d.value;
|
|
6023
|
-
}
|
|
6024
6644
|
if (isExt) {
|
|
6025
6645
|
++datedExternal;
|
|
6026
6646
|
} else {
|
|
@@ -6036,25 +6656,16 @@
|
|
|
6036
6656
|
}
|
|
6037
6657
|
});
|
|
6038
6658
|
let type = null;
|
|
6039
|
-
if (
|
|
6659
|
+
if (valued > 0) {
|
|
6040
6660
|
if (geoUnits > 0 && geoUnits >= calUnits) {
|
|
6041
6661
|
type = 'geologic';
|
|
6042
6662
|
} else if (calUnits > 0) {
|
|
6043
6663
|
type = 'calendar';
|
|
6044
|
-
} else {
|
|
6045
|
-
let calendarish = values.filter(function (v) {
|
|
6046
|
-
return v >= 1500 && v <= 2200;
|
|
6047
|
-
}).length;
|
|
6048
|
-
if (calendarish * 2 > values.length) {
|
|
6049
|
-
type = 'calendar';
|
|
6050
|
-
} else if (maxVal > 10 && minVal <= maxVal * 0.05) {
|
|
6051
|
-
type = 'geologic';
|
|
6052
|
-
}
|
|
6053
6664
|
}
|
|
6054
6665
|
}
|
|
6055
6666
|
let dated = (datedInternal >= 2 && (datedInternal * 2) > internal)
|
|
6056
6667
|
|| (datedExternal >= 2 && (datedExternal * 2) > external);
|
|
6057
|
-
let maxValue =
|
|
6668
|
+
let maxValue = valued > 0 ? maxVal : 0;
|
|
6058
6669
|
return {
|
|
6059
6670
|
type: type,
|
|
6060
6671
|
rootAge: type === 'geologic' ? maxValue : 0,
|
|
@@ -6065,6 +6676,540 @@
|
|
|
6065
6676
|
};
|
|
6066
6677
|
};
|
|
6067
6678
|
|
|
6679
|
+
// ---- dates in a tip's LABEL, and BEAST heights ----------------------
|
|
6680
|
+
// A tip-dated BEAST tree states a node's age as a unitless `height`: time
|
|
6681
|
+
// before the youngest tip, in whatever unit the run happened to use. The
|
|
6682
|
+
// sampling dates are usually in the tip labels themselves
|
|
6683
|
+
// (A_duck_Guangdong_12_2000, NewYork_705_1994.1, EBOV|KR817226|2014-06-10).
|
|
6684
|
+
// If the heights are YEARS, every tip's label date plus its height is the
|
|
6685
|
+
// same calendar date -- the date of height 0. That agreement is the whole
|
|
6686
|
+
// evidence: heights in months or days, or a strain number mistaken for a
|
|
6687
|
+
// date, break it and the tree is left alone.
|
|
6688
|
+
//
|
|
6689
|
+
// Ported from the desktop's TipDateExtractor and HeightDateConverter
|
|
6690
|
+
// (their 0.11.151, commit 6755ba12), whose rules and constants these are.
|
|
6691
|
+
// Christian, 2026-09-17: port it, after they commit.
|
|
6692
|
+
|
|
6693
|
+
// A BARE year or decimal year has to look like a plausible sampling year or
|
|
6694
|
+
// a 4-digit strain number would date the tip. An explicit ISO / month-name
|
|
6695
|
+
// / slash date is strong evidence and gets a looser window.
|
|
6696
|
+
const TIP_DATE_MIN_BARE_YEAR = 1900;
|
|
6697
|
+
const TIP_DATE_MAX_BARE_YEAR = 2100;
|
|
6698
|
+
const TIP_DATE_MIN_YEAR = 1000;
|
|
6699
|
+
const TIP_DATE_MAX_YEAR = 2999;
|
|
6700
|
+
|
|
6701
|
+
// Global, so each matcher can take the RIGHTMOST match; lastIndex is reset
|
|
6702
|
+
// before every use, because these are shared.
|
|
6703
|
+
const TIP_DATE_ISO_FULL = /(?<![0-9])(\d{4})-(\d{1,2})-(\d{1,2})(?![0-9])/g;
|
|
6704
|
+
const TIP_DATE_MONTH_FULL = /(?<![A-Za-z0-9])(\d{1,2})[-\s]([A-Za-z]{3,9})[-\s](\d{4})(?![0-9])/g;
|
|
6705
|
+
const TIP_DATE_SLASH_FULL = /(?<![0-9])(\d{1,4})[/.](\d{1,2})[/.](\d{1,4})(?![0-9])/g;
|
|
6706
|
+
const TIP_DATE_ISO_PARTIAL = /(?<![0-9])(\d{4})-(\d{1,2})(?![0-9-])/g;
|
|
6707
|
+
const TIP_DATE_MONTH_PARTIAL = /(?<![A-Za-z0-9])([A-Za-z]{3,9})[-\s](\d{4})(?![0-9])/g;
|
|
6708
|
+
const TIP_DATE_DECIMAL_YEAR = /(?<![0-9.])(\d{4}\.\d+)(?![0-9])/g;
|
|
6709
|
+
const TIP_DATE_BARE_YEAR = /(?<![0-9.])(\d{4})(?![0-9.])/g;
|
|
6710
|
+
|
|
6711
|
+
const TIP_DATE_MONTHS_ABBR = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
6712
|
+
const TIP_DATE_MONTHS_FULL = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august',
|
|
6713
|
+
'september', 'october', 'november', 'december'];
|
|
6714
|
+
|
|
6715
|
+
function tipDateYearLength(y) {
|
|
6716
|
+
return (((y % 4) === 0 && (y % 100) !== 0) || (y % 400) === 0) ? 366 : 365;
|
|
6717
|
+
}
|
|
6718
|
+
|
|
6719
|
+
function tipDateMonthLength(y, m) {
|
|
6720
|
+
if (m === 2) {
|
|
6721
|
+
return tipDateYearLength(y) === 366 ? 29 : 28;
|
|
6722
|
+
}
|
|
6723
|
+
return ((m === 4) || (m === 6) || (m === 9) || (m === 11)) ? 30 : 31;
|
|
6724
|
+
}
|
|
6725
|
+
|
|
6726
|
+
function tipDateDayOfYear(y, m, d) {
|
|
6727
|
+
let doy = d;
|
|
6728
|
+
for (let i = 1; i < m; ++i) {
|
|
6729
|
+
doy += tipDateMonthLength(y, i);
|
|
6730
|
+
}
|
|
6731
|
+
return doy;
|
|
6732
|
+
}
|
|
6733
|
+
|
|
6734
|
+
// 1-12 for an English month name -- the EXACT 3-letter abbreviation or the
|
|
6735
|
+
// full name, case-insensitively; 0 otherwise. Exact, not a prefix, is what
|
|
6736
|
+
// stops "Marburg", "Junin" or "Decatur" being read as a month.
|
|
6737
|
+
function tipDateMonthNumber(name) {
|
|
6738
|
+
if (!name) {
|
|
6739
|
+
return 0;
|
|
6740
|
+
}
|
|
6741
|
+
let key = String(name).toLowerCase();
|
|
6742
|
+
for (let i = 0; i < 12; ++i) {
|
|
6743
|
+
if (TIP_DATE_MONTHS_ABBR[i] === key || TIP_DATE_MONTHS_FULL[i] === key) {
|
|
6744
|
+
return i + 1;
|
|
6745
|
+
}
|
|
6746
|
+
}
|
|
6747
|
+
return 0;
|
|
6748
|
+
}
|
|
6749
|
+
|
|
6750
|
+
/**
|
|
6751
|
+
* The decimal year of a (year, month, day): a month or day of 0 means "not
|
|
6752
|
+
* given" and maps to the middle of the interval it leaves open (a year to
|
|
6753
|
+
* .5, a month to mid-month, a day to mid-day). Leap-year aware. The
|
|
6754
|
+
* midpoint convention is BEAST's and TreeTime's.
|
|
6755
|
+
*
|
|
6756
|
+
* @param year
|
|
6757
|
+
* @param month 1-12, or <= 0 for "not given"
|
|
6758
|
+
* @param day 1-31, or <= 0 for "not given"
|
|
6759
|
+
* @returns {number}
|
|
6760
|
+
*/
|
|
6761
|
+
forester.tipDateToDecimalYear = function (year, month, day) {
|
|
6762
|
+
if (month <= 0) {
|
|
6763
|
+
return year + 0.5;
|
|
6764
|
+
}
|
|
6765
|
+
let len = tipDateYearLength(year);
|
|
6766
|
+
if (day <= 0) {
|
|
6767
|
+
let firstDoy = tipDateDayOfYear(year, month, 1);
|
|
6768
|
+
return year + (((firstDoy - 1) + (tipDateMonthLength(year, month) / 2)) / len);
|
|
6769
|
+
}
|
|
6770
|
+
return year + ((tipDateDayOfYear(year, month, day) - 0.5) / len);
|
|
6771
|
+
};
|
|
6772
|
+
|
|
6773
|
+
// A full date, validated (an impossible one like 2021-13-40 or Feb 30 is
|
|
6774
|
+
// not a date). The RANGE is the whole day the label names.
|
|
6775
|
+
function tipDateFromYmd(year, month, day, matched, format, ambiguous) {
|
|
6776
|
+
if ((year < TIP_DATE_MIN_YEAR) || (year > TIP_DATE_MAX_YEAR) || (month < 1) || (month > 12)
|
|
6777
|
+
|| (day < 1) || (day > tipDateMonthLength(year, month))) {
|
|
6778
|
+
return null;
|
|
6779
|
+
}
|
|
6780
|
+
let len = tipDateYearLength(year);
|
|
6781
|
+
let doy = tipDateDayOfYear(year, month, day);
|
|
6782
|
+
return {
|
|
6783
|
+
decimalYear: forester.tipDateToDecimalYear(year, month, day),
|
|
6784
|
+
matchedText: matched, precision: 'day', formatLabel: format, ambiguous: ambiguous === true,
|
|
6785
|
+
rangeStart: year + ((doy - 1) / len), rangeEnd: year + (doy / len)
|
|
6786
|
+
};
|
|
6787
|
+
}
|
|
6788
|
+
|
|
6789
|
+
// A year-month date: the day is unknown, so the range is the whole month.
|
|
6790
|
+
function tipDateFromYm(year, month, matched, format) {
|
|
6791
|
+
if ((year < TIP_DATE_MIN_YEAR) || (year > TIP_DATE_MAX_YEAR) || (month < 1) || (month > 12)) {
|
|
6792
|
+
return null;
|
|
6793
|
+
}
|
|
6794
|
+
let len = tipDateYearLength(year);
|
|
6795
|
+
let start = year + ((tipDateDayOfYear(year, month, 1) - 1) / len);
|
|
6796
|
+
return {
|
|
6797
|
+
decimalYear: forester.tipDateToDecimalYear(year, month, 0),
|
|
6798
|
+
matchedText: matched, precision: 'month', formatLabel: format, ambiguous: false,
|
|
6799
|
+
rangeStart: start, rangeEnd: start + (tipDateMonthLength(year, month) / len)
|
|
6800
|
+
};
|
|
6801
|
+
}
|
|
6802
|
+
|
|
6803
|
+
// Every matcher keeps the LAST (rightmost) valid match in the label.
|
|
6804
|
+
function tipDateEachMatch(re, s, fn) {
|
|
6805
|
+
let found = null;
|
|
6806
|
+
let m;
|
|
6807
|
+
re.lastIndex = 0;
|
|
6808
|
+
while ((m = re.exec(s)) !== null) {
|
|
6809
|
+
let d = fn(m);
|
|
6810
|
+
if (d) {
|
|
6811
|
+
found = d;
|
|
6812
|
+
}
|
|
6813
|
+
if (m.index === re.lastIndex) {
|
|
6814
|
+
++re.lastIndex; // a zero-width match would spin forever
|
|
6815
|
+
}
|
|
6816
|
+
}
|
|
6817
|
+
return found;
|
|
6818
|
+
}
|
|
6819
|
+
|
|
6820
|
+
/**
|
|
6821
|
+
* The date a tip label states, or null. The most SPECIFIC format wins
|
|
6822
|
+
* (a full date before a year-month before a bare year), and within one
|
|
6823
|
+
* format the rightmost match does -- a label usually ends with its date.
|
|
6824
|
+
*
|
|
6825
|
+
* The result carries the decimal year (midpoint convention) AND the
|
|
6826
|
+
* calendar RANGE the label actually states: `2021` is all of 2021,
|
|
6827
|
+
* `2021-03` all of March, a day that whole day, and a decimal year its
|
|
6828
|
+
* last written digit (`1993.1` is 1993.05 to 1993.15). The range is what
|
|
6829
|
+
* makes different programs' conventions comparable -- BEAST counts a day
|
|
6830
|
+
* from its start, we read a label at its middle, half a day apart.
|
|
6831
|
+
*
|
|
6832
|
+
* @param label a tip's name
|
|
6833
|
+
* @param monthFirst true to read an ambiguous numeric date (both fields
|
|
6834
|
+
* <= 12) month-first; the default is day-first, as the desktop's
|
|
6835
|
+
* converter always asks for
|
|
6836
|
+
* @returns {object|null}
|
|
6837
|
+
*/
|
|
6838
|
+
forester.parseTipLabelDate = function (label, monthFirst) {
|
|
6839
|
+
if (!label) {
|
|
6840
|
+
return null;
|
|
6841
|
+
}
|
|
6842
|
+
let s = String(label);
|
|
6843
|
+
let m = tipDateEachMatch(TIP_DATE_ISO_FULL, s, function (g) {
|
|
6844
|
+
return tipDateFromYmd(parseInt(g[1], 10), parseInt(g[2], 10), parseInt(g[3], 10), g[0],
|
|
6845
|
+
'ISO date (YYYY-MM-DD)', false);
|
|
6846
|
+
});
|
|
6847
|
+
if (m) {
|
|
6848
|
+
return m;
|
|
6849
|
+
}
|
|
6850
|
+
m = tipDateEachMatch(TIP_DATE_MONTH_FULL, s, function (g) {
|
|
6851
|
+
let mon = tipDateMonthNumber(g[2]);
|
|
6852
|
+
return mon > 0
|
|
6853
|
+
? tipDateFromYmd(parseInt(g[3], 10), mon, parseInt(g[1], 10), g[0], 'month-name date', false)
|
|
6854
|
+
: null;
|
|
6855
|
+
});
|
|
6856
|
+
if (m) {
|
|
6857
|
+
return m;
|
|
6858
|
+
}
|
|
6859
|
+
m = tipDateEachMatch(TIP_DATE_SLASH_FULL, s, function (g) {
|
|
6860
|
+
let a = parseInt(g[1], 10);
|
|
6861
|
+
let b = parseInt(g[2], 10);
|
|
6862
|
+
let c = parseInt(g[3], 10);
|
|
6863
|
+
let year = -1;
|
|
6864
|
+
let month = -1;
|
|
6865
|
+
let day = -1;
|
|
6866
|
+
let ambiguous = false;
|
|
6867
|
+
if (g[1].length === 4) { // YYYY/MM/DD -- unambiguous
|
|
6868
|
+
year = a;
|
|
6869
|
+
month = b;
|
|
6870
|
+
day = c;
|
|
6871
|
+
} else if (g[3].length === 4) { // D/M/YYYY or M/D/YYYY
|
|
6872
|
+
year = c;
|
|
6873
|
+
if (a > 12) {
|
|
6874
|
+
day = a;
|
|
6875
|
+
month = b;
|
|
6876
|
+
} else if (b > 12) {
|
|
6877
|
+
month = a;
|
|
6878
|
+
day = b;
|
|
6879
|
+
} else { // both <= 12: genuinely ambiguous
|
|
6880
|
+
ambiguous = true;
|
|
6881
|
+
if (monthFirst === true) {
|
|
6882
|
+
month = a;
|
|
6883
|
+
day = b;
|
|
6884
|
+
} else {
|
|
6885
|
+
day = a;
|
|
6886
|
+
month = b;
|
|
6887
|
+
}
|
|
6888
|
+
}
|
|
6889
|
+
}
|
|
6890
|
+
return year > 0 ? tipDateFromYmd(year, month, day, g[0], 'numeric date', ambiguous) : null;
|
|
6891
|
+
});
|
|
6892
|
+
if (m) {
|
|
6893
|
+
return m;
|
|
6894
|
+
}
|
|
6895
|
+
m = tipDateEachMatch(TIP_DATE_ISO_PARTIAL, s, function (g) {
|
|
6896
|
+
return tipDateFromYm(parseInt(g[1], 10), parseInt(g[2], 10), g[0], 'ISO year-month (YYYY-MM)');
|
|
6897
|
+
});
|
|
6898
|
+
if (m) {
|
|
6899
|
+
return m;
|
|
6900
|
+
}
|
|
6901
|
+
m = tipDateEachMatch(TIP_DATE_MONTH_PARTIAL, s, function (g) {
|
|
6902
|
+
let mon = tipDateMonthNumber(g[1]);
|
|
6903
|
+
return mon > 0 ? tipDateFromYm(parseInt(g[2], 10), mon, g[0], 'month-name year') : null;
|
|
6904
|
+
});
|
|
6905
|
+
if (m) {
|
|
6906
|
+
return m;
|
|
6907
|
+
}
|
|
6908
|
+
m = tipDateEachMatch(TIP_DATE_DECIMAL_YEAR, s, function (g) {
|
|
6909
|
+
let v = parseFloat(g[1]);
|
|
6910
|
+
let y = Math.floor(v);
|
|
6911
|
+
if ((y < TIP_DATE_MIN_BARE_YEAR) || (y > TIP_DATE_MAX_BARE_YEAR)) {
|
|
6912
|
+
return null;
|
|
6913
|
+
}
|
|
6914
|
+
// the range is the last digit the label actually wrote
|
|
6915
|
+
let half = 0.5 * Math.pow(10, -(g[1].length - g[1].indexOf('.') - 1));
|
|
6916
|
+
return {
|
|
6917
|
+
decimalYear: v, matchedText: g[1], precision: 'day', formatLabel: 'decimal year',
|
|
6918
|
+
ambiguous: false, rangeStart: v - half, rangeEnd: v + half
|
|
6919
|
+
};
|
|
6920
|
+
});
|
|
6921
|
+
if (m) {
|
|
6922
|
+
return m;
|
|
6923
|
+
}
|
|
6924
|
+
return tipDateEachMatch(TIP_DATE_BARE_YEAR, s, function (g) {
|
|
6925
|
+
let y = parseInt(g[1], 10);
|
|
6926
|
+
if ((y < TIP_DATE_MIN_BARE_YEAR) || (y > TIP_DATE_MAX_BARE_YEAR)) {
|
|
6927
|
+
return null;
|
|
6928
|
+
}
|
|
6929
|
+
return {
|
|
6930
|
+
decimalYear: y + 0.5, matchedText: g[1], precision: 'year', formatLabel: 'year',
|
|
6931
|
+
ambiguous: false, rangeStart: y, rangeEnd: y + 1
|
|
6932
|
+
};
|
|
6933
|
+
});
|
|
6934
|
+
};
|
|
6935
|
+
|
|
6936
|
+
// ---- unitless BEAST heights -> calendar dates -----------------------
|
|
6937
|
+
// How far a tip's label date plus its height may miss, in years: enough for
|
|
6938
|
+
// the one-day differences between programs' decimal-year conventions, and
|
|
6939
|
+
// far below the gaps that heights in months or days open up.
|
|
6940
|
+
const HEIGHT_DATE_TOLERANCE_YEARS = 0.01;
|
|
6941
|
+
// at least 19 of every 20 compared tips must agree on the date of height 0
|
|
6942
|
+
const HEIGHT_DATE_MIN_AGREEING_NUM = 19;
|
|
6943
|
+
const HEIGHT_DATE_MIN_AGREEING_DEN = 20;
|
|
6944
|
+
|
|
6945
|
+
// 5 decimals, HALF_UP -- away from zero, which Math.round is not for a
|
|
6946
|
+
// negative half (it takes -0.5 to -0). The desktop rounds with BigDecimal
|
|
6947
|
+
// HALF_UP, and the anchor has to land on the same digits or every date on
|
|
6948
|
+
// the tree shifts.
|
|
6949
|
+
function heightDateRound5(x) {
|
|
6950
|
+
let s = x < 0 ? -1 : 1;
|
|
6951
|
+
return (s * Math.round(Math.abs(x) * 100000)) / 100000;
|
|
6952
|
+
}
|
|
6953
|
+
|
|
6954
|
+
// Whether a compared tip allows height 0 at calendar date x. The tip's
|
|
6955
|
+
// allowed stretch is its LABEL RANGE plus its height, widened by the
|
|
6956
|
+
// tolerance at each end.
|
|
6957
|
+
function heightDateAllows(r, x) {
|
|
6958
|
+
return (x >= ((r[0] + r[2]) - HEIGHT_DATE_TOLERANCE_YEARS))
|
|
6959
|
+
&& (x <= (r[1] + r[2] + HEIGHT_DATE_TOLERANCE_YEARS));
|
|
6960
|
+
}
|
|
6961
|
+
|
|
6962
|
+
// The stretch of calendar dates allowed by the MOST tips, as [start, end],
|
|
6963
|
+
// or null when a separate stretch is allowed by as many -- then which date
|
|
6964
|
+
// height 0 is, is undecided, and a tree we cannot place we leave alone. A
|
|
6965
|
+
// sweep over the tips' allowed intervals, closed at both ends.
|
|
6966
|
+
//
|
|
6967
|
+
// The `tied` refusal is LIVE, and a test pins it. I first argued it was
|
|
6968
|
+
// unreachable -- two disjoint stretches would each need 19 tips in 20, so
|
|
6969
|
+
// 18 in 20 would allow both, and since a tip's allowed dates are one
|
|
6970
|
+
// interval those 18 allow everything between, making it one stretch. That
|
|
6971
|
+
// last step is wrong, and the desktop caught it: the 18 do cover the gap,
|
|
6972
|
+
// but a stretch is the MAXIMUM coverage, which is 19, and 18 < 19, so the
|
|
6973
|
+
// gap joins neither maximum and the two stay separate. The shape is
|
|
6974
|
+
// 19 / 18 / 19.
|
|
6975
|
+
//
|
|
6976
|
+
// It is reached by an ordinary curation error: a mostly year-labelled tree
|
|
6977
|
+
// (a bare year is ~1 year wide, so those tips span both candidates) with
|
|
6978
|
+
// two tips at height 0 whose day labels disagree -- a file cannot have two
|
|
6979
|
+
// different youngest tips. Remove this check and such a tree converts at a
|
|
6980
|
+
// fabricated anchor, silently. My own fixture had all its year tips in ONE
|
|
6981
|
+
// year, which made the different-sampling-times rule fire first and hide
|
|
6982
|
+
// the whole thing.
|
|
6983
|
+
function heightDateMostAllowed(ranges) {
|
|
6984
|
+
let events = [];
|
|
6985
|
+
ranges.forEach(function (r) {
|
|
6986
|
+
events.push([(r[0] + r[2]) - HEIGHT_DATE_TOLERANCE_YEARS, 1]);
|
|
6987
|
+
events.push([r[1] + r[2] + HEIGHT_DATE_TOLERANCE_YEARS, -1]);
|
|
6988
|
+
});
|
|
6989
|
+
// a start sorts before an end at the same x, so intervals that merely
|
|
6990
|
+
// touch do overlap
|
|
6991
|
+
events.sort(function (a, b) {
|
|
6992
|
+
return a[0] !== b[0] ? (a[0] - b[0]) : (b[1] - a[1]);
|
|
6993
|
+
});
|
|
6994
|
+
let depth = 0;
|
|
6995
|
+
let best = 0;
|
|
6996
|
+
let start = 0;
|
|
6997
|
+
let end = 0;
|
|
6998
|
+
let open = false;
|
|
6999
|
+
let tied = false;
|
|
7000
|
+
events.forEach(function (e) {
|
|
7001
|
+
if (e[1] > 0) {
|
|
7002
|
+
++depth;
|
|
7003
|
+
if (depth > best) {
|
|
7004
|
+
best = depth;
|
|
7005
|
+
start = e[0];
|
|
7006
|
+
open = true;
|
|
7007
|
+
tied = false;
|
|
7008
|
+
} else if ((depth === best) && !open) {
|
|
7009
|
+
tied = true;
|
|
7010
|
+
}
|
|
7011
|
+
} else {
|
|
7012
|
+
if (open) {
|
|
7013
|
+
end = e[0];
|
|
7014
|
+
open = false;
|
|
7015
|
+
}
|
|
7016
|
+
--depth;
|
|
7017
|
+
}
|
|
7018
|
+
});
|
|
7019
|
+
return ((best === 0) || tied) ? null : [start, end];
|
|
7020
|
+
}
|
|
7021
|
+
|
|
7022
|
+
// Where the most precisely dated tips put height 0: the median of
|
|
7023
|
+
// (label date + height) over the tips whose label RANGE is at most twice
|
|
7024
|
+
// the narrowest. The middle of the allowed stretch would be pulled about by
|
|
7025
|
+
// the coarse labels -- influenza.tree mixes `1993.11` with `1997` (meaning
|
|
7026
|
+
// the whole of 1997), and that middle, 2005.2525, showed the tip labelled
|
|
7027
|
+
// 1994.1 as 1994.1025.
|
|
7028
|
+
function heightDatePrecisestMedian(tips) {
|
|
7029
|
+
let narrowest = Infinity;
|
|
7030
|
+
tips.forEach(function (r) {
|
|
7031
|
+
narrowest = Math.min(narrowest, r[1] - r[0]);
|
|
7032
|
+
});
|
|
7033
|
+
let values = [];
|
|
7034
|
+
tips.forEach(function (r) {
|
|
7035
|
+
if ((r[1] - r[0]) <= (2 * narrowest)) {
|
|
7036
|
+
values.push(r[3] + r[2]);
|
|
7037
|
+
}
|
|
7038
|
+
});
|
|
7039
|
+
values.sort(function (a, b) {
|
|
7040
|
+
return a - b;
|
|
7041
|
+
});
|
|
7042
|
+
let n = values.length;
|
|
7043
|
+
return ((n % 2) === 1) ? values[(n - 1) / 2] : ((values[(n / 2) - 1] + values[n / 2]) / 2);
|
|
7044
|
+
}
|
|
7045
|
+
|
|
7046
|
+
/**
|
|
7047
|
+
* The calendar date (decimal year) of height 0 for a tip-dated BEAST tree,
|
|
7048
|
+
* or null when the tree does not qualify and must be left alone. Pure.
|
|
7049
|
+
*
|
|
7050
|
+
* Every condition has to hold: no node's date carries a unit (a unit says
|
|
7051
|
+
* what the numbers already are, and this never overrides one); the tree is
|
|
7052
|
+
* a time tree; a strict majority of tips carry both a height and a date in
|
|
7053
|
+
* the label; the date allowed by the most of those tips is allowed by at
|
|
7054
|
+
* least 19 in 20 of them and no separate stretch of dates is allowed by as
|
|
7055
|
+
* many; and the agreeing tips were sampled at different times -- tips all
|
|
7056
|
+
* from one year agree with heights in ANY unit and so prove nothing.
|
|
7057
|
+
*
|
|
7058
|
+
* @param phy a tree
|
|
7059
|
+
* @returns {{present: number, agreeing: number, compared: number}|null}
|
|
7060
|
+
*/
|
|
7061
|
+
forester.inferHeightDateAnchor = function (phy) {
|
|
7062
|
+
if (!phy) {
|
|
7063
|
+
return null;
|
|
7064
|
+
}
|
|
7065
|
+
let root = forester.getTreeRoot(phy);
|
|
7066
|
+
if (!root) {
|
|
7067
|
+
return null;
|
|
7068
|
+
}
|
|
7069
|
+
let united = false;
|
|
7070
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
7071
|
+
if (n.date && n.date.unit && String(n.date.unit).trim().length > 0) {
|
|
7072
|
+
united = true;
|
|
7073
|
+
}
|
|
7074
|
+
});
|
|
7075
|
+
if (united || !forester.isTimeTree(phy)) {
|
|
7076
|
+
return null;
|
|
7077
|
+
}
|
|
7078
|
+
let tips = 0;
|
|
7079
|
+
let ranges = []; // per compared tip: [label start, label end, height, label date]
|
|
7080
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
7081
|
+
if (n.children && n.children.length > 0) {
|
|
7082
|
+
return;
|
|
7083
|
+
}
|
|
7084
|
+
++tips;
|
|
7085
|
+
// not "has a date": a value of 0 -- the youngest tip's height -- is
|
|
7086
|
+
// a height, not an absence
|
|
7087
|
+
if (!n.date || typeof n.date.value !== 'number' || !isFinite(n.date.value)) {
|
|
7088
|
+
return;
|
|
7089
|
+
}
|
|
7090
|
+
let m = forester.parseTipLabelDate(n.name);
|
|
7091
|
+
if (m) {
|
|
7092
|
+
ranges.push([m.rangeStart, m.rangeEnd, n.date.value, m.decimalYear]);
|
|
7093
|
+
}
|
|
7094
|
+
});
|
|
7095
|
+
let compared = ranges.length;
|
|
7096
|
+
if ((compared * 2) <= tips) {
|
|
7097
|
+
return null;
|
|
7098
|
+
}
|
|
7099
|
+
let best = heightDateMostAllowed(ranges);
|
|
7100
|
+
if (best === null) {
|
|
7101
|
+
return null;
|
|
7102
|
+
}
|
|
7103
|
+
let mid = (best[0] + best[1]) / 2;
|
|
7104
|
+
let agreeingTips = [];
|
|
7105
|
+
let latestStart = -Infinity;
|
|
7106
|
+
let earliestEnd = Infinity;
|
|
7107
|
+
ranges.forEach(function (r) {
|
|
7108
|
+
if (heightDateAllows(r, mid)) {
|
|
7109
|
+
agreeingTips.push(r);
|
|
7110
|
+
latestStart = Math.max(latestStart, r[0]);
|
|
7111
|
+
earliestEnd = Math.min(earliestEnd, r[1]);
|
|
7112
|
+
}
|
|
7113
|
+
});
|
|
7114
|
+
let agreeing = agreeingTips.length;
|
|
7115
|
+
if ((agreeing * HEIGHT_DATE_MIN_AGREEING_DEN) < (compared * HEIGHT_DATE_MIN_AGREEING_NUM)) {
|
|
7116
|
+
return null;
|
|
7117
|
+
}
|
|
7118
|
+
if (latestStart <= earliestEnd) {
|
|
7119
|
+
return null; // every agreeing label range overlaps every other
|
|
7120
|
+
}
|
|
7121
|
+
let present = Math.max(best[0], Math.min(best[1], heightDatePrecisestMedian(agreeingTips)));
|
|
7122
|
+
return {present: heightDateRound5(present), agreeing: agreeing, compared: compared};
|
|
7123
|
+
};
|
|
7124
|
+
|
|
7125
|
+
/**
|
|
7126
|
+
* Rewrites every node's date as `present - height`, in years, with the
|
|
7127
|
+
* interval bounds SWAPPED: the older height bound is the earlier date.
|
|
7128
|
+
*
|
|
7129
|
+
* @param phy a tree
|
|
7130
|
+
* @param present the date of height 0, from forester.inferHeightDateAnchor
|
|
7131
|
+
*/
|
|
7132
|
+
forester.convertHeightsToDates = function (phy, present) {
|
|
7133
|
+
forester.preOrderTraversalAll(forester.getTreeRoot(phy), function (n) {
|
|
7134
|
+
let d = n.date;
|
|
7135
|
+
if (!d) {
|
|
7136
|
+
return;
|
|
7137
|
+
}
|
|
7138
|
+
let hasValue = typeof d.value === 'number' && isFinite(d.value);
|
|
7139
|
+
let value = hasValue ? heightDateRound5(present - d.value) : undefined;
|
|
7140
|
+
// the swap: a LARGER height is further back, so it becomes the
|
|
7141
|
+
// SMALLER (earlier) date
|
|
7142
|
+
let min = (typeof d.maximum === 'number' && isFinite(d.maximum))
|
|
7143
|
+
? heightDateRound5(present - d.maximum) : undefined;
|
|
7144
|
+
let max = (typeof d.minimum === 'number' && isFinite(d.minimum))
|
|
7145
|
+
? heightDateRound5(present - d.minimum) : undefined;
|
|
7146
|
+
let next = {};
|
|
7147
|
+
if (d.desc !== undefined) {
|
|
7148
|
+
next.desc = d.desc;
|
|
7149
|
+
}
|
|
7150
|
+
if (value !== undefined) {
|
|
7151
|
+
next.value = value;
|
|
7152
|
+
}
|
|
7153
|
+
if (min !== undefined) {
|
|
7154
|
+
next.minimum = min;
|
|
7155
|
+
}
|
|
7156
|
+
if (max !== undefined) {
|
|
7157
|
+
next.maximum = max;
|
|
7158
|
+
}
|
|
7159
|
+
// a date with no point value keeps an empty unit, as the reader
|
|
7160
|
+
// writes it
|
|
7161
|
+
next.unit = hasValue ? 'year' : '';
|
|
7162
|
+
n.date = next;
|
|
7163
|
+
});
|
|
7164
|
+
};
|
|
7165
|
+
|
|
7166
|
+
/**
|
|
7167
|
+
* The desktop's provenance sentence for a converted tree, which it appends
|
|
7168
|
+
* to the tree's description.
|
|
7169
|
+
*
|
|
7170
|
+
* @param anchor from forester.inferHeightDateAnchor
|
|
7171
|
+
* @param treeName
|
|
7172
|
+
* @param tipCount
|
|
7173
|
+
* @returns {string}
|
|
7174
|
+
*/
|
|
7175
|
+
forester.heightDateDescription = function (anchor, treeName, tipCount) {
|
|
7176
|
+
let phrase = treeName ? ('tree named "' + treeName + '" with ') : 'a tree with ';
|
|
7177
|
+
let present = String(anchor.present);
|
|
7178
|
+
return 'Converted the node heights of ' + phrase + tipCount + (tipCount === 1 ? ' tip' : ' tips')
|
|
7179
|
+
+ ' to calendar dates: the sampling dates in ' + anchor.agreeing + ' of ' + anchor.compared
|
|
7180
|
+
+ ' tip labels put height 0 at ' + present + ', so each date is ' + present + ' minus the height.';
|
|
7181
|
+
};
|
|
7182
|
+
|
|
7183
|
+
/**
|
|
7184
|
+
* Converts every tree of a load whose unitless heights can be placed in
|
|
7185
|
+
* calendar time, appending the provenance sentence to each converted
|
|
7186
|
+
* tree's description. A tree that does not qualify is left exactly as it
|
|
7187
|
+
* was. Returns the number of trees converted.
|
|
7188
|
+
*
|
|
7189
|
+
* @param phys one tree or an array of them
|
|
7190
|
+
* @returns {number}
|
|
7191
|
+
*/
|
|
7192
|
+
forester.convertLoadedHeightsToDates = function (phys) {
|
|
7193
|
+
if (!phys) {
|
|
7194
|
+
return 0;
|
|
7195
|
+
}
|
|
7196
|
+
let list = Array.isArray(phys) ? phys : [phys];
|
|
7197
|
+
let converted = 0;
|
|
7198
|
+
list.forEach(function (phy) {
|
|
7199
|
+
let anchor = forester.inferHeightDateAnchor(phy);
|
|
7200
|
+
if (anchor === null) {
|
|
7201
|
+
return;
|
|
7202
|
+
}
|
|
7203
|
+
forester.convertHeightsToDates(phy, anchor.present);
|
|
7204
|
+
let prov = forester.heightDateDescription(anchor, phy.name,
|
|
7205
|
+
forester.getAllExternalNodes(forester.getTreeRoot(phy)).length);
|
|
7206
|
+
phy.description = (phy.description && String(phy.description).trim().length > 0)
|
|
7207
|
+
? (phy.description + ' ' + prov) : prov;
|
|
7208
|
+
++converted;
|
|
7209
|
+
});
|
|
7210
|
+
return converted;
|
|
7211
|
+
};
|
|
7212
|
+
|
|
6068
7213
|
// ---- axis tick mathematics -----------------------------------------
|
|
6069
7214
|
// The smallest 1/2/5 x 10^k (k may be negative) step >= target.
|
|
6070
7215
|
forester.niceAxisStep = function (target) {
|