archaeopteryx 3.7.0 → 3.9.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,8 +20,8 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.7.0
24
- // 2026-09-10
23
+ // v 3.9.0
24
+ // 2026-09-17
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
27
27
  // analysis of highly annotated phylogenetic trees.
@@ -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.7.0';
106
+ const VERSION = '3.9.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';
@@ -277,6 +277,7 @@ function (root, d3, forester, phyloXml) {
277
277
  const PANEL_STYLE_ID = 'aptx-panel-styles';
278
278
  // How wide a legend row is treated as, for grabbing it with the mouse.
279
279
  const PANEL_WIDTH = 214; // fixed control-panel width; shared by the .aptx-panel CSS and leftPanelClearance() so the two can't drift
280
+ const PANEL_WIDTH_COMPACT = 196; // the same, at panelDensity 'compact'; panelWidth() picks between them
280
281
  const SLIDER_CLASS = 'aptx-slider';
281
282
  const SLIDER_STEP = 0.5;
282
283
  const SPECIATION_COLOR = '#009E73';
@@ -517,6 +518,10 @@ function (root, d3, forester, phyloXml) {
517
518
  let _trees = []; // every tree of the launch (one, or all a file held); _treeData is _trees[_treeIndex]
518
519
  let _treeIndex = 0;
519
520
  let _launchConfig = null; // the config as launch() received it, for showTree()
521
+ let _treeViews = null; // tree object -> the view it was last left in (see viewForTree)
522
+ let _panelSections = null; // panel section name -> true when folded; the user's furniture, remembered (see loadPanelSections)
523
+ let _panelOpenOrder = new Map(); // section name -> when it was opened, so the fit rule folds the oldest first
524
+ let _panelOpenSeq = 0;
520
525
  let _viewOps = {order: null, root: null}; // the tree operations a shared view replays: ladderize direction, midpoint rooting
521
526
  let _lastViewEncoded; // the view last reported to onViewChange; undefined = not settled yet
522
527
  let _viewNotifyPending = false;
@@ -4864,6 +4869,7 @@ function (root, d3, forester, phyloXml) {
4864
4869
  'nhExportWriteConfidences',
4865
4870
  'nodeLabels',
4866
4871
  'onViewChange',
4872
+ 'panelDensity',
4867
4873
  'pngExportScale',
4868
4874
  'rootOffset',
4869
4875
  'supportDotMinimum',
@@ -4939,8 +4945,14 @@ function (root, d3, forester, phyloXml) {
4939
4945
  // the visualizations legend need this, and neither may hard-code it: the
4940
4946
  // panel's own geometry is the only honest source, or the two drift apart --
4941
4947
  // which is exactly how the root ended up drawn behind the panel once.
4948
+ // The panel's width for the density in force. The CSS and this function
4949
+ // must agree, or the tree's left margin and the panel drift apart.
4950
+ function panelWidth() {
4951
+ return (_settings && _settings.panelDensity === 'compact') ? PANEL_WIDTH_COMPACT : PANEL_WIDTH;
4952
+ }
4953
+
4942
4954
  function leftPanelClearance() {
4943
- return CONTROLS_0_LEFT_DEFAULT + PANEL_WIDTH + ROOT_CLEARANCE;
4955
+ return CONTROLS_0_LEFT_DEFAULT + panelWidth() + ROOT_CLEARANCE;
4944
4956
  }
4945
4957
 
4946
4958
  // The only validation initializeState() performs. Hoisted so launch() can
@@ -5283,6 +5295,11 @@ function (root, d3, forester, phyloXml) {
5283
5295
  if (_settings.collapseControlPanel === undefined) {
5284
5296
  _settings.collapseControlPanel = false;
5285
5297
  }
5298
+ if (_settings.panelDensity === undefined) {
5299
+ _settings.panelDensity = 'comfortable';
5300
+ } else if (_settings.panelDensity !== 'comfortable' && _settings.panelDensity !== 'compact') {
5301
+ throw new Error(ERROR + '"panelDensity" must be "comfortable" or "compact"');
5302
+ }
5286
5303
  if (_settings.enableDynamicSizing === undefined) {
5287
5304
  _settings.enableDynamicSizing = true;
5288
5305
  }
@@ -5549,6 +5566,7 @@ function (root, d3, forester, phyloXml) {
5549
5566
  _trees = [];
5550
5567
  _treeIndex = 0;
5551
5568
  _launchConfig = null;
5569
+ _treeViews = null;
5552
5570
  _lastViewEncoded = undefined;
5553
5571
  _viewOps = {order: null, root: null};
5554
5572
  _basicTreeProperties = null;
@@ -5624,6 +5642,16 @@ function (root, d3, forester, phyloXml) {
5624
5642
  + ' is empty or illegally formatted');
5625
5643
  }
5626
5644
  });
5645
+ // A tip-dated BEAST tree states its node ages as unitless `height`s --
5646
+ // time before the youngest tip -- which say nothing about WHEN. Where
5647
+ // the tip labels carry sampling dates and they agree on the date of
5648
+ // height 0, the tree is placed in calendar time here, before anything
5649
+ // reads a date: the axis, Color-by and the visualization candidates all
5650
+ // depend on it. A tree that does not qualify is left exactly as it was,
5651
+ // and a tree whose dates carry a unit is never touched. The desktop
5652
+ // converts at the same point, next to its internal-label policy
5653
+ // (their HeightDateConverter, 0.11.151).
5654
+ forester.convertLoadedHeightsToDates(trees);
5627
5655
  // the two view keys are checked up here with the other arguments,
5628
5656
  // before anything is touched (and where Node can test it)
5629
5657
  if (config && config.view !== undefined && config.view !== null && typeof config.view !== 'object') {
@@ -5632,9 +5660,18 @@ function (root, d3, forester, phyloXml) {
5632
5660
  if (config && config.onViewChange !== undefined && config.onViewChange !== null && typeof config.onViewChange !== 'function') {
5633
5661
  throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
5634
5662
  }
5663
+ // checked up here with the others so an embedder's typo fails at the
5664
+ // call rather than three frames deep, and so Node can test it
5665
+ if (config && config.panelDensity !== undefined
5666
+ && config.panelDensity !== 'comfortable' && config.panelDensity !== 'compact') {
5667
+ throw new Error(ERROR + '"panelDensity" must be "comfortable" or "compact"');
5668
+ }
5635
5669
  // a view names the tree it was made on; a fresh launch reports no
5636
5670
  // view until one changes (settleViewNotification)
5637
5671
  _lastViewEncoded = undefined;
5672
+ // each tree's own view is remembered for the life of this launch,
5673
+ // and only for it: a new launch starts everything fresh
5674
+ _treeViews = new Map();
5638
5675
  let view = config && config.view;
5639
5676
  let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
5640
5677
  return launchInto(container, trees, start, config);
@@ -5818,10 +5855,91 @@ function (root, d3, forester, phyloXml) {
5818
5855
  return makeViewerHandle();
5819
5856
  }
5820
5857
 
5858
+ // ================= each tree keeps its own view =====================
5859
+ // In a file holding several trees, every tree is its own workspace: you
5860
+ // leave one as you had it -- layout, display type, labels, colours,
5861
+ // sizes, tracks, both searches, the clade you switched to and the ones
5862
+ // you collapsed -- and it comes back that way (Christian, 2026-09-16).
5863
+ // This replaces "every tree opens fresh", and with it the rule that a
5864
+ // switch cleared collapsing (Christian, 2026-09-14, made when a switch
5865
+ // discarded everything else too).
5866
+ //
5867
+ // The memory IS the view state, so a remembered tree opens down exactly
5868
+ // the path a shared link opens: launchInto with cfg.view. Nothing here
5869
+ // knows about layouts or checkboxes; whatever getViewState covers is
5870
+ // remembered, and what it leaves out (zoom and pan, the legend's
5871
+ // position, the selection) stays out here too.
5872
+ //
5873
+ // A tree you have NOT opened yet is not handed the whole view -- what a
5874
+ // tree shows and colours by is read from its own content, and imposing
5875
+ // another tree's answer would waste that. It inherits the geometry
5876
+ // alone: the settings that say nothing about what is in the tree. So
5877
+ // stepping through ten trees of one file in the circular layout keeps
5878
+ // the circular layout, while each tree still labels itself its own way.
5879
+ const VIEW_CARRY_KEYS = ['layout', 'display', 'font', 'node', 'branch', 'rotation', 'horizontalLabels'];
5880
+
5881
+ // The tree on screen, as it stands, filed under the tree OBJECT: indices
5882
+ // shift when a tree is appended (representative tips), objects do not.
5883
+ // The `tree` key goes: it names the index this view came from, and
5884
+ // applying it back would read as a switch to itself.
5885
+ function rememberTreeView() {
5886
+ if (!_treeViews || !_trees[_treeIndex] || !_root_const) {
5887
+ return;
5888
+ }
5889
+ let state = getViewState();
5890
+ delete state.tree;
5891
+ _treeViews.set(_trees[_treeIndex], state);
5892
+ }
5893
+
5894
+ // What to open a tree with: the view it was left in, or -- a tree not
5895
+ // opened yet -- the current tree's geometry alone.
5896
+ function viewForTree(tree) {
5897
+ let remembered = _treeViews && _treeViews.get(tree);
5898
+ if (remembered) {
5899
+ return remembered;
5900
+ }
5901
+ let current = _root_const ? getViewState() : {};
5902
+ let carried = {};
5903
+ VIEW_CARRY_KEYS.forEach(function (k) {
5904
+ if (current[k] !== undefined) {
5905
+ carried[k] = current[k];
5906
+ }
5907
+ });
5908
+ return carried;
5909
+ }
5910
+
5911
+ // The one way from one tree of the list to another: the tree being left
5912
+ // is remembered first, and the launch config's own view (a shared link's)
5913
+ // never leaks onto the next tree -- _launchConfig is put back afterwards,
5914
+ // so every later switch still opens each tree its own way. `view` is for
5915
+ // a caller that already has one (a hash naming another tree).
5916
+ function switchToTree(index, view) {
5917
+ rememberTreeView();
5918
+ // The tree being left goes back to the way it was PARSED: its collapsed
5919
+ // clades live in the view just recorded, not on its nodes. Leaving the
5920
+ // flags on the object would change the COLOURS it comes back with --
5921
+ // the palette is dealt once per launch, over the values summarizeView
5922
+ // can see, and a tree whose nodes still carried collapse would deal it
5923
+ // over the reduced set and hand every value a different colour. A
5924
+ // shared link into a collapsed view never does that: it opens a freshly
5925
+ // parsed tree, deals over all of it, and collapses afterwards. A tree
5926
+ // coming back must not either. (Measured on the flavivirus demo: the
5927
+ // legend came back re-coloured until this line went in.)
5928
+ if (_trees[_treeIndex]) {
5929
+ clearCollapsedFlags(_trees[_treeIndex]);
5930
+ }
5931
+ let launchConfig = _launchConfig;
5932
+ let cfg = Object.assign({}, launchConfig || {});
5933
+ cfg.view = view || viewForTree(_trees[index]);
5934
+ let handle = launchInto(_container, _trees, index, cfg);
5935
+ _launchConfig = launchConfig;
5936
+ return handle;
5937
+ }
5938
+
5821
5939
  // Shows another tree of the launched list in the same container under
5822
- // the same config. The tree opens fresh -- its own presets, a clean
5823
- // view -- the way a new tab does on the desktop. The panel's picker and
5824
- // previous / next buttons come here, as does the handle's showTree().
5940
+ // the same config: as you left it, or -- never opened -- on its own
5941
+ // presets in the current geometry. The panel's picker and previous /
5942
+ // next buttons come here, as does the handle's showTree().
5825
5943
  function showTree(index) {
5826
5944
  if (!_container) {
5827
5945
  throw new Error(ERROR + 'showTree(): nothing is launched');
@@ -5832,11 +5950,11 @@ function (root, d3, forester, phyloXml) {
5832
5950
  if (index === _treeIndex) {
5833
5951
  return makeViewerHandle();
5834
5952
  }
5835
- // collapsing is part of the view, and a switched-to tree opens fresh:
5836
- // nothing collapsed, now or when you come back (Christian, 2026-09-14)
5837
- clearCollapsedFlags(_trees[_treeIndex]);
5838
- clearCollapsedFlags(_trees[index]);
5839
- return launchInto(_container, _trees, index, _launchConfig);
5953
+ // Neither tree's collapsed flags are carried on the nodes: the tree
5954
+ // being left has them recorded in its view and then cleared
5955
+ // (switchToTree), and the one arriving has applyViewState clear and
5956
+ // re-apply its own -- a view is always passed, so that always runs.
5957
+ return switchToTree(index, null);
5840
5958
  }
5841
5959
 
5842
5960
  archaeopteryx.parsePhyloXML = function (data) {
@@ -7639,9 +7757,7 @@ function (root, d3, forester, phyloXml) {
7639
7757
  return;
7640
7758
  }
7641
7759
  if (!initial && Number.isInteger(s.tree) && s.tree !== _treeIndex && s.tree >= 0 && s.tree < _trees.length) {
7642
- let cfg = Object.assign({}, _launchConfig || {});
7643
- cfg.view = s;
7644
- launchInto(_container, _trees, s.tree, cfg);
7760
+ switchToTree(s.tree, s); // the view given names another tree: it wins over what that tree remembers
7645
7761
  return;
7646
7762
  }
7647
7763
  if ((s.root === 'midpoint' || s.root === 'mad') && _viewOps.root !== s.root
@@ -7793,6 +7909,9 @@ function (root, d3, forester, phyloXml) {
7793
7909
  setValue(idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1, want.value2 ? String(want.value2) : '');
7794
7910
  updateSearchValue2Visibility(idx);
7795
7911
  updateSearchAutocomplete(idx);
7912
+ if (want.value) {
7913
+ openPanelSection('Search'); // a view carrying a search shows it
7914
+ }
7796
7915
  if (idx === 1 && want.value) {
7797
7916
  showSearchB();
7798
7917
  }
@@ -8115,6 +8234,7 @@ function (root, d3, forester, phyloXml) {
8115
8234
  }
8116
8235
 
8117
8236
  function focusSearch() {
8237
+ openPanelSection('Search'); // jumping to a box inside a folded section has to open it
8118
8238
  let f = byId(SEARCH_FIELD_0);
8119
8239
  if (f) {
8120
8240
  f.focus();
@@ -8814,6 +8934,7 @@ function (root, d3, forester, phyloXml) {
8814
8934
  // Search B is folded away until wanted: the '+ Search B' link (which
8815
8935
  // focuses it) or a view that carries a second search.
8816
8936
  function showSearchB() {
8937
+ openPanelSection('Search');
8817
8938
  let wrap = byId(SEARCH_B_WRAP);
8818
8939
  if (wrap) {
8819
8940
  wrap.style.display = '';
@@ -9009,10 +9130,13 @@ function (root, d3, forester, phyloXml) {
9009
9130
 
9010
9131
  let sc = info.type === 'calendar' ? -corr : corr;
9011
9132
 
9012
- // ---- HPD age bars (internal) + fossil range bars (tips) ----
9133
+ // ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
9013
9134
  forEachDisplayed(function (d) {
9014
- if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
9015
- || d.y === undefined) {
9135
+ // forester.isGenuineDateInterval, not a bare min/max test: the
9136
+ // bars are floored at 1px below, so a bound pair that differs only
9137
+ // by floating-point noise would draw as a visible bracket. This
9138
+ // guard is on EVERY node, tip and internal, geologic and calendar.
9139
+ if (!forester.isGenuineDateInterval(d.date) || d.y === undefined) {
9016
9140
  return;
9017
9141
  }
9018
9142
  let min = d.date.minimum;
@@ -9028,10 +9152,26 @@ function (root, d3, forester, phyloXml) {
9028
9152
  let left = Math.min(xa, xb);
9029
9153
  let w = Math.max(1, Math.abs(xb - xa));
9030
9154
  let y = d.x;
9155
+ // What a TIP's interval means is the axis's to say. On geologic
9156
+ // time it is a fossil's observed range, FAD to LAD, and is drawn
9157
+ // as one below. On CALENDAR time it is the uncertainty of a
9158
+ // sampling date -- a virus sample dated only to its month or year
9159
+ // -- which is the same kind of thing as an internal node's age
9160
+ // interval and is drawn the same way, slimmer. Either way it is
9161
+ // drawn only when it HAS a width: a tip dated to the day states
9162
+ // {d,d}, and a capped tick on every such tip is what made the
9163
+ // readers throw tip intervals away until 2026-09-17 (Christian;
9164
+ // the desktop alike). The width test is the entry guard above.
9165
+ let sampledTip = !d.children && info.type === 'calendar';
9031
9166
  if (d.children) {
9032
9167
  g.append('rect').attr('x', left).attr('y', y - 3.5)
9033
9168
  .attr('width', w).attr('height', 7)
9034
9169
  .attr('fill', HPD_BAR_COLOR);
9170
+ } else if (sampledTip) {
9171
+ g.append('rect').attr('class', 'aptx-sampling-bar')
9172
+ .attr('x', left).attr('y', y - 2.5)
9173
+ .attr('width', w).attr('height', 5)
9174
+ .attr('fill', HPD_BAR_COLOR);
9035
9175
  } else {
9036
9176
  g.append('rect').attr('x', left).attr('y', y - 2.5)
9037
9177
  .attr('width', w).attr('height', 5)
@@ -9675,6 +9815,7 @@ function (root, d3, forester, phyloXml) {
9675
9815
  // found nodes: A's colour, the hit count, the step-through. The next
9676
9816
  // search replaces them, and resetting search A clears them.
9677
9817
  function showRepresentativesAsHits(result) {
9818
+ openPanelSection('Search'); // the tips kept are search A's hits: its navigator has to be visible
9678
9819
  _foundNodes0 = new Set(result.keptTips);
9679
9820
  _searchBox0Empty = false;
9680
9821
  _searchHitIndex = -1;
@@ -9722,14 +9863,12 @@ function (root, d3, forester, phyloXml) {
9722
9863
  forester.stripShortExtension(parentName), result.tipCount);
9723
9864
  copy.description = copy.description ? copy.description + ' ' + provenance : provenance;
9724
9865
 
9725
- let display = _state.phylogram ? (_state.alignPhylogram ? 'aligned' : 'phylogram') : 'cladogram';
9726
- clearCollapsedFlags(_trees[_treeIndex]);
9727
9866
  _trees.push(copy);
9728
- let launchConfig = _launchConfig;
9729
- let cfg = Object.assign({}, launchConfig || {});
9730
- cfg.view = {display: display};
9731
- launchInto(_container, _trees, _trees.length - 1, cfg);
9732
- _launchConfig = launchConfig; // later switches open each tree its own way
9867
+ // a tree nobody has opened: switchToTree gives it this tree's
9868
+ // geometry, which is what "drawn as the tree was" amounts to --
9869
+ // the display type (phylogram, aligned or cladogram) and the
9870
+ // layout. The tree it came from is remembered as it stands.
9871
+ switchToTree(_trees.length - 1, null);
9733
9872
  }
9734
9873
 
9735
9874
  function escPressed() {
@@ -10794,6 +10933,120 @@ function (root, d3, forester, phyloXml) {
10794
10933
  }
10795
10934
  }
10796
10935
 
10936
+ // ============ the panel's sections: what is open, and what fits ========
10937
+ // Three rules, deliberately separate, for hosts with little room to give
10938
+ // (BV-BRC embeds the viewer in a page that already has its own chrome):
10939
+ //
10940
+ // - DEFAULTS. A tree arrives with the sections that DESCRIBE it open --
10941
+ // what it can be coloured by, what it shows -- plus Search, which is
10942
+ // reached for constantly (Christian, 2026-09-16). Folded are the ones
10943
+ // that are adjustments for later: zoom, sizes, the domain controls.
10944
+ // Measured on the 13-property BV-BRC demo in a 700px window: 766px of
10945
+ // panel content becomes 621px, with nothing behind a scroll.
10946
+ // - MEMORY. What the user opens and closes is theirs, and it outlives
10947
+ // the tree: kept for the page and in localStorage, like the light/dark
10948
+ // choice. It is NOT part of a tree's view -- the panel is the user's
10949
+ // furniture, not a property of the tree -- which is why it is not in
10950
+ // getViewState, and why a tree switch no longer reopens everything.
10951
+ // - FIT. Opening a section in a short window folds the section opened
10952
+ // longest ago, and ONLY while the panel would not otherwise fit. On a
10953
+ // tall screen it never fires. It is bounded by PIXELS, not by a count
10954
+ // of open sections: three open sections measure anywhere from 215px to
10955
+ // 562px, so a count would not have bounded the height at all.
10956
+ const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
10957
+ const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures'];
10958
+
10959
+ function loadPanelSections() {
10960
+ if (_panelSections) {
10961
+ return _panelSections;
10962
+ }
10963
+ _panelSections = {};
10964
+ try {
10965
+ let saved = JSON.parse(localStorage.getItem(PANEL_SECTIONS_KEY));
10966
+ if (saved && typeof saved === 'object') {
10967
+ Object.keys(saved).forEach(function (k) {
10968
+ if (typeof saved[k] === 'boolean') {
10969
+ _panelSections[k] = saved[k];
10970
+ }
10971
+ });
10972
+ }
10973
+ } catch {
10974
+ // no storage (private mode), or junk written by something else:
10975
+ // the defaults are a complete answer on their own
10976
+ }
10977
+ return _panelSections;
10978
+ }
10979
+
10980
+ function savePanelSections() {
10981
+ try {
10982
+ localStorage.setItem(PANEL_SECTIONS_KEY, JSON.stringify(_panelSections || {}));
10983
+ } catch {
10984
+ // the choice just won't survive a reload
10985
+ }
10986
+ }
10987
+
10988
+ // Every collapsible section of a panel, in the order they are drawn.
10989
+ function panelSections(panel) {
10990
+ let out = [];
10991
+ panel.querySelectorAll('fieldset').forEach(function (f) {
10992
+ let lg = f.querySelector(':scope > legend.aptx-legend-toggle');
10993
+ if (lg) {
10994
+ out.push({name: lg.textContent.trim(), fieldset: f});
10995
+ }
10996
+ });
10997
+ return out;
10998
+ }
10999
+
11000
+ function sectionFoldedInitially(name) {
11001
+ let remembered = loadPanelSections()[name];
11002
+ return (typeof remembered === 'boolean')
11003
+ ? remembered : PANEL_SECTIONS_CLOSED_BY_DEFAULT.indexOf(name) >= 0;
11004
+ }
11005
+
11006
+ // By how many pixels the panel's body overflows the room it has.
11007
+ function panelOverflow(panel) {
11008
+ let body = panel.querySelector(':scope > .aptx-body');
11009
+ return body ? (body.scrollHeight - body.clientHeight) : 0;
11010
+ }
11011
+
11012
+ // Keeps the panel to one screen: while it overflows, fold the section
11013
+ // opened longest ago. Never the one just opened -- that is the one the
11014
+ // user asked for -- and never the last one standing.
11015
+ function fitPanelSections(panel, justOpened) {
11016
+ let guard = 0;
11017
+ while (panelOverflow(panel) > 0 && ++guard < 20) {
11018
+ let open = panelSections(panel).filter(function (s) {
11019
+ return !s.fieldset.classList.contains('aptx-collapsed') && s.name !== justOpened;
11020
+ });
11021
+ if (open.length === 0) {
11022
+ return;
11023
+ }
11024
+ open.sort(function (a, b) {
11025
+ return (_panelOpenOrder.get(a.name) || 0) - (_panelOpenOrder.get(b.name) || 0);
11026
+ });
11027
+ open[0].fieldset.classList.add('aptx-collapsed');
11028
+ loadPanelSections()[open[0].name] = true;
11029
+ }
11030
+ }
11031
+
11032
+ // Unfolds a section because something the user just did has to be SEEN
11033
+ // inside it: the search box they jumped to, the hits a tool marked.
11034
+ function openPanelSection(name) {
11035
+ let panel = _container && _container.querySelector(':scope > .aptx-panel');
11036
+ if (!panel) {
11037
+ return;
11038
+ }
11039
+ panelSections(panel).forEach(function (s) {
11040
+ if (s.name === name && s.fieldset.classList.contains('aptx-collapsed')) {
11041
+ s.fieldset.classList.remove('aptx-collapsed');
11042
+ loadPanelSections()[name] = false;
11043
+ _panelOpenOrder.set(name, ++_panelOpenSeq);
11044
+ savePanelSections();
11045
+ fitPanelSections(panel, name);
11046
+ }
11047
+ });
11048
+ }
11049
+
10797
11050
  // ===================== Control-panel glyphs =====================
10798
11051
  // Vector glyphs ported from the desktop Archaeopteryx control panel (its
10799
11052
  // DisplayTypeIcon / ControlButtonIcon / LayoutIcon / ThemeToggleIcon /
@@ -11496,7 +11749,28 @@ function (root, d3, forester, phyloXml) {
11496
11749
  + '.aptx-panel .aptx-hide-btn { flex:none; width:20px; height:20px; display:grid; place-items:center; padding:0; border:1px solid var(--p-line-strong); border-radius:6px; background:var(--p-surface2); color:var(--p-muted); cursor:pointer; font-size:15px; line-height:1; }'
11497
11750
  + '.aptx-panel .aptx-hide-btn:hover { background:var(--p-accent-weak); color:var(--p-accent-ink); border-color:var(--p-accent); }'
11498
11751
  + '.aptx-panel.aptx-hidden > .aptx-body { display:none; }'
11499
- + '.aptx-panel.aptx-hidden > .' + PROG_NAME + ' { border-bottom:0; }';
11752
+ + '.aptx-panel.aptx-hidden > .' + PROG_NAME + ' { border-bottom:0; }'
11753
+ // --- compact density (the panelDensity config key) ---------------
11754
+ // Nothing is removed and nothing is hidden: the same controls,
11755
+ // tighter, for a host with little room to give. The width lives
11756
+ // here as well, and panelWidth() returns the same number so the
11757
+ // tree's left margin follows it (leftPanelClearance).
11758
+ + '.aptx-panel.aptx-compact { width:' + PANEL_WIDTH_COMPACT + 'px; font-size:10.5px; }'
11759
+ + '.aptx-panel.aptx-compact fieldset { padding:4px 9px; }'
11760
+ + '.aptx-panel.aptx-compact legend { margin:0 0 3px; }'
11761
+ + '.aptx-panel.aptx-compact .' + PROG_NAME + ' { padding:6px 9px; }'
11762
+ // the title is a fixed 12px, which wrapped onto a second line once
11763
+ // the panel narrowed: it comes down with everything else
11764
+ + '.aptx-panel.aptx-compact .' + PROGNAMELINK + ' { font-size:10.5px; }'
11765
+ + '.aptx-panel.aptx-compact .aptx-checkgrid { gap:2px 8px; }'
11766
+ + '.aptx-panel.aptx-compact .aptx-field-label { margin:5px 0 2px; }'
11767
+ + '.aptx-panel.aptx-compact .aptx-subhead { margin:4px 0 2px; }'
11768
+ + '.aptx-panel.aptx-compact input[type=range] { margin:1px 0 6px; }'
11769
+ + '.aptx-panel.aptx-compact .aptx-slider-row { margin:2px 0; }'
11770
+ + '.aptx-panel.aptx-compact input[type=button], .aptx-panel.aptx-compact .aptx-gbtn { height:22px; margin:1px 2px 1px 0; }'
11771
+ + '.aptx-panel.aptx-compact .aptx-search-row input[type=text], .aptx-panel.aptx-compact .aptx-search-row input[type=button] { height:24px; }'
11772
+ + '.aptx-panel.aptx-compact .aptx-search-menus select { height:22px; }'
11773
+ + '.aptx-panel.aptx-compact .aptx-toolrow { margin-top:3px; }';
11500
11774
  let style = document.createElement('style');
11501
11775
  style.id = PANEL_STYLE_ID;
11502
11776
  style.textContent = css;
@@ -11585,6 +11859,7 @@ function (root, d3, forester, phyloXml) {
11585
11859
  // Collapsible sections: wrap each titled fieldset's content in a body
11586
11860
  // element (so collapsing hides everything under the legend, including
11587
11861
  // bare text labels like the slider captions) and toggle it via the legend.
11862
+ let initiallyOpen = [];
11588
11863
  let fieldsets = body.querySelectorAll('fieldset');
11589
11864
  for (let i = 0; i < fieldsets.length; ++i) {
11590
11865
  let fieldset = fieldsets[i];
@@ -11602,10 +11877,34 @@ function (root, d3, forester, phyloXml) {
11602
11877
  }
11603
11878
  fieldset.appendChild(fsBody);
11604
11879
  legend.classList.add('aptx-legend-toggle');
11880
+ let name = legend.textContent.trim();
11881
+ if (sectionFoldedInitially(name)) {
11882
+ fieldset.classList.add('aptx-collapsed');
11883
+ } else {
11884
+ initiallyOpen.push(name);
11885
+ }
11605
11886
  legend.addEventListener('click', function () {
11606
- fieldset.classList.toggle('aptx-collapsed');
11887
+ let opened = !fieldset.classList.toggle('aptx-collapsed');
11888
+ loadPanelSections()[name] = !opened;
11889
+ if (opened) {
11890
+ _panelOpenOrder.set(name, ++_panelOpenSeq);
11891
+ fitPanelSections(panel, name);
11892
+ }
11893
+ savePanelSections();
11607
11894
  });
11608
11895
  }
11896
+ // What arrived open counts as opened, oldest at the BOTTOM: a panel is
11897
+ // read top-down, so when a short window forces something to fold on
11898
+ // arrival it should be the last section rather than the one describing
11899
+ // the tree at the top. Anything the user opens later is newer than all
11900
+ // of these, and folds after them.
11901
+ for (let i = initiallyOpen.length - 1; i >= 0; --i) {
11902
+ _panelOpenOrder.set(initiallyOpen[i], ++_panelOpenSeq);
11903
+ }
11904
+ // A remembered set of open sections can be taller than this window,
11905
+ // so the fit rule runs once on arrival too -- with no section to
11906
+ // spare, since the user opened none of them just now.
11907
+ fitPanelSections(panel, null);
11609
11908
 
11610
11909
  // Apply the current light/dark choice to this (and every) panel.
11611
11910
  applyPanelTheme();
@@ -11903,7 +12202,10 @@ function (root, d3, forester, phyloXml) {
11903
12202
  return null;
11904
12203
  }
11905
12204
  let hasValue = typeof date.value === 'number';
11906
- let hasRange = typeof date.minimum === 'number' && typeof date.maximum === 'number';
12205
+ // the same predicate the bars use: a bound pair that differs only by
12206
+ // floating-point noise is one number printed twice, and reading it as a
12207
+ // range put "[12.059999999999942 - 12.059999999999949]" in the tooltip
12208
+ let hasRange = forester.isGenuineDateInterval(date);
11907
12209
  let s = hasValue ? String(date.value) : '';
11908
12210
  if (hasRange) {
11909
12211
  s += ' [' + date.minimum + ' - ' + date.maximum + ']';
@@ -12272,6 +12574,9 @@ function (root, d3, forester, phyloXml) {
12272
12574
 
12273
12575
  if (c0) {
12274
12576
  c0.classList.add('aptx-panel');
12577
+ if (_settings.panelDensity === 'compact') {
12578
+ c0.classList.add('aptx-compact');
12579
+ }
12275
12580
  setStyles(c0, {
12276
12581
  'position': 'absolute',
12277
12582
  'left': CONTROLS_0_LEFT_DEFAULT,
@@ -12746,9 +13051,9 @@ function (root, d3, forester, phyloXml) {
12746
13051
 
12747
13052
  // A file with several trees: previous / picker / next in one row,
12748
13053
  // the picker listing every tree by its name (or its number). Each
12749
- // opens fresh in the same container (showTree). The end buttons are
12750
- // disabled at the ends: the panel is rebuilt per tree, so the
12751
- // state set here is always current.
13054
+ // opens in the same container as you left it (showTree). The end
13055
+ // buttons are disabled at the ends: the panel is rebuilt per tree,
13056
+ // so the state set here is always current.
12752
13057
  function makeTreePicker() {
12753
13058
  let n = _trees.length;
12754
13059
  let row = document.createElement('div');