archaeopteryx 3.16.0 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.16.0
23
+ // v 3.18.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
@@ -103,7 +103,7 @@ function (root, d3, forester, phyloXml) {
103
103
  // IIFE's own function name -- a plain object says what it is.)
104
104
  let archaeopteryx = {};
105
105
 
106
- const VERSION = '3.16.0';
106
+ const VERSION = '3.18.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';
@@ -158,6 +158,7 @@ function (root, d3, forester, phyloXml) {
158
158
  const XML_SUFFIX = '.xml';
159
159
  const FASTA_SUFFIX = '.fasta';
160
160
  const NEXUS_SUFFIX = '.nexus';
161
+ const TSV_SUFFIX = '.tsv';
161
162
 
162
163
 
163
164
  // ---------------------------
@@ -188,13 +189,7 @@ function (root, d3, forester, phyloXml) {
188
189
  const BACKGROUND_COLOR_DARK = '#182029';
189
190
  const BRANCH_COLOR_DARK = '#8fa1b3';
190
191
  const LABEL_COLOR_DARK = '#e7eef5';
191
- const NAME_FOR_NH_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + NH_SUFFIX;
192
- const NAME_FOR_PHYLOXML_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + XML_SUFFIX;
193
- const NAME_FOR_PDF_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + PDF_SUFFIX;
194
- const NAME_FOR_PNG_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + PNG_SUFFIX;
195
- const NAME_FOR_SVG_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + SVG_SUFFIX;
196
- const NAME_FOR_FASTA_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + FASTA_SUFFIX;
197
- const NAME_FOR_NEXUS_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + NEXUS_SUFFIX;
192
+ const DOWNLOAD_STEM_DEFAULT = 'archaeopteryx_js';
198
193
  const NODE_LABEL_GAP_DEFAULT = 10;
199
194
  const NODE_SIZE_DEFAULT_DEFAULT = 3;
200
195
  const VISUALIZATIONS_LEGEND_YPOS_DEFAULT = 30;
@@ -242,6 +237,7 @@ function (root, d3, forester, phyloXml) {
242
237
  const DUPLICATION_AND_SPECIATION_COLOR_COLOR = '#E69F00';
243
238
  const DUPLICATION_COLOR = '#D55E00';
244
239
  const FASTA_EXPORT_FORMAT = 'Fasta';
240
+ const TSV_EXPORT_FORMAT = 'TSV';
245
241
  const FONT_SIZE_MAX = 26;
246
242
  const FONT_SIZE_MIN = 2;
247
243
  // branch-data (support / branch-length) text sits 2px under the label
@@ -361,6 +357,12 @@ function (root, d3, forester, phyloXml) {
361
357
  const SUPPORT_DOTS_CB = 'suppdots_cb';
362
358
  const MAD_VALUES_CB = 'mad_cb';
363
359
  const DOWNLOAD_BUTTON = 'dl_b';
360
+ const DOWNLOAD_NAME_FIELD = 'dl_name';
361
+ const DOWNLOAD_AS_SHOWN_BUTTON = 'dl_shown_b';
362
+ const DOWNLOAD_FULL_SIZE_BUTTON = 'dl_full_b';
363
+ const DOWNLOAD_PNG_SCALES = [2, 4, 8];
364
+ const COPY_NEWICK_BUTTON = 'copy_nh_b';
365
+ const COPY_PNG_BUTTON = 'copy_png_b';
364
366
  const DYNAHIDE_CB = 'dynahide_cb';
365
367
  const MSA_CB = 'msa_cb';
366
368
  const MSA_LOGO_CB = 'msa_logo_cb';
@@ -381,7 +383,6 @@ function (root, d3, forester, phyloXml) {
381
383
  const TIME_AXIS_CB = 'timeaxis_cb';
382
384
  const TIME_GRID_CB = 'timegrid_cb';
383
385
  const MSA_SCROLL_ID = 'aptxmsascroll';
384
- const EXPORT_FORMAT_SELECT = 'exp_f_sel';
385
386
  const FONT_SIZE_SLIDER = 'fs_sl';
386
387
  const EXTERNAL_LABEL_CB = 'extl_cb';
387
388
  const INTERNAL_LABEL_CB = 'intl_cb';
@@ -498,6 +499,12 @@ function (root, d3, forester, phyloXml) {
498
499
  // "Instance variables"
499
500
  // ---------------------------
500
501
  let _baseSvg = null;
502
+ // The Download section's two choices. Full size is the default: a figure
503
+ // is usually wanted whole, and "as shown" crops it to wherever the view
504
+ // happened to be. _pngScale starts from pngExportScale, snapped to the
505
+ // nearest of the scales the section offers.
506
+ let _downloadFullSize = true;
507
+ let _pngScale = 4;
501
508
  let _basicTreeProperties = null;
502
509
  let _displayHeight = 0;
503
510
  let _displayWidth = 0;
@@ -1567,8 +1574,11 @@ function (root, d3, forester, phyloXml) {
1567
1574
  }
1568
1575
  }
1569
1576
 
1570
- // items: [{label, action, danger}]; anchored at the click position.
1571
- function showNodeMenu(items, event, titleText) {
1577
+ // items: [{label, action, danger}]; anchored at the click position, or
1578
+ // under (else above) `anchorEl` when one is given. An item may carry a
1579
+ // `detail` line under its label (`warn` tints it), and {heading} starts
1580
+ // a titled group.
1581
+ function showNodeMenu(items, event, titleText, anchorEl) {
1572
1582
  removeNodeMenu();
1573
1583
  if (!items || items.length < 1) {
1574
1584
  return;
@@ -1596,9 +1606,22 @@ function (root, d3, forester, phyloXml) {
1596
1606
  menu.appendChild(note);
1597
1607
  return;
1598
1608
  }
1609
+ if (item.heading) {
1610
+ let heading = document.createElement('div');
1611
+ heading.className = 'aptx-node-menu-title aptx-node-menu-heading';
1612
+ heading.textContent = item.heading;
1613
+ menu.appendChild(heading);
1614
+ return;
1615
+ }
1599
1616
  let b = document.createElement('button');
1600
1617
  b.type = 'button';
1601
1618
  b.textContent = item.label;
1619
+ if (item.detail) {
1620
+ let detail = document.createElement('span');
1621
+ detail.className = 'aptx-menu-detail' + (item.warn ? ' aptx-menu-warn' : '');
1622
+ detail.textContent = item.detail;
1623
+ b.appendChild(detail);
1624
+ }
1602
1625
  if (item.title) {
1603
1626
  b.title = item.title;
1604
1627
  }
@@ -1618,10 +1641,19 @@ function (root, d3, forester, phyloXml) {
1618
1641
  document.body.appendChild(menu);
1619
1642
 
1620
1643
  // Place it at the pointer, nudged back inside the window if it would
1621
- // otherwise hang off the right or bottom edge.
1644
+ // otherwise hang off the right or bottom edge. Opened from a button,
1645
+ // it hangs under the button, or stands on it where there is no room
1646
+ // below -- the Download button sits at the foot of the panel.
1622
1647
  let x = (event && event.pageX !== undefined) ? event.pageX : 0;
1623
1648
  let y = (event && event.pageY !== undefined) ? event.pageY : 0;
1624
1649
  let r = menu.getBoundingClientRect();
1650
+ if (anchorEl) {
1651
+ let a = anchorEl.getBoundingClientRect();
1652
+ let below = document.documentElement.clientHeight - a.bottom;
1653
+ x = window.scrollX + a.left;
1654
+ y = window.scrollY + ((below >= r.height + 12 || a.top < r.height + 12)
1655
+ ? a.bottom + 4 : a.top - r.height - 4);
1656
+ }
1625
1657
  let maxX = window.scrollX + document.documentElement.clientWidth - r.width - 8;
1626
1658
  let maxY = window.scrollY + document.documentElement.clientHeight - r.height - 8;
1627
1659
  menu.style.left = Math.max(window.scrollX + 8, Math.min(x, maxX)) + 'px';
@@ -1643,6 +1675,14 @@ function (root, d3, forester, phyloXml) {
1643
1675
  if (_nodeMenu && _nodeMenu.contains(e.target)) {
1644
1676
  return;
1645
1677
  }
1678
+ // A click on the button the menu hangs from closes it and goes no
1679
+ // further: the button's own handler would open it straight
1680
+ // again. Stopped here, in the capture phase at the document, the
1681
+ // click never reaches the button -- so the button toggles.
1682
+ if (anchorEl && anchorEl.contains(e.target)) {
1683
+ e.stopPropagation();
1684
+ e.preventDefault();
1685
+ }
1646
1686
  removeNodeMenu();
1647
1687
  };
1648
1688
  let onKey = function (e) {
@@ -3207,8 +3247,9 @@ function (root, d3, forester, phyloXml) {
3207
3247
  if (radialDisplay()) {
3208
3248
  return 0;
3209
3249
  }
3210
- if (d.parent) {
3211
- return (d.parent.y - d.y + 1);
3250
+ let p = drawnParent(d);
3251
+ if (p) {
3252
+ return (p.y - d.y + 1);
3212
3253
  } else {
3213
3254
  return 0;
3214
3255
  }
@@ -3228,8 +3269,9 @@ function (root, d3, forester, phyloXml) {
3228
3269
  if (radialDisplay()) {
3229
3270
  return 0;
3230
3271
  }
3231
- if (d.parent) {
3232
- return (0.5 * (d.parent.y - d.y));
3272
+ let p = drawnParent(d);
3273
+ if (p) {
3274
+ return (0.5 * (p.y - d.y));
3233
3275
  } else {
3234
3276
  return 0;
3235
3277
  }
@@ -3250,7 +3292,8 @@ function (root, d3, forester, phyloXml) {
3250
3292
  if (radialDisplay()) {
3251
3293
  return 0;
3252
3294
  }
3253
- return d.parent ? (0.5 * (d.parent.y - d.y)) : 0;
3295
+ let p = drawnParent(d);
3296
+ return p ? (0.5 * (p.y - d.y)) : 0;
3254
3297
  });
3255
3298
 
3256
3299
  node.select('text.brancheventlabel')
@@ -3264,8 +3307,9 @@ function (root, d3, forester, phyloXml) {
3264
3307
  if (radialDisplay()) {
3265
3308
  return 0;
3266
3309
  }
3267
- if (d.parent) {
3268
- return (0.5 * (d.parent.y - d.y));
3310
+ let p = drawnParent(d);
3311
+ if (p) {
3312
+ return (0.5 * (p.y - d.y));
3269
3313
  }
3270
3314
  });
3271
3315
 
@@ -3978,12 +4022,14 @@ function (root, d3, forester, phyloXml) {
3978
4022
  if (d.ux === undefined) {
3979
4023
  return null;
3980
4024
  }
3981
- let p = (d.parent && d.parent.ux !== undefined) ? d.parent : null;
4025
+ let p = drawnParent(d);
4026
+ p = (p && p.ux !== undefined) ? p : null;
3982
4027
  return p ? [0.5 * (d.ux + p.ux), 0.5 * (d.uy + p.uy), angle] : [d.ux, d.uy, angle];
3983
4028
  }
3984
4029
  let r = radialRadius(d.y);
3985
- if (d.parent) {
3986
- r = 0.5 * (r + radialRadius(d.parent.y));
4030
+ let p = drawnParent(d);
4031
+ if (p) {
4032
+ r = 0.5 * (r + radialRadius(p.y));
3987
4033
  }
3988
4034
  let pt = polarXY(radialAngle(d.x), r);
3989
4035
  return [pt[0], pt[1], angle];
@@ -4019,7 +4065,8 @@ function (root, d3, forester, phyloXml) {
4019
4065
  // used to claim nothing -- so the first child's number, landing in
4020
4066
  // almost the same place, was granted and painted over them. The one
4021
4067
  // place the pass could not keep its promise.
4022
- let span = d.parent ? (d.parent.y - d.y) : 0;
4068
+ let p = drawnParent(d);
4069
+ let span = p ? (p.y - d.y) : 0;
4023
4070
  let x = at.mid
4024
4071
  ? (d.y + (0.5 * span) - (w / 2)) // middle-anchored
4025
4072
  : (d.y + span + 1); // start-anchored
@@ -4435,7 +4482,7 @@ function (root, d3, forester, phyloXml) {
4435
4482
  // do not rotate, so the radial case needs only the midpoint
4436
4483
  let at = radialDisplay()
4437
4484
  ? radialBranchMid(d)
4438
- : [d.y + (0.5 * (d.parent.y - d.y)), d.x];
4485
+ : [d.y + (0.5 * (drawnParent(d).y - d.y)), d.x]; // one use
4439
4486
  if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
4440
4487
  d._suppDot = false;
4441
4488
  ++_branchMarksHidden;
@@ -4747,24 +4794,39 @@ function (root, d3, forester, phyloXml) {
4747
4794
  }
4748
4795
  };
4749
4796
 
4797
+ // The node d's branch is DRAWN from: its parent in this layout. That is
4798
+ // d.parent for every node but one -- in a subtree view the top node hangs
4799
+ // from the wrapper the view was entered with, while its own parent keeps
4800
+ // the coordinates of whatever layout last drew it. Every mark placed
4801
+ // "halfway back to the parent" (support dots, confidence and branch length
4802
+ // values, events) went there: into empty space, and further off with each
4803
+ // subtree entered inside a subtree (Christian, 2026-09-28). Asked, not
4804
+ // stored: a node link kept on every node made the representatives copy
4805
+ // (copyTreeKeepingTips) deep-copy each node's whole ancestor chain --
4806
+ // 5.4 s instead of 5 ms on a 6,000-deep tree (the review measured it).
4807
+ function drawnParent(d) {
4808
+ return (d === topNode()) ? _root : d.parent;
4809
+ }
4810
+
4750
4811
  // How long node d's branch is DRAWN, in layout units -- not its
4751
4812
  // branch_length: the gate for decorations that need room on the branch.
4752
4813
  function drawnBranchSpan(d) {
4753
- if (!d.parent) {
4814
+ let p = drawnParent(d);
4815
+ if (!p) {
4754
4816
  return 0;
4755
4817
  }
4756
4818
  if (_state.unrootedDisplay) {
4757
- if (d.ux === undefined || d.parent.ux === undefined) {
4819
+ if (d.ux === undefined || p.ux === undefined) {
4758
4820
  return 0;
4759
4821
  }
4760
- let dx = d.ux - d.parent.ux;
4761
- let dy = d.uy - d.parent.uy;
4822
+ let dx = d.ux - p.ux;
4823
+ let dy = d.uy - p.uy;
4762
4824
  return Math.sqrt((dx * dx) + (dy * dy));
4763
4825
  }
4764
4826
  if (_state.circularDisplay) {
4765
- return Math.abs(radialRadius(d.parent.y) - radialRadius(d.y));
4827
+ return Math.abs(radialRadius(p.y) - radialRadius(d.y));
4766
4828
  }
4767
- return Math.abs(d.parent.y - d.y);
4829
+ return Math.abs(p.y - d.y);
4768
4830
  }
4769
4831
 
4770
4832
  // The Support Dots threshold on the tree's own scale: the setting is a
@@ -4798,7 +4860,7 @@ function (root, d3, forester, phyloXml) {
4798
4860
  }
4799
4861
 
4800
4862
  function showSupportDot(d) {
4801
- if (!_state.showSupportDots || !d.parent
4863
+ if (!_state.showSupportDots || !drawnParent(d)
4802
4864
  || !d.confidences || d.confidences.length === 0) {
4803
4865
  return false;
4804
4866
  }
@@ -4872,17 +4934,18 @@ function (root, d3, forester, phyloXml) {
4872
4934
  // Transform for a branch-data label (confidence / branch length / events):
4873
4935
  // rotate to the node's angle and sit at the midpoint of the branch (radially).
4874
4936
  function branchLabelTransform(d) {
4875
- if (!d.parent || (_state.unrootedDisplay && d.parent.ux === undefined)) {
4937
+ let p = drawnParent(d);
4938
+ if (!p || (_state.unrootedDisplay && p.ux === undefined)) {
4876
4939
  // no laid-out parent (the displayed subtree's root): angle only
4877
4940
  return 'rotate(' + labelAngleDeg(d) + ')';
4878
4941
  }
4879
4942
  let mid;
4880
4943
  if (_state.unrootedDisplay) {
4881
- let dx = d.ux - d.parent.ux;
4882
- let dy = d.uy - d.parent.uy;
4944
+ let dx = d.ux - p.ux;
4945
+ let dy = d.uy - p.uy;
4883
4946
  mid = -Math.sqrt((dx * dx) + (dy * dy)) / 2;
4884
4947
  } else {
4885
- mid = (radialRadius(d.parent.y) - radialRadius(d.y)) / 2;
4948
+ mid = (radialRadius(p.y) - radialRadius(d.y)) / 2;
4886
4949
  }
4887
4950
  return 'rotate(' + labelAngleDeg(d) + ') translate(' + mid + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
4888
4951
  }
@@ -5396,11 +5459,11 @@ function (root, d3, forester, phyloXml) {
5396
5459
  internalNodeFontSize: 'font size is fixed at launch (11px) and changed only via the in-panel Font slider -- not a launch config key',
5397
5460
  branchDataFontSize: 'derived from the fixed label size (2px smaller, floor 6px) and changed only via the in-panel Font slider -- not a launch config key',
5398
5461
  // download filenames follow the tree's name
5399
- nameForNhDownload: 'download names follow "treeName"',
5400
- nameForPhyloXmlDownload: 'download names follow "treeName"',
5401
- nameForPngDownload: 'download names follow "treeName"',
5402
- nameForSvgDownload: 'download names follow "treeName"',
5403
- nameForFastaDownload: 'download names follow "treeName"',
5462
+ nameForNhDownload: 'download names follow "treeName", or the Download section\'s Name field',
5463
+ nameForPhyloXmlDownload: 'download names follow "treeName", or the Download section\'s Name field',
5464
+ nameForPngDownload: 'download names follow "treeName", or the Download section\'s Name field',
5465
+ nameForSvgDownload: 'download names follow "treeName", or the Download section\'s Name field',
5466
+ nameForFastaDownload: 'download names follow "treeName", or the Download section\'s Name field',
5404
5467
  // which taxonomy / sequence field to label with is read off the tree
5405
5468
  showTaxonomyCode: 'taxonomy labelling follows what the tree contains',
5406
5469
  showTaxonomyScientificName: 'taxonomy labelling follows what the tree contains',
@@ -6004,20 +6067,7 @@ function (root, d3, forester, phyloXml) {
6004
6067
  // The tree names itself; a caller-supplied name only ever disagreed with
6005
6068
  // the file. It is the stem of every download filename.
6006
6069
  _state.treeName = _treeData.name ? _treeData.name.trim().replace(/\W+/g, '_') : null;
6007
- _state.nameForNhDownload = _state.treeName
6008
- ? (_state.treeName + NH_SUFFIX) : NAME_FOR_NH_DOWNLOAD_DEFAULT;
6009
- _state.nameForPhyloXmlDownload = _state.treeName
6010
- ? (_state.treeName + XML_SUFFIX) : NAME_FOR_PHYLOXML_DOWNLOAD_DEFAULT;
6011
- _state.nameForPngDownload = _state.treeName
6012
- ? (_state.treeName + PNG_SUFFIX) : NAME_FOR_PNG_DOWNLOAD_DEFAULT;
6013
- _state.nameForPdfDownload = _state.treeName
6014
- ? (_state.treeName + PDF_SUFFIX) : NAME_FOR_PDF_DOWNLOAD_DEFAULT;
6015
- _state.nameForSvgDownload = _state.treeName
6016
- ? (_state.treeName + SVG_SUFFIX) : NAME_FOR_SVG_DOWNLOAD_DEFAULT;
6017
- _state.nameForFastaDownload = _state.treeName
6018
- ? (_state.treeName + FASTA_SUFFIX) : NAME_FOR_FASTA_DOWNLOAD_DEFAULT;
6019
- _state.nameForNexusDownload = _state.treeName
6020
- ? (_state.treeName + NEXUS_SUFFIX) : NAME_FOR_NEXUS_DOWNLOAD_DEFAULT;
6070
+ _state.downloadStem = _state.treeName || DOWNLOAD_STEM_DEFAULT;
6021
6071
 
6022
6072
  // === undefined, not a truthy check: 0 is a legitimate explicit
6023
6073
  // position (the legend's top-left corner) and must survive, not be
@@ -6399,11 +6449,11 @@ function (root, d3, forester, phyloXml) {
6399
6449
  // and a tree whose dates carry a unit is never touched. The desktop
6400
6450
  // converts at the same point, next to its internal-label policy
6401
6451
  // (their HeightDateConverter, 0.11.151).
6402
- // Each node's cumulative divergence is recorded from the branch
6403
- // lengths AS LOADED, before anything rewrites them, so the time <->
6404
- // divergence switch is reversible rather than one-way. Auspice states
6405
- // divergence outright (nextstrain:div) and is unaffected; every other
6406
- // tree states it as its branch lengths, which the time view overwrites.
6452
+ // What the Time | Div switch needs to be lossless is kept from the
6453
+ // branch lengths AS LOADED, before anything rewrites them: the
6454
+ // lengths a tree arrived with in time, and on a BEAST clock-model
6455
+ // tree its divergence, each length times its rate (see
6456
+ // forester.captureDivergence).
6407
6457
  trees.forEach(function (t) {
6408
6458
  forester.captureDivergence(t);
6409
6459
  });
@@ -6884,10 +6934,7 @@ function (root, d3, forester, phyloXml) {
6884
6934
  } else {
6885
6935
  filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.tsv';
6886
6936
  }
6887
- const tsv = forester.externalNodeDataTsv(ext_nodes, function (n, i) {
6888
- return (n.viewId !== undefined) ? n.viewId : i + 1;
6889
- });
6890
- saveAs(new Blob([tsv], {type: 'text/tab-separated-values'}), filename);
6937
+ saveAs(new Blob([tipTableTsv(ext_nodes)], {type: 'text/tab-separated-values'}), filename);
6891
6938
  update();
6892
6939
  }
6893
6940
 
@@ -7170,6 +7217,7 @@ function (root, d3, forester, phyloXml) {
7170
7217
  items.push({label: deleteLabel, danger: true, action: function () {
7171
7218
  forester.deleteSubtree(tree, d);
7172
7219
  _treeData = tree;
7220
+ branchScaleAfterEdit();
7173
7221
  _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
7174
7222
  refreshVisualizations(true);
7175
7223
  search0();
@@ -10239,10 +10287,12 @@ function (root, d3, forester, phyloXml) {
10239
10287
 
10240
10288
  /**
10241
10289
  * Redraws the tree with its branch lengths taken from the chosen metric:
10242
- * TIME (the gaps between the nodes' dates) or DIVERGENCE (what the file's
10243
- * own branch lengths measured, recorded at load). Both metrics stay on the
10244
- * tree, so this is reversible and lossless -- a round trip restores the
10245
- * loaded branch lengths exactly.
10290
+ * TIME (the lengths the tree arrived with, where it arrived in time; its
10291
+ * date gaps otherwise) or DIVERGENCE (what its nodes record, or on a BEAST
10292
+ * clock-model tree each branch's time times its clock rate). Both stay on
10293
+ * the tree, so this is reversible and lossless -- a round trip restores
10294
+ * the branch lengths exactly. Always the WHOLE tree, whatever is on view.
10295
+ * The rules are forester.hasTimeAndDivergence's, shared with the desktop.
10246
10296
  *
10247
10297
  * @param scale 'time' or 'divergence'
10248
10298
  */
@@ -10257,13 +10307,53 @@ function (root, d3, forester, phyloXml) {
10257
10307
  forester.applyDivergenceBranchLengths(_treeData);
10258
10308
  }
10259
10309
  // the branch lengths ARE the layout, so everything measured from them
10260
- // has to be measured again
10261
- _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
10262
- setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, scale === 'time');
10263
- setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, scale !== 'time');
10310
+ // has to be measured again -- for the tree ON VIEW, as switchToSubtree
10311
+ // measures it: a clade's properties are not the whole tree's
10312
+ _basicTreeProperties = forester.collectBasicTreeProperties(displayedRoot());
10313
+ syncBranchScaleControls();
10264
10314
  zoomToFit();
10265
10315
  }
10266
10316
 
10317
+ // The switch's two buttons and whether the pair is shown at all, from the
10318
+ // state: most trees state only one thing, and a control offering a choice
10319
+ // that does not exist is worse than no control.
10320
+ function syncBranchScaleControls() {
10321
+ setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
10322
+ setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
10323
+ let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
10324
+ if (scaleGroup) {
10325
+ scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
10326
+ }
10327
+ }
10328
+
10329
+ // The tree was EDITED (a node deleted). Whether it has two layouts is asked
10330
+ // of the tree as it is now, and the layout on screen is laid out again
10331
+ // from what the tree keeps and records: the branch that took the place of
10332
+ // a removed node's is as long as both were, in either layout. A tree that
10333
+ // was never offered the switch still has the branch lengths of its file,
10334
+ // so what it keeps is taken afresh -- deleting the one tip without a
10335
+ // clock rate leaves a tree with a rate on every branch.
10336
+ function branchScaleAfterEdit() {
10337
+ if (!_treeData) {
10338
+ return;
10339
+ }
10340
+ let was = _branchScaleAvailable;
10341
+ if (!was) {
10342
+ forester.captureDivergence(_treeData);
10343
+ }
10344
+ _branchScaleAvailable = forester.hasTimeAndDivergence(_treeData);
10345
+ if (was && _branchScaleAvailable && _state.branchScale === 'divergence') {
10346
+ forester.applyDivergenceBranchLengths(_treeData);
10347
+ } else if (was) {
10348
+ // in time, or no longer a tree with two layouts: back in time
10349
+ forester.applyTimeBranchLengths(_treeData);
10350
+ _state.branchScale = 'time';
10351
+ } else {
10352
+ _state.branchScale = forester.branchLengthScale(_treeData);
10353
+ }
10354
+ syncBranchScaleControls();
10355
+ }
10356
+
10267
10357
  function branchScaleButtonClicked() {
10268
10358
  setBranchScale(getCheckboxValue(BRANCH_SCALE_TIME_BUTTON) ? 'time' : 'divergence');
10269
10359
  }
@@ -11476,23 +11566,37 @@ function (root, d3, forester, phyloXml) {
11476
11566
  // the tree's own branch scale, so the bands line up with the branches
11477
11567
  // even on a fossil-only tree whose youngest tip is far from the present.
11478
11568
 
11569
+ // Whether the branch lengths on screen measure divergence: the switch says
11570
+ // so where it is offered; the tree's own unit where it is not (a build
11571
+ // that opened in divergence, a tree saved from Div).
11572
+ function branchesShowDivergence() {
11573
+ if (_branchScaleAvailable) {
11574
+ return _state.branchScale === 'divergence';
11575
+ }
11576
+ return !!_treeData && typeof _treeData.branch_length_unit === 'string'
11577
+ && _treeData.branch_length_unit.trim() === 'subs/site';
11578
+ }
11579
+
11479
11580
  function timeAxisShown() {
11480
- return _state.showTimeAxis === true && !radialDisplay()
11581
+ return _state.showTimeAxis === true && !_state.unrootedDisplay
11481
11582
  && _state.phylogram === true
11482
11583
  && _timeInfo !== null && _timeInfo.type !== null
11483
11584
  && _basicTreeProperties.branchLengths === true
11484
11585
  // The axis is an overlay calibrated to the tree's own branch scale,
11485
- // so it must be hidden when the branches are showing DIVERGENCE
11486
- // and divergence is a different measure -- calendar years against
11487
- // substitutions reads as a confident lie. Only then: where the two
11488
- // metrics agree (a BEAST time tree, whose branch lengths ARE time)
11489
- // there is no switch and the axis is calibrated correctly, which
11490
- // is what makes a converted BEAST tree show its years at all.
11491
- && !(_branchScaleAvailable && _state.branchScale === 'divergence');
11586
+ // so it must be hidden when the branches are showing DIVERGENCE --
11587
+ // calendar years against substitutions reads as a confident lie.
11588
+ // Whether the switch is offered is not the question: a Nextstrain
11589
+ // build with one undated node opens in divergence, says so in its
11590
+ // unit, and is refused the switch; its axis was drawn over
11591
+ // substitutions until 2026-09-29. A tree in time shows its axis
11592
+ // (a converted BEAST tree its years) whether or not it has a
11593
+ // second layout.
11594
+ && !branchesShowDivergence();
11492
11595
  }
11493
11596
 
11494
11597
  function timeAxisBottomReserve() {
11495
- if (!timeAxisShown()) {
11598
+ // circular draws its ruler in the tree's own gap, not under it
11599
+ if (!timeAxisShown() || _state.circularDisplay) {
11496
11600
  return 0;
11497
11601
  }
11498
11602
  return _timeInfo.type === 'geologic' ? TIME_GEO_RESERVE : TIME_CAL_RESERVE;
@@ -11652,6 +11756,48 @@ function (root, d3, forester, phyloXml) {
11652
11756
  }
11653
11757
 
11654
11758
  let sc = info.type === 'calendar' ? -corr : corr;
11759
+ let circular = _state.circularDisplay;
11760
+
11761
+ // A bar spanning depths x0..x1 at node d: a band across the branch in
11762
+ // rectangular, a segment along the node's spoke in circular.
11763
+ function timeBar(d, x0, x1, thick, fill, cls) { // bound to d, for probes
11764
+ let el;
11765
+ if (circular) {
11766
+ // A bound older than the root's own estimate is a NEGATIVE
11767
+ // radius, and the bar runs on through the centre onto the far
11768
+ // side of its spoke's line, as the rectangular bar straddles
11769
+ // the root's x. As on the desktop (hpdRadialRange).
11770
+ let a = radialAngle(d.x);
11771
+ let p0 = polarXY(a, radialRadius(x0));
11772
+ let p1 = polarXY(a, radialRadius(x1));
11773
+ el = g.append('line').attr('x1', p0[0]).attr('y1', p0[1])
11774
+ .attr('x2', p1[0]).attr('y2', p1[1])
11775
+ .attr('stroke', fill).attr('stroke-width', thick);
11776
+ } else {
11777
+ el = g.append('rect').attr('x', Math.min(x0, x1)).attr('y', d.x - (thick / 2))
11778
+ .attr('width', Math.max(1, Math.abs(x1 - x0))).attr('height', thick)
11779
+ .attr('fill', fill);
11780
+ }
11781
+ el.datum(d).attr('class', cls);
11782
+ }
11783
+ // a short stroke ACROSS the branch at depth x: a fossil range's end cap
11784
+ function timeCap(d, x) {
11785
+ let p;
11786
+ let q;
11787
+ if (circular) {
11788
+ let a = radialAngle(d.x);
11789
+ let r = radialRadius(x); // negative past the centre, as the bar's end is
11790
+ let half = 4 / Math.max(Math.abs(r), 4); // 4px either side, as an angle
11791
+ p = polarXY(a - half, r);
11792
+ q = polarXY(a + half, r);
11793
+ } else {
11794
+ p = [x, d.x - 4];
11795
+ q = [x, d.x + 4];
11796
+ }
11797
+ g.append('line').attr('x1', p[0]).attr('y1', p[1]).attr('x2', q[0]).attr('y2', q[1])
11798
+ .attr('stroke', FOSSIL_BAR_COLOR).attr('stroke-width', 1)
11799
+ .datum(d).attr('class', 'aptx-fossil-cap');
11800
+ }
11655
11801
 
11656
11802
  // ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
11657
11803
  forEachDisplayed(function (d) {
@@ -11672,9 +11818,15 @@ function (root, d3, forester, phyloXml) {
11672
11818
  let s = sc;
11673
11819
  let xa = d.y - ((max - value) * s);
11674
11820
  let xb = d.y + ((value - min) * s);
11675
- let left = Math.min(xa, xb);
11676
- let w = Math.max(1, Math.abs(xb - xa));
11677
- let y = d.x;
11821
+ if (circular && Math.abs(radialRadius(xb) - radialRadius(xa)) < 1) {
11822
+ // the 1px floor, along the spoke: from the bar's inner end
11823
+ // outward, as the rectangular bar is floored from its left
11824
+ // end and as the desktop draws it (hpdRadialRange). It was
11825
+ // centred on the interval until 2026-09-29.
11826
+ let inner = Math.min(xa, xb);
11827
+ xa = inner;
11828
+ xb = inner + (_radial.maxY / _radial.maxRad);
11829
+ }
11678
11830
  // What a TIP's interval means is the axis's to say. On geologic
11679
11831
  // time it is a fossil's observed range, FAD to LAD, and is drawn
11680
11832
  // as one below. On CALENDAR time it is the uncertainty of a
@@ -11687,27 +11839,24 @@ function (root, d3, forester, phyloXml) {
11687
11839
  // the desktop alike). The width test is the entry guard above.
11688
11840
  let sampledTip = !d.children && info.type === 'calendar';
11689
11841
  if (d.children) {
11690
- g.append('rect').attr('x', left).attr('y', y - 3.5)
11691
- .attr('width', w).attr('height', 7)
11692
- .attr('fill', HPD_BAR_COLOR);
11842
+ timeBar(d, xa, xb, 7, HPD_BAR_COLOR, 'aptx-age-bar');
11693
11843
  } else if (sampledTip) {
11694
- g.append('rect').attr('class', 'aptx-sampling-bar')
11695
- .attr('x', left).attr('y', y - 2.5)
11696
- .attr('width', w).attr('height', 5)
11697
- .attr('fill', HPD_BAR_COLOR);
11844
+ timeBar(d, xa, xb, 5, HPD_BAR_COLOR, 'aptx-sampling-bar');
11698
11845
  } else {
11699
- g.append('rect').attr('x', left).attr('y', y - 2.5)
11700
- .attr('width', w).attr('height', 5)
11701
- .attr('fill', FOSSIL_BAR_COLOR);
11846
+ timeBar(d, xa, xb, 5, FOSSIL_BAR_COLOR, 'aptx-fossil-bar');
11702
11847
  // FAD/LAD end caps, so the range reads as a bracketed interval
11703
- [left, left + w].forEach(function (cx) {
11704
- g.append('line').attr('x1', cx).attr('x2', cx)
11705
- .attr('y1', y - 4).attr('y2', y + 4)
11706
- .attr('stroke', FOSSIL_BAR_COLOR).attr('stroke-width', 1);
11707
- });
11848
+ let lo = Math.min(xa, xb);
11849
+ let hi = Math.max(xa, xb, lo + 1);
11850
+ timeCap(d, lo);
11851
+ timeCap(d, hi);
11708
11852
  }
11709
11853
  });
11710
11854
 
11855
+ if (circular) {
11856
+ drawCircularTimeRuler(info, anchor, maxTipX, corr, g, grid, ink);
11857
+ return;
11858
+ }
11859
+
11711
11860
  // ---- the axis itself: a FLOATING strip (see floatStripGroup) ----
11712
11861
  let ax = floatStripGroup('aptx-time-axis', axisTop - 4, timeAxisBottomReserve() + 4);
11713
11862
  function axisBackdrop(x0, x1) {
@@ -11864,6 +12013,151 @@ function (root, d3, forester, phyloXml) {
11864
12013
  placeFloatingOverlays();
11865
12014
  }
11866
12015
 
12016
+ // The circular layout's time axis. Its ruler runs along the gap between
12017
+ // the last tip and the first -- the one ray no branch crosses -- from the
12018
+ // root out to the tips, so it turns with the tree. "Time Grid" draws each
12019
+ // tick as a full ring behind the tree; a geologic axis lays its intervals
12020
+ // down as coloured rings, as the desktop does.
12021
+ function drawCircularTimeRuler(info, anchor, maxTipX, corr, g, grid, ink) {
12022
+ let depthOf; // a date -> depth, the axis the radius is drawn from
12023
+ let ticks = [];
12024
+ let from; // the root end and the tip end, as dates
12025
+ let to;
12026
+ let bands = [];
12027
+ if (info.type === 'geologic') {
12028
+ let rootAge = info.rootAge;
12029
+ if (!(rootAge > 0)) {
12030
+ return;
12031
+ }
12032
+ let anchorAge = anchor ? anchor.date.value : rootAge;
12033
+ let anchorX = anchor ? anchor.y : _root.y;
12034
+ depthOf = function (age) {
12035
+ return anchorX + ((anchorAge - age) * corr);
12036
+ };
12037
+ from = rootAge;
12038
+ to = Math.max(0, anchorAge - ((maxTipX - anchorX) / corr));
12039
+ forester.maAxisTickValues(rootAge).forEach(function (v) {
12040
+ if (v >= to - 1e-9 && v <= rootAge + 1e-9) {
12041
+ ticks.push({value: v, text: String(v)});
12042
+ }
12043
+ });
12044
+ if (to > 0) {
12045
+ ticks.push({value: to, text: String(Math.round(to * 100) / 100), first: true});
12046
+ }
12047
+ bands = forester.geoOverlapping(forester.geoBandRanks(to, rootAge)[1], to, rootAge);
12048
+ } else {
12049
+ let present = info.presentDate;
12050
+ if (!(present > 0)) {
12051
+ return;
12052
+ }
12053
+ let anchorYear = anchor ? anchor.date.value : present;
12054
+ let anchorX = anchor ? anchor.y : maxTipX;
12055
+ depthOf = function (yv) {
12056
+ return anchorX - ((anchorYear - yv) * corr);
12057
+ };
12058
+ from = anchorYear - ((anchorX - _root.y) / corr);
12059
+ to = present;
12060
+ forester.calendarTickYears(from, to).forEach(function (yv) {
12061
+ ticks.push({value: yv, text: String(Math.round(yv))});
12062
+ });
12063
+ }
12064
+ let radiusOf = function (v) {
12065
+ return Math.max(0, radialRadius(depthOf(v)));
12066
+ };
12067
+ let r0 = radiusOf(from);
12068
+ let r1 = radiusOf(to);
12069
+ if (!(r1 > r0)) {
12070
+ return;
12071
+ }
12072
+ // behind the tree: the geologic rings, then the grid rings
12073
+ let behind = null;
12074
+ if (bands.length > 0 || grid) {
12075
+ behind = grid || _svgGroup.insert('g', 'g').attr('class', 'aptx-timegrid')
12076
+ .style('pointer-events', 'none');
12077
+ }
12078
+ let ring = d3.arc().startAngle(0).endAngle(2 * Math.PI);
12079
+ let named = [];
12080
+ bands.forEach(function (iv) {
12081
+ let a = radiusOf(Math.min(iv.old, from));
12082
+ let b = radiusOf(Math.max(iv.young, to));
12083
+ if (b - a <= 0) {
12084
+ return;
12085
+ }
12086
+ behind.append('path').attr('d', ring.innerRadius(a).outerRadius(b)())
12087
+ .attr('fill', iv.color).attr('fill-opacity', 0.35);
12088
+ named.push({r: (a + b) / 2, span: b - a, text: iv.name, color: iv.color});
12089
+ });
12090
+ if (grid) {
12091
+ ticks.forEach(function (t) {
12092
+ let r = radiusOf(t.value);
12093
+ if (r > r0 + 0.5 && r < r1 - 0.5) {
12094
+ grid.append('circle').attr('r', r).attr('fill', 'none')
12095
+ .attr('stroke', ink).attr('stroke-opacity', 0.18).attr('stroke-width', 1);
12096
+ }
12097
+ });
12098
+ }
12099
+
12100
+ // the ruler, on the gap's centre line
12101
+ let gap = _radialRotation + _radial.angleSpan + ((2 * Math.PI - _radial.angleSpan) / 2);
12102
+ let screen = gap - (Math.PI / 2);
12103
+ let along = [Math.cos(screen), Math.sin(screen)];
12104
+ let across = [-along[1], along[0]];
12105
+ let flip = along[0] < -1e-9; // keep every number upright
12106
+ let deg = (screen * 180 / Math.PI) + (flip ? 180 : 0);
12107
+ let at = function (r, off) {
12108
+ return [(along[0] * r) + (across[0] * off), (along[1] * r) + (across[1] * off)];
12109
+ };
12110
+ let ax = g.append('g').attr('class', 'aptx-time-ruler');
12111
+ let e0 = at(r0, 0);
12112
+ let e1 = at(r1, 0);
12113
+ ax.append('line').attr('x1', e0[0]).attr('y1', e0[1]).attr('x2', e1[0]).attr('y2', e1[1])
12114
+ .attr('stroke', ink).attr('stroke-width', 1);
12115
+ // numbers on one side of the ruler, interval names on the other; the
12116
+ // headline value first, then the round ticks outward, none overlapping
12117
+ function label(r, off, text, fill, backdrop) {
12118
+ let p = at(r, off);
12119
+ let t = ax.append('g')
12120
+ .attr('transform', 'translate(' + p[0] + ',' + p[1] + ') rotate(' + deg + ')');
12121
+ let w = text.length * 5.5;
12122
+ if (backdrop) {
12123
+ t.append('rect').attr('x', -(w / 2) - 2).attr('y', -6).attr('width', w + 4).attr('height', 12)
12124
+ .attr('rx', 2).attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
12125
+ }
12126
+ t.append('text').attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
12127
+ .style('font-size', '9px').style('fill', fill).text(text);
12128
+ }
12129
+ let side = flip ? -1 : 1;
12130
+ let placed = [];
12131
+ ticks.sort(function (p, q) {
12132
+ return (q.first ? 1 : 0) - (p.first ? 1 : 0) || radiusOf(p.value) - radiusOf(q.value);
12133
+ });
12134
+ ticks.forEach(function (t) {
12135
+ let r = radiusOf(t.value);
12136
+ let c0 = at(r, -4);
12137
+ let c1 = at(r, 4);
12138
+ ax.append('line').attr('x1', c0[0]).attr('y1', c0[1]).attr('x2', c1[0]).attr('y2', c1[1])
12139
+ .attr('stroke', ink).attr('stroke-width', 1);
12140
+ let half = t.text.length * 2.8;
12141
+ for (let i = 0; i < placed.length; ++i) {
12142
+ if (Math.abs(placed[i] - r) < (half * 2) + 6) {
12143
+ if (!t.first) {
12144
+ return;
12145
+ }
12146
+ }
12147
+ }
12148
+ label(r, side * 11, t.text, ink, true);
12149
+ placed.push(r);
12150
+ });
12151
+ if (info.type === 'geologic') {
12152
+ label(r1 + 14, 0, 'Ma', ink, false);
12153
+ named.forEach(function (n) {
12154
+ if ((n.text.length * 5.5) + 4 <= n.span) {
12155
+ label(n.r, -side * 11, n.text, ink, true);
12156
+ }
12157
+ });
12158
+ }
12159
+ }
12160
+
11867
12161
  function timeAxisCbClicked() {
11868
12162
  _state.showTimeAxis = getCheckboxValue(TIME_AXIS_CB);
11869
12163
  scheduleUpdate(null, 0);
@@ -13020,12 +13314,13 @@ function (root, d3, forester, phyloXml) {
13020
13314
  syncHeatmapControls();
13021
13315
  let timeCb = byId(TIME_AXIS_CB);
13022
13316
  if (timeCb) {
13023
- timeCb.disabled = radialDisplay();
13317
+ // circular draws it along the gap; only unrooted cannot
13318
+ timeCb.disabled = _state.unrootedDisplay === true;
13024
13319
  }
13025
13320
  let timeGridCb = byId(TIME_GRID_CB);
13026
13321
  if (timeGridCb) {
13027
13322
  // grid lines hang off the time axis: no axis, nothing to grid
13028
- timeGridCb.disabled = radialDisplay() || _state.showTimeAxis !== true;
13323
+ timeGridCb.disabled = _state.unrootedDisplay === true || _state.showTimeAxis !== true;
13029
13324
  }
13030
13325
  let minus = byId(ZOOM_OUT_X);
13031
13326
  let plus = byId(ZOOM_IN_X);
@@ -13272,14 +13567,165 @@ function (root, d3, forester, phyloXml) {
13272
13567
  scheduleUpdate(null, 0);
13273
13568
  }
13274
13569
 
13275
- function downloadButtonPressed() {
13276
- const s = byId(EXPORT_FORMAT_SELECT);
13277
- if (s) {
13278
- let format = s.value;
13279
- downloadTree(format);
13570
+ // The Download button opens the format menu under itself; pressed again
13571
+ // while the menu is open, it closes it.
13572
+ function downloadButtonPressed(event) {
13573
+ let btn = event.currentTarget;
13574
+ showNodeMenu(downloadMenuItems(), null, null, btn);
13575
+ // opened from the keyboard (a click with no pointer), the keyboard
13576
+ // goes into the menu: Tab walks the formats, Escape closes it
13577
+ if (event.detail === 0 && _nodeMenu) {
13578
+ let first = _nodeMenu.querySelector('button');
13579
+ if (first) {
13580
+ first.focus();
13581
+ }
13280
13582
  }
13281
13583
  }
13282
13584
 
13585
+ // Every format says what it keeps: a Newick file silently drops
13586
+ // everything but names, lengths and support, and a user picking by
13587
+ // extension would never know.
13588
+ function downloadMenuItems() {
13589
+ let items = [];
13590
+ let entry = function (format) {
13591
+ return function () {
13592
+ downloadTree(format);
13593
+ };
13594
+ };
13595
+ items.push({heading: 'Graphics, ' + (_downloadFullSize ? 'full size' : 'as shown')});
13596
+ items.push({label: 'SVG', detail: 'vector: edit it in Inkscape or Illustrator',
13597
+ action: entry(SVG_EXPORT_FORMAT)});
13598
+ if (pdfExportAvailable()) {
13599
+ items.push({label: 'PDF', detail: 'vector: ready to print or to put in a paper',
13600
+ action: entry(PDF_EXPORT_FORMAT)});
13601
+ }
13602
+ if (pngExportAvailable()) {
13603
+ items.push({label: 'PNG', detail: 'image, at ' + _pngScale + '× the screen\'s resolution',
13604
+ action: entry(PNG_EXPORT_FORMAT)});
13605
+ }
13606
+ let part = subtreeExtentNote();
13607
+ items.push({heading: part ? 'Tree: ' + part : 'Tree'});
13608
+ items.push({label: 'phyloXML', detail: 'keeps everything the tree carries: taxonomy, sequences, dates, properties',
13609
+ action: entry(PHYLOXML_EXPORT_FORMAT)});
13610
+ items.push({label: 'Newick', detail: 'names, branch lengths and support values only',
13611
+ action: entry(NH_EXPORT_FORMAT)});
13612
+ let nexus = nexusDownloadDetail();
13613
+ items.push({label: 'Nexus', detail: nexus.text, warn: nexus.warn, action: entry(NEXUS_EXPORT_FORMAT)});
13614
+ // FASTA only when there is something to write: a tip carrying a
13615
+ // molecular sequence, aligned or not (same gate as the node menu's
13616
+ // Fasta entries) -- on any other tree the download would be empty
13617
+ items.push({heading: part ? 'Tip data: ' + part : 'Tip data'});
13618
+ items.push({label: 'TSV', detail: 'a table, one row per tip: names, taxonomy, sequence names, '
13619
+ + 'branch lengths and every property', action: entry(TSV_EXPORT_FORMAT)});
13620
+ if (_basicTreeProperties && _basicTreeProperties.maxMolSeqLength > 0) {
13621
+ items.push({label: 'FASTA', detail: 'every molecular sequence the tips carry',
13622
+ action: entry(FASTA_EXPORT_FORMAT)});
13623
+ }
13624
+ return items;
13625
+ }
13626
+
13627
+ // What a Nexus download will hold, asked of the WRITER rather than
13628
+ // predicted: toNexus decides whether the sequences can form a matrix
13629
+ // (equal lengths, unambiguous labels), and when they cannot it says why
13630
+ // in a bracketed comment -- which is the sentence shown here, so the menu
13631
+ // and the file can never disagree.
13632
+ function nexusDownloadDetail() {
13633
+ let plain = {text: 'the tree: names, branch lengths and support values', warn: false};
13634
+ if (!_root || !_basicTreeProperties || !(_basicTreeProperties.maxMolSeqLength > 0)) {
13635
+ return plain;
13636
+ }
13637
+ let nex;
13638
+ try {
13639
+ nex = forester.toNexus(treeForExport(), 9, _settings.nhExportWriteConfidences);
13640
+ } catch {
13641
+ return plain;
13642
+ }
13643
+ if (nex.indexOf('Begin Characters;') >= 0) {
13644
+ return {text: 'the tree and its alignment, in one file', warn: false};
13645
+ }
13646
+ // to the comment's own closing " ]" at the end of its line: a taxon
13647
+ // label quoted inside the reason may itself hold a ']'
13648
+ let why = /\[ Molecular sequences were not written: (.*?) \]$/m.exec(nex);
13649
+ return {
13650
+ text: 'the tree only, without the sequences: ' + (why ? why[1] : 'they cannot form a matrix')
13651
+ + ' phyloXML and FASTA keep them.',
13652
+ warn: true
13653
+ };
13654
+ }
13655
+
13656
+ // The file name the user typed, made safe, with the format's extension.
13657
+ // A typed extension is not doubled up ("tree.svg" saves as tree.svg, and
13658
+ // as tree.png from the PNG entry), and an empty field falls back to the
13659
+ // tree's name.
13660
+ function downloadFileName(suffix) {
13661
+ let field = byId(DOWNLOAD_NAME_FIELD);
13662
+ let stem = (field ? field.value : '').trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_');
13663
+ let lower = stem.toLowerCase();
13664
+ [NH_SUFFIX, NEXUS_SUFFIX, XML_SUFFIX, FASTA_SUFFIX, TSV_SUFFIX, SVG_SUFFIX, PDF_SUFFIX, PNG_SUFFIX,
13665
+ '.nex', '.nwk', '.newick', '.nh', '.nhx', '.tree', '.phyloxml', '.fa', '.fas', '.txt',
13666
+ '.csv'].some(function (x) {
13667
+ if (lower.length > x.length && lower.endsWith(x)) {
13668
+ stem = stem.substring(0, stem.length - x.length);
13669
+ return true;
13670
+ }
13671
+ return false;
13672
+ });
13673
+ if (stem.replace(/[._\s]/g, '') === '') {
13674
+ stem = _state.downloadStem;
13675
+ }
13676
+ return stem + suffix;
13677
+ }
13678
+
13679
+ function copyNewickPressed(event) {
13680
+ let btn = event.currentTarget;
13681
+ let nh = _root ? forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences) : '';
13682
+ copyText(nh).then(function (ok) {
13683
+ flashButton(btn, ok ? 'Copied ✓' : 'Copy failed');
13684
+ });
13685
+ }
13686
+
13687
+ // The async clipboard API where the page allows it (a secure context);
13688
+ // otherwise the old select-and-copy, so a page served over plain http
13689
+ // still copies. Resolves to whether the text made it.
13690
+ function copyText(text) {
13691
+ let legacy = function () {
13692
+ let ta = document.createElement('textarea');
13693
+ ta.value = text;
13694
+ ta.setAttribute('readonly', '');
13695
+ ta.style.position = 'fixed';
13696
+ ta.style.left = '-10000px';
13697
+ document.body.appendChild(ta);
13698
+ ta.select();
13699
+ let ok = false;
13700
+ try {
13701
+ ok = document.execCommand('copy');
13702
+ } catch {
13703
+ ok = false;
13704
+ }
13705
+ ta.remove();
13706
+ return ok;
13707
+ };
13708
+ if (navigator.clipboard && window.isSecureContext) {
13709
+ return navigator.clipboard.writeText(text).then(function () {
13710
+ return true;
13711
+ }, legacy);
13712
+ }
13713
+ return Promise.resolve(legacy());
13714
+ }
13715
+
13716
+ // A button says what just happened on its own face for a moment, then
13717
+ // goes back to its name: no toast to find, and nothing left behind.
13718
+ function flashButton(btn, text) {
13719
+ if (btn.dataset.label === undefined) {
13720
+ btn.dataset.label = btn.textContent;
13721
+ }
13722
+ btn.textContent = text;
13723
+ clearTimeout(btn._flashTimer);
13724
+ btn._flashTimer = setTimeout(function () {
13725
+ btn.textContent = btn.dataset.label;
13726
+ }, 1600);
13727
+ }
13728
+
13283
13729
  function changeBaseBackgoundColor(color) {
13284
13730
  setStylesAll('.' + BASE_BACKGROUND, {
13285
13731
  'fill': color
@@ -13543,7 +13989,9 @@ function (root, d3, forester, phyloXml) {
13543
13989
  // of open sections: three open sections measure anywhere from 215px to
13544
13990
  // 562px, so a count would not have bounded the height at all.
13545
13991
  const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
13546
- const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures'];
13992
+ // Download too (Christian, 2026-09-28): four rows, and not something a
13993
+ // session uses often.
13994
+ const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['View & Tools', 'Sizes', 'Domain Architectures', 'Download'];
13547
13995
 
13548
13996
  function loadPanelSections() {
13549
13997
  if (_panelSections) {
@@ -13558,6 +14006,11 @@ function (root, d3, forester, phyloXml) {
13558
14006
  _panelSections[k] = saved[k];
13559
14007
  }
13560
14008
  });
14009
+ // the section was called Zoom until 2026-09-30: a fold
14010
+ // remembered under that name still counts
14011
+ if (typeof saved.Zoom === 'boolean' && _panelSections['View & Tools'] === undefined) {
14012
+ _panelSections['View & Tools'] = saved.Zoom;
14013
+ }
13561
14014
  }
13562
14015
  } catch {
13563
14016
  // no storage (private mode), or junk written by something else:
@@ -13998,13 +14451,13 @@ function (root, d3, forester, phyloXml) {
13998
14451
  }
13999
14452
  // Dark palette tokens, shared by the system-preference default and the
14000
14453
  // explicit "dark" choice from the header light/dark switch.
14001
- let dark = ' --p-bg:rgba(24,35,46,0.86); --p-ink:#e7eef5; --p-muted:#94a4b3; --p-faint:#6f8090;'
14454
+ let dark = ' --p-bg:rgba(24,35,46,0.86); --p-ink:#e7eef5; --p-muted:#94a4b3; --p-faint:#6f8090; --p-warn:#f0a35e;'
14002
14455
  + ' --p-line:#27343f; --p-line-strong:#35434f; --p-surface2:#202d38;'
14003
14456
  + ' --p-accent:#57a6ff; --p-accent-ink:#9cc7ff; --p-accent-weak:rgba(87,166,255,0.18);'
14004
14457
  + ' --p-shadow-sm:0 1px 2px rgba(0,0,0,0.4);';
14005
14458
  let css = ''
14006
14459
  + '.aptx-panel {'
14007
- + ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
14460
+ + ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
14008
14461
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
14009
14462
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
14010
14463
  + ' --p-shadow-sm:0 1px 2px rgba(23,34,46,0.12);'
@@ -14031,7 +14484,7 @@ function (root, d3, forester, phyloXml) {
14031
14484
  // (they cannot live inside the panel), so they carry their own
14032
14485
  // copy of the palette and follow the same light / dark rules.
14033
14486
  + '.aptx-node-menu, .aptx-suggest {'
14034
- + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
14487
+ + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
14035
14488
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
14036
14489
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
14037
14490
  + '}'
@@ -14061,6 +14514,17 @@ function (root, d3, forester, phyloXml) {
14061
14514
  + ' background:#e5484d; color:#fff; }'
14062
14515
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
14063
14516
  + '.aptx-node-menu-note { padding:4px 9px 7px; color:var(--p-ink); white-space:normal; }'
14517
+ // A group title inside a menu (the Download menu's Graphics / Tree
14518
+ // / Sequences): the title's type, set off from the group above.
14519
+ + '.aptx-node-menu-heading { margin-top:4px; }'
14520
+ + '.aptx-node-menu-heading:first-child { margin-top:0; }'
14521
+ // an entry's second line: what that choice keeps or does
14522
+ + '.aptx-node-menu button .aptx-menu-detail { display:block; white-space:normal; font-size:10px;'
14523
+ + ' line-height:1.35; color:var(--p-muted); margin-top:1px; }'
14524
+ + '.aptx-node-menu button .aptx-menu-detail.aptx-menu-warn { color:var(--p-warn); }'
14525
+ + '.aptx-node-menu button:hover .aptx-menu-detail, .aptx-node-menu button:focus-visible .aptx-menu-detail {'
14526
+ + ' color:inherit; opacity:0.86; }'
14527
+ + '.aptx-node-menu:has(.aptx-menu-detail) { width:264px; max-width:264px; }'
14064
14528
  // The search suggestions: the node menu's chrome under the value box.
14065
14529
  + '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
14066
14530
  + ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
@@ -14079,7 +14543,7 @@ function (root, d3, forester, phyloXml) {
14079
14543
  + ' font-size:10px; color:var(--p-faint); }'
14080
14544
  // The node-data dialog, on the same palette as the panel and the menu.
14081
14545
  + '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
14082
- + ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
14546
+ + ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
14083
14547
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
14084
14548
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
14085
14549
  + '}'
@@ -14229,7 +14693,7 @@ function (root, d3, forester, phyloXml) {
14229
14693
  + ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
14230
14694
  // The hover tooltip, on the same palette.
14231
14695
  + '.aptx-tip {'
14232
- + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
14696
+ + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
14233
14697
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1;'
14234
14698
  + '}'
14235
14699
  + '@media (prefers-color-scheme:dark){ .aptx-tip:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
@@ -14384,6 +14848,16 @@ function (root, d3, forester, phyloXml) {
14384
14848
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn { flex:1 1 0; padding:0; }'
14385
14849
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn:last-child { margin-right:0; }'
14386
14850
  + '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
14851
+ // The Download section: a label column, then a control that fills
14852
+ // the row -- the name field, a segmented choice, or the two buttons
14853
+ // sharing it equally (their words centred, never clipped).
14854
+ + '.aptx-panel .aptx-dlrow { display:flex; align-items:center; gap:6px; margin:4px 0; }'
14855
+ + '.aptx-panel .aptx-dlrow .aptx-dllabel { flex:0 0 52px; font-size:10px; color:var(--p-muted); }'
14856
+ + '.aptx-panel .aptx-dlrow input[type=text] { flex:1 1 auto; min-width:0; height:24px; box-sizing:border-box; }'
14857
+ + '.aptx-panel .aptx-dlrow .aptx-segmented { flex:1 1 auto; display:flex; }'
14858
+ + '.aptx-panel .aptx-dlrow .aptx-seg { flex:1 1 0; padding:3px 4px; font-weight:500; white-space:nowrap; }'
14859
+ + '.aptx-panel .aptx-dlrow .aptx-dlbtn { flex:1 1 0; min-width:0; margin:0; padding:0 6px;'
14860
+ + ' display:flex; align-items:center; justify-content:center; white-space:nowrap; }'
14387
14861
  + '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
14388
14862
  + '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
14389
14863
  // ... except one that has a WORD on it: the rule above sizes the
@@ -14412,7 +14886,6 @@ function (root, d3, forester, phyloXml) {
14412
14886
  + ' line-height:1; color:var(--p-muted); transition:transform .15s,color .15s; }'
14413
14887
  + '.aptx-panel legend.aptx-legend-toggle:hover::after { color:var(--p-accent-ink); }'
14414
14888
  + '.aptx-panel legend.aptx-legend-toggle:hover { color:var(--p-accent-ink); }'
14415
- + '.aptx-panel #' + EXPORT_FORMAT_SELECT + ' { margin-left:6px; }'
14416
14889
  + '.aptx-panel fieldset.aptx-collapsed > legend.aptx-legend-toggle::after { transform:rotate(-90deg); }'
14417
14890
  + '.aptx-panel fieldset.aptx-collapsed > legend { margin-bottom:0; }'
14418
14891
  + '.aptx-panel fieldset.aptx-collapsed > .aptx-fieldset-body { display:none; }'
@@ -15618,14 +16091,6 @@ function (root, d3, forester, phyloXml) {
15618
16091
  // the stylesheet's job now: the search row sizes its R button, and the
15619
16092
  // zoom grid stretches Y+/Y- to the exact width of the button row.)
15620
16093
 
15621
- const downloadButton = byId(DOWNLOAD_BUTTON);
15622
-
15623
- if (downloadButton) {
15624
- setStyles(downloadButton, {
15625
- 'width': '60px', 'margin-bottom': '3px'
15626
- });
15627
- }
15628
-
15629
16094
 
15630
16095
  on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
15631
16096
 
@@ -15842,12 +16307,19 @@ function (root, d3, forester, phyloXml) {
15842
16307
 
15843
16308
  // ----------------
15844
16309
 
15845
- if (downloadButton) {
15846
- downloadButton.addEventListener('click', downloadButtonPressed);
15847
- }
15848
-
15849
- setStyles(byId(EXPORT_FORMAT_SELECT), {
15850
- 'font': 'inherit', 'color': 'inherit'
16310
+ on(DOWNLOAD_BUTTON, 'click', downloadButtonPressed);
16311
+ on(COPY_NEWICK_BUTTON, 'click', copyNewickPressed);
16312
+ on(COPY_PNG_BUTTON, 'click', copyPngPressed);
16313
+ on(DOWNLOAD_AS_SHOWN_BUTTON, 'change', function () {
16314
+ _downloadFullSize = false;
16315
+ });
16316
+ on(DOWNLOAD_FULL_SIZE_BUTTON, 'change', function () {
16317
+ _downloadFullSize = true;
16318
+ });
16319
+ DOWNLOAD_PNG_SCALES.forEach(function (k) {
16320
+ on('dl_png' + k + '_b', 'change', function () {
16321
+ _pngScale = k;
16322
+ });
15851
16323
  });
15852
16324
 
15853
16325
  // ---------------
@@ -16143,14 +16615,14 @@ function (root, d3, forester, phyloXml) {
16143
16615
  h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, PHYLOGRAM_ALIGNED_WHAT));
16144
16616
  h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, CLADOGRAM_WHAT));
16145
16617
  h = h.concat('</div>');
16146
- // The branch SCALE, when the tree states two different things: its
16147
- // nodes' dates, and its branches' divergence. Hidden otherwise,
16618
+ // The branch SCALE, when the tree can be laid out by time and by
16619
+ // divergence (forester.hasTimeAndDivergence). Hidden otherwise,
16148
16620
  // which is most trees.
16149
16621
  h = h.concat('<div class="' + BRANCH_SCALE_CONTROLGROUP + ' aptx-segmented">');
16150
16622
  h = h.concat(makeSegment('Time', BRANCH_SCALE_TIME_BUTTON, 'branch_scale_radio',
16151
- 'branch lengths measure TIME (the nodes\' dates)'));
16623
+ 'lay the tree out by TIME (each branch at its stated length in time; from the node dates where the tree arrived showing divergence)'));
16152
16624
  h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
16153
- 'branch lengths measure DIVERGENCE (substitutions, as the file states them)'));
16625
+ 'lay the tree out by DIVERGENCE (substitutions per site: as the tree records it, or on a BEAST tree each branch\'s time x its clock rate)'));
16154
16626
  h = h.concat('</div>');
16155
16627
  h = h.concat('</div>');
16156
16628
  h = h.concat('</fieldset>');
@@ -16252,8 +16724,8 @@ function (root, d3, forester, phyloXml) {
16252
16724
  if (_timeInfo && _timeInfo.type) {
16253
16725
  opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
16254
16726
  + (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
16255
- + ' and node-age bars (phylogram, rectangular layout only)'));
16256
- opts.push(makeCheckboxItem('Time Grid', TIME_GRID_CB, 'to show/hide vertical grid lines at the '
16727
+ + ' and node-age bars (phylogram; rectangular and circular layouts)'));
16728
+ opts.push(makeCheckboxItem('Time Grid', TIME_GRID_CB, 'to show/hide grid lines (rings, in circular) at the '
16257
16729
  + (_timeInfo.type === 'geologic' ? 'geologic interval boundaries' : 'calendar year ticks')));
16258
16730
  }
16259
16731
 
@@ -16272,7 +16744,7 @@ function (root, d3, forester, phyloXml) {
16272
16744
  // syncZoomRowButtons). The Y buttons span the whole row.
16273
16745
  let h = "";
16274
16746
  h = h.concat('<fieldset>');
16275
- h = h.concat('<legend>Zoom</legend>');
16747
+ h = h.concat('<legend>View &amp; Tools</legend>');
16276
16748
  h = h.concat('<div class="aptx-zoomgrid">');
16277
16749
  h = h.concat(makeButton('Y+', ZOOM_IN_Y, 'zoom in vertically (or Shift+mousewheel)'));
16278
16750
  h = h.concat('<div class="aptx-zoomrow">');
@@ -16284,8 +16756,9 @@ function (root, d3, forester, phyloXml) {
16284
16756
  h = h.concat('</div>');
16285
16757
  h = h.concat(makeButton('Y-', ZOOM_OUT_Y, 'zoom out vertically (or Shift+mousewheel)'));
16286
16758
  h = h.concat('</div>');
16287
- // the tool buttons share the Zoom section rather than paying for
16288
- // a fieldset of their own
16759
+ // the tool buttons share the section with the zoom row rather
16760
+ // than paying for a fieldset of their own -- hence its name
16761
+ // (Christian, 2026-09-30: "ZOOM" named only half of it)
16289
16762
  h = h.concat('<div class="aptx-toolrow">');
16290
16763
  h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
16291
16764
  h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
@@ -16298,34 +16771,49 @@ function (root, d3, forester, phyloXml) {
16298
16771
  return h;
16299
16772
  }
16300
16773
 
16774
+ // A titled section like the others, so it folds. The format is picked
16775
+ // from a menu the Download button opens (showDownloadMenu), where each
16776
+ // format says what it keeps; the rows above it are the choices every
16777
+ // graphic, or every file, shares.
16301
16778
  function makeDownloadSection() {
16302
- let h = "";
16303
- h = h.concat('<form action="#">');
16304
- h = h.concat('<fieldset>');
16305
- h = h.concat('<input type="button" value="Download" name="' + DOWNLOAD_BUTTON + '" title="download/export tree in a selected format" id="' + DOWNLOAD_BUTTON + '">');
16306
- //h = h.concat('<br>');
16307
- h = h.concat('<select name="' + EXPORT_FORMAT_SELECT + '" id="' + EXPORT_FORMAT_SELECT
16308
- + '" aria-label="Download format" title="the file format the Download button writes">');
16779
+ let h = '<fieldset class="aptx-download">';
16780
+ h = h.concat('<legend>Download</legend>');
16781
+ h = h.concat('<div class="aptx-dlrow"><label class="aptx-dllabel" for="' + DOWNLOAD_NAME_FIELD
16782
+ + '" title="the file name: each format adds its own extension">Name</label>');
16783
+ h = h.concat('<input type="text" id="' + DOWNLOAD_NAME_FIELD + '" name="' + DOWNLOAD_NAME_FIELD
16784
+ + '" spellcheck="false" autocomplete="off"'
16785
+ + ' title="the file name: each format adds its own extension"></div>');
16786
+ h = h.concat('<div class="aptx-dlrow"><span class="aptx-dllabel">Graphics</span>');
16787
+ h = h.concat('<div class="aptx-segmented">');
16788
+ h = h.concat(makeSegment('As shown', DOWNLOAD_AS_SHOWN_BUTTON, 'dl_extent_radio',
16789
+ 'SVG, PDF and PNG show what is on screen: the part of the tree in view, at the current zoom'));
16790
+ h = h.concat(makeSegment('Full size', DOWNLOAD_FULL_SIZE_BUTTON, 'dl_extent_radio',
16791
+ 'SVG, PDF and PNG show the whole tree, drawn large enough for every name'));
16792
+ h = h.concat('</div></div>');
16309
16793
  if (pngExportAvailable()) {
16310
- h = h.concat('<option value="' + PNG_EXPORT_FORMAT + '">' + PNG_EXPORT_FORMAT + '</option>');
16311
- }
16312
- if (pdfExportAvailable()) {
16313
- h = h.concat('<option value="' + PDF_EXPORT_FORMAT + '">' + PDF_EXPORT_FORMAT + '</option>');
16314
- }
16315
- h = h.concat('<option value="' + SVG_EXPORT_FORMAT + '">' + SVG_EXPORT_FORMAT + '</option>');
16316
- h = h.concat('<option value="' + PHYLOXML_EXPORT_FORMAT + '">' + PHYLOXML_EXPORT_FORMAT + '</option>');
16317
- h = h.concat('<option value="' + NH_EXPORT_FORMAT + '">' + NH_EXPORT_FORMAT + '</option>');
16318
- h = h.concat('<option value="' + NEXUS_EXPORT_FORMAT + '">' + NEXUS_EXPORT_FORMAT + '</option>');
16319
- // only when there is something to write: a tip carrying a
16320
- // molecular sequence, aligned or not (same gate as the node
16321
- // menu's Fasta entries) -- on any other tree the download would
16322
- // just be an empty file
16323
- if (_basicTreeProperties.maxMolSeqLength > 0) {
16324
- h = h.concat('<option value="' + FASTA_EXPORT_FORMAT + '">' + FASTA_EXPORT_FORMAT + '</option>');
16794
+ h = h.concat('<div class="aptx-dlrow"><span class="aptx-dllabel">PNG</span>');
16795
+ h = h.concat('<div class="aptx-segmented">');
16796
+ DOWNLOAD_PNG_SCALES.forEach(function (k) {
16797
+ h = h.concat(makeSegment(k + '×', 'dl_png' + k + '_b', 'dl_png_radio',
16798
+ 'PNG at ' + k + ' times the resolution of the screen'));
16799
+ });
16800
+ h = h.concat('</div></div>');
16325
16801
  }
16326
- h = h.concat('</select>');
16802
+ h = h.concat('<div class="aptx-dlrow">');
16803
+ h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + DOWNLOAD_BUTTON + '" name="'
16804
+ + DOWNLOAD_BUTTON + '" aria-haspopup="menu"'
16805
+ + ' title="download the tree: choose the format from the menu">Download…</button>');
16806
+ h = h.concat('</div>');
16807
+ h = h.concat('<div class="aptx-dlrow">');
16808
+ h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + COPY_NEWICK_BUTTON + '" name="'
16809
+ + COPY_NEWICK_BUTTON + '" title="copy the tree to the clipboard in Newick format">Copy Newick</button>');
16810
+ if (pngCopyAvailable()) {
16811
+ h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + COPY_PNG_BUTTON + '" name="'
16812
+ + COPY_PNG_BUTTON + '" title="copy the tree to the clipboard as a PNG image, to paste into'
16813
+ + ' a document or a slide: drawn as the Graphics and PNG choices above say">Copy PNG</button>');
16814
+ }
16815
+ h = h.concat('</div>');
16327
16816
  h = h.concat('</fieldset>');
16328
- h = h.concat('</form>');
16329
16817
  return h;
16330
16818
  }
16331
16819
 
@@ -16578,6 +17066,27 @@ function (root, d3, forester, phyloXml) {
16578
17066
  setCheckboxValue(VISUAL_STYLES_CB, _state.useVisualStyles);
16579
17067
  setCheckboxValue(VIS_CB, _state.showVisualizations);
16580
17068
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
17069
+ {
17070
+ let nameField = byId(DOWNLOAD_NAME_FIELD);
17071
+ if (nameField) {
17072
+ nameField.value = _state.downloadStem;
17073
+ }
17074
+ // once per launch, not in initializeSettings: zoom-to-fit re-runs
17075
+ // that, and would throw the user's pick away
17076
+ _pngScale = DOWNLOAD_PNG_SCALES.reduce(function (best, k) {
17077
+ let want = _settings.pngExportScale;
17078
+ return Math.abs(Math.log(k / want)) < Math.abs(Math.log(best / want)) ? k : best;
17079
+ });
17080
+ // any positive number used to be taken as it was; the panel offers
17081
+ // 2x, 4x and 8x, and the setting is now the one it starts on
17082
+ // (Christian, 2026-09-28) -- said, not done silently
17083
+ if (_pngScale !== _settings.pngExportScale) {
17084
+ console.warn(WARNING + ': pngExportScale ' + _settings.pngExportScale + ' is not one of '
17085
+ + DOWNLOAD_PNG_SCALES.join(', ') + ': the PNG starts at ' + _pngScale + 'x');
17086
+ }
17087
+ setRadioButtonValue(_downloadFullSize ? DOWNLOAD_FULL_SIZE_BUTTON : DOWNLOAD_AS_SHOWN_BUTTON, true);
17088
+ setRadioButtonValue('dl_png' + _pngScale + '_b', true);
17089
+ }
16581
17090
  setCheckboxValue(MSA_CB, _state.showMsa);
16582
17091
  setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
16583
17092
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
@@ -16681,14 +17190,7 @@ function (root, d3, forester, phyloXml) {
16681
17190
  disableCheckbox('#' + PHYLOGRAM_BUTTON);
16682
17191
  disableCheckbox('#' + PHYLOGRAM_ALIGNED_BUTTON);
16683
17192
  }
16684
- setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
16685
- setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
16686
- // most trees state only one thing, and a control offering a choice
16687
- // that does not exist is worse than no control
16688
- let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
16689
- if (scaleGroup) {
16690
- scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
16691
- }
17193
+ syncBranchScaleControls();
16692
17194
  }
16693
17195
 
16694
17196
 
@@ -16846,70 +17348,348 @@ function (root, d3, forester, phyloXml) {
16846
17348
  return svgText;
16847
17349
  }
16848
17350
 
16849
- function getTreeAsSvg() {
16850
- let svg = treeSvgElement();
16851
- if (!svg) {
17351
+ // The live tree's <svg>, copied and cleaned for a file: the overview, the
17352
+ // search halos and the hover glow are on-screen aids, not the tree.
17353
+ function exportCopy(svg) {
17354
+ return cleanExportCopy(svg.cloneNode(true));
17355
+ }
17356
+
17357
+ function cleanExportCopy(copy) {
17358
+ // Serialize a COPY with the overview taken out of it: the overview is
17359
+ // an on-screen navigation aid, not part of the tree, and working on a
17360
+ // copy leaves the live display untouched.
17361
+ let overview = copy.querySelector('.aptx-overview');
17362
+ if (overview) {
17363
+ overview.remove();
17364
+ }
17365
+ // the halo discs' translucency and pulse live in page CSS the
17366
+ // export cannot carry -- serialized as-is they become solid blobs
17367
+ copy.querySelectorAll('circle.foundHalo').forEach(function (h) {
17368
+ h.remove();
17369
+ });
17370
+ let glow = copy.querySelector('.aptx-hoverglow');
17371
+ if (glow) {
17372
+ glow.remove();
17373
+ }
17374
+ return copy;
17375
+ }
17376
+
17377
+ // The same element of the copy as `live` is of the svg: children are
17378
+ // cloned in order, so the index finds it -- in a copy nothing has been
17379
+ // taken out of yet (cleaning it removes the overview, and every child
17380
+ // after that would be off by one).
17381
+ function copyCounterpart(svg, copy, live) {
17382
+ let i = Array.prototype.indexOf.call(svg.children, live);
17383
+ return i >= 0 ? copy.children[i] : null;
17384
+ }
17385
+
17386
+ // The graphic an SVG, PDF or PNG download draws, as {text, w, h}: the
17387
+ // serialized light-theme svg and its size in px. Which one follows the
17388
+ // Download section's choice.
17389
+ function exportGraphic() {
17390
+ if (!treeSvgElement() || typeof window.XMLSerializer === 'undefined') {
16852
17391
  return null;
16853
17392
  }
16854
- let svgTree = null;
16855
- if (typeof window.XMLSerializer !== 'undefined') {
16856
- // Serialize a COPY with the overview taken out of it: the overview is
16857
- // an on-screen navigation aid, not part of the tree, and working on a
16858
- // copy leaves the live display untouched.
16859
- let copy = svg.cloneNode(true);
16860
- let overview = copy.querySelector('.aptx-overview');
16861
- if (overview) {
16862
- overview.remove();
16863
- }
16864
- // the halo discs' translucency and pulse live in page CSS the
16865
- // export cannot carry -- serialized as-is they become solid blobs
16866
- copy.querySelectorAll('circle.foundHalo').forEach(function (h) {
16867
- h.remove();
16868
- });
16869
- let glow = copy.querySelector('.aptx-hoverglow');
16870
- if (glow) {
16871
- glow.remove();
16872
- }
16873
- // The floating strips re-anchor to the TREE for export: on screen
16874
- // a strip may be stuck at the viewport bottom, but a figure must
16875
- // not carry an artefact of where the user happened to be scrolled
16876
- // -- the axis belongs at the bottom of the tree. (As the desktop
16877
- // does for PDF and graphics export.)
16878
- let t = d3.zoomTransform(svg);
16879
- copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
16880
- strip.setAttribute('transform', 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')');
17393
+ return _downloadFullSize ? graphicFullSize() : graphicAsShown();
17394
+ }
17395
+
17396
+ // "As shown": the viewport, exactly as it is on screen.
17397
+ function graphicAsShown() {
17398
+ let svg = treeSvgElement();
17399
+ let copy = exportCopy(svg);
17400
+ // The floating strips re-anchor to the TREE for export: on screen
17401
+ // a strip may be stuck at the viewport bottom, but a figure must
17402
+ // not carry an artefact of where the user happened to be scrolled
17403
+ // -- the axis belongs at the bottom of the tree. (As the desktop
17404
+ // does for PDF and graphics export.)
17405
+ let t = d3.zoomTransform(svg);
17406
+ copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
17407
+ strip.setAttribute('transform', 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')');
17408
+ });
17409
+ return {
17410
+ text: toLightExport((new XMLSerializer()).serializeToString(copy)),
17411
+ w: svg.width.baseVal.value || _displayWidth,
17412
+ h: svg.height.baseVal.value || _displayHeight
17413
+ };
17414
+ }
17415
+
17416
+ // How far apart a full-size drawing puts its tips, in label heights, and
17417
+ // how large it may grow at the very most -- a bound on the file, not a
17418
+ // size anyone should meet short of tens of thousands of tips.
17419
+ const FULL_SIZE_ROW_PITCH = 1.3;
17420
+ const FULL_SIZE_MAX_EXTENT = 200000;
17421
+ const FULL_SIZE_MARGIN = 20;
17422
+ const FULL_SIZE_LEGEND_GAP = 24;
17423
+
17424
+ // "Full size": the whole tree, whatever part of it is on screen and
17425
+ // however far it is zoomed out, drawn large enough for every tip name.
17426
+ //
17427
+ // The tree is re-laid-out at that size -- not scaled: scaling the picture
17428
+ // would enlarge the names with it and leave them exactly as crowded --
17429
+ // serialized, and put back. All of it runs in one task, so the screen
17430
+ // never paints the enlarged tree, and the zoom transform is never
17431
+ // touched: the copy simply leaves it out.
17432
+ function graphicFullSize() {
17433
+ let svg = treeSvgElement();
17434
+ let keepW = _displayWidth;
17435
+ let keepH = _displayHeight;
17436
+ let keepHide = _state.dynahide;
17437
+ try {
17438
+ growToFullSize(keepHide);
17439
+ return serializeFullSize(svg);
17440
+ } finally {
17441
+ _state.dynahide = keepHide;
17442
+ _displayWidth = keepW;
17443
+ _displayHeight = keepH;
17444
+ update(null, 0);
17445
+ }
17446
+ }
17447
+
17448
+ // Enlarge the layout until no tip name has to be dropped. Rectangular: the
17449
+ // row pitch is set outright. Circular: the ring is sized from its tips
17450
+ // first. Then every layout is ASKED -- the auto-hide rules, switched on
17451
+ // for the question, are what decide whether a name fits -- and grown
17452
+ // again until none is refused. The width never shrinks below the window,
17453
+ // so a tree zoomed out to a sliver comes out at a readable width.
17454
+ function growToFullSize(userHides) {
17455
+ let pitch = Math.max(4, _state.externalNodeFontSize || 0) * FULL_SIZE_ROW_PITCH;
17456
+ let fit = _settings.enableDynamicSizing ? displaySizeFromContainer() : null;
17457
+ let fitW = (fit && fit.w > 0) ? fit.w : _settings.displayWidth;
17458
+ let fitH = (fit && fit.h > 0) ? fit.h : _settings.displayHeight;
17459
+ let cap = function (v) {
17460
+ return Math.min(FULL_SIZE_MAX_EXTENT, v);
17461
+ };
17462
+ _state.dynahide = true;
17463
+ if (radialDisplay()) {
17464
+ let side = cap(Math.max(Math.min(_displayWidth, _displayHeight), Math.min(fitW, fitH)));
17465
+ _displayWidth = side;
17466
+ _displayHeight = side;
17467
+ update(null, 0);
17468
+ // Sized first so every tip has a label's height of arc: on the
17469
+ // ring in circular, where the names stand; in unrooted, where
17470
+ // there is no ring, as if the tips were spread evenly around the
17471
+ // fan's rim -- the most a fan's size can promise.
17472
+ let rows = 0;
17473
+ forEachDisplayed(function (n) {
17474
+ if (!n.children || isCollapsed(n)) {
17475
+ ++rows;
17476
+ }
16881
17477
  });
16882
- svgTree = toLightExport((new XMLSerializer()).serializeToString(copy));
16883
- } else if (typeof svg.xml !== 'undefined') {
16884
- svgTree = svg.xml;
17478
+ let span = (_state.circularDisplay && _radial) ? Math.max(0.1, _radial.angleSpan) : 2 * Math.PI;
17479
+ let radius = (_state.circularDisplay && _radial) ? _radial.maxRad : _displayWidth * 0.42;
17480
+ let need = (rows * pitch) / span;
17481
+ if (radius > 0 && need > radius) {
17482
+ _displayWidth = cap(_displayWidth * need / radius);
17483
+ _displayHeight = _displayWidth;
17484
+ update(null, 0);
17485
+ }
17486
+ // Then grown while growing still frees names, and no further. In
17487
+ // an unrooted fan names collide that no size will part: flu_h5's
17488
+ // clusters sit on branches a thousand times shorter than the tree,
17489
+ // and at 67,000 px across 176 of its 354 names were still refused
17490
+ // (332 at 850 px, 322 at 2,300) -- chasing them only made a file
17491
+ // too large to paint as a PNG. The drawing kept is the smallest
17492
+ // that showed the fewest refusals.
17493
+ let best = {hidden: _radialNamesHidden, side: _displayWidth};
17494
+ for (let i = 0; i < 12 && _radialNamesHidden > 0 && _displayWidth < FULL_SIZE_MAX_EXTENT; ++i) {
17495
+ _displayWidth = cap(_displayWidth * 1.4);
17496
+ _displayHeight = _displayWidth;
17497
+ update(null, 0);
17498
+ if (_radialNamesHidden < best.hidden * 0.95) {
17499
+ best = {hidden: _radialNamesHidden, side: _displayWidth};
17500
+ } else {
17501
+ break;
17502
+ }
17503
+ }
17504
+ if (_displayWidth !== best.side) {
17505
+ _displayWidth = best.side;
17506
+ _displayHeight = best.side;
17507
+ update(null, 0);
17508
+ }
17509
+ } else {
17510
+ _displayWidth = cap(Math.max(_displayWidth, fitW));
17511
+ _displayHeight = cap(Math.max(_displayHeight, fitH));
17512
+ update(null, 0);
17513
+ for (let i = 0; i < 4 && (_rowUnit < pitch * 0.999 || _dynahide_factor >= 2)
17514
+ && _displayHeight < FULL_SIZE_MAX_EXTENT; ++i) {
17515
+ // the vertical span less the fixed reserves is what the rows
17516
+ // share, so that is the part that scales
17517
+ let span = layoutSpans().vertical;
17518
+ let reserve = _displayHeight - span;
17519
+ let f = Math.max(pitch / Math.max(_rowUnit, 1e-6), 1.05);
17520
+ _displayHeight = cap(reserve + (span * f));
17521
+ update(null, 0);
17522
+ }
17523
+ }
17524
+ // drawn once more under the user's own auto-hide choice: with it off,
17525
+ // what it would have hidden is drawn, as it is on screen
17526
+ if (!userHides) {
17527
+ _state.dynahide = false;
17528
+ update(null, 0);
16885
17529
  }
16886
- return svgTree;
17530
+ }
17531
+
17532
+ // The smallest box around every non-empty one of `boxes` (getBBox
17533
+ // results), as {x0, y0, x1, y1}; null when none has any size.
17534
+ function unionBox(boxes) {
17535
+ let u = null;
17536
+ boxes.forEach(function (b) {
17537
+ if (!b || !(b.width > 0 || b.height > 0)) {
17538
+ return;
17539
+ }
17540
+ if (!u) {
17541
+ u = {x0: b.x, y0: b.y, x1: b.x + b.width, y1: b.y + b.height};
17542
+ } else {
17543
+ u.x0 = Math.min(u.x0, b.x);
17544
+ u.y0 = Math.min(u.y0, b.y);
17545
+ u.x1 = Math.max(u.x1, b.x + b.width);
17546
+ u.y1 = Math.max(u.y1, b.y + b.height);
17547
+ }
17548
+ });
17549
+ return u;
17550
+ }
17551
+
17552
+ // Every card the viewer pins to the WINDOW rather than to the tree: the
17553
+ // colour and shape legends, the domain legend, the heat-map ring's scale.
17554
+ // On screen they sit where the window has room; in a full-size figure the
17555
+ // window is gone, so they are stacked beside the tree instead.
17556
+ const WINDOW_LEGEND_CLASSES = [LEGEND_LABEL_COLOR, LEGEND_NODE_SHAPE, LEGEND_DOMAINS, 'aptx-heatmap-ringlegend'];
17557
+ const FULL_SIZE_LEGEND_STACK_GAP = 12;
17558
+
17559
+ // The enlarged drawing, cropped to what is actually drawn: the tree
17560
+ // (with its floating strips, anchored to it) and, in a column to its left
17561
+ // rather than over it, the legend cards.
17562
+ function serializeFullSize(svg) {
17563
+ let M = FULL_SIZE_MARGIN;
17564
+ // a strip's content is drawn in tree coordinates; its own transform
17565
+ // only places it on screen, and getBBox leaves that out
17566
+ let strips = _floatGroup ? Array.prototype.slice.call(_floatGroup.node().children) : [];
17567
+ let box = unionBox([_svgGroup.node().getBBox()].concat(strips.map(function (g) {
17568
+ return g.getBBox();
17569
+ })));
17570
+ if (!box) {
17571
+ return graphicAsShown();
17572
+ }
17573
+ let cards = [];
17574
+ WINDOW_LEGEND_CLASSES.forEach(function (cls) {
17575
+ let g = svg.querySelector(':scope > g.' + cls);
17576
+ let b = g ? g.getBBox() : null;
17577
+ if (b && (b.width > 0 || b.height > 0)) {
17578
+ cards.push({g: g, b: b});
17579
+ }
17580
+ });
17581
+ let columnW = 0;
17582
+ let columnH = 0;
17583
+ cards.forEach(function (c, i) {
17584
+ columnW = Math.max(columnW, c.b.width);
17585
+ columnH += c.b.height + (i > 0 ? FULL_SIZE_LEGEND_STACK_GAP : 0);
17586
+ });
17587
+ let legendW = cards.length > 0 ? columnW + FULL_SIZE_LEGEND_GAP : 0;
17588
+ let treeW = box.x1 - box.x0;
17589
+ let treeH = box.y1 - box.y0;
17590
+ let W = Math.ceil(M + legendW + treeW + M);
17591
+ let H = Math.ceil(M + Math.max(treeH, columnH) + M);
17592
+
17593
+ let copy = svg.cloneNode(true);
17594
+ let place = 'translate(' + (M + legendW - box.x0) + ',' + (M - box.y0) + ')';
17595
+ let tree = copyCounterpart(svg, copy, _svgGroup.node());
17596
+ if (tree) {
17597
+ tree.setAttribute('transform', place);
17598
+ }
17599
+ copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
17600
+ strip.setAttribute('transform', place);
17601
+ });
17602
+ // A card's own transform, where it has one, only placed it in the
17603
+ // window (getBBox leaves it out), so it is replaced, not added to.
17604
+ let y = M;
17605
+ cards.forEach(function (c) {
17606
+ let cc = copyCounterpart(svg, copy, c.g);
17607
+ if (cc) {
17608
+ cc.setAttribute('transform', 'translate(' + (M - c.b.x) + ',' + (y - c.b.y) + ')');
17609
+ }
17610
+ y += c.b.height + FULL_SIZE_LEGEND_STACK_GAP;
17611
+ });
17612
+ cleanExportCopy(copy);
17613
+ copy.setAttribute('width', W);
17614
+ copy.setAttribute('height', H);
17615
+ copy.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
17616
+ return {text: toLightExport((new XMLSerializer()).serializeToString(copy)), w: W, h: H};
16887
17617
  }
16888
17618
 
16889
17619
  function downloadTree(format) {
16890
- if (format === PNG_EXPORT_FORMAT) {
16891
- changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
16892
- downloadAsPng();
16893
- changeBaseBackgoundColor(_state.backgroundColorDefault);
16894
- } else if (format === SVG_EXPORT_FORMAT) {
16895
- changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
16896
- downloadAsSVG();
16897
- changeBaseBackgoundColor(_state.backgroundColorDefault);
16898
- } else if (format === NH_EXPORT_FORMAT) {
17620
+ if (format === NH_EXPORT_FORMAT) {
16899
17621
  downloadAsNH();
16900
17622
  } else if (format === NEXUS_EXPORT_FORMAT) {
16901
17623
  downloadAsNexus();
16902
17624
  } else if (format === PHYLOXML_EXPORT_FORMAT) {
16903
17625
  downloadAsPhyloXml();
16904
- } else if (format === PDF_EXPORT_FORMAT) {
16905
- changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
16906
- downloadAsPdf();
16907
- changeBaseBackgoundColor(_state.backgroundColorDefault);
16908
17626
  } else if (format === FASTA_EXPORT_FORMAT) {
16909
17627
  downloadAsFastaAll();
17628
+ } else if (format === TSV_EXPORT_FORMAT) {
17629
+ downloadAsTsvAll();
17630
+ } else if (format === SVG_EXPORT_FORMAT || format === PDF_EXPORT_FORMAT || format === PNG_EXPORT_FORMAT) {
17631
+ withGraphic(function (graphic) {
17632
+ if (format === SVG_EXPORT_FORMAT) {
17633
+ downloadAsSVG(graphic);
17634
+ } else if (format === PDF_EXPORT_FORMAT) {
17635
+ downloadAsPdf(graphic);
17636
+ } else {
17637
+ downloadAsPng(graphic);
17638
+ }
17639
+ });
16910
17640
  }
16911
17641
  }
16912
17642
 
17643
+ // Hands fn the graphic the Download section's choices describe, drawn in
17644
+ // the light export colours; a failure -- no graphic, or a throw anywhere
17645
+ // on the way -- goes to fail instead, so a caller waiting on the result
17646
+ // (Copy PNG's clipboard write) always hears back. A full-size drawing
17647
+ // re-lays-out the tree several times over, each pass larger than the
17648
+ // redraw last timed, so the card goes up when that redraw was even a
17649
+ // fraction of the card's threshold.
17650
+ function withGraphic(fn, fail) {
17651
+ // without a handler the error is thrown on, as it was before there
17652
+ // was one -- to the page's error handling and the harnesses -- rather
17653
+ // than turned into a console line and a click that did nothing
17654
+ let failed = fail || function (err) {
17655
+ throw err;
17656
+ };
17657
+ let draw = function () {
17658
+ let graphic;
17659
+ try {
17660
+ changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
17661
+ try {
17662
+ graphic = exportGraphic();
17663
+ } finally {
17664
+ changeBaseBackgoundColor(_state.backgroundColorDefault);
17665
+ }
17666
+ if (!graphic) {
17667
+ throw new Error('this browser cannot serialize the tree');
17668
+ }
17669
+ fn(graphic);
17670
+ } catch (err) {
17671
+ failed(err);
17672
+ }
17673
+ };
17674
+ if (_downloadFullSize && _redrawMs * FULL_SIZE_REDRAWS_EXPECTED >= REDRAW_CARD_MS && _container) {
17675
+ showBusy(_container, 'Drawing the whole tree');
17676
+ afterPaint(function () {
17677
+ try {
17678
+ draw();
17679
+ } finally {
17680
+ hideBusy();
17681
+ }
17682
+ });
17683
+ } else {
17684
+ draw();
17685
+ }
17686
+ }
17687
+
17688
+ // How many redraws a full-size drawing typically costs: the drawing at the
17689
+ // window's size, one or two to reach the size, and the one that puts the
17690
+ // window back -- more on a crowded fan.
17691
+ const FULL_SIZE_REDRAWS_EXPECTED = 4;
17692
+
16913
17693
  /**
16914
17694
  * Saves a Blob to a file using native browser APIs (an <a download> link
16915
17695
  * pointing at an object URL). This replaces the former FileSaver.js
@@ -16937,9 +17717,42 @@ function (root, d3, forester, phyloXml) {
16937
17717
  }, 1000);
16938
17718
  }
16939
17719
 
17720
+ // What a tree file is written from. The whole tree as it is; a subtree
17721
+ // view's root is a bare wrapper made when the subtree was entered, and
17722
+ // written as it stands it gave the file no name at all (phyloXML wrote
17723
+ // none, Nexus "tree1"). So it goes out under the tree's own name, marked
17724
+ // as a subtree. A shallow copy: the drawing's wrapper is left alone, and
17725
+ // the whole-tree files are byte for byte what they were.
17726
+ function treeForExport() {
17727
+ if (_root === _root_const || !_root_const) {
17728
+ return _root;
17729
+ }
17730
+ let name = _root_const.name ? String(_root_const.name).trim() : '';
17731
+ // what the phylogeny itself states goes with the part: without its
17732
+ // branch_length_unit a subtree saved from Div opened again as time
17733
+ let part = Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17734
+ ['rooted', 'rerootable', 'type', 'branch_length_unit'].forEach(function (k) {
17735
+ if (_root_const[k] !== undefined && _root_const[k] !== null) {
17736
+ part[k] = _root_const[k];
17737
+ }
17738
+ });
17739
+ return part;
17740
+ }
17741
+
17742
+ // "subtree, 19 of 50 tips" while a subtree is shown, else null: said in
17743
+ // the Download menu, so nobody writes a part of the tree thinking it the
17744
+ // whole.
17745
+ function subtreeExtentNote() {
17746
+ if (_root === _root_const || !_root_const) {
17747
+ return null;
17748
+ }
17749
+ return 'subtree, ' + forester.getAllExternalNodes(_root).length.toLocaleString() + ' of '
17750
+ + forester.getAllExternalNodes(_root_const).length.toLocaleString() + ' tips';
17751
+ }
17752
+
16940
17753
  function downloadAsPhyloXml() {
16941
- let x = phyloXml.toPhyloXML(_root, 9);
16942
- saveAs(new Blob([x], {type: "application/xml"}), _state.nameForPhyloXmlDownload);
17754
+ let x = phyloXml.toPhyloXML(treeForExport(), 9);
17755
+ saveAs(new Blob([x], {type: "application/xml"}), downloadFileName(XML_SUFFIX));
16943
17756
  }
16944
17757
 
16945
17758
  function downloadAsNH() {
@@ -16947,25 +17760,43 @@ function (root, d3, forester, phyloXml) {
16947
17760
  // not a preference: writing them out produces a file that will not parse
16948
17761
  // back in.
16949
17762
  let nh = forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences);
16950
- saveAs(new Blob([nh], {type: "application/txt"}), _state.nameForNhDownload);
17763
+ saveAs(new Blob([nh], {type: "application/txt"}), downloadFileName(NH_SUFFIX));
16951
17764
  }
16952
17765
 
16953
17766
  function downloadAsNexus() {
16954
17767
  // taxa and trees blocks as on the desktop, plus a characters block
16955
17768
  // when the tips carry an alignment -- carrying tree and alignment
16956
17769
  // in one file is what Nexus is for
16957
- let nex = forester.toNexus(_root, 9, _settings.nhExportWriteConfidences);
16958
- saveAs(new Blob([nex], {type: "application/txt"}), _state.nameForNexusDownload);
17770
+ let phy = treeForExport();
17771
+ let nex = forester.toNexus(phy, 9, _settings.nhExportWriteConfidences);
17772
+ saveAs(new Blob([nex], {type: "application/txt"}), downloadFileName(NEXUS_SUFFIX));
17773
+ }
17774
+
17775
+ function downloadAsSVG(graphic) {
17776
+ saveAs(new Blob([graphic.text], {type: "image/svg+xml"}), downloadFileName(SVG_SUFFIX));
16959
17777
  }
16960
17778
 
16961
- function downloadAsSVG() {
16962
- let svg = getTreeAsSvg();
16963
- saveAs(new Blob([decodeURIComponent(encodeURIComponent(svg))], {type: "application/svg+xml"}), _state.nameForSvgDownload);
17779
+ // Every tip's data, one row per tip top to bottom as drawn: the node
17780
+ // menu's "Download Ext. Node Data" for the whole tree -- the desktop's
17781
+ // columns, and a table that joins back onto the tree as metadata.
17782
+ function downloadAsTsvAll() {
17783
+ saveAs(new Blob([tipTableTsv(forester.getAllExternalNodes(_root).reverse())],
17784
+ {type: 'text/tab-separated-values'}), downloadFileName(TSV_SUFFIX));
17785
+ }
17786
+
17787
+ // The table of `tips` (top to bottom as drawn: getAllExternalNodes
17788
+ // reversed) -- one writer for the Download menu's TSV and the node
17789
+ // menu's Download Ext. Node Data, so their columns and ids cannot drift
17790
+ // apart. The caller walks the tips, once, and may count them too.
17791
+ function tipTableTsv(tips) {
17792
+ return forester.externalNodeDataTsv(tips, function (n, i) {
17793
+ return (n.viewId !== undefined) ? n.viewId : i + 1;
17794
+ });
16964
17795
  }
16965
17796
 
16966
17797
  function downloadAsFastaAll() {
16967
17798
  let fasta_text = forester.getMolecularSequencesAsFasta(_root, '\n');
16968
- saveAs(new Blob([fasta_text], {type: "application/txt"}), _state.nameForFastaDownload);
17799
+ saveAs(new Blob([fasta_text], {type: "application/txt"}), downloadFileName(FASTA_SUFFIX));
16969
17800
  }
16970
17801
 
16971
17802
  // Vector PDF via the OPTIONAL page-level libraries jsPDF and svg2pdf.js
@@ -16979,13 +17810,18 @@ function (root, d3, forester, phyloXml) {
16979
17810
  && window.jspdf.jsPDF.API.svg);
16980
17811
  }
16981
17812
 
16982
- function downloadAsPdf() {
17813
+ // The largest page a PDF may have: 200 inches a side (ISO 32000, Annex C).
17814
+ // Readers refuse, or quietly clip, anything past it -- and a full-size
17815
+ // tree of a few thousand tips is past it -- so a larger drawing is scaled
17816
+ // onto the largest page instead. It is vector: nothing is lost by that.
17817
+ const PDF_MAX_PAGE_PT = 14400;
17818
+
17819
+ function downloadAsPdf(graphic) {
16983
17820
  if (!pdfExportAvailable()) {
16984
17821
  console.error(ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
16985
17822
  return;
16986
17823
  }
16987
- let svg = getTreeAsSvg();
16988
- let el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
17824
+ let el = new DOMParser().parseFromString(graphic.text, 'image/svg+xml').documentElement;
16989
17825
  // the full-canvas background rect is sized in percentages, which
16990
17826
  // svg2pdf renders literally (a dark 100pt square at the origin); the
16991
17827
  // PDF page is white anyway, so the rect is simply dropped
@@ -17017,9 +17853,9 @@ function (root, d3, forester, phyloXml) {
17017
17853
  }
17018
17854
  });
17019
17855
  });
17020
- let svgEl = treeSvgElement();
17021
- let w = (svgEl && svgEl.width.baseVal.value) || _displayWidth;
17022
- let h = (svgEl && svgEl.height.baseVal.value) || _displayHeight;
17856
+ let fit = Math.min(1, PDF_MAX_PAGE_PT / Math.max(graphic.w, graphic.h));
17857
+ let w = graphic.w * fit;
17858
+ let h = graphic.h * fit;
17023
17859
  let pdf = new window.jspdf.jsPDF({
17024
17860
  orientation: w >= h ? 'landscape' : 'portrait',
17025
17861
  unit: 'pt',
@@ -17027,7 +17863,7 @@ function (root, d3, forester, phyloXml) {
17027
17863
  compress: true
17028
17864
  });
17029
17865
  pdf.svg(el, {x: 0, y: 0, width: w, height: h}).then(function () {
17030
- pdf.save(_state.nameForPdfDownload);
17866
+ pdf.save(downloadFileName(PDF_SUFFIX));
17031
17867
  holder.remove();
17032
17868
  }, function (err) {
17033
17869
  holder.remove();
@@ -17044,23 +17880,62 @@ function (root, d3, forester, phyloXml) {
17044
17880
  return !!(window.Canvg && window.Canvg.fromString);
17045
17881
  }
17046
17882
 
17047
- function downloadAsPng() {
17883
+ // The biggest canvas this browser will actually paint, at no more than
17884
+ // the scale asked for. Browsers cap a canvas by side and by area, and
17885
+ // differently (Safari's area cap is a sixteenth of Chrome's); past the
17886
+ // cap a canvas is not an error, it is just blank -- which is what a
17887
+ // full-size PNG of a large tree would silently have been. So the canvas
17888
+ // is TRIED: filled, and its far corner read back.
17889
+ function paintableCanvas(w, h, scale) {
17890
+ for (let k = scale; k > 0.05; k *= 0.8) {
17891
+ let canvas = document.createElement('canvas');
17892
+ canvas.width = Math.max(1, Math.round(w * k));
17893
+ canvas.height = Math.max(1, Math.round(h * k));
17894
+ let ctx = canvas.getContext('2d');
17895
+ if (ctx) {
17896
+ ctx.fillStyle = '#ffffff';
17897
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
17898
+ let px;
17899
+ try {
17900
+ px = ctx.getImageData(canvas.width - 1, canvas.height - 1, 1, 1).data;
17901
+ } catch {
17902
+ px = null;
17903
+ }
17904
+ if (px && px[3] === 255) {
17905
+ return {canvas: canvas, ctx: ctx, scale: k};
17906
+ }
17907
+ }
17908
+ }
17909
+ return null;
17910
+ }
17911
+
17912
+ function downloadAsPng(graphic) {
17913
+ renderPng(graphic).then(function (blob) {
17914
+ saveAs(blob, downloadFileName(PNG_SUFFIX));
17915
+ }, function (err) {
17916
+ console.error(ERROR + 'PNG export failed: ' + err);
17917
+ });
17918
+ }
17919
+
17920
+ // The graphic as a PNG Blob, at the Download section's 2x/4x/8x -- less
17921
+ // where the browser cannot paint a canvas that large (paintableCanvas).
17922
+ function renderPng(graphic) {
17923
+ if (!graphic) {
17924
+ return Promise.reject(new Error('nothing to draw'));
17925
+ }
17048
17926
  if (!pngExportAvailable()) {
17049
- console.error(ERROR + 'PNG export needs the optional canvg library on the page');
17050
- return;
17927
+ return Promise.reject(new Error('PNG export needs the optional canvg library on the page'));
17051
17928
  }
17052
- let svg = getTreeAsSvg();
17053
- // Render onto an up-scaled canvas so the exported PNG is high-resolution
17054
- // rather than 1:1 with the on-screen SVG. Scale is configurable via
17055
- // _settings.pngExportScale (default 4x).
17056
- let svgEl = treeSvgElement();
17057
- let scale = _settings.pngExportScale > 0 ? _settings.pngExportScale : 4;
17058
- let w = (svgEl && svgEl.width.baseVal.value) || _displayWidth;
17059
- let h = (svgEl && svgEl.height.baseVal.value) || _displayHeight;
17060
- let canvas = document.createElement('canvas');
17061
- canvas.width = Math.round(w * scale);
17062
- canvas.height = Math.round(h * scale);
17063
- let ctx = canvas.getContext('2d');
17929
+ let target = paintableCanvas(graphic.w, graphic.h, _pngScale);
17930
+ if (!target) {
17931
+ return Promise.reject(new Error('this browser cannot paint a canvas of that size'));
17932
+ }
17933
+ if (target.scale < _pngScale) {
17934
+ console.warn(WARNING + ': PNG exported at ' + target.scale.toFixed(2) + 'x rather than ' + _pngScale
17935
+ + 'x: the largest image this browser can paint');
17936
+ }
17937
+ let canvas = target.canvas;
17938
+ let ctx = target.ctx;
17064
17939
  // The up-scaling is done on the CONTEXT, not through canvg's own
17065
17940
  // scaleWidth/scaleHeight: canvg 4's scaling pipeline mis-scales (a
17066
17941
  // requested 4x came out at 2.5x, leaving unpainted margins -- and any
@@ -17069,20 +17944,56 @@ function (root, d3, forester, phyloXml) {
17069
17944
  // background before canvg draws a thing. ignoreDimensions keeps canvg
17070
17945
  // from resizing the canvas to the SVG's size (which would erase both
17071
17946
  // the transform and the fill); ignoreClear keeps it from clearing the
17072
- // fill back to transparent. Content outside the on-screen viewport
17073
- // falls outside the canvas and is cropped, so the export shows
17074
- // exactly the visible view. v4's render() is async (v1's canvg()
17947
+ // fill back to transparent. v4's render() is async (v1's canvg()
17075
17948
  // call drew synchronously, so toBlob could follow on the next line).
17076
17949
  ctx.fillStyle = _state.backgroundColorForPrintExportDefault;
17077
17950
  ctx.fillRect(0, 0, canvas.width, canvas.height);
17078
- ctx.scale(scale, scale);
17079
- let v = window.Canvg.fromString(ctx, svg);
17080
- v.render({ignoreDimensions: true, ignoreClear: true}).then(function () {
17081
- canvas.toBlob(function (blob) {
17082
- saveAs(blob, _state.nameForPngDownload);
17951
+ ctx.scale(target.scale, target.scale);
17952
+ let v = window.Canvg.fromString(ctx, graphic.text);
17953
+ return v.render({ignoreDimensions: true, ignoreClear: true}).then(function () {
17954
+ return new Promise(function (resolve, reject) {
17955
+ canvas.toBlob(function (blob) {
17956
+ if (blob) {
17957
+ resolve(blob);
17958
+ } else {
17959
+ reject(new Error('the canvas gave no image'));
17960
+ }
17961
+ }, 'image/png');
17083
17962
  });
17963
+ });
17964
+ }
17965
+
17966
+ // Copying an image needs the async clipboard's write() and ClipboardItem
17967
+ // (a secure page: https, or localhost) as well as canvg; the button is
17968
+ // only offered where all three are there.
17969
+ function pngCopyAvailable() {
17970
+ return pngExportAvailable() && typeof window.ClipboardItem === 'function'
17971
+ && !!(navigator.clipboard && navigator.clipboard.write);
17972
+ }
17973
+
17974
+ // The desktop's "copy image": the picture straight onto the clipboard,
17975
+ // for a Word document or a slide. The ClipboardItem is made at once, with
17976
+ // the image as a PROMISE, inside the click: Safari allows a clipboard
17977
+ // write only during the gesture, and drawing the PNG takes longer than
17978
+ // that -- a promise-valued item is how it waits (Chrome takes it too).
17979
+ function copyPngPressed(event) {
17980
+ let btn = event.currentTarget;
17981
+ let png = new Promise(function (resolve, reject) {
17982
+ withGraphic(function (graphic) {
17983
+ renderPng(graphic).then(resolve, reject);
17984
+ }, reject);
17985
+ });
17986
+ let written;
17987
+ try {
17988
+ written = navigator.clipboard.write([new window.ClipboardItem({'image/png': png})]);
17989
+ } catch (e) {
17990
+ written = Promise.reject(e);
17991
+ }
17992
+ written.then(function () {
17993
+ flashButton(btn, 'Copied ✓');
17084
17994
  }, function (err) {
17085
- console.error(ERROR + 'PNG export failed: ' + err);
17995
+ console.error(ERROR + 'copying the PNG failed: ' + err);
17996
+ flashButton(btn, 'Copy failed');
17086
17997
  });
17087
17998
  }
17088
17999