archaeopteryx 3.6.1 → 3.8.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.6.1
23
+ // v 3.8.0
24
24
  // 2026-09-10
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.6.1';
106
+ const VERSION = '3.8.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';
@@ -380,6 +381,8 @@ function (root, d3, forester, phyloXml) {
380
381
  const INTERNAL_LABEL_CB = 'intl_cb';
381
382
  const LABEL_COLOR_SELECT_MENU = 'lcs_menu';
382
383
  const MIDPOINT_ROOT_BUTTON = 'midpointr_b';
384
+ const REPRESENTATIVES_BUTTON = 'reps_b';
385
+ const REPRESENTATIVES_DIALOG = 'reps_dialog';
383
386
  const MAD_CITATION = 'Tria F, Landan G, Dagan T. Phylogenetic rooting using minimal ancestor deviation. Nature Ecology and Evolution. 2017;1:0193';
384
387
  const UNCOLLAPSE_ALL_BUTTON = 'uncollapse_all_b';
385
388
  const TREE_PREV_BUTTON = 'tree_prev_b';
@@ -515,6 +518,10 @@ function (root, d3, forester, phyloXml) {
515
518
  let _trees = []; // every tree of the launch (one, or all a file held); _treeData is _trees[_treeIndex]
516
519
  let _treeIndex = 0;
517
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;
518
525
  let _viewOps = {order: null, root: null}; // the tree operations a shared view replays: ladderize direction, midpoint rooting
519
526
  let _lastViewEncoded; // the view last reported to onViewChange; undefined = not settled yet
520
527
  let _viewNotifyPending = false;
@@ -4862,6 +4869,7 @@ function (root, d3, forester, phyloXml) {
4862
4869
  'nhExportWriteConfidences',
4863
4870
  'nodeLabels',
4864
4871
  'onViewChange',
4872
+ 'panelDensity',
4865
4873
  'pngExportScale',
4866
4874
  'rootOffset',
4867
4875
  'supportDotMinimum',
@@ -4937,8 +4945,14 @@ function (root, d3, forester, phyloXml) {
4937
4945
  // the visualizations legend need this, and neither may hard-code it: the
4938
4946
  // panel's own geometry is the only honest source, or the two drift apart --
4939
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
+
4940
4954
  function leftPanelClearance() {
4941
- return CONTROLS_0_LEFT_DEFAULT + PANEL_WIDTH + ROOT_CLEARANCE;
4955
+ return CONTROLS_0_LEFT_DEFAULT + panelWidth() + ROOT_CLEARANCE;
4942
4956
  }
4943
4957
 
4944
4958
  // The only validation initializeState() performs. Hoisted so launch() can
@@ -5281,6 +5295,11 @@ function (root, d3, forester, phyloXml) {
5281
5295
  if (_settings.collapseControlPanel === undefined) {
5282
5296
  _settings.collapseControlPanel = false;
5283
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
+ }
5284
5303
  if (_settings.enableDynamicSizing === undefined) {
5285
5304
  _settings.enableDynamicSizing = true;
5286
5305
  }
@@ -5547,6 +5566,7 @@ function (root, d3, forester, phyloXml) {
5547
5566
  _trees = [];
5548
5567
  _treeIndex = 0;
5549
5568
  _launchConfig = null;
5569
+ _treeViews = null;
5550
5570
  _lastViewEncoded = undefined;
5551
5571
  _viewOps = {order: null, root: null};
5552
5572
  _basicTreeProperties = null;
@@ -5630,9 +5650,18 @@ function (root, d3, forester, phyloXml) {
5630
5650
  if (config && config.onViewChange !== undefined && config.onViewChange !== null && typeof config.onViewChange !== 'function') {
5631
5651
  throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
5632
5652
  }
5653
+ // checked up here with the others so an embedder's typo fails at the
5654
+ // call rather than three frames deep, and so Node can test it
5655
+ if (config && config.panelDensity !== undefined
5656
+ && config.panelDensity !== 'comfortable' && config.panelDensity !== 'compact') {
5657
+ throw new Error(ERROR + '"panelDensity" must be "comfortable" or "compact"');
5658
+ }
5633
5659
  // a view names the tree it was made on; a fresh launch reports no
5634
5660
  // view until one changes (settleViewNotification)
5635
5661
  _lastViewEncoded = undefined;
5662
+ // each tree's own view is remembered for the life of this launch,
5663
+ // and only for it: a new launch starts everything fresh
5664
+ _treeViews = new Map();
5636
5665
  let view = config && config.view;
5637
5666
  let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
5638
5667
  return launchInto(container, trees, start, config);
@@ -5816,10 +5845,91 @@ function (root, d3, forester, phyloXml) {
5816
5845
  return makeViewerHandle();
5817
5846
  }
5818
5847
 
5848
+ // ================= each tree keeps its own view =====================
5849
+ // In a file holding several trees, every tree is its own workspace: you
5850
+ // leave one as you had it -- layout, display type, labels, colours,
5851
+ // sizes, tracks, both searches, the clade you switched to and the ones
5852
+ // you collapsed -- and it comes back that way (Christian, 2026-09-16).
5853
+ // This replaces "every tree opens fresh", and with it the rule that a
5854
+ // switch cleared collapsing (Christian, 2026-09-14, made when a switch
5855
+ // discarded everything else too).
5856
+ //
5857
+ // The memory IS the view state, so a remembered tree opens down exactly
5858
+ // the path a shared link opens: launchInto with cfg.view. Nothing here
5859
+ // knows about layouts or checkboxes; whatever getViewState covers is
5860
+ // remembered, and what it leaves out (zoom and pan, the legend's
5861
+ // position, the selection) stays out here too.
5862
+ //
5863
+ // A tree you have NOT opened yet is not handed the whole view -- what a
5864
+ // tree shows and colours by is read from its own content, and imposing
5865
+ // another tree's answer would waste that. It inherits the geometry
5866
+ // alone: the settings that say nothing about what is in the tree. So
5867
+ // stepping through ten trees of one file in the circular layout keeps
5868
+ // the circular layout, while each tree still labels itself its own way.
5869
+ const VIEW_CARRY_KEYS = ['layout', 'display', 'font', 'node', 'branch', 'rotation', 'horizontalLabels'];
5870
+
5871
+ // The tree on screen, as it stands, filed under the tree OBJECT: indices
5872
+ // shift when a tree is appended (representative tips), objects do not.
5873
+ // The `tree` key goes: it names the index this view came from, and
5874
+ // applying it back would read as a switch to itself.
5875
+ function rememberTreeView() {
5876
+ if (!_treeViews || !_trees[_treeIndex] || !_root_const) {
5877
+ return;
5878
+ }
5879
+ let state = getViewState();
5880
+ delete state.tree;
5881
+ _treeViews.set(_trees[_treeIndex], state);
5882
+ }
5883
+
5884
+ // What to open a tree with: the view it was left in, or -- a tree not
5885
+ // opened yet -- the current tree's geometry alone.
5886
+ function viewForTree(tree) {
5887
+ let remembered = _treeViews && _treeViews.get(tree);
5888
+ if (remembered) {
5889
+ return remembered;
5890
+ }
5891
+ let current = _root_const ? getViewState() : {};
5892
+ let carried = {};
5893
+ VIEW_CARRY_KEYS.forEach(function (k) {
5894
+ if (current[k] !== undefined) {
5895
+ carried[k] = current[k];
5896
+ }
5897
+ });
5898
+ return carried;
5899
+ }
5900
+
5901
+ // The one way from one tree of the list to another: the tree being left
5902
+ // is remembered first, and the launch config's own view (a shared link's)
5903
+ // never leaks onto the next tree -- _launchConfig is put back afterwards,
5904
+ // so every later switch still opens each tree its own way. `view` is for
5905
+ // a caller that already has one (a hash naming another tree).
5906
+ function switchToTree(index, view) {
5907
+ rememberTreeView();
5908
+ // The tree being left goes back to the way it was PARSED: its collapsed
5909
+ // clades live in the view just recorded, not on its nodes. Leaving the
5910
+ // flags on the object would change the COLOURS it comes back with --
5911
+ // the palette is dealt once per launch, over the values summarizeView
5912
+ // can see, and a tree whose nodes still carried collapse would deal it
5913
+ // over the reduced set and hand every value a different colour. A
5914
+ // shared link into a collapsed view never does that: it opens a freshly
5915
+ // parsed tree, deals over all of it, and collapses afterwards. A tree
5916
+ // coming back must not either. (Measured on the flavivirus demo: the
5917
+ // legend came back re-coloured until this line went in.)
5918
+ if (_trees[_treeIndex]) {
5919
+ clearCollapsedFlags(_trees[_treeIndex]);
5920
+ }
5921
+ let launchConfig = _launchConfig;
5922
+ let cfg = Object.assign({}, launchConfig || {});
5923
+ cfg.view = view || viewForTree(_trees[index]);
5924
+ let handle = launchInto(_container, _trees, index, cfg);
5925
+ _launchConfig = launchConfig;
5926
+ return handle;
5927
+ }
5928
+
5819
5929
  // Shows another tree of the launched list in the same container under
5820
- // the same config. The tree opens fresh -- its own presets, a clean
5821
- // view -- the way a new tab does on the desktop. The panel's picker and
5822
- // previous / next buttons come here, as does the handle's showTree().
5930
+ // the same config: as you left it, or -- never opened -- on its own
5931
+ // presets in the current geometry. The panel's picker and previous /
5932
+ // next buttons come here, as does the handle's showTree().
5823
5933
  function showTree(index) {
5824
5934
  if (!_container) {
5825
5935
  throw new Error(ERROR + 'showTree(): nothing is launched');
@@ -5830,11 +5940,11 @@ function (root, d3, forester, phyloXml) {
5830
5940
  if (index === _treeIndex) {
5831
5941
  return makeViewerHandle();
5832
5942
  }
5833
- // collapsing is part of the view, and a switched-to tree opens fresh:
5834
- // nothing collapsed, now or when you come back (Christian, 2026-09-14)
5835
- clearCollapsedFlags(_trees[_treeIndex]);
5836
- clearCollapsedFlags(_trees[index]);
5837
- return launchInto(_container, _trees, index, _launchConfig);
5943
+ // Neither tree's collapsed flags are carried on the nodes: the tree
5944
+ // being left has them recorded in its view and then cleared
5945
+ // (switchToTree), and the one arriving has applyViewState clear and
5946
+ // re-apply its own -- a view is always passed, so that always runs.
5947
+ return switchToTree(index, null);
5838
5948
  }
5839
5949
 
5840
5950
  archaeopteryx.parsePhyloXML = function (data) {
@@ -7637,9 +7747,7 @@ function (root, d3, forester, phyloXml) {
7637
7747
  return;
7638
7748
  }
7639
7749
  if (!initial && Number.isInteger(s.tree) && s.tree !== _treeIndex && s.tree >= 0 && s.tree < _trees.length) {
7640
- let cfg = Object.assign({}, _launchConfig || {});
7641
- cfg.view = s;
7642
- launchInto(_container, _trees, s.tree, cfg);
7750
+ switchToTree(s.tree, s); // the view given names another tree: it wins over what that tree remembers
7643
7751
  return;
7644
7752
  }
7645
7753
  if ((s.root === 'midpoint' || s.root === 'mad') && _viewOps.root !== s.root
@@ -7791,6 +7899,9 @@ function (root, d3, forester, phyloXml) {
7791
7899
  setValue(idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1, want.value2 ? String(want.value2) : '');
7792
7900
  updateSearchValue2Visibility(idx);
7793
7901
  updateSearchAutocomplete(idx);
7902
+ if (want.value) {
7903
+ openPanelSection('Search'); // a view carrying a search shows it
7904
+ }
7794
7905
  if (idx === 1 && want.value) {
7795
7906
  showSearchB();
7796
7907
  }
@@ -8113,6 +8224,7 @@ function (root, d3, forester, phyloXml) {
8113
8224
  }
8114
8225
 
8115
8226
  function focusSearch() {
8227
+ openPanelSection('Search'); // jumping to a box inside a folded section has to open it
8116
8228
  let f = byId(SEARCH_FIELD_0);
8117
8229
  if (f) {
8118
8230
  f.focus();
@@ -8812,6 +8924,7 @@ function (root, d3, forester, phyloXml) {
8812
8924
  // Search B is folded away until wanted: the '+ Search B' link (which
8813
8925
  // focuses it) or a view that carries a second search.
8814
8926
  function showSearchB() {
8927
+ openPanelSection('Search');
8815
8928
  let wrap = byId(SEARCH_B_WRAP);
8816
8929
  if (wrap) {
8817
8930
  wrap.style.display = '';
@@ -9007,7 +9120,7 @@ function (root, d3, forester, phyloXml) {
9007
9120
 
9008
9121
  let sc = info.type === 'calendar' ? -corr : corr;
9009
9122
 
9010
- // ---- HPD age bars (internal) + fossil range bars (tips) ----
9123
+ // ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
9011
9124
  forEachDisplayed(function (d) {
9012
9125
  if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
9013
9126
  || d.y === undefined) {
@@ -9026,10 +9139,28 @@ function (root, d3, forester, phyloXml) {
9026
9139
  let left = Math.min(xa, xb);
9027
9140
  let w = Math.max(1, Math.abs(xb - xa));
9028
9141
  let y = d.x;
9142
+ // What a TIP's interval means is the axis's to say. On geologic
9143
+ // time it is a fossil's observed range, FAD to LAD, and is drawn
9144
+ // as one below. On CALENDAR time it is the uncertainty of a
9145
+ // sampling date -- a virus sample dated only to its month or year
9146
+ // -- which is the same kind of thing as an internal node's age
9147
+ // interval and is drawn the same way, slimmer, and only when it
9148
+ // HAS a width: a tip dated to the day states {d,d}, and a capped
9149
+ // tick on every such tip is what made the readers throw tip
9150
+ // intervals away until 2026-09-17 (Christian; the desktop alike).
9151
+ let sampledTip = !d.children && info.type === 'calendar';
9152
+ if (sampledTip && !(max > min)) {
9153
+ return;
9154
+ }
9029
9155
  if (d.children) {
9030
9156
  g.append('rect').attr('x', left).attr('y', y - 3.5)
9031
9157
  .attr('width', w).attr('height', 7)
9032
9158
  .attr('fill', HPD_BAR_COLOR);
9159
+ } else if (sampledTip) {
9160
+ g.append('rect').attr('class', 'aptx-sampling-bar')
9161
+ .attr('x', left).attr('y', y - 2.5)
9162
+ .attr('width', w).attr('height', 5)
9163
+ .attr('fill', HPD_BAR_COLOR);
9033
9164
  } else {
9034
9165
  g.append('rect').attr('x', left).attr('y', y - 2.5)
9035
9166
  .attr('width', w).attr('height', 5)
@@ -9439,6 +9570,296 @@ function (root, d3, forester, phyloXml) {
9439
9570
  }
9440
9571
  }
9441
9572
 
9573
+ // ---- representative tips ------------------------------------------------
9574
+ //
9575
+ // The desktop's Tools > Select Representative Tips, from the tool row: a
9576
+ // dialog takes a distance cutoff or a target count, the representative to
9577
+ // keep and whether the selected and found tips stay; the tips kept show
9578
+ // as search A's hits, and can be cut out into a new tree in the tree
9579
+ // picker (the desktop's new tab). The choosing is
9580
+ // forester.selectRepresentativeTips, held to the desktop's own results by
9581
+ // test/fixtures/rep-contract.tsv.
9582
+
9583
+ // the dialog's inputs, remembered while the page is open (the desktop
9584
+ // remembers them for its session)
9585
+ let _repsInputs = {byCutoff: true, cutoff: '0.05', target: '100', pickIndex: 0};
9586
+
9587
+ // what the viewer writes onto a tree's nodes, left off a tree cut out of it
9588
+ const VIEW_NODE_FIELDS = ['viewId', 'collapsed', 'x', 'y', 'x0', 'y0', 'id', 'hide', 'hasVis', 'distToRoot',
9589
+ 'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText'];
9590
+
9591
+ // the whole tree's tip count, counted again only when the tree changes
9592
+ // (every change makes new _basicTreeProperties)
9593
+ let _repsTipCount = {properties: null, count: 0};
9594
+
9595
+ function wholeTreeTipCount() {
9596
+ if (_repsTipCount.properties !== _basicTreeProperties) {
9597
+ _repsTipCount = {
9598
+ properties: _basicTreeProperties,
9599
+ count: _root_const ? forester.getAllExternalNodes(_root_const).length : 0
9600
+ };
9601
+ }
9602
+ return _repsTipCount.count;
9603
+ }
9604
+
9605
+ // why representative tips cannot be selected now, or null
9606
+ function representativesBlockedReason() {
9607
+ if (_in_subtree) {
9608
+ return 'return to the whole tree to select representative tips';
9609
+ }
9610
+ if (!_root_const || wholeTreeTipCount() < 3) {
9611
+ return 'the tree needs at least three tips to select representatives';
9612
+ }
9613
+ return null;
9614
+ }
9615
+
9616
+ function repsElement(parent, tag, className, text) {
9617
+ let e = document.createElement(tag);
9618
+ if (className) {
9619
+ e.className = className;
9620
+ }
9621
+ if (text !== undefined) {
9622
+ e.textContent = text;
9623
+ }
9624
+ parent.appendChild(e);
9625
+ return e;
9626
+ }
9627
+
9628
+ function repsButton(parent, label, primary, action) {
9629
+ let b = repsElement(parent, 'button', 'aptx-reps-button' + (primary ? ' aptx-reps-primary' : ''), label);
9630
+ b.type = 'button';
9631
+ b.addEventListener('click', action);
9632
+ return b;
9633
+ }
9634
+
9635
+ // Java's Double.parseDouble and Integer.parseInt, which read the desktop's
9636
+ // fields: null for what they refuse
9637
+ function javaParseDouble(s) {
9638
+ let m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)[fFdD]?$/.exec(s);
9639
+ if (m) {
9640
+ return Number(m[1]);
9641
+ }
9642
+ return /^[+-]?(?:Infinity|NaN)$/.test(s) ? Number(s) : null;
9643
+ }
9644
+
9645
+ function javaParseInt(s) {
9646
+ if (!/^[+-]?\d+$/.test(s)) {
9647
+ return null;
9648
+ }
9649
+ let v = Number(s);
9650
+ return (v < -2147483648 || v > 2147483647) ? null : v;
9651
+ }
9652
+
9653
+ function representativesButtonPressed() {
9654
+ if (representativesBlockedReason()) {
9655
+ return;
9656
+ }
9657
+ // the selected and found tips can be kept; taken now, before the
9658
+ // result replaces search A's hits
9659
+ let protectable = [];
9660
+ let seen = new Set();
9661
+ [_selectedNodes, _foundNodes0, _foundNodes1].forEach(function (nodes) {
9662
+ (nodes || []).forEach(function (n) {
9663
+ if ((!n.children || n.children.length === 0) && !seen.has(n)) {
9664
+ seen.add(n);
9665
+ protectable.push(n);
9666
+ }
9667
+ });
9668
+ });
9669
+ showRepresentativesDialog(_treeData, forester.hasUsableBranchLengths(_treeData), protectable);
9670
+ }
9671
+
9672
+ // The desktop's input dialog, its texts and checks included; a distance
9673
+ // cutoff needs branch lengths.
9674
+ function showRepresentativesDialog(phy, hasLengths, protectable) {
9675
+ let shell = makeDialogShell(REPRESENTATIVES_DIALOG, 'Select representative tips', 330);
9676
+ let body = shell.body;
9677
+ body.classList.add('aptx-reps');
9678
+ let startByCutoff = hasLengths && _repsInputs.byCutoff;
9679
+
9680
+ repsElement(body, 'div', 'aptx-reps-lead', 'Select one representative per group of similar tips:');
9681
+ let choice = function (type, label, checked, disabled) {
9682
+ let row = repsElement(body, 'label', 'aptx-reps-choice' + (disabled ? ' aptx-reps-disabled' : ''));
9683
+ let input = repsElement(row, 'input');
9684
+ input.type = type;
9685
+ input.name = REPRESENTATIVES_DIALOG + '_' + type;
9686
+ input.checked = checked;
9687
+ input.disabled = disabled;
9688
+ repsElement(row, 'span', null, label);
9689
+ return input;
9690
+ };
9691
+ let byCutoff = choice('radio', 'By distance cutoff', startByCutoff, !hasLengths);
9692
+ let byTarget = choice('radio', 'By target number of representatives', !startByCutoff, false);
9693
+ if (!hasLengths) {
9694
+ repsElement(body, 'div', 'aptx-reps-note',
9695
+ 'This tree has no branch lengths, so only "target number" is available.');
9696
+ }
9697
+
9698
+ repsElement(body, 'div', 'aptx-dialog-head', 'Value');
9699
+ let value = repsElement(body, 'input', 'aptx-reps-input');
9700
+ value.type = 'text';
9701
+ value.spellcheck = false;
9702
+ value.autocomplete = 'off';
9703
+ value.value = startByCutoff ? _repsInputs.cutoff : _repsInputs.target;
9704
+ let hint = repsElement(body, 'div', 'aptx-reps-note');
9705
+
9706
+ repsElement(body, 'div', 'aptx-dialog-head', 'Representative');
9707
+ let pick = repsElement(body, 'select', 'aptx-reps-input');
9708
+ ['Most central (medoid)', 'Most divergent (longest branch)'].forEach(function (label) {
9709
+ repsElement(pick, 'option', null, label);
9710
+ });
9711
+ pick.selectedIndex = _repsInputs.pickIndex;
9712
+
9713
+ let n = protectable.length;
9714
+ let protect = choice('checkbox', n > 0
9715
+ ? 'Keep the ' + n + ' selected/found ' + (n === 1 ? 'tip' : 'tips') + ' (never drop them)'
9716
+ : 'Keep selected/found tips (none are selected)', n > 0, n === 0);
9717
+ protect.parentNode.classList.add('aptx-reps-protect');
9718
+
9719
+ let error = repsElement(body, 'div', 'aptx-reps-error');
9720
+ let sync = function () {
9721
+ hint.textContent = byCutoff.checked
9722
+ ? 'Maximum distance between any two tips kept in the same group.'
9723
+ : 'Approximate number of representative tips to keep.';
9724
+ error.textContent = '';
9725
+ };
9726
+ byCutoff.addEventListener('change', function () {
9727
+ value.value = _repsInputs.cutoff;
9728
+ sync();
9729
+ });
9730
+ byTarget.addEventListener('change', function () {
9731
+ value.value = _repsInputs.target;
9732
+ sync();
9733
+ });
9734
+ sync();
9735
+
9736
+ let submit = function () {
9737
+ let text = value.value.trim();
9738
+ let cutoff = 0;
9739
+ let target = 0;
9740
+ if (byCutoff.checked) {
9741
+ cutoff = javaParseDouble(text);
9742
+ if (cutoff === null) {
9743
+ error.textContent = 'Please enter a numeric distance cutoff (for example 0.05).';
9744
+ return;
9745
+ }
9746
+ if (!isFinite(cutoff) || cutoff <= 0) {
9747
+ error.textContent = 'The distance cutoff must be a positive number.';
9748
+ return;
9749
+ }
9750
+ } else {
9751
+ target = javaParseInt(text);
9752
+ if (target === null) {
9753
+ error.textContent = 'Please enter a whole number of representatives (for example 100).';
9754
+ return;
9755
+ }
9756
+ if (target < 1) {
9757
+ error.textContent = 'The target number of representatives must be at least 1.';
9758
+ return;
9759
+ }
9760
+ }
9761
+ // a tree without branch lengths forced the target: that is not a preference
9762
+ if (hasLengths) {
9763
+ _repsInputs.byCutoff = byCutoff.checked;
9764
+ }
9765
+ if (byCutoff.checked) {
9766
+ _repsInputs.cutoff = text;
9767
+ } else {
9768
+ _repsInputs.target = text;
9769
+ }
9770
+ _repsInputs.pickIndex = pick.selectedIndex;
9771
+ shell.dialog.close();
9772
+
9773
+ let options = {
9774
+ pick: pick.selectedIndex === 1 ? forester.REPRESENTATIVE_LONGEST_BRANCH : forester.REPRESENTATIVE_MEDOID,
9775
+ protectedTips: protect.checked ? protectable : []
9776
+ };
9777
+ if (byCutoff.checked) {
9778
+ options.cutoff = cutoff;
9779
+ } else {
9780
+ options.target = target;
9781
+ }
9782
+ let result = forester.selectRepresentativeTips(phy, options);
9783
+ showRepresentativesAsHits(result);
9784
+ showRepresentativesResult(phy, result, byCutoff.checked, cutoff, target, options.pick);
9785
+ };
9786
+ value.addEventListener('keydown', function (event) {
9787
+ if (event.key === 'Enter') {
9788
+ event.preventDefault();
9789
+ submit();
9790
+ }
9791
+ });
9792
+
9793
+ let actions = repsElement(body, 'div', 'aptx-reps-actions');
9794
+ repsButton(actions, 'Cancel', false, function () {
9795
+ shell.dialog.close();
9796
+ });
9797
+ repsButton(actions, 'Select', true, submit);
9798
+ shell.dialog.showModal();
9799
+ value.focus();
9800
+ value.select();
9801
+ }
9802
+
9803
+ // The tips kept, as search A's hits -- the desktop shows them as its
9804
+ // found nodes: A's colour, the hit count, the step-through. The next
9805
+ // search replaces them, and resetting search A clears them.
9806
+ function showRepresentativesAsHits(result) {
9807
+ openPanelSection('Search'); // the tips kept are search A's hits: its navigator has to be visible
9808
+ _foundNodes0 = new Set(result.keptTips);
9809
+ _searchBox0Empty = false;
9810
+ _searchHitIndex = -1;
9811
+ scheduleUpdate(null, 0, true);
9812
+ updateSearchHitNavigation();
9813
+ }
9814
+
9815
+ // The desktop's summary, and its offer of a new tree of the tips kept.
9816
+ function showRepresentativesResult(phy, result, byCutoff, cutoff, target, pick) {
9817
+ let shell = makeDialogShell(REPRESENTATIVES_DIALOG, 'Representative tips selected', 360);
9818
+ shell.body.classList.add('aptx-reps');
9819
+ result.summary.split('\n').forEach(function (line) {
9820
+ repsElement(shell.body, 'div', 'aptx-reps-line', line);
9821
+ });
9822
+ let n = result.keptCount;
9823
+ repsElement(shell.body, 'div', 'aptx-reps-question',
9824
+ 'Create a new tree containing only these ' + n + (n === 1 ? ' tip?' : ' tips?'));
9825
+ let actions = repsElement(shell.body, 'div', 'aptx-reps-actions');
9826
+ repsButton(actions, 'Close', false, function () {
9827
+ shell.dialog.close();
9828
+ });
9829
+ let create = repsButton(actions, 'Create tree', true, function () {
9830
+ shell.dialog.close();
9831
+ openRepresentativeTree(phy, result, byCutoff, cutoff, target, pick);
9832
+ });
9833
+ shell.dialog.showModal();
9834
+ create.focus();
9835
+ }
9836
+
9837
+ // The tips kept, cut out into a new tree at the end of the tree picker,
9838
+ // which opens at once: named <parent>_Nreps, its description saying how
9839
+ // it was made, drawn as this tree is (phylogram, aligned or cladogram),
9840
+ // as the desktop's derived tab. This tree is left as it is.
9841
+ function openRepresentativeTree(phy, result, byCutoff, cutoff, target, pick) {
9842
+ let copy = forester.copyTreeKeepingTips(phy, result.keptTips);
9843
+ forester.preOrderTraversalAll(copy, function (n) {
9844
+ VIEW_NODE_FIELDS.forEach(function (f) {
9845
+ delete n[f];
9846
+ });
9847
+ });
9848
+ forester.addParents(copy);
9849
+ let parentName = (typeof phy.name === 'string' && phy.name.length > 0) ? phy.name : null;
9850
+ copy.name = forester.representativeTreeName(parentName, result.keptCount);
9851
+ let provenance = forester.representativeTreeDescription(byCutoff, cutoff, target, pick, result.keptCount,
9852
+ forester.stripShortExtension(parentName), result.tipCount);
9853
+ copy.description = copy.description ? copy.description + ' ' + provenance : provenance;
9854
+
9855
+ _trees.push(copy);
9856
+ // a tree nobody has opened: switchToTree gives it this tree's
9857
+ // geometry, which is what "drawn as the tree was" amounts to --
9858
+ // the display type (phylogram, aligned or cladogram) and the
9859
+ // layout. The tree it came from is remembered as it stands.
9860
+ switchToTree(_trees.length - 1, null);
9861
+ }
9862
+
9442
9863
  function escPressed() {
9443
9864
  if (_in_subtree) {
9444
9865
  _root = _root_const;
@@ -10501,6 +10922,120 @@ function (root, d3, forester, phyloXml) {
10501
10922
  }
10502
10923
  }
10503
10924
 
10925
+ // ============ the panel's sections: what is open, and what fits ========
10926
+ // Three rules, deliberately separate, for hosts with little room to give
10927
+ // (BV-BRC embeds the viewer in a page that already has its own chrome):
10928
+ //
10929
+ // - DEFAULTS. A tree arrives with the sections that DESCRIBE it open --
10930
+ // what it can be coloured by, what it shows -- plus Search, which is
10931
+ // reached for constantly (Christian, 2026-09-16). Folded are the ones
10932
+ // that are adjustments for later: zoom, sizes, the domain controls.
10933
+ // Measured on the 13-property BV-BRC demo in a 700px window: 766px of
10934
+ // panel content becomes 621px, with nothing behind a scroll.
10935
+ // - MEMORY. What the user opens and closes is theirs, and it outlives
10936
+ // the tree: kept for the page and in localStorage, like the light/dark
10937
+ // choice. It is NOT part of a tree's view -- the panel is the user's
10938
+ // furniture, not a property of the tree -- which is why it is not in
10939
+ // getViewState, and why a tree switch no longer reopens everything.
10940
+ // - FIT. Opening a section in a short window folds the section opened
10941
+ // longest ago, and ONLY while the panel would not otherwise fit. On a
10942
+ // tall screen it never fires. It is bounded by PIXELS, not by a count
10943
+ // of open sections: three open sections measure anywhere from 215px to
10944
+ // 562px, so a count would not have bounded the height at all.
10945
+ const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
10946
+ const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures'];
10947
+
10948
+ function loadPanelSections() {
10949
+ if (_panelSections) {
10950
+ return _panelSections;
10951
+ }
10952
+ _panelSections = {};
10953
+ try {
10954
+ let saved = JSON.parse(localStorage.getItem(PANEL_SECTIONS_KEY));
10955
+ if (saved && typeof saved === 'object') {
10956
+ Object.keys(saved).forEach(function (k) {
10957
+ if (typeof saved[k] === 'boolean') {
10958
+ _panelSections[k] = saved[k];
10959
+ }
10960
+ });
10961
+ }
10962
+ } catch {
10963
+ // no storage (private mode), or junk written by something else:
10964
+ // the defaults are a complete answer on their own
10965
+ }
10966
+ return _panelSections;
10967
+ }
10968
+
10969
+ function savePanelSections() {
10970
+ try {
10971
+ localStorage.setItem(PANEL_SECTIONS_KEY, JSON.stringify(_panelSections || {}));
10972
+ } catch {
10973
+ // the choice just won't survive a reload
10974
+ }
10975
+ }
10976
+
10977
+ // Every collapsible section of a panel, in the order they are drawn.
10978
+ function panelSections(panel) {
10979
+ let out = [];
10980
+ panel.querySelectorAll('fieldset').forEach(function (f) {
10981
+ let lg = f.querySelector(':scope > legend.aptx-legend-toggle');
10982
+ if (lg) {
10983
+ out.push({name: lg.textContent.trim(), fieldset: f});
10984
+ }
10985
+ });
10986
+ return out;
10987
+ }
10988
+
10989
+ function sectionFoldedInitially(name) {
10990
+ let remembered = loadPanelSections()[name];
10991
+ return (typeof remembered === 'boolean')
10992
+ ? remembered : PANEL_SECTIONS_CLOSED_BY_DEFAULT.indexOf(name) >= 0;
10993
+ }
10994
+
10995
+ // By how many pixels the panel's body overflows the room it has.
10996
+ function panelOverflow(panel) {
10997
+ let body = panel.querySelector(':scope > .aptx-body');
10998
+ return body ? (body.scrollHeight - body.clientHeight) : 0;
10999
+ }
11000
+
11001
+ // Keeps the panel to one screen: while it overflows, fold the section
11002
+ // opened longest ago. Never the one just opened -- that is the one the
11003
+ // user asked for -- and never the last one standing.
11004
+ function fitPanelSections(panel, justOpened) {
11005
+ let guard = 0;
11006
+ while (panelOverflow(panel) > 0 && ++guard < 20) {
11007
+ let open = panelSections(panel).filter(function (s) {
11008
+ return !s.fieldset.classList.contains('aptx-collapsed') && s.name !== justOpened;
11009
+ });
11010
+ if (open.length === 0) {
11011
+ return;
11012
+ }
11013
+ open.sort(function (a, b) {
11014
+ return (_panelOpenOrder.get(a.name) || 0) - (_panelOpenOrder.get(b.name) || 0);
11015
+ });
11016
+ open[0].fieldset.classList.add('aptx-collapsed');
11017
+ loadPanelSections()[open[0].name] = true;
11018
+ }
11019
+ }
11020
+
11021
+ // Unfolds a section because something the user just did has to be SEEN
11022
+ // inside it: the search box they jumped to, the hits a tool marked.
11023
+ function openPanelSection(name) {
11024
+ let panel = _container && _container.querySelector(':scope > .aptx-panel');
11025
+ if (!panel) {
11026
+ return;
11027
+ }
11028
+ panelSections(panel).forEach(function (s) {
11029
+ if (s.name === name && s.fieldset.classList.contains('aptx-collapsed')) {
11030
+ s.fieldset.classList.remove('aptx-collapsed');
11031
+ loadPanelSections()[name] = false;
11032
+ _panelOpenOrder.set(name, ++_panelOpenSeq);
11033
+ savePanelSections();
11034
+ fitPanelSections(panel, name);
11035
+ }
11036
+ });
11037
+ }
11038
+
10504
11039
  // ===================== Control-panel glyphs =====================
10505
11040
  // Vector glyphs ported from the desktop Archaeopteryx control panel (its
10506
11041
  // DisplayTypeIcon / ControlButtonIcon / LayoutIcon / ThemeToggleIcon /
@@ -10770,6 +11305,16 @@ function (root, d3, forester, phyloXml) {
10770
11305
  + glyphDot(50, 50, 11);
10771
11306
  }
10772
11307
 
11308
+ // Representative tips: two clades of two tips, one tip of each kept --
11309
+ // the dot. The desktop has it only as a menu item, so it is drawn to
11310
+ // match the midpoint glyph's weight.
11311
+ function glyphRepresentatives() {
11312
+ return '<path d="M10,30 V70 M10,30 H38 M10,70 H38 M38,16 V44 M38,56 V84'
11313
+ + ' M38,16 H66 M38,44 H66 M38,56 H66 M38,84 H66"/>'
11314
+ + glyphDot(84, 16, 10)
11315
+ + glyphDot(84, 84, 10);
11316
+ }
11317
+
10773
11318
  // Build one glyph as an inline <svg>. Stroke weight, caps and joins follow
10774
11319
  // the desktop class each glyph came from.
10775
11320
  function makeGlyph(kind) {
@@ -10794,6 +11339,7 @@ function (root, d3, forester, phyloXml) {
10794
11339
  case 'ladderize_asc': sw = 8; cap = 'round'; body = glyphLadderize(true); break;
10795
11340
  case 'ladderize_desc': sw = 8; cap = 'round'; body = glyphLadderize(false); break;
10796
11341
  case 'midpoint': sw = 8; cap = 'round'; body = glyphMidpoint(); break;
11342
+ case 'representatives': sw = 8; cap = 'round'; body = glyphRepresentatives(); break;
10797
11343
  case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
10798
11344
  case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
10799
11345
  case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
@@ -10985,6 +11531,29 @@ function (root, d3, forester, phyloXml) {
10985
11531
  + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
10986
11532
  + '.aptx-kbd { font-family:inherit; font-size:10.5px; line-height:1; padding:3px 5px; min-width:18px; text-align:center; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-bottom-width:2px; border-radius:4px; }'
10987
11533
  + '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
11534
+ // representative tips: the input dialog and its result
11535
+ + '.aptx-reps { line-height:1.35; }'
11536
+ + '.aptx-reps-lead { margin:0 0 4px; }'
11537
+ + '.aptx-reps-choice { display:flex; align-items:center; gap:6px; padding:2px 0; cursor:pointer; }'
11538
+ + '.aptx-reps-choice input { accent-color:var(--p-accent); width:13px; height:13px; margin:0; flex:none; }'
11539
+ + '.aptx-reps-disabled { color:var(--p-faint); cursor:default; }'
11540
+ + '.aptx-reps-input { display:block; box-sizing:border-box; width:100%; font:inherit; color:var(--p-ink);'
11541
+ + ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; padding:4px 7px; }'
11542
+ + '.aptx-reps-input:focus { outline:none; border-color:var(--p-accent); box-shadow:0 0 0 3px var(--p-accent-weak); }'
11543
+ + '.aptx-reps-note { margin-top:3px; font-size:10.5px; color:var(--p-muted); }'
11544
+ + '.aptx-reps-protect { margin-top:10px; }'
11545
+ + '.aptx-reps-error { margin-top:6px; font-size:10.5px; color:#e5484d; }'
11546
+ + '.aptx-reps-error:empty { display:none; }'
11547
+ + '.aptx-reps-line { padding:1px 0; }'
11548
+ + '.aptx-reps-question { margin-top:9px; font-weight:600; }'
11549
+ + '.aptx-reps-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }'
11550
+ + '.aptx-reps-button { font:inherit; font-size:11px; height:26px; padding:0 12px; color:var(--p-ink);'
11551
+ + ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; cursor:pointer;'
11552
+ + ' transition:background .12s,border-color .12s,color .12s; }'
11553
+ + '.aptx-reps-button:hover,.aptx-reps-primary { background:var(--p-accent-weak); border-color:var(--p-accent);'
11554
+ + ' color:var(--p-accent-ink); }'
11555
+ + '.aptx-reps-primary { font-weight:600; }'
11556
+ + '.aptx-reps-button:focus-visible { outline:none; box-shadow:0 0 0 3px var(--p-accent-weak); }'
10988
11557
  + '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
10989
11558
  + ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
10990
11559
  // The hover tooltip, on the same palette.
@@ -11123,7 +11692,9 @@ function (root, d3, forester, phyloXml) {
11123
11692
  + '.aptx-panel .aptx-slider-row input[type=range] { flex:1 1 auto; min-width:0; margin:0; }'
11124
11693
  + '.aptx-panel .aptx-linkbtn { background:none; border:0; padding:1px 0 2px; margin:0; font:inherit; font-size:10px; color:var(--p-accent); cursor:pointer; display:block; }'
11125
11694
  + '.aptx-panel .aptx-linkbtn:hover { text-decoration:underline; }'
11126
- + '.aptx-panel .aptx-toolrow { margin-top:5px; }'
11695
+ // one row, its buttons sharing it evenly, its edges on the zoom rows'
11696
+ + '.aptx-panel .aptx-toolrow { margin-top:5px; display:flex; gap:3px; }'
11697
+ + '.aptx-panel .aptx-toolrow .aptx-gbtn { flex:1 1 0; min-width:0; padding:0; margin:2px 0; }'
11127
11698
  + '.aptx-panel .aptx-searchnav { align-items:center; gap:4px; margin:2px 0 4px; }'
11128
11699
  + '.aptx-panel .aptx-searchnav span { flex:1 1 auto; text-align:center; font-weight:600; font-size:11px; color:var(--p-ink); }'
11129
11700
  + '.aptx-panel .aptx-searchnav .aptx-gbtn:last-child { margin-right:0; }'
@@ -11167,7 +11738,28 @@ function (root, d3, forester, phyloXml) {
11167
11738
  + '.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; }'
11168
11739
  + '.aptx-panel .aptx-hide-btn:hover { background:var(--p-accent-weak); color:var(--p-accent-ink); border-color:var(--p-accent); }'
11169
11740
  + '.aptx-panel.aptx-hidden > .aptx-body { display:none; }'
11170
- + '.aptx-panel.aptx-hidden > .' + PROG_NAME + ' { border-bottom:0; }';
11741
+ + '.aptx-panel.aptx-hidden > .' + PROG_NAME + ' { border-bottom:0; }'
11742
+ // --- compact density (the panelDensity config key) ---------------
11743
+ // Nothing is removed and nothing is hidden: the same controls,
11744
+ // tighter, for a host with little room to give. The width lives
11745
+ // here as well, and panelWidth() returns the same number so the
11746
+ // tree's left margin follows it (leftPanelClearance).
11747
+ + '.aptx-panel.aptx-compact { width:' + PANEL_WIDTH_COMPACT + 'px; font-size:10.5px; }'
11748
+ + '.aptx-panel.aptx-compact fieldset { padding:4px 9px; }'
11749
+ + '.aptx-panel.aptx-compact legend { margin:0 0 3px; }'
11750
+ + '.aptx-panel.aptx-compact .' + PROG_NAME + ' { padding:6px 9px; }'
11751
+ // the title is a fixed 12px, which wrapped onto a second line once
11752
+ // the panel narrowed: it comes down with everything else
11753
+ + '.aptx-panel.aptx-compact .' + PROGNAMELINK + ' { font-size:10.5px; }'
11754
+ + '.aptx-panel.aptx-compact .aptx-checkgrid { gap:2px 8px; }'
11755
+ + '.aptx-panel.aptx-compact .aptx-field-label { margin:5px 0 2px; }'
11756
+ + '.aptx-panel.aptx-compact .aptx-subhead { margin:4px 0 2px; }'
11757
+ + '.aptx-panel.aptx-compact input[type=range] { margin:1px 0 6px; }'
11758
+ + '.aptx-panel.aptx-compact .aptx-slider-row { margin:2px 0; }'
11759
+ + '.aptx-panel.aptx-compact input[type=button], .aptx-panel.aptx-compact .aptx-gbtn { height:22px; margin:1px 2px 1px 0; }'
11760
+ + '.aptx-panel.aptx-compact .aptx-search-row input[type=text], .aptx-panel.aptx-compact .aptx-search-row input[type=button] { height:24px; }'
11761
+ + '.aptx-panel.aptx-compact .aptx-search-menus select { height:22px; }'
11762
+ + '.aptx-panel.aptx-compact .aptx-toolrow { margin-top:3px; }';
11171
11763
  let style = document.createElement('style');
11172
11764
  style.id = PANEL_STYLE_ID;
11173
11765
  style.textContent = css;
@@ -11256,6 +11848,7 @@ function (root, d3, forester, phyloXml) {
11256
11848
  // Collapsible sections: wrap each titled fieldset's content in a body
11257
11849
  // element (so collapsing hides everything under the legend, including
11258
11850
  // bare text labels like the slider captions) and toggle it via the legend.
11851
+ let initiallyOpen = [];
11259
11852
  let fieldsets = body.querySelectorAll('fieldset');
11260
11853
  for (let i = 0; i < fieldsets.length; ++i) {
11261
11854
  let fieldset = fieldsets[i];
@@ -11273,10 +11866,34 @@ function (root, d3, forester, phyloXml) {
11273
11866
  }
11274
11867
  fieldset.appendChild(fsBody);
11275
11868
  legend.classList.add('aptx-legend-toggle');
11869
+ let name = legend.textContent.trim();
11870
+ if (sectionFoldedInitially(name)) {
11871
+ fieldset.classList.add('aptx-collapsed');
11872
+ } else {
11873
+ initiallyOpen.push(name);
11874
+ }
11276
11875
  legend.addEventListener('click', function () {
11277
- fieldset.classList.toggle('aptx-collapsed');
11876
+ let opened = !fieldset.classList.toggle('aptx-collapsed');
11877
+ loadPanelSections()[name] = !opened;
11878
+ if (opened) {
11879
+ _panelOpenOrder.set(name, ++_panelOpenSeq);
11880
+ fitPanelSections(panel, name);
11881
+ }
11882
+ savePanelSections();
11278
11883
  });
11279
11884
  }
11885
+ // What arrived open counts as opened, oldest at the BOTTOM: a panel is
11886
+ // read top-down, so when a short window forces something to fold on
11887
+ // arrival it should be the last section rather than the one describing
11888
+ // the tree at the top. Anything the user opens later is newer than all
11889
+ // of these, and folds after them.
11890
+ for (let i = initiallyOpen.length - 1; i >= 0; --i) {
11891
+ _panelOpenOrder.set(initiallyOpen[i], ++_panelOpenSeq);
11892
+ }
11893
+ // A remembered set of open sections can be taller than this window,
11894
+ // so the fit rule runs once on arrival too -- with no section to
11895
+ // spare, since the user opened none of them just now.
11896
+ fitPanelSections(panel, null);
11280
11897
 
11281
11898
  // Apply the current light/dark choice to this (and every) panel.
11282
11899
  applyPanelTheme();
@@ -11943,6 +12560,9 @@ function (root, d3, forester, phyloXml) {
11943
12560
 
11944
12561
  if (c0) {
11945
12562
  c0.classList.add('aptx-panel');
12563
+ if (_settings.panelDensity === 'compact') {
12564
+ c0.classList.add('aptx-compact');
12565
+ }
11946
12566
  setStyles(c0, {
11947
12567
  'position': 'absolute',
11948
12568
  'left': CONTROLS_0_LEFT_DEFAULT,
@@ -12208,6 +12828,8 @@ function (root, d3, forester, phyloXml) {
12208
12828
 
12209
12829
  on(MIDPOINT_ROOT_BUTTON, 'click', midpointRootButtonPressed);
12210
12830
 
12831
+ on(REPRESENTATIVES_BUTTON, 'click', representativesButtonPressed);
12832
+
12211
12833
  // Search Controls
12212
12834
  // ---------------
12213
12835
 
@@ -12415,9 +13037,9 @@ function (root, d3, forester, phyloXml) {
12415
13037
 
12416
13038
  // A file with several trees: previous / picker / next in one row,
12417
13039
  // the picker listing every tree by its name (or its number). Each
12418
- // opens fresh in the same container (showTree). The end buttons are
12419
- // disabled at the ends: the panel is rebuilt per tree, so the
12420
- // state set here is always current.
13040
+ // opens in the same container as you left it (showTree). The end
13041
+ // buttons are disabled at the ends: the panel is rebuilt per tree,
13042
+ // so the state set here is always current.
12421
13043
  function makeTreePicker() {
12422
13044
  let n = _trees.length;
12423
13045
  let row = document.createElement('div');
@@ -12656,6 +13278,7 @@ function (root, d3, forester, phyloXml) {
12656
13278
  h = h.concat(makeGlyphButton('up_one_level', RETURN_TO_SUPERTREE_BUTTON_BY_ONE, 'move up by one level towards the complete tree (if in a sub-tree)'));
12657
13279
  h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
12658
13280
  h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 're-root the tree: MAD or midpoint'));
13281
+ h = h.concat(makeGlyphButton('representatives', REPRESENTATIVES_BUTTON, 'select representative tips: one per group of close tips'));
12659
13282
  h = h.concat('</div>');
12660
13283
  h = h.concat('</fieldset>');
12661
13284
  return h;
@@ -13020,6 +13643,16 @@ function (root, d3, forester, phyloXml) {
13020
13643
  rerootButton.title = blocked
13021
13644
  || (_in_subtree ? 'return to the whole tree to re-root it' : 're-root the tree: MAD or midpoint');
13022
13645
  }
13646
+ let repsButton = byId(REPRESENTATIVES_BUTTON);
13647
+ if (repsButton) {
13648
+ let repsBlocked = representativesBlockedReason();
13649
+ if (repsBlocked) {
13650
+ disableButton(repsButton);
13651
+ } else {
13652
+ enableButton(repsButton);
13653
+ }
13654
+ repsButton.title = repsBlocked || 'select representative tips: one per group of close tips';
13655
+ }
13023
13656
  let b;
13024
13657
  if (_foundNodes0 && !_searchBox0Empty) {
13025
13658
  b = byId(RESET_SEARCH_A_BTN);