archaeopteryx 3.3.0 → 3.4.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.3.0
23
+ // v 3.4.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.3.0';
106
+ const VERSION = '3.4.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';
@@ -371,6 +371,10 @@ function (root, d3, forester, phyloXml) {
371
371
  const INTERNAL_LABEL_CB = 'intl_cb';
372
372
  const LABEL_COLOR_SELECT_MENU = 'lcs_menu';
373
373
  const MIDPOINT_ROOT_BUTTON = 'midpointr_b';
374
+ const UNCOLLAPSE_ALL_BUTTON = 'uncollapse_all_b';
375
+ const TREE_PREV_BUTTON = 'tree_prev_b';
376
+ const TREE_NEXT_BUTTON = 'tree_next_b';
377
+ const TREE_SELECT = 'tree_select';
374
378
  // The desktop Archaeopteryx logo (forester/archaeopteryx_icon_assets/
375
379
  // archaeopteryx-anime.svg), inlined so the library stays a single file.
376
380
  // Gradient ids are prefixed: they were generic enough to collide with an
@@ -412,8 +416,6 @@ function (root, d3, forester, phyloXml) {
412
416
  const SEARCH_MODE_SELECT_1 = 'sms1';
413
417
  const SEARCH_VALUE2_0 = 'sv2_0';
414
418
  const SEARCH_VALUE2_1 = 'sv2_1';
415
- const SEARCH_DATALIST_0 = 'sdl0';
416
- const SEARCH_DATALIST_1 = 'sdl1';
417
419
  const SEARCH_AUTOCOMPLETE_CAP = 2000;
418
420
  const SEARCH_COMBINE_SELECT = 'scmb';
419
421
  const SEARCH_COMBINE_ROW = 'scmb_row';
@@ -500,6 +502,12 @@ function (root, d3, forester, phyloXml) {
500
502
  let _floatGroup = null;
501
503
  let _floatStrips = {}; // class -> {top, height} in tree coordinates
502
504
  let _treeData = null;
505
+ let _trees = []; // every tree of the launch (one, or all a file held); _treeData is _trees[_treeIndex]
506
+ let _treeIndex = 0;
507
+ let _launchConfig = null; // the config as launch() received it, for showTree()
508
+ let _viewOps = {order: null, root: null}; // the tree operations a shared view replays: ladderize direction, midpoint rooting
509
+ let _lastViewEncoded; // the view last reported to onViewChange; undefined = not settled yet
510
+ let _viewNotifyPending = false;
503
511
  let _treeFn = null;
504
512
  let _vis = null; // the automatic visualizations: candidates, scales, choices
505
513
  let _w = null;
@@ -591,13 +599,32 @@ function (root, d3, forester, phyloXml) {
591
599
  let _zoomed_x_or_y = false;
592
600
  let _node_mouseover_div;
593
601
 
594
- function branchLengthScaling(nodes, width) {
602
+ // The displayed tree's own root: the single child of _root, which is the
603
+ // parsed tree's invisible wrapper (the <phylogeny> element, or the node
604
+ // above a Newick root) or, in a subtree view, the fake node holding the
605
+ // clade. Null if _root is not such a wrapper.
606
+ function topNode() {
607
+ return (_root && _root.children && _root.children.length === 1) ? _root.children[0] : null;
608
+ }
595
609
 
596
- if (_root.parent) {
597
- _root.parent.distToRoot = 0;
598
- }
610
+ function branchLengthScaling(nodes, width) {
611
+ // Distances start at the displayed tree's root. When the whole tree
612
+ // is shown and the file gives that root a branch length, the branch
613
+ // is drawn to scale (Christian, 2026-09-13; it used to be replaced by
614
+ // half the average branch length). A subtree's root starts at 0
615
+ // whatever its branch was: the branch above it is not on screen, and
616
+ // its parent's distance is from another layout -- that stale sum
617
+ // used to draw a root branch as long as the clade's distance from
618
+ // the original root.
619
+ let top = topNode();
599
620
  forester.preOrderTraversalAll(_root, function (n) {
600
- n.distToRoot = (n.parent ? n.parent.distToRoot : 0) + bl(n);
621
+ if (n === _root) {
622
+ n.distToRoot = 0;
623
+ } else if (n === top) {
624
+ n.distToRoot = _in_subtree ? 0 : bl(n);
625
+ } else {
626
+ n.distToRoot = n.parent.distToRoot + bl(n);
627
+ }
601
628
  });
602
629
  let distsToRoot = nodes.map(function (n) {
603
630
  return n.distToRoot;
@@ -612,15 +639,33 @@ function (root, d3, forester, phyloXml) {
612
639
  return yScale;
613
640
 
614
641
  function bl(node) {
615
- if (!node.branch_length || node.branch_length < 0) {
616
- return 0;
617
- } else if (!node.parent || !node.parent.parent) {
618
- return _basicTreeProperties.averageBranchLength * 0.5;
619
- }
620
- return node.branch_length;
642
+ return (!node.branch_length || node.branch_length < 0) ? 0 : node.branch_length;
621
643
  }
622
644
  }
623
645
 
646
+ // The root stub (Christian, 2026-09-13): a short branch into the
647
+ // displayed tree's root, drawn by putting the wrapper node ROOT_STUB_PX
648
+ // to the left of it so their link has that length. Shown in the
649
+ // rectangular layouts for a rooted tree -- a phyloXML rooted="false" or
650
+ // a Nexus [&U] tree shows none -- and in every subtree view, whose root
651
+ // is a clade's, whatever the tree says. When the whole tree is a
652
+ // phylogram and the file gives the root a length, that branch is drawn
653
+ // to scale instead. The radial layouts never draw a stub: the wrapper
654
+ // sits on the root.
655
+ const ROOT_STUB_PX = 12;
656
+
657
+ function placeRootStub() {
658
+ let top = topNode();
659
+ if (!top) {
660
+ return;
661
+ }
662
+ if (_state.phylogram && !_in_subtree && top.y > _root.y) {
663
+ return; // a real root branch, to scale
664
+ }
665
+ let rooted = _in_subtree || (_treeData && _treeData.rooted !== false);
666
+ _root.y = (radialDisplay() || !rooted) ? top.y : top.y - ROOT_STUB_PX;
667
+ }
668
+
624
669
  // Current zoom scale (k) from the zoom behavior's stored transform.
625
670
  function currentZoomScale() {
626
671
  return _baseSvg ? d3.zoomTransform(_baseSvg.node()).k : 1;
@@ -1032,6 +1077,9 @@ function (root, d3, forester, phyloXml) {
1032
1077
  // view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
1033
1078
  // no by precisely 254px and the overview appeared after every Fit.
1034
1079
  maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
1080
+ if (scaleBarShown()) {
1081
+ maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
1082
+ }
1035
1083
  let h = maxY - minY;
1036
1084
  return {x: minX, y: minY, width: maxX - minX, height: h > 0 ? h : 1};
1037
1085
  }
@@ -1103,7 +1151,7 @@ function (root, d3, forester, phyloXml) {
1103
1151
  // collapsed clade has no drawn position.
1104
1152
  let hits = [];
1105
1153
  if (_root) {
1106
- forester.preOrderTraversal(_root, function (n) {
1154
+ forEachDisplayed(function (n) {
1107
1155
  let c = getFoundColor(n);
1108
1156
  if (c) {
1109
1157
  hits.push({node: n, color: c});
@@ -1420,6 +1468,9 @@ function (root, d3, forester, phyloXml) {
1420
1468
  if (item.danger) {
1421
1469
  b.className = 'aptx-menu-danger';
1422
1470
  }
1471
+ if (item.disabled) {
1472
+ b.disabled = true; // offered but not applicable here, as the desktop greys it
1473
+ }
1423
1474
  b.addEventListener('click', function (e) {
1424
1475
  e.stopPropagation();
1425
1476
  removeNodeMenu();
@@ -2001,8 +2052,19 @@ function (root, d3, forester, phyloXml) {
2001
2052
  // its scales from that: the whole tree at launch and after an edit, the
2002
2053
  // displayed subtree on every view change.
2003
2054
  function summarizeView(root) {
2055
+ // the tips on screen: the view's, minus what collapsed clades hide
2056
+ let tips = [];
2057
+ (function walk(n) {
2058
+ if (!n.children) {
2059
+ tips.push(n);
2060
+ } else if (!isCollapsed(n)) {
2061
+ for (let i = 0; i < n.children.length; ++i) {
2062
+ walk(n.children[i]);
2063
+ }
2064
+ }
2065
+ })(root);
2004
2066
  _vis.candidates.forEach(function (c) {
2005
- let s = forester.visualizationSummary(c, root);
2067
+ let s = forester.visualizationSummary(c, tips);
2006
2068
  c.values = s.values;
2007
2069
  c.counts = s.counts;
2008
2070
  c.coverage = s.coverage;
@@ -2625,17 +2687,24 @@ function (root, d3, forester, phyloXml) {
2625
2687
  - bottomOverlayReserve());
2626
2688
  _treeFn = _treeFn.size([_clusterH, _w]);
2627
2689
 
2690
+ // A collapsed clade is a leaf that asks for more than one row: the
2691
+ // separation between neighbouring leaves is the mean of their
2692
+ // weights, a plain tip weighing 1 and a collapsed clade 2r - 1 so
2693
+ // that it spans r rows (collapsedRows).
2694
+ let leafWeight = function (hn) {
2695
+ return isCollapsed(hn.data) ? (2 * collapsedRows(hn.data)) - 1 : 1;
2696
+ };
2628
2697
  _treeFn = _treeFn.separation(function separation(a, b) {
2629
- return a.parent === b.parent ? 1 : 1;
2698
+ return (leafWeight(a) + leafWeight(b)) / 2;
2630
2699
  });
2631
2700
 
2632
- let uncollsed_nodes = forester.calcSumOfExternalDescendants(_root);
2633
2701
  // d3 v7: d3.cluster() lays out a d3.hierarchy rather than mutating our
2634
2702
  // own nodes in place (as d3 v3's d3.layout.cluster().nodes() did). Run
2635
2703
  // the layout on a hierarchy, then copy the computed x/y/depth back onto
2636
- // the forester nodes so the rest of the renderer is unchanged.
2704
+ // the forester nodes so the rest of the renderer is unchanged. A
2705
+ // collapsed node ends the hierarchy: what it hides is not laid out.
2637
2706
  let hierarchy = d3.hierarchy(_root, function (d) {
2638
- return d.children;
2707
+ return isCollapsed(d) ? null : d.children;
2639
2708
  });
2640
2709
  _treeFn(hierarchy);
2641
2710
  hierarchy.each(function (hn) {
@@ -2646,6 +2715,48 @@ function (root, d3, forester, phyloXml) {
2646
2715
  let nodes = hierarchy.descendants().map(function (hn) {
2647
2716
  return hn.data;
2648
2717
  }).reverse();
2718
+ let leaves = hierarchy.leaves();
2719
+ let uncollsed_nodes = leaves.length; // the rows on screen: tips and collapsed clades
2720
+ {
2721
+ // px per row unit: the leaves span the cluster height in
2722
+ // separation units (a collapsed clade counts for its rows)
2723
+ let units = 0;
2724
+ for (let i = 1; i < leaves.length; ++i) {
2725
+ units += (leafWeight(leaves[i - 1]) + leafWeight(leaves[i])) / 2;
2726
+ }
2727
+ _rowUnit = units > 0 ? (leaves[leaves.length - 1].x - leaves[0].x) / units : _clusterH;
2728
+ }
2729
+ _cladogramUnit = hierarchy.height > 0 ? _w / hierarchy.height : _w;
2730
+
2731
+ // The wrapper is laid out as a level of its own, so a cladogram spent
2732
+ // one depth unit on the invisible branch above the root. Pull the
2733
+ // root back to 0 and stretch the rest to the full width; the stub is
2734
+ // placed afterwards (placeRootStub), and a phylogram overrides these
2735
+ // positions anyway.
2736
+ {
2737
+ let top = topNode();
2738
+ if (top && top.y > 0 && _w > top.y) {
2739
+ let unit = top.y;
2740
+ let stretch = _w / (_w - unit);
2741
+ forEachDisplayed(function (n) {
2742
+ if (n !== _root) {
2743
+ n.y = (n.y - unit) * stretch;
2744
+ }
2745
+ });
2746
+ _root.y = 0;
2747
+ _cladogramUnit = unit * stretch;
2748
+ }
2749
+ // a collapsed clade is laid out on the tip column like any leaf;
2750
+ // in a cladogram its apex steps back one level so the wedge ends
2751
+ // where the tips would (a phylogram places it by distance)
2752
+ if (!_state.phylogram) {
2753
+ forEachDisplayed(function (n) {
2754
+ if (isCollapsed(n)) {
2755
+ n.y -= _cladogramUnit;
2756
+ }
2757
+ });
2758
+ }
2759
+ }
2649
2760
 
2650
2761
  // The dim gate: only while a hit is actually DRAWN, so the tree never
2651
2762
  // washes out with nothing emphasised (a 0-hit search, or every hit
@@ -2668,6 +2779,7 @@ function (root, d3, forester, phyloXml) {
2668
2779
  if (_state.phylogram === true) {
2669
2780
  _yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
2670
2781
  }
2782
+ placeRootStub();
2671
2783
 
2672
2784
  if (_state.circularDisplay) {
2673
2785
  let maxY = 0;
@@ -2691,17 +2803,26 @@ function (root, d3, forester, phyloXml) {
2691
2803
  // branchLengthScaling just wrote) and are one constant step per
2692
2804
  // level in cladogram mode; the radial budget matches circular's.
2693
2805
  let budget = Math.min(_displayWidth, _displayHeight) * 0.42;
2806
+ // The fan is laid out from the displayed tree's OWN root, never
2807
+ // from the wrapper above it: an unrooted display draws no root
2808
+ // branch at all, not the file's real one and not the stub
2809
+ // (Christian, 2026-09-13). Laid out from the wrapper, the root
2810
+ // hung one cladogram step -- or its own branch length -- off the
2811
+ // centre on a spoke leading in from nowhere.
2812
+ let top = topNode() || _root;
2694
2813
  let lengthOf;
2814
+ let pxPerUnit = 0; // the phylogram's scale, for the scale bar
2695
2815
  if (_state.phylogram) {
2696
2816
  let maxDist = 0;
2697
- forester.preOrderTraversal(_root, function (n) {
2698
- if (!n.children && n.distToRoot > maxDist) {
2699
- maxDist = n.distToRoot;
2817
+ forester.preOrderTraversal(top, function (n) {
2818
+ if (!n.children && n.distToRoot - top.distToRoot > maxDist) {
2819
+ maxDist = n.distToRoot - top.distToRoot;
2700
2820
  }
2701
2821
  });
2702
2822
  let factor = maxDist > 0 ? budget / maxDist : 1;
2823
+ pxPerUnit = maxDist > 0 ? factor : 0;
2703
2824
  lengthOf = function (n) {
2704
- return Math.max(0, n.distToRoot - (n.parent ? n.parent.distToRoot : 0)) * factor;
2825
+ return Math.max(0, n.distToRoot - n.parent.distToRoot) * factor;
2705
2826
  };
2706
2827
  } else {
2707
2828
  let maxDepth = 0;
@@ -2710,12 +2831,20 @@ function (root, d3, forester, phyloXml) {
2710
2831
  maxDepth = nodes[i].depth;
2711
2832
  }
2712
2833
  }
2713
- let step = budget / Math.max(1, maxDepth);
2834
+ // depths count from the wrapper, so the tree's own levels are one fewer
2835
+ let step = budget / Math.max(1, maxDepth - (top === _root ? 0 : 1));
2714
2836
  lengthOf = function () {
2715
2837
  return step;
2716
2838
  };
2717
2839
  }
2718
- _unroot = forester.equalAngleLayout(_root, UNROOTED_START_ANGLE + _radialRotation, lengthOf);
2840
+ _unroot = forester.equalAngleLayout(top, UNROOTED_START_ANGLE + _radialRotation, lengthOf);
2841
+ _unroot.pxPerUnit = pxPerUnit;
2842
+ if (top !== _root) {
2843
+ // the wrapper sits on the root, so their link has no length
2844
+ _root.ux = top.ux;
2845
+ _root.uy = top.uy;
2846
+ _root.uangle = top.uangle;
2847
+ }
2719
2848
  } else {
2720
2849
  _radial = null;
2721
2850
  _unroot = null;
@@ -2736,6 +2865,7 @@ function (root, d3, forester, phyloXml) {
2736
2865
  });
2737
2866
  }
2738
2867
  }
2868
+ syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
2739
2869
 
2740
2870
  updateButtonEnabledState();
2741
2871
 
@@ -2795,6 +2925,7 @@ function (root, d3, forester, phyloXml) {
2795
2925
  // d3 v4+ no longer folds entered nodes into the update selection, so
2796
2926
  // merge them before the shared styling/positioning below.
2797
2927
  node = nodeEnter.merge(node);
2928
+ drawCollapsedClades(node);
2798
2929
 
2799
2930
  // Create the optional children only where they will show something,
2800
2931
  // and drop them where they will not. Must run BEFORE node.transition()
@@ -3218,12 +3349,14 @@ function (root, d3, forester, phyloXml) {
3218
3349
  }
3219
3350
 
3220
3351
  hideHoverGlow(); // the hovered node may have moved; the next mouseover re-shows it
3352
+ drawScaleBar();
3221
3353
  drawDomainArchitectures();
3222
3354
  drawDomainLegend();
3223
3355
  drawMsaTrack();
3224
3356
  drawTimeOverlays();
3225
3357
  rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
3226
3358
  updateSearchHitNavigation();
3359
+ noteViewChange();
3227
3360
  }
3228
3361
 
3229
3362
  // A node is drawn as a shape only when there is a reason to show one: it
@@ -3257,11 +3390,15 @@ function (root, d3, forester, phyloXml) {
3257
3390
  let styled = _state.nodeSizeDefault > 0 && node.parent && !node.hasVis
3258
3391
  && styleOf !== null && !!styleOf.nodeColor;
3259
3392
 
3260
- // a zero-length branch off the root would otherwise be invisible
3261
- let zeroLengthRootChild = _state.phylogram && node.parent && !node.parent.parent
3262
- && (!node.branch_length || node.branch_length <= 0);
3263
-
3264
- return (visualized || styled || zeroLengthRootChild) ? _state.nodeSizeDefault : 0;
3393
+ // The root is no exception. A 2.x rule gave "a zero-length branch
3394
+ // off the root" a circle so it would not vanish -- but the tree is
3395
+ // laid out from the invisible wrapper above the root, so the node it
3396
+ // matched was the root itself, and only in a phylogram with nothing
3397
+ // coloured: a circle that appeared and disappeared with the display
3398
+ // type and meant nothing. Dropped 2026-09-13; the root is now marked
3399
+ // by its stub (placeRootStub), and wears a circle only for the
3400
+ // reasons any node does.
3401
+ return (visualized || styled) ? _state.nodeSizeDefault : 0;
3265
3402
  };
3266
3403
 
3267
3404
  let makeBranchWidth = function (link) {
@@ -3460,7 +3597,7 @@ function (root, d3, forester, phyloXml) {
3460
3597
  if (isNodeFound(n)) {
3461
3598
  hits.push(n);
3462
3599
  }
3463
- if (n.children) {
3600
+ if (n.children && !isCollapsed(n)) { // a hit inside a collapsed clade has no position to step to
3464
3601
  for (let i = 0; i < n.children.length; ++i) {
3465
3602
  walk(n.children[i]);
3466
3603
  }
@@ -3728,6 +3865,9 @@ function (root, d3, forester, phyloXml) {
3728
3865
  }
3729
3866
 
3730
3867
  let makeNodeLabel = function (phynode) {
3868
+ if (isCollapsed(phynode)) {
3869
+ return ''; // a collapsed clade carries its own label (drawCollapsedClades)
3870
+ }
3731
3871
  if (!_state.showExternalLabels && !(phynode.children)) {
3732
3872
  return null;
3733
3873
  }
@@ -4329,9 +4469,11 @@ function (root, d3, forester, phyloXml) {
4329
4469
  'ladderizeTree',
4330
4470
  'nhExportWriteConfidences',
4331
4471
  'nodeLabels',
4472
+ 'onViewChange',
4332
4473
  'pngExportScale',
4333
4474
  'rootOffset',
4334
4475
  'supportDotMinimum',
4476
+ 'view',
4335
4477
  'zoomToFitUponWindowResize'
4336
4478
  ];
4337
4479
 
@@ -4548,7 +4690,7 @@ function (root, d3, forester, phyloXml) {
4548
4690
  throw new Error(ERROR + '"domainEvalueExponent" must be an integer from '
4549
4691
  + forester.DOMAIN_EVALUE_EXPONENT_MIN + ' to ' + forester.DOMAIN_EVALUE_EXPONENT_MAX);
4550
4692
  }
4551
- _domain = {width: null, palette: null, next: 0, legendFrac: null};
4693
+ _domain = {width: null, radialWidth: null, palette: null, next: 0, legendFrac: null};
4552
4694
  if (_basicTreeProperties.domainArchitectures) {
4553
4695
  // malformed domains are skipped, never fatal -- said once, here
4554
4696
  let ignored = forester.domainArchitectureStats(_treeData).ignored;
@@ -4790,6 +4932,16 @@ function (root, d3, forester, phyloXml) {
4790
4932
  throw new Error(ERROR + 'internalNumericLabels must be "auto", "confidence" or "label"');
4791
4933
  }
4792
4934
  // custom label-field checkboxes (was launch()'s sixth positional arg)
4935
+ if (_settings.view === undefined || _settings.view === null) {
4936
+ _settings.view = null;
4937
+ } else if (typeof _settings.view !== 'object') {
4938
+ throw new Error(ERROR + '"view" must be a view state object (getViewState / decodeViewState) or null');
4939
+ }
4940
+ if (_settings.onViewChange === undefined || _settings.onViewChange === null) {
4941
+ _settings.onViewChange = null;
4942
+ } else if (typeof _settings.onViewChange !== 'function') {
4943
+ throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
4944
+ }
4793
4945
  if (_settings.nodeLabels === undefined) {
4794
4946
  _settings.nodeLabels = null;
4795
4947
  } else if (_settings.nodeLabels !== null && typeof _settings.nodeLabels !== 'object') {
@@ -4892,6 +5044,12 @@ function (root, d3, forester, phyloXml) {
4892
5044
  if (_settings.ladderizeTree) {
4893
5045
  ladderizeSubtree(_root, true); // one pass: a definite arrangement
4894
5046
  }
5047
+ _viewOps = {order: null, root: null};
5048
+ if (_settings.view) {
5049
+ // the shared view, its searches found before the first draw
5050
+ applyViewState(_settings.view, true);
5051
+ runSearches();
5052
+ }
4895
5053
  if (_state.searchAinitialValue) {
4896
5054
  search0();
4897
5055
  }
@@ -4905,6 +5063,7 @@ function (root, d3, forester, phyloXml) {
4905
5063
 
4906
5064
  search0();
4907
5065
  search1();
5066
+ settleViewNotification();
4908
5067
  }
4909
5068
 
4910
5069
  function bindDoc(type, fn, opts) {
@@ -4922,6 +5081,24 @@ function (root, d3, forester, phyloXml) {
4922
5081
  // resolves once the tree is drawn: at once for a small tree,
4923
5082
  // after the deferred first draw for a big one
4924
5083
  ready: _readyPromise,
5084
+ // the trees of the launch -- one, or every tree the file held:
5085
+ // how many, which is shown, and showing another (a fresh
5086
+ // viewer for it; the handle returned is the one to keep)
5087
+ getTreeCount: function () {
5088
+ return _trees.length;
5089
+ },
5090
+ getTreeIndex: function () {
5091
+ return _treeIndex;
5092
+ },
5093
+ showTree: showTree,
5094
+ // the view -- layout, display, labels, colours, searches, the
5095
+ // clade and the collapsed clades, sizes -- as a plain object,
5096
+ // and opening one (see also archaeopteryx.encodeViewState /
5097
+ // decodeViewState and the config's view / onViewChange)
5098
+ getViewState: getViewState,
5099
+ applyViewState: function (state) {
5100
+ applyViewState(state, false);
5101
+ },
4925
5102
  destroy: destroyViewer
4926
5103
  };
4927
5104
  }
@@ -4974,6 +5151,11 @@ function (root, d3, forester, phyloXml) {
4974
5151
  _root = null;
4975
5152
  _root_const = null;
4976
5153
  _treeData = null;
5154
+ _trees = [];
5155
+ _treeIndex = 0;
5156
+ _launchConfig = null;
5157
+ _lastViewEncoded = undefined;
5158
+ _viewOps = {order: null, root: null};
4977
5159
  _basicTreeProperties = null;
4978
5160
  _baseSvg = null;
4979
5161
  _svgGroup = null;
@@ -5029,9 +5211,39 @@ function (root, d3, forester, phyloXml) {
5029
5211
  if (phylo === undefined || phylo === null) {
5030
5212
  throw new Error(ERROR + 'input tree is undefined or null');
5031
5213
  }
5032
- if ((!phylo.children) || (phylo.children.length < 1)) {
5033
- throw new Error(ERROR + 'input tree is empty or illegally formatted');
5214
+ // One tree, or a list of them (every tree a file holds -- parseTrees):
5215
+ // the list is kept, the first is shown, and the panel's picker and the
5216
+ // handle's showTree() move between them.
5217
+ let trees = Array.isArray(phylo) ? phylo.slice() : [phylo];
5218
+ if (trees.length === 0) {
5219
+ throw new Error(ERROR + 'input tree list is empty');
5034
5220
  }
5221
+ trees.forEach(function (t, i) {
5222
+ if (!t || (!t.children) || (t.children.length < 1)) {
5223
+ throw new Error(ERROR + (trees.length > 1 ? 'input tree ' + (i + 1) + ' of ' + trees.length : 'input tree')
5224
+ + ' is empty or illegally formatted');
5225
+ }
5226
+ });
5227
+ // the two view keys are checked up here with the other arguments,
5228
+ // before anything is touched (and where Node can test it)
5229
+ if (config && config.view !== undefined && config.view !== null && typeof config.view !== 'object') {
5230
+ throw new Error(ERROR + '"view" must be a view state object (getViewState / decodeViewState) or null');
5231
+ }
5232
+ if (config && config.onViewChange !== undefined && config.onViewChange !== null && typeof config.onViewChange !== 'function') {
5233
+ throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
5234
+ }
5235
+ // a view names the tree it was made on; a fresh launch reports no
5236
+ // view until one changes (settleViewNotification)
5237
+ _lastViewEncoded = undefined;
5238
+ let view = config && config.view;
5239
+ let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
5240
+ return launchInto(container, trees, start, config);
5241
+ };
5242
+
5243
+ // The launch proper, for one tree of the list: launch() validates and
5244
+ // starts at the first, showTree() re-enters here for another.
5245
+ function launchInto(container, trees, index, config) {
5246
+ let phylo = trees[index];
5035
5247
  let cfg = readConfig(config);
5036
5248
  requireForester();
5037
5249
  requireD3();
@@ -5039,7 +5251,11 @@ function (root, d3, forester, phyloXml) {
5039
5251
 
5040
5252
  let previousContainer = _container; // for the different-container teardown below
5041
5253
  _treeData = phylo;
5254
+ _trees = trees;
5255
+ _treeIndex = index;
5256
+ _launchConfig = config;
5042
5257
  _container = containerEl;
5258
+ assignViewIds(phylo);
5043
5259
  _zoomListener = d3.zoom()
5044
5260
  .scaleExtent([0.1, 10])
5045
5261
  .filter(function (event) {
@@ -5180,10 +5396,24 @@ function (root, d3, forester, phyloXml) {
5180
5396
  }
5181
5397
 
5182
5398
  return makeViewerHandle();
5399
+ }
5183
5400
 
5184
- //////////////////////////////////////////////////////////////////////
5185
-
5186
- };
5401
+ // Shows another tree of the launched list in the same container under
5402
+ // the same config. The tree opens fresh -- its own presets, a clean
5403
+ // view -- the way a new tab does on the desktop. The panel's picker and
5404
+ // previous / next buttons come here, as does the handle's showTree().
5405
+ function showTree(index) {
5406
+ if (!_container) {
5407
+ throw new Error(ERROR + 'showTree(): nothing is launched');
5408
+ }
5409
+ if (!Number.isInteger(index) || index < 0 || index >= _trees.length) {
5410
+ throw new Error(ERROR + 'showTree(): the index must be 0 to ' + (_trees.length - 1) + ', got ' + index);
5411
+ }
5412
+ if (index === _treeIndex) {
5413
+ return makeViewerHandle();
5414
+ }
5415
+ return launchInto(_container, _trees, index, _launchConfig);
5416
+ }
5187
5417
 
5188
5418
  archaeopteryx.parsePhyloXML = function (data) {
5189
5419
  requirePhyloXml();
@@ -5240,8 +5470,9 @@ function (root, d3, forester, phyloXml) {
5240
5470
  return forester.parseAuspiceJson(data);
5241
5471
  };
5242
5472
 
5243
- // A Nexus file can hold several trees; the FIRST one is displayed (any
5244
- // alignment from the file's characters matrix rides along on its tips).
5473
+ // A Nexus file can hold several trees; this returns the FIRST one
5474
+ // (parseTrees returns them all; any alignment from the file's characters
5475
+ // matrix rides along on the tips).
5245
5476
  archaeopteryx.parseNexus = function (data, mode, legacyMode) {
5246
5477
  requireForester();
5247
5478
  let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
@@ -5255,6 +5486,9 @@ function (root, d3, forester, phyloXml) {
5255
5486
  function calcMaxExtLabel() {
5256
5487
  _maxLabelLength = _state.nodeLabelGap;
5257
5488
  forester.preOrderTraversal(_root, function (d) {
5489
+ if (isCollapsed(d)) {
5490
+ _maxLabelLength = Math.max(collapsedLabel(d).length, _maxLabelLength);
5491
+ }
5258
5492
  if (!d.children) {
5259
5493
  let l = makeNodeLabel(d);
5260
5494
  if (l) {
@@ -5589,6 +5823,9 @@ function (root, d3, forester, phyloXml) {
5589
5823
  }
5590
5824
 
5591
5825
  function switchToSubtree(node) {
5826
+ if (node.collapsed) {
5827
+ node.collapsed = false; // a subtree view shows what the clade holds
5828
+ }
5592
5829
  if (node.parent) {
5593
5830
  if (!(node.children)) {
5594
5831
  if (node.parent.parent) {
@@ -5665,6 +5902,14 @@ function (root, d3, forester, phyloXml) {
5665
5902
  // "Switch to ..." matches the desktop's wording for this action.
5666
5903
  items.push({separator: true});
5667
5904
  items.push({label: 'Switch to Subtree', action: function () { switchToSubtree(d); }});
5905
+ // the desktop's two collapse entries, with its enabling rules:
5906
+ // the toggle for an internal node that is not the root and not
5907
+ // in the unrooted layout; the subtree uncollapse only when
5908
+ // something below is collapsed
5909
+ items.push({label: 'Collapse/Uncollapse', disabled: !(collapseAllowed() && d.children),
5910
+ action: function () { collapseToggle(d); }});
5911
+ items.push({label: 'Uncollapse Subtree', disabled: !hasCollapsedIn(d),
5912
+ action: function () { uncollapse(d); }});
5668
5913
  }
5669
5914
  if (d.parent && d.children) {
5670
5915
  // redrawn with no transition: animating a swap sends the two
@@ -6025,7 +6270,7 @@ function (root, d3, forester, phyloXml) {
6025
6270
  updateMsaScrollbar(0, 1, 0);
6026
6271
  return;
6027
6272
  }
6028
- let tips = forester.getAllExternalNodes(_root).filter(function (d) {
6273
+ let tips = displayedTips().filter(function (d) {
6029
6274
  return d.x !== undefined;
6030
6275
  }).sort(function (p, q) {
6031
6276
  return p.x - q.x;
@@ -6525,187 +6770,1214 @@ function (root, d3, forester, phyloXml) {
6525
6770
  scheduleUpdate(null, 0);
6526
6771
  }
6527
6772
 
6528
- // ===================== Protein domain architectures =====================
6529
- // The desktop's domain display, ported from its RenderableDomainArchitecture
6530
- // and TreePanel (the spec is section D1 of the repo's TODO.md). Each tip's
6531
- // architecture is a grey backbone of L residues with a rounded box per
6532
- // domain the E-value threshold admits: in one aligned column past the
6533
- // labels in the rectangular layout, riding the tip's spoke in the radial
6534
- // ones. One scale for the whole tree -- f px per residue, from the track
6535
- // width and the longest architecture -- so lengths compare across tips.
6536
- // Which domains, where, which colour and which legend rows are forester's
6537
- // (tested against the desktop's numbers); this is the SVG, the legend
6538
- // card and the controls. The boxes take no pointer events: nothing to
6539
- // hover or click, as on the desktop.
6773
+ // ===================== Collapsed clades =====================
6774
+ // Collapsing is DISPLAY state on the node (d.collapsed) and nothing
6775
+ // else: forester never sees it, the writers never see it, and the data
6776
+ // tree keeps every tip. The layout treats a collapsed node as a leaf and
6777
+ // the viewer draws it as a wedge -- apex at the node, its two edges
6778
+ // reaching the clade's nearest and farthest tips so its depth stays
6779
+ // readable, filled in the clade's dominant Color-by colour, its height
6780
+ // growing gently with the tip count -- named by the node's own name,
6781
+ // else by the one Color-by value nearly all its tips share, else by the
6782
+ // tips' common name prefix, always with the tip count, and with
6783
+ // "[found/total]" while a search hits inside it (the desktop's touch).
6784
+ // The CONTROLS are the desktop's exactly (Christian, 2026-09-13): the
6785
+ // node menu's "Collapse/Uncollapse" and "Uncollapse Subtree", the
6786
+ // uncollapse-all button in the tool row with the desktop's glyph, no
6787
+ // collapsing in the unrooted layout, never the root. Every walk of the
6788
+ // DISPLAYED tree goes through forEachDisplayed; a hidden tip keeps its
6789
+ // stale position and must never be drawn from.
6790
+ let _rowUnit = 0; // px per row in the last rectangular layout
6540
6791
 
6541
- function domainsShown() {
6542
- return _state.showDomainArchitectures === true
6543
- && _basicTreeProperties.domainArchitectures === true
6544
- && _basicTreeProperties.maxDomainArchitectureLength > 0
6545
- && _state.showExternalLabels
6546
- // in a radial layout the boxes ride the spokes, so only radial
6547
- // labels go with them
6548
- && (!radialDisplay() || !_radialLabelsHorizontal);
6792
+ function collapseAllowed() {
6793
+ return !_state.unrootedDisplay;
6549
6794
  }
6550
6795
 
6551
- // The track width W: a quarter of the viewport at first use (the
6552
- // layout's width, if smaller); d+ / d- scale it by 1.2 / 0.8 and stop
6553
- // at 2000 / 20.
6554
- function domainTrackWidth() {
6555
- if (!_domain.width) {
6556
- let vp = svgSize();
6557
- let vw = Math.min(_displayWidth, (vp && vp.w) ? vp.w : _displayWidth);
6558
- _domain.width = Math.max(DOMAIN_WIDTH_MIN, Math.round(vw * DOMAIN_WIDTH_VIEWPORT_FRACTION));
6796
+ function isCollapsed(d) {
6797
+ return d.collapsed === true && !!d.children && collapseAllowed();
6798
+ }
6799
+
6800
+ // visits the displayed tree: a collapsed node, not what it hides
6801
+ function forEachDisplayed(fn) {
6802
+ if (!_root) {
6803
+ return;
6559
6804
  }
6560
- return _domain.width;
6805
+ (function walk(n) {
6806
+ fn(n);
6807
+ if (n.children && !isCollapsed(n)) {
6808
+ for (let i = 0; i < n.children.length; ++i) {
6809
+ walk(n.children[i]);
6810
+ }
6811
+ }
6812
+ })(_root);
6561
6813
  }
6562
6814
 
6563
- // In a radial layout the track is capped at a share of the radius, or a
6564
- // long architecture would run off the circle.
6565
- function domainEffectiveWidth() {
6566
- let w = domainTrackWidth();
6567
- if (radialDisplay()) {
6568
- let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
6569
- if (r > 0) {
6570
- w = Math.min(w, DOMAIN_RADIAL_WIDTH_FRACTION * r);
6815
+ function displayedTips() {
6816
+ let out = [];
6817
+ forEachDisplayed(function (n) {
6818
+ if (!n.children) {
6819
+ out.push(n);
6571
6820
  }
6572
- }
6573
- return w;
6821
+ });
6822
+ return out;
6574
6823
  }
6575
6824
 
6576
- // px per residue, one factor for every architecture on the tree; Lmax
6577
- // counts every domain whatever its E-value, so the threshold never
6578
- // rescales the tracks
6579
- function domainScale() {
6580
- return (domainEffectiveWidth() / _basicTreeProperties.maxDomainArchitectureLength) * DOMAIN_SCALE_HEADROOM;
6825
+ function displayedLeafCount() {
6826
+ let count = 0;
6827
+ forEachDisplayed(function (n) {
6828
+ if (!n.children || isCollapsed(n)) {
6829
+ count++;
6830
+ }
6831
+ });
6832
+ return count;
6581
6833
  }
6582
6834
 
6583
- // The room the longest tip label takes: the same estimate the layout is
6584
- // sized against (there are no font metrics in the model).
6585
- function domainLabelSpace() {
6586
- return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
6835
+ function hasCollapsedIn(node) {
6836
+ let found = false;
6837
+ forester.preOrderTraversal(node, function (n) {
6838
+ if (n.collapsed === true && n.children) {
6839
+ found = true;
6840
+ }
6841
+ });
6842
+ return found;
6587
6843
  }
6588
6844
 
6589
- // The rectangular layout reserves the tracks' column so the tree and
6590
- // labels compress to make room, as the alignment track does; the radial
6591
- // layouts fit the tracks into the ring instead (fitRadialExtent).
6592
- function domainReserve() {
6593
- if (!domainsShown() || radialDisplay()) {
6594
- return 0;
6845
+ function collapseToggle(d) {
6846
+ if (!collapseAllowed() || !d.children || !d.parent || !d.parent.parent) {
6847
+ return;
6595
6848
  }
6596
- return DOMAIN_TRACK_START_GAP + domainTrackWidth() + DOMAIN_TRACK_END_GAP;
6849
+ d.collapsed = !d.collapsed;
6850
+ calcMaxExtLabel();
6851
+ scheduleUpdate(null, 0);
6597
6852
  }
6598
6853
 
6599
- function domainRadialExtent() {
6600
- if (!domainsShown() || !radialDisplay()) {
6601
- return 0;
6602
- }
6603
- return DOMAIN_RADIAL_GAP + domainEffectiveWidth() + DOMAIN_TRACK_END_GAP;
6854
+ function uncollapse(node) {
6855
+ forester.preOrderTraversal(node, function (n) {
6856
+ n.collapsed = false;
6857
+ });
6858
+ calcMaxExtLabel();
6859
+ scheduleUpdate(null, 0);
6604
6860
  }
6605
6861
 
6606
- // Colours: the names the threshold admits over the WHOLE tree, sorted by
6607
- // code unit, take the palette in order. Dealt at load, after an edit and
6608
- // whenever the threshold changes (the drawn set changes then, so colours
6609
- // can shift); a name met later takes the next unused colour and keeps
6610
- // it. Never derived from the name's characters -- that let SH2 and SH3
6611
- // collide on the desktop.
6612
- function domainPalette() {
6613
- if (!_domain.palette) {
6614
- let names = forester.domainSummary(forester.getTreeRoot(_treeData), _state.domainEvalueExponent).names;
6615
- _domain.palette = Object.create(null);
6616
- names.forEach(function (name, i) {
6617
- _domain.palette[name] = i;
6618
- });
6619
- _domain.next = names.length;
6862
+ function uncollapseAll() {
6863
+ if (_root) {
6864
+ uncollapse(_root);
6620
6865
  }
6621
- return _domain.palette;
6622
6866
  }
6623
6867
 
6624
- function domainColor(name) {
6625
- if (!name) {
6626
- return forester.DOMAIN_UNNAMED_COLOR;
6868
+ function clearCollapsedFlags(node) {
6869
+ forester.preOrderTraversal(node, function (n) {
6870
+ n.collapsed = false;
6871
+ });
6872
+ }
6873
+
6874
+ // How tall a collapsed clade draws, in rows: 1 for a pair, growing with
6875
+ // the logarithm of the tip count, capped so a huge clade does not eat
6876
+ // the screen. The cluster layout's separation turns it into space.
6877
+ function collapsedRows(d) {
6878
+ let tips = forester.getAllExternalNodes(d).length;
6879
+ return Math.max(1, Math.min(2.5, 1 + (Math.log2(Math.max(2, tips)) / 4)));
6880
+ }
6881
+
6882
+ // The clade's name: the node's own; else the one Color-by value at least
6883
+ // 95% of its tips share (a clade named "Bovine" while you look at
6884
+ // hosts); else the tips' common name prefix, trailing separators
6885
+ // dropped; else nothing.
6886
+ function collapsedName(d) {
6887
+ if (d.name && String(d.name).trim().length > 0) {
6888
+ return String(d.name).trim();
6627
6889
  }
6628
- let palette = domainPalette();
6629
- if (palette[name] === undefined) {
6630
- palette[name] = _domain.next++;
6890
+ let tips = forester.getAllExternalNodes(d);
6891
+ let vis = currentColorVis();
6892
+ if (vis && tips.length > 0) {
6893
+ let counts = Object.create(null);
6894
+ tips.forEach(function (t) {
6895
+ let v = forester.visualizationNodeValue(t, vis);
6896
+ if (v !== null && v !== undefined) {
6897
+ counts[v] = (counts[v] || 0) + 1;
6898
+ }
6899
+ });
6900
+ let best = null;
6901
+ Object.keys(counts).forEach(function (v) {
6902
+ if (best === null || counts[v] > counts[best]) {
6903
+ best = v;
6904
+ }
6905
+ });
6906
+ if (best !== null && counts[best] >= 0.95 * tips.length) {
6907
+ return best;
6908
+ }
6631
6909
  }
6632
- return forester.domainQualitativeColor(palette[name]);
6910
+ let prefix = forester.commonNamePrefix(d, forester.nodeLabelProperty(_treeData));
6911
+ prefix = prefix ? prefix.replace(/[\s_\-.:|/]+$/, '') : '';
6912
+ return prefix.length >= 2 ? prefix : '';
6633
6913
  }
6634
6914
 
6635
- function domainBoxHeight(pitch) {
6636
- return Math.max(DOMAIN_BOX_MIN_H, Math.min(DOMAIN_BOX_MAX_H, Math.round(pitch)));
6915
+ function collapsedFoundCounts(d) {
6916
+ let found = 0;
6917
+ let total = 0;
6918
+ forester.preOrderTraversal(d, function (n) {
6919
+ if (!n.children) {
6920
+ total++;
6921
+ if (isNodeFound(n)) {
6922
+ found++;
6923
+ }
6924
+ }
6925
+ });
6926
+ return {found: found, total: total};
6637
6927
  }
6638
6928
 
6639
- function drawDomainArchitectures() {
6640
- if (!_svgGroup) {
6641
- return;
6929
+ function collapsedLabel(d) {
6930
+ let name = collapsedName(d);
6931
+ let total = forester.getAllExternalNodes(d).length;
6932
+ let text = (name ? name + ' · ' : '') + total + (total === 1 ? ' tip' : ' tips');
6933
+ let hits = collapsedFoundCounts(d);
6934
+ if (hits.found > 0) {
6935
+ text += ' [' + hits.found + '/' + hits.total + ']';
6642
6936
  }
6643
- _svgGroup.selectAll('g.aptx-domains').remove();
6644
- if (!domainsShown() || !_root) {
6645
- return;
6937
+ return text;
6938
+ }
6939
+
6940
+ // The colour most of the clade's tips wear under the current Color-by;
6941
+ // the branch colour when nothing is coloured.
6942
+ function collapsedColor(d) {
6943
+ let vis = currentColorVis();
6944
+ if (!vis || !_state.showVisualizations) {
6945
+ return _state.branchColorDefault;
6646
6946
  }
6647
- let tips = forester.getAllExternalNodes(_root).filter(function (d) {
6648
- return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
6947
+ let counts = Object.create(null);
6948
+ let best = null;
6949
+ forester.getAllExternalNodes(d).forEach(function (t) {
6950
+ let c = makeLabelColor(t);
6951
+ if (c === _state.labelColorDefault) {
6952
+ return; // an uncoloured tip casts no vote
6953
+ }
6954
+ counts[c] = (counts[c] || 0) + 1;
6955
+ if (best === null || counts[c] > counts[best]) {
6956
+ best = c;
6957
+ }
6649
6958
  });
6650
- let f = domainScale();
6651
- if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
6652
- return;
6959
+ return best || _state.branchColorDefault;
6960
+ }
6961
+
6962
+ // The wedge's base: the desktop's triangle (paintCollapsedNode with its
6963
+ // default "collapsed with average height") stands on a VERTICAL base at
6964
+ // the clade's average tip distance, so the shape is symmetric about the
6965
+ // node's row and its depth still reads. A cladogram, where every leaf
6966
+ // sits on the tip column, gives it one depth step.
6967
+ function collapsedBase(d) {
6968
+ if (_state.phylogram && _yScale) {
6969
+ let sum = 0;
6970
+ let n = 0;
6971
+ forester.preOrderTraversal(d, function (t) {
6972
+ if (!t.children && typeof t.distToRoot === 'number') {
6973
+ sum += t.distToRoot;
6974
+ ++n;
6975
+ }
6976
+ });
6977
+ if (n > 0) {
6978
+ return Math.max(d.y + 2, _yScale(sum / n));
6979
+ }
6653
6980
  }
6654
- let g = _svgGroup.append('g').attr('class', 'aptx-domains').style('pointer-events', 'none');
6655
- // one vertical gradient per base colour, lighter at the top; the
6656
- // defs ride into the SVG / PDF / PNG exports with the boxes
6657
- let defs = g.append('defs');
6658
- let made = Object.create(null);
6659
- function gradientFor(base) {
6660
- let id = 'aptx-dom-' + base.substring(1);
6661
- if (!made[id]) {
6662
- made[id] = true;
6663
- let grad = defs.append('linearGradient').attr('id', id)
6664
- .attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', 1);
6665
- grad.append('stop').attr('offset', '0%').attr('stop-color', forester.domainLighten(base, 0.12));
6666
- grad.append('stop').attr('offset', '100%').attr('stop-color', forester.domainDarken(base, 0.10));
6981
+ return d.y + _cladogramUnit;
6982
+ }
6983
+
6984
+ let _cladogramUnit = 0; // one depth step of the last cladogram layout, px
6985
+
6986
+ // Draws every collapsed clade in the current node selection: a wedge
6987
+ // path and a label as children of the node's own group, so they move
6988
+ // with it, take its clicks and menu, and ride into exports.
6989
+ function drawCollapsedClades(node) {
6990
+ let wedge = node.selectAll('path.aptx-collapsed').data(function (d) {
6991
+ return isCollapsed(d) ? [d] : [];
6992
+ });
6993
+ wedge.exit().remove();
6994
+ wedge = wedge.enter().append('path').attr('class', 'aptx-collapsed').merge(wedge);
6995
+ let label = node.selectAll('text.aptx-collapsed-label').data(function (d) {
6996
+ return isCollapsed(d) ? [d] : [];
6997
+ });
6998
+ label.exit().remove();
6999
+ label = label.enter().append('text').attr('class', 'aptx-collapsed-label').merge(label);
7000
+
7001
+ let fs = _state.externalNodeFontSize;
7002
+ wedge.each(function (d) {
7003
+ let color = collapsedColor(d);
7004
+ let hits = collapsedFoundCounts(d);
7005
+ let foundColor = hits.found > 0 ? getFoundColor(forester.getAllExternalNodes(d).filter(isNodeFound)[0]) : null;
7006
+ let h = Math.max(6, collapsedRows(d) * _rowUnit * 0.82);
7007
+ let base = collapsedBase(d);
7008
+ let path;
7009
+ if (radialDisplay()) {
7010
+ // in the rotated frame: +x along the spoke from the node (the
7011
+ // group's origin, already at the node's radius), the base
7012
+ // across it -- a distance, never an absolute radius, or the
7013
+ // wedge lands a whole radius further out than its label
7014
+ let dr = radialRadius(base) - radialRadius(d.y);
7015
+ path = 'M0,0 L' + dr + ',' + (-h / 2) + ' L' + dr + ',' + (h / 2) + ' Z';
7016
+ } else {
7017
+ let dx = base - d.y;
7018
+ path = 'M0,0 L' + dx + ',' + (-h / 2) + ' L' + dx + ',' + (h / 2) + ' Z';
7019
+ }
7020
+ d3.select(this)
7021
+ .attr('d', path)
7022
+ .attr('transform', radialDisplay() ? 'rotate(' + labelAngleDeg(d) + ')' : null)
7023
+ .style('fill', (hits.found > 0 && hits.found === hits.total) ? foundColor : color)
7024
+ .style('fill-opacity', (hits.found > 0 && hits.found === hits.total) ? 0.45 : 0.22)
7025
+ .style('stroke', foundColor || color)
7026
+ .style('stroke-width', foundColor ? 1.5 : 1)
7027
+ .style('stroke-opacity', 0.9)
7028
+ .style('stroke-linejoin', 'round');
7029
+ });
7030
+ label.each(function (d) {
7031
+ let base = collapsedBase(d);
7032
+ let hits = collapsedFoundCounts(d);
7033
+ let allFound = hits.found > 0 && hits.found === hits.total;
7034
+ let ink = allFound ? getFoundColor(forester.getAllExternalNodes(d).filter(isNodeFound)[0]) : _state.labelColorDefault;
7035
+ let t = d3.select(this)
7036
+ .text(collapsedLabel(d))
7037
+ .style('font', (allFound ? '600 ' : '') + fs + 'px ' + FONT_DEFAULTS)
7038
+ .style('fill', ink)
7039
+ .style('pointer-events', 'none');
7040
+ if (radialDisplay()) {
7041
+ let r = radialRadius(base) - radialRadius(d.y) + _state.nodeLabelGap;
7042
+ let flip = labelFlip(d);
7043
+ t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + r + ',0)' + (flip ? ' rotate(180)' : ''))
7044
+ .attr('text-anchor', flip ? 'end' : 'start')
7045
+ .attr('x', 0).attr('dy', '0.32em');
7046
+ } else {
7047
+ t.attr('transform', null)
7048
+ .attr('text-anchor', 'start')
7049
+ .attr('x', base - d.y + _state.nodeLabelGap)
7050
+ .attr('dy', (0.3 * fs) + 'px');
6667
7051
  }
6668
- return 'url(#' + id + ')';
7052
+ });
7053
+ }
7054
+
7055
+ // ===================== Shareable views =====================
7056
+ // A view is what a person made of a tree with the panel: the layout and
7057
+ // display type, what is labelled, the colours, the searches, the clade
7058
+ // they switched to, the ones they collapsed, the sizes. It is a plain
7059
+ // object (getViewState), a short "key=value&..." string (encodeViewState
7060
+ // / decodeViewState) that a page keeps in its URL hash, and a launch
7061
+ // config key (view) that opens a tree straight into it; onViewChange
7062
+ // tells the page whenever it changes. A node is named by its viewId, the
7063
+ // launch-time preorder index, which ladderizing and re-rooting leave
7064
+ // alone. NOT in a view: zoom and pan (a view opens fitted), the legend's
7065
+ // position, the node selection.
7066
+
7067
+ const VIEW_SHOW_FLAGS = [
7068
+ ['name', NODE_NAME_CB, 'showNodeName'],
7069
+ ['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
7070
+ ['sequence', SEQUENCE_CB, 'showSequence'],
7071
+ ['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
7072
+ ['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
7073
+ ['external', EXTERNAL_LABEL_CB, 'showExternalLabels'],
7074
+ ['internal', INTERNAL_LABEL_CB, 'showInternalLabels'],
7075
+ ['nodeEvents', NODE_EVENTS_CB, 'showNodeEvents'],
7076
+ ['branchEvents', BRANCH_EVENTS_CB, 'showBranchEvents'],
7077
+ ['supportDots', SUPPORT_DOTS_CB, 'showSupportDots'],
7078
+ ['shortNames', SHORTEN_NODE_NAME_CB, 'shortenNodeNames'],
7079
+ ['autoHide', DYNAHIDE_CB, 'dynahide'],
7080
+ ['visualizations', VIS_CB, 'showVisualizations'],
7081
+ ['visualStyles', VISUAL_STYLES_CB, 'useVisualStyles']
7082
+ ];
7083
+ const RADIAL_ROTATION_STEP = Math.PI / 32; // one press of the rotate buttons
7084
+
7085
+ // Launch-time preorder ids, given once per tree object: a tree shown
7086
+ // again keeps them, so a shared view still names the same nodes.
7087
+ function assignViewIds(phy) {
7088
+ if (phy.viewId !== undefined) {
7089
+ return;
6669
7090
  }
6670
- let labelSpace = domainLabelSpace();
6671
- if (_state.circularDisplay) {
6672
- // every bar starts on one ring past the labels and rides its
6673
- // tip's spoke outward
6674
- let r0 = _radial.maxRad + labelSpace + DOMAIN_RADIAL_GAP;
6675
- let h = domainBoxHeight((r0 * 2 * Math.PI) / tips.length);
6676
- tips.forEach(function (d) {
6677
- let t = g.append('g').attr('transform', 'rotate(' + labelAngleDeg(d) + ')');
6678
- drawOneArchitecture(t, forester.domainArchitectureOf(d), r0, -h / 2, h, f, false, gradientFor);
6679
- });
6680
- } else if (_state.unrootedDisplay) {
6681
- // no common ring: each bar starts past its own tip's label
6682
- let h = domainBoxHeight((Math.PI * 2 * _unroot.maxRad) / tips.length);
6683
- let start = labelSpace + DOMAIN_RADIAL_GAP;
6684
- tips.forEach(function (d) {
6685
- let t = g.append('g').attr('transform', 'translate(' + d.ux + ',' + d.uy + ') rotate(' + labelAngleDeg(d) + ')');
6686
- drawOneArchitecture(t, forester.domainArchitectureOf(d), start, -h / 2, h, f, false, gradientFor);
6687
- });
6688
- } else {
6689
- // one aligned column: past the deepest tip and the longest label.
6690
- // The box height follows the tip pitch (half of it), 6 to 16 px.
6691
- tips.sort(function (p, q) {
6692
- return p.x - q.x;
6693
- });
6694
- let n = tips.length;
6695
- let pitch = n > 1 ? (tips[n - 1].x - tips[0].x) / (n - 1) : _state.externalNodeFontSize;
6696
- let h = domainBoxHeight(pitch / 2);
6697
- let start = _w + _state.nodeLabelGap + labelSpace + DOMAIN_TRACK_START_GAP;
6698
- let labelsOn = _state.domainLabels === 'domains';
6699
- tips.forEach(function (d) {
6700
- drawOneArchitecture(g, forester.domainArchitectureOf(d), start, d.x - (h / 2), h, f, labelsOn, gradientFor);
7091
+ let i = 0;
7092
+ forester.preOrderTraversalAll(phy, function (n) {
7093
+ n.viewId = i++;
7094
+ });
7095
+ }
7096
+
7097
+ function nodeByViewId(id) {
7098
+ let found = null;
7099
+ forester.preOrderTraversalAll(_root_const, function (n) {
7100
+ if (found === null && n.viewId === id) {
7101
+ found = n;
7102
+ }
7103
+ });
7104
+ return found;
7105
+ }
7106
+
7107
+ // The config's custom label checkboxes (nodeLabels) that are offered
7108
+ function customLabelEntries() {
7109
+ let out = [];
7110
+ if (_nodeLabels) {
7111
+ Object.keys(_nodeLabels).forEach(function (key) {
7112
+ let v = _nodeLabels[key];
7113
+ if (v && v.label && v.showButton === true && v.propertyRef && v.description) {
7114
+ out.push({key: key, entry: v});
7115
+ }
6701
7116
  });
6702
7117
  }
7118
+ return out;
6703
7119
  }
6704
7120
 
6705
- function domainRect(g, x, y, w, h, r) {
6706
- return g.append('rect')
6707
- .attr('x', x).attr('y', y).attr('width', w).attr('height', h)
6708
- .attr('rx', r).attr('ry', r);
7121
+ function searchStateOf(idx) {
7122
+ let spec = currentSearchSpec(idx);
7123
+ let value = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
7124
+ if (value.trim().length === 0) {
7125
+ return null;
7126
+ }
7127
+ let q = {field: spec.field.label, mode: spec.mode, value: value};
7128
+ if (spec.mode === 'range' && spec.value2) {
7129
+ q.value2 = String(spec.value2);
7130
+ }
7131
+ return q;
7132
+ }
7133
+
7134
+ function getViewState() {
7135
+ let s = {};
7136
+ if (_treeIndex > 0) {
7137
+ s.tree = _treeIndex;
7138
+ }
7139
+ s.layout = _state.unrootedDisplay ? 'unrooted' : (_state.circularDisplay ? 'circular' : 'rectangular');
7140
+ s.display = _state.phylogram ? (_state.alignPhylogram ? 'aligned' : 'phylogram') : 'cladogram';
7141
+ if (_viewOps.root) {
7142
+ s.root = _viewOps.root;
7143
+ }
7144
+ if (_viewOps.order) {
7145
+ s.order = _viewOps.order;
7146
+ }
7147
+ if (_in_subtree && _root && _root.children && _root.children[0] && _root.children[0].viewId !== undefined) {
7148
+ s.subtree = _root.children[0].viewId;
7149
+ }
7150
+ let collapsed = [];
7151
+ forester.preOrderTraversalAll(_root_const, function (n) {
7152
+ if (n.collapsed && n.viewId !== undefined) {
7153
+ collapsed.push(n.viewId);
7154
+ }
7155
+ });
7156
+ if (collapsed.length > 0) {
7157
+ s.collapsed = collapsed;
7158
+ }
7159
+ s.colorBy = (_vis && _vis.colorId) || 'none';
7160
+ if (_vis && _vis.shapeId) {
7161
+ s.shapeBy = _vis.shapeId;
7162
+ }
7163
+ s.show = VIEW_SHOW_FLAGS.filter(function (f) {
7164
+ return _state[f[2]] === true;
7165
+ }).map(function (f) {
7166
+ return f[0];
7167
+ });
7168
+ customLabelEntries().forEach(function (c) {
7169
+ if (c.entry.selected === true) {
7170
+ s.show.push('custom:' + c.key);
7171
+ }
7172
+ });
7173
+ s.font = _state.externalNodeFontSize;
7174
+ s.node = _state.nodeSizeDefault;
7175
+ s.branch = _state.branchWidthDefault;
7176
+ let rotation = Math.round(_radialRotation / RADIAL_ROTATION_STEP);
7177
+ if (rotation !== 0) {
7178
+ s.rotation = rotation;
7179
+ }
7180
+ if (_radialLabelsHorizontal) {
7181
+ s.horizontalLabels = true;
7182
+ }
7183
+ if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
7184
+ s.msa = _state.showMsa === true;
7185
+ }
7186
+ if (_basicTreeProperties.domainArchitectures === true) {
7187
+ s.domains = _state.showDomainArchitectures === true;
7188
+ s.domainLabels = _state.domainLabels;
7189
+ s.domainGlow = _state.domainGlow === true;
7190
+ s.domainEvalue = _state.domainEvalueExponent;
7191
+ }
7192
+ if (_timeInfo && _timeInfo.type) {
7193
+ s.timeAxis = _state.showTimeAxis === true;
7194
+ s.timeGrid = _state.timeAxisGrid === true;
7195
+ }
7196
+ let a = searchStateOf(0);
7197
+ let b = searchStateOf(1);
7198
+ if (a) {
7199
+ s.searchA = a;
7200
+ }
7201
+ if (b) {
7202
+ s.searchB = b;
7203
+ }
7204
+ if (a && b) {
7205
+ let combine = getValue(SEARCH_COMBINE_SELECT);
7206
+ if (combine === 'and' || combine === 'or') {
7207
+ s.combine = combine;
7208
+ }
7209
+ }
7210
+ if (_state.searchIsCaseSensitive === true) {
7211
+ s.matchCase = true;
7212
+ }
7213
+ if (_state.searchNegateResult === true) {
7214
+ s.inverse = true;
7215
+ }
7216
+ return s;
7217
+ }
7218
+
7219
+ // Opens a view: the tree operations first (midpoint rooting, the
7220
+ // ladderize direction), then the clade and the collapsed clades by node
7221
+ // id, then everything the panel shows. A key the view leaves out keeps
7222
+ // its current value, except the two searches, which an empty view
7223
+ // clears (a view with no search has none). `initial` is the launch --
7224
+ // initialize() draws afterwards; otherwise the tree is redrawn and
7225
+ // fitted. Anything not understood, a hand-edited hash or a view of some
7226
+ // other tree, is skipped rather than guessed at.
7227
+ function applyViewState(s, initial) {
7228
+ if (!s || typeof s !== 'object') {
7229
+ return;
7230
+ }
7231
+ if (!initial && Number.isInteger(s.tree) && s.tree !== _treeIndex && s.tree >= 0 && s.tree < _trees.length) {
7232
+ let cfg = Object.assign({}, _launchConfig || {});
7233
+ cfg.view = s;
7234
+ launchInto(_container, _trees, s.tree, cfg);
7235
+ return;
7236
+ }
7237
+ if (s.root === 'midpoint' && _viewOps.root !== 'midpoint'
7238
+ && (_treeData.rerootable === undefined || _treeData.rerootable === true)) {
7239
+ forester.midpointRoot(_root_const);
7240
+ _viewOps.root = 'midpoint';
7241
+ }
7242
+ if (s.order === 'asc' || s.order === 'desc') {
7243
+ ladderizeSubtree(_root_const, s.order === 'asc', false);
7244
+ _viewOps.order = s.order;
7245
+ if (!_treeFn.visData) {
7246
+ _treeFn.visData = {};
7247
+ }
7248
+ _treeFn.visData.ladderize = s.order !== 'asc'; // the next press goes the other way
7249
+ }
7250
+ _root = _root_const;
7251
+ _in_subtree = false;
7252
+ clearCollapsedFlags(_root_const);
7253
+ if (Number.isInteger(s.subtree)) {
7254
+ let node = nodeByViewId(s.subtree);
7255
+ if (node && node.children && node.parent && node.parent.parent) {
7256
+ _in_subtree = true;
7257
+ _root = {children: [node], x: 0, x0: 0, y: 0, y0: 0};
7258
+ }
7259
+ }
7260
+ if (Array.isArray(s.collapsed) && s.collapsed.length > 0) {
7261
+ let ids = new Set(s.collapsed);
7262
+ forester.preOrderTraversalAll(_root_const, function (n) {
7263
+ if (n.children && n.parent && n.parent.parent && ids.has(n.viewId)) {
7264
+ n.collapsed = true;
7265
+ }
7266
+ });
7267
+ }
7268
+ _basicTreeProperties = forester.collectBasicTreeProperties(_root);
7269
+
7270
+ if (s.layout === 'rectangular' || s.layout === 'circular' || s.layout === 'unrooted') {
7271
+ _state.circularDisplay = s.layout === 'circular';
7272
+ _state.unrootedDisplay = s.layout === 'unrooted';
7273
+ }
7274
+ if (s.display === 'phylogram' || s.display === 'aligned' || s.display === 'cladogram') {
7275
+ let measured = _basicTreeProperties.branchLengths === true;
7276
+ _state.phylogram = measured && s.display !== 'cladogram';
7277
+ _state.alignPhylogram = measured && s.display === 'aligned' && !_state.unrootedDisplay;
7278
+ }
7279
+ if (Array.isArray(s.show)) {
7280
+ let on = new Set(s.show);
7281
+ VIEW_SHOW_FLAGS.forEach(function (f) {
7282
+ _state[f[2]] = on.has(f[0]);
7283
+ });
7284
+ customLabelEntries().forEach(function (c) {
7285
+ c.entry.selected = on.has('custom:' + c.key);
7286
+ });
7287
+ }
7288
+ if (isFinite(s.font)) {
7289
+ setFontSizes(Number(s.font));
7290
+ }
7291
+ if (isFinite(s.node)) {
7292
+ _state.nodeSizeDefault = Math.min(NODE_SIZE_MAX, Math.max(NODE_SIZE_MIN, Number(s.node)));
7293
+ }
7294
+ if (isFinite(s.branch)) {
7295
+ _state.branchWidthDefault = Math.min(BRANCH_WIDTH_MAX, Math.max(BRANCH_WIDTH_MIN, Number(s.branch)));
7296
+ }
7297
+ if (Number.isInteger(s.rotation)) {
7298
+ _radialRotation = (s.rotation * RADIAL_ROTATION_STEP) % (2 * Math.PI);
7299
+ }
7300
+ if (typeof s.horizontalLabels === 'boolean') {
7301
+ _radialLabelsHorizontal = s.horizontalLabels;
7302
+ }
7303
+ if (typeof s.msa === 'boolean') {
7304
+ _state.showMsa = s.msa;
7305
+ }
7306
+ if (typeof s.domains === 'boolean') {
7307
+ _state.showDomainArchitectures = s.domains;
7308
+ }
7309
+ if (s.domainLabels === 'none' || s.domainLabels === 'domains' || s.domainLabels === 'legend') {
7310
+ _state.domainLabels = s.domainLabels;
7311
+ }
7312
+ if (typeof s.domainGlow === 'boolean') {
7313
+ _state.domainGlow = s.domainGlow;
7314
+ }
7315
+ if (Number.isInteger(s.domainEvalue) && s.domainEvalue >= forester.DOMAIN_EVALUE_EXPONENT_MIN
7316
+ && s.domainEvalue <= forester.DOMAIN_EVALUE_EXPONENT_MAX && s.domainEvalue !== _state.domainEvalueExponent) {
7317
+ _state.domainEvalueExponent = s.domainEvalue;
7318
+ if (_domain) {
7319
+ _domain.palette = null; // the drawn set changed: the palette is dealt again
7320
+ }
7321
+ }
7322
+ if (typeof s.timeAxis === 'boolean') {
7323
+ _state.showTimeAxis = s.timeAxis;
7324
+ }
7325
+ if (typeof s.timeGrid === 'boolean') {
7326
+ _state.timeAxisGrid = s.timeGrid;
7327
+ }
7328
+ if (!radialDisplay()) {
7329
+ _radialLabelsHorizontal = false;
7330
+ } else if (_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
7331
+ _radialLabelsHorizontal = false; // the domain boxes ride the spokes, so the labels must too
7332
+ }
7333
+ if (_vis) {
7334
+ if (s.colorBy === 'none') {
7335
+ _vis.colorId = null;
7336
+ } else if (s.colorBy && _vis.byId[s.colorBy]) {
7337
+ _vis.colorId = s.colorBy;
7338
+ _state.showVisualizations = true;
7339
+ }
7340
+ if (s.shapeBy === 'none') {
7341
+ _vis.shapeId = null;
7342
+ } else if (s.shapeBy && _vis.byId[s.shapeBy] && _vis.byId[s.shapeBy].shape) {
7343
+ _vis.shapeId = s.shapeBy;
7344
+ _state.showVisualizations = true;
7345
+ }
7346
+ }
7347
+ _state.searchIsCaseSensitive = s.matchCase === true;
7348
+ _state.searchNegateResult = s.inverse === true;
7349
+ applySearchState(0, s.searchA);
7350
+ applySearchState(1, s.searchB);
7351
+ setValue(SEARCH_COMBINE_SELECT,
7352
+ (s.searchA && s.searchB && (s.combine === 'and' || s.combine === 'or')) ? s.combine : 'independent');
7353
+
7354
+ refreshVisualizations(false);
7355
+ resetVis();
7356
+ syncViewControls();
7357
+ if (!initial) {
7358
+ runSearches();
7359
+ zoomToFit();
7360
+ }
7361
+ }
7362
+
7363
+ // One search box from its view state: the field by its label, the mode
7364
+ // when the field offers it, the value(s); nothing given clears the box.
7365
+ function applySearchState(idx, q) {
7366
+ let fieldSel = byId(idx === 0 ? SEARCH_FIELD_SELECT_0 : SEARCH_FIELD_SELECT_1);
7367
+ let modeSel = byId(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
7368
+ if (!fieldSel || !modeSel) {
7369
+ return;
7370
+ }
7371
+ let want = (q && typeof q === 'object') ? q : {};
7372
+ let fi = want.field ? _searchFields.findIndex(function (f) {
7373
+ return f.label === want.field;
7374
+ }) : -1;
7375
+ fieldSel.value = String(fi >= 0 ? fi : 0);
7376
+ populateSearchModeMenu(idx);
7377
+ if (want.mode && Array.from(modeSel.options).some(function (o) { return o.value === want.mode; })) {
7378
+ modeSel.value = want.mode;
7379
+ }
7380
+ setValue(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1, want.value ? String(want.value) : '');
7381
+ setValue(idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1, want.value2 ? String(want.value2) : '');
7382
+ updateSearchValue2Visibility(idx);
7383
+ updateSearchAutocomplete(idx);
7384
+ if (idx === 1 && want.value) {
7385
+ showSearchB();
7386
+ }
7387
+ }
7388
+
7389
+ // The panel's controls from the state, after applyViewState
7390
+ function syncViewControls() {
7391
+ setDisplayTypeButtons();
7392
+ syncZoomRowButtons();
7393
+ VIEW_SHOW_FLAGS.forEach(function (f) {
7394
+ setCheckboxValue(f[1], _state[f[2]] === true);
7395
+ });
7396
+ customLabelEntries().forEach(function (c) {
7397
+ if (c.entry.cb_id) {
7398
+ setCheckboxValue(c.entry.cb_id, c.entry.selected === true);
7399
+ }
7400
+ });
7401
+ setCheckboxValue(MSA_CB, _state.showMsa === true);
7402
+ setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
7403
+ setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
7404
+ setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
7405
+ setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
7406
+ syncDomainControls();
7407
+ setSliderValue(FONT_SIZE_SLIDER, _state.externalNodeFontSize);
7408
+ setSliderValue(NODE_SIZE_SLIDER, _state.nodeSizeDefault);
7409
+ setSliderValue(BRANCH_WIDTH_SLIDER, _state.branchWidthDefault);
7410
+ setSelectMenuValue(LABEL_COLOR_SELECT_MENU, (_vis && _vis.colorId) || DEFAULT);
7411
+ setSelectMenuValue(NODE_SHAPE_SELECT_MENU, (_vis && _vis.shapeId) || DEFAULT);
7412
+ setCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB, _state.searchIsCaseSensitive === true);
7413
+ setCheckboxValue(SEARCH_OPTIONS_NEGATE_RES_CB, _state.searchNegateResult === true);
7414
+ syncLadderizeGlyph();
7415
+ }
7416
+
7417
+ // The ladderize glyph shows the direction the NEXT press will use.
7418
+ function syncLadderizeGlyph() {
7419
+ let orderBtn = byId(LADDERIZE_BUTTON);
7420
+ if (orderBtn) {
7421
+ let next = !(_treeFn && _treeFn.visData && _treeFn.visData.ladderize === false);
7422
+ orderBtn.innerHTML = makeGlyph(next ? 'ladderize_asc' : 'ladderize_desc');
7423
+ }
7424
+ }
7425
+
7426
+ // onViewChange: once per settled redraw, only when the view actually
7427
+ // changed. The launch's own view is recorded and not reported -- the
7428
+ // page put the tree up and knows what it asked for -- but a relaunch
7429
+ // that changed it (the tree picker) is reported like any change.
7430
+ function noteViewChange() {
7431
+ if (_initializing || !_settings || typeof _settings.onViewChange !== 'function' || _viewNotifyPending) {
7432
+ return;
7433
+ }
7434
+ _viewNotifyPending = true;
7435
+ let seq = _launchSeq;
7436
+ setTimeout(function () {
7437
+ _viewNotifyPending = false;
7438
+ if (seq !== _launchSeq || !_root || !_container) {
7439
+ return;
7440
+ }
7441
+ reportView(false);
7442
+ }, 0);
7443
+ }
7444
+
7445
+ function settleViewNotification() {
7446
+ if (_settings && typeof _settings.onViewChange === 'function') {
7447
+ reportView(_lastViewEncoded === undefined);
7448
+ }
7449
+ }
7450
+
7451
+ function reportView(silent) {
7452
+ let state = getViewState();
7453
+ let encoded = archaeopteryx.encodeViewState(state);
7454
+ if (encoded === _lastViewEncoded) {
7455
+ return;
7456
+ }
7457
+ _lastViewEncoded = encoded;
7458
+ if (silent) {
7459
+ return;
7460
+ }
7461
+ try {
7462
+ _settings.onViewChange(state, encoded);
7463
+ } catch (e) {
7464
+ console.error(ERROR + 'onViewChange threw: ' + (e && e.message ? e.message : e));
7465
+ }
7466
+ }
7467
+
7468
+ // The string form: "key=value&key=value", the values URL-encoded but
7469
+ // with ':' and ',' kept readable (refs and lists) and spaces as '+'.
7470
+ // Lists are comma-joined, booleans 1 / 0, the two searches flattened
7471
+ // to a / af / am / a2 and b / bf / bm / b2. Made for a URL hash, where
7472
+ // none of these characters needs escaping.
7473
+ const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine'];
7474
+ const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
7475
+ const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
7476
+ const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'domains', 'domainGlow', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
7477
+ const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
7478
+
7479
+ function encodeViewValue(v) {
7480
+ return encodeURIComponent(String(v)).replace(/%3A/gi, ':').replace(/%2C/gi, ',').replace(/%20/g, '+');
7481
+ }
7482
+
7483
+ function decodeViewValue(v) {
7484
+ try {
7485
+ return decodeURIComponent(String(v).replace(/\+/g, '%20'));
7486
+ } catch {
7487
+ return '';
7488
+ }
7489
+ }
7490
+
7491
+ archaeopteryx.encodeViewState = function (state) {
7492
+ if (!state || typeof state !== 'object') {
7493
+ return '';
7494
+ }
7495
+ let parts = [];
7496
+ let put = function (k, v) {
7497
+ if (v !== undefined && v !== null && v !== '') {
7498
+ parts.push(k + '=' + encodeViewValue(v));
7499
+ }
7500
+ };
7501
+ VIEW_INT_KEYS.concat(VIEW_TEXT_KEYS, VIEW_NUMBER_KEYS).forEach(function (k) {
7502
+ put(k, state[k]);
7503
+ });
7504
+ if (Array.isArray(state.show)) {
7505
+ parts.push('show=' + state.show.map(encodeViewValue).join(',')); // an empty list is a fact: nothing shown
7506
+ }
7507
+ if (Array.isArray(state.collapsed) && state.collapsed.length > 0) {
7508
+ put('collapsed', state.collapsed.join(','));
7509
+ }
7510
+ VIEW_BOOL_KEYS.forEach(function (k) {
7511
+ if (typeof state[k] === 'boolean') {
7512
+ put(k, state[k] ? 1 : 0);
7513
+ }
7514
+ });
7515
+ VIEW_SEARCH_KEYS.forEach(function (sk) {
7516
+ let q = state[sk[0]];
7517
+ if (q && typeof q === 'object' && q.value) {
7518
+ put(sk[1], q.value);
7519
+ put(sk[1] + 'f', q.field);
7520
+ put(sk[1] + 'm', q.mode);
7521
+ put(sk[1] + '2', q.value2);
7522
+ }
7523
+ });
7524
+ return parts.join('&');
7525
+ };
7526
+
7527
+ archaeopteryx.decodeViewState = function (text) {
7528
+ if (text === undefined || text === null) {
7529
+ return null;
7530
+ }
7531
+ let s = String(text).replace(/^#/, '');
7532
+ if (s.length === 0) {
7533
+ return null;
7534
+ }
7535
+ let raw = {};
7536
+ s.split('&').forEach(function (pair) {
7537
+ let eq = pair.indexOf('=');
7538
+ if (eq > 0) {
7539
+ raw[decodeViewValue(pair.substring(0, eq))] = decodeViewValue(pair.substring(eq + 1));
7540
+ }
7541
+ });
7542
+ let state = {};
7543
+ VIEW_TEXT_KEYS.forEach(function (k) {
7544
+ if (raw[k] !== undefined && raw[k] !== '') {
7545
+ state[k] = raw[k];
7546
+ }
7547
+ });
7548
+ VIEW_INT_KEYS.forEach(function (k) {
7549
+ if (raw[k] !== undefined && /^-?\d+$/.test(raw[k])) {
7550
+ state[k] = parseInt(raw[k], 10);
7551
+ }
7552
+ });
7553
+ VIEW_NUMBER_KEYS.forEach(function (k) {
7554
+ let n = parseFloat(raw[k]);
7555
+ if (isFinite(n)) {
7556
+ state[k] = n;
7557
+ }
7558
+ });
7559
+ VIEW_BOOL_KEYS.forEach(function (k) {
7560
+ if (raw[k] === '1' || raw[k] === '0') {
7561
+ state[k] = raw[k] === '1';
7562
+ }
7563
+ });
7564
+ if (raw.show !== undefined) {
7565
+ state.show = raw.show.split(',').filter(function (x) {
7566
+ return x.length > 0;
7567
+ });
7568
+ }
7569
+ if (raw.collapsed !== undefined) {
7570
+ state.collapsed = raw.collapsed.split(',').filter(function (x) {
7571
+ return /^\d+$/.test(x);
7572
+ }).map(function (x) {
7573
+ return parseInt(x, 10);
7574
+ });
7575
+ }
7576
+ VIEW_SEARCH_KEYS.forEach(function (sk) {
7577
+ let p = sk[1];
7578
+ if (raw[p]) {
7579
+ let q = {value: raw[p]};
7580
+ if (raw[p + 'f']) {
7581
+ q.field = raw[p + 'f'];
7582
+ }
7583
+ if (raw[p + 'm']) {
7584
+ q.mode = raw[p + 'm'];
7585
+ }
7586
+ if (raw[p + '2']) {
7587
+ q.value2 = raw[p + '2'];
7588
+ }
7589
+ state[sk[0]] = q;
7590
+ }
7591
+ });
7592
+ return Object.keys(state).length > 0 ? state : null;
7593
+ };
7594
+
7595
+ // ===================== Keyboard shortcuts =====================
7596
+ // Ctrl on Windows and Linux, Cmd on macOS (Christian, 2026-09-13: "Ctrl/Cmd
7597
+ // combos are better"). The letters follow the desktop where it has the
7598
+ // action (its Alt+O order, Alt+U uncollapse all, Alt+E expand, Cmd+0 fit,
7599
+ // Cmd+G / Cmd+Shift+G next / previous hit); the layout, display type
7600
+ // and time-axis keys are ours. Combos the browser reserves (new tab,
7601
+ // close, tab switching, and on macOS the screenshot keys) are avoided,
7602
+ // and the few browser functions that are overridden (page zoom, find
7603
+ // next) matter little on a page that is one tree. No key for rooting,
7604
+ // by decision. Plain keys stay as they were: Esc / Home reset, O moves
7605
+ // the overview, PageUp / PageDown size the font. Inside a text box only
7606
+ // the combos that cannot interfere with typing fire.
7607
+
7608
+ const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '');
7609
+ const SHORTCUTS_DIALOG = 'aptx_shortcuts';
7610
+
7611
+ // key: what e.key reads (letters lower-case); shift: whether Shift is
7612
+ // held; typing: the combo also fires from inside a text box
7613
+ const SHORTCUTS = [
7614
+ {key: '0', label: '0', what: 'Fit the tree to the window', run: function () { zoomToFit(); }, typing: true},
7615
+ {key: '=', label: '+', aliases: ['+'], what: 'Zoom in', run: function () { zoomInY(); if (!radialDisplay()) { zoomInX(); } }, typing: true},
7616
+ {key: '-', label: '−', aliases: ['_'], what: 'Zoom out', run: function () { zoomOutY(); if (!radialDisplay()) { zoomOutX(); } }, typing: true},
7617
+ {key: 'arrowup', label: '↑', shift: true, what: 'Zoom in vertically', run: function () { zoomInY(); }},
7618
+ {key: 'arrowdown', label: '↓', shift: true, what: 'Zoom out vertically', run: function () { zoomOutY(); }},
7619
+ {key: 'arrowright', label: '→', shift: true, what: 'Zoom in horizontally (circular: rotate)', run: function () { zoomInX(); }},
7620
+ {key: 'arrowleft', label: '←', shift: true, what: 'Zoom out horizontally (circular: rotate back)', run: function () { zoomOutX(); }},
7621
+ {key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
7622
+ {key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
7623
+ {key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
7624
+ {key: 'x', label: 'X', shift: true, what: 'Time axis on / off', run: toggleTimeAxis},
7625
+ {key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
7626
+ {key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
7627
+ {key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
7628
+ {key: 'g', label: 'G', what: 'Next search hit', run: function () { stepToFoundNode(1); }, typing: true},
7629
+ {key: 'g', label: 'G', shift: true, what: 'Previous search hit', run: function () { stepToFoundNode(-1); }, typing: true},
7630
+ {key: ',', label: '<', aliases: ['<'], shift: true, what: 'Previous tree of the file', run: function () { stepTree(-1); }},
7631
+ {key: '.', label: '>', aliases: ['>'], shift: true, what: 'Next tree of the file', run: function () { stepTree(1); }},
7632
+ {key: '/', label: '/', aliases: ['?'], what: 'This list', run: showShortcutsDialog, typing: true}
7633
+ ];
7634
+
7635
+ function shortcutMatches(sc, e) {
7636
+ let k = (e.key || '').toLowerCase();
7637
+ if (k !== sc.key && !(sc.aliases && sc.aliases.indexOf(e.key) >= 0)) {
7638
+ return false;
7639
+ }
7640
+ return !!sc.shift === !!e.shiftKey;
7641
+ }
7642
+
7643
+ // The one document-level handler for the combos: keydown, so the
7644
+ // browser's own action is stopped before it runs.
7645
+ function shortcutKeyDown(e) {
7646
+ let mod = IS_MAC ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey);
7647
+ if (!mod || e.altKey || !_root) {
7648
+ return;
7649
+ }
7650
+ let typing = isTypingTarget(e.target);
7651
+ for (let i = 0; i < SHORTCUTS.length; ++i) {
7652
+ let sc = SHORTCUTS[i];
7653
+ if (shortcutMatches(sc, e)) {
7654
+ if (typing && !sc.typing) {
7655
+ return;
7656
+ }
7657
+ e.preventDefault();
7658
+ sc.run();
7659
+ return;
7660
+ }
7661
+ }
7662
+ }
7663
+
7664
+ function cycleLayout() {
7665
+ let next = _state.unrootedDisplay ? 'rectangular' : (_state.circularDisplay ? 'unrooted' : 'circular');
7666
+ setCheckboxValue(LAYOUT_RECT_BUTTON, next === 'rectangular');
7667
+ setCheckboxValue(LAYOUT_CIRC_BUTTON, next === 'circular');
7668
+ setCheckboxValue(LAYOUT_UNROOTED_BUTTON, next === 'unrooted');
7669
+ layoutButtonClicked();
7670
+ }
7671
+
7672
+ // Phylogram, aligned phylogram, cladogram -- skipping what the tree or
7673
+ // the layout rules out (no branch lengths: cladogram only; unrooted has
7674
+ // no aligned phylogram).
7675
+ function cycleDisplayType() {
7676
+ let measured = _basicTreeProperties && _basicTreeProperties.branchLengths === true;
7677
+ if (!measured) {
7678
+ toCladegram();
7679
+ return;
7680
+ }
7681
+ if (_state.phylogram && !_state.alignPhylogram) {
7682
+ if (_state.unrootedDisplay) {
7683
+ toCladegram();
7684
+ } else {
7685
+ toAlignedPhylogram();
7686
+ }
7687
+ } else if (_state.phylogram) {
7688
+ toCladegram();
7689
+ } else {
7690
+ toPhylogram();
7691
+ }
7692
+ }
7693
+
7694
+ // The Time Axis checkbox's toggle; when the distance / time tree switch
7695
+ // arrives it takes over this key.
7696
+ function toggleTimeAxis() {
7697
+ let cb = byId(TIME_AXIS_CB);
7698
+ if (!cb || cb.disabled) {
7699
+ return;
7700
+ }
7701
+ cb.checked = !cb.checked;
7702
+ timeAxisCbClicked();
7703
+ }
7704
+
7705
+ function focusSearch() {
7706
+ let f = byId(SEARCH_FIELD_0);
7707
+ if (f) {
7708
+ f.focus();
7709
+ f.select();
7710
+ }
7711
+ }
7712
+
7713
+ function stepTree(delta) {
7714
+ let i = _treeIndex + delta;
7715
+ if (i >= 0 && i < _trees.length) {
7716
+ showTree(i);
7717
+ }
7718
+ }
7719
+
7720
+ function shortcutLabel(sc) {
7721
+ let parts = [];
7722
+ if (IS_MAC) {
7723
+ parts.push('⌘');
7724
+ if (sc.shift) {
7725
+ parts.push('⇧');
7726
+ }
7727
+ } else {
7728
+ parts.push('Ctrl');
7729
+ if (sc.shift) {
7730
+ parts.push('Shift');
7731
+ }
7732
+ }
7733
+ parts.push(sc.label);
7734
+ return parts;
7735
+ }
7736
+
7737
+ function keyChips(parts) {
7738
+ let holder = document.createElement('span');
7739
+ holder.className = 'aptx-keys';
7740
+ parts.forEach(function (p) {
7741
+ let kbd = document.createElement('kbd');
7742
+ kbd.className = 'aptx-kbd';
7743
+ kbd.textContent = p;
7744
+ holder.appendChild(kbd);
7745
+ });
7746
+ return holder;
7747
+ }
7748
+
7749
+ // The cheat sheet: every combo above, then the plain keys.
7750
+ function showShortcutsDialog() {
7751
+ let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
7752
+ shell.body.classList.add('aptx-shortcuts');
7753
+ let rows = SHORTCUTS.map(function (sc) {
7754
+ return [shortcutLabel(sc), sc.what];
7755
+ }).concat([
7756
+ [['Esc'], 'Back to the whole tree, the launch view'],
7757
+ [['O'], 'Move the overview to the next corner'],
7758
+ [['PageUp'], 'Larger font'],
7759
+ [['PageDown'], 'Smaller font']
7760
+ ]);
7761
+ rows.forEach(function (row) {
7762
+ let line = document.createElement('div');
7763
+ line.className = 'aptx-dialog-line';
7764
+ let key = document.createElement('span');
7765
+ key.className = 'aptx-dialog-key';
7766
+ key.appendChild(keyChips(row[0]));
7767
+ let val = document.createElement('span');
7768
+ val.className = 'aptx-dialog-val';
7769
+ val.textContent = row[1];
7770
+ line.appendChild(key);
7771
+ line.appendChild(val);
7772
+ shell.body.appendChild(line);
7773
+ });
7774
+ let note = document.createElement('p');
7775
+ note.className = 'aptx-shortcuts-note';
7776
+ note.textContent = 'The mouse wheel zooms; with Shift it zooms vertically only, with Shift+Alt horizontally, with Ctrl+Shift it sizes the font.';
7777
+ shell.body.appendChild(note);
7778
+ shell.dialog.showModal();
7779
+ }
7780
+
7781
+ // ===================== Protein domain architectures =====================
7782
+ // The desktop's domain display, ported from its RenderableDomainArchitecture
7783
+ // and TreePanel (the spec is section D1 of the repo's TODO.md). Each tip's
7784
+ // architecture is a grey backbone of L residues with a rounded box per
7785
+ // domain the E-value threshold admits: in one aligned column past the
7786
+ // labels in the rectangular layout, riding the tip's spoke in the radial
7787
+ // ones. One scale for the whole tree -- f px per residue, from the track
7788
+ // width and the longest architecture -- so lengths compare across tips.
7789
+ // Which domains, where, which colour and which legend rows are forester's
7790
+ // (tested against the desktop's numbers); this is the SVG, the legend
7791
+ // card and the controls. The boxes take no pointer events: nothing to
7792
+ // hover or click, as on the desktop.
7793
+
7794
+ function domainsShown() {
7795
+ return _state.showDomainArchitectures === true
7796
+ && _basicTreeProperties.domainArchitectures === true
7797
+ && _basicTreeProperties.maxDomainArchitectureLength > 0
7798
+ && _state.showExternalLabels
7799
+ // in a radial layout the boxes ride the spokes, so only radial
7800
+ // labels go with them
7801
+ && (!radialDisplay() || !_radialLabelsHorizontal);
7802
+ }
7803
+
7804
+ // The track width W of the rectangular layouts: a quarter of the
7805
+ // viewport at first use (the layout's width, if smaller). The radial
7806
+ // layouts keep a width of their own, starting at the smaller of W and a
7807
+ // fifth of the radius, so a long architecture does not run off the
7808
+ // circle at first sight. d+ / d- scale whichever width the current
7809
+ // layout uses by 1.2 / 0.8 and stop at 2000 / 20. (The desktop instead
7810
+ // caps the drawn width at that fifth of the radius on every redraw,
7811
+ // which leaves its buttons stepping a number the cap then discards --
7812
+ // Christian: "all buttons should work as expected".)
7813
+ function domainRectangularWidth() {
7814
+ if (!_domain.width) {
7815
+ let vp = svgSize();
7816
+ let vw = Math.min(_displayWidth, (vp && vp.w) ? vp.w : _displayWidth);
7817
+ _domain.width = Math.max(DOMAIN_WIDTH_MIN, Math.round(vw * DOMAIN_WIDTH_VIEWPORT_FRACTION));
7818
+ }
7819
+ return _domain.width;
7820
+ }
7821
+
7822
+ function domainTrackWidth() {
7823
+ if (!radialDisplay()) {
7824
+ return domainRectangularWidth();
7825
+ }
7826
+ if (!_domain.radialWidth) {
7827
+ let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
7828
+ if (!(r > 0)) {
7829
+ return domainRectangularWidth(); // no radius yet (before the first radial draw)
7830
+ }
7831
+ _domain.radialWidth = Math.max(DOMAIN_WIDTH_MIN, Math.min(domainRectangularWidth(), DOMAIN_RADIAL_WIDTH_FRACTION * r));
7832
+ }
7833
+ return _domain.radialWidth;
7834
+ }
7835
+
7836
+ function setDomainTrackWidth(w) {
7837
+ if (radialDisplay()) {
7838
+ _domain.radialWidth = w;
7839
+ } else {
7840
+ _domain.width = w;
7841
+ }
7842
+ }
7843
+
7844
+ function domainEffectiveWidth() {
7845
+ return domainTrackWidth();
7846
+ }
7847
+
7848
+ // px per residue, one factor for every architecture on the tree; Lmax
7849
+ // counts every domain whatever its E-value, so the threshold never
7850
+ // rescales the tracks
7851
+ function domainScale() {
7852
+ return (domainEffectiveWidth() / _basicTreeProperties.maxDomainArchitectureLength) * DOMAIN_SCALE_HEADROOM;
7853
+ }
7854
+
7855
+ // The room the longest tip label takes: the same estimate the layout is
7856
+ // sized against (there are no font metrics in the model).
7857
+ function domainLabelSpace() {
7858
+ return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
7859
+ }
7860
+
7861
+ // The rectangular layout reserves the tracks' column so the tree and
7862
+ // labels compress to make room, as the alignment track does; the radial
7863
+ // layouts fit the tracks into the ring instead (fitRadialExtent).
7864
+ function domainReserve() {
7865
+ if (!domainsShown() || radialDisplay()) {
7866
+ return 0;
7867
+ }
7868
+ return DOMAIN_TRACK_START_GAP + domainTrackWidth() + DOMAIN_TRACK_END_GAP;
7869
+ }
7870
+
7871
+ function domainRadialExtent() {
7872
+ if (!domainsShown() || !radialDisplay()) {
7873
+ return 0;
7874
+ }
7875
+ return DOMAIN_RADIAL_GAP + domainEffectiveWidth() + DOMAIN_TRACK_END_GAP;
7876
+ }
7877
+
7878
+ // Colours: the names the threshold admits over the WHOLE tree, sorted by
7879
+ // code unit, take the palette in order. Dealt at load, after an edit and
7880
+ // whenever the threshold changes (the drawn set changes then, so colours
7881
+ // can shift); a name met later takes the next unused colour and keeps
7882
+ // it. Never derived from the name's characters -- that let SH2 and SH3
7883
+ // collide on the desktop.
7884
+ function domainPalette() {
7885
+ if (!_domain.palette) {
7886
+ let names = forester.domainSummary(forester.getTreeRoot(_treeData), _state.domainEvalueExponent).names;
7887
+ _domain.palette = Object.create(null);
7888
+ names.forEach(function (name, i) {
7889
+ _domain.palette[name] = i;
7890
+ });
7891
+ _domain.next = names.length;
7892
+ }
7893
+ return _domain.palette;
7894
+ }
7895
+
7896
+ function domainColor(name) {
7897
+ if (!name) {
7898
+ return forester.DOMAIN_UNNAMED_COLOR;
7899
+ }
7900
+ let palette = domainPalette();
7901
+ if (palette[name] === undefined) {
7902
+ palette[name] = _domain.next++;
7903
+ }
7904
+ return forester.domainQualitativeColor(palette[name]);
7905
+ }
7906
+
7907
+ function domainBoxHeight(pitch) {
7908
+ return Math.max(DOMAIN_BOX_MIN_H, Math.min(DOMAIN_BOX_MAX_H, Math.round(pitch)));
7909
+ }
7910
+
7911
+ function drawDomainArchitectures() {
7912
+ if (!_svgGroup) {
7913
+ return;
7914
+ }
7915
+ _svgGroup.selectAll('g.aptx-domains').remove();
7916
+ if (!domainsShown() || !_root) {
7917
+ return;
7918
+ }
7919
+ let tips = displayedTips().filter(function (d) {
7920
+ return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
7921
+ });
7922
+ let f = domainScale();
7923
+ if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
7924
+ return;
7925
+ }
7926
+ let g = _svgGroup.append('g').attr('class', 'aptx-domains').style('pointer-events', 'none');
7927
+ // one vertical gradient per base colour, lighter at the top; the
7928
+ // defs ride into the SVG / PDF / PNG exports with the boxes
7929
+ let defs = g.append('defs');
7930
+ let made = Object.create(null);
7931
+ function gradientFor(base) {
7932
+ let id = 'aptx-dom-' + base.substring(1);
7933
+ if (!made[id]) {
7934
+ made[id] = true;
7935
+ let grad = defs.append('linearGradient').attr('id', id)
7936
+ .attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', 1);
7937
+ grad.append('stop').attr('offset', '0%').attr('stop-color', forester.domainLighten(base, 0.12));
7938
+ grad.append('stop').attr('offset', '100%').attr('stop-color', forester.domainDarken(base, 0.10));
7939
+ }
7940
+ return 'url(#' + id + ')';
7941
+ }
7942
+ let labelSpace = domainLabelSpace();
7943
+ if (_state.circularDisplay) {
7944
+ // every bar starts on one ring past the labels and rides its
7945
+ // tip's spoke outward
7946
+ let r0 = _radial.maxRad + labelSpace + DOMAIN_RADIAL_GAP;
7947
+ let h = domainBoxHeight((r0 * 2 * Math.PI) / tips.length);
7948
+ tips.forEach(function (d) {
7949
+ let t = g.append('g').attr('transform', 'rotate(' + labelAngleDeg(d) + ')');
7950
+ drawOneArchitecture(t, forester.domainArchitectureOf(d), r0, -h / 2, h, f, false, gradientFor);
7951
+ });
7952
+ } else if (_state.unrootedDisplay) {
7953
+ // no common ring: each bar starts past its own tip's label
7954
+ let h = domainBoxHeight((Math.PI * 2 * _unroot.maxRad) / tips.length);
7955
+ let start = labelSpace + DOMAIN_RADIAL_GAP;
7956
+ tips.forEach(function (d) {
7957
+ let t = g.append('g').attr('transform', 'translate(' + d.ux + ',' + d.uy + ') rotate(' + labelAngleDeg(d) + ')');
7958
+ drawOneArchitecture(t, forester.domainArchitectureOf(d), start, -h / 2, h, f, false, gradientFor);
7959
+ });
7960
+ } else {
7961
+ // one aligned column: past the deepest tip and the longest label.
7962
+ // The box height follows the tip pitch (half of it), 6 to 16 px.
7963
+ tips.sort(function (p, q) {
7964
+ return p.x - q.x;
7965
+ });
7966
+ let n = tips.length;
7967
+ let pitch = n > 1 ? (tips[n - 1].x - tips[0].x) / (n - 1) : _state.externalNodeFontSize;
7968
+ let h = domainBoxHeight(pitch / 2);
7969
+ let start = _w + _state.nodeLabelGap + labelSpace + DOMAIN_TRACK_START_GAP;
7970
+ let labelsOn = _state.domainLabels === 'domains';
7971
+ tips.forEach(function (d) {
7972
+ drawOneArchitecture(g, forester.domainArchitectureOf(d), start, d.x - (h / 2), h, f, labelsOn, gradientFor);
7973
+ });
7974
+ }
7975
+ }
7976
+
7977
+ function domainRect(g, x, y, w, h, r) {
7978
+ return g.append('rect')
7979
+ .attr('x', x).attr('y', y).attr('width', w).attr('height', h)
7980
+ .attr('rx', r).attr('ry', r);
6709
7981
  }
6710
7982
 
6711
7983
  // One architecture, in the desktop's order: the backbone, then per
@@ -6782,7 +8054,7 @@ function (root, d3, forester, phyloXml) {
6782
8054
  // rows in the order the tips are on screen -- top to bottom, or
6783
8055
  // clockwise -- not the data's order, which a ladderized display
6784
8056
  // does not follow
6785
- let tips = forester.getAllExternalNodes(_root).filter(function (d) {
8057
+ let tips = displayedTips().filter(function (d) {
6786
8058
  return d.x !== undefined;
6787
8059
  }).sort(function (p, q) {
6788
8060
  return p.x - q.x;
@@ -6918,9 +8190,16 @@ function (root, d3, forester, phyloXml) {
6918
8190
  if (grow ? w >= DOMAIN_WIDTH_MAX : w <= DOMAIN_WIDTH_MIN) {
6919
8191
  return;
6920
8192
  }
6921
- _domain.width = w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK);
8193
+ setDomainTrackWidth(w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK));
6922
8194
  syncDomainControls();
6923
- scheduleUpdate(null, 0);
8195
+ // a radial layout has no column to reserve, but the fit must take the
8196
+ // new track length into account, so it is refitted
8197
+ if (radialDisplay()) {
8198
+ scheduleUpdate(null, 0, true);
8199
+ afterUpdate(zoomToFit);
8200
+ } else {
8201
+ scheduleUpdate(null, 0);
8202
+ }
6924
8203
  }
6925
8204
 
6926
8205
  function domainEvalueStep(delta) {
@@ -6969,7 +8248,9 @@ function (root, d3, forester, phyloXml) {
6969
8248
 
6970
8249
  // Search B starts hidden to keep the panel compact; one click (or a
6971
8250
  // configured initial value) reveals it, and it stays revealed.
6972
- function revealSearchB() {
8251
+ // Search B is folded away until wanted: the '+ Search B' link (which
8252
+ // focuses it) or a view that carries a second search.
8253
+ function showSearchB() {
6973
8254
  let wrap = byId(SEARCH_B_WRAP);
6974
8255
  if (wrap) {
6975
8256
  wrap.style.display = '';
@@ -6978,6 +8259,10 @@ function (root, d3, forester, phyloXml) {
6978
8259
  if (tgl) {
6979
8260
  tgl.style.display = 'none';
6980
8261
  }
8262
+ }
8263
+
8264
+ function revealSearchB() {
8265
+ showSearchB();
6981
8266
  let f = byId(SEARCH_FIELD_1);
6982
8267
  if (f) {
6983
8268
  f.focus();
@@ -7014,7 +8299,81 @@ function (root, d3, forester, phyloXml) {
7014
8299
  // The navigation bar is fixed at the viewport bottom, so the track's
7015
8300
  // own bottom rows (conservation, consensus, ruler) must end above it
7016
8301
  // or the bar covers them -- the old bare slider did exactly that.
7017
- return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve());
8302
+ // The scale bar sits in the same band, under the tree's last row.
8303
+ return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
8304
+ scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
8305
+ }
8306
+
8307
+ // ===================== Scale bar =====================
8308
+ // A phylogram carries a scale bar at the bottom left of the tree: a
8309
+ // round number of branch-length units (forester.scaleBarLength) with
8310
+ // end ticks and its length written above, drawn in the tree's own
8311
+ // coordinates so it zooms and exports with the tree and its label stays
8312
+ // true. Not in a cladogram (nothing to measure) and not under a time
8313
+ // axis (which is a scale already); in the radial layouts it sits below
8314
+ // the fan at its left edge.
8315
+ const SCALE_BAR_TARGET_PX = 100;
8316
+ const SCALE_BAR_RESERVE = 30;
8317
+
8318
+ function scaleBarShown() {
8319
+ return _state.phylogram === true && _basicTreeProperties.branchLengths === true && !timeAxisShown();
8320
+ }
8321
+
8322
+ // pixels per branch-length unit in the current layout's own coordinates
8323
+ function branchLengthPixelsPerUnit() {
8324
+ if (!_state.phylogram || !_yScale) {
8325
+ return 0;
8326
+ }
8327
+ if (_state.unrootedDisplay) {
8328
+ return (_unroot && _unroot.pxPerUnit) ? _unroot.pxPerUnit : 0;
8329
+ }
8330
+ let unit = _yScale(1) - _yScale(0);
8331
+ if (_state.circularDisplay) {
8332
+ return (_radial && _radial.maxY > 0) ? unit * (_radial.maxRad / _radial.maxY) : 0;
8333
+ }
8334
+ return unit;
8335
+ }
8336
+
8337
+ function drawScaleBar() {
8338
+ if (!_svgGroup) {
8339
+ return;
8340
+ }
8341
+ _svgGroup.selectAll('g.aptx-scalebar').remove();
8342
+ if (!scaleBarShown()) {
8343
+ return;
8344
+ }
8345
+ let bar = forester.scaleBarLength(branchLengthPixelsPerUnit(), SCALE_BAR_TARGET_PX);
8346
+ if (!bar) {
8347
+ return;
8348
+ }
8349
+ let x, y;
8350
+ if (radialDisplay()) {
8351
+ let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
8352
+ let r = (_state.circularDisplay ? _radial.maxRad : _unroot.maxRad) + labelSpace;
8353
+ x = -r;
8354
+ y = r + 24;
8355
+ } else {
8356
+ x = 0;
8357
+ y = _clusterH + SCALE_BAR_RESERVE - 12;
8358
+ }
8359
+ let stroke = _state.branchColorDefault;
8360
+ let width = Math.max(1, _state.branchWidthDefault);
8361
+ let fs = Math.max(9, _state.externalNodeFontSize);
8362
+ let g = _svgGroup.append('g').attr('class', 'aptx-scalebar').style('pointer-events', 'none');
8363
+ g.append('line')
8364
+ .attr('x1', x).attr('y1', y).attr('x2', x + bar.px).attr('y2', y)
8365
+ .style('stroke', stroke).style('stroke-width', width);
8366
+ [x, x + bar.px].forEach(function (tx) {
8367
+ g.append('line')
8368
+ .attr('x1', tx).attr('y1', y - 4).attr('x2', tx).attr('y2', y + 4)
8369
+ .style('stroke', stroke).style('stroke-width', width);
8370
+ });
8371
+ g.append('text')
8372
+ .attr('x', x + (bar.px / 2)).attr('y', y - 6)
8373
+ .attr('text-anchor', 'middle')
8374
+ .style('font', fs + 'px ' + FONT_DEFAULTS)
8375
+ .style('fill', _state.labelColorDefault)
8376
+ .text(bar.label);
7018
8377
  }
7019
8378
 
7020
8379
  function hexToRgbTriple(hex) {
@@ -7050,7 +8409,7 @@ function (root, d3, forester, phyloXml) {
7050
8409
  // average branch length (see bl()), which would shift every band.
7051
8410
  let anchor = null;
7052
8411
  let maxTipX = 0;
7053
- forester.preOrderTraversal(_root, function (n) {
8412
+ forEachDisplayed(function (n) {
7054
8413
  if (n.children) {
7055
8414
  return;
7056
8415
  }
@@ -7088,7 +8447,7 @@ function (root, d3, forester, phyloXml) {
7088
8447
  let sc = info.type === 'calendar' ? -corr : corr;
7089
8448
 
7090
8449
  // ---- HPD age bars (internal) + fossil range bars (tips) ----
7091
- forester.preOrderTraversal(_root, function (d) {
8450
+ forEachDisplayed(function (d) {
7092
8451
  if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
7093
8452
  || d.y === undefined) {
7094
8453
  return;
@@ -7322,7 +8681,7 @@ function (root, d3, forester, phyloXml) {
7322
8681
  setZoomScale(1);
7323
8682
  update(_root, 0);
7324
8683
  _zoomed_x_or_y = true;
7325
- const uncollsed_nodes = forester.calcSumOfExternalDescendants(_root);
8684
+ const uncollsed_nodes = displayedLeafCount();
7326
8685
  _displayHeight = _state.externalNodeFontSize * (uncollsed_nodes * 1.3);
7327
8686
  const min = 40;
7328
8687
  if (_displayHeight < min) {
@@ -7395,13 +8754,10 @@ function (root, d3, forester, phyloXml) {
7395
8754
  if (_treeFn.visData.ladderize === undefined) {
7396
8755
  _treeFn.visData.ladderize = true;
7397
8756
  }
7398
- ladderizeSubtree(_root, _treeFn.visData.ladderize, true);
8757
+ let largestFirst = ladderizeSubtree(_root, _treeFn.visData.ladderize, true);
8758
+ _viewOps.order = largestFirst ? 'asc' : 'desc'; // what a shared view replays
7399
8759
  _treeFn.visData.ladderize = !_treeFn.visData.ladderize;
7400
- // The glyph shows the direction the NEXT press will ladderize in.
7401
- let orderBtn = byId(LADDERIZE_BUTTON);
7402
- if (orderBtn) {
7403
- orderBtn.innerHTML = makeGlyph(_treeFn.visData.ladderize ? 'ladderize_asc' : 'ladderize_desc');
7404
- }
8760
+ syncLadderizeGlyph();
7405
8761
  update(null, 0);
7406
8762
  }
7407
8763
  }
@@ -7424,6 +8780,7 @@ function (root, d3, forester, phyloXml) {
7424
8780
  {
7425
8781
  label: 'Midpoint re-root', action: function () {
7426
8782
  forester.midpointRoot(_root);
8783
+ _viewOps.root = 'midpoint'; // what a shared view replays
7427
8784
  zoomToFit();
7428
8785
  }
7429
8786
  },
@@ -7440,6 +8797,7 @@ function (root, d3, forester, phyloXml) {
7440
8797
  _root = _root_const;
7441
8798
  _in_subtree = false;
7442
8799
  }
8800
+ clearCollapsedFlags(_root); // the launch state has nothing collapsed
7443
8801
 
7444
8802
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
7445
8803
 
@@ -7585,12 +8943,14 @@ function (root, d3, forester, phyloXml) {
7585
8943
  }
7586
8944
 
7587
8945
  function resetSearch0() {
8946
+ closeSuggestions();
7588
8947
  setValue(SEARCH_FIELD_0, '');
7589
8948
  setValue(SEARCH_VALUE2_0, '');
7590
8949
  runSearches();
7591
8950
  }
7592
8951
 
7593
8952
  function resetSearch1() {
8953
+ closeSuggestions();
7594
8954
  setValue(SEARCH_FIELD_1, '');
7595
8955
  setValue(SEARCH_VALUE2_1, '');
7596
8956
  runSearches();
@@ -7695,24 +9055,252 @@ function (root, d3, forester, phyloXml) {
7695
9055
  if (idx === 0) { search0(); } else { search1(); }
7696
9056
  }
7697
9057
 
7698
- // Populate (or detach) the value box's <datalist> so the browser offers
7699
- // type-ahead value suggestions for specific text fields only.
9058
+ // ===================== Search suggestions =====================
9059
+ // The value box's type-ahead: the distinct values of the chosen field,
9060
+ // filtered as you type, in an in-page list under the box drawn like the
9061
+ // node menu in the panel's own theme. It replaces the browser's
9062
+ // <datalist> widget -- the one control the page could not style, which
9063
+ // sat in the panel like a stranger. Off, as before, for numeric fields,
9064
+ // Any Text and the molecular sequence (field.suggest === false), and in
9065
+ // regex mode. The values are gathered once per field or mode change
9066
+ // (updateSearchAutocomplete), not per keystroke: on a big tree that is a
9067
+ // walk of every node.
9068
+
9069
+ const SUGGEST_MAX_ROWS = 10;
9070
+ let _suggestValues = [null, null]; // per box: the field's distinct values, or null when suggestions are off
9071
+ let _suggest = null; // the open list: {idx, input, box, rows, active, onOutside}
9072
+ let _suggestPicked = false; // an Enter that picked a row must not also re-run the search on keyup
9073
+ let _suggestSkipFocus = false; // the focus a pick hands back must not reopen the list
9074
+
7700
9075
  function updateSearchAutocomplete(idx) {
7701
- let dlId = idx === 0 ? SEARCH_DATALIST_0 : SEARCH_DATALIST_1;
7702
- let dl = byId(dlId);
7703
- let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
7704
- if (!dl || !input) return;
9076
+ closeSuggestions();
7705
9077
  let spec = currentSearchSpec(idx);
7706
- let enable = !spec.field.numeric && spec.field.suggest !== false && spec.mode !== 'regex';
7707
- dl.innerHTML = '';
7708
- if (!enable) { input.removeAttribute('list'); return; }
7709
- let vals = forester.distinctSearchValues(_root, spec.field, SEARCH_AUTOCOMPLETE_CAP);
7710
- for (let i = 0; i < vals.length; ++i) {
7711
- let opt = document.createElement('option');
7712
- opt.value = vals[i];
7713
- dl.appendChild(opt);
9078
+ let on = !spec.field.numeric && spec.field.suggest !== false && spec.mode !== 'regex';
9079
+ _suggestValues[idx] = on ? forester.distinctSearchValues(_root, spec.field, SEARCH_AUTOCOMPLETE_CAP) : null;
9080
+ }
9081
+
9082
+ // The term being typed is the last one: ',' is OR and '+' is AND in the
9083
+ // plain-text modes, and a pick replaces just that term.
9084
+ function suggestTermStart(text) {
9085
+ return Math.max(text.lastIndexOf(','), text.lastIndexOf('+')) + 1;
9086
+ }
9087
+
9088
+ // Suggestions compare the way the search will: verbatim while Match
9089
+ // case is on, folded to lower case otherwise (Christian, 2026-09-13,
9090
+ // joint with the desktop).
9091
+ function suggestFold(s) {
9092
+ return _state.searchIsCaseSensitive === true ? s : s.toLowerCase();
9093
+ }
9094
+
9095
+ // The values the typed term admits, matched the way the box's mode will
9096
+ // match them: a prefix for "starts with", a suffix for "ends with", a
9097
+ // substring otherwise.
9098
+ function suggestMatches(idx, term) {
9099
+ let values = _suggestValues[idx] || [];
9100
+ let mode = getValue(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
9101
+ let needle = suggestFold(term);
9102
+ return values.filter(function (v) {
9103
+ if (needle.length === 0) {
9104
+ return true;
9105
+ }
9106
+ let lv = suggestFold(v);
9107
+ if (mode === 'starts_with') {
9108
+ return lv.indexOf(needle) === 0;
9109
+ }
9110
+ if (mode === 'ends_with') {
9111
+ return lv.length >= needle.length && lv.lastIndexOf(needle) === lv.length - needle.length;
9112
+ }
9113
+ return lv.indexOf(needle) >= 0;
9114
+ });
9115
+ }
9116
+
9117
+ function openSuggestions(idx) {
9118
+ closeSuggestions();
9119
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9120
+ if (!input || !_suggestValues[idx] || _suggestValues[idx].length === 0) {
9121
+ return;
9122
+ }
9123
+ let text = input.value;
9124
+ let start = suggestTermStart(text);
9125
+ let term = text.substring(start).trim();
9126
+ let matches = suggestMatches(idx, term);
9127
+ // nothing to offer when nothing matches, or the one match is what is already typed
9128
+ if (matches.length === 0 || (matches.length === 1 && suggestFold(matches[0]) === suggestFold(term))) {
9129
+ return;
9130
+ }
9131
+ let box = document.createElement('div');
9132
+ box.className = 'aptx-suggest';
9133
+ if (_panelTheme) {
9134
+ box.classList.add('aptx-' + _panelTheme); // follow the panel's light/dark choice
9135
+ }
9136
+ let needle = suggestFold(term);
9137
+ let rows = [];
9138
+ matches.slice(0, SUGGEST_MAX_ROWS).forEach(function (v) {
9139
+ let b = document.createElement('button');
9140
+ b.type = 'button';
9141
+ b.tabIndex = -1;
9142
+ b.title = v;
9143
+ // the matched part in bold, wherever it sits in the value
9144
+ let at = needle.length > 0 ? suggestFold(v).indexOf(needle) : -1;
9145
+ if (at >= 0) {
9146
+ b.appendChild(document.createTextNode(v.substring(0, at)));
9147
+ let hit = document.createElement('b');
9148
+ hit.textContent = v.substring(at, at + needle.length);
9149
+ b.appendChild(hit);
9150
+ b.appendChild(document.createTextNode(v.substring(at + needle.length)));
9151
+ } else {
9152
+ b.textContent = v;
9153
+ }
9154
+ b.addEventListener('mousedown', function (e) {
9155
+ e.preventDefault(); // the focus stays in the box: no blur, no flicker
9156
+ });
9157
+ b.addEventListener('click', function (e) {
9158
+ e.stopPropagation();
9159
+ pickSuggestion(idx, v);
9160
+ });
9161
+ box.appendChild(b);
9162
+ rows.push(b);
9163
+ });
9164
+ if (matches.length > SUGGEST_MAX_ROWS) {
9165
+ let more = document.createElement('div');
9166
+ more.className = 'aptx-suggest-more';
9167
+ more.textContent = (matches.length - SUGGEST_MAX_ROWS) + ' more — keep typing';
9168
+ box.appendChild(more);
9169
+ }
9170
+ document.body.appendChild(box);
9171
+ // under the box and as wide as it, or above it when the window's
9172
+ // bottom is too close
9173
+ let r = input.getBoundingClientRect();
9174
+ box.style.minWidth = Math.round(r.width) + 'px';
9175
+ let h = box.getBoundingClientRect().height;
9176
+ let below = r.bottom + 3;
9177
+ let top = (below + h > document.documentElement.clientHeight - 8 && r.top - h - 3 > 8) ? r.top - h - 3 : below;
9178
+ box.style.left = Math.round(r.left + window.scrollX) + 'px';
9179
+ box.style.top = Math.round(top + window.scrollY) + 'px';
9180
+ // a press anywhere else closes it -- in the capture phase, since d3's
9181
+ // zoom stops mousedown on the tree from reaching the document
9182
+ let onOutside = function (e) {
9183
+ if (_suggest && !_suggest.box.contains(e.target) && e.target !== input) {
9184
+ closeSuggestions();
9185
+ }
9186
+ };
9187
+ document.addEventListener('mousedown', onOutside, true);
9188
+ _suggest = {idx: idx, input: input, box: box, rows: rows, active: -1, onOutside: onOutside};
9189
+ }
9190
+
9191
+ function closeSuggestions() {
9192
+ if (!_suggest) {
9193
+ return;
9194
+ }
9195
+ document.removeEventListener('mousedown', _suggest.onOutside, true);
9196
+ _suggest.box.remove();
9197
+ _suggest = null;
9198
+ }
9199
+
9200
+ function setActiveSuggestion(i) {
9201
+ if (!_suggest) {
9202
+ return;
9203
+ }
9204
+ _suggest.rows.forEach(function (b, k) {
9205
+ b.classList.toggle('aptx-active', k === i);
9206
+ });
9207
+ _suggest.active = i;
9208
+ }
9209
+
9210
+ function pickSuggestion(idx, value) {
9211
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9212
+ closeSuggestions();
9213
+ if (!input) {
9214
+ return;
9215
+ }
9216
+ let text = input.value;
9217
+ input.value = text.substring(0, suggestTermStart(text)) + value;
9218
+ _suggestSkipFocus = true;
9219
+ input.focus();
9220
+ runSearches();
9221
+ }
9222
+
9223
+ // Keys in the value box: the arrows open the list and move along it,
9224
+ // Enter picks, Escape and Tab close. An Escape that closed the list must
9225
+ // not also reset the view, exactly as the node menu's does not.
9226
+ function suggestKeydown(idx, e) {
9227
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
9228
+ if (!_suggest) {
9229
+ openSuggestions(idx);
9230
+ if (!_suggest) {
9231
+ return;
9232
+ }
9233
+ }
9234
+ let n = _suggest.rows.length;
9235
+ let a = _suggest.active;
9236
+ // on a list with no row chosen yet, Down starts at the top and Up
9237
+ // at the bottom (the desktop found Up landing one short of it)
9238
+ setActiveSuggestion(e.key === 'ArrowDown' ? (a + 1) % n : (a < 0 ? n - 1 : (a - 1 + n) % n));
9239
+ e.preventDefault();
9240
+ } else if (e.key === 'Enter') {
9241
+ if (_suggest && _suggest.active >= 0) {
9242
+ _suggestPicked = true;
9243
+ pickSuggestion(idx, _suggest.rows[_suggest.active].title);
9244
+ e.preventDefault();
9245
+ } else {
9246
+ closeSuggestions();
9247
+ }
9248
+ } else if (e.key === 'Escape') {
9249
+ if (_suggest) {
9250
+ closeSuggestions();
9251
+ _menuConsumedEsc = true;
9252
+ e.stopPropagation();
9253
+ }
9254
+ } else if (e.key === 'Tab') {
9255
+ closeSuggestions();
9256
+ }
9257
+ }
9258
+
9259
+ // The value box's keyup runs the search. A navigation key changes no
9260
+ // value, and an Enter that just picked a row has already run it.
9261
+ const SUGGEST_NAV_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape', 'Tab', 'Shift',
9262
+ 'Control', 'Alt', 'Meta', 'Home', 'End', 'PageUp', 'PageDown', 'CapsLock'];
9263
+
9264
+ function searchValueKeyup(e) {
9265
+ if (SUGGEST_NAV_KEYS.indexOf(e.key) >= 0) {
9266
+ return;
9267
+ }
9268
+ if (e.key === 'Enter' && _suggestPicked) {
9269
+ _suggestPicked = false;
9270
+ return;
9271
+ }
9272
+ runSearches();
9273
+ }
9274
+
9275
+ function wireSuggestions(idx) {
9276
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9277
+ if (!input) {
9278
+ return;
7714
9279
  }
7715
- input.setAttribute('list', dlId);
9280
+ input.addEventListener('keydown', function (e) {
9281
+ suggestKeydown(idx, e);
9282
+ });
9283
+ input.addEventListener('input', function () {
9284
+ openSuggestions(idx);
9285
+ });
9286
+ input.addEventListener('focus', function () {
9287
+ if (_suggestSkipFocus) {
9288
+ _suggestSkipFocus = false;
9289
+ return;
9290
+ }
9291
+ if (input.value.length > 0) {
9292
+ openSuggestions(idx);
9293
+ }
9294
+ });
9295
+ input.addEventListener('blur', function () {
9296
+ // after a tick: a click on a row keeps the focus, anything else
9297
+ // that took it closes the list
9298
+ setTimeout(function () {
9299
+ if (_suggest && _suggest.idx === idx && document.activeElement !== input) {
9300
+ closeSuggestions();
9301
+ }
9302
+ }, 120);
9303
+ });
7716
9304
  }
7717
9305
 
7718
9306
 
@@ -7745,6 +9333,7 @@ function (root, d3, forester, phyloXml) {
7745
9333
  _radialLabelsHorizontal = false;
7746
9334
  }
7747
9335
  syncZoomRowButtons();
9336
+ afterUpdate(syncDomainControls); // the track width buttons follow the layout's own width
7748
9337
  zoomToFit();
7749
9338
  }
7750
9339
 
@@ -7944,6 +9533,27 @@ function (root, d3, forester, phyloXml) {
7944
9533
  scheduleUpdate(null, 0);
7945
9534
  }
7946
9535
 
9536
+ // The desktop draws a box around "Auto-hide Labels" while labels are
9537
+ // actually being hidden -- the switch being on is one thing, the tree
9538
+ // being dense enough for it to bite is another. Same here, in the
9539
+ // panel's accent: the checkbox item gets a box and its tooltip says how
9540
+ // many labels are shown. Called on every redraw, since the density
9541
+ // changes with the vertical zoom, the font size and the view.
9542
+ function syncDynahideIndicator(active) {
9543
+ let cb = byId(DYNAHIDE_CB);
9544
+ let item = cb ? cb.closest('.aptx-check') : null;
9545
+ if (!item) {
9546
+ return;
9547
+ }
9548
+ if (item.dataset.baseTitle === undefined) {
9549
+ item.dataset.baseTitle = item.title;
9550
+ }
9551
+ item.classList.toggle('aptx-check-active', !!active);
9552
+ item.title = active
9553
+ ? item.dataset.baseTitle + ' — hiding now: 1 in ' + _dynahide_factor + ' labels shown'
9554
+ : item.dataset.baseTitle;
9555
+ }
9556
+
7947
9557
  function dynaHideCbClicked() {
7948
9558
  _state.dynahide = getCheckboxValue(DYNAHIDE_CB);
7949
9559
  resetVis();
@@ -8008,6 +9618,7 @@ function (root, d3, forester, phyloXml) {
8008
9618
 
8009
9619
  function searchOptionsCaseSenstiveCbClicked() {
8010
9620
  _state.searchIsCaseSensitive = getCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB);
9621
+ closeSuggestions(); // an open list was filtered the other way; the next keystroke refilters
8011
9622
  search0();
8012
9623
  search1();
8013
9624
  }
@@ -8502,6 +10113,9 @@ function (root, d3, forester, phyloXml) {
8502
10113
  case 'ladderize_asc': sw = 8; cap = 'round'; body = glyphLadderize(true); break;
8503
10114
  case 'ladderize_desc': sw = 8; cap = 'round'; body = glyphLadderize(false); break;
8504
10115
  case 'midpoint': sw = 8; cap = 'round'; body = glyphMidpoint(); break;
10116
+ case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
10117
+ case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
10118
+ case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
8505
10119
  case 'sun': body = glyphSun(); break;
8506
10120
  case 'moon': body = glyphMoon(); break;
8507
10121
  default: throw new Error('unknown control-panel glyph: ' + kind);
@@ -8511,6 +10125,20 @@ function (root, d3, forester, phyloXml) {
8511
10125
  + ' stroke-linecap="' + cap + '" stroke-linejoin="' + join + '">' + body + '</svg>';
8512
10126
  }
8513
10127
 
10128
+ // The desktop's UNCOLLAPSE_ALL glyph (ControlButtonIcon.paintUncollapse),
10129
+ // ported point for point into the 100-unit box: the collapsed-clade
10130
+ // triangle, apex at the parent, base toward the tips it opens back out
10131
+ // into -- three of them.
10132
+ // A chevron pointing left or right: the previous / next tree buttons.
10133
+ function glyphChevron(right) {
10134
+ return right ? '<path d="M38,18 L66,50 L38,82"/>' : '<path d="M62,18 L34,50 L62,82"/>';
10135
+ }
10136
+
10137
+ function glyphUncollapseAll() {
10138
+ return '<path d="M6,50 L44,16 L44,84 Z" fill="currentColor" stroke="none"/>'
10139
+ + '<path d="M56,20 H94 M56,50 H94 M56,80 H94"/>';
10140
+ }
10141
+
8514
10142
  function injectPanelStyles() {
8515
10143
  loadPanelTheme();
8516
10144
  if (document.getElementById(PANEL_STYLE_ID)) {
@@ -8547,16 +10175,16 @@ function (root, d3, forester, phyloXml) {
8547
10175
  + '.aptx-panel * { box-sizing:border-box; }'
8548
10176
  + '@media (prefers-color-scheme:dark){ .aptx-panel:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
8549
10177
  + '.aptx-panel.aptx-dark {' + dark + '}'
8550
- // The node menu is a separate overlay (it cannot live inside the
8551
- // panel), so it carries its own copy of the palette and follows the
8552
- // same light / dark rules.
8553
- + '.aptx-node-menu {'
10178
+ // The node menu and the search suggestions are separate overlays
10179
+ // (they cannot live inside the panel), so they carry their own
10180
+ // copy of the palette and follow the same light / dark rules.
10181
+ + '.aptx-node-menu, .aptx-suggest {'
8554
10182
  + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
8555
10183
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
8556
10184
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
8557
10185
  + '}'
8558
- + '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
8559
- + '.aptx-node-menu.aptx-dark {' + dark + '}'
10186
+ + '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark), .aptx-suggest:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
10187
+ + '.aptx-node-menu.aptx-dark, .aptx-suggest.aptx-dark {' + dark + '}'
8560
10188
  // The menu itself: fixed 11px type, so it does NOT scale with the
8561
10189
  // tree's zoom the way the old svg-drawn menu did.
8562
10190
  + '.aptx-node-menu { position:absolute; z-index:1000; min-width:196px; max-width:280px; padding:4px;'
@@ -8575,9 +10203,27 @@ function (root, d3, forester, phyloXml) {
8575
10203
  + ' transition:background .1s,color .1s; }'
8576
10204
  + '.aptx-node-menu button:hover, .aptx-node-menu button:focus-visible {'
8577
10205
  + ' background:var(--p-accent); color:#fff; outline:none; }'
10206
+ + '.aptx-node-menu button:disabled, .aptx-node-menu button:disabled:hover {'
10207
+ + ' background:transparent; color:var(--p-faint); cursor:default; }'
8578
10208
  + '.aptx-node-menu button.aptx-menu-danger:hover, .aptx-node-menu button.aptx-menu-danger:focus-visible {'
8579
10209
  + ' background:#e5484d; color:#fff; }'
8580
10210
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
10211
+ // The search suggestions: the node menu's chrome under the value box.
10212
+ + '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
10213
+ + ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
10214
+ + ' -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);'
10215
+ + ' box-shadow:0 12px 30px -12px rgba(23,34,46,0.42),0 2px 6px -2px rgba(23,34,46,0.2);'
10216
+ + ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
10217
+ + ' font-size:11px; line-height:1.45; }'
10218
+ + '.aptx-suggest button { display:block; width:100%; text-align:left; margin:0; padding:4px 9px;'
10219
+ + ' border:0; border-radius:6px; background:transparent; color:var(--p-ink); font:inherit;'
10220
+ + ' cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'
10221
+ + ' transition:background .1s,color .1s; }'
10222
+ + '.aptx-suggest button b { font-weight:700; color:var(--p-accent); }'
10223
+ + '.aptx-suggest button:hover, .aptx-suggest button.aptx-active { background:var(--p-accent); color:#fff; }'
10224
+ + '.aptx-suggest button:hover b, .aptx-suggest button.aptx-active b { color:#fff; }'
10225
+ + '.aptx-suggest-more { padding:4px 9px 2px; margin-top:3px; border-top:1px solid var(--p-line);'
10226
+ + ' font-size:10px; color:var(--p-faint); }'
8581
10227
  // The node-data dialog, on the same palette as the panel and the menu.
8582
10228
  + '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
8583
10229
  + ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
@@ -8651,6 +10297,12 @@ function (root, d3, forester, phyloXml) {
8651
10297
  + '.aptx-dialog-line + .aptx-dialog-line { border-top:1px solid var(--p-line); }'
8652
10298
  + '.aptx-dialog-key { flex:0 0 42%; color:var(--p-muted); }'
8653
10299
  + '.aptx-dialog-val { flex:1 1 auto; min-width:0; overflow-wrap:anywhere; }'
10300
+ // the shortcuts cheat sheet: key chips in the key column
10301
+ + '.aptx-shortcuts .aptx-dialog-key { flex-basis:38%; display:flex; align-items:center; }'
10302
+ + '.aptx-shortcuts .aptx-dialog-val { align-self:center; }'
10303
+ + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
10304
+ + '.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; }'
10305
+ + '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
8654
10306
  + '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
8655
10307
  + ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
8656
10308
  // The hover tooltip, on the same palette.
@@ -8702,13 +10354,24 @@ function (root, d3, forester, phyloXml) {
8702
10354
  + '.aptx-panel .' + TREE_DESC + ' .aptx-tree-name { font-weight:600; font-size:11.5px; line-height:1.35; color:var(--p-ink); overflow-wrap:anywhere; display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:1; overflow:hidden; }'
8703
10355
  + '.aptx-panel .' + TREE_DESC + ' .aptx-tree-descr { margin-top:3px; font-size:10px; line-height:1.45; color:var(--p-muted); overflow-wrap:anywhere; display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; }'
8704
10356
  + '.aptx-panel .' + TREE_DESC + '.aptx-expanded .aptx-tree-name, .aptx-panel .' + TREE_DESC + '.aptx-expanded .aptx-tree-descr { display:block; -webkit-line-clamp:unset; overflow:visible; }'
10357
+ // A file with several trees: previous / picker / next in one row
10358
+ + '.aptx-panel .aptx-tree-picker { display:flex; align-items:center; gap:4px; min-width:0; }'
10359
+ + '.aptx-panel .aptx-tree-picker select { flex:1 1 auto; min-width:0; height:24px; }'
10360
+ + '.aptx-panel .aptx-tree-picker .aptx-gbtn { flex:0 0 auto; min-width:26px; padding:0 4px; margin:0; }'
10361
+ + '.aptx-panel .aptx-tree-picker + .' + TREE_DESC + ' { margin-top:5px; }'
8705
10362
  + '.aptx-panel .aptx-panel-title { font-size:12px; }'
8706
10363
  + '.aptx-panel fieldset { border:0; border-top:1px solid var(--p-line); margin:0; padding:6px 12px; min-width:0; }'
8707
10364
  + '.aptx-panel legend { float:none; width:auto; padding:0; margin:0 0 5px; font-size:9px; font-weight:700; letter-spacing:0.09em; text-transform:uppercase; color:var(--p-faint); }'
8708
10365
  + '.aptx-panel label { cursor:pointer; }'
8709
10366
  + '.aptx-panel input[type=checkbox],.aptx-panel input[type=radio] { accent-color:var(--p-accent); width:13px; height:13px; vertical-align:-2px; margin:0 4px 0 0; }'
8710
10367
  // checkbox/radio + label as one item (used by the Display Data grid and the inline P/A/C and search-option rows)
8711
- + '.aptx-panel .aptx-check { display:flex; align-items:center; gap:6px; cursor:pointer; min-width:0; }'
10368
+ // every item carries an invisible box, so the one that lights up
10369
+ // (Auto-hide Labels while it is hiding) does not shift its neighbours
10370
+ + '.aptx-panel .aptx-check { display:flex; align-items:center; gap:6px; cursor:pointer; min-width:0;'
10371
+ + ' padding:1px 4px; margin:-1px -4px; border:1px solid transparent; border-radius:5px;'
10372
+ + ' transition:border-color .15s,background .15s; }'
10373
+ + '.aptx-panel .aptx-check.aptx-check-active { border-color:var(--p-accent); background:var(--p-accent-weak); }'
10374
+ + '.aptx-panel .aptx-check.aptx-check-active > span { color:var(--p-accent-ink); font-weight:600; }'
8712
10375
  + '.aptx-panel .aptx-check > input { margin:0; flex:none; }'
8713
10376
  + '.aptx-panel .aptx-check > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }'
8714
10377
  + '.aptx-panel .aptx-checkgrid { display:grid; grid-template-columns:1fr 1fr; gap:3px 10px; }'
@@ -8725,7 +10388,9 @@ function (root, d3, forester, phyloXml) {
8725
10388
  + '.aptx-panel .aptx-search-menus { display:flex; gap:4px; margin:2px 0 3px; }'
8726
10389
  + '.aptx-panel .aptx-search-menus select { flex:1 1 0; width:auto; min-width:0; height:24px; font:inherit; }'
8727
10390
  // red cue for an unmatchable query (bad regex / non-number); box-shadow, since the value boxes carry an inline outline:none
8728
- + '.aptx-panel .aptx-search-invalid { box-shadow:0 0 0 2px #e5484d; border-color:#e5484d; }'
10391
+ // specific enough to beat the plain input rule (which used to keep
10392
+ // the border in the line colour, leaving only the ring)
10393
+ + '.aptx-panel input[type=text].aptx-search-invalid { box-shadow:0 0 0 2px #e5484d; border-color:#e5484d; }'
8729
10394
  + '.aptx-panel .aptx-combine { display:flex; align-items:center; gap:8px; margin:6px 0 2px; }'
8730
10395
  + '.aptx-panel .aptx-combine .aptx-field-label { margin:0; flex:none; }'
8731
10396
  + '.aptx-panel .aptx-combine select { flex:1 1 auto; min-width:0; height:26px; font:inherit; }'
@@ -8797,6 +10462,10 @@ function (root, d3, forester, phyloXml) {
8797
10462
  + '.aptx-panel .aptx-seg .aptx-glyph { height:13px; }'
8798
10463
  + '.aptx-panel input[type=text],.aptx-panel select { font-family:inherit; font-size:11px; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; max-width:100%; padding:3px 6px; }'
8799
10464
  + '.aptx-panel input[type=text]:focus,.aptx-panel select:focus { outline:none; border-color:var(--p-accent); box-shadow:0 0 0 3px var(--p-accent-weak); }'
10465
+ // an invalid regex or number stays red WHILE TYPING: without this
10466
+ // the focus rule above, more specific and later, painted the box in
10467
+ // the accent until the focus left it
10468
+ + '.aptx-panel input[type=text].aptx-search-invalid:focus { border-color:#e5484d; box-shadow:0 0 0 2px #e5484d; }'
8800
10469
  // --- collapsible sections, internal scroll, whole-panel hide ---
8801
10470
  // the panel is a child of the tree's container now, so it can be held
8802
10471
  // to that container's height rather than the whole viewport's
@@ -9375,7 +11044,8 @@ function (root, d3, forester, phyloXml) {
9375
11044
  [['Website', WEBSITE, 'cmzmasek.github.io/archaeopteryx-js'],
9376
11045
  ['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
9377
11046
  ['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
9378
- ['License', LICENSE_WEBSITE, LICENSE_NAME]].forEach(function (row) {
11047
+ ['License', LICENSE_WEBSITE, LICENSE_NAME],
11048
+ ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']].forEach(function (row) {
9379
11049
  let line = document.createElement('div');
9380
11050
  line.className = 'aptx-dialog-line';
9381
11051
  let key = document.createElement('span');
@@ -9384,9 +11054,19 @@ function (root, d3, forester, phyloXml) {
9384
11054
  let val = document.createElement('span');
9385
11055
  val.className = 'aptx-dialog-val';
9386
11056
  let a = document.createElement('a');
9387
- a.href = row[1];
9388
- a.target = '_blank';
9389
- a.rel = 'noopener noreferrer';
11057
+ if (row[1]) {
11058
+ a.href = row[1];
11059
+ a.target = '_blank';
11060
+ a.rel = 'noopener noreferrer';
11061
+ } else {
11062
+ // the shortcuts row opens the cheat sheet in place of the About box
11063
+ a.href = '#';
11064
+ a.addEventListener('click', function (e) {
11065
+ e.preventDefault();
11066
+ shell.dialog.close();
11067
+ showShortcutsDialog();
11068
+ });
11069
+ }
9390
11070
  a.textContent = row[2];
9391
11071
  val.appendChild(a);
9392
11072
  line.appendChild(key);
@@ -9601,9 +11281,14 @@ function (root, d3, forester, phyloXml) {
9601
11281
  }
9602
11282
 
9603
11283
 
9604
- on(SEARCH_FIELD_0, 'keyup', search0);
11284
+ on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
9605
11285
 
9606
- on(SEARCH_FIELD_1, 'keyup', search1);
11286
+ on(SEARCH_FIELD_1, 'keyup', searchValueKeyup);
11287
+ wireSuggestions(0);
11288
+ wireSuggestions(1);
11289
+ // the list is placed in page coordinates: a scroll or resize moves the box out from under it
11290
+ window.addEventListener('resize', closeSuggestions);
11291
+ document.addEventListener('scroll', closeSuggestions, true);
9607
11292
 
9608
11293
  on(SEARCH_VALUE2_0, 'keyup', search0);
9609
11294
 
@@ -9659,6 +11344,16 @@ function (root, d3, forester, phyloXml) {
9659
11344
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
9660
11345
  on(MSA_CB, 'click', msaCbClicked);
9661
11346
  on(DOMAINS_CB, 'click', domainsCbClicked);
11347
+ on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
11348
+ on(TREE_PREV_BUTTON, 'click', function () {
11349
+ showTree(_treeIndex - 1);
11350
+ });
11351
+ on(TREE_NEXT_BUTTON, 'click', function () {
11352
+ showTree(_treeIndex + 1);
11353
+ });
11354
+ on(TREE_SELECT, 'change', function () {
11355
+ showTree(parseInt(this.value, 10));
11356
+ });
9662
11357
  onHoldRepeat(DOMAIN_WIDTH_DEC, function () { domainWidthStep(false); });
9663
11358
  onHoldRepeat(DOMAIN_WIDTH_INC, function () { domainWidthStep(true); });
9664
11359
  on(DOMAIN_EVALUE_DEC, 'click', function () { domainEvalueStep(-1); });
@@ -9827,12 +11522,11 @@ function (root, d3, forester, phyloXml) {
9827
11522
  _docListenersBound = true;
9828
11523
 
9829
11524
  bindDoc('keyup', function (e) {
9830
- // The DELIBERATELY minimal shortcut set (user decision,
9831
- // 2026-09-03): Esc/Home reset, O cycles the overview corner,
9832
- // PageUp/PageDown size the font, and the wheel zooms. Everything
9833
- // else is a button -- the old Alt+letter combos were near-
9834
- // undiscoverable (macOS labels the key Option and types glyphs
9835
- // with it).
11525
+ // The plain keys: Esc/Home reset, O cycles the overview corner,
11526
+ // PageUp/PageDown size the font, and the wheel zooms. The old
11527
+ // Alt+letter combos were near-undiscoverable (macOS labels the
11528
+ // key Option and types glyphs with it); the Ctrl / Cmd combos
11529
+ // that replaced them in 2026-09 live in SHORTCUTS, on keydown.
9836
11530
  if (isTypingTarget(e.target)) {
9837
11531
  return; // nothing fires from inside a text box
9838
11532
  }
@@ -9849,7 +11543,11 @@ function (root, d3, forester, phyloXml) {
9849
11543
  });
9850
11544
 
9851
11545
  bindDoc('keydown', function (e) {
9852
- if (isTypingTarget(e.target)) {
11546
+ if (e.defaultPrevented) {
11547
+ return;
11548
+ }
11549
+ shortcutKeyDown(e); // the Ctrl / Cmd combos (SHORTCUTS)
11550
+ if (e.defaultPrevented || isTypingTarget(e.target)) {
9853
11551
  return;
9854
11552
  }
9855
11553
  if (e.keyCode === VK_PAGE_UP) {
@@ -9938,13 +11636,23 @@ function (root, d3, forester, phyloXml) {
9938
11636
  // so tree-file text needs no escaping here. Both can be very long, so CSS
9939
11637
  // clamps each to a few lines; the caller (enableTreeDescExpand) adds the
9940
11638
  // click/keyboard expand affordance only when the text actually overflows.
11639
+ // A launch with several trees puts the tree picker in the same
11640
+ // fieldset, above the description; the picker shows the name, so
11641
+ // the name line is left out then.
9941
11642
  function makeTreeDesc() {
9942
- let name = _treeData.name ? String(_treeData.name).trim() : '';
11643
+ let several = _trees.length > 1;
11644
+ let name = (_treeData.name && !several) ? String(_treeData.name).trim() : '';
9943
11645
  let desc = _treeData.description ? String(_treeData.description).trim() : '';
9944
- if (!name && !desc) {
11646
+ if (!name && !desc && !several) {
9945
11647
  return null;
9946
11648
  }
9947
11649
  let fieldset = document.createElement('fieldset');
11650
+ if (several) {
11651
+ fieldset.appendChild(makeTreePicker());
11652
+ }
11653
+ if (!name && !desc) {
11654
+ return fieldset;
11655
+ }
9948
11656
  let block = document.createElement('div');
9949
11657
  block.className = TREE_DESC;
9950
11658
  let tooltip = '';
@@ -9967,6 +11675,35 @@ function (root, d3, forester, phyloXml) {
9967
11675
  return fieldset;
9968
11676
  }
9969
11677
 
11678
+ // A file with several trees: previous / picker / next in one row,
11679
+ // the picker listing every tree by its name (or its number). Each
11680
+ // opens fresh in the same container (showTree). The end buttons are
11681
+ // disabled at the ends: the panel is rebuilt per tree, so the
11682
+ // state set here is always current.
11683
+ function makeTreePicker() {
11684
+ let n = _trees.length;
11685
+ let row = document.createElement('div');
11686
+ row.className = 'aptx-tree-picker';
11687
+ row.insertAdjacentHTML('beforeend', makeGlyphButton('tree_prev', TREE_PREV_BUTTON, 'previous tree'));
11688
+ let select = document.createElement('select');
11689
+ select.id = TREE_SELECT;
11690
+ select.name = TREE_SELECT;
11691
+ select.title = 'Tree ' + (_treeIndex + 1) + ' of ' + n + ' -- pick another';
11692
+ for (let i = 0; i < n; ++i) {
11693
+ let opt = document.createElement('option');
11694
+ opt.value = String(i);
11695
+ let treeName = _trees[i].name ? String(_trees[i].name).trim() : '';
11696
+ opt.textContent = treeName || ('Tree ' + (i + 1));
11697
+ opt.selected = (i === _treeIndex);
11698
+ select.appendChild(opt);
11699
+ }
11700
+ row.appendChild(select);
11701
+ row.insertAdjacentHTML('beforeend', makeGlyphButton('tree_next', TREE_NEXT_BUTTON, 'next tree'));
11702
+ row.querySelector('#' + TREE_PREV_BUTTON).disabled = (_treeIndex === 0);
11703
+ row.querySelector('#' + TREE_NEXT_BUTTON).disabled = (_treeIndex === n - 1);
11704
+ return row;
11705
+ }
11706
+
9970
11707
  // Make the tree name/description block a keyboard-accessible
9971
11708
  // expand/collapse control, but only while its text is actually clamped
9972
11709
  // (otherwise the pointer cursor + click would be a misleading no-op).
@@ -10176,6 +11913,7 @@ function (root, d3, forester, phyloXml) {
10176
11913
  h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
10177
11914
  h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
10178
11915
  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)'));
11916
+ h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
10179
11917
  h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 'midpoint re-root'));
10180
11918
  h = h.concat('</div>');
10181
11919
  h = h.concat('</fieldset>');
@@ -10296,7 +12034,6 @@ function (root, d3, forester, phyloXml) {
10296
12034
  let val2 = idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1;
10297
12035
  let reset = idx === 0 ? RESET_SEARCH_A_BTN : RESET_SEARCH_B_BTN;
10298
12036
  let resetTip = idx === 0 ? RESET_SEARCH_A_BTN_TOOLTIP : RESET_SEARCH_B_BTN_TOOLTIP;
10299
- let dl = idx === 0 ? SEARCH_DATALIST_0 : SEARCH_DATALIST_1;
10300
12037
  let h = "";
10301
12038
  h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
10302
12039
  h = h.concat('<div class="aptx-search-menus">');
@@ -10308,7 +12045,6 @@ function (root, d3, forester, phyloXml) {
10308
12045
  h = h.concat('<input class="aptx-search-value2" style="display:none" title="upper bound of the range" type="text" name="' + val2 + '" id="' + val2 + '">');
10309
12046
  h = h.concat(makeButton('R', reset, resetTip));
10310
12047
  h = h.concat('</div>');
10311
- h = h.concat('<datalist id="' + dl + '"></datalist>');
10312
12048
  return h;
10313
12049
  }
10314
12050
 
@@ -10493,11 +12229,16 @@ function (root, d3, forester, phyloXml) {
10493
12229
  // definite result -- with it on, ladderizing a tree that is already
10494
12230
  // ladderized REVERSES it, so the outcome depends on the order the tree
10495
12231
  // happened to arrive in.
12232
+ // Returns the direction actually applied (largest first or not): when
12233
+ // the tree already stood that way and alternating was asked for, the
12234
+ // other one.
10496
12235
  function ladderizeSubtree(n, largestFirst, alternateIfUnchanged) {
10497
12236
  let changed = forester.ladderize(n, largestFirst);
10498
12237
  if (alternateIfUnchanged && !changed) {
10499
12238
  forester.ladderize(n, !largestFirst);
12239
+ return !largestFirst;
10500
12240
  }
12241
+ return largestFirst;
10501
12242
  }
10502
12243
 
10503
12244
  function setDisplayTypeButtons() {
@@ -10516,6 +12257,8 @@ function (root, d3, forester, phyloXml) {
10516
12257
 
10517
12258
  function updateButtonEnabledState() {
10518
12259
  syncZoomRowButtons(); // layout- and tree-dependent disables track every render
12260
+ // the uncollapse-all button lives only while something is collapsed, as on the desktop
12261
+ ((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
10519
12262
  if (_in_subtree) {
10520
12263
  enableButton(byId(RETURN_TO_SUPERTREE_BUTTON_BY_ONE));
10521
12264
  enableButton(byId(RETURN_TO_SUPERTREE_BUTTON));
@@ -10888,8 +12631,65 @@ function (root, d3, forester, phyloXml) {
10888
12631
  // Convenience methods for loading tree on HTML page
10889
12632
  // --------------------------------------------------------------
10890
12633
 
12634
+ // The mode as parseTree / parseTrees accept it. The retired brackets
12635
+ // flag used to be the third argument, so a boolean there means the old
12636
+ // four-argument shape and the mode is the fourth; a string is the mode
12637
+ // itself. PARSE_DEFAULTS, not literals -- initializeSettings applies the
12638
+ // same object, and it runs at launch(), AFTER this has already parsed.
12639
+ function resolveInternalLabelsMode(mode, legacyMode) {
12640
+ let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
12641
+ if (typeof m === 'boolean') {
12642
+ // the retired positional flag: true meant "every numeric internal
12643
+ // label is a confidence", which is 'confidence'.
12644
+ return m ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
12645
+ }
12646
+ return m === undefined ? PARSE_DEFAULTS.internalNumericLabels : m;
12647
+ }
12648
+
12649
+ // Every tree the data holds, the format picked from the content and the
12650
+ // location: a Nexus TREES block, a New Hampshire text with one tree per
12651
+ // ';', a phyloXML with several phylogenies; an Auspice dataset is one
12652
+ // tree. Bare numeric internal labels are promoted on every tree.
12653
+ function parseAllTrees(location, data, mode) {
12654
+ requireForester();
12655
+ let loc = location ? String(location).toLowerCase() : '';
12656
+ let text = forester.isString(data) ? data : '';
12657
+ let trees;
12658
+ // Nexus announces itself with "#NEXUS" on the first line, so the
12659
+ // content decides; the filename alone is enough too.
12660
+ if (/^\s*#nexus\b/i.test(text) || /\.(nex|nexus)$/.test(loc)) {
12661
+ trees = forester.parseNexus(data, true, false);
12662
+ if (trees.length === 0) {
12663
+ throw new Error('no tree found in the Nexus data');
12664
+ }
12665
+ trees.forEach(function (t) {
12666
+ promoteInternalLabels(t, mode);
12667
+ });
12668
+ } else if (/^\s*\{/.test(text) || /\.json$/.test(loc)) {
12669
+ trees = [archaeopteryx.parseAuspiceJson(data)];
12670
+ } else if (/^\s*</.test(text) || loc.substr(-3, 3) === 'xml') {
12671
+ // No other supported format can start with '<', so content alone
12672
+ // is enough -- pasted phyloXML has no filename to go by.
12673
+ requirePhyloXml();
12674
+ trees = phyloXml.parse(data, {trim: true, normalize: true});
12675
+ if (trees.length === 0) {
12676
+ throw new Error('no phylogeny found in the phyloXML data');
12677
+ }
12678
+ trees.forEach(function (t) {
12679
+ forester.addParents(t);
12680
+ });
12681
+ } else {
12682
+ trees = forester.parseNewHampshireTrees(data, true, false);
12683
+ trees.forEach(function (t) {
12684
+ promoteInternalLabels(t, mode);
12685
+ });
12686
+ }
12687
+ return trees;
12688
+ }
12689
+
10891
12690
  /**
10892
- * Convenience method for loading tree on HTML page
12691
+ * Parses tree data for an HTML page: the FIRST tree the data holds
12692
+ * (parseTrees returns them all).
10893
12693
  *
10894
12694
  * @param location - file name (only its extension is used; the content is
10895
12695
  * sniffed too, so a pasted tree with no name works)
@@ -10900,41 +12700,21 @@ function (root, d3, forester, phyloXml) {
10900
12700
  * position and is still accepted (and ignored) there,
10901
12701
  * with the mode following it.
10902
12702
  * @param legacyMode - the mode, when the retired flag occupies `mode`
10903
- * @returns {*}
12703
+ * @returns {*} the first tree
10904
12704
  */
10905
12705
  archaeopteryx.parseTree = function (location, data, mode, legacyMode) {
10906
- // The retired brackets flag used to be the third argument, so a
10907
- // boolean there means the old four-argument shape and the mode is the
10908
- // fourth. A string is the mode itself.
10909
- let internalNumericLabels = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
10910
- // PARSE_DEFAULTS, not literals -- initializeSettings applies the same
10911
- // object, and it runs at launch(), AFTER this has already parsed.
10912
- if (typeof internalNumericLabels === 'boolean') {
10913
- // the retired positional flag: true meant "every numeric internal
10914
- // label is a confidence", which is 'confidence'.
10915
- internalNumericLabels = internalNumericLabels
10916
- ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
10917
- } else if (internalNumericLabels === undefined) {
10918
- internalNumericLabels = PARSE_DEFAULTS.internalNumericLabels;
10919
- }
10920
- let tree;
10921
- let loc = location ? String(location).toLowerCase() : '';
10922
- // Nexus announces itself with "#NEXUS" on the first line, so the
10923
- // content decides; the filename alone is enough too.
10924
- if ((forester.isString(data) && /^\s*#nexus\b/i.test(data))
10925
- || /\.(nex|nexus)$/.test(loc)) {
10926
- tree = archaeopteryx.parseNexus(data, internalNumericLabels);
10927
- } else if ((forester.isString(data) && /^\s*\{/.test(data)) || /\.json$/.test(loc)) {
10928
- tree = archaeopteryx.parseAuspiceJson(data);
10929
- } else if ((forester.isString(data) && /^\s*</.test(data))
10930
- || loc.substr(-3, 3) === 'xml') {
10931
- // No other supported format can start with '<', so content alone
10932
- // is enough -- pasted phyloXML has no filename to go by.
10933
- tree = archaeopteryx.parsePhyloXML(data);
10934
- } else {
10935
- tree = archaeopteryx.parseNewHampshire(data, internalNumericLabels);
10936
- }
10937
- return tree;
12706
+ return parseAllTrees(location, data, resolveInternalLabelsMode(mode, legacyMode))[0];
12707
+ };
12708
+
12709
+ /**
12710
+ * Every tree the data holds, in file order, as an array: a Nexus TREES
12711
+ * block, a New Hampshire text with several ';'-terminated trees, a
12712
+ * phyloXML with several phylogenies (an Auspice dataset is one tree).
12713
+ * launch() takes the array and shows the first, with a picker in the
12714
+ * control panel for the others. Same arguments as parseTree.
12715
+ */
12716
+ archaeopteryx.parseTrees = function (location, data, mode, legacyMode) {
12717
+ return parseAllTrees(location, data, resolveInternalLabelsMode(mode, legacyMode));
10938
12718
  };
10939
12719
 
10940
12720
  // Show the viewer's own "working" card in a container. For the part of a
@@ -11007,9 +12787,11 @@ function (root, d3, forester, phyloXml) {
11007
12787
  + ' "internalNumericLabels".');
11008
12788
  }
11009
12789
  let c = config || {};
11010
- let tree;
12790
+ let trees;
11011
12791
  try {
11012
- tree = archaeopteryx.parseTree(fileName, data,
12792
+ // every tree the file holds: launch() shows the first and the
12793
+ // panel's picker offers the rest
12794
+ trees = archaeopteryx.parseTrees(fileName, data,
11013
12795
  effectiveInternalLabelsMode(c));
11014
12796
  } catch (e) {
11015
12797
  // Worth catching only to say that it was the parse, not the launch,
@@ -11023,7 +12805,7 @@ function (root, d3, forester, phyloXml) {
11023
12805
  // launch() already reports its own failures well enough; wrapping them
11024
12806
  // added nothing but a prefix, and swallowing them left the caller with
11025
12807
  // a blank page and no way to find out why.
11026
- return archaeopteryx.launch(container, tree, config);
12808
+ return archaeopteryx.launch(container, trees, config);
11027
12809
  };
11028
12810
 
11029
12811