archaeopteryx 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.7.0
23
+ // v 3.8.0
24
24
  // 2026-09-10
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -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
  };