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.
- package/archaeopteryx.js +112 -17
- package/forester.js +804 -28
- package/package.json +2 -2
package/archaeopteryx.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
24
|
-
// 2026-09-
|
|
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.
|
|
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
|
|
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
|
-
|
|
8216
|
-
|
|
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
|
-
|
|
9126
|
-
|
|
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
|
|
9148
|
-
// HAS a width: a tip dated to the day states
|
|
9149
|
-
// tick on every such tip is what made the
|
|
9150
|
-
// intervals away until 2026-09-17 (Christian;
|
|
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
|
-
|
|
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.
|
|
24
|
-
// 2026-09-
|
|
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
|
|
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
|
|
@@ -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
|
|
6523
|
-
//
|
|
6524
|
-
//
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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.
|
|
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
|
|
35
|
+
"phyloxml": "^1.1.0",
|
|
36
36
|
"d3": "^7.9.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|