archaeopteryx 3.0.0 → 3.2.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.
Files changed (4) hide show
  1. package/README.md +116 -21
  2. package/archaeopteryx.js +1652 -579
  3. package/forester.js +572 -86
  4. package/package.json +1 -1
package/archaeopteryx.js CHANGED
@@ -20,8 +20,8 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.0.0
24
- // 2026-09-04
23
+ // v 3.2.0
24
+ // 2026-09-10
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
27
27
  // analysis of highly annotated phylogenetic trees.
@@ -54,10 +54,7 @@
54
54
  // DOM elements, so jQuery UI (and its CSS) is no longer required.
55
55
  //
56
56
  //
57
- // Developer documentation:
58
- // https://docs.google.com/document/d/1COVe0iYbKtcBQxGTP4_zuimpk2FH9iusOVOgd5xCJ3A
59
- //
60
- // User documentation:
57
+ // Documentation and live demos:
61
58
  // https://cmzmasek.github.io/archaeopteryx-js/
62
59
 
63
60
  // UMD: one file that loads everywhere an embedder might put it --
@@ -106,7 +103,7 @@ function (root, d3, forester, phyloXml) {
106
103
  // IIFE's own function name -- a plain object says what it is.)
107
104
  let archaeopteryx = {};
108
105
 
109
- const VERSION = '3.0.0';
106
+ const VERSION = '3.1.0';
110
107
  const WEBSITE = 'https://cmzmasek.github.io/archaeopteryx-js/';
111
108
  const DESKTOP_WEBSITE = 'https://cmzmasek.github.io/archaeopteryx/';
112
109
  const SOURCE_WEBSITE = 'https://github.com/cmzmasek/archaeopteryx-js';
@@ -177,6 +174,11 @@ function (root, d3, forester, phyloXml) {
177
174
  'Helvetica Neue', 'Arial', 'sans-serif'];
178
175
  // Okabe-Ito color-blind-safe palette for search / selection highlights.
179
176
  const FOUND0_COLOR_DEFAULT = '#0072B2'; // Search A — blue
177
+ // Search A in the dark theme: the same Okabe-Ito palette's sky blue. The
178
+ // light theme's blue is right on white and sinks into a dark ground; sky
179
+ // blue keeps the hue and stays distinct from B's vermillion and A&B's
180
+ // yellow. The other two already read on dark.
181
+ const FOUND0_COLOR_DARK = '#56B4E9';
180
182
  const FOUND1_COLOR_DEFAULT = '#D55E00'; // Search B — vermillion
181
183
  const FOUND0AND1_COLOR_DEFAULT = '#F0E442'; // A and B — yellow
182
184
  const SELECTED_COLOR_DEFAULT = '#009E73'; // Selected — bluish green
@@ -280,6 +282,11 @@ function (root, d3, forester, phyloXml) {
280
282
  const SVG_EXPORT_FORMAT = 'SVG';
281
283
  const TOP_AND_BOTTOM_BORDER_HEIGHT = 10;
282
284
  const TRANSITION_DURATION_DEFAULT = 750;
285
+ // 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;
283
290
  const WARNING = 'ArchaeopteryxJS: WARNING';
284
291
  const MESSAGE = 'ArchaeopteryxJS: ';
285
292
  const ERROR = 'ArchaeopteryxJS: ERROR: ';
@@ -338,7 +345,6 @@ function (root, d3, forester, phyloXml) {
338
345
  const CONFIDENCE_VALUES_CB = 'conf_cb';
339
346
  const SUPPORT_DOTS_CB = 'suppdots_cb';
340
347
  const DOWNLOAD_BUTTON = 'dl_b';
341
- const SUBMIT_SELECTED_NODES_BUTTON = 'submit_sel_nodes_b';
342
348
  const DYNAHIDE_CB = 'dynahide_cb';
343
349
  const MSA_CB = 'msa_cb';
344
350
  const TIME_AXIS_CB = 'timeaxis_cb';
@@ -356,6 +362,7 @@ function (root, d3, forester, phyloXml) {
356
362
  // embedding page's own defs.
357
363
  const ARCHAEOPTERYX_LOGO_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Archaeopteryx"> <defs> <linearGradient id="aptxlogo-wg" x1="0" y1="1" x2="1" y2="0"> <stop offset="0" stop-color="#4f46e5"/><stop offset="0.55" stop-color="#38bdf8"/><stop offset="1" stop-color="#a7f3d0"/> </linearGradient> <linearGradient id="aptxlogo-wg2" x1="0" y1="1" x2="1" y2="0"> <stop offset="0" stop-color="#3730a3"/><stop offset="1" stop-color="#4f79e0"/> </linearGradient> <linearGradient id="aptxlogo-tg" x1="0" y1="1" x2="1" y2="0"> <stop offset="0" stop-color="#f43f5e"/><stop offset="0.5" stop-color="#fb923c"/><stop offset="1" stop-color="#fde047"/> </linearGradient> <linearGradient id="aptxlogo-bg" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#fff7e0"/><stop offset="1" stop-color="#fcd34d"/> </linearGradient> </defs> <g transform="translate(28.9,18.0) scale(0.9091)"> <g stroke="#25304f" stroke-width="6" stroke-linejoin="round" stroke-linecap="round"> <path d="M 78.0,152.0 C 49.3,150.6 12.5,173.3 -4.4,194.3 C 25.1,192.0 66.1,175.5 78.0,152.0 Z" fill="url(#aptxlogo-tg)"/> <path d="M 78.0,152.0 C 48.1,143.8 3.5,158.8 -19.7,176.6 C 12.1,181.2 59.5,173.7 78.0,152.0 Z" fill="url(#aptxlogo-tg)"/> <path d="M 78.0,152.0 C 51.8,138.2 6.9,142.5 -18.7,154.0 C 10.0,164.8 56.0,167.9 78.0,152.0 Z" fill="url(#aptxlogo-tg)"/> <path d="M 78.0,152.0 C 58.9,135.3 20.6,130.3 -2.8,135.1 C 18.9,149.7 56.6,161.2 78.0,152.0 Z" fill="url(#aptxlogo-tg)"/> <path d="M 118.0,126.0 C 110.4,106.2 84.4,93.0 65.5,92.1 C 75.9,110.5 99.0,129.6 118.0,126.0 Z" fill="url(#aptxlogo-wg2)"/> <path d="M 118.0,126.0 C 115.6,103.3 92.6,79.7 73.5,71.3 C 79.1,93.8 97.5,122.0 118.0,126.0 Z" fill="url(#aptxlogo-wg2)"/> <path d="M 118.0,126.0 C 123.0,106.1 110.4,79.8 96.5,67.4 C 94.4,87.9 101.6,117.0 118.0,126.0 Z" fill="url(#aptxlogo-wg2)"/> <path d="M 106,172 L 101,202 M 132,172 L 129,202" fill="none" stroke-width="9"/> <path d="M 101,202 L 88,209 M 101,202 L 112,210 M 101,202 L 99,213 M 129,202 L 116,209 M 129,202 L 140,210 M 129,202 L 128,213" fill="none" stroke-width="6"/> <path d="M 96,176 C 74,168 66,146 76,128 C 86,110 110,102 132,106 C 152,110 164,126 162,146 C 160,166 142,180 120,180 C 112,180 103,179 96,176 Z" fill="url(#aptxlogo-bg)"/> <path d="M 100,174 C 86,166 82,150 90,138 C 96,146 108,152 122,152 C 136,152 148,148 156,140 C 160,156 150,172 128,177 C 118,179 108,178 100,174 Z" fill="#fff6dd" stroke="none"/> <path d="M 96,176 C 74,168 66,146 76,128 C 86,110 110,102 132,106 C 152,110 164,126 162,146 C 160,166 142,180 120,180 C 112,180 103,179 96,176 Z" fill="none"/> <path d="M 150.0,60.0 C 159.5,51.2 158.7,35.2 152.9,25.9 C 145.3,35.7 141.5,51.8 150.0,60.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 164.0,56.0 C 174.4,51.0 177.8,37.3 175.2,28.0 C 166.2,34.2 158.9,46.9 164.0,56.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 136.0,66.0 C 141.8,56.5 137.0,44.2 129.6,38.5 C 125.7,48.3 126.7,61.9 136.0,66.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 158,58 C 186,54 208,72 208,96 C 208,120 188,136 164,134 C 140,132 126,114 128,92 C 130,72 142,60 158,58 Z" fill="url(#aptxlogo-bg)"/> <path d="M 203,82 C 216,84 230,92 238,100 C 230,108 216,113 202,112 C 206,104 206,90 203,82 Z" fill="#fb923c"/> <path d="M 204,101 C 214,102 224,103 234,101" fill="none" stroke-width="4"/> <path d="M 128.0,116.0 C 113.1,91.9 75.3,80.3 50.0,83.2 C 68.3,104.8 103.7,124.6 128.0,116.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 128.0,116.0 C 118.0,85.8 79.7,60.2 51.1,54.1 C 65.4,83.1 98.9,116.3 128.0,116.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 128.0,116.0 C 127.0,83.7 97.3,47.3 71.5,32.7 C 76.9,65.0 99.8,107.4 128.0,116.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 128.0,116.0 C 136.1,88.7 121.1,50.1 103.3,30.9 C 99.0,59.5 106.6,101.2 128.0,116.0 Z" fill="url(#aptxlogo-wg)"/> </g> <g> <ellipse cx="172" cy="94" rx="15" ry="17" fill="#25304f"/> <ellipse cx="172" cy="97" rx="10" ry="11" fill="#38bdf8"/> <circle cx="167" cy="88" r="6" fill="#ffffff"/> <circle cx="178" cy="103" r="3" fill="#ffffff" opacity="0.9"/> <ellipse cx="146" cy="112" rx="9" ry="5" fill="#fb7185" opacity="0.75"/> <path d="M 52,53 C 53.62,59.75 54.25,60.38 61,62 C 54.25,63.62 53.62,64.25 52,71 C 50.38,64.25 49.75,63.62 43,62 C 49.75,60.38 50.38,59.75 52,53 Z" fill="#fde68a"/> <path d="M 222,161 C 223.26,166.25 223.75,166.74 229,168 C 223.75,169.26 223.26,169.75 222,175 C 220.74,169.75 220.25,169.26 215,168 C 220.25,166.74 220.74,166.25 222,161 Z" fill="#fde68a"/> <path d="M 86,208 C 87.08,212.5 87.5,212.92 92,214 C 87.5,215.08 87.08,215.5 86,220 C 84.92,215.5 84.5,215.08 80,214 C 84.5,212.92 84.92,212.5 86,208 Z" fill="#fde68a"/> </g> </g> </svg>';
358
364
  const NODE_DATA = 'node_data_dialog';
365
+ const COLOR_PICKER = 'color_picker_dialog';
359
366
  const NODE_EVENTS_CB = 'nevts_cb';
360
367
  const NODE_NAME_CB = 'nn_cb';
361
368
  const NODE_SHAPE_SELECT_MENU = 'nshapes_menu';
@@ -469,6 +476,14 @@ function (root, d3, forester, phyloXml) {
469
476
  let _searchBox1Empty = true;
470
477
  let _settings = null; // fixed for the life of the launch; never written after init
471
478
  let _svgGroup = null;
479
+ // The floating layer: a sibling of _svgGroup that is NOT zoomed or
480
+ // panned. The bottom strips -- the time axis, the alignment's
481
+ // conservation/consensus/ruler -- are drawn into it in tree coordinates
482
+ // and placed by placeFloatingOverlays() from the current zoom transform,
483
+ // so they ride with the tree until their bottom would leave the viewport
484
+ // and then stick there. See floatStripGroup().
485
+ let _floatGroup = null;
486
+ let _floatStrips = {}; // class -> {top, height} in tree coordinates
472
487
  let _treeData = null;
473
488
  let _treeFn = null;
474
489
  let _vis = null; // the automatic visualizations: candidates, scales, choices
@@ -485,10 +500,12 @@ function (root, d3, forester, phyloXml) {
485
500
  const MSA_CONS_BAR_H = 22; // conservation bar band height
486
501
  const MSA_CONS_TOP_GAP = 4;
487
502
  const MSA_BOTTOM_RESERVE = 56; // vertical room under the rows (conservation + ruler)
503
+ const MSA_NAV_RESERVE = 46; // ... plus the navigation bar floating at the viewport bottom
488
504
  const MSA_MIN_TREE_PX = 220; // the tree itself never shrinks below this
489
505
  let _msaColOffset = 0; // first shown alignment column (0-based)
490
506
  let _msaReserve = 0; // horizontal px reserved for the track, set with _w
491
507
  let _msaScroller = null; // the fixed HTML range input, created lazily
508
+ let _msaNav = null; // the bar around it: paging, jump-to-column, readout
492
509
  let _msaGeom = null; // window geometry of the last draw, for the hover readout
493
510
  // ------ time axis (the desktop's geologic / calendar overlays) ------
494
511
  const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
@@ -509,6 +526,29 @@ function (root, d3, forester, phyloXml) {
509
526
  const RADIAL_MAX_EXTENT = 24000;
510
527
  let _radialLabelsHorizontal = false; // circular layout: external labels upright at the ring instead of riding their spokes
511
528
  let _panelTheme = null; // null = follow OS; 'light' / 'dark' = header switch choice
529
+ // Resolves once the current launch has drawn. Immediately for a small
530
+ // tree; after the deferred first draw for a big one. Exposed as
531
+ // viewer.ready so an embedder can wait for the DOM to exist.
532
+ let _readyPromise = Promise.resolve();
533
+ // Bumped by every launch() and destroy(), so a first draw deferred by a
534
+ // launch that was since torn down or replaced knows not to run.
535
+ let _launchSeq = 0;
536
+ // A redraw waiting for its frame (big trees only): the arguments of every
537
+ // scheduleUpdate() call made since, merged, so that one update() serves
538
+ // them all.
539
+ let _pendingUpdate = null;
540
+ // Work to run once the pending redraw has happened (big trees only):
541
+ // the viewport re-centring a zoom needs the new layout for.
542
+ let _afterUpdate = [];
543
+ // The viewport centre captured by the FIRST zoom step of a burst; the
544
+ // re-centring after the coalesced redraw maps that one point forward.
545
+ let _pendingCentre = null;
546
+ // True while initialize() runs. A redraw scheduled from inside it -- the
547
+ // searches it runs end in one -- is dropped: the draw in progress renders
548
+ // that same state, so the extra pass was always redundant, and on a big
549
+ // tree it would run after the card had gone and merge later interactions
550
+ // into an invisible redraw.
551
+ let _initializing = false;
512
552
  let _searchFields = []; // available search-field descriptors, rebuilt per tree
513
553
  let _zoomListener = null;
514
554
  let _zoomed_x_or_y = false;
@@ -569,8 +609,8 @@ function (root, d3, forester, phyloXml) {
569
609
  // at. It appears and disappears on its own -- there is nothing to switch on.
570
610
  // Clicking or dragging inside it moves the main view there.
571
611
 
572
- const OVERVIEW_WIDTH = 118;
573
- const OVERVIEW_HEIGHT = 92;
612
+ const OVERVIEW_WIDTH = 142;
613
+ const OVERVIEW_HEIGHT = 111;
574
614
  const OVERVIEW_MARGIN = 12;
575
615
  const OVERVIEW_PAD = 5;
576
616
 
@@ -991,9 +1031,44 @@ function (root, d3, forester, phyloXml) {
991
1031
 
992
1032
  function zoom(event) {
993
1033
  _svgGroup.attr('transform', event.transform);
1034
+ placeFloatingOverlays();
994
1035
  updateOverviewViewport();
995
1036
  }
996
1037
 
1038
+ // A strip in the floating layer. Drawn in TREE coordinates like everything
1039
+ // else (its content is x-aligned with the tree), it is then placed by a
1040
+ // transform of its own rather than the tree's -- the same x and scale, but
1041
+ // a y that is STICKY: the strip rides at the tree's bottom edge until that
1042
+ // edge would leave the viewport, and holds at the viewport bottom from
1043
+ // there. Nothing changes while the whole tree fits; nothing disappears
1044
+ // when it does not. `top` and `height` are the strip's extent in tree
1045
+ // coordinates, which is all the placement needs.
1046
+ function floatStripGroup(cls, top, height) {
1047
+ _floatGroup.selectAll('g.' + cls).remove();
1048
+ _floatStrips[cls] = {top: top, height: height};
1049
+ return _floatGroup.append('g').attr('class', cls);
1050
+ }
1051
+
1052
+ const FLOAT_STRIP_MARGIN = 6; // px between a stuck strip and the viewport edge
1053
+
1054
+ function placeFloatingOverlays() {
1055
+ if (!_floatGroup || !_baseSvg) {
1056
+ return;
1057
+ }
1058
+ let t = d3.zoomTransform(_baseSvg.node());
1059
+ let size = svgSize();
1060
+ if (!size) {
1061
+ return;
1062
+ }
1063
+ Object.keys(_floatStrips).forEach(function (cls) {
1064
+ let s = _floatStrips[cls];
1065
+ let stuckY = size.h - FLOAT_STRIP_MARGIN - (t.k * (s.top + s.height));
1066
+ let ty = Math.min(t.y, stuckY);
1067
+ _floatGroup.select('g.' + cls)
1068
+ .attr('transform', 'translate(' + t.x + ',' + ty + ') scale(' + t.k + ')');
1069
+ });
1070
+ }
1071
+
997
1072
  function centerNode(source, x, y) {
998
1073
  let scale = currentZoomScale();
999
1074
  if (!x) {
@@ -1028,6 +1103,53 @@ function (root, d3, forester, phyloXml) {
1028
1103
  const HOVER_GLOW_ALPHAS = [34 / 255, 44 / 255, 58 / 255];
1029
1104
  const HOVER_GLOW_MIN_DIA = 18;
1030
1105
  const HOVER_GLOW_ACCENT = 'rgb(38,117,191)';
1106
+ // Normalisation floors for a node-hued glow (see hoverGlowColor).
1107
+ const HOVER_GLOW_MIN_SAT = 0.15; // below this the colour has no hue to take
1108
+ const HOVER_GLOW_SAT_FLOOR = 0.55;
1109
+ const HOVER_GLOW_VAL_FLOOR = 0.80;
1110
+
1111
+ // The glow takes the hue of the node it marks, when the node has one --
1112
+ // a node coloured by Host Group glows in its host colour -- and the UI
1113
+ // accent otherwise. As on the desktop (TreePanel.hoverGlowColor). The
1114
+ // precedence is the one the node itself is painted by: the Color-by
1115
+ // value, then a per-node visual style, then an event colour, then a
1116
+ // colourised clade's branch colour. NEVER the found/search colour: a
1117
+ // focus ring must not read as a selection state.
1118
+ //
1119
+ // The glow is a 13-23% alpha wash, so a dark or a pale colour would
1120
+ // vanish in it. The hue is kept and saturation and value are floored:
1121
+ // Tableau-style oranges and reds pass unchanged, a muted blue gets a
1122
+ // modest lift, and a grey (no hue to speak of) falls back to the accent.
1123
+ function hoverGlowColor(d) {
1124
+ let c = null;
1125
+ let vis = makeVisNodeFillColor(d);
1126
+ if (vis && vis !== _state.backgroundColorDefault) {
1127
+ c = vis;
1128
+ }
1129
+ if (!c) {
1130
+ let style = nodeStyle(d);
1131
+ if (style && (style.nodeColor || style.fontColor)) {
1132
+ c = style.nodeColor || style.fontColor;
1133
+ }
1134
+ }
1135
+ if (!c && _state.showNodeEvents && d.events && d.children) {
1136
+ c = makeNodeEventsDependentColor(d.events);
1137
+ }
1138
+ if (!c && _state.useVisualStyles && d.color) {
1139
+ let bc = 'rgb(' + d.color.red + ',' + d.color.green + ',' + d.color.blue + ')';
1140
+ if (bc !== _state.branchColorDefault) {
1141
+ c = bc;
1142
+ }
1143
+ }
1144
+ if (!c) {
1145
+ return HOVER_GLOW_ACCENT;
1146
+ }
1147
+ let hsv = rgbToHsv(c);
1148
+ if (hsv.s < HOVER_GLOW_MIN_SAT) {
1149
+ return HOVER_GLOW_ACCENT;
1150
+ }
1151
+ return hsvToHex({h: hsv.h, s: Math.max(hsv.s, HOVER_GLOW_SAT_FLOOR), v: Math.max(hsv.v, HOVER_GLOW_VAL_FLOOR)});
1152
+ }
1031
1153
 
1032
1154
  function showHoverGlow(d) {
1033
1155
  if (!_svgGroup || !d || d.x === undefined) {
@@ -1039,19 +1161,21 @@ function (root, d3, forester, phyloXml) {
1039
1161
  .style('pointer-events', 'none');
1040
1162
  HOVER_GLOW_RADII.forEach(function (unused, i) {
1041
1163
  g.append('circle')
1042
- .attr('fill', HOVER_GLOW_ACCENT)
1043
1164
  .attr('fill-opacity', HOVER_GLOW_ALPHAS[i])
1044
1165
  .attr('stroke', 'none');
1045
1166
  });
1046
1167
  }
1047
1168
  let dia = Math.max(HOVER_GLOW_MIN_DIA, (_state.nodeSizeDefault || 0) * 3);
1048
1169
  let p = layoutPointXY(d);
1170
+ let fill = hoverGlowColor(d);
1049
1171
  g.attr('transform', 'translate(' + p[0] + ',' + p[1] + ')')
1050
1172
  .style('display', null)
1051
1173
  .raise();
1052
- g.selectAll('circle').attr('r', function (unused, i) {
1053
- return (dia * HOVER_GLOW_RADII[i]) / 2;
1054
- });
1174
+ g.selectAll('circle')
1175
+ .attr('fill', fill)
1176
+ .attr('r', function (unused, i) {
1177
+ return (dia * HOVER_GLOW_RADII[i]) / 2;
1178
+ });
1055
1179
  }
1056
1180
 
1057
1181
  function hideHoverGlow() {
@@ -1074,147 +1198,7 @@ function (root, d3, forester, phyloXml) {
1074
1198
 
1075
1199
  function mousemove(event, d) {
1076
1200
 
1077
- let mo_text = '';
1078
- if (d.name) {
1079
- mo_text += 'Name: ' + d.name + '<br>';
1080
- }
1081
- if (d.branch_length) {
1082
- mo_text += 'Distance to Parent: ' + d.branch_length + '<br>';
1083
- }
1084
- if (d.date && (typeof d.date.value === 'number'
1085
- || (typeof d.date.minimum === 'number' && typeof d.date.maximum === 'number'))) {
1086
- let dv = typeof d.date.value === 'number' ? String(d.date.value) : '';
1087
- if (typeof d.date.minimum === 'number' && typeof d.date.maximum === 'number') {
1088
- dv += ' [' + d.date.minimum + ' - ' + d.date.maximum + ']';
1089
- }
1090
- if (d.date.unit) {
1091
- dv += ' ' + d.date.unit;
1092
- }
1093
- mo_text += 'Date: ' + dv.trim() + '<br>';
1094
- }
1095
- mo_text += 'Depth: ' + forester.calcDepth(d) + '<br>';
1096
- let i = 0;
1097
- if (d.confidences) {
1098
- for (i = 0; i < d.confidences.length; ++i) {
1099
- let c = d.confidences[i];
1100
- if (c.type) {
1101
- mo_text += 'Confidence [' + c.type + ']: ' + c.value + '<br>';
1102
- } else {
1103
- mo_text += 'Confidence: ' + c.value + '<br>';
1104
- }
1105
- if (c.stddev) {
1106
- mo_text += '- stdev: ' + c.stddev + '<br>';
1107
- }
1108
- }
1109
- }
1110
- if (d.taxonomies) {
1111
- for (i = 0; i < d.taxonomies.length; ++i) {
1112
- mo_text += 'Taxonomy<br>';
1113
- let t = d.taxonomies[i];
1114
- if (t.id) {
1115
- if (t.id.provider) {
1116
- mo_text += '- Id [' + t.id.provider + ']: ' + t.id.value + '<br>';
1117
- } else {
1118
- mo_text += '- Id: ' + t.id.value + '<br>';
1119
- }
1120
- }
1121
- if (t.code) {
1122
- mo_text += '- Code: ' + t.code + '<br>';
1123
- }
1124
- if (t.scientific_name) {
1125
- mo_text += '- Scientific name: ' + t.scientific_name + '<br>';
1126
- }
1127
- if (t.common_name) {
1128
- mo_text += '- Common name: ' + t.common_name + '<br>';
1129
- }
1130
- if (t.rank) {
1131
- mo_text += '- Rank: ' + t.rank + '<br>';
1132
- }
1133
- }
1134
- }
1135
- if (d.sequences) {
1136
- for (i = 0; i < d.sequences.length; ++i) {
1137
- mo_text += 'Sequence<br>';
1138
- let s = d.sequences[i];
1139
- if (s.accession) {
1140
- if (s.accession.source) {
1141
- mo_text += '- Accession [' + s.accession.source + ']: ' + s.accession.value + '<br>';
1142
- } else {
1143
- mo_text += '- Accession: ' + s.accession.value + '<br>';
1144
- }
1145
- if (s.accession.comment) {
1146
- mo_text += '-- comment: ' + s.accession.comment + '<br>';
1147
- }
1148
- }
1149
- if (s.symbol) {
1150
- mo_text += '- Symbol: ' + s.symbol + '<br>';
1151
- }
1152
- if (s.name) {
1153
- mo_text += '- Name: ' + s.name + '<br>';
1154
- }
1155
- if (s.gene_name) {
1156
- mo_text += '- Gene name: ' + s.gene_name + '<br>';
1157
- }
1158
- if (s.location) {
1159
- mo_text += '- Location: ' + s.location + '<br>';
1160
- }
1161
- if (s.type) {
1162
- mo_text += '- Type: ' + s.type + '<br>';
1163
- }
1164
- }
1165
- }
1166
- if (d.distributions) {
1167
- let distributions = d.distributions;
1168
- for (i = 0; i < distributions.length; ++i) {
1169
- mo_text += 'Distribution: ';
1170
- if (distributions[i].desc) {
1171
- mo_text += distributions[i].desc + '<br>';
1172
- }
1173
- }
1174
- }
1175
- if (d.date) {
1176
- mo_text += 'Date: ';
1177
- let date = d.date;
1178
- if (date.desc) {
1179
- mo_text += date.desc + '<br>';
1180
- }
1181
- }
1182
- if (d.events) {
1183
- mo_text += 'Events<br>';
1184
- let ev = d.events;
1185
- if (ev.type && ev.type.length > 0) {
1186
- mo_text += '- Type: ' + ev.type + '<br>';
1187
- }
1188
- if (ev.duplications && ev.duplications > 0) {
1189
- mo_text += '- Duplications: ' + ev.duplications + '<br>';
1190
- }
1191
- if (ev.speciations && ev.speciations > 0) {
1192
- mo_text += '- Speciations: ' + ev.speciations + '<br>';
1193
- }
1194
- if (ev.losses && ev.losses > 0) {
1195
- mo_text += '- Losses: ' + ev.losses + '<br>';
1196
- }
1197
- }
1198
- if (d.properties && d.properties.length > 0) {
1199
- let propertiesLength = d.properties.length;
1200
- for (i = 0; i < propertiesLength; ++i) {
1201
- let property = d.properties[i];
1202
- if (property.ref && property.value) {
1203
- let prop_ref = property.ref
1204
- if (prop_ref.indexOf(':') > 0) {
1205
- prop_ref = prop_ref.substring(prop_ref.indexOf(':') + 1)
1206
- }
1207
- if (property.unit) {
1208
- mo_text += prop_ref + ': ' + property.value + property.unit + '<br>';
1209
- } else {
1210
- mo_text += prop_ref + ': ' + property.value + '<br>';
1211
- }
1212
- }
1213
- }
1214
- }
1215
- if (d.children) {
1216
- mo_text += 'Sum of Subtree Tips: ' + forester.calcSumOfAllExternalDescendants(d) + '<br>';
1217
- }
1201
+ let mo_text = nodeDataText(d);
1218
1202
 
1219
1203
  // Same label/value layout as the node-data dialog, so the two read alike.
1220
1204
  let tip = _node_mouseover_div.node();
@@ -1359,7 +1343,7 @@ function (root, d3, forester, phyloXml) {
1359
1343
  _vis = {candidates: [], byId: {}, colorId: null, shapeId: null, autoColorId: null,
1360
1344
  labelRef: null, labelPrefix: null,
1361
1345
  legendSortById: {}, colorModeById: {}, legendExpandedById: {},
1362
- colorMemory: {}, colorNext: {}, shapeMemory: {}, shapeNext: {},
1346
+ colorMemory: {}, colorNext: {}, colorOverrides: {}, shapeMemory: {}, shapeNext: {},
1363
1347
  hasStyles: false};
1364
1348
  // The readable-name inference stands on its own: it applies even when
1365
1349
  // the Color / Shape menus are disabled.
@@ -1440,6 +1424,325 @@ function (root, d3, forester, phyloXml) {
1440
1424
  // values first met in a smaller view extend the memory with the next free
1441
1425
  // palette slots. (On the launch view this assigns the sorted domain
1442
1426
  // 0,1,2,... -- exactly the old fixed-palette behaviour.)
1427
+ // Clicking a legend colour opens a colour picker and repaints that value
1428
+ // everywhere -- the legend swatch and every node carrying it -- because
1429
+ // both read the same categoryScale.
1430
+ //
1431
+ // Built in-page rather than using <input type="color">, which was the first
1432
+ // version and was wrong on three counts a user noticed immediately: an OS
1433
+ // widget cannot follow the panel's light/dark choice, cannot carry a close
1434
+ // button of its own, and only reports its value on dismissal, so the tree
1435
+ // did not move while you were choosing. This one is a non-modal dialog --
1436
+ // NOT showModal(), whose backdrop would dim the very tree being previewed.
1437
+ function openColorPicker(current, valueLabel, anchor, onChange) {
1438
+ let anchorClass = anchor && anchor.getAttribute ? anchor.getAttribute('class') : null;
1439
+ let hsv = rgbToHsv(current);
1440
+ let shell = makeDialogShell(COLOR_PICKER, valueLabel, 212);
1441
+ let dialog = shell.dialog;
1442
+
1443
+ let sv = document.createElement('div');
1444
+ sv.className = 'aptx-cp-sv';
1445
+ let svKnob = document.createElement('div');
1446
+ svKnob.className = 'aptx-cp-knob';
1447
+ sv.appendChild(svKnob);
1448
+
1449
+ let hue = document.createElement('div');
1450
+ hue.className = 'aptx-cp-hue';
1451
+ let hueKnob = document.createElement('div');
1452
+ hueKnob.className = 'aptx-cp-knob';
1453
+ hueKnob.style.top = '6px';
1454
+ hue.appendChild(hueKnob);
1455
+
1456
+ let row = document.createElement('div');
1457
+ row.className = 'aptx-cp-row';
1458
+ let preview = document.createElement('div');
1459
+ preview.className = 'aptx-cp-prev';
1460
+ let hex = document.createElement('input');
1461
+ hex.className = 'aptx-cp-hex';
1462
+ hex.type = 'text';
1463
+ hex.spellcheck = false;
1464
+ hex.setAttribute('aria-label', 'hex colour');
1465
+ row.appendChild(preview);
1466
+ row.appendChild(hex);
1467
+
1468
+ // The palette the tree hands out, so "put it back to something that
1469
+ // belongs" is one click rather than a hunt in the gradient.
1470
+ let grid = document.createElement('div');
1471
+ grid.className = 'aptx-cp-grid';
1472
+ VIS_COLOR_PALETTE.forEach(function (c) {
1473
+ let b = document.createElement('button');
1474
+ b.type = 'button';
1475
+ b.className = 'aptx-cp-sw';
1476
+ b.style.background = c;
1477
+ b.title = c;
1478
+ b.addEventListener('click', function () {
1479
+ hsv = rgbToHsv(c);
1480
+ render(true);
1481
+ });
1482
+ grid.appendChild(b);
1483
+ });
1484
+
1485
+ let note = document.createElement('div');
1486
+ note.className = 'aptx-cp-note';
1487
+ note.textContent = 'The tree updates as you pick.';
1488
+
1489
+ shell.body.appendChild(sv);
1490
+ shell.body.appendChild(hue);
1491
+ shell.body.appendChild(row);
1492
+ shell.body.appendChild(grid);
1493
+ shell.body.appendChild(note);
1494
+
1495
+ // Repainting a big tree on every pointer move would fall behind the
1496
+ // pointer, so the tree redraw is coalesced to one per frame while the
1497
+ // picker's own chrome updates immediately.
1498
+ let pendingColor = null;
1499
+ let frame = 0;
1500
+
1501
+ function render(applyNow) {
1502
+ let color = hsvToHex(hsv);
1503
+ sv.style.background = 'linear-gradient(to top,#000,rgba(0,0,0,0)),'
1504
+ + 'linear-gradient(to right,#fff,rgba(255,255,255,0)),'
1505
+ + 'hsl(' + hsv.h + ',100%,50%)';
1506
+ svKnob.style.left = (hsv.s * 100) + '%';
1507
+ svKnob.style.top = ((1 - hsv.v) * 100) + '%';
1508
+ svKnob.style.background = color;
1509
+ hueKnob.style.left = ((hsv.h / 360) * 100) + '%';
1510
+ hueKnob.style.background = 'hsl(' + hsv.h + ',100%,50%)';
1511
+ preview.style.background = color;
1512
+ if (document.activeElement !== hex) {
1513
+ hex.value = color;
1514
+ }
1515
+ if (applyNow) {
1516
+ pendingColor = color;
1517
+ if (!frame) {
1518
+ frame = requestAnimationFrame(function () {
1519
+ frame = 0;
1520
+ onChange(pendingColor);
1521
+ place(); // the legend may have just changed width
1522
+ });
1523
+ }
1524
+ }
1525
+ }
1526
+
1527
+ function track(el, move) {
1528
+ el.addEventListener('pointerdown', function (event) {
1529
+ el.setPointerCapture(event.pointerId);
1530
+ move(event);
1531
+ event.preventDefault();
1532
+ });
1533
+ el.addEventListener('pointermove', function (event) {
1534
+ if (el.hasPointerCapture(event.pointerId)) {
1535
+ move(event);
1536
+ }
1537
+ });
1538
+ }
1539
+ track(sv, function (event) {
1540
+ let r = sv.getBoundingClientRect();
1541
+ hsv.s = Math.max(0, Math.min(1, (event.clientX - r.left) / r.width));
1542
+ hsv.v = 1 - Math.max(0, Math.min(1, (event.clientY - r.top) / r.height));
1543
+ render(true);
1544
+ });
1545
+ track(hue, function (event) {
1546
+ let r = hue.getBoundingClientRect();
1547
+ hsv.h = Math.max(0, Math.min(1, (event.clientX - r.left) / r.width)) * 360;
1548
+ render(true);
1549
+ });
1550
+ hex.addEventListener('input', function () {
1551
+ if (/^#[0-9a-f]{6}$/i.test(hex.value.trim())) {
1552
+ hsv = rgbToHsv(hex.value.trim());
1553
+ render(true);
1554
+ }
1555
+ });
1556
+
1557
+ let closed = false;
1558
+ function close() {
1559
+ document.removeEventListener('pointerdown', onOutside, true);
1560
+ document.removeEventListener('keydown', onKey, true);
1561
+ if (frame) {
1562
+ cancelAnimationFrame(frame);
1563
+ frame = 0;
1564
+ }
1565
+ if (closed) {
1566
+ return;
1567
+ }
1568
+ closed = true;
1569
+ if (dialog.open) {
1570
+ dialog.close();
1571
+ }
1572
+ }
1573
+ // A non-modal dialog gets neither Escape nor a light-dismiss for free.
1574
+ function onKey(event) {
1575
+ if (event.key === 'Escape') {
1576
+ close();
1577
+ }
1578
+ }
1579
+ function onOutside(event) {
1580
+ if (!dialog.contains(event.target)) {
1581
+ close();
1582
+ }
1583
+ }
1584
+
1585
+ // The shell's own close button calls dialog.close() directly, which
1586
+ // would leave these page-level listeners behind on every dismissal
1587
+ // that is not Escape or an outside click. Both routes now run the same
1588
+ // teardown, and close() is idempotent.
1589
+ let shellClose = dialog.querySelector('.aptx-dialog-close');
1590
+ if (shellClose) {
1591
+ shellClose.addEventListener('click', close);
1592
+ }
1593
+ dialog.addEventListener('close', close);
1594
+
1595
+ render(false);
1596
+ dialog.show();
1597
+ place();
1598
+
1599
+ // Beside the legend it edits, never on top of it -- a picker that hides
1600
+ // the swatch it is changing defeats the live preview.
1601
+ //
1602
+ // Re-measured rather than remembered, for two reasons: every redraw
1603
+ // DESTROYS and rebuilds the legend group, so a held reference goes
1604
+ // stale and measures zero; and the card grows when the [reset colors]
1605
+ // chip appears on the first pick, which would leave a
1606
+ // positioned-once picker sitting over the counts. It only MOVES when
1607
+ // it would otherwise overlap, so the dialog does not chase the legend
1608
+ // around while the user drags.
1609
+ function place() {
1610
+ let el = anchorClass ? document.querySelector('g.' + anchorClass) : null;
1611
+ let box = el ? el.getBoundingClientRect() : null;
1612
+ if (!box || !box.width) {
1613
+ return;
1614
+ }
1615
+ let now = dialog.getBoundingClientRect();
1616
+ let overlaps = now.width > 0
1617
+ && !(now.left >= box.right || now.right <= box.left
1618
+ || now.top >= box.bottom || now.bottom <= box.top);
1619
+ if (now.width > 0 && !overlaps) {
1620
+ return;
1621
+ }
1622
+ let w = dialog.offsetWidth;
1623
+ let h = dialog.offsetHeight;
1624
+ // to the right of the card, or to its left when that would leave
1625
+ // the viewport
1626
+ let left = box.right + 10;
1627
+ if (left + w > window.innerWidth - 6) {
1628
+ left = box.left - w - 10;
1629
+ }
1630
+ setStyles(dialog, {
1631
+ 'position': 'fixed',
1632
+ 'margin': '0',
1633
+ 'left': Math.max(6, Math.min(window.innerWidth - w - 6, left)) + 'px',
1634
+ 'top': Math.max(6, Math.min(window.innerHeight - h - 6, box.top)) + 'px'
1635
+ });
1636
+ }
1637
+ setTimeout(function () {
1638
+ document.addEventListener('pointerdown', onOutside, true);
1639
+ document.addEventListener('keydown', onKey, true);
1640
+ }, 0);
1641
+ }
1642
+
1643
+ // HSV, not d3's HSL: a saturation/value square is what a colour picker
1644
+ // shows, and round-tripping through HSL would drift the knob.
1645
+ function rgbToHsv(c) {
1646
+ let col = null;
1647
+ try {
1648
+ col = d3.color(c);
1649
+ } catch {
1650
+ col = null;
1651
+ }
1652
+ col = col ? col.rgb() : {r: 0, g: 0, b: 0};
1653
+ let r = col.r / 255, g = col.g / 255, b = col.b / 255;
1654
+ let max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
1655
+ let h = 0;
1656
+ if (d > 0) {
1657
+ if (max === r) {
1658
+ h = 60 * (((g - b) / d) % 6);
1659
+ } else if (max === g) {
1660
+ h = 60 * (((b - r) / d) + 2);
1661
+ } else {
1662
+ h = 60 * (((r - g) / d) + 4);
1663
+ }
1664
+ }
1665
+ return {h: (h + 360) % 360, s: max === 0 ? 0 : d / max, v: max};
1666
+ }
1667
+
1668
+ function hsvToHex(hsv) {
1669
+ let c = hsv.v * hsv.s;
1670
+ let x = c * (1 - Math.abs(((hsv.h / 60) % 2) - 1));
1671
+ let m = hsv.v - c;
1672
+ let i = Math.floor(hsv.h / 60) % 6;
1673
+ let rgb = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][i];
1674
+ return '#' + rgb.map(function (v) {
1675
+ return Math.round((v + m) * 255).toString(16).padStart(2, '0');
1676
+ }).join('');
1677
+ }
1678
+
1679
+ // The key a value is remembered under. Property values fold to lower case
1680
+ // (the same fold the value grouping uses), so a legend override applies to
1681
+ // every case variant of that value -- which is what the legend shows as
1682
+ // one row anyway. ONE definition: the candidate builder and the legend's
1683
+ // colour picker must agree, or an override would be written under a key
1684
+ // the scale never reads.
1685
+ function visMemoryKey(vis, v) {
1686
+ return vis.kind === 'property' ? String(v).toLowerCase() : v;
1687
+ }
1688
+
1689
+ // Colours live in a per-candidate MEMORY map rather than being computed
1690
+ // from the value's position, so they survive a re-render, a Color-by
1691
+ // switch away and back, and a legend re-sort. That is also what makes a
1692
+ // user override possible at all: writing one entry and rebuilding is
1693
+ // enough, and every consumer (the legend swatch AND the node colour) reads
1694
+ // through the same scale.
1695
+ function buildCategoryScale(vis) {
1696
+ let mem = _vis.colorMemory[vis.id] || (_vis.colorMemory[vis.id] = {});
1697
+ let next = _vis.colorNext[vis.id] || 0;
1698
+ vis.values.forEach(function (v) {
1699
+ let k = visMemoryKey(vis, v);
1700
+ if (!(k in mem)) {
1701
+ mem[k] = extendedPaletteColor(next++);
1702
+ }
1703
+ });
1704
+ _vis.colorNext[vis.id] = next;
1705
+ vis.categoryScale = d3.scaleOrdinal()
1706
+ .domain(vis.values)
1707
+ .range(vis.values.map(function (v) {
1708
+ return mem[visMemoryKey(vis, v)];
1709
+ }));
1710
+ }
1711
+
1712
+ // What the PALETTE had assigned before the user overrode it, keyed the
1713
+ // same way as colorMemory. Storing the displaced colour rather than the
1714
+ // chosen one is what makes reset exact: deleting the memory entry instead
1715
+ // and re-running buildCategoryScale would hand the value the next UNUSED
1716
+ // palette colour, since colorNext has moved on -- so "reset" would restore
1717
+ // a palette colour, just not the one that was there.
1718
+ function hasColorOverrides(vis) {
1719
+ let o = _vis && _vis.colorOverrides && _vis.colorOverrides[vis.id];
1720
+ return !!(o && Object.keys(o).length > 0);
1721
+ }
1722
+
1723
+ function setColorOverride(vis, value, color) {
1724
+ let o = _vis.colorOverrides[vis.id] || (_vis.colorOverrides[vis.id] = {});
1725
+ let k = visMemoryKey(vis, value);
1726
+ if (!(k in o)) {
1727
+ // first override of this value: remember what it displaced
1728
+ o[k] = _vis.colorMemory[vis.id][k];
1729
+ }
1730
+ _vis.colorMemory[vis.id][k] = color;
1731
+ buildCategoryScale(vis);
1732
+ }
1733
+
1734
+ function clearColorOverrides(vis) {
1735
+ let o = _vis.colorOverrides[vis.id];
1736
+ if (!o) {
1737
+ return;
1738
+ }
1739
+ Object.keys(o).forEach(function (k) {
1740
+ _vis.colorMemory[vis.id][k] = o[k];
1741
+ });
1742
+ delete _vis.colorOverrides[vis.id];
1743
+ buildCategoryScale(vis);
1744
+ }
1745
+
1443
1746
  function computeVisualizationCandidates(viewRoot) {
1444
1747
  _vis.candidates = [];
1445
1748
  _vis.byId = {};
@@ -1456,24 +1759,11 @@ function (root, d3, forester, phyloXml) {
1456
1759
  .domain([nums[0], mean, nums[nums.length - 1]]);
1457
1760
  }
1458
1761
  function memoryKey(v) {
1459
- return c.kind === 'property' ? v.toLowerCase() : v;
1762
+ return visMemoryKey(c, v);
1460
1763
  }
1461
1764
  // a switchable candidate needs both scales standing by
1462
1765
  if (c.colorMode === 'category' || c.switchable) {
1463
- let mem = _vis.colorMemory[c.id] || (_vis.colorMemory[c.id] = {});
1464
- let next = _vis.colorNext[c.id] || 0;
1465
- c.values.forEach(function (v) {
1466
- let k = memoryKey(v);
1467
- if (!(k in mem)) {
1468
- mem[k] = extendedPaletteColor(next++);
1469
- }
1470
- });
1471
- _vis.colorNext[c.id] = next;
1472
- c.categoryScale = d3.scaleOrdinal()
1473
- .domain(c.values)
1474
- .range(c.values.map(function (v) {
1475
- return mem[memoryKey(v)];
1476
- }));
1766
+ buildCategoryScale(c);
1477
1767
  }
1478
1768
  if (c.shape) {
1479
1769
  let mem = _vis.shapeMemory[c.id] || (_vis.shapeMemory[c.id] = {});
@@ -1669,6 +1959,16 @@ function (root, d3, forester, phyloXml) {
1669
1959
  }
1670
1960
  });
1671
1961
  }
1962
+ if (kind === 'color' && !isRange && hasColorOverrides(vis)) {
1963
+ chips.push({
1964
+ text: '[reset colors]',
1965
+ tip: 'put the palette colors back',
1966
+ act: function () {
1967
+ clearColorOverrides(vis);
1968
+ update(null, 0);
1969
+ }
1970
+ });
1971
+ }
1672
1972
  if (!isRange && (hidden > 0 || (_vis.legendExpandedById[vis.id] && vis.values.length > LEGEND_MAX_ROWS))) {
1673
1973
  chips.push({
1674
1974
  text: hidden > 0 ? '[+' + hidden + ' more]' : '[fewer]',
@@ -1797,11 +2097,38 @@ function (root, d3, forester, phyloXml) {
1797
2097
  .style('fill', 'none')
1798
2098
  .style('stroke', frame);
1799
2099
  } else {
1800
- g.append('rect')
2100
+ let sw = g.append('rect')
1801
2101
  .attr('x', x + PAD).attr('y', swY)
1802
2102
  .attr('width', SWATCH).attr('height', SWATCH)
1803
2103
  .attr('rx', 2)
1804
2104
  .style('fill', vis.categoryScale(r.value));
2105
+ if (kind === 'color') {
2106
+ // A 9px square is a poor target, so the hit area is the
2107
+ // swatch plus its label -- and it is a separate transparent
2108
+ // rect so the visible swatch keeps its exact geometry.
2109
+ let value = r.value;
2110
+ g.append('rect')
2111
+ .attr('x', x + PAD - 2).attr('y', swY - 2)
2112
+ .attr('width', SWATCH + GAP + legendTextWidth(r.text, rowFont) + 4)
2113
+ .attr('height', SWATCH + 4)
2114
+ .style('fill', 'transparent')
2115
+ .style('cursor', 'pointer')
2116
+ .on('mousedown', function (event) {
2117
+ event.stopPropagation(); // picking a colour is not a drag
2118
+ })
2119
+ .on('click', function (event) {
2120
+ event.stopPropagation();
2121
+ // the legend CARD, not the swatch: a picker that
2122
+ // covers the legend hides the very swatch it changes
2123
+ openColorPicker(vis.categoryScale(value), r.text, this.parentNode, function (picked) {
2124
+ setColorOverride(vis, value, picked);
2125
+ update(null, 0);
2126
+ });
2127
+ })
2128
+ .append('title')
2129
+ .text('click to change the color for "' + r.text + '"');
2130
+ sw.style('cursor', 'pointer');
2131
+ }
1805
2132
  }
1806
2133
  g.append('text')
1807
2134
  .attr('x', x + PAD + SWATCH + GAP).attr('y', baseline)
@@ -1880,6 +2207,82 @@ function (root, d3, forester, phyloXml) {
1880
2207
 
1881
2208
  // --------------------------------------------------------------
1882
2209
 
2210
+ // Redraw -- at once on a small tree, coalesced and deferred on a big one.
2211
+ //
2212
+ // Two things this buys on a big tree, both measured on the 18,512-node
2213
+ // BV-BRC tree where one update() costs over a second:
2214
+ //
2215
+ // - Coalescing. Several handlers redraw more than once per click:
2216
+ // nine of them run search0(), search1() and update() -- three full
2217
+ // redraws, since runSearches() ends in its own update() -- and the
2218
+ // Visualizations and Auto-hide toggles call update() twice back to
2219
+ // back. A slider drag fires an update per input event. Every call
2220
+ // made in the same tick now collapses into ONE update().
2221
+ // - The yield. The redraw runs behind the "working" card on the next
2222
+ // frame, so a click is answered by a visible card instead of a page
2223
+ // that has stopped responding.
2224
+ //
2225
+ // Only for callers that read nothing back afterwards. zoomToFit() and
2226
+ // the layout switches update() and then read the fresh layout in the
2227
+ // same tick, so they keep calling update() directly -- deferring those
2228
+ // would have them measure a tree that has not been drawn yet.
2229
+ //
2230
+ // Arguments merge conservatively: any caller that wants the width
2231
+ // recalculated gets it, the shortest transition wins, and a named source
2232
+ // survives a null one.
2233
+ function scheduleUpdate(source, transitionDuration, doNotRecalculateWidth) {
2234
+ if (_initializing) {
2235
+ runAfterUpdate(); // nothing pending; anything queued can run now
2236
+ return;
2237
+ }
2238
+ if (!_basicTreeProperties || _basicTreeProperties.nodeCount < BIG_TREE_NODES) {
2239
+ update(source, transitionDuration, doNotRecalculateWidth);
2240
+ runAfterUpdate();
2241
+ return;
2242
+ }
2243
+ let dur = (transitionDuration === undefined) ? TRANSITION_DURATION_DEFAULT : transitionDuration;
2244
+ if (_pendingUpdate) {
2245
+ _pendingUpdate.source = _pendingUpdate.source || source;
2246
+ _pendingUpdate.duration = Math.min(_pendingUpdate.duration, dur);
2247
+ _pendingUpdate.noWidth = _pendingUpdate.noWidth && (doNotRecalculateWidth === true);
2248
+ return;
2249
+ }
2250
+ _pendingUpdate = {source: source, duration: dur, noWidth: doNotRecalculateWidth === true};
2251
+ let seq = _launchSeq;
2252
+ showBusy(_container, 'Redrawing ' + _basicTreeProperties.nodeCount.toLocaleString() + ' nodes');
2253
+ afterPaint(function () {
2254
+ let p = _pendingUpdate;
2255
+ _pendingUpdate = null;
2256
+ if (seq !== _launchSeq || !p) {
2257
+ return; // the view was torn down or replaced meanwhile
2258
+ }
2259
+ try {
2260
+ update(p.source, p.duration, p.noWidth);
2261
+ runAfterUpdate();
2262
+ } finally {
2263
+ hideBusy();
2264
+ }
2265
+ });
2266
+ }
2267
+
2268
+ // Run fn once the redraw has happened: now, if none is pending (a small
2269
+ // tree, or nothing scheduled), else after the deferred update() runs.
2270
+ function afterUpdate(fn) {
2271
+ if (_pendingUpdate) {
2272
+ _afterUpdate.push(fn);
2273
+ } else {
2274
+ fn();
2275
+ }
2276
+ }
2277
+
2278
+ function runAfterUpdate() {
2279
+ let fns = _afterUpdate;
2280
+ _afterUpdate = [];
2281
+ fns.forEach(function (fn) {
2282
+ fn();
2283
+ });
2284
+ }
2285
+
1883
2286
  function update(source, transitionDuration, doNotRecalculateWidth) {
1884
2287
 
1885
2288
  if (!source) {
@@ -2060,14 +2463,18 @@ function (root, d3, forester, phyloXml) {
2060
2463
  });
2061
2464
 
2062
2465
 
2063
- // "Pulse Found Nodes", as on the desktop: a translucent breathing
2064
- // disc in the hit's colour behind each hit (first in the group, so
2065
- // everything else draws over it; never a pointer target).
2066
- nodeEnter.append('circle')
2067
- .attr('class', 'foundHalo aptx-found-halo')
2068
- .attr('r', 0)
2069
- .style('pointer-events', 'none');
2070
-
2466
+ // Only the three elements EVERY node needs are created here. The other
2467
+ // six -- the found halo, the four text labels and the support dot --
2468
+ // are created on demand by syncOptionalNodeChildren() below.
2469
+ //
2470
+ // They used to be appended unconditionally, nine elements per node
2471
+ // whether or not they would ever show anything, and on a real BV-BRC
2472
+ // tree (18,512 nodes) that was 222,197 SVG elements of which HALF were
2473
+ // inert: 74,052 of 74,064 <text> elements empty, 43,413 circles at
2474
+ // r=0. The cost is not creating them -- allocating that many elements
2475
+ // takes about two seconds -- it is that every later redraw walks them
2476
+ // all, setting attributes and running an accessor per element. Dropping
2477
+ // the inert half measured 2.7x faster redraws.
2071
2478
  nodeEnter.append('path')
2072
2479
  .attr('d', 'M0,0');
2073
2480
 
@@ -2091,33 +2498,6 @@ function (root, d3, forester, phyloXml) {
2091
2498
  return 0;
2092
2499
  });
2093
2500
 
2094
- nodeEnter.append('text')
2095
- .attr('class', 'extlabel')
2096
- .attr('text-anchor', function (d) {
2097
- return d.children ? "end" : "start";
2098
- })
2099
- .style('font-family', _state.defaultFont)
2100
- .style('fill-opacity', 0.5);
2101
-
2102
- nodeEnter.append('text')
2103
- .attr('class', 'bllabel')
2104
- .style('font-family', _state.defaultFont)
2105
- .style('fill-opacity', 0.5);
2106
-
2107
- nodeEnter.append('text')
2108
- .attr('class', 'conflabel')
2109
- .attr('text-anchor', 'middle')
2110
- .style('font-family', _state.defaultFont);
2111
-
2112
- nodeEnter.append('circle')
2113
- .attr('class', 'suppdot')
2114
- .style('pointer-events', 'none');
2115
-
2116
- nodeEnter.append('text')
2117
- .attr('class', 'brancheventlabel')
2118
- .attr('text-anchor', 'middle')
2119
- .style('font-family', _state.defaultFont);
2120
-
2121
2501
  // Grab the exit selection BEFORE merging. In d3 v3 the exit selection
2122
2502
  // survived on the merged result, but in v4+ merge() returns a NEW
2123
2503
  // selection with no exit, so calling .exit() after this line silently
@@ -2130,6 +2510,12 @@ function (root, d3, forester, phyloXml) {
2130
2510
  // merge them before the shared styling/positioning below.
2131
2511
  node = nodeEnter.merge(node);
2132
2512
 
2513
+ // Create the optional children only where they will show something,
2514
+ // and drop them where they will not. Must run BEFORE node.transition()
2515
+ // below, so a child created now still gets its attributes set by the
2516
+ // .select() calls that follow.
2517
+ syncOptionalNodeChildren(node);
2518
+
2133
2519
  node.select("text.extlabel")
2134
2520
  .style('font-size', function (d) {
2135
2521
  let style = nodeStyle(d);
@@ -2303,6 +2689,7 @@ function (root, d3, forester, phyloXml) {
2303
2689
  }
2304
2690
  });
2305
2691
 
2692
+ if (anySearchHits()) {
2306
2693
  node.select('circle.foundHalo')
2307
2694
  .attr('class', function (d) {
2308
2695
  // the animated class ONLY on hits: hundreds of idle infinite
@@ -2319,6 +2706,7 @@ function (root, d3, forester, phyloXml) {
2319
2706
  .style('fill', function (d) {
2320
2707
  return isNodeFound(d) ? getFoundColor(d) : 'none';
2321
2708
  });
2709
+ }
2322
2710
 
2323
2711
  node.select('circle.nodeCircle')
2324
2712
  .attr('r', function (d) {
@@ -2355,19 +2743,21 @@ function (root, d3, forester, phyloXml) {
2355
2743
 
2356
2744
  nodeUpdate.select('text.extlabel')
2357
2745
  .text(function (d) {
2358
- if (!_state.dynahide || !d.hide) {
2359
- return makeNodeLabel(d);
2360
- }
2746
+ return d._extLabelText; // computed in syncOptionalNodeChildren
2361
2747
  });
2362
2748
 
2363
- nodeUpdate.select('text.bllabel')
2364
- .text(_state.showBranchLengthValues ? makeBranchLengthLabel : null);
2365
-
2366
- nodeUpdate.select('text.conflabel')
2367
- .text(_state.showConfidenceValues ? makeConfidenceValuesLabel : null);
2368
-
2369
- nodeUpdate.select('text.brancheventlabel')
2370
- .text(_state.showBranchEvents ? makeBranchEventsLabel : null);
2749
+ // Guarded, not just passed a null accessor: syncOptionalNodeChildren
2750
+ // has already removed these elements when the switch is off, and a
2751
+ // .select() that finds nothing still walks every node to find out.
2752
+ if (_state.showBranchLengthValues) {
2753
+ nodeUpdate.select('text.bllabel').text(makeBranchLengthLabel);
2754
+ }
2755
+ if (_state.showConfidenceValues) {
2756
+ nodeUpdate.select('text.conflabel').text(makeConfidenceValuesLabel);
2757
+ }
2758
+ if (_state.showBranchEvents) {
2759
+ nodeUpdate.select('text.brancheventlabel').text(makeBranchEventsLabel);
2760
+ }
2371
2761
 
2372
2762
  let drawShapes = _state.showVisualizations || stylesActive();
2373
2763
  nodeUpdate.select('path')
@@ -2376,16 +2766,17 @@ function (root, d3, forester, phyloXml) {
2376
2766
  .style('fill', drawShapes ? makeNodeFillColor : null)
2377
2767
  .attr('d', drawShapes ? makeNodeVisShape : null);
2378
2768
 
2379
- node.each(function (d) {
2380
- if (d.children) {
2381
- if (!drawShapes && makeNodeVisShape(d) === null) {
2382
- d3.select(this).select('path').transition().duration(transitionDuration)
2383
- .attr('d', function () {
2384
- return 'M0,0';
2385
- });
2386
- }
2387
- }
2388
- });
2769
+ // Collapse the vis shape on internal nodes that no longer have one.
2770
+ // This used to be a node.each() that built a fresh d3 selection AND a
2771
+ // fresh transition per internal node -- 5,265 of them on a big tree,
2772
+ // and a full 18k-node walk even when drawShapes made it a no-op. One
2773
+ // filtered selection with one transition does the same thing.
2774
+ if (!drawShapes) {
2775
+ node.filter(function (d) {
2776
+ return d.children && makeNodeVisShape(d) === null;
2777
+ }).select('path').transition().duration(transitionDuration)
2778
+ .attr('d', 'M0,0');
2779
+ }
2389
2780
 
2390
2781
  // Departing nodes are removed OUTRIGHT rather than on the end of a
2391
2782
  // transition. Going to a subtree (or deleting one) fires several updates
@@ -2397,9 +2788,12 @@ function (root, d3, forester, phyloXml) {
2397
2788
  // way; nodes now match.
2398
2789
  nodeExitSelection.remove();
2399
2790
 
2791
+ // No .attr('d', elbow) before the join: it ran elbow() for all 18k+
2792
+ // links against the PRE-JOIN data and the transition below overwrote
2793
+ // the result, so every redraw computed the same geometry twice.
2794
+ // stroke-width used to ride along here and is applied on the merged
2795
+ // selection instead -- it is the only place existing links get it.
2400
2796
  let link = _svgGroup.selectAll('path.link')
2401
- .attr('d', elbow)
2402
- .attr('stroke-width', makeBranchWidth)
2403
2797
  .data(links, function (d) {
2404
2798
  return d.target.id;
2405
2799
  });
@@ -2423,6 +2817,8 @@ function (root, d3, forester, phyloXml) {
2423
2817
 
2424
2818
  link = linkEnter.merge(link);
2425
2819
 
2820
+ link.attr('stroke-width', makeBranchWidth);
2821
+
2426
2822
  link.transition()
2427
2823
  .duration(transitionDuration)
2428
2824
  .attr('stroke', makeBranchColor)
@@ -2877,6 +3273,121 @@ function (root, d3, forester, phyloXml) {
2877
3273
  return false;
2878
3274
  }
2879
3275
 
3276
+ // The six per-node children that are usually not needed. Whether one is
3277
+ // wanted is either a GLOBAL switch (three of the four labels, the support
3278
+ // dot) -- in which case the whole class can be dropped in a single
3279
+ // selectAll().remove() rather than a per-node test -- or a per-node
3280
+ // question (the external label's text, and whether a search hit this node).
3281
+ //
3282
+ // The external label's text is computed HERE and stashed, because it is the
3283
+ // one predicate that costs something and the update below would otherwise
3284
+ // compute it a second time.
3285
+ function anySearchHits() {
3286
+ return (_foundNodes0 && _foundNodes0.size > 0)
3287
+ || (_foundNodes1 && _foundNodes1.size > 0);
3288
+ }
3289
+
3290
+ function syncOptionalNodeChildren(node) {
3291
+ let wantBl = _state.showBranchLengthValues === true;
3292
+ let wantConf = _state.showConfidenceValues === true;
3293
+ let wantEvent = _state.showBranchEvents === true;
3294
+ let wantDot = _state.showSupportDots === true;
3295
+
3296
+ // whole-class removals first: one pass, no per-node callback
3297
+ if (!wantBl) {
3298
+ node.selectAll('text.bllabel').remove();
3299
+ }
3300
+ if (!wantConf) {
3301
+ node.selectAll('text.conflabel').remove();
3302
+ }
3303
+ if (!wantEvent) {
3304
+ node.selectAll('text.brancheventlabel').remove();
3305
+ }
3306
+ if (!wantDot) {
3307
+ node.selectAll('circle.suppdot').remove();
3308
+ }
3309
+
3310
+ // No search running means no halo anywhere -- one pass instead of
3311
+ // 18,512 set lookups plus 18,512 DOM queries.
3312
+ let anyFound = anySearchHits();
3313
+ if (!anyFound) {
3314
+ node.selectAll('circle.foundHalo').remove();
3315
+ }
3316
+
3317
+ // Raw DOM inside the loop, not d3 selections: this runs once per node
3318
+ // per redraw, and wrapping each node in a d3 selection just to ask
3319
+ // whether a child exists costs more than the question is worth.
3320
+ let font = _state.defaultFont;
3321
+ node.each(function (d) {
3322
+ let g = this;
3323
+
3324
+ // The label the update is about to draw. Empty is the common case
3325
+ // on a big tree: Auto-hide Labels blanks them exactly when there
3326
+ // are most nodes to blank.
3327
+ d._extLabelText = (!_state.dynahide || !d.hide) ? (makeNodeLabel(d) || '') : '';
3328
+ let ext = g.querySelector('text.extlabel');
3329
+ if (d._extLabelText !== '') {
3330
+ if (!ext) {
3331
+ d3.select(g).append('text')
3332
+ .attr('class', 'extlabel')
3333
+ .attr('text-anchor', d.children ? 'end' : 'start')
3334
+ .style('font-family', font)
3335
+ .style('fill-opacity', 0.5);
3336
+ }
3337
+ } else if (ext) {
3338
+ ext.remove();
3339
+ }
3340
+
3341
+ if (anyFound) {
3342
+ // The pulse behind a search hit. It must be FIRST in the group
3343
+ // so everything else draws over it -- appending would put it
3344
+ // on top.
3345
+ let halo = g.querySelector('circle.foundHalo');
3346
+ if (isNodeFound(d)) {
3347
+ if (!halo) {
3348
+ d3.select(g).insert('circle', ':first-child')
3349
+ .attr('class', 'foundHalo aptx-found-halo')
3350
+ .attr('r', 0)
3351
+ .style('pointer-events', 'none');
3352
+ }
3353
+ } else if (halo) {
3354
+ halo.remove();
3355
+ }
3356
+ }
3357
+
3358
+ if (wantBl && !g.querySelector('text.bllabel')) {
3359
+ d3.select(g).append('text')
3360
+ .attr('class', 'bllabel')
3361
+ .style('font-family', font)
3362
+ .style('fill-opacity', 0.5);
3363
+ }
3364
+ if (wantConf && !g.querySelector('text.conflabel')) {
3365
+ d3.select(g).append('text')
3366
+ .attr('class', 'conflabel')
3367
+ .attr('text-anchor', 'middle')
3368
+ .style('font-family', font);
3369
+ }
3370
+ if (wantEvent && !g.querySelector('text.brancheventlabel')) {
3371
+ d3.select(g).append('text')
3372
+ .attr('class', 'brancheventlabel')
3373
+ .attr('text-anchor', 'middle')
3374
+ .style('font-family', font);
3375
+ }
3376
+ if (wantDot) {
3377
+ let dot = g.querySelector('circle.suppdot');
3378
+ if (showSupportDot(d)) {
3379
+ if (!dot) {
3380
+ d3.select(g).append('circle')
3381
+ .attr('class', 'suppdot')
3382
+ .style('pointer-events', 'none');
3383
+ }
3384
+ } else if (dot) {
3385
+ dot.remove();
3386
+ }
3387
+ }
3388
+ });
3389
+ }
3390
+
2880
3391
  let makeNodeLabel = function (phynode) {
2881
3392
  if (!_state.showExternalLabels && !(phynode.children)) {
2882
3393
  return null;
@@ -3253,6 +3764,49 @@ function (root, d3, forester, phyloXml) {
3253
3764
  // One list, because there is one config object. Checking each of the two old
3254
3765
  // bags only against its own list meant the right name in the wrong bag was
3255
3766
  // silently ignored -- the very failure this list exists to prevent.
3767
+ // THE one source for the parse-time defaults. initializeSettings runs from
3768
+ // launch(), which is AFTER launchArchaeopteryx has already parsed, and
3769
+ // parseTree is a public entry point that docs/open.html calls directly --
3770
+ // so a second copy of these literals in either place is a default that
3771
+ // cannot take effect. (The desktop's command-line launch drifted from its
3772
+ // File>Open exactly this way, on a constant that had been hardcoded false
3773
+ // since its config parser was removed.) Two equal literals is the bug;
3774
+ // one object read by both is the fix.
3775
+ const PARSE_DEFAULTS = {
3776
+ internalNumericLabels: 'auto'
3777
+ };
3778
+
3779
+ // Keys that still WORK but have moved, or have been retired outright.
3780
+ // Unlike REMOVED_CONFIG these do not throw: they are translated (or
3781
+ // ignored) with a warning, so an upgrade does not break a working embed
3782
+ // over a rename. An entry without a "to" has no replacement -- it is
3783
+ // accepted and dropped.
3784
+ const DEPRECATED_CONFIG = {
3785
+ nhConfidenceValuesInBrackets: {
3786
+ // Retired in 3.1.0. It gated whether "[95]" after a node is read
3787
+ // as a confidence, and setting it false did not reinterpret the
3788
+ // bracket -- it DISCARDED it, so the flag's only power was to
3789
+ // throw support values away. Non-numeric brackets (plain Newick
3790
+ // comments) are ignored either way, and NHX / BEAST blobs go
3791
+ // through a different path entirely, so there is no tree for
3792
+ // which false was the right answer. Its one real purpose was
3793
+ // historical: it used to be mutually exclusive with
3794
+ // nhConfidenceValuesAsInternalNames, and that guard is gone.
3795
+ note: 'bracketed values such as "[95]" are always read as confidences now; the option had no other effect'
3796
+ },
3797
+ nhConfidenceValuesAsInternalNames: {
3798
+ to: 'internalNumericLabels',
3799
+ // true meant "read every numeric internal label as a confidence",
3800
+ // which is exactly 'confidence'. It is NOT 'auto': auto is
3801
+ // all-or-nothing and promotes nothing in a mixed tree, so a caller
3802
+ // silently moved to auto would lose promotions they had.
3803
+ map: function (v) {
3804
+ return v === true ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
3805
+ },
3806
+ note: 'use \'confidence\' for what this flag did (\'auto\' is the new default, and does not promote mixed trees)'
3807
+ }
3808
+ };
3809
+
3256
3810
  const REMOVED_CONFIG = {
3257
3811
  circular: 'renamed to "layout": use layout: "circular"',
3258
3812
  circularDisplay: 'replaced by "layout": use layout: "circular" (or "rectangular" / "unrooted")',
@@ -3428,9 +3982,8 @@ function (root, d3, forester, phyloXml) {
3428
3982
  'enableSubtreeDeletion',
3429
3983
  'enableVisualizations',
3430
3984
  'initialVisualization',
3985
+ 'internalNumericLabels',
3431
3986
  'ladderizeTree',
3432
- 'nhConfidenceValuesAsInternalNames',
3433
- 'nhConfidenceValuesInBrackets',
3434
3987
  'nhExportWriteConfidences',
3435
3988
  'nodeLabels',
3436
3989
  'pngExportScale',
@@ -3453,6 +4006,24 @@ function (root, d3, forester, phyloXml) {
3453
4006
  });
3454
4007
  }
3455
4008
 
4009
+ Object.keys(given).forEach(function (k) {
4010
+ let dep = DEPRECATED_CONFIG[k];
4011
+ if (dep === undefined) {
4012
+ return;
4013
+ }
4014
+ if (dep.to) {
4015
+ console.warn(WARNING + ': "' + k + '" was replaced by "' + dep.to
4016
+ + '": ' + dep.note);
4017
+ // An explicit new key always wins over the old alias.
4018
+ if (given[dep.to] === undefined) {
4019
+ given[dep.to] = dep.map(given[k]);
4020
+ }
4021
+ } else {
4022
+ console.warn(WARNING + ': "' + k + '" is retired and has no effect: ' + dep.note);
4023
+ }
4024
+ delete given[k];
4025
+ });
4026
+
3456
4027
  let removed = Object.keys(given).filter(function (k) {
3457
4028
  return REMOVED_CONFIG[k] !== undefined;
3458
4029
  });
@@ -3493,6 +4064,16 @@ function (root, d3, forester, phyloXml) {
3493
4064
  return CONTROLS_0_LEFT_DEFAULT + PANEL_WIDTH + ROOT_CLEARANCE;
3494
4065
  }
3495
4066
 
4067
+ // The only validation initializeState() performs. Hoisted so launch() can
4068
+ // run it synchronously and defer the rest of initializeState behind the
4069
+ // working card on a big tree; the check inside initializeState stays as
4070
+ // a backstop and can no longer fire from launch().
4071
+ function checkLayoutValue(layout) {
4072
+ if (layout !== undefined && layout !== 'rectangular' && layout !== 'circular' && layout !== 'unrooted') {
4073
+ throw new Error(ERROR + '"layout" must be "rectangular", "circular", or "unrooted"');
4074
+ }
4075
+ }
4076
+
3496
4077
  function initializeState(state) {
3497
4078
  _state = state;
3498
4079
 
@@ -3507,12 +4088,38 @@ function (root, d3, forester, phyloXml) {
3507
4088
  });
3508
4089
  }
3509
4090
 
3510
- // Branch lengths are worth drawing to scale only when MOST branches
3511
- // carry one. A tree where a handful of branches have a length and the
3512
- // rest do not is not a phylogram with gaps -- it is a cladogram.
3513
- let branchCount = _basicTreeProperties.nodeCount - 1;
3514
- _state.phylogram = branchCount > 0
3515
- && (_basicTreeProperties.branchesWithPositiveLength / branchCount) > PHYLOGRAM_MIN_BRANCH_FRACTION;
4091
+ // Branch lengths are worth drawing to scale only when MOST of the
4092
+ // branches that CARRY THE SCALE have one. Two corrections to the
4093
+ // obvious "count every branch" version, both from real BV-BRC trees:
4094
+ //
4095
+ // 1. Judge INTERNAL branches, not all of them. Exporters routinely
4096
+ // omit branch_length on tips where it would be zero, so a tree can
4097
+ // be fully measured internally and still have most of its branches
4098
+ // bare -- segment_3_2025_12_09.xml is 100% measured across its 5265
4099
+ // internal branches and only 16% across its 13246 tips, and used to
4100
+ // open as a cladogram on 0.40 of all branches. A missing TIP length
4101
+ // still draws correctly (the tip sits at its parent); a missing
4102
+ // INTERNAL length is what destroys the scale. A star tree has no
4103
+ // internal branches at all, so there the tips are all there is.
4104
+ // 2. Ask whether a branch HAS a length, not whether it is positive.
4105
+ // An explicit zero is a real measurement -- a tree writing its
4106
+ // polytomies as zero-length internal branches is a phylogram.
4107
+ //
4108
+ // The root is excluded from every count: a branch length is a property
4109
+ // of the branch ABOVE a node, and the root has none.
4110
+ let scaleBearing = _basicTreeProperties.internalBranchCount > 0;
4111
+ let measured = scaleBearing
4112
+ ? _basicTreeProperties.internalBranchesWithLength
4113
+ : _basicTreeProperties.branchesWithLength;
4114
+ let branchCount = scaleBearing
4115
+ ? _basicTreeProperties.internalBranchCount
4116
+ : _basicTreeProperties.branchCount;
4117
+ // branchLengths gates the phylogram BUTTON too (a tree whose every
4118
+ // length is zero cannot be drawn to scale), so the default must agree
4119
+ // with it or the view would contradict its own control.
4120
+ _state.phylogram = _basicTreeProperties.branchLengths === true
4121
+ && branchCount > 0
4122
+ && (measured / branchCount) > PHYLOGRAM_MIN_BRANCH_FRACTION;
3516
4123
  _state.alignPhylogram = false;
3517
4124
  // "layout" is launch-time only: a caller picks the starting layout by
3518
4125
  // name, and from here on _state.circularDisplay/_state.unrootedDisplay
@@ -3554,7 +4161,22 @@ function (root, d3, forester, phyloXml) {
3554
4161
  // The Confidence checkbox APPEARS whenever the tree has support values
3555
4162
  // (that is driven by _basicTreeProperties.confidences where the panel
3556
4163
  // is built) but starts unchecked: confidence values clutter the tree.
3557
- _state.showConfidenceValues = false;
4164
+ //
4165
+ // The ONE exception is a tree whose confidences were just promoted from
4166
+ // bare internal labels: those numbers were ON SCREEN a moment ago, as
4167
+ // node names. Promotion MOVES the label, so leaving the display off
4168
+ // would delete them from the view -- turning "my support values show as
4169
+ // names" into "my support values are gone", which is worse than the bug
4170
+ // being fixed. Promotion must be visually neutral: the same numbers
4171
+ // stay, they merely become confidences.
4172
+ //
4173
+ // Deliberately NOT "any tree that has confidences" (which is what the
4174
+ // desktop does): a phyloXML tree carrying real confidences never had
4175
+ // them drawn, so there is nothing to preserve and today's uncluttered
4176
+ // default is right. Only promotion destroys visible information. That
4177
+ // difference between the two programs is a NAMED divergence, agreed
4178
+ // 2026-09-08 -- do not "fix" it into either shape.
4179
+ _state.showConfidenceValues = !!(_treeData && _treeData.confidencesFromInternalLabels > 0);
3558
4180
  // A tree carrying an aligned mol_seq per tip shows its alignment
3559
4181
  // track from the start, as the desktop auto-enables it on load --
3560
4182
  // unless the caller already said so explicitly (an explicit value
@@ -3786,13 +4408,14 @@ function (root, d3, forester, phyloXml) {
3786
4408
  if (_settings.nhExportWriteConfidences === undefined) {
3787
4409
  _settings.nhExportWriteConfidences = true;
3788
4410
  }
3789
- // the two New Hampshire parse options (used by launchArchaeopteryx /
3790
- // parseTree; they were positional arguments before the one-config API)
3791
- if (_settings.nhConfidenceValuesInBrackets === undefined) {
3792
- _settings.nhConfidenceValuesInBrackets = true;
3793
- }
3794
- if (_settings.nhConfidenceValuesAsInternalNames === undefined) {
3795
- _settings.nhConfidenceValuesAsInternalNames = false;
4411
+ // the New Hampshire parse options (used by launchArchaeopteryx /
4412
+ // parseTree; they were positional arguments before the one-config API).
4413
+ // Both read PARSE_DEFAULTS -- see the comment there for why a second
4414
+ // copy of these literals would be a default that cannot take effect.
4415
+ if (_settings.internalNumericLabels === undefined) {
4416
+ _settings.internalNumericLabels = PARSE_DEFAULTS.internalNumericLabels;
4417
+ } else if (['auto', 'confidence', 'label'].indexOf(_settings.internalNumericLabels) < 0) {
4418
+ throw new Error(ERROR + 'internalNumericLabels must be "auto", "confidence" or "label"');
3796
4419
  }
3797
4420
  // custom label-field checkboxes (was launch()'s sixth positional arg)
3798
4421
  if (_settings.nodeLabels === undefined) {
@@ -3887,6 +4510,10 @@ function (root, d3, forester, phyloXml) {
3887
4510
  populateSearchMenus();
3888
4511
 
3889
4512
  _svgGroup = _baseSvg.append('g');
4513
+ // the floating strips paint over the tree and under the overview
4514
+ _floatGroup = _baseSvg.append('g').attr('class', 'aptx-float')
4515
+ .style('pointer-events', 'none');
4516
+ _floatStrips = {};
3890
4517
  makeOverview(); // appended after the tree group, so it paints on top of it
3891
4518
 
3892
4519
  if (_settings.ladderizeTree) {
@@ -3919,6 +4546,9 @@ function (root, d3, forester, phyloXml) {
3919
4546
  getSelectedNodes: function () {
3920
4547
  return archaeopteryx.getSelectedNodes();
3921
4548
  },
4549
+ // resolves once the tree is drawn: at once for a small tree,
4550
+ // after the deferred first draw for a big one
4551
+ ready: _readyPromise,
3922
4552
  destroy: destroyViewer
3923
4553
  };
3924
4554
  }
@@ -3930,6 +4560,8 @@ function (root, d3, forester, phyloXml) {
3930
4560
  // outlived the view. A later launch() into any container works normally
3931
4561
  // (the page-level handlers rebind).
3932
4562
  function destroyViewer() {
4563
+ ++_launchSeq; // a first draw still waiting for its frame must not run
4564
+ hideBusy();
3933
4565
  if (_container) {
3934
4566
  d3.select(_container).selectAll('svg').remove();
3935
4567
  _container.querySelectorAll('.aptx-panel').forEach(function (p) {
@@ -3940,8 +4572,9 @@ function (root, d3, forester, phyloXml) {
3940
4572
  document.querySelectorAll('dialog.aptx-dialog').forEach(function (dlg) {
3941
4573
  dlg.remove();
3942
4574
  });
3943
- if (_msaScroller) {
3944
- _msaScroller.remove();
4575
+ if (_msaNav) {
4576
+ _msaNav.remove();
4577
+ _msaNav = null;
3945
4578
  _msaScroller = null;
3946
4579
  }
3947
4580
  if (_node_mouseover_div) {
@@ -4044,27 +4677,25 @@ function (root, d3, forester, phyloXml) {
4044
4677
  _radialRotation = 0;
4045
4678
  _radialLabelsHorizontal = false;
4046
4679
  _msaColOffset = 0;
4047
- _vis = null;
4048
-
4049
-
4050
- initializeState(cfg.state);
4680
+ // ---- synchronous: everything that can throw, and the cheap setup --
4681
+ //
4682
+ // initializeSettings validates values and throws on a bad one, so it
4683
+ // stays here. It reads nothing initializeState produces, so it can
4684
+ // run first -- which lets initializeState, the visualization
4685
+ // candidates and the panel move BEHIND the working card on a big tree:
4686
+ // together they were ~2.7 s of synchronous label intelligence and
4687
+ // candidate ranking over 13k tips before the card could even paint.
4688
+ checkLayoutValue(cfg.state ? cfg.state.layout : undefined);
4051
4689
  initializeSettings(cfg.settings);
4052
4690
  _nodeLabels = _settings.nodeLabels || null;
4053
4691
 
4054
-
4055
- initializeVisualizations();
4056
-
4057
- // launch() may run again on the same container (tree switchers do
4058
- // exactly that): tear the previous viewer's DOM down first, or the
4059
- // duplicated element ids leave the NEW panel's controls wired to the
4060
- // OLD, invisible one.
4692
+ // whatever a previous launch left in this container (or in the
4693
+ // container before it) goes now, so the old tree does not sit under
4694
+ // the card while the new one is prepared
4061
4695
  d3.select(containerEl).selectAll('svg').remove();
4062
4696
  containerEl.querySelectorAll('.aptx-panel').forEach(function (p) {
4063
4697
  p.remove();
4064
4698
  });
4065
- // ...and a previous viewer in a DIFFERENT container: one viewer per
4066
- // page is the contract, and "replaced" must never mean "abandoned on
4067
- // screen with its controls cross-wired to the new tree"
4068
4699
  if (previousContainer && previousContainer !== containerEl) {
4069
4700
  d3.select(previousContainer).selectAll('svg').remove();
4070
4701
  previousContainer.querySelectorAll('.aptx-panel').forEach(function (p) {
@@ -4072,10 +4703,6 @@ function (root, d3, forester, phyloXml) {
4072
4703
  });
4073
4704
  }
4074
4705
  _selectedNodes = new Set();
4075
- // and null the old tree: the initialization sequence runs update()
4076
- // once mid-way (applyTreeTheme), which on the first launch is a no-op
4077
- // because _root is null -- a relaunch must start from the same state,
4078
- // not render the OLD tree against half-reset globals
4079
4706
  _root = null;
4080
4707
  _root_const = null;
4081
4708
  _in_subtree = false;
@@ -4085,50 +4712,86 @@ function (root, d3, forester, phyloXml) {
4085
4712
  _overviewContent = null;
4086
4713
  _overviewViewport = null;
4087
4714
  _overviewMap = null;
4715
+ _vis = null;
4088
4716
 
4089
- createGui();
4717
+ // ---- the rest: deferred one frame on a big tree, immediate otherwise
4718
+ let seq = ++_launchSeq;
4719
+ function setupAndDraw() {
4720
+ initializeState(cfg.state);
4721
+ initializeVisualizations();
4722
+ createGui();
4090
4723
 
4091
- _baseSvg = d3.select(containerEl).append('svg')
4092
- .attr('width', _displayWidth)
4093
- .attr('height', _displayHeight)
4094
- .call(_zoomListener);
4724
+ _baseSvg = d3.select(containerEl).append('svg')
4725
+ .attr('width', _displayWidth)
4726
+ .attr('height', _displayHeight)
4727
+ .call(_zoomListener);
4095
4728
 
4096
- if (_settings.enableDynamicSizing) {
4097
- // namespaced: the HOST page may have its own d3 window-resize
4098
- // listener, which an un-namespaced .on('resize') would CLOBBER --
4099
- // and destroy() below must be able to take only ours down
4100
- d3.select(window)
4101
- .on('resize.archaeopteryx', function () {
4102
- let size = displaySizeFromContainer();
4103
- if (!size) {
4729
+ if (_settings.enableDynamicSizing) {
4730
+ d3.select(window)
4731
+ .on('resize.archaeopteryx', function () {
4732
+ let size = displaySizeFromContainer();
4733
+ if (!size) {
4734
+ return;
4735
+ }
4736
+ _displayWidth = size.w;
4737
+ _displayHeight = size.h;
4738
+ _baseSvg.attr('width', size.w);
4739
+ _baseSvg.attr('height', size.h);
4740
+ rebuildOverview();
4741
+ if ((_settings.zoomToFitUponWindowResize === true) && (_zoomed_x_or_y === false) && (Math.abs(currentZoomScale() - 1.0) < 0.001)) {
4742
+ zoomToFit();
4743
+ }
4744
+ });
4745
+ }
4746
+
4747
+ _treeFn = d3.cluster()
4748
+ .size([_displayHeight, _displayWidth]);
4749
+
4750
+ _treeFn.clickEvent = getClickEventListenerNode(phylo);
4751
+
4752
+ _root = phylo;
4753
+ _root_const = _root;
4754
+
4755
+ calcMaxExtLabel();
4756
+ _root.x0 = _displayHeight / 2;
4757
+ _root.y0 = 0;
4758
+ _initializing = true;
4759
+ try {
4760
+ initialize();
4761
+ } finally {
4762
+ _initializing = false;
4763
+ }
4764
+ }
4765
+
4766
+ // Everything that can THROW has already happened above -- bad
4767
+ // arguments, unknown config keys, invalid setting values, an
4768
+ // unresolvable container -- so deferring the rest keeps launch()'s
4769
+ // synchronous-error contract intact. The handle closes over module
4770
+ // state, so it is valid before the draw; viewer.ready says when.
4771
+ let nodeCount = _basicTreeProperties ? _basicTreeProperties.nodeCount : 0;
4772
+ if (nodeCount >= BIG_TREE_NODES) {
4773
+ showBusy(containerEl, 'Drawing ' + nodeCount.toLocaleString() + ' nodes',
4774
+ 'this can take a moment on a tree this size');
4775
+ _readyPromise = new Promise(function (resolve) {
4776
+ afterPaint(function () {
4777
+ // torn down or replaced while we waited: nothing to draw
4778
+ if (seq !== _launchSeq) {
4779
+ resolve();
4104
4780
  return;
4105
4781
  }
4106
- _displayWidth = size.w;
4107
- _displayHeight = size.h;
4108
-
4109
- _baseSvg.attr('width', size.w);
4110
- _baseSvg.attr('height', size.h);
4111
- rebuildOverview();
4112
- if ((_settings.zoomToFitUponWindowResize === true) && (_zoomed_x_or_y === false) && (Math.abs(currentZoomScale() - 1.0) < 0.001)) {
4113
- zoomToFit();
4782
+ try {
4783
+ setupAndDraw();
4784
+ } finally {
4785
+ hideBusy();
4786
+ resolve();
4114
4787
  }
4115
4788
  });
4789
+ });
4790
+ } else {
4791
+ setupAndDraw();
4792
+ _readyPromise = Promise.resolve();
4116
4793
  }
4117
4794
 
4118
- _treeFn = d3.cluster()
4119
- .size([_displayHeight, _displayWidth]);
4120
-
4121
- _treeFn.clickEvent = getClickEventListenerNode(phylo);
4122
-
4123
- _root = phylo;
4124
- _root_const = _root;
4125
-
4126
- calcMaxExtLabel();
4127
-
4128
- _root.x0 = _displayHeight / 2;
4129
- _root.y0 = 0;
4130
- initialize();
4131
-
4132
4795
  return makeViewerHandle();
4133
4796
 
4134
4797
  //////////////////////////////////////////////////////////////////////
@@ -4143,9 +4806,43 @@ function (root, d3, forester, phyloXml) {
4143
4806
  return phy;
4144
4807
  };
4145
4808
 
4146
- archaeopteryx.parseNewHampshire = function (data, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
4809
+ // Bare numeric internal labels become confidence values here, in the two
4810
+ // thin wrappers rather than in launchArchaeopteryx -- because parseTree
4811
+ // dispatches by calling THESE two by name, so every JS path picks it up at
4812
+ // once: parseTree, launchArchaeopteryx (which calls parseTree), and
4813
+ // docs/open.html, which calls parseTree DIRECTLY. Hooking the launch path
4814
+ // instead would have shipped the fix while missing the page the bug was
4815
+ // reported from. (Calling forester.parseNewHampshire directly still
4816
+ // bypasses it -- deliberately: forester is a library that also serves
4817
+ // callers who want the file exactly as written.)
4818
+ //
4819
+ // Nexus needs this as much as Newick: a Nexus tree statement IS Newick, so
4820
+ // ")100:" has always had the same problem there. phyloXML and Auspice do
4821
+ // not -- both carry real confidences, and promoting would corrupt them.
4822
+ //
4823
+ // The count rides on the phylogeny so the VIEWER can react without the
4824
+ // promotion knowing anything about display state: initialize() switches
4825
+ // the confidence display on when anything was promoted, so support values
4826
+ // APPEAR rather than names silently vanishing.
4827
+ function promoteInternalLabels(phy, mode) {
4828
+ let promoted = forester.promoteInternalLabelsToConfidence(phy,
4829
+ mode === undefined ? PARSE_DEFAULTS.internalNumericLabels : mode);
4830
+ if (promoted > 0) {
4831
+ phy.confidencesFromInternalLabels = promoted;
4832
+ }
4833
+ return phy;
4834
+ }
4835
+
4836
+ // The retired brackets flag used to be the second argument and is still
4837
+ // accepted there, ignored, with the mode following it. forester keeps its
4838
+ // own flag -- that parameter is a shared contract with forester-Java, and
4839
+ // a caller reaching straight past this wrapper can still set it -- but
4840
+ // nothing here ever passes anything but true, because false only ever
4841
+ // discarded the bracketed value.
4842
+ archaeopteryx.parseNewHampshire = function (data, mode, legacyMode) {
4147
4843
  requireForester();
4148
- return forester.parseNewHampshire(data, confidenceValuesInBrackets, confidenceValuesAsInternalNames);
4844
+ let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
4845
+ return promoteInternalLabels(forester.parseNewHampshire(data, true, false), m);
4149
4846
  };
4150
4847
 
4151
4848
  // An Auspice / Nextstrain v2 dataset.json; the tree opens on the time
@@ -4158,13 +4855,14 @@ function (root, d3, forester, phyloXml) {
4158
4855
 
4159
4856
  // A Nexus file can hold several trees; the FIRST one is displayed (any
4160
4857
  // alignment from the file's characters matrix rides along on its tips).
4161
- archaeopteryx.parseNexus = function (data, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
4858
+ archaeopteryx.parseNexus = function (data, mode, legacyMode) {
4162
4859
  requireForester();
4163
- let trees = forester.parseNexus(data, confidenceValuesInBrackets, confidenceValuesAsInternalNames);
4860
+ let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
4861
+ let trees = forester.parseNexus(data, true, false);
4164
4862
  if (trees.length === 0) {
4165
4863
  throw new Error('no tree found in the Nexus data');
4166
4864
  }
4167
- return trees[0];
4865
+ return promoteInternalLabels(trees[0], m);
4168
4866
  };
4169
4867
 
4170
4868
  function calcMaxExtLabel() {
@@ -4245,132 +4943,7 @@ function (root, d3, forester, phyloXml) {
4245
4943
 
4246
4944
  function displayNodeData(n) {
4247
4945
  let title = 'Node Data';
4248
- let text = '';
4249
- if (n.name) {
4250
- text += 'Name: ' + n.name + '<br>';
4251
- }
4252
- if (n.branch_length) {
4253
- text += 'Distance to Parent: ' + n.branch_length + '<br>';
4254
- }
4255
- text += 'Depth: ' + forester.calcDepth(n) + '<br>';
4256
- let i = 0;
4257
- if (n.confidences) {
4258
- for (i = 0; i < n.confidences.length; ++i) {
4259
- let c = n.confidences[i];
4260
- if (c.type) {
4261
- text += 'Confidence [' + c.type + ']: ' + c.value + '<br>';
4262
- } else {
4263
- text += 'Confidence: ' + c.value + '<br>';
4264
- }
4265
- if (c.stddev) {
4266
- text += '- stdev: ' + c.stddev + '<br>';
4267
- }
4268
- }
4269
- }
4270
- if (n.taxonomies) {
4271
- for (i = 0; i < n.taxonomies.length; ++i) {
4272
- text += 'Taxonomy<br>';
4273
- let t = n.taxonomies[i];
4274
- if (t.id) {
4275
- if (t.id.provider) {
4276
- text += '- Id [' + t.id.provider + ']: ' + t.id.value + '<br>';
4277
- } else {
4278
- text += '- Id: ' + t.id.value + '<br>';
4279
- }
4280
- }
4281
- if (t.code) {
4282
- text += '- Code: ' + t.code + '<br>';
4283
- }
4284
- if (t.scientific_name) {
4285
- text += '- Scientific name: ' + t.scientific_name + '<br>';
4286
- }
4287
- if (t.common_name) {
4288
- text += '- Common name: ' + t.common_name + '<br>';
4289
- }
4290
- if (t.rank) {
4291
- text += '- Rank: ' + t.rank + '<br>';
4292
- }
4293
- }
4294
- }
4295
- if (n.sequences) {
4296
- for (i = 0; i < n.sequences.length; ++i) {
4297
- text += 'Sequence<br>';
4298
- let s = n.sequences[i];
4299
- if (s.accession) {
4300
- if (s.accession.source) {
4301
- text += '- Accession [' + s.accession.source + ']: ' + s.accession.value + '<br>';
4302
- } else {
4303
- text += '- Accession: ' + s.accession.value + '<br>';
4304
- }
4305
- if (s.accession.comment) {
4306
- text += '-- comment: ' + s.accession.comment + '<br>';
4307
- }
4308
- }
4309
- if (s.symbol) {
4310
- text += '- Symbol: ' + s.symbol + '<br>';
4311
- }
4312
- if (s.name) {
4313
- text += '- Name: ' + s.name + '<br>';
4314
- }
4315
- if (s.gene_name) {
4316
- text += '- Gene name: ' + s.gene_name + '<br>';
4317
- }
4318
- if (s.location) {
4319
- text += '- Location: ' + s.location + '<br>';
4320
- }
4321
- if (s.type) {
4322
- text += '- Type: ' + s.type + '<br>';
4323
- }
4324
- }
4325
- }
4326
- if (n.distributions) {
4327
- let distributions = n.distributions;
4328
- for (i = 0; i < distributions.length; ++i) {
4329
- text += 'Distribution: ';
4330
- if (distributions[i].desc) {
4331
- text += distributions[i].desc + '<br>';
4332
- }
4333
- }
4334
- }
4335
- if (n.date) {
4336
- text += 'Date: ';
4337
- let date = n.date;
4338
- if (date.desc) {
4339
- text += date.desc + '<br>';
4340
- }
4341
- }
4342
- if (n.events) {
4343
- text += 'Events<br>';
4344
- let ev = n.events;
4345
- if (ev.type && ev.type.length > 0) {
4346
- text += '- Type: ' + ev.type + '<br>';
4347
- }
4348
- if (ev.duplications && ev.duplications > 0) {
4349
- text += '- Duplications: ' + ev.duplications + '<br>';
4350
- }
4351
- if (ev.speciations && ev.speciations > 0) {
4352
- text += '- Speciations: ' + ev.speciations + '<br>';
4353
- }
4354
- if (ev.losses && ev.losses > 0) {
4355
- text += '- Losses: ' + ev.losses + '<br>';
4356
- }
4357
- }
4358
- if (n.properties && n.properties.length > 0) {
4359
- let propertiesLength = n.properties.length;
4360
- for (i = 0; i < propertiesLength; ++i) {
4361
- let property = n.properties[i];
4362
- if (property.ref && property.value) {
4363
- if (property.unit) {
4364
- text += property.ref + ': ' + property.value + property.unit + '<br>';
4365
- } else {
4366
- text += property.ref + ': ' + property.value + '<br>';
4367
- }
4368
- }
4369
- }
4370
- }
4371
- if (n.children) {
4372
- text += 'Sum of Subtree Tips: ' + forester.calcSumOfAllExternalDescendants(n) + '<br>';
4373
- }
4946
+ let text = nodeDataText(n);
4374
4947
 
4375
4948
  showNodeDataDialog(title, text, false, 260, 300);
4376
4949
 
@@ -4832,39 +5405,44 @@ function (root, d3, forester, phyloXml) {
4832
5405
  applyZoom();
4833
5406
  return;
4834
5407
  }
4835
- let t = d3.zoomTransform(_baseSvg.node());
4836
- let before = layoutSpans();
4837
- // the layout point currently under the middle of the viewport
4838
- let cx = ((size.w / 2) - t.x) / t.k;
4839
- let cy = ((size.h / 2) - t.y) / t.k;
5408
+ // The point under the viewport centre, captured ONCE per burst: with
5409
+ // a big tree the zoom steps of a wheel flick coalesce into one
5410
+ // deferred redraw, and the re-centring after it maps this one point
5411
+ // forward to the final layout. Capturing again on each step would
5412
+ // measure a transform that has not been applied yet.
5413
+ if (!_pendingCentre) {
5414
+ let t = d3.zoomTransform(_baseSvg.node());
5415
+ _pendingCentre = {
5416
+ size: size,
5417
+ t: t,
5418
+ before: layoutSpans(),
5419
+ cx: ((size.w / 2) - t.x) / t.k,
5420
+ cy: ((size.h / 2) - t.y) / t.k
5421
+ };
5422
+ }
4840
5423
 
4841
5424
  applyZoom();
4842
5425
 
5426
+ afterUpdate(recentreViewport);
5427
+ }
5428
+
5429
+ function recentreViewport() {
5430
+ let p = _pendingCentre;
5431
+ _pendingCentre = null;
5432
+ if (!p || !_baseSvg) {
5433
+ return;
5434
+ }
4843
5435
  let after = layoutSpans();
4844
- // Taken from the layout spans, NOT from a measured bounding box: nodes
4845
- // and labels are moved by a transition, so straight after update() the
4846
- // rendered geometry is still a mix of old and new and measuring it puts
4847
- // the anchor in the wrong place (which showed up as the tree wandering
4848
- // up the screen while zooming out). The spans are exact and immediate.
4849
- //
4850
- // Horizontally the layout has two zones. Points inside the branch span
4851
- // SCALE with it, but a viewport centre can also sit over the label
4852
- // zone beyond it (with long labels, most of the picture) -- label text
4853
- // keeps its length and only TRANSLATES with its anchor node, so that
4854
- // part of the distance is carried over unscaled. Splitting the anchor
4855
- // this way is exact for labels anchored at the far edge and a close
4856
- // approximation for the rest; scaling the whole distance made the
4857
- // display race off horizontally on long-labelled trees.
4858
- // ... and the same at the OTHER end: a centre panned before the root
4859
- // (negative) or past the span is empty margin too, and scaling it
4860
- // flings the tree the way the label overshoot once did.
4861
- let bx = Math.max(0, Math.min(cx, before.horizontal));
4862
- let nx = (bx * (after.horizontal / before.horizontal)) + (cx - bx);
4863
- let by = Math.max(0, Math.min(cy, before.vertical));
4864
- let ny = (by * (after.vertical / before.vertical)) + (cy - by);
5436
+ // Map the captured point through the change in layout extent: a
5437
+ // point inside the tree scales with it, a point in the margin keeps
5438
+ // its offset from the tree's edge.
5439
+ let bx = Math.max(0, Math.min(p.cx, p.before.horizontal));
5440
+ let nx = (bx * (after.horizontal / p.before.horizontal)) + (p.cx - bx);
5441
+ let by = Math.max(0, Math.min(p.cy, p.before.vertical));
5442
+ let ny = (by * (after.vertical / p.before.vertical)) + (p.cy - by);
4865
5443
  _baseSvg.call(_zoomListener.transform, d3.zoomIdentity
4866
- .translate((size.w / 2) - (nx * t.k), (size.h / 2) - (ny * t.k))
4867
- .scale(t.k));
5444
+ .translate((p.size.w / 2) - (nx * p.t.k), (p.size.h / 2) - (ny * p.t.k))
5445
+ .scale(p.t.k));
4868
5446
  }
4869
5447
 
4870
5448
  // The desktop's radial rotation: each press turns the circular layout by
@@ -4873,7 +5451,7 @@ function (root, d3, forester, phyloXml) {
4873
5451
  // so horizontal zoom would be redundant there.
4874
5452
  function rotateRadial(clockwise) {
4875
5453
  _radialRotation = (_radialRotation + (clockwise ? 1 : -1) * Math.PI / 32) % (2 * Math.PI);
4876
- update(null, 0, true);
5454
+ scheduleUpdate(null, 0, true);
4877
5455
  }
4878
5456
 
4879
5457
  // The desktop's fit-width button: fit the tree to the window width,
@@ -4912,7 +5490,7 @@ function (root, d3, forester, phyloXml) {
4912
5490
  } else {
4913
5491
  _displayWidth = _displayWidth * BUTTON_ZOOM_IN_FACTOR;
4914
5492
  }
4915
- update(null, 0);
5493
+ scheduleUpdate(null, 0);
4916
5494
  });
4917
5495
  }
4918
5496
 
@@ -4933,7 +5511,7 @@ function (root, d3, forester, phyloXml) {
4933
5511
  } else {
4934
5512
  _displayHeight = _displayHeight * f;
4935
5513
  }
4936
- update(null, 0);
5514
+ scheduleUpdate(null, 0);
4937
5515
  });
4938
5516
  }
4939
5517
 
@@ -4952,7 +5530,7 @@ function (root, d3, forester, phyloXml) {
4952
5530
  }
4953
5531
  if ((newDisplayWidth - calcMaxTreeLengthForDisplay()) >= 1) {
4954
5532
  _displayWidth = newDisplayWidth;
4955
- update(null, 0);
5533
+ scheduleUpdate(null, 0);
4956
5534
  }
4957
5535
  });
4958
5536
  }
@@ -4973,7 +5551,7 @@ function (root, d3, forester, phyloXml) {
4973
5551
  _displayHeight = min;
4974
5552
  }
4975
5553
  }
4976
- update(null, 0);
5554
+ scheduleUpdate(null, 0);
4977
5555
  });
4978
5556
  }
4979
5557
 
@@ -5039,6 +5617,10 @@ function (root, d3, forester, phyloXml) {
5039
5617
  return;
5040
5618
  }
5041
5619
  _svgGroup.selectAll('g.aptx-msa').remove();
5620
+ if (_floatGroup) {
5621
+ _floatGroup.selectAll('g.aptx-msa-strip').remove();
5622
+ delete _floatStrips['aptx-msa-strip'];
5623
+ }
5042
5624
  if (!msaShown() || !_root) {
5043
5625
  _msaGeom = null;
5044
5626
  updateMsaScrollbar(0, 1, 0);
@@ -5202,8 +5784,47 @@ function (root, d3, forester, phyloXml) {
5202
5784
  let cons = forester.msaConservation(tips.map(function (t) {
5203
5785
  return msaRowSeq(t).slice(offset, offset + visible);
5204
5786
  }), visible, 'identity', isNuc);
5787
+ // A faint dashed guide from each tip -- from the end of its label, or
5788
+ // from the node itself when the label is hidden -- across to its row
5789
+ // in the track, so a row can be read back to its sequence without
5790
+ // counting. Same idea as the aligned-phylogram extension; dashed so it
5791
+ // cannot be mistaken for a branch. Skipped where a label already
5792
+ // reaches the track.
5793
+ let guideFont = _state.externalNodeFontSize + 'px ' + _state.defaultFont;
5794
+ let guideGap = _state.nodeLabelGap;
5795
+ let guideEnd = originX - 3;
5796
+ for (let r = 0; r < n; ++r) {
5797
+ let d = tips[r];
5798
+ let label = d._extLabelText || '';
5799
+ let from;
5800
+ if (label) {
5801
+ let labelX = (_state.phylogram && _state.alignPhylogram && _yScale)
5802
+ ? d.y - _yScale(d.distToRoot) + _w + guideGap
5803
+ : d.y + guideGap;
5804
+ from = labelX + legendTextWidth(label, guideFont) + 5;
5805
+ } else {
5806
+ from = d.y + makeNodeSize(d) + 4;
5807
+ }
5808
+ if (guideEnd - from < 6) {
5809
+ continue;
5810
+ }
5811
+ g.append('line')
5812
+ .attr('x1', from).attr('x2', guideEnd)
5813
+ .attr('y1', d.x).attr('y2', d.x)
5814
+ .attr('stroke', ink)
5815
+ .attr('stroke-width', 1)
5816
+ .attr('stroke-dasharray', '2,3')
5817
+ .style('stroke-opacity', 0.35)
5818
+ .style('pointer-events', 'none');
5819
+ }
5820
+
5821
+ // ---- conservation, consensus, ruler: a FLOATING strip ----
5822
+ let strip = floatStripGroup('aptx-msa-strip', _clusterH, MSA_BOTTOM_RESERVE);
5823
+ strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
5824
+ .attr('width', trackW + 4).attr('height', MSA_BOTTOM_RESERVE)
5825
+ .attr('fill', _state.backgroundColorDefault);
5205
5826
  let consTop = _clusterH + MSA_CONS_TOP_GAP;
5206
- g.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
5827
+ strip.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
5207
5828
  .attr('width', trackW).attr('height', MSA_CONS_BAR_H)
5208
5829
  .attr('fill', ink).attr('fill-opacity', 0.08);
5209
5830
  for (let i = 0; i < visible; ++i) {
@@ -5214,12 +5835,12 @@ function (root, d3, forester, phyloXml) {
5214
5835
  let bh = Math.max(1, Math.round(score * MSA_CONS_BAR_H));
5215
5836
  let x0 = Math.round(originX + (i * cw));
5216
5837
  let x1 = Math.round(originX + ((i + 1) * cw));
5217
- g.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
5838
+ strip.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
5218
5839
  .attr('width', Math.max(1, x1 - x0)).attr('height', bh)
5219
5840
  .attr('fill', ink).attr('fill-opacity', 0.7);
5220
5841
  }
5221
5842
  if (trackW > 170) {
5222
- g.append('text').attr('x', Math.round(originX) + trackW - 3).attr('y', consTop + 9)
5843
+ strip.append('text').attr('x', Math.round(originX) + trackW - 3).attr('y', consTop + 9)
5223
5844
  .attr('text-anchor', 'end')
5224
5845
  .style('font-size', '8px').style('fill', ink).style('fill-opacity', 0.9)
5225
5846
  .text('Consensus identity (n = ' + n + ')');
@@ -5231,7 +5852,7 @@ function (root, d3, forester, phyloXml) {
5231
5852
  if (!cc) {
5232
5853
  continue;
5233
5854
  }
5234
- g.append('text').attr('x', Math.round(originX + (i * cw)) + (cw / 2))
5855
+ strip.append('text').attr('x', Math.round(originX + (i * cw)) + (cw / 2))
5235
5856
  .attr('y', consTop + MSA_CONS_BAR_H + 10)
5236
5857
  .attr('text-anchor', 'middle')
5237
5858
  .style('font-family', 'monospace').style('font-size', '8px')
@@ -5243,7 +5864,7 @@ function (root, d3, forester, phyloXml) {
5243
5864
  // the 1-based column ruler: absolute column numbers at nice steps,
5244
5865
  // and always the first and last column when their edge is in view
5245
5866
  let rulerY = consTop + MSA_CONS_BAR_H + (consensusRow ? 13 : 3);
5246
- g.append('line').attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
5867
+ strip.append('line').attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
5247
5868
  .attr('y1', rulerY).attr('y2', rulerY)
5248
5869
  .attr('stroke', ink).attr('stroke-opacity', 0.8).attr('stroke-width', 1);
5249
5870
  let lastRight = -Infinity;
@@ -5253,12 +5874,12 @@ function (root, d3, forester, phyloXml) {
5253
5874
  return;
5254
5875
  }
5255
5876
  let x = Math.round(originX + (i * cw)) + (cw / 2);
5256
- g.append('line').attr('x1', x).attr('x2', x).attr('y1', rulerY).attr('y2', rulerY + 4)
5877
+ strip.append('line').attr('x1', x).attr('x2', x).attr('y1', rulerY).attr('y2', rulerY + 4)
5257
5878
  .attr('stroke', ink).attr('stroke-opacity', 0.8).attr('stroke-width', 1);
5258
5879
  let label = String(c);
5259
5880
  let half = label.length * 2.8;
5260
5881
  if (force || (x - half) >= (lastRight + 4)) {
5261
- g.append('text').attr('x', x).attr('y', rulerY + 14).attr('text-anchor', 'middle')
5882
+ strip.append('text').attr('x', x).attr('y', rulerY + 14).attr('text-anchor', 'middle')
5262
5883
  .style('font-size', '9px').style('fill', ink)
5263
5884
  .text(label);
5264
5885
  lastRight = x + half;
@@ -5285,49 +5906,152 @@ function (root, d3, forester, phyloXml) {
5285
5906
  if (delta === 0) {
5286
5907
  return;
5287
5908
  }
5288
- let next = Math.max(0, Math.min(maxOffset, _msaColOffset + (delta > 0 ? 3 : -3)));
5289
- if (next !== _msaColOffset) {
5290
- _msaColOffset = next;
5291
- drawMsaTrack();
5292
- }
5909
+ // a tenth of a screen per notch (never under three columns):
5910
+ // fine enough to place a column, and a long alignment is not
5911
+ // ten thousand notches end to end
5912
+ let step = Math.max(3, Math.round(visible / 10));
5913
+ msaScrollTo(_msaColOffset + (delta > 0 ? step : -step));
5293
5914
  });
5294
5915
  _msaGeom = {
5295
5916
  originX: originX, cw: cw, offset: offset, visible: visible, total: total,
5296
5917
  bounds: bounds, tips: tips, isNuc: isNuc
5297
5918
  };
5298
5919
  updateMsaScrollbar(offset, visible, total);
5920
+ placeFloatingOverlays();
5299
5921
  }
5300
5922
 
5301
5923
  // The dedicated column scroller: a fixed HTML range input at the window
5302
5924
  // bottom, shown only while the alignment is wider than its window. Lives
5303
5925
  // outside the svg, so exports never include it.
5926
+ // Scroll the alignment window to start at column `offset` (0-based),
5927
+ // clamped, and redraw. Every way of moving -- slider, wheel, paging
5928
+ // buttons, the jump box -- lands here.
5929
+ function msaScrollTo(offset) {
5930
+ if (!_msaGeom) {
5931
+ return;
5932
+ }
5933
+ let max = Math.max(0, _msaGeom.total - _msaGeom.visible);
5934
+ let next = Math.max(0, Math.min(max, Math.round(offset)));
5935
+ if (next !== _msaColOffset) {
5936
+ _msaColOffset = next;
5937
+ drawMsaTrack();
5938
+ }
5939
+ }
5940
+
5941
+ // The navigation bar under a scrollable alignment. A bare range slider
5942
+ // used to be all there was, and on a 30,000-column alignment 260 px of
5943
+ // slider is ~115 columns per pixel and the wheel moved three columns a
5944
+ // notch -- nothing could be FOUND, only scrolled past. Now: first / page
5945
+ // back / slider / page forward / last, a jump-to-column box, and a
5946
+ // readout of the columns on screen. A page is one screenful of columns.
5304
5947
  function updateMsaScrollbar(offset, visible, total) {
5305
5948
  let scrollable = msaShown() && total > visible;
5306
- if (!_msaScroller && !scrollable) {
5307
- return; // never park a page-level slider under a tree that has no use for it
5308
- }
5309
- if (!_msaScroller) {
5949
+ if (!_msaNav && !scrollable) {
5950
+ return; // never park page-level controls under a tree that has no use for them
5951
+ }
5952
+ if (!_msaNav) {
5953
+ let nav = document.createElement('div');
5954
+ nav.className = 'aptx-msa-nav';
5955
+ if (_panelTheme) {
5956
+ nav.classList.add('aptx-' + _panelTheme);
5957
+ }
5958
+ function button(text, title, act) {
5959
+ let b = document.createElement('button');
5960
+ b.type = 'button';
5961
+ b.className = 'aptx-msa-nav-btn';
5962
+ b.textContent = text;
5963
+ b.title = title;
5964
+ b.addEventListener('click', act);
5965
+ nav.appendChild(b);
5966
+ return b;
5967
+ }
5968
+ button('\u23EE', 'first column', function () {
5969
+ msaScrollTo(0);
5970
+ });
5971
+ button('\u25C0', 'back one screen of columns', function () {
5972
+ msaScrollTo(_msaColOffset - (_msaGeom ? _msaGeom.visible : 0));
5973
+ });
5310
5974
  let el = document.createElement('input');
5311
5975
  el.type = 'range';
5312
5976
  el.id = MSA_SCROLL_ID;
5313
5977
  el.className = 'aptx-msa-scroll';
5314
5978
  el.min = '0';
5315
5979
  el.step = '1';
5316
- el.title = 'scroll the alignment columns (the mouse wheel over the alignment works too)';
5980
+ el.title = 'scroll the alignment (the wheel over the alignment works too; PageUp/PageDown jump)';
5317
5981
  el.addEventListener('input', function () {
5318
5982
  let v = parseInt(el.value, 10);
5319
- if (!isNaN(v) && v !== _msaColOffset) {
5320
- _msaColOffset = v;
5321
- drawMsaTrack();
5983
+ if (!isNaN(v)) {
5984
+ msaScrollTo(v);
5985
+ }
5986
+ });
5987
+ nav.appendChild(el);
5988
+ button('\u25B6', 'forward one screen of columns', function () {
5989
+ msaScrollTo(_msaColOffset + (_msaGeom ? _msaGeom.visible : 0));
5990
+ });
5991
+ button('\u23ED', 'last column', function () {
5992
+ msaScrollTo(Infinity);
5993
+ });
5994
+
5995
+ // "column [ 1234 ] - 1357 of 30,000": the box is the left edge of
5996
+ // the window and takes a column to jump to; the rest reads back
5997
+ let read = document.createElement('span');
5998
+ read.className = 'aptx-msa-nav-read';
5999
+ let lbl = document.createElement('span');
6000
+ lbl.textContent = 'column';
6001
+ let jump = document.createElement('input');
6002
+ jump.type = 'number';
6003
+ jump.min = '1';
6004
+ jump.step = '1';
6005
+ jump.className = 'aptx-msa-nav-jump';
6006
+ jump.title = 'type a column number and press Enter to scroll there';
6007
+ jump.setAttribute('aria-label', 'first visible column; type a column to jump to');
6008
+ function jumpNow() {
6009
+ let v = parseInt(jump.value, 10);
6010
+ if (!isNaN(v)) {
6011
+ msaScrollTo(v - 1);
6012
+ }
6013
+ jump.blur();
6014
+ }
6015
+ jump.addEventListener('keydown', function (event) {
6016
+ if (event.key === 'Enter') {
6017
+ event.preventDefault();
6018
+ jumpNow();
6019
+ } else if (event.key === 'Escape') {
6020
+ jump.value = String(_msaColOffset + 1);
6021
+ jump.blur();
5322
6022
  }
5323
6023
  });
5324
- document.body.appendChild(el);
6024
+ jump.addEventListener('change', jumpNow);
6025
+ let tail = document.createElement('span');
6026
+ tail.className = 'aptx-msa-nav-tail';
6027
+ read.appendChild(lbl);
6028
+ read.appendChild(jump);
6029
+ read.appendChild(tail);
6030
+ nav.appendChild(read);
6031
+
6032
+ document.body.appendChild(nav);
6033
+ _msaNav = nav;
5325
6034
  _msaScroller = el;
6035
+ _msaNav._jump = jump;
6036
+ _msaNav._tail = tail;
5326
6037
  }
5327
- _msaScroller.style.display = scrollable ? 'block' : 'none';
6038
+ _msaNav.style.display = scrollable ? 'flex' : 'none';
5328
6039
  if (scrollable) {
5329
6040
  _msaScroller.max = String(total - visible);
5330
6041
  _msaScroller.value = String(offset);
6042
+ if (document.activeElement !== _msaNav._jump) {
6043
+ _msaNav._jump.value = String(offset + 1);
6044
+ }
6045
+ _msaNav._jump.max = String(total);
6046
+ _msaNav._tail.textContent = '\u2013 ' + (offset + visible).toLocaleString()
6047
+ + ' of ' + total.toLocaleString();
6048
+ let atStart = offset <= 0;
6049
+ let atEnd = offset >= total - visible;
6050
+ let btns = _msaNav.querySelectorAll('.aptx-msa-nav-btn');
6051
+ btns[0].disabled = atStart;
6052
+ btns[1].disabled = atStart;
6053
+ btns[2].disabled = atEnd;
6054
+ btns[3].disabled = atEnd;
5331
6055
  }
5332
6056
  }
5333
6057
 
@@ -5399,7 +6123,7 @@ function (root, d3, forester, phyloXml) {
5399
6123
 
5400
6124
  function msaCbClicked() {
5401
6125
  _state.showMsa = getCheckboxValue(MSA_CB);
5402
- update(null, 0);
6126
+ scheduleUpdate(null, 0);
5403
6127
  }
5404
6128
 
5405
6129
  // Search B starts hidden to keep the panel compact; one click (or a
@@ -5446,7 +6170,10 @@ function (root, d3, forester, phyloXml) {
5446
6170
  // under the tree (left) -- horizontally disjoint, so the two reserves
5447
6171
  // share the same strip rather than stacking.
5448
6172
  function bottomOverlayReserve() {
5449
- return Math.max(msaShown() ? MSA_BOTTOM_RESERVE : 0, timeAxisBottomReserve());
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());
5450
6177
  }
5451
6178
 
5452
6179
  function hexToRgbTriple(hex) {
@@ -5460,6 +6187,10 @@ function (root, d3, forester, phyloXml) {
5460
6187
  return;
5461
6188
  }
5462
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
+ }
5463
6194
  _svgGroup.selectAll('g.aptx-timegrid').remove();
5464
6195
  if (!timeAxisShown() || !_root || !_yScale) {
5465
6196
  return;
@@ -5551,6 +6282,19 @@ function (root, d3, forester, phyloXml) {
5551
6282
  }
5552
6283
  });
5553
6284
 
6285
+ // ---- the axis itself: a FLOATING strip (see floatStripGroup) ----
6286
+ let ax = floatStripGroup('aptx-time-axis', axisTop - 4, timeAxisBottomReserve() + 4);
6287
+ function axisBackdrop(x0, x1) {
6288
+ // opaque, in the background colour, so tips panned under the
6289
+ // strip do not show through between the bands and the ruler
6290
+ let left = Math.min(x0, x1);
6291
+ ax.append('rect').attr('x', left - 2).attr('y', axisTop - 4)
6292
+ .attr('width', Math.abs(x1 - x0) + 4).attr('height', timeAxisBottomReserve() + 4)
6293
+ .attr('fill', _state.backgroundColorDefault);
6294
+ ax.append('line').attr('x1', left - 2).attr('x2', left - 2 + Math.abs(x1 - x0) + 4)
6295
+ .attr('y1', axisTop - 4).attr('y2', axisTop - 4)
6296
+ .attr('stroke', ink).attr('stroke-opacity', 0.25).attr('stroke-width', 1);
6297
+ }
5554
6298
  if (info.type === 'geologic') {
5555
6299
  let rootAge = info.rootAge;
5556
6300
  if (!(rootAge > 0)) {
@@ -5562,7 +6306,10 @@ function (root, d3, forester, phyloXml) {
5562
6306
  return anchorX + ((anchorAge - age) * corr);
5563
6307
  };
5564
6308
  let youngBound = Math.max(0, anchorAge - ((maxTipX - anchorX) / corr));
5565
- let ranks = forester.geoBandRanks(rootAge);
6309
+ // both bounds: a fossil-only clade spans [youngest tip, root],
6310
+ // and a narrow window bands Series over Stage
6311
+ axisBackdrop(xOfAge(rootAge), xOfAge(youngBound));
6312
+ let ranks = forester.geoBandRanks(youngBound, rootAge);
5566
6313
  if (grid) {
5567
6314
  // the FINE rank's old-side boundaries, root and tip edges
5568
6315
  // excluded (they would just retrace the tree's outline)
@@ -5585,12 +6332,12 @@ function (root, d3, forester, phyloXml) {
5585
6332
  if (w <= 0) {
5586
6333
  continue;
5587
6334
  }
5588
- g.append('rect').attr('x', left).attr('y', rowY)
6335
+ ax.append('rect').attr('x', left).attr('y', rowY)
5589
6336
  .attr('width', w).attr('height', TIME_BAND_ROW_H)
5590
6337
  .attr('fill', iv.color)
5591
6338
  .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 0.5);
5592
6339
  if ((iv.name.length * 5.5) + 4 <= w) {
5593
- g.append('text').attr('x', left + (w / 2)).attr('y', rowY + TIME_BAND_ROW_H - 3.5)
6340
+ ax.append('text').attr('x', left + (w / 2)).attr('y', rowY + TIME_BAND_ROW_H - 3.5)
5594
6341
  .attr('text-anchor', 'middle')
5595
6342
  .style('font-size', '9px')
5596
6343
  .style('fill', forester.msaLetterInk(hexToRgbTriple(iv.color)))
@@ -5600,7 +6347,7 @@ function (root, d3, forester, phyloXml) {
5600
6347
  }
5601
6348
  // the "Ma before present" ruler
5602
6349
  let rulerY = axisTop + (2 * TIME_BAND_ROW_H) + 4;
5603
- g.append('line').attr('x1', xOfAge(rootAge)).attr('x2', xOfAge(youngBound))
6350
+ ax.append('line').attr('x1', xOfAge(rootAge)).attr('x2', xOfAge(youngBound))
5604
6351
  .attr('y1', rulerY).attr('y2', rulerY)
5605
6352
  .attr('stroke', ink).attr('stroke-width', 1);
5606
6353
  let labels = [];
@@ -5620,7 +6367,7 @@ function (root, d3, forester, phyloXml) {
5620
6367
  let placed = [];
5621
6368
  labels.forEach(function (l) {
5622
6369
  let x = xOfAge(l.age);
5623
- g.append('line').attr('x1', x).attr('x2', x)
6370
+ ax.append('line').attr('x1', x).attr('x2', x)
5624
6371
  .attr('y1', rulerY).attr('y2', rulerY + 4)
5625
6372
  .attr('stroke', ink).attr('stroke-width', 1);
5626
6373
  let text = String(l.age);
@@ -5633,14 +6380,14 @@ function (root, d3, forester, phyloXml) {
5633
6380
  }
5634
6381
  }
5635
6382
  if (ok || l.priority > 0) {
5636
- g.append('text').attr('x', x).attr('y', rulerY + 14)
6383
+ ax.append('text').attr('x', x).attr('y', rulerY + 14)
5637
6384
  .attr('text-anchor', 'middle')
5638
6385
  .style('font-size', '9px').style('fill', ink)
5639
6386
  .text(text);
5640
6387
  placed.push(x);
5641
6388
  }
5642
6389
  });
5643
- g.append('text').attr('x', xOfAge(youngBound) + 8).attr('y', rulerY + 14)
6390
+ ax.append('text').attr('x', xOfAge(youngBound) + 8).attr('y', rulerY + 14)
5644
6391
  .attr('text-anchor', 'start')
5645
6392
  .style('font-size', '9px').style('fill', ink)
5646
6393
  .text('Ma');
@@ -5666,20 +6413,21 @@ function (root, d3, forester, phyloXml) {
5666
6413
  }
5667
6414
  });
5668
6415
  }
6416
+ axisBackdrop(xOfYear(rootYear), xOfYear(present));
5669
6417
  let rulerY = axisTop + 4;
5670
- g.append('line').attr('x1', xOfYear(rootYear)).attr('x2', xOfYear(present))
6418
+ ax.append('line').attr('x1', xOfYear(rootYear)).attr('x2', xOfYear(present))
5671
6419
  .attr('y1', rulerY).attr('y2', rulerY)
5672
6420
  .attr('stroke', ink).attr('stroke-width', 1);
5673
6421
  let lastRight = -Infinity;
5674
6422
  forester.calendarTickYears(rootYear, present).forEach(function (yv) {
5675
6423
  let x = xOfYear(yv);
5676
- g.append('line').attr('x1', x).attr('x2', x)
6424
+ ax.append('line').attr('x1', x).attr('x2', x)
5677
6425
  .attr('y1', rulerY).attr('y2', rulerY + 4)
5678
6426
  .attr('stroke', ink).attr('stroke-width', 1);
5679
6427
  let text = String(Math.round(yv));
5680
6428
  let half = text.length * 2.8;
5681
6429
  if ((x - half) >= lastRight + 4) {
5682
- g.append('text').attr('x', x).attr('y', rulerY + 14)
6430
+ ax.append('text').attr('x', x).attr('y', rulerY + 14)
5683
6431
  .attr('text-anchor', 'middle')
5684
6432
  .style('font-size', '9px').style('fill', ink)
5685
6433
  .text(text);
@@ -5687,16 +6435,17 @@ function (root, d3, forester, phyloXml) {
5687
6435
  }
5688
6436
  });
5689
6437
  }
6438
+ placeFloatingOverlays();
5690
6439
  }
5691
6440
 
5692
6441
  function timeAxisCbClicked() {
5693
6442
  _state.showTimeAxis = getCheckboxValue(TIME_AXIS_CB);
5694
- update(null, 0);
6443
+ scheduleUpdate(null, 0);
5695
6444
  }
5696
6445
 
5697
6446
  function timeGridCbClicked() {
5698
6447
  _state.timeAxisGrid = getCheckboxValue(TIME_GRID_CB);
5699
- update(null, 0);
6448
+ scheduleUpdate(null, 0);
5700
6449
  }
5701
6450
 
5702
6451
  // Fit the circular tree into the viewport, centred. The root is at the
@@ -5910,7 +6659,9 @@ function (root, d3, forester, phyloXml) {
5910
6659
  setValue(SEARCH_FIELD_1, '');
5911
6660
  }
5912
6661
 
5913
- update(null, 0);
6662
+ // the searches each end in a redraw of their own; on a big tree all
6663
+ // three collapse into one
6664
+ scheduleUpdate(null, 0);
5914
6665
  search0();
5915
6666
  search1();
5916
6667
 
@@ -5957,7 +6708,7 @@ function (root, d3, forester, phyloXml) {
5957
6708
  if (aActive) _foundNodes0 = forester.searchWithSpec(_root, specA);
5958
6709
  if (bActive) _foundNodes1 = forester.searchWithSpec(_root, specB);
5959
6710
  }
5960
- update(null, 0, true);
6711
+ scheduleUpdate(null, 0, true);
5961
6712
  }
5962
6713
 
5963
6714
  // The Combine control only makes sense when both boxes hold a query.
@@ -6127,21 +6878,21 @@ function (root, d3, forester, phyloXml) {
6127
6878
  _state.phylogram = true;
6128
6879
  _state.alignPhylogram = false;
6129
6880
  setDisplayTypeButtons();
6130
- update(null, 0);
6881
+ scheduleUpdate(null, 0);
6131
6882
  }
6132
6883
 
6133
6884
  function toAlignedPhylogram() {
6134
6885
  _state.phylogram = true;
6135
6886
  _state.alignPhylogram = true;
6136
6887
  setDisplayTypeButtons();
6137
- update(null, 0);
6888
+ scheduleUpdate(null, 0);
6138
6889
  }
6139
6890
 
6140
6891
  function toCladegram() {
6141
6892
  _state.phylogram = false;
6142
6893
  _state.alignPhylogram = false;
6143
6894
  setDisplayTypeButtons();
6144
- update(null, 0);
6895
+ scheduleUpdate(null, 0);
6145
6896
  }
6146
6897
 
6147
6898
  function layoutButtonClicked() {
@@ -6248,7 +6999,7 @@ function (root, d3, forester, phyloXml) {
6248
6999
  }
6249
7000
  search0();
6250
7001
  search1();
6251
- update();
7002
+ scheduleUpdate();
6252
7003
  }
6253
7004
 
6254
7005
 
@@ -6276,7 +7027,7 @@ function (root, d3, forester, phyloXml) {
6276
7027
  }
6277
7028
  search0();
6278
7029
  search1();
6279
- update();
7030
+ scheduleUpdate();
6280
7031
  }
6281
7032
 
6282
7033
  function sequenceCbClicked() {
@@ -6287,65 +7038,64 @@ function (root, d3, forester, phyloXml) {
6287
7038
  }
6288
7039
  search0();
6289
7040
  search1();
6290
- update();
7041
+ scheduleUpdate();
6291
7042
  }
6292
7043
 
6293
7044
  function confidenceValuesCbClicked() {
6294
7045
  _state.showConfidenceValues = getCheckboxValue(CONFIDENCE_VALUES_CB);
6295
7046
  search0();
6296
7047
  search1();
6297
- update();
7048
+ scheduleUpdate();
6298
7049
  }
6299
7050
 
6300
7051
  function supportDotsCbClicked() {
6301
7052
  _state.showSupportDots = getCheckboxValue(SUPPORT_DOTS_CB);
6302
- update();
7053
+ scheduleUpdate();
6303
7054
  }
6304
7055
 
6305
7056
  function branchLengthsCbClicked() {
6306
7057
  _state.showBranchLengthValues = getCheckboxValue(BRANCH_LENGTH_VALUES_CB);
6307
- update();
7058
+ scheduleUpdate();
6308
7059
  }
6309
7060
 
6310
7061
  function nodeEventsCbClicked() {
6311
7062
  _state.showNodeEvents = getCheckboxValue(NODE_EVENTS_CB);
6312
7063
  search0();
6313
7064
  search1();
6314
- update();
7065
+ scheduleUpdate();
6315
7066
  }
6316
7067
 
6317
7068
  function branchEventsCbClicked() {
6318
7069
  _state.showBranchEvents = getCheckboxValue(BRANCH_EVENTS_CB);
6319
7070
  search0();
6320
7071
  search1();
6321
- update();
7072
+ scheduleUpdate();
6322
7073
  }
6323
7074
 
6324
7075
  function internalLabelsCbClicked() {
6325
7076
  _state.showInternalLabels = getCheckboxValue(INTERNAL_LABEL_CB);
6326
7077
  search0();
6327
7078
  search1();
6328
- update();
7079
+ scheduleUpdate();
6329
7080
  }
6330
7081
 
6331
7082
  function externalLabelsCbClicked() {
6332
7083
  _state.showExternalLabels = getCheckboxValue(EXTERNAL_LABEL_CB);
6333
7084
  search0();
6334
7085
  search1();
6335
- update();
7086
+ scheduleUpdate();
6336
7087
  }
6337
7088
 
6338
7089
  function visCbClicked() {
6339
7090
  _state.showVisualizations = getCheckboxValue(VIS_CB);
6340
7091
  resetVis();
6341
- update(null, 0);
6342
- update(null, 0);
7092
+ scheduleUpdate(null, 0);
6343
7093
  }
6344
7094
 
6345
7095
  function visualStylesCbClicked() {
6346
7096
  _state.useVisualStyles = getCheckboxValue(VISUAL_STYLES_CB);
6347
7097
  resetVis();
6348
- update(null, 0);
7098
+ scheduleUpdate(null, 0);
6349
7099
  }
6350
7100
 
6351
7101
  function dynaHideCbClicked() {
@@ -6353,8 +7103,7 @@ function (root, d3, forester, phyloXml) {
6353
7103
  resetVis();
6354
7104
  search0();
6355
7105
  search1();
6356
- update(null, 0);
6357
- update(null, 0);
7106
+ scheduleUpdate(null, 0);
6358
7107
  }
6359
7108
 
6360
7109
  function shortenCbClicked() {
@@ -6362,7 +7111,7 @@ function (root, d3, forester, phyloXml) {
6362
7111
  resetVis();
6363
7112
  search0();
6364
7113
  search1();
6365
- update(null, 0);
7114
+ scheduleUpdate(null, 0);
6366
7115
  }
6367
7116
 
6368
7117
  function downloadButtonPressed() {
@@ -6373,11 +7122,6 @@ function (root, d3, forester, phyloXml) {
6373
7122
  }
6374
7123
  }
6375
7124
 
6376
- function submitSelectedPressed() {
6377
- const event = new Event('submit_selected_nodes_event');
6378
- document.dispatchEvent(event);
6379
- }
6380
-
6381
7125
  function changeBaseBackgoundColor(color) {
6382
7126
  setStylesAll('.' + BASE_BACKGROUND, {
6383
7127
  'fill': color
@@ -6386,12 +7130,12 @@ function (root, d3, forester, phyloXml) {
6386
7130
 
6387
7131
  function changeBranchWidth(e) {
6388
7132
  _state.branchWidthDefault = getSliderValue(e);
6389
- update(null, 0, true);
7133
+ scheduleUpdate(null, 0, true);
6390
7134
  }
6391
7135
 
6392
7136
  function changeNodeSize(e) {
6393
7137
  _state.nodeSizeDefault = getSliderValue(e);
6394
- update(null, 0, true);
7138
+ scheduleUpdate(null, 0, true);
6395
7139
  }
6396
7140
 
6397
7141
 
@@ -6399,7 +7143,7 @@ function (root, d3, forester, phyloXml) {
6399
7143
  // data alike, as the desktop does it.
6400
7144
  function changeFontSize(e) {
6401
7145
  setFontSizes(getSliderValue(e));
6402
- update(null, 0, true);
7146
+ scheduleUpdate(null, 0, true);
6403
7147
  }
6404
7148
 
6405
7149
  function clampFontSize(v) {
@@ -6568,6 +7312,7 @@ function (root, d3, forester, phyloXml) {
6568
7312
  _state.backgroundColorDefault = dark ? BACKGROUND_COLOR_DARK : BACKGROUND_COLOR_DEFAULT;
6569
7313
  _state.branchColorDefault = dark ? BRANCH_COLOR_DARK : BRANCH_COLOR_DEFAULT;
6570
7314
  _state.labelColorDefault = dark ? LABEL_COLOR_DARK : LABEL_COLOR_DEFAULT;
7315
+ _state.found0ColorDefault = dark ? FOUND0_COLOR_DARK : FOUND0_COLOR_DEFAULT;
6571
7316
  // Always white, in both themes: getTreeAsSvg() rewrites the dark label
6572
7317
  // and branch colours to their light counterparts on the way out, so an
6573
7318
  // export can no longer end up as white text on a white ground.
@@ -6577,7 +7322,7 @@ function (root, d3, forester, phyloXml) {
6577
7322
  }
6578
7323
  changeBaseBackgoundColor(_state.backgroundColorDefault);
6579
7324
  applyOverviewTheme();
6580
- update(null, 0);
7325
+ scheduleUpdate(null, 0);
6581
7326
  }
6582
7327
 
6583
7328
  // Apply the current theme choice to every panel and refresh the switch icons.
@@ -6988,13 +7733,30 @@ function (root, d3, forester, phyloXml) {
6988
7733
  + ' background:#e5484d; color:#fff; }'
6989
7734
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
6990
7735
  // The node-data dialog, on the same palette as the panel and the menu.
6991
- + '.aptx-dialog {'
7736
+ + '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
6992
7737
  + ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
6993
7738
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
6994
7739
  + ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
6995
7740
  + '}'
6996
- + '@media (prefers-color-scheme:dark){ .aptx-dialog:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
6997
- + '.aptx-dialog.aptx-dark {' + dark + '}'
7741
+ + '@media (prefers-color-scheme:dark){ .aptx-dialog:not(.aptx-light):not(.aptx-dark),'
7742
+ + ' .aptx-busy:not(.aptx-light):not(.aptx-dark), .aptx-msa-nav:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
7743
+ + '.aptx-dialog.aptx-dark, .aptx-busy.aptx-dark, .aptx-msa-nav.aptx-dark {' + dark + '}'
7744
+ // The "working" card shown over the tree area while a big tree is
7745
+ // drawn. It covers the whole container so nothing underneath --
7746
+ // panel included -- can be clicked into a half-built view.
7747
+ + '.aptx-busy { position:absolute; inset:0; z-index:1000; display:grid; place-items:center;'
7748
+ + ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
7749
+ + ' font-size:12px; color:var(--p-ink); cursor:progress; }'
7750
+ + '.aptx-busy-card { display:flex; align-items:center; gap:10px; padding:10px 16px 10px 13px;'
7751
+ + ' border:1px solid var(--p-line-strong); border-radius:12px; background:var(--p-bg);'
7752
+ + ' box-shadow:0 24px 48px -16px rgba(23,34,46,0.45),0 4px 12px -4px rgba(23,34,46,0.22); }'
7753
+ + '.aptx-busy-dot { width:10px; height:10px; border-radius:50%; flex:none;'
7754
+ + ' border:2px solid var(--p-accent-weak); border-top-color:var(--p-accent);'
7755
+ + ' animation:aptx-busy-spin 0.8s linear infinite; }'
7756
+ + '@keyframes aptx-busy-spin { to { transform:rotate(360deg); } }'
7757
+ + '@media (prefers-reduced-motion:reduce){ .aptx-busy-dot { animation:none;'
7758
+ + ' border-color:var(--p-accent); } }'
7759
+ + '.aptx-busy-sub { color:var(--p-muted); }'
6998
7760
  + '.aptx-dialog { padding:0; border:1px solid var(--p-line-strong); border-radius:12px;'
6999
7761
  + ' background:var(--p-bg); color:var(--p-ink); max-width:92vw;'
7000
7762
  + ' box-shadow:0 24px 48px -16px rgba(23,34,46,0.45),0 4px 12px -4px rgba(23,34,46,0.22);'
@@ -7010,6 +7772,28 @@ function (root, d3, forester, phyloXml) {
7010
7772
  + '.aptx-dialog-close:hover { background:var(--p-accent-weak); color:var(--p-accent-ink);'
7011
7773
  + ' border-color:var(--p-accent); }'
7012
7774
  + '.aptx-dialog-body { padding:10px 13px 12px; overflow:auto; }'
7775
+ + '.aptx-cp-sv { position:relative; width:100%; box-sizing:border-box; height:104px; border-radius:7px;'
7776
+ + ' border:1px solid var(--p-line-strong); cursor:crosshair; touch-action:none; }'
7777
+ + '.aptx-cp-hue { position:relative; width:100%; box-sizing:border-box; height:13px; margin-top:9px;'
7778
+ + ' border-radius:7px; border:1px solid var(--p-line-strong); cursor:ew-resize;'
7779
+ + ' touch-action:none; background:linear-gradient(to right,#f00 0%,#ff0 17%,#0f0 33%,'
7780
+ + ' #0ff 50%,#00f 67%,#f0f 83%,#f00 100%); }'
7781
+ + '.aptx-cp-knob { position:absolute; width:12px; height:12px; margin:-7px 0 0 -7px;'
7782
+ + ' border-radius:50%; border:2px solid #fff; pointer-events:none;'
7783
+ + ' box-shadow:0 0 0 1px rgba(0,0,0,0.45),0 1px 3px rgba(0,0,0,0.4); }'
7784
+ + '.aptx-cp-row { display:flex; align-items:center; gap:7px; margin-top:9px; }'
7785
+ + '.aptx-cp-prev { flex:none; width:26px; height:20px; border-radius:5px;'
7786
+ + ' border:1px solid var(--p-line-strong); }'
7787
+ + '.aptx-cp-hex { flex:1; min-width:0; padding:3px 6px; border-radius:5px;'
7788
+ + ' border:1px solid var(--p-line-strong); background:var(--p-surface2);'
7789
+ + ' color:var(--p-ink); font:inherit; font-variant-numeric:tabular-nums; }'
7790
+ + '.aptx-cp-hex:focus { outline:none; border-color:var(--p-accent); }'
7791
+ + '.aptx-cp-grid { display:grid; grid-template-columns:repeat(10,1fr); gap:3px; margin-top:9px; }'
7792
+ + '.aptx-cp-sw { width:100%; aspect-ratio:1; padding:0; border-radius:4px; cursor:pointer;'
7793
+ + ' border:1px solid rgba(128,128,128,0.45); }'
7794
+ + '.aptx-cp-sw:hover { outline:2px solid var(--p-accent); outline-offset:1px; }'
7795
+ + '.aptx-cp-note { margin-top:8px; color:var(--p-faint); }'
7796
+ + '#color_picker_dialog .aptx-dialog-body { overflow:visible; }'
7013
7797
  + '.aptx-dialog-head { margin:10px 0 3px; font-size:9px; font-weight:700; letter-spacing:0.07em;'
7014
7798
  + ' text-transform:uppercase; color:var(--p-faint); }'
7015
7799
  + '.aptx-dialog-body > .aptx-dialog-head:first-child { margin-top:0; }'
@@ -7120,7 +7904,21 @@ function (root, d3, forester, phyloXml) {
7120
7904
  // drawn glyph. The glyph inherits the button's colour (currentColor)
7121
7905
  // and a disabled button fades the whole svg with the button chrome.
7122
7906
  + '.aptx-panel .aptx-gbtn { display:inline-flex; align-items:center; justify-content:center; min-width:32px; padding:0 7px; vertical-align:middle; }'
7123
- + '.aptx-msa-scroll { position:fixed; bottom:10px; left:55%; transform:translateX(-50%); width:260px; display:none; z-index:20; }'
7907
+ + '.aptx-msa-nav { position:fixed; bottom:10px; left:55%; transform:translateX(-50%); z-index:20;'
7908
+ + ' display:none; align-items:center; gap:6px; padding:6px 10px; border-radius:10px;'
7909
+ + ' border:1px solid var(--p-line-strong); background:var(--p-bg); color:var(--p-ink);'
7910
+ + ' box-shadow:0 8px 24px -10px rgba(23,34,46,0.35); font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
7911
+ + ' font-size:11.5px; line-height:1; }'
7912
+ + '.aptx-msa-scroll { width:260px; margin:0 2px; }'
7913
+ + '.aptx-msa-nav-btn { width:24px; height:22px; padding:0; border-radius:6px; border:1px solid var(--p-line-strong);'
7914
+ + ' background:var(--p-surface2); color:var(--p-ink); font-size:10px; line-height:1; cursor:pointer; }'
7915
+ + '.aptx-msa-nav-btn:hover:not(:disabled) { border-color:var(--p-accent); color:var(--p-accent-ink); }'
7916
+ + '.aptx-msa-nav-btn:disabled { opacity:0.35; cursor:default; }'
7917
+ + '.aptx-msa-nav-read { display:inline-flex; align-items:center; gap:5px; margin-left:6px; color:var(--p-muted);'
7918
+ + ' font-variant-numeric:tabular-nums; white-space:nowrap; }'
7919
+ + '.aptx-msa-nav-jump { width:64px; padding:2px 5px; border-radius:5px; border:1px solid var(--p-line-strong);'
7920
+ + ' background:var(--p-surface2); color:var(--p-ink); font:inherit; font-variant-numeric:tabular-nums; text-align:right; }'
7921
+ + '.aptx-msa-nav-jump:focus { outline:none; border-color:var(--p-accent); }'
7124
7922
  + '.aptx-panel .aptx-seg:has(input:disabled) { opacity:0.4; }'
7125
7923
  + '.aptx-panel .aptx-seg:has(input:disabled) { cursor:default; }'
7126
7924
  + '.aptx-panel .aptx-slider-row { display:flex; align-items:center; gap:7px; margin:3px 0; }'
@@ -7368,6 +8166,161 @@ function (root, d3, forester, phyloXml) {
7368
8166
  // Content arrives as "Label: value" lines separated by <br>. Setting the
7369
8167
  // label part apart makes a wall of such lines scannable. Lines without a
7370
8168
  // label (a heading like "Taxonomy", or a FASTA sequence) are left alone.
8169
+ // The node's data as the hover tooltip and the "Display Node Data" dialog
8170
+ // both show it -- ONE builder, because they used to be two near-copies
8171
+ // and carried the same two bugs twice (a bare "Date: " line whenever a
8172
+ // date had no description, and "Distribution: " printed before its
8173
+ // description was checked).
8174
+ //
8175
+ // Order, as the desktop lays it out: first the node itself -- Name,
8176
+ // Distance to parent, Date, Distribution, Depth, Tips below -- then its
8177
+ // confidences, then the headed sections. Every row is either in that
8178
+ // leading block or under a heading, so nothing reads as a stray tail of
8179
+ // the section above it. markUpDataLabels() renders the result: a line
8180
+ // shaped "Key: value" is a row, anything else a heading, and a leading
8181
+ // "- " marks a row as a section's sub-entry.
8182
+ function nodeDataText(d) {
8183
+ let text = '';
8184
+ if (d.name) {
8185
+ text += 'Name: ' + d.name + '<br>';
8186
+ }
8187
+ if (d.branch_length) {
8188
+ text += 'Distance to parent: ' + d.branch_length + '<br>';
8189
+ }
8190
+ let date = dateText(d.date);
8191
+ if (date) {
8192
+ text += 'Date: ' + date + '<br>';
8193
+ }
8194
+ if (d.distributions) {
8195
+ for (let i = 0; i < d.distributions.length; ++i) {
8196
+ // only a distribution that has something to say gets a row
8197
+ if (d.distributions[i].desc) {
8198
+ text += 'Distribution: ' + d.distributions[i].desc + '<br>';
8199
+ }
8200
+ }
8201
+ }
8202
+ text += 'Depth: ' + forester.calcDepth(d) + '<br>';
8203
+ if (d.children) {
8204
+ text += 'Tips below: ' + forester.calcSumOfAllExternalDescendants(d) + '<br>';
8205
+ }
8206
+ if (d.confidences) {
8207
+ for (let i = 0; i < d.confidences.length; ++i) {
8208
+ let c = d.confidences[i];
8209
+ text += (c.type ? 'Confidence [' + c.type + ']: ' : 'Confidence: ') + c.value + '<br>';
8210
+ if (c.stddev) {
8211
+ text += '- stdev: ' + c.stddev + '<br>';
8212
+ }
8213
+ }
8214
+ }
8215
+ if (d.taxonomies) {
8216
+ for (let i = 0; i < d.taxonomies.length; ++i) {
8217
+ let tx = d.taxonomies[i];
8218
+ text += 'Taxonomy<br>';
8219
+ if (tx.id) {
8220
+ text += (tx.id.provider ? '- Id [' + tx.id.provider + ']: ' : '- Id: ') + tx.id.value + '<br>';
8221
+ }
8222
+ if (tx.code) {
8223
+ text += '- Code: ' + tx.code + '<br>';
8224
+ }
8225
+ if (tx.scientific_name) {
8226
+ text += '- Scientific name: ' + tx.scientific_name + '<br>';
8227
+ }
8228
+ if (tx.common_name) {
8229
+ text += '- Common name: ' + tx.common_name + '<br>';
8230
+ }
8231
+ if (tx.rank) {
8232
+ text += '- Rank: ' + tx.rank + '<br>';
8233
+ }
8234
+ }
8235
+ }
8236
+ if (d.sequences) {
8237
+ for (let i = 0; i < d.sequences.length; ++i) {
8238
+ let s = d.sequences[i];
8239
+ text += 'Sequence<br>';
8240
+ if (s.accession) {
8241
+ text += (s.accession.source ? '- Accession [' + s.accession.source + ']: ' : '- Accession: ')
8242
+ + s.accession.value + '<br>';
8243
+ if (s.accession.comment) {
8244
+ text += '-- comment: ' + s.accession.comment + '<br>';
8245
+ }
8246
+ }
8247
+ if (s.symbol) {
8248
+ text += '- Symbol: ' + s.symbol + '<br>';
8249
+ }
8250
+ if (s.name) {
8251
+ text += '- Name: ' + s.name + '<br>';
8252
+ }
8253
+ if (s.gene_name) {
8254
+ text += '- Gene name: ' + s.gene_name + '<br>';
8255
+ }
8256
+ if (s.location) {
8257
+ text += '- Location: ' + s.location + '<br>';
8258
+ }
8259
+ if (s.type) {
8260
+ text += '- Type: ' + s.type + '<br>';
8261
+ }
8262
+ }
8263
+ }
8264
+ if (d.events) {
8265
+ let ev = d.events;
8266
+ text += 'Events<br>';
8267
+ if (ev.type && ev.type.length > 0) {
8268
+ text += '- Type: ' + ev.type + '<br>';
8269
+ }
8270
+ if (ev.duplications && ev.duplications > 0) {
8271
+ text += '- Duplications: ' + ev.duplications + '<br>';
8272
+ }
8273
+ if (ev.speciations && ev.speciations > 0) {
8274
+ text += '- Speciations: ' + ev.speciations + '<br>';
8275
+ }
8276
+ if (ev.losses && ev.losses > 0) {
8277
+ text += '- Losses: ' + ev.losses + '<br>';
8278
+ }
8279
+ }
8280
+ if (d.properties && d.properties.length > 0) {
8281
+ // Named as the Color-by menu names them ("Host Group", not
8282
+ // "BVBRC:host_group"). style:* refs are the desktop's visual-style
8283
+ // namespace -- how a node is drawn, not what it is -- and are
8284
+ // skipped, as the Color-by menu skips them.
8285
+ let rows = '';
8286
+ for (let i = 0; i < d.properties.length; ++i) {
8287
+ let pr = d.properties[i];
8288
+ if (pr.ref && pr.value && pr.ref.indexOf('style:') !== 0) {
8289
+ rows += '- ' + forester.propertyDisplayName(pr.ref) + ': '
8290
+ + pr.value + (pr.unit ? ' ' + pr.unit : '') + '<br>';
8291
+ }
8292
+ }
8293
+ if (rows) {
8294
+ text += 'Properties<br>' + rows;
8295
+ }
8296
+ }
8297
+ return text;
8298
+ }
8299
+
8300
+ // One line for a node's date, as the desktop writes it: the value, its
8301
+ // range, its unit and its description folded together -- "6.5 [5 - 8]
8302
+ // mya (split)" -- or just the description when that is all there is.
8303
+ // Null when there is nothing to say, so no bare "Date: " ever appears.
8304
+ function dateText(date) {
8305
+ if (!date) {
8306
+ return null;
8307
+ }
8308
+ let hasValue = typeof date.value === 'number';
8309
+ let hasRange = typeof date.minimum === 'number' && typeof date.maximum === 'number';
8310
+ let s = hasValue ? String(date.value) : '';
8311
+ if (hasRange) {
8312
+ s += ' [' + date.minimum + ' - ' + date.maximum + ']';
8313
+ }
8314
+ if ((hasValue || hasRange) && date.unit) {
8315
+ s += ' ' + date.unit;
8316
+ }
8317
+ s = s.trim();
8318
+ if (date.desc) {
8319
+ s = s ? s + ' (' + date.desc + ')' : date.desc;
8320
+ }
8321
+ return s || null;
8322
+ }
8323
+
7371
8324
  function markUpDataLabels(escaped) {
7372
8325
  return escaped.split('<br>').map(function (line) {
7373
8326
  if (!line.trim()) {
@@ -7389,6 +8342,68 @@ function (root, d3, forester, phyloXml) {
7389
8342
  // The shell every modal shares: title bar with a close button, a body, the
7390
8343
  // panel's light/dark choice, and removal on close. Returns the body for the
7391
8344
  // caller to fill. Only one dialog of a given id exists at a time.
8345
+ // A "working" card over the tree area. Shown while a big tree's first draw
8346
+ // is deferred, so the user sees the program is busy rather than a page that
8347
+ // has stopped responding -- which is what a 10-second synchronous draw
8348
+ // looks like from the outside.
8349
+ function showBusy(container, text, sub) {
8350
+ container.querySelectorAll(':scope > .aptx-busy').forEach(function (b) {
8351
+ b.remove();
8352
+ });
8353
+ let busy = document.createElement('div');
8354
+ busy.className = 'aptx-busy';
8355
+ if (_panelTheme) {
8356
+ busy.classList.add('aptx-' + _panelTheme);
8357
+ }
8358
+ busy.setAttribute('role', 'status');
8359
+ busy.setAttribute('aria-live', 'polite');
8360
+ let card = document.createElement('div');
8361
+ card.className = 'aptx-busy-card';
8362
+ let dot = document.createElement('span');
8363
+ dot.className = 'aptx-busy-dot';
8364
+ let label = document.createElement('span');
8365
+ label.textContent = text;
8366
+ card.appendChild(dot);
8367
+ card.appendChild(label);
8368
+ if (sub) {
8369
+ let s = document.createElement('span');
8370
+ s.className = 'aptx-busy-sub';
8371
+ s.textContent = sub;
8372
+ card.appendChild(s);
8373
+ }
8374
+ busy.appendChild(card);
8375
+ container.appendChild(busy);
8376
+ return busy;
8377
+ }
8378
+
8379
+ function hideBusy() {
8380
+ if (_container) {
8381
+ _container.querySelectorAll('.aptx-busy').forEach(function (b) {
8382
+ b.remove();
8383
+ });
8384
+ }
8385
+ }
8386
+
8387
+ // Run fn after the browser has had a chance to PAINT. A plain setTimeout
8388
+ // does not promise that; rAF fires just before a paint, so a timeout queued
8389
+ // from inside it lands just after one. A hidden tab may never fire rAF at
8390
+ // all, so a fallback timer guarantees the work still happens.
8391
+ function afterPaint(fn) {
8392
+ let done = false;
8393
+ function go() {
8394
+ if (!done) {
8395
+ done = true;
8396
+ fn();
8397
+ }
8398
+ }
8399
+ if (typeof requestAnimationFrame === 'function') {
8400
+ requestAnimationFrame(function () {
8401
+ setTimeout(go, 0);
8402
+ });
8403
+ }
8404
+ setTimeout(go, 150);
8405
+ }
8406
+
7392
8407
  function makeDialogShell(id, title, width) {
7393
8408
  let existing = document.getElementById(id);
7394
8409
  if (existing) {
@@ -7568,7 +8583,7 @@ function (root, d3, forester, phyloXml) {
7568
8583
  }
7569
8584
  setFontSizes(base);
7570
8585
  setSliderValue(FONT_SIZE_SLIDER, _state.externalNodeFontSize);
7571
- update(null, 0, true);
8586
+ scheduleUpdate(null, 0, true);
7572
8587
  }
7573
8588
 
7574
8589
 
@@ -7660,10 +8675,6 @@ function (root, d3, forester, phyloXml) {
7660
8675
 
7661
8676
  c0.insertAdjacentHTML('beforeend',makeSearchBoxes());
7662
8677
 
7663
- if (_settings.enableManualNodeSelection) {
7664
- //c0.append(makeSubmitSection()); //~~~
7665
- }
7666
-
7667
8678
  if (_settings.enableDownloads) {
7668
8679
  c0.insertAdjacentHTML('beforeend',makeDownloadSection());
7669
8680
  }
@@ -7706,14 +8717,6 @@ function (root, d3, forester, phyloXml) {
7706
8717
  });
7707
8718
  }
7708
8719
 
7709
- const submitSelectedButton = byId(SUBMIT_SELECTED_NODES_BUTTON);
7710
-
7711
- if (submitSelectedButton) {
7712
- setStyles(submitSelectedButton, {
7713
- 'width': '80px', 'margin-bottom': '3px'
7714
- });
7715
- }
7716
-
7717
8720
 
7718
8721
  on(SEARCH_FIELD_0, 'keyup', search0);
7719
8722
 
@@ -7904,10 +8907,6 @@ function (root, d3, forester, phyloXml) {
7904
8907
  downloadButton.addEventListener('click', downloadButtonPressed);
7905
8908
  }
7906
8909
 
7907
- if (submitSelectedButton) {
7908
- submitSelectedButton.addEventListener('click', submitSelectedPressed);
7909
- }
7910
-
7911
8910
  setStyles(byId(EXPORT_FORMAT_SELECT), {
7912
8911
  'font': 'inherit', 'color': 'inherit'
7913
8912
  });
@@ -8320,16 +9319,6 @@ function (root, d3, forester, phyloXml) {
8320
9319
  return h;
8321
9320
  }
8322
9321
 
8323
- function makeSubmitSection() {
8324
- let h = "";
8325
- h = h.concat('<fieldset>');
8326
- h = h.concat('<div class="submit_nodes">');
8327
- h = h.concat('<input type="button" value="Submit Selected" name="' + SUBMIT_SELECTED_NODES_BUTTON + '" title="to submit the selected nodes" id="' + SUBMIT_SELECTED_NODES_BUTTON + '">');
8328
- h = h.concat('</div>');
8329
- h = h.concat('</fieldset>');
8330
- return h;
8331
- }
8332
-
8333
9322
  function makeSliders() {
8334
9323
  let h = "";
8335
9324
  h = h.concat('<fieldset>');
@@ -8627,7 +9616,9 @@ function (root, d3, forester, phyloXml) {
8627
9616
  b.style.color = '';
8628
9617
  } else {
8629
9618
  b.style.background = _state.found0ColorDefault;
8630
- b.style.color = WHITE;
9619
+ // black or white by luminance: the dark theme's sky blue
9620
+ // is too light for white text
9621
+ b.style.color = forester.msaLetterInk(hexToRgbTriple(_state.found0ColorDefault));
8631
9622
  }
8632
9623
  let nd0 = _foundNodes0.size === 1 ? 'node' : 'nodes';
8633
9624
  b.title = 'found ' + _foundNodes0.size + ' ' + nd0 + ' [click to ' + RESET_SEARCH_A_BTN_TOOLTIP + ']';
@@ -8759,6 +9750,15 @@ function (root, d3, forester, phyloXml) {
8759
9750
  if (glow) {
8760
9751
  glow.remove();
8761
9752
  }
9753
+ // The floating strips re-anchor to the TREE for export: on screen
9754
+ // a strip may be stuck at the viewport bottom, but a figure must
9755
+ // not carry an artefact of where the user happened to be scrolled
9756
+ // -- the axis belongs at the bottom of the tree. (As the desktop
9757
+ // does for PDF and graphics export.)
9758
+ let t = d3.zoomTransform(svg);
9759
+ copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
9760
+ strip.setAttribute('transform', 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')');
9761
+ });
8762
9762
  svgTree = toLightExport((new XMLSerializer()).serializeToString(copy));
8763
9763
  } else if (typeof svg.xml !== 'undefined') {
8764
9764
  svgTree = svg.xml;
@@ -8968,18 +9968,31 @@ function (root, d3, forester, phyloXml) {
8968
9968
  /**
8969
9969
  * Convenience method for loading tree on HTML page
8970
9970
  *
8971
- * @param location
8972
- * @param data
8973
- * @param newHamphshireConfidenceValuesInBrackets
8974
- * @param newHamphshireConfidenceValuesAsInternalNames
9971
+ * @param location - file name (only its extension is used; the content is
9972
+ * sniffed too, so a pasted tree with no name works)
9973
+ * @param data - the file's content
9974
+ * @param mode - 'auto' (default) | 'confidence' | 'label', how bare
9975
+ * numeric internal labels are read. The retired
9976
+ * nhConfidenceValuesInBrackets flag used to sit in this
9977
+ * position and is still accepted (and ignored) there,
9978
+ * with the mode following it.
9979
+ * @param legacyMode - the mode, when the retired flag occupies `mode`
8975
9980
  * @returns {*}
8976
9981
  */
8977
- archaeopteryx.parseTree = function (location, data, newHamphshireConfidenceValuesInBrackets, newHamphshireConfidenceValuesAsInternalNames) {
8978
- if (newHamphshireConfidenceValuesInBrackets === undefined) {
8979
- newHamphshireConfidenceValuesInBrackets = true;
8980
- }
8981
- if (newHamphshireConfidenceValuesAsInternalNames === undefined) {
8982
- newHamphshireConfidenceValuesAsInternalNames = false;
9982
+ 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;
8983
9996
  }
8984
9997
  let tree;
8985
9998
  let loc = location ? String(location).toLowerCase() : '';
@@ -8987,34 +10000,94 @@ function (root, d3, forester, phyloXml) {
8987
10000
  // content decides; the filename alone is enough too.
8988
10001
  if ((forester.isString(data) && /^\s*#nexus\b/i.test(data))
8989
10002
  || /\.(nex|nexus)$/.test(loc)) {
8990
- tree = archaeopteryx.parseNexus(data, newHamphshireConfidenceValuesInBrackets, newHamphshireConfidenceValuesAsInternalNames);
10003
+ tree = archaeopteryx.parseNexus(data, internalNumericLabels);
8991
10004
  } else if ((forester.isString(data) && /^\s*\{/.test(data)) || /\.json$/.test(loc)) {
8992
10005
  tree = archaeopteryx.parseAuspiceJson(data);
8993
- } else if (loc.substr(-3, 3) === 'xml') {
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.
8994
10010
  tree = archaeopteryx.parsePhyloXML(data);
8995
10011
  } else {
8996
- tree = archaeopteryx.parseNewHampshire(data, newHamphshireConfidenceValuesInBrackets, newHamphshireConfidenceValuesAsInternalNames);
10012
+ tree = archaeopteryx.parseNewHampshire(data, internalNumericLabels);
8997
10013
  }
8998
10014
  return tree;
8999
10015
  };
9000
10016
 
10017
+ // Show the viewer's own "working" card in a container. For the part of a
10018
+ // load the library cannot defer for you -- your own parse of a big file
10019
+ // before launch(), or a fetch -- so the page shows the same card the
10020
+ // viewer shows while it draws, rather than something foreign or nothing.
10021
+ //
10022
+ // With `work`: the card is painted, work() runs on the next frame, and
10023
+ // the card is removed when it returns -- the browser gets a frame in
10024
+ // between, which is the whole point, and a hidden tab still runs the
10025
+ // work on a fallback timer. Without `work`: the card stays until the
10026
+ // returned function is called; then it is on you to yield.
10027
+ archaeopteryx.busy = function (container, text, sub, work) {
10028
+ let el = (typeof container === 'string') ? document.querySelector(container) : container;
10029
+ if (!el) {
10030
+ throw new Error(ERROR + 'busy(): container not found');
10031
+ }
10032
+ injectPanelStyles();
10033
+ let busy;
10034
+ if (el === document.body) {
10035
+ // whole-page: fixed, and never touch the body's own positioning
10036
+ busy = showBusy(el, text, sub);
10037
+ busy.style.position = 'fixed';
10038
+ } else {
10039
+ if (getComputedStyle(el).position === 'static') {
10040
+ el.style.position = 'relative';
10041
+ }
10042
+ busy = showBusy(el, text, sub);
10043
+ }
10044
+ let remove = function () {
10045
+ busy.remove();
10046
+ };
10047
+ if (typeof work === 'function') {
10048
+ afterPaint(function () {
10049
+ try {
10050
+ work();
10051
+ } finally {
10052
+ remove();
10053
+ }
10054
+ });
10055
+ }
10056
+ return remove;
10057
+ };
10058
+
9001
10059
  // Parse-and-launch in one step: fetch the file's content yourself (the
9002
10060
  // library does no networking), hand the text here. fileName is used only
9003
10061
  // to pick the parser (its extension; the content is sniffed too).
9004
10062
  // Returns the same viewer handle launch() returns.
10063
+ // launchArchaeopteryx parses BEFORE launch() runs readConfig, so the
10064
+ // deprecated alias has to be resolved HERE as well -- translating it later
10065
+ // would be translating it after the tree was already built. readConfig
10066
+ // emits the warning; this only resolves the value, so it is not doubled.
10067
+ function effectiveInternalLabelsMode(c) {
10068
+ if (c.internalNumericLabels !== undefined) {
10069
+ return c.internalNumericLabels;
10070
+ }
10071
+ if (c.nhConfidenceValuesAsInternalNames !== undefined) {
10072
+ return DEPRECATED_CONFIG.nhConfidenceValuesAsInternalNames
10073
+ .map(c.nhConfidenceValuesAsInternalNames);
10074
+ }
10075
+ return undefined;
10076
+ }
10077
+
9005
10078
  archaeopteryx.launchArchaeopteryx = function (container, fileName, data, config) {
9006
10079
  if (arguments.length > 4) {
9007
10080
  throw new Error(ERROR + 'launchArchaeopteryx() takes exactly (container, fileName,'
9008
10081
  + ' data, config); the old trailing arguments were removed. The separate settings'
9009
10082
  + ' bag and "nodeVisualizations" are gone (see launch()), and the two New Hampshire'
9010
- + ' parse options moved into the config as "nhConfidenceValuesInBrackets" /'
9011
- + ' "nhConfidenceValuesAsInternalNames".');
10083
+ + ' parse option moved into the config as'
10084
+ + ' "internalNumericLabels".');
9012
10085
  }
9013
10086
  let c = config || {};
9014
10087
  let tree;
9015
10088
  try {
9016
10089
  tree = archaeopteryx.parseTree(fileName, data,
9017
- c.nhConfidenceValuesInBrackets, c.nhConfidenceValuesAsInternalNames);
10090
+ effectiveInternalLabelsMode(c));
9018
10091
  } catch (e) {
9019
10092
  // Worth catching only to say that it was the parse, not the launch,
9020
10093
  // that failed -- a malformed tree file rather than a bug in here.