archaeopteryx 3.19.0 → 3.21.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
@@ -166,7 +166,7 @@ using `viewer.destroy()` to switch trees in place.
166
166
  ## Using the visualizations
167
167
 
168
168
  A tree opens **already coloured** by its most informative field — the viewer
169
- inspects what the tree carries (taxonomy, sequence fields, custom properties)
169
+ inspects what the tree carries (taxonomy, sequence fields, metadata)
170
170
  and decides by itself what is worth showing. There is nothing to configure.
171
171
 
172
172
  * The **Color** and **Shape** menus (top of the control panel) list every
@@ -661,16 +661,42 @@ number wherever it appears — that is what makes a block of related columns
661
661
  readable as a block, and it is the point of a heat map rather than a row of
662
662
  independent stripes. The scale spans the whole tree, so entering a subtree
663
663
  narrows the rows and leaves the colours where they were. The columns that
664
- appear are exactly the numeric fields the **Color by** menu offers, so the two
665
- agree about what the tree holds; by default their left-to-right order follows
664
+ appear are the numeric fields the **Color by** menu offers, so the two agree
665
+ about what the tree holds — plus a field with **one value on every tip**, which
666
+ has nothing to colour by but is a column all the same (a single-copy core gene
667
+ is a column of 1s); by default their left-to-right order follows
666
668
  the order the file lists them in, which keeps a producer's grouping (core genes,
667
669
  then resistance, then prophages) intact even where some tips are missing a
668
670
  field — see **Order columns** below for the alternatives.
669
671
 
672
+ Where a cell is at least 5 px on screen, each cell is drawn on its own with a
673
+ **thin border in its own hue, darker** — a block of equal values still reads
674
+ as a block, and each cell can be counted. The border stays 0.75 px at any
675
+ zoom. Smaller cells, or a matrix of more than 60,000 cells, are drawn as merged
676
+ runs of equal colour instead, and zooming across that threshold switches
677
+ between the two.
678
+
670
679
  A cell **nobody filled in** is drawn as an outlined empty box, never as the
671
680
  scale's low end: on a presence/absence matrix, reading a missing field as zero
672
681
  states the opposite of what the file says. The key beside the scale names it.
673
682
 
683
+ **A file can open straight into its matrix.** The desktop Archaeopteryx
684
+ stores its figure setting as one property of the phylogeny, directly under
685
+ `<phylogeny>`:
686
+
687
+ ```xml
688
+ <property ref="aptx:figure" datatype="xsd:string" applies_to="phylogeny">v1;columns=pgfam:PGF_00019355\sMATRIX\sCIRCLE\sfalse\ppgfam:PGF_00019313\sMATRIX\sCIRCLE\sfalse</property>
689
+ ```
690
+
691
+ When it names two or more `MATRIX` columns the tree has, the tree opens with the
692
+ heat map on, showing **only those columns, in that order** (the order is then
693
+ `manual`, and **Order columns** can still change it). Other column types are
694
+ the desktop's and are not drawn here. Only a property directly under
695
+ `<phylogeny>` counts: an `aptx:figure` on a clade — where desktops
696
+ 0.11.117 to 0.11.172 wrote it — is ignored and left out of what is saved; the phylogeny's
697
+ own is saved back unchanged. An explicit `showHeatmap`, `heatmapColumnOrder`
698
+ or `heatmapManualOrder` in the config wins over the file.
699
+
674
700
  **Hover any cell** for its tip, the column and its value — or `not assessed` —
675
701
  and the scale it was coloured against. The column names stand under the matrix,
676
702
  turned; a matrix with more columns than will fit shows a window and says so
@@ -1046,7 +1072,7 @@ a popup any more, and nothing fails silently.
1046
1072
  | **Newick** / New Hampshire (`.nwk`, `.nh`, `.tre`) | in / out | The base tree: topology, names, branch lengths, and bracketed confidence values. | [1] |
1047
1073
  | **NHX** — New Hampshire eXtended | in | `[&&NHX:...]` tags riding on Newick: taxonomy (`S=`, `T=`), sequence (`GN=`, `AC=`), support (`B=`), duplication/speciation events (`D=`). | [2] |
1048
1074
  | **Nexus** (`.nex`, `.nexus`) | in / out | One file for the tree(s) and, in a `CHARACTERS`/`DATA` block, an aligned protein/DNA/RNA matrix (sequential or interleaved) — the tree and its alignment together. | [3] |
1049
- | **phyloXML** (`.xml`) | in / out | The richest native format: taxonomy, sequences and alignments, dates, confidences, branch colours and arbitrary custom properties. | [4] |
1075
+ | **phyloXML** (`.xml`) | in / out | The richest native format: taxonomy, sequences and alignments, dates, confidences, branch colours and arbitrary metadata (custom properties). | [4] |
1050
1076
  | **Auspice / Nextstrain** `dataset.json` (v2) | in | Phylodynamic builds: sampling dates and their confidence, cumulative divergence, and discrete traits (country, clade, host, ...) with their posterior distributions. | [5] |
1051
1077
  | **BEAST** / BEAST 2 / TreeAnnotator annotations | in (embedded in Newick/Nexus) | `[&posterior=...,height_95%_HPD={lo,hi},rate=...]`-style blobs: posterior clade support, node-age confidence intervals, per-branch rates and other traits. FigTree's `!color` is read the same way. | [6, 7] |
1052
1078
  | **MrBayes** annotations | in (embedded in Newick/Nexus) | `prob=`/`prob.stddev=` blobs: posterior-probability clade support. | [8] |
@@ -1353,8 +1379,10 @@ copy-pastable JSON.
1353
1379
  | `layout` | `'rectangular'` | The starting layout: `'rectangular'`, `'circular'`, or `'unrooted'`. |
1354
1380
  | `ladderizeTree` | `true` | Ladderize the tree on load: at each node, the larger clade first (any number of children, so a polytomy sorts too). |
1355
1381
  | `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. |
1382
+ | `showProperties` | tree-derived | Open with the nodes' metadata (their phyloXML properties) in their labels — the **Metadata** checkbox. Default: off, unless the file's figure setting (`show.SHOW_PROPERTIES=true`) says on. See [Metadata in the label](#metadata-in-the-label). |
1383
+ | `labelProperties` | tree-derived | Which metadata fields the label shows, in order, as an array of refs (`['BVBRC:species', 'BVBRC:host_name']`). Default: the figure setting's `labelprops`, else `null` — every property but the heat map's columns. |
1356
1384
  | `showMsaLogo` | `false` | Open with the alignment summarised as a sequence logo instead of a conservation bar: a stack of letters per column, as tall as its information content, over the tips currently on screen. Only drawn while the alignment track is shown. |
1357
- | `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. |
1385
+ | `showHeatmap` | tree-derived | 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 — or unless the file's own figure setting (`aptx:figure`, see [Heat maps](#heat-maps)) draws them as a matrix. |
1358
1386
  | `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. |
1359
1387
  | `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. |
1360
1388
  | `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. |
@@ -1849,6 +1877,30 @@ rather than 300 identical "Influenz.." labels).
1849
1877
  Both menus live in the single control panel, above Display Data, which is where
1850
1878
  the desktop puts them.
1851
1879
 
1880
+ ### Metadata in the label
1881
+
1882
+ A tip's other fields — a species, a host, a country, anything a node carries
1883
+ as a `<property>` — are its **metadata**, and they can go into its label, as
1884
+ on the desktop. **Metadata** under Display Data (offered when nodes carry
1885
+ any, off by default) adds the **values** after the rest of the label, joined by ` | ` as the rest of the
1886
+ label is: `255457.3 | Brucella lupini | host`, or `Brucella lupini` alone with
1887
+ Node Name off. (The desktop joins them with `, `; a pipe was chosen here
1888
+ because values often contain commas — a host such as `Bottlenose dolphin,
1889
+ Tursiops truncatus`.) A unit follows its value. With nothing chosen, every
1890
+ field shows in the node's own order, except the heat map's columns while it is
1891
+ shown — a field has one display role. **Metadata fields…** picks which fields
1892
+ (tick them; **All** and **None** tick and untick the lot) and in what order
1893
+ (drag a row, or use its arrows); Apply keeps the ticked ones in the order
1894
+ shown, and picking a field turns Metadata on. The checkbox and the fields
1895
+ travel in a shared view (`show=…,properties`, `labelFields=`), and the config keys are
1896
+ `showProperties` and `labelProperties`.
1897
+
1898
+ A file can choose for itself: the desktop's figure setting (`aptx:figure`,
1899
+ see [Heat maps](#heat-maps)) carries `labelprops=` (the fields, in order)
1900
+ and the display switches `show.SHOW_PROPERTIES=true` and
1901
+ `show.SHOW_NODE_NAMES=false`, and a tree opens labelled that way in both
1902
+ programs. The config wins over the file.
1903
+
1852
1904
  ### Initial label fields
1853
1905
 
1854
1906
  Which of the three label checkboxes — Node Name, Taxonomy, Sequence — start
@@ -2084,8 +2136,10 @@ bottom reserve the fit allows for follows that.
2084
2136
 
2085
2137
  Model (`forester.heatmapColumns(tree)` → `{refs:[{ref,label}], min, max}`,
2086
2138
  pure, in `test/heatmap_test.js`): a column is any candidate that
2087
- `forester.visualizationCandidates` already calls a **numeric property** — so
2088
- candidacy is not re-invented here and the refusal rules are inherited — carried
2139
+ `forester.visualizationCandidates(tree, {keepConstant: true})` calls a
2140
+ **numeric property** — so candidacy is not re-invented here and the refusal
2141
+ rules are inherited, except the one against a field with a single value, which
2142
+ suits colour-by and not a matrix — carried
2089
2143
  by at least one **tip**; an internal node has no row, so a ref only internal
2090
2144
  nodes hold is not a column and its values never reach the scale. `min`/`max`
2091
2145
  span every drawn cell of the whole tree. `forester.heatmapValue(node, ref)`
@@ -2126,7 +2180,7 @@ child arrangement and `ladderizeTree` rewrites it — measured, one file once ga
2126
2180
  only mode that is **not** normalised first — being left alone is the whole point
2127
2181
  of it. It is set by the **Reorder columns…** dialog, which drags or arrow-keys a
2128
2182
  list of the columns as drawn; applying it writes `heatmapManualOrder` and sets
2129
- the mode, exactly as the desktop's Annotation Fields arrows switch a tab to
2183
+ the mode, exactly as the desktop's Metadata Fields arrows switch a tab to
2130
2184
  Manual. (Theirs also chooses which fields are columns and of what type; ours
2131
2185
  takes every numeric field automatically, so the dialog is about order alone.)
2132
2186
  Choosing Manual from the menu with nothing arranged yet freezes the order on
@@ -2188,8 +2242,16 @@ order the columns happened to be in. Measured: without it, the 6×6 linkage
2188
2242
  fixture fed in reverse clustered to `h6,h5,h3,h4,h2,h1` instead of R's
2189
2243
  `h6,h3,h5,h4,h1,h2` — the index order drives the tie-break and the leaf order,
2190
2244
  so the incoming order really does leak through. The normaliser deliberately does
2191
- **not** go through `heatmapColumns`: that applies candidacy rules (a constant
2192
- column is refused), and ordering must not move with them.
2245
+ **not** go through `heatmapColumns`: that applies candidacy rules, and
2246
+ ordering must not move with them.
2247
+
2248
+ `forester.heatmapColumns(tree, only)` keeps just the refs in `only` — the
2249
+ figure's MATRIX columns (`forester.figureMatrixRefs(tree)`, from
2250
+ `forester.readFigure`, which reads the phylogeny's own `aptx:figure` only:
2251
+ `"v1;key=value;…"`, values escaped as the desktop's `PropertyTextCodec` does,
2252
+ `columns=` a `|` list of `ref~TYPE~SHAPE~normalized`; an unknown version is no
2253
+ figure, unknown keys are ignored). `forester.dropCladeFigures(tree)` removes
2254
+ the old clade-level copies at launch.
2193
2255
 
2194
2256
  Distances are `forester.heatmapEuclideanDistances` (R's `dist` convention for
2195
2257
  missing values: pairwise deletion, the squared sum scaled up by `tips / used`;
@@ -104,6 +104,10 @@ export interface ArchaeopteryxConfig {
104
104
  * bar: each column a stack of letters as tall as its information content,
105
105
  * over the tips currently on screen. Only drawn with the track shown. */
106
106
  showMsaLogo?: boolean;
107
+ /** The nodes' property values in their labels (the desktop's "Properties"). */
108
+ showProperties?: boolean;
109
+ /** Which properties the label shows, in order; null for all but the heat map's columns. */
110
+ labelProperties?: string[] | null;
107
111
  /** The scale axis under a phylogram: the time axis on a time tree whose
108
112
  * branches show time, a distance axis on any other. Default: on for a
109
113
  * time tree, off otherwise. */
@@ -169,6 +173,8 @@ export interface ViewState {
169
173
  heatmap?: boolean;
170
174
  heatmapOrder?: 'document' | 'clustered' | 'clustered-presence' | 'alphabetical' | 'frequency' | 'manual';
171
175
  heatmapManual?: string[];
176
+ /** The properties the label shows, in order (labelProperties). */
177
+ labelFields?: string[];
172
178
  domains?: boolean;
173
179
  domainLabels?: 'none' | 'domains' | 'legend';
174
180
  domainGlow?: boolean;
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.19.0
23
+ // v 3.21.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.19.0';
106
+ const VERSION = '3.21.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';
@@ -456,6 +456,9 @@ function (root, d3, forester, phyloXml) {
456
456
  const SEARCH_COMBINE_SELECT = 'scmb';
457
457
  const SEARCH_COMBINE_ROW = 'scmb_row';
458
458
  const SEQUENCE_CB = 'seq_cb';
459
+ const PROPERTIES_CB = 'props_cb';
460
+ const LABEL_FIELDS_BUTTON = 'label_fields_b';
461
+ const LABEL_FIELDS_DIALOG = 'label_fields_dialog';
459
462
  const SHORTEN_NODE_NAME_CB = 'shortennodename_cb';
460
463
  const TAXONOMY_CB = 'tax_cb';
461
464
  const ZOOM_IN_X = 'zoomin_x';
@@ -630,11 +633,24 @@ function (root, d3, forester, phyloXml) {
630
633
  // within 1.2px of a ring boundary came out background-coloured.
631
634
  const HEATMAP_RING_BLEED = 1.2;
632
635
  const HEATMAP_BLANK = 'blank'; // the run-merger's stand-in for a cell nobody filled in
636
+ // Cell borders (Christian, 2026-09-30): each cell outlined in its own hue,
637
+ // darker, so a block of equal values still reads as a block while each
638
+ // cell can be counted. Only where a cell is big enough on SCREEN to carry
639
+ // one, and not on a matrix so large that a node per cell would drag.
640
+ const HEATMAP_BORDER_MIN_PX = 5; // the cell's smaller side on screen
641
+ const HEATMAP_BORDER_MAX_CELLS = 60000;
642
+ const HEATMAP_BORDER_W = 0.75; // screen px at any zoom (non-scaling stroke)
643
+ const HEATMAP_BORDER_DARKEN = 0.8; // d3's darker(k): each step x0.7
644
+ const HEATMAP_SEAL_W = 0.6; // a merged run's stroke in its own colour, over the seams
633
645
  const HEATMAP_DENDRO_GAP = 5; // px between the dendrogram's leaves and the first row
634
646
  const HEATMAP_DENDRO_MIN_BAND = 24; // the band it is drawn in, from the label font
635
647
  const HEATMAP_DENDRO_MAX_BAND = 72;
636
648
  let _heatmapReserve = 0; // horizontal px reserved for the matrix, set with _w
637
649
  let _heatmapModel = null; // forester.heatmapColumns of the WHOLE tree, cached per launch
650
+ let _heatmapCells = null; // what was drawn: {cellMin (tree units), count, bordered}
651
+ let _heatmapBorderTimer = null;
652
+ let _figureMatrix = null; // the MATRIX refs the file's aptx:figure names, when it draws a matrix
653
+ let _labelFields = null; // forester.labelPropertyRefs of the tree, cached until an edit
638
654
  let _heatmapColor = null; // the one scale every column is painted on
639
655
  let _heatmapColOffset = 0; // first shown column, while the matrix is windowed
640
656
  let _heatmapGeom = null; // the last draw's geometry, for the hover readout
@@ -1331,6 +1347,7 @@ function (root, d3, forester, phyloXml) {
1331
1347
  _svgGroup.attr('transform', event.transform);
1332
1348
  placeFloatingOverlays();
1333
1349
  updateOverviewViewport();
1350
+ heatmapBordersFollowZoom();
1334
1351
  }
1335
1352
 
1336
1353
  // A strip in the floating layer. Drawn in TREE coordinates like everything
@@ -3391,7 +3408,7 @@ function (root, d3, forester, phyloXml) {
3391
3408
  _svgGroup.selectAll('g.aptx-align-ext').remove();
3392
3409
  let alignedRect = !radialDisplay() && _state.phylogram && _state.alignPhylogram;
3393
3410
  let tipGuides = alignedRect && _state.showExternalLabels
3394
- && (_state.showNodeName || _state.showTaxonomy || _state.showSequence);
3411
+ && (_state.showNodeName || _state.showTaxonomy || _state.showSequence || _state.showProperties);
3395
3412
  // a collapsed clade's label always shows, so its guide does too: from
3396
3413
  // past the wedge's farthest tip to the label column
3397
3414
  let collapsedGuides = alignedRect ? nodes.filter(function (d) {
@@ -4661,6 +4678,10 @@ function (root, d3, forester, phyloXml) {
4661
4678
  }
4662
4679
 
4663
4680
 
4681
+ if (_state.showProperties && phynode.properties) {
4682
+ l = append(l, labelPropertiesOf(phynode));
4683
+ }
4684
+
4664
4685
  if (_nodeLabels && phynode.properties) {
4665
4686
  const props_length = phynode.properties.length;
4666
4687
  if (props_length > 0) {
@@ -5621,6 +5642,8 @@ function (root, d3, forester, phyloXml) {
5621
5642
  'layout',
5622
5643
  'showMsa',
5623
5644
  'showMsaLogo',
5645
+ 'showProperties',
5646
+ 'labelProperties',
5624
5647
  'showHeatmap',
5625
5648
  'heatmapColumnOrder',
5626
5649
  'heatmapManualOrder',
@@ -5860,8 +5883,18 @@ function (root, d3, forester, phyloXml) {
5860
5883
  // numeric per-tip fields, which is common enough that turning it on
5861
5884
  // by itself would be an opinion about the tree rather than a service
5862
5885
  // -- the alignment track, by contrast, is on when there IS one.
5886
+ // The desktop's figure setting: its MATRIX columns open as the heat
5887
+ // map, only those, in the file's order. Only the phylogeny's own
5888
+ // aptx:figure counts; a copy on a clade (the old place) is dropped,
5889
+ // so a tree saved from here carries none. Here and not in launchInto:
5890
+ // telling whether the columns exist is a scan of every tip, which on
5891
+ // a big tree belongs behind the working card, not in front of it.
5892
+ if (_treeData) {
5893
+ forester.dropCladeFigures(_treeData);
5894
+ }
5895
+ _figureMatrix = _treeData ? figureMatrixOf(_treeData) : null;
5863
5896
  if (_state.showHeatmap === undefined) {
5864
- _state.showHeatmap = false;
5897
+ _state.showHeatmap = _figureMatrix !== null;
5865
5898
  }
5866
5899
  // The logo is a summary of the alignment, not a second view of the
5867
5900
  // tree, so it waits to be asked for: an alignment opens showing its
@@ -5876,7 +5909,7 @@ function (root, d3, forester, phyloXml) {
5876
5909
  // value always wins and is never re-derived -- including 'document',
5877
5910
  // which keeps the order the producer wrote the fields in.
5878
5911
  if (_state.heatmapColumnOrder === undefined) {
5879
- _state.heatmapColumnOrder = null;
5912
+ _state.heatmapColumnOrder = _figureMatrix ? 'manual' : null;
5880
5913
  } else if (_state.heatmapColumnOrder !== null
5881
5914
  && forester.HEATMAP_ORDER_MODES.indexOf(_state.heatmapColumnOrder) < 0) {
5882
5915
  throw new Error(ERROR + '"heatmapColumnOrder" must be one of '
@@ -5887,7 +5920,7 @@ function (root, d3, forester, phyloXml) {
5887
5920
  // not got is ignored rather than fatal -- an order outliving one of
5888
5921
  // its columns is ordinary (see forester.heatmapManualOrder).
5889
5922
  if (_state.heatmapManualOrder === undefined) {
5890
- _state.heatmapManualOrder = null;
5923
+ _state.heatmapManualOrder = _figureMatrix ? _figureMatrix.slice() : null;
5891
5924
  } else if (_state.heatmapManualOrder !== null
5892
5925
  && !(Array.isArray(_state.heatmapManualOrder)
5893
5926
  && _state.heatmapManualOrder.every(function (r) {
@@ -6060,6 +6093,27 @@ function (root, d3, forester, phyloXml) {
6060
6093
  + ' characters)');
6061
6094
  }
6062
6095
 
6096
+ // Properties in the tip label: off unless asked for, as on the desktop,
6097
+ // in every user-visible property's own order unless a list says which.
6098
+ // The file's figure setting (aptx:figure) can say both, and whether
6099
+ // the names show: labelprops, show.SHOW_PROPERTIES and
6100
+ // show.SHOW_NODE_NAMES, the desktop's own keys. The config wins.
6101
+ let figure = _treeData ? forester.readFigure(_treeData) : null;
6102
+ if (_state.showProperties === undefined) {
6103
+ _state.showProperties = !!(figure && figure.show.SHOW_PROPERTIES === true);
6104
+ }
6105
+ if (_state.labelProperties === undefined) {
6106
+ _state.labelProperties = (figure && figure.labelProps) ? figure.labelProps.slice() : null;
6107
+ } else if (_state.labelProperties !== null && !(Array.isArray(_state.labelProperties)
6108
+ && _state.labelProperties.every(function (r) {
6109
+ return typeof r === 'string';
6110
+ }))) {
6111
+ throw new Error(ERROR + '"labelProperties" must be an array of property refs');
6112
+ }
6113
+ if (figure && typeof figure.show.SHOW_NODE_NAMES === 'boolean' && _basicTreeProperties.nodeNames) {
6114
+ _state.showNodeName = figure.show.SHOW_NODE_NAMES;
6115
+ }
6116
+
6063
6117
  // A small tree is drawn with a heavier stroke; hairlines are for trees
6064
6118
  // dense enough to need them.
6065
6119
  _state.branchWidthDefault = _basicTreeProperties.externalNodesCount <= SMALL_TREE_MAX_EXT_NODES
@@ -6518,6 +6572,8 @@ function (root, d3, forester, phyloXml) {
6518
6572
  _treeData = phylo;
6519
6573
  _trees = trees;
6520
6574
  _treeIndex = index;
6575
+ _figureMatrix = null; // read by initializeState, behind the working card
6576
+ _labelFields = null;
6521
6577
  _launchConfig = config;
6522
6578
  _container = containerEl;
6523
6579
  assignViewIds(phylo);
@@ -7275,6 +7331,7 @@ function (root, d3, forester, phyloXml) {
7275
7331
  _domain.palette = null; // the tree changed: the domain names are dealt their colours again
7276
7332
  }
7277
7333
  if (edited) {
7334
+ _labelFields = null; // the fields the labels can show, too
7278
7335
  _heatmapModel = null; // ... and the heat map's columns and scale are derived from it again
7279
7336
  _heatmapColor = null;
7280
7337
  }
@@ -8192,11 +8249,28 @@ function (root, d3, forester, phyloXml) {
8192
8249
 
8193
8250
  function heatmapModel() {
8194
8251
  if (!_heatmapModel && _treeData) {
8195
- _heatmapModel = forester.heatmapColumns(_treeData);
8252
+ _heatmapModel = forester.heatmapColumns(_treeData, _figureMatrix);
8196
8253
  }
8197
8254
  return _heatmapModel;
8198
8255
  }
8199
8256
 
8257
+ // The file's MATRIX columns, when at least a heat map's worth of them are
8258
+ // numeric fields this tree has; otherwise null, and the heat map offers
8259
+ // every numeric field as it would without a figure.
8260
+ // The model it builds is kept as the heat map's own, so the scan runs once.
8261
+ function figureMatrixOf(tree) {
8262
+ let refs = forester.figureMatrixRefs(tree);
8263
+ if (refs.length < HEATMAP_MIN_COLUMNS) {
8264
+ return null;
8265
+ }
8266
+ let model = forester.heatmapColumns(tree, refs);
8267
+ if (model.refs.length < HEATMAP_MIN_COLUMNS) {
8268
+ return null;
8269
+ }
8270
+ _heatmapModel = model;
8271
+ return refs;
8272
+ }
8273
+
8200
8274
  // Offered when the tree holds at least two numeric per-tip refs that
8201
8275
  // actually carry values: one column is a stripe rather than a matrix,
8202
8276
  // and a scale needs something to span.
@@ -8404,7 +8478,53 @@ function (root, d3, forester, phyloXml) {
8404
8478
  return heatmapTopReserve();
8405
8479
  }
8406
8480
 
8481
+ // Whether cells of this size (tree units) and number are drawn one by one
8482
+ // with borders at zoom scale k, or merged into runs.
8483
+ function heatmapBordered(cellMin, count, k) {
8484
+ return count <= HEATMAP_BORDER_MAX_CELLS && (cellMin * k) >= HEATMAP_BORDER_MIN_PX;
8485
+ }
8486
+
8487
+ function heatmapBorderColor(fill) {
8488
+ let c = d3.color(fill);
8489
+ return c ? c.darker(HEATMAP_BORDER_DARKEN).formatHex() : fill;
8490
+ }
8491
+
8492
+ // A cell (or a merged run) of colour `fill`: bordered, its edge a darker
8493
+ // shade of itself; or sealed, stroked in its own colour so that no seam of
8494
+ // background shows between two runs at a fractional zoom -- the faint lines
8495
+ // that came and went with the zoom level. Both strokes are a constant
8496
+ // screen width, so a zoom neither fattens nor loses them.
8497
+ function paintHeatmapCell(el, fill, bordered) {
8498
+ el.attr('fill', fill)
8499
+ .attr('stroke', bordered ? heatmapBorderColor(fill) : fill)
8500
+ .attr('stroke-width', bordered ? HEATMAP_BORDER_W : HEATMAP_SEAL_W)
8501
+ .attr('vector-effect', 'non-scaling-stroke');
8502
+ return el;
8503
+ }
8504
+
8505
+ // Called on every zoom event: when the zoom has carried the cells across
8506
+ // the border threshold, the heat map alone is drawn again once the zoom
8507
+ // settles.
8508
+ function heatmapBordersFollowZoom() {
8509
+ if (!_heatmapCells || !_baseSvg) {
8510
+ return;
8511
+ }
8512
+ let want = heatmapBordered(_heatmapCells.cellMin, _heatmapCells.count, currentZoomScale());
8513
+ if (want === _heatmapCells.bordered) {
8514
+ return;
8515
+ }
8516
+ if (_heatmapBorderTimer) {
8517
+ clearTimeout(_heatmapBorderTimer);
8518
+ }
8519
+ _heatmapBorderTimer = setTimeout(function () {
8520
+ _heatmapBorderTimer = null;
8521
+ drawHeatmapTrack();
8522
+ placeFloatingOverlays();
8523
+ }, 120);
8524
+ }
8525
+
8407
8526
  function drawHeatmapTrack() {
8527
+ _heatmapCells = null;
8408
8528
  if (!_svgGroup) {
8409
8529
  return;
8410
8530
  }
@@ -8487,6 +8607,9 @@ function (root, d3, forester, phyloXml) {
8487
8607
  // a run of CELLS, or its top and bottom edges read as a drawn rule
8488
8608
  // across the matrix rather than as missing data.
8489
8609
  let mergeBlanks = cw < HEATMAP_LABEL_MIN_COL_W;
8610
+ let cellMin = Math.min(cw, n > 1 ? 2 * pad : cw);
8611
+ let bordered = heatmapBordered(cellMin, n * visible, currentZoomScale());
8612
+ _heatmapCells = {cellMin: cellMin, count: n * visible, bordered: bordered};
8490
8613
  for (let r = 0; r < n; ++r) {
8491
8614
  let d = tips[r];
8492
8615
  let cy = Math.round(bounds[r]);
@@ -8508,7 +8631,7 @@ function (root, d3, forester, phyloXml) {
8508
8631
  rect.attr('fill', _state.backgroundColorDefault)
8509
8632
  .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8510
8633
  } else {
8511
- rect.attr('fill', runFill);
8634
+ paintHeatmapCell(rect, runFill, bordered);
8512
8635
  }
8513
8636
  runStart = -1;
8514
8637
  runFill = null;
@@ -8519,7 +8642,8 @@ function (root, d3, forester, phyloXml) {
8519
8642
  if (fill === HEATMAP_BLANK) {
8520
8643
  blanks++;
8521
8644
  }
8522
- if (runFill === fill && (mergeBlanks || fill !== HEATMAP_BLANK)) {
8645
+ // bordered, every cell is its own: a run would hide the cells in it
8646
+ if (!bordered && runFill === fill && (mergeBlanks || fill !== HEATMAP_BLANK)) {
8523
8647
  continue; // the run extends
8524
8648
  }
8525
8649
  flush(i);
@@ -8829,6 +8953,10 @@ function (root, d3, forester, phyloXml) {
8829
8953
  edge[n] = ang[n - 1] + pad;
8830
8954
 
8831
8955
  let blanks = 0;
8956
+ // a cell's smaller side: its ring width, or its arc at the ring's inside
8957
+ let cellMin = Math.min(ringW, n > 1 ? 2 * pad * start : ringW);
8958
+ let bordered = heatmapBordered(cellMin, n * columns.length, currentZoomScale());
8959
+ _heatmapCells = {cellMin: cellMin, count: n * columns.length, bordered: bordered};
8832
8960
  for (let c = 0; c < columns.length; ++c) {
8833
8961
  let r0 = start + (c * ringW);
8834
8962
  let r1 = r0 + ringW;
@@ -8847,15 +8975,19 @@ function (root, d3, forester, phyloXml) {
8847
8975
  // A BLANK is not bled -- it is background-coloured, so a seam
8848
8976
  // beside it shows nothing, and bleeding it would push its
8849
8977
  // outward OUTLINE under the next ring and cost it an edge.
8850
- let bleed = (runFill === HEATMAP_BLANK) ? 0 : HEATMAP_RING_BLEED;
8978
+ // (bordered, the border covers the seam and a bleed would push
8979
+ // one cell's edge under its neighbour)
8980
+ let bleed = (runFill === HEATMAP_BLANK || bordered) ? 0 : HEATMAP_RING_BLEED;
8851
8981
  let a1 = edge[endI] + (bleed / Math.max(1, r1));
8852
8982
  let path = g.append('path')
8853
8983
  .attr('d', heatmapSector(edge[runStart], a1, r0, r1 + bleed));
8854
8984
  if (runFill === HEATMAP_BLANK) {
8855
8985
  path.attr('fill', _state.backgroundColorDefault)
8856
8986
  .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8987
+ } else if (bordered) {
8988
+ paintHeatmapCell(path, runFill, true);
8857
8989
  } else {
8858
- path.attr('fill', runFill);
8990
+ path.attr('fill', runFill); // the bleed already closes the seams here
8859
8991
  }
8860
8992
  runStart = -1;
8861
8993
  runFill = null;
@@ -8866,7 +8998,7 @@ function (root, d3, forester, phyloXml) {
8866
8998
  if (fill === HEATMAP_BLANK) {
8867
8999
  blanks++;
8868
9000
  }
8869
- if (runFill === fill && (ringW < HEATMAP_LABEL_MIN_COL_W || fill !== HEATMAP_BLANK)) {
9001
+ if (!bordered && runFill === fill && (ringW < HEATMAP_LABEL_MIN_COL_W || fill !== HEATMAP_BLANK)) {
8870
9002
  continue;
8871
9003
  }
8872
9004
  flush(i);
@@ -9095,6 +9227,63 @@ function (root, d3, forester, phyloXml) {
9095
9227
  // The same rule as the desktop's resolveEdited, minus its "did the order
9096
9228
  // actually change" test, which it needs only because its dialog does other
9097
9229
  // things too.
9230
+ // Drag a row of a reorder list -- the heat map's columns, the label's
9231
+ // metadata fields -- to a new place. The LIVE node is moved rather than
9232
+ // the list redrawn (a redraw would replace the element the drag is
9233
+ // following), its position read back from the DOM each time, and
9234
+ // `working` moved with it; `draw` runs once the pointer is released. A
9235
+ // press on a control in the row (an arrow, a checkbox) is not a drag.
9236
+ // Shared, so that both dialogs drag the same way: the rows' cursor
9237
+ // promised a drag the fields chooser did not have (Christian, 2026-10-01).
9238
+ function attachReorderDrag(list, row, working, draw) {
9239
+ function rowAt(y) {
9240
+ let rows = Array.prototype.slice.call(list.children);
9241
+ for (let i = 0; i < rows.length; ++i) {
9242
+ let b = rows[i].getBoundingClientRect();
9243
+ if (y < b.top + (b.height / 2)) {
9244
+ return i;
9245
+ }
9246
+ }
9247
+ return rows.length;
9248
+ }
9249
+ row.addEventListener('pointerdown', function (event) {
9250
+ if (event.target.closest('button, input')) {
9251
+ return;
9252
+ }
9253
+ event.preventDefault();
9254
+ row.classList.add('aptx-reorder-dragging');
9255
+ let onMove = function (ev) {
9256
+ let kids = Array.prototype.slice.call(list.children);
9257
+ let from = kids.indexOf(row);
9258
+ let to = rowAt(ev.clientY);
9259
+ if (to > from) {
9260
+ to -= 1; // taking the row out shifts everything after it up one
9261
+ }
9262
+ if (to === from || to < 0 || to >= kids.length) {
9263
+ return;
9264
+ }
9265
+ working.splice(to, 0, working.splice(from, 1)[0]);
9266
+ let others = kids.filter(function (k) {
9267
+ return k !== row;
9268
+ });
9269
+ list.insertBefore(row, others[to] || null);
9270
+ Array.prototype.slice.call(list.children).forEach(function (r, k) {
9271
+ let btns = r.querySelectorAll('.aptx-reorder-move');
9272
+ btns[0].disabled = (k === 0);
9273
+ btns[1].disabled = (k === list.children.length - 1);
9274
+ });
9275
+ };
9276
+ let onUp = function () {
9277
+ row.classList.remove('aptx-reorder-dragging');
9278
+ window.removeEventListener('pointermove', onMove);
9279
+ window.removeEventListener('pointerup', onUp);
9280
+ draw();
9281
+ };
9282
+ window.addEventListener('pointermove', onMove);
9283
+ window.addEventListener('pointerup', onUp);
9284
+ });
9285
+ }
9286
+
9098
9287
  function showHeatmapReorderDialog() {
9099
9288
  let ordered = heatmapOrdered();
9100
9289
  if (!ordered || ordered.columns.length < 2) {
@@ -9107,17 +9296,6 @@ function (root, d3, forester, phyloXml) {
9107
9296
  let list = repsElement(body, 'div', 'aptx-reorder');
9108
9297
  let working = ordered.columns.slice();
9109
9298
 
9110
- function rowAt(y) {
9111
- let rows = Array.prototype.slice.call(list.children);
9112
- for (let i = 0; i < rows.length; ++i) {
9113
- let b = rows[i].getBoundingClientRect();
9114
- if (y < b.top + (b.height / 2)) {
9115
- return i;
9116
- }
9117
- }
9118
- return rows.length;
9119
- }
9120
-
9121
9299
  function move(from, to) {
9122
9300
  if (to < 0 || to >= working.length || from === to) {
9123
9301
  return false;
@@ -9164,49 +9342,7 @@ function (root, d3, forester, phyloXml) {
9164
9342
  list.children[i + d].focus();
9165
9343
  }
9166
9344
  });
9167
- row.addEventListener('pointerdown', function (event) {
9168
- if (event.target.classList.contains('aptx-reorder-move')) {
9169
- return; // the arrows are not a drag handle
9170
- }
9171
- event.preventDefault();
9172
- row.classList.add('aptx-reorder-dragging');
9173
- // The LIVE node is moved rather than the list redrawn: a
9174
- // redraw would replace the element this drag is following.
9175
- // Its position is read back from the DOM each time instead
9176
- // of being tracked in a variable -- the first cut kept an
9177
- // index, updated it, and then used it to work out where to
9178
- // insert, by which point it no longer meant what the
9179
- // calculation assumed.
9180
- let onMove = function (ev) {
9181
- let kids = Array.prototype.slice.call(list.children);
9182
- let from = kids.indexOf(row);
9183
- let to = rowAt(ev.clientY);
9184
- if (to > from) {
9185
- to -= 1; // taking the row out shifts everything after it up one
9186
- }
9187
- if (to === from || to < 0 || to >= kids.length) {
9188
- return;
9189
- }
9190
- working.splice(to, 0, working.splice(from, 1)[0]);
9191
- let others = kids.filter(function (k) {
9192
- return k !== row;
9193
- });
9194
- list.insertBefore(row, others[to] || null);
9195
- Array.prototype.slice.call(list.children).forEach(function (r, k) {
9196
- let btns = r.querySelectorAll('.aptx-reorder-move');
9197
- btns[0].disabled = (k === 0);
9198
- btns[1].disabled = (k === list.children.length - 1);
9199
- });
9200
- };
9201
- let onUp = function () {
9202
- row.classList.remove('aptx-reorder-dragging');
9203
- window.removeEventListener('pointermove', onMove);
9204
- window.removeEventListener('pointerup', onUp);
9205
- draw();
9206
- };
9207
- window.addEventListener('pointermove', onMove);
9208
- window.addEventListener('pointerup', onUp);
9209
- });
9345
+ attachReorderDrag(list, row, working, draw);
9210
9346
  });
9211
9347
  }
9212
9348
  draw();
@@ -9627,6 +9763,7 @@ function (root, d3, forester, phyloXml) {
9627
9763
  ['name', NODE_NAME_CB, 'showNodeName'],
9628
9764
  ['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
9629
9765
  ['sequence', SEQUENCE_CB, 'showSequence'],
9766
+ ['properties', PROPERTIES_CB, 'showProperties'],
9630
9767
  ['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
9631
9768
  ['madValues', MAD_VALUES_CB, 'showMadValues'],
9632
9769
  ['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
@@ -9749,6 +9886,9 @@ function (root, d3, forester, phyloXml) {
9749
9886
  if (heatmapAvailable()) {
9750
9887
  s.heatmap = _state.showHeatmap === true;
9751
9888
  s.heatmapOrder = heatmapMode();
9889
+ if (_state.labelProperties) {
9890
+ s.labelFields = _state.labelProperties.slice();
9891
+ }
9752
9892
  if (_state.heatmapManualOrder) {
9753
9893
  s.heatmapManual = _state.heatmapManualOrder.slice();
9754
9894
  }
@@ -9892,6 +10032,11 @@ function (root, d3, forester, phyloXml) {
9892
10032
  if (typeof s.heatmap === 'boolean') {
9893
10033
  _state.showHeatmap = s.heatmap;
9894
10034
  }
10035
+ if (Array.isArray(s.labelFields) && s.labelFields.every(function (r) {
10036
+ return typeof r === 'string';
10037
+ })) {
10038
+ _state.labelProperties = s.labelFields.slice();
10039
+ }
9895
10040
  if (Array.isArray(s.heatmapManual) && s.heatmapManual.every(function (r) {
9896
10041
  return typeof r === 'string';
9897
10042
  })) {
@@ -10088,7 +10233,7 @@ function (root, d3, forester, phyloXml) {
10088
10233
  'scaleAxis', 'scaleGrid', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
10089
10234
  // Lists of strings, comma-joined. A property ref carries ':' but never a
10090
10235
  // comma, and encodeViewValue leaves both readable in a hash.
10091
- const VIEW_LIST_KEYS = ['heatmapManual'];
10236
+ const VIEW_LIST_KEYS = ['heatmapManual', 'labelFields'];
10092
10237
  const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
10093
10238
 
10094
10239
  function encodeViewValue(v) {
@@ -13593,6 +13738,194 @@ function (root, d3, forester, phyloXml) {
13593
13738
  scheduleUpdate();
13594
13739
  }
13595
13740
 
13741
+ // ===================== Properties in the label =====================
13742
+ // The desktop's "Properties" option and its Annotation Fields chooser:
13743
+ // a node's property values in its label, values only, " | "-joined, in
13744
+ // the order chosen (forester.labelPropertiesText). Nothing chosen means
13745
+ // every user-visible property in the node's own order, less the heat
13746
+ // map's columns while it is shown -- a field has one display role.
13747
+
13748
+ function labelFields() {
13749
+ if (_labelFields === null && _treeData) {
13750
+ _labelFields = forester.labelPropertyRefs(_treeData);
13751
+ }
13752
+ return _labelFields || [];
13753
+ }
13754
+
13755
+ // the heat map's columns while it is shown: left out of the default label
13756
+ function labelFieldsExcluded() {
13757
+ let excluded = Object.create(null);
13758
+ if (heatmapShown()) {
13759
+ let m = heatmapModel();
13760
+ (m ? m.refs : []).forEach(function (c) {
13761
+ excluded[c.ref] = true;
13762
+ });
13763
+ }
13764
+ return excluded;
13765
+ }
13766
+
13767
+ function labelPropertiesOf(node) {
13768
+ return forester.labelPropertiesText(node, _state.labelProperties,
13769
+ _state.labelProperties ? null : labelFieldsExcluded());
13770
+ }
13771
+
13772
+ function propertiesCbClicked() {
13773
+ _state.showProperties = getCheckboxValue(PROPERTIES_CB);
13774
+ if (_state.showProperties) {
13775
+ _state.showExternalLabels = true;
13776
+ setCheckboxValue(EXTERNAL_LABEL_CB, true);
13777
+ }
13778
+ search0();
13779
+ search1();
13780
+ scheduleUpdate();
13781
+ }
13782
+
13783
+ // Which properties the label shows, and in what order: each field ticked
13784
+ // or not, and moved by dragging, with the arrows, or with the arrow keys. Apply keeps the ticked
13785
+ // ones in the order shown; picking a field that was not shown turns the
13786
+ // Properties label on, as the desktop does, since otherwise choosing it
13787
+ // would visibly do nothing. "All" goes back to every field.
13788
+ function showLabelFieldsDialog() {
13789
+ let all = labelFields();
13790
+ if (all.length < 1) {
13791
+ return;
13792
+ }
13793
+ let chosen = _state.labelProperties ? _state.labelProperties.filter(function (r) {
13794
+ return all.indexOf(r) >= 0;
13795
+ }) : null;
13796
+ // the chosen fields first, in their order, then the rest as offered;
13797
+ // with none chosen, ticked is what the label shows by default
13798
+ let excluded = chosen ? null : labelFieldsExcluded();
13799
+ let working = (chosen || []).concat(all.filter(function (r) {
13800
+ return !chosen || chosen.indexOf(r) < 0;
13801
+ })).map(function (ref) {
13802
+ return {ref: ref, on: chosen ? chosen.indexOf(ref) >= 0 : !excluded[ref]};
13803
+ });
13804
+ if (!chosen) {
13805
+ // the ticked ones first, so the list reads as the label does
13806
+ working = working.filter(function (w) {
13807
+ return w.on;
13808
+ }).concat(working.filter(function (w) {
13809
+ return !w.on;
13810
+ }));
13811
+ }
13812
+ let before = working.filter(function (w) {
13813
+ return w.on && (chosen !== null || _state.showProperties);
13814
+ }).map(function (w) {
13815
+ return w.ref;
13816
+ });
13817
+ let shell = makeDialogShell(LABEL_FIELDS_DIALOG, 'Metadata fields', 340);
13818
+ let body = shell.body;
13819
+ repsElement(body, 'div', 'aptx-reps-lead',
13820
+ 'Tick the metadata fields the labels show; drag a field, or use the arrows, to set their order.');
13821
+ let list = repsElement(body, 'div', 'aptx-reorder');
13822
+
13823
+ function move(from, to) {
13824
+ if (to < 0 || to >= working.length || from === to) {
13825
+ return false;
13826
+ }
13827
+ working.splice(to, 0, working.splice(from, 1)[0]);
13828
+ draw();
13829
+ return true;
13830
+ }
13831
+
13832
+ function draw() {
13833
+ list.textContent = '';
13834
+ working.forEach(function (w, i) {
13835
+ let row = repsElement(list, 'div', 'aptx-reorder-row');
13836
+ row.tabIndex = 0;
13837
+ repsElement(row, 'span', 'aptx-reorder-grip', '≡');
13838
+ let box = repsElement(row, 'input', 'aptx-reorder-check');
13839
+ box.type = 'checkbox';
13840
+ box.checked = w.on;
13841
+ box.addEventListener('change', function () {
13842
+ w.on = box.checked;
13843
+ });
13844
+ repsElement(row, 'span', 'aptx-reorder-name', forester.propertyDisplayName(w.ref)).title = w.ref;
13845
+ let up = repsElement(row, 'button', 'aptx-reorder-move', '↑');
13846
+ let down = repsElement(row, 'button', 'aptx-reorder-move', '↓');
13847
+ up.type = 'button';
13848
+ down.type = 'button';
13849
+ up.title = 'Move up';
13850
+ down.title = 'Move down';
13851
+ up.disabled = (i === 0);
13852
+ down.disabled = (i === working.length - 1);
13853
+ up.addEventListener('click', function () {
13854
+ if (move(i, i - 1)) {
13855
+ list.children[i - 1].focus();
13856
+ }
13857
+ });
13858
+ down.addEventListener('click', function () {
13859
+ if (move(i, i + 1)) {
13860
+ list.children[i + 1].focus();
13861
+ }
13862
+ });
13863
+ row.addEventListener('keydown', function (event) {
13864
+ if (event.key === ' ' && event.target === row) {
13865
+ event.preventDefault();
13866
+ box.checked = !box.checked;
13867
+ w.on = box.checked;
13868
+ return;
13869
+ }
13870
+ let d = (event.key === 'ArrowUp') ? -1 : ((event.key === 'ArrowDown') ? 1 : 0);
13871
+ if (d === 0) {
13872
+ return;
13873
+ }
13874
+ event.preventDefault();
13875
+ if (move(i, i + d)) {
13876
+ list.children[i + d].focus();
13877
+ }
13878
+ });
13879
+ attachReorderDrag(list, row, working, draw);
13880
+ });
13881
+ }
13882
+ draw();
13883
+
13884
+ let actions = repsElement(body, 'div', 'aptx-reps-actions');
13885
+ // All and None tick the boxes and leave the dialog open for Apply --
13886
+ // All used to mean "back to the default" and close at once, which
13887
+ // read as the dialog vanishing (Christian, 2026-10-01)
13888
+ let tickAll = function (on) {
13889
+ working.forEach(function (w) {
13890
+ w.on = on;
13891
+ });
13892
+ draw();
13893
+ };
13894
+ repsButton(actions, 'All', false, function () {
13895
+ tickAll(true);
13896
+ });
13897
+ repsButton(actions, 'None', false, function () {
13898
+ tickAll(false);
13899
+ });
13900
+ repsButton(actions, 'Cancel', false, function () {
13901
+ shell.dialog.close();
13902
+ });
13903
+ repsButton(actions, 'Apply', true, function () {
13904
+ _state.labelProperties = working.filter(function (w) {
13905
+ return w.on;
13906
+ }).map(function (w) {
13907
+ return w.ref;
13908
+ });
13909
+ let added = _state.labelProperties.some(function (r) {
13910
+ return before.indexOf(r) < 0;
13911
+ });
13912
+ if (added && !_state.showProperties) {
13913
+ _state.showProperties = true;
13914
+ _state.showExternalLabels = true;
13915
+ setCheckboxValue(PROPERTIES_CB, true);
13916
+ setCheckboxValue(EXTERNAL_LABEL_CB, true);
13917
+ }
13918
+ shell.dialog.close();
13919
+ search0();
13920
+ search1();
13921
+ scheduleUpdate();
13922
+ });
13923
+ shell.dialog.showModal();
13924
+ if (list.firstChild) {
13925
+ list.firstChild.focus();
13926
+ }
13927
+ }
13928
+
13596
13929
  function sequenceCbClicked() {
13597
13930
  _state.showSequence = getCheckboxValue(SEQUENCE_CB);
13598
13931
  if (_state.showSequence) {
@@ -13783,7 +14116,7 @@ function (root, d3, forester, phyloXml) {
13783
14116
  }
13784
14117
  let part = subtreeExtentNote();
13785
14118
  items.push({heading: part ? 'Tree: ' + part : 'Tree'});
13786
- items.push({label: 'phyloXML', detail: 'keeps everything the tree carries: taxonomy, sequences, dates, properties',
14119
+ items.push({label: 'phyloXML', detail: 'keeps everything the tree carries: taxonomy, sequences, dates, metadata',
13787
14120
  action: entry(PHYLOXML_EXPORT_FORMAT)});
13788
14121
  items.push({label: 'Newick', detail: 'names, branch lengths and support values only',
13789
14122
  action: entry(NH_EXPORT_FORMAT)});
@@ -13794,7 +14127,7 @@ function (root, d3, forester, phyloXml) {
13794
14127
  // Fasta entries) -- on any other tree the download would be empty
13795
14128
  items.push({heading: part ? 'Tip data: ' + part : 'Tip data'});
13796
14129
  items.push({label: 'TSV', detail: 'a table, one row per tip: names, taxonomy, sequence names, '
13797
- + 'branch lengths and every property', action: entry(TSV_EXPORT_FORMAT)});
14130
+ + 'branch lengths and every metadata field', action: entry(TSV_EXPORT_FORMAT)});
13798
14131
  if (_basicTreeProperties && _basicTreeProperties.maxMolSeqLength > 0) {
13799
14132
  items.push({label: 'FASTA', detail: 'every molecular sequence the tips carry',
13800
14133
  action: entry(FASTA_EXPORT_FORMAT)});
@@ -15557,7 +15890,7 @@ function (root, d3, forester, phyloXml) {
15557
15890
  }
15558
15891
  }
15559
15892
  if (rows) {
15560
- text += 'Properties<br>' + rows;
15893
+ text += 'Metadata<br>' + rows;
15561
15894
  }
15562
15895
  }
15563
15896
  return text;
@@ -15973,7 +16306,7 @@ function (root, d3, forester, phyloXml) {
15973
16306
  carries('Events', true, parts.join(', '));
15974
16307
  }
15975
16308
  st.propertyRefs.forEach(function (r) {
15976
- carries('Property ' + r.ref, true, statOf(r.nodes, st.nodes, 'nodes'));
16309
+ carries('Metadata ' + r.ref, true, statOf(r.nodes, st.nodes, 'nodes'));
15977
16310
  });
15978
16311
  if (carried === 0) {
15979
16312
  treePropRow(box, 'Annotations', 'none, names and topology only');
@@ -16304,6 +16637,8 @@ function (root, d3, forester, phyloXml) {
16304
16637
  on(TAXONOMY_CB, 'click', taxonomyCbClicked);
16305
16638
 
16306
16639
  on(SEQUENCE_CB, 'click', sequenceCbClicked);
16640
+ on(PROPERTIES_CB, 'click', propertiesCbClicked);
16641
+ on(LABEL_FIELDS_BUTTON, 'click', showLabelFieldsDialog);
16307
16642
 
16308
16643
  on(CONFIDENCE_VALUES_CB, 'click', confidenceValuesCbClicked);
16309
16644
  on(SUPPORT_DOTS_CB, 'click', supportDotsCbClicked);
@@ -16832,6 +17167,12 @@ function (root, d3, forester, phyloXml) {
16832
17167
  if (_basicTreeProperties.sequences) {
16833
17168
  labels.push(makeCheckboxItem('Sequence', SEQUENCE_CB, 'to show/hide node sequence information'));
16834
17169
  }
17170
+ if (labelFields().length > 0) {
17171
+ // "Metadata", not phyloXML's "properties" (Christian, 2026-10-01):
17172
+ // the word the users' own tools use for these fields
17173
+ labels.push(makeCheckboxItem('Metadata', PROPERTIES_CB, 'to show/hide the nodes\' metadata (phyloXML'
17174
+ + ' properties: species, host, …) in their labels; Metadata fields… chooses which, and in what order'));
17175
+ }
16835
17176
  if (_nodeLabels) {
16836
17177
  for (const [key, value] of Object.entries(_nodeLabels)) {
16837
17178
  if (value.label && value.propertyRef && value.description) {
@@ -16913,6 +17254,12 @@ function (root, d3, forester, phyloXml) {
16913
17254
 
16914
17255
  let h = '<fieldset><legend>Display Data</legend>';
16915
17256
  h = h.concat(makeCheckboxGroup('Labels', labels));
17257
+ if (labelFields().length > 0) {
17258
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel"></span>'
17259
+ + '<input type="button" class="aptx-widebtn" value="Metadata fields…" name="' + LABEL_FIELDS_BUTTON
17260
+ + '" id="' + LABEL_FIELDS_BUTTON + '" title="choose which metadata fields the labels show, and in'
17261
+ + ' what order"></div>');
17262
+ }
16916
17263
  h = h.concat(makeCheckboxGroup('Nodes', nodes));
16917
17264
  h = h.concat(makeCheckboxGroup('Options', opts));
16918
17265
  h = h.concat('</fieldset>');
@@ -17171,12 +17518,12 @@ function (root, d3, forester, phyloXml) {
17171
17518
  h = h.concat('<fieldset>');
17172
17519
  h = h.concat('<legend>Visualizations</legend>');
17173
17520
  if (hasColorVisualizations()) {
17174
- h = h.concat(makeSelectMenu('Color by:', '<br>', LABEL_COLOR_SELECT_MENU, 'colorize the node label and the node itself according to a property'));
17521
+ h = h.concat(makeSelectMenu('Color by:', '<br>', LABEL_COLOR_SELECT_MENU, 'colorize the node label and the node itself by a field the tree carries'));
17175
17522
  h = h.concat('<br>');
17176
17523
  h = h.concat('<br>');
17177
17524
  }
17178
17525
  if (hasShapeVisualizations()) {
17179
- h = h.concat(makeSelectMenu('Shape:', '<br>', NODE_SHAPE_SELECT_MENU, 'change the node shape according to a property'));
17526
+ h = h.concat(makeSelectMenu('Shape:', '<br>', NODE_SHAPE_SELECT_MENU, 'change the node shape by a field the tree carries'));
17180
17527
  h = h.concat('<br>');
17181
17528
  }
17182
17529
  h = h.concat('</fieldset>');
@@ -17237,6 +17584,7 @@ function (root, d3, forester, phyloXml) {
17237
17584
  setCheckboxValue(NODE_NAME_CB, _state.showNodeName);
17238
17585
  setCheckboxValue(TAXONOMY_CB, _state.showTaxonomy);
17239
17586
  setCheckboxValue(SEQUENCE_CB, _state.showSequence)
17587
+ setCheckboxValue(PROPERTIES_CB, _state.showProperties);
17240
17588
  setCheckboxValue(CONFIDENCE_VALUES_CB, _state.showConfidenceValues);
17241
17589
  setCheckboxValue(SUPPORT_DOTS_CB, _state.showSupportDots);
17242
17590
  syncMadValuesCheckbox();
@@ -17911,9 +18259,11 @@ function (root, d3, forester, phyloXml) {
17911
18259
  }
17912
18260
  let name = _root_const.name ? String(_root_const.name).trim() : '';
17913
18261
  // what the phylogeny itself states goes with the part: without its
17914
- // branch_length_unit a subtree saved from Div opened again as time
18262
+ // branch_length_unit a subtree saved from Div opened again as time,
18263
+ // and without its properties (the figure setting) the part opened
18264
+ // without the heat map the whole tree opens with
17915
18265
  let part = Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17916
- ['rooted', 'rerootable', 'type', 'branch_length_unit'].forEach(function (k) {
18266
+ ['rooted', 'rerootable', 'type', 'branch_length_unit', 'properties'].forEach(function (k) {
17917
18267
  if (_root_const[k] !== undefined && _root_const[k] !== null) {
17918
18268
  part[k] = _root_const[k];
17919
18269
  }
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.19.0
23
+ // v 3.21.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -2091,7 +2091,12 @@
2091
2091
  return p.applies_to === 'node' || p.applies_to === 'clade';
2092
2092
  };
2093
2093
 
2094
- forester.visualizationCandidates = function (tree) {
2094
+ // options.keepConstant: also return a NUMERIC field that has one value on
2095
+ // every tip. There is nothing to colour by in it, so the selection never
2096
+ // asks for it -- but a matrix does: a single-copy core gene is a column
2097
+ // of 1s, and dropping it from the heat map hid a gene the file lists.
2098
+ forester.visualizationCandidates = function (tree, options) {
2099
+ let keepConstant = !!(options && options.keepConstant);
2095
2100
  let total = 0;
2096
2101
  let stats = Object.create(null); // id -> {kind, ref, label, nodes, values:Set, multi}; null-proto: ids embed file refs
2097
2102
 
@@ -2255,7 +2260,7 @@
2255
2260
  // The legend already carries the honest part: a "no value" row
2256
2261
  // with the uncovered count, at reduced opacity.
2257
2262
  let sparse = covered * VIS_MIN_COVERAGE_DEN < total * VIS_MIN_COVERAGE_NUM;
2258
- if (distinct < 2) {
2263
+ if (distinct < 2 && !(keepConstant && numeric)) {
2259
2264
  return;
2260
2265
  }
2261
2266
  let colorMode;
@@ -2883,11 +2888,18 @@
2883
2888
  * no numeric per-tip property. `min`/`max` are over every value of every
2884
2889
  * column and are `null` when there is nothing to scale.
2885
2890
  */
2886
- forester.heatmapColumns = function (tree) {
2891
+ forester.heatmapColumns = function (tree, only) {
2892
+ let wanted = null; // the figure's columns, when a file names them
2893
+ if (Array.isArray(only)) {
2894
+ wanted = Object.create(null);
2895
+ only.forEach(function (ref) {
2896
+ wanted[ref] = true;
2897
+ });
2898
+ }
2887
2899
  let numeric = Object.create(null);
2888
2900
  let label = Object.create(null);
2889
- forester.visualizationCandidates(tree).forEach(function (c) {
2890
- if (c && c.kind === 'property' && c.numeric === true && c.ref) {
2901
+ forester.visualizationCandidates(tree, {keepConstant: true}).forEach(function (c) {
2902
+ if (c && c.kind === 'property' && c.numeric === true && c.ref && (!wanted || wanted[c.ref])) {
2891
2903
  numeric[c.ref] = true;
2892
2904
  label[c.ref] = c.label || c.ref;
2893
2905
  }
@@ -2933,6 +2945,179 @@
2933
2945
  };
2934
2946
  };
2935
2947
 
2948
+ // --------------------------------------------------------------
2949
+ // The desktop's figure setting (aptx:figure)
2950
+ // --------------------------------------------------------------
2951
+ // The desktop Archaeopteryx stores what a tree is to be drawn with in one
2952
+ // property of the PHYLOGENY: <property ref="aptx:figure" applies_to=
2953
+ // "phylogeny"> as a direct child of <phylogeny>. Its value (FigureSpec
2954
+ // on the desktop) is "v1;key=value;..." with every value escaped
2955
+ // (PropertyTextCodec): \\ \t \n \r, \_ a space, \p "|", \s "~",
2956
+ // \c ";", \e "=". columns= is a "|" list of ref~TYPE~SHAPE~normalized.
2957
+ // Only a direct child of <phylogeny> counts (Christian, 2026-09-30: "a
2958
+ // hard break"): desktops 0.11.117 to 0.11.172 wrote it on the root clade,
2959
+ // and such a copy is ignored here and dropped on the way out, as the
2960
+ // desktop does. An unknown version is no figure; unknown keys are ignored.
2961
+ forester.FIGURE_REF = 'aptx:figure';
2962
+
2963
+ forester.unescapeFigureValue = function (v) {
2964
+ const map = {'\\': '\\', 't': '\t', 'n': '\n', 'r': '\r', '_': ' ', 'p': '|', 's': '~', 'c': ';', 'e': '='};
2965
+ return String(v).replace(/\\(.)/g, function (all, c) {
2966
+ return map[c] !== undefined ? map[c] : all;
2967
+ });
2968
+ };
2969
+
2970
+ // {columns: [{ref, type, shape, normalized}]} from the tree's figure, or
2971
+ // null when it has none (or one this version cannot read).
2972
+ forester.readFigure = function (tree) {
2973
+ let props = tree && Array.isArray(tree.properties) ? tree.properties : [];
2974
+ let p = props.find(function (q) {
2975
+ return q && q.ref === forester.FIGURE_REF;
2976
+ });
2977
+ if (!p || typeof p.value !== 'string') {
2978
+ return null;
2979
+ }
2980
+ let parts = p.value.trim().split(';');
2981
+ if (parts[0] !== 'v1') {
2982
+ return null;
2983
+ }
2984
+ // labelProps: the properties the label shows, in order (null when the
2985
+ // figure does not say, [] when it says none); show: the desktop's
2986
+ // display switches by DisplayOption name ("show.SHOW_PROPERTIES=true")
2987
+ let figure = {columns: [], labelProps: null, show: {}};
2988
+ parts.slice(1).forEach(function (kv) {
2989
+ let at = kv.indexOf('=');
2990
+ if (at < 1) {
2991
+ return;
2992
+ }
2993
+ let key = kv.substring(0, at);
2994
+ let value = forester.unescapeFigureValue(kv.substring(at + 1));
2995
+ if (key === 'labelprops') {
2996
+ figure.labelProps = value.split('|').filter(function (r) {
2997
+ return r.length > 0;
2998
+ });
2999
+ return;
3000
+ }
3001
+ if (key.indexOf('show.') === 0 && (value === 'true' || value === 'false')) {
3002
+ figure.show[key.substring(5)] = value === 'true';
3003
+ return;
3004
+ }
3005
+ if (key !== 'columns') {
3006
+ return;
3007
+ }
3008
+ value.split('|').forEach(function (col) {
3009
+ let f = col.split('~');
3010
+ if (f[0] && f[0].length > 0) {
3011
+ figure.columns.push({ref: f[0], type: f[1] || '', shape: f[2] || 'CIRCLE',
3012
+ normalized: f[3] === 'true'});
3013
+ }
3014
+ });
3015
+ });
3016
+ return figure;
3017
+ };
3018
+
3019
+ // The refs the figure draws as a MATRIX, in its order ([] without one).
3020
+ forester.figureMatrixRefs = function (tree) {
3021
+ let figure = forester.readFigure(tree);
3022
+ let seen = Object.create(null);
3023
+ return figure ? figure.columns.filter(function (c) {
3024
+ let keep = c.type === 'MATRIX' && !seen[c.ref];
3025
+ seen[c.ref] = true;
3026
+ return keep;
3027
+ }).map(function (c) {
3028
+ return c.ref;
3029
+ }) : [];
3030
+ };
3031
+
3032
+ // ---- Properties in the tip label (the desktop's "Properties" option) ----
3033
+
3034
+ // A property a reader would want to see: not the viewer's own aptx:
3035
+ // metadata, not a style: instruction.
3036
+ const LABEL_PROPERTY_SEPARATOR = ' | ';
3037
+
3038
+ forester.isUserVisiblePropertyRef = function (ref) {
3039
+ return typeof ref === 'string' && ref.length > 0 && ref.indexOf('aptx:') !== 0 && ref.indexOf('style:') !== 0;
3040
+ };
3041
+
3042
+ // Every user-visible property ref any node carries, internal nodes
3043
+ // included, sorted by the name the reader sees (then by ref): what the
3044
+ // label-field chooser offers. Deliberately BROADER than the colour-by
3045
+ // candidates, which drop constant, per-tip-unique and internal-only fields
3046
+ // -- all perfectly good label text (the desktop's userVisiblePropertyRefs).
3047
+ forester.labelPropertyRefs = function (tree) {
3048
+ let seen = Object.create(null);
3049
+ forester.preOrderTraversalAll(tree, function (n) {
3050
+ (n.properties || []).forEach(function (p) {
3051
+ if (p && forester.isUserVisiblePropertyRef(p.ref)
3052
+ && p.value !== undefined && p.value !== null && String(p.value).trim().length > 0) {
3053
+ seen[p.ref] = true;
3054
+ }
3055
+ });
3056
+ });
3057
+ return Object.keys(seen).sort(function (a, b) {
3058
+ let da = forester.propertyDisplayName(a).toLowerCase();
3059
+ let db = forester.propertyDisplayName(b).toLowerCase();
3060
+ return da < db ? -1 : (da > db ? 1 : (a < b ? -1 : (a > b ? 1 : 0)));
3061
+ });
3062
+ };
3063
+
3064
+ // A node's properties as one line of label text: values only, a unit
3065
+ // after its value (without its namespace), joined by " | " as the rest of
3066
+ // the label is. Not by ", " as on the desktop (Christian, 2026-09-30): a
3067
+ // value often holds a comma itself -- a BV-BRC host "Bottlenose dolphin,
3068
+ // Tursiops truncatus" -- and "host, species" then reads as three fields. `refs` picks and orders the fields; null is every
3069
+ // user-visible property in the node's own order, less `excluded` (the
3070
+ // heat map's columns: a field has one display role).
3071
+ forester.labelPropertiesText = function (node, refs, excluded) {
3072
+ let props = (node && node.properties) || [];
3073
+ let parts = [];
3074
+ let add = function (p) {
3075
+ if (!p || !forester.isUserVisiblePropertyRef(p.ref) || p.value === undefined || p.value === null) {
3076
+ return;
3077
+ }
3078
+ let v = String(p.value).trim();
3079
+ if (v.length < 1) {
3080
+ return;
3081
+ }
3082
+ if (typeof p.unit === 'string' && p.unit.length > 0) {
3083
+ v += ' ' + p.unit.substring(p.unit.lastIndexOf(':') + 1);
3084
+ }
3085
+ parts.push(v);
3086
+ };
3087
+ if (refs) {
3088
+ refs.forEach(function (ref) {
3089
+ props.forEach(function (p) {
3090
+ if (p && p.ref === ref) {
3091
+ add(p);
3092
+ }
3093
+ });
3094
+ });
3095
+ } else {
3096
+ props.forEach(function (p) {
3097
+ if (!(excluded && p && excluded[p.ref])) {
3098
+ add(p);
3099
+ }
3100
+ });
3101
+ }
3102
+ return parts.join(LABEL_PROPERTY_SEPARATOR);
3103
+ };
3104
+
3105
+ // Removes an aptx:figure from every clade (the old place, now ignored),
3106
+ // so a tree saved from here carries no dead copy. Returns how many.
3107
+ forester.dropCladeFigures = function (tree) {
3108
+ let n = 0;
3109
+ forester.preOrderTraversalAll(tree, function (node) {
3110
+ if (node !== tree && Array.isArray(node.properties)) {
3111
+ let kept = node.properties.filter(function (q) {
3112
+ return !(q && q.ref === forester.FIGURE_REF);
3113
+ });
3114
+ n += node.properties.length - kept.length;
3115
+ node.properties = kept;
3116
+ }
3117
+ });
3118
+ return n;
3119
+ };
3120
+
2936
3121
  /**
2937
3122
  * The number in `node`'s heat-map cell for `ref`, or `null` when there is none.
2938
3123
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.19.0",
3
+ "version": "3.21.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",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "d3": "^7.9.0",
38
- "phyloxml": "^1.1.2"
38
+ "phyloxml": "^1.1.7"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@eslint/js": "^9.13.0",