archaeopteryx 3.9.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.
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.9.0
23
+ // v 3.10.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
@@ -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.9.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
  }
@@ -5651,6 +5661,14 @@ function (root, d3, forester, phyloXml) {
5651
5661
  // and a tree whose dates carry a unit is never touched. The desktop
5652
5662
  // converts at the same point, next to its internal-label policy
5653
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
+ });
5654
5672
  forester.convertLoadedHeightsToDates(trees);
5655
5673
  // the two view keys are checked up here with the other arguments,
5656
5674
  // before anything is touched (and where Node can test it)
@@ -8152,7 +8170,7 @@ function (root, d3, forester, phyloXml) {
8152
8170
  {key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
8153
8171
  {key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
8154
8172
  {key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
8155
- {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},
8156
8174
  {key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
8157
8175
  {key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
8158
8176
  {key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
@@ -8222,9 +8240,45 @@ function (root, d3, forester, phyloXml) {
8222
8240
  }
8223
8241
  }
8224
8242
 
8225
- // The Time Axis checkbox's toggle; when the distance / time tree switch
8226
- // 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.
8227
8277
  function toggleTimeAxis() {
8278
+ if (_branchScaleAvailable) {
8279
+ setBranchScale(_state.branchScale === 'time' ? 'divergence' : 'time');
8280
+ return;
8281
+ }
8228
8282
  let cb = byId(TIME_AXIS_CB);
8229
8283
  if (!cb || cb.disabled) {
8230
8284
  return;
@@ -8966,7 +9020,15 @@ function (root, d3, forester, phyloXml) {
8966
9020
  return _state.showTimeAxis === true && !radialDisplay()
8967
9021
  && _state.phylogram === true
8968
9022
  && _timeInfo !== null && _timeInfo.type !== null
8969
- && _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');
8970
9032
  }
8971
9033
 
8972
9034
  function timeAxisBottomReserve() {
@@ -12736,6 +12798,8 @@ function (root, d3, forester, phyloXml) {
12736
12798
  on(TIME_GRID_CB, 'click', timeGridCbClicked);
12737
12799
 
12738
12800
  on(LAYOUT_RECT_BUTTON, 'click', layoutButtonClicked);
12801
+ on(BRANCH_SCALE_TIME_BUTTON, 'click', branchScaleButtonClicked);
12802
+ on(BRANCH_SCALE_DIV_BUTTON, 'click', branchScaleButtonClicked);
12739
12803
 
12740
12804
  on(LAYOUT_CIRC_BUTTON, 'click', layoutButtonClicked);
12741
12805
  on(LAYOUT_UNROOTED_BUTTON, 'click', layoutButtonClicked);
@@ -13156,6 +13220,15 @@ function (root, d3, forester, phyloXml) {
13156
13220
  h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, 'phylogram display (uses branch length values) with aligned labels'));
13157
13221
  h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, ' cladogram display (ignores branch length values)'));
13158
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>');
13159
13232
  h = h.concat('</div>');
13160
13233
  h = h.concat('</fieldset>');
13161
13234
  return h;
@@ -13631,6 +13704,14 @@ function (root, d3, forester, phyloXml) {
13631
13704
  disableCheckbox('#' + PHYLOGRAM_BUTTON);
13632
13705
  disableCheckbox('#' + PHYLOGRAM_ALIGNED_BUTTON);
13633
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
+ }
13634
13715
  }
13635
13716
 
13636
13717
 
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.9.0
23
+ // v 3.10.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -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
@@ -7104,11 +7298,17 @@
7104
7298
  let agreeingTips = [];
7105
7299
  let latestStart = -Infinity;
7106
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;
7107
7305
  ranges.forEach(function (r) {
7108
7306
  if (heightDateAllows(r, mid)) {
7109
7307
  agreeingTips.push(r);
7110
7308
  latestStart = Math.max(latestStart, r[0]);
7111
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]);
7112
7312
  }
7113
7313
  });
7114
7314
  let agreeing = agreeingTips.length;
@@ -7118,7 +7318,20 @@
7118
7318
  if (latestStart <= earliestEnd) {
7119
7319
  return null; // every agreeing label range overlaps every other
7120
7320
  }
7121
- let present = Math.max(best[0], Math.min(best[1], heightDatePrecisestMedian(agreeingTips)));
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;
7122
7335
  return {present: heightDateRound5(present), agreeing: agreeing, compared: compared};
7123
7336
  };
7124
7337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.9.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",