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 +87 -6
- package/forester.js +220 -7
- package/package.json +1 -1
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
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.
|
|
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
|
|
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
|
-
|
|
8226
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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