archaeopteryx 3.16.0 → 3.17.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.17.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.17.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
@@ -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
 
@@ -13272,12 +13319,163 @@ function (root, d3, forester, phyloXml) {
13272
13319
  scheduleUpdate(null, 0);
13273
13320
  }
13274
13321
 
13275
- function downloadButtonPressed() {
13276
- const s = byId(EXPORT_FORMAT_SELECT);
13277
- if (s) {
13278
- let format = s.value;
13279
- downloadTree(format);
13322
+ // The Download button opens the format menu under itself; pressed again
13323
+ // while the menu is open, it closes it.
13324
+ function downloadButtonPressed(event) {
13325
+ let btn = event.currentTarget;
13326
+ showNodeMenu(downloadMenuItems(), null, null, btn);
13327
+ // opened from the keyboard (a click with no pointer), the keyboard
13328
+ // goes into the menu: Tab walks the formats, Escape closes it
13329
+ if (event.detail === 0 && _nodeMenu) {
13330
+ let first = _nodeMenu.querySelector('button');
13331
+ if (first) {
13332
+ first.focus();
13333
+ }
13334
+ }
13335
+ }
13336
+
13337
+ // Every format says what it keeps: a Newick file silently drops
13338
+ // everything but names, lengths and support, and a user picking by
13339
+ // extension would never know.
13340
+ function downloadMenuItems() {
13341
+ let items = [];
13342
+ let entry = function (format) {
13343
+ return function () {
13344
+ downloadTree(format);
13345
+ };
13346
+ };
13347
+ items.push({heading: 'Graphics, ' + (_downloadFullSize ? 'full size' : 'as shown')});
13348
+ items.push({label: 'SVG', detail: 'vector: edit it in Inkscape or Illustrator',
13349
+ action: entry(SVG_EXPORT_FORMAT)});
13350
+ if (pdfExportAvailable()) {
13351
+ items.push({label: 'PDF', detail: 'vector: ready to print or to put in a paper',
13352
+ action: entry(PDF_EXPORT_FORMAT)});
13353
+ }
13354
+ if (pngExportAvailable()) {
13355
+ items.push({label: 'PNG', detail: 'image, at ' + _pngScale + '× the screen\'s resolution',
13356
+ action: entry(PNG_EXPORT_FORMAT)});
13357
+ }
13358
+ let part = subtreeExtentNote();
13359
+ items.push({heading: part ? 'Tree: ' + part : 'Tree'});
13360
+ items.push({label: 'phyloXML', detail: 'keeps everything the tree carries: taxonomy, sequences, dates, properties',
13361
+ action: entry(PHYLOXML_EXPORT_FORMAT)});
13362
+ items.push({label: 'Newick', detail: 'names, branch lengths and support values only',
13363
+ action: entry(NH_EXPORT_FORMAT)});
13364
+ let nexus = nexusDownloadDetail();
13365
+ items.push({label: 'Nexus', detail: nexus.text, warn: nexus.warn, action: entry(NEXUS_EXPORT_FORMAT)});
13366
+ // FASTA only when there is something to write: a tip carrying a
13367
+ // molecular sequence, aligned or not (same gate as the node menu's
13368
+ // Fasta entries) -- on any other tree the download would be empty
13369
+ items.push({heading: part ? 'Tip data: ' + part : 'Tip data'});
13370
+ items.push({label: 'TSV', detail: 'a table, one row per tip: names, taxonomy, sequence names, '
13371
+ + 'branch lengths and every property', action: entry(TSV_EXPORT_FORMAT)});
13372
+ if (_basicTreeProperties && _basicTreeProperties.maxMolSeqLength > 0) {
13373
+ items.push({label: 'FASTA', detail: 'every molecular sequence the tips carry',
13374
+ action: entry(FASTA_EXPORT_FORMAT)});
13375
+ }
13376
+ return items;
13377
+ }
13378
+
13379
+ // What a Nexus download will hold, asked of the WRITER rather than
13380
+ // predicted: toNexus decides whether the sequences can form a matrix
13381
+ // (equal lengths, unambiguous labels), and when they cannot it says why
13382
+ // in a bracketed comment -- which is the sentence shown here, so the menu
13383
+ // and the file can never disagree.
13384
+ function nexusDownloadDetail() {
13385
+ let plain = {text: 'the tree: names, branch lengths and support values', warn: false};
13386
+ if (!_root || !_basicTreeProperties || !(_basicTreeProperties.maxMolSeqLength > 0)) {
13387
+ return plain;
13388
+ }
13389
+ let nex;
13390
+ try {
13391
+ nex = forester.toNexus(treeForExport(), 9, _settings.nhExportWriteConfidences);
13392
+ } catch {
13393
+ return plain;
13394
+ }
13395
+ if (nex.indexOf('Begin Characters;') >= 0) {
13396
+ return {text: 'the tree and its alignment, in one file', warn: false};
13397
+ }
13398
+ // to the comment's own closing " ]" at the end of its line: a taxon
13399
+ // label quoted inside the reason may itself hold a ']'
13400
+ let why = /\[ Molecular sequences were not written: (.*?) \]$/m.exec(nex);
13401
+ return {
13402
+ text: 'the tree only, without the sequences: ' + (why ? why[1] : 'they cannot form a matrix')
13403
+ + ' phyloXML and FASTA keep them.',
13404
+ warn: true
13405
+ };
13406
+ }
13407
+
13408
+ // The file name the user typed, made safe, with the format's extension.
13409
+ // A typed extension is not doubled up ("tree.svg" saves as tree.svg, and
13410
+ // as tree.png from the PNG entry), and an empty field falls back to the
13411
+ // tree's name.
13412
+ function downloadFileName(suffix) {
13413
+ let field = byId(DOWNLOAD_NAME_FIELD);
13414
+ let stem = (field ? field.value : '').trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_');
13415
+ let lower = stem.toLowerCase();
13416
+ [NH_SUFFIX, NEXUS_SUFFIX, XML_SUFFIX, FASTA_SUFFIX, TSV_SUFFIX, SVG_SUFFIX, PDF_SUFFIX, PNG_SUFFIX,
13417
+ '.nex', '.nwk', '.newick', '.nh', '.nhx', '.tree', '.phyloxml', '.fa', '.fas', '.txt',
13418
+ '.csv'].some(function (x) {
13419
+ if (lower.length > x.length && lower.endsWith(x)) {
13420
+ stem = stem.substring(0, stem.length - x.length);
13421
+ return true;
13422
+ }
13423
+ return false;
13424
+ });
13425
+ if (stem.replace(/[._\s]/g, '') === '') {
13426
+ stem = _state.downloadStem;
13280
13427
  }
13428
+ return stem + suffix;
13429
+ }
13430
+
13431
+ function copyNewickPressed(event) {
13432
+ let btn = event.currentTarget;
13433
+ let nh = _root ? forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences) : '';
13434
+ copyText(nh).then(function (ok) {
13435
+ flashButton(btn, ok ? 'Copied ✓' : 'Copy failed');
13436
+ });
13437
+ }
13438
+
13439
+ // The async clipboard API where the page allows it (a secure context);
13440
+ // otherwise the old select-and-copy, so a page served over plain http
13441
+ // still copies. Resolves to whether the text made it.
13442
+ function copyText(text) {
13443
+ let legacy = function () {
13444
+ let ta = document.createElement('textarea');
13445
+ ta.value = text;
13446
+ ta.setAttribute('readonly', '');
13447
+ ta.style.position = 'fixed';
13448
+ ta.style.left = '-10000px';
13449
+ document.body.appendChild(ta);
13450
+ ta.select();
13451
+ let ok = false;
13452
+ try {
13453
+ ok = document.execCommand('copy');
13454
+ } catch {
13455
+ ok = false;
13456
+ }
13457
+ ta.remove();
13458
+ return ok;
13459
+ };
13460
+ if (navigator.clipboard && window.isSecureContext) {
13461
+ return navigator.clipboard.writeText(text).then(function () {
13462
+ return true;
13463
+ }, legacy);
13464
+ }
13465
+ return Promise.resolve(legacy());
13466
+ }
13467
+
13468
+ // A button says what just happened on its own face for a moment, then
13469
+ // goes back to its name: no toast to find, and nothing left behind.
13470
+ function flashButton(btn, text) {
13471
+ if (btn.dataset.label === undefined) {
13472
+ btn.dataset.label = btn.textContent;
13473
+ }
13474
+ btn.textContent = text;
13475
+ clearTimeout(btn._flashTimer);
13476
+ btn._flashTimer = setTimeout(function () {
13477
+ btn.textContent = btn.dataset.label;
13478
+ }, 1600);
13281
13479
  }
13282
13480
 
13283
13481
  function changeBaseBackgoundColor(color) {
@@ -13543,7 +13741,9 @@ function (root, d3, forester, phyloXml) {
13543
13741
  // of open sections: three open sections measure anywhere from 215px to
13544
13742
  // 562px, so a count would not have bounded the height at all.
13545
13743
  const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
13546
- const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures'];
13744
+ // Download too (Christian, 2026-09-28): four rows, and not something a
13745
+ // session uses often.
13746
+ const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures', 'Download'];
13547
13747
 
13548
13748
  function loadPanelSections() {
13549
13749
  if (_panelSections) {
@@ -13998,13 +14198,13 @@ function (root, d3, forester, phyloXml) {
13998
14198
  }
13999
14199
  // Dark palette tokens, shared by the system-preference default and the
14000
14200
  // 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;'
14201
+ let dark = ' --p-bg:rgba(24,35,46,0.86); --p-ink:#e7eef5; --p-muted:#94a4b3; --p-faint:#6f8090; --p-warn:#f0a35e;'
14002
14202
  + ' --p-line:#27343f; --p-line-strong:#35434f; --p-surface2:#202d38;'
14003
14203
  + ' --p-accent:#57a6ff; --p-accent-ink:#9cc7ff; --p-accent-weak:rgba(87,166,255,0.18);'
14004
14204
  + ' --p-shadow-sm:0 1px 2px rgba(0,0,0,0.4);';
14005
14205
  let css = ''
14006
14206
  + '.aptx-panel {'
14007
- + ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
14207
+ + ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
14008
14208
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
14009
14209
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
14010
14210
  + ' --p-shadow-sm:0 1px 2px rgba(23,34,46,0.12);'
@@ -14031,7 +14231,7 @@ function (root, d3, forester, phyloXml) {
14031
14231
  // (they cannot live inside the panel), so they carry their own
14032
14232
  // copy of the palette and follow the same light / dark rules.
14033
14233
  + '.aptx-node-menu, .aptx-suggest {'
14034
- + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
14234
+ + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
14035
14235
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
14036
14236
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
14037
14237
  + '}'
@@ -14061,6 +14261,17 @@ function (root, d3, forester, phyloXml) {
14061
14261
  + ' background:#e5484d; color:#fff; }'
14062
14262
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
14063
14263
  + '.aptx-node-menu-note { padding:4px 9px 7px; color:var(--p-ink); white-space:normal; }'
14264
+ // A group title inside a menu (the Download menu's Graphics / Tree
14265
+ // / Sequences): the title's type, set off from the group above.
14266
+ + '.aptx-node-menu-heading { margin-top:4px; }'
14267
+ + '.aptx-node-menu-heading:first-child { margin-top:0; }'
14268
+ // an entry's second line: what that choice keeps or does
14269
+ + '.aptx-node-menu button .aptx-menu-detail { display:block; white-space:normal; font-size:10px;'
14270
+ + ' line-height:1.35; color:var(--p-muted); margin-top:1px; }'
14271
+ + '.aptx-node-menu button .aptx-menu-detail.aptx-menu-warn { color:var(--p-warn); }'
14272
+ + '.aptx-node-menu button:hover .aptx-menu-detail, .aptx-node-menu button:focus-visible .aptx-menu-detail {'
14273
+ + ' color:inherit; opacity:0.86; }'
14274
+ + '.aptx-node-menu:has(.aptx-menu-detail) { width:264px; max-width:264px; }'
14064
14275
  // The search suggestions: the node menu's chrome under the value box.
14065
14276
  + '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
14066
14277
  + ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
@@ -14079,7 +14290,7 @@ function (root, d3, forester, phyloXml) {
14079
14290
  + ' font-size:10px; color:var(--p-faint); }'
14080
14291
  // The node-data dialog, on the same palette as the panel and the menu.
14081
14292
  + '.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;'
14293
+ + ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
14083
14294
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
14084
14295
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
14085
14296
  + '}'
@@ -14229,7 +14440,7 @@ function (root, d3, forester, phyloXml) {
14229
14440
  + ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
14230
14441
  // The hover tooltip, on the same palette.
14231
14442
  + '.aptx-tip {'
14232
- + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
14443
+ + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
14233
14444
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1;'
14234
14445
  + '}'
14235
14446
  + '@media (prefers-color-scheme:dark){ .aptx-tip:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
@@ -14384,6 +14595,16 @@ function (root, d3, forester, phyloXml) {
14384
14595
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn { flex:1 1 0; padding:0; }'
14385
14596
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn:last-child { margin-right:0; }'
14386
14597
  + '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
14598
+ // The Download section: a label column, then a control that fills
14599
+ // the row -- the name field, a segmented choice, or the two buttons
14600
+ // sharing it equally (their words centred, never clipped).
14601
+ + '.aptx-panel .aptx-dlrow { display:flex; align-items:center; gap:6px; margin:4px 0; }'
14602
+ + '.aptx-panel .aptx-dlrow .aptx-dllabel { flex:0 0 52px; font-size:10px; color:var(--p-muted); }'
14603
+ + '.aptx-panel .aptx-dlrow input[type=text] { flex:1 1 auto; min-width:0; height:24px; box-sizing:border-box; }'
14604
+ + '.aptx-panel .aptx-dlrow .aptx-segmented { flex:1 1 auto; display:flex; }'
14605
+ + '.aptx-panel .aptx-dlrow .aptx-seg { flex:1 1 0; padding:3px 4px; font-weight:500; white-space:nowrap; }'
14606
+ + '.aptx-panel .aptx-dlrow .aptx-dlbtn { flex:1 1 0; min-width:0; margin:0; padding:0 6px;'
14607
+ + ' display:flex; align-items:center; justify-content:center; white-space:nowrap; }'
14387
14608
  + '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
14388
14609
  + '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
14389
14610
  // ... except one that has a WORD on it: the rule above sizes the
@@ -14412,7 +14633,6 @@ function (root, d3, forester, phyloXml) {
14412
14633
  + ' line-height:1; color:var(--p-muted); transition:transform .15s,color .15s; }'
14413
14634
  + '.aptx-panel legend.aptx-legend-toggle:hover::after { color:var(--p-accent-ink); }'
14414
14635
  + '.aptx-panel legend.aptx-legend-toggle:hover { color:var(--p-accent-ink); }'
14415
- + '.aptx-panel #' + EXPORT_FORMAT_SELECT + ' { margin-left:6px; }'
14416
14636
  + '.aptx-panel fieldset.aptx-collapsed > legend.aptx-legend-toggle::after { transform:rotate(-90deg); }'
14417
14637
  + '.aptx-panel fieldset.aptx-collapsed > legend { margin-bottom:0; }'
14418
14638
  + '.aptx-panel fieldset.aptx-collapsed > .aptx-fieldset-body { display:none; }'
@@ -15618,14 +15838,6 @@ function (root, d3, forester, phyloXml) {
15618
15838
  // the stylesheet's job now: the search row sizes its R button, and the
15619
15839
  // zoom grid stretches Y+/Y- to the exact width of the button row.)
15620
15840
 
15621
- const downloadButton = byId(DOWNLOAD_BUTTON);
15622
-
15623
- if (downloadButton) {
15624
- setStyles(downloadButton, {
15625
- 'width': '60px', 'margin-bottom': '3px'
15626
- });
15627
- }
15628
-
15629
15841
 
15630
15842
  on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
15631
15843
 
@@ -15842,12 +16054,19 @@ function (root, d3, forester, phyloXml) {
15842
16054
 
15843
16055
  // ----------------
15844
16056
 
15845
- if (downloadButton) {
15846
- downloadButton.addEventListener('click', downloadButtonPressed);
15847
- }
15848
-
15849
- setStyles(byId(EXPORT_FORMAT_SELECT), {
15850
- 'font': 'inherit', 'color': 'inherit'
16057
+ on(DOWNLOAD_BUTTON, 'click', downloadButtonPressed);
16058
+ on(COPY_NEWICK_BUTTON, 'click', copyNewickPressed);
16059
+ on(COPY_PNG_BUTTON, 'click', copyPngPressed);
16060
+ on(DOWNLOAD_AS_SHOWN_BUTTON, 'change', function () {
16061
+ _downloadFullSize = false;
16062
+ });
16063
+ on(DOWNLOAD_FULL_SIZE_BUTTON, 'change', function () {
16064
+ _downloadFullSize = true;
16065
+ });
16066
+ DOWNLOAD_PNG_SCALES.forEach(function (k) {
16067
+ on('dl_png' + k + '_b', 'change', function () {
16068
+ _pngScale = k;
16069
+ });
15851
16070
  });
15852
16071
 
15853
16072
  // ---------------
@@ -16298,34 +16517,49 @@ function (root, d3, forester, phyloXml) {
16298
16517
  return h;
16299
16518
  }
16300
16519
 
16520
+ // A titled section like the others, so it folds. The format is picked
16521
+ // from a menu the Download button opens (showDownloadMenu), where each
16522
+ // format says what it keeps; the rows above it are the choices every
16523
+ // graphic, or every file, shares.
16301
16524
  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">');
16525
+ let h = '<fieldset class="aptx-download">';
16526
+ h = h.concat('<legend>Download</legend>');
16527
+ h = h.concat('<div class="aptx-dlrow"><label class="aptx-dllabel" for="' + DOWNLOAD_NAME_FIELD
16528
+ + '" title="the file name: each format adds its own extension">Name</label>');
16529
+ h = h.concat('<input type="text" id="' + DOWNLOAD_NAME_FIELD + '" name="' + DOWNLOAD_NAME_FIELD
16530
+ + '" spellcheck="false" autocomplete="off"'
16531
+ + ' title="the file name: each format adds its own extension"></div>');
16532
+ h = h.concat('<div class="aptx-dlrow"><span class="aptx-dllabel">Graphics</span>');
16533
+ h = h.concat('<div class="aptx-segmented">');
16534
+ h = h.concat(makeSegment('As shown', DOWNLOAD_AS_SHOWN_BUTTON, 'dl_extent_radio',
16535
+ 'SVG, PDF and PNG show what is on screen: the part of the tree in view, at the current zoom'));
16536
+ h = h.concat(makeSegment('Full size', DOWNLOAD_FULL_SIZE_BUTTON, 'dl_extent_radio',
16537
+ 'SVG, PDF and PNG show the whole tree, drawn large enough for every name'));
16538
+ h = h.concat('</div></div>');
16309
16539
  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>');
16540
+ h = h.concat('<div class="aptx-dlrow"><span class="aptx-dllabel">PNG</span>');
16541
+ h = h.concat('<div class="aptx-segmented">');
16542
+ DOWNLOAD_PNG_SCALES.forEach(function (k) {
16543
+ h = h.concat(makeSegment(k + '×', 'dl_png' + k + '_b', 'dl_png_radio',
16544
+ 'PNG at ' + k + ' times the resolution of the screen'));
16545
+ });
16546
+ h = h.concat('</div></div>');
16325
16547
  }
16326
- h = h.concat('</select>');
16548
+ h = h.concat('<div class="aptx-dlrow">');
16549
+ h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + DOWNLOAD_BUTTON + '" name="'
16550
+ + DOWNLOAD_BUTTON + '" aria-haspopup="menu"'
16551
+ + ' title="download the tree: choose the format from the menu">Download…</button>');
16552
+ h = h.concat('</div>');
16553
+ h = h.concat('<div class="aptx-dlrow">');
16554
+ h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + COPY_NEWICK_BUTTON + '" name="'
16555
+ + COPY_NEWICK_BUTTON + '" title="copy the tree to the clipboard in Newick format">Copy Newick</button>');
16556
+ if (pngCopyAvailable()) {
16557
+ h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + COPY_PNG_BUTTON + '" name="'
16558
+ + COPY_PNG_BUTTON + '" title="copy the tree to the clipboard as a PNG image, to paste into'
16559
+ + ' a document or a slide: drawn as the Graphics and PNG choices above say">Copy PNG</button>');
16560
+ }
16561
+ h = h.concat('</div>');
16327
16562
  h = h.concat('</fieldset>');
16328
- h = h.concat('</form>');
16329
16563
  return h;
16330
16564
  }
16331
16565
 
@@ -16578,6 +16812,27 @@ function (root, d3, forester, phyloXml) {
16578
16812
  setCheckboxValue(VISUAL_STYLES_CB, _state.useVisualStyles);
16579
16813
  setCheckboxValue(VIS_CB, _state.showVisualizations);
16580
16814
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
16815
+ {
16816
+ let nameField = byId(DOWNLOAD_NAME_FIELD);
16817
+ if (nameField) {
16818
+ nameField.value = _state.downloadStem;
16819
+ }
16820
+ // once per launch, not in initializeSettings: zoom-to-fit re-runs
16821
+ // that, and would throw the user's pick away
16822
+ _pngScale = DOWNLOAD_PNG_SCALES.reduce(function (best, k) {
16823
+ let want = _settings.pngExportScale;
16824
+ return Math.abs(Math.log(k / want)) < Math.abs(Math.log(best / want)) ? k : best;
16825
+ });
16826
+ // any positive number used to be taken as it was; the panel offers
16827
+ // 2x, 4x and 8x, and the setting is now the one it starts on
16828
+ // (Christian, 2026-09-28) -- said, not done silently
16829
+ if (_pngScale !== _settings.pngExportScale) {
16830
+ console.warn(WARNING + ': pngExportScale ' + _settings.pngExportScale + ' is not one of '
16831
+ + DOWNLOAD_PNG_SCALES.join(', ') + ': the PNG starts at ' + _pngScale + 'x');
16832
+ }
16833
+ setRadioButtonValue(_downloadFullSize ? DOWNLOAD_FULL_SIZE_BUTTON : DOWNLOAD_AS_SHOWN_BUTTON, true);
16834
+ setRadioButtonValue('dl_png' + _pngScale + '_b', true);
16835
+ }
16581
16836
  setCheckboxValue(MSA_CB, _state.showMsa);
16582
16837
  setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
16583
16838
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
@@ -16846,70 +17101,348 @@ function (root, d3, forester, phyloXml) {
16846
17101
  return svgText;
16847
17102
  }
16848
17103
 
16849
- function getTreeAsSvg() {
16850
- let svg = treeSvgElement();
16851
- if (!svg) {
17104
+ // The live tree's <svg>, copied and cleaned for a file: the overview, the
17105
+ // search halos and the hover glow are on-screen aids, not the tree.
17106
+ function exportCopy(svg) {
17107
+ return cleanExportCopy(svg.cloneNode(true));
17108
+ }
17109
+
17110
+ function cleanExportCopy(copy) {
17111
+ // Serialize a COPY with the overview taken out of it: the overview is
17112
+ // an on-screen navigation aid, not part of the tree, and working on a
17113
+ // copy leaves the live display untouched.
17114
+ let overview = copy.querySelector('.aptx-overview');
17115
+ if (overview) {
17116
+ overview.remove();
17117
+ }
17118
+ // the halo discs' translucency and pulse live in page CSS the
17119
+ // export cannot carry -- serialized as-is they become solid blobs
17120
+ copy.querySelectorAll('circle.foundHalo').forEach(function (h) {
17121
+ h.remove();
17122
+ });
17123
+ let glow = copy.querySelector('.aptx-hoverglow');
17124
+ if (glow) {
17125
+ glow.remove();
17126
+ }
17127
+ return copy;
17128
+ }
17129
+
17130
+ // The same element of the copy as `live` is of the svg: children are
17131
+ // cloned in order, so the index finds it -- in a copy nothing has been
17132
+ // taken out of yet (cleaning it removes the overview, and every child
17133
+ // after that would be off by one).
17134
+ function copyCounterpart(svg, copy, live) {
17135
+ let i = Array.prototype.indexOf.call(svg.children, live);
17136
+ return i >= 0 ? copy.children[i] : null;
17137
+ }
17138
+
17139
+ // The graphic an SVG, PDF or PNG download draws, as {text, w, h}: the
17140
+ // serialized light-theme svg and its size in px. Which one follows the
17141
+ // Download section's choice.
17142
+ function exportGraphic() {
17143
+ if (!treeSvgElement() || typeof window.XMLSerializer === 'undefined') {
16852
17144
  return null;
16853
17145
  }
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 + ')');
17146
+ return _downloadFullSize ? graphicFullSize() : graphicAsShown();
17147
+ }
17148
+
17149
+ // "As shown": the viewport, exactly as it is on screen.
17150
+ function graphicAsShown() {
17151
+ let svg = treeSvgElement();
17152
+ let copy = exportCopy(svg);
17153
+ // The floating strips re-anchor to the TREE for export: on screen
17154
+ // a strip may be stuck at the viewport bottom, but a figure must
17155
+ // not carry an artefact of where the user happened to be scrolled
17156
+ // -- the axis belongs at the bottom of the tree. (As the desktop
17157
+ // does for PDF and graphics export.)
17158
+ let t = d3.zoomTransform(svg);
17159
+ copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
17160
+ strip.setAttribute('transform', 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')');
17161
+ });
17162
+ return {
17163
+ text: toLightExport((new XMLSerializer()).serializeToString(copy)),
17164
+ w: svg.width.baseVal.value || _displayWidth,
17165
+ h: svg.height.baseVal.value || _displayHeight
17166
+ };
17167
+ }
17168
+
17169
+ // How far apart a full-size drawing puts its tips, in label heights, and
17170
+ // how large it may grow at the very most -- a bound on the file, not a
17171
+ // size anyone should meet short of tens of thousands of tips.
17172
+ const FULL_SIZE_ROW_PITCH = 1.3;
17173
+ const FULL_SIZE_MAX_EXTENT = 200000;
17174
+ const FULL_SIZE_MARGIN = 20;
17175
+ const FULL_SIZE_LEGEND_GAP = 24;
17176
+
17177
+ // "Full size": the whole tree, whatever part of it is on screen and
17178
+ // however far it is zoomed out, drawn large enough for every tip name.
17179
+ //
17180
+ // The tree is re-laid-out at that size -- not scaled: scaling the picture
17181
+ // would enlarge the names with it and leave them exactly as crowded --
17182
+ // serialized, and put back. All of it runs in one task, so the screen
17183
+ // never paints the enlarged tree, and the zoom transform is never
17184
+ // touched: the copy simply leaves it out.
17185
+ function graphicFullSize() {
17186
+ let svg = treeSvgElement();
17187
+ let keepW = _displayWidth;
17188
+ let keepH = _displayHeight;
17189
+ let keepHide = _state.dynahide;
17190
+ try {
17191
+ growToFullSize(keepHide);
17192
+ return serializeFullSize(svg);
17193
+ } finally {
17194
+ _state.dynahide = keepHide;
17195
+ _displayWidth = keepW;
17196
+ _displayHeight = keepH;
17197
+ update(null, 0);
17198
+ }
17199
+ }
17200
+
17201
+ // Enlarge the layout until no tip name has to be dropped. Rectangular: the
17202
+ // row pitch is set outright. Circular: the ring is sized from its tips
17203
+ // first. Then every layout is ASKED -- the auto-hide rules, switched on
17204
+ // for the question, are what decide whether a name fits -- and grown
17205
+ // again until none is refused. The width never shrinks below the window,
17206
+ // so a tree zoomed out to a sliver comes out at a readable width.
17207
+ function growToFullSize(userHides) {
17208
+ let pitch = Math.max(4, _state.externalNodeFontSize || 0) * FULL_SIZE_ROW_PITCH;
17209
+ let fit = _settings.enableDynamicSizing ? displaySizeFromContainer() : null;
17210
+ let fitW = (fit && fit.w > 0) ? fit.w : _settings.displayWidth;
17211
+ let fitH = (fit && fit.h > 0) ? fit.h : _settings.displayHeight;
17212
+ let cap = function (v) {
17213
+ return Math.min(FULL_SIZE_MAX_EXTENT, v);
17214
+ };
17215
+ _state.dynahide = true;
17216
+ if (radialDisplay()) {
17217
+ let side = cap(Math.max(Math.min(_displayWidth, _displayHeight), Math.min(fitW, fitH)));
17218
+ _displayWidth = side;
17219
+ _displayHeight = side;
17220
+ update(null, 0);
17221
+ // Sized first so every tip has a label's height of arc: on the
17222
+ // ring in circular, where the names stand; in unrooted, where
17223
+ // there is no ring, as if the tips were spread evenly around the
17224
+ // fan's rim -- the most a fan's size can promise.
17225
+ let rows = 0;
17226
+ forEachDisplayed(function (n) {
17227
+ if (!n.children || isCollapsed(n)) {
17228
+ ++rows;
17229
+ }
16881
17230
  });
16882
- svgTree = toLightExport((new XMLSerializer()).serializeToString(copy));
16883
- } else if (typeof svg.xml !== 'undefined') {
16884
- svgTree = svg.xml;
17231
+ let span = (_state.circularDisplay && _radial) ? Math.max(0.1, _radial.angleSpan) : 2 * Math.PI;
17232
+ let radius = (_state.circularDisplay && _radial) ? _radial.maxRad : _displayWidth * 0.42;
17233
+ let need = (rows * pitch) / span;
17234
+ if (radius > 0 && need > radius) {
17235
+ _displayWidth = cap(_displayWidth * need / radius);
17236
+ _displayHeight = _displayWidth;
17237
+ update(null, 0);
17238
+ }
17239
+ // Then grown while growing still frees names, and no further. In
17240
+ // an unrooted fan names collide that no size will part: flu_h5's
17241
+ // clusters sit on branches a thousand times shorter than the tree,
17242
+ // and at 67,000 px across 176 of its 354 names were still refused
17243
+ // (332 at 850 px, 322 at 2,300) -- chasing them only made a file
17244
+ // too large to paint as a PNG. The drawing kept is the smallest
17245
+ // that showed the fewest refusals.
17246
+ let best = {hidden: _radialNamesHidden, side: _displayWidth};
17247
+ for (let i = 0; i < 12 && _radialNamesHidden > 0 && _displayWidth < FULL_SIZE_MAX_EXTENT; ++i) {
17248
+ _displayWidth = cap(_displayWidth * 1.4);
17249
+ _displayHeight = _displayWidth;
17250
+ update(null, 0);
17251
+ if (_radialNamesHidden < best.hidden * 0.95) {
17252
+ best = {hidden: _radialNamesHidden, side: _displayWidth};
17253
+ } else {
17254
+ break;
17255
+ }
17256
+ }
17257
+ if (_displayWidth !== best.side) {
17258
+ _displayWidth = best.side;
17259
+ _displayHeight = best.side;
17260
+ update(null, 0);
17261
+ }
17262
+ } else {
17263
+ _displayWidth = cap(Math.max(_displayWidth, fitW));
17264
+ _displayHeight = cap(Math.max(_displayHeight, fitH));
17265
+ update(null, 0);
17266
+ for (let i = 0; i < 4 && (_rowUnit < pitch * 0.999 || _dynahide_factor >= 2)
17267
+ && _displayHeight < FULL_SIZE_MAX_EXTENT; ++i) {
17268
+ // the vertical span less the fixed reserves is what the rows
17269
+ // share, so that is the part that scales
17270
+ let span = layoutSpans().vertical;
17271
+ let reserve = _displayHeight - span;
17272
+ let f = Math.max(pitch / Math.max(_rowUnit, 1e-6), 1.05);
17273
+ _displayHeight = cap(reserve + (span * f));
17274
+ update(null, 0);
17275
+ }
17276
+ }
17277
+ // drawn once more under the user's own auto-hide choice: with it off,
17278
+ // what it would have hidden is drawn, as it is on screen
17279
+ if (!userHides) {
17280
+ _state.dynahide = false;
17281
+ update(null, 0);
16885
17282
  }
16886
- return svgTree;
17283
+ }
17284
+
17285
+ // The smallest box around every non-empty one of `boxes` (getBBox
17286
+ // results), as {x0, y0, x1, y1}; null when none has any size.
17287
+ function unionBox(boxes) {
17288
+ let u = null;
17289
+ boxes.forEach(function (b) {
17290
+ if (!b || !(b.width > 0 || b.height > 0)) {
17291
+ return;
17292
+ }
17293
+ if (!u) {
17294
+ u = {x0: b.x, y0: b.y, x1: b.x + b.width, y1: b.y + b.height};
17295
+ } else {
17296
+ u.x0 = Math.min(u.x0, b.x);
17297
+ u.y0 = Math.min(u.y0, b.y);
17298
+ u.x1 = Math.max(u.x1, b.x + b.width);
17299
+ u.y1 = Math.max(u.y1, b.y + b.height);
17300
+ }
17301
+ });
17302
+ return u;
17303
+ }
17304
+
17305
+ // Every card the viewer pins to the WINDOW rather than to the tree: the
17306
+ // colour and shape legends, the domain legend, the heat-map ring's scale.
17307
+ // On screen they sit where the window has room; in a full-size figure the
17308
+ // window is gone, so they are stacked beside the tree instead.
17309
+ const WINDOW_LEGEND_CLASSES = [LEGEND_LABEL_COLOR, LEGEND_NODE_SHAPE, LEGEND_DOMAINS, 'aptx-heatmap-ringlegend'];
17310
+ const FULL_SIZE_LEGEND_STACK_GAP = 12;
17311
+
17312
+ // The enlarged drawing, cropped to what is actually drawn: the tree
17313
+ // (with its floating strips, anchored to it) and, in a column to its left
17314
+ // rather than over it, the legend cards.
17315
+ function serializeFullSize(svg) {
17316
+ let M = FULL_SIZE_MARGIN;
17317
+ // a strip's content is drawn in tree coordinates; its own transform
17318
+ // only places it on screen, and getBBox leaves that out
17319
+ let strips = _floatGroup ? Array.prototype.slice.call(_floatGroup.node().children) : [];
17320
+ let box = unionBox([_svgGroup.node().getBBox()].concat(strips.map(function (g) {
17321
+ return g.getBBox();
17322
+ })));
17323
+ if (!box) {
17324
+ return graphicAsShown();
17325
+ }
17326
+ let cards = [];
17327
+ WINDOW_LEGEND_CLASSES.forEach(function (cls) {
17328
+ let g = svg.querySelector(':scope > g.' + cls);
17329
+ let b = g ? g.getBBox() : null;
17330
+ if (b && (b.width > 0 || b.height > 0)) {
17331
+ cards.push({g: g, b: b});
17332
+ }
17333
+ });
17334
+ let columnW = 0;
17335
+ let columnH = 0;
17336
+ cards.forEach(function (c, i) {
17337
+ columnW = Math.max(columnW, c.b.width);
17338
+ columnH += c.b.height + (i > 0 ? FULL_SIZE_LEGEND_STACK_GAP : 0);
17339
+ });
17340
+ let legendW = cards.length > 0 ? columnW + FULL_SIZE_LEGEND_GAP : 0;
17341
+ let treeW = box.x1 - box.x0;
17342
+ let treeH = box.y1 - box.y0;
17343
+ let W = Math.ceil(M + legendW + treeW + M);
17344
+ let H = Math.ceil(M + Math.max(treeH, columnH) + M);
17345
+
17346
+ let copy = svg.cloneNode(true);
17347
+ let place = 'translate(' + (M + legendW - box.x0) + ',' + (M - box.y0) + ')';
17348
+ let tree = copyCounterpart(svg, copy, _svgGroup.node());
17349
+ if (tree) {
17350
+ tree.setAttribute('transform', place);
17351
+ }
17352
+ copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
17353
+ strip.setAttribute('transform', place);
17354
+ });
17355
+ // A card's own transform, where it has one, only placed it in the
17356
+ // window (getBBox leaves it out), so it is replaced, not added to.
17357
+ let y = M;
17358
+ cards.forEach(function (c) {
17359
+ let cc = copyCounterpart(svg, copy, c.g);
17360
+ if (cc) {
17361
+ cc.setAttribute('transform', 'translate(' + (M - c.b.x) + ',' + (y - c.b.y) + ')');
17362
+ }
17363
+ y += c.b.height + FULL_SIZE_LEGEND_STACK_GAP;
17364
+ });
17365
+ cleanExportCopy(copy);
17366
+ copy.setAttribute('width', W);
17367
+ copy.setAttribute('height', H);
17368
+ copy.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
17369
+ return {text: toLightExport((new XMLSerializer()).serializeToString(copy)), w: W, h: H};
16887
17370
  }
16888
17371
 
16889
17372
  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) {
17373
+ if (format === NH_EXPORT_FORMAT) {
16899
17374
  downloadAsNH();
16900
17375
  } else if (format === NEXUS_EXPORT_FORMAT) {
16901
17376
  downloadAsNexus();
16902
17377
  } else if (format === PHYLOXML_EXPORT_FORMAT) {
16903
17378
  downloadAsPhyloXml();
16904
- } else if (format === PDF_EXPORT_FORMAT) {
16905
- changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
16906
- downloadAsPdf();
16907
- changeBaseBackgoundColor(_state.backgroundColorDefault);
16908
17379
  } else if (format === FASTA_EXPORT_FORMAT) {
16909
17380
  downloadAsFastaAll();
17381
+ } else if (format === TSV_EXPORT_FORMAT) {
17382
+ downloadAsTsvAll();
17383
+ } else if (format === SVG_EXPORT_FORMAT || format === PDF_EXPORT_FORMAT || format === PNG_EXPORT_FORMAT) {
17384
+ withGraphic(function (graphic) {
17385
+ if (format === SVG_EXPORT_FORMAT) {
17386
+ downloadAsSVG(graphic);
17387
+ } else if (format === PDF_EXPORT_FORMAT) {
17388
+ downloadAsPdf(graphic);
17389
+ } else {
17390
+ downloadAsPng(graphic);
17391
+ }
17392
+ });
16910
17393
  }
16911
17394
  }
16912
17395
 
17396
+ // Hands fn the graphic the Download section's choices describe, drawn in
17397
+ // the light export colours; a failure -- no graphic, or a throw anywhere
17398
+ // on the way -- goes to fail instead, so a caller waiting on the result
17399
+ // (Copy PNG's clipboard write) always hears back. A full-size drawing
17400
+ // re-lays-out the tree several times over, each pass larger than the
17401
+ // redraw last timed, so the card goes up when that redraw was even a
17402
+ // fraction of the card's threshold.
17403
+ function withGraphic(fn, fail) {
17404
+ // without a handler the error is thrown on, as it was before there
17405
+ // was one -- to the page's error handling and the harnesses -- rather
17406
+ // than turned into a console line and a click that did nothing
17407
+ let failed = fail || function (err) {
17408
+ throw err;
17409
+ };
17410
+ let draw = function () {
17411
+ let graphic;
17412
+ try {
17413
+ changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
17414
+ try {
17415
+ graphic = exportGraphic();
17416
+ } finally {
17417
+ changeBaseBackgoundColor(_state.backgroundColorDefault);
17418
+ }
17419
+ if (!graphic) {
17420
+ throw new Error('this browser cannot serialize the tree');
17421
+ }
17422
+ fn(graphic);
17423
+ } catch (err) {
17424
+ failed(err);
17425
+ }
17426
+ };
17427
+ if (_downloadFullSize && _redrawMs * FULL_SIZE_REDRAWS_EXPECTED >= REDRAW_CARD_MS && _container) {
17428
+ showBusy(_container, 'Drawing the whole tree');
17429
+ afterPaint(function () {
17430
+ try {
17431
+ draw();
17432
+ } finally {
17433
+ hideBusy();
17434
+ }
17435
+ });
17436
+ } else {
17437
+ draw();
17438
+ }
17439
+ }
17440
+
17441
+ // How many redraws a full-size drawing typically costs: the drawing at the
17442
+ // window's size, one or two to reach the size, and the one that puts the
17443
+ // window back -- more on a crowded fan.
17444
+ const FULL_SIZE_REDRAWS_EXPECTED = 4;
17445
+
16913
17446
  /**
16914
17447
  * Saves a Blob to a file using native browser APIs (an <a download> link
16915
17448
  * pointing at an object URL). This replaces the former FileSaver.js
@@ -16937,9 +17470,34 @@ function (root, d3, forester, phyloXml) {
16937
17470
  }, 1000);
16938
17471
  }
16939
17472
 
17473
+ // What a tree file is written from. The whole tree as it is; a subtree
17474
+ // view's root is a bare wrapper made when the subtree was entered, and
17475
+ // written as it stands it gave the file no name at all (phyloXML wrote
17476
+ // none, Nexus "tree1"). So it goes out under the tree's own name, marked
17477
+ // as a subtree. A shallow copy: the drawing's wrapper is left alone, and
17478
+ // the whole-tree files are byte for byte what they were.
17479
+ function treeForExport() {
17480
+ if (_root === _root_const || !_root_const) {
17481
+ return _root;
17482
+ }
17483
+ let name = _root_const.name ? String(_root_const.name).trim() : '';
17484
+ return Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17485
+ }
17486
+
17487
+ // "subtree, 19 of 50 tips" while a subtree is shown, else null: said in
17488
+ // the Download menu, so nobody writes a part of the tree thinking it the
17489
+ // whole.
17490
+ function subtreeExtentNote() {
17491
+ if (_root === _root_const || !_root_const) {
17492
+ return null;
17493
+ }
17494
+ return 'subtree, ' + forester.getAllExternalNodes(_root).length.toLocaleString() + ' of '
17495
+ + forester.getAllExternalNodes(_root_const).length.toLocaleString() + ' tips';
17496
+ }
17497
+
16940
17498
  function downloadAsPhyloXml() {
16941
- let x = phyloXml.toPhyloXML(_root, 9);
16942
- saveAs(new Blob([x], {type: "application/xml"}), _state.nameForPhyloXmlDownload);
17499
+ let x = phyloXml.toPhyloXML(treeForExport(), 9);
17500
+ saveAs(new Blob([x], {type: "application/xml"}), downloadFileName(XML_SUFFIX));
16943
17501
  }
16944
17502
 
16945
17503
  function downloadAsNH() {
@@ -16947,25 +17505,43 @@ function (root, d3, forester, phyloXml) {
16947
17505
  // not a preference: writing them out produces a file that will not parse
16948
17506
  // back in.
16949
17507
  let nh = forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences);
16950
- saveAs(new Blob([nh], {type: "application/txt"}), _state.nameForNhDownload);
17508
+ saveAs(new Blob([nh], {type: "application/txt"}), downloadFileName(NH_SUFFIX));
16951
17509
  }
16952
17510
 
16953
17511
  function downloadAsNexus() {
16954
17512
  // taxa and trees blocks as on the desktop, plus a characters block
16955
17513
  // when the tips carry an alignment -- carrying tree and alignment
16956
17514
  // 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);
17515
+ let phy = treeForExport();
17516
+ let nex = forester.toNexus(phy, 9, _settings.nhExportWriteConfidences);
17517
+ saveAs(new Blob([nex], {type: "application/txt"}), downloadFileName(NEXUS_SUFFIX));
16959
17518
  }
16960
17519
 
16961
- function downloadAsSVG() {
16962
- let svg = getTreeAsSvg();
16963
- saveAs(new Blob([decodeURIComponent(encodeURIComponent(svg))], {type: "application/svg+xml"}), _state.nameForSvgDownload);
17520
+ function downloadAsSVG(graphic) {
17521
+ saveAs(new Blob([graphic.text], {type: "image/svg+xml"}), downloadFileName(SVG_SUFFIX));
17522
+ }
17523
+
17524
+ // Every tip's data, one row per tip top to bottom as drawn: the node
17525
+ // menu's "Download Ext. Node Data" for the whole tree -- the desktop's
17526
+ // columns, and a table that joins back onto the tree as metadata.
17527
+ function downloadAsTsvAll() {
17528
+ saveAs(new Blob([tipTableTsv(forester.getAllExternalNodes(_root).reverse())],
17529
+ {type: 'text/tab-separated-values'}), downloadFileName(TSV_SUFFIX));
17530
+ }
17531
+
17532
+ // The table of `tips` (top to bottom as drawn: getAllExternalNodes
17533
+ // reversed) -- one writer for the Download menu's TSV and the node
17534
+ // menu's Download Ext. Node Data, so their columns and ids cannot drift
17535
+ // apart. The caller walks the tips, once, and may count them too.
17536
+ function tipTableTsv(tips) {
17537
+ return forester.externalNodeDataTsv(tips, function (n, i) {
17538
+ return (n.viewId !== undefined) ? n.viewId : i + 1;
17539
+ });
16964
17540
  }
16965
17541
 
16966
17542
  function downloadAsFastaAll() {
16967
17543
  let fasta_text = forester.getMolecularSequencesAsFasta(_root, '\n');
16968
- saveAs(new Blob([fasta_text], {type: "application/txt"}), _state.nameForFastaDownload);
17544
+ saveAs(new Blob([fasta_text], {type: "application/txt"}), downloadFileName(FASTA_SUFFIX));
16969
17545
  }
16970
17546
 
16971
17547
  // Vector PDF via the OPTIONAL page-level libraries jsPDF and svg2pdf.js
@@ -16979,13 +17555,18 @@ function (root, d3, forester, phyloXml) {
16979
17555
  && window.jspdf.jsPDF.API.svg);
16980
17556
  }
16981
17557
 
16982
- function downloadAsPdf() {
17558
+ // The largest page a PDF may have: 200 inches a side (ISO 32000, Annex C).
17559
+ // Readers refuse, or quietly clip, anything past it -- and a full-size
17560
+ // tree of a few thousand tips is past it -- so a larger drawing is scaled
17561
+ // onto the largest page instead. It is vector: nothing is lost by that.
17562
+ const PDF_MAX_PAGE_PT = 14400;
17563
+
17564
+ function downloadAsPdf(graphic) {
16983
17565
  if (!pdfExportAvailable()) {
16984
17566
  console.error(ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
16985
17567
  return;
16986
17568
  }
16987
- let svg = getTreeAsSvg();
16988
- let el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
17569
+ let el = new DOMParser().parseFromString(graphic.text, 'image/svg+xml').documentElement;
16989
17570
  // the full-canvas background rect is sized in percentages, which
16990
17571
  // svg2pdf renders literally (a dark 100pt square at the origin); the
16991
17572
  // PDF page is white anyway, so the rect is simply dropped
@@ -17017,9 +17598,9 @@ function (root, d3, forester, phyloXml) {
17017
17598
  }
17018
17599
  });
17019
17600
  });
17020
- let svgEl = treeSvgElement();
17021
- let w = (svgEl && svgEl.width.baseVal.value) || _displayWidth;
17022
- let h = (svgEl && svgEl.height.baseVal.value) || _displayHeight;
17601
+ let fit = Math.min(1, PDF_MAX_PAGE_PT / Math.max(graphic.w, graphic.h));
17602
+ let w = graphic.w * fit;
17603
+ let h = graphic.h * fit;
17023
17604
  let pdf = new window.jspdf.jsPDF({
17024
17605
  orientation: w >= h ? 'landscape' : 'portrait',
17025
17606
  unit: 'pt',
@@ -17027,7 +17608,7 @@ function (root, d3, forester, phyloXml) {
17027
17608
  compress: true
17028
17609
  });
17029
17610
  pdf.svg(el, {x: 0, y: 0, width: w, height: h}).then(function () {
17030
- pdf.save(_state.nameForPdfDownload);
17611
+ pdf.save(downloadFileName(PDF_SUFFIX));
17031
17612
  holder.remove();
17032
17613
  }, function (err) {
17033
17614
  holder.remove();
@@ -17044,23 +17625,62 @@ function (root, d3, forester, phyloXml) {
17044
17625
  return !!(window.Canvg && window.Canvg.fromString);
17045
17626
  }
17046
17627
 
17047
- function downloadAsPng() {
17628
+ // The biggest canvas this browser will actually paint, at no more than
17629
+ // the scale asked for. Browsers cap a canvas by side and by area, and
17630
+ // differently (Safari's area cap is a sixteenth of Chrome's); past the
17631
+ // cap a canvas is not an error, it is just blank -- which is what a
17632
+ // full-size PNG of a large tree would silently have been. So the canvas
17633
+ // is TRIED: filled, and its far corner read back.
17634
+ function paintableCanvas(w, h, scale) {
17635
+ for (let k = scale; k > 0.05; k *= 0.8) {
17636
+ let canvas = document.createElement('canvas');
17637
+ canvas.width = Math.max(1, Math.round(w * k));
17638
+ canvas.height = Math.max(1, Math.round(h * k));
17639
+ let ctx = canvas.getContext('2d');
17640
+ if (ctx) {
17641
+ ctx.fillStyle = '#ffffff';
17642
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
17643
+ let px;
17644
+ try {
17645
+ px = ctx.getImageData(canvas.width - 1, canvas.height - 1, 1, 1).data;
17646
+ } catch {
17647
+ px = null;
17648
+ }
17649
+ if (px && px[3] === 255) {
17650
+ return {canvas: canvas, ctx: ctx, scale: k};
17651
+ }
17652
+ }
17653
+ }
17654
+ return null;
17655
+ }
17656
+
17657
+ function downloadAsPng(graphic) {
17658
+ renderPng(graphic).then(function (blob) {
17659
+ saveAs(blob, downloadFileName(PNG_SUFFIX));
17660
+ }, function (err) {
17661
+ console.error(ERROR + 'PNG export failed: ' + err);
17662
+ });
17663
+ }
17664
+
17665
+ // The graphic as a PNG Blob, at the Download section's 2x/4x/8x -- less
17666
+ // where the browser cannot paint a canvas that large (paintableCanvas).
17667
+ function renderPng(graphic) {
17668
+ if (!graphic) {
17669
+ return Promise.reject(new Error('nothing to draw'));
17670
+ }
17048
17671
  if (!pngExportAvailable()) {
17049
- console.error(ERROR + 'PNG export needs the optional canvg library on the page');
17050
- return;
17672
+ return Promise.reject(new Error('PNG export needs the optional canvg library on the page'));
17673
+ }
17674
+ let target = paintableCanvas(graphic.w, graphic.h, _pngScale);
17675
+ if (!target) {
17676
+ return Promise.reject(new Error('this browser cannot paint a canvas of that size'));
17051
17677
  }
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');
17678
+ if (target.scale < _pngScale) {
17679
+ console.warn(WARNING + ': PNG exported at ' + target.scale.toFixed(2) + 'x rather than ' + _pngScale
17680
+ + 'x: the largest image this browser can paint');
17681
+ }
17682
+ let canvas = target.canvas;
17683
+ let ctx = target.ctx;
17064
17684
  // The up-scaling is done on the CONTEXT, not through canvg's own
17065
17685
  // scaleWidth/scaleHeight: canvg 4's scaling pipeline mis-scales (a
17066
17686
  // requested 4x came out at 2.5x, leaving unpainted margins -- and any
@@ -17069,20 +17689,56 @@ function (root, d3, forester, phyloXml) {
17069
17689
  // background before canvg draws a thing. ignoreDimensions keeps canvg
17070
17690
  // from resizing the canvas to the SVG's size (which would erase both
17071
17691
  // 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()
17692
+ // fill back to transparent. v4's render() is async (v1's canvg()
17075
17693
  // call drew synchronously, so toBlob could follow on the next line).
17076
17694
  ctx.fillStyle = _state.backgroundColorForPrintExportDefault;
17077
17695
  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);
17696
+ ctx.scale(target.scale, target.scale);
17697
+ let v = window.Canvg.fromString(ctx, graphic.text);
17698
+ return v.render({ignoreDimensions: true, ignoreClear: true}).then(function () {
17699
+ return new Promise(function (resolve, reject) {
17700
+ canvas.toBlob(function (blob) {
17701
+ if (blob) {
17702
+ resolve(blob);
17703
+ } else {
17704
+ reject(new Error('the canvas gave no image'));
17705
+ }
17706
+ }, 'image/png');
17083
17707
  });
17708
+ });
17709
+ }
17710
+
17711
+ // Copying an image needs the async clipboard's write() and ClipboardItem
17712
+ // (a secure page: https, or localhost) as well as canvg; the button is
17713
+ // only offered where all three are there.
17714
+ function pngCopyAvailable() {
17715
+ return pngExportAvailable() && typeof window.ClipboardItem === 'function'
17716
+ && !!(navigator.clipboard && navigator.clipboard.write);
17717
+ }
17718
+
17719
+ // The desktop's "copy image": the picture straight onto the clipboard,
17720
+ // for a Word document or a slide. The ClipboardItem is made at once, with
17721
+ // the image as a PROMISE, inside the click: Safari allows a clipboard
17722
+ // write only during the gesture, and drawing the PNG takes longer than
17723
+ // that -- a promise-valued item is how it waits (Chrome takes it too).
17724
+ function copyPngPressed(event) {
17725
+ let btn = event.currentTarget;
17726
+ let png = new Promise(function (resolve, reject) {
17727
+ withGraphic(function (graphic) {
17728
+ renderPng(graphic).then(resolve, reject);
17729
+ }, reject);
17730
+ });
17731
+ let written;
17732
+ try {
17733
+ written = navigator.clipboard.write([new window.ClipboardItem({'image/png': png})]);
17734
+ } catch (e) {
17735
+ written = Promise.reject(e);
17736
+ }
17737
+ written.then(function () {
17738
+ flashButton(btn, 'Copied ✓');
17084
17739
  }, function (err) {
17085
- console.error(ERROR + 'PNG export failed: ' + err);
17740
+ console.error(ERROR + 'copying the PNG failed: ' + err);
17741
+ flashButton(btn, 'Copy failed');
17086
17742
  });
17087
17743
  }
17088
17744