archaeopteryx 3.11.0 → 3.13.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.11.0
23
+ // v 3.13.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.11.0';
106
+ const VERSION = '3.13.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';
@@ -363,6 +363,7 @@ function (root, d3, forester, phyloXml) {
363
363
  const DOWNLOAD_BUTTON = 'dl_b';
364
364
  const DYNAHIDE_CB = 'dynahide_cb';
365
365
  const MSA_CB = 'msa_cb';
366
+ const MSA_LOGO_CB = 'msa_logo_cb';
366
367
  const HEATMAP_CB = 'heatmap_cb';
367
368
  const HEATMAP_CONTROLS = 'heatmap_controls';
368
369
  const HEATMAP_ORDER_SELECT = 'heatmap_order';
@@ -422,8 +423,6 @@ function (root, d3, forester, phyloXml) {
422
423
  const RETURN_TO_SUPERTREE_BUTTON_BY_ONE = 'ret1_b';
423
424
  const SEARCH_FIELD_0 = 'sf0';
424
425
  const SEARCH_FIELD_1 = 'sf1';
425
- const SEARCH_B_WRAP = 'search_b_wrap';
426
- const SEARCH_B_TOGGLE = 'search_b_tgl';
427
426
  const SEARCH_NAV_ROW = 'searchnavrow';
428
427
  const SEARCH_NAV_PREV = 'searchnavprev';
429
428
  const SEARCH_NAV_NEXT = 'searchnavnext';
@@ -547,7 +546,11 @@ function (root, d3, forester, phyloXml) {
547
546
  const MSA_MAX_VIEWPORT_FRACTION = 0.6;// nor grows past this share of the display
548
547
  const MSA_CONS_BAR_H = 22; // conservation bar band height
549
548
  const MSA_CONS_TOP_GAP = 4;
550
- const MSA_BOTTOM_RESERVE = 56; // vertical room under the rows (conservation + ruler)
549
+ const MSA_CONS_ROW_H = 13; // the consensus letter row
550
+ const MSA_RULER_H = 17; // the column ruler under it
551
+ const MSA_LOGO_H = 46; // the sequence logo's stack, full height
552
+ const MSA_LOGO_CAP_H = 11; // ... and the caption line above it
553
+ const MSA_LOGO_FONT_PX = 24; // the size logo glyphs are measured at, then scaled
551
554
  const MSA_NAV_RESERVE = 46; // ... plus the navigation bar floating at the viewport bottom
552
555
  const MSA_MIN_TREE_PX = 220; // the tree itself never shrinks below this
553
556
  let _msaColOffset = 0; // first shown alignment column (0-based)
@@ -4083,8 +4086,15 @@ function (root, d3, forester, phyloXml) {
4083
4086
  };
4084
4087
 
4085
4088
 
4089
+ // A branch length of ZERO is a length and gets a label. Testing the
4090
+ // number for truthiness read 0 as "no branch length", so the one branch
4091
+ // whose length is worth pointing out -- two identical sequences, a
4092
+ // multifurcation written out as a run of zero-length branches -- was the
4093
+ // one drawn bare. Same mistake the phyloXML writer was making until
4094
+ // phyloxml 1.1.1; the desktop cannot make it at all, because it marks an
4095
+ // absent length with a sentinel (-1024) rather than with zero.
4086
4096
  let makeBranchLengthLabel = function (phynode) {
4087
- if (phynode.branch_length) {
4097
+ if (phynode.branch_length !== undefined && phynode.branch_length !== null) {
4088
4098
  if (_state.phylogram && _state.minBranchLengthValueToShow && phynode.branch_length < _state.minBranchLengthValueToShow) {
4089
4099
  return;
4090
4100
  }
@@ -4112,7 +4122,9 @@ function (root, d3, forester, phyloXml) {
4112
4122
  if (_state.showConfidenceValues && support.length > 0
4113
4123
  && (!_state.minConfidenceValueToShow || support.some(function (c) { return c.value >= _state.minConfidenceValueToShow; }))) {
4114
4124
  support.forEach(function (c) {
4115
- if (c.value) {
4125
+ // a support of 0 is a support value, and a telling one: the
4126
+ // MAD branch above already tested it this way
4127
+ if (typeof c.value === 'number' && isFinite(c.value)) {
4116
4128
  parts.push(+c.value.toFixed(CONFIDENCE_VALUE_DIGITS_DEFAULT));
4117
4129
  }
4118
4130
  });
@@ -4927,6 +4939,7 @@ function (root, d3, forester, phyloXml) {
4927
4939
  const STATE_KEYS = [
4928
4940
  'layout',
4929
4941
  'showMsa',
4942
+ 'showMsaLogo',
4930
4943
  'showHeatmap',
4931
4944
  'heatmapColumnOrder',
4932
4945
  'heatmapManualOrder',
@@ -5169,6 +5182,12 @@ function (root, d3, forester, phyloXml) {
5169
5182
  if (_state.showHeatmap === undefined) {
5170
5183
  _state.showHeatmap = false;
5171
5184
  }
5185
+ // The logo is a summary of the alignment, not a second view of the
5186
+ // tree, so it waits to be asked for: an alignment opens showing its
5187
+ // residues, which is what a reader came for.
5188
+ if (_state.showMsaLogo === undefined) {
5189
+ _state.showMsaLogo = false;
5190
+ }
5172
5191
  // The columns' order. Left unset it is resolved from the data on first
5173
5192
  // use (heatmapMode / forester.heatmapDefaultOrder): a clustered order,
5174
5193
  // because reading block structure is what a heat map beside a tree is
@@ -6824,6 +6843,64 @@ function (root, d3, forester, phyloXml) {
6824
6843
  && _basicTreeProperties.maxMolSeqLength > 0;
6825
6844
  }
6826
6845
 
6846
+ // The logo replaces the conservation bar and the consensus row rather
6847
+ // than joining them: a stack's height IS the column's conservation and its
6848
+ // top letter IS the consensus, so drawing all three would say the same
6849
+ // thing three times and take three times the room.
6850
+ function msaLogoShown() {
6851
+ return _state.showMsaLogo === true && msaShown();
6852
+ }
6853
+
6854
+ // What the strip under the alignment needs. The conservation form is the
6855
+ // 56 px this was a constant for; the logo form is taller and drops the
6856
+ // consensus row it subsumes.
6857
+ function msaBottomReserve() {
6858
+ return MSA_CONS_TOP_GAP
6859
+ + (msaLogoShown() ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H)
6860
+ + MSA_RULER_H;
6861
+ }
6862
+
6863
+ // Logo letters are drawn at a fixed size and then scaled to their cell and
6864
+ // to their share of the stack, so what is needed is how much ink this face
6865
+ // actually puts on the page for THIS letter -- measured, not assumed: a
6866
+ // 0.6 advance and a 0.72 cap height are true of some monospace faces and
6867
+ // not others, and a letter scaled by the wrong constant overflows its
6868
+ // column.
6869
+ //
6870
+ // Per letter, because a logo's letters must each fill their slice exactly:
6871
+ // measured off one glyph, Q and G hung their descenders through the
6872
+ // baseline and across the ruler, and every letter without a descender sat
6873
+ // a little short. What is scaled to the slice is the glyph's INK BOX
6874
+ // (ascent + descent), and the baseline is then placed so that box lands
6875
+ // inside the slice.
6876
+ let _logoGlyphs = {};
6877
+
6878
+ function logoGlyphMetrics(ch) {
6879
+ if (_logoGlyphs[ch]) {
6880
+ return _logoGlyphs[ch];
6881
+ }
6882
+ if (!_legendMeasureCtx) {
6883
+ _legendMeasureCtx = document.createElement('canvas').getContext('2d');
6884
+ }
6885
+ _legendMeasureCtx.font = MSA_LOGO_FONT_PX + 'px monospace';
6886
+ let m = _legendMeasureCtx.measureText(ch);
6887
+ let ascent = m.actualBoundingBoxAscent;
6888
+ let descent = m.actualBoundingBoxDescent;
6889
+ if (!(ascent > 0)) {
6890
+ ascent = MSA_LOGO_FONT_PX * 0.72; // a face that will not say
6891
+ descent = 0;
6892
+ }
6893
+ if (!(descent > 0)) {
6894
+ descent = 0; // no descender, or none reported
6895
+ }
6896
+ _logoGlyphs[ch] = {
6897
+ width: m.width || (MSA_LOGO_FONT_PX * 0.6),
6898
+ ascent: ascent,
6899
+ descent: descent
6900
+ };
6901
+ return _logoGlyphs[ch];
6902
+ }
6903
+
6827
6904
  function msaRowSeq(d) {
6828
6905
  if (d.sequences && d.sequences.length > 0) {
6829
6906
  let s = d.sequences[0];
@@ -7014,12 +7091,15 @@ function (root, d3, forester, phyloXml) {
7014
7091
  .attr('stroke', ink).attr('stroke-opacity', 0.7).attr('stroke-width', 1);
7015
7092
  }
7016
7093
 
7017
- // conservation band (consensus identity) + consensus letters
7018
- // scored over the visible WINDOW only -- the whole alignment would
7019
- // cost rows x total per redraw; indices below are window-relative
7020
- let cons = forester.msaConservation(tips.map(function (t) {
7094
+ // Scored over the visible WINDOW only -- the whole alignment would
7095
+ // cost rows x total per redraw; indices below are window-relative.
7096
+ // The rows are the tips ON SCREEN: enter a clade and the summary is
7097
+ // that clade's, which is the whole point of putting it next to a tree.
7098
+ let windowRows = tips.map(function (t) {
7021
7099
  return msaRowSeq(t).slice(offset, offset + visible);
7022
- }), visible, 'identity', isNuc);
7100
+ });
7101
+ let logo = msaLogoShown() ? forester.msaLogo(windowRows, visible, isNuc) : null;
7102
+ let cons = logo ? null : forester.msaConservation(windowRows, visible, 'identity', isNuc);
7023
7103
  // A faint dashed guide from each tip -- from the end of its label, or
7024
7104
  // from the node itself when the label is hidden -- across to its row
7025
7105
  // in the track, so a row can be read back to its sequence without
@@ -7055,34 +7135,91 @@ function (root, d3, forester, phyloXml) {
7055
7135
  }
7056
7136
 
7057
7137
  // ---- conservation, consensus, ruler: a FLOATING strip ----
7058
- let strip = floatStripGroup('aptx-msa-strip', _clusterH, MSA_BOTTOM_RESERVE);
7138
+ let stripH = msaBottomReserve();
7139
+ let strip = floatStripGroup('aptx-msa-strip', _clusterH, stripH);
7059
7140
  strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
7060
- .attr('width', trackW + 4).attr('height', MSA_BOTTOM_RESERVE)
7141
+ .attr('width', trackW + 4).attr('height', stripH)
7061
7142
  .attr('fill', _state.backgroundColorDefault);
7062
7143
  let consTop = _clusterH + MSA_CONS_TOP_GAP;
7063
- strip.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
7064
- .attr('width', trackW).attr('height', MSA_CONS_BAR_H)
7065
- .attr('fill', ink).attr('fill-opacity', 0.08);
7066
- for (let i = 0; i < visible; ++i) {
7067
- let score = cons.scores[i] || 0;
7068
- if (score <= 0) {
7069
- continue;
7144
+ // A caption on the panel's own background, so it stays readable over
7145
+ // whatever the band happens to draw underneath it.
7146
+ let caption = function (txt, y) {
7147
+ if (trackW <= 170) {
7148
+ return;
7070
7149
  }
7071
- let bh = Math.max(1, Math.round(score * MSA_CONS_BAR_H));
7072
- let x0 = Math.round(originX + (i * cw));
7073
- let x1 = Math.round(originX + ((i + 1) * cw));
7074
- strip.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
7075
- .attr('width', Math.max(1, x1 - x0)).attr('height', bh)
7076
- .attr('fill', ink).attr('fill-opacity', 0.7);
7077
- }
7078
- if (trackW > 170) {
7079
- strip.append('text').attr('x', Math.round(originX) + trackW - 3).attr('y', consTop + 9)
7150
+ let font = '8px ' + FONT_DEFAULTS;
7151
+ let w = legendTextWidth(txt, font) + 6;
7152
+ let xe = Math.round(originX) + trackW - 3;
7153
+ strip.append('rect').attr('x', xe - w).attr('y', y - 8)
7154
+ .attr('width', w).attr('height', 11)
7155
+ .attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
7156
+ strip.append('text').attr('x', xe - 3).attr('y', y)
7080
7157
  .attr('text-anchor', 'end')
7081
7158
  .style('font-size', '8px').style('fill', ink).style('fill-opacity', 0.9)
7082
- .text('Consensus identity (n = ' + n + ')');
7083
- }
7084
- let consensusRow = cw >= 7;
7085
- if (consensusRow) {
7159
+ .text(txt);
7160
+ };
7161
+ if (logo) {
7162
+ // Each column a stack of letters: total height the column's
7163
+ // information content in bits, each letter's share its frequency.
7164
+ // Least frequent at the BOTTOM, so the eye reads the consensus
7165
+ // off the top of the stack.
7166
+ //
7167
+ // The caption gets a line of its own above the stacks (hence the
7168
+ // CAP_H here). Laid over them it washed out the very letters it
7169
+ // was labelling, which is a poor trade for eleven pixels.
7170
+ let floor = consTop + MSA_LOGO_CAP_H + MSA_LOGO_H;
7171
+ strip.append('line')
7172
+ .attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
7173
+ .attr('y1', floor).attr('y2', floor)
7174
+ .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
7175
+ for (let i = 0; i < visible; ++i) {
7176
+ let col = logo.columns[i];
7177
+ if (!col || !(col.height > 0)) {
7178
+ continue;
7179
+ }
7180
+ let x0 = Math.round(originX + (i * cw));
7181
+ let x1 = Math.round(originX + ((i + 1) * cw));
7182
+ let y = floor;
7183
+ for (let k = col.letters.length - 1; k >= 0; --k) {
7184
+ let letter = col.letters[k];
7185
+ let hpx = (letter.h / logo.maxBits) * MSA_LOGO_H;
7186
+ if (hpx < 0.5) {
7187
+ continue; // under half a pixel of ink: nothing to draw
7188
+ }
7189
+ let glyph = logoGlyphMetrics(letter.ch);
7190
+ let sy = hpx / (glyph.ascent + glyph.descent);
7191
+ let rgb = forester.msaResidueRgb(letter.ch, isNuc);
7192
+ strip.append('text')
7193
+ .attr('transform', 'translate(' + (x0 + ((x1 - x0) / 2)) + ','
7194
+ + (y - (glyph.descent * sy)) + ')'
7195
+ + ' scale(' + (Math.max(1, x1 - x0) / glyph.width) + ',' + sy + ')')
7196
+ .attr('text-anchor', 'middle')
7197
+ .style('font-family', 'monospace')
7198
+ .style('font-size', MSA_LOGO_FONT_PX + 'px')
7199
+ .style('fill', rgb === null ? ink : 'rgb(' + rgb.join(',') + ')')
7200
+ .text(letter.ch);
7201
+ y -= hpx;
7202
+ }
7203
+ }
7204
+ caption('Sequence logo \u2014 0 to ' + logo.maxBits.toFixed(1)
7205
+ + ' bits (n = ' + n + ')', consTop + 8);
7206
+ } else {
7207
+ strip.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
7208
+ .attr('width', trackW).attr('height', MSA_CONS_BAR_H)
7209
+ .attr('fill', ink).attr('fill-opacity', 0.08);
7210
+ for (let i = 0; i < visible; ++i) {
7211
+ let score = cons.scores[i] || 0;
7212
+ if (score <= 0) {
7213
+ continue;
7214
+ }
7215
+ let bh = Math.max(1, Math.round(score * MSA_CONS_BAR_H));
7216
+ let x0 = Math.round(originX + (i * cw));
7217
+ let x1 = Math.round(originX + ((i + 1) * cw));
7218
+ strip.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
7219
+ .attr('width', Math.max(1, x1 - x0)).attr('height', bh)
7220
+ .attr('fill', ink).attr('fill-opacity', 0.7);
7221
+ }
7222
+ caption('Consensus identity (n = ' + n + ')', consTop + 9);
7086
7223
  for (let i = 0; i < visible; ++i) {
7087
7224
  let cc = cons.consensus[i];
7088
7225
  if (!cc) {
@@ -7099,7 +7236,7 @@ function (root, d3, forester, phyloXml) {
7099
7236
 
7100
7237
  // the 1-based column ruler: absolute column numbers at nice steps,
7101
7238
  // and always the first and last column when their edge is in view
7102
- let rulerY = consTop + MSA_CONS_BAR_H + (consensusRow ? 13 : 3);
7239
+ let rulerY = consTop + (logo ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H);
7103
7240
  strip.append('line').attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
7104
7241
  .attr('y1', rulerY).attr('y2', rulerY)
7105
7242
  .attr('stroke', ink).attr('stroke-opacity', 0.8).attr('stroke-width', 1);
@@ -7228,8 +7365,11 @@ function (root, d3, forester, phyloXml) {
7228
7365
  msaScrollTo(Infinity);
7229
7366
  });
7230
7367
 
7231
- // "column [ 1234 ] - 1357 of 30,000": the box is the left edge of
7232
- // the window and takes a column to jump to; the rest reads back
7368
+ // "column [ 1234 ] 1357 of 30,000": the box is the left edge of
7369
+ // the window and takes a column to jump to; the rest reads back.
7370
+ // No dash between them -- the box's own border already separates
7371
+ // the two numbers, and a dash sitting against it read as a minus
7372
+ // sign on the number after it.
7233
7373
  let read = document.createElement('span');
7234
7374
  read.className = 'aptx-msa-nav-read';
7235
7375
  let lbl = document.createElement('span');
@@ -7279,7 +7419,7 @@ function (root, d3, forester, phyloXml) {
7279
7419
  _msaNav._jump.value = String(offset + 1);
7280
7420
  }
7281
7421
  _msaNav._jump.max = String(total);
7282
- _msaNav._tail.textContent = '\u2013 ' + (offset + visible).toLocaleString()
7422
+ _msaNav._tail.textContent = (offset + visible).toLocaleString()
7283
7423
  + ' of ' + total.toLocaleString();
7284
7424
  let atStart = offset <= 0;
7285
7425
  let atEnd = offset >= total - visible;
@@ -7357,6 +7497,11 @@ function (root, d3, forester, phyloXml) {
7357
7497
  scheduleUpdate(null, 0);
7358
7498
  }
7359
7499
 
7500
+ function msaLogoCbClicked() {
7501
+ _state.showMsaLogo = getCheckboxValue(MSA_LOGO_CB);
7502
+ scheduleUpdate(null, 0);
7503
+ }
7504
+
7360
7505
  // ===================== Heat map =====================
7361
7506
  // The desktop's MATRIX annotation columns: one cell per (tip, numeric
7362
7507
  // per-tip property) to the right of the labels, every column painted on
@@ -8930,6 +9075,9 @@ function (root, d3, forester, phyloXml) {
8930
9075
  }
8931
9076
  if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
8932
9077
  s.msa = _state.showMsa === true;
9078
+ if (_state.showMsaLogo === true) {
9079
+ s.msaLogo = true;
9080
+ }
8933
9081
  }
8934
9082
  if (heatmapAvailable()) {
8935
9083
  s.heatmap = _state.showHeatmap === true;
@@ -9058,6 +9206,9 @@ function (root, d3, forester, phyloXml) {
9058
9206
  if (typeof s.msa === 'boolean') {
9059
9207
  _state.showMsa = s.msa;
9060
9208
  }
9209
+ if (typeof s.msaLogo === 'boolean') {
9210
+ _state.showMsaLogo = s.msaLogo;
9211
+ }
9061
9212
  if (typeof s.heatmap === 'boolean') {
9062
9213
  _state.showHeatmap = s.heatmap;
9063
9214
  }
@@ -9150,9 +9301,6 @@ function (root, d3, forester, phyloXml) {
9150
9301
  if (want.value) {
9151
9302
  openPanelSection('Search'); // a view carrying a search shows it
9152
9303
  }
9153
- if (idx === 1 && want.value) {
9154
- showSearchB();
9155
- }
9156
9304
  }
9157
9305
 
9158
9306
  // The panel's controls from the state, after applyViewState
@@ -9168,6 +9316,7 @@ function (root, d3, forester, phyloXml) {
9168
9316
  }
9169
9317
  });
9170
9318
  setCheckboxValue(MSA_CB, _state.showMsa === true);
9319
+ setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
9171
9320
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
9172
9321
  syncHeatmapControls();
9173
9322
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
@@ -9241,10 +9390,20 @@ function (root, d3, forester, phyloXml) {
9241
9390
  // Lists are comma-joined, booleans 1 / 0, the two searches flattened
9242
9391
  // to a / af / am / a2 and b / bf / bm / b2. Made for a URL hash, where
9243
9392
  // none of these characters needs escaping.
9244
- const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine'];
9393
+ // Every key getViewState writes has to be here, or a view survives
9394
+ // getViewState/applyViewState (what an embedder uses) and is silently
9395
+ // dropped from the URL hash (what "Copy link to this view" produces).
9396
+ // The heat map's four keys and the alignment logo went in exactly that
9397
+ // way, while the README promised a link reproduced the figure.
9398
+ const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine',
9399
+ 'heatmapOrder'];
9245
9400
  const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
9246
9401
  const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
9247
- const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'domains', 'domainGlow', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
9402
+ const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow',
9403
+ 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
9404
+ // Lists of strings, comma-joined. A property ref carries ':' but never a
9405
+ // comma, and encodeViewValue leaves both readable in a hash.
9406
+ const VIEW_LIST_KEYS = ['heatmapManual'];
9248
9407
  const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
9249
9408
 
9250
9409
  function encodeViewValue(v) {
@@ -9278,6 +9437,12 @@ function (root, d3, forester, phyloXml) {
9278
9437
  if (Array.isArray(state.collapsed) && state.collapsed.length > 0) {
9279
9438
  put('collapsed', state.collapsed.join(','));
9280
9439
  }
9440
+ VIEW_LIST_KEYS.forEach(function (k) {
9441
+ let v = state[k];
9442
+ if (Array.isArray(v) && v.length > 0) {
9443
+ parts.push(k + '=' + v.map(encodeViewValue).join(','));
9444
+ }
9445
+ });
9281
9446
  VIEW_BOOL_KEYS.forEach(function (k) {
9282
9447
  if (typeof state[k] === 'boolean') {
9283
9448
  put(k, state[k] ? 1 : 0);
@@ -9344,6 +9509,16 @@ function (root, d3, forester, phyloXml) {
9344
9509
  return parseInt(x, 10);
9345
9510
  });
9346
9511
  }
9512
+ VIEW_LIST_KEYS.forEach(function (k) {
9513
+ if (raw[k] !== undefined) {
9514
+ let list = raw[k].split(',').filter(function (x) {
9515
+ return x.length > 0;
9516
+ });
9517
+ if (list.length > 0) {
9518
+ state[k] = list;
9519
+ }
9520
+ }
9521
+ });
9347
9522
  VIEW_SEARCH_KEYS.forEach(function (sk) {
9348
9523
  let p = sk[1];
9349
9524
  if (raw[p]) {
@@ -10261,30 +10436,6 @@ function (root, d3, forester, phyloXml) {
10261
10436
  });
10262
10437
  }
10263
10438
 
10264
- // Search B starts hidden to keep the panel compact; one click (or a
10265
- // configured initial value) reveals it, and it stays revealed.
10266
- // Search B is folded away until wanted: the '+ Search B' link (which
10267
- // focuses it) or a view that carries a second search.
10268
- function showSearchB() {
10269
- openPanelSection('Search');
10270
- let wrap = byId(SEARCH_B_WRAP);
10271
- if (wrap) {
10272
- wrap.style.display = '';
10273
- }
10274
- let tgl = byId(SEARCH_B_TOGGLE);
10275
- if (tgl) {
10276
- tgl.style.display = 'none';
10277
- }
10278
- }
10279
-
10280
- function revealSearchB() {
10281
- showSearchB();
10282
- let f = byId(SEARCH_FIELD_1);
10283
- if (f) {
10284
- f.focus();
10285
- }
10286
- }
10287
-
10288
10439
  // ===================== Time axis =====================
10289
10440
  // The desktop's time overlays, drawn beneath a rectangular PHYLOGRAM of
10290
10441
  // a dated tree: the two-band ICS geologic axis with a "Ma before
@@ -10324,7 +10475,7 @@ function (root, d3, forester, phyloXml) {
10324
10475
  // own bottom rows (conservation, consensus, ruler) must end above it
10325
10476
  // or the bar covers them -- the old bare slider did exactly that.
10326
10477
  // The scale bar sits in the same band, under the tree's last row.
10327
- return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
10478
+ return Math.max(msaShown() ? msaBottomReserve() + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
10328
10479
  heatmapBottomReserve(),
10329
10480
  scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
10330
10481
  }
@@ -11273,8 +11424,6 @@ function (root, d3, forester, phyloXml) {
11273
11424
  }
11274
11425
  if (_state.searchBinitialValue) {
11275
11426
  setValue(SEARCH_FIELD_1, _state.searchBinitialValue);
11276
- revealSearchB();
11277
-
11278
11427
  } else {
11279
11428
  setValue(SEARCH_FIELD_1, '');
11280
11429
  }
@@ -11793,6 +11942,11 @@ function (root, d3, forester, phyloXml) {
11793
11942
  if (msaCb) {
11794
11943
  msaCb.disabled = radialDisplay();
11795
11944
  }
11945
+ let msaLogoCb = byId(MSA_LOGO_CB);
11946
+ if (msaLogoCb) {
11947
+ // nothing to summarize while the alignment itself is not drawn
11948
+ msaLogoCb.disabled = radialDisplay() || _state.showMsa !== true;
11949
+ }
11796
11950
  let heatCb = byId(HEATMAP_CB);
11797
11951
  if (heatCb) {
11798
11952
  // circular draws the columns as rings; only unrooted cannot
@@ -12366,7 +12520,13 @@ function (root, d3, forester, phyloXml) {
12366
12520
  let open = panelSections(panel).filter(function (s) {
12367
12521
  return !s.fieldset.classList.contains('aptx-collapsed') && s.name !== justOpened;
12368
12522
  });
12369
- if (open.length === 0) {
12523
+ // Never the one just opened -- that is the one the user asked
12524
+ // for -- and never the last one standing. Without the second
12525
+ // clause a container dragged short enough empties the panel down
12526
+ // to a stack of legends, taking away the section being worked in.
12527
+ // (justOpened is itself open and excluded from this list, so when
12528
+ // it is set the last one standing is already protected above.)
12529
+ if (open.length === 0 || (!justOpened && open.length === 1)) {
12370
12530
  return;
12371
12531
  }
12372
12532
  open.sort(function (a, b) {
@@ -13073,8 +13233,6 @@ function (root, d3, forester, phyloXml) {
13073
13233
  + '.aptx-panel .aptx-slider-row { display:flex; align-items:center; gap:7px; margin:3px 0; }'
13074
13234
  + '.aptx-panel .aptx-slider-row label { flex:0 0 42px; font-size:10px; color:var(--p-muted); }'
13075
13235
  + '.aptx-panel .aptx-slider-row input[type=range] { flex:1 1 auto; min-width:0; margin:0; }'
13076
- + '.aptx-panel .aptx-linkbtn { background:none; border:0; padding:1px 0 2px; margin:0; font:inherit; font-size:10px; color:var(--p-accent); cursor:pointer; display:block; }'
13077
- + '.aptx-panel .aptx-linkbtn:hover { text-decoration:underline; }'
13078
13236
  // one row, its buttons sharing it evenly, its edges on the zoom rows'
13079
13237
  + '.aptx-panel .aptx-toolrow { margin-top:5px; display:flex; gap:3px; }'
13080
13238
  + '.aptx-panel .aptx-toolrow .aptx-gbtn { flex:1 1 0; min-width:0; padding:0; margin:2px 0; }'
@@ -13282,6 +13440,20 @@ function (root, d3, forester, phyloXml) {
13282
13440
  // spare, since the user opened none of them just now.
13283
13441
  fitPanelSections(panel, null);
13284
13442
 
13443
+ // ... and again whenever the room changes. The panel is held to its
13444
+ // container's height, so a window dragged shorter (or a host resizing
13445
+ // the div around us) can push an arrangement that fitted a moment ago
13446
+ // into a scrollbar. Watching the CONTAINER, not the panel: folding a
13447
+ // section changes the panel's height and would re-enter the observer.
13448
+ // One-way on purpose -- growing the window back does NOT reopen what
13449
+ // was folded. What is open is the user's choice; only running out of
13450
+ // room may overrule it, and then only far enough to fit.
13451
+ if (typeof ResizeObserver !== 'undefined' && panel.parentElement) {
13452
+ new ResizeObserver(function () {
13453
+ fitPanelSections(panel, null);
13454
+ }).observe(panel.parentElement);
13455
+ }
13456
+
13285
13457
  // Apply the current light/dark choice to this (and every) panel.
13286
13458
  applyPanelTheme();
13287
13459
  }
@@ -13426,7 +13598,8 @@ function (root, d3, forester, phyloXml) {
13426
13598
  if (d.name) {
13427
13599
  text += 'Name: ' + d.name + '<br>';
13428
13600
  }
13429
- if (d.branch_length && !(unrooted && d.children)) {
13601
+ if (d.branch_length !== undefined && d.branch_length !== null
13602
+ && !(unrooted && d.children)) {
13430
13603
  text += (unrooted ? 'Branch length: ' : 'Distance to parent: ') + d.branch_length + '<br>';
13431
13604
  }
13432
13605
  let date = dateText(d.date);
@@ -14068,7 +14241,6 @@ function (root, d3, forester, phyloXml) {
14068
14241
  on(CONFIDENCE_VALUES_CB, 'click', confidenceValuesCbClicked);
14069
14242
  on(SUPPORT_DOTS_CB, 'click', supportDotsCbClicked);
14070
14243
  on(MAD_VALUES_CB, 'click', madValuesCbClicked);
14071
- on(SEARCH_B_TOGGLE, 'click', revealSearchB);
14072
14244
  on(SEARCH_NAV_PREV, 'click', function () {
14073
14245
  stepToFoundNode(-1);
14074
14246
  });
@@ -14093,6 +14265,7 @@ function (root, d3, forester, phyloXml) {
14093
14265
 
14094
14266
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
14095
14267
  on(MSA_CB, 'click', msaCbClicked);
14268
+ on(MSA_LOGO_CB, 'click', msaLogoCbClicked);
14096
14269
  on(HEATMAP_CB, 'click', heatmapCbClicked);
14097
14270
  on(HEATMAP_ORDER_SELECT, 'change', heatmapOrderChanged);
14098
14271
  on(HEATMAP_REORDER_BUTTON, 'click', showHeatmapReorderDialog);
@@ -14637,6 +14810,7 @@ function (root, d3, forester, phyloXml) {
14637
14810
  // labels -- they live here
14638
14811
  if (_basicTreeProperties.alignedMolSeqs && _basicTreeProperties.maxMolSeqLength > 0) {
14639
14812
  opts.push(makeCheckboxItem('Alignment', MSA_CB, 'to show/hide the sequence alignment beside the tree (rectangular layout only)'));
14813
+ opts.push(makeCheckboxItem('Sequence Logo', MSA_LOGO_CB, 'to summarize the alignment under it as a sequence logo: each column a stack of letters, as tall as that column\'s information content and shared out by residue frequency. Over the tips currently on screen, so entering a clade summarizes that clade'));
14640
14814
  }
14641
14815
  if (_basicTreeProperties.domainArchitectures) {
14642
14816
  opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
@@ -14810,14 +14984,14 @@ function (root, d3, forester, phyloXml) {
14810
14984
  h = h.concat('<fieldset>');
14811
14985
  h = h.concat('<legend>Search</legend>');
14812
14986
  h = h.concat(makeSearchBox('Search A', 0));
14813
- // Search B starts hidden, one click away: the second box (and the
14814
- // Combine control it brings) is rarely needed, and the panel is
14815
- // long enough without it
14816
- h = h.concat('<button type="button" class="aptx-linkbtn" id="' + SEARCH_B_TOGGLE
14817
- + '" title="add a second search box, combinable with the first">+ Search B</button>');
14818
- h = h.concat('<div id="' + SEARCH_B_WRAP + '" style="display:none">');
14987
+ // Search B used to sit behind a '+ Search B' link, to keep 45px out
14988
+ // of the panel. That link was a second, weaker disclosure mechanism
14989
+ // nested inside the section fold: it was forgotten on every reload
14990
+ // (the fold is remembered), it was one-way -- nothing put B back --
14991
+ // and it defeated the fit rule, since revealing B grew a section
14992
+ // that had just been fitted without it, leaving the panel to
14993
+ // scroll. The fold IS the disclosure. B is simply here.
14819
14994
  h = h.concat(makeSearchBox('Search B', 1));
14820
- h = h.concat('</div>');
14821
14995
  h = h.concat('<div class="aptx-searchnav" id="' + SEARCH_NAV_ROW + '" style="display:none">');
14822
14996
  h = h.concat('<button type="button" class="aptx-gbtn" id="' + SEARCH_NAV_PREV
14823
14997
  + '" title="center the previous search hit">&#9664;</button>');
@@ -14971,6 +15145,7 @@ function (root, d3, forester, phyloXml) {
14971
15145
  setCheckboxValue(VIS_CB, _state.showVisualizations);
14972
15146
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
14973
15147
  setCheckboxValue(MSA_CB, _state.showMsa);
15148
+ setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
14974
15149
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
14975
15150
  syncDomainControls();
14976
15151
  syncHeatmapControls();
@@ -15031,7 +15206,6 @@ function (root, d3, forester, phyloXml) {
15031
15206
  }
15032
15207
  if (_state.searchBinitialValue) {
15033
15208
  setValue(SEARCH_FIELD_1, _state.searchBinitialValue);
15034
- revealSearchB();
15035
15209
  }
15036
15210
  }
15037
15211