archaeopteryx 3.11.0 → 3.12.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/README.md CHANGED
@@ -37,6 +37,7 @@ config key live and shows the exact config JSON to copy into your own
37
37
  * [Nucleotide alignment (600 columns)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=alignment_nt)
38
38
  * [Genome alignment (150 × 30,000 columns)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=genome_alignment)
39
39
  * [Sequence alignment](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=alignment)
40
+ * [Sequence logo (the motif changes by clade)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=sequence_logo)
40
41
  * [Influenza HA (annotated)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=influenza)
41
42
  * [Dinosaur time tree](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=dinosaur)
42
43
  * [Ammonite time tree (fossil ranges)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=ammonite)
@@ -455,7 +456,9 @@ desktop's Unicode entry: it stays with the text field there, as it should.
455
456
  A view is what you made of a tree with the panel: the layout and display
456
457
  type, which labels show, the colour and shape fields, both searches, the
457
458
  clade you switched to, the clades you collapsed, the font, node and branch
458
- sizes, the rotation, the tracks. On the demo pages it rides in the URL's
459
+ sizes, the rotation, and the tracks — which track is shown, the heat map's
460
+ column order (including one you arranged by hand) and whether the alignment
461
+ is summarised as a logo. On the demo pages it rides in the URL's
459
462
  `#` hash and follows every change, so the address bar is always a link to
460
463
  what is on screen: copy it (**Copy link to this view** in the toolbar) and
461
464
  the recipient opens the same tree in the same view. Opening your own file
@@ -472,7 +475,9 @@ as the page does.
472
475
  Embedders get the same four pieces: the handle's `getViewState()` and
473
476
  `applyViewState(state)`, the config's `view` (open straight into one) and
474
477
  `onViewChange(state, encoded)` (called when it changes), and
475
- `archaeopteryx.encodeViewState()` / `decodeViewState()` for the hash form,
478
+ `archaeopteryx.encodeViewState()` / `decodeViewState()` for the hash form
479
+ (every key the state can hold survives the hash — a test reads `getViewState`
480
+ and fails on any key the codec does not know),
476
481
  which reads like
477
482
  `layout=circular&colorBy=tax:common_name&show=name,external&font=9&collapsed=12,44&a=HUMAN&af=Any+Text&am=contains`.
478
483
  Nodes are named by their launch-time preorder index, so a view belongs to
@@ -530,6 +535,23 @@ checkbox under Display Data toggles the whole track.
530
535
  To find a motif, pick **Molecular Sequence** in a search box: it matches the
531
536
  residues as written, gap characters included, as the desktop does.
532
537
 
538
+ **Sequence Logo** (the checkbox under **Alignment**) replaces the conservation
539
+ bar with a **logo**: every column a stack of letters, as tall as that column's
540
+ information content in bits and shared out by residue frequency, most frequent
541
+ on top — the display the MEME Suite and WebLogo draw. A conserved column is one
542
+ tall letter, a variable one a short pile, and the caption gives the scale
543
+ (0 to 2 bits for nucleotides, 0 to 4.3 for amino acids).
544
+
545
+ It summarises **the tips currently on screen**, so entering a clade gives that
546
+ clade's motif rather than the file's, and the caption names how many tips that
547
+ is (`n = 12`). Two consequences worth knowing: gaps are not a letter —
548
+ frequencies are taken over the residues present, and the stack is then scaled
549
+ by the column's occupancy, so a column held up by two sequences out of fifty
550
+ draws short rather than perfectly conserved; and there is **no small-sample
551
+ correction**, because entering a three-tip clade is a normal thing to do and
552
+ Schneider's correction would subtract more than the maximum and leave the
553
+ column blank. Read `n` and judge.
554
+
533
555
  Alignments arrive with the tree: as phyloXML `<mol_seq is_aligned="true">`
534
556
  elements, or in a **Nexus** file whose characters matrix accompanies its tree.
535
557
  The **Nexus** entry in the Download menu writes the current tree *and* its
@@ -1041,6 +1063,7 @@ copy-pastable JSON.
1041
1063
  | `layout` | `'rectangular'` | The starting layout: `'rectangular'`, `'circular'`, or `'unrooted'`. |
1042
1064
  | `ladderizeTree` | `true` | Ladderize the tree on load: at each node, the larger clade first (any number of children, so a polytomy sorts too). |
1043
1065
  | `showMsa` | tree-derived | Open with the alignment track shown. Default: on when the tree carries an aligned `mol_seq`, off otherwise — an explicit `true`/`false` overrides that. |
1066
+ | `showMsaLogo` | `false` | Open with the alignment summarised as a sequence logo instead of a conservation bar: each column a stack of letters as tall as its information content, over the tips currently on screen. Only drawn while the alignment track is shown. |
1044
1067
  | `showHeatmap` | `false` | Open with the heat map shown. Offered whenever the tree carries two or more numeric per-tip fields, but off unless asked for: almost any annotated tree has such fields, so turning it on by itself would be an opinion about the tree rather than a service. |
1045
1068
  | `heatmapColumnOrder` | tree-derived | How the heat map's columns are ordered: `'document'` (as the file lists them), `'clustered'` (Euclidean), `'clustered-presence'` (Bray–Curtis), `'alphabetical'`, `'frequency'`. The clustered modes also draw the dendrogram. Default: a **clustered** order, with the distance chosen from the values — Bray–Curtis where the matrix has zeros to ignore and nothing negative, Euclidean otherwise. An explicit value always wins and is never re-derived. |
1046
1069
  | `heatmapManualOrder` | `null` | The heat map's columns in your own order, as an array of property refs (`['meta:recA', 'meta:gyrA', …]`). Only read while `heatmapColumnOrder` is `'manual'`. A ref the tree has not got is ignored, and a column the list does not name follows the ones it does. |
@@ -1716,8 +1739,32 @@ which clamps and redraws; the tree never moves. A faint dashed guide runs
1716
1739
  from each tip's label (or its node, when labels are hidden) across to that
1717
1740
  tip's row, so a row reads back to its sequence without counting.
1718
1741
 
1719
- The conservation bar, consensus row and column ruler are a **floating strip**
1720
- (see the time axes below); the residue rows stay with their tips.
1742
+ **The sequence logo** (`showMsaLogo`, `forester.msaLogo`) replaces the
1743
+ conservation bar and the consensus row rather than joining them: a stack's
1744
+ height *is* the column's conservation and its top letter *is* the consensus,
1745
+ so all three would say one thing three times. Per column the model returns
1746
+ `bits` = `log₂K − H` over the non-gap residues, `occupancy` = non-gap / rows,
1747
+ `height` = `bits × occupancy`, and the letters most frequent first (ties
1748
+ alphabetical, so a figure reproduces). Rows are the **displayed** tips over
1749
+ the visible window, which is what makes entering a clade re-read the summary.
1750
+ **No small-sample correction**: Schneider's `e_n = (K−1)/(2 ln2 · n)` is for a
1751
+ motif sampled from many sequences, and at n = 3 for protein it exceeds the
1752
+ 4.32-bit maximum, so a perfectly conserved column of a small clade would draw
1753
+ nothing — the caption names `n` instead.
1754
+
1755
+ Each letter is scaled to fill its slice: `sy = hpx / (ascent + descent)` of
1756
+ **that glyph's** ink box, measured once per character off a canvas, with the
1757
+ baseline placed at `y − descent × sy` so the ink lands inside the slice.
1758
+ Measured off one glyph instead, Q and G hung their descenders through the
1759
+ ruler. Note that an SVG `<text>`'s `getBoundingClientRect` is the *font's*
1760
+ layout box, not the ink, so it cannot check this — `test_trees/msa_logo.html`
1761
+ compares letters and order through the DOM and the ink itself is measured from
1762
+ rendered pixels.
1763
+
1764
+ The conservation bar or logo, the consensus row and the column ruler are a
1765
+ **floating strip** (see the time axes below); the residue rows stay with their
1766
+ tips. The strip's height follows what it holds (`msaBottomReserve()`), and the
1767
+ bottom reserve the fit allows for follows that.
1721
1768
 
1722
1769
  ### The heat map
1723
1770
 
@@ -98,6 +98,10 @@ export interface ArchaeopteryxConfig {
98
98
  heatmapManualOrder?: string[] | null;
99
99
  showHeatmap?: boolean;
100
100
  showMsa?: boolean;
101
+ /** Summarize the alignment as a sequence logo instead of a conservation
102
+ * bar: each column a stack of letters as tall as its information content,
103
+ * over the tips currently on screen. Only drawn with the track shown. */
104
+ showMsaLogo?: boolean;
101
105
  showSupportDots?: boolean;
102
106
  showTimeAxis?: boolean;
103
107
  supportDotMinimum?: number;
@@ -150,6 +154,8 @@ export interface ViewState {
150
154
  rotation?: number;
151
155
  horizontalLabels?: boolean;
152
156
  msa?: boolean;
157
+ /** The alignment summarized as a sequence logo. */
158
+ msaLogo?: boolean;
153
159
  heatmap?: boolean;
154
160
  heatmapOrder?: 'document' | 'clustered' | 'clustered-presence' | 'alphabetical' | 'frequency' | 'manual';
155
161
  heatmapManual?: string[];
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.11.0
23
+ // v 3.12.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.12.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';
@@ -547,7 +548,11 @@ function (root, d3, forester, phyloXml) {
547
548
  const MSA_MAX_VIEWPORT_FRACTION = 0.6;// nor grows past this share of the display
548
549
  const MSA_CONS_BAR_H = 22; // conservation bar band height
549
550
  const MSA_CONS_TOP_GAP = 4;
550
- const MSA_BOTTOM_RESERVE = 56; // vertical room under the rows (conservation + ruler)
551
+ const MSA_CONS_ROW_H = 13; // the consensus letter row
552
+ const MSA_RULER_H = 17; // the column ruler under it
553
+ const MSA_LOGO_H = 46; // the sequence logo's stack, full height
554
+ const MSA_LOGO_CAP_H = 11; // ... and the caption line above it
555
+ const MSA_LOGO_FONT_PX = 24; // the size logo glyphs are measured at, then scaled
551
556
  const MSA_NAV_RESERVE = 46; // ... plus the navigation bar floating at the viewport bottom
552
557
  const MSA_MIN_TREE_PX = 220; // the tree itself never shrinks below this
553
558
  let _msaColOffset = 0; // first shown alignment column (0-based)
@@ -4927,6 +4932,7 @@ function (root, d3, forester, phyloXml) {
4927
4932
  const STATE_KEYS = [
4928
4933
  'layout',
4929
4934
  'showMsa',
4935
+ 'showMsaLogo',
4930
4936
  'showHeatmap',
4931
4937
  'heatmapColumnOrder',
4932
4938
  'heatmapManualOrder',
@@ -5169,6 +5175,12 @@ function (root, d3, forester, phyloXml) {
5169
5175
  if (_state.showHeatmap === undefined) {
5170
5176
  _state.showHeatmap = false;
5171
5177
  }
5178
+ // The logo is a summary of the alignment, not a second view of the
5179
+ // tree, so it waits to be asked for: an alignment opens showing its
5180
+ // residues, which is what a reader came for.
5181
+ if (_state.showMsaLogo === undefined) {
5182
+ _state.showMsaLogo = false;
5183
+ }
5172
5184
  // The columns' order. Left unset it is resolved from the data on first
5173
5185
  // use (heatmapMode / forester.heatmapDefaultOrder): a clustered order,
5174
5186
  // because reading block structure is what a heat map beside a tree is
@@ -6824,6 +6836,64 @@ function (root, d3, forester, phyloXml) {
6824
6836
  && _basicTreeProperties.maxMolSeqLength > 0;
6825
6837
  }
6826
6838
 
6839
+ // The logo replaces the conservation bar and the consensus row rather
6840
+ // than joining them: a stack's height IS the column's conservation and its
6841
+ // top letter IS the consensus, so drawing all three would say the same
6842
+ // thing three times and take three times the room.
6843
+ function msaLogoShown() {
6844
+ return _state.showMsaLogo === true && msaShown();
6845
+ }
6846
+
6847
+ // What the strip under the alignment needs. The conservation form is the
6848
+ // 56 px this was a constant for; the logo form is taller and drops the
6849
+ // consensus row it subsumes.
6850
+ function msaBottomReserve() {
6851
+ return MSA_CONS_TOP_GAP
6852
+ + (msaLogoShown() ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H)
6853
+ + MSA_RULER_H;
6854
+ }
6855
+
6856
+ // Logo letters are drawn at a fixed size and then scaled to their cell and
6857
+ // to their share of the stack, so what is needed is how much ink this face
6858
+ // actually puts on the page for THIS letter -- measured, not assumed: a
6859
+ // 0.6 advance and a 0.72 cap height are true of some monospace faces and
6860
+ // not others, and a letter scaled by the wrong constant overflows its
6861
+ // column.
6862
+ //
6863
+ // Per letter, because a logo's letters must each fill their slice exactly:
6864
+ // measured off one glyph, Q and G hung their descenders through the
6865
+ // baseline and across the ruler, and every letter without a descender sat
6866
+ // a little short. What is scaled to the slice is the glyph's INK BOX
6867
+ // (ascent + descent), and the baseline is then placed so that box lands
6868
+ // inside the slice.
6869
+ let _logoGlyphs = {};
6870
+
6871
+ function logoGlyphMetrics(ch) {
6872
+ if (_logoGlyphs[ch]) {
6873
+ return _logoGlyphs[ch];
6874
+ }
6875
+ if (!_legendMeasureCtx) {
6876
+ _legendMeasureCtx = document.createElement('canvas').getContext('2d');
6877
+ }
6878
+ _legendMeasureCtx.font = MSA_LOGO_FONT_PX + 'px monospace';
6879
+ let m = _legendMeasureCtx.measureText(ch);
6880
+ let ascent = m.actualBoundingBoxAscent;
6881
+ let descent = m.actualBoundingBoxDescent;
6882
+ if (!(ascent > 0)) {
6883
+ ascent = MSA_LOGO_FONT_PX * 0.72; // a face that will not say
6884
+ descent = 0;
6885
+ }
6886
+ if (!(descent > 0)) {
6887
+ descent = 0; // no descender, or none reported
6888
+ }
6889
+ _logoGlyphs[ch] = {
6890
+ width: m.width || (MSA_LOGO_FONT_PX * 0.6),
6891
+ ascent: ascent,
6892
+ descent: descent
6893
+ };
6894
+ return _logoGlyphs[ch];
6895
+ }
6896
+
6827
6897
  function msaRowSeq(d) {
6828
6898
  if (d.sequences && d.sequences.length > 0) {
6829
6899
  let s = d.sequences[0];
@@ -7014,12 +7084,15 @@ function (root, d3, forester, phyloXml) {
7014
7084
  .attr('stroke', ink).attr('stroke-opacity', 0.7).attr('stroke-width', 1);
7015
7085
  }
7016
7086
 
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) {
7087
+ // Scored over the visible WINDOW only -- the whole alignment would
7088
+ // cost rows x total per redraw; indices below are window-relative.
7089
+ // The rows are the tips ON SCREEN: enter a clade and the summary is
7090
+ // that clade's, which is the whole point of putting it next to a tree.
7091
+ let windowRows = tips.map(function (t) {
7021
7092
  return msaRowSeq(t).slice(offset, offset + visible);
7022
- }), visible, 'identity', isNuc);
7093
+ });
7094
+ let logo = msaLogoShown() ? forester.msaLogo(windowRows, visible, isNuc) : null;
7095
+ let cons = logo ? null : forester.msaConservation(windowRows, visible, 'identity', isNuc);
7023
7096
  // A faint dashed guide from each tip -- from the end of its label, or
7024
7097
  // from the node itself when the label is hidden -- across to its row
7025
7098
  // in the track, so a row can be read back to its sequence without
@@ -7055,34 +7128,91 @@ function (root, d3, forester, phyloXml) {
7055
7128
  }
7056
7129
 
7057
7130
  // ---- conservation, consensus, ruler: a FLOATING strip ----
7058
- let strip = floatStripGroup('aptx-msa-strip', _clusterH, MSA_BOTTOM_RESERVE);
7131
+ let stripH = msaBottomReserve();
7132
+ let strip = floatStripGroup('aptx-msa-strip', _clusterH, stripH);
7059
7133
  strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
7060
- .attr('width', trackW + 4).attr('height', MSA_BOTTOM_RESERVE)
7134
+ .attr('width', trackW + 4).attr('height', stripH)
7061
7135
  .attr('fill', _state.backgroundColorDefault);
7062
7136
  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;
7137
+ // A caption on the panel's own background, so it stays readable over
7138
+ // whatever the band happens to draw underneath it.
7139
+ let caption = function (txt, y) {
7140
+ if (trackW <= 170) {
7141
+ return;
7070
7142
  }
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)
7143
+ let font = '8px ' + FONT_DEFAULTS;
7144
+ let w = legendTextWidth(txt, font) + 6;
7145
+ let xe = Math.round(originX) + trackW - 3;
7146
+ strip.append('rect').attr('x', xe - w).attr('y', y - 8)
7147
+ .attr('width', w).attr('height', 11)
7148
+ .attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
7149
+ strip.append('text').attr('x', xe - 3).attr('y', y)
7080
7150
  .attr('text-anchor', 'end')
7081
7151
  .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) {
7152
+ .text(txt);
7153
+ };
7154
+ if (logo) {
7155
+ // Each column a stack of letters: total height the column's
7156
+ // information content in bits, each letter's share its frequency.
7157
+ // Least frequent at the BOTTOM, so the eye reads the consensus
7158
+ // off the top of the stack.
7159
+ //
7160
+ // The caption gets a line of its own above the stacks (hence the
7161
+ // CAP_H here). Laid over them it washed out the very letters it
7162
+ // was labelling, which is a poor trade for eleven pixels.
7163
+ let floor = consTop + MSA_LOGO_CAP_H + MSA_LOGO_H;
7164
+ strip.append('line')
7165
+ .attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
7166
+ .attr('y1', floor).attr('y2', floor)
7167
+ .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
7168
+ for (let i = 0; i < visible; ++i) {
7169
+ let col = logo.columns[i];
7170
+ if (!col || !(col.height > 0)) {
7171
+ continue;
7172
+ }
7173
+ let x0 = Math.round(originX + (i * cw));
7174
+ let x1 = Math.round(originX + ((i + 1) * cw));
7175
+ let y = floor;
7176
+ for (let k = col.letters.length - 1; k >= 0; --k) {
7177
+ let letter = col.letters[k];
7178
+ let hpx = (letter.h / logo.maxBits) * MSA_LOGO_H;
7179
+ if (hpx < 0.5) {
7180
+ continue; // under half a pixel of ink: nothing to draw
7181
+ }
7182
+ let glyph = logoGlyphMetrics(letter.ch);
7183
+ let sy = hpx / (glyph.ascent + glyph.descent);
7184
+ let rgb = forester.msaResidueRgb(letter.ch, isNuc);
7185
+ strip.append('text')
7186
+ .attr('transform', 'translate(' + (x0 + ((x1 - x0) / 2)) + ','
7187
+ + (y - (glyph.descent * sy)) + ')'
7188
+ + ' scale(' + (Math.max(1, x1 - x0) / glyph.width) + ',' + sy + ')')
7189
+ .attr('text-anchor', 'middle')
7190
+ .style('font-family', 'monospace')
7191
+ .style('font-size', MSA_LOGO_FONT_PX + 'px')
7192
+ .style('fill', rgb === null ? ink : 'rgb(' + rgb.join(',') + ')')
7193
+ .text(letter.ch);
7194
+ y -= hpx;
7195
+ }
7196
+ }
7197
+ caption('Sequence logo \u2014 0 to ' + logo.maxBits.toFixed(1)
7198
+ + ' bits (n = ' + n + ')', consTop + 8);
7199
+ } else {
7200
+ strip.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
7201
+ .attr('width', trackW).attr('height', MSA_CONS_BAR_H)
7202
+ .attr('fill', ink).attr('fill-opacity', 0.08);
7203
+ for (let i = 0; i < visible; ++i) {
7204
+ let score = cons.scores[i] || 0;
7205
+ if (score <= 0) {
7206
+ continue;
7207
+ }
7208
+ let bh = Math.max(1, Math.round(score * MSA_CONS_BAR_H));
7209
+ let x0 = Math.round(originX + (i * cw));
7210
+ let x1 = Math.round(originX + ((i + 1) * cw));
7211
+ strip.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
7212
+ .attr('width', Math.max(1, x1 - x0)).attr('height', bh)
7213
+ .attr('fill', ink).attr('fill-opacity', 0.7);
7214
+ }
7215
+ caption('Consensus identity (n = ' + n + ')', consTop + 9);
7086
7216
  for (let i = 0; i < visible; ++i) {
7087
7217
  let cc = cons.consensus[i];
7088
7218
  if (!cc) {
@@ -7099,7 +7229,7 @@ function (root, d3, forester, phyloXml) {
7099
7229
 
7100
7230
  // the 1-based column ruler: absolute column numbers at nice steps,
7101
7231
  // and always the first and last column when their edge is in view
7102
- let rulerY = consTop + MSA_CONS_BAR_H + (consensusRow ? 13 : 3);
7232
+ let rulerY = consTop + (logo ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H);
7103
7233
  strip.append('line').attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
7104
7234
  .attr('y1', rulerY).attr('y2', rulerY)
7105
7235
  .attr('stroke', ink).attr('stroke-opacity', 0.8).attr('stroke-width', 1);
@@ -7357,6 +7487,11 @@ function (root, d3, forester, phyloXml) {
7357
7487
  scheduleUpdate(null, 0);
7358
7488
  }
7359
7489
 
7490
+ function msaLogoCbClicked() {
7491
+ _state.showMsaLogo = getCheckboxValue(MSA_LOGO_CB);
7492
+ scheduleUpdate(null, 0);
7493
+ }
7494
+
7360
7495
  // ===================== Heat map =====================
7361
7496
  // The desktop's MATRIX annotation columns: one cell per (tip, numeric
7362
7497
  // per-tip property) to the right of the labels, every column painted on
@@ -8930,6 +9065,9 @@ function (root, d3, forester, phyloXml) {
8930
9065
  }
8931
9066
  if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
8932
9067
  s.msa = _state.showMsa === true;
9068
+ if (_state.showMsaLogo === true) {
9069
+ s.msaLogo = true;
9070
+ }
8933
9071
  }
8934
9072
  if (heatmapAvailable()) {
8935
9073
  s.heatmap = _state.showHeatmap === true;
@@ -9058,6 +9196,9 @@ function (root, d3, forester, phyloXml) {
9058
9196
  if (typeof s.msa === 'boolean') {
9059
9197
  _state.showMsa = s.msa;
9060
9198
  }
9199
+ if (typeof s.msaLogo === 'boolean') {
9200
+ _state.showMsaLogo = s.msaLogo;
9201
+ }
9061
9202
  if (typeof s.heatmap === 'boolean') {
9062
9203
  _state.showHeatmap = s.heatmap;
9063
9204
  }
@@ -9168,6 +9309,7 @@ function (root, d3, forester, phyloXml) {
9168
9309
  }
9169
9310
  });
9170
9311
  setCheckboxValue(MSA_CB, _state.showMsa === true);
9312
+ setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
9171
9313
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
9172
9314
  syncHeatmapControls();
9173
9315
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
@@ -9241,10 +9383,20 @@ function (root, d3, forester, phyloXml) {
9241
9383
  // Lists are comma-joined, booleans 1 / 0, the two searches flattened
9242
9384
  // to a / af / am / a2 and b / bf / bm / b2. Made for a URL hash, where
9243
9385
  // none of these characters needs escaping.
9244
- const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine'];
9386
+ // Every key getViewState writes has to be here, or a view survives
9387
+ // getViewState/applyViewState (what an embedder uses) and is silently
9388
+ // dropped from the URL hash (what "Copy link to this view" produces).
9389
+ // The heat map's four keys and the alignment logo went in exactly that
9390
+ // way, while the README promised a link reproduced the figure.
9391
+ const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine',
9392
+ 'heatmapOrder'];
9245
9393
  const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
9246
9394
  const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
9247
- const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'domains', 'domainGlow', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
9395
+ const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow',
9396
+ 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
9397
+ // Lists of strings, comma-joined. A property ref carries ':' but never a
9398
+ // comma, and encodeViewValue leaves both readable in a hash.
9399
+ const VIEW_LIST_KEYS = ['heatmapManual'];
9248
9400
  const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
9249
9401
 
9250
9402
  function encodeViewValue(v) {
@@ -9278,6 +9430,12 @@ function (root, d3, forester, phyloXml) {
9278
9430
  if (Array.isArray(state.collapsed) && state.collapsed.length > 0) {
9279
9431
  put('collapsed', state.collapsed.join(','));
9280
9432
  }
9433
+ VIEW_LIST_KEYS.forEach(function (k) {
9434
+ let v = state[k];
9435
+ if (Array.isArray(v) && v.length > 0) {
9436
+ parts.push(k + '=' + v.map(encodeViewValue).join(','));
9437
+ }
9438
+ });
9281
9439
  VIEW_BOOL_KEYS.forEach(function (k) {
9282
9440
  if (typeof state[k] === 'boolean') {
9283
9441
  put(k, state[k] ? 1 : 0);
@@ -9344,6 +9502,16 @@ function (root, d3, forester, phyloXml) {
9344
9502
  return parseInt(x, 10);
9345
9503
  });
9346
9504
  }
9505
+ VIEW_LIST_KEYS.forEach(function (k) {
9506
+ if (raw[k] !== undefined) {
9507
+ let list = raw[k].split(',').filter(function (x) {
9508
+ return x.length > 0;
9509
+ });
9510
+ if (list.length > 0) {
9511
+ state[k] = list;
9512
+ }
9513
+ }
9514
+ });
9347
9515
  VIEW_SEARCH_KEYS.forEach(function (sk) {
9348
9516
  let p = sk[1];
9349
9517
  if (raw[p]) {
@@ -10324,7 +10492,7 @@ function (root, d3, forester, phyloXml) {
10324
10492
  // own bottom rows (conservation, consensus, ruler) must end above it
10325
10493
  // or the bar covers them -- the old bare slider did exactly that.
10326
10494
  // 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(),
10495
+ return Math.max(msaShown() ? msaBottomReserve() + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
10328
10496
  heatmapBottomReserve(),
10329
10497
  scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
10330
10498
  }
@@ -11793,6 +11961,11 @@ function (root, d3, forester, phyloXml) {
11793
11961
  if (msaCb) {
11794
11962
  msaCb.disabled = radialDisplay();
11795
11963
  }
11964
+ let msaLogoCb = byId(MSA_LOGO_CB);
11965
+ if (msaLogoCb) {
11966
+ // nothing to summarize while the alignment itself is not drawn
11967
+ msaLogoCb.disabled = radialDisplay() || _state.showMsa !== true;
11968
+ }
11796
11969
  let heatCb = byId(HEATMAP_CB);
11797
11970
  if (heatCb) {
11798
11971
  // circular draws the columns as rings; only unrooted cannot
@@ -14093,6 +14266,7 @@ function (root, d3, forester, phyloXml) {
14093
14266
 
14094
14267
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
14095
14268
  on(MSA_CB, 'click', msaCbClicked);
14269
+ on(MSA_LOGO_CB, 'click', msaLogoCbClicked);
14096
14270
  on(HEATMAP_CB, 'click', heatmapCbClicked);
14097
14271
  on(HEATMAP_ORDER_SELECT, 'change', heatmapOrderChanged);
14098
14272
  on(HEATMAP_REORDER_BUTTON, 'click', showHeatmapReorderDialog);
@@ -14637,6 +14811,7 @@ function (root, d3, forester, phyloXml) {
14637
14811
  // labels -- they live here
14638
14812
  if (_basicTreeProperties.alignedMolSeqs && _basicTreeProperties.maxMolSeqLength > 0) {
14639
14813
  opts.push(makeCheckboxItem('Alignment', MSA_CB, 'to show/hide the sequence alignment beside the tree (rectangular layout only)'));
14814
+ 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
14815
  }
14641
14816
  if (_basicTreeProperties.domainArchitectures) {
14642
14817
  opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
@@ -14971,6 +15146,7 @@ function (root, d3, forester, phyloXml) {
14971
15146
  setCheckboxValue(VIS_CB, _state.showVisualizations);
14972
15147
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
14973
15148
  setCheckboxValue(MSA_CB, _state.showMsa);
15149
+ setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
14974
15150
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
14975
15151
  syncDomainControls();
14976
15152
  syncHeatmapControls();
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.11.0
23
+ // v 3.12.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -8440,6 +8440,70 @@
8440
8440
  return {scores: scores, consensus: consensus};
8441
8441
  };
8442
8442
 
8443
+ // Per-column sequence logo over the given rows -- the stack of letters
8444
+ // whose total height is the column's information content in BITS and whose
8445
+ // shares are the residue frequencies (Schneider & Stephens 1990), which is
8446
+ // the display the MEME Suite and WebLogo draw.
8447
+ //
8448
+ // No small-sample correction. Schneider's e_n = (K-1)/(2 ln2 n) is meant
8449
+ // for a motif sampled from many sequences; here the rows are the tips
8450
+ // currently on screen, so n is routinely 3 or 4 -- and for protein at n=3
8451
+ // the correction (4.57 bits) EXCEEDS the maximum (4.32), so a perfectly
8452
+ // conserved column of a small clade would draw nothing at all, exactly
8453
+ // when the reader has asked about that clade. The caller names n instead.
8454
+ //
8455
+ // Gaps are not a letter: frequencies are taken over the non-gap residues,
8456
+ // and the stack is then scaled by the column's occupancy, so a column held
8457
+ // up by two residues out of fifty is short rather than perfectly conserved.
8458
+ // `bits` and `occupancy` are returned separately so a caller can say which
8459
+ // of the two made a column short.
8460
+ //
8461
+ // Letters come back most frequent FIRST, ties broken alphabetically, so a
8462
+ // figure is reproducible rather than at the mercy of key order.
8463
+ forester.msaLogo = function (rows, length, nucleotide) {
8464
+ let maxBits = Math.log(nucleotide ? 4 : 20) / Math.LN2;
8465
+ if (!(length >= 0) || !isFinite(length)) {
8466
+ return {columns: [], maxBits: maxBits};
8467
+ }
8468
+ length = Math.floor(length);
8469
+ let n = rows.length;
8470
+ let columns = new Array(length);
8471
+ for (let c = 0; c < length; ++c) {
8472
+ let counts = {};
8473
+ let nonGap = 0;
8474
+ for (let r = 0; r < n; ++r) {
8475
+ let row = rows[r];
8476
+ let ch = (row && c < row.length) ? row.charAt(c) : '-';
8477
+ if (forester.isMsaGap(ch)) {
8478
+ continue;
8479
+ }
8480
+ ch = ch.toUpperCase();
8481
+ ++nonGap;
8482
+ counts[ch] = (counts[ch] || 0) + 1;
8483
+ }
8484
+ if (n < 1 || nonGap < 1) {
8485
+ columns[c] = {bits: 0, occupancy: 0, height: 0, letters: []};
8486
+ continue;
8487
+ }
8488
+ let H = 0;
8489
+ Object.keys(counts).forEach(function (ch) {
8490
+ let p = counts[ch] / nonGap;
8491
+ H -= p * (Math.log(p) / Math.LN2);
8492
+ });
8493
+ let bits = Math.max(0, maxBits - H);
8494
+ let occupancy = nonGap / n;
8495
+ let height = bits * occupancy;
8496
+ let letters = Object.keys(counts).sort().sort(function (a, b) {
8497
+ return counts[b] - counts[a]; // count first, alphabetical within a tie
8498
+ }).map(function (ch) {
8499
+ let p = counts[ch] / nonGap;
8500
+ return {ch: ch, p: p, h: p * height};
8501
+ });
8502
+ columns[c] = {bits: bits, occupancy: occupancy, height: height, letters: letters};
8503
+ }
8504
+ return {columns: columns, maxBits: maxBits};
8505
+ };
8506
+
8443
8507
  // The hover readout's description of one residue: full name, class (amino
8444
8508
  // acids), Kyte-Doolittle hydropathy. Returns null for a gap.
8445
8509
  forester.msaResidueInfo = function (ch, nucleotide) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Archaeopteryx.js is a software tool for the visualization and analysis of highly annotated phylogenetic trees.",
5
5
  "main": "archaeopteryx.js",
6
6
  "types": "archaeopteryx.d.ts",