archaeopteryx 3.17.0 → 3.19.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/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.17.0
23
+ // v 3.19.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.17.0';
106
+ const VERSION = '3.19.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';
@@ -380,8 +380,8 @@ function (root, d3, forester, phyloXml) {
380
380
  const DOMAIN_EVALUE_READOUT = 'domain_evalue';
381
381
  const DOMAIN_LABELS_SELECT = 'domain_labels';
382
382
  const DOMAIN_GLOW_CB = 'domain_glow_cb';
383
- const TIME_AXIS_CB = 'timeaxis_cb';
384
- const TIME_GRID_CB = 'timegrid_cb';
383
+ const SCALE_AXIS_CB = 'scaleaxis_cb';
384
+ const SCALE_GRID_CB = 'scalegrid_cb';
385
385
  const MSA_SCROLL_ID = 'aptxmsascroll';
386
386
  const FONT_SIZE_SLIDER = 'fs_sl';
387
387
  const EXTERNAL_LABEL_CB = 'extl_cb';
@@ -641,6 +641,7 @@ function (root, d3, forester, phyloXml) {
641
641
  // ------ time axis (the desktop's geologic / calendar overlays) ------
642
642
  const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
643
643
  const TIME_CAL_RESERVE = 26; // the calendar year ruler
644
+ const DISTANCE_AXIS_RESERVE = 26; // the distance ruler, one label row
644
645
  const TIME_BAND_ROW_H = 13;
645
646
  const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, the usual HPD tint
646
647
  const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
@@ -5440,6 +5441,23 @@ function (root, d3, forester, phyloXml) {
5440
5441
  return v === true ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
5441
5442
  },
5442
5443
  note: 'use \'confidence\' for what this flag did (\'auto\' is the new default, and does not promote mixed trees)'
5444
+ },
5445
+ // Renamed in 3.19.0, when the axis stopped being time-only: a tree
5446
+ // whose branches measure divergence gets a distance axis from the
5447
+ // same switch. The meaning on a time tree is unchanged.
5448
+ showTimeAxis: {
5449
+ to: 'showScaleAxis',
5450
+ map: function (v) {
5451
+ return v;
5452
+ },
5453
+ note: 'the one switch now draws the time axis on a time tree and a distance axis on any other phylogram'
5454
+ },
5455
+ timeAxisGrid: {
5456
+ to: 'showScaleGrid',
5457
+ map: function (v) {
5458
+ return v;
5459
+ },
5460
+ note: 'the grid follows the scale axis, time or distance'
5443
5461
  }
5444
5462
  };
5445
5463
 
@@ -5610,8 +5628,8 @@ function (root, d3, forester, phyloXml) {
5610
5628
  'domainLabels',
5611
5629
  'domainGlow',
5612
5630
  'domainEvalueExponent',
5613
- 'showTimeAxis',
5614
- 'timeAxisGrid',
5631
+ 'showScaleAxis',
5632
+ 'showScaleGrid',
5615
5633
  'showSupportDots',
5616
5634
  'searchAinitialValue',
5617
5635
  'searchBinitialValue',
@@ -5910,10 +5928,11 @@ function (root, d3, forester, phyloXml) {
5910
5928
  + ' with a missing or impossible from / to / E-value ignored');
5911
5929
  }
5912
5930
  }
5913
- // Likewise a dated tree draws its time axis from the start --
5914
- // geologic ICS bands or calendar years, decided from the <date>
5915
- // elements by forester.timeAxisInfo -- again unless the caller set
5916
- // showTimeAxis explicitly.
5931
+ // Likewise a dated tree draws its scale axis from the start -- a
5932
+ // time axis of geologic ICS bands or calendar years, decided from the
5933
+ // <date> elements by forester.timeAxisInfo -- again unless the caller
5934
+ // set showScaleAxis explicitly. Any other tree opens without one, as
5935
+ // on the desktop: its distance axis is asked for.
5917
5936
  _timeInfo = _treeData ? forester.timeAxisInfo(forester.getTreeRoot(_treeData)) : null;
5918
5937
  _timeTree = _treeData ? forester.isTimeTree(_treeData) : false;
5919
5938
  // Whether this tree says two different things -- when its nodes sit in
@@ -5922,10 +5941,13 @@ function (root, d3, forester, phyloXml) {
5922
5941
  // BEAST or Newick file arrives stating its own branch lengths.
5923
5942
  _branchScaleAvailable = _treeData ? forester.hasTimeAndDivergence(_treeData) : false;
5924
5943
  _state.branchScale = _treeData ? forester.branchLengthScale(_treeData) : 'divergence';
5925
- if (_state.showTimeAxis === undefined) {
5926
- _state.showTimeAxis = !!(_timeInfo && _timeInfo.type);
5944
+ if (_state.showScaleAxis === undefined) {
5945
+ // on for a tree SHOWN in time; a dated tree that opens in
5946
+ // divergence (a build refused the switch) is a divergence tree
5947
+ // on screen, and opens without its distance axis like any other
5948
+ _state.showScaleAxis = !!(_timeInfo && _timeInfo.type) && !branchesShowDivergence();
5927
5949
  }
5928
- _state.timeAxisGrid = _state.timeAxisGrid === true; // desktop default: off
5950
+ _state.showScaleGrid = _state.showScaleGrid === true; // desktop default: off
5929
5951
  _state.showSupportDots = _state.showSupportDots === true;
5930
5952
  _state.showNodeEvents = _basicTreeProperties.nodeEvents === true;
5931
5953
  _state.showBranchEvents = _basicTreeProperties.branchEvents === true;
@@ -6449,11 +6471,11 @@ function (root, d3, forester, phyloXml) {
6449
6471
  // and a tree whose dates carry a unit is never touched. The desktop
6450
6472
  // converts at the same point, next to its internal-label policy
6451
6473
  // (their HeightDateConverter, 0.11.151).
6452
- // Each node's cumulative divergence is recorded from the branch
6453
- // lengths AS LOADED, before anything rewrites them, so the time <->
6454
- // divergence switch is reversible rather than one-way. Auspice states
6455
- // divergence outright (nextstrain:div) and is unaffected; every other
6456
- // tree states it as its branch lengths, which the time view overwrites.
6474
+ // What the Time | Div switch needs to be lossless is kept from the
6475
+ // branch lengths AS LOADED, before anything rewrites them: the
6476
+ // lengths a tree arrived with in time, and on a BEAST clock-model
6477
+ // tree its divergence, each length times its rate (see
6478
+ // forester.captureDivergence).
6457
6479
  trees.forEach(function (t) {
6458
6480
  forester.captureDivergence(t);
6459
6481
  });
@@ -7217,6 +7239,7 @@ function (root, d3, forester, phyloXml) {
7217
7239
  items.push({label: deleteLabel, danger: true, action: function () {
7218
7240
  forester.deleteSubtree(tree, d);
7219
7241
  _treeData = tree;
7242
+ branchScaleAfterEdit();
7220
7243
  _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
7221
7244
  refreshVisualizations(true);
7222
7245
  search0();
@@ -9736,9 +9759,12 @@ function (root, d3, forester, phyloXml) {
9736
9759
  s.domainGlow = _state.domainGlow === true;
9737
9760
  s.domainEvalue = _state.domainEvalueExponent;
9738
9761
  }
9739
- if (_timeInfo && _timeInfo.type) {
9740
- s.timeAxis = _state.showTimeAxis === true;
9741
- s.timeGrid = _state.timeAxisGrid === true;
9762
+ if (_basicTreeProperties.branchLengths === true) {
9763
+ s.scaleAxis = _state.showScaleAxis === true;
9764
+ s.scaleGrid = _state.showScaleGrid === true;
9765
+ }
9766
+ if (_branchScaleAvailable) {
9767
+ s.scale = _state.branchScale; // time or divergence: which layout the branches show
9742
9768
  }
9743
9769
  let a = searchStateOf(0);
9744
9770
  let b = searchStateOf(1);
@@ -9809,6 +9835,16 @@ function (root, d3, forester, phyloXml) {
9809
9835
  }
9810
9836
  });
9811
9837
  }
9838
+ // the branches' layout, time or divergence, on a tree that has both:
9839
+ // laid out before the properties are measured, since the lengths are
9840
+ // the layout. A tree with one layout ignores the key.
9841
+ if (_branchScaleAvailable && (s.scale === 'time' || s.scale === 'divergence') && s.scale !== _state.branchScale) {
9842
+ let laidOut = (s.scale === 'time') ? forester.applyTimeBranchLengths(_treeData)
9843
+ : forester.applyDivergenceBranchLengths(_treeData);
9844
+ if (laidOut) {
9845
+ _state.branchScale = s.scale;
9846
+ }
9847
+ }
9812
9848
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
9813
9849
 
9814
9850
  if (s.layout === 'rectangular' || s.layout === 'circular' || s.layout === 'unrooted') {
@@ -9880,11 +9916,15 @@ function (root, d3, forester, phyloXml) {
9880
9916
  _domain.palette = null; // the drawn set changed: the palette is dealt again
9881
9917
  }
9882
9918
  }
9883
- if (typeof s.timeAxis === 'boolean') {
9884
- _state.showTimeAxis = s.timeAxis;
9919
+ // timeAxis / timeGrid: the names before the axis also measured
9920
+ // distance, still read so that older links keep their axis
9921
+ let axis = typeof s.scaleAxis === 'boolean' ? s.scaleAxis : s.timeAxis;
9922
+ if (typeof axis === 'boolean') {
9923
+ _state.showScaleAxis = axis;
9885
9924
  }
9886
- if (typeof s.timeGrid === 'boolean') {
9887
- _state.timeAxisGrid = s.timeGrid;
9925
+ let grid = typeof s.scaleGrid === 'boolean' ? s.scaleGrid : s.timeGrid;
9926
+ if (typeof grid === 'boolean') {
9927
+ _state.showScaleGrid = grid;
9888
9928
  }
9889
9929
  if (!radialDisplay()) {
9890
9930
  _radialLabelsHorizontal = false;
@@ -9951,6 +9991,7 @@ function (root, d3, forester, phyloXml) {
9951
9991
  function syncViewControls() {
9952
9992
  setDisplayTypeButtons();
9953
9993
  syncZoomRowButtons();
9994
+ syncBranchScaleControls();
9954
9995
  VIEW_SHOW_FLAGS.forEach(function (f) {
9955
9996
  setCheckboxValue(f[1], _state[f[2]] === true);
9956
9997
  });
@@ -9963,8 +10004,8 @@ function (root, d3, forester, phyloXml) {
9963
10004
  setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
9964
10005
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
9965
10006
  syncHeatmapControls();
9966
- setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
9967
- setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
10007
+ setCheckboxValue(SCALE_AXIS_CB, _state.showScaleAxis === true);
10008
+ setCheckboxValue(SCALE_GRID_CB, _state.showScaleGrid === true);
9968
10009
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
9969
10010
  setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
9970
10011
  syncDomainControls();
@@ -10040,11 +10081,11 @@ function (root, d3, forester, phyloXml) {
10040
10081
  // The heat map's four keys and the alignment logo went in exactly that
10041
10082
  // way, while the README promised a link reproduced the figure.
10042
10083
  const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine',
10043
- 'heatmapOrder'];
10084
+ 'heatmapOrder', 'scale'];
10044
10085
  const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
10045
10086
  const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
10046
10087
  const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow',
10047
- 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
10088
+ 'scaleAxis', 'scaleGrid', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
10048
10089
  // Lists of strings, comma-joined. A property ref carries ':' but never a
10049
10090
  // comma, and encodeViewValue leaves both readable in a hash.
10050
10091
  const VIEW_LIST_KEYS = ['heatmapManual'];
@@ -10211,7 +10252,7 @@ function (root, d3, forester, phyloXml) {
10211
10252
  {key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
10212
10253
  {key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
10213
10254
  {key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
10214
- {key: 'x', label: 'X', shift: true, what: 'Time / divergence scale, or the time axis', run: toggleTimeAxis},
10255
+ {key: 'x', label: 'X', shift: true, what: 'Time / divergence scale, or the scale axis', run: toggleScaleAxis},
10215
10256
  {key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
10216
10257
  {key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
10217
10258
  {key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
@@ -10286,10 +10327,12 @@ function (root, d3, forester, phyloXml) {
10286
10327
 
10287
10328
  /**
10288
10329
  * Redraws the tree with its branch lengths taken from the chosen metric:
10289
- * TIME (the gaps between the nodes' dates) or DIVERGENCE (what the file's
10290
- * own branch lengths measured, recorded at load). Both metrics stay on the
10291
- * tree, so this is reversible and lossless -- a round trip restores the
10292
- * loaded branch lengths exactly.
10330
+ * TIME (the lengths the tree arrived with, where it arrived in time; its
10331
+ * date gaps otherwise) or DIVERGENCE (what its nodes record, or on a BEAST
10332
+ * clock-model tree each branch's time times its clock rate). Both stay on
10333
+ * the tree, so this is reversible and lossless -- a round trip restores
10334
+ * the branch lengths exactly. Always the WHOLE tree, whatever is on view.
10335
+ * The rules are forester.hasTimeAndDivergence's, shared with the desktop.
10293
10336
  *
10294
10337
  * @param scale 'time' or 'divergence'
10295
10338
  */
@@ -10304,31 +10347,71 @@ function (root, d3, forester, phyloXml) {
10304
10347
  forester.applyDivergenceBranchLengths(_treeData);
10305
10348
  }
10306
10349
  // the branch lengths ARE the layout, so everything measured from them
10307
- // has to be measured again
10308
- _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
10309
- setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, scale === 'time');
10310
- setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, scale !== 'time');
10350
+ // has to be measured again -- for the tree ON VIEW, as switchToSubtree
10351
+ // measures it: a clade's properties are not the whole tree's
10352
+ _basicTreeProperties = forester.collectBasicTreeProperties(displayedRoot());
10353
+ syncBranchScaleControls();
10311
10354
  zoomToFit();
10312
10355
  }
10313
10356
 
10357
+ // The switch's two buttons and whether the pair is shown at all, from the
10358
+ // state: most trees state only one thing, and a control offering a choice
10359
+ // that does not exist is worse than no control.
10360
+ function syncBranchScaleControls() {
10361
+ setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
10362
+ setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
10363
+ let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
10364
+ if (scaleGroup) {
10365
+ scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
10366
+ }
10367
+ }
10368
+
10369
+ // The tree was EDITED (a node deleted). Whether it has two layouts is asked
10370
+ // of the tree as it is now, and the layout on screen is laid out again
10371
+ // from what the tree keeps and records: the branch that took the place of
10372
+ // a removed node's is as long as both were, in either layout. A tree that
10373
+ // was never offered the switch still has the branch lengths of its file,
10374
+ // so what it keeps is taken afresh -- deleting the one tip without a
10375
+ // clock rate leaves a tree with a rate on every branch.
10376
+ function branchScaleAfterEdit() {
10377
+ if (!_treeData) {
10378
+ return;
10379
+ }
10380
+ let was = _branchScaleAvailable;
10381
+ if (!was) {
10382
+ forester.captureDivergence(_treeData);
10383
+ }
10384
+ _branchScaleAvailable = forester.hasTimeAndDivergence(_treeData);
10385
+ if (was && _branchScaleAvailable && _state.branchScale === 'divergence') {
10386
+ forester.applyDivergenceBranchLengths(_treeData);
10387
+ } else if (was) {
10388
+ // in time, or no longer a tree with two layouts: back in time
10389
+ forester.applyTimeBranchLengths(_treeData);
10390
+ _state.branchScale = 'time';
10391
+ } else {
10392
+ _state.branchScale = forester.branchLengthScale(_treeData);
10393
+ }
10394
+ syncBranchScaleControls();
10395
+ }
10396
+
10314
10397
  function branchScaleButtonClicked() {
10315
10398
  setBranchScale(getCheckboxValue(BRANCH_SCALE_TIME_BUTTON) ? 'time' : 'divergence');
10316
10399
  }
10317
10400
 
10318
10401
  // Shift+X. The switch takes this key over where the tree offers it, as was
10319
- // always intended; on every other tree it still works the Time Axis
10402
+ // always intended; on every other tree it works the Scale Axis
10320
10403
  // checkbox, so the key never does nothing.
10321
- function toggleTimeAxis() {
10404
+ function toggleScaleAxis() {
10322
10405
  if (_branchScaleAvailable) {
10323
10406
  setBranchScale(_state.branchScale === 'time' ? 'divergence' : 'time');
10324
10407
  return;
10325
10408
  }
10326
- let cb = byId(TIME_AXIS_CB);
10409
+ let cb = byId(SCALE_AXIS_CB);
10327
10410
  if (!cb || cb.disabled) {
10328
10411
  return;
10329
10412
  }
10330
10413
  cb.checked = !cb.checked;
10331
- timeAxisCbClicked();
10414
+ scaleAxisCbClicked();
10332
10415
  }
10333
10416
 
10334
10417
  function focusSearch() {
@@ -11523,25 +11606,55 @@ function (root, d3, forester, phyloXml) {
11523
11606
  // the tree's own branch scale, so the bands line up with the branches
11524
11607
  // even on a fossil-only tree whose youngest tip is far from the present.
11525
11608
 
11526
- function timeAxisShown() {
11527
- return _state.showTimeAxis === true && !radialDisplay()
11609
+ // Whether the branch lengths on screen measure divergence: the switch says
11610
+ // so where it is offered; the tree's own unit where it is not (a build
11611
+ // that opened in divergence, a tree saved from Div).
11612
+ function branchesShowDivergence() {
11613
+ if (_branchScaleAvailable) {
11614
+ return _state.branchScale === 'divergence';
11615
+ }
11616
+ return !!_treeData && typeof _treeData.branch_length_unit === 'string'
11617
+ && _treeData.branch_length_unit.trim() === 'subs/site';
11618
+ }
11619
+
11620
+ // The Scale Axis switch, as on the desktop: an axis under a rectangular
11621
+ // or circular phylogram. What it measures is the tree's to say -- time
11622
+ // on a time tree whose branches show time, distance from the root on
11623
+ // every other -- so flipping Time | Div changes what the axis reads,
11624
+ // never whether there is one.
11625
+ function scaleAxisShown() {
11626
+ return _state.showScaleAxis === true && !_state.unrootedDisplay
11528
11627
  && _state.phylogram === true
11529
- && _timeInfo !== null && _timeInfo.type !== null
11530
- && _basicTreeProperties.branchLengths === true
11531
- // The axis is an overlay calibrated to the tree's own branch scale,
11532
- // so it must be hidden when the branches are showing DIVERGENCE
11533
- // and divergence is a different measure -- calendar years against
11534
- // substitutions reads as a confident lie. Only then: where the two
11535
- // metrics agree (a BEAST time tree, whose branch lengths ARE time)
11536
- // there is no switch and the axis is calibrated correctly, which
11537
- // is what makes a converted BEAST tree show its years at all.
11538
- && !(_branchScaleAvailable && _state.branchScale === 'divergence');
11628
+ && _basicTreeProperties.branchLengths === true;
11539
11629
  }
11540
11630
 
11541
- function timeAxisBottomReserve() {
11542
- if (!timeAxisShown()) {
11631
+ function distanceAxisShown() {
11632
+ return scaleAxisShown() && !timeAxisShown();
11633
+ }
11634
+
11635
+ function timeAxisShown() {
11636
+ return scaleAxisShown()
11637
+ && _timeInfo !== null && _timeInfo.type !== null
11638
+ // The axis is an overlay calibrated to the tree's own branch scale,
11639
+ // so it must be hidden when the branches are showing DIVERGENCE --
11640
+ // calendar years against substitutions reads as a confident lie.
11641
+ // Whether the switch is offered is not the question: a Nextstrain
11642
+ // build with one undated node opens in divergence, says so in its
11643
+ // unit, and is refused the switch; its axis was drawn over
11644
+ // substitutions until 2026-09-29. A tree in time shows its axis
11645
+ // (a converted BEAST tree its years) whether or not it has a
11646
+ // second layout.
11647
+ && !branchesShowDivergence();
11648
+ }
11649
+
11650
+ function scaleAxisBottomReserve() {
11651
+ // circular draws its ruler in the tree's own gap, not under it
11652
+ if (!scaleAxisShown() || _state.circularDisplay) {
11543
11653
  return 0;
11544
11654
  }
11655
+ if (!timeAxisShown()) {
11656
+ return DISTANCE_AXIS_RESERVE;
11657
+ }
11545
11658
  return _timeInfo.type === 'geologic' ? TIME_GEO_RESERVE : TIME_CAL_RESERVE;
11546
11659
  }
11547
11660
 
@@ -11553,7 +11666,7 @@ function (root, d3, forester, phyloXml) {
11553
11666
  // own bottom rows (conservation, consensus, ruler) must end above it
11554
11667
  // or the bar covers them -- the old bare slider did exactly that.
11555
11668
  // The scale bar sits in the same band, under the tree's last row.
11556
- return Math.max(msaShown() ? msaBottomReserve() + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
11669
+ return Math.max(msaShown() ? msaBottomReserve() + MSA_NAV_RESERVE : 0, scaleAxisBottomReserve(),
11557
11670
  heatmapBottomReserve(),
11558
11671
  scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
11559
11672
  }
@@ -11563,14 +11676,15 @@ function (root, d3, forester, phyloXml) {
11563
11676
  // round number of branch-length units (forester.scaleBarLength) with
11564
11677
  // end ticks and its length written above, drawn in the tree's own
11565
11678
  // coordinates so it zooms and exports with the tree and its label stays
11566
- // true. Not in a cladogram (nothing to measure) and not under a time
11567
- // axis (which is a scale already); in the radial layouts it sits below
11568
- // the fan at its left edge.
11679
+ // true. Not in a cladogram (nothing to measure) and not under the scale
11680
+ // axis (which says the same, and more); in the radial layouts it sits
11681
+ // below the fan at its left edge. The desktop keeps its bar beside its
11682
+ // axis; ours gives way (Christian, 2026-09-30).
11569
11683
  const SCALE_BAR_TARGET_PX = 100;
11570
11684
  const SCALE_BAR_RESERVE = 30;
11571
11685
 
11572
11686
  function scaleBarShown() {
11573
- return _state.phylogram === true && _basicTreeProperties.branchLengths === true && !timeAxisShown();
11687
+ return _state.phylogram === true && _basicTreeProperties.branchLengths === true && !scaleAxisShown();
11574
11688
  }
11575
11689
 
11576
11690
  // pixels per branch-length unit in the current layout's own coordinates
@@ -11646,6 +11760,15 @@ function (root, d3, forester, phyloXml) {
11646
11760
  delete _floatStrips['aptx-time-axis'];
11647
11761
  }
11648
11762
  _svgGroup.selectAll('g.aptx-timegrid').remove();
11763
+ _svgGroup.selectAll('g.aptx-distance, g.aptx-distance-grid').remove();
11764
+ if (_floatGroup) {
11765
+ _floatGroup.selectAll('g.aptx-distance-axis').remove();
11766
+ delete _floatStrips['aptx-distance-axis'];
11767
+ }
11768
+ if (distanceAxisShown()) {
11769
+ drawDistanceAxis();
11770
+ return;
11771
+ }
11649
11772
  if (!timeAxisShown() || !_root || !_yScale) {
11650
11773
  return;
11651
11774
  }
@@ -11687,7 +11810,7 @@ function (root, d3, forester, phyloXml) {
11687
11810
  // BEHIND the tree (inserted first in the group) at the fine geologic
11688
11811
  // boundaries / calendar year ticks; each axis branch below fills it
11689
11812
  let grid = null;
11690
- if (_state.timeAxisGrid) {
11813
+ if (_state.showScaleGrid) {
11691
11814
  grid = _svgGroup.insert('g', 'g').attr('class', 'aptx-timegrid')
11692
11815
  .style('pointer-events', 'none');
11693
11816
  }
@@ -11699,6 +11822,48 @@ function (root, d3, forester, phyloXml) {
11699
11822
  }
11700
11823
 
11701
11824
  let sc = info.type === 'calendar' ? -corr : corr;
11825
+ let circular = _state.circularDisplay;
11826
+
11827
+ // A bar spanning depths x0..x1 at node d: a band across the branch in
11828
+ // rectangular, a segment along the node's spoke in circular.
11829
+ function timeBar(d, x0, x1, thick, fill, cls) { // bound to d, for probes
11830
+ let el;
11831
+ if (circular) {
11832
+ // A bound older than the root's own estimate is a NEGATIVE
11833
+ // radius, and the bar runs on through the centre onto the far
11834
+ // side of its spoke's line, as the rectangular bar straddles
11835
+ // the root's x. As on the desktop (hpdRadialRange).
11836
+ let a = radialAngle(d.x);
11837
+ let p0 = polarXY(a, radialRadius(x0));
11838
+ let p1 = polarXY(a, radialRadius(x1));
11839
+ el = g.append('line').attr('x1', p0[0]).attr('y1', p0[1])
11840
+ .attr('x2', p1[0]).attr('y2', p1[1])
11841
+ .attr('stroke', fill).attr('stroke-width', thick);
11842
+ } else {
11843
+ el = g.append('rect').attr('x', Math.min(x0, x1)).attr('y', d.x - (thick / 2))
11844
+ .attr('width', Math.max(1, Math.abs(x1 - x0))).attr('height', thick)
11845
+ .attr('fill', fill);
11846
+ }
11847
+ el.datum(d).attr('class', cls);
11848
+ }
11849
+ // a short stroke ACROSS the branch at depth x: a fossil range's end cap
11850
+ function timeCap(d, x) {
11851
+ let p;
11852
+ let q;
11853
+ if (circular) {
11854
+ let a = radialAngle(d.x);
11855
+ let r = radialRadius(x); // negative past the centre, as the bar's end is
11856
+ let half = 4 / Math.max(Math.abs(r), 4); // 4px either side, as an angle
11857
+ p = polarXY(a - half, r);
11858
+ q = polarXY(a + half, r);
11859
+ } else {
11860
+ p = [x, d.x - 4];
11861
+ q = [x, d.x + 4];
11862
+ }
11863
+ g.append('line').attr('x1', p[0]).attr('y1', p[1]).attr('x2', q[0]).attr('y2', q[1])
11864
+ .attr('stroke', FOSSIL_BAR_COLOR).attr('stroke-width', 1)
11865
+ .datum(d).attr('class', 'aptx-fossil-cap');
11866
+ }
11702
11867
 
11703
11868
  // ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
11704
11869
  forEachDisplayed(function (d) {
@@ -11719,9 +11884,15 @@ function (root, d3, forester, phyloXml) {
11719
11884
  let s = sc;
11720
11885
  let xa = d.y - ((max - value) * s);
11721
11886
  let xb = d.y + ((value - min) * s);
11722
- let left = Math.min(xa, xb);
11723
- let w = Math.max(1, Math.abs(xb - xa));
11724
- let y = d.x;
11887
+ if (circular && Math.abs(radialRadius(xb) - radialRadius(xa)) < 1) {
11888
+ // the 1px floor, along the spoke: from the bar's inner end
11889
+ // outward, as the rectangular bar is floored from its left
11890
+ // end and as the desktop draws it (hpdRadialRange). It was
11891
+ // centred on the interval until 2026-09-29.
11892
+ let inner = Math.min(xa, xb);
11893
+ xa = inner;
11894
+ xb = inner + (_radial.maxY / _radial.maxRad);
11895
+ }
11725
11896
  // What a TIP's interval means is the axis's to say. On geologic
11726
11897
  // time it is a fossil's observed range, FAD to LAD, and is drawn
11727
11898
  // as one below. On CALENDAR time it is the uncertainty of a
@@ -11734,39 +11905,35 @@ function (root, d3, forester, phyloXml) {
11734
11905
  // the desktop alike). The width test is the entry guard above.
11735
11906
  let sampledTip = !d.children && info.type === 'calendar';
11736
11907
  if (d.children) {
11737
- g.append('rect').attr('x', left).attr('y', y - 3.5)
11738
- .attr('width', w).attr('height', 7)
11739
- .attr('fill', HPD_BAR_COLOR);
11908
+ timeBar(d, xa, xb, 7, HPD_BAR_COLOR, 'aptx-age-bar');
11740
11909
  } else if (sampledTip) {
11741
- g.append('rect').attr('class', 'aptx-sampling-bar')
11742
- .attr('x', left).attr('y', y - 2.5)
11743
- .attr('width', w).attr('height', 5)
11744
- .attr('fill', HPD_BAR_COLOR);
11910
+ timeBar(d, xa, xb, 5, HPD_BAR_COLOR, 'aptx-sampling-bar');
11745
11911
  } else {
11746
- g.append('rect').attr('x', left).attr('y', y - 2.5)
11747
- .attr('width', w).attr('height', 5)
11748
- .attr('fill', FOSSIL_BAR_COLOR);
11912
+ timeBar(d, xa, xb, 5, FOSSIL_BAR_COLOR, 'aptx-fossil-bar');
11749
11913
  // FAD/LAD end caps, so the range reads as a bracketed interval
11750
- [left, left + w].forEach(function (cx) {
11751
- g.append('line').attr('x1', cx).attr('x2', cx)
11752
- .attr('y1', y - 4).attr('y2', y + 4)
11753
- .attr('stroke', FOSSIL_BAR_COLOR).attr('stroke-width', 1);
11754
- });
11914
+ let lo = Math.min(xa, xb);
11915
+ let hi = Math.max(xa, xb, lo + 1);
11916
+ timeCap(d, lo);
11917
+ timeCap(d, hi);
11755
11918
  }
11756
11919
  });
11757
11920
 
11921
+ if (circular) {
11922
+ drawCircularTimeRuler(info, anchor, maxTipX, corr, g, grid, ink);
11923
+ return;
11924
+ }
11925
+
11758
11926
  // ---- the axis itself: a FLOATING strip (see floatStripGroup) ----
11759
- let ax = floatStripGroup('aptx-time-axis', axisTop - 4, timeAxisBottomReserve() + 4);
11927
+ // the viewer's sans-serif: an SVG text inherits the browser's serif
11928
+ let ax = floatStripGroup('aptx-time-axis', axisTop - 4, scaleAxisBottomReserve() + 4)
11929
+ .style('font-family', FONT_DEFAULTS);
11760
11930
  function axisBackdrop(x0, x1) {
11761
11931
  // opaque, in the background colour, so tips panned under the
11762
11932
  // strip do not show through between the bands and the ruler
11763
11933
  let left = Math.min(x0, x1);
11764
11934
  ax.append('rect').attr('x', left - 2).attr('y', axisTop - 4)
11765
- .attr('width', Math.abs(x1 - x0) + 4).attr('height', timeAxisBottomReserve() + 4)
11935
+ .attr('width', Math.abs(x1 - x0) + 4).attr('height', scaleAxisBottomReserve() + 4)
11766
11936
  .attr('fill', _state.backgroundColorDefault);
11767
- ax.append('line').attr('x1', left - 2).attr('x2', left - 2 + Math.abs(x1 - x0) + 4)
11768
- .attr('y1', axisTop - 4).attr('y2', axisTop - 4)
11769
- .attr('stroke', ink).attr('stroke-opacity', 0.25).attr('stroke-width', 1);
11770
11937
  }
11771
11938
  if (info.type === 'geologic') {
11772
11939
  let rootAge = info.rootAge;
@@ -11911,13 +12078,269 @@ function (root, d3, forester, phyloXml) {
11911
12078
  placeFloatingOverlays();
11912
12079
  }
11913
12080
 
11914
- function timeAxisCbClicked() {
11915
- _state.showTimeAxis = getCheckboxValue(TIME_AXIS_CB);
12081
+ // The circular layout's time axis. Its ruler runs along the gap between
12082
+ // the last tip and the first -- the one ray no branch crosses -- from the
12083
+ // root out to the tips, so it turns with the tree. "Time Grid" draws each
12084
+ // tick as a full ring behind the tree; a geologic axis lays its intervals
12085
+ // down as coloured rings, as the desktop does.
12086
+ function drawCircularTimeRuler(info, anchor, maxTipX, corr, g, grid, ink) {
12087
+ let depthOf; // a date -> depth, the axis the radius is drawn from
12088
+ let ticks = [];
12089
+ let from; // the root end and the tip end, as dates
12090
+ let to;
12091
+ let bands = [];
12092
+ let unit = null; // written once past the tip end
12093
+ if (info.type === 'distance') {
12094
+ // not a date: a distance from the displayed root, 0 at its centre
12095
+ depthOf = function (v) {
12096
+ return info.rootDepth + (v * corr);
12097
+ };
12098
+ from = 0;
12099
+ to = info.maxDist;
12100
+ ticks = info.ticks.map(function (v) {
12101
+ return {value: v, text: String(v)};
12102
+ });
12103
+ unit = info.unit;
12104
+ } else if (info.type === 'geologic') {
12105
+ let rootAge = info.rootAge;
12106
+ if (!(rootAge > 0)) {
12107
+ return;
12108
+ }
12109
+ let anchorAge = anchor ? anchor.date.value : rootAge;
12110
+ let anchorX = anchor ? anchor.y : _root.y;
12111
+ depthOf = function (age) {
12112
+ return anchorX + ((anchorAge - age) * corr);
12113
+ };
12114
+ from = rootAge;
12115
+ to = Math.max(0, anchorAge - ((maxTipX - anchorX) / corr));
12116
+ forester.maAxisTickValues(rootAge).forEach(function (v) {
12117
+ if (v >= to - 1e-9 && v <= rootAge + 1e-9) {
12118
+ ticks.push({value: v, text: String(v)});
12119
+ }
12120
+ });
12121
+ if (to > 0) {
12122
+ ticks.push({value: to, text: String(Math.round(to * 100) / 100), first: true});
12123
+ }
12124
+ bands = forester.geoOverlapping(forester.geoBandRanks(to, rootAge)[1], to, rootAge);
12125
+ unit = 'Ma';
12126
+ } else {
12127
+ let present = info.presentDate;
12128
+ if (!(present > 0)) {
12129
+ return;
12130
+ }
12131
+ let anchorYear = anchor ? anchor.date.value : present;
12132
+ let anchorX = anchor ? anchor.y : maxTipX;
12133
+ depthOf = function (yv) {
12134
+ return anchorX - ((anchorYear - yv) * corr);
12135
+ };
12136
+ from = anchorYear - ((anchorX - _root.y) / corr);
12137
+ to = present;
12138
+ forester.calendarTickYears(from, to).forEach(function (yv) {
12139
+ ticks.push({value: yv, text: String(Math.round(yv))});
12140
+ });
12141
+ }
12142
+ let radiusOf = function (v) {
12143
+ return Math.max(0, radialRadius(depthOf(v)));
12144
+ };
12145
+ let r0 = radiusOf(from);
12146
+ let r1 = radiusOf(to);
12147
+ if (!(r1 > r0)) {
12148
+ return;
12149
+ }
12150
+ // behind the tree: the geologic rings, then the grid rings
12151
+ let behind = null;
12152
+ if (bands.length > 0 || grid) {
12153
+ behind = grid || _svgGroup.insert('g', 'g').attr('class', 'aptx-timegrid')
12154
+ .style('pointer-events', 'none');
12155
+ }
12156
+ let ring = d3.arc().startAngle(0).endAngle(2 * Math.PI);
12157
+ let named = [];
12158
+ bands.forEach(function (iv) {
12159
+ let a = radiusOf(Math.min(iv.old, from));
12160
+ let b = radiusOf(Math.max(iv.young, to));
12161
+ if (b - a <= 0) {
12162
+ return;
12163
+ }
12164
+ behind.append('path').attr('d', ring.innerRadius(a).outerRadius(b)())
12165
+ .attr('fill', iv.color).attr('fill-opacity', 0.35);
12166
+ named.push({r: (a + b) / 2, span: b - a, text: iv.name, color: iv.color});
12167
+ });
12168
+ if (grid) {
12169
+ ticks.forEach(function (t) {
12170
+ let r = radiusOf(t.value);
12171
+ if (r > r0 + 0.5 && r < r1 - 0.5) {
12172
+ grid.append('circle').attr('r', r).attr('fill', 'none')
12173
+ .attr('stroke', ink).attr('stroke-opacity', 0.18).attr('stroke-width', 1);
12174
+ }
12175
+ });
12176
+ }
12177
+
12178
+ // the ruler, on the gap's centre line
12179
+ let gap = _radialRotation + _radial.angleSpan + ((2 * Math.PI - _radial.angleSpan) / 2);
12180
+ let screen = gap - (Math.PI / 2);
12181
+ let along = [Math.cos(screen), Math.sin(screen)];
12182
+ let across = [-along[1], along[0]];
12183
+ let flip = along[0] < -1e-9; // keep every number upright
12184
+ let deg = (screen * 180 / Math.PI) + (flip ? 180 : 0);
12185
+ let at = function (r, off) {
12186
+ return [(along[0] * r) + (across[0] * off), (along[1] * r) + (across[1] * off)];
12187
+ };
12188
+ let ax = g.append('g').attr('class', 'aptx-time-ruler').style('font-family', FONT_DEFAULTS);
12189
+ let e0 = at(r0, 0);
12190
+ let e1 = at(r1, 0);
12191
+ ax.append('line').attr('x1', e0[0]).attr('y1', e0[1]).attr('x2', e1[0]).attr('y2', e1[1])
12192
+ .attr('stroke', ink).attr('stroke-width', 1);
12193
+ // numbers on one side of the ruler, interval names on the other; the
12194
+ // headline value first, then the round ticks outward, none overlapping
12195
+ function label(r, off, text, fill, backdrop) {
12196
+ let p = at(r, off);
12197
+ let t = ax.append('g')
12198
+ .attr('transform', 'translate(' + p[0] + ',' + p[1] + ') rotate(' + deg + ')');
12199
+ let w = text.length * 5.5;
12200
+ if (backdrop) {
12201
+ t.append('rect').attr('x', -(w / 2) - 2).attr('y', -6).attr('width', w + 4).attr('height', 12)
12202
+ .attr('rx', 2).attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
12203
+ }
12204
+ t.append('text').attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
12205
+ .style('font-size', '9px').style('fill', fill).text(text);
12206
+ }
12207
+ let side = flip ? -1 : 1;
12208
+ let placed = [];
12209
+ ticks.sort(function (p, q) {
12210
+ return (q.first ? 1 : 0) - (p.first ? 1 : 0) || radiusOf(p.value) - radiusOf(q.value);
12211
+ });
12212
+ ticks.forEach(function (t) {
12213
+ let r = radiusOf(t.value);
12214
+ let c0 = at(r, -4);
12215
+ let c1 = at(r, 4);
12216
+ ax.append('line').attr('x1', c0[0]).attr('y1', c0[1]).attr('x2', c1[0]).attr('y2', c1[1])
12217
+ .attr('stroke', ink).attr('stroke-width', 1);
12218
+ let half = t.text.length * 2.8;
12219
+ for (let i = 0; i < placed.length; ++i) {
12220
+ if (Math.abs(placed[i] - r) < (half * 2) + 6) {
12221
+ if (!t.first) {
12222
+ return;
12223
+ }
12224
+ }
12225
+ }
12226
+ label(r, side * 11, t.text, ink, true);
12227
+ placed.push(r);
12228
+ });
12229
+ if (unit) {
12230
+ // along the ruler past its end, so a long unit reads outward
12231
+ label(r1 + 8 + (unit.length * 2.8), 0, unit, ink, false);
12232
+ }
12233
+ if (info.type === 'geologic') {
12234
+ named.forEach(function (n) {
12235
+ if ((n.text.length * 5.5) + 4 <= n.span) {
12236
+ label(n.r, -side * 11, n.text, ink, true);
12237
+ }
12238
+ });
12239
+ }
12240
+ }
12241
+
12242
+ // The distance axis, the desktop's "Scale Axis": a ruler from 0 at the
12243
+ // displayed root out to the deepest tip, in branch-length units, with
12244
+ // round ticks and the tree's unit at its end; "Scale Grid" draws each
12245
+ // tick as a faint line (a ring, in circular) behind the tree. Measured
12246
+ // per draw, so a subtree view's axis starts at ITS root.
12247
+ function drawDistanceAxis() {
12248
+ if (!_root || !_yScale) {
12249
+ return;
12250
+ }
12251
+ let corr = Math.abs(_yScale(1) - _yScale(0));
12252
+ if (!isFinite(corr) || corr <= 0) {
12253
+ return;
12254
+ }
12255
+ let rootDepth = (topNode() || _root).y;
12256
+ let maxTipX = rootDepth;
12257
+ forEachDisplayed(function (n) {
12258
+ if (!n.children && n.y > maxTipX) {
12259
+ maxTipX = n.y;
12260
+ }
12261
+ });
12262
+ let maxDist = (maxTipX - rootDepth) / corr;
12263
+ let ticks = forester.distanceAxisTickValues(maxDist);
12264
+ if (ticks.length === 0) {
12265
+ return;
12266
+ }
12267
+ let unit = _treeData && typeof _treeData.branch_length_unit === 'string'
12268
+ ? _treeData.branch_length_unit.trim() : '';
12269
+ let ink = _state.branchColorDefault;
12270
+ let grid = null;
12271
+ if (_state.showScaleGrid) {
12272
+ grid = _svgGroup.insert('g', 'g').attr('class', 'aptx-distance-grid')
12273
+ .style('pointer-events', 'none');
12274
+ }
12275
+ if (_state.circularDisplay) {
12276
+ let g = _svgGroup.append('g').attr('class', 'aptx-distance').style('pointer-events', 'none');
12277
+ drawCircularTimeRuler({type: 'distance', rootDepth: rootDepth, maxDist: maxDist, ticks: ticks,
12278
+ unit: unit || null}, null, maxTipX, corr, g, grid, ink);
12279
+ return;
12280
+ }
12281
+ let xOf = function (v) {
12282
+ return rootDepth + (v * corr);
12283
+ };
12284
+ let axisTop = _clusterH + 6;
12285
+ if (grid) {
12286
+ // the root and tip ends excluded: they would retrace the tree
12287
+ ticks.forEach(function (v) {
12288
+ if (v > 0 && v < maxDist - 1e-9) {
12289
+ grid.append('line').attr('x1', xOf(v)).attr('x2', xOf(v))
12290
+ .attr('y1', 0).attr('y2', axisTop)
12291
+ .attr('stroke', ink).attr('stroke-opacity', 0.18)
12292
+ .attr('stroke-width', 1);
12293
+ }
12294
+ });
12295
+ }
12296
+ let reserve = scaleAxisBottomReserve();
12297
+ let ax = floatStripGroup('aptx-distance-axis', axisTop - 4, reserve + 4)
12298
+ .style('font-family', FONT_DEFAULTS);
12299
+ let x0 = xOf(0);
12300
+ let x1 = xOf(maxDist);
12301
+ // opaque, as the time axis's, so tips panned under it do not show
12302
+ ax.append('rect').attr('x', x0 - 2).attr('y', axisTop - 4)
12303
+ .attr('width', (x1 - x0) + 4).attr('height', reserve + 4)
12304
+ .attr('fill', _state.backgroundColorDefault);
12305
+ let rulerY = axisTop + 4;
12306
+ ax.append('line').attr('x1', x0).attr('x2', x1)
12307
+ .attr('y1', rulerY).attr('y2', rulerY)
12308
+ .attr('stroke', ink).attr('stroke-width', 1);
12309
+ // every tick drawn, its number only where it clears the last one
12310
+ let lastRight = -Infinity;
12311
+ ticks.forEach(function (v) {
12312
+ let x = xOf(v);
12313
+ ax.append('line').attr('x1', x).attr('x2', x)
12314
+ .attr('y1', rulerY).attr('y2', rulerY + 4)
12315
+ .attr('stroke', ink).attr('stroke-width', 1);
12316
+ let text = String(v);
12317
+ let half = text.length * 2.8;
12318
+ if ((x - half) >= lastRight + 4) {
12319
+ ax.append('text').attr('x', x).attr('y', rulerY + 14)
12320
+ .attr('text-anchor', 'middle')
12321
+ .style('font-size', '9px').style('fill', ink)
12322
+ .text(text);
12323
+ lastRight = x + half;
12324
+ }
12325
+ });
12326
+ if (unit) {
12327
+ // past the ruler's end, and past the last number, which is
12328
+ // centred on that end
12329
+ ax.append('text').attr('x', Math.max(x1 + 8, lastRight + 6)).attr('y', rulerY + 14)
12330
+ .attr('text-anchor', 'start')
12331
+ .style('font-size', '9px').style('fill', ink)
12332
+ .text(unit);
12333
+ }
12334
+ placeFloatingOverlays();
12335
+ }
12336
+
12337
+ function scaleAxisCbClicked() {
12338
+ _state.showScaleAxis = getCheckboxValue(SCALE_AXIS_CB);
11916
12339
  scheduleUpdate(null, 0);
11917
12340
  }
11918
12341
 
11919
- function timeGridCbClicked() {
11920
- _state.timeAxisGrid = getCheckboxValue(TIME_GRID_CB);
12342
+ function scaleGridCbClicked() {
12343
+ _state.showScaleGrid = getCheckboxValue(SCALE_GRID_CB);
11921
12344
  scheduleUpdate(null, 0);
11922
12345
  }
11923
12346
 
@@ -13065,14 +13488,17 @@ function (root, d3, forester, phyloXml) {
13065
13488
  heatCb.disabled = _state.unrootedDisplay === true;
13066
13489
  }
13067
13490
  syncHeatmapControls();
13068
- let timeCb = byId(TIME_AXIS_CB);
13069
- if (timeCb) {
13070
- timeCb.disabled = radialDisplay();
13071
- }
13072
- let timeGridCb = byId(TIME_GRID_CB);
13073
- if (timeGridCb) {
13074
- // grid lines hang off the time axis: no axis, nothing to grid
13075
- timeGridCb.disabled = radialDisplay() || _state.showTimeAxis !== true;
13491
+ let axisCb = byId(SCALE_AXIS_CB);
13492
+ if (axisCb) {
13493
+ // circular draws it along the gap; unrooted cannot, and a
13494
+ // cladogram has nothing to measure
13495
+ axisCb.disabled = _state.unrootedDisplay === true || _state.phylogram !== true;
13496
+ }
13497
+ let gridCb = byId(SCALE_GRID_CB);
13498
+ if (gridCb) {
13499
+ // grid lines hang off the scale axis: no axis, nothing to grid
13500
+ gridCb.disabled = _state.unrootedDisplay === true || _state.phylogram !== true
13501
+ || _state.showScaleAxis !== true;
13076
13502
  }
13077
13503
  let minus = byId(ZOOM_OUT_X);
13078
13504
  let plus = byId(ZOOM_IN_X);
@@ -13743,7 +14169,7 @@ function (root, d3, forester, phyloXml) {
13743
14169
  const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
13744
14170
  // Download too (Christian, 2026-09-28): four rows, and not something a
13745
14171
  // session uses often.
13746
- const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures', 'Download'];
14172
+ const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['View & Tools', 'Sizes', 'Domain Architectures', 'Download'];
13747
14173
 
13748
14174
  function loadPanelSections() {
13749
14175
  if (_panelSections) {
@@ -13758,6 +14184,11 @@ function (root, d3, forester, phyloXml) {
13758
14184
  _panelSections[k] = saved[k];
13759
14185
  }
13760
14186
  });
14187
+ // the section was called Zoom until 2026-09-30: a fold
14188
+ // remembered under that name still counts
14189
+ if (typeof saved.Zoom === 'boolean' && _panelSections['View & Tools'] === undefined) {
14190
+ _panelSections['View & Tools'] = saved.Zoom;
14191
+ }
13761
14192
  }
13762
14193
  } catch {
13763
14194
  // no storage (private mode), or junk written by something else:
@@ -15922,8 +16353,8 @@ function (root, d3, forester, phyloXml) {
15922
16353
  on(DOMAIN_EVALUE_INC, 'click', function () { domainEvalueStep(1); });
15923
16354
  on(DOMAIN_LABELS_SELECT, 'change', domainLabelsChanged);
15924
16355
  on(DOMAIN_GLOW_CB, 'click', domainGlowCbClicked);
15925
- on(TIME_AXIS_CB, 'click', timeAxisCbClicked);
15926
- on(TIME_GRID_CB, 'click', timeGridCbClicked);
16356
+ on(SCALE_AXIS_CB, 'click', scaleAxisCbClicked);
16357
+ on(SCALE_GRID_CB, 'click', scaleGridCbClicked);
15927
16358
 
15928
16359
  on(LAYOUT_RECT_BUTTON, 'click', layoutButtonClicked);
15929
16360
  on(BRANCH_SCALE_TIME_BUTTON, 'click', branchScaleButtonClicked);
@@ -16362,14 +16793,14 @@ function (root, d3, forester, phyloXml) {
16362
16793
  h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, PHYLOGRAM_ALIGNED_WHAT));
16363
16794
  h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, CLADOGRAM_WHAT));
16364
16795
  h = h.concat('</div>');
16365
- // The branch SCALE, when the tree states two different things: its
16366
- // nodes' dates, and its branches' divergence. Hidden otherwise,
16796
+ // The branch SCALE, when the tree can be laid out by time and by
16797
+ // divergence (forester.hasTimeAndDivergence). Hidden otherwise,
16367
16798
  // which is most trees.
16368
16799
  h = h.concat('<div class="' + BRANCH_SCALE_CONTROLGROUP + ' aptx-segmented">');
16369
16800
  h = h.concat(makeSegment('Time', BRANCH_SCALE_TIME_BUTTON, 'branch_scale_radio',
16370
- 'branch lengths measure TIME (the nodes\' dates)'));
16801
+ 'lay the tree out by TIME (each branch at its stated length in time; from the node dates where the tree arrived showing divergence)'));
16371
16802
  h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
16372
- 'branch lengths measure DIVERGENCE (substitutions, as the file states them)'));
16803
+ 'lay the tree out by DIVERGENCE (substitutions per site: as the tree records it, or on a BEAST tree each branch\'s time x its clock rate)'));
16373
16804
  h = h.concat('</div>');
16374
16805
  h = h.concat('</div>');
16375
16806
  h = h.concat('</fieldset>');
@@ -16468,12 +16899,16 @@ function (root, d3, forester, phyloXml) {
16468
16899
  if (heatmapAvailable()) {
16469
16900
  opts.push(makeCheckboxItem('Heat Map', HEATMAP_CB, 'to show/hide a cell per tip and numeric field beside the tree, every column on one color scale; concentric rings in the circular layout (not in the unrooted one, where a column has no ring to be)'));
16470
16901
  }
16471
- if (_timeInfo && _timeInfo.type) {
16472
- opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
16473
- + (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
16474
- + ' and node-age bars (phylogram, rectangular layout only)'));
16475
- opts.push(makeCheckboxItem('Time Grid', TIME_GRID_CB, 'to show/hide vertical grid lines at the '
16476
- + (_timeInfo.type === 'geologic' ? 'geologic interval boundaries' : 'calendar year ticks')));
16902
+ if (_basicTreeProperties.branchLengths === true) {
16903
+ let timeAxis = _timeInfo && _timeInfo.type
16904
+ ? (_timeInfo.type === 'geologic' ? 'the geologic (ICS) time axis' : 'the calendar time axis')
16905
+ : null;
16906
+ opts.push(makeCheckboxItem('Scale Axis', SCALE_AXIS_CB, 'to show/hide '
16907
+ + (timeAxis ? timeAxis + ' and node-age bars (a distance axis while the branches show divergence)'
16908
+ : 'a labeled distance axis from the root to the deepest tip')
16909
+ + ' (phylogram; rectangular and circular layouts)'));
16910
+ opts.push(makeCheckboxItem('Scale Grid', SCALE_GRID_CB, 'to show/hide grid lines (rings, in circular) at the '
16911
+ + (timeAxis ? 'time axis\'s intervals or ticks' : 'scale axis ticks')));
16477
16912
  }
16478
16913
 
16479
16914
  let h = '<fieldset><legend>Display Data</legend>';
@@ -16491,7 +16926,7 @@ function (root, d3, forester, phyloXml) {
16491
16926
  // syncZoomRowButtons). The Y buttons span the whole row.
16492
16927
  let h = "";
16493
16928
  h = h.concat('<fieldset>');
16494
- h = h.concat('<legend>Zoom</legend>');
16929
+ h = h.concat('<legend>View &amp; Tools</legend>');
16495
16930
  h = h.concat('<div class="aptx-zoomgrid">');
16496
16931
  h = h.concat(makeButton('Y+', ZOOM_IN_Y, 'zoom in vertically (or Shift+mousewheel)'));
16497
16932
  h = h.concat('<div class="aptx-zoomrow">');
@@ -16503,8 +16938,9 @@ function (root, d3, forester, phyloXml) {
16503
16938
  h = h.concat('</div>');
16504
16939
  h = h.concat(makeButton('Y-', ZOOM_OUT_Y, 'zoom out vertically (or Shift+mousewheel)'));
16505
16940
  h = h.concat('</div>');
16506
- // the tool buttons share the Zoom section rather than paying for
16507
- // a fieldset of their own
16941
+ // the tool buttons share the section with the zoom row rather
16942
+ // than paying for a fieldset of their own -- hence its name
16943
+ // (Christian, 2026-09-30: "ZOOM" named only half of it)
16508
16944
  h = h.concat('<div class="aptx-toolrow">');
16509
16945
  h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
16510
16946
  h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
@@ -16838,8 +17274,8 @@ function (root, d3, forester, phyloXml) {
16838
17274
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
16839
17275
  syncDomainControls();
16840
17276
  syncHeatmapControls();
16841
- setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
16842
- setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
17277
+ setCheckboxValue(SCALE_AXIS_CB, _state.showScaleAxis);
17278
+ setCheckboxValue(SCALE_GRID_CB, _state.showScaleGrid);
16843
17279
  setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);
16844
17280
  populateVisualizationMenus();
16845
17281
  initializeSearchOptions();
@@ -16936,14 +17372,7 @@ function (root, d3, forester, phyloXml) {
16936
17372
  disableCheckbox('#' + PHYLOGRAM_BUTTON);
16937
17373
  disableCheckbox('#' + PHYLOGRAM_ALIGNED_BUTTON);
16938
17374
  }
16939
- setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
16940
- setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
16941
- // most trees state only one thing, and a control offering a choice
16942
- // that does not exist is worse than no control
16943
- let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
16944
- if (scaleGroup) {
16945
- scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
16946
- }
17375
+ syncBranchScaleControls();
16947
17376
  }
16948
17377
 
16949
17378
 
@@ -17481,7 +17910,15 @@ function (root, d3, forester, phyloXml) {
17481
17910
  return _root;
17482
17911
  }
17483
17912
  let name = _root_const.name ? String(_root_const.name).trim() : '';
17484
- return Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17913
+ // what the phylogeny itself states goes with the part: without its
17914
+ // branch_length_unit a subtree saved from Div opened again as time
17915
+ let part = Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17916
+ ['rooted', 'rerootable', 'type', 'branch_length_unit'].forEach(function (k) {
17917
+ if (_root_const[k] !== undefined && _root_const[k] !== null) {
17918
+ part[k] = _root_const[k];
17919
+ }
17920
+ });
17921
+ return part;
17485
17922
  }
17486
17923
 
17487
17924
  // "subtree, 19 of 50 tips" while a subtree is shown, else null: said in