archaeopteryx 3.10.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
@@ -33,9 +33,11 @@ config key live and shows the exact config JSON to copy into your own
33
33
  * [Caliciviridae (186 strains)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=caliciviridae_500)
34
34
  * [Adenoviridae (321 strains)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=adenoviridae)
35
35
  * [Apaf-1 gene family (domain architectures)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=apaf)
36
+ * [Clustergram (heat map + column clustering)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=clustergram)
36
37
  * [Nucleotide alignment (600 columns)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=alignment_nt)
37
38
  * [Genome alignment (150 × 30,000 columns)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=genome_alignment)
38
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)
39
41
  * [Influenza HA (annotated)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=influenza)
40
42
  * [Dinosaur time tree](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=dinosaur)
41
43
  * [Ammonite time tree (fossil ranges)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=ammonite)
@@ -220,7 +222,7 @@ Subtree** opens everything below a node; the tool row's uncollapse-all button
220
222
  (the desktop's glyph, lit only while something is collapsed) opens the whole
221
223
  tree, and so does **Esc**. The wedge has its apex at the node, one edge
222
224
  reaching the clade's nearest tip and the other its farthest, so the shape
223
- shows how uneven the clade's branch lengths are, as iTOL draws it (one depth
225
+ shows how uneven the clade's branch lengths are (one depth
224
226
  step in a cladogram). Its label stands where a tip's would: on the label
225
227
  column in the aligned phylogram and on the outer ring in circular, with the
226
228
  same guide line; it is filled in the colour most of its tips wear under the current
@@ -370,8 +372,9 @@ Date" comes back as `Collection Date` in every menu; a header that already
370
372
  reads as `namespace:name` is kept as it is). From there nothing is special:
371
373
  the columns are offered for **Color-by** and **Shape** by the same rules as
372
374
  any property, with the same legends; they are **search** fields, typed
373
- numeric when every filled cell is a number; they appear in the **node data**;
374
- and they are written into a phyloXML export, so a saved tree keeps them.
375
+ numeric when every filled cell is a number; the numeric ones become **heat
376
+ map** columns; they appear in the **node data**; and they are written into a
377
+ phyloXML export, so a saved tree keeps them.
375
378
  Tip names are matched exactly, then case-insensitively; empty cells add
376
379
  nothing; a column the tree already carries under the same ref is replaced by
377
380
  the table's values. Quoted cells, `#` comment lines and Windows line ends are
@@ -453,7 +456,9 @@ desktop's Unicode entry: it stays with the text field there, as it should.
453
456
  A view is what you made of a tree with the panel: the layout and display
454
457
  type, which labels show, the colour and shape fields, both searches, the
455
458
  clade you switched to, the clades you collapsed, the font, node and branch
456
- 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
457
462
  `#` hash and follows every change, so the address bar is always a link to
458
463
  what is on screen: copy it (**Copy link to this view** in the toolbar) and
459
464
  the recipient opens the same tree in the same view. Opening your own file
@@ -470,7 +475,9 @@ as the page does.
470
475
  Embedders get the same four pieces: the handle's `getViewState()` and
471
476
  `applyViewState(state)`, the config's `view` (open straight into one) and
472
477
  `onViewChange(state, encoded)` (called when it changes), and
473
- `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),
474
481
  which reads like
475
482
  `layout=circular&colorBy=tax:common_name&show=name,external&font=9&collapsed=12,44&a=HUMAN&af=Any+Text&am=contains`.
476
483
  Nodes are named by their launch-time preorder index, so a view belongs to
@@ -528,11 +535,110 @@ checkbox under Display Data toggles the whole track.
528
535
  To find a motif, pick **Molecular Sequence** in a search box: it matches the
529
536
  residues as written, gap characters included, as the desktop does.
530
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
+
531
555
  Alignments arrive with the tree: as phyloXML `<mol_seq is_aligned="true">`
532
556
  elements, or in a **Nexus** file whose characters matrix accompanies its tree.
533
557
  The **Nexus** entry in the Download menu writes the current tree *and* its
534
558
  alignment back into one Nexus file (Taxa, Characters and Trees blocks).
535
559
 
560
+ ## Heat maps
561
+
562
+ A tree whose tips carry numeric fields shows them as a **heat map** beside the
563
+ tree: one row per tip, one column per field, each cell coloured by its value
564
+ (the columns stand on the tips' common edge; in the **circular** layout that
565
+ edge is a ring, so each column becomes a concentric ring past the labels — the
566
+ unrooted layout has no such edge, and so no heat map). Turn
567
+ it on with the **Heat Map** checkbox under
568
+ Display Data; it is offered whenever the tree has two or more numeric per-tip
569
+ fields.
570
+
571
+ Every column is painted on **one shared scale**, so a colour means the same
572
+ number wherever it appears — that is what makes a block of related columns
573
+ readable as a block, and it is the point of a heat map rather than a row of
574
+ independent stripes. The scale spans the whole tree, so entering a subtree
575
+ narrows the rows and leaves the colours where they were. The columns that
576
+ appear are exactly the numeric fields the **Color by** menu offers, so the two
577
+ agree about what the tree holds; by default their left-to-right order follows
578
+ the order the file lists them in, which keeps a producer's grouping (core genes,
579
+ then resistance, then prophages) intact even where some tips are missing a
580
+ field — see **Order columns** below for the alternatives.
581
+
582
+ A cell **nobody filled in** is drawn as an outlined empty box, never as the
583
+ scale's low end: on a presence/absence matrix, reading a missing field as zero
584
+ states the opposite of what the file says. The key beside the scale names it.
585
+
586
+ **Hover any cell** for its tip, the column and its value — or `not assessed` —
587
+ and the scale it was coloured against. The column names stand under the matrix,
588
+ turned; a matrix with more columns than will fit shows a window and says so
589
+ (`Columns 1–240 of 400`), which the mouse wheel over the matrix scrolls.
590
+
591
+ **Order columns** (in the Heat Map section of the panel) decides where the
592
+ columns go. The two **clustered** orders put columns that behave alike side by
593
+ side and draw the clustering itself as a **dendrogram above the matrix** — a
594
+ clustergram — and one of them is what a heat map opens on, because reading block
595
+ structure is what a matrix beside a tree is for. *As in the input* is there when
596
+ you want the producer's own grouping back — and, because phyloXML gives every
597
+ node its own property list, its tooltip says whether that really is the input's
598
+ order or only the order most tips agree on. Both clustered orders are
599
+ complete-linkage hierarchical clustering, written to give the same answer as
600
+ R's `hclust(dist(t(m)), method = "complete")`; they differ in what "alike" means:
601
+
602
+ * **Clustered (co-occurrence)** uses Euclidean distance, the default of R's
603
+ `pheatmap`, `heatmap.2` and `ComplexHeatmap`.
604
+ * **Clustered (ignoring shared absence)** uses the **Bray–Curtis** dissimilarity
605
+ (R `vegan`'s `vegdist` default). Euclidean distance has the *double-zero
606
+ problem*: two genes both **absent** from the same strains are counted as
607
+ agreeing there, so on a sparse pan-genome the rare genes cluster together
608
+ merely for being rare. Bray–Curtis drops a tip where both columns are 0
609
+ instead of scoring it as agreement. On 0/1 data it is exactly the
610
+ Sørensen–Dice dissimilarity.
611
+
612
+ Which of the two a tree opens on is decided by its values: **Bray–Curtis where
613
+ the matrix has zeros to ignore and nothing negative, Euclidean otherwise**. A
614
+ zero is precisely the precondition for a double zero to exist, and Bray–Curtis is
615
+ meant for values 0 or more — on a matrix of years or coordinates it reads
616
+ magnitude instead of pattern, and negative values leave some pairs with no
617
+ distance at all. Set `heatmapColumnOrder` to override; an explicit choice is
618
+ never re-derived.
619
+
620
+ *Alphabetical* and *Frequency* (highest mean value first, over the tips that
621
+ have a value) are there too, and **Reorder columns…** opens a list you can drag
622
+ (or move with the arrow keys) into any order you like. Doing so sets **Manual**,
623
+ and nothing re-sorts a manual order — a sorting mode that quietly undid your
624
+ arrangement would make the arrangement pointless. *Automatic* in that dialog
625
+ hands the order back to the data. A manual order rides in a shared view, so a
626
+ link reproduces the figure exactly; a column it does not name — one added by an
627
+ edit, or a table joined since — follows the ones it does.
628
+
629
+ Whatever the mode, a blank is never read as 0, and
630
+ the dendrogram is drawn only when it describes the columns actually on screen —
631
+ never over a scrolled window, never over an order that did not come from a
632
+ clustering, and never over the rings, where it would have to bend.
633
+
634
+ Sørensen, T. (1948) *Biol. Skr.* 5, 1–34 · Bray, J.R., Curtis, J.T. (1957)
635
+ *Ecol. Monogr.* 27, 325–349 · Eisen, M.B. *et al.* (1998) *PNAS* 95, 14863–8.
636
+
637
+ Heat-map data arrives with the tree, as phyloXML `<property>` elements on the
638
+ tips (`<property ref="meta:recA" datatype="xsd:integer" applies_to="node">2
639
+ </property>`), or from a **metadata table** joined to it on the open page — a
640
+ table's numeric columns become heat-map columns like any others.
641
+
536
642
  ## Time trees
537
643
 
538
644
  A tree whose nodes carry phyloXML `<date>` elements is drawn against time.
@@ -957,6 +1063,10 @@ copy-pastable JSON.
957
1063
  | `layout` | `'rectangular'` | The starting layout: `'rectangular'`, `'circular'`, or `'unrooted'`. |
958
1064
  | `ladderizeTree` | `true` | Ladderize the tree on load: at each node, the larger clade first (any number of children, so a polytomy sorts too). |
959
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. |
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. |
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. |
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. |
960
1070
  | `showDomainArchitectures` | tree-derived | Open with the domain tracks shown. Default: on when any tip carries a `<domain_architecture>`, off otherwise — an explicit `true`/`false` overrides that. |
961
1071
  | `domainLabels` | `'domains'` | Where domain names go: `'domains'` (on the boxes), `'legend'` (a card), or `'none'`. |
962
1072
  | `domainGlow` | `false` | Open with the glow around each domain box on. |
@@ -1505,7 +1615,7 @@ Legend fieldset (Show / Dir / four arrows / R) is gone; so is the shift- or
1505
1615
  alt-click placement it documented. `visualizationsLegendXpos` and
1506
1616
  `visualizationsLegendYpos` still set where they start out.
1507
1617
 
1508
- ## The layouts, alignment track and time axes (developer spec)
1618
+ ## The layouts, tracks and time axes (developer spec)
1509
1619
 
1510
1620
  The 2026 additions beyond the visualization system, specified tightly enough
1511
1621
  to rebuild. All pure logic lives in forester.js under `npm test`; the viewer
@@ -1629,8 +1739,221 @@ which clamps and redraws; the tree never moves. A faint dashed guide runs
1629
1739
  from each tip's label (or its node, when labels are hidden) across to that
1630
1740
  tip's row, so a row reads back to its sequence without counting.
1631
1741
 
1632
- The conservation bar, consensus row and column ruler are a **floating strip**
1633
- (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.
1768
+
1769
+ ### The heat map
1770
+
1771
+ Model (`forester.heatmapColumns(tree)` → `{refs:[{ref,label}], min, max}`,
1772
+ pure, in `test/heatmap_test.js`): a column is any candidate that
1773
+ `forester.visualizationCandidates` already calls a **numeric property** — so
1774
+ candidacy is not re-invented here and the refusal rules are inherited — carried
1775
+ by at least one **tip**; an internal node has no row, so a ref only internal
1776
+ nodes hold is not a column and its values never reach the scale. `min`/`max`
1777
+ span every drawn cell of the whole tree. `forester.heatmapValue(node, ref)`
1778
+ returns the number or **null**: null for absent, blank and non-numeric alike,
1779
+ and for a ref a node carries twice, the first.
1780
+
1781
+ Column order is a **consensus** (`forester.heatmapConsensusOrder`), because
1782
+ phyloXML gives every node its own property list: tips can list the same columns
1783
+ in different orders, and on `docs/data/influenza.xml` 2 of 6 do. Each tip votes
1784
+ on the pairs it lists next to each other, the majority direction of each pair
1785
+ becomes an edge, and the columns are read off a topological sort of that graph.
1786
+
1787
+ Two rules this replaced, both wrong in ways that showed:
1788
+
1789
+ * **First appearance** put a core gene at column 40 of 40 — 7 of 100 tips in the
1790
+ pan-genome demo carry no `dnaK` and the first tip is one of them. (Worse, the
1791
+ traversal reaches a flat tree's tips *last*-first, so "first" meant the file's
1792
+ last tip.)
1793
+ * **Mean position** fixed that, and then interleaved blocks no tip carries
1794
+ together: tips holding `A,B` and tips holding `X,Y` put A and X both at
1795
+ position 0, giving `X A Y B` instead of `A B X Y`. No tie-break can mend it —
1796
+ the average is what is wrong, because it compares positions measured on tips
1797
+ that share no frame of reference.
1798
+
1799
+ Only **adjacent** pairs vote, which keeps this linear in the data; every pair
1800
+ would be quadratic in the column count, which is exactly where a wide matrix
1801
+ hurts. Transitivity comes from the sort instead. The cost is that adjacency can
1802
+ manufacture a cycle where all pairs would not (tips `A,B,C` and `C,A` give
1803
+ A→B→C→A), so cycles are broken at the column fewest others wait on rather than
1804
+ assumed away — a column is never dropped because the votes disagreed. Where the
1805
+ graph is silent the old rule still decides: mean position, then the
1806
+ better-attested column, then the ref. None of it depends on the order the tips
1807
+ were reached in, which matters because that order follows the tree's current
1808
+ child arrangement and `ladderizeTree` rewrites it — measured, one file once gave
1809
+ `B A` ladderized and `A B` not.
1810
+
1811
+ `'manual'` is the reader's own order (`forester.heatmapManualOrder`), and the
1812
+ only mode that is **not** normalised first — being left alone is the whole point
1813
+ of it. It is set by the **Reorder columns…** dialog, which drags or arrow-keys a
1814
+ list of the columns as drawn; applying it writes `heatmapManualOrder` and sets
1815
+ the mode, exactly as the desktop's Annotation Fields arrows switch a tab to
1816
+ Manual. (Theirs also chooses which fields are columns and of what type; ours
1817
+ takes every numeric field automatically, so the dialog is about order alone.)
1818
+ Choosing Manual from the menu with nothing arranged yet freezes the order on
1819
+ screen — an empty list would mean "no opinion" and silently re-derive the very
1820
+ order the reader asked to keep. A manual order offers no dendrogram, because
1821
+ nothing clustered it.
1822
+
1823
+ The mode a tree opens on, when the caller did not say, is
1824
+ `forester.heatmapDefaultOrder`: `'clustered-presence'` when some value is 0 and
1825
+ none is negative, `'clustered'` otherwise, `'document'` when there are no values
1826
+ at all. Measured, which is why the rule is not simply "always Bray–Curtis": on a
1827
+ latitude/longitude matrix 2 of 6 pairs come out `+Infinity` (no distance, so
1828
+ they join last arbitrarily), and a year column among small ones sits at 0.9995
1829
+ from every one of them — maximally distant for being large rather than for any
1830
+ pattern, while *within* a block of comparable magnitude Bray–Curtis is exactly
1831
+ right. The desktop fixes its default at Euclidean instead, which is consistent
1832
+ with its columns being hand-picked where ours are every numeric field the tree
1833
+ carries. The resolved choice is cached on the model, never written back into the
1834
+ state, so the next tree in a multi-tree file does not inherit this one's answer.
1835
+
1836
+ Gate (`heatmapShown`): `showHeatmap` state (default **false**) AND **not** the
1837
+ unrooted layout AND at least `HEATMAP_MIN_COLUMNS` (2) columns with values —
1838
+ one column is a stripe, not a matrix. Rectangular draws the track, circular the
1839
+ rings (`heatmapCircular`).
1840
+
1841
+ Geometry: the matrix reserves `HEATMAP_TRACK_GAP(8) + band` from `_w`, where
1842
+ `band = min(columns × 14 px, clamp(viewportWidth × 0.45, 60 px, whatever leaves
1843
+ the tree ≥ 220 px))`. It is budgeted **before** the alignment, and the
1844
+ alignment's own band then yields to it: the matrix wants a fixed, finite width
1845
+ while the alignment's band is a window that scrolls and so loses nothing by
1846
+ giving way. (Budgeted the other way round, an 18-column matrix got a 60 px
1847
+ sliver.) It sits between the domain tracks and the alignment, its right edge at
1848
+ `displayWidth − rootOffset − msaReserve`. Rows tile the cluster height by the
1849
+ same once-derived midpoints the alignment uses. Column width is
1850
+ `clamp(band / columns, 3, 14)`; past `band / 3` columns the matrix shows a
1851
+ window, scrolled by the wheel and stated in the caption.
1852
+
1853
+ Colour: `d3.scaleLinear().range(VIS_COLOR_RAMP).domain([min, mid, max])` — the
1854
+ same 3-stop viridis the numeric visualizations use — built once and kept with
1855
+ the model, so every redraw and every view paints the same colours. A degenerate
1856
+ range (one value everywhere) takes the ramp's middle stop rather than mapping a
1857
+ zero-width domain. Same-colour runs merge into single rects.
1858
+
1859
+ A blank cell is the background **with an outline**, and a run of them stays a
1860
+ run of cells wherever a column is at least 7 px wide. Leaving a blank bare is
1861
+ not enough: measured on the sparse demo, a bare blank stands at a contrast
1862
+ ratio of 13.4 against the scale's low end in the light theme but **1.08** in
1863
+ the dark one — that is to say, in the dark theme "not assessed" and "zero" were
1864
+ the same picture.
1865
+
1866
+ **Column order** (`forester.heatmapOrder(tree, columns, mode)` →
1867
+ `{columns, dendrogram}`, pure, in `test/clustering_test.js`): the desktop's
1868
+ *View → Order Matrix Columns*, minus its Manual mode, which means "the order you
1869
+ dragged the rows into" and there is no drag-to-reorder dialog here. Every
1870
+ data-driven mode first normalises to **document order**
1871
+ (`forester.heatmapInDocumentOrder`, the same consensus over exactly the columns
1872
+ given, with columns no tip carries at the end) so a result cannot depend on the
1873
+ order the columns happened to be in. Measured: without it, the 6×6 linkage
1874
+ fixture fed in reverse clustered to `h6,h5,h3,h4,h2,h1` instead of R's
1875
+ `h6,h3,h5,h4,h1,h2` — the index order drives the tie-break and the leaf order,
1876
+ so the incoming order really does leak through. The normaliser deliberately does
1877
+ **not** go through `heatmapColumns`: that applies candidacy rules (a constant
1878
+ column is refused), and ordering must not move with them.
1879
+
1880
+ Distances are `forester.heatmapEuclideanDistances` (R's `dist` convention for
1881
+ missing values: pairwise deletion, the squared sum scaled up by `tips / used`;
1882
+ a pair sharing no assessed tip is `+Infinity`, where R returns `NA` and its
1883
+ `hclust` then refuses) and `forester.heatmapBrayCurtisDistances`
1884
+ (`sum|x−y| / sum(x+y)` over the jointly assessed tips — being a ratio it needs
1885
+ no scale-up; three cases `vegdist` cannot answer are decided so clustering never
1886
+ sees a `NaN`: no shared tip → `+Infinity`, 0 at every shared tip → 0, a
1887
+ non-positive total with columns that differ → `+Infinity`).
1888
+ `forester.heatmapCompleteLinkage` returns R's `hclust` object — `$merge` node
1889
+ ids (negative = singleton, positive = the cluster formed at that stage), the
1890
+ `$height` each merge happened at, and `$order` — reproducing R's tie-break
1891
+ (first pair the `(i, j)` scan finds) and `hcass2` leaf order.
1892
+
1893
+ **The expectations are R's own output**, R 4.5.3 and vegan 2.7-2, carried over
1894
+ from the desktop's `MatrixColumnOrderTest`, which generated them. Pinning to R
1895
+ pins the JS and the desktop to each other, which is the point: the two must
1896
+ cluster a matrix identically.
1897
+
1898
+ The dendrogram is drawn above the grid in the tree's own group, so it zooms,
1899
+ pans and exports with the cells. Heights are **linear in the merge distance**,
1900
+ so a block that joins low really is drawn tighter than one that joins high;
1901
+ spacing merges evenly by rank would read more clearly and would say something
1902
+ false. A merge with no finite height goes to the top of the band, above every
1903
+ measurable merge, which is where it belongs: it joined last. It is drawn only
1904
+ when the dendrogram's leaves ARE the columns on screen — never over a scrolled
1905
+ **window**, whose merges would reach columns the reader cannot see.
1906
+
1907
+ Its band is reserved **inside** the layout (the rows are laid out in a span
1908
+ shorter by the band and every node moves down by it), not taken off the canvas
1909
+ and made up for by the fit: a fit happens once, and switching the order is a
1910
+ redraw, so the fit route left the dendrogram 34 px above the top of the window.
1911
+
1912
+ **Circular** (`drawHeatmapRings`) is the same matrix in polar form: each column
1913
+ a concentric **ring** starting at `maxRad + tipLabelSpace + 7`, each cell an arc
1914
+ over that tip's own angular slice, with the slice boundaries derived once
1915
+ between neighbours exactly as the rows are. Ring thickness is
1916
+ `clamp(maxRad × 1.2 / columns, 1, 14)` — capped against the tree's own radius,
1917
+ so a wide matrix cannot turn the tree into a dot at the middle of a dartboard —
1918
+ and `fitRadialExtent` allows for the stack. Same-colour neighbours merge into
1919
+ one sector, which is what keeps a big fan drawable (281 paths for 18 × 50
1920
+ cells on the sparse demo).
1921
+
1922
+ Each ring carries its name **tangentially at the fan's seam**, the gap the
1923
+ layout already leaves between the last tip and the first: the one place a label
1924
+ can sit without covering a cell. Tangentially means rotated by the angle
1925
+ itself — `polarXY` puts angle *a* at screen direction *a* − 90°, so rotating by
1926
+ *a* − 90 instead lays every name out along the seam, where the rings are one
1927
+ ring-width apart and the names several times that. Measured before the fix: 44
1928
+ overlapping pairs out of 18 names.
1929
+
1930
+ The scale becomes a **corner card** (`drawHeatmapRingLegend`, bottom-left,
1931
+ screen coordinates so it neither turns nor scales with the fan), because a ring
1932
+ has no "under" to hang a strip from. There is no dendrogram: it would have to
1933
+ bend, and the desktop makes the same call. **Unrooted gets no heat map at all** —
1934
+ every tip there ends at its own radius, so a column has no ring to be. Hover is
1935
+ the same two questions in polar form: which ring, and which slice, the slice
1936
+ compared modulo a full turn because the fan rotates.
1937
+
1938
+ **Where the readout lands** is one function, `placeHoverReadout(el, event)`,
1939
+ shared by the heat map, the alignment track and the node tooltip. It sets the
1940
+ text, then measures the element — `.aptx-tip` is `width:max-content`, so its
1941
+ size is whatever it is currently saying — and offers it at `pageX + 14`,
1942
+ `pageY + 14`, flipping to the other side of the pointer on either axis only
1943
+ where that would leave the window (clamped to the edge if neither side fits).
1944
+ The heat map and the alignment both used to guess instead, shifting left by a
1945
+ hard-coded 280 px against a readout 181 px wide: since both tracks hug the right
1946
+ edge, every cell flipped and every readout floated ~95 px clear of the cell it
1947
+ described. `test_trees/heatmap_tip.html` pins it, in both layouts, and fails
1948
+ unless all four placements were exercised.
1949
+
1950
+ In the rectangular layout the turned column names, the gradient, its two
1951
+ numbers, the blank key and the caption are a **floating strip**; its reserve adds `MSA_NAV_RESERVE` while the
1952
+ alignment is shown, or the alignment's navigation bar (fixed at the viewport
1953
+ bottom) covers the scale.
1954
+
1955
+ Everything is plain rects, lines, text and one `<linearGradient>`, so the SVG,
1956
+ PDF and PNG exports match the screen.
1634
1957
 
1635
1958
  ### The time axes
1636
1959
 
@@ -83,7 +83,25 @@ export interface ArchaeopteryxConfig {
83
83
  rootOffset?: number;
84
84
  searchAinitialValue?: string | null;
85
85
  searchBinitialValue?: string | null;
86
+ /** Open with the heat map shown. Default false: it is offered whenever
87
+ * the tree has two or more numeric per-tip fields, which is most annotated
88
+ * trees, so it waits to be asked for. */
89
+ /** How the heat map's columns are ordered. The clustered modes also draw
90
+ * the dendrogram above the matrix. Default: a clustered order with the
91
+ * distance chosen from the values — Bray–Curtis where the matrix has zeros
92
+ * to ignore and nothing negative, Euclidean otherwise. An explicit value
93
+ * always wins and is never re-derived. */
94
+ heatmapColumnOrder?: 'document' | 'clustered' | 'clustered-presence' | 'alphabetical' | 'frequency' | 'manual';
95
+ /** The heat map's columns in your own order, as property refs. Only read
96
+ * while heatmapColumnOrder is 'manual'. A ref the tree has not got is
97
+ * ignored; a column this does not name follows the ones it does. */
98
+ heatmapManualOrder?: string[] | null;
99
+ showHeatmap?: boolean;
86
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;
87
105
  showSupportDots?: boolean;
88
106
  showTimeAxis?: boolean;
89
107
  supportDotMinimum?: number;
@@ -136,6 +154,11 @@ export interface ViewState {
136
154
  rotation?: number;
137
155
  horizontalLabels?: boolean;
138
156
  msa?: boolean;
157
+ /** The alignment summarized as a sequence logo. */
158
+ msaLogo?: boolean;
159
+ heatmap?: boolean;
160
+ heatmapOrder?: 'document' | 'clustered' | 'clustered-presence' | 'alphabetical' | 'frequency' | 'manual';
161
+ heatmapManual?: string[];
139
162
  domains?: boolean;
140
163
  domainLabels?: 'none' | 'domains' | 'legend';
141
164
  domainGlow?: boolean;