archaeopteryx 3.4.1 → 3.5.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/README.md CHANGED
@@ -27,6 +27,7 @@ config key live and shows the exact config JSON to copy into your own
27
27
  * [Auspice / Nextstrain JSON](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=auspice)
28
28
  * [Swine H1 HA1 + alignment (Nexus)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=swh1)
29
29
  * [BEAST annotations (Nexus)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=beast)
30
+ * [Flavivirus mature peptides (10 trees)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=flavivirus)
30
31
  * [SARS-CoV-2 time tree (calendar)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=sarscov2)
31
32
  * [Herpesviridae DNA polymerase (201 tips)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=herpes_dnapol)
32
33
  * [Caliciviridae (186 strains)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=caliciviridae_500)
@@ -257,6 +258,67 @@ also resets rotation and label direction. Unrooted
257
258
  additionally greys out the aligned-phylogram option and Auto-hide Labels
258
259
  (there is no common label edge, and no even row spacing to hide against).
259
260
 
261
+ ## Rooting
262
+
263
+ The tool row's re-root button asks how: **MAD re-root (Tria et al., 2017)**
264
+ or **Midpoint re-root** (hover over the MAD entry for the full citation). A
265
+ node's menu offers **Reroot** on the branch above that node. None of them is
266
+ offered for a tree whose phyloXML says `rerootable="false"`, nor for a time
267
+ tree, one whose internal nodes are mostly dated (BEAST node heights,
268
+ Nextstrain dates, phyloXML `<date>`s): a new root would contradict the dates.
269
+ For such a tree the re-root button and the node menu's Reroot are greyed,
270
+ their tooltip saying why, and a shared view's root is ignored.
271
+
272
+ A re-root can take the meaning away from data on internal nodes: a node's
273
+ name, taxonomy, sequence, events, distribution, date, references or node
274
+ properties describe its clade, and a new root changes the clade of every
275
+ node between the old root and the new one. So before re-rooting (from the
276
+ button's menu or the node menu), the change is worked out on a copy of the
277
+ tree, and when it would change the clade of any internal node carrying data,
278
+ a warning says how many (“This tree has data on 15 internal nodes.
279
+ Re-rooting changes the clade of 3 of them, so their data may no longer
280
+ describe them.”) with **Re-root** and **Cancel**. Branch lengths, support and
281
+ MAD values, branch colours and `style:` properties do not count: they belong
282
+ to the branch, or to the look.
283
+
284
+ A tree its file declares unrooted (phyloXML `rooted="false"`, Nexus `[&U]`),
285
+ shown in the unrooted layout, has no root to measure from. There the hover
286
+ card and Display Node Data show an internal node's **Tips around** — the tips
287
+ on each of its sides, smallest first, such as `2 · 3 · 5` — instead of
288
+ distance to parent, depth and tips below; a tip shows its **Branch length**
289
+ and no depth; and the Depth from Root, Distance from Root and Clade Size
290
+ search fields are not offered. The same tree in the rectangular or circular
291
+ layout keeps all of them, since those layouts draw a root.
292
+
293
+ **MAD rooting** (minimal ancestor deviation) roots the tree without assuming
294
+ a clock. The common ancestor of two tips ought to lie halfway between them,
295
+ so every branch and position is scored by how far the tip pairs' ancestors
296
+ fall from that halfway point, and the root goes where that deviation is
297
+ smallest [1]. It has been compared with other rooting methods on prokaryotic
298
+ gene families [2]. It needs branch lengths and at least three tips, and is
299
+ offered only then. The algorithm is the desktop Archaeopteryx's, and gives
300
+ the same roots; it runs in O(n²) time and O(n) memory (the 13,246-tip H5N1
301
+ demo tree roots in 0.4 s, measured in Node).
302
+
303
+ Every internal branch then carries its **MAD value**: the root-mean-square
304
+ deviation the tree would have with the root on that branch. Lower is better,
305
+ and the root's branch has the smallest. The **MAD Values** checkbox (Display
306
+ Data → Labels, present while the tree carries them) writes them on the
307
+ branches, ahead of any support value, as `MAD/support`: `0.02/95`. They are
308
+ not support, so the Confidence labels, Support Dots and the Confidence search
309
+ field leave them out. Midpoint or manual re-rooting removes them, since they
310
+ describe the MAD rooting only. A shared view remembers a MAD root. A phyloXML
311
+ download keeps them as `<confidence type="MAD">`, as the desktop writes them;
312
+ a Newick or Nexus download never puts one where a support value goes.
313
+
314
+ 1. Tria, F.D.K., Landan, G., Dagan, T. (2017). Phylogenetic rooting using
315
+ minimal ancestor deviation. *Nature Ecology & Evolution*, 1, 0193.
316
+ <https://www.nature.com/articles/s41559-017-0193>
317
+ 2. Wade, T., Rangel, L.T., Kundu, S., Fournier, G.P., Bansal, M.S. (2020).
318
+ Assessing the accuracy of phylogenetic rooting methods on prokaryotic
319
+ gene families. *PLOS ONE*, 15(5), e0232950.
320
+ <https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0232950>
321
+
260
322
  ## Metadata tables
261
323
 
262
324
  A tree file rarely carries everything known about its tips. A **metadata
@@ -114,15 +114,15 @@ export interface ViewState {
114
114
  display?: 'phylogram' | 'aligned' | 'cladogram';
115
115
  /** The ladderize direction applied. */
116
116
  order?: 'asc' | 'desc';
117
- /** Midpoint re-rooted. */
118
- root?: 'midpoint';
117
+ /** Re-rooted: at the midpoint, or by minimal ancestor deviation. */
118
+ root?: 'midpoint' | 'mad';
119
119
  subtree?: number;
120
120
  collapsed?: number[];
121
121
  /** A visualization id (as the Color-by menu values them), or 'none'. */
122
122
  colorBy?: string;
123
123
  shapeBy?: string;
124
124
  /** The panel's checked boxes: name, taxonomy, sequence, confidence,
125
- * branchLength, external, internal, nodeEvents, branchEvents,
125
+ * madValues, branchLength, external, internal, nodeEvents, branchEvents,
126
126
  * supportDots, shortNames, autoHide, visualizations, visualStyles, and
127
127
  * custom:<key> for a nodeLabels checkbox. */
128
128
  show?: string[];
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.4.1
23
+ // v 3.5.0
24
24
  // 2026-09-10
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.4.1';
106
+ const VERSION = '3.5.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';
@@ -350,6 +350,7 @@ function (root, d3, forester, phyloXml) {
350
350
  const CLADOGRAM_BUTTON = 'cla_b';
351
351
  const CONFIDENCE_VALUES_CB = 'conf_cb';
352
352
  const SUPPORT_DOTS_CB = 'suppdots_cb';
353
+ const MAD_VALUES_CB = 'mad_cb';
353
354
  const DOWNLOAD_BUTTON = 'dl_b';
354
355
  const DYNAHIDE_CB = 'dynahide_cb';
355
356
  const MSA_CB = 'msa_cb';
@@ -371,6 +372,7 @@ function (root, d3, forester, phyloXml) {
371
372
  const INTERNAL_LABEL_CB = 'intl_cb';
372
373
  const LABEL_COLOR_SELECT_MENU = 'lcs_menu';
373
374
  const MIDPOINT_ROOT_BUTTON = 'midpointr_b';
375
+ const MAD_CITATION = 'Tria F, Landan G, Dagan T. Phylogenetic rooting using minimal ancestor deviation. Nature Ecology and Evolution. 2017;1:0193';
374
376
  const UNCOLLAPSE_ALL_BUTTON = 'uncollapse_all_b';
375
377
  const TREE_PREV_BUTTON = 'tree_prev_b';
376
378
  const TREE_NEXT_BUTTON = 'tree_next_b';
@@ -559,6 +561,7 @@ function (root, d3, forester, phyloXml) {
559
561
  const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, FigTree-like
560
562
  const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
561
563
  let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
564
+ let _timeTree = false; // forester.isTimeTree: never re-rooted
562
565
  let _clusterH = 0; // the cluster layout's vertical extent, set per render
563
566
  let _docListenersBound = false; // page-level key/wheel handlers bind once, not per launch
564
567
  let _docListeners = []; // ...and destroy() can take every one of them down again
@@ -1462,9 +1465,19 @@ function (root, d3, forester, phyloXml) {
1462
1465
  menu.appendChild(document.createElement('hr'));
1463
1466
  return;
1464
1467
  }
1468
+ if (item.note) { // a sentence to read before choosing, such as a warning
1469
+ let note = document.createElement('div');
1470
+ note.className = 'aptx-node-menu-note';
1471
+ note.textContent = item.note;
1472
+ menu.appendChild(note);
1473
+ return;
1474
+ }
1465
1475
  let b = document.createElement('button');
1466
1476
  b.type = 'button';
1467
1477
  b.textContent = item.label;
1478
+ if (item.title) {
1479
+ b.title = item.title;
1480
+ }
1468
1481
  if (item.danger) {
1469
1482
  b.className = 'aptx-menu-danger';
1470
1483
  }
@@ -3200,7 +3213,7 @@ function (root, d3, forester, phyloXml) {
3200
3213
  if (_state.showBranchLengthValues) {
3201
3214
  nodeChild('text.bllabel').text(makeBranchLengthLabel);
3202
3215
  }
3203
- if (_state.showConfidenceValues) {
3216
+ if (_state.showConfidenceValues || _state.showMadValues) {
3204
3217
  nodeChild('text.conflabel').text(makeConfidenceValuesLabel);
3205
3218
  }
3206
3219
  if (_state.showBranchEvents) {
@@ -3811,7 +3824,7 @@ function (root, d3, forester, phyloXml) {
3811
3824
 
3812
3825
  function syncOptionalNodeChildren(node) {
3813
3826
  let wantBl = _state.showBranchLengthValues === true;
3814
- let wantConf = _state.showConfidenceValues === true;
3827
+ let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
3815
3828
  let wantEvent = _state.showBranchEvents === true;
3816
3829
  let wantDot = _state.showSupportDots === true;
3817
3830
 
@@ -4080,39 +4093,32 @@ function (root, d3, forester, phyloXml) {
4080
4093
  }
4081
4094
  };
4082
4095
 
4096
+ // A branch's values as "MAD/support", as the desktop writes them: the MAD
4097
+ // value (from MAD rooting, to two decimals, 0 included -- low is good)
4098
+ // while MAD Values is on, then the support values while Confidence is on,
4099
+ // those only when one reaches the minimum. A MAD value is never support.
4083
4100
  let makeConfidenceValuesLabel = function (phynode) {
4084
- if (phynode.confidences && phynode.confidences.length > 0) {
4085
- let c = phynode.confidences;
4086
- let cl = c.length;
4087
- if (_state.minConfidenceValueToShow) {
4088
- let show = false;
4089
- for (let i = 0; i < cl; ++i) {
4090
- if (c[i].value >= _state.minConfidenceValueToShow) {
4091
- show = true;
4092
- break;
4093
- }
4094
- }
4095
- if (!show) {
4096
- return;
4097
- }
4101
+ if (!phynode.confidences || phynode.confidences.length === 0) {
4102
+ return;
4103
+ }
4104
+ let parts = [];
4105
+ let support = [];
4106
+ phynode.confidences.forEach(function (c) {
4107
+ if (c.type !== forester.MAD_CONFIDENCE_TYPE) {
4108
+ support.push(c);
4109
+ } else if (_state.showMadValues && typeof c.value === 'number' && isFinite(c.value)) {
4110
+ parts.push(+c.value.toFixed(2));
4098
4111
  }
4099
- if (cl === 1) {
4100
- if (c[0].value) {
4101
- return +c[0].value.toFixed(CONFIDENCE_VALUE_DIGITS_DEFAULT);
4102
- }
4103
- } else {
4104
- let s = "";
4105
- for (let ii = 0; ii < cl; ++ii) {
4106
- if (c[ii].value) {
4107
- if (ii > 0) {
4108
- s += "/";
4109
- }
4110
- s += +c[ii].value.toFixed(CONFIDENCE_VALUE_DIGITS_DEFAULT);
4111
- }
4112
+ });
4113
+ if (_state.showConfidenceValues && support.length > 0
4114
+ && (!_state.minConfidenceValueToShow || support.some(function (c) { return c.value >= _state.minConfidenceValueToShow; }))) {
4115
+ support.forEach(function (c) {
4116
+ if (c.value) {
4117
+ parts.push(+c.value.toFixed(CONFIDENCE_VALUE_DIGITS_DEFAULT));
4112
4118
  }
4113
- return s;
4114
- }
4119
+ });
4115
4120
  }
4121
+ return parts.length > 0 ? parts.join('/') : undefined;
4116
4122
  };
4117
4123
 
4118
4124
  let makeBranchEventsLabel = function (phynode) {
@@ -4173,12 +4179,23 @@ function (root, d3, forester, phyloXml) {
4173
4179
  return (branchWidth + SUPPORT_DOT_EXTRA_DIAMETER) / 2;
4174
4180
  }
4175
4181
 
4182
+ // A branch's first confidence that is support: a MAD value (MAD rooting)
4183
+ // rates a root position, not the clade.
4184
+ function firstSupportValue(d) {
4185
+ for (let i = 0; i < d.confidences.length; ++i) {
4186
+ if (d.confidences[i].type !== forester.MAD_CONFIDENCE_TYPE) {
4187
+ return d.confidences[i].value;
4188
+ }
4189
+ }
4190
+ return undefined;
4191
+ }
4192
+
4176
4193
  function showSupportDot(d) {
4177
4194
  if (!_state.showSupportDots || !d.parent
4178
4195
  || !d.confidences || d.confidences.length === 0) {
4179
4196
  return false;
4180
4197
  }
4181
- let v = d.confidences[0].value;
4198
+ let v = firstSupportValue(d);
4182
4199
  if (typeof v !== 'number' || !isFinite(v) || v < supportDotThreshold()) {
4183
4200
  return false;
4184
4201
  }
@@ -4763,6 +4780,7 @@ function (root, d3, forester, phyloXml) {
4763
4780
  // elements by forester.timeAxisInfo -- again unless the caller set
4764
4781
  // showTimeAxis explicitly.
4765
4782
  _timeInfo = _treeData ? forester.timeAxisInfo(forester.getTreeRoot(_treeData)) : null;
4783
+ _timeTree = _treeData ? forester.isTimeTree(_treeData) : false;
4766
4784
  if (_state.showTimeAxis === undefined) {
4767
4785
  _state.showTimeAxis = !!(_timeInfo && _timeInfo.type);
4768
4786
  }
@@ -5877,14 +5895,25 @@ function (root, d3, forester, phyloXml) {
5877
5895
  update(null, 0);
5878
5896
  }});
5879
5897
  }
5880
- if (!_in_subtree && d.parent && d.parent.parent
5881
- && ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
5882
- items.push({label: 'Reroot', action: function () {
5883
- rerootKeepingCollapse(function () {
5884
- forester.reRoot(tree, d, -1);
5885
- });
5886
- zoomToFit();
5887
- }});
5898
+ if (!_in_subtree && d.parent && d.parent.parent) {
5899
+ let blocked = rerootBlockedReason();
5900
+ if (blocked) {
5901
+ // offered but greyed, saying why, as the desktop does
5902
+ items.push({label: 'Reroot', disabled: true, title: blocked, action: function () {
5903
+ }});
5904
+ } else {
5905
+ items.push({label: 'Reroot', action: function () {
5906
+ confirmRerooting(tree, 'node', d, event, function () {
5907
+ rerootKeepingCollapse(function () {
5908
+ forester.removeMadConfidences(tree); // they rate the MAD rooting only
5909
+ forester.reRoot(tree, d, -1);
5910
+ });
5911
+ _basicTreeProperties = forester.collectBasicTreeProperties(_root);
5912
+ syncMadValuesCheckbox();
5913
+ zoomToFit();
5914
+ });
5915
+ }});
5916
+ }
5888
5917
  }
5889
5918
  if (_settings.enableManualNodeSelection) {
5890
5919
  items.push({label: 'Select/Deselect Node', action: function () { selectDeselectNode(d); }});
@@ -7106,6 +7135,7 @@ function (root, d3, forester, phyloXml) {
7106
7135
  ['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
7107
7136
  ['sequence', SEQUENCE_CB, 'showSequence'],
7108
7137
  ['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
7138
+ ['madValues', MAD_VALUES_CB, 'showMadValues'],
7109
7139
  ['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
7110
7140
  ['external', EXTERNAL_LABEL_CB, 'showExternalLabels'],
7111
7141
  ['internal', INTERNAL_LABEL_CB, 'showInternalLabels'],
@@ -7271,12 +7301,9 @@ function (root, d3, forester, phyloXml) {
7271
7301
  launchInto(_container, _trees, s.tree, cfg);
7272
7302
  return;
7273
7303
  }
7274
- if (s.root === 'midpoint' && _viewOps.root !== 'midpoint'
7275
- && (_treeData.rerootable === undefined || _treeData.rerootable === true)) {
7276
- rerootKeepingCollapse(function () {
7277
- forester.midpointRoot(_root_const);
7278
- });
7279
- _viewOps.root = 'midpoint';
7304
+ if ((s.root === 'midpoint' || s.root === 'mad') && _viewOps.root !== s.root
7305
+ && rerootingAllowed()) {
7306
+ rootTreeBy(s.root);
7280
7307
  }
7281
7308
  if (s.order === 'asc' || s.order === 'desc') {
7282
7309
  ladderizeSubtree(_root_const, s.order === 'asc', false);
@@ -7309,6 +7336,9 @@ function (root, d3, forester, phyloXml) {
7309
7336
  if (s.layout === 'rectangular' || s.layout === 'circular' || s.layout === 'unrooted') {
7310
7337
  _state.circularDisplay = s.layout === 'circular';
7311
7338
  _state.unrootedDisplay = s.layout === 'unrooted';
7339
+ if (_treeData && _treeData.rooted === false && byId(SEARCH_FIELD_SELECT_0)) {
7340
+ populateSearchMenus(); // before the view's searches pick their fields
7341
+ }
7312
7342
  }
7313
7343
  if (s.display === 'phylogram' || s.display === 'aligned' || s.display === 'cladogram') {
7314
7344
  let measured = _basicTreeProperties.branchLengths === true;
@@ -8801,11 +8831,86 @@ function (root, d3, forester, phyloXml) {
8801
8831
  }
8802
8832
  }
8803
8833
 
8804
- // Midpoint re-rooting rearranges the whole tree and its button is easy to
8805
- // hit by accident, so it asks first -- through the same little popup the
8806
- // node menu uses (click anywhere else or press Esc to cancel).
8834
+ // Why the tree cannot be re-rooted, or null when it can. Every way of
8835
+ // re-rooting asks -- the re-root button and its menu, the node menu's
8836
+ // Reroot, a shared view's root: never a tree its file marks
8837
+ // rerootable="false", never a time tree. The reason is the greyed
8838
+ // controls' tooltip, as on the desktop.
8839
+ function rerootBlockedReason() {
8840
+ if (!_treeData) {
8841
+ return 'no tree';
8842
+ }
8843
+ if (_treeData.rerootable === false) {
8844
+ return 'This tree is marked as not re-rootable (rerootable="false").';
8845
+ }
8846
+ if (_timeTree) {
8847
+ return 'Time trees can\'t be re-rooted: their branch lengths are times measured from this root.';
8848
+ }
8849
+ return null;
8850
+ }
8851
+
8852
+ function rerootingAllowed() {
8853
+ return rerootBlockedReason() === null;
8854
+ }
8855
+
8856
+ // MAD rooting needs branch lengths, three tips, and a tree that may be
8857
+ // re-rooted at all.
8858
+ function madRootingPossible() {
8859
+ return rerootingAllowed()
8860
+ && !!_basicTreeProperties && !!_basicTreeProperties.branchLengths
8861
+ && _basicTreeProperties.externalNodesCount >= 3;
8862
+ }
8863
+
8864
+ // Roots the whole tree by 'mad' or 'midpoint', from the re-root menu or a
8865
+ // shared view. MAD values rate the MAD rooting only, so any other rooting
8866
+ // removes them.
8867
+ function rootTreeBy(method) {
8868
+ let rooted = true;
8869
+ rerootKeepingCollapse(function () {
8870
+ if (method === 'mad') {
8871
+ rooted = forester.madRoot(_root_const);
8872
+ } else {
8873
+ forester.removeMadConfidences(_root_const);
8874
+ forester.midpointRoot(_root_const);
8875
+ }
8876
+ });
8877
+ if (rooted) {
8878
+ _viewOps.root = method; // what a shared view replays
8879
+ }
8880
+ _basicTreeProperties = forester.collectBasicTreeProperties(_root_const);
8881
+ syncMadValuesCheckbox();
8882
+ }
8883
+
8884
+ // Before a re-root from the panel or the node menu (a shared view never
8885
+ // asks: whoever shared it chose): when it would change the clade of
8886
+ // internal nodes carrying data, say so where the choice was made, and
8887
+ // re-root only on "Re-root". Otherwise re-root at once.
8888
+ function confirmRerooting(phy, method, node, anchor, proceed) {
8889
+ let effect = forester.cladesChangedByRerooting(phy, method, node);
8890
+ let k = effect.changed.length;
8891
+ if (k === 0) {
8892
+ proceed();
8893
+ return;
8894
+ }
8895
+ let n = effect.annotated.length;
8896
+ let text = (n === 1)
8897
+ ? 'This tree has data on 1 internal node. Re-rooting changes its clade, so its data may no longer describe it.'
8898
+ : 'This tree has data on ' + n + ' internal nodes. Re-rooting changes the clade of ' + k + ' of them, so '
8899
+ + (k === 1 ? 'its data may no longer describe it.' : 'their data may no longer describe them.');
8900
+ showNodeMenu([
8901
+ {note: text},
8902
+ {label: 'Re-root', action: proceed},
8903
+ {label: 'Cancel', action: function () {
8904
+ }}
8905
+ ], anchor, 'Re-root tree');
8906
+ }
8907
+
8908
+ // Re-rooting rearranges the whole tree and its button is easy to hit by
8909
+ // accident, so it asks first -- through the same little popup the node
8910
+ // menu uses (click anywhere else or press Esc to cancel) -- and the popup
8911
+ // is where the method is picked.
8807
8912
  function midpointRootButtonPressed(event) {
8808
- if (!_in_subtree && _root && ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
8913
+ if (!_in_subtree && _root && rerootingAllowed()) {
8809
8914
  let ev = event;
8810
8915
  if (!ev || ev.pageX === undefined || (ev.pageX === 0 && ev.pageY === 0)) {
8811
8916
  // keyboard/synthetic invocation: anchor the popup at the button
@@ -8815,21 +8920,30 @@ function (root, d3, forester, phyloXml) {
8815
8920
  ev = {pageX: window.scrollX + r.right + 4, pageY: window.scrollY + r.top};
8816
8921
  }
8817
8922
  }
8818
- showNodeMenu([
8819
- {
8820
- label: 'Midpoint re-root', action: function () {
8821
- rerootKeepingCollapse(function () {
8822
- forester.midpointRoot(_root);
8923
+ let items = [];
8924
+ if (madRootingPossible()) {
8925
+ items.push({
8926
+ label: 'MAD re-root (Tria et al., 2017)', title: MAD_CITATION, action: function () {
8927
+ confirmRerooting(_root_const, 'mad', null, ev, function () {
8928
+ rootTreeBy('mad');
8929
+ zoomToFit();
8823
8930
  });
8824
- _viewOps.root = 'midpoint'; // what a shared view replays
8825
- zoomToFit();
8826
- }
8827
- },
8828
- {
8829
- label: 'Cancel', action: function () {
8830
8931
  }
8932
+ });
8933
+ }
8934
+ items.push({
8935
+ label: 'Midpoint re-root', action: function () {
8936
+ confirmRerooting(_root_const, 'midpoint', null, ev, function () {
8937
+ rootTreeBy('midpoint');
8938
+ zoomToFit();
8939
+ });
8940
+ }
8941
+ });
8942
+ items.push({
8943
+ label: 'Cancel', action: function () {
8831
8944
  }
8832
- ], ev, 'midpoint re-root the tree?');
8945
+ });
8946
+ showNodeMenu(items, ev, 're-root the tree?');
8833
8947
  }
8834
8948
  }
8835
8949
 
@@ -9034,7 +9148,11 @@ function (root, d3, forester, phyloXml) {
9034
9148
  // then set up each box's mode menu. Called when a tree is (re)loaded.
9035
9149
  function populateSearchMenus() {
9036
9150
  let before = _searchFields;
9037
- _searchFields = forester.availableSearchFields(_root);
9151
+ // an unrooted tree in the unrooted layout has no root to measure from
9152
+ let rootMeasured = [forester.searchFields.depth, forester.searchFields.distanceFromRoot, forester.searchFields.cladeSize];
9153
+ _searchFields = forester.availableSearchFields(_root).filter(function (f) {
9154
+ return !unrootedContext() || rootMeasured.indexOf(f) < 0;
9155
+ });
9038
9156
  [SEARCH_FIELD_SELECT_0, SEARCH_FIELD_SELECT_1].forEach(function (selId) {
9039
9157
  let sel = byId(selId);
9040
9158
  if (!sel) return;
@@ -9369,6 +9487,11 @@ function (root, d3, forester, phyloXml) {
9369
9487
  function layoutButtonClicked() {
9370
9488
  _state.circularDisplay = getCheckboxValue(LAYOUT_CIRC_BUTTON);
9371
9489
  _state.unrootedDisplay = getCheckboxValue(LAYOUT_UNROOTED_BUTTON);
9490
+ if (_treeData && _treeData.rooted === false) {
9491
+ populateSearchMenus(); // the root-measured search fields go and come with the unrooted layout
9492
+ search0();
9493
+ search1();
9494
+ }
9372
9495
  if (radialDisplay() && _state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
9373
9496
  // the domain boxes ride the tips' spokes, so the labels must too
9374
9497
  _radialLabelsHorizontal = false;
@@ -9529,6 +9652,31 @@ function (root, d3, forester, phyloXml) {
9529
9652
  scheduleUpdate();
9530
9653
  }
9531
9654
 
9655
+ function madValuesCbClicked() {
9656
+ _state.showMadValues = getCheckboxValue(MAD_VALUES_CB);
9657
+ scheduleUpdate();
9658
+ }
9659
+
9660
+ // The MAD Values checkbox is built for every tree MAD rooting could root
9661
+ // and shows while the tree carries MAD values: after MAD rooting, or from
9662
+ // a file saved after it. Any other rooting removes the values, and the
9663
+ // checkbox goes with them. Inside a subtree the whole tree's state stands.
9664
+ function syncMadValuesCheckbox() {
9665
+ let cb = byId(MAD_VALUES_CB);
9666
+ if (!cb || _in_subtree) {
9667
+ return;
9668
+ }
9669
+ let present = !!(_basicTreeProperties && _basicTreeProperties.madValues);
9670
+ if (!present) {
9671
+ _state.showMadValues = false;
9672
+ }
9673
+ setCheckboxValue(MAD_VALUES_CB, _state.showMadValues === true);
9674
+ let item = cb.closest('label');
9675
+ if (item) {
9676
+ item.style.display = present ? '' : 'none';
9677
+ }
9678
+ }
9679
+
9532
9680
  function branchLengthsCbClicked() {
9533
9681
  _state.showBranchLengthValues = getCheckboxValue(BRANCH_LENGTH_VALUES_CB);
9534
9682
  scheduleUpdate();
@@ -10249,6 +10397,7 @@ function (root, d3, forester, phyloXml) {
10249
10397
  + '.aptx-node-menu button.aptx-menu-danger:hover, .aptx-node-menu button.aptx-menu-danger:focus-visible {'
10250
10398
  + ' background:#e5484d; color:#fff; }'
10251
10399
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
10400
+ + '.aptx-node-menu-note { padding:4px 9px 7px; color:var(--p-ink); white-space:normal; }'
10252
10401
  // The search suggestions: the node menu's chrome under the value box.
10253
10402
  + '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
10254
10403
  + ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
@@ -10731,6 +10880,32 @@ function (root, d3, forester, phyloXml) {
10731
10880
  // Content arrives as "Label: value" lines separated by <br>. Setting the
10732
10881
  // label part apart makes a wall of such lines scannable. Lines without a
10733
10882
  // label (a heading like "Taxonomy", or a FASTA sequence) are left alone.
10883
+ // The unrooted layout of a tree its file declares unrooted (phyloXML
10884
+ // rooted="false", Nexus [&U]; a plain Newick tree declares nothing). The
10885
+ // unrooted layout of a rooted tree still has its root.
10886
+ function unrootedContext() {
10887
+ return _state.unrootedDisplay === true && !!_treeData && _treeData.rooted === false;
10888
+ }
10889
+
10890
+ // The tips on each side of an internal node of an unrooted tree, one
10891
+ // count per neighbour, smallest first: what "tips below" becomes when
10892
+ // there is no below.
10893
+ function tipsAround(d) {
10894
+ let sides = d.children.map(function (c) {
10895
+ return forester.calcSumOfAllExternalDescendants(c);
10896
+ });
10897
+ let below = sides.reduce(function (a, b) {
10898
+ return a + b;
10899
+ }, 0);
10900
+ let all = forester.calcSumOfAllExternalDescendants(forester.getTreeRoot(_treeData));
10901
+ if (all > below) {
10902
+ sides.push(all - below);
10903
+ }
10904
+ return sides.sort(function (a, b) {
10905
+ return a - b;
10906
+ });
10907
+ }
10908
+
10734
10909
  // The node's data as the hover tooltip and the "Display Node Data" dialog
10735
10910
  // both show it -- ONE builder, because they used to be two near-copies
10736
10911
  // and carried the same two bugs twice (a bare "Date: " line whenever a
@@ -10744,13 +10919,19 @@ function (root, d3, forester, phyloXml) {
10744
10919
  // the section above it. markUpDataLabels() renders the result: a line
10745
10920
  // shaped "Key: value" is a row, anything else a heading, and a leading
10746
10921
  // "- " marks a row as a section's sub-entry.
10922
+ //
10923
+ // A tree its file declares unrooted, shown in the unrooted layout, has no
10924
+ // root to measure from (unrootedContext): an internal node shows no
10925
+ // distance to parent, depth or tips below but the tips on each of its
10926
+ // sides, and a tip's branch is just its branch length -- as on the desktop.
10747
10927
  function nodeDataText(d) {
10748
10928
  let text = '';
10929
+ let unrooted = unrootedContext();
10749
10930
  if (d.name) {
10750
10931
  text += 'Name: ' + d.name + '<br>';
10751
10932
  }
10752
- if (d.branch_length) {
10753
- text += 'Distance to parent: ' + d.branch_length + '<br>';
10933
+ if (d.branch_length && !(unrooted && d.children)) {
10934
+ text += (unrooted ? 'Branch length: ' : 'Distance to parent: ') + d.branch_length + '<br>';
10754
10935
  }
10755
10936
  let date = dateText(d.date);
10756
10937
  if (date) {
@@ -10764,9 +10945,13 @@ function (root, d3, forester, phyloXml) {
10764
10945
  }
10765
10946
  }
10766
10947
  }
10767
- text += 'Depth: ' + forester.calcDepth(d) + '<br>';
10768
- if (d.children) {
10769
- text += 'Tips below: ' + forester.calcSumOfAllExternalDescendants(d) + '<br>';
10948
+ if (!unrooted) {
10949
+ text += 'Depth: ' + forester.calcDepth(d) + '<br>';
10950
+ if (d.children) {
10951
+ text += 'Tips below: ' + forester.calcSumOfAllExternalDescendants(d) + '<br>';
10952
+ }
10953
+ } else if (d.children) {
10954
+ text += 'Tips around: ' + tipsAround(d).join(' · ') + '<br>';
10770
10955
  }
10771
10956
  if (d.confidences) {
10772
10957
  for (let i = 0; i < d.confidences.length; ++i) {
@@ -11359,6 +11544,7 @@ function (root, d3, forester, phyloXml) {
11359
11544
 
11360
11545
  on(CONFIDENCE_VALUES_CB, 'click', confidenceValuesCbClicked);
11361
11546
  on(SUPPORT_DOTS_CB, 'click', supportDotsCbClicked);
11547
+ on(MAD_VALUES_CB, 'click', madValuesCbClicked);
11362
11548
  on(SEARCH_B_TOGGLE, 'click', revealSearchB);
11363
11549
  on(SEARCH_NAV_PREV, 'click', function () {
11364
11550
  stepToFoundNode(-1);
@@ -11867,6 +12053,9 @@ function (root, d3, forester, phyloXml) {
11867
12053
  if (_basicTreeProperties.confidences) {
11868
12054
  labels.push(makeCheckboxItem('Confidence', CONFIDENCE_VALUES_CB, 'to show/hide confidence values'));
11869
12055
  }
12056
+ if (_basicTreeProperties.madValues || madRootingPossible()) {
12057
+ labels.push(makeCheckboxItem('MAD Values', MAD_VALUES_CB, 'to show/hide MAD values: the ancestor deviation were the root on that branch (lower is better, the root branch has the smallest)'));
12058
+ }
11870
12059
  if (_basicTreeProperties.branchLengths) {
11871
12060
  labels.push(makeCheckboxItem('Branch Length', BRANCH_LENGTH_VALUES_CB, 'to show/hide branch length values'));
11872
12061
  }
@@ -11955,7 +12144,7 @@ function (root, d3, forester, phyloXml) {
11955
12144
  h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
11956
12145
  h = h.concat(makeGlyphButton('up_one_level', RETURN_TO_SUPERTREE_BUTTON_BY_ONE, 'move up by one level towards the complete tree (if in a sub-tree)'));
11957
12146
  h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
11958
- h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 'midpoint re-root'));
12147
+ h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 're-root the tree: MAD or midpoint'));
11959
12148
  h = h.concat('</div>');
11960
12149
  h = h.concat('</fieldset>');
11961
12150
  return h;
@@ -12190,6 +12379,7 @@ function (root, d3, forester, phyloXml) {
12190
12379
  setCheckboxValue(SEQUENCE_CB, _state.showSequence)
12191
12380
  setCheckboxValue(CONFIDENCE_VALUES_CB, _state.showConfidenceValues);
12192
12381
  setCheckboxValue(SUPPORT_DOTS_CB, _state.showSupportDots);
12382
+ syncMadValuesCheckbox();
12193
12383
  setCheckboxValue(BRANCH_LENGTH_VALUES_CB, _state.showBranchLengthValues);
12194
12384
  setCheckboxValue(NODE_EVENTS_CB, _state.showNodeEvents);
12195
12385
  setCheckboxValue(BRANCH_EVENTS_CB, _state.showBranchEvents);
@@ -12308,10 +12498,16 @@ function (root, d3, forester, phyloXml) {
12308
12498
  disableButton(byId(RETURN_TO_SUPERTREE_BUTTON));
12309
12499
  }
12310
12500
 
12311
- if (!_in_subtree && ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
12312
- enableButton(byId(MIDPOINT_ROOT_BUTTON));
12501
+ let rerootButton = byId(MIDPOINT_ROOT_BUTTON);
12502
+ let blocked = rerootBlockedReason();
12503
+ if (!_in_subtree && !blocked) {
12504
+ enableButton(rerootButton);
12313
12505
  } else {
12314
- disableButton(byId(MIDPOINT_ROOT_BUTTON));
12506
+ disableButton(rerootButton);
12507
+ }
12508
+ if (rerootButton) {
12509
+ rerootButton.title = blocked
12510
+ || (_in_subtree ? 'return to the whole tree to re-root it' : 're-root the tree: MAD or midpoint');
12315
12511
  }
12316
12512
  let b;
12317
12513
  if (_foundNodes0 && !_searchBox0Empty) {
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.4.1
23
+ // v 3.5.0
24
24
  // 2026-09-10
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -252,7 +252,9 @@
252
252
  if (!node) {
253
253
  throw ("cannot re-root on null node");
254
254
  }
255
- if (!branchLength) {
255
+ // no position: the middle of the branch. 0 is a position -- the root
256
+ // right at the node, where MAD rooting can put it.
257
+ if (typeof branchLength !== 'number' || isNaN(branchLength)) {
256
258
  branchLength = -1;
257
259
  }
258
260
  if (forester.isString(node)) {
@@ -449,6 +451,510 @@
449
451
  }
450
452
  };
451
453
 
454
+ /** The confidence type of the per-branch values madRoot records. */
455
+ forester.MAD_CONFIDENCE_TYPE = 'MAD';
456
+
457
+ const MAD_EPSILON = 1e-9;
458
+
459
+ /**
460
+ * Roots the tree by Minimal Ancestor Deviation (Tria, Landan & Dagan,
461
+ * Nature Ecology & Evolution 1, 0193, 2017; doi:10.1038/s41559-017-0193),
462
+ * ported from the desktop's PhylogenyMethods.madRoot.
463
+ *
464
+ * Under a (relaxed) clock the ancestor of two tips is equidistant from
465
+ * both; the pair (i,j) with ancestor a deviates by |2*dist(a,i)/dist(i,j) - 1|.
466
+ * For every branch the root position minimizing the summed squared
467
+ * deviation of all tip pairs is found analytically, and the branch and
468
+ * position with the smallest total become the root.
469
+ *
470
+ * Every internal branch gets a confidence of type 'MAD': the root-mean-
471
+ * square deviation were the root placed on that branch -- low is good,
472
+ * and the root's branch carries the smallest. Pendant branches get none,
473
+ * and MAD values from an earlier run are replaced.
474
+ *
475
+ * A no-op for fewer than three tips or a tree without branch lengths.
476
+ * O(n^2) time and O(n) memory: the desktop fills an n x n distance matrix
477
+ * only to sum its columns (1.4 GB at 13,000 tips), so the column sums are
478
+ * added up here as the pairs are met, and a subtree's tips are a range of
479
+ * tip numbers rather than a list.
480
+ *
481
+ * @param phy the tree
482
+ * @returns {boolean} whether the tree was re-rooted
483
+ */
484
+ forester.madRoot = function (phy) {
485
+ let root = forester.getTreeRoot(phy);
486
+ if (!root) {
487
+ return false;
488
+ }
489
+ let t = madTraversal(root);
490
+ let pre = t.pre;
491
+ let kids = t.kids;
492
+ let post = t.post;
493
+ let m = pre.length;
494
+ let tipNo = new Int32Array(m).fill(-1);
495
+ let tipPos = [];
496
+ for (let k = 0; k < m; ++k) {
497
+ if (kids[k].length === 0) {
498
+ tipNo[k] = tipPos.length;
499
+ tipPos.push(k);
500
+ }
501
+ }
502
+ let n = tipPos.length;
503
+ if (n < 3) {
504
+ return false;
505
+ }
506
+ // depth (distance from the current root) of every node
507
+ let depth = new Float64Array(m);
508
+ let maxDepth = 0;
509
+ for (let k = 1; k < m; ++k) {
510
+ depth[k] = depth[t.parentOf[k]] + madLength(pre[k]);
511
+ maxDepth = Math.max(maxDepth, depth[k]);
512
+ }
513
+ if (maxDepth <= 0) {
514
+ return false; // no usable branch lengths
515
+ }
516
+ let tipDepth = new Float64Array(n);
517
+ for (let i = 0; i < n; ++i) {
518
+ tipDepth[i] = depth[tipPos[i]];
519
+ }
520
+ // Pass 1 (post-order): the within-subtree deviation sums, and the
521
+ // column sums sum_i 1/d^2 and sum_i 1/d of every tip. For a pair whose
522
+ // common ancestor sits at depth dm, d = depth[i] + depth[j] - 2*dm and
523
+ // the deviation is 2*(depth[i]-dm)/d - 1.
524
+ let lo = new Int32Array(m); // a subtree's tips are the numbers lo..hi-1
525
+ let hi = new Int32Array(m);
526
+ let down = new Float64Array(m); // sum dev^2 over pairs with their ancestor inside the subtree
527
+ let w0 = new Float64Array(m); // sum_{i!=j in subtree} 1/d^2 (ordered)
528
+ let w1 = new Float64Array(m); // sum_{i!=j in subtree} (2*depth[j]/d^2 - 1/d) (j second)
529
+ let w2 = new Float64Array(m); // sum_{i!=j in subtree} (2*depth[j]/d - 1)^2 (j second)
530
+ let col0 = new Float64Array(n);
531
+ let colInv = new Float64Array(n);
532
+ let partners = new Int32Array(n); // the tips each tip is apart from (d > MAD_EPSILON)
533
+ for (let q = 0; q < m; ++q) {
534
+ let k = post[q];
535
+ let ch = kids[k];
536
+ if (ch.length === 0) {
537
+ lo[k] = tipNo[k];
538
+ hi[k] = tipNo[k] + 1;
539
+ continue;
540
+ }
541
+ let dm = depth[k];
542
+ let dwn = 0, sw0 = 0, sw1 = 0, sw2 = 0;
543
+ for (let c = 0; c < ch.length; ++c) {
544
+ dwn += down[ch[c]];
545
+ sw0 += w0[ch[c]];
546
+ sw1 += w1[ch[c]];
547
+ sw2 += w2[ch[c]];
548
+ }
549
+ // the pairs whose ancestor is this node: one tip from each of two children
550
+ for (let a = 0; a < ch.length; ++a) {
551
+ for (let b = a + 1; b < ch.length; ++b) {
552
+ for (let i = lo[ch[a]]; i < hi[ch[a]]; ++i) {
553
+ let di = tipDepth[i];
554
+ for (let j = lo[ch[b]]; j < hi[ch[b]]; ++j) {
555
+ let dj = tipDepth[j];
556
+ let dij = (di - dm) + (dj - dm);
557
+ if (dij > MAD_EPSILON) {
558
+ let inv = 1.0 / dij;
559
+ let inv2 = inv * inv;
560
+ let dev = (2.0 * (di - dm) * inv) - 1.0;
561
+ dwn += dev * dev;
562
+ sw0 += 2.0 * inv2; // both orderings
563
+ sw1 += (2.0 * (di + dj) * inv2) - (2.0 * inv);
564
+ let gi = (2.0 * di * inv) - 1.0;
565
+ let gj = (2.0 * dj * inv) - 1.0;
566
+ sw2 += (gi * gi) + (gj * gj);
567
+ col0[i] += inv2;
568
+ col0[j] += inv2;
569
+ colInv[i] += inv;
570
+ colInv[j] += inv;
571
+ ++partners[i];
572
+ ++partners[j];
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+ lo[k] = lo[ch[0]];
579
+ hi[k] = hi[ch[ch.length - 1]];
580
+ down[k] = dwn;
581
+ w0[k] = sw0;
582
+ w1[k] = sw1;
583
+ w2[k] = sw2;
584
+ }
585
+ // Pass 2 (post-order): per-subtree "all-i" sums a0,a1,a2; the cross
586
+ // sums between a subtree and its complement are then b_k = a_k - w_k.
587
+ let a0 = new Float64Array(m);
588
+ let a1 = new Float64Array(m);
589
+ let a2 = new Float64Array(m);
590
+ let b0 = new Float64Array(m);
591
+ let b1 = new Float64Array(m);
592
+ let b2 = new Float64Array(m);
593
+ for (let q = 0; q < m; ++q) {
594
+ let k = post[q];
595
+ let s0 = 0, s1 = 0, s2 = 0;
596
+ if (kids[k].length === 0) {
597
+ let j = tipNo[k];
598
+ s0 = col0[j];
599
+ s1 = (2.0 * tipDepth[j] * col0[j]) - colInv[j];
600
+ // (2*depth/d - 1)^2 expands to 4*depth^2/d^2 - 4*depth/d + 1: the
601
+ // 1 is owed once per pair actually summed. The desktop adds n - 1,
602
+ // which also counts the pairs at distance 0 (identical sequences)
603
+ // that every other sum skips.
604
+ s2 = (4.0 * tipDepth[j] * tipDepth[j] * col0[j]) - (4.0 * tipDepth[j] * colInv[j]) + partners[j];
605
+ } else {
606
+ for (let c = 0; c < kids[k].length; ++c) {
607
+ s0 += a0[kids[k][c]];
608
+ s1 += a1[kids[k][c]];
609
+ s2 += a2[kids[k][c]];
610
+ }
611
+ }
612
+ a0[k] = s0;
613
+ a1[k] = s1;
614
+ a2[k] = s2;
615
+ b0[k] = s0 - w0[k];
616
+ b1[k] = s1 - w1[k];
617
+ b2[k] = s2 - w2[k];
618
+ }
619
+ // Pass 3 (pre-order): up[k] = the deviation sum of the pairs whose
620
+ // ancestor lies outside subtree k, by the rerooting recursion, so
621
+ // every branch's total is O(1).
622
+ let up = new Float64Array(m);
623
+ let atParent = new Float64Array(m); // a branch's cross deviation with the root at its parent
624
+ for (let k = 0; k < m; ++k) {
625
+ let ch = kids[k];
626
+ if (ch.length === 0) {
627
+ continue;
628
+ }
629
+ let sumDown = 0;
630
+ let sumAtParent = 0;
631
+ for (let c = 0; c < ch.length; ++c) {
632
+ let x = ch[c];
633
+ sumDown += down[x];
634
+ atParent[x] = madCross(depth[x], b0[x], b1[x], b2[x], madLength(pre[x]));
635
+ sumAtParent += atParent[x];
636
+ }
637
+ // cross deviation among all the groups meeting here: the child subtrees and the complement
638
+ let own = (k === 0) ? 0.0 : madCross(depth[k], b0[k], b1[k], b2[k], 0.0);
639
+ let crossAmong = (sumAtParent + own) / 2.0;
640
+ for (let c = 0; c < ch.length; ++c) {
641
+ let x = ch[c];
642
+ up[x] = up[k] + (sumDown - down[x]) + (crossAmong - atParent[x]);
643
+ }
644
+ }
645
+ // The branch and position with the smallest total deviation, and every
646
+ // branch's smallest MAD value keyed by the tips on its far side, so
647
+ // the value finds its branch again after the re-root has turned
648
+ // parents into children. The first minimum in post-order wins, as on
649
+ // the desktop.
650
+ let hash = madTipHashes(n);
651
+ let nPairs = (n * (n - 1.0)) / 2.0;
652
+ let madByKey = new Map();
653
+ let bestSsd = Infinity;
654
+ let best = -1;
655
+ let bestX = 0;
656
+ for (let q = 0; q < m; ++q) {
657
+ let c = post[q];
658
+ if (c === 0) {
659
+ continue;
660
+ }
661
+ let length = madLength(pre[c]);
662
+ // the optimal root position, as a distance from c toward its parent, kept on the branch
663
+ let x = (b0[c] > MAD_EPSILON) ? (depth[c] - (b1[c] / (2.0 * b0[c]))) : 0.0;
664
+ if (x < 0) {
665
+ x = 0;
666
+ } else if (x > length) {
667
+ x = length;
668
+ }
669
+ let ssd = madCross(depth[c], b0[c], b1[c], b2[c], x) + down[c] + up[c];
670
+ // a sum of squares; clamp a tiny negative left by cancellation, so sqrt is never NaN
671
+ let mad = Math.sqrt(Math.max(0.0, ssd) / nPairs);
672
+ let key = hash.key(hi[c] - lo[c], hash.xa[hi[c]] ^ hash.xa[lo[c]], hash.xb[hi[c]] ^ hash.xb[lo[c]], lo[c] === 0);
673
+ let prev = madByKey.get(key);
674
+ if (prev === undefined || mad < prev) {
675
+ madByKey.set(key, mad); // the two halves of a bifurcating root share a key
676
+ }
677
+ if (ssd < bestSsd) {
678
+ bestSsd = ssd;
679
+ best = c;
680
+ bestX = x;
681
+ }
682
+ }
683
+ if (best < 0) {
684
+ return false;
685
+ }
686
+ let tipNumber = new Map();
687
+ for (let i = 0; i < n; ++i) {
688
+ tipNumber.set(pre[tipPos[i]], i);
689
+ }
690
+ forester.removeMadConfidences(phy);
691
+ forester.reRoot(phy, pre[best], bestX);
692
+ // annotate the internal branches of the re-rooted tree
693
+ let r = madTraversal(forester.getTreeRoot(phy));
694
+ let count = new Int32Array(r.pre.length);
695
+ let xa = new Int32Array(r.pre.length);
696
+ let xb = new Int32Array(r.pre.length);
697
+ let hasFirst = new Uint8Array(r.pre.length);
698
+ for (let q = 0; q < r.post.length; ++q) {
699
+ let k = r.post[q];
700
+ let ch = r.kids[k];
701
+ if (ch.length === 0) {
702
+ let i = tipNumber.get(r.pre[k]);
703
+ count[k] = 1;
704
+ xa[k] = hash.xa[i + 1] ^ hash.xa[i];
705
+ xb[k] = hash.xb[i + 1] ^ hash.xb[i];
706
+ hasFirst[k] = (i === 0) ? 1 : 0;
707
+ continue;
708
+ }
709
+ for (let c = 0; c < ch.length; ++c) {
710
+ count[k] += count[ch[c]];
711
+ xa[k] ^= xa[ch[c]];
712
+ xb[k] ^= xb[ch[c]];
713
+ hasFirst[k] |= hasFirst[ch[c]];
714
+ }
715
+ if (k !== 0) {
716
+ let mad = madByKey.get(hash.key(count[k], xa[k], xb[k], hasFirst[k] === 1));
717
+ if (mad !== undefined) {
718
+ let kept = r.pre[k].confidences || [];
719
+ r.pre[k].confidences = kept.concat([{value: mad, type: forester.MAD_CONFIDENCE_TYPE}]);
720
+ }
721
+ }
722
+ }
723
+ return true;
724
+ };
725
+
726
+ /**
727
+ * Removes every 'MAD' confidence (from madRoot) and keeps all others: a
728
+ * tree rooted any other way no longer has the rooting those values rate.
729
+ *
730
+ * @param phy the tree
731
+ */
732
+ forester.removeMadConfidences = function (phy) {
733
+ let root = forester.getTreeRoot(phy);
734
+ if (!root) {
735
+ return;
736
+ }
737
+ madTraversal(root).pre.forEach(function (node) {
738
+ if (node.confidences && node.confidences.some(function (c) { return c.type === forester.MAD_CONFIDENCE_TYPE; })) {
739
+ // a new array: re-rooting can leave two branches sharing one
740
+ let kept = node.confidences.filter(function (c) { return c.type !== forester.MAD_CONFIDENCE_TYPE; });
741
+ if (kept.length > 0) {
742
+ node.confidences = kept;
743
+ } else {
744
+ delete node.confidences;
745
+ }
746
+ }
747
+ });
748
+ };
749
+
750
+ /**
751
+ * Whether a node carries data about the node itself, the kind a
752
+ * re-rooting can take the meaning away from: a name, taxonomy, sequence
753
+ * (a domain architecture lives there), events, distribution, date,
754
+ * references, or a property about the node. Not data in this sense: the
755
+ * branch above it -- length, support and MAD values, colour, width, a
756
+ * property applying to the branch -- which re-rooting carries along,
757
+ * visual styling (style: properties), the viewer's own aptx: properties,
758
+ * and an empty taxonomy. The desktop's list is the same.
759
+ *
760
+ * @param node
761
+ * @returns {boolean}
762
+ */
763
+ forester.nodeHasData = function (node) {
764
+ let filled = function (x) {
765
+ return x !== undefined && x !== null && x !== '' && !(Array.isArray(x) && x.length === 0);
766
+ };
767
+ if ((typeof node.name === 'string' && node.name.length > 0)
768
+ || (node.taxonomies && node.taxonomies.some(function (t) {
769
+ return t && Object.keys(t).some(function (key) { return filled(t[key]); });
770
+ }))
771
+ || (node.sequences && node.sequences.length > 0)
772
+ || node.events
773
+ || (node.distributions && node.distributions.length > 0)
774
+ || node.date
775
+ || (node.references && node.references.length > 0)) {
776
+ return true;
777
+ }
778
+ return !!node.properties && node.properties.some(function (p) {
779
+ return p.applies_to !== BRANCH_EVENT_APPLIES_TO
780
+ && !(typeof p.ref === 'string' && (p.ref.indexOf('style:') === 0 || p.ref.indexOf('aptx:') === 0));
781
+ });
782
+ };
783
+
784
+ /**
785
+ * What a re-rooting would do to the data on internal nodes, worked out on
786
+ * a bare copy so the tree itself is untouched: `annotated`, the internal
787
+ * nodes carrying data (nodeHasData), and `changed`, those among them whose
788
+ * clade -- the tips below -- the re-rooting changes. Those lie between
789
+ * the old root and the new one (an old two-child root disappears); every
790
+ * other node keeps its tips. Midpoint and MAD rooting find their root on
791
+ * the copy first. Nothing is copied when no internal node carries data.
792
+ *
793
+ * @param phy the tree
794
+ * @param method 'mad', 'midpoint', or 'node': the root on the branch above `node`
795
+ * @param node the node, for 'node'
796
+ * @returns {{annotated: Array, changed: Array}} nodes of `phy`
797
+ */
798
+ forester.cladesChangedByRerooting = function (phy, method, node) {
799
+ let t = madTraversal(forester.getTreeRoot(phy));
800
+ let annotated = [];
801
+ for (let k = 0; k < t.pre.length; ++k) {
802
+ if (t.kids[k].length > 0 && forester.nodeHasData(t.pre[k])) {
803
+ annotated.push(t.pre[k]);
804
+ }
805
+ }
806
+ if (annotated.length === 0) {
807
+ return {annotated: annotated, changed: []};
808
+ }
809
+ // the copy: shape and branch lengths only, each copy knowing its original
810
+ let copyOf = new Map();
811
+ let tipNumber = new Map();
812
+ for (let k = 0; k < t.pre.length; ++k) {
813
+ let c = {branch_length: t.pre[k].branch_length, original: t.pre[k]};
814
+ if (t.kids[k].length > 0) {
815
+ c.children = [];
816
+ } else {
817
+ tipNumber.set(t.pre[k], tipNumber.size);
818
+ }
819
+ if (k > 0) {
820
+ c.parent = copyOf.get(t.pre[t.parentOf[k]]);
821
+ c.parent.children.push(c);
822
+ }
823
+ copyOf.set(t.pre[k], c);
824
+ }
825
+ let copy = {children: [copyOf.get(t.pre[0])]};
826
+ copy.children[0].parent = copy;
827
+ if (method === 'mad') {
828
+ forester.madRoot(copy);
829
+ } else if (method === 'midpoint') {
830
+ forester.midpointRoot(copy);
831
+ } else {
832
+ forester.reRoot(copy, copyOf.get(node), -1);
833
+ }
834
+ // the tips below every node, before and after, as a count and two hashes
835
+ let hash = madTipHashes(tipNumber.size);
836
+ let cladeKeys = function (traversal, originalOf) {
837
+ let count = new Int32Array(traversal.pre.length);
838
+ let xa = new Int32Array(traversal.pre.length);
839
+ let xb = new Int32Array(traversal.pre.length);
840
+ let keys = new Map();
841
+ for (let q = 0; q < traversal.post.length; ++q) {
842
+ let k = traversal.post[q];
843
+ let ch = traversal.kids[k];
844
+ if (ch.length === 0) {
845
+ let i = tipNumber.get(originalOf(traversal.pre[k]));
846
+ count[k] = 1;
847
+ xa[k] = hash.xa[i + 1] ^ hash.xa[i];
848
+ xb[k] = hash.xb[i + 1] ^ hash.xb[i];
849
+ continue;
850
+ }
851
+ for (let c = 0; c < ch.length; ++c) {
852
+ count[k] += count[ch[c]];
853
+ xa[k] ^= xa[ch[c]];
854
+ xb[k] ^= xb[ch[c]];
855
+ }
856
+ let original = originalOf(traversal.pre[k]);
857
+ if (original) { // not the copy's new root node
858
+ keys.set(original, count[k] + ':' + xa[k] + ':' + xb[k]);
859
+ }
860
+ }
861
+ return keys;
862
+ };
863
+ let before = cladeKeys(t, function (n) { return n; });
864
+ let after = cladeKeys(madTraversal(forester.getTreeRoot(copy)), function (c) { return c.original; });
865
+ return {
866
+ annotated: annotated,
867
+ changed: annotated.filter(function (n) { return after.get(n) !== before.get(n); })
868
+ };
869
+ };
870
+
871
+ function madLength(node) {
872
+ return node.branch_length > 0 ? node.branch_length : 0;
873
+ }
874
+
875
+ // The sum of squared cross-pair deviations between subtree c and its
876
+ // complement, with the root at distance x from c toward its parent. With
877
+ // K = 2*(x - depth[c]) a pair deviates by K/d + (2*depth[j]/d - 1), so the
878
+ // sum is K^2*b0 + 2*K*b1 + b2.
879
+ function madCross(depthC, b0, b1, b2, x) {
880
+ let k = 2.0 * (x - depthC);
881
+ return (k * k * b0) + (2.0 * k * b1) + b2;
882
+ }
883
+
884
+ // Pre-order (the root at 0, parents before children) and post-order,
885
+ // children left to right, as node positions -- without recursion, since
886
+ // a caterpillar tree nests as deep as it has tips.
887
+ function madTraversal(root) {
888
+ let pre = [];
889
+ let parentOf = [];
890
+ let stack = [root];
891
+ let stackParent = [-1];
892
+ while (stack.length > 0) {
893
+ let node = stack.pop();
894
+ let p = stackParent.pop();
895
+ let k = pre.length;
896
+ pre.push(node);
897
+ parentOf.push(p);
898
+ if (node.children) {
899
+ for (let i = node.children.length - 1; i >= 0; --i) {
900
+ stack.push(node.children[i]);
901
+ stackParent.push(k);
902
+ }
903
+ }
904
+ }
905
+ let kids = pre.map(function () {
906
+ return [];
907
+ });
908
+ for (let k = 1; k < pre.length; ++k) {
909
+ kids[parentOf[k]].push(k); // siblings come in left to right
910
+ }
911
+ // the mirrored pre-order, reversed, is the post-order left to right
912
+ let post = [];
913
+ let st = [0];
914
+ while (st.length > 0) {
915
+ let k = st.pop();
916
+ post.push(k);
917
+ for (let c = 0; c < kids[k].length; ++c) {
918
+ st.push(kids[k][c]);
919
+ }
920
+ }
921
+ post.reverse();
922
+ return {pre: pre, parentOf: parentOf, kids: kids, post: post};
923
+ }
924
+
925
+ // A set of tips as a key that does not depend on the rooting: its size
926
+ // and two 32-bit XOR hashes of its tips, taken on the side WITHOUT tip 0
927
+ // so both sides of a branch give the same key. xa/xb are prefix XORs, so
928
+ // the tips lo..hi-1 hash to xa[hi] ^ xa[lo].
929
+ function madTipHashes(n) {
930
+ let xa = new Int32Array(n + 1);
931
+ let xb = new Int32Array(n + 1);
932
+ let s = 0x2545f491;
933
+ let next = function () { // a fixed seed: the same tree always hashes the same
934
+ s = (s + 0x9e3779b9) | 0;
935
+ let z = s;
936
+ z = Math.imul(z ^ (z >>> 16), 0x85ebca6b);
937
+ z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35);
938
+ return z ^ (z >>> 16);
939
+ };
940
+ for (let i = 0; i < n; ++i) {
941
+ xa[i + 1] = xa[i] ^ next();
942
+ xb[i + 1] = xb[i] ^ next();
943
+ }
944
+ return {
945
+ xa: xa,
946
+ xb: xb,
947
+ key: function (size, ha, hb, holdsFirst) {
948
+ if (holdsFirst) {
949
+ size = n - size;
950
+ ha ^= xa[n];
951
+ hb ^= xb[n];
952
+ }
953
+ return size + ':' + ha + ':' + hb;
954
+ }
955
+ };
956
+ }
957
+
452
958
  forester.getFurthestDescendant = function (node) {
453
959
  let children = forester.getAllExternalNodes(node);
454
960
  let farthest = null;
@@ -1691,6 +2197,8 @@
1691
2197
  properties.longestNodeName = 0;
1692
2198
  properties.branchLengths = false;
1693
2199
  properties.confidences = false;
2200
+ // MAD values (madRoot) rate root positions, not clades: never support
2201
+ properties.madValues = false;
1694
2202
  // the largest confidence value seen -- how a caller tells a
1695
2203
  // posterior-probability tree (max <= 1) from a bootstrap tree
1696
2204
  properties.maxConfidence = 0;
@@ -1803,8 +2311,12 @@
1803
2311
  }
1804
2312
  }
1805
2313
  if (n.confidences && n.confidences.length > 0) {
1806
- properties.confidences = true;
1807
2314
  for (let ci = 0; ci < n.confidences.length; ++ci) {
2315
+ if (n.confidences[ci].type === forester.MAD_CONFIDENCE_TYPE) {
2316
+ properties.madValues = true;
2317
+ continue;
2318
+ }
2319
+ properties.confidences = true;
1808
2320
  let cv = n.confidences[ci].value;
1809
2321
  if (typeof cv === 'number' && isFinite(cv) && cv > properties.maxConfidence) {
1810
2322
  properties.maxConfidence = cv;
@@ -3470,11 +3982,17 @@
3470
3982
  nh += ":" + node.branch_length;
3471
3983
  }
3472
3984
  }
3473
- if (writeConfidences && node.confidences && node.confidences.length === 1 && node.confidences[0].value !== undefined && node.confidences[0].value !== null) {
3985
+ // the support slot holds support: a MAD value (madRoot) never goes
3986
+ // there -- it would read as support, and would crowd out the
3987
+ // bootstrap on a branch carrying both. phyloXML keeps it, typed.
3988
+ let support = writeConfidences && node.confidences
3989
+ ? node.confidences.filter(function (c) { return c.type !== forester.MAD_CONFIDENCE_TYPE; })
3990
+ : [];
3991
+ if (support.length === 1 && support[0].value !== undefined && support[0].value !== null) {
3474
3992
  if (decPointsMax && decPointsMax > 0) {
3475
- nh += "[" + forester.roundNumber(node.confidences[0].value, decPointsMax) + "]";
3993
+ nh += "[" + forester.roundNumber(support[0].value, decPointsMax) + "]";
3476
3994
  } else {
3477
- nh += "[" + node.confidences[0].value + "]";
3995
+ nh += "[" + support[0].value + "]";
3478
3996
  }
3479
3997
  }
3480
3998
  if (!last) {
@@ -4258,7 +4776,8 @@
4258
4776
  }, {suggest: false});
4259
4777
 
4260
4778
  const SEARCH_BRANCH_LENGTH = numericField('Branch Length', n => (typeof n.branch_length === 'number') ? [n.branch_length] : []);
4261
- const SEARCH_CONFIDENCE = numericField('Confidence', n => n.confidences ? n.confidences.map(c => c.value).filter(v => typeof v === 'number') : []);
4779
+ // support only: a MAD value rates a root position, not the clade
4780
+ const SEARCH_CONFIDENCE = numericField('Confidence', n => n.confidences ? n.confidences.filter(c => c.type !== forester.MAD_CONFIDENCE_TYPE).map(c => c.value).filter(v => typeof v === 'number') : []);
4262
4781
  const SEARCH_CLADE_SIZE = numericField('Clade Size (tips)', n => [n._srchClade], {metrics: true});
4263
4782
  const SEARCH_CHILD_COUNT = numericField('Number of Children', n => [n.children ? n.children.length : 0]);
4264
4783
  const SEARCH_DEPTH = numericField('Depth from Root', n => [n._srchDepth], {metrics: true});
@@ -4397,7 +4916,7 @@
4397
4916
  if (!hasBL && typeof n.branch_length === 'number' && n.branch_length >= 0) hasBL = true;
4398
4917
  if (!hasConf && n.confidences) {
4399
4918
  for (let i = 0; i < n.confidences.length; ++i) {
4400
- if (typeof n.confidences[i].value === 'number') { hasConf = true; break; }
4919
+ if (typeof n.confidences[i].value === 'number' && n.confidences[i].type !== forester.MAD_CONFIDENCE_TYPE) { hasConf = true; break; }
4401
4920
  }
4402
4921
  }
4403
4922
  if (n.properties) {
@@ -4845,6 +5364,32 @@
4845
5364
  'calendar year': 1, 'calendar years': 1
4846
5365
  };
4847
5366
 
5367
+ /**
5368
+ * Whether the tree's branch lengths are time: most of its internal nodes
5369
+ * carry a date, and at least two do -- BEAST node heights, Nextstrain
5370
+ * dates, phyloXML <date>s on ancestors. Tip dates alone do not make one:
5371
+ * a divergence tree can carry collection dates, and re-rooting it (as a
5372
+ * root-to-tip regression does) is a normal step. A time tree is never
5373
+ * re-rooted: a new root would contradict the ancestors' dates. The
5374
+ * desktop's rule is the same (its detectTimeTree, internal-node half).
5375
+ *
5376
+ * @param phy the tree
5377
+ * @returns {boolean}
5378
+ */
5379
+ forester.isTimeTree = function (phy) {
5380
+ let internal = 0;
5381
+ let dated = 0;
5382
+ forester.preOrderTraversalAll(forester.getTreeRoot(phy), function (n) {
5383
+ if (n.children && n.children.length > 0) {
5384
+ ++internal;
5385
+ if (n.date && typeof n.date.value === 'number' && isFinite(n.date.value)) {
5386
+ ++dated;
5387
+ }
5388
+ }
5389
+ });
5390
+ return dated >= 2 && dated * 2 > internal;
5391
+ };
5392
+
4848
5393
  // Everything the viewer needs to decide about and draw a time axis:
4849
5394
  // {type: 'geologic'|'calendar'|null, rootAge, presentDate, dated,
4850
5395
  // hasInternalIntervals, hasExternalIntervals}. rootAge (geologic) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.4.1",
3
+ "version": "3.5.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",