archaeopteryx 3.2.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.2.1
23
+ // v 3.4.0
24
24
  // 2026-09-10
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
@@ -103,7 +103,7 @@ function (root, d3, forester, phyloXml) {
103
103
  // IIFE's own function name -- a plain object says what it is.)
104
104
  let archaeopteryx = {};
105
105
 
106
- const VERSION = '3.2.1';
106
+ const VERSION = '3.4.0';
107
107
  const WEBSITE = 'https://cmzmasek.github.io/archaeopteryx-js/';
108
108
  const DESKTOP_WEBSITE = 'https://cmzmasek.github.io/archaeopteryx/';
109
109
  const SOURCE_WEBSITE = 'https://github.com/cmzmasek/archaeopteryx-js';
@@ -251,6 +251,7 @@ function (root, d3, forester, phyloXml) {
251
251
  const LABEL_SIZE_CALC_FACTOR = 0.5;
252
252
  const LEGEND_LABEL_COLOR = 'legendLabelColor';
253
253
  const LEGEND_NODE_SHAPE = 'legendNodeShape';
254
+ const LEGEND_DOMAINS = 'legendDomains';
254
255
  const NH_EXPORT_FORMAT = 'Newick';
255
256
  const NEXUS_EXPORT_FORMAT = 'Nexus';
256
257
  const NODE_SIZE_MAX = 9;
@@ -283,10 +284,15 @@ function (root, d3, forester, phyloXml) {
283
284
  const TOP_AND_BOTTOM_BORDER_HEIGHT = 10;
284
285
  const TRANSITION_DURATION_DEFAULT = 750;
285
286
  // Above this many nodes the first draw is deferred by one frame behind a
286
- // "working" card, so the page shows it is busy instead of appearing frozen.
287
- // Below it everything stays synchronous exactly as before -- the deferral
288
- // buys nothing on a tree that draws in a few hundred milliseconds.
289
- const BIG_TREE_NODES = 2000;
287
+ // "working" card, so the page shows it is busy instead of appearing frozen,
288
+ // and later redraws coalesce instead of running synchronously. Below it
289
+ // everything stays synchronous exactly as before -- the deferral buys
290
+ // nothing on a tree that draws in a few hundred milliseconds.
291
+ //
292
+ // Raised from 2000 to 3000 on 2026-09-11: a redraw of the 18,512-node tree
293
+ // went from ~4.2 s to ~1.9 s that day, so the size at which a draw is slow
294
+ // enough to need announcing moved up with it.
295
+ const BIG_TREE_NODES = 3000;
290
296
  const WARNING = 'ArchaeopteryxJS: WARNING';
291
297
  const MESSAGE = 'ArchaeopteryxJS: ';
292
298
  const ERROR = 'ArchaeopteryxJS: ERROR: ';
@@ -347,6 +353,15 @@ function (root, d3, forester, phyloXml) {
347
353
  const DOWNLOAD_BUTTON = 'dl_b';
348
354
  const DYNAHIDE_CB = 'dynahide_cb';
349
355
  const MSA_CB = 'msa_cb';
356
+ const DOMAINS_CB = 'domains_cb';
357
+ const DOMAIN_CONTROLS = 'domain_controls';
358
+ const DOMAIN_WIDTH_DEC = 'domain_width_dec';
359
+ const DOMAIN_WIDTH_INC = 'domain_width_inc';
360
+ const DOMAIN_EVALUE_DEC = 'domain_evalue_dec';
361
+ const DOMAIN_EVALUE_INC = 'domain_evalue_inc';
362
+ const DOMAIN_EVALUE_READOUT = 'domain_evalue';
363
+ const DOMAIN_LABELS_SELECT = 'domain_labels';
364
+ const DOMAIN_GLOW_CB = 'domain_glow_cb';
350
365
  const TIME_AXIS_CB = 'timeaxis_cb';
351
366
  const TIME_GRID_CB = 'timegrid_cb';
352
367
  const MSA_SCROLL_ID = 'aptxmsascroll';
@@ -356,6 +371,10 @@ function (root, d3, forester, phyloXml) {
356
371
  const INTERNAL_LABEL_CB = 'intl_cb';
357
372
  const LABEL_COLOR_SELECT_MENU = 'lcs_menu';
358
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';
359
378
  // The desktop Archaeopteryx logo (forester/archaeopteryx_icon_assets/
360
379
  // archaeopteryx-anime.svg), inlined so the library stays a single file.
361
380
  // Gradient ids are prefixed: they were generic enough to collide with an
@@ -397,8 +416,6 @@ function (root, d3, forester, phyloXml) {
397
416
  const SEARCH_MODE_SELECT_1 = 'sms1';
398
417
  const SEARCH_VALUE2_0 = 'sv2_0';
399
418
  const SEARCH_VALUE2_1 = 'sv2_1';
400
- const SEARCH_DATALIST_0 = 'sdl0';
401
- const SEARCH_DATALIST_1 = 'sdl1';
402
419
  const SEARCH_AUTOCOMPLETE_CAP = 2000;
403
420
  const SEARCH_COMBINE_SELECT = 'scmb';
404
421
  const SEARCH_COMBINE_ROW = 'scmb_row';
@@ -485,6 +502,12 @@ function (root, d3, forester, phyloXml) {
485
502
  let _floatGroup = null;
486
503
  let _floatStrips = {}; // class -> {top, height} in tree coordinates
487
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;
488
511
  let _treeFn = null;
489
512
  let _vis = null; // the automatic visualizations: candidates, scales, choices
490
513
  let _w = null;
@@ -507,6 +530,28 @@ function (root, d3, forester, phyloXml) {
507
530
  let _msaScroller = null; // the fixed HTML range input, created lazily
508
531
  let _msaNav = null; // the bar around it: paging, jump-to-column, readout
509
532
  let _msaGeom = null; // window geometry of the last draw, for the hover readout
533
+ // ------ protein domain architectures (the desktop's domain display) ------
534
+ const DOMAIN_TRACK_START_GAP = 20; // px from the aligned label column to the backbone's start
535
+ const DOMAIN_TRACK_END_GAP = 10; // px kept free past the widest track
536
+ const DOMAIN_RADIAL_GAP = 4; // px from the label ring / the tip's label to the backbone, radial layouts
537
+ const DOMAIN_WIDTH_MIN = 20; // the track width stops growing / shrinking at these
538
+ const DOMAIN_WIDTH_MAX = 2000;
539
+ const DOMAIN_WIDTH_GROW = 1.2; // one d+ press
540
+ const DOMAIN_WIDTH_SHRINK = 0.8; // one d- press
541
+ const DOMAIN_WIDTH_VIEWPORT_FRACTION = 0.25; // the initial track width
542
+ const DOMAIN_RADIAL_WIDTH_FRACTION = 0.2; // ... capped at this share of the radius in radial layouts
543
+ const DOMAIN_SCALE_HEADROOM = 0.9; // drawn length over reserved length
544
+ const DOMAIN_BOX_MIN_H = 6;
545
+ const DOMAIN_BOX_MAX_H = 16;
546
+ const DOMAIN_BACKBONE_COLOR = 'rgb(100,100,100)'; // the same in both themes and in exports
547
+ const DOMAIN_SHADOW_COLOR = 'rgb(8,18,21)';
548
+ const DOMAIN_SHADOWS = [[0.4, 0.7, 40], [0.9, 1.5, 28], [1.6, 2.5, 17]]; // dx, dy, alpha of 255, in drawing order
549
+ const DOMAIN_GLOWS = [[3.2, 20], [1.6, 34]]; // grown by, alpha of 255
550
+ const DOMAIN_LEGEND_INSET = 10; // px from the bottom-right corner, the legend's home
551
+ const DOMAIN_LEGEND_ROW_MAX_PX = 240; // a legend row is clipped to this
552
+ const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
553
+ let _domain = null; // {width, palette, next, legendFrac}, set per launch
554
+ let _domainReserve = 0; // horizontal px reserved for the tracks, set with _w
510
555
  // ------ time axis (the desktop's geologic / calendar overlays) ------
511
556
  const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
512
557
  const TIME_CAL_RESERVE = 26; // the calendar year ruler
@@ -554,13 +599,32 @@ function (root, d3, forester, phyloXml) {
554
599
  let _zoomed_x_or_y = false;
555
600
  let _node_mouseover_div;
556
601
 
557
- 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
+ }
558
609
 
559
- if (_root.parent) {
560
- _root.parent.distToRoot = 0;
561
- }
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();
562
620
  forester.preOrderTraversalAll(_root, function (n) {
563
- 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
+ }
564
628
  });
565
629
  let distsToRoot = nodes.map(function (n) {
566
630
  return n.distToRoot;
@@ -575,13 +639,31 @@ function (root, d3, forester, phyloXml) {
575
639
  return yScale;
576
640
 
577
641
  function bl(node) {
578
- if (!node.branch_length || node.branch_length < 0) {
579
- return 0;
580
- } else if (!node.parent || !node.parent.parent) {
581
- return _basicTreeProperties.averageBranchLength * 0.5;
582
- }
583
- return node.branch_length;
642
+ return (!node.branch_length || node.branch_length < 0) ? 0 : node.branch_length;
643
+ }
644
+ }
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;
584
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;
585
667
  }
586
668
 
587
669
  // Current zoom scale (k) from the zoom behavior's stored transform.
@@ -613,6 +695,14 @@ function (root, d3, forester, phyloXml) {
613
695
  const OVERVIEW_HEIGHT = 111;
614
696
  const OVERVIEW_MARGIN = 12;
615
697
  const OVERVIEW_PAD = 5;
698
+ // Smallest the "you are here" rectangle is allowed to get, in overview
699
+ // pixels. Zoom vertically into a 13,000-tip tree and the visible slice is
700
+ // a fraction of one percent of it: the rectangle is then accurate,
701
+ // sub-pixel and useless. Below this it stops being a measurement and
702
+ // becomes a marker -- it still says WHERE you are, it just stops claiming
703
+ // to say exactly how much you can see. The inner frame is 132 x 101, so
704
+ // this is small enough never to mislead about position.
705
+ const OVERVIEW_VIEWPORT_MIN = 7;
616
706
 
617
707
  let _overviewGroup = null; // the whole overview, appended above the tree
618
708
  let _overviewContent = null; // the scaled miniature inside it
@@ -913,17 +1003,93 @@ function (root, d3, forester, phyloXml) {
913
1003
  // Rebuild the miniature. Called when the tree itself changes: the branch
914
1004
  // paths are copied straight from what was just rendered, so the overview
915
1005
  // matches the real display in either layout without redoing any geometry.
1006
+ // The links of the CURRENT layout. The overview is built from these --
1007
+ // from the MODEL -- never by reading back what was drawn.
1008
+ //
1009
+ // Reading the drawn tree is what broke it: update() animates the links
1010
+ // with a transition, and the overview was rebuilt from inside update(),
1011
+ // so every attribute it read was the frame BEFORE the one being drawn.
1012
+ // Logging from inside the function showed all 215 links degenerate on
1013
+ // EVERY rebuild, in every display mode -- the tree had not been drawn
1014
+ // yet. The desktop has never had this bug because its miniature is
1015
+ // likewise computed from the model.
1016
+ let _overviewLinks = null;
1017
+
1018
+ // The tree's extent for the overview, in un-zoomed tree coordinates:
1019
+ // branches as the layout placed them, plus the space the layout reserved
1020
+ // for external labels.
1021
+ //
1022
+ // ONE extent, used for BOTH the miniature's placement and the "is the
1023
+ // overview needed at all" test. That is the property that matters, and
1024
+ // getting it wrong is what made the first attempt at this worse than the
1025
+ // bug: the miniature was fitted to the branches while the visibility test
1026
+ // measured branches PLUS labels, so the "you are here" rectangle was
1027
+ // drawn against a different extent than the picture under it. The two may
1028
+ // live in different spaces -- the desktop deliberately keeps the
1029
+ // rectangle in frame space -- but they must derive from one extent.
1030
+ //
1031
+ // Labels count, by Christian's decision and as the desktop does it: the
1032
+ // question is whether what the user can SEE fits, and a cut-off label is
1033
+ // not visible.
1034
+ function overviewExtent() {
1035
+ if (_state.circularDisplay || _state.unrootedDisplay) {
1036
+ // maxRad already reaches the outer label ring -- fitCircular()
1037
+ // fits the display to exactly this.
1038
+ let r = _state.circularDisplay
1039
+ ? (_radial ? _radial.maxRad : null)
1040
+ : (_unroot ? _unroot.maxRad : null);
1041
+ if (!r || r <= 0) {
1042
+ return null;
1043
+ }
1044
+ return {x: -r, y: -r, width: 2 * r, height: 2 * r};
1045
+ }
1046
+ if (!_overviewLinks || _overviewLinks.length === 0) {
1047
+ return null;
1048
+ }
1049
+ // elbow() writes 'M source.y,source.x V target.x H target.y', so the
1050
+ // horizontal axis is .y and the vertical axis is .x.
1051
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1052
+ for (let i = 0, len = _overviewLinks.length; i !== len; ++i) {
1053
+ let l = _overviewLinks[i];
1054
+ let sx = l.source.y, tx = l.target.y;
1055
+ let sy = l.source.x, ty = l.target.x;
1056
+ if (sx < minX) { minX = sx; }
1057
+ if (tx < minX) { minX = tx; }
1058
+ if (sx > maxX) { maxX = sx; }
1059
+ if (tx > maxX) { maxX = tx; }
1060
+ if (sy < minY) { minY = sy; }
1061
+ if (ty < minY) { minY = ty; }
1062
+ if (sy > maxY) { maxY = sy; }
1063
+ if (ty > maxY) { maxY = ty; }
1064
+ }
1065
+ if (!isFinite(minX) || !isFinite(minY) || maxX <= minX) {
1066
+ return null;
1067
+ }
1068
+ // The label reservation the layout was sized against, MINUS the root
1069
+ // offset. calcMaxTreeLengthForDisplay() bundles both: the space kept
1070
+ // to the LEFT of the root and the space kept to the RIGHT for
1071
+ // external labels. Only the label half belongs on this edge.
1072
+ //
1073
+ // Including the root offset made the box exactly rootOffset too wide,
1074
+ // and since centerNode() shifts the view left by that same amount to
1075
+ // seat the root, the right edge of the box always fell just outside
1076
+ // the viewport: measured box=[0,13 1497x755] against
1077
+ // view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
1078
+ // no by precisely 254px and the overview appeared after every Fit.
1079
+ maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
1080
+ if (scaleBarShown()) {
1081
+ maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
1082
+ }
1083
+ let h = maxY - minY;
1084
+ return {x: minX, y: minY, width: maxX - minX, height: h > 0 ? h : 1};
1085
+ }
1086
+
916
1087
  function rebuildOverview() {
917
1088
  if (!_overviewGroup) {
918
1089
  return;
919
1090
  }
920
1091
  let size = svgSize();
921
- let box = null;
922
- try {
923
- box = _svgGroup.node().getBBox(); // in un-zoomed tree coordinates
924
- } catch {
925
- box = null;
926
- }
1092
+ let box = overviewExtent(); // from the MODEL: see overviewExtent()
927
1093
  if (!size || !box || box.width <= 0 || box.height <= 0) {
928
1094
  _overviewGroup.style('display', 'none');
929
1095
  _overviewMap = null;
@@ -942,15 +1108,32 @@ function (root, d3, forester, phyloXml) {
942
1108
  _overviewContent.attr('transform', 'translate(' + _overviewMap.tx + ',' + _overviewMap.ty + ') scale(' + scale + ')');
943
1109
 
944
1110
  let paths = [];
945
- _svgGroup.selectAll('path.link').each(function () {
946
- let d = this.getAttribute('d');
947
- if (d) {
948
- paths.push(d);
1111
+ if (_overviewLinks) {
1112
+ for (let i = 0, len = _overviewLinks.length; i !== len; ++i) {
1113
+ let d = elbow(_overviewLinks[i]); // the SAME routine the tree is drawn with
1114
+ if (d) {
1115
+ paths.push(d);
1116
+ }
949
1117
  }
950
- });
951
- let sel = _overviewContent.selectAll('path').data(paths);
1118
+ }
1119
+ // ONE path for the whole miniature, not one per link. Every elbow()
1120
+ // string starts with an absolute moveto, so concatenating them yields
1121
+ // a single path of many subpaths that renders identically -- they
1122
+ // already share one stroke, width and vector-effect, so nothing
1123
+ // per-link was being expressed by separate elements.
1124
+ //
1125
+ // It is worth doing because the alternative keeps a SECOND copy of
1126
+ // every link in the document: 18,512 extra elements on the big demo
1127
+ // tree, rewritten on every redraw. Measured on that tree, same data:
1128
+ // writing 18,512 paths 222 ms, writing the one concatenated path
1129
+ // 9 ms. Verified equivalent before adopting -- node ordering, the
1130
+ // index of the first node group, and the datum order of the inserted
1131
+ // paths all match, and the union bounding box is unchanged.
1132
+ let sel = _overviewContent.selectAll('path.aptx-overview-links')
1133
+ .data(paths.length > 0 ? [paths.join(' ')] : []);
952
1134
  sel.exit().remove();
953
1135
  sel.enter().append('path')
1136
+ .attr('class', 'aptx-overview-links')
954
1137
  .merge(sel)
955
1138
  .attr('d', function (d) {
956
1139
  return d;
@@ -968,7 +1151,7 @@ function (root, d3, forester, phyloXml) {
968
1151
  // collapsed clade has no drawn position.
969
1152
  let hits = [];
970
1153
  if (_root) {
971
- forester.preOrderTraversal(_root, function (n) {
1154
+ forEachDisplayed(function (n) {
972
1155
  let c = getFoundColor(n);
973
1156
  if (c) {
974
1157
  hits.push({node: n, color: c});
@@ -1023,10 +1206,32 @@ function (root, d3, forester, phyloXml) {
1023
1206
  let y0 = Math.max(OVERVIEW_PAD, _overviewMap.ty + (visY * _overviewMap.scale));
1024
1207
  let x1 = Math.min(OVERVIEW_WIDTH - OVERVIEW_PAD, _overviewMap.tx + ((visX + visW) * _overviewMap.scale));
1025
1208
  let y1 = Math.min(OVERVIEW_HEIGHT - OVERVIEW_PAD, _overviewMap.ty + ((visY + visH) * _overviewMap.scale));
1209
+ // Keep the rectangle visible when the visible slice is tiny. Grown
1210
+ // about its own centre, so the marker stays over the part of the tree
1211
+ // you are actually looking at, then pushed back inside the frame if
1212
+ // that growth would take it out.
1213
+ let floorSpan = function (a, b, lo, hi) {
1214
+ if ((b - a) >= OVERVIEW_VIEWPORT_MIN || (hi - lo) <= OVERVIEW_VIEWPORT_MIN) {
1215
+ return [a, b];
1216
+ }
1217
+ let mid = (a + b) / 2;
1218
+ let start = mid - (OVERVIEW_VIEWPORT_MIN / 2);
1219
+ let end = start + OVERVIEW_VIEWPORT_MIN;
1220
+ if (start < lo) {
1221
+ start = lo;
1222
+ end = lo + OVERVIEW_VIEWPORT_MIN;
1223
+ } else if (end > hi) {
1224
+ end = hi;
1225
+ start = hi - OVERVIEW_VIEWPORT_MIN;
1226
+ }
1227
+ return [start, end];
1228
+ };
1229
+ let xs = floorSpan(x0, x1, OVERVIEW_PAD, OVERVIEW_WIDTH - OVERVIEW_PAD);
1230
+ let ys = floorSpan(y0, y1, OVERVIEW_PAD, OVERVIEW_HEIGHT - OVERVIEW_PAD);
1026
1231
  _overviewViewport
1027
- .attr('x', x0).attr('y', y0)
1028
- .attr('width', Math.max(0, x1 - x0))
1029
- .attr('height', Math.max(0, y1 - y0));
1232
+ .attr('x', xs[0]).attr('y', ys[0])
1233
+ .attr('width', Math.max(0, xs[1] - xs[0]))
1234
+ .attr('height', Math.max(0, ys[1] - ys[0]));
1030
1235
  }
1031
1236
 
1032
1237
  function zoom(event) {
@@ -1263,6 +1468,9 @@ function (root, d3, forester, phyloXml) {
1263
1468
  if (item.danger) {
1264
1469
  b.className = 'aptx-menu-danger';
1265
1470
  }
1471
+ if (item.disabled) {
1472
+ b.disabled = true; // offered but not applicable here, as the desktop greys it
1473
+ }
1266
1474
  b.addEventListener('click', function (e) {
1267
1475
  e.stopPropagation();
1268
1476
  removeNodeMenu();
@@ -1311,6 +1519,79 @@ function (root, d3, forester, phyloXml) {
1311
1519
  document.addEventListener('keydown', onKey);
1312
1520
  }
1313
1521
 
1522
+ // One set of listeners on the tree group instead of five on every node.
1523
+ //
1524
+ // These used to be attached in the node enter: click and contextmenu on
1525
+ // each g.node, and mouseover/mousemove/mouseout on each
1526
+ // circle.nodeCircleOptions. On an 18,512-node tree that is 92,560
1527
+ // registrations on the first draw, and it measured 300-450 ms of one.
1528
+ // Mouse events bubble, so one listener per type on the group does the same
1529
+ // work -- find which node the event came from, and hand the handler its
1530
+ // datum. The handlers never used `this`, which is what makes this a
1531
+ // straight substitution rather than a rewrite.
1532
+ //
1533
+ // Note it is mouseover/mouseout, NOT mouseenter/mouseleave: only the
1534
+ // former pair bubbles, so only the former pair can be delegated.
1535
+ function attachNodeEventDelegation(group) {
1536
+
1537
+ // d3 stores a node's datum on the element as __data__, which is all
1538
+ // selection.datum() reads. Going straight to it avoids allocating a
1539
+ // selection on every mousemove.
1540
+ function datumOf(el) {
1541
+ return el ? el.__data__ : null;
1542
+ }
1543
+
1544
+ // The hover target stays the invisible 5px circle rather than the whole
1545
+ // node, or the tooltip would follow the labels too.
1546
+ function hoverDatum(event) {
1547
+ let t = event.target;
1548
+ if (!t || !t.classList || !t.classList.contains('nodeCircleOptions')) {
1549
+ return null;
1550
+ }
1551
+ return datumOf(t.parentNode);
1552
+ }
1553
+
1554
+ // Click and the context menu take the whole node group, so the node's
1555
+ // LABEL works as a target too -- a much easier thing to aim at than a
1556
+ // 5px circle.
1557
+ function nodeDatum(event) {
1558
+ let t = event.target;
1559
+ return datumOf(t && t.closest ? t.closest('g.node') : null);
1560
+ }
1561
+
1562
+ group
1563
+ .on('click', function (event) {
1564
+ let d = nodeDatum(event);
1565
+ if (d && _treeFn.clickEvent) {
1566
+ _treeFn.clickEvent(event, d);
1567
+ }
1568
+ })
1569
+ .on('contextmenu', function (event) {
1570
+ let d = nodeDatum(event);
1571
+ if (d && _treeFn.clickEvent) {
1572
+ event.preventDefault(); // ours instead of the browser's menu
1573
+ _treeFn.clickEvent(event, d);
1574
+ }
1575
+ })
1576
+ .on('mouseover', function (event) {
1577
+ let d = hoverDatum(event);
1578
+ if (d) {
1579
+ mouseover(event, d);
1580
+ }
1581
+ })
1582
+ .on('mousemove', function (event) {
1583
+ let d = hoverDatum(event);
1584
+ if (d) {
1585
+ mousemove(event, d);
1586
+ }
1587
+ })
1588
+ .on('mouseout', function (event) {
1589
+ if (hoverDatum(event)) {
1590
+ mouseout();
1591
+ }
1592
+ });
1593
+ }
1594
+
1314
1595
  function mouseout() {
1315
1596
  hideHoverGlow();
1316
1597
  // Fade only. Emptying the tooltip here collapsed it to nothing but its
@@ -1330,9 +1611,16 @@ function (root, d3, forester, phyloXml) {
1330
1611
  // the next launch).
1331
1612
  //
1332
1613
  // The candidates come from forester.visualizationCandidates: the tree is
1333
- // the only input. Each candidate carries its scales, built once per
1334
- // launch from the COMPLETE tree -- so a value keeps its colour inside a
1335
- // subtree view even when the subtree does not contain it.
1614
+ // the only input, and candidacy is decided on the TREE -- at launch, and
1615
+ // again only after a permanent edit (a deletion). A VIEW -- the subtree
1616
+ // the user switched into -- never changes what is offered or chosen; it
1617
+ // only re-summarizes each candidate over the tips on screen (legend rows
1618
+ // and counts, the "no value" row, a numeric field's band and gradient
1619
+ // range) through forester.visualizationSummary. Colour and shape
1620
+ // identities live in the memory maps below and so survive every view.
1621
+ // Until 2026-09-12 the classifier was re-run per view and its refusals
1622
+ // applied to the chosen field, which dropped the colouring in 61% of the
1623
+ // corpus's clades and did not bring it back on return.
1336
1624
  // The tree as displayed right now: the subtree the user switched into,
1337
1625
  // otherwise the (possibly edited) full tree.
1338
1626
  function displayedRoot() {
@@ -1379,7 +1667,8 @@ function (root, d3, forester, phyloXml) {
1379
1667
  if (!_settings.enableVisualizations) {
1380
1668
  return;
1381
1669
  }
1382
- computeVisualizationCandidates(_treeData);
1670
+ installCandidates(forester.visualizationCandidates(_treeData));
1671
+ summarizeView(_treeData);
1383
1672
  // The embedder may name the visualization to open with (the
1384
1673
  // initialVisualization setting, matched case-insensitively against
1385
1674
  // the Color-menu label or the internal id). A name this tree cannot
@@ -1406,14 +1695,20 @@ function (root, d3, forester, phyloXml) {
1406
1695
  _vis.autoColorId = requested.id;
1407
1696
  _vis.colorId = requested.id;
1408
1697
  _state.showVisualizations = true;
1409
- } else if (_vis.candidates.length > 0 && !_vis.candidates[0].wide) {
1410
- // Auto-apply the best candidate: the classifier returns them best
1411
- // first, so a tree opens already coloured by its most informative
1412
- // field instead of grey with a menu to discover. A wide field
1413
- // (21+ values, legend capped) is offered but never imposed.
1414
- _vis.autoColorId = _vis.candidates[0].id;
1415
- _vis.colorId = _vis.autoColorId;
1416
- _state.showVisualizations = true;
1698
+ } else {
1699
+ // Auto-apply the best candidate that is not WIDE: the classifier
1700
+ // returns them best first, so a tree opens already coloured by
1701
+ // its most informative field instead of grey with a menu to
1702
+ // discover. A wide field (21+ values, legend capped) is offered
1703
+ // but never imposed -- and it does not block what sits below it
1704
+ // either: a tree carrying a wide field and an In-Group opens by
1705
+ // the In-Group, not uncoloured.
1706
+ let first = forester.openingVisualization(_vis.candidates);
1707
+ if (first) {
1708
+ _vis.autoColorId = first.id;
1709
+ _vis.colorId = _vis.autoColorId;
1710
+ _state.showVisualizations = true;
1711
+ }
1417
1712
  }
1418
1713
  }
1419
1714
 
@@ -1743,49 +2038,83 @@ function (root, d3, forester, phyloXml) {
1743
2038
  buildCategoryScale(vis);
1744
2039
  }
1745
2040
 
1746
- function computeVisualizationCandidates(viewRoot) {
1747
- _vis.candidates = [];
2041
+ // The tree's candidates become THE candidates: the menu, the tiers, the
2042
+ // ids the memory maps are keyed by. Their scales come from summarizeView.
2043
+ function installCandidates(list) {
2044
+ _vis.candidates = list;
1748
2045
  _vis.byId = {};
1749
- forester.visualizationCandidates(viewRoot).forEach(function (c) {
1750
- if (c.numeric) {
1751
- // ramps are POSITION in the view's range, not identity: a
1752
- // subtree spanning six years gets a full-width gradient
1753
- let nums = c.values.map(Number);
1754
- let mean = nums.reduce(function (a, b) {
1755
- return a + b;
1756
- }, 0) / nums.length;
1757
- c.rangeScale = d3.scaleLinear()
1758
- .range(VIS_COLOR_RAMP)
1759
- .domain([nums[0], mean, nums[nums.length - 1]]);
1760
- }
1761
- function memoryKey(v) {
1762
- return visMemoryKey(c, v);
1763
- }
1764
- // a switchable candidate needs both scales standing by
1765
- if (c.colorMode === 'category' || c.switchable) {
1766
- buildCategoryScale(c);
1767
- }
1768
- if (c.shape) {
1769
- let mem = _vis.shapeMemory[c.id] || (_vis.shapeMemory[c.id] = {});
1770
- let next = _vis.shapeNext[c.id] || 0;
1771
- c.values.forEach(function (v) {
1772
- let k = memoryKey(v);
1773
- if (!(k in mem)) {
1774
- mem[k] = VIS_SHAPES[next++ % VIS_SHAPES.length];
1775
- }
1776
- });
1777
- _vis.shapeNext[c.id] = next;
1778
- c.shapeScale = d3.scaleOrdinal()
1779
- .domain(c.values)
1780
- .range(c.values.map(function (v) {
1781
- return mem[memoryKey(v)];
1782
- }));
1783
- }
1784
- _vis.candidates.push(c);
2046
+ list.forEach(function (c) {
1785
2047
  _vis.byId[c.id] = c;
1786
2048
  });
1787
2049
  }
1788
2050
 
2051
+ // Re-summarizes every candidate over the tips under `root` and rebuilds
2052
+ // its scales from that: the whole tree at launch and after an edit, the
2053
+ // displayed subtree on every view change.
2054
+ function summarizeView(root) {
2055
+ // the tips on screen: the view's, minus what collapsed clades hide
2056
+ let tips = [];
2057
+ (function walk(n) {
2058
+ if (!n.children) {
2059
+ tips.push(n);
2060
+ } else if (!isCollapsed(n)) {
2061
+ for (let i = 0; i < n.children.length; ++i) {
2062
+ walk(n.children[i]);
2063
+ }
2064
+ }
2065
+ })(root);
2066
+ _vis.candidates.forEach(function (c) {
2067
+ let s = forester.visualizationSummary(c, tips);
2068
+ c.values = s.values;
2069
+ c.counts = s.counts;
2070
+ c.coverage = s.coverage;
2071
+ c.total = s.total;
2072
+ if (c.numeric) {
2073
+ c.colorMode = s.colorMode;
2074
+ c.switchable = s.switchable;
2075
+ }
2076
+ buildScales(c);
2077
+ });
2078
+ }
2079
+
2080
+ function buildScales(c) {
2081
+ c.rangeScale = null;
2082
+ if (c.numeric && c.values.length > 0) {
2083
+ // ramps are POSITION in the view's range, not identity: a
2084
+ // subtree spanning six years gets a full-width gradient
2085
+ let nums = c.values.map(Number);
2086
+ let mean = nums.reduce(function (a, b) {
2087
+ return a + b;
2088
+ }, 0) / nums.length;
2089
+ c.rangeScale = d3.scaleLinear()
2090
+ .range(VIS_COLOR_RAMP)
2091
+ .domain([nums[0], mean, nums[nums.length - 1]]);
2092
+ }
2093
+ function memoryKey(v) {
2094
+ return visMemoryKey(c, v);
2095
+ }
2096
+ // a switchable candidate needs both scales standing by
2097
+ if (c.colorMode === 'category' || c.switchable) {
2098
+ buildCategoryScale(c);
2099
+ }
2100
+ if (c.shape) {
2101
+ let mem = _vis.shapeMemory[c.id] || (_vis.shapeMemory[c.id] = {});
2102
+ let next = _vis.shapeNext[c.id] || 0;
2103
+ c.values.forEach(function (v) {
2104
+ let k = memoryKey(v);
2105
+ if (!(k in mem)) {
2106
+ mem[k] = VIS_SHAPES[next++ % VIS_SHAPES.length];
2107
+ }
2108
+ });
2109
+ _vis.shapeNext[c.id] = next;
2110
+ c.shapeScale = d3.scaleOrdinal()
2111
+ .domain(c.values)
2112
+ .range(c.values.map(function (v) {
2113
+ return mem[memoryKey(v)];
2114
+ }));
2115
+ }
2116
+ }
2117
+
1789
2118
  function currentColorVis() {
1790
2119
  return ((_vis && _vis.colorId) ? _vis.byId[_vis.colorId] : null) || null;
1791
2120
  }
@@ -1797,7 +2126,10 @@ function (root, d3, forester, phyloXml) {
1797
2126
  // A candidate's colour mode, honouring the legend's [colors] / [gradient]
1798
2127
  // switch, and the scale that mode wants.
1799
2128
  function colorModeOf(vis) {
1800
- return _vis.colorModeById[vis.id] || vis.colorMode;
2129
+ // the legend's [colors]/[gradient] choice holds only while the field
2130
+ // is switchable in THIS view (as on the desktop): above 20 distinct
2131
+ // values it is a gradient, whatever was chosen inside a smaller clade
2132
+ return (vis.switchable && _vis.colorModeById[vis.id]) || vis.colorMode;
1801
2133
  }
1802
2134
 
1803
2135
  // Legend row order: numbers list in numeric order by default, words by
@@ -1847,9 +2179,10 @@ function (root, d3, forester, phyloXml) {
1847
2179
  // a titled, bordered panel; value rows with counts; a [by count] / [A-Z]
1848
2180
  // sort toggle in the title row (count-first is the desktop's default);
1849
2181
  // and for numeric ranges a horizontal gradient bar with the min and max
1850
- // beneath it. Unlike the desktop there is no "+N more" cap machinery:
1851
- // the classifier already refuses fields above 20 values, so a legend
1852
- // can never overflow.
2182
+ // beneath it. A long legend shows its 20 most frequent rows and a
2183
+ // [+N more] chip, like the desktop's. Rows, counts and the "no value"
2184
+ // row describe the tips on SCREEN (the view's summary); the colours are
2185
+ // launch-lifetime identities from the memory maps.
1853
2186
  //
1854
2187
  // The cards live in the tree's own svg, so they ride along into the PNG
1855
2188
  // and SVG exports, and their colours are the four theme colours the
@@ -1915,7 +2248,9 @@ function (root, d3, forester, phyloXml) {
1915
2248
  const titleFont = '600 ' + (FS + 1) + 'px ' + FONT_DEFAULTS;
1916
2249
  const ink = _state.labelColorDefault;
1917
2250
  const frame = _state.branchColorDefault;
1918
- const isRange = kind === 'color' && colorModeOf(vis) === 'range';
2251
+ // a field with no value in this view has no range to draw: the card
2252
+ // is then just its "no value" row
2253
+ const isRange = kind === 'color' && colorModeOf(vis) === 'range' && vis.values.length > 0;
1919
2254
 
1920
2255
  const LEGEND_MAX_ROWS = 20;
1921
2256
  let rows = [];
@@ -2283,6 +2618,28 @@ function (root, d3, forester, phyloXml) {
2283
2618
  });
2284
2619
  }
2285
2620
 
2621
+ // Animate when there is something to animate; otherwise set the values
2622
+ // straight onto the selection.
2623
+ //
2624
+ // d3 builds a schedule for every element a transition touches, whether or
2625
+ // not that transition will animate anything -- and a duration of 0 animates
2626
+ // nothing. Nearly every redraw here asks for 0: all 39 call sites that pass
2627
+ // a duration pass 0, and only the 15 that omit the argument animate. So the
2628
+ // common path was paying for machinery it never used. On the 18,512-node
2629
+ // tree, ONE `transition.select('path')` measured ~190-235 ms against ~15 ms
2630
+ // for the same select on a plain selection -- and the node update does six
2631
+ // such selects, plus the node and link roots.
2632
+ //
2633
+ // interrupt() first: a transition still running from an earlier redraw would
2634
+ // otherwise finish afterwards and overwrite what we just set. The transition
2635
+ // this replaces cancelled that animation implicitly, by taking over the same
2636
+ // schedule; a plain selection has to say so.
2637
+ function animateOrSet(selection, duration) {
2638
+ return duration > 0
2639
+ ? selection.transition().duration(duration)
2640
+ : selection.interrupt();
2641
+ }
2642
+
2286
2643
  function update(source, transitionDuration, doNotRecalculateWidth) {
2287
2644
 
2288
2645
  if (!source) {
@@ -2300,6 +2657,8 @@ function (root, d3, forester, phyloXml) {
2300
2657
  // the alignment track reserves its window on the right, so the
2301
2658
  // tree and labels compress to make room rather than overlapping
2302
2659
  _msaReserve = 0;
2660
+ // and the domain tracks reserve their column past the labels
2661
+ _domainReserve = domainReserve();
2303
2662
  if (msaShown()) {
2304
2663
  let fullPx = _basicTreeProperties.maxMolSeqLength * MSA_COL_WIDTH;
2305
2664
  // budgeted from the VIEWPORT: sizing it from the zoomed layout
@@ -2310,11 +2669,11 @@ function (root, d3, forester, phyloXml) {
2310
2669
  let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
2311
2670
  // a wide alignment must not squeeze the tree itself away: the
2312
2671
  // band yields until the tree keeps its minimum share
2313
- let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2672
+ let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2314
2673
  band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
2315
2674
  _msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
2316
2675
  }
2317
- _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve;
2676
+ _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve;
2318
2677
  if (_w < 1) {
2319
2678
  _w = 1;
2320
2679
  }
@@ -2328,17 +2687,24 @@ function (root, d3, forester, phyloXml) {
2328
2687
  - bottomOverlayReserve());
2329
2688
  _treeFn = _treeFn.size([_clusterH, _w]);
2330
2689
 
2690
+ // A collapsed clade is a leaf that asks for more than one row: the
2691
+ // separation between neighbouring leaves is the mean of their
2692
+ // weights, a plain tip weighing 1 and a collapsed clade 2r - 1 so
2693
+ // that it spans r rows (collapsedRows).
2694
+ let leafWeight = function (hn) {
2695
+ return isCollapsed(hn.data) ? (2 * collapsedRows(hn.data)) - 1 : 1;
2696
+ };
2331
2697
  _treeFn = _treeFn.separation(function separation(a, b) {
2332
- return a.parent === b.parent ? 1 : 1;
2698
+ return (leafWeight(a) + leafWeight(b)) / 2;
2333
2699
  });
2334
2700
 
2335
- let uncollsed_nodes = forester.calcSumOfExternalDescendants(_root);
2336
2701
  // d3 v7: d3.cluster() lays out a d3.hierarchy rather than mutating our
2337
2702
  // own nodes in place (as d3 v3's d3.layout.cluster().nodes() did). Run
2338
2703
  // the layout on a hierarchy, then copy the computed x/y/depth back onto
2339
- // the forester nodes so the rest of the renderer is unchanged.
2704
+ // the forester nodes so the rest of the renderer is unchanged. A
2705
+ // collapsed node ends the hierarchy: what it hides is not laid out.
2340
2706
  let hierarchy = d3.hierarchy(_root, function (d) {
2341
- return d.children;
2707
+ return isCollapsed(d) ? null : d.children;
2342
2708
  });
2343
2709
  _treeFn(hierarchy);
2344
2710
  hierarchy.each(function (hn) {
@@ -2349,6 +2715,48 @@ function (root, d3, forester, phyloXml) {
2349
2715
  let nodes = hierarchy.descendants().map(function (hn) {
2350
2716
  return hn.data;
2351
2717
  }).reverse();
2718
+ let leaves = hierarchy.leaves();
2719
+ let uncollsed_nodes = leaves.length; // the rows on screen: tips and collapsed clades
2720
+ {
2721
+ // px per row unit: the leaves span the cluster height in
2722
+ // separation units (a collapsed clade counts for its rows)
2723
+ let units = 0;
2724
+ for (let i = 1; i < leaves.length; ++i) {
2725
+ units += (leafWeight(leaves[i - 1]) + leafWeight(leaves[i])) / 2;
2726
+ }
2727
+ _rowUnit = units > 0 ? (leaves[leaves.length - 1].x - leaves[0].x) / units : _clusterH;
2728
+ }
2729
+ _cladogramUnit = hierarchy.height > 0 ? _w / hierarchy.height : _w;
2730
+
2731
+ // The wrapper is laid out as a level of its own, so a cladogram spent
2732
+ // one depth unit on the invisible branch above the root. Pull the
2733
+ // root back to 0 and stretch the rest to the full width; the stub is
2734
+ // placed afterwards (placeRootStub), and a phylogram overrides these
2735
+ // positions anyway.
2736
+ {
2737
+ let top = topNode();
2738
+ if (top && top.y > 0 && _w > top.y) {
2739
+ let unit = top.y;
2740
+ let stretch = _w / (_w - unit);
2741
+ forEachDisplayed(function (n) {
2742
+ if (n !== _root) {
2743
+ n.y = (n.y - unit) * stretch;
2744
+ }
2745
+ });
2746
+ _root.y = 0;
2747
+ _cladogramUnit = unit * stretch;
2748
+ }
2749
+ // a collapsed clade is laid out on the tip column like any leaf;
2750
+ // in a cladogram its apex steps back one level so the wedge ends
2751
+ // where the tips would (a phylogram places it by distance)
2752
+ if (!_state.phylogram) {
2753
+ forEachDisplayed(function (n) {
2754
+ if (isCollapsed(n)) {
2755
+ n.y -= _cladogramUnit;
2756
+ }
2757
+ });
2758
+ }
2759
+ }
2352
2760
 
2353
2761
  // The dim gate: only while a hit is actually DRAWN, so the tree never
2354
2762
  // washes out with nothing emphasised (a 0-hit search, or every hit
@@ -2365,11 +2773,13 @@ function (root, d3, forester, phyloXml) {
2365
2773
  let links = hierarchy.links().map(function (link) {
2366
2774
  return {source: link.source.data, target: link.target.data};
2367
2775
  });
2776
+ _overviewLinks = links; // the overview is built from these, not from the DOM
2368
2777
  let gap = _state.nodeLabelGap;
2369
2778
 
2370
2779
  if (_state.phylogram === true) {
2371
2780
  _yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
2372
2781
  }
2782
+ placeRootStub();
2373
2783
 
2374
2784
  if (_state.circularDisplay) {
2375
2785
  let maxY = 0;
@@ -2393,17 +2803,26 @@ function (root, d3, forester, phyloXml) {
2393
2803
  // branchLengthScaling just wrote) and are one constant step per
2394
2804
  // level in cladogram mode; the radial budget matches circular's.
2395
2805
  let budget = Math.min(_displayWidth, _displayHeight) * 0.42;
2806
+ // The fan is laid out from the displayed tree's OWN root, never
2807
+ // from the wrapper above it: an unrooted display draws no root
2808
+ // branch at all, not the file's real one and not the stub
2809
+ // (Christian, 2026-09-13). Laid out from the wrapper, the root
2810
+ // hung one cladogram step -- or its own branch length -- off the
2811
+ // centre on a spoke leading in from nowhere.
2812
+ let top = topNode() || _root;
2396
2813
  let lengthOf;
2814
+ let pxPerUnit = 0; // the phylogram's scale, for the scale bar
2397
2815
  if (_state.phylogram) {
2398
2816
  let maxDist = 0;
2399
- forester.preOrderTraversal(_root, function (n) {
2400
- if (!n.children && n.distToRoot > maxDist) {
2401
- maxDist = n.distToRoot;
2817
+ forester.preOrderTraversal(top, function (n) {
2818
+ if (!n.children && n.distToRoot - top.distToRoot > maxDist) {
2819
+ maxDist = n.distToRoot - top.distToRoot;
2402
2820
  }
2403
2821
  });
2404
2822
  let factor = maxDist > 0 ? budget / maxDist : 1;
2823
+ pxPerUnit = maxDist > 0 ? factor : 0;
2405
2824
  lengthOf = function (n) {
2406
- return Math.max(0, n.distToRoot - (n.parent ? n.parent.distToRoot : 0)) * factor;
2825
+ return Math.max(0, n.distToRoot - n.parent.distToRoot) * factor;
2407
2826
  };
2408
2827
  } else {
2409
2828
  let maxDepth = 0;
@@ -2412,12 +2831,20 @@ function (root, d3, forester, phyloXml) {
2412
2831
  maxDepth = nodes[i].depth;
2413
2832
  }
2414
2833
  }
2415
- let step = budget / Math.max(1, maxDepth);
2834
+ // depths count from the wrapper, so the tree's own levels are one fewer
2835
+ let step = budget / Math.max(1, maxDepth - (top === _root ? 0 : 1));
2416
2836
  lengthOf = function () {
2417
2837
  return step;
2418
2838
  };
2419
2839
  }
2420
- _unroot = forester.equalAngleLayout(_root, UNROOTED_START_ANGLE + _radialRotation, lengthOf);
2840
+ _unroot = forester.equalAngleLayout(top, UNROOTED_START_ANGLE + _radialRotation, lengthOf);
2841
+ _unroot.pxPerUnit = pxPerUnit;
2842
+ if (top !== _root) {
2843
+ // the wrapper sits on the root, so their link has no length
2844
+ _root.ux = top.ux;
2845
+ _root.uy = top.uy;
2846
+ _root.uangle = top.uangle;
2847
+ }
2421
2848
  } else {
2422
2849
  _radial = null;
2423
2850
  _unroot = null;
@@ -2438,6 +2865,7 @@ function (root, d3, forester, phyloXml) {
2438
2865
  });
2439
2866
  }
2440
2867
  }
2868
+ syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
2441
2869
 
2442
2870
  updateButtonEnabledState();
2443
2871
 
@@ -2446,21 +2874,14 @@ function (root, d3, forester, phyloXml) {
2446
2874
  return d.id || (d.id = ++_i);
2447
2875
  });
2448
2876
 
2877
+ // No listeners here: click, contextmenu and the hover trio are
2878
+ // delegated once to the tree group -- see attachNodeEventDelegation().
2449
2879
  let nodeEnter = node.enter().append('g')
2450
2880
  .attr('class', 'node')
2451
2881
  .attr('transform', function () {
2452
2882
  return 'translate(' + source.y0 + ',' + source.x0 + ')';
2453
2883
  })
2454
- .style('cursor', 'default')
2455
- .on('click', _treeFn.clickEvent)
2456
- // Right-click opens the same menu. The left-click target is an
2457
- // invisible 5px circle on the node itself, which is hard to hit; this
2458
- // handler sits on the whole node group, so the node's LABEL works as
2459
- // the target too -- a much easier thing to aim at.
2460
- .on('contextmenu', function (event, d) {
2461
- event.preventDefault(); // ours instead of the browser's menu
2462
- _treeFn.clickEvent.call(this, event, d);
2463
- });
2884
+ .style('cursor', 'default');
2464
2885
 
2465
2886
 
2466
2887
  // Only the three elements EVERY node needs are created here. The other
@@ -2483,11 +2904,6 @@ function (root, d3, forester, phyloXml) {
2483
2904
  .attr('r', 0);
2484
2905
 
2485
2906
  nodeEnter.append('circle')
2486
- .on("mouseover", mouseover)
2487
- .on("mousemove", function (event, d) {
2488
- mousemove(event, d);
2489
- })
2490
- .on("mouseout", mouseout)
2491
2907
  .style('cursor', 'pointer')
2492
2908
  .style('opacity', '0')
2493
2909
  .attr('class', 'nodeCircleOptions')
@@ -2509,6 +2925,7 @@ function (root, d3, forester, phyloXml) {
2509
2925
  // d3 v4+ no longer folds entered nodes into the update selection, so
2510
2926
  // merge them before the shared styling/positioning below.
2511
2927
  node = nodeEnter.merge(node);
2928
+ drawCollapsedClades(node);
2512
2929
 
2513
2930
  // Create the optional children only where they will show something,
2514
2931
  // and drop them where they will not. Must run BEFORE node.transition()
@@ -2708,20 +3125,29 @@ function (root, d3, forester, phyloXml) {
2708
3125
  });
2709
3126
  }
2710
3127
 
2711
- node.select('circle.nodeCircle')
2712
- .attr('r', function (d) {
2713
- if (((_state.showVisualizations && !_state.showNodeEvents) && (makeNodeFillColor(d) === _state.backgroundColorDefault))) {
2714
- return 0;
2715
- }
2716
- return makeNodeSize(d);
2717
- })
2718
- .style('stroke', function (d) {
2719
- return makeNodeStrokeColor(d);
2720
- })
2721
- .style('stroke-width', _state.branchWidthDefault)
2722
- .style('fill', function (d) {
2723
- return (_state.showVisualizations || _state.showNodeEvents || isNodeFound(d) || isNodeSelected(d)) ? makeNodeFillColor(d) : _state.backgroundColorDefault;
2724
- });
3128
+ // One pass, and the fill colour computed once.
3129
+ //
3130
+ // This was four chained .attr/.style calls, so d3 walked all 18.5k
3131
+ // circles four times; and makeNodeFillColor() was called TWICE per
3132
+ // node -- once to decide whether the dot collapses to r=0, once for
3133
+ // the fill itself -- which is not a cheap accessor: it checks the
3134
+ // search hits, the node events, the visualization colour and the
3135
+ // node's own style.
3136
+ //
3137
+ // The union of the two conditions that need the colour is just the
3138
+ // fill condition, since (showVisualizations && !showNodeEvents)
3139
+ // implies showVisualizations. So it is computed once when needed and
3140
+ // reused, and left null when neither branch wants it.
3141
+ node.select('circle.nodeCircle').each(function (d) {
3142
+ let fill = (_state.showVisualizations || _state.showNodeEvents
3143
+ || isNodeFound(d) || isNodeSelected(d)) ? makeNodeFillColor(d) : null;
3144
+ let r = ((_state.showVisualizations && !_state.showNodeEvents)
3145
+ && (fill === _state.backgroundColorDefault)) ? 0 : makeNodeSize(d);
3146
+ this.setAttribute('r', r);
3147
+ this.style.stroke = makeNodeStrokeColor(d);
3148
+ this.style.strokeWidth = _state.branchWidthDefault;
3149
+ this.style.fill = (fill === null) ? _state.backgroundColorDefault : fill;
3150
+ });
2725
3151
 
2726
3152
 
2727
3153
  // Dim Non-Matches: one opacity on the node GROUP dims its labels, dot,
@@ -2732,16 +3158,24 @@ function (root, d3, forester, phyloXml) {
2732
3158
  return (_dimNonMatches && !getFoundColor(d)) ? DIM_NON_MATCH_OPACITY : null;
2733
3159
  });
2734
3160
 
2735
- let nodeUpdate = node.transition()
2736
- .duration(transitionDuration)
3161
+ let nodeUpdate = animateOrSet(node, transitionDuration)
2737
3162
  .attr('transform', function (d) {
2738
3163
  return nodeTransform(d);
2739
3164
  });
2740
3165
 
2741
- nodeUpdate.select('text')
3166
+ // The same rule for a node's CHILD elements: derive from the transition
3167
+ // when animating, else select straight off the node and interrupt any
3168
+ // animation still on that child.
3169
+ function nodeChild(selector) {
3170
+ return transitionDuration > 0
3171
+ ? nodeUpdate.select(selector)
3172
+ : node.select(selector).interrupt();
3173
+ }
3174
+
3175
+ nodeChild('text')
2742
3176
  .style('fill-opacity', 1);
2743
3177
 
2744
- nodeUpdate.select('text.extlabel')
3178
+ nodeChild('text.extlabel')
2745
3179
  .text(function (d) {
2746
3180
  return d._extLabelText; // computed in syncOptionalNodeChildren
2747
3181
  });
@@ -2750,17 +3184,17 @@ function (root, d3, forester, phyloXml) {
2750
3184
  // has already removed these elements when the switch is off, and a
2751
3185
  // .select() that finds nothing still walks every node to find out.
2752
3186
  if (_state.showBranchLengthValues) {
2753
- nodeUpdate.select('text.bllabel').text(makeBranchLengthLabel);
3187
+ nodeChild('text.bllabel').text(makeBranchLengthLabel);
2754
3188
  }
2755
3189
  if (_state.showConfidenceValues) {
2756
- nodeUpdate.select('text.conflabel').text(makeConfidenceValuesLabel);
3190
+ nodeChild('text.conflabel').text(makeConfidenceValuesLabel);
2757
3191
  }
2758
3192
  if (_state.showBranchEvents) {
2759
- nodeUpdate.select('text.brancheventlabel').text(makeBranchEventsLabel);
3193
+ nodeChild('text.brancheventlabel').text(makeBranchEventsLabel);
2760
3194
  }
2761
3195
 
2762
3196
  let drawShapes = _state.showVisualizations || stylesActive();
2763
- nodeUpdate.select('path')
3197
+ nodeChild('path')
2764
3198
  .style('stroke', drawShapes ? makeNodeStrokeColor : null)
2765
3199
  .style('stroke-width', _state.branchWidthDefault)
2766
3200
  .style('fill', drawShapes ? makeNodeFillColor : null)
@@ -2772,9 +3206,9 @@ function (root, d3, forester, phyloXml) {
2772
3206
  // and a full 18k-node walk even when drawShapes made it a no-op. One
2773
3207
  // filtered selection with one transition does the same thing.
2774
3208
  if (!drawShapes) {
2775
- node.filter(function (d) {
3209
+ animateOrSet(node.filter(function (d) {
2776
3210
  return d.children && makeNodeVisShape(d) === null;
2777
- }).select('path').transition().duration(transitionDuration)
3211
+ }).select('path'), transitionDuration)
2778
3212
  .attr('d', 'M0,0');
2779
3213
  }
2780
3214
 
@@ -2798,7 +3232,26 @@ function (root, d3, forester, phyloXml) {
2798
3232
  return d.target.id;
2799
3233
  });
2800
3234
 
2801
- let linkEnter = link.enter().insert('path', 'g')
3235
+ // The reference node for the insert below, resolved ONCE.
3236
+ //
3237
+ // d3's insert(name, before) takes `before` as a SELECTOR STRING and
3238
+ // then runs parent.querySelector(before) for EVERY element it
3239
+ // inserts. With 18.5k links going into a group that already holds
3240
+ // 18.5k node <g>s, that is 18.5k full-subtree queries and it
3241
+ // dominated the first draw: measured 1,883 ms against 66 ms for the
3242
+ // same inserts with the lookup hoisted -- 28x, and about a third of
3243
+ // the time to open the biggest demo tree.
3244
+ //
3245
+ // Passing a function instead makes d3 use its return value as-is. The
3246
+ // result is identical: every link still lands before the first node
3247
+ // group, in the same order, so links stay painted behind nodes. It
3248
+ // must be re-resolved each update because the node join above may
3249
+ // have just created or removed those groups.
3250
+ let linkBefore = _svgGroup.node().querySelector('g');
3251
+
3252
+ let linkEnter = link.enter().insert('path', function () {
3253
+ return linkBefore;
3254
+ })
2802
3255
  .attr('class', 'link')
2803
3256
  .attr('fill', 'none')
2804
3257
  .attr('stroke-width', makeBranchWidth)
@@ -2819,8 +3272,7 @@ function (root, d3, forester, phyloXml) {
2819
3272
 
2820
3273
  link.attr('stroke-width', makeBranchWidth);
2821
3274
 
2822
- link.transition()
2823
- .duration(transitionDuration)
3275
+ animateOrSet(link, transitionDuration)
2824
3276
  .attr('stroke', makeBranchColor)
2825
3277
  .attr('d', elbow);
2826
3278
 
@@ -2897,10 +3349,14 @@ function (root, d3, forester, phyloXml) {
2897
3349
  }
2898
3350
 
2899
3351
  hideHoverGlow(); // the hovered node may have moved; the next mouseover re-shows it
3352
+ drawScaleBar();
3353
+ drawDomainArchitectures();
3354
+ drawDomainLegend();
2900
3355
  drawMsaTrack();
2901
3356
  drawTimeOverlays();
2902
3357
  rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
2903
3358
  updateSearchHitNavigation();
3359
+ noteViewChange();
2904
3360
  }
2905
3361
 
2906
3362
  // A node is drawn as a shape only when there is a reason to show one: it
@@ -2934,11 +3390,15 @@ function (root, d3, forester, phyloXml) {
2934
3390
  let styled = _state.nodeSizeDefault > 0 && node.parent && !node.hasVis
2935
3391
  && styleOf !== null && !!styleOf.nodeColor;
2936
3392
 
2937
- // a zero-length branch off the root would otherwise be invisible
2938
- let zeroLengthRootChild = _state.phylogram && node.parent && !node.parent.parent
2939
- && (!node.branch_length || node.branch_length <= 0);
2940
-
2941
- return (visualized || styled || zeroLengthRootChild) ? _state.nodeSizeDefault : 0;
3393
+ // The root is no exception. A 2.x rule gave "a zero-length branch
3394
+ // off the root" a circle so it would not vanish -- but the tree is
3395
+ // laid out from the invisible wrapper above the root, so the node it
3396
+ // matched was the root itself, and only in a phylogram with nothing
3397
+ // coloured: a circle that appeared and disappeared with the display
3398
+ // type and meant nothing. Dropped 2026-09-13; the root is now marked
3399
+ // by its stub (placeRootStub), and wears a circle only for the
3400
+ // reasons any node does.
3401
+ return (visualized || styled) ? _state.nodeSizeDefault : 0;
2942
3402
  };
2943
3403
 
2944
3404
  let makeBranchWidth = function (link) {
@@ -3090,7 +3550,10 @@ function (root, d3, forester, phyloXml) {
3090
3550
  if (value === null) {
3091
3551
  return null;
3092
3552
  }
3093
- return colorModeOf(vis) === 'range' ? vis.rangeScale(Number(value)) : vis.categoryScale(value);
3553
+ if (colorModeOf(vis) === 'range') {
3554
+ return vis.rangeScale ? vis.rangeScale(Number(value)) : null;
3555
+ }
3556
+ return vis.categoryScale(value);
3094
3557
  }
3095
3558
 
3096
3559
  let makeVisNodeFillColor = function (node) {
@@ -3134,7 +3597,7 @@ function (root, d3, forester, phyloXml) {
3134
3597
  if (isNodeFound(n)) {
3135
3598
  hits.push(n);
3136
3599
  }
3137
- if (n.children) {
3600
+ if (n.children && !isCollapsed(n)) { // a hit inside a collapsed clade has no position to step to
3138
3601
  for (let i = 0; i < n.children.length; ++i) {
3139
3602
  walk(n.children[i]);
3140
3603
  }
@@ -3211,7 +3674,7 @@ function (root, d3, forester, phyloXml) {
3211
3674
  if (_vis && _vis.labelRef && !phynode.children && phynode.properties) {
3212
3675
  for (let i = 0; i < phynode.properties.length; ++i) {
3213
3676
  let p = phynode.properties[i];
3214
- if (p.ref === _vis.labelRef && p.applies_to === 'node' && p.value) {
3677
+ if (p.ref === _vis.labelRef && forester.isNodeScopedProperty(p) && p.value) {
3215
3678
  return String(p.value);
3216
3679
  }
3217
3680
  }
@@ -3293,25 +3756,38 @@ function (root, d3, forester, phyloXml) {
3293
3756
  let wantEvent = _state.showBranchEvents === true;
3294
3757
  let wantDot = _state.showSupportDots === true;
3295
3758
 
3296
- // whole-class removals first: one pass, no per-node callback
3759
+ // Whole-class removals first: ONE query over the tree group, not one
3760
+ // per node. `node.selectAll(...)` runs querySelectorAll on every one of
3761
+ // the 18,512 node groups, and five of those is 92,560 queries made
3762
+ // largely to discover there is nothing to remove -- measured at 82-92 ms
3763
+ // per redraw on the big tree, against 40-50 ms for the whole per-node
3764
+ // loop below it. Each of these classes is created only in this function,
3765
+ // on a g.node inside _svgGroup, so one query over the group finds
3766
+ // exactly the same elements. (The overview's miniature is out of reach
3767
+ // either way: it hangs off _baseSvg, not _svgGroup.)
3768
+ let dropAll = function (selector) {
3769
+ if (_svgGroup) {
3770
+ _svgGroup.selectAll(selector).remove();
3771
+ }
3772
+ };
3297
3773
  if (!wantBl) {
3298
- node.selectAll('text.bllabel').remove();
3774
+ dropAll('text.bllabel');
3299
3775
  }
3300
3776
  if (!wantConf) {
3301
- node.selectAll('text.conflabel').remove();
3777
+ dropAll('text.conflabel');
3302
3778
  }
3303
3779
  if (!wantEvent) {
3304
- node.selectAll('text.brancheventlabel').remove();
3780
+ dropAll('text.brancheventlabel');
3305
3781
  }
3306
3782
  if (!wantDot) {
3307
- node.selectAll('circle.suppdot').remove();
3783
+ dropAll('circle.suppdot');
3308
3784
  }
3309
3785
 
3310
3786
  // No search running means no halo anywhere -- one pass instead of
3311
3787
  // 18,512 set lookups plus 18,512 DOM queries.
3312
3788
  let anyFound = anySearchHits();
3313
3789
  if (!anyFound) {
3314
- node.selectAll('circle.foundHalo').remove();
3790
+ dropAll('circle.foundHalo');
3315
3791
  }
3316
3792
 
3317
3793
  // Raw DOM inside the loop, not d3 selections: this runs once per node
@@ -3389,6 +3865,9 @@ function (root, d3, forester, phyloXml) {
3389
3865
  }
3390
3866
 
3391
3867
  let makeNodeLabel = function (phynode) {
3868
+ if (isCollapsed(phynode)) {
3869
+ return ''; // a collapsed clade carries its own label (drawCollapsedClades)
3870
+ }
3392
3871
  if (!_state.showExternalLabels && !(phynode.children)) {
3393
3872
  return null;
3394
3873
  }
@@ -3471,7 +3950,7 @@ function (root, d3, forester, phyloXml) {
3471
3950
  if (value.selected === true && value.propertyRef) {
3472
3951
  let prop_text = '';
3473
3952
  for (let pm = 0; pm < props_length; ++pm) {
3474
- if (phynode.properties[pm].ref === value.propertyRef && phynode.properties[pm].datatype === 'xsd:string' && phynode.properties[pm].applies_to === 'node') {
3953
+ if (phynode.properties[pm].ref === value.propertyRef && phynode.properties[pm].datatype === 'xsd:string' && forester.isNodeScopedProperty(phynode.properties[pm])) {
3475
3954
  if (prop_text.length > 0) {
3476
3955
  prop_text += ', '
3477
3956
  }
@@ -3962,6 +4441,10 @@ function (root, d3, forester, phyloXml) {
3962
4441
  const STATE_KEYS = [
3963
4442
  'layout',
3964
4443
  'showMsa',
4444
+ 'showDomainArchitectures',
4445
+ 'domainLabels',
4446
+ 'domainGlow',
4447
+ 'domainEvalueExponent',
3965
4448
  'showTimeAxis',
3966
4449
  'timeAxisGrid',
3967
4450
  'showSupportDots',
@@ -3986,9 +4469,11 @@ function (root, d3, forester, phyloXml) {
3986
4469
  'ladderizeTree',
3987
4470
  'nhExportWriteConfidences',
3988
4471
  'nodeLabels',
4472
+ 'onViewChange',
3989
4473
  'pngExportScale',
3990
4474
  'rootOffset',
3991
4475
  'supportDotMinimum',
4476
+ 'view',
3992
4477
  'zoomToFitUponWindowResize'
3993
4478
  ];
3994
4479
 
@@ -4081,12 +4566,8 @@ function (root, d3, forester, phyloXml) {
4081
4566
  // explicitly is derived from what the loaded tree actually contains
4082
4567
  // (an explicit caller option always wins). Field presence comes from
4083
4568
  // the same per-tree discovery that drives the search Field menu.
4084
- let presentFields = new Set();
4085
- if (_treeData) {
4086
- forester.availableSearchFields(_treeData).forEach(function (f) {
4087
- presentFields.add(f.key);
4088
- });
4089
- }
4569
+ let presentFields = new Set(_treeData ? forester.availableSearchFields(_treeData) : []);
4570
+ let F = forester.searchFields;
4090
4571
 
4091
4572
  // Branch lengths are worth drawing to scale only when MOST of the
4092
4573
  // branches that CARRY THE SCALE have one. Two corrections to the
@@ -4185,6 +4666,39 @@ function (root, d3, forester, phyloXml) {
4185
4666
  _state.showMsa = _basicTreeProperties.alignedMolSeqs === true
4186
4667
  && _basicTreeProperties.maxMolSeqLength > 0;
4187
4668
  }
4669
+ // A tree with protein domain architectures on its tips draws them
4670
+ // from the start (the desktop switches its toggle on at load), again
4671
+ // unless the caller decided. The track width is set at the first
4672
+ // draw from the viewport; threshold, label mode and glow come from
4673
+ // the config or the desktop's defaults.
4674
+ if (_state.showDomainArchitectures === undefined) {
4675
+ _state.showDomainArchitectures = _basicTreeProperties.domainArchitectures === true;
4676
+ }
4677
+ if (_state.domainLabels === undefined) {
4678
+ _state.domainLabels = 'domains';
4679
+ } else if (DOMAIN_LABEL_MODES.indexOf(_state.domainLabels) < 0) {
4680
+ throw new Error(ERROR + '"domainLabels" must be "none", "domains" or "legend"');
4681
+ }
4682
+ if (_state.domainGlow === undefined) {
4683
+ _state.domainGlow = false;
4684
+ }
4685
+ if (_state.domainEvalueExponent === undefined) {
4686
+ _state.domainEvalueExponent = forester.DOMAIN_EVALUE_EXPONENT_DEFAULT;
4687
+ } else if (!Number.isInteger(_state.domainEvalueExponent)
4688
+ || _state.domainEvalueExponent < forester.DOMAIN_EVALUE_EXPONENT_MIN
4689
+ || _state.domainEvalueExponent > forester.DOMAIN_EVALUE_EXPONENT_MAX) {
4690
+ throw new Error(ERROR + '"domainEvalueExponent" must be an integer from '
4691
+ + forester.DOMAIN_EVALUE_EXPONENT_MIN + ' to ' + forester.DOMAIN_EVALUE_EXPONENT_MAX);
4692
+ }
4693
+ _domain = {width: null, radialWidth: null, palette: null, next: 0, legendFrac: null};
4694
+ if (_basicTreeProperties.domainArchitectures) {
4695
+ // malformed domains are skipped, never fatal -- said once, here
4696
+ let ignored = forester.domainArchitectureStats(_treeData).ignored;
4697
+ if (ignored > 0) {
4698
+ console.warn(MESSAGE + ignored + ' protein domain' + (ignored === 1 ? '' : 's')
4699
+ + ' with a missing or impossible from / to / E-value ignored');
4700
+ }
4701
+ }
4188
4702
  // Likewise a dated tree draws its time axis from the start --
4189
4703
  // geologic ICS bands or calendar years, decided from the <date>
4190
4704
  // elements by forester.timeAxisInfo -- again unless the caller set
@@ -4209,18 +4723,18 @@ function (root, d3, forester, phyloXml) {
4209
4723
  // Which taxonomy fields to label with is decided from the tree, not
4210
4724
  // configured: show the scientific name and code when present, and fall
4211
4725
  // back to the common name only when there is no scientific name.
4212
- _state.showTaxonomyCode = presentFields.has('TC');
4213
- _state.showTaxonomyScientificName = presentFields.has('TS');
4214
- _state.showTaxonomyCommonName = presentFields.has('TN') && !presentFields.has('TS');
4726
+ _state.showTaxonomyCode = presentFields.has(F.taxonomyCode);
4727
+ _state.showTaxonomyScientificName = presentFields.has(F.taxonomyScientificName);
4728
+ _state.showTaxonomyCommonName = presentFields.has(F.taxonomyCommonName) && !presentFields.has(F.taxonomyScientificName);
4215
4729
  _state.showTaxonomyRank = false;
4216
4730
  _state.showTaxonomySynonyms = false;
4217
4731
  // Likewise ONE good sequence identifier rather than all of them, in
4218
4732
  // order of preference: sequence name, gene name, symbol, accession.
4219
- _state.showSequenceName = presentFields.has('SN');
4220
- _state.showSequenceGeneSymbol = presentFields.has('GN') && !presentFields.has('SN');
4221
- _state.showSequenceSymbol = presentFields.has('SS') && !presentFields.has('SN') && !presentFields.has('GN');
4222
- _state.showSequenceAccession = presentFields.has('SA') && !presentFields.has('SN')
4223
- && !presentFields.has('GN') && !presentFields.has('SS');
4733
+ _state.showSequenceName = presentFields.has(F.sequenceName);
4734
+ _state.showSequenceGeneSymbol = presentFields.has(F.geneName) && !presentFields.has(F.sequenceName);
4735
+ _state.showSequenceSymbol = presentFields.has(F.sequenceSymbol) && !presentFields.has(F.sequenceName) && !presentFields.has(F.geneName);
4736
+ _state.showSequenceAccession = presentFields.has(F.sequenceAccession) && !presentFields.has(F.sequenceName)
4737
+ && !presentFields.has(F.geneName) && !presentFields.has(F.sequenceSymbol);
4224
4738
 
4225
4739
  // Which of the three groups start CHECKED is then decided from the
4226
4740
  // label text itself (forester.suggestLabelFields): a field whose text
@@ -4243,7 +4757,7 @@ function (root, d3, forester, phyloXml) {
4243
4757
  if (labelRef && n.properties) {
4244
4758
  for (let pi = 0; pi < n.properties.length; ++pi) {
4245
4759
  let p = n.properties[pi];
4246
- if (p.ref === labelRef && p.applies_to === 'node' && p.value) {
4760
+ if (p.ref === labelRef && forester.isNodeScopedProperty(p) && p.value) {
4247
4761
  return String(p.value);
4248
4762
  }
4249
4763
  }
@@ -4418,6 +4932,16 @@ function (root, d3, forester, phyloXml) {
4418
4932
  throw new Error(ERROR + 'internalNumericLabels must be "auto", "confidence" or "label"');
4419
4933
  }
4420
4934
  // custom label-field checkboxes (was launch()'s sixth positional arg)
4935
+ if (_settings.view === undefined || _settings.view === null) {
4936
+ _settings.view = null;
4937
+ } else if (typeof _settings.view !== 'object') {
4938
+ throw new Error(ERROR + '"view" must be a view state object (getViewState / decodeViewState) or null');
4939
+ }
4940
+ if (_settings.onViewChange === undefined || _settings.onViewChange === null) {
4941
+ _settings.onViewChange = null;
4942
+ } else if (typeof _settings.onViewChange !== 'function') {
4943
+ throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
4944
+ }
4421
4945
  if (_settings.nodeLabels === undefined) {
4422
4946
  _settings.nodeLabels = null;
4423
4947
  } else if (_settings.nodeLabels !== null && typeof _settings.nodeLabels !== 'object') {
@@ -4510,6 +5034,7 @@ function (root, d3, forester, phyloXml) {
4510
5034
  populateSearchMenus();
4511
5035
 
4512
5036
  _svgGroup = _baseSvg.append('g');
5037
+ attachNodeEventDelegation(_svgGroup);
4513
5038
  // the floating strips paint over the tree and under the overview
4514
5039
  _floatGroup = _baseSvg.append('g').attr('class', 'aptx-float')
4515
5040
  .style('pointer-events', 'none');
@@ -4519,6 +5044,12 @@ function (root, d3, forester, phyloXml) {
4519
5044
  if (_settings.ladderizeTree) {
4520
5045
  ladderizeSubtree(_root, true); // one pass: a definite arrangement
4521
5046
  }
5047
+ _viewOps = {order: null, root: null};
5048
+ if (_settings.view) {
5049
+ // the shared view, its searches found before the first draw
5050
+ applyViewState(_settings.view, true);
5051
+ runSearches();
5052
+ }
4522
5053
  if (_state.searchAinitialValue) {
4523
5054
  search0();
4524
5055
  }
@@ -4532,6 +5063,7 @@ function (root, d3, forester, phyloXml) {
4532
5063
 
4533
5064
  search0();
4534
5065
  search1();
5066
+ settleViewNotification();
4535
5067
  }
4536
5068
 
4537
5069
  function bindDoc(type, fn, opts) {
@@ -4549,6 +5081,24 @@ function (root, d3, forester, phyloXml) {
4549
5081
  // resolves once the tree is drawn: at once for a small tree,
4550
5082
  // after the deferred first draw for a big one
4551
5083
  ready: _readyPromise,
5084
+ // the trees of the launch -- one, or every tree the file held:
5085
+ // how many, which is shown, and showing another (a fresh
5086
+ // viewer for it; the handle returned is the one to keep)
5087
+ getTreeCount: function () {
5088
+ return _trees.length;
5089
+ },
5090
+ getTreeIndex: function () {
5091
+ return _treeIndex;
5092
+ },
5093
+ showTree: showTree,
5094
+ // the view -- layout, display, labels, colours, searches, the
5095
+ // clade and the collapsed clades, sizes -- as a plain object,
5096
+ // and opening one (see also archaeopteryx.encodeViewState /
5097
+ // decodeViewState and the config's view / onViewChange)
5098
+ getViewState: getViewState,
5099
+ applyViewState: function (state) {
5100
+ applyViewState(state, false);
5101
+ },
4552
5102
  destroy: destroyViewer
4553
5103
  };
4554
5104
  }
@@ -4581,6 +5131,13 @@ function (root, d3, forester, phyloXml) {
4581
5131
  _node_mouseover_div.remove(); // the body-level tooltip div (d3 selection)
4582
5132
  _node_mouseover_div = null;
4583
5133
  }
5134
+ // A redraw still waiting for its frame belongs to the viewer being torn
5135
+ // down. Left set, its callback would read the NEXT viewer's pending
5136
+ // update, null it, then find its own sequence number stale and return
5137
+ // -- having already discarded work that was not its own. I could not
5138
+ // construct that ordering from the UI, and one line means nobody has to
5139
+ // reason about it again.
5140
+ _pendingUpdate = null;
4584
5141
  clearInterval(_intervalId); // a zoom button held down at destroy time
4585
5142
  d3.select(window).on('resize.archaeopteryx', null);
4586
5143
  _docListeners.forEach(function (l) {
@@ -4594,10 +5151,16 @@ function (root, d3, forester, phyloXml) {
4594
5151
  _root = null;
4595
5152
  _root_const = null;
4596
5153
  _treeData = null;
5154
+ _trees = [];
5155
+ _treeIndex = 0;
5156
+ _launchConfig = null;
5157
+ _lastViewEncoded = undefined;
5158
+ _viewOps = {order: null, root: null};
4597
5159
  _basicTreeProperties = null;
4598
5160
  _baseSvg = null;
4599
5161
  _svgGroup = null;
4600
5162
  _overviewGroup = null;
5163
+ _overviewLinks = null;
4601
5164
  _overviewContent = null;
4602
5165
  _overviewViewport = null;
4603
5166
  _overviewMap = null;
@@ -4648,9 +5211,39 @@ function (root, d3, forester, phyloXml) {
4648
5211
  if (phylo === undefined || phylo === null) {
4649
5212
  throw new Error(ERROR + 'input tree is undefined or null');
4650
5213
  }
4651
- if ((!phylo.children) || (phylo.children.length < 1)) {
4652
- throw new Error(ERROR + 'input tree is empty or illegally formatted');
5214
+ // One tree, or a list of them (every tree a file holds -- parseTrees):
5215
+ // the list is kept, the first is shown, and the panel's picker and the
5216
+ // handle's showTree() move between them.
5217
+ let trees = Array.isArray(phylo) ? phylo.slice() : [phylo];
5218
+ if (trees.length === 0) {
5219
+ throw new Error(ERROR + 'input tree list is empty');
4653
5220
  }
5221
+ trees.forEach(function (t, i) {
5222
+ if (!t || (!t.children) || (t.children.length < 1)) {
5223
+ throw new Error(ERROR + (trees.length > 1 ? 'input tree ' + (i + 1) + ' of ' + trees.length : 'input tree')
5224
+ + ' is empty or illegally formatted');
5225
+ }
5226
+ });
5227
+ // the two view keys are checked up here with the other arguments,
5228
+ // before anything is touched (and where Node can test it)
5229
+ if (config && config.view !== undefined && config.view !== null && typeof config.view !== 'object') {
5230
+ throw new Error(ERROR + '"view" must be a view state object (getViewState / decodeViewState) or null');
5231
+ }
5232
+ if (config && config.onViewChange !== undefined && config.onViewChange !== null && typeof config.onViewChange !== 'function') {
5233
+ throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
5234
+ }
5235
+ // a view names the tree it was made on; a fresh launch reports no
5236
+ // view until one changes (settleViewNotification)
5237
+ _lastViewEncoded = undefined;
5238
+ let view = config && config.view;
5239
+ let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
5240
+ return launchInto(container, trees, start, config);
5241
+ };
5242
+
5243
+ // The launch proper, for one tree of the list: launch() validates and
5244
+ // starts at the first, showTree() re-enters here for another.
5245
+ function launchInto(container, trees, index, config) {
5246
+ let phylo = trees[index];
4654
5247
  let cfg = readConfig(config);
4655
5248
  requireForester();
4656
5249
  requireD3();
@@ -4658,7 +5251,11 @@ function (root, d3, forester, phyloXml) {
4658
5251
 
4659
5252
  let previousContainer = _container; // for the different-container teardown below
4660
5253
  _treeData = phylo;
5254
+ _trees = trees;
5255
+ _treeIndex = index;
5256
+ _launchConfig = config;
4661
5257
  _container = containerEl;
5258
+ assignViewIds(phylo);
4662
5259
  _zoomListener = d3.zoom()
4663
5260
  .scaleExtent([0.1, 10])
4664
5261
  .filter(function (event) {
@@ -4724,7 +5321,13 @@ function (root, d3, forester, phyloXml) {
4724
5321
  _baseSvg = d3.select(containerEl).append('svg')
4725
5322
  .attr('width', _displayWidth)
4726
5323
  .attr('height', _displayHeight)
4727
- .call(_zoomListener);
5324
+ .call(_zoomListener)
5325
+ // d3.zoom binds its own double-click-to-zoom, which scales the
5326
+ // picture -- fonts and all -- and so behaves like the wheel
5327
+ // zoom this program deliberately replaced with a re-layout.
5328
+ // It is also easy to hit by accident: a double click that
5329
+ // misses a node is a miss, not a request to magnify the tree.
5330
+ .on('dblclick.zoom', null);
4728
5331
 
4729
5332
  if (_settings.enableDynamicSizing) {
4730
5333
  d3.select(window)
@@ -4793,10 +5396,24 @@ function (root, d3, forester, phyloXml) {
4793
5396
  }
4794
5397
 
4795
5398
  return makeViewerHandle();
5399
+ }
4796
5400
 
4797
- //////////////////////////////////////////////////////////////////////
4798
-
4799
- };
5401
+ // Shows another tree of the launched list in the same container under
5402
+ // the same config. The tree opens fresh -- its own presets, a clean
5403
+ // view -- the way a new tab does on the desktop. The panel's picker and
5404
+ // previous / next buttons come here, as does the handle's showTree().
5405
+ function showTree(index) {
5406
+ if (!_container) {
5407
+ throw new Error(ERROR + 'showTree(): nothing is launched');
5408
+ }
5409
+ if (!Number.isInteger(index) || index < 0 || index >= _trees.length) {
5410
+ throw new Error(ERROR + 'showTree(): the index must be 0 to ' + (_trees.length - 1) + ', got ' + index);
5411
+ }
5412
+ if (index === _treeIndex) {
5413
+ return makeViewerHandle();
5414
+ }
5415
+ return launchInto(_container, _trees, index, _launchConfig);
5416
+ }
4800
5417
 
4801
5418
  archaeopteryx.parsePhyloXML = function (data) {
4802
5419
  requirePhyloXml();
@@ -4853,8 +5470,9 @@ function (root, d3, forester, phyloXml) {
4853
5470
  return forester.parseAuspiceJson(data);
4854
5471
  };
4855
5472
 
4856
- // A Nexus file can hold several trees; the FIRST one is displayed (any
4857
- // alignment from the file's characters matrix rides along on its tips).
5473
+ // A Nexus file can hold several trees; this returns the FIRST one
5474
+ // (parseTrees returns them all; any alignment from the file's characters
5475
+ // matrix rides along on the tips).
4858
5476
  archaeopteryx.parseNexus = function (data, mode, legacyMode) {
4859
5477
  requireForester();
4860
5478
  let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
@@ -4868,6 +5486,9 @@ function (root, d3, forester, phyloXml) {
4868
5486
  function calcMaxExtLabel() {
4869
5487
  _maxLabelLength = _state.nodeLabelGap;
4870
5488
  forester.preOrderTraversal(_root, function (d) {
5489
+ if (isCollapsed(d)) {
5490
+ _maxLabelLength = Math.max(collapsedLabel(d).length, _maxLabelLength);
5491
+ }
4871
5492
  if (!d.children) {
4872
5493
  let l = makeNodeLabel(d);
4873
5494
  if (l) {
@@ -4989,7 +5610,7 @@ function (root, d3, forester, phyloXml) {
4989
5610
  let properties_text = '';
4990
5611
  let prev_property_ref = null;
4991
5612
  for (let pl = 0; pl < l; ++pl) {
4992
- if (sorted_properties[pl].applies_to === 'node') {
5613
+ if (forester.isNodeScopedProperty(sorted_properties[pl])) {
4993
5614
  if (sorted_properties[pl].ref === prev_property_ref) {
4994
5615
  properties_text = addSepSame(properties_text);
4995
5616
  } else {
@@ -5202,6 +5823,9 @@ function (root, d3, forester, phyloXml) {
5202
5823
  }
5203
5824
 
5204
5825
  function switchToSubtree(node) {
5826
+ if (node.collapsed) {
5827
+ node.collapsed = false; // a subtree view shows what the clade holds
5828
+ }
5205
5829
  if (node.parent) {
5206
5830
  if (!(node.children)) {
5207
5831
  if (node.parent.parent) {
@@ -5222,7 +5846,7 @@ function (root, d3, forester, phyloXml) {
5222
5846
  fakeNode.y0 = 0;
5223
5847
  _root = fakeNode;
5224
5848
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
5225
- refreshVisualizations();
5849
+ refreshVisualizations(false);
5226
5850
  search0();
5227
5851
  search1();
5228
5852
  zoomToFit();
@@ -5278,6 +5902,14 @@ function (root, d3, forester, phyloXml) {
5278
5902
  // "Switch to ..." matches the desktop's wording for this action.
5279
5903
  items.push({separator: true});
5280
5904
  items.push({label: 'Switch to Subtree', action: function () { switchToSubtree(d); }});
5905
+ // the desktop's two collapse entries, with its enabling rules:
5906
+ // the toggle for an internal node that is not the root and not
5907
+ // in the unrooted layout; the subtree uncollapse only when
5908
+ // something below is collapsed
5909
+ items.push({label: 'Collapse/Uncollapse', disabled: !(collapseAllowed() && d.children),
5910
+ action: function () { collapseToggle(d); }});
5911
+ items.push({label: 'Uncollapse Subtree', disabled: !hasCollapsedIn(d),
5912
+ action: function () { uncollapse(d); }});
5281
5913
  }
5282
5914
  if (d.parent && d.children) {
5283
5915
  // redrawn with no transition: animating a swap sends the two
@@ -5335,7 +5967,7 @@ function (root, d3, forester, phyloXml) {
5335
5967
  forester.deleteSubtree(tree, d);
5336
5968
  _treeData = tree;
5337
5969
  _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
5338
- refreshVisualizations();
5970
+ refreshVisualizations(true);
5339
5971
  search0();
5340
5972
  search1();
5341
5973
  zoomToFit();
@@ -5350,27 +5982,39 @@ function (root, d3, forester, phyloXml) {
5350
5982
  }
5351
5983
 
5352
5984
 
5353
- // After a permanent tree edit (subtree deletion), the candidates are
5354
- // re-derived from what is left; the user's choices survive when their
5355
- // fields do. Plain subtree NAVIGATION does not come through here -- a
5356
- // subtree keeps its parent tree's visualizations, so colours stay
5357
- // stable diving in and out.
5358
- // The view changed -- into or out of a subtree, or the tree was edited.
5359
- // Candidates, menus and legends are recomputed for what is displayed; the
5360
- // user's choices survive when their fields do (otherwise the menu goes
5361
- // back to default and the tree to plain ink -- honest, not a silent
5362
- // switch); colour/shape identities persist via the memory maps; and no
5363
- // checkbox moves, because a view change is not a user choice.
5364
- function refreshVisualizations() {
5985
+ // The view changed (into or out of a subtree), or the tree was EDITED.
5986
+ //
5987
+ // A view never changes what is offered or chosen: the menu stands, the
5988
+ // colour and shape stand, and only the summaries move -- legend rows and
5989
+ // counts, the "no value" row, a numeric band -- to describe the tips on
5990
+ // screen. Diving into a one-value clade coloured by Genus shows a
5991
+ // one-row legend, not a grey tree.
5992
+ //
5993
+ // An edit re-derives the candidates from what is left, because it is a
5994
+ // different tree now. The chosen fields are kept as long as they still
5995
+ // carry a value on any remaining tip (visualizationCandidatesKeeping):
5996
+ // the refusal rules decide what is OFFERED, never what is already
5997
+ // chosen. Only a field with no value left anywhere falls back to default.
5998
+ // No checkbox moves either way, because a view change is not a user choice.
5999
+ function refreshVisualizations(edited) {
6000
+ if (edited && _domain) {
6001
+ _domain.palette = null; // the tree changed: the domain names are dealt their colours again
6002
+ }
5365
6003
  if (!_vis) {
5366
6004
  return;
5367
6005
  }
5368
- let colorId = _vis.colorId;
5369
- let shapeId = _vis.shapeId;
5370
- computeVisualizationCandidates(displayedRoot());
5371
- _vis.colorId = (colorId && _vis.byId[colorId]) ? colorId : null;
5372
- _vis.shapeId = (shapeId && _vis.byId[shapeId] && _vis.byId[shapeId].shape) ? shapeId : null;
5373
- populateVisualizationMenus();
6006
+ if (edited) {
6007
+ let colorId = _vis.colorId;
6008
+ let shapeId = _vis.shapeId;
6009
+ installCandidates(forester.visualizationCandidatesKeeping(_treeData,
6010
+ [currentColorVis(), currentShapeVis()]));
6011
+ _vis.colorId = (colorId && _vis.byId[colorId]) ? colorId : null;
6012
+ _vis.shapeId = (shapeId && _vis.byId[shapeId] && _vis.byId[shapeId].shape) ? shapeId : null;
6013
+ }
6014
+ summarizeView(displayedRoot());
6015
+ if (edited) {
6016
+ populateVisualizationMenus();
6017
+ }
5374
6018
  removeColorLegend(LEGEND_LABEL_COLOR);
5375
6019
  removeShapeLegend(LEGEND_NODE_SHAPE);
5376
6020
  }
@@ -5394,7 +6038,7 @@ function (root, d3, forester, phyloXml) {
5394
6038
  // part of the real spans (an anchor ratio taken from anything else
5395
6039
  // once flung the whole tree off-screen after three X+ presses).
5396
6040
  return {
5397
- horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve),
6041
+ horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve),
5398
6042
  vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT) - bottomOverlayReserve())
5399
6043
  };
5400
6044
  }
@@ -5626,7 +6270,7 @@ function (root, d3, forester, phyloXml) {
5626
6270
  updateMsaScrollbar(0, 1, 0);
5627
6271
  return;
5628
6272
  }
5629
- let tips = forester.getAllExternalNodes(_root).filter(function (d) {
6273
+ let tips = displayedTips().filter(function (d) {
5630
6274
  return d.x !== undefined;
5631
6275
  }).sort(function (p, q) {
5632
6276
  return p.x - q.x;
@@ -6126,102 +6770,1658 @@ function (root, d3, forester, phyloXml) {
6126
6770
  scheduleUpdate(null, 0);
6127
6771
  }
6128
6772
 
6129
- // Search B starts hidden to keep the panel compact; one click (or a
6130
- // configured initial value) reveals it, and it stays revealed.
6131
- function revealSearchB() {
6132
- let wrap = byId(SEARCH_B_WRAP);
6133
- if (wrap) {
6134
- wrap.style.display = '';
6135
- }
6136
- let tgl = byId(SEARCH_B_TOGGLE);
6137
- if (tgl) {
6138
- tgl.style.display = 'none';
6139
- }
6140
- let f = byId(SEARCH_FIELD_1);
6141
- if (f) {
6142
- f.focus();
6143
- }
6144
- }
6773
+ // ===================== Collapsed clades =====================
6774
+ // Collapsing is DISPLAY state on the node (d.collapsed) and nothing
6775
+ // else: forester never sees it, the writers never see it, and the data
6776
+ // tree keeps every tip. The layout treats a collapsed node as a leaf and
6777
+ // the viewer draws it as a wedge -- apex at the node, its two edges
6778
+ // reaching the clade's nearest and farthest tips so its depth stays
6779
+ // readable, filled in the clade's dominant Color-by colour, its height
6780
+ // growing gently with the tip count -- named by the node's own name,
6781
+ // else by the one Color-by value nearly all its tips share, else by the
6782
+ // tips' common name prefix, always with the tip count, and with
6783
+ // "[found/total]" while a search hits inside it (the desktop's touch).
6784
+ // The CONTROLS are the desktop's exactly (Christian, 2026-09-13): the
6785
+ // node menu's "Collapse/Uncollapse" and "Uncollapse Subtree", the
6786
+ // uncollapse-all button in the tool row with the desktop's glyph, no
6787
+ // collapsing in the unrooted layout, never the root. Every walk of the
6788
+ // DISPLAYED tree goes through forEachDisplayed; a hidden tip keeps its
6789
+ // stale position and must never be drawn from.
6790
+ let _rowUnit = 0; // px per row in the last rectangular layout
6145
6791
 
6146
- // ===================== Time axis =====================
6147
- // The desktop's time overlays, drawn beneath a rectangular PHYLOGRAM of
6148
- // a dated tree: the two-band ICS geologic axis with a "Ma before
6149
- // present" ruler (or a calendar-year ruler), HPD age bars on dated
6150
- // internal nodes and sepia fossil-range (FAD/LAD) bars on dated tips.
6151
- // The tree layout itself never changes -- time is an overlay anchored to
6152
- // the tree's own branch scale, so the bands line up with the branches
6153
- // even on a fossil-only tree whose youngest tip is far from the present.
6792
+ function collapseAllowed() {
6793
+ return !_state.unrootedDisplay;
6794
+ }
6154
6795
 
6155
- function timeAxisShown() {
6156
- return _state.showTimeAxis === true && !radialDisplay()
6157
- && _state.phylogram === true
6158
- && _timeInfo !== null && _timeInfo.type !== null
6159
- && _basicTreeProperties.branchLengths === true;
6796
+ function isCollapsed(d) {
6797
+ return d.collapsed === true && !!d.children && collapseAllowed();
6160
6798
  }
6161
6799
 
6162
- function timeAxisBottomReserve() {
6163
- if (!timeAxisShown()) {
6164
- return 0;
6800
+ // visits the displayed tree: a collapsed node, not what it hides
6801
+ function forEachDisplayed(fn) {
6802
+ if (!_root) {
6803
+ return;
6165
6804
  }
6166
- return _timeInfo.type === 'geologic' ? TIME_GEO_RESERVE : TIME_CAL_RESERVE;
6805
+ (function walk(n) {
6806
+ fn(n);
6807
+ if (n.children && !isCollapsed(n)) {
6808
+ for (let i = 0; i < n.children.length; ++i) {
6809
+ walk(n.children[i]);
6810
+ }
6811
+ }
6812
+ })(_root);
6167
6813
  }
6168
6814
 
6169
- // The MSA footer sits under the alignment band (right) and the time axis
6170
- // under the tree (left) -- horizontally disjoint, so the two reserves
6171
- // share the same strip rather than stacking.
6172
- function bottomOverlayReserve() {
6173
- // The navigation bar is fixed at the viewport bottom, so the track's
6174
- // own bottom rows (conservation, consensus, ruler) must end above it
6175
- // or the bar covers them -- the old bare slider did exactly that.
6176
- return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve());
6815
+ function displayedTips() {
6816
+ let out = [];
6817
+ forEachDisplayed(function (n) {
6818
+ if (!n.children) {
6819
+ out.push(n);
6820
+ }
6821
+ });
6822
+ return out;
6177
6823
  }
6178
6824
 
6179
- function hexToRgbTriple(hex) {
6180
- let h = hex.charAt(0) === '#' ? hex.substring(1) : hex;
6181
- return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16),
6182
- parseInt(h.substring(4, 6), 16)];
6825
+ function displayedLeafCount() {
6826
+ let count = 0;
6827
+ forEachDisplayed(function (n) {
6828
+ if (!n.children || isCollapsed(n)) {
6829
+ count++;
6830
+ }
6831
+ });
6832
+ return count;
6183
6833
  }
6184
6834
 
6185
- function drawTimeOverlays() {
6186
- if (!_svgGroup) {
6187
- return;
6188
- }
6189
- _svgGroup.selectAll('g.aptx-time').remove();
6190
- if (_floatGroup) {
6191
- _floatGroup.selectAll('g.aptx-time-axis').remove();
6192
- delete _floatStrips['aptx-time-axis'];
6193
- }
6194
- _svgGroup.selectAll('g.aptx-timegrid').remove();
6195
- if (!timeAxisShown() || !_root || !_yScale) {
6835
+ function hasCollapsedIn(node) {
6836
+ let found = false;
6837
+ forester.preOrderTraversal(node, function (n) {
6838
+ if (n.collapsed === true && n.children) {
6839
+ found = true;
6840
+ }
6841
+ });
6842
+ return found;
6843
+ }
6844
+
6845
+ function collapseToggle(d) {
6846
+ if (!collapseAllowed() || !d.children || !d.parent || !d.parent.parent) {
6196
6847
  return;
6197
6848
  }
6198
- // recomputed per draw, so a subtree view calibrates to ITS oldest node
6199
- let info = forester.timeAxisInfo(_root);
6200
- if (!info.type) {
6201
- return;
6849
+ d.collapsed = !d.collapsed;
6850
+ calcMaxExtLabel();
6851
+ scheduleUpdate(null, 0);
6852
+ }
6853
+
6854
+ function uncollapse(node) {
6855
+ forester.preOrderTraversal(node, function (n) {
6856
+ n.collapsed = false;
6857
+ });
6858
+ calcMaxExtLabel();
6859
+ scheduleUpdate(null, 0);
6860
+ }
6861
+
6862
+ function uncollapseAll() {
6863
+ if (_root) {
6864
+ uncollapse(_root);
6202
6865
  }
6203
- let corr = Math.abs(_yScale(1) - _yScale(0));
6204
- if (!isFinite(corr) || corr <= 0) {
6205
- return;
6866
+ }
6867
+
6868
+ function clearCollapsedFlags(node) {
6869
+ forester.preOrderTraversal(node, function (n) {
6870
+ n.collapsed = false;
6871
+ });
6872
+ }
6873
+
6874
+ // How tall a collapsed clade draws, in rows: 1 for a pair, growing with
6875
+ // the logarithm of the tip count, capped so a huge clade does not eat
6876
+ // the screen. The cluster layout's separation turns it into space.
6877
+ function collapsedRows(d) {
6878
+ let tips = forester.getAllExternalNodes(d).length;
6879
+ return Math.max(1, Math.min(2.5, 1 + (Math.log2(Math.max(2, tips)) / 4)));
6880
+ }
6881
+
6882
+ // The clade's name: the node's own; else the one Color-by value at least
6883
+ // 95% of its tips share (a clade named "Bovine" while you look at
6884
+ // hosts); else the tips' common name prefix, trailing separators
6885
+ // dropped; else nothing.
6886
+ function collapsedName(d) {
6887
+ if (d.name && String(d.name).trim().length > 0) {
6888
+ return String(d.name).trim();
6206
6889
  }
6207
- // Anchor the age->x mapping at the DEEPEST DATED TIP rather than the
6208
- // root: the root and its direct children carry a synthetic half-
6209
- // average branch length (see bl()), which would shift every band.
6210
- let anchor = null;
6211
- let maxTipX = 0;
6212
- forester.preOrderTraversal(_root, function (n) {
6213
- if (n.children) {
6214
- return;
6215
- }
6216
- if (n.y > maxTipX) {
6217
- maxTipX = n.y;
6218
- }
6219
- if (n.date && typeof n.date.value === 'number') {
6220
- if (!anchor || n.distToRoot > anchor.distToRoot) {
6221
- anchor = n;
6890
+ let tips = forester.getAllExternalNodes(d);
6891
+ let vis = currentColorVis();
6892
+ if (vis && tips.length > 0) {
6893
+ let counts = Object.create(null);
6894
+ tips.forEach(function (t) {
6895
+ let v = forester.visualizationNodeValue(t, vis);
6896
+ if (v !== null && v !== undefined) {
6897
+ counts[v] = (counts[v] || 0) + 1;
6222
6898
  }
6899
+ });
6900
+ let best = null;
6901
+ Object.keys(counts).forEach(function (v) {
6902
+ if (best === null || counts[v] > counts[best]) {
6903
+ best = v;
6904
+ }
6905
+ });
6906
+ if (best !== null && counts[best] >= 0.95 * tips.length) {
6907
+ return best;
6223
6908
  }
6224
- });
6909
+ }
6910
+ let prefix = forester.commonNamePrefix(d, forester.nodeLabelProperty(_treeData));
6911
+ prefix = prefix ? prefix.replace(/[\s_\-.:|/]+$/, '') : '';
6912
+ return prefix.length >= 2 ? prefix : '';
6913
+ }
6914
+
6915
+ function collapsedFoundCounts(d) {
6916
+ let found = 0;
6917
+ let total = 0;
6918
+ forester.preOrderTraversal(d, function (n) {
6919
+ if (!n.children) {
6920
+ total++;
6921
+ if (isNodeFound(n)) {
6922
+ found++;
6923
+ }
6924
+ }
6925
+ });
6926
+ return {found: found, total: total};
6927
+ }
6928
+
6929
+ function collapsedLabel(d) {
6930
+ let name = collapsedName(d);
6931
+ let total = forester.getAllExternalNodes(d).length;
6932
+ let text = (name ? name + ' · ' : '') + total + (total === 1 ? ' tip' : ' tips');
6933
+ let hits = collapsedFoundCounts(d);
6934
+ if (hits.found > 0) {
6935
+ text += ' [' + hits.found + '/' + hits.total + ']';
6936
+ }
6937
+ return text;
6938
+ }
6939
+
6940
+ // The colour most of the clade's tips wear under the current Color-by;
6941
+ // the branch colour when nothing is coloured.
6942
+ function collapsedColor(d) {
6943
+ let vis = currentColorVis();
6944
+ if (!vis || !_state.showVisualizations) {
6945
+ return _state.branchColorDefault;
6946
+ }
6947
+ let counts = Object.create(null);
6948
+ let best = null;
6949
+ forester.getAllExternalNodes(d).forEach(function (t) {
6950
+ let c = makeLabelColor(t);
6951
+ if (c === _state.labelColorDefault) {
6952
+ return; // an uncoloured tip casts no vote
6953
+ }
6954
+ counts[c] = (counts[c] || 0) + 1;
6955
+ if (best === null || counts[c] > counts[best]) {
6956
+ best = c;
6957
+ }
6958
+ });
6959
+ return best || _state.branchColorDefault;
6960
+ }
6961
+
6962
+ // The wedge's base: the desktop's triangle (paintCollapsedNode with its
6963
+ // default "collapsed with average height") stands on a VERTICAL base at
6964
+ // the clade's average tip distance, so the shape is symmetric about the
6965
+ // node's row and its depth still reads. A cladogram, where every leaf
6966
+ // sits on the tip column, gives it one depth step.
6967
+ function collapsedBase(d) {
6968
+ if (_state.phylogram && _yScale) {
6969
+ let sum = 0;
6970
+ let n = 0;
6971
+ forester.preOrderTraversal(d, function (t) {
6972
+ if (!t.children && typeof t.distToRoot === 'number') {
6973
+ sum += t.distToRoot;
6974
+ ++n;
6975
+ }
6976
+ });
6977
+ if (n > 0) {
6978
+ return Math.max(d.y + 2, _yScale(sum / n));
6979
+ }
6980
+ }
6981
+ return d.y + _cladogramUnit;
6982
+ }
6983
+
6984
+ let _cladogramUnit = 0; // one depth step of the last cladogram layout, px
6985
+
6986
+ // Draws every collapsed clade in the current node selection: a wedge
6987
+ // path and a label as children of the node's own group, so they move
6988
+ // with it, take its clicks and menu, and ride into exports.
6989
+ function drawCollapsedClades(node) {
6990
+ let wedge = node.selectAll('path.aptx-collapsed').data(function (d) {
6991
+ return isCollapsed(d) ? [d] : [];
6992
+ });
6993
+ wedge.exit().remove();
6994
+ wedge = wedge.enter().append('path').attr('class', 'aptx-collapsed').merge(wedge);
6995
+ let label = node.selectAll('text.aptx-collapsed-label').data(function (d) {
6996
+ return isCollapsed(d) ? [d] : [];
6997
+ });
6998
+ label.exit().remove();
6999
+ label = label.enter().append('text').attr('class', 'aptx-collapsed-label').merge(label);
7000
+
7001
+ let fs = _state.externalNodeFontSize;
7002
+ wedge.each(function (d) {
7003
+ let color = collapsedColor(d);
7004
+ let hits = collapsedFoundCounts(d);
7005
+ let foundColor = hits.found > 0 ? getFoundColor(forester.getAllExternalNodes(d).filter(isNodeFound)[0]) : null;
7006
+ let h = Math.max(6, collapsedRows(d) * _rowUnit * 0.82);
7007
+ let base = collapsedBase(d);
7008
+ let path;
7009
+ if (radialDisplay()) {
7010
+ // in the rotated frame: +x along the spoke from the node (the
7011
+ // group's origin, already at the node's radius), the base
7012
+ // across it -- a distance, never an absolute radius, or the
7013
+ // wedge lands a whole radius further out than its label
7014
+ let dr = radialRadius(base) - radialRadius(d.y);
7015
+ path = 'M0,0 L' + dr + ',' + (-h / 2) + ' L' + dr + ',' + (h / 2) + ' Z';
7016
+ } else {
7017
+ let dx = base - d.y;
7018
+ path = 'M0,0 L' + dx + ',' + (-h / 2) + ' L' + dx + ',' + (h / 2) + ' Z';
7019
+ }
7020
+ d3.select(this)
7021
+ .attr('d', path)
7022
+ .attr('transform', radialDisplay() ? 'rotate(' + labelAngleDeg(d) + ')' : null)
7023
+ .style('fill', (hits.found > 0 && hits.found === hits.total) ? foundColor : color)
7024
+ .style('fill-opacity', (hits.found > 0 && hits.found === hits.total) ? 0.45 : 0.22)
7025
+ .style('stroke', foundColor || color)
7026
+ .style('stroke-width', foundColor ? 1.5 : 1)
7027
+ .style('stroke-opacity', 0.9)
7028
+ .style('stroke-linejoin', 'round');
7029
+ });
7030
+ label.each(function (d) {
7031
+ let base = collapsedBase(d);
7032
+ let hits = collapsedFoundCounts(d);
7033
+ let allFound = hits.found > 0 && hits.found === hits.total;
7034
+ let ink = allFound ? getFoundColor(forester.getAllExternalNodes(d).filter(isNodeFound)[0]) : _state.labelColorDefault;
7035
+ let t = d3.select(this)
7036
+ .text(collapsedLabel(d))
7037
+ .style('font', (allFound ? '600 ' : '') + fs + 'px ' + FONT_DEFAULTS)
7038
+ .style('fill', ink)
7039
+ .style('pointer-events', 'none');
7040
+ if (radialDisplay()) {
7041
+ let r = radialRadius(base) - radialRadius(d.y) + _state.nodeLabelGap;
7042
+ let flip = labelFlip(d);
7043
+ t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + r + ',0)' + (flip ? ' rotate(180)' : ''))
7044
+ .attr('text-anchor', flip ? 'end' : 'start')
7045
+ .attr('x', 0).attr('dy', '0.32em');
7046
+ } else {
7047
+ t.attr('transform', null)
7048
+ .attr('text-anchor', 'start')
7049
+ .attr('x', base - d.y + _state.nodeLabelGap)
7050
+ .attr('dy', (0.3 * fs) + 'px');
7051
+ }
7052
+ });
7053
+ }
7054
+
7055
+ // ===================== Shareable views =====================
7056
+ // A view is what a person made of a tree with the panel: the layout and
7057
+ // display type, what is labelled, the colours, the searches, the clade
7058
+ // they switched to, the ones they collapsed, the sizes. It is a plain
7059
+ // object (getViewState), a short "key=value&..." string (encodeViewState
7060
+ // / decodeViewState) that a page keeps in its URL hash, and a launch
7061
+ // config key (view) that opens a tree straight into it; onViewChange
7062
+ // tells the page whenever it changes. A node is named by its viewId, the
7063
+ // launch-time preorder index, which ladderizing and re-rooting leave
7064
+ // alone. NOT in a view: zoom and pan (a view opens fitted), the legend's
7065
+ // position, the node selection.
7066
+
7067
+ const VIEW_SHOW_FLAGS = [
7068
+ ['name', NODE_NAME_CB, 'showNodeName'],
7069
+ ['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
7070
+ ['sequence', SEQUENCE_CB, 'showSequence'],
7071
+ ['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
7072
+ ['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
7073
+ ['external', EXTERNAL_LABEL_CB, 'showExternalLabels'],
7074
+ ['internal', INTERNAL_LABEL_CB, 'showInternalLabels'],
7075
+ ['nodeEvents', NODE_EVENTS_CB, 'showNodeEvents'],
7076
+ ['branchEvents', BRANCH_EVENTS_CB, 'showBranchEvents'],
7077
+ ['supportDots', SUPPORT_DOTS_CB, 'showSupportDots'],
7078
+ ['shortNames', SHORTEN_NODE_NAME_CB, 'shortenNodeNames'],
7079
+ ['autoHide', DYNAHIDE_CB, 'dynahide'],
7080
+ ['visualizations', VIS_CB, 'showVisualizations'],
7081
+ ['visualStyles', VISUAL_STYLES_CB, 'useVisualStyles']
7082
+ ];
7083
+ const RADIAL_ROTATION_STEP = Math.PI / 32; // one press of the rotate buttons
7084
+
7085
+ // Launch-time preorder ids, given once per tree object: a tree shown
7086
+ // again keeps them, so a shared view still names the same nodes.
7087
+ function assignViewIds(phy) {
7088
+ if (phy.viewId !== undefined) {
7089
+ return;
7090
+ }
7091
+ let i = 0;
7092
+ forester.preOrderTraversalAll(phy, function (n) {
7093
+ n.viewId = i++;
7094
+ });
7095
+ }
7096
+
7097
+ function nodeByViewId(id) {
7098
+ let found = null;
7099
+ forester.preOrderTraversalAll(_root_const, function (n) {
7100
+ if (found === null && n.viewId === id) {
7101
+ found = n;
7102
+ }
7103
+ });
7104
+ return found;
7105
+ }
7106
+
7107
+ // The config's custom label checkboxes (nodeLabels) that are offered
7108
+ function customLabelEntries() {
7109
+ let out = [];
7110
+ if (_nodeLabels) {
7111
+ Object.keys(_nodeLabels).forEach(function (key) {
7112
+ let v = _nodeLabels[key];
7113
+ if (v && v.label && v.showButton === true && v.propertyRef && v.description) {
7114
+ out.push({key: key, entry: v});
7115
+ }
7116
+ });
7117
+ }
7118
+ return out;
7119
+ }
7120
+
7121
+ function searchStateOf(idx) {
7122
+ let spec = currentSearchSpec(idx);
7123
+ let value = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
7124
+ if (value.trim().length === 0) {
7125
+ return null;
7126
+ }
7127
+ let q = {field: spec.field.label, mode: spec.mode, value: value};
7128
+ if (spec.mode === 'range' && spec.value2) {
7129
+ q.value2 = String(spec.value2);
7130
+ }
7131
+ return q;
7132
+ }
7133
+
7134
+ function getViewState() {
7135
+ let s = {};
7136
+ if (_treeIndex > 0) {
7137
+ s.tree = _treeIndex;
7138
+ }
7139
+ s.layout = _state.unrootedDisplay ? 'unrooted' : (_state.circularDisplay ? 'circular' : 'rectangular');
7140
+ s.display = _state.phylogram ? (_state.alignPhylogram ? 'aligned' : 'phylogram') : 'cladogram';
7141
+ if (_viewOps.root) {
7142
+ s.root = _viewOps.root;
7143
+ }
7144
+ if (_viewOps.order) {
7145
+ s.order = _viewOps.order;
7146
+ }
7147
+ if (_in_subtree && _root && _root.children && _root.children[0] && _root.children[0].viewId !== undefined) {
7148
+ s.subtree = _root.children[0].viewId;
7149
+ }
7150
+ let collapsed = [];
7151
+ forester.preOrderTraversalAll(_root_const, function (n) {
7152
+ if (n.collapsed && n.viewId !== undefined) {
7153
+ collapsed.push(n.viewId);
7154
+ }
7155
+ });
7156
+ if (collapsed.length > 0) {
7157
+ s.collapsed = collapsed;
7158
+ }
7159
+ s.colorBy = (_vis && _vis.colorId) || 'none';
7160
+ if (_vis && _vis.shapeId) {
7161
+ s.shapeBy = _vis.shapeId;
7162
+ }
7163
+ s.show = VIEW_SHOW_FLAGS.filter(function (f) {
7164
+ return _state[f[2]] === true;
7165
+ }).map(function (f) {
7166
+ return f[0];
7167
+ });
7168
+ customLabelEntries().forEach(function (c) {
7169
+ if (c.entry.selected === true) {
7170
+ s.show.push('custom:' + c.key);
7171
+ }
7172
+ });
7173
+ s.font = _state.externalNodeFontSize;
7174
+ s.node = _state.nodeSizeDefault;
7175
+ s.branch = _state.branchWidthDefault;
7176
+ let rotation = Math.round(_radialRotation / RADIAL_ROTATION_STEP);
7177
+ if (rotation !== 0) {
7178
+ s.rotation = rotation;
7179
+ }
7180
+ if (_radialLabelsHorizontal) {
7181
+ s.horizontalLabels = true;
7182
+ }
7183
+ if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
7184
+ s.msa = _state.showMsa === true;
7185
+ }
7186
+ if (_basicTreeProperties.domainArchitectures === true) {
7187
+ s.domains = _state.showDomainArchitectures === true;
7188
+ s.domainLabels = _state.domainLabels;
7189
+ s.domainGlow = _state.domainGlow === true;
7190
+ s.domainEvalue = _state.domainEvalueExponent;
7191
+ }
7192
+ if (_timeInfo && _timeInfo.type) {
7193
+ s.timeAxis = _state.showTimeAxis === true;
7194
+ s.timeGrid = _state.timeAxisGrid === true;
7195
+ }
7196
+ let a = searchStateOf(0);
7197
+ let b = searchStateOf(1);
7198
+ if (a) {
7199
+ s.searchA = a;
7200
+ }
7201
+ if (b) {
7202
+ s.searchB = b;
7203
+ }
7204
+ if (a && b) {
7205
+ let combine = getValue(SEARCH_COMBINE_SELECT);
7206
+ if (combine === 'and' || combine === 'or') {
7207
+ s.combine = combine;
7208
+ }
7209
+ }
7210
+ if (_state.searchIsCaseSensitive === true) {
7211
+ s.matchCase = true;
7212
+ }
7213
+ if (_state.searchNegateResult === true) {
7214
+ s.inverse = true;
7215
+ }
7216
+ return s;
7217
+ }
7218
+
7219
+ // Opens a view: the tree operations first (midpoint rooting, the
7220
+ // ladderize direction), then the clade and the collapsed clades by node
7221
+ // id, then everything the panel shows. A key the view leaves out keeps
7222
+ // its current value, except the two searches, which an empty view
7223
+ // clears (a view with no search has none). `initial` is the launch --
7224
+ // initialize() draws afterwards; otherwise the tree is redrawn and
7225
+ // fitted. Anything not understood, a hand-edited hash or a view of some
7226
+ // other tree, is skipped rather than guessed at.
7227
+ function applyViewState(s, initial) {
7228
+ if (!s || typeof s !== 'object') {
7229
+ return;
7230
+ }
7231
+ if (!initial && Number.isInteger(s.tree) && s.tree !== _treeIndex && s.tree >= 0 && s.tree < _trees.length) {
7232
+ let cfg = Object.assign({}, _launchConfig || {});
7233
+ cfg.view = s;
7234
+ launchInto(_container, _trees, s.tree, cfg);
7235
+ return;
7236
+ }
7237
+ if (s.root === 'midpoint' && _viewOps.root !== 'midpoint'
7238
+ && (_treeData.rerootable === undefined || _treeData.rerootable === true)) {
7239
+ forester.midpointRoot(_root_const);
7240
+ _viewOps.root = 'midpoint';
7241
+ }
7242
+ if (s.order === 'asc' || s.order === 'desc') {
7243
+ ladderizeSubtree(_root_const, s.order === 'asc', false);
7244
+ _viewOps.order = s.order;
7245
+ if (!_treeFn.visData) {
7246
+ _treeFn.visData = {};
7247
+ }
7248
+ _treeFn.visData.ladderize = s.order !== 'asc'; // the next press goes the other way
7249
+ }
7250
+ _root = _root_const;
7251
+ _in_subtree = false;
7252
+ clearCollapsedFlags(_root_const);
7253
+ if (Number.isInteger(s.subtree)) {
7254
+ let node = nodeByViewId(s.subtree);
7255
+ if (node && node.children && node.parent && node.parent.parent) {
7256
+ _in_subtree = true;
7257
+ _root = {children: [node], x: 0, x0: 0, y: 0, y0: 0};
7258
+ }
7259
+ }
7260
+ if (Array.isArray(s.collapsed) && s.collapsed.length > 0) {
7261
+ let ids = new Set(s.collapsed);
7262
+ forester.preOrderTraversalAll(_root_const, function (n) {
7263
+ if (n.children && n.parent && n.parent.parent && ids.has(n.viewId)) {
7264
+ n.collapsed = true;
7265
+ }
7266
+ });
7267
+ }
7268
+ _basicTreeProperties = forester.collectBasicTreeProperties(_root);
7269
+
7270
+ if (s.layout === 'rectangular' || s.layout === 'circular' || s.layout === 'unrooted') {
7271
+ _state.circularDisplay = s.layout === 'circular';
7272
+ _state.unrootedDisplay = s.layout === 'unrooted';
7273
+ }
7274
+ if (s.display === 'phylogram' || s.display === 'aligned' || s.display === 'cladogram') {
7275
+ let measured = _basicTreeProperties.branchLengths === true;
7276
+ _state.phylogram = measured && s.display !== 'cladogram';
7277
+ _state.alignPhylogram = measured && s.display === 'aligned' && !_state.unrootedDisplay;
7278
+ }
7279
+ if (Array.isArray(s.show)) {
7280
+ let on = new Set(s.show);
7281
+ VIEW_SHOW_FLAGS.forEach(function (f) {
7282
+ _state[f[2]] = on.has(f[0]);
7283
+ });
7284
+ customLabelEntries().forEach(function (c) {
7285
+ c.entry.selected = on.has('custom:' + c.key);
7286
+ });
7287
+ }
7288
+ if (isFinite(s.font)) {
7289
+ setFontSizes(Number(s.font));
7290
+ }
7291
+ if (isFinite(s.node)) {
7292
+ _state.nodeSizeDefault = Math.min(NODE_SIZE_MAX, Math.max(NODE_SIZE_MIN, Number(s.node)));
7293
+ }
7294
+ if (isFinite(s.branch)) {
7295
+ _state.branchWidthDefault = Math.min(BRANCH_WIDTH_MAX, Math.max(BRANCH_WIDTH_MIN, Number(s.branch)));
7296
+ }
7297
+ if (Number.isInteger(s.rotation)) {
7298
+ _radialRotation = (s.rotation * RADIAL_ROTATION_STEP) % (2 * Math.PI);
7299
+ }
7300
+ if (typeof s.horizontalLabels === 'boolean') {
7301
+ _radialLabelsHorizontal = s.horizontalLabels;
7302
+ }
7303
+ if (typeof s.msa === 'boolean') {
7304
+ _state.showMsa = s.msa;
7305
+ }
7306
+ if (typeof s.domains === 'boolean') {
7307
+ _state.showDomainArchitectures = s.domains;
7308
+ }
7309
+ if (s.domainLabels === 'none' || s.domainLabels === 'domains' || s.domainLabels === 'legend') {
7310
+ _state.domainLabels = s.domainLabels;
7311
+ }
7312
+ if (typeof s.domainGlow === 'boolean') {
7313
+ _state.domainGlow = s.domainGlow;
7314
+ }
7315
+ if (Number.isInteger(s.domainEvalue) && s.domainEvalue >= forester.DOMAIN_EVALUE_EXPONENT_MIN
7316
+ && s.domainEvalue <= forester.DOMAIN_EVALUE_EXPONENT_MAX && s.domainEvalue !== _state.domainEvalueExponent) {
7317
+ _state.domainEvalueExponent = s.domainEvalue;
7318
+ if (_domain) {
7319
+ _domain.palette = null; // the drawn set changed: the palette is dealt again
7320
+ }
7321
+ }
7322
+ if (typeof s.timeAxis === 'boolean') {
7323
+ _state.showTimeAxis = s.timeAxis;
7324
+ }
7325
+ if (typeof s.timeGrid === 'boolean') {
7326
+ _state.timeAxisGrid = s.timeGrid;
7327
+ }
7328
+ if (!radialDisplay()) {
7329
+ _radialLabelsHorizontal = false;
7330
+ } else if (_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
7331
+ _radialLabelsHorizontal = false; // the domain boxes ride the spokes, so the labels must too
7332
+ }
7333
+ if (_vis) {
7334
+ if (s.colorBy === 'none') {
7335
+ _vis.colorId = null;
7336
+ } else if (s.colorBy && _vis.byId[s.colorBy]) {
7337
+ _vis.colorId = s.colorBy;
7338
+ _state.showVisualizations = true;
7339
+ }
7340
+ if (s.shapeBy === 'none') {
7341
+ _vis.shapeId = null;
7342
+ } else if (s.shapeBy && _vis.byId[s.shapeBy] && _vis.byId[s.shapeBy].shape) {
7343
+ _vis.shapeId = s.shapeBy;
7344
+ _state.showVisualizations = true;
7345
+ }
7346
+ }
7347
+ _state.searchIsCaseSensitive = s.matchCase === true;
7348
+ _state.searchNegateResult = s.inverse === true;
7349
+ applySearchState(0, s.searchA);
7350
+ applySearchState(1, s.searchB);
7351
+ setValue(SEARCH_COMBINE_SELECT,
7352
+ (s.searchA && s.searchB && (s.combine === 'and' || s.combine === 'or')) ? s.combine : 'independent');
7353
+
7354
+ refreshVisualizations(false);
7355
+ resetVis();
7356
+ syncViewControls();
7357
+ if (!initial) {
7358
+ runSearches();
7359
+ zoomToFit();
7360
+ }
7361
+ }
7362
+
7363
+ // One search box from its view state: the field by its label, the mode
7364
+ // when the field offers it, the value(s); nothing given clears the box.
7365
+ function applySearchState(idx, q) {
7366
+ let fieldSel = byId(idx === 0 ? SEARCH_FIELD_SELECT_0 : SEARCH_FIELD_SELECT_1);
7367
+ let modeSel = byId(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
7368
+ if (!fieldSel || !modeSel) {
7369
+ return;
7370
+ }
7371
+ let want = (q && typeof q === 'object') ? q : {};
7372
+ let fi = want.field ? _searchFields.findIndex(function (f) {
7373
+ return f.label === want.field;
7374
+ }) : -1;
7375
+ fieldSel.value = String(fi >= 0 ? fi : 0);
7376
+ populateSearchModeMenu(idx);
7377
+ if (want.mode && Array.from(modeSel.options).some(function (o) { return o.value === want.mode; })) {
7378
+ modeSel.value = want.mode;
7379
+ }
7380
+ setValue(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1, want.value ? String(want.value) : '');
7381
+ setValue(idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1, want.value2 ? String(want.value2) : '');
7382
+ updateSearchValue2Visibility(idx);
7383
+ updateSearchAutocomplete(idx);
7384
+ if (idx === 1 && want.value) {
7385
+ showSearchB();
7386
+ }
7387
+ }
7388
+
7389
+ // The panel's controls from the state, after applyViewState
7390
+ function syncViewControls() {
7391
+ setDisplayTypeButtons();
7392
+ syncZoomRowButtons();
7393
+ VIEW_SHOW_FLAGS.forEach(function (f) {
7394
+ setCheckboxValue(f[1], _state[f[2]] === true);
7395
+ });
7396
+ customLabelEntries().forEach(function (c) {
7397
+ if (c.entry.cb_id) {
7398
+ setCheckboxValue(c.entry.cb_id, c.entry.selected === true);
7399
+ }
7400
+ });
7401
+ setCheckboxValue(MSA_CB, _state.showMsa === true);
7402
+ setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
7403
+ setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
7404
+ setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
7405
+ setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
7406
+ syncDomainControls();
7407
+ setSliderValue(FONT_SIZE_SLIDER, _state.externalNodeFontSize);
7408
+ setSliderValue(NODE_SIZE_SLIDER, _state.nodeSizeDefault);
7409
+ setSliderValue(BRANCH_WIDTH_SLIDER, _state.branchWidthDefault);
7410
+ setSelectMenuValue(LABEL_COLOR_SELECT_MENU, (_vis && _vis.colorId) || DEFAULT);
7411
+ setSelectMenuValue(NODE_SHAPE_SELECT_MENU, (_vis && _vis.shapeId) || DEFAULT);
7412
+ setCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB, _state.searchIsCaseSensitive === true);
7413
+ setCheckboxValue(SEARCH_OPTIONS_NEGATE_RES_CB, _state.searchNegateResult === true);
7414
+ syncLadderizeGlyph();
7415
+ }
7416
+
7417
+ // The ladderize glyph shows the direction the NEXT press will use.
7418
+ function syncLadderizeGlyph() {
7419
+ let orderBtn = byId(LADDERIZE_BUTTON);
7420
+ if (orderBtn) {
7421
+ let next = !(_treeFn && _treeFn.visData && _treeFn.visData.ladderize === false);
7422
+ orderBtn.innerHTML = makeGlyph(next ? 'ladderize_asc' : 'ladderize_desc');
7423
+ }
7424
+ }
7425
+
7426
+ // onViewChange: once per settled redraw, only when the view actually
7427
+ // changed. The launch's own view is recorded and not reported -- the
7428
+ // page put the tree up and knows what it asked for -- but a relaunch
7429
+ // that changed it (the tree picker) is reported like any change.
7430
+ function noteViewChange() {
7431
+ if (_initializing || !_settings || typeof _settings.onViewChange !== 'function' || _viewNotifyPending) {
7432
+ return;
7433
+ }
7434
+ _viewNotifyPending = true;
7435
+ let seq = _launchSeq;
7436
+ setTimeout(function () {
7437
+ _viewNotifyPending = false;
7438
+ if (seq !== _launchSeq || !_root || !_container) {
7439
+ return;
7440
+ }
7441
+ reportView(false);
7442
+ }, 0);
7443
+ }
7444
+
7445
+ function settleViewNotification() {
7446
+ if (_settings && typeof _settings.onViewChange === 'function') {
7447
+ reportView(_lastViewEncoded === undefined);
7448
+ }
7449
+ }
7450
+
7451
+ function reportView(silent) {
7452
+ let state = getViewState();
7453
+ let encoded = archaeopteryx.encodeViewState(state);
7454
+ if (encoded === _lastViewEncoded) {
7455
+ return;
7456
+ }
7457
+ _lastViewEncoded = encoded;
7458
+ if (silent) {
7459
+ return;
7460
+ }
7461
+ try {
7462
+ _settings.onViewChange(state, encoded);
7463
+ } catch (e) {
7464
+ console.error(ERROR + 'onViewChange threw: ' + (e && e.message ? e.message : e));
7465
+ }
7466
+ }
7467
+
7468
+ // The string form: "key=value&key=value", the values URL-encoded but
7469
+ // with ':' and ',' kept readable (refs and lists) and spaces as '+'.
7470
+ // Lists are comma-joined, booleans 1 / 0, the two searches flattened
7471
+ // to a / af / am / a2 and b / bf / bm / b2. Made for a URL hash, where
7472
+ // none of these characters needs escaping.
7473
+ const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine'];
7474
+ const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
7475
+ const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
7476
+ const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'domains', 'domainGlow', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
7477
+ const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
7478
+
7479
+ function encodeViewValue(v) {
7480
+ return encodeURIComponent(String(v)).replace(/%3A/gi, ':').replace(/%2C/gi, ',').replace(/%20/g, '+');
7481
+ }
7482
+
7483
+ function decodeViewValue(v) {
7484
+ try {
7485
+ return decodeURIComponent(String(v).replace(/\+/g, '%20'));
7486
+ } catch {
7487
+ return '';
7488
+ }
7489
+ }
7490
+
7491
+ archaeopteryx.encodeViewState = function (state) {
7492
+ if (!state || typeof state !== 'object') {
7493
+ return '';
7494
+ }
7495
+ let parts = [];
7496
+ let put = function (k, v) {
7497
+ if (v !== undefined && v !== null && v !== '') {
7498
+ parts.push(k + '=' + encodeViewValue(v));
7499
+ }
7500
+ };
7501
+ VIEW_INT_KEYS.concat(VIEW_TEXT_KEYS, VIEW_NUMBER_KEYS).forEach(function (k) {
7502
+ put(k, state[k]);
7503
+ });
7504
+ if (Array.isArray(state.show)) {
7505
+ parts.push('show=' + state.show.map(encodeViewValue).join(',')); // an empty list is a fact: nothing shown
7506
+ }
7507
+ if (Array.isArray(state.collapsed) && state.collapsed.length > 0) {
7508
+ put('collapsed', state.collapsed.join(','));
7509
+ }
7510
+ VIEW_BOOL_KEYS.forEach(function (k) {
7511
+ if (typeof state[k] === 'boolean') {
7512
+ put(k, state[k] ? 1 : 0);
7513
+ }
7514
+ });
7515
+ VIEW_SEARCH_KEYS.forEach(function (sk) {
7516
+ let q = state[sk[0]];
7517
+ if (q && typeof q === 'object' && q.value) {
7518
+ put(sk[1], q.value);
7519
+ put(sk[1] + 'f', q.field);
7520
+ put(sk[1] + 'm', q.mode);
7521
+ put(sk[1] + '2', q.value2);
7522
+ }
7523
+ });
7524
+ return parts.join('&');
7525
+ };
7526
+
7527
+ archaeopteryx.decodeViewState = function (text) {
7528
+ if (text === undefined || text === null) {
7529
+ return null;
7530
+ }
7531
+ let s = String(text).replace(/^#/, '');
7532
+ if (s.length === 0) {
7533
+ return null;
7534
+ }
7535
+ let raw = {};
7536
+ s.split('&').forEach(function (pair) {
7537
+ let eq = pair.indexOf('=');
7538
+ if (eq > 0) {
7539
+ raw[decodeViewValue(pair.substring(0, eq))] = decodeViewValue(pair.substring(eq + 1));
7540
+ }
7541
+ });
7542
+ let state = {};
7543
+ VIEW_TEXT_KEYS.forEach(function (k) {
7544
+ if (raw[k] !== undefined && raw[k] !== '') {
7545
+ state[k] = raw[k];
7546
+ }
7547
+ });
7548
+ VIEW_INT_KEYS.forEach(function (k) {
7549
+ if (raw[k] !== undefined && /^-?\d+$/.test(raw[k])) {
7550
+ state[k] = parseInt(raw[k], 10);
7551
+ }
7552
+ });
7553
+ VIEW_NUMBER_KEYS.forEach(function (k) {
7554
+ let n = parseFloat(raw[k]);
7555
+ if (isFinite(n)) {
7556
+ state[k] = n;
7557
+ }
7558
+ });
7559
+ VIEW_BOOL_KEYS.forEach(function (k) {
7560
+ if (raw[k] === '1' || raw[k] === '0') {
7561
+ state[k] = raw[k] === '1';
7562
+ }
7563
+ });
7564
+ if (raw.show !== undefined) {
7565
+ state.show = raw.show.split(',').filter(function (x) {
7566
+ return x.length > 0;
7567
+ });
7568
+ }
7569
+ if (raw.collapsed !== undefined) {
7570
+ state.collapsed = raw.collapsed.split(',').filter(function (x) {
7571
+ return /^\d+$/.test(x);
7572
+ }).map(function (x) {
7573
+ return parseInt(x, 10);
7574
+ });
7575
+ }
7576
+ VIEW_SEARCH_KEYS.forEach(function (sk) {
7577
+ let p = sk[1];
7578
+ if (raw[p]) {
7579
+ let q = {value: raw[p]};
7580
+ if (raw[p + 'f']) {
7581
+ q.field = raw[p + 'f'];
7582
+ }
7583
+ if (raw[p + 'm']) {
7584
+ q.mode = raw[p + 'm'];
7585
+ }
7586
+ if (raw[p + '2']) {
7587
+ q.value2 = raw[p + '2'];
7588
+ }
7589
+ state[sk[0]] = q;
7590
+ }
7591
+ });
7592
+ return Object.keys(state).length > 0 ? state : null;
7593
+ };
7594
+
7595
+ // ===================== Keyboard shortcuts =====================
7596
+ // Ctrl on Windows and Linux, Cmd on macOS (Christian, 2026-09-13: "Ctrl/Cmd
7597
+ // combos are better"). The letters follow the desktop where it has the
7598
+ // action (its Alt+O order, Alt+U uncollapse all, Alt+E expand, Cmd+0 fit,
7599
+ // Cmd+G / Cmd+Shift+G next / previous hit); the layout, display type
7600
+ // and time-axis keys are ours. Combos the browser reserves (new tab,
7601
+ // close, tab switching, and on macOS the screenshot keys) are avoided,
7602
+ // and the few browser functions that are overridden (page zoom, find
7603
+ // next) matter little on a page that is one tree. No key for rooting,
7604
+ // by decision. Plain keys stay as they were: Esc / Home reset, O moves
7605
+ // the overview, PageUp / PageDown size the font. Inside a text box only
7606
+ // the combos that cannot interfere with typing fire.
7607
+
7608
+ const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '');
7609
+ const SHORTCUTS_DIALOG = 'aptx_shortcuts';
7610
+
7611
+ // key: what e.key reads (letters lower-case); shift: whether Shift is
7612
+ // held; typing: the combo also fires from inside a text box
7613
+ const SHORTCUTS = [
7614
+ {key: '0', label: '0', what: 'Fit the tree to the window', run: function () { zoomToFit(); }, typing: true},
7615
+ {key: '=', label: '+', aliases: ['+'], what: 'Zoom in', run: function () { zoomInY(); if (!radialDisplay()) { zoomInX(); } }, typing: true},
7616
+ {key: '-', label: '−', aliases: ['_'], what: 'Zoom out', run: function () { zoomOutY(); if (!radialDisplay()) { zoomOutX(); } }, typing: true},
7617
+ {key: 'arrowup', label: '↑', shift: true, what: 'Zoom in vertically', run: function () { zoomInY(); }},
7618
+ {key: 'arrowdown', label: '↓', shift: true, what: 'Zoom out vertically', run: function () { zoomOutY(); }},
7619
+ {key: 'arrowright', label: '→', shift: true, what: 'Zoom in horizontally (circular: rotate)', run: function () { zoomInX(); }},
7620
+ {key: 'arrowleft', label: '←', shift: true, what: 'Zoom out horizontally (circular: rotate back)', run: function () { zoomOutX(); }},
7621
+ {key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
7622
+ {key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
7623
+ {key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
7624
+ {key: 'x', label: 'X', shift: true, what: 'Time axis on / off', run: toggleTimeAxis},
7625
+ {key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
7626
+ {key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
7627
+ {key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
7628
+ {key: 'g', label: 'G', what: 'Next search hit', run: function () { stepToFoundNode(1); }, typing: true},
7629
+ {key: 'g', label: 'G', shift: true, what: 'Previous search hit', run: function () { stepToFoundNode(-1); }, typing: true},
7630
+ {key: ',', label: '<', aliases: ['<'], shift: true, what: 'Previous tree of the file', run: function () { stepTree(-1); }},
7631
+ {key: '.', label: '>', aliases: ['>'], shift: true, what: 'Next tree of the file', run: function () { stepTree(1); }},
7632
+ {key: '/', label: '/', aliases: ['?'], what: 'This list', run: showShortcutsDialog, typing: true}
7633
+ ];
7634
+
7635
+ function shortcutMatches(sc, e) {
7636
+ let k = (e.key || '').toLowerCase();
7637
+ if (k !== sc.key && !(sc.aliases && sc.aliases.indexOf(e.key) >= 0)) {
7638
+ return false;
7639
+ }
7640
+ return !!sc.shift === !!e.shiftKey;
7641
+ }
7642
+
7643
+ // The one document-level handler for the combos: keydown, so the
7644
+ // browser's own action is stopped before it runs.
7645
+ function shortcutKeyDown(e) {
7646
+ let mod = IS_MAC ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey);
7647
+ if (!mod || e.altKey || !_root) {
7648
+ return;
7649
+ }
7650
+ let typing = isTypingTarget(e.target);
7651
+ for (let i = 0; i < SHORTCUTS.length; ++i) {
7652
+ let sc = SHORTCUTS[i];
7653
+ if (shortcutMatches(sc, e)) {
7654
+ if (typing && !sc.typing) {
7655
+ return;
7656
+ }
7657
+ e.preventDefault();
7658
+ sc.run();
7659
+ return;
7660
+ }
7661
+ }
7662
+ }
7663
+
7664
+ function cycleLayout() {
7665
+ let next = _state.unrootedDisplay ? 'rectangular' : (_state.circularDisplay ? 'unrooted' : 'circular');
7666
+ setCheckboxValue(LAYOUT_RECT_BUTTON, next === 'rectangular');
7667
+ setCheckboxValue(LAYOUT_CIRC_BUTTON, next === 'circular');
7668
+ setCheckboxValue(LAYOUT_UNROOTED_BUTTON, next === 'unrooted');
7669
+ layoutButtonClicked();
7670
+ }
7671
+
7672
+ // Phylogram, aligned phylogram, cladogram -- skipping what the tree or
7673
+ // the layout rules out (no branch lengths: cladogram only; unrooted has
7674
+ // no aligned phylogram).
7675
+ function cycleDisplayType() {
7676
+ let measured = _basicTreeProperties && _basicTreeProperties.branchLengths === true;
7677
+ if (!measured) {
7678
+ toCladegram();
7679
+ return;
7680
+ }
7681
+ if (_state.phylogram && !_state.alignPhylogram) {
7682
+ if (_state.unrootedDisplay) {
7683
+ toCladegram();
7684
+ } else {
7685
+ toAlignedPhylogram();
7686
+ }
7687
+ } else if (_state.phylogram) {
7688
+ toCladegram();
7689
+ } else {
7690
+ toPhylogram();
7691
+ }
7692
+ }
7693
+
7694
+ // The Time Axis checkbox's toggle; when the distance / time tree switch
7695
+ // arrives it takes over this key.
7696
+ function toggleTimeAxis() {
7697
+ let cb = byId(TIME_AXIS_CB);
7698
+ if (!cb || cb.disabled) {
7699
+ return;
7700
+ }
7701
+ cb.checked = !cb.checked;
7702
+ timeAxisCbClicked();
7703
+ }
7704
+
7705
+ function focusSearch() {
7706
+ let f = byId(SEARCH_FIELD_0);
7707
+ if (f) {
7708
+ f.focus();
7709
+ f.select();
7710
+ }
7711
+ }
7712
+
7713
+ function stepTree(delta) {
7714
+ let i = _treeIndex + delta;
7715
+ if (i >= 0 && i < _trees.length) {
7716
+ showTree(i);
7717
+ }
7718
+ }
7719
+
7720
+ function shortcutLabel(sc) {
7721
+ let parts = [];
7722
+ if (IS_MAC) {
7723
+ parts.push('⌘');
7724
+ if (sc.shift) {
7725
+ parts.push('⇧');
7726
+ }
7727
+ } else {
7728
+ parts.push('Ctrl');
7729
+ if (sc.shift) {
7730
+ parts.push('Shift');
7731
+ }
7732
+ }
7733
+ parts.push(sc.label);
7734
+ return parts;
7735
+ }
7736
+
7737
+ function keyChips(parts) {
7738
+ let holder = document.createElement('span');
7739
+ holder.className = 'aptx-keys';
7740
+ parts.forEach(function (p) {
7741
+ let kbd = document.createElement('kbd');
7742
+ kbd.className = 'aptx-kbd';
7743
+ kbd.textContent = p;
7744
+ holder.appendChild(kbd);
7745
+ });
7746
+ return holder;
7747
+ }
7748
+
7749
+ // The cheat sheet: every combo above, then the plain keys.
7750
+ function showShortcutsDialog() {
7751
+ let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
7752
+ shell.body.classList.add('aptx-shortcuts');
7753
+ let rows = SHORTCUTS.map(function (sc) {
7754
+ return [shortcutLabel(sc), sc.what];
7755
+ }).concat([
7756
+ [['Esc'], 'Back to the whole tree, the launch view'],
7757
+ [['O'], 'Move the overview to the next corner'],
7758
+ [['PageUp'], 'Larger font'],
7759
+ [['PageDown'], 'Smaller font']
7760
+ ]);
7761
+ rows.forEach(function (row) {
7762
+ let line = document.createElement('div');
7763
+ line.className = 'aptx-dialog-line';
7764
+ let key = document.createElement('span');
7765
+ key.className = 'aptx-dialog-key';
7766
+ key.appendChild(keyChips(row[0]));
7767
+ let val = document.createElement('span');
7768
+ val.className = 'aptx-dialog-val';
7769
+ val.textContent = row[1];
7770
+ line.appendChild(key);
7771
+ line.appendChild(val);
7772
+ shell.body.appendChild(line);
7773
+ });
7774
+ let note = document.createElement('p');
7775
+ note.className = 'aptx-shortcuts-note';
7776
+ note.textContent = 'The mouse wheel zooms; with Shift it zooms vertically only, with Shift+Alt horizontally, with Ctrl+Shift it sizes the font.';
7777
+ shell.body.appendChild(note);
7778
+ shell.dialog.showModal();
7779
+ }
7780
+
7781
+ // ===================== Protein domain architectures =====================
7782
+ // The desktop's domain display, ported from its RenderableDomainArchitecture
7783
+ // and TreePanel (the spec is section D1 of the repo's TODO.md). Each tip's
7784
+ // architecture is a grey backbone of L residues with a rounded box per
7785
+ // domain the E-value threshold admits: in one aligned column past the
7786
+ // labels in the rectangular layout, riding the tip's spoke in the radial
7787
+ // ones. One scale for the whole tree -- f px per residue, from the track
7788
+ // width and the longest architecture -- so lengths compare across tips.
7789
+ // Which domains, where, which colour and which legend rows are forester's
7790
+ // (tested against the desktop's numbers); this is the SVG, the legend
7791
+ // card and the controls. The boxes take no pointer events: nothing to
7792
+ // hover or click, as on the desktop.
7793
+
7794
+ function domainsShown() {
7795
+ return _state.showDomainArchitectures === true
7796
+ && _basicTreeProperties.domainArchitectures === true
7797
+ && _basicTreeProperties.maxDomainArchitectureLength > 0
7798
+ && _state.showExternalLabels
7799
+ // in a radial layout the boxes ride the spokes, so only radial
7800
+ // labels go with them
7801
+ && (!radialDisplay() || !_radialLabelsHorizontal);
7802
+ }
7803
+
7804
+ // The track width W of the rectangular layouts: a quarter of the
7805
+ // viewport at first use (the layout's width, if smaller). The radial
7806
+ // layouts keep a width of their own, starting at the smaller of W and a
7807
+ // fifth of the radius, so a long architecture does not run off the
7808
+ // circle at first sight. d+ / d- scale whichever width the current
7809
+ // layout uses by 1.2 / 0.8 and stop at 2000 / 20. (The desktop instead
7810
+ // caps the drawn width at that fifth of the radius on every redraw,
7811
+ // which leaves its buttons stepping a number the cap then discards --
7812
+ // Christian: "all buttons should work as expected".)
7813
+ function domainRectangularWidth() {
7814
+ if (!_domain.width) {
7815
+ let vp = svgSize();
7816
+ let vw = Math.min(_displayWidth, (vp && vp.w) ? vp.w : _displayWidth);
7817
+ _domain.width = Math.max(DOMAIN_WIDTH_MIN, Math.round(vw * DOMAIN_WIDTH_VIEWPORT_FRACTION));
7818
+ }
7819
+ return _domain.width;
7820
+ }
7821
+
7822
+ function domainTrackWidth() {
7823
+ if (!radialDisplay()) {
7824
+ return domainRectangularWidth();
7825
+ }
7826
+ if (!_domain.radialWidth) {
7827
+ let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
7828
+ if (!(r > 0)) {
7829
+ return domainRectangularWidth(); // no radius yet (before the first radial draw)
7830
+ }
7831
+ _domain.radialWidth = Math.max(DOMAIN_WIDTH_MIN, Math.min(domainRectangularWidth(), DOMAIN_RADIAL_WIDTH_FRACTION * r));
7832
+ }
7833
+ return _domain.radialWidth;
7834
+ }
7835
+
7836
+ function setDomainTrackWidth(w) {
7837
+ if (radialDisplay()) {
7838
+ _domain.radialWidth = w;
7839
+ } else {
7840
+ _domain.width = w;
7841
+ }
7842
+ }
7843
+
7844
+ function domainEffectiveWidth() {
7845
+ return domainTrackWidth();
7846
+ }
7847
+
7848
+ // px per residue, one factor for every architecture on the tree; Lmax
7849
+ // counts every domain whatever its E-value, so the threshold never
7850
+ // rescales the tracks
7851
+ function domainScale() {
7852
+ return (domainEffectiveWidth() / _basicTreeProperties.maxDomainArchitectureLength) * DOMAIN_SCALE_HEADROOM;
7853
+ }
7854
+
7855
+ // The room the longest tip label takes: the same estimate the layout is
7856
+ // sized against (there are no font metrics in the model).
7857
+ function domainLabelSpace() {
7858
+ return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
7859
+ }
7860
+
7861
+ // The rectangular layout reserves the tracks' column so the tree and
7862
+ // labels compress to make room, as the alignment track does; the radial
7863
+ // layouts fit the tracks into the ring instead (fitRadialExtent).
7864
+ function domainReserve() {
7865
+ if (!domainsShown() || radialDisplay()) {
7866
+ return 0;
7867
+ }
7868
+ return DOMAIN_TRACK_START_GAP + domainTrackWidth() + DOMAIN_TRACK_END_GAP;
7869
+ }
7870
+
7871
+ function domainRadialExtent() {
7872
+ if (!domainsShown() || !radialDisplay()) {
7873
+ return 0;
7874
+ }
7875
+ return DOMAIN_RADIAL_GAP + domainEffectiveWidth() + DOMAIN_TRACK_END_GAP;
7876
+ }
7877
+
7878
+ // Colours: the names the threshold admits over the WHOLE tree, sorted by
7879
+ // code unit, take the palette in order. Dealt at load, after an edit and
7880
+ // whenever the threshold changes (the drawn set changes then, so colours
7881
+ // can shift); a name met later takes the next unused colour and keeps
7882
+ // it. Never derived from the name's characters -- that let SH2 and SH3
7883
+ // collide on the desktop.
7884
+ function domainPalette() {
7885
+ if (!_domain.palette) {
7886
+ let names = forester.domainSummary(forester.getTreeRoot(_treeData), _state.domainEvalueExponent).names;
7887
+ _domain.palette = Object.create(null);
7888
+ names.forEach(function (name, i) {
7889
+ _domain.palette[name] = i;
7890
+ });
7891
+ _domain.next = names.length;
7892
+ }
7893
+ return _domain.palette;
7894
+ }
7895
+
7896
+ function domainColor(name) {
7897
+ if (!name) {
7898
+ return forester.DOMAIN_UNNAMED_COLOR;
7899
+ }
7900
+ let palette = domainPalette();
7901
+ if (palette[name] === undefined) {
7902
+ palette[name] = _domain.next++;
7903
+ }
7904
+ return forester.domainQualitativeColor(palette[name]);
7905
+ }
7906
+
7907
+ function domainBoxHeight(pitch) {
7908
+ return Math.max(DOMAIN_BOX_MIN_H, Math.min(DOMAIN_BOX_MAX_H, Math.round(pitch)));
7909
+ }
7910
+
7911
+ function drawDomainArchitectures() {
7912
+ if (!_svgGroup) {
7913
+ return;
7914
+ }
7915
+ _svgGroup.selectAll('g.aptx-domains').remove();
7916
+ if (!domainsShown() || !_root) {
7917
+ return;
7918
+ }
7919
+ let tips = displayedTips().filter(function (d) {
7920
+ return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
7921
+ });
7922
+ let f = domainScale();
7923
+ if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
7924
+ return;
7925
+ }
7926
+ let g = _svgGroup.append('g').attr('class', 'aptx-domains').style('pointer-events', 'none');
7927
+ // one vertical gradient per base colour, lighter at the top; the
7928
+ // defs ride into the SVG / PDF / PNG exports with the boxes
7929
+ let defs = g.append('defs');
7930
+ let made = Object.create(null);
7931
+ function gradientFor(base) {
7932
+ let id = 'aptx-dom-' + base.substring(1);
7933
+ if (!made[id]) {
7934
+ made[id] = true;
7935
+ let grad = defs.append('linearGradient').attr('id', id)
7936
+ .attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', 1);
7937
+ grad.append('stop').attr('offset', '0%').attr('stop-color', forester.domainLighten(base, 0.12));
7938
+ grad.append('stop').attr('offset', '100%').attr('stop-color', forester.domainDarken(base, 0.10));
7939
+ }
7940
+ return 'url(#' + id + ')';
7941
+ }
7942
+ let labelSpace = domainLabelSpace();
7943
+ if (_state.circularDisplay) {
7944
+ // every bar starts on one ring past the labels and rides its
7945
+ // tip's spoke outward
7946
+ let r0 = _radial.maxRad + labelSpace + DOMAIN_RADIAL_GAP;
7947
+ let h = domainBoxHeight((r0 * 2 * Math.PI) / tips.length);
7948
+ tips.forEach(function (d) {
7949
+ let t = g.append('g').attr('transform', 'rotate(' + labelAngleDeg(d) + ')');
7950
+ drawOneArchitecture(t, forester.domainArchitectureOf(d), r0, -h / 2, h, f, false, gradientFor);
7951
+ });
7952
+ } else if (_state.unrootedDisplay) {
7953
+ // no common ring: each bar starts past its own tip's label
7954
+ let h = domainBoxHeight((Math.PI * 2 * _unroot.maxRad) / tips.length);
7955
+ let start = labelSpace + DOMAIN_RADIAL_GAP;
7956
+ tips.forEach(function (d) {
7957
+ let t = g.append('g').attr('transform', 'translate(' + d.ux + ',' + d.uy + ') rotate(' + labelAngleDeg(d) + ')');
7958
+ drawOneArchitecture(t, forester.domainArchitectureOf(d), start, -h / 2, h, f, false, gradientFor);
7959
+ });
7960
+ } else {
7961
+ // one aligned column: past the deepest tip and the longest label.
7962
+ // The box height follows the tip pitch (half of it), 6 to 16 px.
7963
+ tips.sort(function (p, q) {
7964
+ return p.x - q.x;
7965
+ });
7966
+ let n = tips.length;
7967
+ let pitch = n > 1 ? (tips[n - 1].x - tips[0].x) / (n - 1) : _state.externalNodeFontSize;
7968
+ let h = domainBoxHeight(pitch / 2);
7969
+ let start = _w + _state.nodeLabelGap + labelSpace + DOMAIN_TRACK_START_GAP;
7970
+ let labelsOn = _state.domainLabels === 'domains';
7971
+ tips.forEach(function (d) {
7972
+ drawOneArchitecture(g, forester.domainArchitectureOf(d), start, d.x - (h / 2), h, f, labelsOn, gradientFor);
7973
+ });
7974
+ }
7975
+ }
7976
+
7977
+ function domainRect(g, x, y, w, h, r) {
7978
+ return g.append('rect')
7979
+ .attr('x', x).attr('y', y).attr('width', w).attr('height', h)
7980
+ .attr('rx', r).attr('ry', r);
7981
+ }
7982
+
7983
+ // One architecture, in the desktop's order: the backbone, then per
7984
+ // admitted domain its stepped drop shadow, the optional glow, the
7985
+ // gradient body with its border, and -- rectangular only -- the name,
7986
+ // when it fits the box.
7987
+ function drawOneArchitecture(g, da, start, y1, h, f, labelsOn, gradientFor) {
7988
+ if (!da) {
7989
+ return;
7990
+ }
7991
+ let geo = forester.domainBoxes(da, start, f, _state.domainEvalueExponent);
7992
+ g.append('rect')
7993
+ .attr('x', geo.backbone.x).attr('y', y1 + (h / 2) - 0.5)
7994
+ .attr('width', geo.backbone.w).attr('height', 1)
7995
+ .style('fill', DOMAIN_BACKBONE_COLOR);
7996
+ let fs = Math.min(_state.externalNodeFontSize, h - 2);
7997
+ let font = fs + 'px ' + FONT_DEFAULTS;
7998
+ geo.boxes.forEach(function (b) {
7999
+ let base = domainColor(b.name);
8000
+ let r = Math.min(2, Math.min(b.w, h) / 2);
8001
+ DOMAIN_SHADOWS.forEach(function (s) {
8002
+ domainRect(g, b.x + s[0], y1 + s[1], b.w, h, r)
8003
+ .style('fill', DOMAIN_SHADOW_COLOR)
8004
+ .style('fill-opacity', s[2] / 255);
8005
+ });
8006
+ if (_state.domainGlow) {
8007
+ DOMAIN_GLOWS.forEach(function (gl) {
8008
+ let o = gl[0];
8009
+ domainRect(g, b.x - o, y1 - o, b.w + (2 * o), h + (2 * o), r + o)
8010
+ .style('fill', base)
8011
+ .style('fill-opacity', gl[1] / 255);
8012
+ });
8013
+ }
8014
+ domainRect(g, b.x, y1, b.w, h, r)
8015
+ .style('fill', gradientFor(base))
8016
+ .style('stroke', forester.domainDarken(base, 0.24))
8017
+ .style('stroke-width', 1);
8018
+ if (labelsOn && b.name && fs > 4 && legendTextWidth(b.name, font) <= b.w - 4) {
8019
+ g.append('text')
8020
+ .attr('x', b.x + (b.w / 2)).attr('y', y1 + (h / 2))
8021
+ .attr('dy', '0.35em')
8022
+ .attr('text-anchor', 'middle')
8023
+ .style('font', font)
8024
+ .style('fill', forester.domainLabelInk(base))
8025
+ .text(b.name);
8026
+ }
8027
+ });
8028
+ }
8029
+
8030
+ function clipTextToWidth(text, font, maxPx) {
8031
+ if (legendTextWidth(text, font) <= maxPx) {
8032
+ return text;
8033
+ }
8034
+ let s = text;
8035
+ while (s.length > 1 && legendTextWidth(s + '…', font) > maxPx) {
8036
+ s = s.substring(0, s.length - 1);
8037
+ }
8038
+ return s + '…';
8039
+ }
8040
+
8041
+ // The domain legend, in "Legend" mode only: the names drawn in the view,
8042
+ // in first-appearance order, each with its box count; a flat swatch per
8043
+ // row. Its home is the bottom-right corner; a drag keeps its place as a
8044
+ // fraction of the view, a double-click sends it home. Its own card, so
8045
+ // it neither follows nor crowds the Color-by legend.
8046
+ function drawDomainLegend() {
8047
+ if (!_baseSvg) {
8048
+ return;
8049
+ }
8050
+ _baseSvg.selectAll('g.' + LEGEND_DOMAINS).remove();
8051
+ if (!_root || !domainsShown() || _state.domainLabels !== 'legend') {
8052
+ return;
8053
+ }
8054
+ // rows in the order the tips are on screen -- top to bottom, or
8055
+ // clockwise -- not the data's order, which a ladderized display
8056
+ // does not follow
8057
+ let tips = displayedTips().filter(function (d) {
8058
+ return d.x !== undefined;
8059
+ }).sort(function (p, q) {
8060
+ return p.x - q.x;
8061
+ });
8062
+ let summary = forester.domainSummary(tips, _state.domainEvalueExponent);
8063
+ let size = svgSize();
8064
+ if (summary.legend.length === 0 || !size) {
8065
+ return;
8066
+ }
8067
+ const FS = Math.max(11, _state.externalNodeFontSize || 11);
8068
+ const PAD = 9;
8069
+ const ROW = FS + 6;
8070
+ const SWATCH = 10;
8071
+ const GAP = 6;
8072
+ const rowFont = FS + 'px ' + FONT_DEFAULTS;
8073
+ const titleFont = '600 ' + (FS + 1) + 'px ' + FONT_DEFAULTS;
8074
+ const ink = _state.labelColorDefault;
8075
+ const frame = _state.branchColorDefault;
8076
+ let title = 'Protein domains (E ≤ 1e' + _state.domainEvalueExponent + ')';
8077
+ let rows = summary.legend.map(function (r) {
8078
+ return {
8079
+ text: clipTextToWidth(r.name + ' (' + r.count + ')', rowFont, DOMAIN_LEGEND_ROW_MAX_PX),
8080
+ color: domainColor(r.name)
8081
+ };
8082
+ });
8083
+ let width = legendTextWidth(title, titleFont);
8084
+ rows.forEach(function (r) {
8085
+ width = Math.max(width, SWATCH + GAP + legendTextWidth(r.text, rowFont));
8086
+ });
8087
+ width += 2 * PAD;
8088
+ let height = PAD + ROW + (rows.length * ROW) + PAD - 2;
8089
+ let x, y;
8090
+ if (_domain.legendFrac) {
8091
+ x = _domain.legendFrac.fx * size.w;
8092
+ y = _domain.legendFrac.fy * size.h;
8093
+ } else {
8094
+ x = size.w - width - DOMAIN_LEGEND_INSET;
8095
+ y = size.h - height - DOMAIN_LEGEND_INSET;
8096
+ }
8097
+ x = Math.max(0, Math.min(size.w - 20, x));
8098
+ y = Math.max(0, Math.min(size.h - 20, y));
8099
+
8100
+ let g = _baseSvg.append('g').attr('class', LEGEND_DOMAINS)
8101
+ .style('cursor', 'move')
8102
+ .call(d3.drag()
8103
+ .on('start', function (event) {
8104
+ if (event.sourceEvent) {
8105
+ event.sourceEvent.stopPropagation(); // not a pan of the tree
8106
+ }
8107
+ })
8108
+ .on('drag', function (event) {
8109
+ x += event.dx;
8110
+ y += event.dy;
8111
+ _domain.legendFrac = {fx: x / size.w, fy: y / size.h};
8112
+ drawDomainLegend();
8113
+ }))
8114
+ .on('dblclick', function (event) {
8115
+ event.stopPropagation();
8116
+ _domain.legendFrac = null;
8117
+ drawDomainLegend();
8118
+ });
8119
+ g.append('title').text('drag to move; double-click to send it back to the corner');
8120
+ g.append('rect')
8121
+ .attr('x', x).attr('y', y)
8122
+ .attr('width', width).attr('height', height)
8123
+ .attr('rx', 5)
8124
+ .style('fill', _state.backgroundColorDefault)
8125
+ .style('fill-opacity', 0.92)
8126
+ .style('stroke', frame)
8127
+ .style('stroke-opacity', 0.5);
8128
+ let baseline = y + PAD + FS;
8129
+ g.append('text')
8130
+ .attr('x', x + PAD).attr('y', baseline)
8131
+ .style('font', titleFont)
8132
+ .style('fill', ink)
8133
+ .text(title);
8134
+ rows.forEach(function (r) {
8135
+ baseline += ROW;
8136
+ g.append('rect')
8137
+ .attr('x', x + PAD).attr('y', baseline - SWATCH + 1)
8138
+ .attr('width', SWATCH).attr('height', SWATCH)
8139
+ .attr('rx', 2)
8140
+ .style('fill', r.color);
8141
+ g.append('text')
8142
+ .attr('x', x + PAD + SWATCH + GAP).attr('y', baseline)
8143
+ .style('font', rowFont)
8144
+ .style('fill', ink)
8145
+ .text(r.text);
8146
+ });
8147
+ }
8148
+
8149
+ // The controls follow the toggle: hidden without it, and while shown
8150
+ // the readout, the select, the glow box and the four step buttons say
8151
+ // where things stand (a button at its limit is disabled, not a no-op).
8152
+ function syncDomainControls() {
8153
+ let fs = byId(DOMAIN_CONTROLS);
8154
+ if (!fs) {
8155
+ return;
8156
+ }
8157
+ let on = _state.showDomainArchitectures === true && _basicTreeProperties.domainArchitectures === true;
8158
+ setCheckboxValue(DOMAINS_CB, _state.showDomainArchitectures === true);
8159
+ fs.style.display = on ? '' : 'none';
8160
+ if (!on) {
8161
+ return;
8162
+ }
8163
+ let readout = byId(DOMAIN_EVALUE_READOUT);
8164
+ if (readout) {
8165
+ readout.textContent = forester.domainEvalueLabel(_state.domainEvalueExponent);
8166
+ }
8167
+ let w = domainTrackWidth();
8168
+ (w > DOMAIN_WIDTH_MIN ? enableButton : disableButton)(byId(DOMAIN_WIDTH_DEC));
8169
+ (w < DOMAIN_WIDTH_MAX ? enableButton : disableButton)(byId(DOMAIN_WIDTH_INC));
8170
+ (_state.domainEvalueExponent > forester.DOMAIN_EVALUE_EXPONENT_MIN ? enableButton : disableButton)(byId(DOMAIN_EVALUE_DEC));
8171
+ (_state.domainEvalueExponent < forester.DOMAIN_EVALUE_EXPONENT_MAX ? enableButton : disableButton)(byId(DOMAIN_EVALUE_INC));
8172
+ setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
8173
+ setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
8174
+ }
8175
+
8176
+ function domainsCbClicked() {
8177
+ _state.showDomainArchitectures = getCheckboxValue(DOMAINS_CB);
8178
+ if (_state.showDomainArchitectures && radialDisplay()) {
8179
+ _radialLabelsHorizontal = false; // the boxes ride the spokes, so the labels must too
8180
+ syncZoomRowButtons();
8181
+ }
8182
+ syncDomainControls();
8183
+ scheduleUpdate(null, 0);
8184
+ }
8185
+
8186
+ // d+ / d-: the width grows by 1.2 while under 2000, shrinks by 0.8 while
8187
+ // over 20 (so one press can carry it past a limit; the next is refused)
8188
+ function domainWidthStep(grow) {
8189
+ let w = domainTrackWidth();
8190
+ if (grow ? w >= DOMAIN_WIDTH_MAX : w <= DOMAIN_WIDTH_MIN) {
8191
+ return;
8192
+ }
8193
+ setDomainTrackWidth(w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK));
8194
+ syncDomainControls();
8195
+ // a radial layout has no column to reserve, but the fit must take the
8196
+ // new track length into account, so it is refitted
8197
+ if (radialDisplay()) {
8198
+ scheduleUpdate(null, 0, true);
8199
+ afterUpdate(zoomToFit);
8200
+ } else {
8201
+ scheduleUpdate(null, 0);
8202
+ }
8203
+ }
8204
+
8205
+ function domainEvalueStep(delta) {
8206
+ let e = _state.domainEvalueExponent + delta;
8207
+ if (e < forester.DOMAIN_EVALUE_EXPONENT_MIN || e > forester.DOMAIN_EVALUE_EXPONENT_MAX) {
8208
+ return;
8209
+ }
8210
+ _state.domainEvalueExponent = e;
8211
+ _domain.palette = null; // the drawn set changed: the palette is dealt again
8212
+ syncDomainControls();
8213
+ scheduleUpdate(null, 0, true);
8214
+ }
8215
+
8216
+ function domainLabelsChanged() {
8217
+ _state.domainLabels = getValue(DOMAIN_LABELS_SELECT);
8218
+ scheduleUpdate(null, 0, true);
8219
+ }
8220
+
8221
+ function domainGlowCbClicked() {
8222
+ _state.domainGlow = getCheckboxValue(DOMAIN_GLOW_CB);
8223
+ scheduleUpdate(null, 0, true);
8224
+ }
8225
+
8226
+ // A step button that repeats while held: one step on press, then after
8227
+ // a pause one every 90 ms until release (the desktop's auto-repeat on
8228
+ // d- / d+). A keyboard press steps once.
8229
+ function onHoldRepeat(id, step) {
8230
+ let delay = null;
8231
+ let repeat = null;
8232
+ onHold(id, function () {
8233
+ step();
8234
+ delay = setTimeout(function () {
8235
+ repeat = setInterval(step, 90);
8236
+ }, 400);
8237
+ }, function () {
8238
+ if (delay) {
8239
+ clearTimeout(delay);
8240
+ delay = null;
8241
+ }
8242
+ if (repeat) {
8243
+ clearInterval(repeat);
8244
+ repeat = null;
8245
+ }
8246
+ });
8247
+ }
8248
+
8249
+ // Search B starts hidden to keep the panel compact; one click (or a
8250
+ // configured initial value) reveals it, and it stays revealed.
8251
+ // Search B is folded away until wanted: the '+ Search B' link (which
8252
+ // focuses it) or a view that carries a second search.
8253
+ function showSearchB() {
8254
+ let wrap = byId(SEARCH_B_WRAP);
8255
+ if (wrap) {
8256
+ wrap.style.display = '';
8257
+ }
8258
+ let tgl = byId(SEARCH_B_TOGGLE);
8259
+ if (tgl) {
8260
+ tgl.style.display = 'none';
8261
+ }
8262
+ }
8263
+
8264
+ function revealSearchB() {
8265
+ showSearchB();
8266
+ let f = byId(SEARCH_FIELD_1);
8267
+ if (f) {
8268
+ f.focus();
8269
+ }
8270
+ }
8271
+
8272
+ // ===================== Time axis =====================
8273
+ // The desktop's time overlays, drawn beneath a rectangular PHYLOGRAM of
8274
+ // a dated tree: the two-band ICS geologic axis with a "Ma before
8275
+ // present" ruler (or a calendar-year ruler), HPD age bars on dated
8276
+ // internal nodes and sepia fossil-range (FAD/LAD) bars on dated tips.
8277
+ // The tree layout itself never changes -- time is an overlay anchored to
8278
+ // the tree's own branch scale, so the bands line up with the branches
8279
+ // even on a fossil-only tree whose youngest tip is far from the present.
8280
+
8281
+ function timeAxisShown() {
8282
+ return _state.showTimeAxis === true && !radialDisplay()
8283
+ && _state.phylogram === true
8284
+ && _timeInfo !== null && _timeInfo.type !== null
8285
+ && _basicTreeProperties.branchLengths === true;
8286
+ }
8287
+
8288
+ function timeAxisBottomReserve() {
8289
+ if (!timeAxisShown()) {
8290
+ return 0;
8291
+ }
8292
+ return _timeInfo.type === 'geologic' ? TIME_GEO_RESERVE : TIME_CAL_RESERVE;
8293
+ }
8294
+
8295
+ // The MSA footer sits under the alignment band (right) and the time axis
8296
+ // under the tree (left) -- horizontally disjoint, so the two reserves
8297
+ // share the same strip rather than stacking.
8298
+ function bottomOverlayReserve() {
8299
+ // The navigation bar is fixed at the viewport bottom, so the track's
8300
+ // own bottom rows (conservation, consensus, ruler) must end above it
8301
+ // or the bar covers them -- the old bare slider did exactly that.
8302
+ // The scale bar sits in the same band, under the tree's last row.
8303
+ return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
8304
+ scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
8305
+ }
8306
+
8307
+ // ===================== Scale bar =====================
8308
+ // A phylogram carries a scale bar at the bottom left of the tree: a
8309
+ // round number of branch-length units (forester.scaleBarLength) with
8310
+ // end ticks and its length written above, drawn in the tree's own
8311
+ // coordinates so it zooms and exports with the tree and its label stays
8312
+ // true. Not in a cladogram (nothing to measure) and not under a time
8313
+ // axis (which is a scale already); in the radial layouts it sits below
8314
+ // the fan at its left edge.
8315
+ const SCALE_BAR_TARGET_PX = 100;
8316
+ const SCALE_BAR_RESERVE = 30;
8317
+
8318
+ function scaleBarShown() {
8319
+ return _state.phylogram === true && _basicTreeProperties.branchLengths === true && !timeAxisShown();
8320
+ }
8321
+
8322
+ // pixels per branch-length unit in the current layout's own coordinates
8323
+ function branchLengthPixelsPerUnit() {
8324
+ if (!_state.phylogram || !_yScale) {
8325
+ return 0;
8326
+ }
8327
+ if (_state.unrootedDisplay) {
8328
+ return (_unroot && _unroot.pxPerUnit) ? _unroot.pxPerUnit : 0;
8329
+ }
8330
+ let unit = _yScale(1) - _yScale(0);
8331
+ if (_state.circularDisplay) {
8332
+ return (_radial && _radial.maxY > 0) ? unit * (_radial.maxRad / _radial.maxY) : 0;
8333
+ }
8334
+ return unit;
8335
+ }
8336
+
8337
+ function drawScaleBar() {
8338
+ if (!_svgGroup) {
8339
+ return;
8340
+ }
8341
+ _svgGroup.selectAll('g.aptx-scalebar').remove();
8342
+ if (!scaleBarShown()) {
8343
+ return;
8344
+ }
8345
+ let bar = forester.scaleBarLength(branchLengthPixelsPerUnit(), SCALE_BAR_TARGET_PX);
8346
+ if (!bar) {
8347
+ return;
8348
+ }
8349
+ let x, y;
8350
+ if (radialDisplay()) {
8351
+ let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
8352
+ let r = (_state.circularDisplay ? _radial.maxRad : _unroot.maxRad) + labelSpace;
8353
+ x = -r;
8354
+ y = r + 24;
8355
+ } else {
8356
+ x = 0;
8357
+ y = _clusterH + SCALE_BAR_RESERVE - 12;
8358
+ }
8359
+ let stroke = _state.branchColorDefault;
8360
+ let width = Math.max(1, _state.branchWidthDefault);
8361
+ let fs = Math.max(9, _state.externalNodeFontSize);
8362
+ let g = _svgGroup.append('g').attr('class', 'aptx-scalebar').style('pointer-events', 'none');
8363
+ g.append('line')
8364
+ .attr('x1', x).attr('y1', y).attr('x2', x + bar.px).attr('y2', y)
8365
+ .style('stroke', stroke).style('stroke-width', width);
8366
+ [x, x + bar.px].forEach(function (tx) {
8367
+ g.append('line')
8368
+ .attr('x1', tx).attr('y1', y - 4).attr('x2', tx).attr('y2', y + 4)
8369
+ .style('stroke', stroke).style('stroke-width', width);
8370
+ });
8371
+ g.append('text')
8372
+ .attr('x', x + (bar.px / 2)).attr('y', y - 6)
8373
+ .attr('text-anchor', 'middle')
8374
+ .style('font', fs + 'px ' + FONT_DEFAULTS)
8375
+ .style('fill', _state.labelColorDefault)
8376
+ .text(bar.label);
8377
+ }
8378
+
8379
+ function hexToRgbTriple(hex) {
8380
+ let h = hex.charAt(0) === '#' ? hex.substring(1) : hex;
8381
+ return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16),
8382
+ parseInt(h.substring(4, 6), 16)];
8383
+ }
8384
+
8385
+ function drawTimeOverlays() {
8386
+ if (!_svgGroup) {
8387
+ return;
8388
+ }
8389
+ _svgGroup.selectAll('g.aptx-time').remove();
8390
+ if (_floatGroup) {
8391
+ _floatGroup.selectAll('g.aptx-time-axis').remove();
8392
+ delete _floatStrips['aptx-time-axis'];
8393
+ }
8394
+ _svgGroup.selectAll('g.aptx-timegrid').remove();
8395
+ if (!timeAxisShown() || !_root || !_yScale) {
8396
+ return;
8397
+ }
8398
+ // recomputed per draw, so a subtree view calibrates to ITS oldest node
8399
+ let info = forester.timeAxisInfo(_root);
8400
+ if (!info.type) {
8401
+ return;
8402
+ }
8403
+ let corr = Math.abs(_yScale(1) - _yScale(0));
8404
+ if (!isFinite(corr) || corr <= 0) {
8405
+ return;
8406
+ }
8407
+ // Anchor the age->x mapping at the DEEPEST DATED TIP rather than the
8408
+ // root: the root and its direct children carry a synthetic half-
8409
+ // average branch length (see bl()), which would shift every band.
8410
+ let anchor = null;
8411
+ let maxTipX = 0;
8412
+ forEachDisplayed(function (n) {
8413
+ if (n.children) {
8414
+ return;
8415
+ }
8416
+ if (n.y > maxTipX) {
8417
+ maxTipX = n.y;
8418
+ }
8419
+ if (n.date && typeof n.date.value === 'number') {
8420
+ if (!anchor || n.distToRoot > anchor.distToRoot) {
8421
+ anchor = n;
8422
+ }
8423
+ }
8424
+ });
6225
8425
  // mouse-transparent: the HPD bar rect sits right on top of an internal
6226
8426
  // node's circle and would otherwise swallow its hover and click
6227
8427
  let g = _svgGroup.append('g').attr('class', 'aptx-time')
@@ -6247,7 +8447,7 @@ function (root, d3, forester, phyloXml) {
6247
8447
  let sc = info.type === 'calendar' ? -corr : corr;
6248
8448
 
6249
8449
  // ---- HPD age bars (internal) + fossil range bars (tips) ----
6250
- forester.preOrderTraversal(_root, function (d) {
8450
+ forEachDisplayed(function (d) {
6251
8451
  if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
6252
8452
  || d.y === undefined) {
6253
8453
  return;
@@ -6465,7 +8665,7 @@ function (root, d3, forester, phyloXml) {
6465
8665
  return;
6466
8666
  }
6467
8667
  let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
6468
- let outer = maxRad + labelSpace;
8668
+ let outer = maxRad + labelSpace + domainRadialExtent();
6469
8669
  let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
6470
8670
  let scale = 0.9 * (Math.min(W, H) / (2 * outer));
6471
8671
  if (!isFinite(scale) || scale <= 0) {
@@ -6481,7 +8681,7 @@ function (root, d3, forester, phyloXml) {
6481
8681
  setZoomScale(1);
6482
8682
  update(_root, 0);
6483
8683
  _zoomed_x_or_y = true;
6484
- const uncollsed_nodes = forester.calcSumOfExternalDescendants(_root);
8684
+ const uncollsed_nodes = displayedLeafCount();
6485
8685
  _displayHeight = _state.externalNodeFontSize * (uncollsed_nodes * 1.3);
6486
8686
  const min = 40;
6487
8687
  if (_displayHeight < min) {
@@ -6496,7 +8696,7 @@ function (root, d3, forester, phyloXml) {
6496
8696
  _root = _root_const;
6497
8697
  _in_subtree = false;
6498
8698
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
6499
- refreshVisualizations();
8699
+ refreshVisualizations(false);
6500
8700
  search0();
6501
8701
  search1();
6502
8702
  zoomToFit();
@@ -6535,7 +8735,7 @@ function (root, d3, forester, phyloXml) {
6535
8735
  }
6536
8736
 
6537
8737
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
6538
- refreshVisualizations();
8738
+ refreshVisualizations(false);
6539
8739
  search0();
6540
8740
  search1();
6541
8741
  zoomToFit();
@@ -6554,13 +8754,10 @@ function (root, d3, forester, phyloXml) {
6554
8754
  if (_treeFn.visData.ladderize === undefined) {
6555
8755
  _treeFn.visData.ladderize = true;
6556
8756
  }
6557
- ladderizeSubtree(_root, _treeFn.visData.ladderize, true);
8757
+ let largestFirst = ladderizeSubtree(_root, _treeFn.visData.ladderize, true);
8758
+ _viewOps.order = largestFirst ? 'asc' : 'desc'; // what a shared view replays
6558
8759
  _treeFn.visData.ladderize = !_treeFn.visData.ladderize;
6559
- // The glyph shows the direction the NEXT press will ladderize in.
6560
- let orderBtn = byId(LADDERIZE_BUTTON);
6561
- if (orderBtn) {
6562
- orderBtn.innerHTML = makeGlyph(_treeFn.visData.ladderize ? 'ladderize_asc' : 'ladderize_desc');
6563
- }
8760
+ syncLadderizeGlyph();
6564
8761
  update(null, 0);
6565
8762
  }
6566
8763
  }
@@ -6583,6 +8780,7 @@ function (root, d3, forester, phyloXml) {
6583
8780
  {
6584
8781
  label: 'Midpoint re-root', action: function () {
6585
8782
  forester.midpointRoot(_root);
8783
+ _viewOps.root = 'midpoint'; // what a shared view replays
6586
8784
  zoomToFit();
6587
8785
  }
6588
8786
  },
@@ -6599,6 +8797,7 @@ function (root, d3, forester, phyloXml) {
6599
8797
  _root = _root_const;
6600
8798
  _in_subtree = false;
6601
8799
  }
8800
+ clearCollapsedFlags(_root); // the launch state has nothing collapsed
6602
8801
 
6603
8802
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
6604
8803
 
@@ -6608,7 +8807,7 @@ function (root, d3, forester, phyloXml) {
6608
8807
  _radialLabelsHorizontal = false;
6609
8808
  _msaColOffset = 0;
6610
8809
  syncZoomRowButtons();
6611
- refreshVisualizations();
8810
+ refreshVisualizations(false);
6612
8811
  // Esc resets to the launch state -- the auto-applied colour, when its
6613
8812
  // field still exists in what remains of the tree.
6614
8813
  if (_vis) {
@@ -6744,12 +8943,14 @@ function (root, d3, forester, phyloXml) {
6744
8943
  }
6745
8944
 
6746
8945
  function resetSearch0() {
8946
+ closeSuggestions();
6747
8947
  setValue(SEARCH_FIELD_0, '');
6748
8948
  setValue(SEARCH_VALUE2_0, '');
6749
8949
  runSearches();
6750
8950
  }
6751
8951
 
6752
8952
  function resetSearch1() {
8953
+ closeSuggestions();
6753
8954
  setValue(SEARCH_FIELD_1, '');
6754
8955
  setValue(SEARCH_VALUE2_1, '');
6755
8956
  runSearches();
@@ -6763,12 +8964,10 @@ function (root, d3, forester, phyloXml) {
6763
8964
 
6764
8965
  // Read a search box's current field / mode / value(s) into a spec.
6765
8966
  function currentSearchSpec(idx) {
6766
- let key = getValue(idx === 0 ? SEARCH_FIELD_SELECT_0 : SEARCH_FIELD_SELECT_1);
6767
- let field = null;
6768
- for (let i = 0; i < _searchFields.length; ++i) {
6769
- if (_searchFields[i].key === key) { field = _searchFields[i]; break; }
6770
- }
6771
- if (!field) field = _searchFields[0] || { key: 'ANY', label: 'Any Text', numeric: false };
8967
+ // the field menu's option values index _searchFields; before a tree
8968
+ // is loaded there is only Any Text
8969
+ let field = _searchFields[Number(getValue(idx === 0 ? SEARCH_FIELD_SELECT_0 : SEARCH_FIELD_SELECT_1))]
8970
+ || _searchFields[0] || forester.availableSearchFields(null)[0];
6772
8971
  let mode = getValue(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
6773
8972
  if (!mode) mode = field.numeric ? 'eq' : 'contains';
6774
8973
  return {
@@ -6793,20 +8992,23 @@ function (root, d3, forester, phyloXml) {
6793
8992
  // Fill both search-field dropdowns from the loaded tree's available fields,
6794
8993
  // then set up each box's mode menu. Called when a tree is (re)loaded.
6795
8994
  function populateSearchMenus() {
8995
+ let before = _searchFields;
6796
8996
  _searchFields = forester.availableSearchFields(_root);
6797
8997
  [SEARCH_FIELD_SELECT_0, SEARCH_FIELD_SELECT_1].forEach(function (selId) {
6798
8998
  let sel = byId(selId);
6799
8999
  if (!sel) return;
6800
- let prev = sel.value;
9000
+ // a box keeps its field when the new tree still offers it (labels
9001
+ // are unique: a property's label is its ref, which has a colon)
9002
+ let prev = before[Number(sel.value)];
9003
+ let keep = prev ? _searchFields.findIndex(f => f.label === prev.label) : -1;
6801
9004
  sel.innerHTML = '';
6802
9005
  for (let i = 0; i < _searchFields.length; ++i) {
6803
9006
  let opt = document.createElement('option');
6804
- opt.value = _searchFields[i].key;
9007
+ opt.value = String(i);
6805
9008
  opt.textContent = _searchFields[i].label;
6806
9009
  sel.appendChild(opt);
6807
9010
  }
6808
- if (prev && _searchFields.some(f => f.key === prev)) sel.value = prev;
6809
- else sel.value = 'ANY';
9011
+ sel.value = String(keep >= 0 ? keep : 0);
6810
9012
  });
6811
9013
  populateSearchModeMenu(0);
6812
9014
  populateSearchModeMenu(1);
@@ -6853,24 +9055,252 @@ function (root, d3, forester, phyloXml) {
6853
9055
  if (idx === 0) { search0(); } else { search1(); }
6854
9056
  }
6855
9057
 
6856
- // Populate (or detach) the value box's <datalist> so the browser offers
6857
- // type-ahead value suggestions for specific text fields only.
9058
+ // ===================== Search suggestions =====================
9059
+ // The value box's type-ahead: the distinct values of the chosen field,
9060
+ // filtered as you type, in an in-page list under the box drawn like the
9061
+ // node menu in the panel's own theme. It replaces the browser's
9062
+ // <datalist> widget -- the one control the page could not style, which
9063
+ // sat in the panel like a stranger. Off, as before, for numeric fields,
9064
+ // Any Text and the molecular sequence (field.suggest === false), and in
9065
+ // regex mode. The values are gathered once per field or mode change
9066
+ // (updateSearchAutocomplete), not per keystroke: on a big tree that is a
9067
+ // walk of every node.
9068
+
9069
+ const SUGGEST_MAX_ROWS = 10;
9070
+ let _suggestValues = [null, null]; // per box: the field's distinct values, or null when suggestions are off
9071
+ let _suggest = null; // the open list: {idx, input, box, rows, active, onOutside}
9072
+ let _suggestPicked = false; // an Enter that picked a row must not also re-run the search on keyup
9073
+ let _suggestSkipFocus = false; // the focus a pick hands back must not reopen the list
9074
+
6858
9075
  function updateSearchAutocomplete(idx) {
6859
- let dlId = idx === 0 ? SEARCH_DATALIST_0 : SEARCH_DATALIST_1;
6860
- let dl = byId(dlId);
6861
- let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
6862
- if (!dl || !input) return;
9076
+ closeSuggestions();
6863
9077
  let spec = currentSearchSpec(idx);
6864
- let enable = !spec.field.numeric && spec.field.key !== 'ANY' && spec.field.key !== 'MS' && spec.mode !== 'regex';
6865
- dl.innerHTML = '';
6866
- if (!enable) { input.removeAttribute('list'); return; }
6867
- let vals = forester.distinctSearchValues(_root, spec.field, SEARCH_AUTOCOMPLETE_CAP);
6868
- for (let i = 0; i < vals.length; ++i) {
6869
- let opt = document.createElement('option');
6870
- opt.value = vals[i];
6871
- dl.appendChild(opt);
9078
+ let on = !spec.field.numeric && spec.field.suggest !== false && spec.mode !== 'regex';
9079
+ _suggestValues[idx] = on ? forester.distinctSearchValues(_root, spec.field, SEARCH_AUTOCOMPLETE_CAP) : null;
9080
+ }
9081
+
9082
+ // The term being typed is the last one: ',' is OR and '+' is AND in the
9083
+ // plain-text modes, and a pick replaces just that term.
9084
+ function suggestTermStart(text) {
9085
+ return Math.max(text.lastIndexOf(','), text.lastIndexOf('+')) + 1;
9086
+ }
9087
+
9088
+ // Suggestions compare the way the search will: verbatim while Match
9089
+ // case is on, folded to lower case otherwise (Christian, 2026-09-13,
9090
+ // joint with the desktop).
9091
+ function suggestFold(s) {
9092
+ return _state.searchIsCaseSensitive === true ? s : s.toLowerCase();
9093
+ }
9094
+
9095
+ // The values the typed term admits, matched the way the box's mode will
9096
+ // match them: a prefix for "starts with", a suffix for "ends with", a
9097
+ // substring otherwise.
9098
+ function suggestMatches(idx, term) {
9099
+ let values = _suggestValues[idx] || [];
9100
+ let mode = getValue(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
9101
+ let needle = suggestFold(term);
9102
+ return values.filter(function (v) {
9103
+ if (needle.length === 0) {
9104
+ return true;
9105
+ }
9106
+ let lv = suggestFold(v);
9107
+ if (mode === 'starts_with') {
9108
+ return lv.indexOf(needle) === 0;
9109
+ }
9110
+ if (mode === 'ends_with') {
9111
+ return lv.length >= needle.length && lv.lastIndexOf(needle) === lv.length - needle.length;
9112
+ }
9113
+ return lv.indexOf(needle) >= 0;
9114
+ });
9115
+ }
9116
+
9117
+ function openSuggestions(idx) {
9118
+ closeSuggestions();
9119
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9120
+ if (!input || !_suggestValues[idx] || _suggestValues[idx].length === 0) {
9121
+ return;
9122
+ }
9123
+ let text = input.value;
9124
+ let start = suggestTermStart(text);
9125
+ let term = text.substring(start).trim();
9126
+ let matches = suggestMatches(idx, term);
9127
+ // nothing to offer when nothing matches, or the one match is what is already typed
9128
+ if (matches.length === 0 || (matches.length === 1 && suggestFold(matches[0]) === suggestFold(term))) {
9129
+ return;
9130
+ }
9131
+ let box = document.createElement('div');
9132
+ box.className = 'aptx-suggest';
9133
+ if (_panelTheme) {
9134
+ box.classList.add('aptx-' + _panelTheme); // follow the panel's light/dark choice
9135
+ }
9136
+ let needle = suggestFold(term);
9137
+ let rows = [];
9138
+ matches.slice(0, SUGGEST_MAX_ROWS).forEach(function (v) {
9139
+ let b = document.createElement('button');
9140
+ b.type = 'button';
9141
+ b.tabIndex = -1;
9142
+ b.title = v;
9143
+ // the matched part in bold, wherever it sits in the value
9144
+ let at = needle.length > 0 ? suggestFold(v).indexOf(needle) : -1;
9145
+ if (at >= 0) {
9146
+ b.appendChild(document.createTextNode(v.substring(0, at)));
9147
+ let hit = document.createElement('b');
9148
+ hit.textContent = v.substring(at, at + needle.length);
9149
+ b.appendChild(hit);
9150
+ b.appendChild(document.createTextNode(v.substring(at + needle.length)));
9151
+ } else {
9152
+ b.textContent = v;
9153
+ }
9154
+ b.addEventListener('mousedown', function (e) {
9155
+ e.preventDefault(); // the focus stays in the box: no blur, no flicker
9156
+ });
9157
+ b.addEventListener('click', function (e) {
9158
+ e.stopPropagation();
9159
+ pickSuggestion(idx, v);
9160
+ });
9161
+ box.appendChild(b);
9162
+ rows.push(b);
9163
+ });
9164
+ if (matches.length > SUGGEST_MAX_ROWS) {
9165
+ let more = document.createElement('div');
9166
+ more.className = 'aptx-suggest-more';
9167
+ more.textContent = (matches.length - SUGGEST_MAX_ROWS) + ' more — keep typing';
9168
+ box.appendChild(more);
9169
+ }
9170
+ document.body.appendChild(box);
9171
+ // under the box and as wide as it, or above it when the window's
9172
+ // bottom is too close
9173
+ let r = input.getBoundingClientRect();
9174
+ box.style.minWidth = Math.round(r.width) + 'px';
9175
+ let h = box.getBoundingClientRect().height;
9176
+ let below = r.bottom + 3;
9177
+ let top = (below + h > document.documentElement.clientHeight - 8 && r.top - h - 3 > 8) ? r.top - h - 3 : below;
9178
+ box.style.left = Math.round(r.left + window.scrollX) + 'px';
9179
+ box.style.top = Math.round(top + window.scrollY) + 'px';
9180
+ // a press anywhere else closes it -- in the capture phase, since d3's
9181
+ // zoom stops mousedown on the tree from reaching the document
9182
+ let onOutside = function (e) {
9183
+ if (_suggest && !_suggest.box.contains(e.target) && e.target !== input) {
9184
+ closeSuggestions();
9185
+ }
9186
+ };
9187
+ document.addEventListener('mousedown', onOutside, true);
9188
+ _suggest = {idx: idx, input: input, box: box, rows: rows, active: -1, onOutside: onOutside};
9189
+ }
9190
+
9191
+ function closeSuggestions() {
9192
+ if (!_suggest) {
9193
+ return;
9194
+ }
9195
+ document.removeEventListener('mousedown', _suggest.onOutside, true);
9196
+ _suggest.box.remove();
9197
+ _suggest = null;
9198
+ }
9199
+
9200
+ function setActiveSuggestion(i) {
9201
+ if (!_suggest) {
9202
+ return;
9203
+ }
9204
+ _suggest.rows.forEach(function (b, k) {
9205
+ b.classList.toggle('aptx-active', k === i);
9206
+ });
9207
+ _suggest.active = i;
9208
+ }
9209
+
9210
+ function pickSuggestion(idx, value) {
9211
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9212
+ closeSuggestions();
9213
+ if (!input) {
9214
+ return;
9215
+ }
9216
+ let text = input.value;
9217
+ input.value = text.substring(0, suggestTermStart(text)) + value;
9218
+ _suggestSkipFocus = true;
9219
+ input.focus();
9220
+ runSearches();
9221
+ }
9222
+
9223
+ // Keys in the value box: the arrows open the list and move along it,
9224
+ // Enter picks, Escape and Tab close. An Escape that closed the list must
9225
+ // not also reset the view, exactly as the node menu's does not.
9226
+ function suggestKeydown(idx, e) {
9227
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
9228
+ if (!_suggest) {
9229
+ openSuggestions(idx);
9230
+ if (!_suggest) {
9231
+ return;
9232
+ }
9233
+ }
9234
+ let n = _suggest.rows.length;
9235
+ let a = _suggest.active;
9236
+ // on a list with no row chosen yet, Down starts at the top and Up
9237
+ // at the bottom (the desktop found Up landing one short of it)
9238
+ setActiveSuggestion(e.key === 'ArrowDown' ? (a + 1) % n : (a < 0 ? n - 1 : (a - 1 + n) % n));
9239
+ e.preventDefault();
9240
+ } else if (e.key === 'Enter') {
9241
+ if (_suggest && _suggest.active >= 0) {
9242
+ _suggestPicked = true;
9243
+ pickSuggestion(idx, _suggest.rows[_suggest.active].title);
9244
+ e.preventDefault();
9245
+ } else {
9246
+ closeSuggestions();
9247
+ }
9248
+ } else if (e.key === 'Escape') {
9249
+ if (_suggest) {
9250
+ closeSuggestions();
9251
+ _menuConsumedEsc = true;
9252
+ e.stopPropagation();
9253
+ }
9254
+ } else if (e.key === 'Tab') {
9255
+ closeSuggestions();
9256
+ }
9257
+ }
9258
+
9259
+ // The value box's keyup runs the search. A navigation key changes no
9260
+ // value, and an Enter that just picked a row has already run it.
9261
+ const SUGGEST_NAV_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape', 'Tab', 'Shift',
9262
+ 'Control', 'Alt', 'Meta', 'Home', 'End', 'PageUp', 'PageDown', 'CapsLock'];
9263
+
9264
+ function searchValueKeyup(e) {
9265
+ if (SUGGEST_NAV_KEYS.indexOf(e.key) >= 0) {
9266
+ return;
6872
9267
  }
6873
- input.setAttribute('list', dlId);
9268
+ if (e.key === 'Enter' && _suggestPicked) {
9269
+ _suggestPicked = false;
9270
+ return;
9271
+ }
9272
+ runSearches();
9273
+ }
9274
+
9275
+ function wireSuggestions(idx) {
9276
+ let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
9277
+ if (!input) {
9278
+ return;
9279
+ }
9280
+ input.addEventListener('keydown', function (e) {
9281
+ suggestKeydown(idx, e);
9282
+ });
9283
+ input.addEventListener('input', function () {
9284
+ openSuggestions(idx);
9285
+ });
9286
+ input.addEventListener('focus', function () {
9287
+ if (_suggestSkipFocus) {
9288
+ _suggestSkipFocus = false;
9289
+ return;
9290
+ }
9291
+ if (input.value.length > 0) {
9292
+ openSuggestions(idx);
9293
+ }
9294
+ });
9295
+ input.addEventListener('blur', function () {
9296
+ // after a tick: a click on a row keeps the focus, anything else
9297
+ // that took it closes the list
9298
+ setTimeout(function () {
9299
+ if (_suggest && _suggest.idx === idx && document.activeElement !== input) {
9300
+ closeSuggestions();
9301
+ }
9302
+ }, 120);
9303
+ });
6874
9304
  }
6875
9305
 
6876
9306
 
@@ -6898,7 +9328,12 @@ function (root, d3, forester, phyloXml) {
6898
9328
  function layoutButtonClicked() {
6899
9329
  _state.circularDisplay = getCheckboxValue(LAYOUT_CIRC_BUTTON);
6900
9330
  _state.unrootedDisplay = getCheckboxValue(LAYOUT_UNROOTED_BUTTON);
9331
+ if (radialDisplay() && _state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
9332
+ // the domain boxes ride the tips' spokes, so the labels must too
9333
+ _radialLabelsHorizontal = false;
9334
+ }
6901
9335
  syncZoomRowButtons();
9336
+ afterUpdate(syncDomainControls); // the track width buttons follow the layout's own width
6902
9337
  zoomToFit();
6903
9338
  }
6904
9339
 
@@ -7098,6 +9533,27 @@ function (root, d3, forester, phyloXml) {
7098
9533
  scheduleUpdate(null, 0);
7099
9534
  }
7100
9535
 
9536
+ // The desktop draws a box around "Auto-hide Labels" while labels are
9537
+ // actually being hidden -- the switch being on is one thing, the tree
9538
+ // being dense enough for it to bite is another. Same here, in the
9539
+ // panel's accent: the checkbox item gets a box and its tooltip says how
9540
+ // many labels are shown. Called on every redraw, since the density
9541
+ // changes with the vertical zoom, the font size and the view.
9542
+ function syncDynahideIndicator(active) {
9543
+ let cb = byId(DYNAHIDE_CB);
9544
+ let item = cb ? cb.closest('.aptx-check') : null;
9545
+ if (!item) {
9546
+ return;
9547
+ }
9548
+ if (item.dataset.baseTitle === undefined) {
9549
+ item.dataset.baseTitle = item.title;
9550
+ }
9551
+ item.classList.toggle('aptx-check-active', !!active);
9552
+ item.title = active
9553
+ ? item.dataset.baseTitle + ' — hiding now: 1 in ' + _dynahide_factor + ' labels shown'
9554
+ : item.dataset.baseTitle;
9555
+ }
9556
+
7101
9557
  function dynaHideCbClicked() {
7102
9558
  _state.dynahide = getCheckboxValue(DYNAHIDE_CB);
7103
9559
  resetVis();
@@ -7162,6 +9618,7 @@ function (root, d3, forester, phyloXml) {
7162
9618
 
7163
9619
  function searchOptionsCaseSenstiveCbClicked() {
7164
9620
  _state.searchIsCaseSensitive = getCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB);
9621
+ closeSuggestions(); // an open list was filtered the other way; the next keystroke refilters
7165
9622
  search0();
7166
9623
  search1();
7167
9624
  }
@@ -7656,6 +10113,9 @@ function (root, d3, forester, phyloXml) {
7656
10113
  case 'ladderize_asc': sw = 8; cap = 'round'; body = glyphLadderize(true); break;
7657
10114
  case 'ladderize_desc': sw = 8; cap = 'round'; body = glyphLadderize(false); break;
7658
10115
  case 'midpoint': sw = 8; cap = 'round'; body = glyphMidpoint(); break;
10116
+ case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
10117
+ case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
10118
+ case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
7659
10119
  case 'sun': body = glyphSun(); break;
7660
10120
  case 'moon': body = glyphMoon(); break;
7661
10121
  default: throw new Error('unknown control-panel glyph: ' + kind);
@@ -7665,6 +10125,20 @@ function (root, d3, forester, phyloXml) {
7665
10125
  + ' stroke-linecap="' + cap + '" stroke-linejoin="' + join + '">' + body + '</svg>';
7666
10126
  }
7667
10127
 
10128
+ // The desktop's UNCOLLAPSE_ALL glyph (ControlButtonIcon.paintUncollapse),
10129
+ // ported point for point into the 100-unit box: the collapsed-clade
10130
+ // triangle, apex at the parent, base toward the tips it opens back out
10131
+ // into -- three of them.
10132
+ // A chevron pointing left or right: the previous / next tree buttons.
10133
+ function glyphChevron(right) {
10134
+ return right ? '<path d="M38,18 L66,50 L38,82"/>' : '<path d="M62,18 L34,50 L62,82"/>';
10135
+ }
10136
+
10137
+ function glyphUncollapseAll() {
10138
+ return '<path d="M6,50 L44,16 L44,84 Z" fill="currentColor" stroke="none"/>'
10139
+ + '<path d="M56,20 H94 M56,50 H94 M56,80 H94"/>';
10140
+ }
10141
+
7668
10142
  function injectPanelStyles() {
7669
10143
  loadPanelTheme();
7670
10144
  if (document.getElementById(PANEL_STYLE_ID)) {
@@ -7701,16 +10175,16 @@ function (root, d3, forester, phyloXml) {
7701
10175
  + '.aptx-panel * { box-sizing:border-box; }'
7702
10176
  + '@media (prefers-color-scheme:dark){ .aptx-panel:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
7703
10177
  + '.aptx-panel.aptx-dark {' + dark + '}'
7704
- // The node menu is a separate overlay (it cannot live inside the
7705
- // panel), so it carries its own copy of the palette and follows the
7706
- // same light / dark rules.
7707
- + '.aptx-node-menu {'
10178
+ // The node menu and the search suggestions are separate overlays
10179
+ // (they cannot live inside the panel), so they carry their own
10180
+ // copy of the palette and follow the same light / dark rules.
10181
+ + '.aptx-node-menu, .aptx-suggest {'
7708
10182
  + ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
7709
10183
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
7710
10184
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
7711
10185
  + '}'
7712
- + '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
7713
- + '.aptx-node-menu.aptx-dark {' + dark + '}'
10186
+ + '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark), .aptx-suggest:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
10187
+ + '.aptx-node-menu.aptx-dark, .aptx-suggest.aptx-dark {' + dark + '}'
7714
10188
  // The menu itself: fixed 11px type, so it does NOT scale with the
7715
10189
  // tree's zoom the way the old svg-drawn menu did.
7716
10190
  + '.aptx-node-menu { position:absolute; z-index:1000; min-width:196px; max-width:280px; padding:4px;'
@@ -7729,9 +10203,27 @@ function (root, d3, forester, phyloXml) {
7729
10203
  + ' transition:background .1s,color .1s; }'
7730
10204
  + '.aptx-node-menu button:hover, .aptx-node-menu button:focus-visible {'
7731
10205
  + ' background:var(--p-accent); color:#fff; outline:none; }'
10206
+ + '.aptx-node-menu button:disabled, .aptx-node-menu button:disabled:hover {'
10207
+ + ' background:transparent; color:var(--p-faint); cursor:default; }'
7732
10208
  + '.aptx-node-menu button.aptx-menu-danger:hover, .aptx-node-menu button.aptx-menu-danger:focus-visible {'
7733
10209
  + ' background:#e5484d; color:#fff; }'
7734
10210
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
10211
+ // The search suggestions: the node menu's chrome under the value box.
10212
+ + '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
10213
+ + ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
10214
+ + ' -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);'
10215
+ + ' box-shadow:0 12px 30px -12px rgba(23,34,46,0.42),0 2px 6px -2px rgba(23,34,46,0.2);'
10216
+ + ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
10217
+ + ' font-size:11px; line-height:1.45; }'
10218
+ + '.aptx-suggest button { display:block; width:100%; text-align:left; margin:0; padding:4px 9px;'
10219
+ + ' border:0; border-radius:6px; background:transparent; color:var(--p-ink); font:inherit;'
10220
+ + ' cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'
10221
+ + ' transition:background .1s,color .1s; }'
10222
+ + '.aptx-suggest button b { font-weight:700; color:var(--p-accent); }'
10223
+ + '.aptx-suggest button:hover, .aptx-suggest button.aptx-active { background:var(--p-accent); color:#fff; }'
10224
+ + '.aptx-suggest button:hover b, .aptx-suggest button.aptx-active b { color:#fff; }'
10225
+ + '.aptx-suggest-more { padding:4px 9px 2px; margin-top:3px; border-top:1px solid var(--p-line);'
10226
+ + ' font-size:10px; color:var(--p-faint); }'
7735
10227
  // The node-data dialog, on the same palette as the panel and the menu.
7736
10228
  + '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
7737
10229
  + ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
@@ -7744,9 +10236,13 @@ function (root, d3, forester, phyloXml) {
7744
10236
  // The "working" card shown over the tree area while a big tree is
7745
10237
  // drawn. It covers the whole container so nothing underneath --
7746
10238
  // panel included -- can be clicked into a half-built view.
10239
+ // cursor stays default on purpose: `progress` draws the macOS
10240
+ // beach ball next to the pointer, which reads as "the program has
10241
+ // hung" when the card is already saying exactly what is happening
10242
+ // and for how many nodes. One clear signal, not two.
7747
10243
  + '.aptx-busy { position:absolute; inset:0; z-index:1000; display:grid; place-items:center;'
7748
10244
  + ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
7749
- + ' font-size:12px; color:var(--p-ink); cursor:progress; }'
10245
+ + ' font-size:12px; color:var(--p-ink); cursor:default; }'
7750
10246
  + '.aptx-busy-card { display:flex; align-items:center; gap:10px; padding:10px 16px 10px 13px;'
7751
10247
  + ' border:1px solid var(--p-line-strong); border-radius:12px; background:var(--p-bg);'
7752
10248
  + ' box-shadow:0 24px 48px -16px rgba(23,34,46,0.45),0 4px 12px -4px rgba(23,34,46,0.22); }'
@@ -7801,6 +10297,12 @@ function (root, d3, forester, phyloXml) {
7801
10297
  + '.aptx-dialog-line + .aptx-dialog-line { border-top:1px solid var(--p-line); }'
7802
10298
  + '.aptx-dialog-key { flex:0 0 42%; color:var(--p-muted); }'
7803
10299
  + '.aptx-dialog-val { flex:1 1 auto; min-width:0; overflow-wrap:anywhere; }'
10300
+ // the shortcuts cheat sheet: key chips in the key column
10301
+ + '.aptx-shortcuts .aptx-dialog-key { flex-basis:38%; display:flex; align-items:center; }'
10302
+ + '.aptx-shortcuts .aptx-dialog-val { align-self:center; }'
10303
+ + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
10304
+ + '.aptx-kbd { font-family:inherit; font-size:10.5px; line-height:1; padding:3px 5px; min-width:18px; text-align:center; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-bottom-width:2px; border-radius:4px; }'
10305
+ + '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
7804
10306
  + '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
7805
10307
  + ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
7806
10308
  // The hover tooltip, on the same palette.
@@ -7852,13 +10354,24 @@ function (root, d3, forester, phyloXml) {
7852
10354
  + '.aptx-panel .' + TREE_DESC + ' .aptx-tree-name { font-weight:600; font-size:11.5px; line-height:1.35; color:var(--p-ink); overflow-wrap:anywhere; display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:1; overflow:hidden; }'
7853
10355
  + '.aptx-panel .' + TREE_DESC + ' .aptx-tree-descr { margin-top:3px; font-size:10px; line-height:1.45; color:var(--p-muted); overflow-wrap:anywhere; display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; }'
7854
10356
  + '.aptx-panel .' + TREE_DESC + '.aptx-expanded .aptx-tree-name, .aptx-panel .' + TREE_DESC + '.aptx-expanded .aptx-tree-descr { display:block; -webkit-line-clamp:unset; overflow:visible; }'
10357
+ // A file with several trees: previous / picker / next in one row
10358
+ + '.aptx-panel .aptx-tree-picker { display:flex; align-items:center; gap:4px; min-width:0; }'
10359
+ + '.aptx-panel .aptx-tree-picker select { flex:1 1 auto; min-width:0; height:24px; }'
10360
+ + '.aptx-panel .aptx-tree-picker .aptx-gbtn { flex:0 0 auto; min-width:26px; padding:0 4px; margin:0; }'
10361
+ + '.aptx-panel .aptx-tree-picker + .' + TREE_DESC + ' { margin-top:5px; }'
7855
10362
  + '.aptx-panel .aptx-panel-title { font-size:12px; }'
7856
10363
  + '.aptx-panel fieldset { border:0; border-top:1px solid var(--p-line); margin:0; padding:6px 12px; min-width:0; }'
7857
10364
  + '.aptx-panel legend { float:none; width:auto; padding:0; margin:0 0 5px; font-size:9px; font-weight:700; letter-spacing:0.09em; text-transform:uppercase; color:var(--p-faint); }'
7858
10365
  + '.aptx-panel label { cursor:pointer; }'
7859
10366
  + '.aptx-panel input[type=checkbox],.aptx-panel input[type=radio] { accent-color:var(--p-accent); width:13px; height:13px; vertical-align:-2px; margin:0 4px 0 0; }'
7860
10367
  // checkbox/radio + label as one item (used by the Display Data grid and the inline P/A/C and search-option rows)
7861
- + '.aptx-panel .aptx-check { display:flex; align-items:center; gap:6px; cursor:pointer; min-width:0; }'
10368
+ // every item carries an invisible box, so the one that lights up
10369
+ // (Auto-hide Labels while it is hiding) does not shift its neighbours
10370
+ + '.aptx-panel .aptx-check { display:flex; align-items:center; gap:6px; cursor:pointer; min-width:0;'
10371
+ + ' padding:1px 4px; margin:-1px -4px; border:1px solid transparent; border-radius:5px;'
10372
+ + ' transition:border-color .15s,background .15s; }'
10373
+ + '.aptx-panel .aptx-check.aptx-check-active { border-color:var(--p-accent); background:var(--p-accent-weak); }'
10374
+ + '.aptx-panel .aptx-check.aptx-check-active > span { color:var(--p-accent-ink); font-weight:600; }'
7862
10375
  + '.aptx-panel .aptx-check > input { margin:0; flex:none; }'
7863
10376
  + '.aptx-panel .aptx-check > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }'
7864
10377
  + '.aptx-panel .aptx-checkgrid { display:grid; grid-template-columns:1fr 1fr; gap:3px 10px; }'
@@ -7875,7 +10388,9 @@ function (root, d3, forester, phyloXml) {
7875
10388
  + '.aptx-panel .aptx-search-menus { display:flex; gap:4px; margin:2px 0 3px; }'
7876
10389
  + '.aptx-panel .aptx-search-menus select { flex:1 1 0; width:auto; min-width:0; height:24px; font:inherit; }'
7877
10390
  // red cue for an unmatchable query (bad regex / non-number); box-shadow, since the value boxes carry an inline outline:none
7878
- + '.aptx-panel .aptx-search-invalid { box-shadow:0 0 0 2px #e5484d; border-color:#e5484d; }'
10391
+ // specific enough to beat the plain input rule (which used to keep
10392
+ // the border in the line colour, leaving only the ring)
10393
+ + '.aptx-panel input[type=text].aptx-search-invalid { box-shadow:0 0 0 2px #e5484d; border-color:#e5484d; }'
7879
10394
  + '.aptx-panel .aptx-combine { display:flex; align-items:center; gap:8px; margin:6px 0 2px; }'
7880
10395
  + '.aptx-panel .aptx-combine .aptx-field-label { margin:0; flex:none; }'
7881
10396
  + '.aptx-panel .aptx-combine select { flex:1 1 auto; min-width:0; height:26px; font:inherit; }'
@@ -7938,10 +10453,19 @@ function (root, d3, forester, phyloXml) {
7938
10453
  + '.aptx-panel .aptx-zoomrow { display:flex; }'
7939
10454
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn { flex:1 1 0; padding:0; }'
7940
10455
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn:last-child { margin-right:0; }'
10456
+ + '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
10457
+ + '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
10458
+ + '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
10459
+ + '.aptx-panel .aptx-domrow select { flex:1 1 auto; min-width:0; }'
10460
+ + '.aptx-panel .aptx-domreadout { flex:1 1 auto; text-align:center; font-size:12px; font-variant-numeric:tabular-nums; color:var(--p-ink); }'
7941
10461
  + '.aptx-panel .aptx-glyph { height:14px; width:auto; display:block; overflow:visible; }'
7942
10462
  + '.aptx-panel .aptx-seg .aptx-glyph { height:13px; }'
7943
10463
  + '.aptx-panel input[type=text],.aptx-panel select { font-family:inherit; font-size:11px; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; max-width:100%; padding:3px 6px; }'
7944
10464
  + '.aptx-panel input[type=text]:focus,.aptx-panel select:focus { outline:none; border-color:var(--p-accent); box-shadow:0 0 0 3px var(--p-accent-weak); }'
10465
+ // an invalid regex or number stays red WHILE TYPING: without this
10466
+ // the focus rule above, more specific and later, painted the box in
10467
+ // the accent until the focus left it
10468
+ + '.aptx-panel input[type=text].aptx-search-invalid:focus { border-color:#e5484d; box-shadow:0 0 0 2px #e5484d; }'
7945
10469
  // --- collapsible sections, internal scroll, whole-panel hide ---
7946
10470
  // the panel is a child of the tree's container now, so it can be held
7947
10471
  // to that container's height rather than the whole viewport's
@@ -8259,6 +10783,32 @@ function (root, d3, forester, phyloXml) {
8259
10783
  if (s.type) {
8260
10784
  text += '- Type: ' + s.type + '<br>';
8261
10785
  }
10786
+ // The domain architecture, which phyloXML has always carried
10787
+ // and this dialog has never shown: a reader could find a node
10788
+ // by searching for one of its domains -- forester indexes them
10789
+ // as the "Domain" search field -- and then see nothing about it
10790
+ // here. Listed in POSITION order rather than file order, since
10791
+ // an architecture is a line along the protein and reading it
10792
+ // out of order tells you nothing.
10793
+ let da = s.domain_architecture;
10794
+ if (da && da.domains && da.domains.length > 0) {
10795
+ text += '- Domain architecture: '
10796
+ + (da.length ? da.length + ' aa, ' : '')
10797
+ + da.domains.length + (da.domains.length === 1 ? ' domain' : ' domains')
10798
+ + '<br>';
10799
+ da.domains.slice().sort(function (a, b) {
10800
+ return (a.from - b.from) || (a.to - b.to);
10801
+ }).forEach(function (dom) {
10802
+ let name = dom.name || '(unnamed)';
10803
+ let span = (dom.from !== undefined && dom.to !== undefined)
10804
+ ? dom.from + '-' + dom.to : '';
10805
+ // the confidence on a domain is its e-value; an
10806
+ // explicit 0 is a real score, so test for undefined
10807
+ let ev = (dom.confidence !== undefined && dom.confidence !== null)
10808
+ ? ' (E=' + dom.confidence + ')' : '';
10809
+ text += '-- ' + name + ': ' + span + ev + '<br>';
10810
+ });
10811
+ }
8262
10812
  }
8263
10813
  }
8264
10814
  if (d.events) {
@@ -8494,7 +11044,8 @@ function (root, d3, forester, phyloXml) {
8494
11044
  [['Website', WEBSITE, 'cmzmasek.github.io/archaeopteryx-js'],
8495
11045
  ['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
8496
11046
  ['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
8497
- ['License', LICENSE_WEBSITE, LICENSE_NAME]].forEach(function (row) {
11047
+ ['License', LICENSE_WEBSITE, LICENSE_NAME],
11048
+ ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']].forEach(function (row) {
8498
11049
  let line = document.createElement('div');
8499
11050
  line.className = 'aptx-dialog-line';
8500
11051
  let key = document.createElement('span');
@@ -8503,9 +11054,19 @@ function (root, d3, forester, phyloXml) {
8503
11054
  let val = document.createElement('span');
8504
11055
  val.className = 'aptx-dialog-val';
8505
11056
  let a = document.createElement('a');
8506
- a.href = row[1];
8507
- a.target = '_blank';
8508
- a.rel = 'noopener noreferrer';
11057
+ if (row[1]) {
11058
+ a.href = row[1];
11059
+ a.target = '_blank';
11060
+ a.rel = 'noopener noreferrer';
11061
+ } else {
11062
+ // the shortcuts row opens the cheat sheet in place of the About box
11063
+ a.href = '#';
11064
+ a.addEventListener('click', function (e) {
11065
+ e.preventDefault();
11066
+ shell.dialog.close();
11067
+ showShortcutsDialog();
11068
+ });
11069
+ }
8509
11070
  a.textContent = row[2];
8510
11071
  val.appendChild(a);
8511
11072
  line.appendChild(key);
@@ -8668,6 +11229,8 @@ function (root, d3, forester, phyloXml) {
8668
11229
 
8669
11230
  c0.insertAdjacentHTML('beforeend',makeDisplayControl());
8670
11231
 
11232
+ c0.insertAdjacentHTML('beforeend',makeDomainControls());
11233
+
8671
11234
  c0.insertAdjacentHTML('beforeend',makeZoomControl());
8672
11235
 
8673
11236
 
@@ -8718,9 +11281,14 @@ function (root, d3, forester, phyloXml) {
8718
11281
  }
8719
11282
 
8720
11283
 
8721
- on(SEARCH_FIELD_0, 'keyup', search0);
11284
+ on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
8722
11285
 
8723
- on(SEARCH_FIELD_1, 'keyup', search1);
11286
+ on(SEARCH_FIELD_1, 'keyup', searchValueKeyup);
11287
+ wireSuggestions(0);
11288
+ wireSuggestions(1);
11289
+ // the list is placed in page coordinates: a scroll or resize moves the box out from under it
11290
+ window.addEventListener('resize', closeSuggestions);
11291
+ document.addEventListener('scroll', closeSuggestions, true);
8724
11292
 
8725
11293
  on(SEARCH_VALUE2_0, 'keyup', search0);
8726
11294
 
@@ -8775,6 +11343,23 @@ function (root, d3, forester, phyloXml) {
8775
11343
 
8776
11344
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
8777
11345
  on(MSA_CB, 'click', msaCbClicked);
11346
+ on(DOMAINS_CB, 'click', domainsCbClicked);
11347
+ on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
11348
+ on(TREE_PREV_BUTTON, 'click', function () {
11349
+ showTree(_treeIndex - 1);
11350
+ });
11351
+ on(TREE_NEXT_BUTTON, 'click', function () {
11352
+ showTree(_treeIndex + 1);
11353
+ });
11354
+ on(TREE_SELECT, 'change', function () {
11355
+ showTree(parseInt(this.value, 10));
11356
+ });
11357
+ onHoldRepeat(DOMAIN_WIDTH_DEC, function () { domainWidthStep(false); });
11358
+ onHoldRepeat(DOMAIN_WIDTH_INC, function () { domainWidthStep(true); });
11359
+ on(DOMAIN_EVALUE_DEC, 'click', function () { domainEvalueStep(-1); });
11360
+ on(DOMAIN_EVALUE_INC, 'click', function () { domainEvalueStep(1); });
11361
+ on(DOMAIN_LABELS_SELECT, 'change', domainLabelsChanged);
11362
+ on(DOMAIN_GLOW_CB, 'click', domainGlowCbClicked);
8778
11363
  on(TIME_AXIS_CB, 'click', timeAxisCbClicked);
8779
11364
  on(TIME_GRID_CB, 'click', timeGridCbClicked);
8780
11365
 
@@ -8937,12 +11522,11 @@ function (root, d3, forester, phyloXml) {
8937
11522
  _docListenersBound = true;
8938
11523
 
8939
11524
  bindDoc('keyup', function (e) {
8940
- // The DELIBERATELY minimal shortcut set (user decision,
8941
- // 2026-09-03): Esc/Home reset, O cycles the overview corner,
8942
- // PageUp/PageDown size the font, and the wheel zooms. Everything
8943
- // else is a button -- the old Alt+letter combos were near-
8944
- // undiscoverable (macOS labels the key Option and types glyphs
8945
- // with it).
11525
+ // The plain keys: Esc/Home reset, O cycles the overview corner,
11526
+ // PageUp/PageDown size the font, and the wheel zooms. The old
11527
+ // Alt+letter combos were near-undiscoverable (macOS labels the
11528
+ // key Option and types glyphs with it); the Ctrl / Cmd combos
11529
+ // that replaced them in 2026-09 live in SHORTCUTS, on keydown.
8946
11530
  if (isTypingTarget(e.target)) {
8947
11531
  return; // nothing fires from inside a text box
8948
11532
  }
@@ -8959,7 +11543,11 @@ function (root, d3, forester, phyloXml) {
8959
11543
  });
8960
11544
 
8961
11545
  bindDoc('keydown', function (e) {
8962
- if (isTypingTarget(e.target)) {
11546
+ if (e.defaultPrevented) {
11547
+ return;
11548
+ }
11549
+ shortcutKeyDown(e); // the Ctrl / Cmd combos (SHORTCUTS)
11550
+ if (e.defaultPrevented || isTypingTarget(e.target)) {
8963
11551
  return;
8964
11552
  }
8965
11553
  if (e.keyCode === VK_PAGE_UP) {
@@ -9048,13 +11636,23 @@ function (root, d3, forester, phyloXml) {
9048
11636
  // so tree-file text needs no escaping here. Both can be very long, so CSS
9049
11637
  // clamps each to a few lines; the caller (enableTreeDescExpand) adds the
9050
11638
  // click/keyboard expand affordance only when the text actually overflows.
11639
+ // A launch with several trees puts the tree picker in the same
11640
+ // fieldset, above the description; the picker shows the name, so
11641
+ // the name line is left out then.
9051
11642
  function makeTreeDesc() {
9052
- let name = _treeData.name ? String(_treeData.name).trim() : '';
11643
+ let several = _trees.length > 1;
11644
+ let name = (_treeData.name && !several) ? String(_treeData.name).trim() : '';
9053
11645
  let desc = _treeData.description ? String(_treeData.description).trim() : '';
9054
- if (!name && !desc) {
11646
+ if (!name && !desc && !several) {
9055
11647
  return null;
9056
11648
  }
9057
11649
  let fieldset = document.createElement('fieldset');
11650
+ if (several) {
11651
+ fieldset.appendChild(makeTreePicker());
11652
+ }
11653
+ if (!name && !desc) {
11654
+ return fieldset;
11655
+ }
9058
11656
  let block = document.createElement('div');
9059
11657
  block.className = TREE_DESC;
9060
11658
  let tooltip = '';
@@ -9077,6 +11675,35 @@ function (root, d3, forester, phyloXml) {
9077
11675
  return fieldset;
9078
11676
  }
9079
11677
 
11678
+ // A file with several trees: previous / picker / next in one row,
11679
+ // the picker listing every tree by its name (or its number). Each
11680
+ // opens fresh in the same container (showTree). The end buttons are
11681
+ // disabled at the ends: the panel is rebuilt per tree, so the
11682
+ // state set here is always current.
11683
+ function makeTreePicker() {
11684
+ let n = _trees.length;
11685
+ let row = document.createElement('div');
11686
+ row.className = 'aptx-tree-picker';
11687
+ row.insertAdjacentHTML('beforeend', makeGlyphButton('tree_prev', TREE_PREV_BUTTON, 'previous tree'));
11688
+ let select = document.createElement('select');
11689
+ select.id = TREE_SELECT;
11690
+ select.name = TREE_SELECT;
11691
+ select.title = 'Tree ' + (_treeIndex + 1) + ' of ' + n + ' -- pick another';
11692
+ for (let i = 0; i < n; ++i) {
11693
+ let opt = document.createElement('option');
11694
+ opt.value = String(i);
11695
+ let treeName = _trees[i].name ? String(_trees[i].name).trim() : '';
11696
+ opt.textContent = treeName || ('Tree ' + (i + 1));
11697
+ opt.selected = (i === _treeIndex);
11698
+ select.appendChild(opt);
11699
+ }
11700
+ row.appendChild(select);
11701
+ row.insertAdjacentHTML('beforeend', makeGlyphButton('tree_next', TREE_NEXT_BUTTON, 'next tree'));
11702
+ row.querySelector('#' + TREE_PREV_BUTTON).disabled = (_treeIndex === 0);
11703
+ row.querySelector('#' + TREE_NEXT_BUTTON).disabled = (_treeIndex === n - 1);
11704
+ return row;
11705
+ }
11706
+
9080
11707
  // Make the tree name/description block a keyboard-accessible
9081
11708
  // expand/collapse control, but only while its text is actually clamped
9082
11709
  // (otherwise the pointer cursor + click would be a misleading no-op).
@@ -9242,6 +11869,9 @@ function (root, d3, forester, phyloXml) {
9242
11869
  if (_basicTreeProperties.alignedMolSeqs && _basicTreeProperties.maxMolSeqLength > 0) {
9243
11870
  opts.push(makeCheckboxItem('Alignment', MSA_CB, 'to show/hide the sequence alignment beside the tree (rectangular layout only)'));
9244
11871
  }
11872
+ if (_basicTreeProperties.domainArchitectures) {
11873
+ opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
11874
+ }
9245
11875
  if (_timeInfo && _timeInfo.type) {
9246
11876
  opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
9247
11877
  + (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
@@ -9283,6 +11913,7 @@ function (root, d3, forester, phyloXml) {
9283
11913
  h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
9284
11914
  h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
9285
11915
  h = h.concat(makeGlyphButton('up_one_level', RETURN_TO_SUPERTREE_BUTTON_BY_ONE, 'move up by one level towards the complete tree (if in a sub-tree)'));
11916
+ h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
9286
11917
  h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 'midpoint re-root'));
9287
11918
  h = h.concat('</div>');
9288
11919
  h = h.concat('</fieldset>');
@@ -9319,6 +11950,35 @@ function (root, d3, forester, phyloXml) {
9319
11950
  return h;
9320
11951
  }
9321
11952
 
11953
+ // The desktop's domain controls, shown only while the Domain
11954
+ // Architectures toggle is on (syncDomainControls): the track width,
11955
+ // the E-value threshold with its readout, the label mode, the glow.
11956
+ function makeDomainControls() {
11957
+ let h = '<fieldset id="' + DOMAIN_CONTROLS + '" style="display:none">';
11958
+ h = h.concat('<legend>Domain Architectures</legend>');
11959
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel">Track width</span>');
11960
+ h = h.concat(makeButton('−', DOMAIN_WIDTH_DEC, 'narrow the domain tracks (hold to repeat)'));
11961
+ h = h.concat(makeButton('+', DOMAIN_WIDTH_INC, 'widen the domain tracks (hold to repeat)'));
11962
+ h = h.concat('</div>');
11963
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel">E-value ≤</span>');
11964
+ h = h.concat(makeButton('−', DOMAIN_EVALUE_DEC, 'Decrease the E-value threshold by a factor of 10'));
11965
+ h = h.concat('<span class="aptx-domreadout" id="' + DOMAIN_EVALUE_READOUT
11966
+ + '" title="domains with an E-value up to this are drawn"></span>');
11967
+ h = h.concat(makeButton('+', DOMAIN_EVALUE_INC, 'Increase the E-value threshold by a factor of 10'));
11968
+ h = h.concat('</div>');
11969
+ h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + DOMAIN_LABELS_SELECT + '">Labels</label>');
11970
+ h = h.concat('<select name="' + DOMAIN_LABELS_SELECT + '" id="' + DOMAIN_LABELS_SELECT
11971
+ + '" title="where the domain names go: on the boxes (rectangular layout only), in a legend, or nowhere">');
11972
+ h = h.concat('<option value="domains">On domains</option>');
11973
+ h = h.concat('<option value="legend">Legend</option>');
11974
+ h = h.concat('<option value="none">None</option>');
11975
+ h = h.concat('</select></div>');
11976
+ h = h.concat('<div class="aptx-checkgrid">'
11977
+ + makeCheckboxItem('Glow', DOMAIN_GLOW_CB, 'a soft glow in each domain\'s own colour around its box') + '</div>');
11978
+ h = h.concat('</fieldset>');
11979
+ return h;
11980
+ }
11981
+
9322
11982
  function makeSliders() {
9323
11983
  let h = "";
9324
11984
  h = h.concat('<fieldset>');
@@ -9374,7 +12034,6 @@ function (root, d3, forester, phyloXml) {
9374
12034
  let val2 = idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1;
9375
12035
  let reset = idx === 0 ? RESET_SEARCH_A_BTN : RESET_SEARCH_B_BTN;
9376
12036
  let resetTip = idx === 0 ? RESET_SEARCH_A_BTN_TOOLTIP : RESET_SEARCH_B_BTN_TOOLTIP;
9377
- let dl = idx === 0 ? SEARCH_DATALIST_0 : SEARCH_DATALIST_1;
9378
12037
  let h = "";
9379
12038
  h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
9380
12039
  h = h.concat('<div class="aptx-search-menus">');
@@ -9386,7 +12045,6 @@ function (root, d3, forester, phyloXml) {
9386
12045
  h = h.concat('<input class="aptx-search-value2" style="display:none" title="upper bound of the range" type="text" name="' + val2 + '" id="' + val2 + '">');
9387
12046
  h = h.concat(makeButton('R', reset, resetTip));
9388
12047
  h = h.concat('</div>');
9389
- h = h.concat('<datalist id="' + dl + '"></datalist>');
9390
12048
  return h;
9391
12049
  }
9392
12050
 
@@ -9500,6 +12158,7 @@ function (root, d3, forester, phyloXml) {
9500
12158
  setCheckboxValue(VIS_CB, _state.showVisualizations);
9501
12159
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
9502
12160
  setCheckboxValue(MSA_CB, _state.showMsa);
12161
+ syncDomainControls();
9503
12162
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
9504
12163
  setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
9505
12164
  setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);
@@ -9570,11 +12229,16 @@ function (root, d3, forester, phyloXml) {
9570
12229
  // definite result -- with it on, ladderizing a tree that is already
9571
12230
  // ladderized REVERSES it, so the outcome depends on the order the tree
9572
12231
  // happened to arrive in.
12232
+ // Returns the direction actually applied (largest first or not): when
12233
+ // the tree already stood that way and alternating was asked for, the
12234
+ // other one.
9573
12235
  function ladderizeSubtree(n, largestFirst, alternateIfUnchanged) {
9574
12236
  let changed = forester.ladderize(n, largestFirst);
9575
12237
  if (alternateIfUnchanged && !changed) {
9576
12238
  forester.ladderize(n, !largestFirst);
12239
+ return !largestFirst;
9577
12240
  }
12241
+ return largestFirst;
9578
12242
  }
9579
12243
 
9580
12244
  function setDisplayTypeButtons() {
@@ -9593,6 +12257,8 @@ function (root, d3, forester, phyloXml) {
9593
12257
 
9594
12258
  function updateButtonEnabledState() {
9595
12259
  syncZoomRowButtons(); // layout- and tree-dependent disables track every render
12260
+ // the uncollapse-all button lives only while something is collapsed, as on the desktop
12261
+ ((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
9596
12262
  if (_in_subtree) {
9597
12263
  enableButton(byId(RETURN_TO_SUPERTREE_BUTTON_BY_ONE));
9598
12264
  enableButton(byId(RETURN_TO_SUPERTREE_BUTTON));
@@ -9965,8 +12631,65 @@ function (root, d3, forester, phyloXml) {
9965
12631
  // Convenience methods for loading tree on HTML page
9966
12632
  // --------------------------------------------------------------
9967
12633
 
12634
+ // The mode as parseTree / parseTrees accept it. The retired brackets
12635
+ // flag used to be the third argument, so a boolean there means the old
12636
+ // four-argument shape and the mode is the fourth; a string is the mode
12637
+ // itself. PARSE_DEFAULTS, not literals -- initializeSettings applies the
12638
+ // same object, and it runs at launch(), AFTER this has already parsed.
12639
+ function resolveInternalLabelsMode(mode, legacyMode) {
12640
+ let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
12641
+ if (typeof m === 'boolean') {
12642
+ // the retired positional flag: true meant "every numeric internal
12643
+ // label is a confidence", which is 'confidence'.
12644
+ return m ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
12645
+ }
12646
+ return m === undefined ? PARSE_DEFAULTS.internalNumericLabels : m;
12647
+ }
12648
+
12649
+ // Every tree the data holds, the format picked from the content and the
12650
+ // location: a Nexus TREES block, a New Hampshire text with one tree per
12651
+ // ';', a phyloXML with several phylogenies; an Auspice dataset is one
12652
+ // tree. Bare numeric internal labels are promoted on every tree.
12653
+ function parseAllTrees(location, data, mode) {
12654
+ requireForester();
12655
+ let loc = location ? String(location).toLowerCase() : '';
12656
+ let text = forester.isString(data) ? data : '';
12657
+ let trees;
12658
+ // Nexus announces itself with "#NEXUS" on the first line, so the
12659
+ // content decides; the filename alone is enough too.
12660
+ if (/^\s*#nexus\b/i.test(text) || /\.(nex|nexus)$/.test(loc)) {
12661
+ trees = forester.parseNexus(data, true, false);
12662
+ if (trees.length === 0) {
12663
+ throw new Error('no tree found in the Nexus data');
12664
+ }
12665
+ trees.forEach(function (t) {
12666
+ promoteInternalLabels(t, mode);
12667
+ });
12668
+ } else if (/^\s*\{/.test(text) || /\.json$/.test(loc)) {
12669
+ trees = [archaeopteryx.parseAuspiceJson(data)];
12670
+ } else if (/^\s*</.test(text) || loc.substr(-3, 3) === 'xml') {
12671
+ // No other supported format can start with '<', so content alone
12672
+ // is enough -- pasted phyloXML has no filename to go by.
12673
+ requirePhyloXml();
12674
+ trees = phyloXml.parse(data, {trim: true, normalize: true});
12675
+ if (trees.length === 0) {
12676
+ throw new Error('no phylogeny found in the phyloXML data');
12677
+ }
12678
+ trees.forEach(function (t) {
12679
+ forester.addParents(t);
12680
+ });
12681
+ } else {
12682
+ trees = forester.parseNewHampshireTrees(data, true, false);
12683
+ trees.forEach(function (t) {
12684
+ promoteInternalLabels(t, mode);
12685
+ });
12686
+ }
12687
+ return trees;
12688
+ }
12689
+
9968
12690
  /**
9969
- * Convenience method for loading tree on HTML page
12691
+ * Parses tree data for an HTML page: the FIRST tree the data holds
12692
+ * (parseTrees returns them all).
9970
12693
  *
9971
12694
  * @param location - file name (only its extension is used; the content is
9972
12695
  * sniffed too, so a pasted tree with no name works)
@@ -9977,41 +12700,21 @@ function (root, d3, forester, phyloXml) {
9977
12700
  * position and is still accepted (and ignored) there,
9978
12701
  * with the mode following it.
9979
12702
  * @param legacyMode - the mode, when the retired flag occupies `mode`
9980
- * @returns {*}
12703
+ * @returns {*} the first tree
9981
12704
  */
9982
12705
  archaeopteryx.parseTree = function (location, data, mode, legacyMode) {
9983
- // The retired brackets flag used to be the third argument, so a
9984
- // boolean there means the old four-argument shape and the mode is the
9985
- // fourth. A string is the mode itself.
9986
- let internalNumericLabels = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
9987
- // PARSE_DEFAULTS, not literals -- initializeSettings applies the same
9988
- // object, and it runs at launch(), AFTER this has already parsed.
9989
- if (typeof internalNumericLabels === 'boolean') {
9990
- // the retired positional flag: true meant "every numeric internal
9991
- // label is a confidence", which is 'confidence'.
9992
- internalNumericLabels = internalNumericLabels
9993
- ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
9994
- } else if (internalNumericLabels === undefined) {
9995
- internalNumericLabels = PARSE_DEFAULTS.internalNumericLabels;
9996
- }
9997
- let tree;
9998
- let loc = location ? String(location).toLowerCase() : '';
9999
- // Nexus announces itself with "#NEXUS" on the first line, so the
10000
- // content decides; the filename alone is enough too.
10001
- if ((forester.isString(data) && /^\s*#nexus\b/i.test(data))
10002
- || /\.(nex|nexus)$/.test(loc)) {
10003
- tree = archaeopteryx.parseNexus(data, internalNumericLabels);
10004
- } else if ((forester.isString(data) && /^\s*\{/.test(data)) || /\.json$/.test(loc)) {
10005
- tree = archaeopteryx.parseAuspiceJson(data);
10006
- } else if ((forester.isString(data) && /^\s*</.test(data))
10007
- || loc.substr(-3, 3) === 'xml') {
10008
- // No other supported format can start with '<', so content alone
10009
- // is enough -- pasted phyloXML has no filename to go by.
10010
- tree = archaeopteryx.parsePhyloXML(data);
10011
- } else {
10012
- tree = archaeopteryx.parseNewHampshire(data, internalNumericLabels);
10013
- }
10014
- return tree;
12706
+ return parseAllTrees(location, data, resolveInternalLabelsMode(mode, legacyMode))[0];
12707
+ };
12708
+
12709
+ /**
12710
+ * Every tree the data holds, in file order, as an array: a Nexus TREES
12711
+ * block, a New Hampshire text with several ';'-terminated trees, a
12712
+ * phyloXML with several phylogenies (an Auspice dataset is one tree).
12713
+ * launch() takes the array and shows the first, with a picker in the
12714
+ * control panel for the others. Same arguments as parseTree.
12715
+ */
12716
+ archaeopteryx.parseTrees = function (location, data, mode, legacyMode) {
12717
+ return parseAllTrees(location, data, resolveInternalLabelsMode(mode, legacyMode));
10015
12718
  };
10016
12719
 
10017
12720
  // Show the viewer's own "working" card in a container. For the part of a
@@ -10084,9 +12787,11 @@ function (root, d3, forester, phyloXml) {
10084
12787
  + ' "internalNumericLabels".');
10085
12788
  }
10086
12789
  let c = config || {};
10087
- let tree;
12790
+ let trees;
10088
12791
  try {
10089
- tree = archaeopteryx.parseTree(fileName, data,
12792
+ // every tree the file holds: launch() shows the first and the
12793
+ // panel's picker offers the rest
12794
+ trees = archaeopteryx.parseTrees(fileName, data,
10090
12795
  effectiveInternalLabelsMode(c));
10091
12796
  } catch (e) {
10092
12797
  // Worth catching only to say that it was the parse, not the launch,
@@ -10100,7 +12805,7 @@ function (root, d3, forester, phyloXml) {
10100
12805
  // launch() already reports its own failures well enough; wrapping them
10101
12806
  // added nothing but a prefix, and swallowing them left the caller with
10102
12807
  // a blank page and no way to find out why.
10103
- return archaeopteryx.launch(container, tree, config);
12808
+ return archaeopteryx.launch(container, trees, config);
10104
12809
  };
10105
12810
 
10106
12811