archaeopteryx 3.17.0 → 3.18.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.18.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.18.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';
@@ -6449,11 +6449,11 @@ function (root, d3, forester, phyloXml) {
6449
6449
  // and a tree whose dates carry a unit is never touched. The desktop
6450
6450
  // converts at the same point, next to its internal-label policy
6451
6451
  // (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.
6452
+ // What the Time | Div switch needs to be lossless is kept from the
6453
+ // branch lengths AS LOADED, before anything rewrites them: the
6454
+ // lengths a tree arrived with in time, and on a BEAST clock-model
6455
+ // tree its divergence, each length times its rate (see
6456
+ // forester.captureDivergence).
6457
6457
  trees.forEach(function (t) {
6458
6458
  forester.captureDivergence(t);
6459
6459
  });
@@ -7217,6 +7217,7 @@ function (root, d3, forester, phyloXml) {
7217
7217
  items.push({label: deleteLabel, danger: true, action: function () {
7218
7218
  forester.deleteSubtree(tree, d);
7219
7219
  _treeData = tree;
7220
+ branchScaleAfterEdit();
7220
7221
  _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
7221
7222
  refreshVisualizations(true);
7222
7223
  search0();
@@ -10286,10 +10287,12 @@ function (root, d3, forester, phyloXml) {
10286
10287
 
10287
10288
  /**
10288
10289
  * 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.
10290
+ * TIME (the lengths the tree arrived with, where it arrived in time; its
10291
+ * date gaps otherwise) or DIVERGENCE (what its nodes record, or on a BEAST
10292
+ * clock-model tree each branch's time times its clock rate). Both stay on
10293
+ * the tree, so this is reversible and lossless -- a round trip restores
10294
+ * the branch lengths exactly. Always the WHOLE tree, whatever is on view.
10295
+ * The rules are forester.hasTimeAndDivergence's, shared with the desktop.
10293
10296
  *
10294
10297
  * @param scale 'time' or 'divergence'
10295
10298
  */
@@ -10304,13 +10307,53 @@ function (root, d3, forester, phyloXml) {
10304
10307
  forester.applyDivergenceBranchLengths(_treeData);
10305
10308
  }
10306
10309
  // 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');
10310
+ // has to be measured again -- for the tree ON VIEW, as switchToSubtree
10311
+ // measures it: a clade's properties are not the whole tree's
10312
+ _basicTreeProperties = forester.collectBasicTreeProperties(displayedRoot());
10313
+ syncBranchScaleControls();
10311
10314
  zoomToFit();
10312
10315
  }
10313
10316
 
10317
+ // The switch's two buttons and whether the pair is shown at all, from the
10318
+ // state: most trees state only one thing, and a control offering a choice
10319
+ // that does not exist is worse than no control.
10320
+ function syncBranchScaleControls() {
10321
+ setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
10322
+ setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
10323
+ let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
10324
+ if (scaleGroup) {
10325
+ scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
10326
+ }
10327
+ }
10328
+
10329
+ // The tree was EDITED (a node deleted). Whether it has two layouts is asked
10330
+ // of the tree as it is now, and the layout on screen is laid out again
10331
+ // from what the tree keeps and records: the branch that took the place of
10332
+ // a removed node's is as long as both were, in either layout. A tree that
10333
+ // was never offered the switch still has the branch lengths of its file,
10334
+ // so what it keeps is taken afresh -- deleting the one tip without a
10335
+ // clock rate leaves a tree with a rate on every branch.
10336
+ function branchScaleAfterEdit() {
10337
+ if (!_treeData) {
10338
+ return;
10339
+ }
10340
+ let was = _branchScaleAvailable;
10341
+ if (!was) {
10342
+ forester.captureDivergence(_treeData);
10343
+ }
10344
+ _branchScaleAvailable = forester.hasTimeAndDivergence(_treeData);
10345
+ if (was && _branchScaleAvailable && _state.branchScale === 'divergence') {
10346
+ forester.applyDivergenceBranchLengths(_treeData);
10347
+ } else if (was) {
10348
+ // in time, or no longer a tree with two layouts: back in time
10349
+ forester.applyTimeBranchLengths(_treeData);
10350
+ _state.branchScale = 'time';
10351
+ } else {
10352
+ _state.branchScale = forester.branchLengthScale(_treeData);
10353
+ }
10354
+ syncBranchScaleControls();
10355
+ }
10356
+
10314
10357
  function branchScaleButtonClicked() {
10315
10358
  setBranchScale(getCheckboxValue(BRANCH_SCALE_TIME_BUTTON) ? 'time' : 'divergence');
10316
10359
  }
@@ -11523,23 +11566,37 @@ function (root, d3, forester, phyloXml) {
11523
11566
  // the tree's own branch scale, so the bands line up with the branches
11524
11567
  // even on a fossil-only tree whose youngest tip is far from the present.
11525
11568
 
11569
+ // Whether the branch lengths on screen measure divergence: the switch says
11570
+ // so where it is offered; the tree's own unit where it is not (a build
11571
+ // that opened in divergence, a tree saved from Div).
11572
+ function branchesShowDivergence() {
11573
+ if (_branchScaleAvailable) {
11574
+ return _state.branchScale === 'divergence';
11575
+ }
11576
+ return !!_treeData && typeof _treeData.branch_length_unit === 'string'
11577
+ && _treeData.branch_length_unit.trim() === 'subs/site';
11578
+ }
11579
+
11526
11580
  function timeAxisShown() {
11527
- return _state.showTimeAxis === true && !radialDisplay()
11581
+ return _state.showTimeAxis === true && !_state.unrootedDisplay
11528
11582
  && _state.phylogram === true
11529
11583
  && _timeInfo !== null && _timeInfo.type !== null
11530
11584
  && _basicTreeProperties.branchLengths === true
11531
11585
  // 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');
11586
+ // so it must be hidden when the branches are showing DIVERGENCE --
11587
+ // calendar years against substitutions reads as a confident lie.
11588
+ // Whether the switch is offered is not the question: a Nextstrain
11589
+ // build with one undated node opens in divergence, says so in its
11590
+ // unit, and is refused the switch; its axis was drawn over
11591
+ // substitutions until 2026-09-29. A tree in time shows its axis
11592
+ // (a converted BEAST tree its years) whether or not it has a
11593
+ // second layout.
11594
+ && !branchesShowDivergence();
11539
11595
  }
11540
11596
 
11541
11597
  function timeAxisBottomReserve() {
11542
- if (!timeAxisShown()) {
11598
+ // circular draws its ruler in the tree's own gap, not under it
11599
+ if (!timeAxisShown() || _state.circularDisplay) {
11543
11600
  return 0;
11544
11601
  }
11545
11602
  return _timeInfo.type === 'geologic' ? TIME_GEO_RESERVE : TIME_CAL_RESERVE;
@@ -11699,6 +11756,48 @@ function (root, d3, forester, phyloXml) {
11699
11756
  }
11700
11757
 
11701
11758
  let sc = info.type === 'calendar' ? -corr : corr;
11759
+ let circular = _state.circularDisplay;
11760
+
11761
+ // A bar spanning depths x0..x1 at node d: a band across the branch in
11762
+ // rectangular, a segment along the node's spoke in circular.
11763
+ function timeBar(d, x0, x1, thick, fill, cls) { // bound to d, for probes
11764
+ let el;
11765
+ if (circular) {
11766
+ // A bound older than the root's own estimate is a NEGATIVE
11767
+ // radius, and the bar runs on through the centre onto the far
11768
+ // side of its spoke's line, as the rectangular bar straddles
11769
+ // the root's x. As on the desktop (hpdRadialRange).
11770
+ let a = radialAngle(d.x);
11771
+ let p0 = polarXY(a, radialRadius(x0));
11772
+ let p1 = polarXY(a, radialRadius(x1));
11773
+ el = g.append('line').attr('x1', p0[0]).attr('y1', p0[1])
11774
+ .attr('x2', p1[0]).attr('y2', p1[1])
11775
+ .attr('stroke', fill).attr('stroke-width', thick);
11776
+ } else {
11777
+ el = g.append('rect').attr('x', Math.min(x0, x1)).attr('y', d.x - (thick / 2))
11778
+ .attr('width', Math.max(1, Math.abs(x1 - x0))).attr('height', thick)
11779
+ .attr('fill', fill);
11780
+ }
11781
+ el.datum(d).attr('class', cls);
11782
+ }
11783
+ // a short stroke ACROSS the branch at depth x: a fossil range's end cap
11784
+ function timeCap(d, x) {
11785
+ let p;
11786
+ let q;
11787
+ if (circular) {
11788
+ let a = radialAngle(d.x);
11789
+ let r = radialRadius(x); // negative past the centre, as the bar's end is
11790
+ let half = 4 / Math.max(Math.abs(r), 4); // 4px either side, as an angle
11791
+ p = polarXY(a - half, r);
11792
+ q = polarXY(a + half, r);
11793
+ } else {
11794
+ p = [x, d.x - 4];
11795
+ q = [x, d.x + 4];
11796
+ }
11797
+ g.append('line').attr('x1', p[0]).attr('y1', p[1]).attr('x2', q[0]).attr('y2', q[1])
11798
+ .attr('stroke', FOSSIL_BAR_COLOR).attr('stroke-width', 1)
11799
+ .datum(d).attr('class', 'aptx-fossil-cap');
11800
+ }
11702
11801
 
11703
11802
  // ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
11704
11803
  forEachDisplayed(function (d) {
@@ -11719,9 +11818,15 @@ function (root, d3, forester, phyloXml) {
11719
11818
  let s = sc;
11720
11819
  let xa = d.y - ((max - value) * s);
11721
11820
  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;
11821
+ if (circular && Math.abs(radialRadius(xb) - radialRadius(xa)) < 1) {
11822
+ // the 1px floor, along the spoke: from the bar's inner end
11823
+ // outward, as the rectangular bar is floored from its left
11824
+ // end and as the desktop draws it (hpdRadialRange). It was
11825
+ // centred on the interval until 2026-09-29.
11826
+ let inner = Math.min(xa, xb);
11827
+ xa = inner;
11828
+ xb = inner + (_radial.maxY / _radial.maxRad);
11829
+ }
11725
11830
  // What a TIP's interval means is the axis's to say. On geologic
11726
11831
  // time it is a fossil's observed range, FAD to LAD, and is drawn
11727
11832
  // as one below. On CALENDAR time it is the uncertainty of a
@@ -11734,27 +11839,24 @@ function (root, d3, forester, phyloXml) {
11734
11839
  // the desktop alike). The width test is the entry guard above.
11735
11840
  let sampledTip = !d.children && info.type === 'calendar';
11736
11841
  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);
11842
+ timeBar(d, xa, xb, 7, HPD_BAR_COLOR, 'aptx-age-bar');
11740
11843
  } 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);
11844
+ timeBar(d, xa, xb, 5, HPD_BAR_COLOR, 'aptx-sampling-bar');
11745
11845
  } 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);
11846
+ timeBar(d, xa, xb, 5, FOSSIL_BAR_COLOR, 'aptx-fossil-bar');
11749
11847
  // 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
- });
11848
+ let lo = Math.min(xa, xb);
11849
+ let hi = Math.max(xa, xb, lo + 1);
11850
+ timeCap(d, lo);
11851
+ timeCap(d, hi);
11755
11852
  }
11756
11853
  });
11757
11854
 
11855
+ if (circular) {
11856
+ drawCircularTimeRuler(info, anchor, maxTipX, corr, g, grid, ink);
11857
+ return;
11858
+ }
11859
+
11758
11860
  // ---- the axis itself: a FLOATING strip (see floatStripGroup) ----
11759
11861
  let ax = floatStripGroup('aptx-time-axis', axisTop - 4, timeAxisBottomReserve() + 4);
11760
11862
  function axisBackdrop(x0, x1) {
@@ -11911,6 +12013,151 @@ function (root, d3, forester, phyloXml) {
11911
12013
  placeFloatingOverlays();
11912
12014
  }
11913
12015
 
12016
+ // The circular layout's time axis. Its ruler runs along the gap between
12017
+ // the last tip and the first -- the one ray no branch crosses -- from the
12018
+ // root out to the tips, so it turns with the tree. "Time Grid" draws each
12019
+ // tick as a full ring behind the tree; a geologic axis lays its intervals
12020
+ // down as coloured rings, as the desktop does.
12021
+ function drawCircularTimeRuler(info, anchor, maxTipX, corr, g, grid, ink) {
12022
+ let depthOf; // a date -> depth, the axis the radius is drawn from
12023
+ let ticks = [];
12024
+ let from; // the root end and the tip end, as dates
12025
+ let to;
12026
+ let bands = [];
12027
+ if (info.type === 'geologic') {
12028
+ let rootAge = info.rootAge;
12029
+ if (!(rootAge > 0)) {
12030
+ return;
12031
+ }
12032
+ let anchorAge = anchor ? anchor.date.value : rootAge;
12033
+ let anchorX = anchor ? anchor.y : _root.y;
12034
+ depthOf = function (age) {
12035
+ return anchorX + ((anchorAge - age) * corr);
12036
+ };
12037
+ from = rootAge;
12038
+ to = Math.max(0, anchorAge - ((maxTipX - anchorX) / corr));
12039
+ forester.maAxisTickValues(rootAge).forEach(function (v) {
12040
+ if (v >= to - 1e-9 && v <= rootAge + 1e-9) {
12041
+ ticks.push({value: v, text: String(v)});
12042
+ }
12043
+ });
12044
+ if (to > 0) {
12045
+ ticks.push({value: to, text: String(Math.round(to * 100) / 100), first: true});
12046
+ }
12047
+ bands = forester.geoOverlapping(forester.geoBandRanks(to, rootAge)[1], to, rootAge);
12048
+ } else {
12049
+ let present = info.presentDate;
12050
+ if (!(present > 0)) {
12051
+ return;
12052
+ }
12053
+ let anchorYear = anchor ? anchor.date.value : present;
12054
+ let anchorX = anchor ? anchor.y : maxTipX;
12055
+ depthOf = function (yv) {
12056
+ return anchorX - ((anchorYear - yv) * corr);
12057
+ };
12058
+ from = anchorYear - ((anchorX - _root.y) / corr);
12059
+ to = present;
12060
+ forester.calendarTickYears(from, to).forEach(function (yv) {
12061
+ ticks.push({value: yv, text: String(Math.round(yv))});
12062
+ });
12063
+ }
12064
+ let radiusOf = function (v) {
12065
+ return Math.max(0, radialRadius(depthOf(v)));
12066
+ };
12067
+ let r0 = radiusOf(from);
12068
+ let r1 = radiusOf(to);
12069
+ if (!(r1 > r0)) {
12070
+ return;
12071
+ }
12072
+ // behind the tree: the geologic rings, then the grid rings
12073
+ let behind = null;
12074
+ if (bands.length > 0 || grid) {
12075
+ behind = grid || _svgGroup.insert('g', 'g').attr('class', 'aptx-timegrid')
12076
+ .style('pointer-events', 'none');
12077
+ }
12078
+ let ring = d3.arc().startAngle(0).endAngle(2 * Math.PI);
12079
+ let named = [];
12080
+ bands.forEach(function (iv) {
12081
+ let a = radiusOf(Math.min(iv.old, from));
12082
+ let b = radiusOf(Math.max(iv.young, to));
12083
+ if (b - a <= 0) {
12084
+ return;
12085
+ }
12086
+ behind.append('path').attr('d', ring.innerRadius(a).outerRadius(b)())
12087
+ .attr('fill', iv.color).attr('fill-opacity', 0.35);
12088
+ named.push({r: (a + b) / 2, span: b - a, text: iv.name, color: iv.color});
12089
+ });
12090
+ if (grid) {
12091
+ ticks.forEach(function (t) {
12092
+ let r = radiusOf(t.value);
12093
+ if (r > r0 + 0.5 && r < r1 - 0.5) {
12094
+ grid.append('circle').attr('r', r).attr('fill', 'none')
12095
+ .attr('stroke', ink).attr('stroke-opacity', 0.18).attr('stroke-width', 1);
12096
+ }
12097
+ });
12098
+ }
12099
+
12100
+ // the ruler, on the gap's centre line
12101
+ let gap = _radialRotation + _radial.angleSpan + ((2 * Math.PI - _radial.angleSpan) / 2);
12102
+ let screen = gap - (Math.PI / 2);
12103
+ let along = [Math.cos(screen), Math.sin(screen)];
12104
+ let across = [-along[1], along[0]];
12105
+ let flip = along[0] < -1e-9; // keep every number upright
12106
+ let deg = (screen * 180 / Math.PI) + (flip ? 180 : 0);
12107
+ let at = function (r, off) {
12108
+ return [(along[0] * r) + (across[0] * off), (along[1] * r) + (across[1] * off)];
12109
+ };
12110
+ let ax = g.append('g').attr('class', 'aptx-time-ruler');
12111
+ let e0 = at(r0, 0);
12112
+ let e1 = at(r1, 0);
12113
+ ax.append('line').attr('x1', e0[0]).attr('y1', e0[1]).attr('x2', e1[0]).attr('y2', e1[1])
12114
+ .attr('stroke', ink).attr('stroke-width', 1);
12115
+ // numbers on one side of the ruler, interval names on the other; the
12116
+ // headline value first, then the round ticks outward, none overlapping
12117
+ function label(r, off, text, fill, backdrop) {
12118
+ let p = at(r, off);
12119
+ let t = ax.append('g')
12120
+ .attr('transform', 'translate(' + p[0] + ',' + p[1] + ') rotate(' + deg + ')');
12121
+ let w = text.length * 5.5;
12122
+ if (backdrop) {
12123
+ t.append('rect').attr('x', -(w / 2) - 2).attr('y', -6).attr('width', w + 4).attr('height', 12)
12124
+ .attr('rx', 2).attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
12125
+ }
12126
+ t.append('text').attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
12127
+ .style('font-size', '9px').style('fill', fill).text(text);
12128
+ }
12129
+ let side = flip ? -1 : 1;
12130
+ let placed = [];
12131
+ ticks.sort(function (p, q) {
12132
+ return (q.first ? 1 : 0) - (p.first ? 1 : 0) || radiusOf(p.value) - radiusOf(q.value);
12133
+ });
12134
+ ticks.forEach(function (t) {
12135
+ let r = radiusOf(t.value);
12136
+ let c0 = at(r, -4);
12137
+ let c1 = at(r, 4);
12138
+ ax.append('line').attr('x1', c0[0]).attr('y1', c0[1]).attr('x2', c1[0]).attr('y2', c1[1])
12139
+ .attr('stroke', ink).attr('stroke-width', 1);
12140
+ let half = t.text.length * 2.8;
12141
+ for (let i = 0; i < placed.length; ++i) {
12142
+ if (Math.abs(placed[i] - r) < (half * 2) + 6) {
12143
+ if (!t.first) {
12144
+ return;
12145
+ }
12146
+ }
12147
+ }
12148
+ label(r, side * 11, t.text, ink, true);
12149
+ placed.push(r);
12150
+ });
12151
+ if (info.type === 'geologic') {
12152
+ label(r1 + 14, 0, 'Ma', ink, false);
12153
+ named.forEach(function (n) {
12154
+ if ((n.text.length * 5.5) + 4 <= n.span) {
12155
+ label(n.r, -side * 11, n.text, ink, true);
12156
+ }
12157
+ });
12158
+ }
12159
+ }
12160
+
11914
12161
  function timeAxisCbClicked() {
11915
12162
  _state.showTimeAxis = getCheckboxValue(TIME_AXIS_CB);
11916
12163
  scheduleUpdate(null, 0);
@@ -13067,12 +13314,13 @@ function (root, d3, forester, phyloXml) {
13067
13314
  syncHeatmapControls();
13068
13315
  let timeCb = byId(TIME_AXIS_CB);
13069
13316
  if (timeCb) {
13070
- timeCb.disabled = radialDisplay();
13317
+ // circular draws it along the gap; only unrooted cannot
13318
+ timeCb.disabled = _state.unrootedDisplay === true;
13071
13319
  }
13072
13320
  let timeGridCb = byId(TIME_GRID_CB);
13073
13321
  if (timeGridCb) {
13074
13322
  // grid lines hang off the time axis: no axis, nothing to grid
13075
- timeGridCb.disabled = radialDisplay() || _state.showTimeAxis !== true;
13323
+ timeGridCb.disabled = _state.unrootedDisplay === true || _state.showTimeAxis !== true;
13076
13324
  }
13077
13325
  let minus = byId(ZOOM_OUT_X);
13078
13326
  let plus = byId(ZOOM_IN_X);
@@ -13743,7 +13991,7 @@ function (root, d3, forester, phyloXml) {
13743
13991
  const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
13744
13992
  // Download too (Christian, 2026-09-28): four rows, and not something a
13745
13993
  // session uses often.
13746
- const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures', 'Download'];
13994
+ const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['View & Tools', 'Sizes', 'Domain Architectures', 'Download'];
13747
13995
 
13748
13996
  function loadPanelSections() {
13749
13997
  if (_panelSections) {
@@ -13758,6 +14006,11 @@ function (root, d3, forester, phyloXml) {
13758
14006
  _panelSections[k] = saved[k];
13759
14007
  }
13760
14008
  });
14009
+ // the section was called Zoom until 2026-09-30: a fold
14010
+ // remembered under that name still counts
14011
+ if (typeof saved.Zoom === 'boolean' && _panelSections['View & Tools'] === undefined) {
14012
+ _panelSections['View & Tools'] = saved.Zoom;
14013
+ }
13761
14014
  }
13762
14015
  } catch {
13763
14016
  // no storage (private mode), or junk written by something else:
@@ -16362,14 +16615,14 @@ function (root, d3, forester, phyloXml) {
16362
16615
  h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, PHYLOGRAM_ALIGNED_WHAT));
16363
16616
  h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, CLADOGRAM_WHAT));
16364
16617
  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,
16618
+ // The branch SCALE, when the tree can be laid out by time and by
16619
+ // divergence (forester.hasTimeAndDivergence). Hidden otherwise,
16367
16620
  // which is most trees.
16368
16621
  h = h.concat('<div class="' + BRANCH_SCALE_CONTROLGROUP + ' aptx-segmented">');
16369
16622
  h = h.concat(makeSegment('Time', BRANCH_SCALE_TIME_BUTTON, 'branch_scale_radio',
16370
- 'branch lengths measure TIME (the nodes\' dates)'));
16623
+ '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
16624
  h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
16372
- 'branch lengths measure DIVERGENCE (substitutions, as the file states them)'));
16625
+ '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
16626
  h = h.concat('</div>');
16374
16627
  h = h.concat('</div>');
16375
16628
  h = h.concat('</fieldset>');
@@ -16471,8 +16724,8 @@ function (root, d3, forester, phyloXml) {
16471
16724
  if (_timeInfo && _timeInfo.type) {
16472
16725
  opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
16473
16726
  + (_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 '
16727
+ + ' and node-age bars (phylogram; rectangular and circular layouts)'));
16728
+ opts.push(makeCheckboxItem('Time Grid', TIME_GRID_CB, 'to show/hide grid lines (rings, in circular) at the '
16476
16729
  + (_timeInfo.type === 'geologic' ? 'geologic interval boundaries' : 'calendar year ticks')));
16477
16730
  }
16478
16731
 
@@ -16491,7 +16744,7 @@ function (root, d3, forester, phyloXml) {
16491
16744
  // syncZoomRowButtons). The Y buttons span the whole row.
16492
16745
  let h = "";
16493
16746
  h = h.concat('<fieldset>');
16494
- h = h.concat('<legend>Zoom</legend>');
16747
+ h = h.concat('<legend>View &amp; Tools</legend>');
16495
16748
  h = h.concat('<div class="aptx-zoomgrid">');
16496
16749
  h = h.concat(makeButton('Y+', ZOOM_IN_Y, 'zoom in vertically (or Shift+mousewheel)'));
16497
16750
  h = h.concat('<div class="aptx-zoomrow">');
@@ -16503,8 +16756,9 @@ function (root, d3, forester, phyloXml) {
16503
16756
  h = h.concat('</div>');
16504
16757
  h = h.concat(makeButton('Y-', ZOOM_OUT_Y, 'zoom out vertically (or Shift+mousewheel)'));
16505
16758
  h = h.concat('</div>');
16506
- // the tool buttons share the Zoom section rather than paying for
16507
- // a fieldset of their own
16759
+ // the tool buttons share the section with the zoom row rather
16760
+ // than paying for a fieldset of their own -- hence its name
16761
+ // (Christian, 2026-09-30: "ZOOM" named only half of it)
16508
16762
  h = h.concat('<div class="aptx-toolrow">');
16509
16763
  h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
16510
16764
  h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
@@ -16936,14 +17190,7 @@ function (root, d3, forester, phyloXml) {
16936
17190
  disableCheckbox('#' + PHYLOGRAM_BUTTON);
16937
17191
  disableCheckbox('#' + PHYLOGRAM_ALIGNED_BUTTON);
16938
17192
  }
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
- }
17193
+ syncBranchScaleControls();
16947
17194
  }
16948
17195
 
16949
17196
 
@@ -17481,7 +17728,15 @@ function (root, d3, forester, phyloXml) {
17481
17728
  return _root;
17482
17729
  }
17483
17730
  let name = _root_const.name ? String(_root_const.name).trim() : '';
17484
- return Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17731
+ // what the phylogeny itself states goes with the part: without its
17732
+ // branch_length_unit a subtree saved from Div opened again as time
17733
+ let part = Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17734
+ ['rooted', 'rerootable', 'type', 'branch_length_unit'].forEach(function (k) {
17735
+ if (_root_const[k] !== undefined && _root_const[k] !== null) {
17736
+ part[k] = _root_const[k];
17737
+ }
17738
+ });
17739
+ return part;
17485
17740
  }
17486
17741
 
17487
17742
  // "subtree, 19 of 50 tips" while a subtree is shown, else null: said in