archaeopteryx 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/archaeopteryx.js +112 -17
  2. package/forester.js +804 -28
  3. package/package.json +2 -2
package/archaeopteryx.js CHANGED
@@ -20,8 +20,8 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.8.0
24
- // 2026-09-10
23
+ // v 3.10.0
24
+ // 2026-09-17
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
27
27
  // analysis of highly annotated phylogenetic trees.
@@ -103,7 +103,7 @@ function (root, d3, forester, phyloXml) {
103
103
  // IIFE's own function name -- a plain object says what it is.)
104
104
  let archaeopteryx = {};
105
105
 
106
- const VERSION = '3.8.0';
106
+ const VERSION = '3.10.0';
107
107
  const WEBSITE = 'https://cmzmasek.github.io/archaeopteryx-js/';
108
108
  const DESKTOP_WEBSITE = 'https://cmzmasek.github.io/archaeopteryx/';
109
109
  const SOURCE_WEBSITE = 'https://github.com/cmzmasek/archaeopteryx-js';
@@ -404,6 +404,9 @@ function (root, d3, forester, phyloXml) {
404
404
  const PHYLOGRAM_ALIGNED_BUTTON = 'phya_b';
405
405
  const PHYLOGRAM_BUTTON = 'phy_b';
406
406
  const PHYLOGRAM_CLADOGRAM_CONTROLGROUP = 'phy_cla_g';
407
+ const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
408
+ const BRANCH_SCALE_TIME_BUTTON = 'branch_scale_time_b';
409
+ const BRANCH_SCALE_DIV_BUTTON = 'branch_scale_div_b';
407
410
  const ABOUT_DIALOG = 'aptx_about';
408
411
  const PROG_NAME = 'progname';
409
412
  const PROGNAMELINK = 'prognamelink';
@@ -577,6 +580,7 @@ function (root, d3, forester, phyloXml) {
577
580
  const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
578
581
  let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
579
582
  let _timeTree = false; // forester.isTimeTree: never re-rooted
583
+ let _branchScaleAvailable = false; // the tree states time AND divergence, so the switch is offered
580
584
  let _clusterH = 0; // the cluster layout's vertical extent, set per render
581
585
  let _docListenersBound = false; // page-level key/wheel handlers bind once, not per launch
582
586
  let _docListeners = []; // ...and destroy() can take every one of them down again
@@ -5111,6 +5115,12 @@ function (root, d3, forester, phyloXml) {
5111
5115
  // showTimeAxis explicitly.
5112
5116
  _timeInfo = _treeData ? forester.timeAxisInfo(forester.getTreeRoot(_treeData)) : null;
5113
5117
  _timeTree = _treeData ? forester.isTimeTree(_treeData) : false;
5118
+ // Whether this tree says two different things -- when its nodes sit in
5119
+ // time AND its branches measure something else -- and which of the two
5120
+ // it arrived showing. An Auspice build arrives in the time view; a
5121
+ // BEAST or Newick file arrives stating its own branch lengths.
5122
+ _branchScaleAvailable = _treeData ? forester.hasTimeAndDivergence(_treeData) : false;
5123
+ _state.branchScale = _treeData ? forester.branchLengthScale(_treeData) : 'divergence';
5114
5124
  if (_state.showTimeAxis === undefined) {
5115
5125
  _state.showTimeAxis = !!(_timeInfo && _timeInfo.type);
5116
5126
  }
@@ -5642,6 +5652,24 @@ function (root, d3, forester, phyloXml) {
5642
5652
  + ' is empty or illegally formatted');
5643
5653
  }
5644
5654
  });
5655
+ // A tip-dated BEAST tree states its node ages as unitless `height`s --
5656
+ // time before the youngest tip -- which say nothing about WHEN. Where
5657
+ // the tip labels carry sampling dates and they agree on the date of
5658
+ // height 0, the tree is placed in calendar time here, before anything
5659
+ // reads a date: the axis, Color-by and the visualization candidates all
5660
+ // depend on it. A tree that does not qualify is left exactly as it was,
5661
+ // and a tree whose dates carry a unit is never touched. The desktop
5662
+ // converts at the same point, next to its internal-label policy
5663
+ // (their HeightDateConverter, 0.11.151).
5664
+ // Each node's cumulative divergence is recorded from the branch
5665
+ // lengths AS LOADED, before anything rewrites them, so the time <->
5666
+ // divergence switch is reversible rather than one-way. Auspice states
5667
+ // divergence outright (nextstrain:div) and is unaffected; every other
5668
+ // tree states it as its branch lengths, which the time view overwrites.
5669
+ trees.forEach(function (t) {
5670
+ forester.captureDivergence(t);
5671
+ });
5672
+ forester.convertLoadedHeightsToDates(trees);
5645
5673
  // the two view keys are checked up here with the other arguments,
5646
5674
  // before anything is touched (and where Node can test it)
5647
5675
  if (config && config.view !== undefined && config.view !== null && typeof config.view !== 'object') {
@@ -8142,7 +8170,7 @@ function (root, d3, forester, phyloXml) {
8142
8170
  {key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
8143
8171
  {key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
8144
8172
  {key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
8145
- {key: 'x', label: 'X', shift: true, what: 'Time axis on / off', run: toggleTimeAxis},
8173
+ {key: 'x', label: 'X', shift: true, what: 'Time / divergence scale, or the time axis', run: toggleTimeAxis},
8146
8174
  {key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
8147
8175
  {key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
8148
8176
  {key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
@@ -8212,9 +8240,45 @@ function (root, d3, forester, phyloXml) {
8212
8240
  }
8213
8241
  }
8214
8242
 
8215
- // The Time Axis checkbox's toggle; when the distance / time tree switch
8216
- // arrives it takes over this key.
8243
+ /**
8244
+ * Redraws the tree with its branch lengths taken from the chosen metric:
8245
+ * TIME (the gaps between the nodes' dates) or DIVERGENCE (what the file's
8246
+ * own branch lengths measured, recorded at load). Both metrics stay on the
8247
+ * tree, so this is reversible and lossless -- a round trip restores the
8248
+ * loaded branch lengths exactly.
8249
+ *
8250
+ * @param scale 'time' or 'divergence'
8251
+ */
8252
+ function setBranchScale(scale) {
8253
+ if (!_branchScaleAvailable || !_treeData || _state.branchScale === scale) {
8254
+ return;
8255
+ }
8256
+ _state.branchScale = scale;
8257
+ if (scale === 'time') {
8258
+ forester.applyTimeBranchLengths(_treeData);
8259
+ } else {
8260
+ forester.applyDivergenceBranchLengths(_treeData);
8261
+ }
8262
+ // the branch lengths ARE the layout, so everything measured from them
8263
+ // has to be measured again
8264
+ _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
8265
+ setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, scale === 'time');
8266
+ setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, scale !== 'time');
8267
+ zoomToFit();
8268
+ }
8269
+
8270
+ function branchScaleButtonClicked() {
8271
+ setBranchScale(getCheckboxValue(BRANCH_SCALE_TIME_BUTTON) ? 'time' : 'divergence');
8272
+ }
8273
+
8274
+ // Shift+X. The switch takes this key over where the tree offers it, as was
8275
+ // always intended; on every other tree it still works the Time Axis
8276
+ // checkbox, so the key never does nothing.
8217
8277
  function toggleTimeAxis() {
8278
+ if (_branchScaleAvailable) {
8279
+ setBranchScale(_state.branchScale === 'time' ? 'divergence' : 'time');
8280
+ return;
8281
+ }
8218
8282
  let cb = byId(TIME_AXIS_CB);
8219
8283
  if (!cb || cb.disabled) {
8220
8284
  return;
@@ -8956,7 +9020,15 @@ function (root, d3, forester, phyloXml) {
8956
9020
  return _state.showTimeAxis === true && !radialDisplay()
8957
9021
  && _state.phylogram === true
8958
9022
  && _timeInfo !== null && _timeInfo.type !== null
8959
- && _basicTreeProperties.branchLengths === true;
9023
+ && _basicTreeProperties.branchLengths === true
9024
+ // The axis is an overlay calibrated to the tree's own branch scale,
9025
+ // so it must be hidden when the branches are showing DIVERGENCE
9026
+ // and divergence is a different measure -- calendar years against
9027
+ // substitutions reads as a confident lie. Only then: where the two
9028
+ // metrics agree (a BEAST time tree, whose branch lengths ARE time)
9029
+ // there is no switch and the axis is calibrated correctly, which
9030
+ // is what makes a converted BEAST tree show its years at all.
9031
+ && !(_branchScaleAvailable && _state.branchScale === 'divergence');
8960
9032
  }
8961
9033
 
8962
9034
  function timeAxisBottomReserve() {
@@ -9122,8 +9194,11 @@ function (root, d3, forester, phyloXml) {
9122
9194
 
9123
9195
  // ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
9124
9196
  forEachDisplayed(function (d) {
9125
- if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
9126
- || d.y === undefined) {
9197
+ // forester.isGenuineDateInterval, not a bare min/max test: the
9198
+ // bars are floored at 1px below, so a bound pair that differs only
9199
+ // by floating-point noise would draw as a visible bracket. This
9200
+ // guard is on EVERY node, tip and internal, geologic and calendar.
9201
+ if (!forester.isGenuineDateInterval(d.date) || d.y === undefined) {
9127
9202
  return;
9128
9203
  }
9129
9204
  let min = d.date.minimum;
@@ -9144,14 +9219,12 @@ function (root, d3, forester, phyloXml) {
9144
9219
  // as one below. On CALENDAR time it is the uncertainty of a
9145
9220
  // sampling date -- a virus sample dated only to its month or year
9146
9221
  // -- which is the same kind of thing as an internal node's age
9147
- // interval and is drawn the same way, slimmer, and only when it
9148
- // HAS a width: a tip dated to the day states {d,d}, and a capped
9149
- // tick on every such tip is what made the readers throw tip
9150
- // intervals away until 2026-09-17 (Christian; the desktop alike).
9222
+ // interval and is drawn the same way, slimmer. Either way it is
9223
+ // drawn only when it HAS a width: a tip dated to the day states
9224
+ // {d,d}, and a capped tick on every such tip is what made the
9225
+ // readers throw tip intervals away until 2026-09-17 (Christian;
9226
+ // the desktop alike). The width test is the entry guard above.
9151
9227
  let sampledTip = !d.children && info.type === 'calendar';
9152
- if (sampledTip && !(max > min)) {
9153
- return;
9154
- }
9155
9228
  if (d.children) {
9156
9229
  g.append('rect').attr('x', left).attr('y', y - 3.5)
9157
9230
  .attr('width', w).attr('height', 7)
@@ -12191,7 +12264,10 @@ function (root, d3, forester, phyloXml) {
12191
12264
  return null;
12192
12265
  }
12193
12266
  let hasValue = typeof date.value === 'number';
12194
- let hasRange = typeof date.minimum === 'number' && typeof date.maximum === 'number';
12267
+ // the same predicate the bars use: a bound pair that differs only by
12268
+ // floating-point noise is one number printed twice, and reading it as a
12269
+ // range put "[12.059999999999942 - 12.059999999999949]" in the tooltip
12270
+ let hasRange = forester.isGenuineDateInterval(date);
12195
12271
  let s = hasValue ? String(date.value) : '';
12196
12272
  if (hasRange) {
12197
12273
  s += ' [' + date.minimum + ' - ' + date.maximum + ']';
@@ -12722,6 +12798,8 @@ function (root, d3, forester, phyloXml) {
12722
12798
  on(TIME_GRID_CB, 'click', timeGridCbClicked);
12723
12799
 
12724
12800
  on(LAYOUT_RECT_BUTTON, 'click', layoutButtonClicked);
12801
+ on(BRANCH_SCALE_TIME_BUTTON, 'click', branchScaleButtonClicked);
12802
+ on(BRANCH_SCALE_DIV_BUTTON, 'click', branchScaleButtonClicked);
12725
12803
 
12726
12804
  on(LAYOUT_CIRC_BUTTON, 'click', layoutButtonClicked);
12727
12805
  on(LAYOUT_UNROOTED_BUTTON, 'click', layoutButtonClicked);
@@ -13142,6 +13220,15 @@ function (root, d3, forester, phyloXml) {
13142
13220
  h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, 'phylogram display (uses branch length values) with aligned labels'));
13143
13221
  h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, ' cladogram display (ignores branch length values)'));
13144
13222
  h = h.concat('</div>');
13223
+ // The branch SCALE, when the tree states two different things: its
13224
+ // nodes' dates, and its branches' divergence. Hidden otherwise,
13225
+ // which is most trees.
13226
+ h = h.concat('<div class="' + BRANCH_SCALE_CONTROLGROUP + ' aptx-segmented">');
13227
+ h = h.concat(makeSegment('Time', BRANCH_SCALE_TIME_BUTTON, 'branch_scale_radio',
13228
+ 'branch lengths measure TIME (the nodes\' dates)'));
13229
+ h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
13230
+ 'branch lengths measure DIVERGENCE (substitutions, as the file states them)'));
13231
+ h = h.concat('</div>');
13145
13232
  h = h.concat('</div>');
13146
13233
  h = h.concat('</fieldset>');
13147
13234
  return h;
@@ -13617,6 +13704,14 @@ function (root, d3, forester, phyloXml) {
13617
13704
  disableCheckbox('#' + PHYLOGRAM_BUTTON);
13618
13705
  disableCheckbox('#' + PHYLOGRAM_ALIGNED_BUTTON);
13619
13706
  }
13707
+ setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
13708
+ setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
13709
+ // most trees state only one thing, and a control offering a choice
13710
+ // that does not exist is worse than no control
13711
+ let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
13712
+ if (scaleGroup) {
13713
+ scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
13714
+ }
13620
13715
  }
13621
13716
 
13622
13717
 
package/forester.js CHANGED
@@ -20,8 +20,8 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.8.0
24
- // 2026-09-10
23
+ // v 3.10.0
24
+ // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
27
27
  //
@@ -4988,6 +4988,11 @@
4988
4988
  ? node.date.value : null;
4989
4989
  }
4990
4990
 
4991
+ // A node's CUMULATIVE divergence from the root. Auspice states it outright
4992
+ // as nextstrain:div; every other tree states it as branch lengths, which
4993
+ // are the same thing in difference form, so it is summed at load and kept
4994
+ // on the node (see forester.captureDivergence). The property wins where it
4995
+ // exists, so an Auspice tree behaves exactly as it always did.
4991
4996
  function auspiceNodeDiv(node) {
4992
4997
  if (node.properties) {
4993
4998
  for (let i = 0; i < node.properties.length; ++i) {
@@ -4997,7 +5002,8 @@
4997
5002
  }
4998
5003
  }
4999
5004
  }
5000
- return null;
5005
+ return (typeof node._divergence === 'number' && isFinite(node._divergence))
5006
+ ? node._divergence : null;
5001
5007
  }
5002
5008
 
5003
5009
  function auspiceHasAnyDate(node) {
@@ -5051,12 +5057,200 @@
5051
5057
  setDeltaBranchLengths(forester.getTreeRoot(phy), null, auspiceNodeDiv);
5052
5058
  };
5053
5059
 
5054
- // True when the tree carries BOTH a time signal (a dated node) AND a
5055
- // divergence signal (a nextstrain:div property), so the toggle is
5056
- // meaningful at all.
5060
+ /**
5061
+ * Records each node's cumulative divergence from the root, summed from the
5062
+ * branch lengths AS LOADED, so the divergence view survives the time view
5063
+ * overwriting `branch_length`. Kept in `_divergence`, which is private:
5064
+ * no writer emits it, and a re-read of our own output re-derives it.
5065
+ *
5066
+ * A tree whose branch lengths ARE its dates (an Auspice time tree, say)
5067
+ * gets the snapshot too; it is `hasTimeAndDivergence` that decides whether
5068
+ * the two views differ enough to be worth offering.
5069
+ *
5070
+ * Call once per tree at load, before anything rewrites a branch length.
5071
+ *
5072
+ * @param phy the tree
5073
+ */
5074
+ forester.captureDivergence = function (phy) {
5075
+ let root = forester.getTreeRoot(phy);
5076
+ if (!root) {
5077
+ return;
5078
+ }
5079
+ (function walk(node, cumulative) {
5080
+ node._divergence = cumulative;
5081
+ let children = node.children;
5082
+ if (children) {
5083
+ for (let i = 0; i < children.length; ++i) {
5084
+ let bl = children[i].branch_length;
5085
+ let step = (typeof bl === 'number' && isFinite(bl) && bl > 0) ? bl : 0;
5086
+ walk(children[i], cumulative + step);
5087
+ }
5088
+ }
5089
+ }(root, 0));
5090
+ };
5091
+
5092
+ // How much the tree's SHAPE would change between the two metrics, as a
5093
+ // fraction of its width: each tip's distance from the root under each
5094
+ // metric, normalised by the deepest tip, and the largest disagreement.
5095
+ //
5096
+ // Comparing branch lengths PAIR BY PAIR is the wrong question and gave the
5097
+ // wrong answer: on influenza.tree the branch lengths differ from the date
5098
+ // gaps by a median of 7% per branch, which looks like a separate measure,
5099
+ // but the differences cancel along every path and all 687 tips land within
5100
+ // 0.3% of where the other metric puts them. Its branch lengths ARE time --
5101
+ // a BEAST time tree states time -- so a toggle would redraw one picture
5102
+ // twice. A real Nextstrain build, where divergence is genuinely a
5103
+ // different measurement, moves tips by 24.8% of the tree's width.
5104
+ //
5105
+ // Both metrics are read WITHOUT touching branch_length, so this can be
5106
+ // asked at load without disturbing the tree.
5107
+ function layoutShiftBetweenMetrics(root) {
5108
+ let rootDate = auspiceNodeDate(root);
5109
+ let time = [];
5110
+ let div = [];
5111
+ let ok = true;
5112
+ (function walk(n, t, d) {
5113
+ let nt = auspiceNodeDate(n);
5114
+ let nd = auspiceNodeDiv(n);
5115
+ let tHere = (nt !== null && rootDate !== null) ? Math.abs(nt - rootDate) : t;
5116
+ let dHere = (nd !== null) ? nd : d;
5117
+ if (!n.children || n.children.length === 0) {
5118
+ if (nt === null || nd === null) {
5119
+ ok = false;
5120
+ }
5121
+ time.push(tHere);
5122
+ div.push(dHere);
5123
+ return;
5124
+ }
5125
+ for (let i = 0; i < n.children.length; ++i) {
5126
+ walk(n.children[i], tHere, dHere);
5127
+ }
5128
+ }(root, 0, 0));
5129
+ if (!ok || time.length === 0) {
5130
+ return 0;
5131
+ }
5132
+ let tMax = Math.max.apply(null, time.length > 50000 ? time.slice(0, 50000) : time);
5133
+ let dMax = Math.max.apply(null, div.length > 50000 ? div.slice(0, 50000) : div);
5134
+ if (!(tMax > 0) || !(dMax > 0)) {
5135
+ return 0;
5136
+ }
5137
+ let worst = 0;
5138
+ for (let i = 0; i < time.length; ++i) {
5139
+ worst = Math.max(worst, Math.abs((time[i] / tMax) - (div[i] / dMax)));
5140
+ }
5141
+ return worst;
5142
+ }
5143
+
5144
+ // Below this the two views are the same picture and the switch is noise.
5145
+ // influenza.tree measures 0.003, nextstrain-ncov.json 0.248.
5146
+ const BRANCH_METRIC_SHIFT_MIN = 0.02;
5147
+
5148
+ function divergenceDiffersFromTime(root) {
5149
+ return layoutShiftBetweenMetrics(root) > BRANCH_METRIC_SHIFT_MIN;
5150
+ }
5151
+
5152
+ // Which metric the CURRENT branch lengths hold -- a different question
5153
+ // from whether the two metrics differ, and it needs the pair-by-pair
5154
+ // comparison: after forester.applyTimeBranchLengths every branch length is
5155
+ // exactly its ends' date gap, and after applyDivergenceBranchLengths it is
5156
+ // not. Asked so a switch starts from what is on screen.
5157
+ function branchLengthsAreTime(root) {
5158
+ let pairs = 0;
5159
+ let same = 0;
5160
+ forester.preOrderTraversalAll(root, function (n) {
5161
+ if (!n.children) {
5162
+ return;
5163
+ }
5164
+ let pv = auspiceNodeDate(n);
5165
+ if (pv === null) {
5166
+ return;
5167
+ }
5168
+ for (let i = 0; i < n.children.length; ++i) {
5169
+ let c = n.children[i];
5170
+ let cv = auspiceNodeDate(c);
5171
+ let bl = c.branch_length;
5172
+ if (cv === null || typeof bl !== 'number' || !isFinite(bl)) {
5173
+ continue;
5174
+ }
5175
+ ++pairs;
5176
+ let scale = Math.max(Math.abs(bl), Math.abs(cv - pv), 1e-9);
5177
+ if (Math.abs(Math.abs(cv - pv) - bl) <= (scale * 1e-6)) {
5178
+ ++same;
5179
+ }
5180
+ }
5181
+ });
5182
+ return pairs > 0 && (same * 20) >= (pairs * 19);
5183
+ }
5184
+
5185
+ // Whether the dates run the way the time view needs them to: a CALENDAR
5186
+ // date increases toward the tips, and setDeltaBranchLengths takes
5187
+ // child - parent and clamps at 0. A BEAST height runs the other way -- it
5188
+ // is an age, largest at the root -- so a tree still stating heights would
5189
+ // get a branch length of 0 everywhere and collapse to a point. Measured,
5190
+ // not assumed: forester.convertHeightsToDates turns heights into calendar
5191
+ // dates, and a tree it REFUSED still states ages.
5192
+ function timeIncreasesTowardTips(root) {
5193
+ let up = 0;
5194
+ let pairs = 0;
5195
+ forester.preOrderTraversalAll(root, function (n) {
5196
+ if (!n.children) {
5197
+ return;
5198
+ }
5199
+ let pv = auspiceNodeDate(n);
5200
+ if (pv === null) {
5201
+ return;
5202
+ }
5203
+ for (let i = 0; i < n.children.length; ++i) {
5204
+ let cv = auspiceNodeDate(n.children[i]);
5205
+ if (cv === null || cv === pv) {
5206
+ continue;
5207
+ }
5208
+ ++pairs;
5209
+ if (cv > pv) {
5210
+ ++up;
5211
+ }
5212
+ }
5213
+ });
5214
+ return pairs > 0 && (up * 2) > pairs;
5215
+ }
5216
+
5217
+ /**
5218
+ * Which metric the tree's branch lengths currently state. An Auspice build
5219
+ * arrives in the time view (its parser writes date deltas); a BEAST or
5220
+ * Newick file arrives stating whatever it was written with, which is the
5221
+ * divergence view. Asked rather than assumed, so a toggle starts from
5222
+ * what is actually on screen.
5223
+ *
5224
+ * @param phy the tree
5225
+ * @returns {string} 'time' or 'divergence'
5226
+ */
5227
+ forester.branchLengthScale = function (phy) {
5228
+ let root = forester.getTreeRoot(phy);
5229
+ return (root && branchLengthsAreTime(root)) ? 'time' : 'divergence';
5230
+ };
5231
+
5232
+ /**
5233
+ * True when the tree carries BOTH a time signal (dated nodes, running the
5234
+ * calendar way) and a divergence signal that says something different, so
5235
+ * a time <-> divergence toggle is meaningful. Auspice states divergence as
5236
+ * nextstrain:div; a BEAST tree states it as its branch lengths, which on a
5237
+ * real one differ from the dates on nearly every branch (influenza.tree:
5238
+ * 1344 of 1372).
5239
+ *
5240
+ * @param phy the tree
5241
+ * @returns {boolean}
5242
+ */
5057
5243
  forester.hasTimeAndDivergence = function (phy) {
5058
5244
  let root = forester.getTreeRoot(phy);
5059
- return auspiceHasAnyDate(root) && auspiceHasAnyDiv(root);
5245
+ if (!root || !auspiceHasAnyDate(root) || !timeIncreasesTowardTips(root)) {
5246
+ return false;
5247
+ }
5248
+ // auspiceHasAnyDiv is true for every tree once the divergence has been
5249
+ // captured, so it cannot decide this on its own -- it only says a
5250
+ // divergence measure EXISTS. Whether it says anything different is the
5251
+ // shift test, and that is the question worth asking of both kinds of
5252
+ // tree.
5253
+ return auspiceHasAnyDiv(root) && divergenceDiffersFromTime(root);
5060
5254
  };
5061
5255
 
5062
5256
  // A number that is not NaN. It used to test only for null, undefined and
@@ -6519,9 +6713,17 @@
6519
6713
  // ---- time-tree detection -------------------------------------------
6520
6714
  // Two date conventions: GEOLOGIC ages (Ma before present, decreasing
6521
6715
  // toward the tips) and CALENDAR years (increasing toward the tips).
6522
- // Decided from the <date> unit attributes, with a magnitude fallback for
6523
- // unitless dates: values mostly in [1500, 2200] read as years; values
6524
- // spanning from large down toward ~0 read as ages.
6716
+ // Decided from the <date> unit attributes and NOTHING ELSE. A unitless
6717
+ // tree gets no axis: guessing from magnitude read every tip-dated BEAST
6718
+ // tree as geologic, because BEAST states a node's age as a unitless
6719
+ // `height` and an influenza tree spanning 12 years looks exactly like one
6720
+ // spanning 12 Ma. It drew Miocene/Pliocene bands and a "Ma" ruler under
6721
+ // tips labelled 1993..2005 -- a confident false claim across the whole
6722
+ // figure, which is worse than no axis. Measured over every tree we ship:
6723
+ // the guess decided 3 trees and got all 3 wrong, every other axis comes
6724
+ // from a unit, and none relied on the old [1500, 2200] year rule. The
6725
+ // desktop dropped the same rule on 2026-09-10 (their e9ac62ec) on the
6726
+ // same evidence; Christian, 2026-09-17, both programs.
6525
6727
  const GEO_DATE_UNITS = {
6526
6728
  mya: 1, ma: 1, myr: 1, myrs: 1, my: 1, ga: 1, gya: 1, bya: 1, kya: 1,
6527
6729
  'million years': 1, 'billion years': 1
@@ -6531,6 +6733,40 @@
6531
6733
  'calendar year': 1, 'calendar years': 1
6532
6734
  };
6533
6735
 
6736
+ // A date's bounds state a real interval only when they differ by more than
6737
+ // floating-point noise. TreeAnnotator writes
6738
+ // height_95%_HPD={9.0,9.000000000000004} on a tip it dated EXACTLY: one
6739
+ // number printed twice through binary floating point, not a width. Read as
6740
+ // a width it put a fossil-range bracket on 686 of influenza.tree's 687
6741
+ // tips. The tolerance is RELATIVE because the noise is: a bound near 2000
6742
+ // carries more of it than one near 0. Nothing real is this narrow -- a
6743
+ // single day is 0.0027 of a calendar year, while 1e-9 of 2000 is about a
6744
+ // minute. Both programs had this gap, on two paths each (an auto-enable
6745
+ // predicate with no tolerance, and a painter with no width test at all).
6746
+ const DATE_INTERVAL_REL_TOL = 1e-9;
6747
+
6748
+ /**
6749
+ * Whether a date's minimum/maximum are a genuine interval rather than one
6750
+ * value printed twice. Every reader of date bounds must ask this, drawing
6751
+ * included: a bar floored at 1px draws noise as a visible bracket.
6752
+ *
6753
+ * @param date a node's date object
6754
+ * @returns {boolean}
6755
+ */
6756
+ forester.isGenuineDateInterval = function (date) {
6757
+ if (!date || typeof date.minimum !== 'number' || typeof date.maximum !== 'number'
6758
+ || !isFinite(date.minimum) || !isFinite(date.maximum)) {
6759
+ return false;
6760
+ }
6761
+ let scale = Math.max(Math.abs(date.minimum), Math.abs(date.maximum), 1);
6762
+ // the ABSOLUTE difference: bounds handed over in the wrong order still
6763
+ // state a width, and every painter already normalises the order before
6764
+ // drawing. The desktop's AptxUtil.hasDateIntervalWidth reads it the
6765
+ // same way, down to this constant -- the two programs must not draw
6766
+ // different figures from one file (their message, 2026-09-17).
6767
+ return Math.abs(date.maximum - date.minimum) > (scale * DATE_INTERVAL_REL_TOL);
6768
+ };
6769
+
6534
6770
  /**
6535
6771
  * Whether the tree's branch lengths are time: most of its internal nodes
6536
6772
  * carry a date, and at least two do -- BEAST node heights, Nextstrain
@@ -6563,9 +6799,8 @@
6563
6799
  // presentDate (calendar) are both the LARGEST date value -- the oldest
6564
6800
  // node for ages, the most recent tip for years.
6565
6801
  forester.timeAxisInfo = function (root) {
6566
- let values = [];
6802
+ let valued = 0;
6567
6803
  let maxVal = -Infinity; // running, not Math.max.apply: 150k dated tips overflow the call stack
6568
- let minVal = Infinity;
6569
6804
  let geoUnits = 0;
6570
6805
  let calUnits = 0;
6571
6806
  let internal = 0;
@@ -6585,7 +6820,7 @@
6585
6820
  if (!d) {
6586
6821
  return;
6587
6822
  }
6588
- let interval = (typeof d.minimum === 'number') && (typeof d.maximum === 'number');
6823
+ let interval = forester.isGenuineDateInterval(d);
6589
6824
  if (interval) {
6590
6825
  if (isExt) {
6591
6826
  hasExternalIntervals = true;
@@ -6596,13 +6831,10 @@
6596
6831
  if (typeof d.value !== 'number' || !isFinite(d.value)) {
6597
6832
  return; // 1e400 parses to Infinity and must never reach the tick loops
6598
6833
  }
6599
- values.push(d.value);
6834
+ ++valued;
6600
6835
  if (d.value > maxVal) {
6601
6836
  maxVal = d.value;
6602
6837
  }
6603
- if (d.value < minVal) {
6604
- minVal = d.value;
6605
- }
6606
6838
  if (isExt) {
6607
6839
  ++datedExternal;
6608
6840
  } else {
@@ -6618,25 +6850,16 @@
6618
6850
  }
6619
6851
  });
6620
6852
  let type = null;
6621
- if (values.length > 0) {
6853
+ if (valued > 0) {
6622
6854
  if (geoUnits > 0 && geoUnits >= calUnits) {
6623
6855
  type = 'geologic';
6624
6856
  } else if (calUnits > 0) {
6625
6857
  type = 'calendar';
6626
- } else {
6627
- let calendarish = values.filter(function (v) {
6628
- return v >= 1500 && v <= 2200;
6629
- }).length;
6630
- if (calendarish * 2 > values.length) {
6631
- type = 'calendar';
6632
- } else if (maxVal > 10 && minVal <= maxVal * 0.05) {
6633
- type = 'geologic';
6634
- }
6635
6858
  }
6636
6859
  }
6637
6860
  let dated = (datedInternal >= 2 && (datedInternal * 2) > internal)
6638
6861
  || (datedExternal >= 2 && (datedExternal * 2) > external);
6639
- let maxValue = values.length > 0 ? maxVal : 0;
6862
+ let maxValue = valued > 0 ? maxVal : 0;
6640
6863
  return {
6641
6864
  type: type,
6642
6865
  rootAge: type === 'geologic' ? maxValue : 0,
@@ -6647,6 +6870,559 @@
6647
6870
  };
6648
6871
  };
6649
6872
 
6873
+ // ---- dates in a tip's LABEL, and BEAST heights ----------------------
6874
+ // A tip-dated BEAST tree states a node's age as a unitless `height`: time
6875
+ // before the youngest tip, in whatever unit the run happened to use. The
6876
+ // sampling dates are usually in the tip labels themselves
6877
+ // (A_duck_Guangdong_12_2000, NewYork_705_1994.1, EBOV|KR817226|2014-06-10).
6878
+ // If the heights are YEARS, every tip's label date plus its height is the
6879
+ // same calendar date -- the date of height 0. That agreement is the whole
6880
+ // evidence: heights in months or days, or a strain number mistaken for a
6881
+ // date, break it and the tree is left alone.
6882
+ //
6883
+ // Ported from the desktop's TipDateExtractor and HeightDateConverter
6884
+ // (their 0.11.151, commit 6755ba12), whose rules and constants these are.
6885
+ // Christian, 2026-09-17: port it, after they commit.
6886
+
6887
+ // A BARE year or decimal year has to look like a plausible sampling year or
6888
+ // a 4-digit strain number would date the tip. An explicit ISO / month-name
6889
+ // / slash date is strong evidence and gets a looser window.
6890
+ const TIP_DATE_MIN_BARE_YEAR = 1900;
6891
+ const TIP_DATE_MAX_BARE_YEAR = 2100;
6892
+ const TIP_DATE_MIN_YEAR = 1000;
6893
+ const TIP_DATE_MAX_YEAR = 2999;
6894
+
6895
+ // Global, so each matcher can take the RIGHTMOST match; lastIndex is reset
6896
+ // before every use, because these are shared.
6897
+ const TIP_DATE_ISO_FULL = /(?<![0-9])(\d{4})-(\d{1,2})-(\d{1,2})(?![0-9])/g;
6898
+ const TIP_DATE_MONTH_FULL = /(?<![A-Za-z0-9])(\d{1,2})[-\s]([A-Za-z]{3,9})[-\s](\d{4})(?![0-9])/g;
6899
+ const TIP_DATE_SLASH_FULL = /(?<![0-9])(\d{1,4})[/.](\d{1,2})[/.](\d{1,4})(?![0-9])/g;
6900
+ const TIP_DATE_ISO_PARTIAL = /(?<![0-9])(\d{4})-(\d{1,2})(?![0-9-])/g;
6901
+ const TIP_DATE_MONTH_PARTIAL = /(?<![A-Za-z0-9])([A-Za-z]{3,9})[-\s](\d{4})(?![0-9])/g;
6902
+ const TIP_DATE_DECIMAL_YEAR = /(?<![0-9.])(\d{4}\.\d+)(?![0-9])/g;
6903
+ const TIP_DATE_BARE_YEAR = /(?<![0-9.])(\d{4})(?![0-9.])/g;
6904
+
6905
+ const TIP_DATE_MONTHS_ABBR = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
6906
+ const TIP_DATE_MONTHS_FULL = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august',
6907
+ 'september', 'october', 'november', 'december'];
6908
+
6909
+ function tipDateYearLength(y) {
6910
+ return (((y % 4) === 0 && (y % 100) !== 0) || (y % 400) === 0) ? 366 : 365;
6911
+ }
6912
+
6913
+ function tipDateMonthLength(y, m) {
6914
+ if (m === 2) {
6915
+ return tipDateYearLength(y) === 366 ? 29 : 28;
6916
+ }
6917
+ return ((m === 4) || (m === 6) || (m === 9) || (m === 11)) ? 30 : 31;
6918
+ }
6919
+
6920
+ function tipDateDayOfYear(y, m, d) {
6921
+ let doy = d;
6922
+ for (let i = 1; i < m; ++i) {
6923
+ doy += tipDateMonthLength(y, i);
6924
+ }
6925
+ return doy;
6926
+ }
6927
+
6928
+ // 1-12 for an English month name -- the EXACT 3-letter abbreviation or the
6929
+ // full name, case-insensitively; 0 otherwise. Exact, not a prefix, is what
6930
+ // stops "Marburg", "Junin" or "Decatur" being read as a month.
6931
+ function tipDateMonthNumber(name) {
6932
+ if (!name) {
6933
+ return 0;
6934
+ }
6935
+ let key = String(name).toLowerCase();
6936
+ for (let i = 0; i < 12; ++i) {
6937
+ if (TIP_DATE_MONTHS_ABBR[i] === key || TIP_DATE_MONTHS_FULL[i] === key) {
6938
+ return i + 1;
6939
+ }
6940
+ }
6941
+ return 0;
6942
+ }
6943
+
6944
+ /**
6945
+ * The decimal year of a (year, month, day): a month or day of 0 means "not
6946
+ * given" and maps to the middle of the interval it leaves open (a year to
6947
+ * .5, a month to mid-month, a day to mid-day). Leap-year aware. The
6948
+ * midpoint convention is BEAST's and TreeTime's.
6949
+ *
6950
+ * @param year
6951
+ * @param month 1-12, or <= 0 for "not given"
6952
+ * @param day 1-31, or <= 0 for "not given"
6953
+ * @returns {number}
6954
+ */
6955
+ forester.tipDateToDecimalYear = function (year, month, day) {
6956
+ if (month <= 0) {
6957
+ return year + 0.5;
6958
+ }
6959
+ let len = tipDateYearLength(year);
6960
+ if (day <= 0) {
6961
+ let firstDoy = tipDateDayOfYear(year, month, 1);
6962
+ return year + (((firstDoy - 1) + (tipDateMonthLength(year, month) / 2)) / len);
6963
+ }
6964
+ return year + ((tipDateDayOfYear(year, month, day) - 0.5) / len);
6965
+ };
6966
+
6967
+ // A full date, validated (an impossible one like 2021-13-40 or Feb 30 is
6968
+ // not a date). The RANGE is the whole day the label names.
6969
+ function tipDateFromYmd(year, month, day, matched, format, ambiguous) {
6970
+ if ((year < TIP_DATE_MIN_YEAR) || (year > TIP_DATE_MAX_YEAR) || (month < 1) || (month > 12)
6971
+ || (day < 1) || (day > tipDateMonthLength(year, month))) {
6972
+ return null;
6973
+ }
6974
+ let len = tipDateYearLength(year);
6975
+ let doy = tipDateDayOfYear(year, month, day);
6976
+ return {
6977
+ decimalYear: forester.tipDateToDecimalYear(year, month, day),
6978
+ matchedText: matched, precision: 'day', formatLabel: format, ambiguous: ambiguous === true,
6979
+ rangeStart: year + ((doy - 1) / len), rangeEnd: year + (doy / len)
6980
+ };
6981
+ }
6982
+
6983
+ // A year-month date: the day is unknown, so the range is the whole month.
6984
+ function tipDateFromYm(year, month, matched, format) {
6985
+ if ((year < TIP_DATE_MIN_YEAR) || (year > TIP_DATE_MAX_YEAR) || (month < 1) || (month > 12)) {
6986
+ return null;
6987
+ }
6988
+ let len = tipDateYearLength(year);
6989
+ let start = year + ((tipDateDayOfYear(year, month, 1) - 1) / len);
6990
+ return {
6991
+ decimalYear: forester.tipDateToDecimalYear(year, month, 0),
6992
+ matchedText: matched, precision: 'month', formatLabel: format, ambiguous: false,
6993
+ rangeStart: start, rangeEnd: start + (tipDateMonthLength(year, month) / len)
6994
+ };
6995
+ }
6996
+
6997
+ // Every matcher keeps the LAST (rightmost) valid match in the label.
6998
+ function tipDateEachMatch(re, s, fn) {
6999
+ let found = null;
7000
+ let m;
7001
+ re.lastIndex = 0;
7002
+ while ((m = re.exec(s)) !== null) {
7003
+ let d = fn(m);
7004
+ if (d) {
7005
+ found = d;
7006
+ }
7007
+ if (m.index === re.lastIndex) {
7008
+ ++re.lastIndex; // a zero-width match would spin forever
7009
+ }
7010
+ }
7011
+ return found;
7012
+ }
7013
+
7014
+ /**
7015
+ * The date a tip label states, or null. The most SPECIFIC format wins
7016
+ * (a full date before a year-month before a bare year), and within one
7017
+ * format the rightmost match does -- a label usually ends with its date.
7018
+ *
7019
+ * The result carries the decimal year (midpoint convention) AND the
7020
+ * calendar RANGE the label actually states: `2021` is all of 2021,
7021
+ * `2021-03` all of March, a day that whole day, and a decimal year its
7022
+ * last written digit (`1993.1` is 1993.05 to 1993.15). The range is what
7023
+ * makes different programs' conventions comparable -- BEAST counts a day
7024
+ * from its start, we read a label at its middle, half a day apart.
7025
+ *
7026
+ * @param label a tip's name
7027
+ * @param monthFirst true to read an ambiguous numeric date (both fields
7028
+ * <= 12) month-first; the default is day-first, as the desktop's
7029
+ * converter always asks for
7030
+ * @returns {object|null}
7031
+ */
7032
+ forester.parseTipLabelDate = function (label, monthFirst) {
7033
+ if (!label) {
7034
+ return null;
7035
+ }
7036
+ let s = String(label);
7037
+ let m = tipDateEachMatch(TIP_DATE_ISO_FULL, s, function (g) {
7038
+ return tipDateFromYmd(parseInt(g[1], 10), parseInt(g[2], 10), parseInt(g[3], 10), g[0],
7039
+ 'ISO date (YYYY-MM-DD)', false);
7040
+ });
7041
+ if (m) {
7042
+ return m;
7043
+ }
7044
+ m = tipDateEachMatch(TIP_DATE_MONTH_FULL, s, function (g) {
7045
+ let mon = tipDateMonthNumber(g[2]);
7046
+ return mon > 0
7047
+ ? tipDateFromYmd(parseInt(g[3], 10), mon, parseInt(g[1], 10), g[0], 'month-name date', false)
7048
+ : null;
7049
+ });
7050
+ if (m) {
7051
+ return m;
7052
+ }
7053
+ m = tipDateEachMatch(TIP_DATE_SLASH_FULL, s, function (g) {
7054
+ let a = parseInt(g[1], 10);
7055
+ let b = parseInt(g[2], 10);
7056
+ let c = parseInt(g[3], 10);
7057
+ let year = -1;
7058
+ let month = -1;
7059
+ let day = -1;
7060
+ let ambiguous = false;
7061
+ if (g[1].length === 4) { // YYYY/MM/DD -- unambiguous
7062
+ year = a;
7063
+ month = b;
7064
+ day = c;
7065
+ } else if (g[3].length === 4) { // D/M/YYYY or M/D/YYYY
7066
+ year = c;
7067
+ if (a > 12) {
7068
+ day = a;
7069
+ month = b;
7070
+ } else if (b > 12) {
7071
+ month = a;
7072
+ day = b;
7073
+ } else { // both <= 12: genuinely ambiguous
7074
+ ambiguous = true;
7075
+ if (monthFirst === true) {
7076
+ month = a;
7077
+ day = b;
7078
+ } else {
7079
+ day = a;
7080
+ month = b;
7081
+ }
7082
+ }
7083
+ }
7084
+ return year > 0 ? tipDateFromYmd(year, month, day, g[0], 'numeric date', ambiguous) : null;
7085
+ });
7086
+ if (m) {
7087
+ return m;
7088
+ }
7089
+ m = tipDateEachMatch(TIP_DATE_ISO_PARTIAL, s, function (g) {
7090
+ return tipDateFromYm(parseInt(g[1], 10), parseInt(g[2], 10), g[0], 'ISO year-month (YYYY-MM)');
7091
+ });
7092
+ if (m) {
7093
+ return m;
7094
+ }
7095
+ m = tipDateEachMatch(TIP_DATE_MONTH_PARTIAL, s, function (g) {
7096
+ let mon = tipDateMonthNumber(g[1]);
7097
+ return mon > 0 ? tipDateFromYm(parseInt(g[2], 10), mon, g[0], 'month-name year') : null;
7098
+ });
7099
+ if (m) {
7100
+ return m;
7101
+ }
7102
+ m = tipDateEachMatch(TIP_DATE_DECIMAL_YEAR, s, function (g) {
7103
+ let v = parseFloat(g[1]);
7104
+ let y = Math.floor(v);
7105
+ if ((y < TIP_DATE_MIN_BARE_YEAR) || (y > TIP_DATE_MAX_BARE_YEAR)) {
7106
+ return null;
7107
+ }
7108
+ // the range is the last digit the label actually wrote
7109
+ let half = 0.5 * Math.pow(10, -(g[1].length - g[1].indexOf('.') - 1));
7110
+ return {
7111
+ decimalYear: v, matchedText: g[1], precision: 'day', formatLabel: 'decimal year',
7112
+ ambiguous: false, rangeStart: v - half, rangeEnd: v + half
7113
+ };
7114
+ });
7115
+ if (m) {
7116
+ return m;
7117
+ }
7118
+ return tipDateEachMatch(TIP_DATE_BARE_YEAR, s, function (g) {
7119
+ let y = parseInt(g[1], 10);
7120
+ if ((y < TIP_DATE_MIN_BARE_YEAR) || (y > TIP_DATE_MAX_BARE_YEAR)) {
7121
+ return null;
7122
+ }
7123
+ return {
7124
+ decimalYear: y + 0.5, matchedText: g[1], precision: 'year', formatLabel: 'year',
7125
+ ambiguous: false, rangeStart: y, rangeEnd: y + 1
7126
+ };
7127
+ });
7128
+ };
7129
+
7130
+ // ---- unitless BEAST heights -> calendar dates -----------------------
7131
+ // How far a tip's label date plus its height may miss, in years: enough for
7132
+ // the one-day differences between programs' decimal-year conventions, and
7133
+ // far below the gaps that heights in months or days open up.
7134
+ const HEIGHT_DATE_TOLERANCE_YEARS = 0.01;
7135
+ // at least 19 of every 20 compared tips must agree on the date of height 0
7136
+ const HEIGHT_DATE_MIN_AGREEING_NUM = 19;
7137
+ const HEIGHT_DATE_MIN_AGREEING_DEN = 20;
7138
+
7139
+ // 5 decimals, HALF_UP -- away from zero, which Math.round is not for a
7140
+ // negative half (it takes -0.5 to -0). The desktop rounds with BigDecimal
7141
+ // HALF_UP, and the anchor has to land on the same digits or every date on
7142
+ // the tree shifts.
7143
+ function heightDateRound5(x) {
7144
+ let s = x < 0 ? -1 : 1;
7145
+ return (s * Math.round(Math.abs(x) * 100000)) / 100000;
7146
+ }
7147
+
7148
+ // Whether a compared tip allows height 0 at calendar date x. The tip's
7149
+ // allowed stretch is its LABEL RANGE plus its height, widened by the
7150
+ // tolerance at each end.
7151
+ function heightDateAllows(r, x) {
7152
+ return (x >= ((r[0] + r[2]) - HEIGHT_DATE_TOLERANCE_YEARS))
7153
+ && (x <= (r[1] + r[2] + HEIGHT_DATE_TOLERANCE_YEARS));
7154
+ }
7155
+
7156
+ // The stretch of calendar dates allowed by the MOST tips, as [start, end],
7157
+ // or null when a separate stretch is allowed by as many -- then which date
7158
+ // height 0 is, is undecided, and a tree we cannot place we leave alone. A
7159
+ // sweep over the tips' allowed intervals, closed at both ends.
7160
+ //
7161
+ // The `tied` refusal is LIVE, and a test pins it. I first argued it was
7162
+ // unreachable -- two disjoint stretches would each need 19 tips in 20, so
7163
+ // 18 in 20 would allow both, and since a tip's allowed dates are one
7164
+ // interval those 18 allow everything between, making it one stretch. That
7165
+ // last step is wrong, and the desktop caught it: the 18 do cover the gap,
7166
+ // but a stretch is the MAXIMUM coverage, which is 19, and 18 < 19, so the
7167
+ // gap joins neither maximum and the two stay separate. The shape is
7168
+ // 19 / 18 / 19.
7169
+ //
7170
+ // It is reached by an ordinary curation error: a mostly year-labelled tree
7171
+ // (a bare year is ~1 year wide, so those tips span both candidates) with
7172
+ // two tips at height 0 whose day labels disagree -- a file cannot have two
7173
+ // different youngest tips. Remove this check and such a tree converts at a
7174
+ // fabricated anchor, silently. My own fixture had all its year tips in ONE
7175
+ // year, which made the different-sampling-times rule fire first and hide
7176
+ // the whole thing.
7177
+ function heightDateMostAllowed(ranges) {
7178
+ let events = [];
7179
+ ranges.forEach(function (r) {
7180
+ events.push([(r[0] + r[2]) - HEIGHT_DATE_TOLERANCE_YEARS, 1]);
7181
+ events.push([r[1] + r[2] + HEIGHT_DATE_TOLERANCE_YEARS, -1]);
7182
+ });
7183
+ // a start sorts before an end at the same x, so intervals that merely
7184
+ // touch do overlap
7185
+ events.sort(function (a, b) {
7186
+ return a[0] !== b[0] ? (a[0] - b[0]) : (b[1] - a[1]);
7187
+ });
7188
+ let depth = 0;
7189
+ let best = 0;
7190
+ let start = 0;
7191
+ let end = 0;
7192
+ let open = false;
7193
+ let tied = false;
7194
+ events.forEach(function (e) {
7195
+ if (e[1] > 0) {
7196
+ ++depth;
7197
+ if (depth > best) {
7198
+ best = depth;
7199
+ start = e[0];
7200
+ open = true;
7201
+ tied = false;
7202
+ } else if ((depth === best) && !open) {
7203
+ tied = true;
7204
+ }
7205
+ } else {
7206
+ if (open) {
7207
+ end = e[0];
7208
+ open = false;
7209
+ }
7210
+ --depth;
7211
+ }
7212
+ });
7213
+ return ((best === 0) || tied) ? null : [start, end];
7214
+ }
7215
+
7216
+ // Where the most precisely dated tips put height 0: the median of
7217
+ // (label date + height) over the tips whose label RANGE is at most twice
7218
+ // the narrowest. The middle of the allowed stretch would be pulled about by
7219
+ // the coarse labels -- influenza.tree mixes `1993.11` with `1997` (meaning
7220
+ // the whole of 1997), and that middle, 2005.2525, showed the tip labelled
7221
+ // 1994.1 as 1994.1025.
7222
+ function heightDatePrecisestMedian(tips) {
7223
+ let narrowest = Infinity;
7224
+ tips.forEach(function (r) {
7225
+ narrowest = Math.min(narrowest, r[1] - r[0]);
7226
+ });
7227
+ let values = [];
7228
+ tips.forEach(function (r) {
7229
+ if ((r[1] - r[0]) <= (2 * narrowest)) {
7230
+ values.push(r[3] + r[2]);
7231
+ }
7232
+ });
7233
+ values.sort(function (a, b) {
7234
+ return a - b;
7235
+ });
7236
+ let n = values.length;
7237
+ return ((n % 2) === 1) ? values[(n - 1) / 2] : ((values[(n / 2) - 1] + values[n / 2]) / 2);
7238
+ }
7239
+
7240
+ /**
7241
+ * The calendar date (decimal year) of height 0 for a tip-dated BEAST tree,
7242
+ * or null when the tree does not qualify and must be left alone. Pure.
7243
+ *
7244
+ * Every condition has to hold: no node's date carries a unit (a unit says
7245
+ * what the numbers already are, and this never overrides one); the tree is
7246
+ * a time tree; a strict majority of tips carry both a height and a date in
7247
+ * the label; the date allowed by the most of those tips is allowed by at
7248
+ * least 19 in 20 of them and no separate stretch of dates is allowed by as
7249
+ * many; and the agreeing tips were sampled at different times -- tips all
7250
+ * from one year agree with heights in ANY unit and so prove nothing.
7251
+ *
7252
+ * @param phy a tree
7253
+ * @returns {{present: number, agreeing: number, compared: number}|null}
7254
+ */
7255
+ forester.inferHeightDateAnchor = function (phy) {
7256
+ if (!phy) {
7257
+ return null;
7258
+ }
7259
+ let root = forester.getTreeRoot(phy);
7260
+ if (!root) {
7261
+ return null;
7262
+ }
7263
+ let united = false;
7264
+ forester.preOrderTraversalAll(root, function (n) {
7265
+ if (n.date && n.date.unit && String(n.date.unit).trim().length > 0) {
7266
+ united = true;
7267
+ }
7268
+ });
7269
+ if (united || !forester.isTimeTree(phy)) {
7270
+ return null;
7271
+ }
7272
+ let tips = 0;
7273
+ let ranges = []; // per compared tip: [label start, label end, height, label date]
7274
+ forester.preOrderTraversalAll(root, function (n) {
7275
+ if (n.children && n.children.length > 0) {
7276
+ return;
7277
+ }
7278
+ ++tips;
7279
+ // not "has a date": a value of 0 -- the youngest tip's height -- is
7280
+ // a height, not an absence
7281
+ if (!n.date || typeof n.date.value !== 'number' || !isFinite(n.date.value)) {
7282
+ return;
7283
+ }
7284
+ let m = forester.parseTipLabelDate(n.name);
7285
+ if (m) {
7286
+ ranges.push([m.rangeStart, m.rangeEnd, n.date.value, m.decimalYear]);
7287
+ }
7288
+ });
7289
+ let compared = ranges.length;
7290
+ if ((compared * 2) <= tips) {
7291
+ return null;
7292
+ }
7293
+ let best = heightDateMostAllowed(ranges);
7294
+ if (best === null) {
7295
+ return null;
7296
+ }
7297
+ let mid = (best[0] + best[1]) / 2;
7298
+ let agreeingTips = [];
7299
+ let latestStart = -Infinity;
7300
+ let earliestEnd = Infinity;
7301
+ // the dates the agreeing tips actually STATE, un-widened: their labels
7302
+ // plus their heights, with no tolerance added
7303
+ let offerLo = -Infinity;
7304
+ let offerHi = Infinity;
7305
+ ranges.forEach(function (r) {
7306
+ if (heightDateAllows(r, mid)) {
7307
+ agreeingTips.push(r);
7308
+ latestStart = Math.max(latestStart, r[0]);
7309
+ earliestEnd = Math.min(earliestEnd, r[1]);
7310
+ offerLo = Math.max(offerLo, r[0] + r[2]);
7311
+ offerHi = Math.min(offerHi, r[1] + r[2]);
7312
+ }
7313
+ });
7314
+ let agreeing = agreeingTips.length;
7315
+ if ((agreeing * HEIGHT_DATE_MIN_AGREEING_DEN) < (compared * HEIGHT_DATE_MIN_AGREEING_NUM)) {
7316
+ return null;
7317
+ }
7318
+ if (latestStart <= earliestEnd) {
7319
+ return null; // every agreeing label range overlaps every other
7320
+ }
7321
+ // The anchor is clamped into what the agreeing tips actually STATE,
7322
+ // not into the stretch the tolerance opened up. The tolerance decides
7323
+ // AGREEMENT -- how far a tip may miss and still count -- and must not
7324
+ // then place the date: clamping into the widened stretch let the answer
7325
+ // drift by up to the tolerance, about four days. Found by the desktop
7326
+ // on ground truth: three Nextstrain .nwk trees whose .nexus siblings
7327
+ // carry the real dates were out by 4.15, 3.96 and 2.92 days, and land
7328
+ // within half a day after this (2026-09-17).
7329
+ //
7330
+ // Where the agreeing tips overlap only BECAUSE of the tolerance and
7331
+ // state nothing in common, there is nothing to clamp into and the
7332
+ // precise-label median stands.
7333
+ let median = heightDatePrecisestMedian(agreeingTips);
7334
+ let present = (offerLo <= offerHi) ? Math.max(offerLo, Math.min(offerHi, median)) : median;
7335
+ return {present: heightDateRound5(present), agreeing: agreeing, compared: compared};
7336
+ };
7337
+
7338
+ /**
7339
+ * Rewrites every node's date as `present - height`, in years, with the
7340
+ * interval bounds SWAPPED: the older height bound is the earlier date.
7341
+ *
7342
+ * @param phy a tree
7343
+ * @param present the date of height 0, from forester.inferHeightDateAnchor
7344
+ */
7345
+ forester.convertHeightsToDates = function (phy, present) {
7346
+ forester.preOrderTraversalAll(forester.getTreeRoot(phy), function (n) {
7347
+ let d = n.date;
7348
+ if (!d) {
7349
+ return;
7350
+ }
7351
+ let hasValue = typeof d.value === 'number' && isFinite(d.value);
7352
+ let value = hasValue ? heightDateRound5(present - d.value) : undefined;
7353
+ // the swap: a LARGER height is further back, so it becomes the
7354
+ // SMALLER (earlier) date
7355
+ let min = (typeof d.maximum === 'number' && isFinite(d.maximum))
7356
+ ? heightDateRound5(present - d.maximum) : undefined;
7357
+ let max = (typeof d.minimum === 'number' && isFinite(d.minimum))
7358
+ ? heightDateRound5(present - d.minimum) : undefined;
7359
+ let next = {};
7360
+ if (d.desc !== undefined) {
7361
+ next.desc = d.desc;
7362
+ }
7363
+ if (value !== undefined) {
7364
+ next.value = value;
7365
+ }
7366
+ if (min !== undefined) {
7367
+ next.minimum = min;
7368
+ }
7369
+ if (max !== undefined) {
7370
+ next.maximum = max;
7371
+ }
7372
+ // a date with no point value keeps an empty unit, as the reader
7373
+ // writes it
7374
+ next.unit = hasValue ? 'year' : '';
7375
+ n.date = next;
7376
+ });
7377
+ };
7378
+
7379
+ /**
7380
+ * The desktop's provenance sentence for a converted tree, which it appends
7381
+ * to the tree's description.
7382
+ *
7383
+ * @param anchor from forester.inferHeightDateAnchor
7384
+ * @param treeName
7385
+ * @param tipCount
7386
+ * @returns {string}
7387
+ */
7388
+ forester.heightDateDescription = function (anchor, treeName, tipCount) {
7389
+ let phrase = treeName ? ('tree named "' + treeName + '" with ') : 'a tree with ';
7390
+ let present = String(anchor.present);
7391
+ return 'Converted the node heights of ' + phrase + tipCount + (tipCount === 1 ? ' tip' : ' tips')
7392
+ + ' to calendar dates: the sampling dates in ' + anchor.agreeing + ' of ' + anchor.compared
7393
+ + ' tip labels put height 0 at ' + present + ', so each date is ' + present + ' minus the height.';
7394
+ };
7395
+
7396
+ /**
7397
+ * Converts every tree of a load whose unitless heights can be placed in
7398
+ * calendar time, appending the provenance sentence to each converted
7399
+ * tree's description. A tree that does not qualify is left exactly as it
7400
+ * was. Returns the number of trees converted.
7401
+ *
7402
+ * @param phys one tree or an array of them
7403
+ * @returns {number}
7404
+ */
7405
+ forester.convertLoadedHeightsToDates = function (phys) {
7406
+ if (!phys) {
7407
+ return 0;
7408
+ }
7409
+ let list = Array.isArray(phys) ? phys : [phys];
7410
+ let converted = 0;
7411
+ list.forEach(function (phy) {
7412
+ let anchor = forester.inferHeightDateAnchor(phy);
7413
+ if (anchor === null) {
7414
+ return;
7415
+ }
7416
+ forester.convertHeightsToDates(phy, anchor.present);
7417
+ let prov = forester.heightDateDescription(anchor, phy.name,
7418
+ forester.getAllExternalNodes(forester.getTreeRoot(phy)).length);
7419
+ phy.description = (phy.description && String(phy.description).trim().length > 0)
7420
+ ? (phy.description + ' ' + prov) : prov;
7421
+ ++converted;
7422
+ });
7423
+ return converted;
7424
+ };
7425
+
6650
7426
  // ---- axis tick mathematics -----------------------------------------
6651
7427
  // The smallest 1/2/5 x 10^k (k may be negative) step >= target.
6652
7428
  forester.niceAxisStep = function (target) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.8.0",
3
+ "version": "3.10.0",
4
4
  "description": "Archaeopteryx.js is a software tool for the visualization and analysis of highly annotated phylogenetic trees.",
5
5
  "main": "archaeopteryx.js",
6
6
  "types": "archaeopteryx.d.ts",
@@ -32,7 +32,7 @@
32
32
  "url": "git+https://github.com/cmzmasek/archaeopteryx-js.git"
33
33
  },
34
34
  "dependencies": {
35
- "phyloxml": "^1.0.2",
35
+ "phyloxml": "^1.1.0",
36
36
  "d3": "^7.9.0"
37
37
  },
38
38
  "devDependencies": {