archaeopteryx 3.23.0 → 3.25.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
@@ -25,7 +25,7 @@ config key live and shows the exact config JSON to copy into your own
25
25
  `launch()` call.
26
26
 
27
27
  * [Auspice / Nextstrain JSON (Time | Div, Clock plot)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=auspice)
28
- * [Swine H1 HA1 + alignment (Nexus)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=swh1)
28
+ * [Swine H1 HA1 + alignment (Nexus; Clock plot from the years in the names)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=swh1)
29
29
  * [BEAST annotations (Nexus)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=beast)
30
30
  * [Flavivirus mature peptides (10 trees)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=flavivirus)
31
31
  * [SARS-CoV-2 time tree (calendar)](https://cmzmasek.github.io/archaeopteryx-js/demo.html?tree=sarscov2)
@@ -64,23 +64,32 @@ Archaeopteryx.js has the following dependencies:
64
64
  * d3.js (version 7): https://www.npmjs.com/package/d3
65
65
  * sax.js (1.6.1): https://www.npmjs.com/package/sax/v/1.6.1
66
66
 
67
- For **raster PNG export** (optional — the PNG entry appears in the Download
68
- menu only when `window.Canvg` is present):
69
- * canvg (4.x): https://www.npmjs.com/package/canvg — publishes ES modules
70
- only (no classic-script global build), so bridge it yourself:
71
- ```html
72
- <script type="module">
73
- import {Canvg} from 'http://path/to/canvg.js'; // a self-contained build; see below
74
- window.Canvg = Canvg;
75
- </script>
76
- ```
77
- A module script is deferred regardless of where it sits in the page, and
78
- PNG export only runs later from a Download click, so this can go anywhere
79
- before the click — it does not need to precede archaeopteryx.js's own
80
- `<script>` tag. `docs/lib/canvg.js` in this repository is one such
81
- self-contained build (npm's `canvg@4.0.3` bundled into one file, e.g. via
82
- `esm.sh/canvg@4?bundle`, with its one unnecessary Node-environment shim
83
- import removed — see the file's own header comment).
67
+ For **raster PNG export** (optional — the PNG entry, the Copy PNG button and
68
+ the PNG-scale row appear only when `window.Canvg` is present):
69
+ * canvg (4.x): https://www.npmjs.com/package/canvg. **Load it before
70
+ `launch()` is called:** the Download menu looks for it at click time, but
71
+ the Copy PNG button and the PNG-scale row are built with the control
72
+ panel at launch, so a page that provides it later gets a PNG entry and
73
+ nothing else. canvg publishes ES modules only (no classic-script global
74
+ build); this repository ships two ready-made files:
75
+ * `docs/lib/canvg.global.js` — a classic-script build for a plain
76
+ `<script src="canvg.global.js"></script>`, or for one more file in a
77
+ site's concatenated bundle; it defines `window.Canvg` itself. This is
78
+ what the demo pages use. (Generated from `canvg.js` by
79
+ `test/make_canvg_global.js`; a unit test keeps the two in step.)
80
+ * `docs/lib/canvg.js` — the self-contained ES module build (npm's
81
+ `canvg@4.0.3` bundled into one file via `esm.sh/canvg@4?bundle`, with
82
+ its one unnecessary Node-environment shim import removed — see the
83
+ file's own header), for a page that prefers a module bridge of its own:
84
+ ```html
85
+ <script type="module">
86
+ import {Canvg} from 'http://path/to/canvg.js';
87
+ window.Canvg = Canvg;
88
+ </script>
89
+ ```
90
+ A module script runs after the document is parsed, so a page that calls
91
+ `launch()` from an inline script during parsing must use the classic
92
+ build instead.
84
93
 
85
94
  For **vector PDF export** (optional — the PDF entry appears in the Download
86
95
  menu only when both are loaded before archaeopteryx.js):
@@ -933,11 +942,28 @@ ancestors' points, each at its own date and divergence; the panel opens
933
942
  without them, since the line is the tips' and a deep root would crowd them
934
943
  into a corner.
935
944
 
936
- The button is offered on a tree that has **Time | Div** and whose line can
937
- be drawn: three tips or more, not all on one date. A tree sampled at one
938
- moment has no slope to estimate. (A tree whose branch lengths are divergence
939
- and whose tips alone are dated — the tree a root-to-tip plot is run on
940
- *before* dating — has no plot yet.)
945
+ The button is offered on two kinds of tree, whenever the line can be drawn
946
+ (three tips or more, not all on one date; a tree sampled at one moment has
947
+ no slope to estimate):
948
+
949
+ - a tree that has **Time | Div**: every node is dated and has a divergence,
950
+ so the ancestors can be shown too;
951
+ - a tree whose **branch lengths are its divergence and whose tips alone are
952
+ dated** — the tree a root-to-tip plot is run on *before* dating, TempEst's
953
+ case. A tip's divergence is the sum of the branch lengths from the root;
954
+ the plot has tips only (**Internal nodes** is not offered, since no
955
+ ancestor is dated); **Root date, in the tree** says `none`, since the tree
956
+ states no root date, and **Rate** is in the tree's own branch length unit,
957
+ where it states one. A tip's date is what the file states for it (a
958
+ phyloXML `<date>`, a `num_date` annotation) or, failing that, the date in
959
+ its **name**, as TempEst reads it: the rightmost date the label carries
960
+ (`A/swine/Iowa/A02524571/2020|1A.3.3.3` is 2020; a year alone is taken as
961
+ mid-year, 2020.5; a day/month-ambiguous `03/04/2020` is read day first),
962
+ and the panel's note says how many names it read and how many were
963
+ ambiguous. An ancestor is never dated by its name. Every tip must be
964
+ dated, by the file or by its name, and every branch must have a length: a
965
+ tree with a gap is not offered the plot, rather than plotted with tips
966
+ missing.
941
967
 
942
968
  # forester.js
943
969
  forester.js is a general suite for dealing with phylogenetic trees.
@@ -1039,8 +1065,8 @@ Both entry points take **exactly** the arguments shown — a call with the old
1039
1065
  trailing arguments (the separate settings bag, `nodeVisualizations`,
1040
1066
  `nodeLabels`, `specialVisualizations`, or the positional Newick parse
1041
1067
  options) **throws** with a message saying where each one went: everything now
1042
- lives in the **one config object** (`nodeLabels` and
1043
- `internalNumericLabels` are config keys). `config` itself is optional — `archaeopteryx.launch('#phylogram1',
1068
+ lives in the **one config object** (`internalNumericLabels` is a config key;
1069
+ `nodeLabels` is retired, see the deprecated keys below). `config` itself is optional — `archaeopteryx.launch('#phylogram1',
1044
1070
  tree)` works.
1045
1071
 
1046
1072
  `container` is a **CSS selector or the DOM element itself** (frameworks hand
@@ -1534,14 +1560,13 @@ copy-pastable JSON.
1534
1560
  | `onViewChange` | `null` | `function (state, encoded)`, called once per settled redraw when the view changed: `state` as `getViewState()` returns it, `encoded` its hash-ready string. The demo pages write it into the URL. |
1535
1561
  | `searchBinitialValue` | `null` | Prefill search box B. |
1536
1562
  | `enableVisualizations` | `true` | Offer the Color / Shape visualizations (which fields they cover is decided from the tree). |
1537
- | `initialVisualization` | `null` | The visualization to open with, by its Color-menu name (e.g. `'Host'`; case-insensitive). A name the tree cannot honour logs a console warning and falls back to the automatic choice, so a site-wide value is safe on trees without that field. Default: Archaeopteryx.js picks the most informative field itself. |
1563
+ | `initialVisualization` | `null` | The visualization to open with, by its Color-menu name (e.g. `'Host'`; case-insensitive). A name the tree cannot honour logs a console warning and falls back to the automatic choice, so a site-wide value is safe on trees without that field. A name the tree *can* honour is always honoured, but when it is a field the automatic choice would not have opened with -- more than 20 values, or a value on fewer than two thirds of the tips -- a console warning says so and names the field the viewer would have chosen. Default: Archaeopteryx.js picks the most informative field itself. |
1538
1564
  | `visualizationsLegendXpos` | `254` | Legend position, x. |
1539
1565
  | `visualizationsLegendYpos` | `30` | Legend position, y. |
1540
1566
  | `enableDownloads` | `true` | Offer the download buttons. |
1541
1567
  | `pngExportScale` | `4` | The PNG resolution the Download section starts on: 2, 4 or 8 (any other value is snapped to the nearest, with a console warning). |
1542
1568
  | `nhExportWriteConfidences` | `true` | Write confidences into exported Newick. |
1543
1569
  | `internalNumericLabels` | `'auto'` | Newick / Nexus parsing: how a bare numeric internal label (`)100:0.05`) is read. `'auto'` reads them as confidence values only when *every* internal label looks like support; `'confidence'` reads every numeric label as one, whatever its value; `'label'` keeps them as names. Replaces `nhConfidenceValuesAsInternalNames` (still accepted, with a warning; its `true` maps to `'confidence'`). |
1544
- | `nodeLabels` | `null` | Custom label-field checkboxes: `{key: {label, description, propertyRef, showButton, selected}}` — each adds a panel checkbox labelling nodes with the named property's value. (Was `launch()`'s sixth positional argument.) |
1545
1570
  | `enableSubtreeDeletion` | `true` | Offer node / subtree deletion in the node menu. |
1546
1571
  | `enableAccessToDatabases` | `true` | Offer the “Access DB” link in the node menu. |
1547
1572
  | `enableManualNodeSelection` | `false` | Add the Select/Deselect entries to the node menu. |
@@ -1564,7 +1589,7 @@ whatever it names.
1564
1589
 
1565
1590
  ### Accepted, with a warning
1566
1591
 
1567
- Four keys are neither current nor removed: they are accepted so an existing
1592
+ Five keys are neither current nor removed: they are accepted so an existing
1568
1593
  embed keeps working, and warn on the console.
1569
1594
 
1570
1595
  | Key | What happens |
@@ -1573,6 +1598,7 @@ embed keeps working, and warn on the console.
1573
1598
  | `nhConfidenceValuesInBrackets` | Retired: ignored. It gated whether `[95]` is read as a confidence, but setting it `false` never reinterpreted the bracket — it *discarded* it, so the option's only effect was to throw support values away. A bracket that is not a number is a Newick comment and was ignored either way, and NHX / BEAST blobs go through a different path. Bracketed values are now always read as confidences. |
1574
1599
  | `showTimeAxis` | Translated to `showScaleAxis`, value unchanged: the switch now also draws a distance axis on a tree that is not in time. An explicit `showScaleAxis` always wins. |
1575
1600
  | `timeAxisGrid` | Translated to `showScaleGrid`, value unchanged. |
1601
+ | `nodeLabels` | Retired in 3.25.0: ignored, with a warning. The custom label checkboxes it declared predate the Metadata checkbox and its "Metadata fields…" chooser, which offer exactly the fields the tree carries; a fixed site-wide list put checkboxes for absent fields on every tree. `labelProperties` sets the starting fields where the automatic choice is not wanted. Removed in a later release. |
1576
1602
 
1577
1603
  ### What replaced the rest
1578
1604
 
@@ -1717,6 +1743,21 @@ All 118 of them, alphabetically:
1717
1743
  | `visualizationsLegendXposOrig` | Internal bookkeeping; set visualizationsLegendXpos. |
1718
1744
  | `visualizationsLegendYposOrig` | Internal bookkeeping; set visualizationsLegendYpos. |
1719
1745
 
1746
+ ### Removed from `forester` — calling these throws `TypeError`
1747
+
1748
+ 3.0.0 also dropped twelve exports of `forester.js` that the viewer no longer
1749
+ used. They are gone, not stubbed, so a 2.x embedding that calls one fails with
1750
+ `TypeError: forester.<name> is not a function` — inside its own parse step, if
1751
+ that is where it called it.
1752
+
1753
+ | Function | Why, and what to do instead |
1754
+ | --- | --- |
1755
+ | `collectPropertyRefs` | Nothing to call. The viewer derives the Color-by candidates (`forester.visualizationCandidates(tree)`, below) and the label-field chooser's list from the tree itself, and substitutes a readable name property (`genome_name`) for identifier-like tip names on its own. A 2.x embedding that built `nodeLabels` from this function's result should delete that block together with the `nodeLabels` key; `labelProperties` sets the starting label fields where the automatic choice is not wanted. |
1756
+ | `collapse`, `unCollapse` | The subtree-collapse feature was removed; they were its only writers. |
1757
+ | `getChildren` | Only meant something while collapse existed; read `node.children`. |
1758
+ | `findByTaxonomyCode`, `findByTaxonomyScientificName` | Superseded by the search (the panel's search fields). |
1759
+ | `calcAverageTreeHeight`, `calcMaxDepth`, `calcBranchLengthSimpleStatistics`, `removeMaxBranchLength`, `isHasNodeData`, `getOneDistinctTaxonomy` | Never used by the viewer; nothing replaces them. |
1760
+
1720
1761
  ## The automatic visualization system (developer spec)
1721
1762
 
1722
1763
  This section, together with **Value grouping** below, is written to be
@@ -2599,15 +2640,25 @@ deliberate difference.)
2599
2640
  ### The clock plot
2600
2641
 
2601
2642
  **Model** (`forester.js`, pure). `forester.clockPlotKind(phy)` answers
2602
- `'divergence'` or `null`: the tree has Time and Div
2603
- (`forester.hasTimeAndDivergence`) and three tips or more, not all on one
2604
- date. It is asked of the **tree**, never of the view. A second kind,
2605
- `'root-to-tip'` — divergence branch lengths, dated tips, no dated ancestors —
2606
- is described beside the function and not built. `forester.clockPlotData(phy,
2607
- view)` returns `{kind, forward, unit, fromRates, points, root, fit}`: a point
2643
+ `'divergence'`, `'root-to-tip'` or `null`: `'divergence'` where the tree has
2644
+ Time and Div (`forester.hasTimeAndDivergence`); `'root-to-tip'` where every
2645
+ tip is dated (by the file, or by the date in its name:
2646
+ `forester.parseTipLabelDate`), no ancestor is, every branch states a length
2647
+ and no branch states a clock rate (a clock-model tree's lengths are time) — and in
2648
+ both cases three tips or more, not all on one date. It is asked of the
2649
+ **tree**, never of the view. `forester.clockPlotData(phy, view)` returns
2650
+ `{kind, forward, unit, divUnit, fromRates, points, root, fit}`: a point
2608
2651
  `{node, date, div, tip}` per node of the view (a tip also carries its
2609
- `residual`), `forward` false where the dates are ages, `fromRates` true where
2610
- the divergence is time × clock rate, and `fit` =
2652
+ `residual`; on a root-to-tip tree the tips only, their `div` the sum of the
2653
+ branch lengths from the tree's root), `forward` false where the dates are
2654
+ ages (on a root-to-tip tree: where their unit is geologic), `divUnit` the
2655
+ divergence's unit (`subs/site` on a Time | Div tree, a root-to-tip tree's
2656
+ stated branch length unit, or `null`), `fromRates` true where the divergence
2657
+ is time × clock rate, `calendar` true where the dates are calendar years
2658
+ (their unit says so, or they were read off the tips' names), `fromNames` and
2659
+ `ambiguousNames` how many tips a root-to-tip tree dates by their names and
2660
+ how many of those are day/month-ambiguous (read day first), `root.date`
2661
+ `null` on a root-to-tip tree, and `fit` =
2611
2662
  `forester.clockRegression(dates, divergences)` over the tips — ordinary least
2612
2663
  squares about the means, `{n, slope, intercept, r2}` — plus `rate` (the slope
2613
2664
  in the direction time runs) and `rootDate` (where the line reaches the view
@@ -29,9 +29,13 @@ export interface Phylogeny extends PhylogenyNode {
29
29
 
30
30
  export type Layout = 'rectangular' | 'circular' | 'unrooted';
31
31
 
32
- /** One custom label-field entry (the config's nodeLabels values).
32
+ /** @deprecated Retired in 3.25.0 with `nodeLabels`: accepted with a console
33
+ * warning, no effect; removed in a later release. The Metadata checkbox and
34
+ * its "Metadata fields…" chooser offer the tree's own fields.
33
35
  *
34
- * Two modes:
36
+ * One custom label-field entry (the config's nodeLabels values).
37
+ *
38
+ * Two modes (3.0.0 to 3.24.0):
35
39
  * - a PANEL CHECKBOX needs label, description AND propertyRef set, plus
36
40
  * showButton: true -- omit showButton (or any of the three) and no
37
41
  * checkbox appears;
@@ -85,6 +89,10 @@ export interface ArchaeopteryxConfig {
85
89
  nhConfidenceValuesAsInternalNames?: boolean;
86
90
  nhConfidenceValuesInBrackets?: boolean;
87
91
  nhExportWriteConfidences?: boolean;
92
+ /** @deprecated Retired in 3.25.0: accepted with a console warning, no
93
+ * effect; removed in a later release. The Metadata checkbox and its
94
+ * "Metadata fields…" chooser offer the tree's own fields; labelProperties
95
+ * sets the starting fields. */
88
96
  nodeLabels?: Record<string, NodeLabelSpec> | null;
89
97
  /** Called once per settled redraw when the view changed: the state as
90
98
  * getViewState() returns it, and its hash-ready string. */
@@ -176,8 +184,8 @@ export interface ViewState {
176
184
  shapeBy?: string;
177
185
  /** The panel's checked boxes: name, taxonomy, sequence, confidence,
178
186
  * madValues, branchLength, external, internal, nodeEvents, branchEvents,
179
- * supportDots, shortNames, autoHide, visualizations, visualStyles, and
180
- * custom:<key> for a nodeLabels checkbox. */
187
+ * supportDots, shortNames, autoHide, visualizations, visualStyles. (A
188
+ * custom:<key> flag from a 3.24.0 hash is read and ignored.) */
181
189
  show?: string[];
182
190
  font?: number;
183
191
  node?: number;
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.23.0
23
+ // v 3.25.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.23.0';
106
+ const VERSION = '3.25.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';
@@ -539,7 +539,6 @@ function (root, d3, forester, phyloXml) {
539
539
  let _container = null; // the resolved container ELEMENT (never a selector string)
540
540
  let _intervalId = 0;
541
541
  let _maxLabelLength = 0;
542
- let _nodeLabels = null;
543
542
  let _state = null; // live display state: what the control panel writes to
544
543
  let _root = null;
545
544
  let _root_const = null;
@@ -606,6 +605,7 @@ function (root, d3, forester, phyloXml) {
606
605
  const DOMAIN_SCALE_HEADROOM = 0.9; // drawn length over reserved length
607
606
  const DOMAIN_BOX_MIN_H = 6;
608
607
  const DOMAIN_BOX_MAX_H = 16;
608
+ const LEGEND_MAX_ROWS = 20; // value rows a legend lists before "n more"; the rest fold
609
609
  const DOMAIN_BACKBONE_COLOR = 'rgb(100,100,100)'; // the same in both themes and in exports
610
610
  const DOMAIN_SHADOW_COLOR = 'rgb(8,18,21)';
611
611
  const DOMAIN_SHADOWS = [[0.4, 0.7, 40], [0.9, 1.5, 28], [1.6, 2.5, 17]]; // dx, dy, alpha of 255, in drawing order
@@ -1948,7 +1948,32 @@ function (root, d3, forester, phyloXml) {
1948
1948
  // an explicit request is honoured even for a wide field (21+
1949
1949
  // values) the automatic path would only offer, never impose;
1950
1950
  // autoColorId too, so it survives subtree views like the
1951
- // automatic pick does
1951
+ // automatic pick does.
1952
+ //
1953
+ // Honoured, but not in silence. A request for a field the
1954
+ // automatic choice would not open with -- wide, or sparse (on
1955
+ // fewer than 2/3 of the tips; it opens only when nothing denser
1956
+ // exists) -- says so in the console and names the field the
1957
+ // viewer would have chosen. BV-BRC's virus trees carried a
1958
+ // site-wide 'Host' from the influenza days: family trees opened
1959
+ // coloured by 39 to 168 hosts while Genus (4 to 15 values) sat
1960
+ // in the file, and nothing anywhere said the viewer had been
1961
+ // overruled (Christian, 2026-10-08: a console warning when a
1962
+ // sub-optimal visualization is forced is needed). A request for
1963
+ // a clean field that merely ranks below the automatic pick is a
1964
+ // site's taste and warns nothing; nor does a sparse field that
1965
+ // is the automatic pick itself.
1966
+ let auto = forester.openingVisualization(_vis.candidates);
1967
+ if ((requested.wide || requested.sparse) && !(auto && auto.id === requested.id)) {
1968
+ let why = requested.wide
1969
+ ? requested.values.length + ' distinct values on this tree, more than the '
1970
+ + LEGEND_MAX_ROWS + ' the legend lists'
1971
+ : 'a value on ' + requested.coverage + ' of the tree\'s ' + requested.total + ' tips';
1972
+ console.warn(WARNING + ': initialVisualization "' + _settings.initialVisualization
1973
+ + '" names a field with ' + why + '; left to itself the viewer would '
1974
+ + (auto ? 'open with "' + auto.label + '" (' + auto.values.length + ' values)'
1975
+ : 'open uncoloured'));
1976
+ }
1952
1977
  _vis.autoColorId = requested.id;
1953
1978
  _vis.colorId = requested.id;
1954
1979
  _state.showVisualizations = true;
@@ -2144,6 +2169,7 @@ function (root, d3, forester, phyloXml) {
2144
2169
  }
2145
2170
  dialog.addEventListener('close', close);
2146
2171
 
2172
+ let placed = false; // set by place() once the picker has been put somewhere
2147
2173
  render(false);
2148
2174
  dialog.show();
2149
2175
  place();
@@ -2151,40 +2177,67 @@ function (root, d3, forester, phyloXml) {
2151
2177
  // Beside the legend it edits, never on top of it -- a picker that hides
2152
2178
  // the swatch it is changing defeats the live preview.
2153
2179
  //
2154
- // Re-measured rather than remembered, for two reasons: every redraw
2155
- // DESTROYS and rebuilds the legend group, so a held reference goes
2156
- // stale and measures zero; and the card grows when the [reset colors]
2157
- // chip appears on the first pick, which would leave a
2158
- // positioned-once picker sitting over the counts. It only MOVES when
2159
- // it would otherwise overlap, so the dialog does not chase the legend
2160
- // around while the user drags.
2180
+ // Placed OUTRIGHT the first time, viewport-fixed. A non-modal <dialog>
2181
+ // appended to the body sits at its static position, the end of the
2182
+ // document: on the demo page that is the top of the window (the
2183
+ // container is out of flow), on a page that flows, such as BV-BRC's,
2184
+ // it is below everything on it. Until 2026-10-08 the picker moved
2185
+ // only when that spot overlapped the card, so on BV-BRC it opened
2186
+ // under the site's own bars, off the bottom of the window
2187
+ // (Christian's screenshot). Fixed positioning also keeps a scrolling
2188
+ // page from carrying it away.
2189
+ //
2190
+ // Re-measured rather than remembered afterwards, for two reasons:
2191
+ // every redraw DESTROYS and rebuilds the legend group, so a held
2192
+ // reference goes stale and measures zero; and the card grows when the
2193
+ // [reset colors] chip appears on the first pick, which would leave a
2194
+ // positioned-once picker sitting over the counts. Once placed it only
2195
+ // MOVES when it would otherwise overlap, so the dialog does not chase
2196
+ // the legend around while the user drags.
2161
2197
  function place() {
2162
2198
  let el = anchorClass ? document.querySelector('g.' + anchorClass) : null;
2163
2199
  let box = el ? el.getBoundingClientRect() : null;
2164
- if (!box || !box.width) {
2165
- return;
2200
+ if (box && !box.width) {
2201
+ box = null;
2166
2202
  }
2167
- let now = dialog.getBoundingClientRect();
2168
- let overlaps = now.width > 0
2169
- && !(now.left >= box.right || now.right <= box.left
2170
- || now.top >= box.bottom || now.bottom <= box.top);
2171
- if (now.width > 0 && !overlaps) {
2172
- return;
2203
+ if (placed) {
2204
+ if (!box) {
2205
+ return;
2206
+ }
2207
+ let now = dialog.getBoundingClientRect();
2208
+ let overlaps = now.width > 0
2209
+ && !(now.left >= box.right || now.right <= box.left
2210
+ || now.top >= box.bottom || now.bottom <= box.top);
2211
+ if (!overlaps) {
2212
+ return;
2213
+ }
2173
2214
  }
2174
2215
  let w = dialog.offsetWidth;
2175
2216
  let h = dialog.offsetHeight;
2176
- // to the right of the card, or to its left when that would leave
2177
- // the viewport
2178
- let left = box.right + 10;
2179
- if (left + w > window.innerWidth - 6) {
2180
- left = box.left - w - 10;
2217
+ let left;
2218
+ let top;
2219
+ if (box) {
2220
+ // to the right of the card, or to its left when that would
2221
+ // leave the viewport
2222
+ left = box.right + 10;
2223
+ if (left + w > window.innerWidth - 6) {
2224
+ left = box.left - w - 10;
2225
+ }
2226
+ top = box.top;
2227
+ } else {
2228
+ // no card to sit beside (the view has no swatch for the value):
2229
+ // the tree area's top left corner, inside the window
2230
+ let area = _container ? _container.getBoundingClientRect() : null;
2231
+ left = area ? area.left + 14 : 10;
2232
+ top = area ? Math.max(area.top, 0) + 14 : 10;
2181
2233
  }
2182
2234
  setStyles(dialog, {
2183
2235
  'position': 'fixed',
2184
2236
  'margin': '0',
2185
2237
  'left': Math.max(6, Math.min(window.innerWidth - w - 6, left)) + 'px',
2186
- 'top': Math.max(6, Math.min(window.innerHeight - h - 6, box.top)) + 'px'
2238
+ 'top': Math.max(6, Math.min(window.innerHeight - h - 6, top)) + 'px'
2187
2239
  });
2240
+ placed = true;
2188
2241
  }
2189
2242
  setTimeout(function () {
2190
2243
  document.addEventListener('pointerdown', onOutside, true);
@@ -2521,7 +2574,6 @@ function (root, d3, forester, phyloXml) {
2521
2574
  // is then just its "no value" row
2522
2575
  const isRange = kind === 'color' && colorModeOf(vis) === 'range' && vis.values.length > 0;
2523
2576
 
2524
- const LEGEND_MAX_ROWS = 20;
2525
2577
  let rows = [];
2526
2578
  let hidden = 0;
2527
2579
  if (!isRange) {
@@ -4725,25 +4777,6 @@ function (root, d3, forester, phyloXml) {
4725
4777
  l = append(l, labelPropertiesOf(phynode));
4726
4778
  }
4727
4779
 
4728
- if (_nodeLabels && phynode.properties) {
4729
- const props_length = phynode.properties.length;
4730
- if (props_length > 0) {
4731
- for (const value of Object.values(_nodeLabels)) {
4732
- if (value.selected === true && value.propertyRef) {
4733
- let prop_text = '';
4734
- for (let pm = 0; pm < props_length; ++pm) {
4735
- if (phynode.properties[pm].ref === value.propertyRef && phynode.properties[pm].datatype === 'xsd:string' && forester.isNodeScopedProperty(phynode.properties[pm])) {
4736
- if (prop_text.length > 0) {
4737
- prop_text += ', '
4738
- }
4739
- prop_text += phynode.properties[pm].value;
4740
- }
4741
- }
4742
- l = append(l, prop_text);
4743
- }
4744
- }
4745
- }
4746
- }
4747
4780
 
4748
4781
 
4749
4782
  if (_state.showDistributions && phynode.distributions && phynode.distributions.length > 0) {
@@ -5522,6 +5555,21 @@ function (root, d3, forester, phyloXml) {
5522
5555
  return v;
5523
5556
  },
5524
5557
  note: 'the grid follows the scale axis, time or distance'
5558
+ },
5559
+ // Retired in 3.25.0 (Christian, 2026-10-08: "go with the deprecation").
5560
+ // The custom label checkboxes it declared -- a site's fixed list of
5561
+ // property refs, one panel checkbox each, labelling nodes with the
5562
+ // property's value -- predate the Metadata checkbox and its
5563
+ // "Metadata fields…" chooser, which offer exactly the fields the tree
5564
+ // carries and replace identifier-like tip names with a name property
5565
+ // on their own. On BV-BRC the list, written for influenza, put eight
5566
+ // checkboxes on every virus tree, six of them for fields the tree
5567
+ // did not have. The checkbox code went with the key (the label
5568
+ // text, the panel rows, the view state's custom:<key> flags, which
5569
+ // an old hash may still carry and the reader now ignores). Accepted
5570
+ // with this warning for one release cycle, then REMOVED_CONFIG.
5571
+ nodeLabels: {
5572
+ note: 'the Metadata checkbox and its "Metadata fields…" chooser offer the tree\'s own fields; labelProperties sets the starting fields'
5525
5573
  }
5526
5574
  };
5527
5575
 
@@ -5721,7 +5769,6 @@ function (root, d3, forester, phyloXml) {
5721
5769
  'internalNumericLabels',
5722
5770
  'ladderizeTree',
5723
5771
  'nhExportWriteConfidences',
5724
- 'nodeLabels',
5725
5772
  'onViewChange',
5726
5773
  'panelDensity',
5727
5774
  'pngExportScale',
@@ -6321,19 +6368,6 @@ function (root, d3, forester, phyloXml) {
6321
6368
  } else if (typeof _settings.onViewChange !== 'function') {
6322
6369
  throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
6323
6370
  }
6324
- if (_settings.nodeLabels === undefined) {
6325
- _settings.nodeLabels = null;
6326
- } else if (_settings.nodeLabels !== null && typeof _settings.nodeLabels !== 'object') {
6327
- throw new Error('nodeLabels must be an object (see the README) or null');
6328
- }
6329
- if (_settings.nodeLabels) {
6330
- // a DEEP copy: the viewer writes runtime state into these specs
6331
- // (the checkbox element id, the live checked state), and the
6332
- // caller's config object must stay declarative -- an embedder
6333
- // reusing one nodeLabels constant across launches must not
6334
- // inherit the previous session's checkbox state
6335
- _settings.nodeLabels = JSON.parse(JSON.stringify(_settings.nodeLabels));
6336
- }
6337
6371
  if (_settings.pngExportScale === undefined) {
6338
6372
  _settings.pngExportScale = 4;
6339
6373
  } else if (typeof _settings.pngExportScale !== 'number'
@@ -6593,7 +6627,7 @@ function (root, d3, forester, phyloXml) {
6593
6627
  + ' trailing arguments were removed. The separate settings bag merged into the one'
6594
6628
  + ' config object; "nodeVisualizations" and "specialVisualizations" are gone for good'
6595
6629
  + ' (visualizations are derived automatically from the tree itself); "nodeLabels"'
6596
- + ' moved into the config as its "nodeLabels" key.');
6630
+ + ' is retired (the Metadata checkbox offers the tree\'s own fields).');
6597
6631
  }
6598
6632
  if (phylo === undefined || phylo === null) {
6599
6633
  throw new Error(ERROR + 'input tree is undefined or null');
@@ -6684,9 +6718,8 @@ function (root, d3, forester, phyloXml) {
6684
6718
  .on('zoom', zoom);
6685
6719
  _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
6686
6720
 
6687
- // Every launch starts from a clean slate: _vis is rebuilt below and
6688
- // _nodeLabels reassigned, never inherited from a previous launch in
6689
- // the same page.
6721
+ // Every launch starts from a clean slate: _vis is rebuilt below, never
6722
+ // inherited from a previous launch in the same page.
6690
6723
  _radialRotation = 0;
6691
6724
  _radialLabelsHorizontal = false;
6692
6725
  _msaColOffset = 0;
@@ -6704,7 +6737,6 @@ function (root, d3, forester, phyloXml) {
6704
6737
  // candidate ranking over 13k tips before the card could even paint.
6705
6738
  checkLayoutValue(cfg.state ? cfg.state.layout : undefined);
6706
6739
  initializeSettings(cfg.settings);
6707
- _nodeLabels = _settings.nodeLabels || null;
6708
6740
 
6709
6741
  // whatever a previous launch left in this container (or in the
6710
6742
  // container before it) goes now, so the old tree does not sit under
@@ -9896,20 +9928,6 @@ function (root, d3, forester, phyloXml) {
9896
9928
  return found;
9897
9929
  }
9898
9930
 
9899
- // The config's custom label checkboxes (nodeLabels) that are offered
9900
- function customLabelEntries() {
9901
- let out = [];
9902
- if (_nodeLabels) {
9903
- Object.keys(_nodeLabels).forEach(function (key) {
9904
- let v = _nodeLabels[key];
9905
- if (v && v.label && v.showButton === true && v.propertyRef && v.description) {
9906
- out.push({key: key, entry: v});
9907
- }
9908
- });
9909
- }
9910
- return out;
9911
- }
9912
-
9913
9931
  function searchStateOf(idx) {
9914
9932
  let spec = currentSearchSpec(idx);
9915
9933
  let value = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
@@ -9957,11 +9975,6 @@ function (root, d3, forester, phyloXml) {
9957
9975
  }).map(function (f) {
9958
9976
  return f[0];
9959
9977
  });
9960
- customLabelEntries().forEach(function (c) {
9961
- if (c.entry.selected === true) {
9962
- s.show.push('custom:' + c.key);
9963
- }
9964
- });
9965
9978
  s.font = _state.externalNodeFontSize;
9966
9979
  s.node = _state.nodeSizeDefault;
9967
9980
  s.branch = _state.branchWidthDefault;
@@ -10107,9 +10120,6 @@ function (root, d3, forester, phyloXml) {
10107
10120
  VIEW_SHOW_FLAGS.forEach(function (f) {
10108
10121
  _state[f[2]] = on.has(f[0]);
10109
10122
  });
10110
- customLabelEntries().forEach(function (c) {
10111
- c.entry.selected = on.has('custom:' + c.key);
10112
- });
10113
10123
  }
10114
10124
  if (isFinite(s.font)) {
10115
10125
  setFontSizes(Number(s.font));
@@ -10259,11 +10269,6 @@ function (root, d3, forester, phyloXml) {
10259
10269
  VIEW_SHOW_FLAGS.forEach(function (f) {
10260
10270
  setCheckboxValue(f[1], _state[f[2]] === true);
10261
10271
  });
10262
- customLabelEntries().forEach(function (c) {
10263
- if (c.entry.cb_id) {
10264
- setCheckboxValue(c.entry.cb_id, c.entry.selected === true);
10265
- }
10266
- });
10267
10272
  setCheckboxValue(MSA_CB, _state.showMsa === true);
10268
10273
  setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
10269
10274
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
@@ -11171,10 +11176,12 @@ function (root, d3, forester, phyloXml) {
11171
11176
  row.appendChild(input);
11172
11177
  row.appendChild(span);
11173
11178
  options.appendChild(row);
11174
- return input;
11179
+ return row;
11175
11180
  };
11176
11181
  check('Regression line', 'line', 'to show/hide the least-squares line through the tips');
11177
- check('Internal nodes', 'internal', 'to show/hide the internal nodes: their dates were inferred,'
11182
+ // on a root-to-tip tree no ancestor is dated: nothing to show, so the
11183
+ // choice is not offered (layClockPlot hides it by the data's kind)
11184
+ let internalRow = check('Internal nodes', 'internal', 'to show/hide the internal nodes: their dates were inferred,'
11178
11185
  + ' and they take no part in the line');
11179
11186
  let deselect = document.createElement('button');
11180
11187
  deselect.type = 'button';
@@ -11197,7 +11204,7 @@ function (root, d3, forester, phyloXml) {
11197
11204
  body.appendChild(note);
11198
11205
 
11199
11206
  _clockPlot = {dialog: shell.dialog, svg: svg, axes: axes, internal: internal, line: line, tips: tips,
11200
- ring: ring, box: box, stats: stats, note: note, deselect: deselect,
11207
+ ring: ring, box: box, stats: stats, note: note, deselect: deselect, internalRow: internalRow,
11201
11208
  data: null, marks: [], seen: null, hover: null, lays: 0, x: null, y: null};
11202
11209
  // the shell's close control, and anything else that closes the dialog
11203
11210
  let closed = function () {
@@ -11408,7 +11415,9 @@ function (root, d3, forester, phyloXml) {
11408
11415
  }
11409
11416
 
11410
11417
  function layClockPlot(cp, data) {
11411
- let calendar = data.forward && !!_timeInfo && _timeInfo.type === 'calendar';
11418
+ let calendar = data.calendar;
11419
+ let rootToTip = data.kind === 'root-to-tip';
11420
+ cp.internalRow.style.display = rootToTip ? 'none' : '';
11412
11421
  let shown = data.points.filter(function (p) {
11413
11422
  return p.tip || _clockPlotOptions.internal;
11414
11423
  });
@@ -11464,7 +11473,7 @@ function (root, d3, forester, phyloXml) {
11464
11473
  seg('aptx-clock-frame', left, top, left, bottom);
11465
11474
  let unit = data.unit && !calendar ? ' (' + data.unit + ')' : '';
11466
11475
  text('aptx-clock-title', (data.forward ? 'Date' : 'Age') + unit, (left + right) / 2, CLOCK_PLOT_H - 6, 'middle');
11467
- let yt = text('aptx-clock-title', 'Divergence (subs/site)', 0, 0, 'middle');
11476
+ let yt = text('aptx-clock-title', 'Divergence' + (data.divUnit ? ' (' + data.divUnit + ')' : ''), 0, 0, 'middle');
11468
11477
  yt.setAttribute('transform', 'translate(12,' + ((top + bottom) / 2) + ') rotate(-90)');
11469
11478
 
11470
11479
  // --- the points: ancestors under the line, tips over it
@@ -11506,12 +11515,13 @@ function (root, d3, forester, phyloXml) {
11506
11515
  let tipCount = data.points.reduce(function (n, p) {
11507
11516
  return n + (p.tip ? 1 : 0);
11508
11517
  }, 0);
11509
- let per = ' subs/site per ' + (calendar ? 'year' : (data.unit || 'unit of time'));
11518
+ let per = (data.divUnit ? ' ' + data.divUnit : '') + ' per ' + (calendar ? 'year' : (data.unit || 'unit of time'));
11510
11519
  if (fit) {
11511
11520
  treePropRow(cp.stats, 'Rate', clockNumber(fit.rate) + per);
11512
11521
  treePropRow(cp.stats, 'Root date, by the line', fit.rootDate === null
11513
11522
  ? 'none: divergence does not rise with time' : clockDate(fit.rootDate, calendar));
11514
- treePropRow(cp.stats, 'Root date, in the tree', clockDate(data.root.date, calendar));
11523
+ treePropRow(cp.stats, 'Root date, in the tree', data.root.date === null
11524
+ ? 'none: only the tips are dated' : clockDate(data.root.date, calendar));
11515
11525
  treePropRow(cp.stats, 'R²', fit.r2 === null ? 'none: every tip has one divergence' : clockNumber(fit.r2));
11516
11526
  } else {
11517
11527
  treePropRow(cp.stats, 'Line', 'none: it takes three tips, not all on one date');
@@ -11520,6 +11530,15 @@ function (root, d3, forester, phyloXml) {
11520
11530
  cp.note.textContent = (data.fromRates
11521
11531
  ? 'Divergence here is each branch’s time × its clock rate: the points show the model’s rates, not a measurement of their own. '
11522
11532
  : '')
11533
+ + (rootToTip
11534
+ ? 'Divergence here is each tip’s distance from the root along the branch lengths the file states; only the tips are dated. '
11535
+ : '')
11536
+ + (data.fromNames > 0
11537
+ ? (data.fromNames === 1 ? 'One tip is dated by its name' : countNumber(data.fromNames) + ' tips are dated by their names')
11538
+ + ' (a year alone is taken as mid-year'
11539
+ + (data.ambiguousNames > 0 ? '; ' + countNumber(data.ambiguousNames) + ' with day and month ambiguous, read day first' : '')
11540
+ + '). '
11541
+ : '')
11523
11542
  + 'The line is fitted to the tips only. Tips share ancestry, so R² describes the fit and is no test.'
11524
11543
  + ' Click a point to select its node; drag to select the tips in a box.';
11525
11544
  // what was laid down, for the browser harness: read here, where it is
@@ -15366,22 +15385,6 @@ function (root, d3, forester, phyloXml) {
15366
15385
  }
15367
15386
 
15368
15387
 
15369
- function customCbClicked(cb_id) {
15370
- if (_nodeLabels) {
15371
- const cb_value = getCheckboxValue(cb_id);
15372
- for (const value of Object.values(_nodeLabels)) {
15373
- if (value.label && value.showButton === true && value.propertyRef && value.description) {
15374
- if (value.cb_id === cb_id) {
15375
- value.selected = cb_value;
15376
- }
15377
- }
15378
- }
15379
- search0();
15380
- search1();
15381
- update();
15382
- }
15383
- }
15384
-
15385
15388
  function taxonomyCbClicked() {
15386
15389
  _state.showTaxonomy = getCheckboxValue(TAXONOMY_CB);
15387
15390
  if (_state.showTaxonomy) {
@@ -18225,9 +18228,12 @@ function (root, d3, forester, phyloXml) {
18225
18228
  // live. Injecting twice is a no-op.
18226
18229
  injectPanelStyles();
18227
18230
 
18231
+ // REMOVED (2026-10-07): the 2.x class "node_mouseover_tooltip" beside
18232
+ // "aptx-tip". A host's old stylesheet outranked .aptx-tip through it
18233
+ // (div.X beats .Y) and restyled the popup; see CHANGELOG, Unreleased.
18228
18234
  if (!_node_mouseover_div) {
18229
18235
  _node_mouseover_div = d3.select("body").append("div")
18230
- .attr("class", "node_mouseover_tooltip aptx-tip")
18236
+ .attr("class", "aptx-tip")
18231
18237
  .style("opacity", 1e-6);
18232
18238
  }
18233
18239
 
@@ -18414,20 +18420,6 @@ function (root, d3, forester, phyloXml) {
18414
18420
 
18415
18421
  on(SHORTEN_NODE_NAME_CB, 'click', shortenCbClicked);
18416
18422
 
18417
- if (_nodeLabels) {
18418
- for (const [key, value] of Object.entries(_nodeLabels)) {
18419
- if (value.label && value.showButton === true && value.propertyRef && value.description) {
18420
- const cb_id = makeIdForCustomCheckboxButton(key);
18421
- on(cb_id, 'click', function () {
18422
- customCbClicked(cb_id);
18423
- });
18424
- if (value.selected === true) {
18425
- setCheckboxValue(cb_id, true);
18426
- }
18427
- }
18428
- }
18429
- }
18430
-
18431
18423
  on(LABEL_COLOR_SELECT_MENU, 'change', function () {
18432
18424
  let v = this.value;
18433
18425
  if (v && v !== DEFAULT && _vis && _vis.byId[v]) {
@@ -18868,10 +18860,6 @@ function (root, d3, forester, phyloXml) {
18868
18860
  return '<label class="aptx-seg" title="' + tooltip + '"><input type="radio" name="' + radioGroup + '" id="' + id + '">' + content + '</label>';
18869
18861
  }
18870
18862
 
18871
- function makeIdForCustomCheckboxButton(key) {
18872
- return key + '__cb';
18873
- }
18874
-
18875
18863
  function makeDisplayControl() {
18876
18864
  let labels = [];
18877
18865
  let nodes = [];
@@ -18893,17 +18881,6 @@ function (root, d3, forester, phyloXml) {
18893
18881
  labels.push(makeCheckboxItem('Metadata', PROPERTIES_CB, 'to show/hide the nodes\' metadata (phyloXML'
18894
18882
  + ' properties: species, host, …) in their labels; Metadata fields… chooses which, and in what order'));
18895
18883
  }
18896
- if (_nodeLabels) {
18897
- for (const [key, value] of Object.entries(_nodeLabels)) {
18898
- if (value.label && value.propertyRef && value.description) {
18899
- const cb_id = makeIdForCustomCheckboxButton(key);
18900
- if (value.showButton === true) {
18901
- labels.push(makeCheckboxItem(value.label, cb_id, value.description));
18902
- }
18903
- value.cb_id = cb_id;
18904
- }
18905
- }
18906
- }
18907
18884
  if (_basicTreeProperties.confidences) {
18908
18885
  labels.push(makeCheckboxItem('Confidence', CONFIDENCE_VALUES_CB, 'to show/hide confidence values'));
18909
18886
  }
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.23.0
23
+ // v 3.25.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -7254,18 +7254,28 @@
7254
7254
  // a panel linked to the tree, named "Clock plot"; JS only until the
7255
7255
  // design is final.
7256
7256
  //
7257
- // THE RULE, designed for two kinds of tree, the first of them built:
7257
+ // THE RULE, designed for two kinds of tree (both built; the second on
7258
+ // Christian's word, 2026-10-03):
7258
7259
  //
7259
7260
  // 1. 'divergence' -- a tree with Time and Div (hasTimeAndDivergence): each
7260
7261
  // node states a date and a divergence from the root, recorded on it or
7261
7262
  // summed from each branch's time x its clock rate. Every node is a
7262
7263
  // point; which of the two layouts is on screen does not matter.
7263
- // 2. 'root-to-tip' (NOT BUILT) -- a tree whose branch lengths are
7264
- // divergence and whose tips are dated, with no dates on its ancestors
7265
- // (it is not a time tree): a tip's divergence is the sum of the stated
7266
- // lengths from the root. Tips only. This is the tree a clock plot is
7267
- // wanted for most -- is there a signal worth dating? -- and the one
7268
- // whose answer depends on its root.
7264
+ // 2. 'root-to-tip' -- a tree whose branch lengths are its divergence and
7265
+ // whose tips are dated, with no date on any ancestor (it is not a time
7266
+ // tree): a tip's divergence is the sum of the stated lengths from the
7267
+ // root. Tips only. This is the tree a clock plot is wanted for most --
7268
+ // is there a signal worth dating? -- and the one whose answer depends
7269
+ // on its root. TempEst's plot. A tip is dated by what the file STATES
7270
+ // for it (a phyloXML <date>, a num_date annotation), or else by the
7271
+ // date its NAME carries, read by the label-date parser both programs
7272
+ // anchor BEAST heights with (Christian, 2026-10-03: names count, as
7273
+ // TempEst reads them; a year alone is mid-year). An ancestor is never
7274
+ // dated by its name. Every tip must be dated and every branch must
7275
+ // state a length: a tip with either missing has no point, and a plot
7276
+ // that drops tips silently answers a different question than it was
7277
+ // asked. A clock-model tree (a rate on every branch) is never this
7278
+ // kind: its lengths are time.
7269
7279
  //
7270
7280
  // In both, the plot is offered when its LINE can be drawn: three tips or
7271
7281
  // more, and not all of them on one date. A tree sampled at one moment has
@@ -7277,19 +7287,76 @@
7277
7287
  // an observation; an ancestor's was inferred, usually with a clock, and
7278
7288
  // counting it would have the estimate confirm itself. The line is not
7279
7289
  // forced through the root, so the date it reaches the root's divergence
7280
- // at can be held against the date the tree states for the root.
7290
+ // at can be held against the date the tree states for the root -- where
7291
+ // the tree states one; a root-to-tip tree does not, and there the line's
7292
+ // date is the only estimate.
7281
7293
  const CLOCK_PLOT_MIN_TIPS = 3;
7282
7294
 
7295
+ // A tip's date on a root-to-tip tree: the one the file states, else the
7296
+ // one its name carries (forester.parseTipLabelDate: the rightmost date in
7297
+ // the label; a year alone is mid-year; an ambiguous numeric date is read
7298
+ // day first). {value, fromName, ambiguous}, or null for no date at all.
7299
+ function rootToTipDate(n) {
7300
+ let d = auspiceNodeDate(n);
7301
+ if (d !== null) {
7302
+ return {value: d, fromName: false, ambiguous: false};
7303
+ }
7304
+ let m = (typeof n.name === 'string' && n.name.length > 0) ? forester.parseTipLabelDate(n.name) : null;
7305
+ return m ? {value: m.decimalYear, fromName: true, ambiguous: m.ambiguous === true} : null;
7306
+ }
7307
+
7308
+ // Kind 2 of THE RULE, before the tips are counted: dated tips (by the
7309
+ // file or by their names), undated ancestors (by the file; a name dates
7310
+ // no ancestor), a length on every branch, no clock rates.
7311
+ function isRootToTipTree(root) {
7312
+ if (!root || root._divergenceFromRates === true || !everyLengthWasStated(root)) {
7313
+ return false;
7314
+ }
7315
+ let ok = true;
7316
+ forester.preOrderTraversalAll(root, function (n) {
7317
+ if (!ok) {
7318
+ return;
7319
+ }
7320
+ ok = (n.children && n.children.length > 0) ? auspiceNodeDate(n) === null : rootToTipDate(n) !== null;
7321
+ });
7322
+ return ok;
7323
+ }
7324
+
7325
+ // Each node's distance from the root along the stated branch lengths:
7326
+ // a root-to-tip tree's divergence.
7327
+ function rootToTipDistances(root) {
7328
+ let dist = new Map();
7329
+ (function walk(n, d) {
7330
+ dist.set(n, d);
7331
+ let children = n.children;
7332
+ if (children) {
7333
+ for (let i = 0; i < children.length; ++i) {
7334
+ let len = children[i].branch_length;
7335
+ walk(children[i], d + ((typeof len === 'number' && isFinite(len)) ? len : 0));
7336
+ }
7337
+ }
7338
+ }(root, 0));
7339
+ return dist;
7340
+ }
7341
+
7283
7342
  /**
7284
7343
  * Which clock plot the tree has, or null for none (see THE RULE above).
7285
7344
  *
7286
7345
  * @param phy the tree
7287
- * @returns {string|null} 'divergence', or null
7346
+ * @returns {string|null} 'divergence', 'root-to-tip', or null
7288
7347
  */
7289
7348
  forester.clockPlotKind = function (phy) {
7290
- if (!phy || !forester.hasTimeAndDivergence(phy)) {
7349
+ if (!phy) {
7291
7350
  return null;
7292
7351
  }
7352
+ let kind = forester.hasTimeAndDivergence(phy) ? 'divergence'
7353
+ : (isRootToTipTree(forester.getTreeRoot(phy)) ? 'root-to-tip' : null);
7354
+ if (kind === null) {
7355
+ return null;
7356
+ }
7357
+ let dateOf = kind === 'root-to-tip' ? function (n) {
7358
+ return rootToTipDate(n).value; // every tip has one: the kind says so
7359
+ } : auspiceNodeDate;
7293
7360
  let tips = 0;
7294
7361
  let first = null;
7295
7362
  let differ = false;
@@ -7298,14 +7365,14 @@
7298
7365
  return;
7299
7366
  }
7300
7367
  ++tips;
7301
- let d = auspiceNodeDate(n);
7368
+ let d = dateOf(n);
7302
7369
  if (first === null) {
7303
7370
  first = d;
7304
7371
  } else if (d !== first) {
7305
7372
  differ = true;
7306
7373
  }
7307
7374
  });
7308
- return (tips >= CLOCK_PLOT_MIN_TIPS && differ) ? 'divergence' : null;
7375
+ return (tips >= CLOCK_PLOT_MIN_TIPS && differ) ? kind : null;
7309
7376
  };
7310
7377
 
7311
7378
  /**
@@ -7363,14 +7430,25 @@
7363
7430
  * @param view the tree or clade to plot (the tree, a node, or the
7364
7431
  * viewer's subtree root); the whole tree where none is given
7365
7432
  * @returns {object|null} null where the tree has no clock plot; else
7366
- * kind 'divergence'
7433
+ * kind 'divergence' or 'root-to-tip'
7367
7434
  * forward whether the dates increase toward the tips (calendar
7368
7435
  * dates); false for ages
7369
7436
  * unit the dates' unit as the tree states it, or null
7437
+ * divUnit the divergence's unit: 'subs/site' for a tree with Time
7438
+ * and Div, a root-to-tip tree's stated branch length unit,
7439
+ * or null
7370
7440
  * fromRates whether the divergence is time x clock rate
7441
+ * calendar whether the dates are calendar years: their unit says so,
7442
+ * or they were read off the tips' names
7443
+ * fromNames how many tips are dated by their names (a root-to-tip
7444
+ * tree; 0 otherwise)
7445
+ * ambiguousNames how many of those names carry a day/month-ambiguous
7446
+ * numeric date, read day first
7371
7447
  * points [{node, date, div, tip}] in preorder; the tips'
7372
- * `residual` is their divergence less the line's
7373
- * root {node, date, div}: the top of the view
7448
+ * `residual` is their divergence less the line's. On a
7449
+ * root-to-tip tree the tips only: no ancestor has a date
7450
+ * root {node, date, div}: the top of the view; `date` null on a
7451
+ * root-to-tip tree
7374
7452
  * fit forester.clockRegression over the tips, with
7375
7453
  * rate the slope in the direction time runs: divergence
7376
7454
  * per unit of time (negative where the tips'
@@ -7391,24 +7469,49 @@
7391
7469
  if (view) {
7392
7470
  top = (!view.parent && view.children && view.children.length === 1) ? view.children[0] : view;
7393
7471
  }
7472
+ // a root-to-tip tree's divergence is the path from the TREE's root,
7473
+ // in the view of a clade too: the clade's top then has one as well
7474
+ let rootToTip = kind === 'root-to-tip';
7475
+ let dist = rootToTip ? rootToTipDistances(treeRoot) : null;
7476
+ let divOf = rootToTip ? function (n) {
7477
+ return dist.get(n);
7478
+ } : auspiceNodeDiv;
7394
7479
  let points = [];
7395
7480
  let xs = [];
7396
7481
  let ys = [];
7482
+ let fromNames = 0;
7483
+ let ambiguousNames = 0;
7397
7484
  forester.preOrderTraversalAll(top, function (n) {
7485
+ let tip = !(n.children && n.children.length > 0);
7398
7486
  let date = auspiceNodeDate(n);
7399
- let div = auspiceNodeDiv(n);
7487
+ if (rootToTip && tip && date === null) {
7488
+ let named = rootToTipDate(n); // every tip has one: the kind says so
7489
+ date = named ? named.value : null;
7490
+ if (named && named.fromName) {
7491
+ ++fromNames;
7492
+ if (named.ambiguous) {
7493
+ ++ambiguousNames;
7494
+ }
7495
+ }
7496
+ }
7497
+ let div = divOf(n);
7400
7498
  if (date === null || div === null) {
7401
- return; // not on a tree with Time and Div; a guard, not a rule
7499
+ return; // an undated ancestor (root-to-tip), or a guard, not a rule
7402
7500
  }
7403
- let tip = !(n.children && n.children.length > 0);
7404
7501
  points.push({node: n, date: date, div: div, tip: tip});
7405
7502
  if (tip) {
7406
7503
  xs.push(date);
7407
7504
  ys.push(div);
7408
7505
  }
7409
7506
  });
7410
- let forward = timeIncreasesTowardTips(treeRoot);
7411
- let root = {node: top, date: auspiceNodeDate(top), div: auspiceNodeDiv(top)};
7507
+ let info = forester.timeAxisInfo(treeRoot);
7508
+ // a root-to-tip tree has no dated pair to read the direction off:
7509
+ // its dates are calendar dates unless their unit says ages
7510
+ let forward = rootToTip ? info.type !== 'geologic' : timeIncreasesTowardTips(treeRoot);
7511
+ // calendar years: the dates' unit says so, or they were read off the
7512
+ // tips' names, which carry no other kind of date
7513
+ let calendar = forward && (info.type === 'calendar' || (rootToTip && info.type === null && fromNames > 0));
7514
+ let root = {node: top, date: auspiceNodeDate(top), div: divOf(top)};
7412
7515
  let fit = forester.clockRegression(xs, ys);
7413
7516
  if (fit) {
7414
7517
  fit.rate = forward ? fit.slope : -fit.slope;
@@ -7420,11 +7523,17 @@
7420
7523
  }
7421
7524
  }
7422
7525
  }
7526
+ let lengthUnit = (phy !== treeRoot && typeof phy.branch_length_unit === 'string'
7527
+ && phy.branch_length_unit.trim().length > 0) ? phy.branch_length_unit.trim() : null;
7423
7528
  return {
7424
7529
  kind: kind,
7425
7530
  forward: forward,
7426
7531
  unit: dateUnitOf(treeRoot),
7427
- fromRates: treeRoot._divergenceFromRates === true && !recordsDivergence(treeRoot),
7532
+ divUnit: rootToTip ? lengthUnit : DIVERGENCE_LENGTH_UNIT,
7533
+ fromRates: !rootToTip && treeRoot._divergenceFromRates === true && !recordsDivergence(treeRoot),
7534
+ calendar: calendar,
7535
+ fromNames: fromNames,
7536
+ ambiguousNames: ambiguousNames,
7428
7537
  points: points,
7429
7538
  root: root,
7430
7539
  fit: fit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.23.0",
3
+ "version": "3.25.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",
@@ -20,7 +20,7 @@
20
20
  "LICENSE"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node test/forester_test.js && node test/search_test.js && node test/visualization_test.js && node test/domain_test.js && node test/gene_test.js && node test/clock_test.js && node test/metadata_test.js && node test/heatmap_test.js && node test/clustering_test.js",
23
+ "test": "node test/forester_test.js && node test/search_test.js && node test/visualization_test.js && node test/domain_test.js && node test/gene_test.js && node test/clock_test.js && node test/metadata_test.js && node test/heatmap_test.js && node test/clustering_test.js && node test/canvg_global_test.js",
24
24
  "lint": "eslint .",
25
25
  "lint:fix": "eslint . --fix",
26
26
  "format": "prettier --write .",