archaeopteryx 3.14.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.14.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.14.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';
@@ -1481,6 +1495,26 @@ function (root, d3, forester, phyloXml) {
1481
1495
  // so the guess was what the reader saw every time.
1482
1496
  const HOVER_READOUT_GAP = 14;
1483
1497
 
1498
+ // One way to open an external page. There were three: window.open + a
1499
+ // win.focus() that throws when a popup blocker hands back null and which
1500
+ // passed no noopener, an <a target="_blank" rel="noopener noreferrer">,
1501
+ // and a third for the domain link. Three security postures for one job,
1502
+ // and a change to the policy would have had to find all of them.
1503
+ //
1504
+ // noopener: the opened page must not get a handle on this window.
1505
+ // noreferrer with it, since a tree can come from anywhere and its URL is
1506
+ // not the external site's business.
1507
+ function openExternal(url) {
1508
+ if (!url) {
1509
+ return null;
1510
+ }
1511
+ let win = window.open(url, '_blank', 'noopener,noreferrer');
1512
+ if (win) {
1513
+ win.focus(); // a blocked popup hands back null; that is not an error
1514
+ }
1515
+ return win;
1516
+ }
1517
+
1484
1518
  function placeHoverReadout(el, event) {
1485
1519
  let w = el.offsetWidth;
1486
1520
  let h = el.offsetHeight;
@@ -2319,12 +2353,23 @@ function (root, d3, forester, phyloXml) {
2319
2353
 
2320
2354
  let _legendMeasureCtx = null;
2321
2355
 
2322
- function legendTextWidth(text, font) {
2356
+ // One lazily made canvas for every text measurement: the legend's widths,
2357
+ // the branch-data line metrics and the sequence logo's glyph boxes each
2358
+ // used to make their own.
2359
+ function measureCtx() {
2323
2360
  if (!_legendMeasureCtx) {
2324
2361
  _legendMeasureCtx = document.createElement('canvas').getContext('2d');
2325
2362
  }
2326
- _legendMeasureCtx.font = font;
2327
- return _legendMeasureCtx.measureText(text).width;
2363
+ return _legendMeasureCtx;
2364
+ }
2365
+
2366
+ function legendTextWidth(text, font) {
2367
+ let ctx = measureCtx();
2368
+ if (!ctx) {
2369
+ return 0;
2370
+ }
2371
+ ctx.font = font;
2372
+ return ctx.measureText(text).width;
2328
2373
  }
2329
2374
 
2330
2375
  // The card's row order and cap, the desktop's way: rank by count first
@@ -2935,7 +2980,6 @@ function (root, d3, forester, phyloXml) {
2935
2980
  return {source: link.source.data, target: link.target.data};
2936
2981
  });
2937
2982
  _overviewLinks = links; // the overview is built from these, not from the DOM
2938
- let gap = _state.nodeLabelGap;
2939
2983
 
2940
2984
  if (_state.phylogram === true) {
2941
2985
  _yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
@@ -3017,9 +3061,20 @@ function (root, d3, forester, phyloXml) {
3017
3061
  }
3018
3062
 
3019
3063
  if (_state.dynahide) {
3020
- if (_state.unrootedDisplay) {
3021
- // as on the desktop: no label auto-hiding in unrooted (there
3022
- // 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.
3023
3078
  forester.preOrderTraversal(_root, function (n) {
3024
3079
  n.hide = false;
3025
3080
  });
@@ -3031,8 +3086,6 @@ function (root, d3, forester, phyloXml) {
3031
3086
  });
3032
3087
  }
3033
3088
  }
3034
- syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
3035
-
3036
3089
  updateButtonEnabledState();
3037
3090
 
3038
3091
  // Only the nodes with something drawn for them individually get a
@@ -3099,11 +3152,10 @@ function (root, d3, forester, phyloXml) {
3099
3152
  return makeFoundOutlineColor(d) ? '0.9px' : null;
3100
3153
  })
3101
3154
  .style('paint-order', 'stroke')
3155
+ // anchor, dy and x all come from nodeLabelPlacement, which the
3156
+ // occupancy box is built from too
3102
3157
  .attr('text-anchor', function (d) {
3103
- if (radialDisplay()) {
3104
- return labelFlip(d) ? 'end' : 'start';
3105
- }
3106
- return d.children ? 'end' : 'start';
3158
+ return nodeLabelPlacement(d).anchor;
3107
3159
  })
3108
3160
  .attr('transform', function (d) {
3109
3161
  if (_state.unrootedDisplay) {
@@ -3131,42 +3183,10 @@ function (root, d3, forester, phyloXml) {
3131
3183
  return 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
3132
3184
  })
3133
3185
  .attr('dy', function (d) {
3134
- if (radialDisplay()) {
3135
- return '0.32em';
3136
- }
3137
- if (d.children) {
3138
- // the desktop's above-branch internal label: the baseline
3139
- // is lifted so the glyph bottoms sit just clear of the
3140
- // horizontal branch line, instead of straddling it
3141
- return -(Math.round(0.2 * _state.internalNodeFontSize) + 1) + 'px';
3142
- }
3143
- return 0.3 * _state.externalNodeFontSize + 'px';
3186
+ return nodeLabelPlacement(d).dy + 'px';
3144
3187
  })
3145
3188
  .attr('x', function (d) {
3146
- if (radialDisplay()) {
3147
- return labelFlip(d) ? -gap : gap;
3148
- }
3149
- if (!(d.children)) {
3150
- if (_state.phylogram && _state.alignPhylogram) {
3151
- return (-_yScale(d.distToRoot) + _w + gap);
3152
- } else {
3153
- return gap;
3154
- }
3155
- } else {
3156
- // right-aligned so the label ends just left of the node
3157
- // (the desktop's publication-style placement); a long
3158
- // label on a node near the root shifts right so its
3159
- // leading characters stay on the canvas rather than
3160
- // clipping off the left edge
3161
- let label = makeNodeLabel(d) || '';
3162
- let est = label.length * _state.internalNodeFontSize * 0.55;
3163
- let leftmost = d.y - gap - est;
3164
- let minX = 2 - _settings.rootOffset;
3165
- if (leftmost < minX) {
3166
- return -gap + (minX - leftmost);
3167
- }
3168
- return -gap;
3169
- }
3189
+ return nodeLabelPlacement(d).x;
3170
3190
  });
3171
3191
 
3172
3192
  node.select('text.bllabel')
@@ -3178,7 +3198,11 @@ function (root, d3, forester, phyloXml) {
3178
3198
  .attr('transform', function (d) {
3179
3199
  return radialDisplay() ? branchLabelTransform(d) : null;
3180
3200
  })
3181
- .attr('dy', '-.25em')
3201
+ // Every one of these three reads its offset from branchMarkPlacement,
3202
+ // the same function the occupancy boxes are built from: a box that is
3203
+ // not where the text is hides the wrong marks, and two copies of the
3204
+ // same number drift apart the first time one of them is edited.
3205
+ .attr('dy', branchMarkPlacement('bl', _state.branchDataFontSize).dy)
3182
3206
  .attr('x', function (d) {
3183
3207
  if (radialDisplay()) {
3184
3208
  return 0;
@@ -3199,7 +3223,7 @@ function (root, d3, forester, phyloXml) {
3199
3223
  .attr('transform', function (d) {
3200
3224
  return radialDisplay() ? branchLabelTransform(d) : null;
3201
3225
  })
3202
- .attr('dy', _state.branchDataFontSize)
3226
+ .attr('dy', branchMarkPlacement('conf', _state.branchDataFontSize).dy)
3203
3227
  .attr('x', function (d) {
3204
3228
  if (radialDisplay()) {
3205
3229
  return 0;
@@ -3235,7 +3259,7 @@ function (root, d3, forester, phyloXml) {
3235
3259
  .attr('transform', function (d) {
3236
3260
  return radialDisplay() ? branchLabelTransform(d) : null;
3237
3261
  })
3238
- .attr('dy', '-.25em')
3262
+ .attr('dy', branchMarkPlacement('event', _state.branchDataFontSize).dy)
3239
3263
  .attr('x', function (d) {
3240
3264
  if (radialDisplay()) {
3241
3265
  return 0;
@@ -3326,7 +3350,7 @@ function (root, d3, forester, phyloXml) {
3326
3350
  // a collapsed clade's label always shows, so its guide does too: from
3327
3351
  // past the wedge's farthest tip to the label column
3328
3352
  let collapsedGuides = alignedRect ? nodes.filter(function (d) {
3329
- return isCollapsed(d) && !(_state.dynahide && d.hide);
3353
+ return isCollapsed(d) && nameDrawn(d);
3330
3354
  }) : [];
3331
3355
  if (tipGuides || collapsedGuides.length > 0) {
3332
3356
  let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
@@ -3339,7 +3363,7 @@ function (root, d3, forester, phyloXml) {
3339
3363
  if (tipGuides) {
3340
3364
  guideStyle(ext.selectAll('path.aptx-tip-guide')
3341
3365
  .data(links.filter(function (d) {
3342
- return (!d.target.children && !(_state.dynahide && d.target.hide));
3366
+ return (!d.target.children && nameDrawn(d.target));
3343
3367
  }))
3344
3368
  .enter().append('path').attr('class', 'aptx-tip-guide'))
3345
3369
  .attr('d', function (d) {
@@ -3362,7 +3386,7 @@ function (root, d3, forester, phyloXml) {
3362
3386
  // tips while their labels show; a collapsed clade always (its label
3363
3387
  // always shows), from its wedge's farthest reach out to the ring
3364
3388
  let connected = nodes.filter(function (d) {
3365
- if (_state.dynahide && d.hide) {
3389
+ if (!nameDrawn(d)) {
3366
3390
  return false;
3367
3391
  }
3368
3392
  if (isCollapsed(d)) {
@@ -3820,6 +3844,420 @@ function (root, d3, forester, phyloXml) {
3820
3844
  // a support dot. Returns the nodes that need a <g> -- those with any of
3821
3845
  // them, and every collapsed clade (its wedge and label). The rest are
3822
3846
  // drawn entirely by drawTreeGeometry's batched paths.
3847
+ // ===================== Crowded branch data =====================
3848
+ // A branch-anchored mark is drawn only when its box does not overlap one
3849
+ // already granted in the same pass. Ported from the desktop's
3850
+ // LabelOccupancy (0.11.161) as a JOINT rule on Christian's word,
3851
+ // 2026-09-25: "match the desktop's new auto-hide rule for crowded branch
3852
+ // data". Neither side retunes it alone.
3853
+ //
3854
+ // It exists because the obvious rule does not work. The desktop built
3855
+ // "hide when the branch is drawn shorter than the text" first and measured
3856
+ // it: on an 825-tip tree a third-of-text threshold hid 285 numbers of
3857
+ // which only 51 collided, and still left 22 pile-ups. A number is
3858
+ // unreadable when it overlaps ANOTHER number, which depends on closeness
3859
+ // in y as much as in x, and branch length only sees x. Measured here
3860
+ // before adopting it: with Auto-hide Labels already ON, 240 of 267 drawn
3861
+ // support numbers overlapped another on flu_h5.xml (90%), 149 of 262 on
3862
+ // Adenoviridae, 13 of 119 on bcl2 -- our auto-hide only thinned TIP labels
3863
+ // then, and had never touched branch data.
3864
+ //
3865
+ // WHAT MUST MATCH, or the two viewers drop different marks:
3866
+ // - the tie-break: PREORDER, first claim wins, so the mark nearer the
3867
+ // root keeps its place. d3's descendants() is preorder already.
3868
+ // - TWO maps, numbers and symbols. The symbol sits ON the branch and the
3869
+ // numbers just above and below it, so one shared map would let a
3870
+ // branch's own symbol block its own numbers.
3871
+ // - a refused claim records NOTHING, so a rejected mark never blocks a
3872
+ // later one.
3873
+ // - a zero-size box is granted and not recorded.
3874
+ // - symbols are never shrunk to fit: in a size-scaled mode the diameter
3875
+ // IS the support, and a smaller dot would report weaker support.
3876
+ //
3877
+ // WHAT CANNOT MATCH, and is not meant to: the COUNTS. The boxes come from
3878
+ // each program's own font metrics, so the same tree at the same size drops
3879
+ // a similar but not identical set. The rule is the contract; the tally is
3880
+ // not.
3881
+ //
3882
+ // Where a branch-anchored mark sits, in the tree group's coordinates.
3883
+ // Mirrors the placement in syncOptionalNodeChildren exactly -- the branch
3884
+ // length back toward the parent and above the branch, the confidence at
3885
+ // the branch midpoint and below it -- because a box that is not where the
3886
+ // text is would hide the wrong marks.
3887
+ // The font's LINE box, which is what the marks are laid out with and what
3888
+ // the desktop measures too (FontMetrics.getHeight()). Not the per-glyph
3889
+ // ink box: two numbers collide when their lines overlap, and ink boxes
3890
+ // would let "1" slide under "0.81".
3891
+ //
3892
+ // Measured from the font rather than assumed. The fallback is what this
3893
+ // font actually reports at 9px -- ascent 9, descent 2, line 11 -- read off
3894
+ // a rendered label's getBBox rather than guessed.
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;
3910
+
3911
+ function branchFontMetrics(fontPx) {
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;
3918
+ }
3919
+ let ctx = measureCtx();
3920
+ ctx.font = fontPx + 'px ' + FONT_DEFAULTS;
3921
+ let m = ctx.measureText('0');
3922
+ let ascent = m.fontBoundingBoxAscent;
3923
+ let descent = m.fontBoundingBoxDescent;
3924
+ // Both, separately: a canvas that reports an ascent and no descent
3925
+ // would give height = ascent + undefined = NaN, every box would be
3926
+ // refused a claim as zero-sized, and the feature would turn itself off
3927
+ // with no error and a green suite.
3928
+ if (!(ascent > 0)) {
3929
+ ascent = fontPx;
3930
+ }
3931
+ if (!(descent >= 0)) {
3932
+ descent = fontPx * (2 / 9);
3933
+ }
3934
+ let metrics = {px: fontPx, ascent: ascent, descent: descent,
3935
+ height: ascent + descent};
3936
+ _fontMetricsCache.set(fontPx, metrics);
3937
+ return metrics;
3938
+ }
3939
+
3940
+ // Where a branch mark sits on its branch, read off the rendered elements
3941
+ // rather than inferred: the horizontal anchor and the `dy` the element is
3942
+ // drawn with. Getting either wrong is not a rounding error. A
3943
+ // middle-anchored box modelled as left-anchored is half its own width out,
3944
+ // which once let 44% of the granted numbers still overlap; and a branch
3945
+ // EVENT sits a quarter em ABOVE the line like the branch length, not a
3946
+ // font size below it like the confidence, so modelling it as a confidence
3947
+ // put its box on the wrong side of the branch -- the one side it could
3948
+ // never collide on.
3949
+ function branchMarkPlacement(kind, fontPx) {
3950
+ if (kind === 'conf') {
3951
+ return {mid: true, dy: fontPx};
3952
+ }
3953
+ if (kind === 'event') {
3954
+ // A radial layout draws every mark on ONE point, the branch's
3955
+ // midpoint, so a branch event and a branch length -- both a quarter
3956
+ // em above the line -- printed straight through each other. The
3957
+ // event is lifted a whole line clear of the length. The rectangular
3958
+ // layout already separates them along the branch (the length at the
3959
+ // parent end, the event at the middle), so there they share a line.
3960
+ return {mid: true, dy: radialDisplay()
3961
+ ? ((-0.25 * fontPx) - branchFontMetrics(fontPx).height)
3962
+ : (-0.25 * fontPx)};
3963
+ }
3964
+ return {mid: false, dy: -0.25 * fontPx}; // 'bl': start-anchored, a quarter em up
3965
+ }
3966
+
3967
+ // The incoming branch's MIDPOINT and screen direction, in the tree group's
3968
+ // own coordinates -- what a radial mark is actually drawn on, since
3969
+ // branchLabelTransform rotates to the spoke and translates half a branch
3970
+ // back along it. Circular: the leg runs along ONE spoke, so the midpoint is
3971
+ // the mean of the two radii at the same angle (an arc's chord is not on the
3972
+ // leg). Unrooted: the branch is a straight line, so it is the mean of the
3973
+ // two points. Both match the desktop's two call sites of
3974
+ // paintBranchDataRadial.
3975
+ function radialBranchMid(d) {
3976
+ let angle = spokeAngle(d);
3977
+ if (_state.unrootedDisplay) {
3978
+ if (d.ux === undefined) {
3979
+ return null;
3980
+ }
3981
+ let p = (d.parent && d.parent.ux !== undefined) ? d.parent : null;
3982
+ return p ? [0.5 * (d.ux + p.ux), 0.5 * (d.uy + p.uy), angle] : [d.ux, d.uy, angle];
3983
+ }
3984
+ let r = radialRadius(d.y);
3985
+ if (d.parent) {
3986
+ r = 0.5 * (r + radialRadius(d.parent.y));
3987
+ }
3988
+ let pt = polarXY(radialAngle(d.x), r);
3989
+ return [pt[0], pt[1], angle];
3990
+ }
3991
+
3992
+ function branchMarkBox(d, kind, text, fontPx) {
3993
+ let w = legendTextWidth(text, fontPx + 'px ' + FONT_DEFAULTS);
3994
+ let fm = branchFontMetrics(fontPx);
3995
+ let h = fm.height;
3996
+ let at = branchMarkPlacement(kind, fontPx);
3997
+ if (radialDisplay()) {
3998
+ // The mark rides the branch, so its box is the AXIS-ALIGNED bounds
3999
+ // of the rotated rectangle, centred where the text's own centre
4000
+ // lands: forester.rotatedLabelBox, which carries the desktop's
4001
+ // formula and the reasons it is the conservative one.
4002
+ let mid = radialBranchMid(d);
4003
+ if (!mid) {
4004
+ return null;
4005
+ }
4006
+ // `dy` shifts the BASELINE, so the box's centre sits half a line
4007
+ // below it; and the 180-degree flip that keeps the far half of the
4008
+ // fan upright carries this perpendicular offset with it.
4009
+ let off = at.dy + (0.5 * (fm.descent - fm.ascent));
4010
+ if (labelFlip(d)) {
4011
+ off = -off;
4012
+ }
4013
+ // every radial mark is middle-anchored at x=0, so only the
4014
+ // perpendicular offset moves its centre off the branch midpoint
4015
+ return forester.rotatedLabelBox(mid[0] - (Math.sin(mid[2]) * off),
4016
+ mid[1] + (Math.cos(mid[2]) * off), w, h, mid[2]);
4017
+ }
4018
+ // The ROOT's numbers are drawn too, at x=0 in its own group, and they
4019
+ // used to claim nothing -- so the first child's number, landing in
4020
+ // almost the same place, was granted and painted over them. The one
4021
+ // place the pass could not keep its promise.
4022
+ let span = d.parent ? (d.parent.y - d.y) : 0;
4023
+ let x = at.mid
4024
+ ? (d.y + (0.5 * span) - (w / 2)) // middle-anchored
4025
+ : (d.y + span + 1); // start-anchored
4026
+ return [x, (d.x + at.dy) - fm.ascent, w, h];
4027
+ }
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
+
3823
4261
  function prepareNodeDrawing(nodes) {
3824
4262
  let wantBl = _state.showBranchLengthValues === true;
3825
4263
  let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
@@ -3836,11 +4274,30 @@ function (root, d3, forester, phyloXml) {
3836
4274
  }
3837
4275
  // Empty is the common case on a big tree: Auto-hide Labels blanks
3838
4276
  // them exactly when there are most nodes to blank.
3839
- 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) || '');
3840
4283
  d._blText = wantBl ? asText(makeBranchLengthLabel(d)) : '';
3841
4284
  d._confText = wantConf ? asText(makeConfidenceValuesLabel(d)) : '';
3842
4285
  d._eventText = wantEvent ? asText(makeBranchEventsLabel(d)) : '';
3843
4286
  d._suppDot = wantDot && showSupportDot(d);
4287
+ }
4288
+ // Before `drawn` is built, not after: a refused mark used to leave a
4289
+ // childless <g class="node"> behind, and those are transformed on
4290
+ // every animation frame. On the big tree that was ~18,000 empty groups
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);
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();
4299
+ for (let i = 0, len = nodes.length; i !== len; ++i) {
4300
+ let d = nodes[i];
3844
4301
  if (d._extLabelText !== '' || d._blText !== '' || d._confText !== ''
3845
4302
  || d._eventText !== '' || d._suppDot || isCollapsed(d)) {
3846
4303
  drawn.push(d);
@@ -3849,6 +4306,144 @@ function (root, d3, forester, phyloXml) {
3849
4306
  return drawn;
3850
4307
  }
3851
4308
 
4309
+ // The occupancy pass, in PREORDER -- so the mark nearer the root claims
4310
+ // its space first and keeps it. NOT the order the layout hands out: d3's
4311
+ // descendants() is breadth-first and the viewer reverses it for drawing,
4312
+ // which puts the root LAST and inverts the entire tie-break. That shipped,
4313
+ // and a review caught it; forester.preorderOf derives the right order from
4314
+ // the nodes themselves.
4315
+ //
4316
+ // Runs before anything is drawn, so the decision cannot depend on the
4317
+ // order the DOM happens to be updated in.
4318
+ //
4319
+ // Rides the existing Auto-hide Labels toggle: switch it off and everything
4320
+ // is drawn. Runs in ALL THREE layouts, as the desktop does -- the boxes are
4321
+ // built in the tree group's own coordinates, which is where every mark is
4322
+ // actually drawn, so the circular and unrooted views are measured in their
4323
+ // own space and not in the cluster's. (The tip-label decimation that shares
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.)
4327
+ function hideCrowdedBranchData(nodes) {
4328
+ if (!_state.dynahide) {
4329
+ return;
4330
+ }
4331
+ // No layout state, no honest boxes. Bailing out draws everything; going
4332
+ // on would put every mark at the origin, where they all overlap and all
4333
+ // but the first would be hidden -- a blank tree, with no error.
4334
+ if ((_state.circularDisplay && !_radial) || (_state.unrootedDisplay && !_unroot)) {
4335
+ return;
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
+ };
4355
+ let fontPx = _state.branchDataFontSize;
4356
+ let dotCell = 0;
4357
+ for (let i = 0; i < nodes.length; ++i) {
4358
+ if (nodes[i]._suppDot) {
4359
+ dotCell = Math.max(dotCell, 2 * supportDotRadius(nodes[i]));
4360
+ }
4361
+ }
4362
+ let cell = Math.max(fontPx, dotCell);
4363
+ let numbers = forester.labelOccupancy(cell);
4364
+ let symbols = forester.labelOccupancy(cell);
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
+
4393
+ for (let i = 0, len = ordered.length; i !== len; ++i) {
4394
+ let d = ordered[i];
4395
+ // Branch length first, then confidence: they sit on opposite sides
4396
+ // of the branch, so they rarely contend, but the order decides
4397
+ // which survives when they do.
4398
+ if (d._blText !== '') {
4399
+ let b = branchMarkBox(d, 'bl', d._blText, fontPx);
4400
+ if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4401
+ d._blText = '';
4402
+ ++_branchMarksHidden;
4403
+ } else {
4404
+ stash(d, 'bl', b);
4405
+ }
4406
+ }
4407
+ if (d._confText !== '') {
4408
+ let b = branchMarkBox(d, 'conf', d._confText, fontPx);
4409
+ if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4410
+ d._confText = '';
4411
+ ++_branchMarksHidden;
4412
+ } else {
4413
+ stash(d, 'conf', b);
4414
+ }
4415
+ }
4416
+ // Branch EVENTS are branch-anchored too, half a branch from the
4417
+ // branch length and on the same side of it, so they can land on
4418
+ // the same pixels. Left out of the map, they neither refused a
4419
+ // number nor were refused by one, and the pass reported "no
4420
+ // overlapping marks" while the user could see two.
4421
+ if (d._eventText !== '') {
4422
+ let b = branchMarkBox(d, 'event', d._eventText, fontPx);
4423
+ if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
4424
+ d._eventText = '';
4425
+ ++_branchMarksHidden;
4426
+ } else {
4427
+ stash(d, 'event', b);
4428
+ }
4429
+ }
4430
+ if (d._suppDot) {
4431
+ // never shrunk to fit: in a size-scaled mode the diameter IS
4432
+ // the support value, and a smaller dot reports weaker support
4433
+ let r = supportDotRadius(d);
4434
+ // showSupportDot already required a parent; a circle's bounds
4435
+ // do not rotate, so the radial case needs only the midpoint
4436
+ let at = radialDisplay()
4437
+ ? radialBranchMid(d)
4438
+ : [d.y + (0.5 * (d.parent.y - d.y)), d.x];
4439
+ if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
4440
+ d._suppDot = false;
4441
+ ++_branchMarksHidden;
4442
+ }
4443
+ }
4444
+ }
4445
+ }
4446
+
3852
4447
  function syncOptionalNodeChildren(node) {
3853
4448
  let wantBl = _state.showBranchLengthValues === true;
3854
4449
  let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
@@ -4877,8 +5472,12 @@ function (root, d3, forester, phyloXml) {
4877
5472
  showTaxonomyButton: 'shown automatically when the tree has taxonomies',
4878
5473
  showSequenceButton: 'shown automatically when the tree has sequences',
4879
5474
  showBranchColorsButton: 'the Visual Styles checkbox appears when the tree has branch colours or style properties',
4880
- showDynahideButton: 'shown automatically once the tree has enough tips to need it',
4881
- 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',
4882
5481
  showExternalLabelsButton: 'always shown',
4883
5482
  showInternalLabelsButton: 'shown automatically when the tree has internal node data',
4884
5483
  // the collapse-by-depth / rank / feature feature is gone
@@ -6379,8 +6978,7 @@ function (root, d3, forester, phyloXml) {
6379
6978
  }
6380
6979
 
6381
6980
  if (url) {
6382
- let win = window.open(url, '_blank');
6383
- win.focus();
6981
+ openExternal(url);
6384
6982
  } else {
6385
6983
  // Not a programming error -- the user clicked and there is
6386
6984
  // simply nowhere to go -- so this one gets told in the same
@@ -6880,11 +7478,9 @@ function (root, d3, forester, phyloXml) {
6880
7478
  if (_logoGlyphs[ch]) {
6881
7479
  return _logoGlyphs[ch];
6882
7480
  }
6883
- if (!_legendMeasureCtx) {
6884
- _legendMeasureCtx = document.createElement('canvas').getContext('2d');
6885
- }
6886
- _legendMeasureCtx.font = MSA_LOGO_FONT_PX + 'px monospace';
6887
- let m = _legendMeasureCtx.measureText(ch);
7481
+ let ctx = measureCtx();
7482
+ ctx.font = MSA_LOGO_FONT_PX + 'px monospace';
7483
+ let m = ctx.measureText(ch);
6888
7484
  let ascent = m.actualBoundingBoxAscent;
6889
7485
  let descent = m.actualBoundingBoxDescent;
6890
7486
  if (!(ascent > 0)) {
@@ -9628,7 +10224,9 @@ function (root, d3, forester, phyloXml) {
9628
10224
  }
9629
10225
  if (_state.phylogram && !_state.alignPhylogram) {
9630
10226
  if (_state.unrootedDisplay) {
9631
- toCladegram();
10227
+ toCladegram(); // unrooted cannot align
10228
+ } else if (_state.circularDisplay) {
10229
+ toCladegram(); // circular is already showing the aligned type
9632
10230
  } else {
9633
10231
  toAlignedPhylogram();
9634
10232
  }
@@ -9732,6 +10330,274 @@ function (root, d3, forester, phyloXml) {
9732
10330
  }
9733
10331
 
9734
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
+
9735
10601
  function showShortcutsDialog() {
9736
10602
  let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
9737
10603
  shell.body.classList.add('aptx-shortcuts');
@@ -9955,17 +10821,146 @@ function (root, d3, forester, phyloXml) {
9955
10821
  // the later one's shadow and glow used to fall across the earlier body,
9956
10822
  // and now lie under it; overlapping shadows of one level no longer
9957
10823
  // darken each other.
10824
+ // ===================== Domain hover =====================
10825
+ // Domain architectures are what Archaeopteryx has always been best at, and
10826
+ // until now the boxes said nothing: you could see a domain but not read
10827
+ // its name, its E-value or where it sits, short of opening the node's data
10828
+ // dialog and finding it in a list. Christian, 2026-09-23.
10829
+ //
10830
+ // The readout is the one the heat map and the alignment already use, so a
10831
+ // hover anywhere in the viewer looks and behaves the same, and it is
10832
+ // placed by placeHoverReadout, which measures the box rather than assuming
10833
+ // a size.
10834
+
10835
+ function domainInfoAt(event) {
10836
+ let t = event.target;
10837
+ return (t && t.__aptxDomain) ? t.__aptxDomain : null;
10838
+ }
10839
+
10840
+ let _domainHovered = null;
10841
+
10842
+ function domainHover(event) {
10843
+ let info = domainInfoAt(event);
10844
+ if (!info) {
10845
+ domainHoverOut();
10846
+ return;
10847
+ }
10848
+ // mousemove fires all the way across a box, and a wide domain is
10849
+ // hundreds of pixels. The TEXT only changes when the box does, so
10850
+ // re-escaping it, reparsing it as HTML and restarting the fade sixty
10851
+ // times a second would be work for a byte-identical result -- and the
10852
+ // restarted transition would keep opacity approaching 0.95 without
10853
+ // ever arriving. The readout must still FOLLOW the pointer, so the
10854
+ // placement runs either way.
10855
+ let tip_el = _node_mouseover_div.node();
10856
+ if (info === _domainHovered) {
10857
+ placeHoverReadout(tip_el, event);
10858
+ return;
10859
+ }
10860
+ _domainHovered = info;
10861
+ let ref = forester.domainReference(info);
10862
+ let txt = 'Domain: ' + (info.name || '(unnamed)') + '<br>';
10863
+ let ev = forester.domainEvalueText(info.evalue);
10864
+ if (ev !== null) {
10865
+ txt += 'E-value: ' + ev + '<br>';
10866
+ }
10867
+ txt += 'Residues: ' + info.from + '–' + info.to
10868
+ + ' (' + ((info.to - info.from) + 1) + ' aa'
10869
+ + (info.proteinLength > 0 ? (' of ' + info.proteinLength) : '') + ')<br>';
10870
+ if (info.tip) {
10871
+ txt += 'Tip: ' + info.tip + '<br>';
10872
+ }
10873
+ if (info.id) {
10874
+ txt += 'Accession: ' + info.id + '<br>';
10875
+ }
10876
+ if (ref) {
10877
+ // A readout that follows the pointer cannot hold a link you could
10878
+ // reach, so the BOX is the link and the readout says so. Written
10879
+ // as a "label: value" row on purpose: markUpDataLabels renders a
10880
+ // line WITHOUT a colon as a section divider (9px bold uppercase
10881
+ // faint), which is the wrong voice entirely for a call to action.
10882
+ txt += ref.exact ? 'Click: the Pfam entry' : 'Click: to look this domain up at InterPro';
10883
+ }
10884
+ tip_el.classList.remove('aptx-light', 'aptx-dark');
10885
+ if (_panelTheme) {
10886
+ tip_el.classList.add('aptx-' + _panelTheme);
10887
+ }
10888
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
10889
+ placeHoverReadout(tip_el, event);
10890
+ _node_mouseover_div.transition().duration(100).style('opacity', 0.95);
10891
+ }
10892
+
10893
+ function domainHoverOut() {
10894
+ _domainHovered = null;
10895
+ _node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
10896
+ }
10897
+
10898
+ let _domainOpenedAt = 0;
10899
+
10900
+ function domainClicked(event) {
10901
+ let info = domainInfoAt(event);
10902
+ if (!info) {
10903
+ return;
10904
+ }
10905
+ // A click that LANDED on a box is the box's, whether or not it has
10906
+ // somewhere to send you; letting an unlinkable domain fall through
10907
+ // would open the node menu for whatever node is within five units.
10908
+ event.stopPropagation();
10909
+ let ref = forester.domainReference(info);
10910
+ if (!ref) {
10911
+ return;
10912
+ }
10913
+ // Double-clicking is the natural gesture for "open this", and it
10914
+ // arrives as two clicks: without this it opens two identical tabs.
10915
+ let now = Date.now();
10916
+ if ((now - _domainOpenedAt) < 500) {
10917
+ return;
10918
+ }
10919
+ _domainOpenedAt = now;
10920
+ openExternal(ref.url);
10921
+ }
10922
+
10923
+ // The tree zooms on double click. Over a domain box the gesture means the
10924
+ // box, so the zoom must not also happen behind the tabs that just opened.
10925
+ function domainDblClicked(event) {
10926
+ if (domainInfoAt(event)) {
10927
+ event.stopPropagation();
10928
+ event.preventDefault();
10929
+ }
10930
+ }
10931
+
9958
10932
  function drawDomainArchitectures() {
9959
10933
  if (!_svgGroup) {
9960
10934
  return;
9961
10935
  }
9962
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.)
9963
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
+ }
9964
10958
  return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
9965
10959
  }) : [];
9966
10960
  let f = tips.length > 0 ? domainScale() : 0;
9967
10961
  if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
9968
10962
  existing.remove();
10963
+ domainHoverOut(); // the box under the pointer just stopped existing
9969
10964
  return;
9970
10965
  }
9971
10966
  // [defs, the batched paths, the bodies, the names], kept between
@@ -9985,6 +10980,20 @@ function (root, d3, forester, phyloXml) {
9985
10980
  let underLayer = g.children[1];
9986
10981
  let bodyLayer = g.children[2];
9987
10982
  let labelLayer = g.children[3];
10983
+ // The group ignores the mouse so the tree underneath stays clickable;
10984
+ // the BODIES alone take it back, so a hover lands on a domain and not
10985
+ // on its shadow, its glow or its label.
10986
+ bodyLayer.style.pointerEvents = 'auto';
10987
+ if (!bodyLayer.__aptxWired) {
10988
+ bodyLayer.__aptxWired = true;
10989
+ // mousemove alone, as the heat map and the alignment do it: a
10990
+ // mouseover is always followed by a mousemove, so binding both
10991
+ // only built the readout twice on entry.
10992
+ bodyLayer.addEventListener('mousemove', domainHover);
10993
+ bodyLayer.addEventListener('mouseout', domainHoverOut);
10994
+ bodyLayer.addEventListener('click', domainClicked);
10995
+ bodyLayer.addEventListener('dblclick', domainDblClicked);
10996
+ }
9988
10997
 
9989
10998
  // one vertical gradient per base colour, lighter at the top; the
9990
10999
  // defs ride into the SVG / PDF / PNG exports with the boxes
@@ -10087,6 +11096,10 @@ function (root, d3, forester, phyloXml) {
10087
11096
  }
10088
11097
  let set = row.transform ? pathSet() : track;
10089
11098
  let geo = forester.domainBoxes(da, row.start, f, _state.domainEvalueExponent);
11099
+ // once per ROW: displayNodeName walks the node's properties when a
11100
+ // label-ref visualization is on, and a row can hold dozens of boxes
11101
+ let rowTip = displayNodeName(row.d) || '';
11102
+ let rowLength = Number(da.length) || 0;
10090
11103
  let h = row.h;
10091
11104
  let y1 = row.y1;
10092
11105
  set.backbones.push(roundRectPath(geo.backbone.x, y1 + (h / 2) - 0.5, geo.backbone.w, 1, 0));
@@ -10110,7 +11123,13 @@ function (root, d3, forester, phyloXml) {
10110
11123
  });
10111
11124
  }
10112
11125
  bodies.push({x: b.x, y: y1, w: b.w, h: h, r: r, transform: row.transform,
10113
- fill: gradientFor(base), stroke: forester.domainDarken(base, 0.24)});
11126
+ fill: gradientFor(base), stroke: forester.domainDarken(base, 0.24),
11127
+ // what the hover readout says. Carried per BOX rather than
11128
+ // looked up again on hover: the boxes are pooled and
11129
+ // reused between redraws, so an index into the tree would
11130
+ // go stale the moment the tips on screen change.
11131
+ info: {name: b.name, from: b.from, to: b.to, evalue: b.evalue, id: b.id,
11132
+ tip: rowTip, proteinLength: rowLength}});
10114
11133
  if (row.labelsOn && b.name && fs > 4 && legendTextWidth(b.name, font) <= b.w - 4) {
10115
11134
  names.push({x: b.x + (b.w / 2), y: y1 + (h / 2), font: font,
10116
11135
  fill: forester.domainLabelInk(base), text: b.name});
@@ -10134,6 +11153,11 @@ function (root, d3, forester, phyloXml) {
10134
11153
  paths[k].style.fill = u.fill;
10135
11154
  paths[k].style.fillOpacity = u.opacity;
10136
11155
  });
11156
+ // A redraw can take the hovered box away without the pointer moving
11157
+ // -- a keyboard zoom, a panel change applied with Enter, a collapse --
11158
+ // and a removed element fires no mouseout, so the readout would stay
11159
+ // painted over a domain that is no longer there.
11160
+ domainHoverOut();
10137
11161
  let rects = sizeLayer(bodyLayer, 'rect', bodies.length);
10138
11162
  bodies.forEach(function (b, k) {
10139
11163
  let el = rects[k];
@@ -10151,6 +11175,11 @@ function (root, d3, forester, phyloXml) {
10151
11175
  el.style.fill = b.fill;
10152
11176
  el.style.stroke = b.stroke;
10153
11177
  el.style.strokeWidth = 1;
11178
+ el.__aptxDomain = b.info;
11179
+ // only where there is somewhere to go: an unnamed domain with no
11180
+ // accession has no reference, and a hand cursor over it would
11181
+ // promise a link that the click cannot deliver
11182
+ el.style.cursor = forester.domainReference(b.info) ? 'pointer' : 'default';
10154
11183
  });
10155
11184
  let texts = sizeLayer(labelLayer, 'text', names.length);
10156
11185
  names.forEach(function (t, k) {
@@ -11091,7 +12120,8 @@ function (root, d3, forester, phyloXml) {
11091
12120
 
11092
12121
  // what the viewer writes onto a tree's nodes, left off a tree cut out of it
11093
12122
  const VIEW_NODE_FIELDS = ['viewId', 'collapsed', 'x', 'y', 'x0', 'y0', 'id', 'hide', 'hasVis', 'distToRoot',
11094
- 'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText'];
12123
+ 'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText',
12124
+ '_labelBoxes', '_labelDropped', '_markBoxes'];
11095
12125
 
11096
12126
  // the whole tree's tip count, counted again only when the tree changes
11097
12127
  // (every change makes new _basicTreeProperties)
@@ -11887,16 +12917,27 @@ function (root, d3, forester, phyloXml) {
11887
12917
  scheduleUpdate(null, 0);
11888
12918
  }
11889
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.
11890
12927
  function toAlignedPhylogram() {
11891
12928
  _state.phylogram = true;
11892
- _state.alignPhylogram = true;
12929
+ if (!_state.circularDisplay) {
12930
+ _state.alignPhylogram = true;
12931
+ }
11893
12932
  setDisplayTypeButtons();
11894
12933
  scheduleUpdate(null, 0);
11895
12934
  }
11896
12935
 
11897
12936
  function toCladegram() {
11898
12937
  _state.phylogram = false;
11899
- _state.alignPhylogram = false;
12938
+ if (!_state.circularDisplay) {
12939
+ _state.alignPhylogram = false;
12940
+ }
11900
12941
  setDisplayTypeButtons();
11901
12942
  scheduleUpdate(null, 0);
11902
12943
  }
@@ -11928,18 +12969,40 @@ function (root, d3, forester, phyloXml) {
11928
12969
  // against; an alignment / time axis is inherently horizontal. Synced
11929
12970
  // BEFORE the zoom-row early return, and (via updateButtonEnabledState)
11930
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;
11931
12987
  let phyBtn = byId(PHYLOGRAM_BUTTON);
11932
12988
  if (phyBtn) {
11933
- phyBtn.disabled = _basicTreeProperties.branchLengths !== true;
12989
+ phyBtn.disabled = !measured || _state.circularDisplay;
12990
+ phyBtn.title = PHYLOGRAM_WHAT;
11934
12991
  }
11935
12992
  let alignBtn = byId(PHYLOGRAM_ALIGNED_BUTTON);
11936
12993
  if (alignBtn) {
11937
- alignBtn.disabled = _state.unrootedDisplay || _basicTreeProperties.branchLengths !== true;
11938
- }
11939
- let dyna = byId(DYNAHIDE_CB);
11940
- if (dyna) {
11941
- dyna.disabled = _state.unrootedDisplay;
12994
+ alignBtn.disabled = !measured || _state.unrootedDisplay;
12995
+ alignBtn.title = PHYLOGRAM_ALIGNED_WHAT;
11942
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();
13001
+ // Auto-hide Labels stays live in every layout. It used to be greyed out
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.
11943
13006
  let msaCb = byId(MSA_CB);
11944
13007
  if (msaCb) {
11945
13008
  msaCb.disabled = radialDisplay();
@@ -12156,7 +13219,15 @@ function (root, d3, forester, phyloXml) {
12156
13219
  // panel's accent: the checkbox item gets a box and its tooltip says how
12157
13220
  // many labels are shown. Called on every redraw, since the density
12158
13221
  // changes with the vertical zoom, the font size and the view.
12159
- 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() {
12160
13231
  let cb = byId(DYNAHIDE_CB);
12161
13232
  let item = cb ? cb.closest('.aptx-check') : null;
12162
13233
  if (!item) {
@@ -12165,9 +13236,23 @@ function (root, d3, forester, phyloXml) {
12165
13236
  if (item.dataset.baseTitle === undefined) {
12166
13237
  item.dataset.baseTitle = item.title;
12167
13238
  }
12168
- 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);
12169
13254
  item.title = active
12170
- ? item.dataset.baseTitle + ', hiding now: 1 in ' + _dynahide_factor + ' labels shown'
13255
+ ? item.dataset.baseTitle + ', hiding now: ' + parts.join(', and ')
12171
13256
  : item.dataset.baseTitle;
12172
13257
  }
12173
13258
 
@@ -12806,6 +13891,14 @@ function (root, d3, forester, phyloXml) {
12806
13891
  + glyphDot(50, 29, 5.5);
12807
13892
  }
12808
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
+
12809
13902
  function glyphSun() {
12810
13903
  let s = glyphDot(50, 50, 20);
12811
13904
  for (let i = 0; i < 8; ++i) {
@@ -12874,6 +13967,7 @@ function (root, d3, forester, phyloXml) {
12874
13967
  case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
12875
13968
  case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
12876
13969
  case 'info': sw = 7; cap = 'round'; body = glyphInfo(); break;
13970
+ case 'cheat_sheet': sw = 7; cap = 'round'; body = glyphCheatSheet(); break;
12877
13971
  case 'sun': body = glyphSun(); break;
12878
13972
  case 'moon': body = glyphMoon(); break;
12879
13973
  default: throw new Error('unknown control-panel glyph: ' + kind);
@@ -13072,6 +14166,18 @@ function (root, d3, forester, phyloXml) {
13072
14166
  + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
13073
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; }'
13074
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); }'
13075
14181
  // representative tips: the input dialog and its result
13076
14182
  + '.aptx-reps { line-height:1.35; }'
13077
14183
  + '.aptx-reps-lead { margin:0 0 4px; }'
@@ -13143,7 +14249,15 @@ function (root, d3, forester, phyloXml) {
13143
14249
  + '.aptx-tip .aptx-dialog-line { padding:1px 0; }'
13144
14250
  + '.aptx-tip .aptx-dialog-line + .aptx-dialog-line { border-top:0; }'
13145
14251
  + '.aptx-tip .aptx-dialog-key { flex:0 0 auto; min-width:96px; }'
13146
- + '.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; }'
13147
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; }'
13148
14262
  + '.aptx-panel .' + PROGNAMELINK + ' { background:none; padding:0; margin:0; cursor:pointer;'
13149
14263
  + ' font-family:inherit; font-weight:inherit; letter-spacing:inherit; }'
@@ -13394,6 +14508,18 @@ function (root, d3, forester, phyloXml) {
13394
14508
  });
13395
14509
  actions.appendChild(infoBtn);
13396
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
+
13397
14523
  let progName = header.querySelector('.' + PROGNAMELINK);
13398
14524
  if (progName) {
13399
14525
  // Clicking the title used to navigate away to the project site;
@@ -13465,6 +14591,10 @@ function (root, d3, forester, phyloXml) {
13465
14591
  fitPanelSections(panel, name);
13466
14592
  }
13467
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();
13468
14598
  });
13469
14599
  }
13470
14600
  // What arrived open counts as opened, oldest at the BOTTOM: a panel is
@@ -14253,7 +15383,8 @@ function (root, d3, forester, phyloXml) {
14253
15383
  ['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
14254
15384
  ['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
14255
15385
  ['License', LICENSE_WEBSITE, LICENSE_NAME],
14256
- ['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) {
14257
15388
  let line = document.createElement('div');
14258
15389
  line.className = 'aptx-dialog-line';
14259
15390
  let key = document.createElement('span');
@@ -14267,12 +15398,14 @@ function (root, d3, forester, phyloXml) {
14267
15398
  a.target = '_blank';
14268
15399
  a.rel = 'noopener noreferrer';
14269
15400
  } else {
14270
- // 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;
14271
15404
  a.href = '#';
14272
15405
  a.addEventListener('click', function (e) {
14273
15406
  e.preventDefault();
14274
15407
  shell.dialog.close();
14275
- showShortcutsDialog();
15408
+ opener();
14276
15409
  });
14277
15410
  }
14278
15411
  a.textContent = row[2];
@@ -14843,7 +15976,11 @@ function (root, d3, forester, phyloXml) {
14843
15976
  function makeProgramDesc() {
14844
15977
  let h = "";
14845
15978
  h = h.concat('<div class=' + PROG_NAME + '>');
14846
- 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>');
14847
15984
  h = h.concat('</div>');
14848
15985
  return h;
14849
15986
  }
@@ -14994,14 +16131,17 @@ function (root, d3, forester, phyloXml) {
14994
16131
  // desktop
14995
16132
  let layoutGroup = 'layout_control_radio';
14996
16133
  h = h.concat('<div class="aptx-segmented">');
14997
- h = h.concat(makeSegment(makeGlyph('rectangular'), LAYOUT_RECT_BUTTON, layoutGroup, 'rectangular, root at left'));
14998
- h = h.concat(makeSegment(makeGlyph('circular'), LAYOUT_CIRC_BUTTON, layoutGroup, 'circular'));
14999
- 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'));
15000
16140
  h = h.concat('</div>');
15001
16141
  h = h.concat('<div class="' + PHYLOGRAM_CLADOGRAM_CONTROLGROUP + ' aptx-segmented">');
15002
- h = h.concat(makeSegment(makeGlyph('phylogram'), PHYLOGRAM_BUTTON, radioGroup, 'phylogram display (uses branch length values)'));
15003
- h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, 'phylogram display (uses branch length values) with aligned labels'));
15004
- 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));
15005
16145
  h = h.concat('</div>');
15006
16146
  // The branch SCALE, when the tree states two different things: its
15007
16147
  // nodes' dates, and its branches' divergence. Hidden otherwise,
@@ -15084,10 +16224,10 @@ function (root, d3, forester, phyloXml) {
15084
16224
  // "Auto-hide Labels" matches the desktop, which renamed the historical
15085
16225
  // "Dyna Hide" because that named the mechanism rather than what the user
15086
16226
  // sees. The _state.dynahide field keeps its name internally.
15087
- opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels when 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));
15088
16228
  opts.push(makeCheckboxItem('Short Names', SHORTEN_NODE_NAME_CB, 'to shorten long node names'));
15089
16229
  if (_basicTreeProperties.confidences) {
15090
- opts.push(makeCheckboxItem('Support Dots', SUPPORT_DOTS_CB, 'to mark every branch with support of at least ' + _settings.supportDotMinimum + '% with a dot at its midpoint'));
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'));
15091
16231
  }
15092
16232
  // Styles and visualizations sit here too, so the Nodes group only
15093
16233
  // appears at all on a tree carrying node/branch events
@@ -15164,7 +16304,8 @@ function (root, d3, forester, phyloXml) {
15164
16304
  h = h.concat('<fieldset>');
15165
16305
  h = h.concat('<input type="button" value="Download" name="' + DOWNLOAD_BUTTON + '" title="download/export tree in a selected format" id="' + DOWNLOAD_BUTTON + '">');
15166
16306
  //h = h.concat('<br>');
15167
- 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">');
15168
16309
  if (pngExportAvailable()) {
15169
16310
  h = h.concat('<option value="' + PNG_EXPORT_FORMAT + '">' + PNG_EXPORT_FORMAT + '</option>');
15170
16311
  }
@@ -15314,8 +16455,10 @@ function (root, d3, forester, phyloXml) {
15314
16455
  let h = "";
15315
16456
  h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
15316
16457
  h = h.concat('<div class="aptx-search-menus">');
15317
- h = h.concat('<select class="aptx-search-field" name="' + fsel + '" id="' + fsel + '" title="the field to search in"></select>');
15318
- 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>');
15319
16462
  h = h.concat('</div>');
15320
16463
  h = h.concat('<div class="aptx-search-row">');
15321
16464
  h = h.concat('<input class="aptx-search-value" autocomplete="off" title="' + valTip + '" type="text" name="' + val + '" id="' + val + '">');
@@ -15522,9 +16665,15 @@ function (root, d3, forester, phyloXml) {
15522
16665
  }
15523
16666
 
15524
16667
  function setDisplayTypeButtons() {
15525
- setRadioButtonValue(PHYLOGRAM_BUTTON, _state.phylogram && !_state.alignPhylogram);
15526
- setRadioButtonValue(CLADOGRAM_BUTTON, !_state.phylogram && !_state.alignPhylogram);
15527
- 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);
15528
16677
  setCheckboxValue(LAYOUT_CIRC_BUTTON, _state.circularDisplay);
15529
16678
  setCheckboxValue(LAYOUT_UNROOTED_BUTTON, _state.unrootedDisplay);
15530
16679
  setCheckboxValue(LAYOUT_RECT_BUTTON, !radialDisplay());
@@ -15545,6 +16694,7 @@ function (root, d3, forester, phyloXml) {
15545
16694
 
15546
16695
  function updateButtonEnabledState() {
15547
16696
  syncZoomRowButtons(); // layout- and tree-dependent disables track every render
16697
+ refreshCheatSheet(); // an open sheet describes the panel as it is now
15548
16698
  // the uncollapse-all button lives only while something is collapsed, as on the desktop
15549
16699
  ((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
15550
16700
  if (_in_subtree) {