archaeopteryx 3.19.0 → 3.20.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
@@ -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
@@ -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' property values in their labels. Default: off, unless the file's figure setting (`show.SHOW_PROPERTIES=true`) says on. See [Properties in the label](#properties-in-the-label). |
1383
+ | `labelProperties` | tree-derived | Which properties 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,28 @@ 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
+ ### Properties in the label
1881
+
1882
+ A tip's other fields — a species, a host, a country, anything a node carries
1883
+ as a `<property>` — can go into its label, as on the desktop. **Properties**
1884
+ under Display Data (offered when nodes carry properties, off by default) adds
1885
+ their **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 property shows in the
1890
+ node's own order, except the heat map's columns while it is shown — a field
1891
+ has one display role. **Label fields…** picks which properties, and in what
1892
+ order; picking one turns Properties on. The checkbox and the fields travel in
1893
+ a shared view (`show=…,properties`, `labelFields=`), and the config keys are
1894
+ `showProperties` and `labelProperties`.
1895
+
1896
+ A file can choose for itself: the desktop's figure setting (`aptx:figure`,
1897
+ see [Heat maps](#heat-maps)) carries `labelprops=` (the properties, in order)
1898
+ and the display switches `show.SHOW_PROPERTIES=true` and
1899
+ `show.SHOW_NODE_NAMES=false`, and a tree opens labelled that way in both
1900
+ programs. The config wins over the file.
1901
+
1852
1902
  ### Initial label fields
1853
1903
 
1854
1904
  Which of the three label checkboxes — Node Name, Taxonomy, Sequence — start
@@ -2084,8 +2134,10 @@ bottom reserve the fit allows for follows that.
2084
2134
 
2085
2135
  Model (`forester.heatmapColumns(tree)` → `{refs:[{ref,label}], min, max}`,
2086
2136
  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
2137
+ `forester.visualizationCandidates(tree, {keepConstant: true})` calls a
2138
+ **numeric property** — so candidacy is not re-invented here and the refusal
2139
+ rules are inherited, except the one against a field with a single value, which
2140
+ suits colour-by and not a matrix — carried
2089
2141
  by at least one **tip**; an internal node has no row, so a ref only internal
2090
2142
  nodes hold is not a column and its values never reach the scale. `min`/`max`
2091
2143
  span every drawn cell of the whole tree. `forester.heatmapValue(node, ref)`
@@ -2188,8 +2240,16 @@ order the columns happened to be in. Measured: without it, the 6×6 linkage
2188
2240
  fixture fed in reverse clustered to `h6,h5,h3,h4,h2,h1` instead of R's
2189
2241
  `h6,h3,h5,h4,h1,h2` — the index order drives the tie-break and the leaf order,
2190
2242
  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.
2243
+ **not** go through `heatmapColumns`: that applies candidacy rules, and
2244
+ ordering must not move with them.
2245
+
2246
+ `forester.heatmapColumns(tree, only)` keeps just the refs in `only` — the
2247
+ figure's MATRIX columns (`forester.figureMatrixRefs(tree)`, from
2248
+ `forester.readFigure`, which reads the phylogeny's own `aptx:figure` only:
2249
+ `"v1;key=value;…"`, values escaped as the desktop's `PropertyTextCodec` does,
2250
+ `columns=` a `|` list of `ref~TYPE~SHAPE~normalized`; an unknown version is no
2251
+ figure, unknown keys are ignored). `forester.dropCladeFigures(tree)` removes
2252
+ the old clade-level copies at launch.
2193
2253
 
2194
2254
  Distances are `forester.heatmapEuclideanDistances` (R's `dist` convention for
2195
2255
  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.20.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.20.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);
@@ -9627,6 +9759,7 @@ function (root, d3, forester, phyloXml) {
9627
9759
  ['name', NODE_NAME_CB, 'showNodeName'],
9628
9760
  ['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
9629
9761
  ['sequence', SEQUENCE_CB, 'showSequence'],
9762
+ ['properties', PROPERTIES_CB, 'showProperties'],
9630
9763
  ['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
9631
9764
  ['madValues', MAD_VALUES_CB, 'showMadValues'],
9632
9765
  ['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
@@ -9749,6 +9882,9 @@ function (root, d3, forester, phyloXml) {
9749
9882
  if (heatmapAvailable()) {
9750
9883
  s.heatmap = _state.showHeatmap === true;
9751
9884
  s.heatmapOrder = heatmapMode();
9885
+ if (_state.labelProperties) {
9886
+ s.labelFields = _state.labelProperties.slice();
9887
+ }
9752
9888
  if (_state.heatmapManualOrder) {
9753
9889
  s.heatmapManual = _state.heatmapManualOrder.slice();
9754
9890
  }
@@ -9892,6 +10028,11 @@ function (root, d3, forester, phyloXml) {
9892
10028
  if (typeof s.heatmap === 'boolean') {
9893
10029
  _state.showHeatmap = s.heatmap;
9894
10030
  }
10031
+ if (Array.isArray(s.labelFields) && s.labelFields.every(function (r) {
10032
+ return typeof r === 'string';
10033
+ })) {
10034
+ _state.labelProperties = s.labelFields.slice();
10035
+ }
9895
10036
  if (Array.isArray(s.heatmapManual) && s.heatmapManual.every(function (r) {
9896
10037
  return typeof r === 'string';
9897
10038
  })) {
@@ -10088,7 +10229,7 @@ function (root, d3, forester, phyloXml) {
10088
10229
  'scaleAxis', 'scaleGrid', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
10089
10230
  // Lists of strings, comma-joined. A property ref carries ':' but never a
10090
10231
  // comma, and encodeViewValue leaves both readable in a hash.
10091
- const VIEW_LIST_KEYS = ['heatmapManual'];
10232
+ const VIEW_LIST_KEYS = ['heatmapManual', 'labelFields'];
10092
10233
  const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
10093
10234
 
10094
10235
  function encodeViewValue(v) {
@@ -13593,6 +13734,182 @@ function (root, d3, forester, phyloXml) {
13593
13734
  scheduleUpdate();
13594
13735
  }
13595
13736
 
13737
+ // ===================== Properties in the label =====================
13738
+ // The desktop's "Properties" option and its Annotation Fields chooser:
13739
+ // a node's property values in its label, values only, " | "-joined, in
13740
+ // the order chosen (forester.labelPropertiesText). Nothing chosen means
13741
+ // every user-visible property in the node's own order, less the heat
13742
+ // map's columns while it is shown -- a field has one display role.
13743
+
13744
+ function labelFields() {
13745
+ if (_labelFields === null && _treeData) {
13746
+ _labelFields = forester.labelPropertyRefs(_treeData);
13747
+ }
13748
+ return _labelFields || [];
13749
+ }
13750
+
13751
+ // the heat map's columns while it is shown: left out of the default label
13752
+ function labelFieldsExcluded() {
13753
+ let excluded = Object.create(null);
13754
+ if (heatmapShown()) {
13755
+ let m = heatmapModel();
13756
+ (m ? m.refs : []).forEach(function (c) {
13757
+ excluded[c.ref] = true;
13758
+ });
13759
+ }
13760
+ return excluded;
13761
+ }
13762
+
13763
+ function labelPropertiesOf(node) {
13764
+ return forester.labelPropertiesText(node, _state.labelProperties,
13765
+ _state.labelProperties ? null : labelFieldsExcluded());
13766
+ }
13767
+
13768
+ function propertiesCbClicked() {
13769
+ _state.showProperties = getCheckboxValue(PROPERTIES_CB);
13770
+ if (_state.showProperties) {
13771
+ _state.showExternalLabels = true;
13772
+ setCheckboxValue(EXTERNAL_LABEL_CB, true);
13773
+ }
13774
+ search0();
13775
+ search1();
13776
+ scheduleUpdate();
13777
+ }
13778
+
13779
+ // Which properties the label shows, and in what order: each field ticked
13780
+ // or not, and moved with the arrows (or the arrow keys). Apply keeps the ticked
13781
+ // ones in the order shown; picking a field that was not shown turns the
13782
+ // Properties label on, as the desktop does, since otherwise choosing it
13783
+ // would visibly do nothing. "All" goes back to every field.
13784
+ function showLabelFieldsDialog() {
13785
+ let all = labelFields();
13786
+ if (all.length < 1) {
13787
+ return;
13788
+ }
13789
+ let chosen = _state.labelProperties ? _state.labelProperties.filter(function (r) {
13790
+ return all.indexOf(r) >= 0;
13791
+ }) : null;
13792
+ // the chosen fields first, in their order, then the rest as offered;
13793
+ // with none chosen, ticked is what the label shows by default
13794
+ let excluded = chosen ? null : labelFieldsExcluded();
13795
+ let working = (chosen || []).concat(all.filter(function (r) {
13796
+ return !chosen || chosen.indexOf(r) < 0;
13797
+ })).map(function (ref) {
13798
+ return {ref: ref, on: chosen ? chosen.indexOf(ref) >= 0 : !excluded[ref]};
13799
+ });
13800
+ if (!chosen) {
13801
+ // the ticked ones first, so the list reads as the label does
13802
+ working = working.filter(function (w) {
13803
+ return w.on;
13804
+ }).concat(working.filter(function (w) {
13805
+ return !w.on;
13806
+ }));
13807
+ }
13808
+ let before = working.filter(function (w) {
13809
+ return w.on && (chosen !== null || _state.showProperties);
13810
+ }).map(function (w) {
13811
+ return w.ref;
13812
+ });
13813
+ let shell = makeDialogShell(LABEL_FIELDS_DIALOG, 'Label fields', 340);
13814
+ let body = shell.body;
13815
+ repsElement(body, 'div', 'aptx-reps-lead',
13816
+ 'Tick the properties the labels show, and set their order with the arrows.');
13817
+ let list = repsElement(body, 'div', 'aptx-reorder');
13818
+
13819
+ function move(from, to) {
13820
+ if (to < 0 || to >= working.length || from === to) {
13821
+ return false;
13822
+ }
13823
+ working.splice(to, 0, working.splice(from, 1)[0]);
13824
+ draw();
13825
+ return true;
13826
+ }
13827
+
13828
+ function draw() {
13829
+ list.textContent = '';
13830
+ working.forEach(function (w, i) {
13831
+ let row = repsElement(list, 'div', 'aptx-reorder-row');
13832
+ row.tabIndex = 0;
13833
+ let box = repsElement(row, 'input', 'aptx-reorder-check');
13834
+ box.type = 'checkbox';
13835
+ box.checked = w.on;
13836
+ box.addEventListener('change', function () {
13837
+ w.on = box.checked;
13838
+ });
13839
+ repsElement(row, 'span', 'aptx-reorder-name', forester.propertyDisplayName(w.ref)).title = w.ref;
13840
+ let up = repsElement(row, 'button', 'aptx-reorder-move', '↑');
13841
+ let down = repsElement(row, 'button', 'aptx-reorder-move', '↓');
13842
+ up.type = 'button';
13843
+ down.type = 'button';
13844
+ up.title = 'Move up';
13845
+ down.title = 'Move down';
13846
+ up.disabled = (i === 0);
13847
+ down.disabled = (i === working.length - 1);
13848
+ up.addEventListener('click', function () {
13849
+ if (move(i, i - 1)) {
13850
+ list.children[i - 1].focus();
13851
+ }
13852
+ });
13853
+ down.addEventListener('click', function () {
13854
+ if (move(i, i + 1)) {
13855
+ list.children[i + 1].focus();
13856
+ }
13857
+ });
13858
+ row.addEventListener('keydown', function (event) {
13859
+ if (event.key === ' ' && event.target === row) {
13860
+ event.preventDefault();
13861
+ box.checked = !box.checked;
13862
+ w.on = box.checked;
13863
+ return;
13864
+ }
13865
+ let d = (event.key === 'ArrowUp') ? -1 : ((event.key === 'ArrowDown') ? 1 : 0);
13866
+ if (d === 0) {
13867
+ return;
13868
+ }
13869
+ event.preventDefault();
13870
+ if (move(i, i + d)) {
13871
+ list.children[i + d].focus();
13872
+ }
13873
+ });
13874
+ });
13875
+ }
13876
+ draw();
13877
+
13878
+ let actions = repsElement(body, 'div', 'aptx-reps-actions');
13879
+ repsButton(actions, 'All', false, function () {
13880
+ _state.labelProperties = null;
13881
+ shell.dialog.close();
13882
+ scheduleUpdate();
13883
+ });
13884
+ repsButton(actions, 'Cancel', false, function () {
13885
+ shell.dialog.close();
13886
+ });
13887
+ repsButton(actions, 'Apply', true, function () {
13888
+ _state.labelProperties = working.filter(function (w) {
13889
+ return w.on;
13890
+ }).map(function (w) {
13891
+ return w.ref;
13892
+ });
13893
+ let added = _state.labelProperties.some(function (r) {
13894
+ return before.indexOf(r) < 0;
13895
+ });
13896
+ if (added && !_state.showProperties) {
13897
+ _state.showProperties = true;
13898
+ _state.showExternalLabels = true;
13899
+ setCheckboxValue(PROPERTIES_CB, true);
13900
+ setCheckboxValue(EXTERNAL_LABEL_CB, true);
13901
+ }
13902
+ shell.dialog.close();
13903
+ search0();
13904
+ search1();
13905
+ scheduleUpdate();
13906
+ });
13907
+ shell.dialog.showModal();
13908
+ if (list.firstChild) {
13909
+ list.firstChild.focus();
13910
+ }
13911
+ }
13912
+
13596
13913
  function sequenceCbClicked() {
13597
13914
  _state.showSequence = getCheckboxValue(SEQUENCE_CB);
13598
13915
  if (_state.showSequence) {
@@ -16304,6 +16621,8 @@ function (root, d3, forester, phyloXml) {
16304
16621
  on(TAXONOMY_CB, 'click', taxonomyCbClicked);
16305
16622
 
16306
16623
  on(SEQUENCE_CB, 'click', sequenceCbClicked);
16624
+ on(PROPERTIES_CB, 'click', propertiesCbClicked);
16625
+ on(LABEL_FIELDS_BUTTON, 'click', showLabelFieldsDialog);
16307
16626
 
16308
16627
  on(CONFIDENCE_VALUES_CB, 'click', confidenceValuesCbClicked);
16309
16628
  on(SUPPORT_DOTS_CB, 'click', supportDotsCbClicked);
@@ -16832,6 +17151,10 @@ function (root, d3, forester, phyloXml) {
16832
17151
  if (_basicTreeProperties.sequences) {
16833
17152
  labels.push(makeCheckboxItem('Sequence', SEQUENCE_CB, 'to show/hide node sequence information'));
16834
17153
  }
17154
+ if (labelFields().length > 0) {
17155
+ labels.push(makeCheckboxItem('Properties', PROPERTIES_CB, 'to show/hide the nodes\' property values'
17156
+ + ' (Label fields… chooses which, and in what order)'));
17157
+ }
16835
17158
  if (_nodeLabels) {
16836
17159
  for (const [key, value] of Object.entries(_nodeLabels)) {
16837
17160
  if (value.label && value.propertyRef && value.description) {
@@ -16913,6 +17236,12 @@ function (root, d3, forester, phyloXml) {
16913
17236
 
16914
17237
  let h = '<fieldset><legend>Display Data</legend>';
16915
17238
  h = h.concat(makeCheckboxGroup('Labels', labels));
17239
+ if (labelFields().length > 0) {
17240
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel"></span>'
17241
+ + '<input type="button" class="aptx-widebtn" value="Label fields…" name="' + LABEL_FIELDS_BUTTON
17242
+ + '" id="' + LABEL_FIELDS_BUTTON + '" title="choose which properties the labels show, and in what'
17243
+ + ' order"></div>');
17244
+ }
16916
17245
  h = h.concat(makeCheckboxGroup('Nodes', nodes));
16917
17246
  h = h.concat(makeCheckboxGroup('Options', opts));
16918
17247
  h = h.concat('</fieldset>');
@@ -17237,6 +17566,7 @@ function (root, d3, forester, phyloXml) {
17237
17566
  setCheckboxValue(NODE_NAME_CB, _state.showNodeName);
17238
17567
  setCheckboxValue(TAXONOMY_CB, _state.showTaxonomy);
17239
17568
  setCheckboxValue(SEQUENCE_CB, _state.showSequence)
17569
+ setCheckboxValue(PROPERTIES_CB, _state.showProperties);
17240
17570
  setCheckboxValue(CONFIDENCE_VALUES_CB, _state.showConfidenceValues);
17241
17571
  setCheckboxValue(SUPPORT_DOTS_CB, _state.showSupportDots);
17242
17572
  syncMadValuesCheckbox();
@@ -17911,9 +18241,11 @@ function (root, d3, forester, phyloXml) {
17911
18241
  }
17912
18242
  let name = _root_const.name ? String(_root_const.name).trim() : '';
17913
18243
  // what the phylogeny itself states goes with the part: without its
17914
- // branch_length_unit a subtree saved from Div opened again as time
18244
+ // branch_length_unit a subtree saved from Div opened again as time,
18245
+ // and without its properties (the figure setting) the part opened
18246
+ // without the heat map the whole tree opens with
17915
18247
  let part = Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17916
- ['rooted', 'rerootable', 'type', 'branch_length_unit'].forEach(function (k) {
18248
+ ['rooted', 'rerootable', 'type', 'branch_length_unit', 'properties'].forEach(function (k) {
17917
18249
  if (_root_const[k] !== undefined && _root_const[k] !== null) {
17918
18250
  part[k] = _root_const[k];
17919
18251
  }
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.19.0
23
+ // v 3.20.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.20.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",