archaeopteryx 3.3.0 → 3.4.1

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.1
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.1';
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
  }
@@ -1099,12 +1147,12 @@ function (root, d3, forester, phyloXml) {
1099
1147
  // on the desktop: dots in the search colours, so hits outside the
1100
1148
  // current viewport can be spotted and steered to. Search results
1101
1149
  // re-run update(), which ends here, so the dots track every search.
1102
- // Visible nodes only (children, not _children): a hit inside a
1103
- // collapsed clade has no drawn position.
1150
+ // Displayed nodes only: a hit inside a collapsed clade has no drawn
1151
+ // position, so the clade gets the dot, in its wedge's mark colour.
1104
1152
  let hits = [];
1105
1153
  if (_root) {
1106
- forester.preOrderTraversal(_root, function (n) {
1107
- let c = getFoundColor(n);
1154
+ forEachDisplayed(function (n) {
1155
+ let c = getFoundColor(n) || (isCollapsed(n) ? collapsedMarkColor(n) : null);
1108
1156
  if (c) {
1109
1157
  hits.push({node: n, color: c});
1110
1158
  }
@@ -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();
@@ -1946,6 +1997,7 @@ function (root, d3, forester, phyloXml) {
1946
1997
  }
1947
1998
  });
1948
1999
  _vis.colorNext[vis.id] = next;
2000
+ vis.categoryOnScreen = new Set(vis.values); // what the scale may be asked about (visualizationColorFor)
1949
2001
  vis.categoryScale = d3.scaleOrdinal()
1950
2002
  .domain(vis.values)
1951
2003
  .range(vis.values.map(function (v) {
@@ -2001,8 +2053,19 @@ function (root, d3, forester, phyloXml) {
2001
2053
  // its scales from that: the whole tree at launch and after an edit, the
2002
2054
  // displayed subtree on every view change.
2003
2055
  function summarizeView(root) {
2056
+ // the tips on screen: the view's, minus what collapsed clades hide
2057
+ let tips = [];
2058
+ (function walk(n) {
2059
+ if (!n.children) {
2060
+ tips.push(n);
2061
+ } else if (!isCollapsed(n)) {
2062
+ for (let i = 0; i < n.children.length; ++i) {
2063
+ walk(n.children[i]);
2064
+ }
2065
+ }
2066
+ })(root);
2004
2067
  _vis.candidates.forEach(function (c) {
2005
- let s = forester.visualizationSummary(c, root);
2068
+ let s = forester.visualizationSummary(c, tips);
2006
2069
  c.values = s.values;
2007
2070
  c.counts = s.counts;
2008
2071
  c.coverage = s.coverage;
@@ -2625,17 +2688,24 @@ function (root, d3, forester, phyloXml) {
2625
2688
  - bottomOverlayReserve());
2626
2689
  _treeFn = _treeFn.size([_clusterH, _w]);
2627
2690
 
2691
+ // A collapsed clade is a leaf that asks for more than one row: the
2692
+ // separation between neighbouring leaves is the mean of their
2693
+ // weights, a plain tip weighing 1 and a collapsed clade 2r - 1 so
2694
+ // that it spans r rows (collapsedRows).
2695
+ let leafWeight = function (hn) {
2696
+ return isCollapsed(hn.data) ? (2 * collapsedRows(hn.data)) - 1 : 1;
2697
+ };
2628
2698
  _treeFn = _treeFn.separation(function separation(a, b) {
2629
- return a.parent === b.parent ? 1 : 1;
2699
+ return (leafWeight(a) + leafWeight(b)) / 2;
2630
2700
  });
2631
2701
 
2632
- let uncollsed_nodes = forester.calcSumOfExternalDescendants(_root);
2633
2702
  // d3 v7: d3.cluster() lays out a d3.hierarchy rather than mutating our
2634
2703
  // own nodes in place (as d3 v3's d3.layout.cluster().nodes() did). Run
2635
2704
  // 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.
2705
+ // the forester nodes so the rest of the renderer is unchanged. A
2706
+ // collapsed node ends the hierarchy: what it hides is not laid out.
2637
2707
  let hierarchy = d3.hierarchy(_root, function (d) {
2638
- return d.children;
2708
+ return isCollapsed(d) ? null : d.children;
2639
2709
  });
2640
2710
  _treeFn(hierarchy);
2641
2711
  hierarchy.each(function (hn) {
@@ -2646,14 +2716,59 @@ function (root, d3, forester, phyloXml) {
2646
2716
  let nodes = hierarchy.descendants().map(function (hn) {
2647
2717
  return hn.data;
2648
2718
  }).reverse();
2719
+ let leaves = hierarchy.leaves();
2720
+ let uncollsed_nodes = leaves.length; // the rows on screen: tips and collapsed clades
2721
+ {
2722
+ // px per row unit: the leaves span the cluster height in
2723
+ // separation units (a collapsed clade counts for its rows)
2724
+ let units = 0;
2725
+ for (let i = 1; i < leaves.length; ++i) {
2726
+ units += (leafWeight(leaves[i - 1]) + leafWeight(leaves[i])) / 2;
2727
+ }
2728
+ _rowUnit = units > 0 ? (leaves[leaves.length - 1].x - leaves[0].x) / units : _clusterH;
2729
+ }
2730
+ _cladogramUnit = hierarchy.height > 0 ? _w / hierarchy.height : _w;
2731
+
2732
+ // The wrapper is laid out as a level of its own, so a cladogram spent
2733
+ // one depth unit on the invisible branch above the root. Pull the
2734
+ // root back to 0 and stretch the rest to the full width; the stub is
2735
+ // placed afterwards (placeRootStub), and a phylogram overrides these
2736
+ // positions anyway.
2737
+ {
2738
+ let top = topNode();
2739
+ if (top && top.y > 0 && _w > top.y) {
2740
+ let unit = top.y;
2741
+ let stretch = _w / (_w - unit);
2742
+ forEachDisplayed(function (n) {
2743
+ if (n !== _root) {
2744
+ n.y = (n.y - unit) * stretch;
2745
+ }
2746
+ });
2747
+ _root.y = 0;
2748
+ _cladogramUnit = unit * stretch;
2749
+ }
2750
+ // a collapsed clade is laid out on the tip column like any leaf;
2751
+ // in a cladogram its apex steps back one level so the wedge ends
2752
+ // where the tips would (a phylogram places it by distance)
2753
+ if (!_state.phylogram) {
2754
+ forEachDisplayed(function (n) {
2755
+ if (isCollapsed(n)) {
2756
+ n.y -= _cladogramUnit;
2757
+ }
2758
+ });
2759
+ }
2760
+ }
2649
2761
 
2650
- // The dim gate: only while a hit is actually DRAWN, so the tree never
2762
+ // The dim gate: only while a hit is actually SHOWN, so the tree never
2651
2763
  // washes out with nothing emphasised (a 0-hit search, or every hit
2652
- // hidden inside a collapsed clade / outside the displayed subtree).
2764
+ // outside the displayed subtree). A collapsed clade holding a hit
2765
+ // counts as shown: it stays bright, outlined and counting its hits,
2766
+ // so the rest fading points at it (Christian, 2026-09-14: "less
2767
+ // confusing to users" than no dimming at all).
2653
2768
  _dimNonMatches = false;
2654
2769
  if ((_foundNodes0 && _foundNodes0.size > 0) || (_foundNodes1 && _foundNodes1.size > 0)) {
2655
2770
  for (let i = 0, len = nodes.length; i !== len; ++i) {
2656
- if (isNodeFound(nodes[i])) {
2771
+ if (isNodeFound(nodes[i]) || (isCollapsed(nodes[i]) && collapsedFoundCounts(nodes[i]).found > 0)) {
2657
2772
  _dimNonMatches = true;
2658
2773
  break;
2659
2774
  }
@@ -2668,12 +2783,18 @@ function (root, d3, forester, phyloXml) {
2668
2783
  if (_state.phylogram === true) {
2669
2784
  _yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
2670
2785
  }
2786
+ placeRootStub();
2671
2787
 
2672
2788
  if (_state.circularDisplay) {
2789
+ // the outermost drawn point sets the scale: a node, or the far
2790
+ // edge of a collapsed clade's wedge -- which reaches the clade's
2791
+ // farthest tip, hidden from nodes but not from the drawing, and
2792
+ // would otherwise poke through the ring the labels stand on
2673
2793
  let maxY = 0;
2674
2794
  for (let i = 0; i < nodes.length; ++i) {
2675
- if (nodes[i].y > maxY) {
2676
- maxY = nodes[i].y;
2795
+ let y = isCollapsed(nodes[i]) ? Math.max(nodes[i].y, collapsedReach(nodes[i])[1]) : nodes[i].y;
2796
+ if (y > maxY) {
2797
+ maxY = y;
2677
2798
  }
2678
2799
  }
2679
2800
  _radial = {
@@ -2691,17 +2812,26 @@ function (root, d3, forester, phyloXml) {
2691
2812
  // branchLengthScaling just wrote) and are one constant step per
2692
2813
  // level in cladogram mode; the radial budget matches circular's.
2693
2814
  let budget = Math.min(_displayWidth, _displayHeight) * 0.42;
2815
+ // The fan is laid out from the displayed tree's OWN root, never
2816
+ // from the wrapper above it: an unrooted display draws no root
2817
+ // branch at all, not the file's real one and not the stub
2818
+ // (Christian, 2026-09-13). Laid out from the wrapper, the root
2819
+ // hung one cladogram step -- or its own branch length -- off the
2820
+ // centre on a spoke leading in from nowhere.
2821
+ let top = topNode() || _root;
2694
2822
  let lengthOf;
2823
+ let pxPerUnit = 0; // the phylogram's scale, for the scale bar
2695
2824
  if (_state.phylogram) {
2696
2825
  let maxDist = 0;
2697
- forester.preOrderTraversal(_root, function (n) {
2698
- if (!n.children && n.distToRoot > maxDist) {
2699
- maxDist = n.distToRoot;
2826
+ forester.preOrderTraversal(top, function (n) {
2827
+ if (!n.children && n.distToRoot - top.distToRoot > maxDist) {
2828
+ maxDist = n.distToRoot - top.distToRoot;
2700
2829
  }
2701
2830
  });
2702
2831
  let factor = maxDist > 0 ? budget / maxDist : 1;
2832
+ pxPerUnit = maxDist > 0 ? factor : 0;
2703
2833
  lengthOf = function (n) {
2704
- return Math.max(0, n.distToRoot - (n.parent ? n.parent.distToRoot : 0)) * factor;
2834
+ return Math.max(0, n.distToRoot - n.parent.distToRoot) * factor;
2705
2835
  };
2706
2836
  } else {
2707
2837
  let maxDepth = 0;
@@ -2710,12 +2840,20 @@ function (root, d3, forester, phyloXml) {
2710
2840
  maxDepth = nodes[i].depth;
2711
2841
  }
2712
2842
  }
2713
- let step = budget / Math.max(1, maxDepth);
2843
+ // depths count from the wrapper, so the tree's own levels are one fewer
2844
+ let step = budget / Math.max(1, maxDepth - (top === _root ? 0 : 1));
2714
2845
  lengthOf = function () {
2715
2846
  return step;
2716
2847
  };
2717
2848
  }
2718
- _unroot = forester.equalAngleLayout(_root, UNROOTED_START_ANGLE + _radialRotation, lengthOf);
2849
+ _unroot = forester.equalAngleLayout(top, UNROOTED_START_ANGLE + _radialRotation, lengthOf);
2850
+ _unroot.pxPerUnit = pxPerUnit;
2851
+ if (top !== _root) {
2852
+ // the wrapper sits on the root, so their link has no length
2853
+ _root.ux = top.ux;
2854
+ _root.uy = top.uy;
2855
+ _root.uangle = top.uangle;
2856
+ }
2719
2857
  } else {
2720
2858
  _radial = null;
2721
2859
  _unroot = null;
@@ -2736,6 +2874,7 @@ function (root, d3, forester, phyloXml) {
2736
2874
  });
2737
2875
  }
2738
2876
  }
2877
+ syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
2739
2878
 
2740
2879
  updateButtonEnabledState();
2741
2880
 
@@ -2795,6 +2934,7 @@ function (root, d3, forester, phyloXml) {
2795
2934
  // d3 v4+ no longer folds entered nodes into the update selection, so
2796
2935
  // merge them before the shared styling/positioning below.
2797
2936
  node = nodeEnter.merge(node);
2937
+ drawCollapsedClades(node);
2798
2938
 
2799
2939
  // Create the optional children only where they will show something,
2800
2940
  // and drop them where they will not. Must run BEFORE node.transition()
@@ -3022,9 +3162,14 @@ function (root, d3, forester, phyloXml) {
3022
3162
  // Dim Non-Matches: one opacity on the node GROUP dims its labels, dot,
3023
3163
  // shape and branch-data numbers together; the branch lines are separate
3024
3164
  // path.link elements and keep their full colour, as on the desktop.
3025
- // Hits and selected nodes (getFoundColor) are never dimmed.
3165
+ // Hits and selected nodes (getFoundColor) are never dimmed -- and
3166
+ // neither is a collapsed clade holding one: its wedge and label live in
3167
+ // the clade node's group, and the clade node itself is never a hit.
3026
3168
  node.style('opacity', function (d) {
3027
- return (_dimNonMatches && !getFoundColor(d)) ? DIM_NON_MATCH_OPACITY : null;
3169
+ if (!_dimNonMatches || getFoundColor(d) || (isCollapsed(d) && collapsedHoldsHighlight(d))) {
3170
+ return null;
3171
+ }
3172
+ return DIM_NON_MATCH_OPACITY;
3028
3173
  });
3029
3174
 
3030
3175
  let nodeUpdate = animateOrSet(node, transitionDuration)
@@ -3172,43 +3317,75 @@ function (root, d3, forester, phyloXml) {
3172
3317
  // _svgGroup, so that name put them in the way of selectAll('path.link')
3173
3318
  // -- the main link data-join, and the overview's miniature.
3174
3319
  _svgGroup.selectAll('g.aptx-align-ext').remove();
3175
- if (!radialDisplay() && _state.phylogram && _state.alignPhylogram && _state.showExternalLabels
3176
- && (_state.showNodeName || _state.showTaxonomy || _state.showSequence)) {
3320
+ let alignedRect = !radialDisplay() && _state.phylogram && _state.alignPhylogram;
3321
+ let tipGuides = alignedRect && _state.showExternalLabels
3322
+ && (_state.showNodeName || _state.showTaxonomy || _state.showSequence);
3323
+ // a collapsed clade's label always shows, so its guide does too: from
3324
+ // past the wedge's farthest tip to the label column
3325
+ let collapsedGuides = alignedRect ? nodes.filter(function (d) {
3326
+ return isCollapsed(d) && !(_state.dynahide && d.hide);
3327
+ }) : [];
3328
+ if (tipGuides || collapsedGuides.length > 0) {
3177
3329
  let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
3178
- ext.selectAll('path')
3179
- .data(links.filter(function (d) {
3180
- return (!d.target.children && !(_state.dynahide && d.target.hide));
3181
- }))
3182
- .enter().append('path')
3183
- .attr('fill', 'none')
3184
- .attr('stroke-width', 1)
3185
- .attr('stroke', _state.branchColorDefault)
3186
- .style('stroke-opacity', 0.25)
3330
+ let guideStyle = function (sel) {
3331
+ return sel.attr('fill', 'none')
3332
+ .attr('stroke-width', 1)
3333
+ .attr('stroke', _state.branchColorDefault)
3334
+ .style('stroke-opacity', 0.25);
3335
+ };
3336
+ if (tipGuides) {
3337
+ guideStyle(ext.selectAll('path.aptx-tip-guide')
3338
+ .data(links.filter(function (d) {
3339
+ return (!d.target.children && !(_state.dynahide && d.target.hide));
3340
+ }))
3341
+ .enter().append('path').attr('class', 'aptx-tip-guide'))
3342
+ .attr('d', function (d) {
3343
+ return connection(d.target);
3344
+ });
3345
+ }
3346
+ guideStyle(ext.selectAll('path.aptx-collapsed-guide')
3347
+ .data(collapsedGuides)
3348
+ .enter().append('path').attr('class', 'aptx-collapsed-guide'))
3187
3349
  .attr('d', function (d) {
3188
- return connection(d.target);
3350
+ let x1 = collapsedReach(d)[1] + 5;
3351
+ return (_w - x1) > 5 ? 'M' + x1 + ',' + d.x + 'L' + _w + ',' + d.x : null;
3189
3352
  });
3190
3353
  }
3191
3354
 
3192
3355
  // circular: a thin dashed connector from each external node out to the
3193
3356
  // common label ring (so labels line up like iTOL's aligned display).
3194
3357
  _svgGroup.selectAll('g.aptx-radial-conn').remove();
3195
- if (_state.circularDisplay && _state.showExternalLabels) {
3196
- let conn = _svgGroup.insert('g', 'g').attr('class', 'aptx-radial-conn');
3197
- conn.selectAll('line')
3198
- .data(nodes.filter(function (d) {
3199
- return !d.children && !(_state.dynahide && d.hide);
3200
- }))
3201
- .enter().append('line')
3202
- .attr('stroke', _state.branchColorDefault)
3203
- // width/opacity match the rectangular aligned extensions; the
3204
- // dash alone marks these as connectors (0.5px at 0.3 vanished)
3205
- .attr('stroke-width', 1)
3206
- .style('stroke-opacity', 0.4)
3207
- .style('stroke-dasharray', '2,3')
3208
- .attr('x1', function (d) { return radialXY(d.x, d.y)[0]; })
3209
- .attr('y1', function (d) { return radialXY(d.x, d.y)[1]; })
3210
- .attr('x2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[0]; })
3211
- .attr('y2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[1]; });
3358
+ if (_state.circularDisplay) {
3359
+ // tips while their labels show; a collapsed clade always (its label
3360
+ // always shows), from its wedge's farthest reach out to the ring
3361
+ let connected = nodes.filter(function (d) {
3362
+ if (_state.dynahide && d.hide) {
3363
+ return false;
3364
+ }
3365
+ if (isCollapsed(d)) {
3366
+ return _radial.maxRad - radialRadius(collapsedReach(d)[1]) > 2;
3367
+ }
3368
+ return !d.children && _state.showExternalLabels;
3369
+ });
3370
+ let innerY = function (d) {
3371
+ return isCollapsed(d) ? collapsedReach(d)[1] : d.y;
3372
+ };
3373
+ if (connected.length > 0) {
3374
+ let conn = _svgGroup.insert('g', 'g').attr('class', 'aptx-radial-conn');
3375
+ conn.selectAll('line')
3376
+ .data(connected)
3377
+ .enter().append('line')
3378
+ .attr('stroke', _state.branchColorDefault)
3379
+ // width/opacity match the rectangular aligned extensions; the
3380
+ // dash alone marks these as connectors (0.5px at 0.3 vanished)
3381
+ .attr('stroke-width', 1)
3382
+ .style('stroke-opacity', 0.4)
3383
+ .style('stroke-dasharray', '2,3')
3384
+ .attr('x1', function (d) { return radialXY(d.x, innerY(d))[0]; })
3385
+ .attr('y1', function (d) { return radialXY(d.x, innerY(d))[1]; })
3386
+ .attr('x2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[0]; })
3387
+ .attr('y2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[1]; });
3388
+ }
3212
3389
  }
3213
3390
 
3214
3391
  for (let i = 0, len = nodes.length; i !== len; ++i) {
@@ -3218,12 +3395,14 @@ function (root, d3, forester, phyloXml) {
3218
3395
  }
3219
3396
 
3220
3397
  hideHoverGlow(); // the hovered node may have moved; the next mouseover re-shows it
3398
+ drawScaleBar();
3221
3399
  drawDomainArchitectures();
3222
3400
  drawDomainLegend();
3223
3401
  drawMsaTrack();
3224
3402
  drawTimeOverlays();
3225
3403
  rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
3226
3404
  updateSearchHitNavigation();
3405
+ noteViewChange();
3227
3406
  }
3228
3407
 
3229
3408
  // A node is drawn as a shape only when there is a reason to show one: it
@@ -3257,11 +3436,15 @@ function (root, d3, forester, phyloXml) {
3257
3436
  let styled = _state.nodeSizeDefault > 0 && node.parent && !node.hasVis
3258
3437
  && styleOf !== null && !!styleOf.nodeColor;
3259
3438
 
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;
3439
+ // The root is no exception. A 2.x rule gave "a zero-length branch
3440
+ // off the root" a circle so it would not vanish -- but the tree is
3441
+ // laid out from the invisible wrapper above the root, so the node it
3442
+ // matched was the root itself, and only in a phylogram with nothing
3443
+ // coloured: a circle that appeared and disappeared with the display
3444
+ // type and meant nothing. Dropped 2026-09-13; the root is now marked
3445
+ // by its stub (placeRootStub), and wears a circle only for the
3446
+ // reasons any node does.
3447
+ return (visualized || styled) ? _state.nodeSizeDefault : 0;
3265
3448
  };
3266
3449
 
3267
3450
  let makeBranchWidth = function (link) {
@@ -3416,6 +3599,17 @@ function (root, d3, forester, phyloXml) {
3416
3599
  if (colorModeOf(vis) === 'range') {
3417
3600
  return vis.rangeScale ? vis.rangeScale(Number(value)) : null;
3418
3601
  }
3602
+ // A value only tips hidden in a collapsed clade carry is not on
3603
+ // screen, so not in the scale's domain -- and a d3 ordinal scale asked
3604
+ // about an unknown value extends its domain and hands out an on-screen
3605
+ // value's colour. Such a value keeps its remembered colour instead
3606
+ // (assigned at launch over the whole tree, or the user's override):
3607
+ // the clade's wedge keeps the colour its tips wore. Christian,
3608
+ // 2026-09-14; reported by the desktop session, which follows.
3609
+ if (vis.categoryOnScreen && !vis.categoryOnScreen.has(value)) {
3610
+ let mem = _vis.colorMemory[vis.id];
3611
+ return (mem && mem[visMemoryKey(vis, value)]) || null;
3612
+ }
3419
3613
  return vis.categoryScale(value);
3420
3614
  }
3421
3615
 
@@ -3456,11 +3650,13 @@ function (root, d3, forester, phyloXml) {
3456
3650
  if (_root && ((_foundNodes0 && _foundNodes0.size > 0) || (_foundNodes1 && _foundNodes1.size > 0))) {
3457
3651
  // natural (top-to-bottom) drawing order -- forester's preorder
3458
3652
  // walks children in reverse, which made the NEXT arrow step upward
3653
+ // a collapsed clade holding hits is ONE stop, on its node: the hits
3654
+ // inside have no position of their own to step to
3459
3655
  let walk = function (n) {
3460
- if (isNodeFound(n)) {
3656
+ if (isNodeFound(n) || (isCollapsed(n) && collapsedFoundCounts(n).found > 0)) {
3461
3657
  hits.push(n);
3462
3658
  }
3463
- if (n.children) {
3659
+ if (n.children && !isCollapsed(n)) {
3464
3660
  for (let i = 0; i < n.children.length; ++i) {
3465
3661
  walk(n.children[i]);
3466
3662
  }
@@ -3728,6 +3924,9 @@ function (root, d3, forester, phyloXml) {
3728
3924
  }
3729
3925
 
3730
3926
  let makeNodeLabel = function (phynode) {
3927
+ if (isCollapsed(phynode)) {
3928
+ return ''; // a collapsed clade carries its own label (drawCollapsedClades)
3929
+ }
3731
3930
  if (!_state.showExternalLabels && !(phynode.children)) {
3732
3931
  return null;
3733
3932
  }
@@ -4329,9 +4528,11 @@ function (root, d3, forester, phyloXml) {
4329
4528
  'ladderizeTree',
4330
4529
  'nhExportWriteConfidences',
4331
4530
  'nodeLabels',
4531
+ 'onViewChange',
4332
4532
  'pngExportScale',
4333
4533
  'rootOffset',
4334
4534
  'supportDotMinimum',
4535
+ 'view',
4335
4536
  'zoomToFitUponWindowResize'
4336
4537
  ];
4337
4538
 
@@ -4548,7 +4749,7 @@ function (root, d3, forester, phyloXml) {
4548
4749
  throw new Error(ERROR + '"domainEvalueExponent" must be an integer from '
4549
4750
  + forester.DOMAIN_EVALUE_EXPONENT_MIN + ' to ' + forester.DOMAIN_EVALUE_EXPONENT_MAX);
4550
4751
  }
4551
- _domain = {width: null, palette: null, next: 0, legendFrac: null};
4752
+ _domain = {width: null, radialWidth: null, palette: null, next: 0, legendFrac: null};
4552
4753
  if (_basicTreeProperties.domainArchitectures) {
4553
4754
  // malformed domains are skipped, never fatal -- said once, here
4554
4755
  let ignored = forester.domainArchitectureStats(_treeData).ignored;
@@ -4790,6 +4991,16 @@ function (root, d3, forester, phyloXml) {
4790
4991
  throw new Error(ERROR + 'internalNumericLabels must be "auto", "confidence" or "label"');
4791
4992
  }
4792
4993
  // custom label-field checkboxes (was launch()'s sixth positional arg)
4994
+ if (_settings.view === undefined || _settings.view === null) {
4995
+ _settings.view = null;
4996
+ } else if (typeof _settings.view !== 'object') {
4997
+ throw new Error(ERROR + '"view" must be a view state object (getViewState / decodeViewState) or null');
4998
+ }
4999
+ if (_settings.onViewChange === undefined || _settings.onViewChange === null) {
5000
+ _settings.onViewChange = null;
5001
+ } else if (typeof _settings.onViewChange !== 'function') {
5002
+ throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
5003
+ }
4793
5004
  if (_settings.nodeLabels === undefined) {
4794
5005
  _settings.nodeLabels = null;
4795
5006
  } else if (_settings.nodeLabels !== null && typeof _settings.nodeLabels !== 'object') {
@@ -4892,6 +5103,12 @@ function (root, d3, forester, phyloXml) {
4892
5103
  if (_settings.ladderizeTree) {
4893
5104
  ladderizeSubtree(_root, true); // one pass: a definite arrangement
4894
5105
  }
5106
+ _viewOps = {order: null, root: null};
5107
+ if (_settings.view) {
5108
+ // the shared view, its searches found before the first draw
5109
+ applyViewState(_settings.view, true);
5110
+ runSearches();
5111
+ }
4895
5112
  if (_state.searchAinitialValue) {
4896
5113
  search0();
4897
5114
  }
@@ -4905,6 +5122,7 @@ function (root, d3, forester, phyloXml) {
4905
5122
 
4906
5123
  search0();
4907
5124
  search1();
5125
+ settleViewNotification();
4908
5126
  }
4909
5127
 
4910
5128
  function bindDoc(type, fn, opts) {
@@ -4922,6 +5140,24 @@ function (root, d3, forester, phyloXml) {
4922
5140
  // resolves once the tree is drawn: at once for a small tree,
4923
5141
  // after the deferred first draw for a big one
4924
5142
  ready: _readyPromise,
5143
+ // the trees of the launch -- one, or every tree the file held:
5144
+ // how many, which is shown, and showing another (a fresh
5145
+ // viewer for it; the handle returned is the one to keep)
5146
+ getTreeCount: function () {
5147
+ return _trees.length;
5148
+ },
5149
+ getTreeIndex: function () {
5150
+ return _treeIndex;
5151
+ },
5152
+ showTree: showTree,
5153
+ // the view -- layout, display, labels, colours, searches, the
5154
+ // clade and the collapsed clades, sizes -- as a plain object,
5155
+ // and opening one (see also archaeopteryx.encodeViewState /
5156
+ // decodeViewState and the config's view / onViewChange)
5157
+ getViewState: getViewState,
5158
+ applyViewState: function (state) {
5159
+ applyViewState(state, false);
5160
+ },
4925
5161
  destroy: destroyViewer
4926
5162
  };
4927
5163
  }
@@ -4974,6 +5210,11 @@ function (root, d3, forester, phyloXml) {
4974
5210
  _root = null;
4975
5211
  _root_const = null;
4976
5212
  _treeData = null;
5213
+ _trees = [];
5214
+ _treeIndex = 0;
5215
+ _launchConfig = null;
5216
+ _lastViewEncoded = undefined;
5217
+ _viewOps = {order: null, root: null};
4977
5218
  _basicTreeProperties = null;
4978
5219
  _baseSvg = null;
4979
5220
  _svgGroup = null;
@@ -5029,9 +5270,39 @@ function (root, d3, forester, phyloXml) {
5029
5270
  if (phylo === undefined || phylo === null) {
5030
5271
  throw new Error(ERROR + 'input tree is undefined or null');
5031
5272
  }
5032
- if ((!phylo.children) || (phylo.children.length < 1)) {
5033
- throw new Error(ERROR + 'input tree is empty or illegally formatted');
5273
+ // One tree, or a list of them (every tree a file holds -- parseTrees):
5274
+ // the list is kept, the first is shown, and the panel's picker and the
5275
+ // handle's showTree() move between them.
5276
+ let trees = Array.isArray(phylo) ? phylo.slice() : [phylo];
5277
+ if (trees.length === 0) {
5278
+ throw new Error(ERROR + 'input tree list is empty');
5034
5279
  }
5280
+ trees.forEach(function (t, i) {
5281
+ if (!t || (!t.children) || (t.children.length < 1)) {
5282
+ throw new Error(ERROR + (trees.length > 1 ? 'input tree ' + (i + 1) + ' of ' + trees.length : 'input tree')
5283
+ + ' is empty or illegally formatted');
5284
+ }
5285
+ });
5286
+ // the two view keys are checked up here with the other arguments,
5287
+ // before anything is touched (and where Node can test it)
5288
+ if (config && config.view !== undefined && config.view !== null && typeof config.view !== 'object') {
5289
+ throw new Error(ERROR + '"view" must be a view state object (getViewState / decodeViewState) or null');
5290
+ }
5291
+ if (config && config.onViewChange !== undefined && config.onViewChange !== null && typeof config.onViewChange !== 'function') {
5292
+ throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
5293
+ }
5294
+ // a view names the tree it was made on; a fresh launch reports no
5295
+ // view until one changes (settleViewNotification)
5296
+ _lastViewEncoded = undefined;
5297
+ let view = config && config.view;
5298
+ let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
5299
+ return launchInto(container, trees, start, config);
5300
+ };
5301
+
5302
+ // The launch proper, for one tree of the list: launch() validates and
5303
+ // starts at the first, showTree() re-enters here for another.
5304
+ function launchInto(container, trees, index, config) {
5305
+ let phylo = trees[index];
5035
5306
  let cfg = readConfig(config);
5036
5307
  requireForester();
5037
5308
  requireD3();
@@ -5039,7 +5310,11 @@ function (root, d3, forester, phyloXml) {
5039
5310
 
5040
5311
  let previousContainer = _container; // for the different-container teardown below
5041
5312
  _treeData = phylo;
5313
+ _trees = trees;
5314
+ _treeIndex = index;
5315
+ _launchConfig = config;
5042
5316
  _container = containerEl;
5317
+ assignViewIds(phylo);
5043
5318
  _zoomListener = d3.zoom()
5044
5319
  .scaleExtent([0.1, 10])
5045
5320
  .filter(function (event) {
@@ -5180,10 +5455,28 @@ function (root, d3, forester, phyloXml) {
5180
5455
  }
5181
5456
 
5182
5457
  return makeViewerHandle();
5458
+ }
5183
5459
 
5184
- //////////////////////////////////////////////////////////////////////
5185
-
5186
- };
5460
+ // Shows another tree of the launched list in the same container under
5461
+ // the same config. The tree opens fresh -- its own presets, a clean
5462
+ // view -- the way a new tab does on the desktop. The panel's picker and
5463
+ // previous / next buttons come here, as does the handle's showTree().
5464
+ function showTree(index) {
5465
+ if (!_container) {
5466
+ throw new Error(ERROR + 'showTree(): nothing is launched');
5467
+ }
5468
+ if (!Number.isInteger(index) || index < 0 || index >= _trees.length) {
5469
+ throw new Error(ERROR + 'showTree(): the index must be 0 to ' + (_trees.length - 1) + ', got ' + index);
5470
+ }
5471
+ if (index === _treeIndex) {
5472
+ return makeViewerHandle();
5473
+ }
5474
+ // collapsing is part of the view, and a switched-to tree opens fresh:
5475
+ // nothing collapsed, now or when you come back (Christian, 2026-09-14)
5476
+ clearCollapsedFlags(_trees[_treeIndex]);
5477
+ clearCollapsedFlags(_trees[index]);
5478
+ return launchInto(_container, _trees, index, _launchConfig);
5479
+ }
5187
5480
 
5188
5481
  archaeopteryx.parsePhyloXML = function (data) {
5189
5482
  requirePhyloXml();
@@ -5240,8 +5533,9 @@ function (root, d3, forester, phyloXml) {
5240
5533
  return forester.parseAuspiceJson(data);
5241
5534
  };
5242
5535
 
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).
5536
+ // A Nexus file can hold several trees; this returns the FIRST one
5537
+ // (parseTrees returns them all; any alignment from the file's characters
5538
+ // matrix rides along on the tips).
5245
5539
  archaeopteryx.parseNexus = function (data, mode, legacyMode) {
5246
5540
  requireForester();
5247
5541
  let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
@@ -5255,6 +5549,9 @@ function (root, d3, forester, phyloXml) {
5255
5549
  function calcMaxExtLabel() {
5256
5550
  _maxLabelLength = _state.nodeLabelGap;
5257
5551
  forester.preOrderTraversal(_root, function (d) {
5552
+ if (isCollapsed(d)) {
5553
+ _maxLabelLength = Math.max(collapsedLabel(d).length, _maxLabelLength);
5554
+ }
5258
5555
  if (!d.children) {
5259
5556
  let l = makeNodeLabel(d);
5260
5557
  if (l) {
@@ -5337,134 +5634,21 @@ function (root, d3, forester, phyloXml) {
5337
5634
  update();
5338
5635
  }
5339
5636
 
5637
+ // The tips under the node as a tab-separated table with a header,
5638
+ // the desktop's columns (forester.externalNodeDataTable), top to
5639
+ // bottom as drawn; it reads back through a metadata-table join.
5340
5640
  function downloadExternalNodeDataAll(node) {
5341
-
5342
- let addSep = function (t) {
5343
- if (t.length > 0) {
5344
- t += '\t';
5345
- }
5346
- return t;
5347
- };
5348
-
5349
- let addSepSame = function (t) {
5350
- if (t.length > 0) {
5351
- t += ', ';
5352
- }
5353
- return t;
5354
- };
5355
- let text_all = '';
5356
-
5357
5641
  const ext_nodes = forester.getAllExternalNodes(node).reverse();
5358
-
5359
5642
  let filename;
5360
5643
  if (ext_nodes.length === 1 && ext_nodes[0].name) {
5361
- filename = 'External_Node_Data_for_Node_' + ext_nodes[0].name.replace(/\W/g, '_') + '.txt';
5644
+ filename = 'External_Node_Data_for_Node_' + ext_nodes[0].name.replace(/\W/g, '_') + '.tsv';
5362
5645
  } else {
5363
- filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.txt';
5364
- }
5365
-
5366
- for (let j = 0, l = ext_nodes.length; j < l; ++j) {
5367
- let text = '';
5368
- let n = ext_nodes[j];
5369
- if (n.name) {
5370
- text += n.name
5371
- }
5372
-
5373
- if (n.properties && (n.properties.length > 0)) {
5374
- const sorted_properties = n.properties.concat().sort();
5375
- const l = sorted_properties.length;
5376
- let properties_text = '';
5377
- let prev_property_ref = null;
5378
- for (let pl = 0; pl < l; ++pl) {
5379
- if (forester.isNodeScopedProperty(sorted_properties[pl])) {
5380
- if (sorted_properties[pl].ref === prev_property_ref) {
5381
- properties_text = addSepSame(properties_text);
5382
- } else {
5383
- prev_property_ref = sorted_properties[pl].ref;
5384
- properties_text = addSep(properties_text);
5385
- }
5386
- properties_text += sorted_properties[pl].value;
5387
- }
5388
- }
5389
- if (properties_text.length > 0) {
5390
- text = addSep(text);
5391
- text += properties_text;
5392
- }
5393
- }
5394
-
5395
- if (n.taxonomies) {
5396
- let tax_text = '';
5397
- for (let i = 0; i < n.taxonomies.length; ++i) {
5398
- let t = n.taxonomies[i];
5399
- if (t.id) {
5400
- if (t.id.provider) {
5401
- tax_text = addSep(tax_text);
5402
- tax_text += '[' + t.id.provider + ']:' + t.id.value;
5403
- } else {
5404
- tax_text = addSep(tax_text);
5405
- tax_text += t.id.value;
5406
- }
5407
- }
5408
- if (t.code) {
5409
- tax_text = addSep(tax_text);
5410
- tax_text += t.code;
5411
- }
5412
- if (t.scientific_name) {
5413
- tax_text = addSep(tax_text);
5414
- tax_text += t.scientific_name;
5415
- }
5416
- if (t.common_name) {
5417
- tax_text = addSep(tax_text);
5418
- tax_text += t.common_name;
5419
- }
5420
- if (t.rank) {
5421
- tax_text = addSep(tax_text);
5422
- tax_text += t.rank;
5423
- }
5424
- }
5425
- text = addSep(text);
5426
- text += tax_text;
5427
- }
5428
- if (n.sequences) {
5429
- let seq_text = '';
5430
- for (let i = 0; i < n.sequences.length; ++i) {
5431
- let s = n.sequences[i];
5432
- if (s.accession) {
5433
- if (s.accession.source) {
5434
- seq_text = addSep(seq_text);
5435
- seq_text += '[' + s.accession.source + ']:' + s.accession.value;
5436
- } else {
5437
- seq_text = addSep(seq_text);
5438
- seq_text += s.accession.value;
5439
- }
5440
- }
5441
- if (s.symbol) {
5442
- seq_text = addSep(seq_text);
5443
- seq_text += s.symbol;
5444
- }
5445
- if (s.name) {
5446
- seq_text = addSep(seq_text);
5447
- seq_text += s.name;
5448
- }
5449
- if (s.gene_name) {
5450
- seq_text = addSep(seq_text);
5451
- seq_text += s.gene_name;
5452
- }
5453
- if (s.location) {
5454
- seq_text = addSep(seq_text);
5455
- seq_text += s.location;
5456
- }
5457
- }
5458
- text = addSep(text);
5459
- text += seq_text;
5460
- }
5461
- if (text.length > 0) {
5462
- text_all += text + '\n';
5463
- }
5646
+ filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.tsv';
5464
5647
  }
5465
-
5466
- saveAs(new Blob([text_all], {type: "application/txt"}), filename);
5467
-
5648
+ const tsv = forester.externalNodeDataTsv(ext_nodes, function (n, i) {
5649
+ return (n.viewId !== undefined) ? n.viewId : i + 1;
5650
+ });
5651
+ saveAs(new Blob([tsv], {type: 'text/tab-separated-values'}), filename);
5468
5652
  update();
5469
5653
  }
5470
5654
 
@@ -5589,6 +5773,9 @@ function (root, d3, forester, phyloXml) {
5589
5773
  }
5590
5774
 
5591
5775
  function switchToSubtree(node) {
5776
+ if (node.collapsed) {
5777
+ node.collapsed = false; // a subtree view shows what the clade holds
5778
+ }
5592
5779
  if (node.parent) {
5593
5780
  if (!(node.children)) {
5594
5781
  if (node.parent.parent) {
@@ -5665,6 +5852,14 @@ function (root, d3, forester, phyloXml) {
5665
5852
  // "Switch to ..." matches the desktop's wording for this action.
5666
5853
  items.push({separator: true});
5667
5854
  items.push({label: 'Switch to Subtree', action: function () { switchToSubtree(d); }});
5855
+ // the desktop's two collapse entries, with its enabling rules:
5856
+ // the toggle for an internal node that is not the root and not
5857
+ // in the unrooted layout; the subtree uncollapse only when
5858
+ // something below is collapsed
5859
+ items.push({label: 'Collapse/Uncollapse', disabled: !(collapseAllowed() && d.children),
5860
+ action: function () { collapseToggle(d); }});
5861
+ items.push({label: 'Uncollapse Subtree', disabled: !hasCollapsedIn(d),
5862
+ action: function () { uncollapse(d); }});
5668
5863
  }
5669
5864
  if (d.parent && d.children) {
5670
5865
  // redrawn with no transition: animating a swap sends the two
@@ -5684,7 +5879,12 @@ function (root, d3, forester, phyloXml) {
5684
5879
  }
5685
5880
  if (!_in_subtree && d.parent && d.parent.parent
5686
5881
  && ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
5687
- items.push({label: 'Reroot', action: function () { forester.reRoot(tree, d, -1); zoomToFit(); }});
5882
+ items.push({label: 'Reroot', action: function () {
5883
+ rerootKeepingCollapse(function () {
5884
+ forester.reRoot(tree, d, -1);
5885
+ });
5886
+ zoomToFit();
5887
+ }});
5688
5888
  }
5689
5889
  if (_settings.enableManualNodeSelection) {
5690
5890
  items.push({label: 'Select/Deselect Node', action: function () { selectDeselectNode(d); }});
@@ -6025,7 +6225,7 @@ function (root, d3, forester, phyloXml) {
6025
6225
  updateMsaScrollbar(0, 1, 0);
6026
6226
  return;
6027
6227
  }
6028
- let tips = forester.getAllExternalNodes(_root).filter(function (d) {
6228
+ let tips = displayedTips().filter(function (d) {
6029
6229
  return d.x !== undefined;
6030
6230
  }).sort(function (p, q) {
6031
6231
  return p.x - q.x;
@@ -6525,139 +6725,1250 @@ function (root, d3, forester, phyloXml) {
6525
6725
  scheduleUpdate(null, 0);
6526
6726
  }
6527
6727
 
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.
6728
+ // ===================== Collapsed clades =====================
6729
+ // Collapsing is DISPLAY state on the node (d.collapsed) and nothing
6730
+ // else: forester never sees it, the writers never see it, and the data
6731
+ // tree keeps every tip. The layout treats a collapsed node as a leaf and
6732
+ // the viewer draws it as a wedge -- apex at the node, its two edges
6733
+ // reaching the clade's nearest and farthest tips so its depth stays
6734
+ // readable, filled in the clade's dominant Color-by colour, its height
6735
+ // growing gently with the tip count -- named by the node's own name,
6736
+ // else by the one Color-by value nearly all its tips share, else by the
6737
+ // tips' common name prefix, always with the tip count, and with
6738
+ // "[found/total]" while a search hits inside it (the desktop's touch).
6739
+ // The CONTROLS are the desktop's exactly (Christian, 2026-09-13): the
6740
+ // node menu's "Collapse/Uncollapse" and "Uncollapse Subtree", the
6741
+ // uncollapse-all button in the tool row with the desktop's glyph, no
6742
+ // collapsing in the unrooted layout, never the root. Every walk of the
6743
+ // DISPLAYED tree goes through forEachDisplayed; a hidden tip keeps its
6744
+ // stale position and must never be drawn from.
6745
+ let _rowUnit = 0; // px per row in the last rectangular layout
6540
6746
 
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);
6747
+ function collapseAllowed() {
6748
+ return !_state.unrootedDisplay;
6549
6749
  }
6550
6750
 
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));
6751
+ function isCollapsed(d) {
6752
+ return d.collapsed === true && !!d.children && collapseAllowed();
6753
+ }
6754
+
6755
+ // visits the displayed tree: a collapsed node, not what it hides
6756
+ function forEachDisplayed(fn) {
6757
+ if (!_root) {
6758
+ return;
6559
6759
  }
6560
- return _domain.width;
6760
+ (function walk(n) {
6761
+ fn(n);
6762
+ if (n.children && !isCollapsed(n)) {
6763
+ for (let i = 0; i < n.children.length; ++i) {
6764
+ walk(n.children[i]);
6765
+ }
6766
+ }
6767
+ })(_root);
6561
6768
  }
6562
6769
 
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);
6770
+ function displayedTips() {
6771
+ let out = [];
6772
+ forEachDisplayed(function (n) {
6773
+ if (!n.children) {
6774
+ out.push(n);
6775
+ }
6776
+ });
6777
+ return out;
6778
+ }
6779
+
6780
+ function displayedLeafCount() {
6781
+ let count = 0;
6782
+ forEachDisplayed(function (n) {
6783
+ if (!n.children || isCollapsed(n)) {
6784
+ count++;
6785
+ }
6786
+ });
6787
+ return count;
6788
+ }
6789
+
6790
+ function hasCollapsedIn(node) {
6791
+ let found = false;
6792
+ forester.preOrderTraversal(node, function (n) {
6793
+ if (n.collapsed === true && n.children) {
6794
+ found = true;
6571
6795
  }
6796
+ });
6797
+ return found;
6798
+ }
6799
+
6800
+ function collapseToggle(d) {
6801
+ if (!collapseAllowed() || !d.children || !d.parent || !d.parent.parent) {
6802
+ return;
6572
6803
  }
6573
- return w;
6804
+ d.collapsed = !d.collapsed;
6805
+ calcMaxExtLabel();
6806
+ refreshVisualizations(false); // the legends describe the tips on screen
6807
+ scheduleUpdate(null, 0);
6574
6808
  }
6575
6809
 
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;
6810
+ function uncollapse(node) {
6811
+ forester.preOrderTraversal(node, function (n) {
6812
+ n.collapsed = false;
6813
+ });
6814
+ calcMaxExtLabel();
6815
+ refreshVisualizations(false);
6816
+ scheduleUpdate(null, 0);
6581
6817
  }
6582
6818
 
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;
6819
+ function uncollapseAll() {
6820
+ if (_root) {
6821
+ uncollapse(_root);
6822
+ }
6587
6823
  }
6588
6824
 
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;
6825
+ function clearCollapsedFlags(node) {
6826
+ forester.preOrderTraversal(node, function (n) {
6827
+ n.collapsed = false;
6828
+ });
6829
+ }
6830
+
6831
+ // Runs a re-rooting without turning a collapsed clade inside out. A clade
6832
+ // on the path to the new root has its parent turned into a child, so its
6833
+ // flag would go on hiding a different set of tips -- with a midpoint
6834
+ // inside a collapsed clade, the whole rest of the tree. Such a clade
6835
+ // opens; every other clade keeps its tips and stays collapsed.
6836
+ function rerootKeepingCollapse(reroot) {
6837
+ let flagged = [];
6838
+ forester.preOrderTraversalAll(_root_const || _root, function (n) {
6839
+ if (n.collapsed === true && n.children) {
6840
+ flagged.push({node: n, tips: forester.getAllExternalNodes(n)});
6841
+ }
6842
+ });
6843
+ reroot();
6844
+ let opened = false;
6845
+ flagged.forEach(function (f) {
6846
+ let now = f.node.children ? forester.getAllExternalNodes(f.node) : [];
6847
+ let kept = new Set(f.tips);
6848
+ if (now.length !== f.tips.length || !now.every(function (t) { return kept.has(t); })) {
6849
+ f.node.collapsed = false;
6850
+ opened = true;
6851
+ }
6852
+ });
6853
+ if (opened) {
6854
+ calcMaxExtLabel();
6855
+ refreshVisualizations(false);
6595
6856
  }
6596
- return DOMAIN_TRACK_START_GAP + domainTrackWidth() + DOMAIN_TRACK_END_GAP;
6597
6857
  }
6598
6858
 
6599
- function domainRadialExtent() {
6600
- if (!domainsShown() || !radialDisplay()) {
6601
- return 0;
6859
+ // What a collapsed clade is marked with for the tips it hides: the colour
6860
+ // of its first search hit, else the selection colour when a tip is
6861
+ // selected, else null. The wedge, the overview dot and the dimming all
6862
+ // read it, so they always agree.
6863
+ function collapsedMarkColor(d) {
6864
+ let tips = forester.getAllExternalNodes(d);
6865
+ let hit = tips.filter(isNodeFound)[0];
6866
+ if (hit) {
6867
+ return getFoundColor(hit);
6602
6868
  }
6603
- return DOMAIN_RADIAL_GAP + domainEffectiveWidth() + DOMAIN_TRACK_END_GAP;
6869
+ return tips.some(function (t) { return _selectedNodes.has(t); }) ? _state.selectedColorDefault : null;
6604
6870
  }
6605
6871
 
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;
6872
+ // How tall a collapsed clade draws, in rows: 1 for a pair, growing with
6873
+ // the logarithm of the tip count, capped so a huge clade does not eat
6874
+ // the screen. The cluster layout's separation turns it into space.
6875
+ function collapsedRows(d) {
6876
+ let tips = forester.getAllExternalNodes(d).length;
6877
+ return Math.max(1, Math.min(2.5, 1 + (Math.log2(Math.max(2, tips)) / 4)));
6878
+ }
6879
+
6880
+ // The clade's name: the node's own; else the one Color-by value at least
6881
+ // 95% of its tips share (a clade named "Bovine" while you look at
6882
+ // hosts); else the tips' common name prefix, trailing separators
6883
+ // dropped; else nothing.
6884
+ function collapsedName(d) {
6885
+ if (d.name && String(d.name).trim().length > 0) {
6886
+ return String(d.name).trim();
6887
+ }
6888
+ let tips = forester.getAllExternalNodes(d);
6889
+ let vis = currentColorVis();
6890
+ if (vis && tips.length > 0) {
6891
+ let counts = Object.create(null);
6892
+ tips.forEach(function (t) {
6893
+ let v = forester.visualizationNodeValue(t, vis);
6894
+ if (v !== null && v !== undefined) {
6895
+ counts[v] = (counts[v] || 0) + 1;
6896
+ }
6618
6897
  });
6619
- _domain.next = names.length;
6898
+ let best = null;
6899
+ Object.keys(counts).forEach(function (v) {
6900
+ if (best === null || counts[v] > counts[best]) {
6901
+ best = v;
6902
+ }
6903
+ });
6904
+ if (best !== null && counts[best] >= 0.95 * tips.length) {
6905
+ return best;
6906
+ }
6620
6907
  }
6621
- return _domain.palette;
6908
+ let prefix = forester.commonNamePrefix(d, forester.nodeLabelProperty(_treeData));
6909
+ prefix = prefix ? prefix.replace(/[\s_\-.:|/]+$/, '') : '';
6910
+ return prefix.length >= 2 ? prefix : '';
6622
6911
  }
6623
6912
 
6624
- function domainColor(name) {
6625
- if (!name) {
6626
- return forester.DOMAIN_UNNAMED_COLOR;
6627
- }
6628
- let palette = domainPalette();
6629
- if (palette[name] === undefined) {
6630
- palette[name] = _domain.next++;
6913
+ // Whether any tip hidden in a collapsed clade is a hit or selected -- the
6914
+ // clade then stays undimmed, as that tip would be if it were drawn.
6915
+ function collapsedHoldsHighlight(d) {
6916
+ return collapsedMarkColor(d) !== null;
6917
+ }
6918
+
6919
+ // Whether every tip a collapsed clade hides is marked: all of them search
6920
+ // hits when the clade holds any hit, else all of them selected. Such a
6921
+ // wedge is filled in the mark colour and its label set in it, bold.
6922
+ function collapsedFullyMarked(d) {
6923
+ let hits = collapsedFoundCounts(d);
6924
+ if (hits.found > 0) {
6925
+ return hits.found === hits.total;
6631
6926
  }
6632
- return forester.domainQualitativeColor(palette[name]);
6927
+ let tips = forester.getAllExternalNodes(d);
6928
+ return tips.length > 0 && tips.every(function (t) { return _selectedNodes.has(t); });
6633
6929
  }
6634
6930
 
6635
- function domainBoxHeight(pitch) {
6636
- return Math.max(DOMAIN_BOX_MIN_H, Math.min(DOMAIN_BOX_MAX_H, Math.round(pitch)));
6931
+ function collapsedFoundCounts(d) {
6932
+ let found = 0;
6933
+ let total = 0;
6934
+ forester.preOrderTraversal(d, function (n) {
6935
+ if (!n.children) {
6936
+ total++;
6937
+ if (isNodeFound(n)) {
6938
+ found++;
6939
+ }
6940
+ }
6941
+ });
6942
+ return {found: found, total: total};
6637
6943
  }
6638
6944
 
6639
- function drawDomainArchitectures() {
6640
- if (!_svgGroup) {
6641
- return;
6945
+ function collapsedLabel(d) {
6946
+ let name = collapsedName(d);
6947
+ let total = forester.getAllExternalNodes(d).length;
6948
+ let text = (name ? name + ' · ' : '') + total + (total === 1 ? ' tip' : ' tips');
6949
+ let hits = collapsedFoundCounts(d);
6950
+ if (hits.found > 0) {
6951
+ text += ' [' + hits.found + '/' + hits.total + ']';
6642
6952
  }
6643
- _svgGroup.selectAll('g.aptx-domains').remove();
6644
- if (!domainsShown() || !_root) {
6645
- return;
6953
+ return text;
6954
+ }
6955
+
6956
+ // The colour most of the clade's tips wear under the current Color-by;
6957
+ // the branch colour when nothing is coloured.
6958
+ function collapsedColor(d) {
6959
+ let vis = currentColorVis();
6960
+ if (!vis || !_state.showVisualizations) {
6961
+ return _state.branchColorDefault;
6646
6962
  }
6647
- let tips = forester.getAllExternalNodes(_root).filter(function (d) {
6648
- return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
6963
+ let counts = Object.create(null);
6964
+ let best = null;
6965
+ forester.getAllExternalNodes(d).forEach(function (t) {
6966
+ let c = makeLabelColor(t);
6967
+ if (c === _state.labelColorDefault) {
6968
+ return; // an uncoloured tip casts no vote
6969
+ }
6970
+ counts[c] = (counts[c] || 0) + 1;
6971
+ if (best === null || counts[c] > counts[best]) {
6972
+ best = c;
6973
+ }
6649
6974
  });
6650
- let f = domainScale();
6651
- if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
6652
- return;
6975
+ return best || _state.branchColorDefault;
6976
+ }
6977
+
6978
+ // The wedge's reach: the clade's nearest and farthest tips, in the
6979
+ // layout's x. One edge runs to the nearest tip and the other to the
6980
+ // farthest, so the shape itself shows how uneven the clade's branch
6981
+ // lengths are (as iTOL draws it). Christian chose this over the
6982
+ // desktop's symmetric triangle on an average base (2026-09-14). A
6983
+ // phylogram measures the tips; a cladogram, where every leaf sits on
6984
+ // the tip column, gives the wedge one depth step.
6985
+ function collapsedReach(d) {
6986
+ if (_state.phylogram && _yScale) {
6987
+ let min = Infinity;
6988
+ let max = -Infinity;
6989
+ forester.preOrderTraversal(d, function (t) {
6990
+ if (!t.children && typeof t.distToRoot === 'number') {
6991
+ if (t.distToRoot < min) { min = t.distToRoot; }
6992
+ if (t.distToRoot > max) { max = t.distToRoot; }
6993
+ }
6994
+ });
6995
+ if (isFinite(min)) {
6996
+ return [Math.max(d.y + 2, _yScale(min)), Math.max(d.y + 2, _yScale(max))];
6997
+ }
6653
6998
  }
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);
6999
+ return [d.y + _cladogramUnit, d.y + _cladogramUnit];
7000
+ }
7001
+
7002
+ let _cladogramUnit = 0; // one depth step of the last cladogram layout, px
7003
+
7004
+ // Draws every collapsed clade in the current node selection: a wedge
7005
+ // path and a label as children of the node's own group, so they move
7006
+ // with it, take its clicks and menu, and ride into exports.
7007
+ function drawCollapsedClades(node) {
7008
+ let wedge = node.selectAll('path.aptx-collapsed').data(function (d) {
7009
+ return isCollapsed(d) ? [d] : [];
7010
+ });
7011
+ wedge.exit().remove();
7012
+ wedge = wedge.enter().append('path').attr('class', 'aptx-collapsed').merge(wedge);
7013
+ let label = node.selectAll('text.aptx-collapsed-label').data(function (d) {
7014
+ return isCollapsed(d) ? [d] : [];
7015
+ });
7016
+ label.exit().remove();
7017
+ label = label.enter().append('text').attr('class', 'aptx-collapsed-label').merge(label);
7018
+
7019
+ let fs = _state.externalNodeFontSize;
7020
+ wedge.each(function (d) {
7021
+ let color = collapsedColor(d);
7022
+ let mark = collapsedMarkColor(d);
7023
+ let full = collapsedFullyMarked(d);
7024
+ let h = Math.max(6, collapsedRows(d) * _rowUnit * 0.82);
7025
+ let reach = collapsedReach(d);
7026
+ let path;
7027
+ if (radialDisplay()) {
7028
+ // in the rotated frame: +x along the spoke from the node (the
7029
+ // group's origin, already at the node's radius) -- distances,
7030
+ // never absolute radii, or the wedge lands a whole radius
7031
+ // further out than its label
7032
+ let r0 = radialRadius(d.y);
7033
+ let dr1 = radialRadius(reach[0]) - r0;
7034
+ let dr2 = radialRadius(reach[1]) - r0;
7035
+ path = 'M0,0 L' + dr1 + ',' + (-h / 2) + ' L' + dr2 + ',' + (h / 2) + ' Z';
7036
+ } else {
7037
+ let dx1 = reach[0] - d.y;
7038
+ let dx2 = reach[1] - d.y;
7039
+ path = 'M0,0 L' + dx1 + ',' + (-h / 2) + ' L' + dx2 + ',' + (h / 2) + ' Z';
7040
+ }
7041
+ d3.select(this)
7042
+ .attr('d', path)
7043
+ .attr('transform', radialDisplay() ? 'rotate(' + labelAngleDeg(d) + ')' : null)
7044
+ .style('fill', full ? mark : color)
7045
+ .style('fill-opacity', full ? 0.45 : 0.22)
7046
+ .style('stroke', mark || color)
7047
+ .style('stroke-width', mark ? 1.5 : 1)
7048
+ .style('stroke-opacity', 0.9)
7049
+ .style('stroke-linejoin', 'round');
7050
+ });
7051
+ label.each(function (d) {
7052
+ let far = collapsedReach(d)[1]; // the label clears the farthest tip
7053
+ let allFound = collapsedFullyMarked(d);
7054
+ let ink = allFound ? collapsedMarkColor(d) : _state.labelColorDefault;
7055
+ let t = d3.select(this)
7056
+ .text(collapsedLabel(d))
7057
+ .style('font', (allFound ? '600 ' : '') + fs + 'px ' + FONT_DEFAULTS)
7058
+ .style('fill', ink)
7059
+ .style('pointer-events', 'none');
7060
+ // placed exactly as a tip's label is (update's text.extlabel): on
7061
+ // the common ring in circular, rotated along the spoke or upright;
7062
+ // on the label column in the aligned phylogram; else just past the
7063
+ // wedge, where a tip's label sits past its tip
7064
+ let gap = _state.nodeLabelGap;
7065
+ let flip = radialDisplay() && labelFlip(d);
7066
+ if (_state.circularDisplay && _radial) {
7067
+ t.attr('text-anchor', flip ? 'end' : 'start')
7068
+ .attr('x', flip ? -gap : gap).attr('dy', '0.32em');
7069
+ if (_radialLabelsHorizontal) {
7070
+ let p = radialXY(d.x, d.y);
7071
+ let q = polarXY(radialAngle(d.x), _radial.maxRad);
7072
+ t.attr('transform', 'translate(' + (q[0] - p[0]) + ',' + (q[1] - p[1]) + ')');
7073
+ } else {
7074
+ let off = _radial.maxRad - radialRadius(d.y);
7075
+ t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (flip ? ' rotate(180)' : ''));
7076
+ }
7077
+ } else if (radialDisplay()) {
7078
+ let r = radialRadius(far) - radialRadius(d.y) + gap;
7079
+ t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + r + ',0)' + (flip ? ' rotate(180)' : ''))
7080
+ .attr('text-anchor', flip ? 'end' : 'start')
7081
+ .attr('x', 0).attr('dy', '0.32em');
7082
+ } else {
7083
+ let column = (_state.phylogram && _state.alignPhylogram) ? _w : far;
7084
+ t.attr('transform', null)
7085
+ .attr('text-anchor', 'start')
7086
+ .attr('x', column - d.y + gap)
7087
+ .attr('dy', (0.3 * fs) + 'px');
7088
+ }
7089
+ });
7090
+ }
7091
+
7092
+ // ===================== Shareable views =====================
7093
+ // A view is what a person made of a tree with the panel: the layout and
7094
+ // display type, what is labelled, the colours, the searches, the clade
7095
+ // they switched to, the ones they collapsed, the sizes. It is a plain
7096
+ // object (getViewState), a short "key=value&..." string (encodeViewState
7097
+ // / decodeViewState) that a page keeps in its URL hash, and a launch
7098
+ // config key (view) that opens a tree straight into it; onViewChange
7099
+ // tells the page whenever it changes. A node is named by its viewId, the
7100
+ // launch-time preorder index, which ladderizing and re-rooting leave
7101
+ // alone. NOT in a view: zoom and pan (a view opens fitted), the legend's
7102
+ // position, the node selection.
7103
+
7104
+ const VIEW_SHOW_FLAGS = [
7105
+ ['name', NODE_NAME_CB, 'showNodeName'],
7106
+ ['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
7107
+ ['sequence', SEQUENCE_CB, 'showSequence'],
7108
+ ['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
7109
+ ['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
7110
+ ['external', EXTERNAL_LABEL_CB, 'showExternalLabels'],
7111
+ ['internal', INTERNAL_LABEL_CB, 'showInternalLabels'],
7112
+ ['nodeEvents', NODE_EVENTS_CB, 'showNodeEvents'],
7113
+ ['branchEvents', BRANCH_EVENTS_CB, 'showBranchEvents'],
7114
+ ['supportDots', SUPPORT_DOTS_CB, 'showSupportDots'],
7115
+ ['shortNames', SHORTEN_NODE_NAME_CB, 'shortenNodeNames'],
7116
+ ['autoHide', DYNAHIDE_CB, 'dynahide'],
7117
+ ['visualizations', VIS_CB, 'showVisualizations'],
7118
+ ['visualStyles', VISUAL_STYLES_CB, 'useVisualStyles']
7119
+ ];
7120
+ const RADIAL_ROTATION_STEP = Math.PI / 32; // one press of the rotate buttons
7121
+
7122
+ // Launch-time preorder ids, given once per tree object: a tree shown
7123
+ // again keeps them, so a shared view still names the same nodes.
7124
+ function assignViewIds(phy) {
7125
+ if (phy.viewId !== undefined) {
7126
+ return;
7127
+ }
7128
+ let i = 0;
7129
+ forester.preOrderTraversalAll(phy, function (n) {
7130
+ n.viewId = i++;
7131
+ });
7132
+ }
7133
+
7134
+ function nodeByViewId(id) {
7135
+ let found = null;
7136
+ forester.preOrderTraversalAll(_root_const, function (n) {
7137
+ if (found === null && n.viewId === id) {
7138
+ found = n;
7139
+ }
7140
+ });
7141
+ return found;
7142
+ }
7143
+
7144
+ // The config's custom label checkboxes (nodeLabels) that are offered
7145
+ function customLabelEntries() {
7146
+ let out = [];
7147
+ if (_nodeLabels) {
7148
+ Object.keys(_nodeLabels).forEach(function (key) {
7149
+ let v = _nodeLabels[key];
7150
+ if (v && v.label && v.showButton === true && v.propertyRef && v.description) {
7151
+ out.push({key: key, entry: v});
7152
+ }
7153
+ });
7154
+ }
7155
+ return out;
7156
+ }
7157
+
7158
+ function searchStateOf(idx) {
7159
+ let spec = currentSearchSpec(idx);
7160
+ let value = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
7161
+ if (value.trim().length === 0) {
7162
+ return null;
7163
+ }
7164
+ let q = {field: spec.field.label, mode: spec.mode, value: value};
7165
+ if (spec.mode === 'range' && spec.value2) {
7166
+ q.value2 = String(spec.value2);
7167
+ }
7168
+ return q;
7169
+ }
7170
+
7171
+ function getViewState() {
7172
+ let s = {};
7173
+ if (_treeIndex > 0) {
7174
+ s.tree = _treeIndex;
7175
+ }
7176
+ s.layout = _state.unrootedDisplay ? 'unrooted' : (_state.circularDisplay ? 'circular' : 'rectangular');
7177
+ s.display = _state.phylogram ? (_state.alignPhylogram ? 'aligned' : 'phylogram') : 'cladogram';
7178
+ if (_viewOps.root) {
7179
+ s.root = _viewOps.root;
7180
+ }
7181
+ if (_viewOps.order) {
7182
+ s.order = _viewOps.order;
7183
+ }
7184
+ if (_in_subtree && _root && _root.children && _root.children[0] && _root.children[0].viewId !== undefined) {
7185
+ s.subtree = _root.children[0].viewId;
7186
+ }
7187
+ let collapsed = [];
7188
+ forester.preOrderTraversalAll(_root_const, function (n) {
7189
+ if (n.collapsed && n.viewId !== undefined) {
7190
+ collapsed.push(n.viewId);
7191
+ }
7192
+ });
7193
+ if (collapsed.length > 0) {
7194
+ s.collapsed = collapsed;
7195
+ }
7196
+ s.colorBy = (_vis && _vis.colorId) || 'none';
7197
+ if (_vis && _vis.shapeId) {
7198
+ s.shapeBy = _vis.shapeId;
7199
+ }
7200
+ s.show = VIEW_SHOW_FLAGS.filter(function (f) {
7201
+ return _state[f[2]] === true;
7202
+ }).map(function (f) {
7203
+ return f[0];
7204
+ });
7205
+ customLabelEntries().forEach(function (c) {
7206
+ if (c.entry.selected === true) {
7207
+ s.show.push('custom:' + c.key);
7208
+ }
7209
+ });
7210
+ s.font = _state.externalNodeFontSize;
7211
+ s.node = _state.nodeSizeDefault;
7212
+ s.branch = _state.branchWidthDefault;
7213
+ let rotation = Math.round(_radialRotation / RADIAL_ROTATION_STEP);
7214
+ if (rotation !== 0) {
7215
+ s.rotation = rotation;
7216
+ }
7217
+ if (_radialLabelsHorizontal) {
7218
+ s.horizontalLabels = true;
7219
+ }
7220
+ if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
7221
+ s.msa = _state.showMsa === true;
7222
+ }
7223
+ if (_basicTreeProperties.domainArchitectures === true) {
7224
+ s.domains = _state.showDomainArchitectures === true;
7225
+ s.domainLabels = _state.domainLabels;
7226
+ s.domainGlow = _state.domainGlow === true;
7227
+ s.domainEvalue = _state.domainEvalueExponent;
7228
+ }
7229
+ if (_timeInfo && _timeInfo.type) {
7230
+ s.timeAxis = _state.showTimeAxis === true;
7231
+ s.timeGrid = _state.timeAxisGrid === true;
7232
+ }
7233
+ let a = searchStateOf(0);
7234
+ let b = searchStateOf(1);
7235
+ if (a) {
7236
+ s.searchA = a;
7237
+ }
7238
+ if (b) {
7239
+ s.searchB = b;
7240
+ }
7241
+ if (a && b) {
7242
+ let combine = getValue(SEARCH_COMBINE_SELECT);
7243
+ if (combine === 'and' || combine === 'or') {
7244
+ s.combine = combine;
7245
+ }
7246
+ }
7247
+ if (_state.searchIsCaseSensitive === true) {
7248
+ s.matchCase = true;
7249
+ }
7250
+ if (_state.searchNegateResult === true) {
7251
+ s.inverse = true;
7252
+ }
7253
+ return s;
7254
+ }
7255
+
7256
+ // Opens a view: the tree operations first (midpoint rooting, the
7257
+ // ladderize direction), then the clade and the collapsed clades by node
7258
+ // id, then everything the panel shows. A key the view leaves out keeps
7259
+ // its current value, except the two searches, which an empty view
7260
+ // clears (a view with no search has none). `initial` is the launch --
7261
+ // initialize() draws afterwards; otherwise the tree is redrawn and
7262
+ // fitted. Anything not understood, a hand-edited hash or a view of some
7263
+ // other tree, is skipped rather than guessed at.
7264
+ function applyViewState(s, initial) {
7265
+ if (!s || typeof s !== 'object') {
7266
+ return;
7267
+ }
7268
+ if (!initial && Number.isInteger(s.tree) && s.tree !== _treeIndex && s.tree >= 0 && s.tree < _trees.length) {
7269
+ let cfg = Object.assign({}, _launchConfig || {});
7270
+ cfg.view = s;
7271
+ launchInto(_container, _trees, s.tree, cfg);
7272
+ return;
7273
+ }
7274
+ if (s.root === 'midpoint' && _viewOps.root !== 'midpoint'
7275
+ && (_treeData.rerootable === undefined || _treeData.rerootable === true)) {
7276
+ rerootKeepingCollapse(function () {
7277
+ forester.midpointRoot(_root_const);
7278
+ });
7279
+ _viewOps.root = 'midpoint';
7280
+ }
7281
+ if (s.order === 'asc' || s.order === 'desc') {
7282
+ ladderizeSubtree(_root_const, s.order === 'asc', false);
7283
+ _viewOps.order = s.order;
7284
+ if (!_treeFn.visData) {
7285
+ _treeFn.visData = {};
7286
+ }
7287
+ _treeFn.visData.ladderize = s.order !== 'asc'; // the next press goes the other way
7288
+ }
7289
+ _root = _root_const;
7290
+ _in_subtree = false;
7291
+ clearCollapsedFlags(_root_const);
7292
+ if (Number.isInteger(s.subtree)) {
7293
+ let node = nodeByViewId(s.subtree);
7294
+ if (node && node.children && node.parent && node.parent.parent) {
7295
+ _in_subtree = true;
7296
+ _root = {children: [node], x: 0, x0: 0, y: 0, y0: 0};
7297
+ }
7298
+ }
7299
+ if (Array.isArray(s.collapsed) && s.collapsed.length > 0) {
7300
+ let ids = new Set(s.collapsed);
7301
+ forester.preOrderTraversalAll(_root_const, function (n) {
7302
+ if (n.children && n.parent && n.parent.parent && ids.has(n.viewId)) {
7303
+ n.collapsed = true;
7304
+ }
7305
+ });
7306
+ }
7307
+ _basicTreeProperties = forester.collectBasicTreeProperties(_root);
7308
+
7309
+ if (s.layout === 'rectangular' || s.layout === 'circular' || s.layout === 'unrooted') {
7310
+ _state.circularDisplay = s.layout === 'circular';
7311
+ _state.unrootedDisplay = s.layout === 'unrooted';
7312
+ }
7313
+ if (s.display === 'phylogram' || s.display === 'aligned' || s.display === 'cladogram') {
7314
+ let measured = _basicTreeProperties.branchLengths === true;
7315
+ _state.phylogram = measured && s.display !== 'cladogram';
7316
+ _state.alignPhylogram = measured && s.display === 'aligned' && !_state.unrootedDisplay;
7317
+ }
7318
+ if (Array.isArray(s.show)) {
7319
+ let on = new Set(s.show);
7320
+ VIEW_SHOW_FLAGS.forEach(function (f) {
7321
+ _state[f[2]] = on.has(f[0]);
7322
+ });
7323
+ customLabelEntries().forEach(function (c) {
7324
+ c.entry.selected = on.has('custom:' + c.key);
7325
+ });
7326
+ }
7327
+ if (isFinite(s.font)) {
7328
+ setFontSizes(Number(s.font));
7329
+ }
7330
+ if (isFinite(s.node)) {
7331
+ _state.nodeSizeDefault = Math.min(NODE_SIZE_MAX, Math.max(NODE_SIZE_MIN, Number(s.node)));
7332
+ }
7333
+ if (isFinite(s.branch)) {
7334
+ _state.branchWidthDefault = Math.min(BRANCH_WIDTH_MAX, Math.max(BRANCH_WIDTH_MIN, Number(s.branch)));
7335
+ }
7336
+ if (Number.isInteger(s.rotation)) {
7337
+ _radialRotation = (s.rotation * RADIAL_ROTATION_STEP) % (2 * Math.PI);
7338
+ }
7339
+ if (typeof s.horizontalLabels === 'boolean') {
7340
+ _radialLabelsHorizontal = s.horizontalLabels;
7341
+ }
7342
+ if (typeof s.msa === 'boolean') {
7343
+ _state.showMsa = s.msa;
7344
+ }
7345
+ if (typeof s.domains === 'boolean') {
7346
+ _state.showDomainArchitectures = s.domains;
7347
+ }
7348
+ if (s.domainLabels === 'none' || s.domainLabels === 'domains' || s.domainLabels === 'legend') {
7349
+ _state.domainLabels = s.domainLabels;
7350
+ }
7351
+ if (typeof s.domainGlow === 'boolean') {
7352
+ _state.domainGlow = s.domainGlow;
7353
+ }
7354
+ if (Number.isInteger(s.domainEvalue) && s.domainEvalue >= forester.DOMAIN_EVALUE_EXPONENT_MIN
7355
+ && s.domainEvalue <= forester.DOMAIN_EVALUE_EXPONENT_MAX && s.domainEvalue !== _state.domainEvalueExponent) {
7356
+ _state.domainEvalueExponent = s.domainEvalue;
7357
+ if (_domain) {
7358
+ _domain.palette = null; // the drawn set changed: the palette is dealt again
7359
+ }
7360
+ }
7361
+ if (typeof s.timeAxis === 'boolean') {
7362
+ _state.showTimeAxis = s.timeAxis;
7363
+ }
7364
+ if (typeof s.timeGrid === 'boolean') {
7365
+ _state.timeAxisGrid = s.timeGrid;
7366
+ }
7367
+ if (!radialDisplay()) {
7368
+ _radialLabelsHorizontal = false;
7369
+ } else if (_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
7370
+ _radialLabelsHorizontal = false; // the domain boxes ride the spokes, so the labels must too
7371
+ }
7372
+ if (_vis) {
7373
+ if (s.colorBy === 'none') {
7374
+ _vis.colorId = null;
7375
+ } else if (s.colorBy && _vis.byId[s.colorBy]) {
7376
+ _vis.colorId = s.colorBy;
7377
+ _state.showVisualizations = true;
7378
+ }
7379
+ if (s.shapeBy === 'none') {
7380
+ _vis.shapeId = null;
7381
+ } else if (s.shapeBy && _vis.byId[s.shapeBy] && _vis.byId[s.shapeBy].shape) {
7382
+ _vis.shapeId = s.shapeBy;
7383
+ _state.showVisualizations = true;
7384
+ }
7385
+ }
7386
+ _state.searchIsCaseSensitive = s.matchCase === true;
7387
+ _state.searchNegateResult = s.inverse === true;
7388
+ applySearchState(0, s.searchA);
7389
+ applySearchState(1, s.searchB);
7390
+ setValue(SEARCH_COMBINE_SELECT,
7391
+ (s.searchA && s.searchB && (s.combine === 'and' || s.combine === 'or')) ? s.combine : 'independent');
7392
+
7393
+ refreshVisualizations(false);
7394
+ resetVis();
7395
+ syncViewControls();
7396
+ if (!initial) {
7397
+ runSearches();
7398
+ zoomToFit();
7399
+ }
7400
+ }
7401
+
7402
+ // One search box from its view state: the field by its label, the mode
7403
+ // when the field offers it, the value(s); nothing given clears the box.
7404
+ function applySearchState(idx, q) {
7405
+ let fieldSel = byId(idx === 0 ? SEARCH_FIELD_SELECT_0 : SEARCH_FIELD_SELECT_1);
7406
+ let modeSel = byId(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
7407
+ if (!fieldSel || !modeSel) {
7408
+ return;
7409
+ }
7410
+ let want = (q && typeof q === 'object') ? q : {};
7411
+ let fi = want.field ? _searchFields.findIndex(function (f) {
7412
+ return f.label === want.field;
7413
+ }) : -1;
7414
+ fieldSel.value = String(fi >= 0 ? fi : 0);
7415
+ populateSearchModeMenu(idx);
7416
+ if (want.mode && Array.from(modeSel.options).some(function (o) { return o.value === want.mode; })) {
7417
+ modeSel.value = want.mode;
7418
+ }
7419
+ setValue(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1, want.value ? String(want.value) : '');
7420
+ setValue(idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1, want.value2 ? String(want.value2) : '');
7421
+ updateSearchValue2Visibility(idx);
7422
+ updateSearchAutocomplete(idx);
7423
+ if (idx === 1 && want.value) {
7424
+ showSearchB();
7425
+ }
7426
+ }
7427
+
7428
+ // The panel's controls from the state, after applyViewState
7429
+ function syncViewControls() {
7430
+ setDisplayTypeButtons();
7431
+ syncZoomRowButtons();
7432
+ VIEW_SHOW_FLAGS.forEach(function (f) {
7433
+ setCheckboxValue(f[1], _state[f[2]] === true);
7434
+ });
7435
+ customLabelEntries().forEach(function (c) {
7436
+ if (c.entry.cb_id) {
7437
+ setCheckboxValue(c.entry.cb_id, c.entry.selected === true);
7438
+ }
7439
+ });
7440
+ setCheckboxValue(MSA_CB, _state.showMsa === true);
7441
+ setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
7442
+ setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
7443
+ setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
7444
+ setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
7445
+ syncDomainControls();
7446
+ setSliderValue(FONT_SIZE_SLIDER, _state.externalNodeFontSize);
7447
+ setSliderValue(NODE_SIZE_SLIDER, _state.nodeSizeDefault);
7448
+ setSliderValue(BRANCH_WIDTH_SLIDER, _state.branchWidthDefault);
7449
+ setSelectMenuValue(LABEL_COLOR_SELECT_MENU, (_vis && _vis.colorId) || DEFAULT);
7450
+ setSelectMenuValue(NODE_SHAPE_SELECT_MENU, (_vis && _vis.shapeId) || DEFAULT);
7451
+ setCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB, _state.searchIsCaseSensitive === true);
7452
+ setCheckboxValue(SEARCH_OPTIONS_NEGATE_RES_CB, _state.searchNegateResult === true);
7453
+ syncLadderizeGlyph();
7454
+ }
7455
+
7456
+ // The ladderize glyph shows the direction the NEXT press will use.
7457
+ function syncLadderizeGlyph() {
7458
+ let orderBtn = byId(LADDERIZE_BUTTON);
7459
+ if (orderBtn) {
7460
+ let next = !(_treeFn && _treeFn.visData && _treeFn.visData.ladderize === false);
7461
+ orderBtn.innerHTML = makeGlyph(next ? 'ladderize_asc' : 'ladderize_desc');
7462
+ }
7463
+ }
7464
+
7465
+ // onViewChange: once per settled redraw, only when the view actually
7466
+ // changed. The launch's own view is recorded and not reported -- the
7467
+ // page put the tree up and knows what it asked for -- but a relaunch
7468
+ // that changed it (the tree picker) is reported like any change.
7469
+ function noteViewChange() {
7470
+ if (_initializing || !_settings || typeof _settings.onViewChange !== 'function' || _viewNotifyPending) {
7471
+ return;
7472
+ }
7473
+ _viewNotifyPending = true;
7474
+ let seq = _launchSeq;
7475
+ setTimeout(function () {
7476
+ _viewNotifyPending = false;
7477
+ if (seq !== _launchSeq || !_root || !_container) {
7478
+ return;
7479
+ }
7480
+ reportView(false);
7481
+ }, 0);
7482
+ }
7483
+
7484
+ function settleViewNotification() {
7485
+ if (_settings && typeof _settings.onViewChange === 'function') {
7486
+ reportView(_lastViewEncoded === undefined);
7487
+ }
7488
+ }
7489
+
7490
+ function reportView(silent) {
7491
+ let state = getViewState();
7492
+ let encoded = archaeopteryx.encodeViewState(state);
7493
+ if (encoded === _lastViewEncoded) {
7494
+ return;
7495
+ }
7496
+ _lastViewEncoded = encoded;
7497
+ if (silent) {
7498
+ return;
7499
+ }
7500
+ try {
7501
+ _settings.onViewChange(state, encoded);
7502
+ } catch (e) {
7503
+ console.error(ERROR + 'onViewChange threw: ' + (e && e.message ? e.message : e));
7504
+ }
7505
+ }
7506
+
7507
+ // The string form: "key=value&key=value", the values URL-encoded but
7508
+ // with ':' and ',' kept readable (refs and lists) and spaces as '+'.
7509
+ // Lists are comma-joined, booleans 1 / 0, the two searches flattened
7510
+ // to a / af / am / a2 and b / bf / bm / b2. Made for a URL hash, where
7511
+ // none of these characters needs escaping.
7512
+ const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine'];
7513
+ const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
7514
+ const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
7515
+ const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'domains', 'domainGlow', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
7516
+ const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
7517
+
7518
+ function encodeViewValue(v) {
7519
+ return encodeURIComponent(String(v)).replace(/%3A/gi, ':').replace(/%2C/gi, ',').replace(/%20/g, '+');
7520
+ }
7521
+
7522
+ function decodeViewValue(v) {
7523
+ try {
7524
+ return decodeURIComponent(String(v).replace(/\+/g, '%20'));
7525
+ } catch {
7526
+ return '';
7527
+ }
7528
+ }
7529
+
7530
+ archaeopteryx.encodeViewState = function (state) {
7531
+ if (!state || typeof state !== 'object') {
7532
+ return '';
7533
+ }
7534
+ let parts = [];
7535
+ let put = function (k, v) {
7536
+ if (v !== undefined && v !== null && v !== '') {
7537
+ parts.push(k + '=' + encodeViewValue(v));
7538
+ }
7539
+ };
7540
+ VIEW_INT_KEYS.concat(VIEW_TEXT_KEYS, VIEW_NUMBER_KEYS).forEach(function (k) {
7541
+ put(k, state[k]);
7542
+ });
7543
+ if (Array.isArray(state.show)) {
7544
+ parts.push('show=' + state.show.map(encodeViewValue).join(',')); // an empty list is a fact: nothing shown
7545
+ }
7546
+ if (Array.isArray(state.collapsed) && state.collapsed.length > 0) {
7547
+ put('collapsed', state.collapsed.join(','));
7548
+ }
7549
+ VIEW_BOOL_KEYS.forEach(function (k) {
7550
+ if (typeof state[k] === 'boolean') {
7551
+ put(k, state[k] ? 1 : 0);
7552
+ }
7553
+ });
7554
+ VIEW_SEARCH_KEYS.forEach(function (sk) {
7555
+ let q = state[sk[0]];
7556
+ if (q && typeof q === 'object' && q.value) {
7557
+ put(sk[1], q.value);
7558
+ put(sk[1] + 'f', q.field);
7559
+ put(sk[1] + 'm', q.mode);
7560
+ put(sk[1] + '2', q.value2);
7561
+ }
7562
+ });
7563
+ return parts.join('&');
7564
+ };
7565
+
7566
+ archaeopteryx.decodeViewState = function (text) {
7567
+ if (text === undefined || text === null) {
7568
+ return null;
7569
+ }
7570
+ let s = String(text).replace(/^#/, '');
7571
+ if (s.length === 0) {
7572
+ return null;
7573
+ }
7574
+ let raw = {};
7575
+ s.split('&').forEach(function (pair) {
7576
+ let eq = pair.indexOf('=');
7577
+ if (eq > 0) {
7578
+ raw[decodeViewValue(pair.substring(0, eq))] = decodeViewValue(pair.substring(eq + 1));
7579
+ }
7580
+ });
7581
+ let state = {};
7582
+ VIEW_TEXT_KEYS.forEach(function (k) {
7583
+ if (raw[k] !== undefined && raw[k] !== '') {
7584
+ state[k] = raw[k];
7585
+ }
7586
+ });
7587
+ VIEW_INT_KEYS.forEach(function (k) {
7588
+ if (raw[k] !== undefined && /^-?\d+$/.test(raw[k])) {
7589
+ state[k] = parseInt(raw[k], 10);
7590
+ }
7591
+ });
7592
+ VIEW_NUMBER_KEYS.forEach(function (k) {
7593
+ let n = parseFloat(raw[k]);
7594
+ if (isFinite(n)) {
7595
+ state[k] = n;
7596
+ }
7597
+ });
7598
+ VIEW_BOOL_KEYS.forEach(function (k) {
7599
+ if (raw[k] === '1' || raw[k] === '0') {
7600
+ state[k] = raw[k] === '1';
7601
+ }
7602
+ });
7603
+ if (raw.show !== undefined) {
7604
+ state.show = raw.show.split(',').filter(function (x) {
7605
+ return x.length > 0;
7606
+ });
7607
+ }
7608
+ if (raw.collapsed !== undefined) {
7609
+ state.collapsed = raw.collapsed.split(',').filter(function (x) {
7610
+ return /^\d+$/.test(x);
7611
+ }).map(function (x) {
7612
+ return parseInt(x, 10);
7613
+ });
7614
+ }
7615
+ VIEW_SEARCH_KEYS.forEach(function (sk) {
7616
+ let p = sk[1];
7617
+ if (raw[p]) {
7618
+ let q = {value: raw[p]};
7619
+ if (raw[p + 'f']) {
7620
+ q.field = raw[p + 'f'];
7621
+ }
7622
+ if (raw[p + 'm']) {
7623
+ q.mode = raw[p + 'm'];
7624
+ }
7625
+ if (raw[p + '2']) {
7626
+ q.value2 = raw[p + '2'];
7627
+ }
7628
+ state[sk[0]] = q;
7629
+ }
7630
+ });
7631
+ return Object.keys(state).length > 0 ? state : null;
7632
+ };
7633
+
7634
+ // ===================== Keyboard shortcuts =====================
7635
+ // Ctrl on Windows and Linux, Cmd on macOS (Christian, 2026-09-13: "Ctrl/Cmd
7636
+ // combos are better"). The letters follow the desktop where it has the
7637
+ // action (its Alt+O order, Alt+U uncollapse all, Alt+E expand, Cmd+0 fit,
7638
+ // Cmd+G / Cmd+Shift+G next / previous hit); the layout, display type
7639
+ // and time-axis keys are ours. Combos the browser reserves (new tab,
7640
+ // close, tab switching, and on macOS the screenshot keys) are avoided,
7641
+ // and the few browser functions that are overridden (page zoom, find
7642
+ // next) matter little on a page that is one tree. No key for rooting,
7643
+ // by decision. Plain keys stay as they were: Esc / Home reset, O moves
7644
+ // the overview, PageUp / PageDown size the font. Inside a text box only
7645
+ // the combos that cannot interfere with typing fire.
7646
+
7647
+ const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '');
7648
+ const SHORTCUTS_DIALOG = 'aptx_shortcuts';
7649
+
7650
+ // key: what e.key reads (letters lower-case); shift: whether Shift is
7651
+ // held; typing: the combo also fires from inside a text box
7652
+ const SHORTCUTS = [
7653
+ {key: '0', label: '0', what: 'Fit the tree to the window', run: function () { zoomToFit(); }, typing: true},
7654
+ {key: '=', label: '+', aliases: ['+'], what: 'Zoom in', run: function () { zoomInY(); if (!radialDisplay()) { zoomInX(); } }, typing: true},
7655
+ {key: '-', label: '−', aliases: ['_'], what: 'Zoom out', run: function () { zoomOutY(); if (!radialDisplay()) { zoomOutX(); } }, typing: true},
7656
+ {key: 'arrowup', label: '↑', shift: true, what: 'Zoom in vertically', run: function () { zoomInY(); }},
7657
+ {key: 'arrowdown', label: '↓', shift: true, what: 'Zoom out vertically', run: function () { zoomOutY(); }},
7658
+ {key: 'arrowright', label: '→', shift: true, what: 'Zoom in horizontally (circular: rotate)', run: function () { zoomInX(); }},
7659
+ {key: 'arrowleft', label: '←', shift: true, what: 'Zoom out horizontally (circular: rotate back)', run: function () { zoomOutX(); }},
7660
+ {key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
7661
+ {key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
7662
+ {key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
7663
+ {key: 'x', label: 'X', shift: true, what: 'Time axis on / off', run: toggleTimeAxis},
7664
+ {key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
7665
+ {key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
7666
+ {key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
7667
+ {key: 'g', label: 'G', what: 'Next search hit', run: function () { stepToFoundNode(1); }, typing: true},
7668
+ {key: 'g', label: 'G', shift: true, what: 'Previous search hit', run: function () { stepToFoundNode(-1); }, typing: true},
7669
+ {key: ',', label: '<', aliases: ['<'], shift: true, what: 'Previous tree of the file', run: function () { stepTree(-1); }},
7670
+ {key: '.', label: '>', aliases: ['>'], shift: true, what: 'Next tree of the file', run: function () { stepTree(1); }},
7671
+ {key: '/', label: '/', aliases: ['?'], what: 'This list', run: showShortcutsDialog, typing: true}
7672
+ ];
7673
+
7674
+ function shortcutMatches(sc, e) {
7675
+ let k = (e.key || '').toLowerCase();
7676
+ if (k !== sc.key && !(sc.aliases && sc.aliases.indexOf(e.key) >= 0)) {
7677
+ return false;
7678
+ }
7679
+ return !!sc.shift === !!e.shiftKey;
7680
+ }
7681
+
7682
+ // The one document-level handler for the combos: keydown, so the
7683
+ // browser's own action is stopped before it runs.
7684
+ function shortcutKeyDown(e) {
7685
+ let mod = IS_MAC ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey);
7686
+ if (!mod || e.altKey || !_root) {
7687
+ return;
7688
+ }
7689
+ let typing = isTypingTarget(e.target);
7690
+ for (let i = 0; i < SHORTCUTS.length; ++i) {
7691
+ let sc = SHORTCUTS[i];
7692
+ if (shortcutMatches(sc, e)) {
7693
+ if (typing && !sc.typing) {
7694
+ return;
7695
+ }
7696
+ e.preventDefault();
7697
+ sc.run();
7698
+ return;
7699
+ }
7700
+ }
7701
+ }
7702
+
7703
+ function cycleLayout() {
7704
+ let next = _state.unrootedDisplay ? 'rectangular' : (_state.circularDisplay ? 'unrooted' : 'circular');
7705
+ setCheckboxValue(LAYOUT_RECT_BUTTON, next === 'rectangular');
7706
+ setCheckboxValue(LAYOUT_CIRC_BUTTON, next === 'circular');
7707
+ setCheckboxValue(LAYOUT_UNROOTED_BUTTON, next === 'unrooted');
7708
+ layoutButtonClicked();
7709
+ }
7710
+
7711
+ // Phylogram, aligned phylogram, cladogram -- skipping what the tree or
7712
+ // the layout rules out (no branch lengths: cladogram only; unrooted has
7713
+ // no aligned phylogram).
7714
+ function cycleDisplayType() {
7715
+ let measured = _basicTreeProperties && _basicTreeProperties.branchLengths === true;
7716
+ if (!measured) {
7717
+ toCladegram();
7718
+ return;
7719
+ }
7720
+ if (_state.phylogram && !_state.alignPhylogram) {
7721
+ if (_state.unrootedDisplay) {
7722
+ toCladegram();
7723
+ } else {
7724
+ toAlignedPhylogram();
7725
+ }
7726
+ } else if (_state.phylogram) {
7727
+ toCladegram();
7728
+ } else {
7729
+ toPhylogram();
7730
+ }
7731
+ }
7732
+
7733
+ // The Time Axis checkbox's toggle; when the distance / time tree switch
7734
+ // arrives it takes over this key.
7735
+ function toggleTimeAxis() {
7736
+ let cb = byId(TIME_AXIS_CB);
7737
+ if (!cb || cb.disabled) {
7738
+ return;
7739
+ }
7740
+ cb.checked = !cb.checked;
7741
+ timeAxisCbClicked();
7742
+ }
7743
+
7744
+ function focusSearch() {
7745
+ let f = byId(SEARCH_FIELD_0);
7746
+ if (f) {
7747
+ f.focus();
7748
+ f.select();
7749
+ }
7750
+ }
7751
+
7752
+ function stepTree(delta) {
7753
+ let i = _treeIndex + delta;
7754
+ if (i >= 0 && i < _trees.length) {
7755
+ showTree(i);
7756
+ }
7757
+ }
7758
+
7759
+ function shortcutLabel(sc) {
7760
+ let parts = [];
7761
+ if (IS_MAC) {
7762
+ parts.push('⌘');
7763
+ if (sc.shift) {
7764
+ parts.push('⇧');
7765
+ }
7766
+ } else {
7767
+ parts.push('Ctrl');
7768
+ if (sc.shift) {
7769
+ parts.push('Shift');
7770
+ }
7771
+ }
7772
+ parts.push(sc.label);
7773
+ return parts;
7774
+ }
7775
+
7776
+ function keyChips(parts) {
7777
+ let holder = document.createElement('span');
7778
+ holder.className = 'aptx-keys';
7779
+ parts.forEach(function (p) {
7780
+ let kbd = document.createElement('kbd');
7781
+ kbd.className = 'aptx-kbd';
7782
+ kbd.textContent = p;
7783
+ holder.appendChild(kbd);
7784
+ });
7785
+ return holder;
7786
+ }
7787
+
7788
+ // The cheat sheet: every combo above, then the plain keys.
7789
+ function showShortcutsDialog() {
7790
+ let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
7791
+ shell.body.classList.add('aptx-shortcuts');
7792
+ let rows = SHORTCUTS.map(function (sc) {
7793
+ return [shortcutLabel(sc), sc.what];
7794
+ }).concat([
7795
+ [['Esc'], 'Back to the whole tree, the launch view'],
7796
+ [['O'], 'Move the overview to the next corner'],
7797
+ [['PageUp'], 'Larger font'],
7798
+ [['PageDown'], 'Smaller font']
7799
+ ]);
7800
+ rows.forEach(function (row) {
7801
+ let line = document.createElement('div');
7802
+ line.className = 'aptx-dialog-line';
7803
+ let key = document.createElement('span');
7804
+ key.className = 'aptx-dialog-key';
7805
+ key.appendChild(keyChips(row[0]));
7806
+ let val = document.createElement('span');
7807
+ val.className = 'aptx-dialog-val';
7808
+ val.textContent = row[1];
7809
+ line.appendChild(key);
7810
+ line.appendChild(val);
7811
+ shell.body.appendChild(line);
7812
+ });
7813
+ let note = document.createElement('p');
7814
+ note.className = 'aptx-shortcuts-note';
7815
+ note.textContent = 'The mouse wheel zooms; with Shift it zooms vertically only, with Shift+Alt horizontally, with Ctrl+Shift it sizes the font.';
7816
+ shell.body.appendChild(note);
7817
+ shell.dialog.showModal();
7818
+ }
7819
+
7820
+ // ===================== Protein domain architectures =====================
7821
+ // The desktop's domain display, ported from its RenderableDomainArchitecture
7822
+ // and TreePanel (the spec is section D1 of the repo's TODO.md). Each tip's
7823
+ // architecture is a grey backbone of L residues with a rounded box per
7824
+ // domain the E-value threshold admits: in one aligned column past the
7825
+ // labels in the rectangular layout, riding the tip's spoke in the radial
7826
+ // ones. One scale for the whole tree -- f px per residue, from the track
7827
+ // width and the longest architecture -- so lengths compare across tips.
7828
+ // Which domains, where, which colour and which legend rows are forester's
7829
+ // (tested against the desktop's numbers); this is the SVG, the legend
7830
+ // card and the controls. The boxes take no pointer events: nothing to
7831
+ // hover or click, as on the desktop.
7832
+
7833
+ function domainsShown() {
7834
+ return _state.showDomainArchitectures === true
7835
+ && _basicTreeProperties.domainArchitectures === true
7836
+ && _basicTreeProperties.maxDomainArchitectureLength > 0
7837
+ && _state.showExternalLabels
7838
+ // in a radial layout the boxes ride the spokes, so only radial
7839
+ // labels go with them
7840
+ && (!radialDisplay() || !_radialLabelsHorizontal);
7841
+ }
7842
+
7843
+ // The track width W of the rectangular layouts: a quarter of the
7844
+ // viewport at first use (the layout's width, if smaller). The radial
7845
+ // layouts keep a width of their own, starting at the smaller of W and a
7846
+ // fifth of the radius, so a long architecture does not run off the
7847
+ // circle at first sight. d+ / d- scale whichever width the current
7848
+ // layout uses by 1.2 / 0.8 and stop at 2000 / 20. (The desktop instead
7849
+ // caps the drawn width at that fifth of the radius on every redraw,
7850
+ // which leaves its buttons stepping a number the cap then discards --
7851
+ // Christian: "all buttons should work as expected".)
7852
+ function domainRectangularWidth() {
7853
+ if (!_domain.width) {
7854
+ let vp = svgSize();
7855
+ let vw = Math.min(_displayWidth, (vp && vp.w) ? vp.w : _displayWidth);
7856
+ _domain.width = Math.max(DOMAIN_WIDTH_MIN, Math.round(vw * DOMAIN_WIDTH_VIEWPORT_FRACTION));
7857
+ }
7858
+ return _domain.width;
7859
+ }
7860
+
7861
+ function domainTrackWidth() {
7862
+ if (!radialDisplay()) {
7863
+ return domainRectangularWidth();
7864
+ }
7865
+ if (!_domain.radialWidth) {
7866
+ let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
7867
+ if (!(r > 0)) {
7868
+ return domainRectangularWidth(); // no radius yet (before the first radial draw)
7869
+ }
7870
+ _domain.radialWidth = Math.max(DOMAIN_WIDTH_MIN, Math.min(domainRectangularWidth(), DOMAIN_RADIAL_WIDTH_FRACTION * r));
7871
+ }
7872
+ return _domain.radialWidth;
7873
+ }
7874
+
7875
+ function setDomainTrackWidth(w) {
7876
+ if (radialDisplay()) {
7877
+ _domain.radialWidth = w;
7878
+ } else {
7879
+ _domain.width = w;
7880
+ }
7881
+ }
7882
+
7883
+ function domainEffectiveWidth() {
7884
+ return domainTrackWidth();
7885
+ }
7886
+
7887
+ // px per residue, one factor for every architecture on the tree; Lmax
7888
+ // counts every domain whatever its E-value, so the threshold never
7889
+ // rescales the tracks
7890
+ function domainScale() {
7891
+ return (domainEffectiveWidth() / _basicTreeProperties.maxDomainArchitectureLength) * DOMAIN_SCALE_HEADROOM;
7892
+ }
7893
+
7894
+ // The room the longest tip label takes: the same estimate the layout is
7895
+ // sized against (there are no font metrics in the model).
7896
+ function domainLabelSpace() {
7897
+ return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
7898
+ }
7899
+
7900
+ // The rectangular layout reserves the tracks' column so the tree and
7901
+ // labels compress to make room, as the alignment track does; the radial
7902
+ // layouts fit the tracks into the ring instead (fitRadialExtent).
7903
+ function domainReserve() {
7904
+ if (!domainsShown() || radialDisplay()) {
7905
+ return 0;
7906
+ }
7907
+ return DOMAIN_TRACK_START_GAP + domainTrackWidth() + DOMAIN_TRACK_END_GAP;
7908
+ }
7909
+
7910
+ function domainRadialExtent() {
7911
+ if (!domainsShown() || !radialDisplay()) {
7912
+ return 0;
7913
+ }
7914
+ return DOMAIN_RADIAL_GAP + domainEffectiveWidth() + DOMAIN_TRACK_END_GAP;
7915
+ }
7916
+
7917
+ // Colours: the names the threshold admits over the WHOLE tree, sorted by
7918
+ // code unit, take the palette in order. Dealt at load, after an edit and
7919
+ // whenever the threshold changes (the drawn set changes then, so colours
7920
+ // can shift); a name met later takes the next unused colour and keeps
7921
+ // it. Never derived from the name's characters -- that let SH2 and SH3
7922
+ // collide on the desktop.
7923
+ function domainPalette() {
7924
+ if (!_domain.palette) {
7925
+ let names = forester.domainSummary(forester.getTreeRoot(_treeData), _state.domainEvalueExponent).names;
7926
+ _domain.palette = Object.create(null);
7927
+ names.forEach(function (name, i) {
7928
+ _domain.palette[name] = i;
7929
+ });
7930
+ _domain.next = names.length;
7931
+ }
7932
+ return _domain.palette;
7933
+ }
7934
+
7935
+ function domainColor(name) {
7936
+ if (!name) {
7937
+ return forester.DOMAIN_UNNAMED_COLOR;
7938
+ }
7939
+ let palette = domainPalette();
7940
+ if (palette[name] === undefined) {
7941
+ palette[name] = _domain.next++;
7942
+ }
7943
+ return forester.domainQualitativeColor(palette[name]);
7944
+ }
7945
+
7946
+ function domainBoxHeight(pitch) {
7947
+ return Math.max(DOMAIN_BOX_MIN_H, Math.min(DOMAIN_BOX_MAX_H, Math.round(pitch)));
7948
+ }
7949
+
7950
+ function drawDomainArchitectures() {
7951
+ if (!_svgGroup) {
7952
+ return;
7953
+ }
7954
+ _svgGroup.selectAll('g.aptx-domains').remove();
7955
+ if (!domainsShown() || !_root) {
7956
+ return;
7957
+ }
7958
+ let tips = displayedTips().filter(function (d) {
7959
+ return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
7960
+ });
7961
+ let f = domainScale();
7962
+ if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
7963
+ return;
7964
+ }
7965
+ let g = _svgGroup.append('g').attr('class', 'aptx-domains').style('pointer-events', 'none');
7966
+ // one vertical gradient per base colour, lighter at the top; the
7967
+ // defs ride into the SVG / PDF / PNG exports with the boxes
7968
+ let defs = g.append('defs');
7969
+ let made = Object.create(null);
7970
+ function gradientFor(base) {
7971
+ let id = 'aptx-dom-' + base.substring(1);
6661
7972
  if (!made[id]) {
6662
7973
  made[id] = true;
6663
7974
  let grad = defs.append('linearGradient').attr('id', id)
@@ -6782,7 +8093,7 @@ function (root, d3, forester, phyloXml) {
6782
8093
  // rows in the order the tips are on screen -- top to bottom, or
6783
8094
  // clockwise -- not the data's order, which a ladderized display
6784
8095
  // does not follow
6785
- let tips = forester.getAllExternalNodes(_root).filter(function (d) {
8096
+ let tips = displayedTips().filter(function (d) {
6786
8097
  return d.x !== undefined;
6787
8098
  }).sort(function (p, q) {
6788
8099
  return p.x - q.x;
@@ -6918,9 +8229,16 @@ function (root, d3, forester, phyloXml) {
6918
8229
  if (grow ? w >= DOMAIN_WIDTH_MAX : w <= DOMAIN_WIDTH_MIN) {
6919
8230
  return;
6920
8231
  }
6921
- _domain.width = w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK);
8232
+ setDomainTrackWidth(w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK));
6922
8233
  syncDomainControls();
6923
- scheduleUpdate(null, 0);
8234
+ // a radial layout has no column to reserve, but the fit must take the
8235
+ // new track length into account, so it is refitted
8236
+ if (radialDisplay()) {
8237
+ scheduleUpdate(null, 0, true);
8238
+ afterUpdate(zoomToFit);
8239
+ } else {
8240
+ scheduleUpdate(null, 0);
8241
+ }
6924
8242
  }
6925
8243
 
6926
8244
  function domainEvalueStep(delta) {
@@ -6969,7 +8287,9 @@ function (root, d3, forester, phyloXml) {
6969
8287
 
6970
8288
  // Search B starts hidden to keep the panel compact; one click (or a
6971
8289
  // configured initial value) reveals it, and it stays revealed.
6972
- function revealSearchB() {
8290
+ // Search B is folded away until wanted: the '+ Search B' link (which
8291
+ // focuses it) or a view that carries a second search.
8292
+ function showSearchB() {
6973
8293
  let wrap = byId(SEARCH_B_WRAP);
6974
8294
  if (wrap) {
6975
8295
  wrap.style.display = '';
@@ -6978,6 +8298,10 @@ function (root, d3, forester, phyloXml) {
6978
8298
  if (tgl) {
6979
8299
  tgl.style.display = 'none';
6980
8300
  }
8301
+ }
8302
+
8303
+ function revealSearchB() {
8304
+ showSearchB();
6981
8305
  let f = byId(SEARCH_FIELD_1);
6982
8306
  if (f) {
6983
8307
  f.focus();
@@ -7014,7 +8338,81 @@ function (root, d3, forester, phyloXml) {
7014
8338
  // The navigation bar is fixed at the viewport bottom, so the track's
7015
8339
  // own bottom rows (conservation, consensus, ruler) must end above it
7016
8340
  // 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());
8341
+ // The scale bar sits in the same band, under the tree's last row.
8342
+ return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
8343
+ scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
8344
+ }
8345
+
8346
+ // ===================== Scale bar =====================
8347
+ // A phylogram carries a scale bar at the bottom left of the tree: a
8348
+ // round number of branch-length units (forester.scaleBarLength) with
8349
+ // end ticks and its length written above, drawn in the tree's own
8350
+ // coordinates so it zooms and exports with the tree and its label stays
8351
+ // true. Not in a cladogram (nothing to measure) and not under a time
8352
+ // axis (which is a scale already); in the radial layouts it sits below
8353
+ // the fan at its left edge.
8354
+ const SCALE_BAR_TARGET_PX = 100;
8355
+ const SCALE_BAR_RESERVE = 30;
8356
+
8357
+ function scaleBarShown() {
8358
+ return _state.phylogram === true && _basicTreeProperties.branchLengths === true && !timeAxisShown();
8359
+ }
8360
+
8361
+ // pixels per branch-length unit in the current layout's own coordinates
8362
+ function branchLengthPixelsPerUnit() {
8363
+ if (!_state.phylogram || !_yScale) {
8364
+ return 0;
8365
+ }
8366
+ if (_state.unrootedDisplay) {
8367
+ return (_unroot && _unroot.pxPerUnit) ? _unroot.pxPerUnit : 0;
8368
+ }
8369
+ let unit = _yScale(1) - _yScale(0);
8370
+ if (_state.circularDisplay) {
8371
+ return (_radial && _radial.maxY > 0) ? unit * (_radial.maxRad / _radial.maxY) : 0;
8372
+ }
8373
+ return unit;
8374
+ }
8375
+
8376
+ function drawScaleBar() {
8377
+ if (!_svgGroup) {
8378
+ return;
8379
+ }
8380
+ _svgGroup.selectAll('g.aptx-scalebar').remove();
8381
+ if (!scaleBarShown()) {
8382
+ return;
8383
+ }
8384
+ let bar = forester.scaleBarLength(branchLengthPixelsPerUnit(), SCALE_BAR_TARGET_PX);
8385
+ if (!bar) {
8386
+ return;
8387
+ }
8388
+ let x, y;
8389
+ if (radialDisplay()) {
8390
+ let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
8391
+ let r = (_state.circularDisplay ? _radial.maxRad : _unroot.maxRad) + labelSpace;
8392
+ x = -r;
8393
+ y = r + 24;
8394
+ } else {
8395
+ x = 0;
8396
+ y = _clusterH + SCALE_BAR_RESERVE - 12;
8397
+ }
8398
+ let stroke = _state.branchColorDefault;
8399
+ let width = Math.max(1, _state.branchWidthDefault);
8400
+ let fs = Math.max(9, _state.externalNodeFontSize);
8401
+ let g = _svgGroup.append('g').attr('class', 'aptx-scalebar').style('pointer-events', 'none');
8402
+ g.append('line')
8403
+ .attr('x1', x).attr('y1', y).attr('x2', x + bar.px).attr('y2', y)
8404
+ .style('stroke', stroke).style('stroke-width', width);
8405
+ [x, x + bar.px].forEach(function (tx) {
8406
+ g.append('line')
8407
+ .attr('x1', tx).attr('y1', y - 4).attr('x2', tx).attr('y2', y + 4)
8408
+ .style('stroke', stroke).style('stroke-width', width);
8409
+ });
8410
+ g.append('text')
8411
+ .attr('x', x + (bar.px / 2)).attr('y', y - 6)
8412
+ .attr('text-anchor', 'middle')
8413
+ .style('font', fs + 'px ' + FONT_DEFAULTS)
8414
+ .style('fill', _state.labelColorDefault)
8415
+ .text(bar.label);
7018
8416
  }
7019
8417
 
7020
8418
  function hexToRgbTriple(hex) {
@@ -7050,7 +8448,7 @@ function (root, d3, forester, phyloXml) {
7050
8448
  // average branch length (see bl()), which would shift every band.
7051
8449
  let anchor = null;
7052
8450
  let maxTipX = 0;
7053
- forester.preOrderTraversal(_root, function (n) {
8451
+ forEachDisplayed(function (n) {
7054
8452
  if (n.children) {
7055
8453
  return;
7056
8454
  }
@@ -7088,7 +8486,7 @@ function (root, d3, forester, phyloXml) {
7088
8486
  let sc = info.type === 'calendar' ? -corr : corr;
7089
8487
 
7090
8488
  // ---- HPD age bars (internal) + fossil range bars (tips) ----
7091
- forester.preOrderTraversal(_root, function (d) {
8489
+ forEachDisplayed(function (d) {
7092
8490
  if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
7093
8491
  || d.y === undefined) {
7094
8492
  return;
@@ -7322,7 +8720,7 @@ function (root, d3, forester, phyloXml) {
7322
8720
  setZoomScale(1);
7323
8721
  update(_root, 0);
7324
8722
  _zoomed_x_or_y = true;
7325
- const uncollsed_nodes = forester.calcSumOfExternalDescendants(_root);
8723
+ const uncollsed_nodes = displayedLeafCount();
7326
8724
  _displayHeight = _state.externalNodeFontSize * (uncollsed_nodes * 1.3);
7327
8725
  const min = 40;
7328
8726
  if (_displayHeight < min) {
@@ -7395,13 +8793,10 @@ function (root, d3, forester, phyloXml) {
7395
8793
  if (_treeFn.visData.ladderize === undefined) {
7396
8794
  _treeFn.visData.ladderize = true;
7397
8795
  }
7398
- ladderizeSubtree(_root, _treeFn.visData.ladderize, true);
8796
+ let largestFirst = ladderizeSubtree(_root, _treeFn.visData.ladderize, true);
8797
+ _viewOps.order = largestFirst ? 'asc' : 'desc'; // what a shared view replays
7399
8798
  _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
- }
8799
+ syncLadderizeGlyph();
7405
8800
  update(null, 0);
7406
8801
  }
7407
8802
  }
@@ -7423,7 +8818,10 @@ function (root, d3, forester, phyloXml) {
7423
8818
  showNodeMenu([
7424
8819
  {
7425
8820
  label: 'Midpoint re-root', action: function () {
7426
- forester.midpointRoot(_root);
8821
+ rerootKeepingCollapse(function () {
8822
+ forester.midpointRoot(_root);
8823
+ });
8824
+ _viewOps.root = 'midpoint'; // what a shared view replays
7427
8825
  zoomToFit();
7428
8826
  }
7429
8827
  },
@@ -7440,6 +8838,7 @@ function (root, d3, forester, phyloXml) {
7440
8838
  _root = _root_const;
7441
8839
  _in_subtree = false;
7442
8840
  }
8841
+ clearCollapsedFlags(_root); // the launch state has nothing collapsed
7443
8842
 
7444
8843
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
7445
8844
 
@@ -7585,12 +8984,14 @@ function (root, d3, forester, phyloXml) {
7585
8984
  }
7586
8985
 
7587
8986
  function resetSearch0() {
8987
+ closeSuggestions();
7588
8988
  setValue(SEARCH_FIELD_0, '');
7589
8989
  setValue(SEARCH_VALUE2_0, '');
7590
8990
  runSearches();
7591
8991
  }
7592
8992
 
7593
8993
  function resetSearch1() {
8994
+ closeSuggestions();
7594
8995
  setValue(SEARCH_FIELD_1, '');
7595
8996
  setValue(SEARCH_VALUE2_1, '');
7596
8997
  runSearches();
@@ -7695,24 +9096,252 @@ function (root, d3, forester, phyloXml) {
7695
9096
  if (idx === 0) { search0(); } else { search1(); }
7696
9097
  }
7697
9098
 
7698
- // Populate (or detach) the value box's <datalist> so the browser offers
7699
- // type-ahead value suggestions for specific text fields only.
9099
+ // ===================== Search suggestions =====================
9100
+ // The value box's type-ahead: the distinct values of the chosen field,
9101
+ // filtered as you type, in an in-page list under the box drawn like the
9102
+ // node menu in the panel's own theme. It replaces the browser's
9103
+ // <datalist> widget -- the one control the page could not style, which
9104
+ // sat in the panel like a stranger. Off, as before, for numeric fields,
9105
+ // Any Text and the molecular sequence (field.suggest === false), and in
9106
+ // regex mode. The values are gathered once per field or mode change
9107
+ // (updateSearchAutocomplete), not per keystroke: on a big tree that is a
9108
+ // walk of every node.
9109
+
9110
+ const SUGGEST_MAX_ROWS = 10;
9111
+ let _suggestValues = [null, null]; // per box: the field's distinct values, or null when suggestions are off
9112
+ let _suggest = null; // the open list: {idx, input, box, rows, active, onOutside}
9113
+ let _suggestPicked = false; // an Enter that picked a row must not also re-run the search on keyup
9114
+ let _suggestSkipFocus = false; // the focus a pick hands back must not reopen the list
9115
+
7700
9116
  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;
9117
+ closeSuggestions();
7705
9118
  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);
9119
+ let on = !spec.field.numeric && spec.field.suggest !== false && spec.mode !== 'regex';
9120
+ _suggestValues[idx] = on ? forester.distinctSearchValues(_root, spec.field, SEARCH_AUTOCOMPLETE_CAP) : null;
9121
+ }
9122
+
9123
+ // The term being typed is the last one: ',' is OR and '+' is AND in the
9124
+ // plain-text modes, and a pick replaces just that term.
9125
+ function suggestTermStart(text) {
9126
+ return Math.max(text.lastIndexOf(','), text.lastIndexOf('+')) + 1;
9127
+ }
9128
+
9129
+ // Suggestions compare the way the search will: verbatim while Match
9130
+ // case is on, folded to lower case otherwise (Christian, 2026-09-13,
9131
+ // joint with the desktop).
9132
+ function suggestFold(s) {
9133
+ return _state.searchIsCaseSensitive === true ? s : s.toLowerCase();
9134
+ }
9135
+
9136
+ // The values the typed term admits, matched the way the box's mode will
9137
+ // match them: a prefix for "starts with", a suffix for "ends with", a
9138
+ // substring otherwise.
9139
+ function suggestMatches(idx, term) {
9140
+ let values = _suggestValues[idx] || [];
9141
+ let mode = getValue(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
9142
+ let needle = suggestFold(term);
9143
+ return values.filter(function (v) {
9144
+ if (needle.length === 0) {
9145
+ return true;
9146
+ }
9147
+ let lv = suggestFold(v);
9148
+ if (mode === 'starts_with') {
9149
+ return lv.indexOf(needle) === 0;
9150
+ }
9151
+ if (mode === 'ends_with') {
9152
+ return lv.length >= needle.length && lv.lastIndexOf(needle) === lv.length - needle.length;
9153
+ }
9154
+ return lv.indexOf(needle) >= 0;
9155
+ });
9156
+ }
9157
+
9158
+ function openSuggestions(idx) {
9159
+ closeSuggestions();
9160
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9161
+ if (!input || !_suggestValues[idx] || _suggestValues[idx].length === 0) {
9162
+ return;
9163
+ }
9164
+ let text = input.value;
9165
+ let start = suggestTermStart(text);
9166
+ let term = text.substring(start).trim();
9167
+ let matches = suggestMatches(idx, term);
9168
+ // nothing to offer when nothing matches, or the one match is what is already typed
9169
+ if (matches.length === 0 || (matches.length === 1 && suggestFold(matches[0]) === suggestFold(term))) {
9170
+ return;
9171
+ }
9172
+ let box = document.createElement('div');
9173
+ box.className = 'aptx-suggest';
9174
+ if (_panelTheme) {
9175
+ box.classList.add('aptx-' + _panelTheme); // follow the panel's light/dark choice
9176
+ }
9177
+ let needle = suggestFold(term);
9178
+ let rows = [];
9179
+ matches.slice(0, SUGGEST_MAX_ROWS).forEach(function (v) {
9180
+ let b = document.createElement('button');
9181
+ b.type = 'button';
9182
+ b.tabIndex = -1;
9183
+ b.title = v;
9184
+ // the matched part in bold, wherever it sits in the value
9185
+ let at = needle.length > 0 ? suggestFold(v).indexOf(needle) : -1;
9186
+ if (at >= 0) {
9187
+ b.appendChild(document.createTextNode(v.substring(0, at)));
9188
+ let hit = document.createElement('b');
9189
+ hit.textContent = v.substring(at, at + needle.length);
9190
+ b.appendChild(hit);
9191
+ b.appendChild(document.createTextNode(v.substring(at + needle.length)));
9192
+ } else {
9193
+ b.textContent = v;
9194
+ }
9195
+ b.addEventListener('mousedown', function (e) {
9196
+ e.preventDefault(); // the focus stays in the box: no blur, no flicker
9197
+ });
9198
+ b.addEventListener('click', function (e) {
9199
+ e.stopPropagation();
9200
+ pickSuggestion(idx, v);
9201
+ });
9202
+ box.appendChild(b);
9203
+ rows.push(b);
9204
+ });
9205
+ if (matches.length > SUGGEST_MAX_ROWS) {
9206
+ let more = document.createElement('div');
9207
+ more.className = 'aptx-suggest-more';
9208
+ more.textContent = (matches.length - SUGGEST_MAX_ROWS) + ' more — keep typing';
9209
+ box.appendChild(more);
9210
+ }
9211
+ document.body.appendChild(box);
9212
+ // under the box and as wide as it, or above it when the window's
9213
+ // bottom is too close
9214
+ let r = input.getBoundingClientRect();
9215
+ box.style.minWidth = Math.round(r.width) + 'px';
9216
+ let h = box.getBoundingClientRect().height;
9217
+ let below = r.bottom + 3;
9218
+ let top = (below + h > document.documentElement.clientHeight - 8 && r.top - h - 3 > 8) ? r.top - h - 3 : below;
9219
+ box.style.left = Math.round(r.left + window.scrollX) + 'px';
9220
+ box.style.top = Math.round(top + window.scrollY) + 'px';
9221
+ // a press anywhere else closes it -- in the capture phase, since d3's
9222
+ // zoom stops mousedown on the tree from reaching the document
9223
+ let onOutside = function (e) {
9224
+ if (_suggest && !_suggest.box.contains(e.target) && e.target !== input) {
9225
+ closeSuggestions();
9226
+ }
9227
+ };
9228
+ document.addEventListener('mousedown', onOutside, true);
9229
+ _suggest = {idx: idx, input: input, box: box, rows: rows, active: -1, onOutside: onOutside};
9230
+ }
9231
+
9232
+ function closeSuggestions() {
9233
+ if (!_suggest) {
9234
+ return;
9235
+ }
9236
+ document.removeEventListener('mousedown', _suggest.onOutside, true);
9237
+ _suggest.box.remove();
9238
+ _suggest = null;
9239
+ }
9240
+
9241
+ function setActiveSuggestion(i) {
9242
+ if (!_suggest) {
9243
+ return;
9244
+ }
9245
+ _suggest.rows.forEach(function (b, k) {
9246
+ b.classList.toggle('aptx-active', k === i);
9247
+ });
9248
+ _suggest.active = i;
9249
+ }
9250
+
9251
+ function pickSuggestion(idx, value) {
9252
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9253
+ closeSuggestions();
9254
+ if (!input) {
9255
+ return;
9256
+ }
9257
+ let text = input.value;
9258
+ input.value = text.substring(0, suggestTermStart(text)) + value;
9259
+ _suggestSkipFocus = true;
9260
+ input.focus();
9261
+ runSearches();
9262
+ }
9263
+
9264
+ // Keys in the value box: the arrows open the list and move along it,
9265
+ // Enter picks, Escape and Tab close. An Escape that closed the list must
9266
+ // not also reset the view, exactly as the node menu's does not.
9267
+ function suggestKeydown(idx, e) {
9268
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
9269
+ if (!_suggest) {
9270
+ openSuggestions(idx);
9271
+ if (!_suggest) {
9272
+ return;
9273
+ }
9274
+ }
9275
+ let n = _suggest.rows.length;
9276
+ let a = _suggest.active;
9277
+ // on a list with no row chosen yet, Down starts at the top and Up
9278
+ // at the bottom (the desktop found Up landing one short of it)
9279
+ setActiveSuggestion(e.key === 'ArrowDown' ? (a + 1) % n : (a < 0 ? n - 1 : (a - 1 + n) % n));
9280
+ e.preventDefault();
9281
+ } else if (e.key === 'Enter') {
9282
+ if (_suggest && _suggest.active >= 0) {
9283
+ _suggestPicked = true;
9284
+ pickSuggestion(idx, _suggest.rows[_suggest.active].title);
9285
+ e.preventDefault();
9286
+ } else {
9287
+ closeSuggestions();
9288
+ }
9289
+ } else if (e.key === 'Escape') {
9290
+ if (_suggest) {
9291
+ closeSuggestions();
9292
+ _menuConsumedEsc = true;
9293
+ e.stopPropagation();
9294
+ }
9295
+ } else if (e.key === 'Tab') {
9296
+ closeSuggestions();
9297
+ }
9298
+ }
9299
+
9300
+ // The value box's keyup runs the search. A navigation key changes no
9301
+ // value, and an Enter that just picked a row has already run it.
9302
+ const SUGGEST_NAV_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape', 'Tab', 'Shift',
9303
+ 'Control', 'Alt', 'Meta', 'Home', 'End', 'PageUp', 'PageDown', 'CapsLock'];
9304
+
9305
+ function searchValueKeyup(e) {
9306
+ if (SUGGEST_NAV_KEYS.indexOf(e.key) >= 0) {
9307
+ return;
9308
+ }
9309
+ if (e.key === 'Enter' && _suggestPicked) {
9310
+ _suggestPicked = false;
9311
+ return;
9312
+ }
9313
+ runSearches();
9314
+ }
9315
+
9316
+ function wireSuggestions(idx) {
9317
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9318
+ if (!input) {
9319
+ return;
7714
9320
  }
7715
- input.setAttribute('list', dlId);
9321
+ input.addEventListener('keydown', function (e) {
9322
+ suggestKeydown(idx, e);
9323
+ });
9324
+ input.addEventListener('input', function () {
9325
+ openSuggestions(idx);
9326
+ });
9327
+ input.addEventListener('focus', function () {
9328
+ if (_suggestSkipFocus) {
9329
+ _suggestSkipFocus = false;
9330
+ return;
9331
+ }
9332
+ if (input.value.length > 0) {
9333
+ openSuggestions(idx);
9334
+ }
9335
+ });
9336
+ input.addEventListener('blur', function () {
9337
+ // after a tick: a click on a row keeps the focus, anything else
9338
+ // that took it closes the list
9339
+ setTimeout(function () {
9340
+ if (_suggest && _suggest.idx === idx && document.activeElement !== input) {
9341
+ closeSuggestions();
9342
+ }
9343
+ }, 120);
9344
+ });
7716
9345
  }
7717
9346
 
7718
9347
 
@@ -7745,6 +9374,7 @@ function (root, d3, forester, phyloXml) {
7745
9374
  _radialLabelsHorizontal = false;
7746
9375
  }
7747
9376
  syncZoomRowButtons();
9377
+ afterUpdate(syncDomainControls); // the track width buttons follow the layout's own width
7748
9378
  zoomToFit();
7749
9379
  }
7750
9380
 
@@ -7944,6 +9574,27 @@ function (root, d3, forester, phyloXml) {
7944
9574
  scheduleUpdate(null, 0);
7945
9575
  }
7946
9576
 
9577
+ // The desktop draws a box around "Auto-hide Labels" while labels are
9578
+ // actually being hidden -- the switch being on is one thing, the tree
9579
+ // being dense enough for it to bite is another. Same here, in the
9580
+ // panel's accent: the checkbox item gets a box and its tooltip says how
9581
+ // many labels are shown. Called on every redraw, since the density
9582
+ // changes with the vertical zoom, the font size and the view.
9583
+ function syncDynahideIndicator(active) {
9584
+ let cb = byId(DYNAHIDE_CB);
9585
+ let item = cb ? cb.closest('.aptx-check') : null;
9586
+ if (!item) {
9587
+ return;
9588
+ }
9589
+ if (item.dataset.baseTitle === undefined) {
9590
+ item.dataset.baseTitle = item.title;
9591
+ }
9592
+ item.classList.toggle('aptx-check-active', !!active);
9593
+ item.title = active
9594
+ ? item.dataset.baseTitle + ' — hiding now: 1 in ' + _dynahide_factor + ' labels shown'
9595
+ : item.dataset.baseTitle;
9596
+ }
9597
+
7947
9598
  function dynaHideCbClicked() {
7948
9599
  _state.dynahide = getCheckboxValue(DYNAHIDE_CB);
7949
9600
  resetVis();
@@ -8008,6 +9659,7 @@ function (root, d3, forester, phyloXml) {
8008
9659
 
8009
9660
  function searchOptionsCaseSenstiveCbClicked() {
8010
9661
  _state.searchIsCaseSensitive = getCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB);
9662
+ closeSuggestions(); // an open list was filtered the other way; the next keystroke refilters
8011
9663
  search0();
8012
9664
  search1();
8013
9665
  }
@@ -8502,6 +10154,9 @@ function (root, d3, forester, phyloXml) {
8502
10154
  case 'ladderize_asc': sw = 8; cap = 'round'; body = glyphLadderize(true); break;
8503
10155
  case 'ladderize_desc': sw = 8; cap = 'round'; body = glyphLadderize(false); break;
8504
10156
  case 'midpoint': sw = 8; cap = 'round'; body = glyphMidpoint(); break;
10157
+ case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
10158
+ case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
10159
+ case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
8505
10160
  case 'sun': body = glyphSun(); break;
8506
10161
  case 'moon': body = glyphMoon(); break;
8507
10162
  default: throw new Error('unknown control-panel glyph: ' + kind);
@@ -8511,6 +10166,20 @@ function (root, d3, forester, phyloXml) {
8511
10166
  + ' stroke-linecap="' + cap + '" stroke-linejoin="' + join + '">' + body + '</svg>';
8512
10167
  }
8513
10168
 
10169
+ // The desktop's UNCOLLAPSE_ALL glyph (ControlButtonIcon.paintUncollapse),
10170
+ // ported point for point into the 100-unit box: the collapsed-clade
10171
+ // triangle, apex at the parent, base toward the tips it opens back out
10172
+ // into -- three of them.
10173
+ // A chevron pointing left or right: the previous / next tree buttons.
10174
+ function glyphChevron(right) {
10175
+ return right ? '<path d="M38,18 L66,50 L38,82"/>' : '<path d="M62,18 L34,50 L62,82"/>';
10176
+ }
10177
+
10178
+ function glyphUncollapseAll() {
10179
+ return '<path d="M6,50 L44,16 L44,84 Z" fill="currentColor" stroke="none"/>'
10180
+ + '<path d="M56,20 H94 M56,50 H94 M56,80 H94"/>';
10181
+ }
10182
+
8514
10183
  function injectPanelStyles() {
8515
10184
  loadPanelTheme();
8516
10185
  if (document.getElementById(PANEL_STYLE_ID)) {
@@ -8547,16 +10216,16 @@ function (root, d3, forester, phyloXml) {
8547
10216
  + '.aptx-panel * { box-sizing:border-box; }'
8548
10217
  + '@media (prefers-color-scheme:dark){ .aptx-panel:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
8549
10218
  + '.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 {'
10219
+ // The node menu and the search suggestions are separate overlays
10220
+ // (they cannot live inside the panel), so they carry their own
10221
+ // copy of the palette and follow the same light / dark rules.
10222
+ + '.aptx-node-menu, .aptx-suggest {'
8554
10223
  + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
8555
10224
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
8556
10225
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
8557
10226
  + '}'
8558
- + '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
8559
- + '.aptx-node-menu.aptx-dark {' + dark + '}'
10227
+ + '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark), .aptx-suggest:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
10228
+ + '.aptx-node-menu.aptx-dark, .aptx-suggest.aptx-dark {' + dark + '}'
8560
10229
  // The menu itself: fixed 11px type, so it does NOT scale with the
8561
10230
  // tree's zoom the way the old svg-drawn menu did.
8562
10231
  + '.aptx-node-menu { position:absolute; z-index:1000; min-width:196px; max-width:280px; padding:4px;'
@@ -8575,9 +10244,27 @@ function (root, d3, forester, phyloXml) {
8575
10244
  + ' transition:background .1s,color .1s; }'
8576
10245
  + '.aptx-node-menu button:hover, .aptx-node-menu button:focus-visible {'
8577
10246
  + ' background:var(--p-accent); color:#fff; outline:none; }'
10247
+ + '.aptx-node-menu button:disabled, .aptx-node-menu button:disabled:hover {'
10248
+ + ' background:transparent; color:var(--p-faint); cursor:default; }'
8578
10249
  + '.aptx-node-menu button.aptx-menu-danger:hover, .aptx-node-menu button.aptx-menu-danger:focus-visible {'
8579
10250
  + ' background:#e5484d; color:#fff; }'
8580
10251
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
10252
+ // The search suggestions: the node menu's chrome under the value box.
10253
+ + '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
10254
+ + ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
10255
+ + ' -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);'
10256
+ + ' box-shadow:0 12px 30px -12px rgba(23,34,46,0.42),0 2px 6px -2px rgba(23,34,46,0.2);'
10257
+ + ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
10258
+ + ' font-size:11px; line-height:1.45; }'
10259
+ + '.aptx-suggest button { display:block; width:100%; text-align:left; margin:0; padding:4px 9px;'
10260
+ + ' border:0; border-radius:6px; background:transparent; color:var(--p-ink); font:inherit;'
10261
+ + ' cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'
10262
+ + ' transition:background .1s,color .1s; }'
10263
+ + '.aptx-suggest button b { font-weight:700; color:var(--p-accent); }'
10264
+ + '.aptx-suggest button:hover, .aptx-suggest button.aptx-active { background:var(--p-accent); color:#fff; }'
10265
+ + '.aptx-suggest button:hover b, .aptx-suggest button.aptx-active b { color:#fff; }'
10266
+ + '.aptx-suggest-more { padding:4px 9px 2px; margin-top:3px; border-top:1px solid var(--p-line);'
10267
+ + ' font-size:10px; color:var(--p-faint); }'
8581
10268
  // The node-data dialog, on the same palette as the panel and the menu.
8582
10269
  + '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
8583
10270
  + ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
@@ -8651,6 +10338,12 @@ function (root, d3, forester, phyloXml) {
8651
10338
  + '.aptx-dialog-line + .aptx-dialog-line { border-top:1px solid var(--p-line); }'
8652
10339
  + '.aptx-dialog-key { flex:0 0 42%; color:var(--p-muted); }'
8653
10340
  + '.aptx-dialog-val { flex:1 1 auto; min-width:0; overflow-wrap:anywhere; }'
10341
+ // the shortcuts cheat sheet: key chips in the key column
10342
+ + '.aptx-shortcuts .aptx-dialog-key { flex-basis:38%; display:flex; align-items:center; }'
10343
+ + '.aptx-shortcuts .aptx-dialog-val { align-self:center; }'
10344
+ + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
10345
+ + '.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; }'
10346
+ + '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
8654
10347
  + '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
8655
10348
  + ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
8656
10349
  // The hover tooltip, on the same palette.
@@ -8702,13 +10395,24 @@ function (root, d3, forester, phyloXml) {
8702
10395
  + '.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
10396
  + '.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
10397
  + '.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; }'
10398
+ // A file with several trees: previous / picker / next in one row
10399
+ + '.aptx-panel .aptx-tree-picker { display:flex; align-items:center; gap:4px; min-width:0; }'
10400
+ + '.aptx-panel .aptx-tree-picker select { flex:1 1 auto; min-width:0; height:24px; }'
10401
+ + '.aptx-panel .aptx-tree-picker .aptx-gbtn { flex:0 0 auto; min-width:26px; padding:0 4px; margin:0; }'
10402
+ + '.aptx-panel .aptx-tree-picker + .' + TREE_DESC + ' { margin-top:5px; }'
8705
10403
  + '.aptx-panel .aptx-panel-title { font-size:12px; }'
8706
10404
  + '.aptx-panel fieldset { border:0; border-top:1px solid var(--p-line); margin:0; padding:6px 12px; min-width:0; }'
8707
10405
  + '.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
10406
  + '.aptx-panel label { cursor:pointer; }'
8709
10407
  + '.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
10408
  // 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; }'
10409
+ // every item carries an invisible box, so the one that lights up
10410
+ // (Auto-hide Labels while it is hiding) does not shift its neighbours
10411
+ + '.aptx-panel .aptx-check { display:flex; align-items:center; gap:6px; cursor:pointer; min-width:0;'
10412
+ + ' padding:1px 4px; margin:-1px -4px; border:1px solid transparent; border-radius:5px;'
10413
+ + ' transition:border-color .15s,background .15s; }'
10414
+ + '.aptx-panel .aptx-check.aptx-check-active { border-color:var(--p-accent); background:var(--p-accent-weak); }'
10415
+ + '.aptx-panel .aptx-check.aptx-check-active > span { color:var(--p-accent-ink); font-weight:600; }'
8712
10416
  + '.aptx-panel .aptx-check > input { margin:0; flex:none; }'
8713
10417
  + '.aptx-panel .aptx-check > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }'
8714
10418
  + '.aptx-panel .aptx-checkgrid { display:grid; grid-template-columns:1fr 1fr; gap:3px 10px; }'
@@ -8725,7 +10429,9 @@ function (root, d3, forester, phyloXml) {
8725
10429
  + '.aptx-panel .aptx-search-menus { display:flex; gap:4px; margin:2px 0 3px; }'
8726
10430
  + '.aptx-panel .aptx-search-menus select { flex:1 1 0; width:auto; min-width:0; height:24px; font:inherit; }'
8727
10431
  // 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; }'
10432
+ // specific enough to beat the plain input rule (which used to keep
10433
+ // the border in the line colour, leaving only the ring)
10434
+ + '.aptx-panel input[type=text].aptx-search-invalid { box-shadow:0 0 0 2px #e5484d; border-color:#e5484d; }'
8729
10435
  + '.aptx-panel .aptx-combine { display:flex; align-items:center; gap:8px; margin:6px 0 2px; }'
8730
10436
  + '.aptx-panel .aptx-combine .aptx-field-label { margin:0; flex:none; }'
8731
10437
  + '.aptx-panel .aptx-combine select { flex:1 1 auto; min-width:0; height:26px; font:inherit; }'
@@ -8797,6 +10503,10 @@ function (root, d3, forester, phyloXml) {
8797
10503
  + '.aptx-panel .aptx-seg .aptx-glyph { height:13px; }'
8798
10504
  + '.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
10505
  + '.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); }'
10506
+ // an invalid regex or number stays red WHILE TYPING: without this
10507
+ // the focus rule above, more specific and later, painted the box in
10508
+ // the accent until the focus left it
10509
+ + '.aptx-panel input[type=text].aptx-search-invalid:focus { border-color:#e5484d; box-shadow:0 0 0 2px #e5484d; }'
8800
10510
  // --- collapsible sections, internal scroll, whole-panel hide ---
8801
10511
  // the panel is a child of the tree's container now, so it can be held
8802
10512
  // to that container's height rather than the whole viewport's
@@ -9375,7 +11085,8 @@ function (root, d3, forester, phyloXml) {
9375
11085
  [['Website', WEBSITE, 'cmzmasek.github.io/archaeopteryx-js'],
9376
11086
  ['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
9377
11087
  ['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
9378
- ['License', LICENSE_WEBSITE, LICENSE_NAME]].forEach(function (row) {
11088
+ ['License', LICENSE_WEBSITE, LICENSE_NAME],
11089
+ ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']].forEach(function (row) {
9379
11090
  let line = document.createElement('div');
9380
11091
  line.className = 'aptx-dialog-line';
9381
11092
  let key = document.createElement('span');
@@ -9384,9 +11095,19 @@ function (root, d3, forester, phyloXml) {
9384
11095
  let val = document.createElement('span');
9385
11096
  val.className = 'aptx-dialog-val';
9386
11097
  let a = document.createElement('a');
9387
- a.href = row[1];
9388
- a.target = '_blank';
9389
- a.rel = 'noopener noreferrer';
11098
+ if (row[1]) {
11099
+ a.href = row[1];
11100
+ a.target = '_blank';
11101
+ a.rel = 'noopener noreferrer';
11102
+ } else {
11103
+ // the shortcuts row opens the cheat sheet in place of the About box
11104
+ a.href = '#';
11105
+ a.addEventListener('click', function (e) {
11106
+ e.preventDefault();
11107
+ shell.dialog.close();
11108
+ showShortcutsDialog();
11109
+ });
11110
+ }
9390
11111
  a.textContent = row[2];
9391
11112
  val.appendChild(a);
9392
11113
  line.appendChild(key);
@@ -9601,9 +11322,14 @@ function (root, d3, forester, phyloXml) {
9601
11322
  }
9602
11323
 
9603
11324
 
9604
- on(SEARCH_FIELD_0, 'keyup', search0);
11325
+ on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
9605
11326
 
9606
- on(SEARCH_FIELD_1, 'keyup', search1);
11327
+ on(SEARCH_FIELD_1, 'keyup', searchValueKeyup);
11328
+ wireSuggestions(0);
11329
+ wireSuggestions(1);
11330
+ // the list is placed in page coordinates: a scroll or resize moves the box out from under it
11331
+ window.addEventListener('resize', closeSuggestions);
11332
+ document.addEventListener('scroll', closeSuggestions, true);
9607
11333
 
9608
11334
  on(SEARCH_VALUE2_0, 'keyup', search0);
9609
11335
 
@@ -9659,6 +11385,16 @@ function (root, d3, forester, phyloXml) {
9659
11385
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
9660
11386
  on(MSA_CB, 'click', msaCbClicked);
9661
11387
  on(DOMAINS_CB, 'click', domainsCbClicked);
11388
+ on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
11389
+ on(TREE_PREV_BUTTON, 'click', function () {
11390
+ showTree(_treeIndex - 1);
11391
+ });
11392
+ on(TREE_NEXT_BUTTON, 'click', function () {
11393
+ showTree(_treeIndex + 1);
11394
+ });
11395
+ on(TREE_SELECT, 'change', function () {
11396
+ showTree(parseInt(this.value, 10));
11397
+ });
9662
11398
  onHoldRepeat(DOMAIN_WIDTH_DEC, function () { domainWidthStep(false); });
9663
11399
  onHoldRepeat(DOMAIN_WIDTH_INC, function () { domainWidthStep(true); });
9664
11400
  on(DOMAIN_EVALUE_DEC, 'click', function () { domainEvalueStep(-1); });
@@ -9827,12 +11563,11 @@ function (root, d3, forester, phyloXml) {
9827
11563
  _docListenersBound = true;
9828
11564
 
9829
11565
  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).
11566
+ // The plain keys: Esc/Home reset, O cycles the overview corner,
11567
+ // PageUp/PageDown size the font, and the wheel zooms. The old
11568
+ // Alt+letter combos were near-undiscoverable (macOS labels the
11569
+ // key Option and types glyphs with it); the Ctrl / Cmd combos
11570
+ // that replaced them in 2026-09 live in SHORTCUTS, on keydown.
9836
11571
  if (isTypingTarget(e.target)) {
9837
11572
  return; // nothing fires from inside a text box
9838
11573
  }
@@ -9849,7 +11584,11 @@ function (root, d3, forester, phyloXml) {
9849
11584
  });
9850
11585
 
9851
11586
  bindDoc('keydown', function (e) {
9852
- if (isTypingTarget(e.target)) {
11587
+ if (e.defaultPrevented) {
11588
+ return;
11589
+ }
11590
+ shortcutKeyDown(e); // the Ctrl / Cmd combos (SHORTCUTS)
11591
+ if (e.defaultPrevented || isTypingTarget(e.target)) {
9853
11592
  return;
9854
11593
  }
9855
11594
  if (e.keyCode === VK_PAGE_UP) {
@@ -9938,13 +11677,23 @@ function (root, d3, forester, phyloXml) {
9938
11677
  // so tree-file text needs no escaping here. Both can be very long, so CSS
9939
11678
  // clamps each to a few lines; the caller (enableTreeDescExpand) adds the
9940
11679
  // click/keyboard expand affordance only when the text actually overflows.
11680
+ // A launch with several trees puts the tree picker in the same
11681
+ // fieldset, above the description; the picker shows the name, so
11682
+ // the name line is left out then.
9941
11683
  function makeTreeDesc() {
9942
- let name = _treeData.name ? String(_treeData.name).trim() : '';
11684
+ let several = _trees.length > 1;
11685
+ let name = (_treeData.name && !several) ? String(_treeData.name).trim() : '';
9943
11686
  let desc = _treeData.description ? String(_treeData.description).trim() : '';
9944
- if (!name && !desc) {
11687
+ if (!name && !desc && !several) {
9945
11688
  return null;
9946
11689
  }
9947
11690
  let fieldset = document.createElement('fieldset');
11691
+ if (several) {
11692
+ fieldset.appendChild(makeTreePicker());
11693
+ }
11694
+ if (!name && !desc) {
11695
+ return fieldset;
11696
+ }
9948
11697
  let block = document.createElement('div');
9949
11698
  block.className = TREE_DESC;
9950
11699
  let tooltip = '';
@@ -9967,6 +11716,35 @@ function (root, d3, forester, phyloXml) {
9967
11716
  return fieldset;
9968
11717
  }
9969
11718
 
11719
+ // A file with several trees: previous / picker / next in one row,
11720
+ // the picker listing every tree by its name (or its number). Each
11721
+ // opens fresh in the same container (showTree). The end buttons are
11722
+ // disabled at the ends: the panel is rebuilt per tree, so the
11723
+ // state set here is always current.
11724
+ function makeTreePicker() {
11725
+ let n = _trees.length;
11726
+ let row = document.createElement('div');
11727
+ row.className = 'aptx-tree-picker';
11728
+ row.insertAdjacentHTML('beforeend', makeGlyphButton('tree_prev', TREE_PREV_BUTTON, 'previous tree'));
11729
+ let select = document.createElement('select');
11730
+ select.id = TREE_SELECT;
11731
+ select.name = TREE_SELECT;
11732
+ select.title = 'Tree ' + (_treeIndex + 1) + ' of ' + n + ' -- pick another';
11733
+ for (let i = 0; i < n; ++i) {
11734
+ let opt = document.createElement('option');
11735
+ opt.value = String(i);
11736
+ let treeName = _trees[i].name ? String(_trees[i].name).trim() : '';
11737
+ opt.textContent = treeName || ('Tree ' + (i + 1));
11738
+ opt.selected = (i === _treeIndex);
11739
+ select.appendChild(opt);
11740
+ }
11741
+ row.appendChild(select);
11742
+ row.insertAdjacentHTML('beforeend', makeGlyphButton('tree_next', TREE_NEXT_BUTTON, 'next tree'));
11743
+ row.querySelector('#' + TREE_PREV_BUTTON).disabled = (_treeIndex === 0);
11744
+ row.querySelector('#' + TREE_NEXT_BUTTON).disabled = (_treeIndex === n - 1);
11745
+ return row;
11746
+ }
11747
+
9970
11748
  // Make the tree name/description block a keyboard-accessible
9971
11749
  // expand/collapse control, but only while its text is actually clamped
9972
11750
  // (otherwise the pointer cursor + click would be a misleading no-op).
@@ -10176,6 +11954,7 @@ function (root, d3, forester, phyloXml) {
10176
11954
  h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
10177
11955
  h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
10178
11956
  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)'));
11957
+ h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
10179
11958
  h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 'midpoint re-root'));
10180
11959
  h = h.concat('</div>');
10181
11960
  h = h.concat('</fieldset>');
@@ -10296,7 +12075,6 @@ function (root, d3, forester, phyloXml) {
10296
12075
  let val2 = idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1;
10297
12076
  let reset = idx === 0 ? RESET_SEARCH_A_BTN : RESET_SEARCH_B_BTN;
10298
12077
  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
12078
  let h = "";
10301
12079
  h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
10302
12080
  h = h.concat('<div class="aptx-search-menus">');
@@ -10308,7 +12086,6 @@ function (root, d3, forester, phyloXml) {
10308
12086
  h = h.concat('<input class="aptx-search-value2" style="display:none" title="upper bound of the range" type="text" name="' + val2 + '" id="' + val2 + '">');
10309
12087
  h = h.concat(makeButton('R', reset, resetTip));
10310
12088
  h = h.concat('</div>');
10311
- h = h.concat('<datalist id="' + dl + '"></datalist>');
10312
12089
  return h;
10313
12090
  }
10314
12091
 
@@ -10493,11 +12270,16 @@ function (root, d3, forester, phyloXml) {
10493
12270
  // definite result -- with it on, ladderizing a tree that is already
10494
12271
  // ladderized REVERSES it, so the outcome depends on the order the tree
10495
12272
  // happened to arrive in.
12273
+ // Returns the direction actually applied (largest first or not): when
12274
+ // the tree already stood that way and alternating was asked for, the
12275
+ // other one.
10496
12276
  function ladderizeSubtree(n, largestFirst, alternateIfUnchanged) {
10497
12277
  let changed = forester.ladderize(n, largestFirst);
10498
12278
  if (alternateIfUnchanged && !changed) {
10499
12279
  forester.ladderize(n, !largestFirst);
12280
+ return !largestFirst;
10500
12281
  }
12282
+ return largestFirst;
10501
12283
  }
10502
12284
 
10503
12285
  function setDisplayTypeButtons() {
@@ -10516,6 +12298,8 @@ function (root, d3, forester, phyloXml) {
10516
12298
 
10517
12299
  function updateButtonEnabledState() {
10518
12300
  syncZoomRowButtons(); // layout- and tree-dependent disables track every render
12301
+ // the uncollapse-all button lives only while something is collapsed, as on the desktop
12302
+ ((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
10519
12303
  if (_in_subtree) {
10520
12304
  enableButton(byId(RETURN_TO_SUPERTREE_BUTTON_BY_ONE));
10521
12305
  enableButton(byId(RETURN_TO_SUPERTREE_BUTTON));
@@ -10888,8 +12672,65 @@ function (root, d3, forester, phyloXml) {
10888
12672
  // Convenience methods for loading tree on HTML page
10889
12673
  // --------------------------------------------------------------
10890
12674
 
12675
+ // The mode as parseTree / parseTrees accept it. The retired brackets
12676
+ // flag used to be the third argument, so a boolean there means the old
12677
+ // four-argument shape and the mode is the fourth; a string is the mode
12678
+ // itself. PARSE_DEFAULTS, not literals -- initializeSettings applies the
12679
+ // same object, and it runs at launch(), AFTER this has already parsed.
12680
+ function resolveInternalLabelsMode(mode, legacyMode) {
12681
+ let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
12682
+ if (typeof m === 'boolean') {
12683
+ // the retired positional flag: true meant "every numeric internal
12684
+ // label is a confidence", which is 'confidence'.
12685
+ return m ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
12686
+ }
12687
+ return m === undefined ? PARSE_DEFAULTS.internalNumericLabels : m;
12688
+ }
12689
+
12690
+ // Every tree the data holds, the format picked from the content and the
12691
+ // location: a Nexus TREES block, a New Hampshire text with one tree per
12692
+ // ';', a phyloXML with several phylogenies; an Auspice dataset is one
12693
+ // tree. Bare numeric internal labels are promoted on every tree.
12694
+ function parseAllTrees(location, data, mode) {
12695
+ requireForester();
12696
+ let loc = location ? String(location).toLowerCase() : '';
12697
+ let text = forester.isString(data) ? data : '';
12698
+ let trees;
12699
+ // Nexus announces itself with "#NEXUS" on the first line, so the
12700
+ // content decides; the filename alone is enough too.
12701
+ if (/^\s*#nexus\b/i.test(text) || /\.(nex|nexus)$/.test(loc)) {
12702
+ trees = forester.parseNexus(data, true, false);
12703
+ if (trees.length === 0) {
12704
+ throw new Error('no tree found in the Nexus data');
12705
+ }
12706
+ trees.forEach(function (t) {
12707
+ promoteInternalLabels(t, mode);
12708
+ });
12709
+ } else if (/^\s*\{/.test(text) || /\.json$/.test(loc)) {
12710
+ trees = [archaeopteryx.parseAuspiceJson(data)];
12711
+ } else if (/^\s*</.test(text) || loc.substr(-3, 3) === 'xml') {
12712
+ // No other supported format can start with '<', so content alone
12713
+ // is enough -- pasted phyloXML has no filename to go by.
12714
+ requirePhyloXml();
12715
+ trees = phyloXml.parse(data, {trim: true, normalize: true});
12716
+ if (trees.length === 0) {
12717
+ throw new Error('no phylogeny found in the phyloXML data');
12718
+ }
12719
+ trees.forEach(function (t) {
12720
+ forester.addParents(t);
12721
+ });
12722
+ } else {
12723
+ trees = forester.parseNewHampshireTrees(data, true, false);
12724
+ trees.forEach(function (t) {
12725
+ promoteInternalLabels(t, mode);
12726
+ });
12727
+ }
12728
+ return trees;
12729
+ }
12730
+
10891
12731
  /**
10892
- * Convenience method for loading tree on HTML page
12732
+ * Parses tree data for an HTML page: the FIRST tree the data holds
12733
+ * (parseTrees returns them all).
10893
12734
  *
10894
12735
  * @param location - file name (only its extension is used; the content is
10895
12736
  * sniffed too, so a pasted tree with no name works)
@@ -10900,41 +12741,21 @@ function (root, d3, forester, phyloXml) {
10900
12741
  * position and is still accepted (and ignored) there,
10901
12742
  * with the mode following it.
10902
12743
  * @param legacyMode - the mode, when the retired flag occupies `mode`
10903
- * @returns {*}
12744
+ * @returns {*} the first tree
10904
12745
  */
10905
12746
  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;
12747
+ return parseAllTrees(location, data, resolveInternalLabelsMode(mode, legacyMode))[0];
12748
+ };
12749
+
12750
+ /**
12751
+ * Every tree the data holds, in file order, as an array: a Nexus TREES
12752
+ * block, a New Hampshire text with several ';'-terminated trees, a
12753
+ * phyloXML with several phylogenies (an Auspice dataset is one tree).
12754
+ * launch() takes the array and shows the first, with a picker in the
12755
+ * control panel for the others. Same arguments as parseTree.
12756
+ */
12757
+ archaeopteryx.parseTrees = function (location, data, mode, legacyMode) {
12758
+ return parseAllTrees(location, data, resolveInternalLabelsMode(mode, legacyMode));
10938
12759
  };
10939
12760
 
10940
12761
  // Show the viewer's own "working" card in a container. For the part of a
@@ -11007,9 +12828,11 @@ function (root, d3, forester, phyloXml) {
11007
12828
  + ' "internalNumericLabels".');
11008
12829
  }
11009
12830
  let c = config || {};
11010
- let tree;
12831
+ let trees;
11011
12832
  try {
11012
- tree = archaeopteryx.parseTree(fileName, data,
12833
+ // every tree the file holds: launch() shows the first and the
12834
+ // panel's picker offers the rest
12835
+ trees = archaeopteryx.parseTrees(fileName, data,
11013
12836
  effectiveInternalLabelsMode(c));
11014
12837
  } catch (e) {
11015
12838
  // Worth catching only to say that it was the parse, not the launch,
@@ -11023,7 +12846,7 @@ function (root, d3, forester, phyloXml) {
11023
12846
  // launch() already reports its own failures well enough; wrapping them
11024
12847
  // added nothing but a prefix, and swallowing them left the caller with
11025
12848
  // a blank page and no way to find out why.
11026
- return archaeopteryx.launch(container, tree, config);
12849
+ return archaeopteryx.launch(container, trees, config);
11027
12850
  };
11028
12851
 
11029
12852