archaeopteryx 3.15.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.15.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.15.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';
@@ -408,6 +409,20 @@ function (root, d3, forester, phyloXml) {
408
409
  const VIS_CB = 'vis_cb';
409
410
  const LADDERIZE_BUTTON = 'ladderize_b';
410
411
  const PHYLOGRAM_ALIGNED_BUTTON = 'phya_b';
412
+ // What the three display types DO, in one place: the tooltip, the reason a
413
+ // type is greyed, and the cheat-sheet row are all this sentence. A control
414
+ // named only after itself ("phylogram display (uses branch length
415
+ // values)") tells a reader who already knows what it is.
416
+ // Each says WHEN it is greyed, whether or not it is greyed now: that is a
417
+ // property of the control, not of the view, and a reader of the sheet in
418
+ // one layout is owed what happens in the others.
419
+ const PHYLOGRAM_WHAT = 'phylogram: branch lengths drawn to scale, so the tips end ragged.'
420
+ + ' Greyed in the circular layout, which always carries its labels to the outer ring,'
421
+ + ' so there the aligned phylogram is the one drawn';
422
+ const PHYLOGRAM_ALIGNED_WHAT = 'aligned phylogram: branch lengths to scale, with every tip'
423
+ + ' carried on to a common column, or in the circular layout to the outer ring.'
424
+ + ' Greyed in the unrooted layout, which has no column or ring to carry them to';
425
+ const CLADOGRAM_WHAT = 'cladogram: topology only, branch lengths ignored, every tip flush';
411
426
  const PHYLOGRAM_BUTTON = 'phy_b';
412
427
  const PHYLOGRAM_CLADOGRAM_CONTROLGROUP = 'phy_cla_g';
413
428
  const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
@@ -484,6 +499,12 @@ function (root, d3, forester, phyloXml) {
484
499
  // "Instance variables"
485
500
  // ---------------------------
486
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;
487
508
  let _basicTreeProperties = null;
488
509
  let _displayHeight = 0;
489
510
  let _displayWidth = 0;
@@ -1553,8 +1574,11 @@ function (root, d3, forester, phyloXml) {
1553
1574
  }
1554
1575
  }
1555
1576
 
1556
- // items: [{label, action, danger}]; anchored at the click position.
1557
- 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) {
1558
1582
  removeNodeMenu();
1559
1583
  if (!items || items.length < 1) {
1560
1584
  return;
@@ -1582,9 +1606,22 @@ function (root, d3, forester, phyloXml) {
1582
1606
  menu.appendChild(note);
1583
1607
  return;
1584
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
+ }
1585
1616
  let b = document.createElement('button');
1586
1617
  b.type = 'button';
1587
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
+ }
1588
1625
  if (item.title) {
1589
1626
  b.title = item.title;
1590
1627
  }
@@ -1604,10 +1641,19 @@ function (root, d3, forester, phyloXml) {
1604
1641
  document.body.appendChild(menu);
1605
1642
 
1606
1643
  // Place it at the pointer, nudged back inside the window if it would
1607
- // 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.
1608
1647
  let x = (event && event.pageX !== undefined) ? event.pageX : 0;
1609
1648
  let y = (event && event.pageY !== undefined) ? event.pageY : 0;
1610
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
+ }
1611
1657
  let maxX = window.scrollX + document.documentElement.clientWidth - r.width - 8;
1612
1658
  let maxY = window.scrollY + document.documentElement.clientHeight - r.height - 8;
1613
1659
  menu.style.left = Math.max(window.scrollX + 8, Math.min(x, maxX)) + 'px';
@@ -1629,6 +1675,14 @@ function (root, d3, forester, phyloXml) {
1629
1675
  if (_nodeMenu && _nodeMenu.contains(e.target)) {
1630
1676
  return;
1631
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
+ }
1632
1686
  removeNodeMenu();
1633
1687
  };
1634
1688
  let onKey = function (e) {
@@ -2966,7 +3020,6 @@ function (root, d3, forester, phyloXml) {
2966
3020
  return {source: link.source.data, target: link.target.data};
2967
3021
  });
2968
3022
  _overviewLinks = links; // the overview is built from these, not from the DOM
2969
- let gap = _state.nodeLabelGap;
2970
3023
 
2971
3024
  if (_state.phylogram === true) {
2972
3025
  _yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
@@ -3048,9 +3101,20 @@ function (root, d3, forester, phyloXml) {
3048
3101
  }
3049
3102
 
3050
3103
  if (_state.dynahide) {
3051
- if (_state.unrootedDisplay) {
3052
- // as on the desktop: no label auto-hiding in unrooted (there
3053
- // is no even row spacing to decimate against)
3104
+ if (radialDisplay()) {
3105
+ // The every-k-th DECIMATION does not run in either radial
3106
+ // layout: it thins by index against even ROW spacing, which a
3107
+ // fan does not have. An unrooted fan has no rows at all; a
3108
+ // circular one has a ring, whose room is set by its
3109
+ // circumference and not by the display's height. Measured on
3110
+ // Caliciviridae_100 at 1100x850, the index rule kept 48 of 97
3111
+ // names in circular while all 97 fitted the ring with NOT ONE
3112
+ // overlapping pair, and on flu_h5 it kept 59 of 354 where 118
3113
+ // are readable. (118 is the shipped rule measured; 100 was a
3114
+ // greedy estimate in DOM order made before it was built, and
3115
+ // is what this comment said until a review caught it against
3116
+ // README and CHANGELOG.) Both are now thinned by hideCrowdedTipLabels
3117
+ // instead, on whether the names actually overprint.
3054
3118
  forester.preOrderTraversal(_root, function (n) {
3055
3119
  n.hide = false;
3056
3120
  });
@@ -3062,8 +3126,6 @@ function (root, d3, forester, phyloXml) {
3062
3126
  });
3063
3127
  }
3064
3128
  }
3065
- syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
3066
-
3067
3129
  updateButtonEnabledState();
3068
3130
 
3069
3131
  // Only the nodes with something drawn for them individually get a
@@ -3130,11 +3192,10 @@ function (root, d3, forester, phyloXml) {
3130
3192
  return makeFoundOutlineColor(d) ? '0.9px' : null;
3131
3193
  })
3132
3194
  .style('paint-order', 'stroke')
3195
+ // anchor, dy and x all come from nodeLabelPlacement, which the
3196
+ // occupancy box is built from too
3133
3197
  .attr('text-anchor', function (d) {
3134
- if (radialDisplay()) {
3135
- return labelFlip(d) ? 'end' : 'start';
3136
- }
3137
- return d.children ? 'end' : 'start';
3198
+ return nodeLabelPlacement(d).anchor;
3138
3199
  })
3139
3200
  .attr('transform', function (d) {
3140
3201
  if (_state.unrootedDisplay) {
@@ -3162,42 +3223,10 @@ function (root, d3, forester, phyloXml) {
3162
3223
  return 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
3163
3224
  })
3164
3225
  .attr('dy', function (d) {
3165
- if (radialDisplay()) {
3166
- return '0.32em';
3167
- }
3168
- if (d.children) {
3169
- // the desktop's above-branch internal label: the baseline
3170
- // is lifted so the glyph bottoms sit just clear of the
3171
- // horizontal branch line, instead of straddling it
3172
- return -(Math.round(0.2 * _state.internalNodeFontSize) + 1) + 'px';
3173
- }
3174
- return 0.3 * _state.externalNodeFontSize + 'px';
3226
+ return nodeLabelPlacement(d).dy + 'px';
3175
3227
  })
3176
3228
  .attr('x', function (d) {
3177
- if (radialDisplay()) {
3178
- return labelFlip(d) ? -gap : gap;
3179
- }
3180
- if (!(d.children)) {
3181
- if (_state.phylogram && _state.alignPhylogram) {
3182
- return (-_yScale(d.distToRoot) + _w + gap);
3183
- } else {
3184
- return gap;
3185
- }
3186
- } else {
3187
- // right-aligned so the label ends just left of the node
3188
- // (the desktop's publication-style placement); a long
3189
- // label on a node near the root shifts right so its
3190
- // leading characters stay on the canvas rather than
3191
- // clipping off the left edge
3192
- let label = makeNodeLabel(d) || '';
3193
- let est = label.length * _state.internalNodeFontSize * 0.55;
3194
- let leftmost = d.y - gap - est;
3195
- let minX = 2 - _settings.rootOffset;
3196
- if (leftmost < minX) {
3197
- return -gap + (minX - leftmost);
3198
- }
3199
- return -gap;
3200
- }
3229
+ return nodeLabelPlacement(d).x;
3201
3230
  });
3202
3231
 
3203
3232
  node.select('text.bllabel')
@@ -3218,8 +3247,9 @@ function (root, d3, forester, phyloXml) {
3218
3247
  if (radialDisplay()) {
3219
3248
  return 0;
3220
3249
  }
3221
- if (d.parent) {
3222
- return (d.parent.y - d.y + 1);
3250
+ let p = drawnParent(d);
3251
+ if (p) {
3252
+ return (p.y - d.y + 1);
3223
3253
  } else {
3224
3254
  return 0;
3225
3255
  }
@@ -3239,8 +3269,9 @@ function (root, d3, forester, phyloXml) {
3239
3269
  if (radialDisplay()) {
3240
3270
  return 0;
3241
3271
  }
3242
- if (d.parent) {
3243
- return (0.5 * (d.parent.y - d.y));
3272
+ let p = drawnParent(d);
3273
+ if (p) {
3274
+ return (0.5 * (p.y - d.y));
3244
3275
  } else {
3245
3276
  return 0;
3246
3277
  }
@@ -3261,7 +3292,8 @@ function (root, d3, forester, phyloXml) {
3261
3292
  if (radialDisplay()) {
3262
3293
  return 0;
3263
3294
  }
3264
- 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;
3265
3297
  });
3266
3298
 
3267
3299
  node.select('text.brancheventlabel')
@@ -3275,8 +3307,9 @@ function (root, d3, forester, phyloXml) {
3275
3307
  if (radialDisplay()) {
3276
3308
  return 0;
3277
3309
  }
3278
- if (d.parent) {
3279
- return (0.5 * (d.parent.y - d.y));
3310
+ let p = drawnParent(d);
3311
+ if (p) {
3312
+ return (0.5 * (p.y - d.y));
3280
3313
  }
3281
3314
  });
3282
3315
 
@@ -3361,7 +3394,7 @@ function (root, d3, forester, phyloXml) {
3361
3394
  // a collapsed clade's label always shows, so its guide does too: from
3362
3395
  // past the wedge's farthest tip to the label column
3363
3396
  let collapsedGuides = alignedRect ? nodes.filter(function (d) {
3364
- return isCollapsed(d) && !(_state.dynahide && d.hide);
3397
+ return isCollapsed(d) && nameDrawn(d);
3365
3398
  }) : [];
3366
3399
  if (tipGuides || collapsedGuides.length > 0) {
3367
3400
  let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
@@ -3374,7 +3407,7 @@ function (root, d3, forester, phyloXml) {
3374
3407
  if (tipGuides) {
3375
3408
  guideStyle(ext.selectAll('path.aptx-tip-guide')
3376
3409
  .data(links.filter(function (d) {
3377
- return (!d.target.children && !(_state.dynahide && d.target.hide));
3410
+ return (!d.target.children && nameDrawn(d.target));
3378
3411
  }))
3379
3412
  .enter().append('path').attr('class', 'aptx-tip-guide'))
3380
3413
  .attr('d', function (d) {
@@ -3397,7 +3430,7 @@ function (root, d3, forester, phyloXml) {
3397
3430
  // tips while their labels show; a collapsed clade always (its label
3398
3431
  // always shows), from its wedge's farthest reach out to the ring
3399
3432
  let connected = nodes.filter(function (d) {
3400
- if (_state.dynahide && d.hide) {
3433
+ if (!nameDrawn(d)) {
3401
3434
  return false;
3402
3435
  }
3403
3436
  if (isCollapsed(d)) {
@@ -3870,8 +3903,8 @@ function (root, d3, forester, phyloXml) {
3870
3903
  // in y as much as in x, and branch length only sees x. Measured here
3871
3904
  // before adopting it: with Auto-hide Labels already ON, 240 of 267 drawn
3872
3905
  // support numbers overlapped another on flu_h5.xml (90%), 149 of 262 on
3873
- // Adenoviridae, 13 of 119 on bcl2 -- our auto-hide decimates TIP labels
3874
- // and had never touched branch data.
3906
+ // Adenoviridae, 13 of 119 on bcl2 -- our auto-hide only thinned TIP labels
3907
+ // then, and had never touched branch data.
3875
3908
  //
3876
3909
  // WHAT MUST MATCH, or the two viewers drop different marks:
3877
3910
  // - the tie-break: PREORDER, first claim wins, so the mark nearer the
@@ -3903,11 +3936,29 @@ function (root, d3, forester, phyloXml) {
3903
3936
  // Measured from the font rather than assumed. The fallback is what this
3904
3937
  // font actually reports at 9px -- ascent 9, descent 2, line 11 -- read off
3905
3938
  // a rendered label's getBBox rather than guessed.
3906
- let _branchFontCache = null;
3939
+ let _fontMetricsCache = new Map();
3940
+
3941
+ // how many names the radial overlap rule took on the last render, for the
3942
+ // Auto-hide indicator: those layouts thin by overlap, not by index, so
3943
+ // _dynahide_factor says nothing about them
3944
+ let _radialNamesHidden = 0;
3945
+
3946
+ // and how many branch marks the crowding rule refused on the last render.
3947
+ // The switch governs three things -- the every-k-th thinning, the radial
3948
+ // overlap thinning and the branch-data rule -- and the indicator used to
3949
+ // ask only about names, so a view where every name fitted but 201 numbers
3950
+ // were being refused showed a dark toggle over a tree it was thinning.
3951
+ // (Caliciviridae circular; apaf RECTANGULAR did it too, so this half was
3952
+ // never right in any layout.)
3953
+ let _branchMarksHidden = 0;
3907
3954
 
3908
3955
  function branchFontMetrics(fontPx) {
3909
- if (_branchFontCache && _branchFontCache.px === fontPx) {
3910
- return _branchFontCache;
3956
+ // One cache for every size asked for, not one slot: labels and branch
3957
+ // marks run at different sizes in the same pass, and a single slot
3958
+ // would re-measure on every alternation.
3959
+ let hit = _fontMetricsCache.get(fontPx);
3960
+ if (hit) {
3961
+ return hit;
3911
3962
  }
3912
3963
  let ctx = measureCtx();
3913
3964
  ctx.font = fontPx + 'px ' + FONT_DEFAULTS;
@@ -3924,9 +3975,10 @@ function (root, d3, forester, phyloXml) {
3924
3975
  if (!(descent >= 0)) {
3925
3976
  descent = fontPx * (2 / 9);
3926
3977
  }
3927
- _branchFontCache = {px: fontPx, ascent: ascent, descent: descent,
3978
+ let metrics = {px: fontPx, ascent: ascent, descent: descent,
3928
3979
  height: ascent + descent};
3929
- return _branchFontCache;
3980
+ _fontMetricsCache.set(fontPx, metrics);
3981
+ return metrics;
3930
3982
  }
3931
3983
 
3932
3984
  // Where a branch mark sits on its branch, read off the rendered elements
@@ -3970,12 +4022,14 @@ function (root, d3, forester, phyloXml) {
3970
4022
  if (d.ux === undefined) {
3971
4023
  return null;
3972
4024
  }
3973
- let p = (d.parent && d.parent.ux !== undefined) ? d.parent : null;
4025
+ let p = drawnParent(d);
4026
+ p = (p && p.ux !== undefined) ? p : null;
3974
4027
  return p ? [0.5 * (d.ux + p.ux), 0.5 * (d.uy + p.uy), angle] : [d.ux, d.uy, angle];
3975
4028
  }
3976
4029
  let r = radialRadius(d.y);
3977
- if (d.parent) {
3978
- r = 0.5 * (r + radialRadius(d.parent.y));
4030
+ let p = drawnParent(d);
4031
+ if (p) {
4032
+ r = 0.5 * (r + radialRadius(p.y));
3979
4033
  }
3980
4034
  let pt = polarXY(radialAngle(d.x), r);
3981
4035
  return [pt[0], pt[1], angle];
@@ -4011,13 +4065,246 @@ function (root, d3, forester, phyloXml) {
4011
4065
  // used to claim nothing -- so the first child's number, landing in
4012
4066
  // almost the same place, was granted and painted over them. The one
4013
4067
  // place the pass could not keep its promise.
4014
- let span = d.parent ? (d.parent.y - d.y) : 0;
4068
+ let p = drawnParent(d);
4069
+ let span = p ? (p.y - d.y) : 0;
4015
4070
  let x = at.mid
4016
4071
  ? (d.y + (0.5 * span) - (w / 2)) // middle-anchored
4017
4072
  : (d.y + span + 1); // start-anchored
4018
4073
  return [x, (d.x + at.dy) - fm.ascent, w, h];
4019
4074
  }
4020
4075
 
4076
+ // ---- the node's own label: one source for drawing it and for reserving
4077
+ // its space ----
4078
+ //
4079
+ // The three attributes below (text-anchor, x, dy) and the occupancy box
4080
+ // that keeps branch numbers off the name are the same decision, so they
4081
+ // are made once here. Modelled after branchMarkPlacement, and for the same
4082
+ // reason: a box that is not where the text is reserves the wrong space,
4083
+ // and two copies of the arithmetic drift the first time one is edited.
4084
+ function nodeLabelPlacement(d) {
4085
+ let gap = _state.nodeLabelGap;
4086
+ if (radialDisplay()) {
4087
+ let flip = labelFlip(d);
4088
+ return {anchor: flip ? 'end' : 'start', x: flip ? -gap : gap,
4089
+ dy: 0.32 * nodeLabelFontSize(d)};
4090
+ }
4091
+ if (!d.children) {
4092
+ let x = (_state.phylogram && _state.alignPhylogram)
4093
+ ? (-_yScale(d.distToRoot) + _w + gap)
4094
+ : gap;
4095
+ return {anchor: 'start', x: x, dy: 0.3 * _state.externalNodeFontSize};
4096
+ }
4097
+ // the desktop's above-branch internal label: right-aligned so it ends
4098
+ // just left of the node, and shifted right when a long label on a node
4099
+ // near the root would otherwise clip off the left edge
4100
+ let label = makeNodeLabel(d) || '';
4101
+ let est = label.length * _state.internalNodeFontSize * 0.55;
4102
+ let leftmost = d.y - gap - est;
4103
+ let minX = 2 - _settings.rootOffset;
4104
+ let x = (leftmost < minX) ? (-gap + (minX - leftmost)) : -gap;
4105
+ return {anchor: 'end', x: x,
4106
+ dy: -(Math.round(0.2 * _state.internalNodeFontSize) + 1)};
4107
+ }
4108
+
4109
+ // The size the label is actually drawn at: a per-node style overrides the
4110
+ // internal/external default, and the box has to follow it or a styled
4111
+ // tree reserves the wrong space.
4112
+ function nodeLabelFontSize(d) {
4113
+ let style = nodeStyle(d);
4114
+ if (style && style.fontSize) {
4115
+ return style.fontSize;
4116
+ }
4117
+ return d.children ? _state.internalNodeFontSize : _state.externalNodeFontSize;
4118
+ }
4119
+
4120
+ // The label's rectangle in the tree group's coordinates, as a start point
4121
+ // on the baseline, a direction the text runs in, and its extents.
4122
+ // Returns null when there is no label drawn.
4123
+ function nodeLabelGeometry(d) {
4124
+ let text = d._extLabelText;
4125
+ if (!text) {
4126
+ return null;
4127
+ }
4128
+ let fontPx = nodeLabelFontSize(d);
4129
+ let style = nodeStyle(d);
4130
+ let bold = getFoundColor(d)
4131
+ || (style && (style.fontStyle === 'bold' || style.fontStyle === 'bold_italic'));
4132
+ let w = legendTextWidth(text, (bold ? 'bold ' : '') + fontPx + 'px ' + FONT_DEFAULTS);
4133
+ let fm = branchFontMetrics(fontPx);
4134
+ let p = nodeLabelPlacement(d);
4135
+ let at = layoutPointXY(d);
4136
+ if (at[0] === undefined || at[1] === undefined) {
4137
+ return null; // not laid out (an unrooted node the layout never reached)
4138
+ }
4139
+ if (!radialDisplay() || _radialLabelsHorizontal) {
4140
+ // upright text, whatever the layout: one axis-aligned rectangle
4141
+ let base = at;
4142
+ if (_state.circularDisplay && _radialLabelsHorizontal && !d.children && _radial) {
4143
+ // the label rides out to its point on the ring, in screen terms
4144
+ base = polarXY(radialAngle(d.x), _radial.maxRad);
4145
+ }
4146
+ let x0 = base[0] + p.x - (p.anchor === 'end' ? w : 0);
4147
+ return {x: x0, y: (base[1] + p.dy) - fm.ascent, w: w, h: fm.height, angle: 0};
4148
+ }
4149
+ // Radial: the text starts a gap beyond the node (or beyond the ring,
4150
+ // for a circular tip) and runs OUTWARD along the spoke -- on both
4151
+ // halves of the fan, because the 180-degree flip that keeps the far
4152
+ // half upright also reverses which end the anchor is.
4153
+ //
4154
+ // Built as an offset FROM THE NODE, not as a distance from the origin.
4155
+ // A circular node lies on the ray its label runs along, so radius
4156
+ // arithmetic happens to work there; an unrooted one does not -- its
4157
+ // spoke angle is the direction its own branch came in at, which has
4158
+ // nothing to do with its bearing from the centre. Measured with the
4159
+ // radius version: unrooted still had 7 of 12 numbers printing through
4160
+ // a name on flu_h5, because the boxes were being reserved in places
4161
+ // no label was.
4162
+ let theta = spokeAngle(d);
4163
+ let along = _state.nodeLabelGap;
4164
+ if (_state.circularDisplay && !d.children && _radial) {
4165
+ along += _radial.maxRad - radialRadius(d.y); // the pull out to the ring
4166
+ }
4167
+ let flip = labelFlip(d);
4168
+ // the baseline offset is perpendicular to the text, and the flip
4169
+ // carries it across with everything else
4170
+ let off = flip ? -p.dy : p.dy;
4171
+ let top = flip ? (off - fm.descent) : (off - fm.ascent);
4172
+ let cos = Math.cos(theta), sin = Math.sin(theta);
4173
+ return {x: at[0] + (cos * along) - (sin * top),
4174
+ y: at[1] + (sin * along) + (cos * top),
4175
+ w: w, h: fm.height, angle: theta};
4176
+ }
4177
+
4178
+ // The label's own outline, as four corners in the tree group's
4179
+ // coordinates: what the turned-box occupancy tests against.
4180
+ function nodeLabelQuad(g) {
4181
+ if (!g || !(g.w > 0) || !(g.h > 0)) {
4182
+ return null;
4183
+ }
4184
+ let cos = Math.cos(g.angle), sin = Math.sin(g.angle);
4185
+ let corners = [[0, 0], [g.w, 0], [g.w, g.h], [0, g.h]];
4186
+ let quad = [];
4187
+ for (let i = 0; i < corners.length; ++i) {
4188
+ quad.push([g.x + (cos * corners[i][0]) - (sin * corners[i][1]),
4189
+ g.y + (sin * corners[i][0]) + (cos * corners[i][1])]);
4190
+ }
4191
+ return quad;
4192
+ }
4193
+
4194
+ // ---- crowded TIP LABELS, in both radial layouts ----
4195
+ //
4196
+ // The desktop's rule: a tip label is drawn only if its own outline overlaps
4197
+ // no label already drawn this pass. Both fans use it; the rectangular
4198
+ // layout keeps the every-k-th thinning, whose row pitch is a real quantity
4199
+ // there.
4200
+ //
4201
+ // Unrooted had nothing at all before this -- its labels sit wherever the
4202
+ // equal-angle layout put the tip. Circular had the rectangular rule, which
4203
+ // reads the display's HEIGHT against the node count and says nothing about
4204
+ // a ring: it kept 48 of 97 names on Caliciviridae where all 97 fit with no
4205
+ // overlap, and 59 of 354 on flu_h5 where 118 are readable.
4206
+ //
4207
+ // A SEARCH HIT is placed without being asked, so it can be drawn across a
4208
+ // name already down -- intended (the hit is what is being looked for), and
4209
+ // measured: a 3-hit search on confidences unrooted puts 3 hits across
4210
+ // non-hits, 8.1 px deep. It is the one case where "drawn only where it
4211
+ // overlaps nothing" does not hold, and the README says so.
4212
+ // Measured at 1100x850 before this, overlapping pairs among the labels
4213
+ // drawn: 172 of 97 labels on Caliciviridae_100, 71 of 42 on confidences,
4214
+ // 10 of 31 on apaf -- clumps of a dozen names printed through each other.
4215
+ //
4216
+ // OUTLINES, not their axis-aligned bounds: a name lying near its spoke has
4217
+ // bounds several times its own area, and on a 48-label ring those bounds
4218
+ // called 28 pairs crowded where a reader sees none.
4219
+ //
4220
+ // Order is the tree's own, root first (forester.preorderOf), which is also
4221
+ // what the desktop's unrooted painter does -- it reaches an internal node
4222
+ // before its subtree -- so both programs keep the same label when two
4223
+ // contend. A search HIT is placed without being asked, so the name being
4224
+ // looked for is always on the screen and everything after it keeps clear
4225
+ // of it (Christian, 2026-09-26: hits are exempt for tip labels, and for
4226
+ // nothing else).
4227
+ // Is this node's name on the screen? Asked by everything drawn to a label
4228
+ // -- the aligned guides, the circular ring connectors -- and it must be
4229
+ // ONE question. It used to be spelled `_state.dynahide && d.hide`, which
4230
+ // is the every-k-th rule and nothing else: when the radial layouts started
4231
+ // hiding by overlap they left `hide` false for everyone, so on flu_h5 in
4232
+ // circular 354 dashed connectors ran out to the ring for 118 names, 236 of
4233
+ // them ending at nothing. `_labelDropped` is set by BOTH rules.
4234
+ function nameDrawn(d) {
4235
+ return !d._labelDropped;
4236
+ }
4237
+
4238
+ function hideCrowdedTipLabels(nodes) {
4239
+ _radialNamesHidden = 0;
4240
+ if (!_state.dynahide || !radialDisplay()) {
4241
+ return;
4242
+ }
4243
+ // no layout, no honest outlines; bailing draws everything
4244
+ if (_state.unrootedDisplay ? !_unroot : !_radial) {
4245
+ return;
4246
+ }
4247
+ let labels = forester.orientedOccupancy(Math.max(4, _state.externalNodeFontSize));
4248
+ let ordered = forester.preorderOf(nodes);
4249
+ for (let i = 0, len = ordered.length; i !== len; ++i) {
4250
+ let d = ordered[i];
4251
+ if (d.children || d._extLabelText === '') {
4252
+ continue;
4253
+ }
4254
+ let quad = nodeLabelQuad(nodeLabelGeometry(d));
4255
+ if (!quad) {
4256
+ continue;
4257
+ }
4258
+ if (getFoundColor(d)) {
4259
+ labels.occupy(quad);
4260
+ } else if (!labels.claim(quad)) {
4261
+ d._extLabelText = '';
4262
+ d._labelDropped = true;
4263
+ ++_radialNamesHidden;
4264
+ }
4265
+ }
4266
+ }
4267
+
4268
+ // The boxes to reserve for a label. An upright label is one rectangle. A
4269
+ // rotated one is reserved as a RUN of short boxes along its baseline,
4270
+ // never as one axis-aligned box around the whole thing: the bounds of a
4271
+ // rotated rectangle grow with its LENGTH, so a 120 px name lying at 30
4272
+ // degrees would reserve several times its own area. Stepping by the line
4273
+ // height bounds the error at one step whatever the name is.
4274
+ //
4275
+ // Honestly measured: the run and the single box draw the SAME numbers on
4276
+ // every tree tried (apaf, Caliciviridae, confidences) bar one -- flu_h5
4277
+ // unrooted, 4 against 3 -- and a purpose-built sparse fixture with 28-
4278
+ // character names could not separate them at all
4279
+ // (test_trees/label_reservation.html). The views that would show the
4280
+ // difference are already saturated, and the ones with room refuse nothing
4281
+ // either way. It is kept for the bound, not for the count: the error of
4282
+ // the single box grows without limit as names get longer, and this is
4283
+ // ten lines.
4284
+ function nodeLabelClaimBoxes(g) {
4285
+ if (!g || !(g.w > 0) || !(g.h > 0)) {
4286
+ return [];
4287
+ }
4288
+ if (g.angle === 0) {
4289
+ return [[g.x, g.y, g.w, g.h]];
4290
+ }
4291
+ let cos = Math.cos(g.angle), sin = Math.sin(g.angle);
4292
+ let step = Math.max(4, g.h);
4293
+ let boxes = [];
4294
+ for (let t = 0; t < g.w; t += step) {
4295
+ let len = Math.min(step, g.w - t);
4296
+ // the four corners of this slice, in tree coordinates
4297
+ let xs = [], ys = [];
4298
+ for (const [a, b] of [[t, 0], [t + len, 0], [t + len, g.h], [t, g.h]]) {
4299
+ xs.push(g.x + (cos * a) - (sin * b));
4300
+ ys.push(g.y + (sin * a) + (cos * b));
4301
+ }
4302
+ let x0 = Math.min(...xs), y0 = Math.min(...ys);
4303
+ boxes.push([x0, y0, Math.max(...xs) - x0, Math.max(...ys) - y0]);
4304
+ }
4305
+ return boxes;
4306
+ }
4307
+
4021
4308
  function prepareNodeDrawing(nodes) {
4022
4309
  let wantBl = _state.showBranchLengthValues === true;
4023
4310
  let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
@@ -4034,7 +4321,12 @@ function (root, d3, forester, phyloXml) {
4034
4321
  }
4035
4322
  // Empty is the common case on a big tree: Auto-hide Labels blanks
4036
4323
  // them exactly when there are most nodes to blank.
4037
- d._extLabelText = (!_state.dynahide || !d.hide) ? (makeNodeLabel(d) || '') : '';
4324
+ // WHY the text is empty matters downstream: a label the crowding
4325
+ // rule took away is a different thing from a label the user
4326
+ // switched off, and marks that accompany a name must tell them
4327
+ // apart (drawDomainArchitectures).
4328
+ d._labelDropped = _state.dynahide === true && d.hide === true;
4329
+ d._extLabelText = d._labelDropped ? '' : (makeNodeLabel(d) || '');
4038
4330
  d._blText = wantBl ? asText(makeBranchLengthLabel(d)) : '';
4039
4331
  d._confText = wantConf ? asText(makeConfidenceValuesLabel(d)) : '';
4040
4332
  d._eventText = wantEvent ? asText(makeBranchEventsLabel(d)) : '';
@@ -4044,7 +4336,13 @@ function (root, d3, forester, phyloXml) {
4044
4336
  // childless <g class="node"> behind, and those are transformed on
4045
4337
  // every animation frame. On the big tree that was ~18,000 empty groups
4046
4338
  // per redraw, defeating the very thing the `drawn` filter is for.
4339
+ // names first: the ones that survive then reserve their space inside
4340
+ // hideCrowdedBranchData, and the marks are placed against what is left
4341
+ hideCrowdedTipLabels(nodes);
4047
4342
  hideCrowdedBranchData(nodes);
4343
+ // told after the rules have run, not before: what the unrooted rule
4344
+ // hid is only known once it has looked at the labels
4345
+ syncDynahideIndicator();
4048
4346
  for (let i = 0, len = nodes.length; i !== len; ++i) {
4049
4347
  let d = nodes[i];
4050
4348
  if (d._extLabelText !== '' || d._blText !== '' || d._confText !== ''
@@ -4070,8 +4368,9 @@ function (root, d3, forester, phyloXml) {
4070
4368
  // built in the tree group's own coordinates, which is where every mark is
4071
4369
  // actually drawn, so the circular and unrooted views are measured in their
4072
4370
  // own space and not in the cluster's. (The tip-label decimation that shares
4073
- // the toggle is still skipped in unrooted: that one needs even row spacing
4074
- // to decimate against, and there is none.)
4371
+ // the toggle runs in the rectangular layout alone; both fans have no rows
4372
+ // to decimate against, and hideCrowdedTipLabels thins their names by
4373
+ // overlap instead, running before this does.)
4075
4374
  function hideCrowdedBranchData(nodes) {
4076
4375
  if (!_state.dynahide) {
4077
4376
  return;
@@ -4082,6 +4381,24 @@ function (root, d3, forester, phyloXml) {
4082
4381
  if ((_state.circularDisplay && !_radial) || (_state.unrootedDisplay && !_unroot)) {
4083
4382
  return;
4084
4383
  }
4384
+ _branchMarksHidden = 0;
4385
+ // Each granted mark's claimed box is kept on the node, as a label's is
4386
+ // (_labelBoxes). Names have had an ink-coverage check since the
4387
+ // reservation went in -- every point of a drawn name must lie inside
4388
+ // the space it claimed -- and branch MARKS never did, which is the
4389
+ // half that failed on a Linux CI runner while the label half passed.
4390
+ for (let i = 0; i < nodes.length; ++i) {
4391
+ nodes[i]._markBoxes = null;
4392
+ }
4393
+ let stash = function (d, kind, box) {
4394
+ if (!box) {
4395
+ return;
4396
+ }
4397
+ if (!d._markBoxes) {
4398
+ d._markBoxes = {};
4399
+ }
4400
+ d._markBoxes[kind] = box;
4401
+ };
4085
4402
  let fontPx = _state.branchDataFontSize;
4086
4403
  let dotCell = 0;
4087
4404
  for (let i = 0; i < nodes.length; ++i) {
@@ -4093,6 +4410,33 @@ function (root, d3, forester, phyloXml) {
4093
4410
  let numbers = forester.labelOccupancy(cell);
4094
4411
  let symbols = forester.labelOccupancy(cell);
4095
4412
  let ordered = forester.preorderOf(nodes);
4413
+
4414
+ // THE NAMES GO DOWN FIRST, and they are not asked: a node label is
4415
+ // drawn whatever else is there, so it reserves its space with occupy
4416
+ // rather than claim. Everything below is then refused against it, and
4417
+ // names outrank numbers -- the desktop's order, and the one a reader
4418
+ // expects, since a name is what the tree is for and a value that has
4419
+ // printed through one is worse than a value not drawn at all.
4420
+ //
4421
+ // Measured before this existed, at 1100x850 with both numbers on:
4422
+ // 44 of 93 numbers printed through a name on flu_h5 in the
4423
+ // rectangular layout, 30 of 55 on confidences, 10 of 15 unrooted.
4424
+ // The 3.15.0 pass kept numbers off each OTHER and off nothing else.
4425
+ for (let i = 0, len = ordered.length; i !== len; ++i) {
4426
+ let boxes = nodeLabelClaimBoxes(nodeLabelGeometry(ordered[i]));
4427
+ // kept on the node, beside the other per-render drawing state, so
4428
+ // the reservation can be compared with the ink it is supposed to
4429
+ // cover: a box that is merely SMALL -- short by a descent, or a
4430
+ // few per cent narrow -- still hides nothing wrongly and still
4431
+ // shows no overlap anywhere a number does not happen to sit. The
4432
+ // desktop pins the same property pixel by pixel.
4433
+ ordered[i]._labelBoxes = boxes.length ? boxes : null;
4434
+ for (let b = 0; b < boxes.length; ++b) {
4435
+ numbers.occupy(boxes[b][0], boxes[b][1], boxes[b][2], boxes[b][3]);
4436
+ symbols.occupy(boxes[b][0], boxes[b][1], boxes[b][2], boxes[b][3]);
4437
+ }
4438
+ }
4439
+
4096
4440
  for (let i = 0, len = ordered.length; i !== len; ++i) {
4097
4441
  let d = ordered[i];
4098
4442
  // Branch length first, then confidence: they sit on opposite sides
@@ -4102,12 +4446,18 @@ function (root, d3, forester, phyloXml) {
4102
4446
  let b = branchMarkBox(d, 'bl', d._blText, fontPx);
4103
4447
  if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4104
4448
  d._blText = '';
4449
+ ++_branchMarksHidden;
4450
+ } else {
4451
+ stash(d, 'bl', b);
4105
4452
  }
4106
4453
  }
4107
4454
  if (d._confText !== '') {
4108
4455
  let b = branchMarkBox(d, 'conf', d._confText, fontPx);
4109
4456
  if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4110
4457
  d._confText = '';
4458
+ ++_branchMarksHidden;
4459
+ } else {
4460
+ stash(d, 'conf', b);
4111
4461
  }
4112
4462
  }
4113
4463
  // Branch EVENTS are branch-anchored too, half a branch from the
@@ -4119,6 +4469,9 @@ function (root, d3, forester, phyloXml) {
4119
4469
  let b = branchMarkBox(d, 'event', d._eventText, fontPx);
4120
4470
  if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4121
4471
  d._eventText = '';
4472
+ ++_branchMarksHidden;
4473
+ } else {
4474
+ stash(d, 'event', b);
4122
4475
  }
4123
4476
  }
4124
4477
  if (d._suppDot) {
@@ -4129,9 +4482,10 @@ function (root, d3, forester, phyloXml) {
4129
4482
  // do not rotate, so the radial case needs only the midpoint
4130
4483
  let at = radialDisplay()
4131
4484
  ? radialBranchMid(d)
4132
- : [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
4133
4486
  if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
4134
4487
  d._suppDot = false;
4488
+ ++_branchMarksHidden;
4135
4489
  }
4136
4490
  }
4137
4491
  }
@@ -4440,24 +4794,39 @@ function (root, d3, forester, phyloXml) {
4440
4794
  }
4441
4795
  };
4442
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
+
4443
4811
  // How long node d's branch is DRAWN, in layout units -- not its
4444
4812
  // branch_length: the gate for decorations that need room on the branch.
4445
4813
  function drawnBranchSpan(d) {
4446
- if (!d.parent) {
4814
+ let p = drawnParent(d);
4815
+ if (!p) {
4447
4816
  return 0;
4448
4817
  }
4449
4818
  if (_state.unrootedDisplay) {
4450
- if (d.ux === undefined || d.parent.ux === undefined) {
4819
+ if (d.ux === undefined || p.ux === undefined) {
4451
4820
  return 0;
4452
4821
  }
4453
- let dx = d.ux - d.parent.ux;
4454
- let dy = d.uy - d.parent.uy;
4822
+ let dx = d.ux - p.ux;
4823
+ let dy = d.uy - p.uy;
4455
4824
  return Math.sqrt((dx * dx) + (dy * dy));
4456
4825
  }
4457
4826
  if (_state.circularDisplay) {
4458
- return Math.abs(radialRadius(d.parent.y) - radialRadius(d.y));
4827
+ return Math.abs(radialRadius(p.y) - radialRadius(d.y));
4459
4828
  }
4460
- return Math.abs(d.parent.y - d.y);
4829
+ return Math.abs(p.y - d.y);
4461
4830
  }
4462
4831
 
4463
4832
  // The Support Dots threshold on the tree's own scale: the setting is a
@@ -4491,7 +4860,7 @@ function (root, d3, forester, phyloXml) {
4491
4860
  }
4492
4861
 
4493
4862
  function showSupportDot(d) {
4494
- if (!_state.showSupportDots || !d.parent
4863
+ if (!_state.showSupportDots || !drawnParent(d)
4495
4864
  || !d.confidences || d.confidences.length === 0) {
4496
4865
  return false;
4497
4866
  }
@@ -4565,17 +4934,18 @@ function (root, d3, forester, phyloXml) {
4565
4934
  // Transform for a branch-data label (confidence / branch length / events):
4566
4935
  // rotate to the node's angle and sit at the midpoint of the branch (radially).
4567
4936
  function branchLabelTransform(d) {
4568
- if (!d.parent || (_state.unrootedDisplay && d.parent.ux === undefined)) {
4937
+ let p = drawnParent(d);
4938
+ if (!p || (_state.unrootedDisplay && p.ux === undefined)) {
4569
4939
  // no laid-out parent (the displayed subtree's root): angle only
4570
4940
  return 'rotate(' + labelAngleDeg(d) + ')';
4571
4941
  }
4572
4942
  let mid;
4573
4943
  if (_state.unrootedDisplay) {
4574
- let dx = d.ux - d.parent.ux;
4575
- let dy = d.uy - d.parent.uy;
4944
+ let dx = d.ux - p.ux;
4945
+ let dy = d.uy - p.uy;
4576
4946
  mid = -Math.sqrt((dx * dx) + (dy * dy)) / 2;
4577
4947
  } else {
4578
- mid = (radialRadius(d.parent.y) - radialRadius(d.y)) / 2;
4948
+ mid = (radialRadius(p.y) - radialRadius(d.y)) / 2;
4579
4949
  }
4580
4950
  return 'rotate(' + labelAngleDeg(d) + ') translate(' + mid + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
4581
4951
  }
@@ -5089,11 +5459,11 @@ function (root, d3, forester, phyloXml) {
5089
5459
  internalNodeFontSize: 'font size is fixed at launch (11px) and changed only via the in-panel Font slider -- not a launch config key',
5090
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',
5091
5461
  // download filenames follow the tree's name
5092
- nameForNhDownload: 'download names follow "treeName"',
5093
- nameForPhyloXmlDownload: 'download names follow "treeName"',
5094
- nameForPngDownload: 'download names follow "treeName"',
5095
- nameForSvgDownload: 'download names follow "treeName"',
5096
- 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',
5097
5467
  // which taxonomy / sequence field to label with is read off the tree
5098
5468
  showTaxonomyCode: 'taxonomy labelling follows what the tree contains',
5099
5469
  showTaxonomyScientificName: 'taxonomy labelling follows what the tree contains',
@@ -5165,8 +5535,12 @@ function (root, d3, forester, phyloXml) {
5165
5535
  showTaxonomyButton: 'shown automatically when the tree has taxonomies',
5166
5536
  showSequenceButton: 'shown automatically when the tree has sequences',
5167
5537
  showBranchColorsButton: 'the Visual Styles checkbox appears when the tree has branch colours or style properties',
5168
- showDynahideButton: 'shown automatically once the tree has enough tips to need it',
5169
- showShortenNodeNamesButton: 'shown automatically when the tree has long node names',
5538
+ // These two are ALWAYS offered, deliberately: they do nothing on a tree
5539
+ // that needs neither, and a control that comes and goes between trees
5540
+ // is worse than one that is simply there. (Both messages used to
5541
+ // promise a conditional appearance the code had already rejected.)
5542
+ showDynahideButton: 'the Auto-hide Labels checkbox is always shown',
5543
+ showShortenNodeNamesButton: 'the Short Names checkbox is always shown; it starts on when the tree has long node names',
5170
5544
  showExternalLabelsButton: 'always shown',
5171
5545
  showInternalLabelsButton: 'shown automatically when the tree has internal node data',
5172
5546
  // the collapse-by-depth / rank / feature feature is gone
@@ -5693,20 +6067,7 @@ function (root, d3, forester, phyloXml) {
5693
6067
  // The tree names itself; a caller-supplied name only ever disagreed with
5694
6068
  // the file. It is the stem of every download filename.
5695
6069
  _state.treeName = _treeData.name ? _treeData.name.trim().replace(/\W+/g, '_') : null;
5696
- _state.nameForNhDownload = _state.treeName
5697
- ? (_state.treeName + NH_SUFFIX) : NAME_FOR_NH_DOWNLOAD_DEFAULT;
5698
- _state.nameForPhyloXmlDownload = _state.treeName
5699
- ? (_state.treeName + XML_SUFFIX) : NAME_FOR_PHYLOXML_DOWNLOAD_DEFAULT;
5700
- _state.nameForPngDownload = _state.treeName
5701
- ? (_state.treeName + PNG_SUFFIX) : NAME_FOR_PNG_DOWNLOAD_DEFAULT;
5702
- _state.nameForPdfDownload = _state.treeName
5703
- ? (_state.treeName + PDF_SUFFIX) : NAME_FOR_PDF_DOWNLOAD_DEFAULT;
5704
- _state.nameForSvgDownload = _state.treeName
5705
- ? (_state.treeName + SVG_SUFFIX) : NAME_FOR_SVG_DOWNLOAD_DEFAULT;
5706
- _state.nameForFastaDownload = _state.treeName
5707
- ? (_state.treeName + FASTA_SUFFIX) : NAME_FOR_FASTA_DOWNLOAD_DEFAULT;
5708
- _state.nameForNexusDownload = _state.treeName
5709
- ? (_state.treeName + NEXUS_SUFFIX) : NAME_FOR_NEXUS_DOWNLOAD_DEFAULT;
6070
+ _state.downloadStem = _state.treeName || DOWNLOAD_STEM_DEFAULT;
5710
6071
 
5711
6072
  // === undefined, not a truthy check: 0 is a legitimate explicit
5712
6073
  // position (the legend's top-left corner) and must survive, not be
@@ -6573,10 +6934,7 @@ function (root, d3, forester, phyloXml) {
6573
6934
  } else {
6574
6935
  filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.tsv';
6575
6936
  }
6576
- const tsv = forester.externalNodeDataTsv(ext_nodes, function (n, i) {
6577
- return (n.viewId !== undefined) ? n.viewId : i + 1;
6578
- });
6579
- saveAs(new Blob([tsv], {type: 'text/tab-separated-values'}), filename);
6937
+ saveAs(new Blob([tipTableTsv(ext_nodes)], {type: 'text/tab-separated-values'}), filename);
6580
6938
  update();
6581
6939
  }
6582
6940
 
@@ -9913,7 +10271,9 @@ function (root, d3, forester, phyloXml) {
9913
10271
  }
9914
10272
  if (_state.phylogram && !_state.alignPhylogram) {
9915
10273
  if (_state.unrootedDisplay) {
9916
- toCladegram();
10274
+ toCladegram(); // unrooted cannot align
10275
+ } else if (_state.circularDisplay) {
10276
+ toCladegram(); // circular is already showing the aligned type
9917
10277
  } else {
9918
10278
  toAlignedPhylogram();
9919
10279
  }
@@ -10017,6 +10377,274 @@ function (root, d3, forester, phyloXml) {
10017
10377
  }
10018
10378
 
10019
10379
  // The cheat sheet: every combo above, then the plain keys.
10380
+ // ===================== The control-panel cheat sheet =====================
10381
+ //
10382
+ // Every control the panel is SHOWING, in the order it shows them, each
10383
+ // beside the sentence that already explains it. The desktop's
10384
+ // ControlPanelCheatSheet, and built the same way on one principle: nothing
10385
+ // is written twice. A row's picture is the control's own glyph, cloned;
10386
+ // a row's words are the tooltip the control already carries. So a new
10387
+ // control needs no edit here -- it needs a tooltip, and
10388
+ // test_trees/panel_descriptions.html fails by name for any visible control
10389
+ // without one.
10390
+ //
10391
+ // Two rules of Christian's, 2026-09-25, which the desktop follows too:
10392
+ // name and description are separated by a COLON, never a dash; and no live
10393
+ // VALUES appear -- a label reading "Font size: 6" is cut at the colon, so
10394
+ // the sheet says what the control is and never what it is currently set
10395
+ // to. "A printed 40% is wrong for every reader but the one who printed
10396
+ // it."
10397
+ //
10398
+ // Modeless, and placed beside the panel rather than over it: a sheet you
10399
+ // must close to touch the control it describes is half a sheet.
10400
+ const CHEAT_SHEET_DIALOG = 'aptx_cheatsheet';
10401
+ let _cheatSheetBody = null;
10402
+
10403
+ // this viewer's own panel, not another viewer's on the same page
10404
+ function cheatSheetPanel() {
10405
+ return (_container && _container.querySelector(':scope > .aptx-panel'))
10406
+ || document.querySelector('.aptx-panel');
10407
+ }
10408
+
10409
+ // The words for one control: its own title, or the title of the <label>
10410
+ // that names it -- which is a SIBLING, not an ancestor, for the select
10411
+ // menus (makeSelectMenu puts it there).
10412
+ function controlDescription(el) {
10413
+ let n = el;
10414
+ for (let i = 0; i < 4 && n; ++i) {
10415
+ let t = (n.getAttribute && (n.getAttribute('title') || n.getAttribute('aria-label'))) || '';
10416
+ if (t.trim() !== '') {
10417
+ return t.trim();
10418
+ }
10419
+ n = n.parentElement;
10420
+ }
10421
+ if (el.id) {
10422
+ let lab = document.querySelector('label[for="' + el.id + '"]');
10423
+ let t = lab ? (lab.getAttribute('title') || lab.getAttribute('aria-label') || '') : '';
10424
+ if (t.trim() !== '') {
10425
+ return t.trim();
10426
+ }
10427
+ }
10428
+ return '';
10429
+ }
10430
+
10431
+ // What the control is called, with any live value cut away. A checkbox is
10432
+ // named by its own text, a menu or slider by the label beside it, a glyph
10433
+ // button by nothing -- its picture is its name, and the row shows that.
10434
+ function controlName(el) {
10435
+ // ONE cut, at the end, over whatever source the name came from.
10436
+ //
10437
+ // It used to be applied only to the two sources that could carry a
10438
+ // value -- a <label for> and an aria-label -- and not to a checkbox's
10439
+ // own text or a button's value. That left "no sheet name contains a
10440
+ // colon" meaning two different things on the two programs: on the
10441
+ // desktop every name IS a cut label, so the rule asserts the cut ran
10442
+ // and can never fire on correct output; here it would ALSO have
10443
+ // constrained what a control may be called, and a checkbox legitimately
10444
+ // labelled "Ratio 1:1" would have failed it. Cutting everywhere makes
10445
+ // the invariant mean the same thing on both sides.
10446
+ //
10447
+ // What the cut is doing here, exactly: our labels read "Color by:" and
10448
+ // "Shape:", so it removes a TRAILING colon, and removing the cut fails
10449
+ // the sheet's value rule by name ("Color by:"). What it is NOT doing
10450
+ // here is removing a value -- nothing follows a colon on this panel,
10451
+ // unlike the desktop's "Font size: 6". So it is exercised but for the
10452
+ // lesser of its two reasons. (Said precisely because the looser
10453
+ // version of this note -- "a no-op, removing it passes every check" --
10454
+ // was true for about an hour and then was not, once the desktop's
10455
+ // sharper value rule was adopted here.)
10456
+ return cutAtValue(rawControlName(el));
10457
+ }
10458
+
10459
+ // "Font size: 6" -> "Font size". The colon is where a value starts, and a
10460
+ // value on a sheet is wrong for everyone but whoever was looking when it
10461
+ // was made.
10462
+ function cutAtValue(name) {
10463
+ return name ? name.split(':')[0].trim() : '';
10464
+ }
10465
+
10466
+ // What the control is called, before the cut. A checkbox is named by its
10467
+ // own text, a menu or slider by the label beside it, a glyph button by
10468
+ // nothing -- its picture is its name, and the row shows that.
10469
+ function rawControlName(el) {
10470
+ if (el.type === 'checkbox' || el.type === 'radio') {
10471
+ let span = el.parentElement ? el.parentElement.querySelector('span') : null;
10472
+ if (span && span.textContent.trim() !== '') {
10473
+ return span.textContent.trim();
10474
+ }
10475
+ }
10476
+ if (el.tagName === 'INPUT' && el.value && (el.type === 'button' || el.type === 'submit')) {
10477
+ return el.value.trim();
10478
+ }
10479
+ if (el.id) {
10480
+ let lab = document.querySelector('label[for="' + el.id + '"]');
10481
+ if (lab && lab.textContent.trim() !== '') {
10482
+ return lab.textContent.trim();
10483
+ }
10484
+ }
10485
+ // a menu with no visible label of its own says what it is here
10486
+ let aria = el.getAttribute('aria-label');
10487
+ if (aria && aria.trim() !== '') {
10488
+ return aria.trim();
10489
+ }
10490
+ // and a text button -- the hide toggle, the reset chips -- is its text
10491
+ let own = (el.textContent || '').trim();
10492
+ if (own !== '' && el.tagName === 'BUTTON' && !el.querySelector('svg')) {
10493
+ return own;
10494
+ }
10495
+ // a segmented button drawn as TEXT rather than a glyph (Time / Div) is
10496
+ // named by its own label, which is the only thing on it
10497
+ let seg = el.closest ? el.closest('label.aptx-seg') : null;
10498
+ if (seg && !seg.querySelector('svg')) {
10499
+ let segText = (seg.textContent || '').trim();
10500
+ if (segText !== '') {
10501
+ return segText;
10502
+ }
10503
+ }
10504
+ return '';
10505
+ }
10506
+
10507
+ // What the user actually sees and clicks. A segmented button's <input> is
10508
+ // 0x0 and transparent -- the visible thing is the <label> around it, which
10509
+ // carries the glyph and the tooltip -- so asking the input whether it is
10510
+ // on the screen answered no for all eight of them: the three layouts, the
10511
+ // three display types and the time/divergence pair were absent from the
10512
+ // sheet entirely (Christian, 2026-09-28).
10513
+ function controlSurface(el) {
10514
+ let box = el.getBoundingClientRect();
10515
+ if (box.width > 0 || box.height > 0) {
10516
+ return el;
10517
+ }
10518
+ let label = el.closest ? el.closest('label') : null;
10519
+ if (label) {
10520
+ let lb = label.getBoundingClientRect();
10521
+ if (lb.width > 0 || lb.height > 0) {
10522
+ return label;
10523
+ }
10524
+ }
10525
+ return null;
10526
+ }
10527
+
10528
+ function cheatSheetRows() {
10529
+ let panel = cheatSheetPanel();
10530
+ if (!panel) {
10531
+ return [];
10532
+ }
10533
+ let rows = [];
10534
+ let seen = new Set();
10535
+ let all = panel.querySelectorAll('input, button, select');
10536
+ for (let i = 0; i < all.length; ++i) {
10537
+ let el = all[i];
10538
+ // what the panel is SHOWING: a control the tree gives no data for
10539
+ // is not on the screen and does not belong on the sheet. A
10540
+ // DISABLED one is on the screen, and saying what it is is how a
10541
+ // reader learns why it is greyed.
10542
+ let surface = el.disabled ? (controlSurface(el) || el) : controlSurface(el);
10543
+ if (!surface) {
10544
+ continue;
10545
+ }
10546
+ let words = controlDescription(el);
10547
+ if (words === '' || seen.has(el)) {
10548
+ continue;
10549
+ }
10550
+ seen.add(el);
10551
+ // the glyph belongs to the surface, not to a 0x0 input inside it
10552
+ let svg = surface.querySelector ? surface.querySelector('svg.aptx-glyph') : null;
10553
+ rows.push({name: controlName(el), words: words, glyph: svg});
10554
+ }
10555
+ return rows;
10556
+ }
10557
+
10558
+ function renderCheatSheet(body) {
10559
+ // everything but the standing lead paragraph
10560
+ let keep = body.querySelector('.aptx-cheat-lead');
10561
+ while (body.firstChild) {
10562
+ body.removeChild(body.firstChild);
10563
+ }
10564
+ if (keep) {
10565
+ body.appendChild(keep);
10566
+ }
10567
+ let rows = cheatSheetRows();
10568
+ rows.forEach(function (row) {
10569
+ let line = document.createElement('div');
10570
+ line.className = 'aptx-dialog-line aptx-cheat-line';
10571
+ let key = document.createElement('span');
10572
+ key.className = 'aptx-dialog-key aptx-cheat-key';
10573
+ if (row.glyph) {
10574
+ let g = row.glyph.cloneNode(true);
10575
+ g.removeAttribute('class');
10576
+ g.setAttribute('class', 'aptx-glyph aptx-cheat-glyph');
10577
+ key.appendChild(g);
10578
+ }
10579
+ if (row.name) {
10580
+ let n = document.createElement('span');
10581
+ n.className = 'aptx-cheat-name';
10582
+ n.textContent = row.name;
10583
+ key.appendChild(n);
10584
+ }
10585
+ let val = document.createElement('span');
10586
+ val.className = 'aptx-dialog-val';
10587
+ val.textContent = row.words;
10588
+ line.appendChild(key);
10589
+ line.appendChild(val);
10590
+ body.appendChild(line);
10591
+ });
10592
+ if (rows.length === 0) {
10593
+ let empty = document.createElement('p');
10594
+ empty.className = 'aptx-shortcuts-note';
10595
+ empty.textContent = 'No controls are showing.';
10596
+ body.appendChild(empty);
10597
+ }
10598
+ }
10599
+
10600
+ // The panel is rebuilt on every render, so an open sheet is re-read from
10601
+ // it rather than left describing a panel that has changed underneath.
10602
+ function refreshCheatSheet() {
10603
+ if (_cheatSheetBody && _cheatSheetBody.isConnected) {
10604
+ renderCheatSheet(_cheatSheetBody);
10605
+ }
10606
+ }
10607
+
10608
+ function showCheatSheetDialog() {
10609
+ let shell = makeDialogShell(CHEAT_SHEET_DIALOG, 'Control panel', 460);
10610
+ shell.body.classList.add('aptx-shortcuts');
10611
+ shell.body.classList.add('aptx-cheat');
10612
+ // says what the reader is looking at, as the desktop's sheet does
10613
+ let lead = document.createElement('p');
10614
+ lead.className = 'aptx-cheat-lead';
10615
+ lead.textContent = 'Every control the panel is showing, with the icon it draws and what it does.';
10616
+ shell.body.appendChild(lead);
10617
+ _cheatSheetBody = shell.body;
10618
+ renderCheatSheet(shell.body);
10619
+ shell.dialog.addEventListener('close', function () {
10620
+ if (_cheatSheetBody === shell.body) {
10621
+ _cheatSheetBody = null;
10622
+ }
10623
+ });
10624
+ // modeless, as the colour picker is: the point is to read a row and
10625
+ // click the control it names without closing anything
10626
+ shell.dialog.show();
10627
+ placeBesidePanel(shell.dialog);
10628
+ }
10629
+
10630
+ // Beside the panel, never over it, and inside the window.
10631
+ function placeBesidePanel(dialog) {
10632
+ let panel = cheatSheetPanel();
10633
+ let box = panel ? panel.getBoundingClientRect() : null;
10634
+ let w = dialog.offsetWidth;
10635
+ let h = dialog.offsetHeight;
10636
+ let left = box ? (box.right + 10) : 10;
10637
+ if (left + w > window.innerWidth - 6) {
10638
+ left = box ? (box.left - w - 10) : 6;
10639
+ }
10640
+ setStyles(dialog, {
10641
+ 'position': 'fixed',
10642
+ 'margin': '0',
10643
+ 'left': Math.max(6, Math.min(window.innerWidth - w - 6, left)) + 'px',
10644
+ 'top': Math.max(6, Math.min(window.innerHeight - h - 6, box ? box.top : 10)) + 'px'
10645
+ });
10646
+ }
10647
+
10020
10648
  function showShortcutsDialog() {
10021
10649
  let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
10022
10650
  shell.body.classList.add('aptx-shortcuts');
@@ -10353,7 +10981,27 @@ function (root, d3, forester, phyloXml) {
10353
10981
  return;
10354
10982
  }
10355
10983
  let existing = _svgGroup.select('g.aptx-domains');
10984
+ // An architecture goes WITH ITS NAME, and is left out when the name
10985
+ // is. domainsShown() already ties the whole track to external labels
10986
+ // being on; this is the same rule per tip, and it became necessary the
10987
+ // day unrooted started hiding crowded names -- before that every tip
10988
+ // in every layout kept its label, so a nameless architecture could not
10989
+ // happen. Measured on apaf when it could: 7 of 31 tips drew an
10990
+ // architecture with no name beside it, identifiable only by which
10991
+ // spoke it sat on. (The desktop reached the same rule from the other
10992
+ // end: a tip IMAGE painted before its label's claim was left orphaned
10993
+ // under a neighbour's name.)
10356
10994
  let tips = (domainsShown() && _root) ? displayedTips().filter(function (d) {
10995
+ // Keyed on "the RULE hid this name", not on "there is no name".
10996
+ // The two part company the moment the user switches the name
10997
+ // fields off: nothing is hidden by the rule then, every label is
10998
+ // empty, and every architecture should still be drawn. Keying on
10999
+ // the empty text took all 31 of apaf's architectures away with the
11000
+ // Node Name checkbox -- shipped, and caught by the desktop
11001
+ // warning us they had met the same reading of their own fix.
11002
+ if (d._labelDropped) {
11003
+ return false;
11004
+ }
10357
11005
  return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
10358
11006
  }) : [];
10359
11007
  let f = tips.length > 0 ? domainScale() : 0;
@@ -11519,7 +12167,8 @@ function (root, d3, forester, phyloXml) {
11519
12167
 
11520
12168
  // what the viewer writes onto a tree's nodes, left off a tree cut out of it
11521
12169
  const VIEW_NODE_FIELDS = ['viewId', 'collapsed', 'x', 'y', 'x0', 'y0', 'id', 'hide', 'hasVis', 'distToRoot',
11522
- 'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText'];
12170
+ 'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText',
12171
+ '_labelBoxes', '_labelDropped', '_markBoxes'];
11523
12172
 
11524
12173
  // the whole tree's tip count, counted again only when the tree changes
11525
12174
  // (every change makes new _basicTreeProperties)
@@ -12315,16 +12964,27 @@ function (root, d3, forester, phyloXml) {
12315
12964
  scheduleUpdate(null, 0);
12316
12965
  }
12317
12966
 
12967
+ // In CIRCULAR these two leave the aligned/unaligned flag alone. That
12968
+ // layout aligns whatever the flag says, so the button shown as chosen
12969
+ // there is "aligned" regardless -- and writing the flag from it would
12970
+ // silently change the user's choice for the layouts that CAN express it.
12971
+ // A user who likes the plain phylogram in rectangular, steps into
12972
+ // circular, flips to the cladogram and back, and returns, would have
12973
+ // found the aligned one waiting.
12318
12974
  function toAlignedPhylogram() {
12319
12975
  _state.phylogram = true;
12320
- _state.alignPhylogram = true;
12976
+ if (!_state.circularDisplay) {
12977
+ _state.alignPhylogram = true;
12978
+ }
12321
12979
  setDisplayTypeButtons();
12322
12980
  scheduleUpdate(null, 0);
12323
12981
  }
12324
12982
 
12325
12983
  function toCladegram() {
12326
12984
  _state.phylogram = false;
12327
- _state.alignPhylogram = false;
12985
+ if (!_state.circularDisplay) {
12986
+ _state.alignPhylogram = false;
12987
+ }
12328
12988
  setDisplayTypeButtons();
12329
12989
  scheduleUpdate(null, 0);
12330
12990
  }
@@ -12356,19 +13016,40 @@ function (root, d3, forester, phyloXml) {
12356
13016
  // against; an alignment / time axis is inherently horizontal. Synced
12357
13017
  // BEFORE the zoom-row early return, and (via updateButtonEnabledState)
12358
13018
  // on every render, so subtree switches keep them honest.
13019
+ // WHICH of the two phylogram buttons is dead depends on the layout,
13020
+ // and it is the one the layout CANNOT do -- not the one it always
13021
+ // does. Circular always carries its external labels out to a common
13022
+ // ring on dashed connectors, whatever is chosen, so the circular
13023
+ // phylogram IS the aligned one: the unavailable option there is the
13024
+ // UNALIGNED phylogram. Unrooted is the other way round -- it has no
13025
+ // common edge to align to, so what it cannot do is align.
13026
+ //
13027
+ // (Greying the aligned button in circular was the first attempt, on
13028
+ // the grounds that picking it changed nothing. True, but backwards:
13029
+ // it disabled the button that describes what the layout does and left
13030
+ // live the one it cannot do, and the panel then showed "phylogram"
13031
+ // selected over a picture whose labels are plainly aligned. Christian
13032
+ // spotted the inconsistency, 2026-09-27.)
13033
+ let measured = _basicTreeProperties.branchLengths === true;
12359
13034
  let phyBtn = byId(PHYLOGRAM_BUTTON);
12360
13035
  if (phyBtn) {
12361
- phyBtn.disabled = _basicTreeProperties.branchLengths !== true;
13036
+ phyBtn.disabled = !measured || _state.circularDisplay;
13037
+ phyBtn.title = PHYLOGRAM_WHAT;
12362
13038
  }
12363
13039
  let alignBtn = byId(PHYLOGRAM_ALIGNED_BUTTON);
12364
13040
  if (alignBtn) {
12365
- alignBtn.disabled = _state.unrootedDisplay || _basicTreeProperties.branchLengths !== true;
13041
+ alignBtn.disabled = !measured || _state.unrootedDisplay;
13042
+ alignBtn.title = PHYLOGRAM_ALIGNED_WHAT;
12366
13043
  }
13044
+ // Which button is SHOWN as chosen is layout-dependent too, and this
13045
+ // was only re-synced when the display type changed -- so switching
13046
+ // layout left the previous layout's answer on the screen.
13047
+ setDisplayTypeButtons();
12367
13048
  // Auto-hide Labels stays live in every layout. It used to be greyed out
12368
- // in unrooted, where the tip-label decimation it governs does not run --
12369
- // but it also governs the crowded branch-data rule, which does run
12370
- // there, so greying it out left the user unable to switch off something
12371
- // that was happening.
13049
+ // in unrooted, where the every-k-th tip-label decimation does not run --
13050
+ // but it also governs the crowded branch-data rule, and now the
13051
+ // unrooted tip-label rule, both of which do run there, so greying it
13052
+ // out left the user unable to switch off what they could see.
12372
13053
  let msaCb = byId(MSA_CB);
12373
13054
  if (msaCb) {
12374
13055
  msaCb.disabled = radialDisplay();
@@ -12585,7 +13266,15 @@ function (root, d3, forester, phyloXml) {
12585
13266
  // panel's accent: the checkbox item gets a box and its tooltip says how
12586
13267
  // many labels are shown. Called on every redraw, since the density
12587
13268
  // changes with the vertical zoom, the font size and the view.
12588
- function syncDynahideIndicator(active) {
13269
+ // Lights the toggle while it is actually taking something away, and says
13270
+ // what. The switch governs three rules, and the light has to ask all three
13271
+ // rather than whichever one was written first: the every-k-th thinning
13272
+ // (rectangular, by index against the row pitch), the overlap thinning
13273
+ // (both fans, by whether names would overprint) and the crowded
13274
+ // branch-data rule (every layout). Asking only the first two left it dark
13275
+ // over a circular tree with 201 numbers refused and every name drawn --
13276
+ // and over a RECTANGULAR one with 18 refused, which was never right.
13277
+ function syncDynahideIndicator() {
12589
13278
  let cb = byId(DYNAHIDE_CB);
12590
13279
  let item = cb ? cb.closest('.aptx-check') : null;
12591
13280
  if (!item) {
@@ -12594,10 +13283,23 @@ function (root, d3, forester, phyloXml) {
12594
13283
  if (item.dataset.baseTitle === undefined) {
12595
13284
  item.dataset.baseTitle = item.title;
12596
13285
  }
12597
- item.classList.toggle('aptx-check-active', !!active);
13286
+ let parts = [];
13287
+ if (!radialDisplay() && _dynahide_factor >= 2) {
13288
+ parts.push('1 in ' + _dynahide_factor + ' labels shown');
13289
+ } else if (radialDisplay() && _radialNamesHidden > 0) {
13290
+ parts.push(_radialNamesHidden + ' name' + (_radialNamesHidden === 1 ? '' : 's')
13291
+ + ' that would overprint');
13292
+ }
13293
+ if (_branchMarksHidden > 0) {
13294
+ // said only when it is happening: the clause used to be printed
13295
+ // unconditionally, which made a promise of every render
13296
+ parts.push(_branchMarksHidden + ' branch value' + (_branchMarksHidden === 1 ? '' : 's')
13297
+ + ' that would overlap');
13298
+ }
13299
+ let active = _state.dynahide === true && parts.length > 0;
13300
+ item.classList.toggle('aptx-check-active', active);
12598
13301
  item.title = active
12599
- ? item.dataset.baseTitle + ', hiding now: 1 in ' + _dynahide_factor
12600
- + ' labels shown, and branch data that would overlap'
13302
+ ? item.dataset.baseTitle + ', hiding now: ' + parts.join(', and ')
12601
13303
  : item.dataset.baseTitle;
12602
13304
  }
12603
13305
 
@@ -12617,12 +13319,163 @@ function (root, d3, forester, phyloXml) {
12617
13319
  scheduleUpdate(null, 0);
12618
13320
  }
12619
13321
 
12620
- function downloadButtonPressed() {
12621
- const s = byId(EXPORT_FORMAT_SELECT);
12622
- if (s) {
12623
- let format = s.value;
12624
- 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;
12625
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;
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);
12626
13479
  }
12627
13480
 
12628
13481
  function changeBaseBackgoundColor(color) {
@@ -12888,7 +13741,9 @@ function (root, d3, forester, phyloXml) {
12888
13741
  // of open sections: three open sections measure anywhere from 215px to
12889
13742
  // 562px, so a count would not have bounded the height at all.
12890
13743
  const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
12891
- 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'];
12892
13747
 
12893
13748
  function loadPanelSections() {
12894
13749
  if (_panelSections) {
@@ -13236,6 +14091,14 @@ function (root, d3, forester, phyloXml) {
13236
14091
  + glyphDot(50, 29, 5.5);
13237
14092
  }
13238
14093
 
14094
+ // a reference card: the outline, and the rows on it
14095
+ function glyphCheatSheet() {
14096
+ return '<rect x="18" y="12" width="64" height="76" rx="8"/>'
14097
+ + glyphLine(32, 34, 68, 34)
14098
+ + glyphLine(32, 50, 68, 50)
14099
+ + glyphLine(32, 66, 56, 66);
14100
+ }
14101
+
13239
14102
  function glyphSun() {
13240
14103
  let s = glyphDot(50, 50, 20);
13241
14104
  for (let i = 0; i < 8; ++i) {
@@ -13304,6 +14167,7 @@ function (root, d3, forester, phyloXml) {
13304
14167
  case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
13305
14168
  case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
13306
14169
  case 'info': sw = 7; cap = 'round'; body = glyphInfo(); break;
14170
+ case 'cheat_sheet': sw = 7; cap = 'round'; body = glyphCheatSheet(); break;
13307
14171
  case 'sun': body = glyphSun(); break;
13308
14172
  case 'moon': body = glyphMoon(); break;
13309
14173
  default: throw new Error('unknown control-panel glyph: ' + kind);
@@ -13334,13 +14198,13 @@ function (root, d3, forester, phyloXml) {
13334
14198
  }
13335
14199
  // Dark palette tokens, shared by the system-preference default and the
13336
14200
  // explicit "dark" choice from the header light/dark switch.
13337
- 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;'
13338
14202
  + ' --p-line:#27343f; --p-line-strong:#35434f; --p-surface2:#202d38;'
13339
14203
  + ' --p-accent:#57a6ff; --p-accent-ink:#9cc7ff; --p-accent-weak:rgba(87,166,255,0.18);'
13340
14204
  + ' --p-shadow-sm:0 1px 2px rgba(0,0,0,0.4);';
13341
14205
  let css = ''
13342
14206
  + '.aptx-panel {'
13343
- + ' --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;'
13344
14208
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
13345
14209
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
13346
14210
  + ' --p-shadow-sm:0 1px 2px rgba(23,34,46,0.12);'
@@ -13367,7 +14231,7 @@ function (root, d3, forester, phyloXml) {
13367
14231
  // (they cannot live inside the panel), so they carry their own
13368
14232
  // copy of the palette and follow the same light / dark rules.
13369
14233
  + '.aptx-node-menu, .aptx-suggest {'
13370
- + ' --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;'
13371
14235
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
13372
14236
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
13373
14237
  + '}'
@@ -13397,6 +14261,17 @@ function (root, d3, forester, phyloXml) {
13397
14261
  + ' background:#e5484d; color:#fff; }'
13398
14262
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
13399
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; }'
13400
14275
  // The search suggestions: the node menu's chrome under the value box.
13401
14276
  + '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
13402
14277
  + ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
@@ -13415,7 +14290,7 @@ function (root, d3, forester, phyloXml) {
13415
14290
  + ' font-size:10px; color:var(--p-faint); }'
13416
14291
  // The node-data dialog, on the same palette as the panel and the menu.
13417
14292
  + '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
13418
- + ' --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;'
13419
14294
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
13420
14295
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
13421
14296
  + '}'
@@ -13502,6 +14377,18 @@ function (root, d3, forester, phyloXml) {
13502
14377
  + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
13503
14378
  + '.aptx-kbd { font-family:inherit; font-size:10.5px; line-height:1; padding:3px 5px; min-width:18px; text-align:center; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-bottom-width:2px; border-radius:4px; }'
13504
14379
  + '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
14380
+ // the cheat sheet: a scrolling card of glyph + name : description
14381
+ // 31 rows is taller than a short window; the card scrolls rather
14382
+ // than running off the bottom, and the title bar stays put
14383
+ + '#' + CHEAT_SHEET_DIALOG + ' { max-height:min(78vh, 760px); display:flex; flex-direction:column; }'
14384
+ + '#' + CHEAT_SHEET_DIALOG + ' .aptx-dialog-body { min-height:0; }'
14385
+ + '.aptx-cheat-lead { margin:0 0 9px; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
14386
+ + '.aptx-cheat .aptx-dialog-line { padding:3px 0; align-items:flex-start; }'
14387
+ + '.aptx-cheat .aptx-cheat-key { flex:0 0 38%; display:flex; align-items:center; gap:6px; color:var(--p-ink); }'
14388
+ + '.aptx-cheat .aptx-cheat-glyph { width:14px; height:14px; flex:none; color:var(--p-muted); }'
14389
+ + '.aptx-cheat .aptx-cheat-name { overflow-wrap:anywhere; }'
14390
+ + '.aptx-cheat .aptx-dialog-val { color:var(--p-muted); line-height:1.35; }'
14391
+ + '.aptx-cheat .aptx-cheat-key:not(:empty)::after { content:":"; color:var(--p-faint); }'
13505
14392
  // representative tips: the input dialog and its result
13506
14393
  + '.aptx-reps { line-height:1.35; }'
13507
14394
  + '.aptx-reps-lead { margin:0 0 4px; }'
@@ -13553,7 +14440,7 @@ function (root, d3, forester, phyloXml) {
13553
14440
  + ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
13554
14441
  // The hover tooltip, on the same palette.
13555
14442
  + '.aptx-tip {'
13556
- + ' --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;'
13557
14444
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1;'
13558
14445
  + '}'
13559
14446
  + '@media (prefers-color-scheme:dark){ .aptx-tip:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
@@ -13573,7 +14460,15 @@ function (root, d3, forester, phyloXml) {
13573
14460
  + '.aptx-tip .aptx-dialog-line { padding:1px 0; }'
13574
14461
  + '.aptx-tip .aptx-dialog-line + .aptx-dialog-line { border-top:0; }'
13575
14462
  + '.aptx-tip .aptx-dialog-key { flex:0 0 auto; min-width:96px; }'
13576
- + '.aptx-panel .' + PROG_NAME + ' { display:flex; align-items:center; gap:8px; padding:9px 12px; border-bottom:1px solid var(--p-line); font-weight:600; letter-spacing:-0.01em; }'
14463
+ // WRAPS. The title's longest word cannot break, so on a narrow
14464
+ // panel the buttons had nowhere to go and overflowed the padding:
14465
+ // with four of them they ended 2.4 px past the panel's own right
14466
+ // edge, and squeezed the title into two ragged centred lines on
14467
+ // the way. Wrapping puts them on their own right-aligned row
14468
+ // instead, which also gives the title back its full width -- so
14469
+ // "Archaeopteryx.js 3.15.0" sits on ONE line where it used to
14470
+ // break in two.
14471
+ + '.aptx-panel .' + PROG_NAME + ' { display:flex; align-items:center; flex-wrap:wrap; gap:6px 8px; padding:9px 12px; border-bottom:1px solid var(--p-line); font-weight:600; letter-spacing:-0.01em; }'
13577
14472
  + '.aptx-panel .' + PROGNAMELINK + ',.aptx-panel .' + PROGNAMELINK + ':link,.aptx-panel .' + PROGNAMELINK + ':visited { color:var(--p-accent-ink); text-decoration:none; font-size:12px; border:0; }'
13578
14473
  + '.aptx-panel .' + PROGNAMELINK + ' { background:none; padding:0; margin:0; cursor:pointer;'
13579
14474
  + ' font-family:inherit; font-weight:inherit; letter-spacing:inherit; }'
@@ -13700,6 +14595,16 @@ function (root, d3, forester, phyloXml) {
13700
14595
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn { flex:1 1 0; padding:0; }'
13701
14596
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn:last-child { margin-right:0; }'
13702
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; }'
13703
14608
  + '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
13704
14609
  + '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
13705
14610
  // ... except one that has a WORD on it: the rule above sizes the
@@ -13728,7 +14633,6 @@ function (root, d3, forester, phyloXml) {
13728
14633
  + ' line-height:1; color:var(--p-muted); transition:transform .15s,color .15s; }'
13729
14634
  + '.aptx-panel legend.aptx-legend-toggle:hover::after { color:var(--p-accent-ink); }'
13730
14635
  + '.aptx-panel legend.aptx-legend-toggle:hover { color:var(--p-accent-ink); }'
13731
- + '.aptx-panel #' + EXPORT_FORMAT_SELECT + ' { margin-left:6px; }'
13732
14636
  + '.aptx-panel fieldset.aptx-collapsed > legend.aptx-legend-toggle::after { transform:rotate(-90deg); }'
13733
14637
  + '.aptx-panel fieldset.aptx-collapsed > legend { margin-bottom:0; }'
13734
14638
  + '.aptx-panel fieldset.aptx-collapsed > .aptx-fieldset-body { display:none; }'
@@ -13824,6 +14728,18 @@ function (root, d3, forester, phyloXml) {
13824
14728
  });
13825
14729
  actions.appendChild(infoBtn);
13826
14730
 
14731
+ let sheetBtn = document.createElement('button');
14732
+ sheetBtn.type = 'button';
14733
+ sheetBtn.className = 'aptx-info-btn';
14734
+ sheetBtn.title = 'Control panel: what each control does';
14735
+ sheetBtn.setAttribute('aria-label', 'Control panel cheat sheet');
14736
+ sheetBtn.innerHTML = makeGlyph('cheat_sheet');
14737
+ sheetBtn.addEventListener('click', function (e) {
14738
+ e.stopPropagation();
14739
+ showCheatSheetDialog();
14740
+ });
14741
+ actions.appendChild(sheetBtn);
14742
+
13827
14743
  let progName = header.querySelector('.' + PROGNAMELINK);
13828
14744
  if (progName) {
13829
14745
  // Clicking the title used to navigate away to the project site;
@@ -13895,6 +14811,10 @@ function (root, d3, forester, phyloXml) {
13895
14811
  fitPanelSections(panel, name);
13896
14812
  }
13897
14813
  savePanelSections();
14814
+ // a folded section's controls are off the screen, so they
14815
+ // leave the cheat sheet: it describes what the panel is
14816
+ // showing, and folding does not go through a tree render
14817
+ refreshCheatSheet();
13898
14818
  });
13899
14819
  }
13900
14820
  // What arrived open counts as opened, oldest at the BOTTOM: a panel is
@@ -14683,7 +15603,8 @@ function (root, d3, forester, phyloXml) {
14683
15603
  ['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
14684
15604
  ['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
14685
15605
  ['License', LICENSE_WEBSITE, LICENSE_NAME],
14686
- ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']].forEach(function (row) {
15606
+ ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')'],
15607
+ ['Control panel', null, 'what each control does']].forEach(function (row) {
14687
15608
  let line = document.createElement('div');
14688
15609
  line.className = 'aptx-dialog-line';
14689
15610
  let key = document.createElement('span');
@@ -14697,12 +15618,14 @@ function (root, d3, forester, phyloXml) {
14697
15618
  a.target = '_blank';
14698
15619
  a.rel = 'noopener noreferrer';
14699
15620
  } else {
14700
- // the shortcuts row opens the cheat sheet in place of the About box
15621
+ // the two rows with no URL open another dialog in place of
15622
+ // this one: the key list, and the control-panel sheet
15623
+ let opener = row[0] === 'Control panel' ? showCheatSheetDialog : showShortcutsDialog;
14701
15624
  a.href = '#';
14702
15625
  a.addEventListener('click', function (e) {
14703
15626
  e.preventDefault();
14704
15627
  shell.dialog.close();
14705
- showShortcutsDialog();
15628
+ opener();
14706
15629
  });
14707
15630
  }
14708
15631
  a.textContent = row[2];
@@ -14915,14 +15838,6 @@ function (root, d3, forester, phyloXml) {
14915
15838
  // the stylesheet's job now: the search row sizes its R button, and the
14916
15839
  // zoom grid stretches Y+/Y- to the exact width of the button row.)
14917
15840
 
14918
- const downloadButton = byId(DOWNLOAD_BUTTON);
14919
-
14920
- if (downloadButton) {
14921
- setStyles(downloadButton, {
14922
- 'width': '60px', 'margin-bottom': '3px'
14923
- });
14924
- }
14925
-
14926
15841
 
14927
15842
  on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
14928
15843
 
@@ -15139,12 +16054,19 @@ function (root, d3, forester, phyloXml) {
15139
16054
 
15140
16055
  // ----------------
15141
16056
 
15142
- if (downloadButton) {
15143
- downloadButton.addEventListener('click', downloadButtonPressed);
15144
- }
15145
-
15146
- setStyles(byId(EXPORT_FORMAT_SELECT), {
15147
- '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
+ });
15148
16070
  });
15149
16071
 
15150
16072
  // ---------------
@@ -15273,7 +16195,11 @@ function (root, d3, forester, phyloXml) {
15273
16195
  function makeProgramDesc() {
15274
16196
  let h = "";
15275
16197
  h = h.concat('<div class=' + PROG_NAME + '>');
15276
- h = h.concat('<button type="button" class="' + PROGNAMELINK + '" title="About ' + NAME + '">' + NAME + ' ' + VERSION + '</button>');
16198
+ // aria-label without the version: the cheat sheet names a control
16199
+ // by this, and a version is a value -- it belongs in the About box
16200
+ // it opens, not in a row that says what the button is
16201
+ h = h.concat('<button type="button" class="' + PROGNAMELINK + '" aria-label="' + NAME
16202
+ + '" title="About ' + NAME + '">' + NAME + ' ' + VERSION + '</button>');
15277
16203
  h = h.concat('</div>');
15278
16204
  return h;
15279
16205
  }
@@ -15424,14 +16350,17 @@ function (root, d3, forester, phyloXml) {
15424
16350
  // desktop
15425
16351
  let layoutGroup = 'layout_control_radio';
15426
16352
  h = h.concat('<div class="aptx-segmented">');
15427
- h = h.concat(makeSegment(makeGlyph('rectangular'), LAYOUT_RECT_BUTTON, layoutGroup, 'rectangular, root at left'));
15428
- h = h.concat(makeSegment(makeGlyph('circular'), LAYOUT_CIRC_BUTTON, layoutGroup, 'circular'));
15429
- h = h.concat(makeSegment(makeGlyph('unrooted'), LAYOUT_UNROOTED_BUTTON, layoutGroup, 'unrooted (equal-angle)'));
16353
+ h = h.concat(makeSegment(makeGlyph('rectangular'), LAYOUT_RECT_BUTTON, layoutGroup,
16354
+ 'rectangular layout: the root at the left, the tips in even rows down the page'));
16355
+ h = h.concat(makeSegment(makeGlyph('circular'), LAYOUT_CIRC_BUTTON, layoutGroup,
16356
+ 'circular layout: the tree as a fan, every tip label carried out to a common ring'));
16357
+ h = h.concat(makeSegment(makeGlyph('unrooted'), LAYOUT_UNROOTED_BUTTON, layoutGroup,
16358
+ 'unrooted layout: equal-angle, drawing no root branch at all'));
15430
16359
  h = h.concat('</div>');
15431
16360
  h = h.concat('<div class="' + PHYLOGRAM_CLADOGRAM_CONTROLGROUP + ' aptx-segmented">');
15432
- h = h.concat(makeSegment(makeGlyph('phylogram'), PHYLOGRAM_BUTTON, radioGroup, 'phylogram display (uses branch length values)'));
15433
- h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, 'phylogram display (uses branch length values) with aligned labels'));
15434
- h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, ' cladogram display (ignores branch length values)'));
16361
+ h = h.concat(makeSegment(makeGlyph('phylogram'), PHYLOGRAM_BUTTON, radioGroup, PHYLOGRAM_WHAT));
16362
+ h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, PHYLOGRAM_ALIGNED_WHAT));
16363
+ h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, CLADOGRAM_WHAT));
15435
16364
  h = h.concat('</div>');
15436
16365
  // The branch SCALE, when the tree states two different things: its
15437
16366
  // nodes' dates, and its branches' divergence. Hidden otherwise,
@@ -15514,7 +16443,7 @@ function (root, d3, forester, phyloXml) {
15514
16443
  // "Auto-hide Labels" matches the desktop, which renamed the historical
15515
16444
  // "Dyna Hide" because that named the mechanism rather than what the user
15516
16445
  // sees. The _state.dynahide field keeps its name internally.
15517
- opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels, and branch values that would overprint each other, where the tree is too dense for them to be readable', true));
16446
+ opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels, and branch values that would overprint a name or each other, where the tree is too dense for them to be readable', true));
15518
16447
  opts.push(makeCheckboxItem('Short Names', SHORTEN_NODE_NAME_CB, 'to shorten long node names'));
15519
16448
  if (_basicTreeProperties.confidences) {
15520
16449
  opts.push(makeCheckboxItem('Support Dots', SUPPORT_DOTS_CB, 'to mark a branch with support of at least ' + _settings.supportDotMinimum + '% with a dot at its midpoint, where there is room to draw it'));
@@ -15588,33 +16517,49 @@ function (root, d3, forester, phyloXml) {
15588
16517
  return h;
15589
16518
  }
15590
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.
15591
16524
  function makeDownloadSection() {
15592
- let h = "";
15593
- h = h.concat('<form action="#">');
15594
- h = h.concat('<fieldset>');
15595
- h = h.concat('<input type="button" value="Download" name="' + DOWNLOAD_BUTTON + '" title="download/export tree in a selected format" id="' + DOWNLOAD_BUTTON + '">');
15596
- //h = h.concat('<br>');
15597
- h = h.concat('<select name="' + EXPORT_FORMAT_SELECT + '" id="' + EXPORT_FORMAT_SELECT + '">');
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>');
15598
16539
  if (pngExportAvailable()) {
15599
- h = h.concat('<option value="' + PNG_EXPORT_FORMAT + '">' + PNG_EXPORT_FORMAT + '</option>');
15600
- }
15601
- if (pdfExportAvailable()) {
15602
- h = h.concat('<option value="' + PDF_EXPORT_FORMAT + '">' + PDF_EXPORT_FORMAT + '</option>');
15603
- }
15604
- h = h.concat('<option value="' + SVG_EXPORT_FORMAT + '">' + SVG_EXPORT_FORMAT + '</option>');
15605
- h = h.concat('<option value="' + PHYLOXML_EXPORT_FORMAT + '">' + PHYLOXML_EXPORT_FORMAT + '</option>');
15606
- h = h.concat('<option value="' + NH_EXPORT_FORMAT + '">' + NH_EXPORT_FORMAT + '</option>');
15607
- h = h.concat('<option value="' + NEXUS_EXPORT_FORMAT + '">' + NEXUS_EXPORT_FORMAT + '</option>');
15608
- // only when there is something to write: a tip carrying a
15609
- // molecular sequence, aligned or not (same gate as the node
15610
- // menu's Fasta entries) -- on any other tree the download would
15611
- // just be an empty file
15612
- if (_basicTreeProperties.maxMolSeqLength > 0) {
15613
- 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>');
15614
16547
  }
15615
- 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>');
15616
16562
  h = h.concat('</fieldset>');
15617
- h = h.concat('</form>');
15618
16563
  return h;
15619
16564
  }
15620
16565
 
@@ -15744,8 +16689,10 @@ function (root, d3, forester, phyloXml) {
15744
16689
  let h = "";
15745
16690
  h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
15746
16691
  h = h.concat('<div class="aptx-search-menus">');
15747
- h = h.concat('<select class="aptx-search-field" name="' + fsel + '" id="' + fsel + '" title="the field to search in"></select>');
15748
- h = h.concat('<select class="aptx-search-mode" name="' + msel + '" id="' + msel + '" title="how to match"></select>');
16692
+ h = h.concat('<select class="aptx-search-field" name="' + fsel + '" id="' + fsel
16693
+ + '" aria-label="' + label + ' field" title="the field to search in"></select>');
16694
+ h = h.concat('<select class="aptx-search-mode" name="' + msel + '" id="' + msel
16695
+ + '" aria-label="' + label + ' match" title="how to match"></select>');
15749
16696
  h = h.concat('</div>');
15750
16697
  h = h.concat('<div class="aptx-search-row">');
15751
16698
  h = h.concat('<input class="aptx-search-value" autocomplete="off" title="' + valTip + '" type="text" name="' + val + '" id="' + val + '">');
@@ -15865,6 +16812,27 @@ function (root, d3, forester, phyloXml) {
15865
16812
  setCheckboxValue(VISUAL_STYLES_CB, _state.useVisualStyles);
15866
16813
  setCheckboxValue(VIS_CB, _state.showVisualizations);
15867
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
+ }
15868
16836
  setCheckboxValue(MSA_CB, _state.showMsa);
15869
16837
  setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
15870
16838
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
@@ -15952,9 +16920,15 @@ function (root, d3, forester, phyloXml) {
15952
16920
  }
15953
16921
 
15954
16922
  function setDisplayTypeButtons() {
15955
- setRadioButtonValue(PHYLOGRAM_BUTTON, _state.phylogram && !_state.alignPhylogram);
15956
- setRadioButtonValue(CLADOGRAM_BUTTON, !_state.phylogram && !_state.alignPhylogram);
15957
- setRadioButtonValue(PHYLOGRAM_ALIGNED_BUTTON, _state.alignPhylogram && _state.phylogram);
16923
+ // In circular the labels ARE aligned, whatever alignPhylogram says, so
16924
+ // that is the button shown as chosen: the panel describes the picture.
16925
+ // The flag itself is left alone -- it is the user's choice for the
16926
+ // layouts that can express it, and returning to rectangular restores
16927
+ // what they picked there.
16928
+ let alignedLook = _state.phylogram && (_state.alignPhylogram || _state.circularDisplay);
16929
+ setRadioButtonValue(PHYLOGRAM_BUTTON, _state.phylogram && !alignedLook);
16930
+ setRadioButtonValue(CLADOGRAM_BUTTON, !_state.phylogram);
16931
+ setRadioButtonValue(PHYLOGRAM_ALIGNED_BUTTON, alignedLook);
15958
16932
  setCheckboxValue(LAYOUT_CIRC_BUTTON, _state.circularDisplay);
15959
16933
  setCheckboxValue(LAYOUT_UNROOTED_BUTTON, _state.unrootedDisplay);
15960
16934
  setCheckboxValue(LAYOUT_RECT_BUTTON, !radialDisplay());
@@ -15975,6 +16949,7 @@ function (root, d3, forester, phyloXml) {
15975
16949
 
15976
16950
  function updateButtonEnabledState() {
15977
16951
  syncZoomRowButtons(); // layout- and tree-dependent disables track every render
16952
+ refreshCheatSheet(); // an open sheet describes the panel as it is now
15978
16953
  // the uncollapse-all button lives only while something is collapsed, as on the desktop
15979
16954
  ((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
15980
16955
  if (_in_subtree) {
@@ -16126,70 +17101,348 @@ function (root, d3, forester, phyloXml) {
16126
17101
  return svgText;
16127
17102
  }
16128
17103
 
16129
- function getTreeAsSvg() {
16130
- let svg = treeSvgElement();
16131
- 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') {
16132
17144
  return null;
16133
17145
  }
16134
- let svgTree = null;
16135
- if (typeof window.XMLSerializer !== 'undefined') {
16136
- // Serialize a COPY with the overview taken out of it: the overview is
16137
- // an on-screen navigation aid, not part of the tree, and working on a
16138
- // copy leaves the live display untouched.
16139
- let copy = svg.cloneNode(true);
16140
- let overview = copy.querySelector('.aptx-overview');
16141
- if (overview) {
16142
- overview.remove();
16143
- }
16144
- // the halo discs' translucency and pulse live in page CSS the
16145
- // export cannot carry -- serialized as-is they become solid blobs
16146
- copy.querySelectorAll('circle.foundHalo').forEach(function (h) {
16147
- h.remove();
16148
- });
16149
- let glow = copy.querySelector('.aptx-hoverglow');
16150
- if (glow) {
16151
- glow.remove();
16152
- }
16153
- // The floating strips re-anchor to the TREE for export: on screen
16154
- // a strip may be stuck at the viewport bottom, but a figure must
16155
- // not carry an artefact of where the user happened to be scrolled
16156
- // -- the axis belongs at the bottom of the tree. (As the desktop
16157
- // does for PDF and graphics export.)
16158
- let t = d3.zoomTransform(svg);
16159
- copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
16160
- 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
+ }
16161
17230
  });
16162
- svgTree = toLightExport((new XMLSerializer()).serializeToString(copy));
16163
- } else if (typeof svg.xml !== 'undefined') {
16164
- 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);
16165
17282
  }
16166
- 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};
16167
17370
  }
16168
17371
 
16169
17372
  function downloadTree(format) {
16170
- if (format === PNG_EXPORT_FORMAT) {
16171
- changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
16172
- downloadAsPng();
16173
- changeBaseBackgoundColor(_state.backgroundColorDefault);
16174
- } else if (format === SVG_EXPORT_FORMAT) {
16175
- changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
16176
- downloadAsSVG();
16177
- changeBaseBackgoundColor(_state.backgroundColorDefault);
16178
- } else if (format === NH_EXPORT_FORMAT) {
17373
+ if (format === NH_EXPORT_FORMAT) {
16179
17374
  downloadAsNH();
16180
17375
  } else if (format === NEXUS_EXPORT_FORMAT) {
16181
17376
  downloadAsNexus();
16182
17377
  } else if (format === PHYLOXML_EXPORT_FORMAT) {
16183
17378
  downloadAsPhyloXml();
16184
- } else if (format === PDF_EXPORT_FORMAT) {
16185
- changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
16186
- downloadAsPdf();
16187
- changeBaseBackgoundColor(_state.backgroundColorDefault);
16188
17379
  } else if (format === FASTA_EXPORT_FORMAT) {
16189
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
+ });
16190
17393
  }
16191
17394
  }
16192
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
+
16193
17446
  /**
16194
17447
  * Saves a Blob to a file using native browser APIs (an <a download> link
16195
17448
  * pointing at an object URL). This replaces the former FileSaver.js
@@ -16217,9 +17470,34 @@ function (root, d3, forester, phyloXml) {
16217
17470
  }, 1000);
16218
17471
  }
16219
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
+
16220
17498
  function downloadAsPhyloXml() {
16221
- let x = phyloXml.toPhyloXML(_root, 9);
16222
- 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));
16223
17501
  }
16224
17502
 
16225
17503
  function downloadAsNH() {
@@ -16227,25 +17505,43 @@ function (root, d3, forester, phyloXml) {
16227
17505
  // not a preference: writing them out produces a file that will not parse
16228
17506
  // back in.
16229
17507
  let nh = forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences);
16230
- saveAs(new Blob([nh], {type: "application/txt"}), _state.nameForNhDownload);
17508
+ saveAs(new Blob([nh], {type: "application/txt"}), downloadFileName(NH_SUFFIX));
16231
17509
  }
16232
17510
 
16233
17511
  function downloadAsNexus() {
16234
17512
  // taxa and trees blocks as on the desktop, plus a characters block
16235
17513
  // when the tips carry an alignment -- carrying tree and alignment
16236
17514
  // in one file is what Nexus is for
16237
- let nex = forester.toNexus(_root, 9, _settings.nhExportWriteConfidences);
16238
- 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));
17518
+ }
17519
+
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));
16239
17530
  }
16240
17531
 
16241
- function downloadAsSVG() {
16242
- let svg = getTreeAsSvg();
16243
- saveAs(new Blob([decodeURIComponent(encodeURIComponent(svg))], {type: "application/svg+xml"}), _state.nameForSvgDownload);
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
+ });
16244
17540
  }
16245
17541
 
16246
17542
  function downloadAsFastaAll() {
16247
17543
  let fasta_text = forester.getMolecularSequencesAsFasta(_root, '\n');
16248
- saveAs(new Blob([fasta_text], {type: "application/txt"}), _state.nameForFastaDownload);
17544
+ saveAs(new Blob([fasta_text], {type: "application/txt"}), downloadFileName(FASTA_SUFFIX));
16249
17545
  }
16250
17546
 
16251
17547
  // Vector PDF via the OPTIONAL page-level libraries jsPDF and svg2pdf.js
@@ -16259,13 +17555,18 @@ function (root, d3, forester, phyloXml) {
16259
17555
  && window.jspdf.jsPDF.API.svg);
16260
17556
  }
16261
17557
 
16262
- 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) {
16263
17565
  if (!pdfExportAvailable()) {
16264
17566
  console.error(ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
16265
17567
  return;
16266
17568
  }
16267
- let svg = getTreeAsSvg();
16268
- let el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
17569
+ let el = new DOMParser().parseFromString(graphic.text, 'image/svg+xml').documentElement;
16269
17570
  // the full-canvas background rect is sized in percentages, which
16270
17571
  // svg2pdf renders literally (a dark 100pt square at the origin); the
16271
17572
  // PDF page is white anyway, so the rect is simply dropped
@@ -16297,9 +17598,9 @@ function (root, d3, forester, phyloXml) {
16297
17598
  }
16298
17599
  });
16299
17600
  });
16300
- let svgEl = treeSvgElement();
16301
- let w = (svgEl && svgEl.width.baseVal.value) || _displayWidth;
16302
- 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;
16303
17604
  let pdf = new window.jspdf.jsPDF({
16304
17605
  orientation: w >= h ? 'landscape' : 'portrait',
16305
17606
  unit: 'pt',
@@ -16307,7 +17608,7 @@ function (root, d3, forester, phyloXml) {
16307
17608
  compress: true
16308
17609
  });
16309
17610
  pdf.svg(el, {x: 0, y: 0, width: w, height: h}).then(function () {
16310
- pdf.save(_state.nameForPdfDownload);
17611
+ pdf.save(downloadFileName(PDF_SUFFIX));
16311
17612
  holder.remove();
16312
17613
  }, function (err) {
16313
17614
  holder.remove();
@@ -16324,23 +17625,62 @@ function (root, d3, forester, phyloXml) {
16324
17625
  return !!(window.Canvg && window.Canvg.fromString);
16325
17626
  }
16326
17627
 
16327
- 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
+ }
16328
17671
  if (!pngExportAvailable()) {
16329
- console.error(ERROR + 'PNG export needs the optional canvg library on the page');
16330
- 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'));
17677
+ }
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');
16331
17681
  }
16332
- let svg = getTreeAsSvg();
16333
- // Render onto an up-scaled canvas so the exported PNG is high-resolution
16334
- // rather than 1:1 with the on-screen SVG. Scale is configurable via
16335
- // _settings.pngExportScale (default 4x).
16336
- let svgEl = treeSvgElement();
16337
- let scale = _settings.pngExportScale > 0 ? _settings.pngExportScale : 4;
16338
- let w = (svgEl && svgEl.width.baseVal.value) || _displayWidth;
16339
- let h = (svgEl && svgEl.height.baseVal.value) || _displayHeight;
16340
- let canvas = document.createElement('canvas');
16341
- canvas.width = Math.round(w * scale);
16342
- canvas.height = Math.round(h * scale);
16343
- let ctx = canvas.getContext('2d');
17682
+ let canvas = target.canvas;
17683
+ let ctx = target.ctx;
16344
17684
  // The up-scaling is done on the CONTEXT, not through canvg's own
16345
17685
  // scaleWidth/scaleHeight: canvg 4's scaling pipeline mis-scales (a
16346
17686
  // requested 4x came out at 2.5x, leaving unpainted margins -- and any
@@ -16349,20 +17689,56 @@ function (root, d3, forester, phyloXml) {
16349
17689
  // background before canvg draws a thing. ignoreDimensions keeps canvg
16350
17690
  // from resizing the canvas to the SVG's size (which would erase both
16351
17691
  // the transform and the fill); ignoreClear keeps it from clearing the
16352
- // fill back to transparent. Content outside the on-screen viewport
16353
- // falls outside the canvas and is cropped, so the export shows
16354
- // 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()
16355
17693
  // call drew synchronously, so toBlob could follow on the next line).
16356
17694
  ctx.fillStyle = _state.backgroundColorForPrintExportDefault;
16357
17695
  ctx.fillRect(0, 0, canvas.width, canvas.height);
16358
- ctx.scale(scale, scale);
16359
- let v = window.Canvg.fromString(ctx, svg);
16360
- v.render({ignoreDimensions: true, ignoreClear: true}).then(function () {
16361
- canvas.toBlob(function (blob) {
16362
- 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');
16363
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 ✓');
16364
17739
  }, function (err) {
16365
- console.error(ERROR + 'PNG export failed: ' + err);
17740
+ console.error(ERROR + 'copying the PNG failed: ' + err);
17741
+ flashButton(btn, 'Copy failed');
16366
17742
  });
16367
17743
  }
16368
17744