archaeopteryx 3.15.0 → 3.16.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.16.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.16.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';
@@ -408,6 +408,20 @@ function (root, d3, forester, phyloXml) {
408
408
  const VIS_CB = 'vis_cb';
409
409
  const LADDERIZE_BUTTON = 'ladderize_b';
410
410
  const PHYLOGRAM_ALIGNED_BUTTON = 'phya_b';
411
+ // What the three display types DO, in one place: the tooltip, the reason a
412
+ // type is greyed, and the cheat-sheet row are all this sentence. A control
413
+ // named only after itself ("phylogram display (uses branch length
414
+ // values)") tells a reader who already knows what it is.
415
+ // Each says WHEN it is greyed, whether or not it is greyed now: that is a
416
+ // property of the control, not of the view, and a reader of the sheet in
417
+ // one layout is owed what happens in the others.
418
+ const PHYLOGRAM_WHAT = 'phylogram: branch lengths drawn to scale, so the tips end ragged.'
419
+ + ' Greyed in the circular layout, which always carries its labels to the outer ring,'
420
+ + ' so there the aligned phylogram is the one drawn';
421
+ const PHYLOGRAM_ALIGNED_WHAT = 'aligned phylogram: branch lengths to scale, with every tip'
422
+ + ' carried on to a common column, or in the circular layout to the outer ring.'
423
+ + ' Greyed in the unrooted layout, which has no column or ring to carry them to';
424
+ const CLADOGRAM_WHAT = 'cladogram: topology only, branch lengths ignored, every tip flush';
411
425
  const PHYLOGRAM_BUTTON = 'phy_b';
412
426
  const PHYLOGRAM_CLADOGRAM_CONTROLGROUP = 'phy_cla_g';
413
427
  const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
@@ -2966,7 +2980,6 @@ function (root, d3, forester, phyloXml) {
2966
2980
  return {source: link.source.data, target: link.target.data};
2967
2981
  });
2968
2982
  _overviewLinks = links; // the overview is built from these, not from the DOM
2969
- let gap = _state.nodeLabelGap;
2970
2983
 
2971
2984
  if (_state.phylogram === true) {
2972
2985
  _yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
@@ -3048,9 +3061,20 @@ function (root, d3, forester, phyloXml) {
3048
3061
  }
3049
3062
 
3050
3063
  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)
3064
+ if (radialDisplay()) {
3065
+ // The every-k-th DECIMATION does not run in either radial
3066
+ // layout: it thins by index against even ROW spacing, which a
3067
+ // fan does not have. An unrooted fan has no rows at all; a
3068
+ // circular one has a ring, whose room is set by its
3069
+ // circumference and not by the display's height. Measured on
3070
+ // Caliciviridae_100 at 1100x850, the index rule kept 48 of 97
3071
+ // names in circular while all 97 fitted the ring with NOT ONE
3072
+ // overlapping pair, and on flu_h5 it kept 59 of 354 where 118
3073
+ // are readable. (118 is the shipped rule measured; 100 was a
3074
+ // greedy estimate in DOM order made before it was built, and
3075
+ // is what this comment said until a review caught it against
3076
+ // README and CHANGELOG.) Both are now thinned by hideCrowdedTipLabels
3077
+ // instead, on whether the names actually overprint.
3054
3078
  forester.preOrderTraversal(_root, function (n) {
3055
3079
  n.hide = false;
3056
3080
  });
@@ -3062,8 +3086,6 @@ function (root, d3, forester, phyloXml) {
3062
3086
  });
3063
3087
  }
3064
3088
  }
3065
- syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
3066
-
3067
3089
  updateButtonEnabledState();
3068
3090
 
3069
3091
  // Only the nodes with something drawn for them individually get a
@@ -3130,11 +3152,10 @@ function (root, d3, forester, phyloXml) {
3130
3152
  return makeFoundOutlineColor(d) ? '0.9px' : null;
3131
3153
  })
3132
3154
  .style('paint-order', 'stroke')
3155
+ // anchor, dy and x all come from nodeLabelPlacement, which the
3156
+ // occupancy box is built from too
3133
3157
  .attr('text-anchor', function (d) {
3134
- if (radialDisplay()) {
3135
- return labelFlip(d) ? 'end' : 'start';
3136
- }
3137
- return d.children ? 'end' : 'start';
3158
+ return nodeLabelPlacement(d).anchor;
3138
3159
  })
3139
3160
  .attr('transform', function (d) {
3140
3161
  if (_state.unrootedDisplay) {
@@ -3162,42 +3183,10 @@ function (root, d3, forester, phyloXml) {
3162
3183
  return 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
3163
3184
  })
3164
3185
  .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';
3186
+ return nodeLabelPlacement(d).dy + 'px';
3175
3187
  })
3176
3188
  .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
- }
3189
+ return nodeLabelPlacement(d).x;
3201
3190
  });
3202
3191
 
3203
3192
  node.select('text.bllabel')
@@ -3361,7 +3350,7 @@ function (root, d3, forester, phyloXml) {
3361
3350
  // a collapsed clade's label always shows, so its guide does too: from
3362
3351
  // past the wedge's farthest tip to the label column
3363
3352
  let collapsedGuides = alignedRect ? nodes.filter(function (d) {
3364
- return isCollapsed(d) && !(_state.dynahide && d.hide);
3353
+ return isCollapsed(d) && nameDrawn(d);
3365
3354
  }) : [];
3366
3355
  if (tipGuides || collapsedGuides.length > 0) {
3367
3356
  let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
@@ -3374,7 +3363,7 @@ function (root, d3, forester, phyloXml) {
3374
3363
  if (tipGuides) {
3375
3364
  guideStyle(ext.selectAll('path.aptx-tip-guide')
3376
3365
  .data(links.filter(function (d) {
3377
- return (!d.target.children && !(_state.dynahide && d.target.hide));
3366
+ return (!d.target.children && nameDrawn(d.target));
3378
3367
  }))
3379
3368
  .enter().append('path').attr('class', 'aptx-tip-guide'))
3380
3369
  .attr('d', function (d) {
@@ -3397,7 +3386,7 @@ function (root, d3, forester, phyloXml) {
3397
3386
  // tips while their labels show; a collapsed clade always (its label
3398
3387
  // always shows), from its wedge's farthest reach out to the ring
3399
3388
  let connected = nodes.filter(function (d) {
3400
- if (_state.dynahide && d.hide) {
3389
+ if (!nameDrawn(d)) {
3401
3390
  return false;
3402
3391
  }
3403
3392
  if (isCollapsed(d)) {
@@ -3870,8 +3859,8 @@ function (root, d3, forester, phyloXml) {
3870
3859
  // in y as much as in x, and branch length only sees x. Measured here
3871
3860
  // before adopting it: with Auto-hide Labels already ON, 240 of 267 drawn
3872
3861
  // 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.
3862
+ // Adenoviridae, 13 of 119 on bcl2 -- our auto-hide only thinned TIP labels
3863
+ // then, and had never touched branch data.
3875
3864
  //
3876
3865
  // WHAT MUST MATCH, or the two viewers drop different marks:
3877
3866
  // - the tie-break: PREORDER, first claim wins, so the mark nearer the
@@ -3903,11 +3892,29 @@ function (root, d3, forester, phyloXml) {
3903
3892
  // Measured from the font rather than assumed. The fallback is what this
3904
3893
  // font actually reports at 9px -- ascent 9, descent 2, line 11 -- read off
3905
3894
  // a rendered label's getBBox rather than guessed.
3906
- let _branchFontCache = null;
3895
+ let _fontMetricsCache = new Map();
3896
+
3897
+ // how many names the radial overlap rule took on the last render, for the
3898
+ // Auto-hide indicator: those layouts thin by overlap, not by index, so
3899
+ // _dynahide_factor says nothing about them
3900
+ let _radialNamesHidden = 0;
3901
+
3902
+ // and how many branch marks the crowding rule refused on the last render.
3903
+ // The switch governs three things -- the every-k-th thinning, the radial
3904
+ // overlap thinning and the branch-data rule -- and the indicator used to
3905
+ // ask only about names, so a view where every name fitted but 201 numbers
3906
+ // were being refused showed a dark toggle over a tree it was thinning.
3907
+ // (Caliciviridae circular; apaf RECTANGULAR did it too, so this half was
3908
+ // never right in any layout.)
3909
+ let _branchMarksHidden = 0;
3907
3910
 
3908
3911
  function branchFontMetrics(fontPx) {
3909
- if (_branchFontCache && _branchFontCache.px === fontPx) {
3910
- return _branchFontCache;
3912
+ // One cache for every size asked for, not one slot: labels and branch
3913
+ // marks run at different sizes in the same pass, and a single slot
3914
+ // would re-measure on every alternation.
3915
+ let hit = _fontMetricsCache.get(fontPx);
3916
+ if (hit) {
3917
+ return hit;
3911
3918
  }
3912
3919
  let ctx = measureCtx();
3913
3920
  ctx.font = fontPx + 'px ' + FONT_DEFAULTS;
@@ -3924,9 +3931,10 @@ function (root, d3, forester, phyloXml) {
3924
3931
  if (!(descent >= 0)) {
3925
3932
  descent = fontPx * (2 / 9);
3926
3933
  }
3927
- _branchFontCache = {px: fontPx, ascent: ascent, descent: descent,
3934
+ let metrics = {px: fontPx, ascent: ascent, descent: descent,
3928
3935
  height: ascent + descent};
3929
- return _branchFontCache;
3936
+ _fontMetricsCache.set(fontPx, metrics);
3937
+ return metrics;
3930
3938
  }
3931
3939
 
3932
3940
  // Where a branch mark sits on its branch, read off the rendered elements
@@ -4018,6 +4026,238 @@ function (root, d3, forester, phyloXml) {
4018
4026
  return [x, (d.x + at.dy) - fm.ascent, w, h];
4019
4027
  }
4020
4028
 
4029
+ // ---- the node's own label: one source for drawing it and for reserving
4030
+ // its space ----
4031
+ //
4032
+ // The three attributes below (text-anchor, x, dy) and the occupancy box
4033
+ // that keeps branch numbers off the name are the same decision, so they
4034
+ // are made once here. Modelled after branchMarkPlacement, and for the same
4035
+ // reason: a box that is not where the text is reserves the wrong space,
4036
+ // and two copies of the arithmetic drift the first time one is edited.
4037
+ function nodeLabelPlacement(d) {
4038
+ let gap = _state.nodeLabelGap;
4039
+ if (radialDisplay()) {
4040
+ let flip = labelFlip(d);
4041
+ return {anchor: flip ? 'end' : 'start', x: flip ? -gap : gap,
4042
+ dy: 0.32 * nodeLabelFontSize(d)};
4043
+ }
4044
+ if (!d.children) {
4045
+ let x = (_state.phylogram && _state.alignPhylogram)
4046
+ ? (-_yScale(d.distToRoot) + _w + gap)
4047
+ : gap;
4048
+ return {anchor: 'start', x: x, dy: 0.3 * _state.externalNodeFontSize};
4049
+ }
4050
+ // the desktop's above-branch internal label: right-aligned so it ends
4051
+ // just left of the node, and shifted right when a long label on a node
4052
+ // near the root would otherwise clip off the left edge
4053
+ let label = makeNodeLabel(d) || '';
4054
+ let est = label.length * _state.internalNodeFontSize * 0.55;
4055
+ let leftmost = d.y - gap - est;
4056
+ let minX = 2 - _settings.rootOffset;
4057
+ let x = (leftmost < minX) ? (-gap + (minX - leftmost)) : -gap;
4058
+ return {anchor: 'end', x: x,
4059
+ dy: -(Math.round(0.2 * _state.internalNodeFontSize) + 1)};
4060
+ }
4061
+
4062
+ // The size the label is actually drawn at: a per-node style overrides the
4063
+ // internal/external default, and the box has to follow it or a styled
4064
+ // tree reserves the wrong space.
4065
+ function nodeLabelFontSize(d) {
4066
+ let style = nodeStyle(d);
4067
+ if (style && style.fontSize) {
4068
+ return style.fontSize;
4069
+ }
4070
+ return d.children ? _state.internalNodeFontSize : _state.externalNodeFontSize;
4071
+ }
4072
+
4073
+ // The label's rectangle in the tree group's coordinates, as a start point
4074
+ // on the baseline, a direction the text runs in, and its extents.
4075
+ // Returns null when there is no label drawn.
4076
+ function nodeLabelGeometry(d) {
4077
+ let text = d._extLabelText;
4078
+ if (!text) {
4079
+ return null;
4080
+ }
4081
+ let fontPx = nodeLabelFontSize(d);
4082
+ let style = nodeStyle(d);
4083
+ let bold = getFoundColor(d)
4084
+ || (style && (style.fontStyle === 'bold' || style.fontStyle === 'bold_italic'));
4085
+ let w = legendTextWidth(text, (bold ? 'bold ' : '') + fontPx + 'px ' + FONT_DEFAULTS);
4086
+ let fm = branchFontMetrics(fontPx);
4087
+ let p = nodeLabelPlacement(d);
4088
+ let at = layoutPointXY(d);
4089
+ if (at[0] === undefined || at[1] === undefined) {
4090
+ return null; // not laid out (an unrooted node the layout never reached)
4091
+ }
4092
+ if (!radialDisplay() || _radialLabelsHorizontal) {
4093
+ // upright text, whatever the layout: one axis-aligned rectangle
4094
+ let base = at;
4095
+ if (_state.circularDisplay && _radialLabelsHorizontal && !d.children && _radial) {
4096
+ // the label rides out to its point on the ring, in screen terms
4097
+ base = polarXY(radialAngle(d.x), _radial.maxRad);
4098
+ }
4099
+ let x0 = base[0] + p.x - (p.anchor === 'end' ? w : 0);
4100
+ return {x: x0, y: (base[1] + p.dy) - fm.ascent, w: w, h: fm.height, angle: 0};
4101
+ }
4102
+ // Radial: the text starts a gap beyond the node (or beyond the ring,
4103
+ // for a circular tip) and runs OUTWARD along the spoke -- on both
4104
+ // halves of the fan, because the 180-degree flip that keeps the far
4105
+ // half upright also reverses which end the anchor is.
4106
+ //
4107
+ // Built as an offset FROM THE NODE, not as a distance from the origin.
4108
+ // A circular node lies on the ray its label runs along, so radius
4109
+ // arithmetic happens to work there; an unrooted one does not -- its
4110
+ // spoke angle is the direction its own branch came in at, which has
4111
+ // nothing to do with its bearing from the centre. Measured with the
4112
+ // radius version: unrooted still had 7 of 12 numbers printing through
4113
+ // a name on flu_h5, because the boxes were being reserved in places
4114
+ // no label was.
4115
+ let theta = spokeAngle(d);
4116
+ let along = _state.nodeLabelGap;
4117
+ if (_state.circularDisplay && !d.children && _radial) {
4118
+ along += _radial.maxRad - radialRadius(d.y); // the pull out to the ring
4119
+ }
4120
+ let flip = labelFlip(d);
4121
+ // the baseline offset is perpendicular to the text, and the flip
4122
+ // carries it across with everything else
4123
+ let off = flip ? -p.dy : p.dy;
4124
+ let top = flip ? (off - fm.descent) : (off - fm.ascent);
4125
+ let cos = Math.cos(theta), sin = Math.sin(theta);
4126
+ return {x: at[0] + (cos * along) - (sin * top),
4127
+ y: at[1] + (sin * along) + (cos * top),
4128
+ w: w, h: fm.height, angle: theta};
4129
+ }
4130
+
4131
+ // The label's own outline, as four corners in the tree group's
4132
+ // coordinates: what the turned-box occupancy tests against.
4133
+ function nodeLabelQuad(g) {
4134
+ if (!g || !(g.w > 0) || !(g.h > 0)) {
4135
+ return null;
4136
+ }
4137
+ let cos = Math.cos(g.angle), sin = Math.sin(g.angle);
4138
+ let corners = [[0, 0], [g.w, 0], [g.w, g.h], [0, g.h]];
4139
+ let quad = [];
4140
+ for (let i = 0; i < corners.length; ++i) {
4141
+ quad.push([g.x + (cos * corners[i][0]) - (sin * corners[i][1]),
4142
+ g.y + (sin * corners[i][0]) + (cos * corners[i][1])]);
4143
+ }
4144
+ return quad;
4145
+ }
4146
+
4147
+ // ---- crowded TIP LABELS, in both radial layouts ----
4148
+ //
4149
+ // The desktop's rule: a tip label is drawn only if its own outline overlaps
4150
+ // no label already drawn this pass. Both fans use it; the rectangular
4151
+ // layout keeps the every-k-th thinning, whose row pitch is a real quantity
4152
+ // there.
4153
+ //
4154
+ // Unrooted had nothing at all before this -- its labels sit wherever the
4155
+ // equal-angle layout put the tip. Circular had the rectangular rule, which
4156
+ // reads the display's HEIGHT against the node count and says nothing about
4157
+ // a ring: it kept 48 of 97 names on Caliciviridae where all 97 fit with no
4158
+ // overlap, and 59 of 354 on flu_h5 where 118 are readable.
4159
+ //
4160
+ // A SEARCH HIT is placed without being asked, so it can be drawn across a
4161
+ // name already down -- intended (the hit is what is being looked for), and
4162
+ // measured: a 3-hit search on confidences unrooted puts 3 hits across
4163
+ // non-hits, 8.1 px deep. It is the one case where "drawn only where it
4164
+ // overlaps nothing" does not hold, and the README says so.
4165
+ // Measured at 1100x850 before this, overlapping pairs among the labels
4166
+ // drawn: 172 of 97 labels on Caliciviridae_100, 71 of 42 on confidences,
4167
+ // 10 of 31 on apaf -- clumps of a dozen names printed through each other.
4168
+ //
4169
+ // OUTLINES, not their axis-aligned bounds: a name lying near its spoke has
4170
+ // bounds several times its own area, and on a 48-label ring those bounds
4171
+ // called 28 pairs crowded where a reader sees none.
4172
+ //
4173
+ // Order is the tree's own, root first (forester.preorderOf), which is also
4174
+ // what the desktop's unrooted painter does -- it reaches an internal node
4175
+ // before its subtree -- so both programs keep the same label when two
4176
+ // contend. A search HIT is placed without being asked, so the name being
4177
+ // looked for is always on the screen and everything after it keeps clear
4178
+ // of it (Christian, 2026-09-26: hits are exempt for tip labels, and for
4179
+ // nothing else).
4180
+ // Is this node's name on the screen? Asked by everything drawn to a label
4181
+ // -- the aligned guides, the circular ring connectors -- and it must be
4182
+ // ONE question. It used to be spelled `_state.dynahide && d.hide`, which
4183
+ // is the every-k-th rule and nothing else: when the radial layouts started
4184
+ // hiding by overlap they left `hide` false for everyone, so on flu_h5 in
4185
+ // circular 354 dashed connectors ran out to the ring for 118 names, 236 of
4186
+ // them ending at nothing. `_labelDropped` is set by BOTH rules.
4187
+ function nameDrawn(d) {
4188
+ return !d._labelDropped;
4189
+ }
4190
+
4191
+ function hideCrowdedTipLabels(nodes) {
4192
+ _radialNamesHidden = 0;
4193
+ if (!_state.dynahide || !radialDisplay()) {
4194
+ return;
4195
+ }
4196
+ // no layout, no honest outlines; bailing draws everything
4197
+ if (_state.unrootedDisplay ? !_unroot : !_radial) {
4198
+ return;
4199
+ }
4200
+ let labels = forester.orientedOccupancy(Math.max(4, _state.externalNodeFontSize));
4201
+ let ordered = forester.preorderOf(nodes);
4202
+ for (let i = 0, len = ordered.length; i !== len; ++i) {
4203
+ let d = ordered[i];
4204
+ if (d.children || d._extLabelText === '') {
4205
+ continue;
4206
+ }
4207
+ let quad = nodeLabelQuad(nodeLabelGeometry(d));
4208
+ if (!quad) {
4209
+ continue;
4210
+ }
4211
+ if (getFoundColor(d)) {
4212
+ labels.occupy(quad);
4213
+ } else if (!labels.claim(quad)) {
4214
+ d._extLabelText = '';
4215
+ d._labelDropped = true;
4216
+ ++_radialNamesHidden;
4217
+ }
4218
+ }
4219
+ }
4220
+
4221
+ // The boxes to reserve for a label. An upright label is one rectangle. A
4222
+ // rotated one is reserved as a RUN of short boxes along its baseline,
4223
+ // never as one axis-aligned box around the whole thing: the bounds of a
4224
+ // rotated rectangle grow with its LENGTH, so a 120 px name lying at 30
4225
+ // degrees would reserve several times its own area. Stepping by the line
4226
+ // height bounds the error at one step whatever the name is.
4227
+ //
4228
+ // Honestly measured: the run and the single box draw the SAME numbers on
4229
+ // every tree tried (apaf, Caliciviridae, confidences) bar one -- flu_h5
4230
+ // unrooted, 4 against 3 -- and a purpose-built sparse fixture with 28-
4231
+ // character names could not separate them at all
4232
+ // (test_trees/label_reservation.html). The views that would show the
4233
+ // difference are already saturated, and the ones with room refuse nothing
4234
+ // either way. It is kept for the bound, not for the count: the error of
4235
+ // the single box grows without limit as names get longer, and this is
4236
+ // ten lines.
4237
+ function nodeLabelClaimBoxes(g) {
4238
+ if (!g || !(g.w > 0) || !(g.h > 0)) {
4239
+ return [];
4240
+ }
4241
+ if (g.angle === 0) {
4242
+ return [[g.x, g.y, g.w, g.h]];
4243
+ }
4244
+ let cos = Math.cos(g.angle), sin = Math.sin(g.angle);
4245
+ let step = Math.max(4, g.h);
4246
+ let boxes = [];
4247
+ for (let t = 0; t < g.w; t += step) {
4248
+ let len = Math.min(step, g.w - t);
4249
+ // the four corners of this slice, in tree coordinates
4250
+ let xs = [], ys = [];
4251
+ for (const [a, b] of [[t, 0], [t + len, 0], [t + len, g.h], [t, g.h]]) {
4252
+ xs.push(g.x + (cos * a) - (sin * b));
4253
+ ys.push(g.y + (sin * a) + (cos * b));
4254
+ }
4255
+ let x0 = Math.min(...xs), y0 = Math.min(...ys);
4256
+ boxes.push([x0, y0, Math.max(...xs) - x0, Math.max(...ys) - y0]);
4257
+ }
4258
+ return boxes;
4259
+ }
4260
+
4021
4261
  function prepareNodeDrawing(nodes) {
4022
4262
  let wantBl = _state.showBranchLengthValues === true;
4023
4263
  let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
@@ -4034,7 +4274,12 @@ function (root, d3, forester, phyloXml) {
4034
4274
  }
4035
4275
  // Empty is the common case on a big tree: Auto-hide Labels blanks
4036
4276
  // them exactly when there are most nodes to blank.
4037
- d._extLabelText = (!_state.dynahide || !d.hide) ? (makeNodeLabel(d) || '') : '';
4277
+ // WHY the text is empty matters downstream: a label the crowding
4278
+ // rule took away is a different thing from a label the user
4279
+ // switched off, and marks that accompany a name must tell them
4280
+ // apart (drawDomainArchitectures).
4281
+ d._labelDropped = _state.dynahide === true && d.hide === true;
4282
+ d._extLabelText = d._labelDropped ? '' : (makeNodeLabel(d) || '');
4038
4283
  d._blText = wantBl ? asText(makeBranchLengthLabel(d)) : '';
4039
4284
  d._confText = wantConf ? asText(makeConfidenceValuesLabel(d)) : '';
4040
4285
  d._eventText = wantEvent ? asText(makeBranchEventsLabel(d)) : '';
@@ -4044,7 +4289,13 @@ function (root, d3, forester, phyloXml) {
4044
4289
  // childless <g class="node"> behind, and those are transformed on
4045
4290
  // every animation frame. On the big tree that was ~18,000 empty groups
4046
4291
  // per redraw, defeating the very thing the `drawn` filter is for.
4292
+ // names first: the ones that survive then reserve their space inside
4293
+ // hideCrowdedBranchData, and the marks are placed against what is left
4294
+ hideCrowdedTipLabels(nodes);
4047
4295
  hideCrowdedBranchData(nodes);
4296
+ // told after the rules have run, not before: what the unrooted rule
4297
+ // hid is only known once it has looked at the labels
4298
+ syncDynahideIndicator();
4048
4299
  for (let i = 0, len = nodes.length; i !== len; ++i) {
4049
4300
  let d = nodes[i];
4050
4301
  if (d._extLabelText !== '' || d._blText !== '' || d._confText !== ''
@@ -4070,8 +4321,9 @@ function (root, d3, forester, phyloXml) {
4070
4321
  // built in the tree group's own coordinates, which is where every mark is
4071
4322
  // actually drawn, so the circular and unrooted views are measured in their
4072
4323
  // 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.)
4324
+ // the toggle runs in the rectangular layout alone; both fans have no rows
4325
+ // to decimate against, and hideCrowdedTipLabels thins their names by
4326
+ // overlap instead, running before this does.)
4075
4327
  function hideCrowdedBranchData(nodes) {
4076
4328
  if (!_state.dynahide) {
4077
4329
  return;
@@ -4082,6 +4334,24 @@ function (root, d3, forester, phyloXml) {
4082
4334
  if ((_state.circularDisplay && !_radial) || (_state.unrootedDisplay && !_unroot)) {
4083
4335
  return;
4084
4336
  }
4337
+ _branchMarksHidden = 0;
4338
+ // Each granted mark's claimed box is kept on the node, as a label's is
4339
+ // (_labelBoxes). Names have had an ink-coverage check since the
4340
+ // reservation went in -- every point of a drawn name must lie inside
4341
+ // the space it claimed -- and branch MARKS never did, which is the
4342
+ // half that failed on a Linux CI runner while the label half passed.
4343
+ for (let i = 0; i < nodes.length; ++i) {
4344
+ nodes[i]._markBoxes = null;
4345
+ }
4346
+ let stash = function (d, kind, box) {
4347
+ if (!box) {
4348
+ return;
4349
+ }
4350
+ if (!d._markBoxes) {
4351
+ d._markBoxes = {};
4352
+ }
4353
+ d._markBoxes[kind] = box;
4354
+ };
4085
4355
  let fontPx = _state.branchDataFontSize;
4086
4356
  let dotCell = 0;
4087
4357
  for (let i = 0; i < nodes.length; ++i) {
@@ -4093,6 +4363,33 @@ function (root, d3, forester, phyloXml) {
4093
4363
  let numbers = forester.labelOccupancy(cell);
4094
4364
  let symbols = forester.labelOccupancy(cell);
4095
4365
  let ordered = forester.preorderOf(nodes);
4366
+
4367
+ // THE NAMES GO DOWN FIRST, and they are not asked: a node label is
4368
+ // drawn whatever else is there, so it reserves its space with occupy
4369
+ // rather than claim. Everything below is then refused against it, and
4370
+ // names outrank numbers -- the desktop's order, and the one a reader
4371
+ // expects, since a name is what the tree is for and a value that has
4372
+ // printed through one is worse than a value not drawn at all.
4373
+ //
4374
+ // Measured before this existed, at 1100x850 with both numbers on:
4375
+ // 44 of 93 numbers printed through a name on flu_h5 in the
4376
+ // rectangular layout, 30 of 55 on confidences, 10 of 15 unrooted.
4377
+ // The 3.15.0 pass kept numbers off each OTHER and off nothing else.
4378
+ for (let i = 0, len = ordered.length; i !== len; ++i) {
4379
+ let boxes = nodeLabelClaimBoxes(nodeLabelGeometry(ordered[i]));
4380
+ // kept on the node, beside the other per-render drawing state, so
4381
+ // the reservation can be compared with the ink it is supposed to
4382
+ // cover: a box that is merely SMALL -- short by a descent, or a
4383
+ // few per cent narrow -- still hides nothing wrongly and still
4384
+ // shows no overlap anywhere a number does not happen to sit. The
4385
+ // desktop pins the same property pixel by pixel.
4386
+ ordered[i]._labelBoxes = boxes.length ? boxes : null;
4387
+ for (let b = 0; b < boxes.length; ++b) {
4388
+ numbers.occupy(boxes[b][0], boxes[b][1], boxes[b][2], boxes[b][3]);
4389
+ symbols.occupy(boxes[b][0], boxes[b][1], boxes[b][2], boxes[b][3]);
4390
+ }
4391
+ }
4392
+
4096
4393
  for (let i = 0, len = ordered.length; i !== len; ++i) {
4097
4394
  let d = ordered[i];
4098
4395
  // Branch length first, then confidence: they sit on opposite sides
@@ -4102,12 +4399,18 @@ function (root, d3, forester, phyloXml) {
4102
4399
  let b = branchMarkBox(d, 'bl', d._blText, fontPx);
4103
4400
  if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4104
4401
  d._blText = '';
4402
+ ++_branchMarksHidden;
4403
+ } else {
4404
+ stash(d, 'bl', b);
4105
4405
  }
4106
4406
  }
4107
4407
  if (d._confText !== '') {
4108
4408
  let b = branchMarkBox(d, 'conf', d._confText, fontPx);
4109
4409
  if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4110
4410
  d._confText = '';
4411
+ ++_branchMarksHidden;
4412
+ } else {
4413
+ stash(d, 'conf', b);
4111
4414
  }
4112
4415
  }
4113
4416
  // Branch EVENTS are branch-anchored too, half a branch from the
@@ -4119,6 +4422,9 @@ function (root, d3, forester, phyloXml) {
4119
4422
  let b = branchMarkBox(d, 'event', d._eventText, fontPx);
4120
4423
  if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4121
4424
  d._eventText = '';
4425
+ ++_branchMarksHidden;
4426
+ } else {
4427
+ stash(d, 'event', b);
4122
4428
  }
4123
4429
  }
4124
4430
  if (d._suppDot) {
@@ -4132,6 +4438,7 @@ function (root, d3, forester, phyloXml) {
4132
4438
  : [d.y + (0.5 * (d.parent.y - d.y)), d.x];
4133
4439
  if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
4134
4440
  d._suppDot = false;
4441
+ ++_branchMarksHidden;
4135
4442
  }
4136
4443
  }
4137
4444
  }
@@ -5165,8 +5472,12 @@ function (root, d3, forester, phyloXml) {
5165
5472
  showTaxonomyButton: 'shown automatically when the tree has taxonomies',
5166
5473
  showSequenceButton: 'shown automatically when the tree has sequences',
5167
5474
  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',
5475
+ // These two are ALWAYS offered, deliberately: they do nothing on a tree
5476
+ // that needs neither, and a control that comes and goes between trees
5477
+ // is worse than one that is simply there. (Both messages used to
5478
+ // promise a conditional appearance the code had already rejected.)
5479
+ showDynahideButton: 'the Auto-hide Labels checkbox is always shown',
5480
+ showShortenNodeNamesButton: 'the Short Names checkbox is always shown; it starts on when the tree has long node names',
5170
5481
  showExternalLabelsButton: 'always shown',
5171
5482
  showInternalLabelsButton: 'shown automatically when the tree has internal node data',
5172
5483
  // the collapse-by-depth / rank / feature feature is gone
@@ -9913,7 +10224,9 @@ function (root, d3, forester, phyloXml) {
9913
10224
  }
9914
10225
  if (_state.phylogram && !_state.alignPhylogram) {
9915
10226
  if (_state.unrootedDisplay) {
9916
- toCladegram();
10227
+ toCladegram(); // unrooted cannot align
10228
+ } else if (_state.circularDisplay) {
10229
+ toCladegram(); // circular is already showing the aligned type
9917
10230
  } else {
9918
10231
  toAlignedPhylogram();
9919
10232
  }
@@ -10017,6 +10330,274 @@ function (root, d3, forester, phyloXml) {
10017
10330
  }
10018
10331
 
10019
10332
  // The cheat sheet: every combo above, then the plain keys.
10333
+ // ===================== The control-panel cheat sheet =====================
10334
+ //
10335
+ // Every control the panel is SHOWING, in the order it shows them, each
10336
+ // beside the sentence that already explains it. The desktop's
10337
+ // ControlPanelCheatSheet, and built the same way on one principle: nothing
10338
+ // is written twice. A row's picture is the control's own glyph, cloned;
10339
+ // a row's words are the tooltip the control already carries. So a new
10340
+ // control needs no edit here -- it needs a tooltip, and
10341
+ // test_trees/panel_descriptions.html fails by name for any visible control
10342
+ // without one.
10343
+ //
10344
+ // Two rules of Christian's, 2026-09-25, which the desktop follows too:
10345
+ // name and description are separated by a COLON, never a dash; and no live
10346
+ // VALUES appear -- a label reading "Font size: 6" is cut at the colon, so
10347
+ // the sheet says what the control is and never what it is currently set
10348
+ // to. "A printed 40% is wrong for every reader but the one who printed
10349
+ // it."
10350
+ //
10351
+ // Modeless, and placed beside the panel rather than over it: a sheet you
10352
+ // must close to touch the control it describes is half a sheet.
10353
+ const CHEAT_SHEET_DIALOG = 'aptx_cheatsheet';
10354
+ let _cheatSheetBody = null;
10355
+
10356
+ // this viewer's own panel, not another viewer's on the same page
10357
+ function cheatSheetPanel() {
10358
+ return (_container && _container.querySelector(':scope > .aptx-panel'))
10359
+ || document.querySelector('.aptx-panel');
10360
+ }
10361
+
10362
+ // The words for one control: its own title, or the title of the <label>
10363
+ // that names it -- which is a SIBLING, not an ancestor, for the select
10364
+ // menus (makeSelectMenu puts it there).
10365
+ function controlDescription(el) {
10366
+ let n = el;
10367
+ for (let i = 0; i < 4 && n; ++i) {
10368
+ let t = (n.getAttribute && (n.getAttribute('title') || n.getAttribute('aria-label'))) || '';
10369
+ if (t.trim() !== '') {
10370
+ return t.trim();
10371
+ }
10372
+ n = n.parentElement;
10373
+ }
10374
+ if (el.id) {
10375
+ let lab = document.querySelector('label[for="' + el.id + '"]');
10376
+ let t = lab ? (lab.getAttribute('title') || lab.getAttribute('aria-label') || '') : '';
10377
+ if (t.trim() !== '') {
10378
+ return t.trim();
10379
+ }
10380
+ }
10381
+ return '';
10382
+ }
10383
+
10384
+ // What the control is called, with any live value cut away. A checkbox is
10385
+ // named by its own text, a menu or slider by the label beside it, a glyph
10386
+ // button by nothing -- its picture is its name, and the row shows that.
10387
+ function controlName(el) {
10388
+ // ONE cut, at the end, over whatever source the name came from.
10389
+ //
10390
+ // It used to be applied only to the two sources that could carry a
10391
+ // value -- a <label for> and an aria-label -- and not to a checkbox's
10392
+ // own text or a button's value. That left "no sheet name contains a
10393
+ // colon" meaning two different things on the two programs: on the
10394
+ // desktop every name IS a cut label, so the rule asserts the cut ran
10395
+ // and can never fire on correct output; here it would ALSO have
10396
+ // constrained what a control may be called, and a checkbox legitimately
10397
+ // labelled "Ratio 1:1" would have failed it. Cutting everywhere makes
10398
+ // the invariant mean the same thing on both sides.
10399
+ //
10400
+ // What the cut is doing here, exactly: our labels read "Color by:" and
10401
+ // "Shape:", so it removes a TRAILING colon, and removing the cut fails
10402
+ // the sheet's value rule by name ("Color by:"). What it is NOT doing
10403
+ // here is removing a value -- nothing follows a colon on this panel,
10404
+ // unlike the desktop's "Font size: 6". So it is exercised but for the
10405
+ // lesser of its two reasons. (Said precisely because the looser
10406
+ // version of this note -- "a no-op, removing it passes every check" --
10407
+ // was true for about an hour and then was not, once the desktop's
10408
+ // sharper value rule was adopted here.)
10409
+ return cutAtValue(rawControlName(el));
10410
+ }
10411
+
10412
+ // "Font size: 6" -> "Font size". The colon is where a value starts, and a
10413
+ // value on a sheet is wrong for everyone but whoever was looking when it
10414
+ // was made.
10415
+ function cutAtValue(name) {
10416
+ return name ? name.split(':')[0].trim() : '';
10417
+ }
10418
+
10419
+ // What the control is called, before the cut. A checkbox is named by its
10420
+ // own text, a menu or slider by the label beside it, a glyph button by
10421
+ // nothing -- its picture is its name, and the row shows that.
10422
+ function rawControlName(el) {
10423
+ if (el.type === 'checkbox' || el.type === 'radio') {
10424
+ let span = el.parentElement ? el.parentElement.querySelector('span') : null;
10425
+ if (span && span.textContent.trim() !== '') {
10426
+ return span.textContent.trim();
10427
+ }
10428
+ }
10429
+ if (el.tagName === 'INPUT' && el.value && (el.type === 'button' || el.type === 'submit')) {
10430
+ return el.value.trim();
10431
+ }
10432
+ if (el.id) {
10433
+ let lab = document.querySelector('label[for="' + el.id + '"]');
10434
+ if (lab && lab.textContent.trim() !== '') {
10435
+ return lab.textContent.trim();
10436
+ }
10437
+ }
10438
+ // a menu with no visible label of its own says what it is here
10439
+ let aria = el.getAttribute('aria-label');
10440
+ if (aria && aria.trim() !== '') {
10441
+ return aria.trim();
10442
+ }
10443
+ // and a text button -- the hide toggle, the reset chips -- is its text
10444
+ let own = (el.textContent || '').trim();
10445
+ if (own !== '' && el.tagName === 'BUTTON' && !el.querySelector('svg')) {
10446
+ return own;
10447
+ }
10448
+ // a segmented button drawn as TEXT rather than a glyph (Time / Div) is
10449
+ // named by its own label, which is the only thing on it
10450
+ let seg = el.closest ? el.closest('label.aptx-seg') : null;
10451
+ if (seg && !seg.querySelector('svg')) {
10452
+ let segText = (seg.textContent || '').trim();
10453
+ if (segText !== '') {
10454
+ return segText;
10455
+ }
10456
+ }
10457
+ return '';
10458
+ }
10459
+
10460
+ // What the user actually sees and clicks. A segmented button's <input> is
10461
+ // 0x0 and transparent -- the visible thing is the <label> around it, which
10462
+ // carries the glyph and the tooltip -- so asking the input whether it is
10463
+ // on the screen answered no for all eight of them: the three layouts, the
10464
+ // three display types and the time/divergence pair were absent from the
10465
+ // sheet entirely (Christian, 2026-09-28).
10466
+ function controlSurface(el) {
10467
+ let box = el.getBoundingClientRect();
10468
+ if (box.width > 0 || box.height > 0) {
10469
+ return el;
10470
+ }
10471
+ let label = el.closest ? el.closest('label') : null;
10472
+ if (label) {
10473
+ let lb = label.getBoundingClientRect();
10474
+ if (lb.width > 0 || lb.height > 0) {
10475
+ return label;
10476
+ }
10477
+ }
10478
+ return null;
10479
+ }
10480
+
10481
+ function cheatSheetRows() {
10482
+ let panel = cheatSheetPanel();
10483
+ if (!panel) {
10484
+ return [];
10485
+ }
10486
+ let rows = [];
10487
+ let seen = new Set();
10488
+ let all = panel.querySelectorAll('input, button, select');
10489
+ for (let i = 0; i < all.length; ++i) {
10490
+ let el = all[i];
10491
+ // what the panel is SHOWING: a control the tree gives no data for
10492
+ // is not on the screen and does not belong on the sheet. A
10493
+ // DISABLED one is on the screen, and saying what it is is how a
10494
+ // reader learns why it is greyed.
10495
+ let surface = el.disabled ? (controlSurface(el) || el) : controlSurface(el);
10496
+ if (!surface) {
10497
+ continue;
10498
+ }
10499
+ let words = controlDescription(el);
10500
+ if (words === '' || seen.has(el)) {
10501
+ continue;
10502
+ }
10503
+ seen.add(el);
10504
+ // the glyph belongs to the surface, not to a 0x0 input inside it
10505
+ let svg = surface.querySelector ? surface.querySelector('svg.aptx-glyph') : null;
10506
+ rows.push({name: controlName(el), words: words, glyph: svg});
10507
+ }
10508
+ return rows;
10509
+ }
10510
+
10511
+ function renderCheatSheet(body) {
10512
+ // everything but the standing lead paragraph
10513
+ let keep = body.querySelector('.aptx-cheat-lead');
10514
+ while (body.firstChild) {
10515
+ body.removeChild(body.firstChild);
10516
+ }
10517
+ if (keep) {
10518
+ body.appendChild(keep);
10519
+ }
10520
+ let rows = cheatSheetRows();
10521
+ rows.forEach(function (row) {
10522
+ let line = document.createElement('div');
10523
+ line.className = 'aptx-dialog-line aptx-cheat-line';
10524
+ let key = document.createElement('span');
10525
+ key.className = 'aptx-dialog-key aptx-cheat-key';
10526
+ if (row.glyph) {
10527
+ let g = row.glyph.cloneNode(true);
10528
+ g.removeAttribute('class');
10529
+ g.setAttribute('class', 'aptx-glyph aptx-cheat-glyph');
10530
+ key.appendChild(g);
10531
+ }
10532
+ if (row.name) {
10533
+ let n = document.createElement('span');
10534
+ n.className = 'aptx-cheat-name';
10535
+ n.textContent = row.name;
10536
+ key.appendChild(n);
10537
+ }
10538
+ let val = document.createElement('span');
10539
+ val.className = 'aptx-dialog-val';
10540
+ val.textContent = row.words;
10541
+ line.appendChild(key);
10542
+ line.appendChild(val);
10543
+ body.appendChild(line);
10544
+ });
10545
+ if (rows.length === 0) {
10546
+ let empty = document.createElement('p');
10547
+ empty.className = 'aptx-shortcuts-note';
10548
+ empty.textContent = 'No controls are showing.';
10549
+ body.appendChild(empty);
10550
+ }
10551
+ }
10552
+
10553
+ // The panel is rebuilt on every render, so an open sheet is re-read from
10554
+ // it rather than left describing a panel that has changed underneath.
10555
+ function refreshCheatSheet() {
10556
+ if (_cheatSheetBody && _cheatSheetBody.isConnected) {
10557
+ renderCheatSheet(_cheatSheetBody);
10558
+ }
10559
+ }
10560
+
10561
+ function showCheatSheetDialog() {
10562
+ let shell = makeDialogShell(CHEAT_SHEET_DIALOG, 'Control panel', 460);
10563
+ shell.body.classList.add('aptx-shortcuts');
10564
+ shell.body.classList.add('aptx-cheat');
10565
+ // says what the reader is looking at, as the desktop's sheet does
10566
+ let lead = document.createElement('p');
10567
+ lead.className = 'aptx-cheat-lead';
10568
+ lead.textContent = 'Every control the panel is showing, with the icon it draws and what it does.';
10569
+ shell.body.appendChild(lead);
10570
+ _cheatSheetBody = shell.body;
10571
+ renderCheatSheet(shell.body);
10572
+ shell.dialog.addEventListener('close', function () {
10573
+ if (_cheatSheetBody === shell.body) {
10574
+ _cheatSheetBody = null;
10575
+ }
10576
+ });
10577
+ // modeless, as the colour picker is: the point is to read a row and
10578
+ // click the control it names without closing anything
10579
+ shell.dialog.show();
10580
+ placeBesidePanel(shell.dialog);
10581
+ }
10582
+
10583
+ // Beside the panel, never over it, and inside the window.
10584
+ function placeBesidePanel(dialog) {
10585
+ let panel = cheatSheetPanel();
10586
+ let box = panel ? panel.getBoundingClientRect() : null;
10587
+ let w = dialog.offsetWidth;
10588
+ let h = dialog.offsetHeight;
10589
+ let left = box ? (box.right + 10) : 10;
10590
+ if (left + w > window.innerWidth - 6) {
10591
+ left = box ? (box.left - w - 10) : 6;
10592
+ }
10593
+ setStyles(dialog, {
10594
+ 'position': 'fixed',
10595
+ 'margin': '0',
10596
+ 'left': Math.max(6, Math.min(window.innerWidth - w - 6, left)) + 'px',
10597
+ 'top': Math.max(6, Math.min(window.innerHeight - h - 6, box ? box.top : 10)) + 'px'
10598
+ });
10599
+ }
10600
+
10020
10601
  function showShortcutsDialog() {
10021
10602
  let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
10022
10603
  shell.body.classList.add('aptx-shortcuts');
@@ -10353,7 +10934,27 @@ function (root, d3, forester, phyloXml) {
10353
10934
  return;
10354
10935
  }
10355
10936
  let existing = _svgGroup.select('g.aptx-domains');
10937
+ // An architecture goes WITH ITS NAME, and is left out when the name
10938
+ // is. domainsShown() already ties the whole track to external labels
10939
+ // being on; this is the same rule per tip, and it became necessary the
10940
+ // day unrooted started hiding crowded names -- before that every tip
10941
+ // in every layout kept its label, so a nameless architecture could not
10942
+ // happen. Measured on apaf when it could: 7 of 31 tips drew an
10943
+ // architecture with no name beside it, identifiable only by which
10944
+ // spoke it sat on. (The desktop reached the same rule from the other
10945
+ // end: a tip IMAGE painted before its label's claim was left orphaned
10946
+ // under a neighbour's name.)
10356
10947
  let tips = (domainsShown() && _root) ? displayedTips().filter(function (d) {
10948
+ // Keyed on "the RULE hid this name", not on "there is no name".
10949
+ // The two part company the moment the user switches the name
10950
+ // fields off: nothing is hidden by the rule then, every label is
10951
+ // empty, and every architecture should still be drawn. Keying on
10952
+ // the empty text took all 31 of apaf's architectures away with the
10953
+ // Node Name checkbox -- shipped, and caught by the desktop
10954
+ // warning us they had met the same reading of their own fix.
10955
+ if (d._labelDropped) {
10956
+ return false;
10957
+ }
10357
10958
  return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
10358
10959
  }) : [];
10359
10960
  let f = tips.length > 0 ? domainScale() : 0;
@@ -11519,7 +12120,8 @@ function (root, d3, forester, phyloXml) {
11519
12120
 
11520
12121
  // what the viewer writes onto a tree's nodes, left off a tree cut out of it
11521
12122
  const VIEW_NODE_FIELDS = ['viewId', 'collapsed', 'x', 'y', 'x0', 'y0', 'id', 'hide', 'hasVis', 'distToRoot',
11522
- 'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText'];
12123
+ 'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText',
12124
+ '_labelBoxes', '_labelDropped', '_markBoxes'];
11523
12125
 
11524
12126
  // the whole tree's tip count, counted again only when the tree changes
11525
12127
  // (every change makes new _basicTreeProperties)
@@ -12315,16 +12917,27 @@ function (root, d3, forester, phyloXml) {
12315
12917
  scheduleUpdate(null, 0);
12316
12918
  }
12317
12919
 
12920
+ // In CIRCULAR these two leave the aligned/unaligned flag alone. That
12921
+ // layout aligns whatever the flag says, so the button shown as chosen
12922
+ // there is "aligned" regardless -- and writing the flag from it would
12923
+ // silently change the user's choice for the layouts that CAN express it.
12924
+ // A user who likes the plain phylogram in rectangular, steps into
12925
+ // circular, flips to the cladogram and back, and returns, would have
12926
+ // found the aligned one waiting.
12318
12927
  function toAlignedPhylogram() {
12319
12928
  _state.phylogram = true;
12320
- _state.alignPhylogram = true;
12929
+ if (!_state.circularDisplay) {
12930
+ _state.alignPhylogram = true;
12931
+ }
12321
12932
  setDisplayTypeButtons();
12322
12933
  scheduleUpdate(null, 0);
12323
12934
  }
12324
12935
 
12325
12936
  function toCladegram() {
12326
12937
  _state.phylogram = false;
12327
- _state.alignPhylogram = false;
12938
+ if (!_state.circularDisplay) {
12939
+ _state.alignPhylogram = false;
12940
+ }
12328
12941
  setDisplayTypeButtons();
12329
12942
  scheduleUpdate(null, 0);
12330
12943
  }
@@ -12356,19 +12969,40 @@ function (root, d3, forester, phyloXml) {
12356
12969
  // against; an alignment / time axis is inherently horizontal. Synced
12357
12970
  // BEFORE the zoom-row early return, and (via updateButtonEnabledState)
12358
12971
  // on every render, so subtree switches keep them honest.
12972
+ // WHICH of the two phylogram buttons is dead depends on the layout,
12973
+ // and it is the one the layout CANNOT do -- not the one it always
12974
+ // does. Circular always carries its external labels out to a common
12975
+ // ring on dashed connectors, whatever is chosen, so the circular
12976
+ // phylogram IS the aligned one: the unavailable option there is the
12977
+ // UNALIGNED phylogram. Unrooted is the other way round -- it has no
12978
+ // common edge to align to, so what it cannot do is align.
12979
+ //
12980
+ // (Greying the aligned button in circular was the first attempt, on
12981
+ // the grounds that picking it changed nothing. True, but backwards:
12982
+ // it disabled the button that describes what the layout does and left
12983
+ // live the one it cannot do, and the panel then showed "phylogram"
12984
+ // selected over a picture whose labels are plainly aligned. Christian
12985
+ // spotted the inconsistency, 2026-09-27.)
12986
+ let measured = _basicTreeProperties.branchLengths === true;
12359
12987
  let phyBtn = byId(PHYLOGRAM_BUTTON);
12360
12988
  if (phyBtn) {
12361
- phyBtn.disabled = _basicTreeProperties.branchLengths !== true;
12989
+ phyBtn.disabled = !measured || _state.circularDisplay;
12990
+ phyBtn.title = PHYLOGRAM_WHAT;
12362
12991
  }
12363
12992
  let alignBtn = byId(PHYLOGRAM_ALIGNED_BUTTON);
12364
12993
  if (alignBtn) {
12365
- alignBtn.disabled = _state.unrootedDisplay || _basicTreeProperties.branchLengths !== true;
12994
+ alignBtn.disabled = !measured || _state.unrootedDisplay;
12995
+ alignBtn.title = PHYLOGRAM_ALIGNED_WHAT;
12366
12996
  }
12997
+ // Which button is SHOWN as chosen is layout-dependent too, and this
12998
+ // was only re-synced when the display type changed -- so switching
12999
+ // layout left the previous layout's answer on the screen.
13000
+ setDisplayTypeButtons();
12367
13001
  // 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.
13002
+ // in unrooted, where the every-k-th tip-label decimation does not run --
13003
+ // but it also governs the crowded branch-data rule, and now the
13004
+ // unrooted tip-label rule, both of which do run there, so greying it
13005
+ // out left the user unable to switch off what they could see.
12372
13006
  let msaCb = byId(MSA_CB);
12373
13007
  if (msaCb) {
12374
13008
  msaCb.disabled = radialDisplay();
@@ -12585,7 +13219,15 @@ function (root, d3, forester, phyloXml) {
12585
13219
  // panel's accent: the checkbox item gets a box and its tooltip says how
12586
13220
  // many labels are shown. Called on every redraw, since the density
12587
13221
  // changes with the vertical zoom, the font size and the view.
12588
- function syncDynahideIndicator(active) {
13222
+ // Lights the toggle while it is actually taking something away, and says
13223
+ // what. The switch governs three rules, and the light has to ask all three
13224
+ // rather than whichever one was written first: the every-k-th thinning
13225
+ // (rectangular, by index against the row pitch), the overlap thinning
13226
+ // (both fans, by whether names would overprint) and the crowded
13227
+ // branch-data rule (every layout). Asking only the first two left it dark
13228
+ // over a circular tree with 201 numbers refused and every name drawn --
13229
+ // and over a RECTANGULAR one with 18 refused, which was never right.
13230
+ function syncDynahideIndicator() {
12589
13231
  let cb = byId(DYNAHIDE_CB);
12590
13232
  let item = cb ? cb.closest('.aptx-check') : null;
12591
13233
  if (!item) {
@@ -12594,10 +13236,23 @@ function (root, d3, forester, phyloXml) {
12594
13236
  if (item.dataset.baseTitle === undefined) {
12595
13237
  item.dataset.baseTitle = item.title;
12596
13238
  }
12597
- item.classList.toggle('aptx-check-active', !!active);
13239
+ let parts = [];
13240
+ if (!radialDisplay() && _dynahide_factor >= 2) {
13241
+ parts.push('1 in ' + _dynahide_factor + ' labels shown');
13242
+ } else if (radialDisplay() && _radialNamesHidden > 0) {
13243
+ parts.push(_radialNamesHidden + ' name' + (_radialNamesHidden === 1 ? '' : 's')
13244
+ + ' that would overprint');
13245
+ }
13246
+ if (_branchMarksHidden > 0) {
13247
+ // said only when it is happening: the clause used to be printed
13248
+ // unconditionally, which made a promise of every render
13249
+ parts.push(_branchMarksHidden + ' branch value' + (_branchMarksHidden === 1 ? '' : 's')
13250
+ + ' that would overlap');
13251
+ }
13252
+ let active = _state.dynahide === true && parts.length > 0;
13253
+ item.classList.toggle('aptx-check-active', active);
12598
13254
  item.title = active
12599
- ? item.dataset.baseTitle + ', hiding now: 1 in ' + _dynahide_factor
12600
- + ' labels shown, and branch data that would overlap'
13255
+ ? item.dataset.baseTitle + ', hiding now: ' + parts.join(', and ')
12601
13256
  : item.dataset.baseTitle;
12602
13257
  }
12603
13258
 
@@ -13236,6 +13891,14 @@ function (root, d3, forester, phyloXml) {
13236
13891
  + glyphDot(50, 29, 5.5);
13237
13892
  }
13238
13893
 
13894
+ // a reference card: the outline, and the rows on it
13895
+ function glyphCheatSheet() {
13896
+ return '<rect x="18" y="12" width="64" height="76" rx="8"/>'
13897
+ + glyphLine(32, 34, 68, 34)
13898
+ + glyphLine(32, 50, 68, 50)
13899
+ + glyphLine(32, 66, 56, 66);
13900
+ }
13901
+
13239
13902
  function glyphSun() {
13240
13903
  let s = glyphDot(50, 50, 20);
13241
13904
  for (let i = 0; i < 8; ++i) {
@@ -13304,6 +13967,7 @@ function (root, d3, forester, phyloXml) {
13304
13967
  case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
13305
13968
  case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
13306
13969
  case 'info': sw = 7; cap = 'round'; body = glyphInfo(); break;
13970
+ case 'cheat_sheet': sw = 7; cap = 'round'; body = glyphCheatSheet(); break;
13307
13971
  case 'sun': body = glyphSun(); break;
13308
13972
  case 'moon': body = glyphMoon(); break;
13309
13973
  default: throw new Error('unknown control-panel glyph: ' + kind);
@@ -13502,6 +14166,18 @@ function (root, d3, forester, phyloXml) {
13502
14166
  + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
13503
14167
  + '.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
14168
  + '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
14169
+ // the cheat sheet: a scrolling card of glyph + name : description
14170
+ // 31 rows is taller than a short window; the card scrolls rather
14171
+ // than running off the bottom, and the title bar stays put
14172
+ + '#' + CHEAT_SHEET_DIALOG + ' { max-height:min(78vh, 760px); display:flex; flex-direction:column; }'
14173
+ + '#' + CHEAT_SHEET_DIALOG + ' .aptx-dialog-body { min-height:0; }'
14174
+ + '.aptx-cheat-lead { margin:0 0 9px; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
14175
+ + '.aptx-cheat .aptx-dialog-line { padding:3px 0; align-items:flex-start; }'
14176
+ + '.aptx-cheat .aptx-cheat-key { flex:0 0 38%; display:flex; align-items:center; gap:6px; color:var(--p-ink); }'
14177
+ + '.aptx-cheat .aptx-cheat-glyph { width:14px; height:14px; flex:none; color:var(--p-muted); }'
14178
+ + '.aptx-cheat .aptx-cheat-name { overflow-wrap:anywhere; }'
14179
+ + '.aptx-cheat .aptx-dialog-val { color:var(--p-muted); line-height:1.35; }'
14180
+ + '.aptx-cheat .aptx-cheat-key:not(:empty)::after { content:":"; color:var(--p-faint); }'
13505
14181
  // representative tips: the input dialog and its result
13506
14182
  + '.aptx-reps { line-height:1.35; }'
13507
14183
  + '.aptx-reps-lead { margin:0 0 4px; }'
@@ -13573,7 +14249,15 @@ function (root, d3, forester, phyloXml) {
13573
14249
  + '.aptx-tip .aptx-dialog-line { padding:1px 0; }'
13574
14250
  + '.aptx-tip .aptx-dialog-line + .aptx-dialog-line { border-top:0; }'
13575
14251
  + '.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; }'
14252
+ // WRAPS. The title's longest word cannot break, so on a narrow
14253
+ // panel the buttons had nowhere to go and overflowed the padding:
14254
+ // with four of them they ended 2.4 px past the panel's own right
14255
+ // edge, and squeezed the title into two ragged centred lines on
14256
+ // the way. Wrapping puts them on their own right-aligned row
14257
+ // instead, which also gives the title back its full width -- so
14258
+ // "Archaeopteryx.js 3.15.0" sits on ONE line where it used to
14259
+ // break in two.
14260
+ + '.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
14261
  + '.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
14262
  + '.aptx-panel .' + PROGNAMELINK + ' { background:none; padding:0; margin:0; cursor:pointer;'
13579
14263
  + ' font-family:inherit; font-weight:inherit; letter-spacing:inherit; }'
@@ -13824,6 +14508,18 @@ function (root, d3, forester, phyloXml) {
13824
14508
  });
13825
14509
  actions.appendChild(infoBtn);
13826
14510
 
14511
+ let sheetBtn = document.createElement('button');
14512
+ sheetBtn.type = 'button';
14513
+ sheetBtn.className = 'aptx-info-btn';
14514
+ sheetBtn.title = 'Control panel: what each control does';
14515
+ sheetBtn.setAttribute('aria-label', 'Control panel cheat sheet');
14516
+ sheetBtn.innerHTML = makeGlyph('cheat_sheet');
14517
+ sheetBtn.addEventListener('click', function (e) {
14518
+ e.stopPropagation();
14519
+ showCheatSheetDialog();
14520
+ });
14521
+ actions.appendChild(sheetBtn);
14522
+
13827
14523
  let progName = header.querySelector('.' + PROGNAMELINK);
13828
14524
  if (progName) {
13829
14525
  // Clicking the title used to navigate away to the project site;
@@ -13895,6 +14591,10 @@ function (root, d3, forester, phyloXml) {
13895
14591
  fitPanelSections(panel, name);
13896
14592
  }
13897
14593
  savePanelSections();
14594
+ // a folded section's controls are off the screen, so they
14595
+ // leave the cheat sheet: it describes what the panel is
14596
+ // showing, and folding does not go through a tree render
14597
+ refreshCheatSheet();
13898
14598
  });
13899
14599
  }
13900
14600
  // What arrived open counts as opened, oldest at the BOTTOM: a panel is
@@ -14683,7 +15383,8 @@ function (root, d3, forester, phyloXml) {
14683
15383
  ['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
14684
15384
  ['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
14685
15385
  ['License', LICENSE_WEBSITE, LICENSE_NAME],
14686
- ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']].forEach(function (row) {
15386
+ ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')'],
15387
+ ['Control panel', null, 'what each control does']].forEach(function (row) {
14687
15388
  let line = document.createElement('div');
14688
15389
  line.className = 'aptx-dialog-line';
14689
15390
  let key = document.createElement('span');
@@ -14697,12 +15398,14 @@ function (root, d3, forester, phyloXml) {
14697
15398
  a.target = '_blank';
14698
15399
  a.rel = 'noopener noreferrer';
14699
15400
  } else {
14700
- // the shortcuts row opens the cheat sheet in place of the About box
15401
+ // the two rows with no URL open another dialog in place of
15402
+ // this one: the key list, and the control-panel sheet
15403
+ let opener = row[0] === 'Control panel' ? showCheatSheetDialog : showShortcutsDialog;
14701
15404
  a.href = '#';
14702
15405
  a.addEventListener('click', function (e) {
14703
15406
  e.preventDefault();
14704
15407
  shell.dialog.close();
14705
- showShortcutsDialog();
15408
+ opener();
14706
15409
  });
14707
15410
  }
14708
15411
  a.textContent = row[2];
@@ -15273,7 +15976,11 @@ function (root, d3, forester, phyloXml) {
15273
15976
  function makeProgramDesc() {
15274
15977
  let h = "";
15275
15978
  h = h.concat('<div class=' + PROG_NAME + '>');
15276
- h = h.concat('<button type="button" class="' + PROGNAMELINK + '" title="About ' + NAME + '">' + NAME + ' ' + VERSION + '</button>');
15979
+ // aria-label without the version: the cheat sheet names a control
15980
+ // by this, and a version is a value -- it belongs in the About box
15981
+ // it opens, not in a row that says what the button is
15982
+ h = h.concat('<button type="button" class="' + PROGNAMELINK + '" aria-label="' + NAME
15983
+ + '" title="About ' + NAME + '">' + NAME + ' ' + VERSION + '</button>');
15277
15984
  h = h.concat('</div>');
15278
15985
  return h;
15279
15986
  }
@@ -15424,14 +16131,17 @@ function (root, d3, forester, phyloXml) {
15424
16131
  // desktop
15425
16132
  let layoutGroup = 'layout_control_radio';
15426
16133
  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)'));
16134
+ h = h.concat(makeSegment(makeGlyph('rectangular'), LAYOUT_RECT_BUTTON, layoutGroup,
16135
+ 'rectangular layout: the root at the left, the tips in even rows down the page'));
16136
+ h = h.concat(makeSegment(makeGlyph('circular'), LAYOUT_CIRC_BUTTON, layoutGroup,
16137
+ 'circular layout: the tree as a fan, every tip label carried out to a common ring'));
16138
+ h = h.concat(makeSegment(makeGlyph('unrooted'), LAYOUT_UNROOTED_BUTTON, layoutGroup,
16139
+ 'unrooted layout: equal-angle, drawing no root branch at all'));
15430
16140
  h = h.concat('</div>');
15431
16141
  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)'));
16142
+ h = h.concat(makeSegment(makeGlyph('phylogram'), PHYLOGRAM_BUTTON, radioGroup, PHYLOGRAM_WHAT));
16143
+ h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, PHYLOGRAM_ALIGNED_WHAT));
16144
+ h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, CLADOGRAM_WHAT));
15435
16145
  h = h.concat('</div>');
15436
16146
  // The branch SCALE, when the tree states two different things: its
15437
16147
  // nodes' dates, and its branches' divergence. Hidden otherwise,
@@ -15514,7 +16224,7 @@ function (root, d3, forester, phyloXml) {
15514
16224
  // "Auto-hide Labels" matches the desktop, which renamed the historical
15515
16225
  // "Dyna Hide" because that named the mechanism rather than what the user
15516
16226
  // 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));
16227
+ 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
16228
  opts.push(makeCheckboxItem('Short Names', SHORTEN_NODE_NAME_CB, 'to shorten long node names'));
15519
16229
  if (_basicTreeProperties.confidences) {
15520
16230
  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'));
@@ -15594,7 +16304,8 @@ function (root, d3, forester, phyloXml) {
15594
16304
  h = h.concat('<fieldset>');
15595
16305
  h = h.concat('<input type="button" value="Download" name="' + DOWNLOAD_BUTTON + '" title="download/export tree in a selected format" id="' + DOWNLOAD_BUTTON + '">');
15596
16306
  //h = h.concat('<br>');
15597
- h = h.concat('<select name="' + EXPORT_FORMAT_SELECT + '" id="' + EXPORT_FORMAT_SELECT + '">');
16307
+ h = h.concat('<select name="' + EXPORT_FORMAT_SELECT + '" id="' + EXPORT_FORMAT_SELECT
16308
+ + '" aria-label="Download format" title="the file format the Download button writes">');
15598
16309
  if (pngExportAvailable()) {
15599
16310
  h = h.concat('<option value="' + PNG_EXPORT_FORMAT + '">' + PNG_EXPORT_FORMAT + '</option>');
15600
16311
  }
@@ -15744,8 +16455,10 @@ function (root, d3, forester, phyloXml) {
15744
16455
  let h = "";
15745
16456
  h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
15746
16457
  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>');
16458
+ h = h.concat('<select class="aptx-search-field" name="' + fsel + '" id="' + fsel
16459
+ + '" aria-label="' + label + ' field" title="the field to search in"></select>');
16460
+ h = h.concat('<select class="aptx-search-mode" name="' + msel + '" id="' + msel
16461
+ + '" aria-label="' + label + ' match" title="how to match"></select>');
15749
16462
  h = h.concat('</div>');
15750
16463
  h = h.concat('<div class="aptx-search-row">');
15751
16464
  h = h.concat('<input class="aptx-search-value" autocomplete="off" title="' + valTip + '" type="text" name="' + val + '" id="' + val + '">');
@@ -15952,9 +16665,15 @@ function (root, d3, forester, phyloXml) {
15952
16665
  }
15953
16666
 
15954
16667
  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);
16668
+ // In circular the labels ARE aligned, whatever alignPhylogram says, so
16669
+ // that is the button shown as chosen: the panel describes the picture.
16670
+ // The flag itself is left alone -- it is the user's choice for the
16671
+ // layouts that can express it, and returning to rectangular restores
16672
+ // what they picked there.
16673
+ let alignedLook = _state.phylogram && (_state.alignPhylogram || _state.circularDisplay);
16674
+ setRadioButtonValue(PHYLOGRAM_BUTTON, _state.phylogram && !alignedLook);
16675
+ setRadioButtonValue(CLADOGRAM_BUTTON, !_state.phylogram);
16676
+ setRadioButtonValue(PHYLOGRAM_ALIGNED_BUTTON, alignedLook);
15958
16677
  setCheckboxValue(LAYOUT_CIRC_BUTTON, _state.circularDisplay);
15959
16678
  setCheckboxValue(LAYOUT_UNROOTED_BUTTON, _state.unrootedDisplay);
15960
16679
  setCheckboxValue(LAYOUT_RECT_BUTTON, !radialDisplay());
@@ -15975,6 +16694,7 @@ function (root, d3, forester, phyloXml) {
15975
16694
 
15976
16695
  function updateButtonEnabledState() {
15977
16696
  syncZoomRowButtons(); // layout- and tree-dependent disables track every render
16697
+ refreshCheatSheet(); // an open sheet describes the panel as it is now
15978
16698
  // the uncollapse-all button lives only while something is collapsed, as on the desktop
15979
16699
  ((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
15980
16700
  if (_in_subtree) {