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/forester.js CHANGED
@@ -20,8 +20,8 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.7.0
24
- // 2026-09-10
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 (q) {
3109
- if (cj === q) {
3110
- q = null;
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
- } else if (cj === "'" || cj === '"') {
3113
- q = cj;
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 quotes is data, not a separator (height_95%_HPD={1.4,1.5} must
3165
- // stay one token).
3166
- function splitTopLevelCommas(s) {
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 d = parseFloat(v);
3204
- return (isFinite(d) && isFinite(Number(v))) ? d : null;
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
- && /^#[0-9a-f]{6}$/i.test(value)) {
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
- // age preference: median, then mean, then height -- and each piece
3342
- // parsed independently, so an unparseable point value never discards
3343
- // a valid {lo,hi} interval
3344
- let v = heightMedian !== null ? heightMedian
3345
- : (heightMean !== null ? heightMean : height);
3346
- let dv = (v !== null) ? parseBeastNumber(v) : null;
3347
- let interval = hpd || range;
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
- let date = {};
3352
- if (dv !== null) {
3353
- date.value = dv;
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
- if (interval) {
3356
- date.minimum = interval[0];
3357
- date.maximum = interval[1];
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
- node.date = date;
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, content) {
3371
- content.split(':').forEach(function (tag) {
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
- if (/^&&NHX:/i.test(blob)) {
3406
- applyNhxTags(node, blob.substring(6));
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 -- [&region="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
- function addNodeProperty(node, ref, value) {
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: isFinite(parseFloat(v)) && isFinite(Number(v)) ? 'xsd:decimal' : 'xsd:string',
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 (doc.version !== 'v2' || !doc.tree || typeof doc.tree !== 'object'
4282
- || Array.isArray(doc.tree)) {
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 TIP is a dated sample: keep its point date (the calendar axis)
4391
- // but drop the date INTERVAL -- the divergence-time uncertainty (the
4392
- // node-age bars) belongs to the INTERNAL nodes, and a tip interval
4393
- // would read as a fossil-style observed range on a viral tree.
4394
- forester.preOrderTraversalAll(root, function (n) {
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, with a magnitude fallback for
5941
- // unitless dates: values mostly in [1500, 2200] read as years; values
5942
- // spanning from large down toward ~0 read as ages.
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 values = [];
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 = (typeof d.minimum === 'number') && (typeof d.maximum === 'number');
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
- values.push(d.value);
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 (values.length > 0) {
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 = values.length > 0 ? maxVal : 0;
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) {