archaeopteryx 3.2.0 → 3.3.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 +96 -8
  2. package/archaeopteryx.js +1106 -183
  3. package/forester.js +895 -437
  4. package/package.json +3 -3
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.2.0
23
+ // v 3.3.0
24
24
  // 2026-09-10
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
@@ -103,7 +103,7 @@ function (root, d3, forester, phyloXml) {
103
103
  // IIFE's own function name -- a plain object says what it is.)
104
104
  let archaeopteryx = {};
105
105
 
106
- const VERSION = '3.1.0';
106
+ const VERSION = '3.3.0';
107
107
  const WEBSITE = 'https://cmzmasek.github.io/archaeopteryx-js/';
108
108
  const DESKTOP_WEBSITE = 'https://cmzmasek.github.io/archaeopteryx/';
109
109
  const SOURCE_WEBSITE = 'https://github.com/cmzmasek/archaeopteryx-js';
@@ -251,6 +251,7 @@ function (root, d3, forester, phyloXml) {
251
251
  const LABEL_SIZE_CALC_FACTOR = 0.5;
252
252
  const LEGEND_LABEL_COLOR = 'legendLabelColor';
253
253
  const LEGEND_NODE_SHAPE = 'legendNodeShape';
254
+ const LEGEND_DOMAINS = 'legendDomains';
254
255
  const NH_EXPORT_FORMAT = 'Newick';
255
256
  const NEXUS_EXPORT_FORMAT = 'Nexus';
256
257
  const NODE_SIZE_MAX = 9;
@@ -283,10 +284,15 @@ function (root, d3, forester, phyloXml) {
283
284
  const TOP_AND_BOTTOM_BORDER_HEIGHT = 10;
284
285
  const TRANSITION_DURATION_DEFAULT = 750;
285
286
  // Above this many nodes the first draw is deferred by one frame behind a
286
- // "working" card, so the page shows it is busy instead of appearing frozen.
287
- // Below it everything stays synchronous exactly as before -- the deferral
288
- // buys nothing on a tree that draws in a few hundred milliseconds.
289
- const BIG_TREE_NODES = 2000;
287
+ // "working" card, so the page shows it is busy instead of appearing frozen,
288
+ // and later redraws coalesce instead of running synchronously. Below it
289
+ // everything stays synchronous exactly as before -- the deferral buys
290
+ // nothing on a tree that draws in a few hundred milliseconds.
291
+ //
292
+ // Raised from 2000 to 3000 on 2026-09-11: a redraw of the 18,512-node tree
293
+ // went from ~4.2 s to ~1.9 s that day, so the size at which a draw is slow
294
+ // enough to need announcing moved up with it.
295
+ const BIG_TREE_NODES = 3000;
290
296
  const WARNING = 'ArchaeopteryxJS: WARNING';
291
297
  const MESSAGE = 'ArchaeopteryxJS: ';
292
298
  const ERROR = 'ArchaeopteryxJS: ERROR: ';
@@ -347,6 +353,15 @@ function (root, d3, forester, phyloXml) {
347
353
  const DOWNLOAD_BUTTON = 'dl_b';
348
354
  const DYNAHIDE_CB = 'dynahide_cb';
349
355
  const MSA_CB = 'msa_cb';
356
+ const DOMAINS_CB = 'domains_cb';
357
+ const DOMAIN_CONTROLS = 'domain_controls';
358
+ const DOMAIN_WIDTH_DEC = 'domain_width_dec';
359
+ const DOMAIN_WIDTH_INC = 'domain_width_inc';
360
+ const DOMAIN_EVALUE_DEC = 'domain_evalue_dec';
361
+ const DOMAIN_EVALUE_INC = 'domain_evalue_inc';
362
+ const DOMAIN_EVALUE_READOUT = 'domain_evalue';
363
+ const DOMAIN_LABELS_SELECT = 'domain_labels';
364
+ const DOMAIN_GLOW_CB = 'domain_glow_cb';
350
365
  const TIME_AXIS_CB = 'timeaxis_cb';
351
366
  const TIME_GRID_CB = 'timegrid_cb';
352
367
  const MSA_SCROLL_ID = 'aptxmsascroll';
@@ -507,6 +522,28 @@ function (root, d3, forester, phyloXml) {
507
522
  let _msaScroller = null; // the fixed HTML range input, created lazily
508
523
  let _msaNav = null; // the bar around it: paging, jump-to-column, readout
509
524
  let _msaGeom = null; // window geometry of the last draw, for the hover readout
525
+ // ------ protein domain architectures (the desktop's domain display) ------
526
+ const DOMAIN_TRACK_START_GAP = 20; // px from the aligned label column to the backbone's start
527
+ const DOMAIN_TRACK_END_GAP = 10; // px kept free past the widest track
528
+ const DOMAIN_RADIAL_GAP = 4; // px from the label ring / the tip's label to the backbone, radial layouts
529
+ const DOMAIN_WIDTH_MIN = 20; // the track width stops growing / shrinking at these
530
+ const DOMAIN_WIDTH_MAX = 2000;
531
+ const DOMAIN_WIDTH_GROW = 1.2; // one d+ press
532
+ const DOMAIN_WIDTH_SHRINK = 0.8; // one d- press
533
+ const DOMAIN_WIDTH_VIEWPORT_FRACTION = 0.25; // the initial track width
534
+ const DOMAIN_RADIAL_WIDTH_FRACTION = 0.2; // ... capped at this share of the radius in radial layouts
535
+ const DOMAIN_SCALE_HEADROOM = 0.9; // drawn length over reserved length
536
+ const DOMAIN_BOX_MIN_H = 6;
537
+ const DOMAIN_BOX_MAX_H = 16;
538
+ const DOMAIN_BACKBONE_COLOR = 'rgb(100,100,100)'; // the same in both themes and in exports
539
+ const DOMAIN_SHADOW_COLOR = 'rgb(8,18,21)';
540
+ const DOMAIN_SHADOWS = [[0.4, 0.7, 40], [0.9, 1.5, 28], [1.6, 2.5, 17]]; // dx, dy, alpha of 255, in drawing order
541
+ const DOMAIN_GLOWS = [[3.2, 20], [1.6, 34]]; // grown by, alpha of 255
542
+ const DOMAIN_LEGEND_INSET = 10; // px from the bottom-right corner, the legend's home
543
+ const DOMAIN_LEGEND_ROW_MAX_PX = 240; // a legend row is clipped to this
544
+ const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
545
+ let _domain = null; // {width, palette, next, legendFrac}, set per launch
546
+ let _domainReserve = 0; // horizontal px reserved for the tracks, set with _w
510
547
  // ------ time axis (the desktop's geologic / calendar overlays) ------
511
548
  const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
512
549
  const TIME_CAL_RESERVE = 26; // the calendar year ruler
@@ -613,6 +650,14 @@ function (root, d3, forester, phyloXml) {
613
650
  const OVERVIEW_HEIGHT = 111;
614
651
  const OVERVIEW_MARGIN = 12;
615
652
  const OVERVIEW_PAD = 5;
653
+ // Smallest the "you are here" rectangle is allowed to get, in overview
654
+ // pixels. Zoom vertically into a 13,000-tip tree and the visible slice is
655
+ // a fraction of one percent of it: the rectangle is then accurate,
656
+ // sub-pixel and useless. Below this it stops being a measurement and
657
+ // becomes a marker -- it still says WHERE you are, it just stops claiming
658
+ // to say exactly how much you can see. The inner frame is 132 x 101, so
659
+ // this is small enough never to mislead about position.
660
+ const OVERVIEW_VIEWPORT_MIN = 7;
616
661
 
617
662
  let _overviewGroup = null; // the whole overview, appended above the tree
618
663
  let _overviewContent = null; // the scaled miniature inside it
@@ -913,17 +958,90 @@ function (root, d3, forester, phyloXml) {
913
958
  // Rebuild the miniature. Called when the tree itself changes: the branch
914
959
  // paths are copied straight from what was just rendered, so the overview
915
960
  // matches the real display in either layout without redoing any geometry.
961
+ // The links of the CURRENT layout. The overview is built from these --
962
+ // from the MODEL -- never by reading back what was drawn.
963
+ //
964
+ // Reading the drawn tree is what broke it: update() animates the links
965
+ // with a transition, and the overview was rebuilt from inside update(),
966
+ // so every attribute it read was the frame BEFORE the one being drawn.
967
+ // Logging from inside the function showed all 215 links degenerate on
968
+ // EVERY rebuild, in every display mode -- the tree had not been drawn
969
+ // yet. The desktop has never had this bug because its miniature is
970
+ // likewise computed from the model.
971
+ let _overviewLinks = null;
972
+
973
+ // The tree's extent for the overview, in un-zoomed tree coordinates:
974
+ // branches as the layout placed them, plus the space the layout reserved
975
+ // for external labels.
976
+ //
977
+ // ONE extent, used for BOTH the miniature's placement and the "is the
978
+ // overview needed at all" test. That is the property that matters, and
979
+ // getting it wrong is what made the first attempt at this worse than the
980
+ // bug: the miniature was fitted to the branches while the visibility test
981
+ // measured branches PLUS labels, so the "you are here" rectangle was
982
+ // drawn against a different extent than the picture under it. The two may
983
+ // live in different spaces -- the desktop deliberately keeps the
984
+ // rectangle in frame space -- but they must derive from one extent.
985
+ //
986
+ // Labels count, by Christian's decision and as the desktop does it: the
987
+ // question is whether what the user can SEE fits, and a cut-off label is
988
+ // not visible.
989
+ function overviewExtent() {
990
+ if (_state.circularDisplay || _state.unrootedDisplay) {
991
+ // maxRad already reaches the outer label ring -- fitCircular()
992
+ // fits the display to exactly this.
993
+ let r = _state.circularDisplay
994
+ ? (_radial ? _radial.maxRad : null)
995
+ : (_unroot ? _unroot.maxRad : null);
996
+ if (!r || r <= 0) {
997
+ return null;
998
+ }
999
+ return {x: -r, y: -r, width: 2 * r, height: 2 * r};
1000
+ }
1001
+ if (!_overviewLinks || _overviewLinks.length === 0) {
1002
+ return null;
1003
+ }
1004
+ // elbow() writes 'M source.y,source.x V target.x H target.y', so the
1005
+ // horizontal axis is .y and the vertical axis is .x.
1006
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1007
+ for (let i = 0, len = _overviewLinks.length; i !== len; ++i) {
1008
+ let l = _overviewLinks[i];
1009
+ let sx = l.source.y, tx = l.target.y;
1010
+ let sy = l.source.x, ty = l.target.x;
1011
+ if (sx < minX) { minX = sx; }
1012
+ if (tx < minX) { minX = tx; }
1013
+ if (sx > maxX) { maxX = sx; }
1014
+ if (tx > maxX) { maxX = tx; }
1015
+ if (sy < minY) { minY = sy; }
1016
+ if (ty < minY) { minY = ty; }
1017
+ if (sy > maxY) { maxY = sy; }
1018
+ if (ty > maxY) { maxY = ty; }
1019
+ }
1020
+ if (!isFinite(minX) || !isFinite(minY) || maxX <= minX) {
1021
+ return null;
1022
+ }
1023
+ // The label reservation the layout was sized against, MINUS the root
1024
+ // offset. calcMaxTreeLengthForDisplay() bundles both: the space kept
1025
+ // to the LEFT of the root and the space kept to the RIGHT for
1026
+ // external labels. Only the label half belongs on this edge.
1027
+ //
1028
+ // Including the root offset made the box exactly rootOffset too wide,
1029
+ // and since centerNode() shifts the view left by that same amount to
1030
+ // seat the root, the right edge of the box always fell just outside
1031
+ // the viewport: measured box=[0,13 1497x755] against
1032
+ // view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
1033
+ // no by precisely 254px and the overview appeared after every Fit.
1034
+ maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
1035
+ let h = maxY - minY;
1036
+ return {x: minX, y: minY, width: maxX - minX, height: h > 0 ? h : 1};
1037
+ }
1038
+
916
1039
  function rebuildOverview() {
917
1040
  if (!_overviewGroup) {
918
1041
  return;
919
1042
  }
920
1043
  let size = svgSize();
921
- let box = null;
922
- try {
923
- box = _svgGroup.node().getBBox(); // in un-zoomed tree coordinates
924
- } catch {
925
- box = null;
926
- }
1044
+ let box = overviewExtent(); // from the MODEL: see overviewExtent()
927
1045
  if (!size || !box || box.width <= 0 || box.height <= 0) {
928
1046
  _overviewGroup.style('display', 'none');
929
1047
  _overviewMap = null;
@@ -942,15 +1060,32 @@ function (root, d3, forester, phyloXml) {
942
1060
  _overviewContent.attr('transform', 'translate(' + _overviewMap.tx + ',' + _overviewMap.ty + ') scale(' + scale + ')');
943
1061
 
944
1062
  let paths = [];
945
- _svgGroup.selectAll('path.link').each(function () {
946
- let d = this.getAttribute('d');
947
- if (d) {
948
- paths.push(d);
1063
+ if (_overviewLinks) {
1064
+ for (let i = 0, len = _overviewLinks.length; i !== len; ++i) {
1065
+ let d = elbow(_overviewLinks[i]); // the SAME routine the tree is drawn with
1066
+ if (d) {
1067
+ paths.push(d);
1068
+ }
949
1069
  }
950
- });
951
- let sel = _overviewContent.selectAll('path').data(paths);
1070
+ }
1071
+ // ONE path for the whole miniature, not one per link. Every elbow()
1072
+ // string starts with an absolute moveto, so concatenating them yields
1073
+ // a single path of many subpaths that renders identically -- they
1074
+ // already share one stroke, width and vector-effect, so nothing
1075
+ // per-link was being expressed by separate elements.
1076
+ //
1077
+ // It is worth doing because the alternative keeps a SECOND copy of
1078
+ // every link in the document: 18,512 extra elements on the big demo
1079
+ // tree, rewritten on every redraw. Measured on that tree, same data:
1080
+ // writing 18,512 paths 222 ms, writing the one concatenated path
1081
+ // 9 ms. Verified equivalent before adopting -- node ordering, the
1082
+ // index of the first node group, and the datum order of the inserted
1083
+ // paths all match, and the union bounding box is unchanged.
1084
+ let sel = _overviewContent.selectAll('path.aptx-overview-links')
1085
+ .data(paths.length > 0 ? [paths.join(' ')] : []);
952
1086
  sel.exit().remove();
953
1087
  sel.enter().append('path')
1088
+ .attr('class', 'aptx-overview-links')
954
1089
  .merge(sel)
955
1090
  .attr('d', function (d) {
956
1091
  return d;
@@ -1023,10 +1158,32 @@ function (root, d3, forester, phyloXml) {
1023
1158
  let y0 = Math.max(OVERVIEW_PAD, _overviewMap.ty + (visY * _overviewMap.scale));
1024
1159
  let x1 = Math.min(OVERVIEW_WIDTH - OVERVIEW_PAD, _overviewMap.tx + ((visX + visW) * _overviewMap.scale));
1025
1160
  let y1 = Math.min(OVERVIEW_HEIGHT - OVERVIEW_PAD, _overviewMap.ty + ((visY + visH) * _overviewMap.scale));
1161
+ // Keep the rectangle visible when the visible slice is tiny. Grown
1162
+ // about its own centre, so the marker stays over the part of the tree
1163
+ // you are actually looking at, then pushed back inside the frame if
1164
+ // that growth would take it out.
1165
+ let floorSpan = function (a, b, lo, hi) {
1166
+ if ((b - a) >= OVERVIEW_VIEWPORT_MIN || (hi - lo) <= OVERVIEW_VIEWPORT_MIN) {
1167
+ return [a, b];
1168
+ }
1169
+ let mid = (a + b) / 2;
1170
+ let start = mid - (OVERVIEW_VIEWPORT_MIN / 2);
1171
+ let end = start + OVERVIEW_VIEWPORT_MIN;
1172
+ if (start < lo) {
1173
+ start = lo;
1174
+ end = lo + OVERVIEW_VIEWPORT_MIN;
1175
+ } else if (end > hi) {
1176
+ end = hi;
1177
+ start = hi - OVERVIEW_VIEWPORT_MIN;
1178
+ }
1179
+ return [start, end];
1180
+ };
1181
+ let xs = floorSpan(x0, x1, OVERVIEW_PAD, OVERVIEW_WIDTH - OVERVIEW_PAD);
1182
+ let ys = floorSpan(y0, y1, OVERVIEW_PAD, OVERVIEW_HEIGHT - OVERVIEW_PAD);
1026
1183
  _overviewViewport
1027
- .attr('x', x0).attr('y', y0)
1028
- .attr('width', Math.max(0, x1 - x0))
1029
- .attr('height', Math.max(0, y1 - y0));
1184
+ .attr('x', xs[0]).attr('y', ys[0])
1185
+ .attr('width', Math.max(0, xs[1] - xs[0]))
1186
+ .attr('height', Math.max(0, ys[1] - ys[0]));
1030
1187
  }
1031
1188
 
1032
1189
  function zoom(event) {
@@ -1311,6 +1468,79 @@ function (root, d3, forester, phyloXml) {
1311
1468
  document.addEventListener('keydown', onKey);
1312
1469
  }
1313
1470
 
1471
+ // One set of listeners on the tree group instead of five on every node.
1472
+ //
1473
+ // These used to be attached in the node enter: click and contextmenu on
1474
+ // each g.node, and mouseover/mousemove/mouseout on each
1475
+ // circle.nodeCircleOptions. On an 18,512-node tree that is 92,560
1476
+ // registrations on the first draw, and it measured 300-450 ms of one.
1477
+ // Mouse events bubble, so one listener per type on the group does the same
1478
+ // work -- find which node the event came from, and hand the handler its
1479
+ // datum. The handlers never used `this`, which is what makes this a
1480
+ // straight substitution rather than a rewrite.
1481
+ //
1482
+ // Note it is mouseover/mouseout, NOT mouseenter/mouseleave: only the
1483
+ // former pair bubbles, so only the former pair can be delegated.
1484
+ function attachNodeEventDelegation(group) {
1485
+
1486
+ // d3 stores a node's datum on the element as __data__, which is all
1487
+ // selection.datum() reads. Going straight to it avoids allocating a
1488
+ // selection on every mousemove.
1489
+ function datumOf(el) {
1490
+ return el ? el.__data__ : null;
1491
+ }
1492
+
1493
+ // The hover target stays the invisible 5px circle rather than the whole
1494
+ // node, or the tooltip would follow the labels too.
1495
+ function hoverDatum(event) {
1496
+ let t = event.target;
1497
+ if (!t || !t.classList || !t.classList.contains('nodeCircleOptions')) {
1498
+ return null;
1499
+ }
1500
+ return datumOf(t.parentNode);
1501
+ }
1502
+
1503
+ // Click and the context menu take the whole node group, so the node's
1504
+ // LABEL works as a target too -- a much easier thing to aim at than a
1505
+ // 5px circle.
1506
+ function nodeDatum(event) {
1507
+ let t = event.target;
1508
+ return datumOf(t && t.closest ? t.closest('g.node') : null);
1509
+ }
1510
+
1511
+ group
1512
+ .on('click', function (event) {
1513
+ let d = nodeDatum(event);
1514
+ if (d && _treeFn.clickEvent) {
1515
+ _treeFn.clickEvent(event, d);
1516
+ }
1517
+ })
1518
+ .on('contextmenu', function (event) {
1519
+ let d = nodeDatum(event);
1520
+ if (d && _treeFn.clickEvent) {
1521
+ event.preventDefault(); // ours instead of the browser's menu
1522
+ _treeFn.clickEvent(event, d);
1523
+ }
1524
+ })
1525
+ .on('mouseover', function (event) {
1526
+ let d = hoverDatum(event);
1527
+ if (d) {
1528
+ mouseover(event, d);
1529
+ }
1530
+ })
1531
+ .on('mousemove', function (event) {
1532
+ let d = hoverDatum(event);
1533
+ if (d) {
1534
+ mousemove(event, d);
1535
+ }
1536
+ })
1537
+ .on('mouseout', function (event) {
1538
+ if (hoverDatum(event)) {
1539
+ mouseout();
1540
+ }
1541
+ });
1542
+ }
1543
+
1314
1544
  function mouseout() {
1315
1545
  hideHoverGlow();
1316
1546
  // Fade only. Emptying the tooltip here collapsed it to nothing but its
@@ -1330,9 +1560,16 @@ function (root, d3, forester, phyloXml) {
1330
1560
  // the next launch).
1331
1561
  //
1332
1562
  // The candidates come from forester.visualizationCandidates: the tree is
1333
- // the only input. Each candidate carries its scales, built once per
1334
- // launch from the COMPLETE tree -- so a value keeps its colour inside a
1335
- // subtree view even when the subtree does not contain it.
1563
+ // the only input, and candidacy is decided on the TREE -- at launch, and
1564
+ // again only after a permanent edit (a deletion). A VIEW -- the subtree
1565
+ // the user switched into -- never changes what is offered or chosen; it
1566
+ // only re-summarizes each candidate over the tips on screen (legend rows
1567
+ // and counts, the "no value" row, a numeric field's band and gradient
1568
+ // range) through forester.visualizationSummary. Colour and shape
1569
+ // identities live in the memory maps below and so survive every view.
1570
+ // Until 2026-09-12 the classifier was re-run per view and its refusals
1571
+ // applied to the chosen field, which dropped the colouring in 61% of the
1572
+ // corpus's clades and did not bring it back on return.
1336
1573
  // The tree as displayed right now: the subtree the user switched into,
1337
1574
  // otherwise the (possibly edited) full tree.
1338
1575
  function displayedRoot() {
@@ -1379,7 +1616,8 @@ function (root, d3, forester, phyloXml) {
1379
1616
  if (!_settings.enableVisualizations) {
1380
1617
  return;
1381
1618
  }
1382
- computeVisualizationCandidates(_treeData);
1619
+ installCandidates(forester.visualizationCandidates(_treeData));
1620
+ summarizeView(_treeData);
1383
1621
  // The embedder may name the visualization to open with (the
1384
1622
  // initialVisualization setting, matched case-insensitively against
1385
1623
  // the Color-menu label or the internal id). A name this tree cannot
@@ -1406,14 +1644,20 @@ function (root, d3, forester, phyloXml) {
1406
1644
  _vis.autoColorId = requested.id;
1407
1645
  _vis.colorId = requested.id;
1408
1646
  _state.showVisualizations = true;
1409
- } else if (_vis.candidates.length > 0 && !_vis.candidates[0].wide) {
1410
- // Auto-apply the best candidate: the classifier returns them best
1411
- // first, so a tree opens already coloured by its most informative
1412
- // field instead of grey with a menu to discover. A wide field
1413
- // (21+ values, legend capped) is offered but never imposed.
1414
- _vis.autoColorId = _vis.candidates[0].id;
1415
- _vis.colorId = _vis.autoColorId;
1416
- _state.showVisualizations = true;
1647
+ } else {
1648
+ // Auto-apply the best candidate that is not WIDE: the classifier
1649
+ // returns them best first, so a tree opens already coloured by
1650
+ // its most informative field instead of grey with a menu to
1651
+ // discover. A wide field (21+ values, legend capped) is offered
1652
+ // but never imposed -- and it does not block what sits below it
1653
+ // either: a tree carrying a wide field and an In-Group opens by
1654
+ // the In-Group, not uncoloured.
1655
+ let first = forester.openingVisualization(_vis.candidates);
1656
+ if (first) {
1657
+ _vis.autoColorId = first.id;
1658
+ _vis.colorId = _vis.autoColorId;
1659
+ _state.showVisualizations = true;
1660
+ }
1417
1661
  }
1418
1662
  }
1419
1663
 
@@ -1743,49 +1987,72 @@ function (root, d3, forester, phyloXml) {
1743
1987
  buildCategoryScale(vis);
1744
1988
  }
1745
1989
 
1746
- function computeVisualizationCandidates(viewRoot) {
1747
- _vis.candidates = [];
1990
+ // The tree's candidates become THE candidates: the menu, the tiers, the
1991
+ // ids the memory maps are keyed by. Their scales come from summarizeView.
1992
+ function installCandidates(list) {
1993
+ _vis.candidates = list;
1748
1994
  _vis.byId = {};
1749
- forester.visualizationCandidates(viewRoot).forEach(function (c) {
1750
- if (c.numeric) {
1751
- // ramps are POSITION in the view's range, not identity: a
1752
- // subtree spanning six years gets a full-width gradient
1753
- let nums = c.values.map(Number);
1754
- let mean = nums.reduce(function (a, b) {
1755
- return a + b;
1756
- }, 0) / nums.length;
1757
- c.rangeScale = d3.scaleLinear()
1758
- .range(VIS_COLOR_RAMP)
1759
- .domain([nums[0], mean, nums[nums.length - 1]]);
1760
- }
1761
- function memoryKey(v) {
1762
- return visMemoryKey(c, v);
1763
- }
1764
- // a switchable candidate needs both scales standing by
1765
- if (c.colorMode === 'category' || c.switchable) {
1766
- buildCategoryScale(c);
1767
- }
1768
- if (c.shape) {
1769
- let mem = _vis.shapeMemory[c.id] || (_vis.shapeMemory[c.id] = {});
1770
- let next = _vis.shapeNext[c.id] || 0;
1771
- c.values.forEach(function (v) {
1772
- let k = memoryKey(v);
1773
- if (!(k in mem)) {
1774
- mem[k] = VIS_SHAPES[next++ % VIS_SHAPES.length];
1775
- }
1776
- });
1777
- _vis.shapeNext[c.id] = next;
1778
- c.shapeScale = d3.scaleOrdinal()
1779
- .domain(c.values)
1780
- .range(c.values.map(function (v) {
1781
- return mem[memoryKey(v)];
1782
- }));
1783
- }
1784
- _vis.candidates.push(c);
1995
+ list.forEach(function (c) {
1785
1996
  _vis.byId[c.id] = c;
1786
1997
  });
1787
1998
  }
1788
1999
 
2000
+ // Re-summarizes every candidate over the tips under `root` and rebuilds
2001
+ // its scales from that: the whole tree at launch and after an edit, the
2002
+ // displayed subtree on every view change.
2003
+ function summarizeView(root) {
2004
+ _vis.candidates.forEach(function (c) {
2005
+ let s = forester.visualizationSummary(c, root);
2006
+ c.values = s.values;
2007
+ c.counts = s.counts;
2008
+ c.coverage = s.coverage;
2009
+ c.total = s.total;
2010
+ if (c.numeric) {
2011
+ c.colorMode = s.colorMode;
2012
+ c.switchable = s.switchable;
2013
+ }
2014
+ buildScales(c);
2015
+ });
2016
+ }
2017
+
2018
+ function buildScales(c) {
2019
+ c.rangeScale = null;
2020
+ if (c.numeric && c.values.length > 0) {
2021
+ // ramps are POSITION in the view's range, not identity: a
2022
+ // subtree spanning six years gets a full-width gradient
2023
+ let nums = c.values.map(Number);
2024
+ let mean = nums.reduce(function (a, b) {
2025
+ return a + b;
2026
+ }, 0) / nums.length;
2027
+ c.rangeScale = d3.scaleLinear()
2028
+ .range(VIS_COLOR_RAMP)
2029
+ .domain([nums[0], mean, nums[nums.length - 1]]);
2030
+ }
2031
+ function memoryKey(v) {
2032
+ return visMemoryKey(c, v);
2033
+ }
2034
+ // a switchable candidate needs both scales standing by
2035
+ if (c.colorMode === 'category' || c.switchable) {
2036
+ buildCategoryScale(c);
2037
+ }
2038
+ if (c.shape) {
2039
+ let mem = _vis.shapeMemory[c.id] || (_vis.shapeMemory[c.id] = {});
2040
+ let next = _vis.shapeNext[c.id] || 0;
2041
+ c.values.forEach(function (v) {
2042
+ let k = memoryKey(v);
2043
+ if (!(k in mem)) {
2044
+ mem[k] = VIS_SHAPES[next++ % VIS_SHAPES.length];
2045
+ }
2046
+ });
2047
+ _vis.shapeNext[c.id] = next;
2048
+ c.shapeScale = d3.scaleOrdinal()
2049
+ .domain(c.values)
2050
+ .range(c.values.map(function (v) {
2051
+ return mem[memoryKey(v)];
2052
+ }));
2053
+ }
2054
+ }
2055
+
1789
2056
  function currentColorVis() {
1790
2057
  return ((_vis && _vis.colorId) ? _vis.byId[_vis.colorId] : null) || null;
1791
2058
  }
@@ -1797,7 +2064,10 @@ function (root, d3, forester, phyloXml) {
1797
2064
  // A candidate's colour mode, honouring the legend's [colors] / [gradient]
1798
2065
  // switch, and the scale that mode wants.
1799
2066
  function colorModeOf(vis) {
1800
- return _vis.colorModeById[vis.id] || vis.colorMode;
2067
+ // the legend's [colors]/[gradient] choice holds only while the field
2068
+ // is switchable in THIS view (as on the desktop): above 20 distinct
2069
+ // values it is a gradient, whatever was chosen inside a smaller clade
2070
+ return (vis.switchable && _vis.colorModeById[vis.id]) || vis.colorMode;
1801
2071
  }
1802
2072
 
1803
2073
  // Legend row order: numbers list in numeric order by default, words by
@@ -1847,9 +2117,10 @@ function (root, d3, forester, phyloXml) {
1847
2117
  // a titled, bordered panel; value rows with counts; a [by count] / [A-Z]
1848
2118
  // sort toggle in the title row (count-first is the desktop's default);
1849
2119
  // and for numeric ranges a horizontal gradient bar with the min and max
1850
- // beneath it. Unlike the desktop there is no "+N more" cap machinery:
1851
- // the classifier already refuses fields above 20 values, so a legend
1852
- // can never overflow.
2120
+ // beneath it. A long legend shows its 20 most frequent rows and a
2121
+ // [+N more] chip, like the desktop's. Rows, counts and the "no value"
2122
+ // row describe the tips on SCREEN (the view's summary); the colours are
2123
+ // launch-lifetime identities from the memory maps.
1853
2124
  //
1854
2125
  // The cards live in the tree's own svg, so they ride along into the PNG
1855
2126
  // and SVG exports, and their colours are the four theme colours the
@@ -1915,7 +2186,9 @@ function (root, d3, forester, phyloXml) {
1915
2186
  const titleFont = '600 ' + (FS + 1) + 'px ' + FONT_DEFAULTS;
1916
2187
  const ink = _state.labelColorDefault;
1917
2188
  const frame = _state.branchColorDefault;
1918
- const isRange = kind === 'color' && colorModeOf(vis) === 'range';
2189
+ // a field with no value in this view has no range to draw: the card
2190
+ // is then just its "no value" row
2191
+ const isRange = kind === 'color' && colorModeOf(vis) === 'range' && vis.values.length > 0;
1919
2192
 
1920
2193
  const LEGEND_MAX_ROWS = 20;
1921
2194
  let rows = [];
@@ -2283,6 +2556,28 @@ function (root, d3, forester, phyloXml) {
2283
2556
  });
2284
2557
  }
2285
2558
 
2559
+ // Animate when there is something to animate; otherwise set the values
2560
+ // straight onto the selection.
2561
+ //
2562
+ // d3 builds a schedule for every element a transition touches, whether or
2563
+ // not that transition will animate anything -- and a duration of 0 animates
2564
+ // nothing. Nearly every redraw here asks for 0: all 39 call sites that pass
2565
+ // a duration pass 0, and only the 15 that omit the argument animate. So the
2566
+ // common path was paying for machinery it never used. On the 18,512-node
2567
+ // tree, ONE `transition.select('path')` measured ~190-235 ms against ~15 ms
2568
+ // for the same select on a plain selection -- and the node update does six
2569
+ // such selects, plus the node and link roots.
2570
+ //
2571
+ // interrupt() first: a transition still running from an earlier redraw would
2572
+ // otherwise finish afterwards and overwrite what we just set. The transition
2573
+ // this replaces cancelled that animation implicitly, by taking over the same
2574
+ // schedule; a plain selection has to say so.
2575
+ function animateOrSet(selection, duration) {
2576
+ return duration > 0
2577
+ ? selection.transition().duration(duration)
2578
+ : selection.interrupt();
2579
+ }
2580
+
2286
2581
  function update(source, transitionDuration, doNotRecalculateWidth) {
2287
2582
 
2288
2583
  if (!source) {
@@ -2300,6 +2595,8 @@ function (root, d3, forester, phyloXml) {
2300
2595
  // the alignment track reserves its window on the right, so the
2301
2596
  // tree and labels compress to make room rather than overlapping
2302
2597
  _msaReserve = 0;
2598
+ // and the domain tracks reserve their column past the labels
2599
+ _domainReserve = domainReserve();
2303
2600
  if (msaShown()) {
2304
2601
  let fullPx = _basicTreeProperties.maxMolSeqLength * MSA_COL_WIDTH;
2305
2602
  // budgeted from the VIEWPORT: sizing it from the zoomed layout
@@ -2310,11 +2607,11 @@ function (root, d3, forester, phyloXml) {
2310
2607
  let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
2311
2608
  // a wide alignment must not squeeze the tree itself away: the
2312
2609
  // band yields until the tree keeps its minimum share
2313
- let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2610
+ let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2314
2611
  band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
2315
2612
  _msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
2316
2613
  }
2317
- _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve;
2614
+ _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve;
2318
2615
  if (_w < 1) {
2319
2616
  _w = 1;
2320
2617
  }
@@ -2365,6 +2662,7 @@ function (root, d3, forester, phyloXml) {
2365
2662
  let links = hierarchy.links().map(function (link) {
2366
2663
  return {source: link.source.data, target: link.target.data};
2367
2664
  });
2665
+ _overviewLinks = links; // the overview is built from these, not from the DOM
2368
2666
  let gap = _state.nodeLabelGap;
2369
2667
 
2370
2668
  if (_state.phylogram === true) {
@@ -2446,21 +2744,14 @@ function (root, d3, forester, phyloXml) {
2446
2744
  return d.id || (d.id = ++_i);
2447
2745
  });
2448
2746
 
2747
+ // No listeners here: click, contextmenu and the hover trio are
2748
+ // delegated once to the tree group -- see attachNodeEventDelegation().
2449
2749
  let nodeEnter = node.enter().append('g')
2450
2750
  .attr('class', 'node')
2451
2751
  .attr('transform', function () {
2452
2752
  return 'translate(' + source.y0 + ',' + source.x0 + ')';
2453
2753
  })
2454
- .style('cursor', 'default')
2455
- .on('click', _treeFn.clickEvent)
2456
- // Right-click opens the same menu. The left-click target is an
2457
- // invisible 5px circle on the node itself, which is hard to hit; this
2458
- // handler sits on the whole node group, so the node's LABEL works as
2459
- // the target too -- a much easier thing to aim at.
2460
- .on('contextmenu', function (event, d) {
2461
- event.preventDefault(); // ours instead of the browser's menu
2462
- _treeFn.clickEvent.call(this, event, d);
2463
- });
2754
+ .style('cursor', 'default');
2464
2755
 
2465
2756
 
2466
2757
  // Only the three elements EVERY node needs are created here. The other
@@ -2483,11 +2774,6 @@ function (root, d3, forester, phyloXml) {
2483
2774
  .attr('r', 0);
2484
2775
 
2485
2776
  nodeEnter.append('circle')
2486
- .on("mouseover", mouseover)
2487
- .on("mousemove", function (event, d) {
2488
- mousemove(event, d);
2489
- })
2490
- .on("mouseout", mouseout)
2491
2777
  .style('cursor', 'pointer')
2492
2778
  .style('opacity', '0')
2493
2779
  .attr('class', 'nodeCircleOptions')
@@ -2708,20 +2994,29 @@ function (root, d3, forester, phyloXml) {
2708
2994
  });
2709
2995
  }
2710
2996
 
2711
- node.select('circle.nodeCircle')
2712
- .attr('r', function (d) {
2713
- if (((_state.showVisualizations && !_state.showNodeEvents) && (makeNodeFillColor(d) === _state.backgroundColorDefault))) {
2714
- return 0;
2715
- }
2716
- return makeNodeSize(d);
2717
- })
2718
- .style('stroke', function (d) {
2719
- return makeNodeStrokeColor(d);
2720
- })
2721
- .style('stroke-width', _state.branchWidthDefault)
2722
- .style('fill', function (d) {
2723
- return (_state.showVisualizations || _state.showNodeEvents || isNodeFound(d) || isNodeSelected(d)) ? makeNodeFillColor(d) : _state.backgroundColorDefault;
2724
- });
2997
+ // One pass, and the fill colour computed once.
2998
+ //
2999
+ // This was four chained .attr/.style calls, so d3 walked all 18.5k
3000
+ // circles four times; and makeNodeFillColor() was called TWICE per
3001
+ // node -- once to decide whether the dot collapses to r=0, once for
3002
+ // the fill itself -- which is not a cheap accessor: it checks the
3003
+ // search hits, the node events, the visualization colour and the
3004
+ // node's own style.
3005
+ //
3006
+ // The union of the two conditions that need the colour is just the
3007
+ // fill condition, since (showVisualizations && !showNodeEvents)
3008
+ // implies showVisualizations. So it is computed once when needed and
3009
+ // reused, and left null when neither branch wants it.
3010
+ node.select('circle.nodeCircle').each(function (d) {
3011
+ let fill = (_state.showVisualizations || _state.showNodeEvents
3012
+ || isNodeFound(d) || isNodeSelected(d)) ? makeNodeFillColor(d) : null;
3013
+ let r = ((_state.showVisualizations && !_state.showNodeEvents)
3014
+ && (fill === _state.backgroundColorDefault)) ? 0 : makeNodeSize(d);
3015
+ this.setAttribute('r', r);
3016
+ this.style.stroke = makeNodeStrokeColor(d);
3017
+ this.style.strokeWidth = _state.branchWidthDefault;
3018
+ this.style.fill = (fill === null) ? _state.backgroundColorDefault : fill;
3019
+ });
2725
3020
 
2726
3021
 
2727
3022
  // Dim Non-Matches: one opacity on the node GROUP dims its labels, dot,
@@ -2732,16 +3027,24 @@ function (root, d3, forester, phyloXml) {
2732
3027
  return (_dimNonMatches && !getFoundColor(d)) ? DIM_NON_MATCH_OPACITY : null;
2733
3028
  });
2734
3029
 
2735
- let nodeUpdate = node.transition()
2736
- .duration(transitionDuration)
3030
+ let nodeUpdate = animateOrSet(node, transitionDuration)
2737
3031
  .attr('transform', function (d) {
2738
3032
  return nodeTransform(d);
2739
3033
  });
2740
3034
 
2741
- nodeUpdate.select('text')
3035
+ // The same rule for a node's CHILD elements: derive from the transition
3036
+ // when animating, else select straight off the node and interrupt any
3037
+ // animation still on that child.
3038
+ function nodeChild(selector) {
3039
+ return transitionDuration > 0
3040
+ ? nodeUpdate.select(selector)
3041
+ : node.select(selector).interrupt();
3042
+ }
3043
+
3044
+ nodeChild('text')
2742
3045
  .style('fill-opacity', 1);
2743
3046
 
2744
- nodeUpdate.select('text.extlabel')
3047
+ nodeChild('text.extlabel')
2745
3048
  .text(function (d) {
2746
3049
  return d._extLabelText; // computed in syncOptionalNodeChildren
2747
3050
  });
@@ -2750,17 +3053,17 @@ function (root, d3, forester, phyloXml) {
2750
3053
  // has already removed these elements when the switch is off, and a
2751
3054
  // .select() that finds nothing still walks every node to find out.
2752
3055
  if (_state.showBranchLengthValues) {
2753
- nodeUpdate.select('text.bllabel').text(makeBranchLengthLabel);
3056
+ nodeChild('text.bllabel').text(makeBranchLengthLabel);
2754
3057
  }
2755
3058
  if (_state.showConfidenceValues) {
2756
- nodeUpdate.select('text.conflabel').text(makeConfidenceValuesLabel);
3059
+ nodeChild('text.conflabel').text(makeConfidenceValuesLabel);
2757
3060
  }
2758
3061
  if (_state.showBranchEvents) {
2759
- nodeUpdate.select('text.brancheventlabel').text(makeBranchEventsLabel);
3062
+ nodeChild('text.brancheventlabel').text(makeBranchEventsLabel);
2760
3063
  }
2761
3064
 
2762
3065
  let drawShapes = _state.showVisualizations || stylesActive();
2763
- nodeUpdate.select('path')
3066
+ nodeChild('path')
2764
3067
  .style('stroke', drawShapes ? makeNodeStrokeColor : null)
2765
3068
  .style('stroke-width', _state.branchWidthDefault)
2766
3069
  .style('fill', drawShapes ? makeNodeFillColor : null)
@@ -2772,9 +3075,9 @@ function (root, d3, forester, phyloXml) {
2772
3075
  // and a full 18k-node walk even when drawShapes made it a no-op. One
2773
3076
  // filtered selection with one transition does the same thing.
2774
3077
  if (!drawShapes) {
2775
- node.filter(function (d) {
3078
+ animateOrSet(node.filter(function (d) {
2776
3079
  return d.children && makeNodeVisShape(d) === null;
2777
- }).select('path').transition().duration(transitionDuration)
3080
+ }).select('path'), transitionDuration)
2778
3081
  .attr('d', 'M0,0');
2779
3082
  }
2780
3083
 
@@ -2798,7 +3101,26 @@ function (root, d3, forester, phyloXml) {
2798
3101
  return d.target.id;
2799
3102
  });
2800
3103
 
2801
- let linkEnter = link.enter().insert('path', 'g')
3104
+ // The reference node for the insert below, resolved ONCE.
3105
+ //
3106
+ // d3's insert(name, before) takes `before` as a SELECTOR STRING and
3107
+ // then runs parent.querySelector(before) for EVERY element it
3108
+ // inserts. With 18.5k links going into a group that already holds
3109
+ // 18.5k node <g>s, that is 18.5k full-subtree queries and it
3110
+ // dominated the first draw: measured 1,883 ms against 66 ms for the
3111
+ // same inserts with the lookup hoisted -- 28x, and about a third of
3112
+ // the time to open the biggest demo tree.
3113
+ //
3114
+ // Passing a function instead makes d3 use its return value as-is. The
3115
+ // result is identical: every link still lands before the first node
3116
+ // group, in the same order, so links stay painted behind nodes. It
3117
+ // must be re-resolved each update because the node join above may
3118
+ // have just created or removed those groups.
3119
+ let linkBefore = _svgGroup.node().querySelector('g');
3120
+
3121
+ let linkEnter = link.enter().insert('path', function () {
3122
+ return linkBefore;
3123
+ })
2802
3124
  .attr('class', 'link')
2803
3125
  .attr('fill', 'none')
2804
3126
  .attr('stroke-width', makeBranchWidth)
@@ -2819,8 +3141,7 @@ function (root, d3, forester, phyloXml) {
2819
3141
 
2820
3142
  link.attr('stroke-width', makeBranchWidth);
2821
3143
 
2822
- link.transition()
2823
- .duration(transitionDuration)
3144
+ animateOrSet(link, transitionDuration)
2824
3145
  .attr('stroke', makeBranchColor)
2825
3146
  .attr('d', elbow);
2826
3147
 
@@ -2897,6 +3218,8 @@ function (root, d3, forester, phyloXml) {
2897
3218
  }
2898
3219
 
2899
3220
  hideHoverGlow(); // the hovered node may have moved; the next mouseover re-shows it
3221
+ drawDomainArchitectures();
3222
+ drawDomainLegend();
2900
3223
  drawMsaTrack();
2901
3224
  drawTimeOverlays();
2902
3225
  rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
@@ -3090,7 +3413,10 @@ function (root, d3, forester, phyloXml) {
3090
3413
  if (value === null) {
3091
3414
  return null;
3092
3415
  }
3093
- return colorModeOf(vis) === 'range' ? vis.rangeScale(Number(value)) : vis.categoryScale(value);
3416
+ if (colorModeOf(vis) === 'range') {
3417
+ return vis.rangeScale ? vis.rangeScale(Number(value)) : null;
3418
+ }
3419
+ return vis.categoryScale(value);
3094
3420
  }
3095
3421
 
3096
3422
  let makeVisNodeFillColor = function (node) {
@@ -3211,7 +3537,7 @@ function (root, d3, forester, phyloXml) {
3211
3537
  if (_vis && _vis.labelRef && !phynode.children && phynode.properties) {
3212
3538
  for (let i = 0; i < phynode.properties.length; ++i) {
3213
3539
  let p = phynode.properties[i];
3214
- if (p.ref === _vis.labelRef && p.applies_to === 'node' && p.value) {
3540
+ if (p.ref === _vis.labelRef && forester.isNodeScopedProperty(p) && p.value) {
3215
3541
  return String(p.value);
3216
3542
  }
3217
3543
  }
@@ -3293,25 +3619,38 @@ function (root, d3, forester, phyloXml) {
3293
3619
  let wantEvent = _state.showBranchEvents === true;
3294
3620
  let wantDot = _state.showSupportDots === true;
3295
3621
 
3296
- // whole-class removals first: one pass, no per-node callback
3622
+ // Whole-class removals first: ONE query over the tree group, not one
3623
+ // per node. `node.selectAll(...)` runs querySelectorAll on every one of
3624
+ // the 18,512 node groups, and five of those is 92,560 queries made
3625
+ // largely to discover there is nothing to remove -- measured at 82-92 ms
3626
+ // per redraw on the big tree, against 40-50 ms for the whole per-node
3627
+ // loop below it. Each of these classes is created only in this function,
3628
+ // on a g.node inside _svgGroup, so one query over the group finds
3629
+ // exactly the same elements. (The overview's miniature is out of reach
3630
+ // either way: it hangs off _baseSvg, not _svgGroup.)
3631
+ let dropAll = function (selector) {
3632
+ if (_svgGroup) {
3633
+ _svgGroup.selectAll(selector).remove();
3634
+ }
3635
+ };
3297
3636
  if (!wantBl) {
3298
- node.selectAll('text.bllabel').remove();
3637
+ dropAll('text.bllabel');
3299
3638
  }
3300
3639
  if (!wantConf) {
3301
- node.selectAll('text.conflabel').remove();
3640
+ dropAll('text.conflabel');
3302
3641
  }
3303
3642
  if (!wantEvent) {
3304
- node.selectAll('text.brancheventlabel').remove();
3643
+ dropAll('text.brancheventlabel');
3305
3644
  }
3306
3645
  if (!wantDot) {
3307
- node.selectAll('circle.suppdot').remove();
3646
+ dropAll('circle.suppdot');
3308
3647
  }
3309
3648
 
3310
3649
  // No search running means no halo anywhere -- one pass instead of
3311
3650
  // 18,512 set lookups plus 18,512 DOM queries.
3312
3651
  let anyFound = anySearchHits();
3313
3652
  if (!anyFound) {
3314
- node.selectAll('circle.foundHalo').remove();
3653
+ dropAll('circle.foundHalo');
3315
3654
  }
3316
3655
 
3317
3656
  // Raw DOM inside the loop, not d3 selections: this runs once per node
@@ -3471,7 +3810,7 @@ function (root, d3, forester, phyloXml) {
3471
3810
  if (value.selected === true && value.propertyRef) {
3472
3811
  let prop_text = '';
3473
3812
  for (let pm = 0; pm < props_length; ++pm) {
3474
- if (phynode.properties[pm].ref === value.propertyRef && phynode.properties[pm].datatype === 'xsd:string' && phynode.properties[pm].applies_to === 'node') {
3813
+ if (phynode.properties[pm].ref === value.propertyRef && phynode.properties[pm].datatype === 'xsd:string' && forester.isNodeScopedProperty(phynode.properties[pm])) {
3475
3814
  if (prop_text.length > 0) {
3476
3815
  prop_text += ', '
3477
3816
  }
@@ -3962,6 +4301,10 @@ function (root, d3, forester, phyloXml) {
3962
4301
  const STATE_KEYS = [
3963
4302
  'layout',
3964
4303
  'showMsa',
4304
+ 'showDomainArchitectures',
4305
+ 'domainLabels',
4306
+ 'domainGlow',
4307
+ 'domainEvalueExponent',
3965
4308
  'showTimeAxis',
3966
4309
  'timeAxisGrid',
3967
4310
  'showSupportDots',
@@ -4081,12 +4424,8 @@ function (root, d3, forester, phyloXml) {
4081
4424
  // explicitly is derived from what the loaded tree actually contains
4082
4425
  // (an explicit caller option always wins). Field presence comes from
4083
4426
  // the same per-tree discovery that drives the search Field menu.
4084
- let presentFields = new Set();
4085
- if (_treeData) {
4086
- forester.availableSearchFields(_treeData).forEach(function (f) {
4087
- presentFields.add(f.key);
4088
- });
4089
- }
4427
+ let presentFields = new Set(_treeData ? forester.availableSearchFields(_treeData) : []);
4428
+ let F = forester.searchFields;
4090
4429
 
4091
4430
  // Branch lengths are worth drawing to scale only when MOST of the
4092
4431
  // branches that CARRY THE SCALE have one. Two corrections to the
@@ -4185,6 +4524,39 @@ function (root, d3, forester, phyloXml) {
4185
4524
  _state.showMsa = _basicTreeProperties.alignedMolSeqs === true
4186
4525
  && _basicTreeProperties.maxMolSeqLength > 0;
4187
4526
  }
4527
+ // A tree with protein domain architectures on its tips draws them
4528
+ // from the start (the desktop switches its toggle on at load), again
4529
+ // unless the caller decided. The track width is set at the first
4530
+ // draw from the viewport; threshold, label mode and glow come from
4531
+ // the config or the desktop's defaults.
4532
+ if (_state.showDomainArchitectures === undefined) {
4533
+ _state.showDomainArchitectures = _basicTreeProperties.domainArchitectures === true;
4534
+ }
4535
+ if (_state.domainLabels === undefined) {
4536
+ _state.domainLabels = 'domains';
4537
+ } else if (DOMAIN_LABEL_MODES.indexOf(_state.domainLabels) < 0) {
4538
+ throw new Error(ERROR + '"domainLabels" must be "none", "domains" or "legend"');
4539
+ }
4540
+ if (_state.domainGlow === undefined) {
4541
+ _state.domainGlow = false;
4542
+ }
4543
+ if (_state.domainEvalueExponent === undefined) {
4544
+ _state.domainEvalueExponent = forester.DOMAIN_EVALUE_EXPONENT_DEFAULT;
4545
+ } else if (!Number.isInteger(_state.domainEvalueExponent)
4546
+ || _state.domainEvalueExponent < forester.DOMAIN_EVALUE_EXPONENT_MIN
4547
+ || _state.domainEvalueExponent > forester.DOMAIN_EVALUE_EXPONENT_MAX) {
4548
+ throw new Error(ERROR + '"domainEvalueExponent" must be an integer from '
4549
+ + forester.DOMAIN_EVALUE_EXPONENT_MIN + ' to ' + forester.DOMAIN_EVALUE_EXPONENT_MAX);
4550
+ }
4551
+ _domain = {width: null, palette: null, next: 0, legendFrac: null};
4552
+ if (_basicTreeProperties.domainArchitectures) {
4553
+ // malformed domains are skipped, never fatal -- said once, here
4554
+ let ignored = forester.domainArchitectureStats(_treeData).ignored;
4555
+ if (ignored > 0) {
4556
+ console.warn(MESSAGE + ignored + ' protein domain' + (ignored === 1 ? '' : 's')
4557
+ + ' with a missing or impossible from / to / E-value ignored');
4558
+ }
4559
+ }
4188
4560
  // Likewise a dated tree draws its time axis from the start --
4189
4561
  // geologic ICS bands or calendar years, decided from the <date>
4190
4562
  // elements by forester.timeAxisInfo -- again unless the caller set
@@ -4209,18 +4581,18 @@ function (root, d3, forester, phyloXml) {
4209
4581
  // Which taxonomy fields to label with is decided from the tree, not
4210
4582
  // configured: show the scientific name and code when present, and fall
4211
4583
  // back to the common name only when there is no scientific name.
4212
- _state.showTaxonomyCode = presentFields.has('TC');
4213
- _state.showTaxonomyScientificName = presentFields.has('TS');
4214
- _state.showTaxonomyCommonName = presentFields.has('TN') && !presentFields.has('TS');
4584
+ _state.showTaxonomyCode = presentFields.has(F.taxonomyCode);
4585
+ _state.showTaxonomyScientificName = presentFields.has(F.taxonomyScientificName);
4586
+ _state.showTaxonomyCommonName = presentFields.has(F.taxonomyCommonName) && !presentFields.has(F.taxonomyScientificName);
4215
4587
  _state.showTaxonomyRank = false;
4216
4588
  _state.showTaxonomySynonyms = false;
4217
4589
  // Likewise ONE good sequence identifier rather than all of them, in
4218
4590
  // order of preference: sequence name, gene name, symbol, accession.
4219
- _state.showSequenceName = presentFields.has('SN');
4220
- _state.showSequenceGeneSymbol = presentFields.has('GN') && !presentFields.has('SN');
4221
- _state.showSequenceSymbol = presentFields.has('SS') && !presentFields.has('SN') && !presentFields.has('GN');
4222
- _state.showSequenceAccession = presentFields.has('SA') && !presentFields.has('SN')
4223
- && !presentFields.has('GN') && !presentFields.has('SS');
4591
+ _state.showSequenceName = presentFields.has(F.sequenceName);
4592
+ _state.showSequenceGeneSymbol = presentFields.has(F.geneName) && !presentFields.has(F.sequenceName);
4593
+ _state.showSequenceSymbol = presentFields.has(F.sequenceSymbol) && !presentFields.has(F.sequenceName) && !presentFields.has(F.geneName);
4594
+ _state.showSequenceAccession = presentFields.has(F.sequenceAccession) && !presentFields.has(F.sequenceName)
4595
+ && !presentFields.has(F.geneName) && !presentFields.has(F.sequenceSymbol);
4224
4596
 
4225
4597
  // Which of the three groups start CHECKED is then decided from the
4226
4598
  // label text itself (forester.suggestLabelFields): a field whose text
@@ -4243,7 +4615,7 @@ function (root, d3, forester, phyloXml) {
4243
4615
  if (labelRef && n.properties) {
4244
4616
  for (let pi = 0; pi < n.properties.length; ++pi) {
4245
4617
  let p = n.properties[pi];
4246
- if (p.ref === labelRef && p.applies_to === 'node' && p.value) {
4618
+ if (p.ref === labelRef && forester.isNodeScopedProperty(p) && p.value) {
4247
4619
  return String(p.value);
4248
4620
  }
4249
4621
  }
@@ -4510,6 +4882,7 @@ function (root, d3, forester, phyloXml) {
4510
4882
  populateSearchMenus();
4511
4883
 
4512
4884
  _svgGroup = _baseSvg.append('g');
4885
+ attachNodeEventDelegation(_svgGroup);
4513
4886
  // the floating strips paint over the tree and under the overview
4514
4887
  _floatGroup = _baseSvg.append('g').attr('class', 'aptx-float')
4515
4888
  .style('pointer-events', 'none');
@@ -4581,6 +4954,13 @@ function (root, d3, forester, phyloXml) {
4581
4954
  _node_mouseover_div.remove(); // the body-level tooltip div (d3 selection)
4582
4955
  _node_mouseover_div = null;
4583
4956
  }
4957
+ // A redraw still waiting for its frame belongs to the viewer being torn
4958
+ // down. Left set, its callback would read the NEXT viewer's pending
4959
+ // update, null it, then find its own sequence number stale and return
4960
+ // -- having already discarded work that was not its own. I could not
4961
+ // construct that ordering from the UI, and one line means nobody has to
4962
+ // reason about it again.
4963
+ _pendingUpdate = null;
4584
4964
  clearInterval(_intervalId); // a zoom button held down at destroy time
4585
4965
  d3.select(window).on('resize.archaeopteryx', null);
4586
4966
  _docListeners.forEach(function (l) {
@@ -4598,6 +4978,7 @@ function (root, d3, forester, phyloXml) {
4598
4978
  _baseSvg = null;
4599
4979
  _svgGroup = null;
4600
4980
  _overviewGroup = null;
4981
+ _overviewLinks = null;
4601
4982
  _overviewContent = null;
4602
4983
  _overviewViewport = null;
4603
4984
  _overviewMap = null;
@@ -4724,7 +5105,13 @@ function (root, d3, forester, phyloXml) {
4724
5105
  _baseSvg = d3.select(containerEl).append('svg')
4725
5106
  .attr('width', _displayWidth)
4726
5107
  .attr('height', _displayHeight)
4727
- .call(_zoomListener);
5108
+ .call(_zoomListener)
5109
+ // d3.zoom binds its own double-click-to-zoom, which scales the
5110
+ // picture -- fonts and all -- and so behaves like the wheel
5111
+ // zoom this program deliberately replaced with a re-layout.
5112
+ // It is also easy to hit by accident: a double click that
5113
+ // misses a node is a miss, not a request to magnify the tree.
5114
+ .on('dblclick.zoom', null);
4728
5115
 
4729
5116
  if (_settings.enableDynamicSizing) {
4730
5117
  d3.select(window)
@@ -4989,7 +5376,7 @@ function (root, d3, forester, phyloXml) {
4989
5376
  let properties_text = '';
4990
5377
  let prev_property_ref = null;
4991
5378
  for (let pl = 0; pl < l; ++pl) {
4992
- if (sorted_properties[pl].applies_to === 'node') {
5379
+ if (forester.isNodeScopedProperty(sorted_properties[pl])) {
4993
5380
  if (sorted_properties[pl].ref === prev_property_ref) {
4994
5381
  properties_text = addSepSame(properties_text);
4995
5382
  } else {
@@ -5222,7 +5609,7 @@ function (root, d3, forester, phyloXml) {
5222
5609
  fakeNode.y0 = 0;
5223
5610
  _root = fakeNode;
5224
5611
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
5225
- refreshVisualizations();
5612
+ refreshVisualizations(false);
5226
5613
  search0();
5227
5614
  search1();
5228
5615
  zoomToFit();
@@ -5335,7 +5722,7 @@ function (root, d3, forester, phyloXml) {
5335
5722
  forester.deleteSubtree(tree, d);
5336
5723
  _treeData = tree;
5337
5724
  _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
5338
- refreshVisualizations();
5725
+ refreshVisualizations(true);
5339
5726
  search0();
5340
5727
  search1();
5341
5728
  zoomToFit();
@@ -5350,27 +5737,39 @@ function (root, d3, forester, phyloXml) {
5350
5737
  }
5351
5738
 
5352
5739
 
5353
- // After a permanent tree edit (subtree deletion), the candidates are
5354
- // re-derived from what is left; the user's choices survive when their
5355
- // fields do. Plain subtree NAVIGATION does not come through here -- a
5356
- // subtree keeps its parent tree's visualizations, so colours stay
5357
- // stable diving in and out.
5358
- // The view changed -- into or out of a subtree, or the tree was edited.
5359
- // Candidates, menus and legends are recomputed for what is displayed; the
5360
- // user's choices survive when their fields do (otherwise the menu goes
5361
- // back to default and the tree to plain ink -- honest, not a silent
5362
- // switch); colour/shape identities persist via the memory maps; and no
5363
- // checkbox moves, because a view change is not a user choice.
5364
- function refreshVisualizations() {
5740
+ // The view changed (into or out of a subtree), or the tree was EDITED.
5741
+ //
5742
+ // A view never changes what is offered or chosen: the menu stands, the
5743
+ // colour and shape stand, and only the summaries move -- legend rows and
5744
+ // counts, the "no value" row, a numeric band -- to describe the tips on
5745
+ // screen. Diving into a one-value clade coloured by Genus shows a
5746
+ // one-row legend, not a grey tree.
5747
+ //
5748
+ // An edit re-derives the candidates from what is left, because it is a
5749
+ // different tree now. The chosen fields are kept as long as they still
5750
+ // carry a value on any remaining tip (visualizationCandidatesKeeping):
5751
+ // the refusal rules decide what is OFFERED, never what is already
5752
+ // chosen. Only a field with no value left anywhere falls back to default.
5753
+ // No checkbox moves either way, because a view change is not a user choice.
5754
+ function refreshVisualizations(edited) {
5755
+ if (edited && _domain) {
5756
+ _domain.palette = null; // the tree changed: the domain names are dealt their colours again
5757
+ }
5365
5758
  if (!_vis) {
5366
5759
  return;
5367
5760
  }
5368
- let colorId = _vis.colorId;
5369
- let shapeId = _vis.shapeId;
5370
- computeVisualizationCandidates(displayedRoot());
5371
- _vis.colorId = (colorId && _vis.byId[colorId]) ? colorId : null;
5372
- _vis.shapeId = (shapeId && _vis.byId[shapeId] && _vis.byId[shapeId].shape) ? shapeId : null;
5373
- populateVisualizationMenus();
5761
+ if (edited) {
5762
+ let colorId = _vis.colorId;
5763
+ let shapeId = _vis.shapeId;
5764
+ installCandidates(forester.visualizationCandidatesKeeping(_treeData,
5765
+ [currentColorVis(), currentShapeVis()]));
5766
+ _vis.colorId = (colorId && _vis.byId[colorId]) ? colorId : null;
5767
+ _vis.shapeId = (shapeId && _vis.byId[shapeId] && _vis.byId[shapeId].shape) ? shapeId : null;
5768
+ }
5769
+ summarizeView(displayedRoot());
5770
+ if (edited) {
5771
+ populateVisualizationMenus();
5772
+ }
5374
5773
  removeColorLegend(LEGEND_LABEL_COLOR);
5375
5774
  removeShapeLegend(LEGEND_NODE_SHAPE);
5376
5775
  }
@@ -5394,7 +5793,7 @@ function (root, d3, forester, phyloXml) {
5394
5793
  // part of the real spans (an anchor ratio taken from anything else
5395
5794
  // once flung the whole tree off-screen after three X+ presses).
5396
5795
  return {
5397
- horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve),
5796
+ horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve),
5398
5797
  vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT) - bottomOverlayReserve())
5399
5798
  };
5400
5799
  }
@@ -6126,6 +6525,448 @@ function (root, d3, forester, phyloXml) {
6126
6525
  scheduleUpdate(null, 0);
6127
6526
  }
6128
6527
 
6528
+ // ===================== Protein domain architectures =====================
6529
+ // The desktop's domain display, ported from its RenderableDomainArchitecture
6530
+ // and TreePanel (the spec is section D1 of the repo's TODO.md). Each tip's
6531
+ // architecture is a grey backbone of L residues with a rounded box per
6532
+ // domain the E-value threshold admits: in one aligned column past the
6533
+ // labels in the rectangular layout, riding the tip's spoke in the radial
6534
+ // ones. One scale for the whole tree -- f px per residue, from the track
6535
+ // width and the longest architecture -- so lengths compare across tips.
6536
+ // Which domains, where, which colour and which legend rows are forester's
6537
+ // (tested against the desktop's numbers); this is the SVG, the legend
6538
+ // card and the controls. The boxes take no pointer events: nothing to
6539
+ // hover or click, as on the desktop.
6540
+
6541
+ function domainsShown() {
6542
+ return _state.showDomainArchitectures === true
6543
+ && _basicTreeProperties.domainArchitectures === true
6544
+ && _basicTreeProperties.maxDomainArchitectureLength > 0
6545
+ && _state.showExternalLabels
6546
+ // in a radial layout the boxes ride the spokes, so only radial
6547
+ // labels go with them
6548
+ && (!radialDisplay() || !_radialLabelsHorizontal);
6549
+ }
6550
+
6551
+ // The track width W: a quarter of the viewport at first use (the
6552
+ // layout's width, if smaller); d+ / d- scale it by 1.2 / 0.8 and stop
6553
+ // at 2000 / 20.
6554
+ function domainTrackWidth() {
6555
+ if (!_domain.width) {
6556
+ let vp = svgSize();
6557
+ let vw = Math.min(_displayWidth, (vp && vp.w) ? vp.w : _displayWidth);
6558
+ _domain.width = Math.max(DOMAIN_WIDTH_MIN, Math.round(vw * DOMAIN_WIDTH_VIEWPORT_FRACTION));
6559
+ }
6560
+ return _domain.width;
6561
+ }
6562
+
6563
+ // In a radial layout the track is capped at a share of the radius, or a
6564
+ // long architecture would run off the circle.
6565
+ function domainEffectiveWidth() {
6566
+ let w = domainTrackWidth();
6567
+ if (radialDisplay()) {
6568
+ let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
6569
+ if (r > 0) {
6570
+ w = Math.min(w, DOMAIN_RADIAL_WIDTH_FRACTION * r);
6571
+ }
6572
+ }
6573
+ return w;
6574
+ }
6575
+
6576
+ // px per residue, one factor for every architecture on the tree; Lmax
6577
+ // counts every domain whatever its E-value, so the threshold never
6578
+ // rescales the tracks
6579
+ function domainScale() {
6580
+ return (domainEffectiveWidth() / _basicTreeProperties.maxDomainArchitectureLength) * DOMAIN_SCALE_HEADROOM;
6581
+ }
6582
+
6583
+ // The room the longest tip label takes: the same estimate the layout is
6584
+ // sized against (there are no font metrics in the model).
6585
+ function domainLabelSpace() {
6586
+ return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
6587
+ }
6588
+
6589
+ // The rectangular layout reserves the tracks' column so the tree and
6590
+ // labels compress to make room, as the alignment track does; the radial
6591
+ // layouts fit the tracks into the ring instead (fitRadialExtent).
6592
+ function domainReserve() {
6593
+ if (!domainsShown() || radialDisplay()) {
6594
+ return 0;
6595
+ }
6596
+ return DOMAIN_TRACK_START_GAP + domainTrackWidth() + DOMAIN_TRACK_END_GAP;
6597
+ }
6598
+
6599
+ function domainRadialExtent() {
6600
+ if (!domainsShown() || !radialDisplay()) {
6601
+ return 0;
6602
+ }
6603
+ return DOMAIN_RADIAL_GAP + domainEffectiveWidth() + DOMAIN_TRACK_END_GAP;
6604
+ }
6605
+
6606
+ // Colours: the names the threshold admits over the WHOLE tree, sorted by
6607
+ // code unit, take the palette in order. Dealt at load, after an edit and
6608
+ // whenever the threshold changes (the drawn set changes then, so colours
6609
+ // can shift); a name met later takes the next unused colour and keeps
6610
+ // it. Never derived from the name's characters -- that let SH2 and SH3
6611
+ // collide on the desktop.
6612
+ function domainPalette() {
6613
+ if (!_domain.palette) {
6614
+ let names = forester.domainSummary(forester.getTreeRoot(_treeData), _state.domainEvalueExponent).names;
6615
+ _domain.palette = Object.create(null);
6616
+ names.forEach(function (name, i) {
6617
+ _domain.palette[name] = i;
6618
+ });
6619
+ _domain.next = names.length;
6620
+ }
6621
+ return _domain.palette;
6622
+ }
6623
+
6624
+ function domainColor(name) {
6625
+ if (!name) {
6626
+ return forester.DOMAIN_UNNAMED_COLOR;
6627
+ }
6628
+ let palette = domainPalette();
6629
+ if (palette[name] === undefined) {
6630
+ palette[name] = _domain.next++;
6631
+ }
6632
+ return forester.domainQualitativeColor(palette[name]);
6633
+ }
6634
+
6635
+ function domainBoxHeight(pitch) {
6636
+ return Math.max(DOMAIN_BOX_MIN_H, Math.min(DOMAIN_BOX_MAX_H, Math.round(pitch)));
6637
+ }
6638
+
6639
+ function drawDomainArchitectures() {
6640
+ if (!_svgGroup) {
6641
+ return;
6642
+ }
6643
+ _svgGroup.selectAll('g.aptx-domains').remove();
6644
+ if (!domainsShown() || !_root) {
6645
+ return;
6646
+ }
6647
+ let tips = forester.getAllExternalNodes(_root).filter(function (d) {
6648
+ return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
6649
+ });
6650
+ let f = domainScale();
6651
+ if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
6652
+ return;
6653
+ }
6654
+ let g = _svgGroup.append('g').attr('class', 'aptx-domains').style('pointer-events', 'none');
6655
+ // one vertical gradient per base colour, lighter at the top; the
6656
+ // defs ride into the SVG / PDF / PNG exports with the boxes
6657
+ let defs = g.append('defs');
6658
+ let made = Object.create(null);
6659
+ function gradientFor(base) {
6660
+ let id = 'aptx-dom-' + base.substring(1);
6661
+ if (!made[id]) {
6662
+ made[id] = true;
6663
+ let grad = defs.append('linearGradient').attr('id', id)
6664
+ .attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', 1);
6665
+ grad.append('stop').attr('offset', '0%').attr('stop-color', forester.domainLighten(base, 0.12));
6666
+ grad.append('stop').attr('offset', '100%').attr('stop-color', forester.domainDarken(base, 0.10));
6667
+ }
6668
+ return 'url(#' + id + ')';
6669
+ }
6670
+ let labelSpace = domainLabelSpace();
6671
+ if (_state.circularDisplay) {
6672
+ // every bar starts on one ring past the labels and rides its
6673
+ // tip's spoke outward
6674
+ let r0 = _radial.maxRad + labelSpace + DOMAIN_RADIAL_GAP;
6675
+ let h = domainBoxHeight((r0 * 2 * Math.PI) / tips.length);
6676
+ tips.forEach(function (d) {
6677
+ let t = g.append('g').attr('transform', 'rotate(' + labelAngleDeg(d) + ')');
6678
+ drawOneArchitecture(t, forester.domainArchitectureOf(d), r0, -h / 2, h, f, false, gradientFor);
6679
+ });
6680
+ } else if (_state.unrootedDisplay) {
6681
+ // no common ring: each bar starts past its own tip's label
6682
+ let h = domainBoxHeight((Math.PI * 2 * _unroot.maxRad) / tips.length);
6683
+ let start = labelSpace + DOMAIN_RADIAL_GAP;
6684
+ tips.forEach(function (d) {
6685
+ let t = g.append('g').attr('transform', 'translate(' + d.ux + ',' + d.uy + ') rotate(' + labelAngleDeg(d) + ')');
6686
+ drawOneArchitecture(t, forester.domainArchitectureOf(d), start, -h / 2, h, f, false, gradientFor);
6687
+ });
6688
+ } else {
6689
+ // one aligned column: past the deepest tip and the longest label.
6690
+ // The box height follows the tip pitch (half of it), 6 to 16 px.
6691
+ tips.sort(function (p, q) {
6692
+ return p.x - q.x;
6693
+ });
6694
+ let n = tips.length;
6695
+ let pitch = n > 1 ? (tips[n - 1].x - tips[0].x) / (n - 1) : _state.externalNodeFontSize;
6696
+ let h = domainBoxHeight(pitch / 2);
6697
+ let start = _w + _state.nodeLabelGap + labelSpace + DOMAIN_TRACK_START_GAP;
6698
+ let labelsOn = _state.domainLabels === 'domains';
6699
+ tips.forEach(function (d) {
6700
+ drawOneArchitecture(g, forester.domainArchitectureOf(d), start, d.x - (h / 2), h, f, labelsOn, gradientFor);
6701
+ });
6702
+ }
6703
+ }
6704
+
6705
+ function domainRect(g, x, y, w, h, r) {
6706
+ return g.append('rect')
6707
+ .attr('x', x).attr('y', y).attr('width', w).attr('height', h)
6708
+ .attr('rx', r).attr('ry', r);
6709
+ }
6710
+
6711
+ // One architecture, in the desktop's order: the backbone, then per
6712
+ // admitted domain its stepped drop shadow, the optional glow, the
6713
+ // gradient body with its border, and -- rectangular only -- the name,
6714
+ // when it fits the box.
6715
+ function drawOneArchitecture(g, da, start, y1, h, f, labelsOn, gradientFor) {
6716
+ if (!da) {
6717
+ return;
6718
+ }
6719
+ let geo = forester.domainBoxes(da, start, f, _state.domainEvalueExponent);
6720
+ g.append('rect')
6721
+ .attr('x', geo.backbone.x).attr('y', y1 + (h / 2) - 0.5)
6722
+ .attr('width', geo.backbone.w).attr('height', 1)
6723
+ .style('fill', DOMAIN_BACKBONE_COLOR);
6724
+ let fs = Math.min(_state.externalNodeFontSize, h - 2);
6725
+ let font = fs + 'px ' + FONT_DEFAULTS;
6726
+ geo.boxes.forEach(function (b) {
6727
+ let base = domainColor(b.name);
6728
+ let r = Math.min(2, Math.min(b.w, h) / 2);
6729
+ DOMAIN_SHADOWS.forEach(function (s) {
6730
+ domainRect(g, b.x + s[0], y1 + s[1], b.w, h, r)
6731
+ .style('fill', DOMAIN_SHADOW_COLOR)
6732
+ .style('fill-opacity', s[2] / 255);
6733
+ });
6734
+ if (_state.domainGlow) {
6735
+ DOMAIN_GLOWS.forEach(function (gl) {
6736
+ let o = gl[0];
6737
+ domainRect(g, b.x - o, y1 - o, b.w + (2 * o), h + (2 * o), r + o)
6738
+ .style('fill', base)
6739
+ .style('fill-opacity', gl[1] / 255);
6740
+ });
6741
+ }
6742
+ domainRect(g, b.x, y1, b.w, h, r)
6743
+ .style('fill', gradientFor(base))
6744
+ .style('stroke', forester.domainDarken(base, 0.24))
6745
+ .style('stroke-width', 1);
6746
+ if (labelsOn && b.name && fs > 4 && legendTextWidth(b.name, font) <= b.w - 4) {
6747
+ g.append('text')
6748
+ .attr('x', b.x + (b.w / 2)).attr('y', y1 + (h / 2))
6749
+ .attr('dy', '0.35em')
6750
+ .attr('text-anchor', 'middle')
6751
+ .style('font', font)
6752
+ .style('fill', forester.domainLabelInk(base))
6753
+ .text(b.name);
6754
+ }
6755
+ });
6756
+ }
6757
+
6758
+ function clipTextToWidth(text, font, maxPx) {
6759
+ if (legendTextWidth(text, font) <= maxPx) {
6760
+ return text;
6761
+ }
6762
+ let s = text;
6763
+ while (s.length > 1 && legendTextWidth(s + '…', font) > maxPx) {
6764
+ s = s.substring(0, s.length - 1);
6765
+ }
6766
+ return s + '…';
6767
+ }
6768
+
6769
+ // The domain legend, in "Legend" mode only: the names drawn in the view,
6770
+ // in first-appearance order, each with its box count; a flat swatch per
6771
+ // row. Its home is the bottom-right corner; a drag keeps its place as a
6772
+ // fraction of the view, a double-click sends it home. Its own card, so
6773
+ // it neither follows nor crowds the Color-by legend.
6774
+ function drawDomainLegend() {
6775
+ if (!_baseSvg) {
6776
+ return;
6777
+ }
6778
+ _baseSvg.selectAll('g.' + LEGEND_DOMAINS).remove();
6779
+ if (!_root || !domainsShown() || _state.domainLabels !== 'legend') {
6780
+ return;
6781
+ }
6782
+ // rows in the order the tips are on screen -- top to bottom, or
6783
+ // clockwise -- not the data's order, which a ladderized display
6784
+ // does not follow
6785
+ let tips = forester.getAllExternalNodes(_root).filter(function (d) {
6786
+ return d.x !== undefined;
6787
+ }).sort(function (p, q) {
6788
+ return p.x - q.x;
6789
+ });
6790
+ let summary = forester.domainSummary(tips, _state.domainEvalueExponent);
6791
+ let size = svgSize();
6792
+ if (summary.legend.length === 0 || !size) {
6793
+ return;
6794
+ }
6795
+ const FS = Math.max(11, _state.externalNodeFontSize || 11);
6796
+ const PAD = 9;
6797
+ const ROW = FS + 6;
6798
+ const SWATCH = 10;
6799
+ const GAP = 6;
6800
+ const rowFont = FS + 'px ' + FONT_DEFAULTS;
6801
+ const titleFont = '600 ' + (FS + 1) + 'px ' + FONT_DEFAULTS;
6802
+ const ink = _state.labelColorDefault;
6803
+ const frame = _state.branchColorDefault;
6804
+ let title = 'Protein domains (E ≤ 1e' + _state.domainEvalueExponent + ')';
6805
+ let rows = summary.legend.map(function (r) {
6806
+ return {
6807
+ text: clipTextToWidth(r.name + ' (' + r.count + ')', rowFont, DOMAIN_LEGEND_ROW_MAX_PX),
6808
+ color: domainColor(r.name)
6809
+ };
6810
+ });
6811
+ let width = legendTextWidth(title, titleFont);
6812
+ rows.forEach(function (r) {
6813
+ width = Math.max(width, SWATCH + GAP + legendTextWidth(r.text, rowFont));
6814
+ });
6815
+ width += 2 * PAD;
6816
+ let height = PAD + ROW + (rows.length * ROW) + PAD - 2;
6817
+ let x, y;
6818
+ if (_domain.legendFrac) {
6819
+ x = _domain.legendFrac.fx * size.w;
6820
+ y = _domain.legendFrac.fy * size.h;
6821
+ } else {
6822
+ x = size.w - width - DOMAIN_LEGEND_INSET;
6823
+ y = size.h - height - DOMAIN_LEGEND_INSET;
6824
+ }
6825
+ x = Math.max(0, Math.min(size.w - 20, x));
6826
+ y = Math.max(0, Math.min(size.h - 20, y));
6827
+
6828
+ let g = _baseSvg.append('g').attr('class', LEGEND_DOMAINS)
6829
+ .style('cursor', 'move')
6830
+ .call(d3.drag()
6831
+ .on('start', function (event) {
6832
+ if (event.sourceEvent) {
6833
+ event.sourceEvent.stopPropagation(); // not a pan of the tree
6834
+ }
6835
+ })
6836
+ .on('drag', function (event) {
6837
+ x += event.dx;
6838
+ y += event.dy;
6839
+ _domain.legendFrac = {fx: x / size.w, fy: y / size.h};
6840
+ drawDomainLegend();
6841
+ }))
6842
+ .on('dblclick', function (event) {
6843
+ event.stopPropagation();
6844
+ _domain.legendFrac = null;
6845
+ drawDomainLegend();
6846
+ });
6847
+ g.append('title').text('drag to move; double-click to send it back to the corner');
6848
+ g.append('rect')
6849
+ .attr('x', x).attr('y', y)
6850
+ .attr('width', width).attr('height', height)
6851
+ .attr('rx', 5)
6852
+ .style('fill', _state.backgroundColorDefault)
6853
+ .style('fill-opacity', 0.92)
6854
+ .style('stroke', frame)
6855
+ .style('stroke-opacity', 0.5);
6856
+ let baseline = y + PAD + FS;
6857
+ g.append('text')
6858
+ .attr('x', x + PAD).attr('y', baseline)
6859
+ .style('font', titleFont)
6860
+ .style('fill', ink)
6861
+ .text(title);
6862
+ rows.forEach(function (r) {
6863
+ baseline += ROW;
6864
+ g.append('rect')
6865
+ .attr('x', x + PAD).attr('y', baseline - SWATCH + 1)
6866
+ .attr('width', SWATCH).attr('height', SWATCH)
6867
+ .attr('rx', 2)
6868
+ .style('fill', r.color);
6869
+ g.append('text')
6870
+ .attr('x', x + PAD + SWATCH + GAP).attr('y', baseline)
6871
+ .style('font', rowFont)
6872
+ .style('fill', ink)
6873
+ .text(r.text);
6874
+ });
6875
+ }
6876
+
6877
+ // The controls follow the toggle: hidden without it, and while shown
6878
+ // the readout, the select, the glow box and the four step buttons say
6879
+ // where things stand (a button at its limit is disabled, not a no-op).
6880
+ function syncDomainControls() {
6881
+ let fs = byId(DOMAIN_CONTROLS);
6882
+ if (!fs) {
6883
+ return;
6884
+ }
6885
+ let on = _state.showDomainArchitectures === true && _basicTreeProperties.domainArchitectures === true;
6886
+ setCheckboxValue(DOMAINS_CB, _state.showDomainArchitectures === true);
6887
+ fs.style.display = on ? '' : 'none';
6888
+ if (!on) {
6889
+ return;
6890
+ }
6891
+ let readout = byId(DOMAIN_EVALUE_READOUT);
6892
+ if (readout) {
6893
+ readout.textContent = forester.domainEvalueLabel(_state.domainEvalueExponent);
6894
+ }
6895
+ let w = domainTrackWidth();
6896
+ (w > DOMAIN_WIDTH_MIN ? enableButton : disableButton)(byId(DOMAIN_WIDTH_DEC));
6897
+ (w < DOMAIN_WIDTH_MAX ? enableButton : disableButton)(byId(DOMAIN_WIDTH_INC));
6898
+ (_state.domainEvalueExponent > forester.DOMAIN_EVALUE_EXPONENT_MIN ? enableButton : disableButton)(byId(DOMAIN_EVALUE_DEC));
6899
+ (_state.domainEvalueExponent < forester.DOMAIN_EVALUE_EXPONENT_MAX ? enableButton : disableButton)(byId(DOMAIN_EVALUE_INC));
6900
+ setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
6901
+ setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
6902
+ }
6903
+
6904
+ function domainsCbClicked() {
6905
+ _state.showDomainArchitectures = getCheckboxValue(DOMAINS_CB);
6906
+ if (_state.showDomainArchitectures && radialDisplay()) {
6907
+ _radialLabelsHorizontal = false; // the boxes ride the spokes, so the labels must too
6908
+ syncZoomRowButtons();
6909
+ }
6910
+ syncDomainControls();
6911
+ scheduleUpdate(null, 0);
6912
+ }
6913
+
6914
+ // d+ / d-: the width grows by 1.2 while under 2000, shrinks by 0.8 while
6915
+ // over 20 (so one press can carry it past a limit; the next is refused)
6916
+ function domainWidthStep(grow) {
6917
+ let w = domainTrackWidth();
6918
+ if (grow ? w >= DOMAIN_WIDTH_MAX : w <= DOMAIN_WIDTH_MIN) {
6919
+ return;
6920
+ }
6921
+ _domain.width = w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK);
6922
+ syncDomainControls();
6923
+ scheduleUpdate(null, 0);
6924
+ }
6925
+
6926
+ function domainEvalueStep(delta) {
6927
+ let e = _state.domainEvalueExponent + delta;
6928
+ if (e < forester.DOMAIN_EVALUE_EXPONENT_MIN || e > forester.DOMAIN_EVALUE_EXPONENT_MAX) {
6929
+ return;
6930
+ }
6931
+ _state.domainEvalueExponent = e;
6932
+ _domain.palette = null; // the drawn set changed: the palette is dealt again
6933
+ syncDomainControls();
6934
+ scheduleUpdate(null, 0, true);
6935
+ }
6936
+
6937
+ function domainLabelsChanged() {
6938
+ _state.domainLabels = getValue(DOMAIN_LABELS_SELECT);
6939
+ scheduleUpdate(null, 0, true);
6940
+ }
6941
+
6942
+ function domainGlowCbClicked() {
6943
+ _state.domainGlow = getCheckboxValue(DOMAIN_GLOW_CB);
6944
+ scheduleUpdate(null, 0, true);
6945
+ }
6946
+
6947
+ // A step button that repeats while held: one step on press, then after
6948
+ // a pause one every 90 ms until release (the desktop's auto-repeat on
6949
+ // d- / d+). A keyboard press steps once.
6950
+ function onHoldRepeat(id, step) {
6951
+ let delay = null;
6952
+ let repeat = null;
6953
+ onHold(id, function () {
6954
+ step();
6955
+ delay = setTimeout(function () {
6956
+ repeat = setInterval(step, 90);
6957
+ }, 400);
6958
+ }, function () {
6959
+ if (delay) {
6960
+ clearTimeout(delay);
6961
+ delay = null;
6962
+ }
6963
+ if (repeat) {
6964
+ clearInterval(repeat);
6965
+ repeat = null;
6966
+ }
6967
+ });
6968
+ }
6969
+
6129
6970
  // Search B starts hidden to keep the panel compact; one click (or a
6130
6971
  // configured initial value) reveals it, and it stays revealed.
6131
6972
  function revealSearchB() {
@@ -6465,7 +7306,7 @@ function (root, d3, forester, phyloXml) {
6465
7306
  return;
6466
7307
  }
6467
7308
  let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
6468
- let outer = maxRad + labelSpace;
7309
+ let outer = maxRad + labelSpace + domainRadialExtent();
6469
7310
  let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
6470
7311
  let scale = 0.9 * (Math.min(W, H) / (2 * outer));
6471
7312
  if (!isFinite(scale) || scale <= 0) {
@@ -6496,7 +7337,7 @@ function (root, d3, forester, phyloXml) {
6496
7337
  _root = _root_const;
6497
7338
  _in_subtree = false;
6498
7339
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
6499
- refreshVisualizations();
7340
+ refreshVisualizations(false);
6500
7341
  search0();
6501
7342
  search1();
6502
7343
  zoomToFit();
@@ -6535,7 +7376,7 @@ function (root, d3, forester, phyloXml) {
6535
7376
  }
6536
7377
 
6537
7378
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
6538
- refreshVisualizations();
7379
+ refreshVisualizations(false);
6539
7380
  search0();
6540
7381
  search1();
6541
7382
  zoomToFit();
@@ -6608,7 +7449,7 @@ function (root, d3, forester, phyloXml) {
6608
7449
  _radialLabelsHorizontal = false;
6609
7450
  _msaColOffset = 0;
6610
7451
  syncZoomRowButtons();
6611
- refreshVisualizations();
7452
+ refreshVisualizations(false);
6612
7453
  // Esc resets to the launch state -- the auto-applied colour, when its
6613
7454
  // field still exists in what remains of the tree.
6614
7455
  if (_vis) {
@@ -6763,12 +7604,10 @@ function (root, d3, forester, phyloXml) {
6763
7604
 
6764
7605
  // Read a search box's current field / mode / value(s) into a spec.
6765
7606
  function currentSearchSpec(idx) {
6766
- let key = getValue(idx === 0 ? SEARCH_FIELD_SELECT_0 : SEARCH_FIELD_SELECT_1);
6767
- let field = null;
6768
- for (let i = 0; i < _searchFields.length; ++i) {
6769
- if (_searchFields[i].key === key) { field = _searchFields[i]; break; }
6770
- }
6771
- if (!field) field = _searchFields[0] || { key: 'ANY', label: 'Any Text', numeric: false };
7607
+ // the field menu's option values index _searchFields; before a tree
7608
+ // is loaded there is only Any Text
7609
+ let field = _searchFields[Number(getValue(idx === 0 ? SEARCH_FIELD_SELECT_0 : SEARCH_FIELD_SELECT_1))]
7610
+ || _searchFields[0] || forester.availableSearchFields(null)[0];
6772
7611
  let mode = getValue(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
6773
7612
  if (!mode) mode = field.numeric ? 'eq' : 'contains';
6774
7613
  return {
@@ -6793,20 +7632,23 @@ function (root, d3, forester, phyloXml) {
6793
7632
  // Fill both search-field dropdowns from the loaded tree's available fields,
6794
7633
  // then set up each box's mode menu. Called when a tree is (re)loaded.
6795
7634
  function populateSearchMenus() {
7635
+ let before = _searchFields;
6796
7636
  _searchFields = forester.availableSearchFields(_root);
6797
7637
  [SEARCH_FIELD_SELECT_0, SEARCH_FIELD_SELECT_1].forEach(function (selId) {
6798
7638
  let sel = byId(selId);
6799
7639
  if (!sel) return;
6800
- let prev = sel.value;
7640
+ // a box keeps its field when the new tree still offers it (labels
7641
+ // are unique: a property's label is its ref, which has a colon)
7642
+ let prev = before[Number(sel.value)];
7643
+ let keep = prev ? _searchFields.findIndex(f => f.label === prev.label) : -1;
6801
7644
  sel.innerHTML = '';
6802
7645
  for (let i = 0; i < _searchFields.length; ++i) {
6803
7646
  let opt = document.createElement('option');
6804
- opt.value = _searchFields[i].key;
7647
+ opt.value = String(i);
6805
7648
  opt.textContent = _searchFields[i].label;
6806
7649
  sel.appendChild(opt);
6807
7650
  }
6808
- if (prev && _searchFields.some(f => f.key === prev)) sel.value = prev;
6809
- else sel.value = 'ANY';
7651
+ sel.value = String(keep >= 0 ? keep : 0);
6810
7652
  });
6811
7653
  populateSearchModeMenu(0);
6812
7654
  populateSearchModeMenu(1);
@@ -6861,7 +7703,7 @@ function (root, d3, forester, phyloXml) {
6861
7703
  let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
6862
7704
  if (!dl || !input) return;
6863
7705
  let spec = currentSearchSpec(idx);
6864
- let enable = !spec.field.numeric && spec.field.key !== 'ANY' && spec.field.key !== 'MS' && spec.mode !== 'regex';
7706
+ let enable = !spec.field.numeric && spec.field.suggest !== false && spec.mode !== 'regex';
6865
7707
  dl.innerHTML = '';
6866
7708
  if (!enable) { input.removeAttribute('list'); return; }
6867
7709
  let vals = forester.distinctSearchValues(_root, spec.field, SEARCH_AUTOCOMPLETE_CAP);
@@ -6898,6 +7740,10 @@ function (root, d3, forester, phyloXml) {
6898
7740
  function layoutButtonClicked() {
6899
7741
  _state.circularDisplay = getCheckboxValue(LAYOUT_CIRC_BUTTON);
6900
7742
  _state.unrootedDisplay = getCheckboxValue(LAYOUT_UNROOTED_BUTTON);
7743
+ if (radialDisplay() && _state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
7744
+ // the domain boxes ride the tips' spokes, so the labels must too
7745
+ _radialLabelsHorizontal = false;
7746
+ }
6901
7747
  syncZoomRowButtons();
6902
7748
  zoomToFit();
6903
7749
  }
@@ -7744,9 +8590,13 @@ function (root, d3, forester, phyloXml) {
7744
8590
  // The "working" card shown over the tree area while a big tree is
7745
8591
  // drawn. It covers the whole container so nothing underneath --
7746
8592
  // panel included -- can be clicked into a half-built view.
8593
+ // cursor stays default on purpose: `progress` draws the macOS
8594
+ // beach ball next to the pointer, which reads as "the program has
8595
+ // hung" when the card is already saying exactly what is happening
8596
+ // and for how many nodes. One clear signal, not two.
7747
8597
  + '.aptx-busy { position:absolute; inset:0; z-index:1000; display:grid; place-items:center;'
7748
8598
  + ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
7749
- + ' font-size:12px; color:var(--p-ink); cursor:progress; }'
8599
+ + ' font-size:12px; color:var(--p-ink); cursor:default; }'
7750
8600
  + '.aptx-busy-card { display:flex; align-items:center; gap:10px; padding:10px 16px 10px 13px;'
7751
8601
  + ' border:1px solid var(--p-line-strong); border-radius:12px; background:var(--p-bg);'
7752
8602
  + ' box-shadow:0 24px 48px -16px rgba(23,34,46,0.45),0 4px 12px -4px rgba(23,34,46,0.22); }'
@@ -7938,6 +8788,11 @@ function (root, d3, forester, phyloXml) {
7938
8788
  + '.aptx-panel .aptx-zoomrow { display:flex; }'
7939
8789
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn { flex:1 1 0; padding:0; }'
7940
8790
  + '.aptx-panel .aptx-zoomrow .aptx-gbtn:last-child { margin-right:0; }'
8791
+ + '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
8792
+ + '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
8793
+ + '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
8794
+ + '.aptx-panel .aptx-domrow select { flex:1 1 auto; min-width:0; }'
8795
+ + '.aptx-panel .aptx-domreadout { flex:1 1 auto; text-align:center; font-size:12px; font-variant-numeric:tabular-nums; color:var(--p-ink); }'
7941
8796
  + '.aptx-panel .aptx-glyph { height:14px; width:auto; display:block; overflow:visible; }'
7942
8797
  + '.aptx-panel .aptx-seg .aptx-glyph { height:13px; }'
7943
8798
  + '.aptx-panel input[type=text],.aptx-panel select { font-family:inherit; font-size:11px; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; max-width:100%; padding:3px 6px; }'
@@ -8259,6 +9114,32 @@ function (root, d3, forester, phyloXml) {
8259
9114
  if (s.type) {
8260
9115
  text += '- Type: ' + s.type + '<br>';
8261
9116
  }
9117
+ // The domain architecture, which phyloXML has always carried
9118
+ // and this dialog has never shown: a reader could find a node
9119
+ // by searching for one of its domains -- forester indexes them
9120
+ // as the "Domain" search field -- and then see nothing about it
9121
+ // here. Listed in POSITION order rather than file order, since
9122
+ // an architecture is a line along the protein and reading it
9123
+ // out of order tells you nothing.
9124
+ let da = s.domain_architecture;
9125
+ if (da && da.domains && da.domains.length > 0) {
9126
+ text += '- Domain architecture: '
9127
+ + (da.length ? da.length + ' aa, ' : '')
9128
+ + da.domains.length + (da.domains.length === 1 ? ' domain' : ' domains')
9129
+ + '<br>';
9130
+ da.domains.slice().sort(function (a, b) {
9131
+ return (a.from - b.from) || (a.to - b.to);
9132
+ }).forEach(function (dom) {
9133
+ let name = dom.name || '(unnamed)';
9134
+ let span = (dom.from !== undefined && dom.to !== undefined)
9135
+ ? dom.from + '-' + dom.to : '';
9136
+ // the confidence on a domain is its e-value; an
9137
+ // explicit 0 is a real score, so test for undefined
9138
+ let ev = (dom.confidence !== undefined && dom.confidence !== null)
9139
+ ? ' (E=' + dom.confidence + ')' : '';
9140
+ text += '-- ' + name + ': ' + span + ev + '<br>';
9141
+ });
9142
+ }
8262
9143
  }
8263
9144
  }
8264
9145
  if (d.events) {
@@ -8668,6 +9549,8 @@ function (root, d3, forester, phyloXml) {
8668
9549
 
8669
9550
  c0.insertAdjacentHTML('beforeend',makeDisplayControl());
8670
9551
 
9552
+ c0.insertAdjacentHTML('beforeend',makeDomainControls());
9553
+
8671
9554
  c0.insertAdjacentHTML('beforeend',makeZoomControl());
8672
9555
 
8673
9556
 
@@ -8775,6 +9658,13 @@ function (root, d3, forester, phyloXml) {
8775
9658
 
8776
9659
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
8777
9660
  on(MSA_CB, 'click', msaCbClicked);
9661
+ on(DOMAINS_CB, 'click', domainsCbClicked);
9662
+ onHoldRepeat(DOMAIN_WIDTH_DEC, function () { domainWidthStep(false); });
9663
+ onHoldRepeat(DOMAIN_WIDTH_INC, function () { domainWidthStep(true); });
9664
+ on(DOMAIN_EVALUE_DEC, 'click', function () { domainEvalueStep(-1); });
9665
+ on(DOMAIN_EVALUE_INC, 'click', function () { domainEvalueStep(1); });
9666
+ on(DOMAIN_LABELS_SELECT, 'change', domainLabelsChanged);
9667
+ on(DOMAIN_GLOW_CB, 'click', domainGlowCbClicked);
8778
9668
  on(TIME_AXIS_CB, 'click', timeAxisCbClicked);
8779
9669
  on(TIME_GRID_CB, 'click', timeGridCbClicked);
8780
9670
 
@@ -9242,6 +10132,9 @@ function (root, d3, forester, phyloXml) {
9242
10132
  if (_basicTreeProperties.alignedMolSeqs && _basicTreeProperties.maxMolSeqLength > 0) {
9243
10133
  opts.push(makeCheckboxItem('Alignment', MSA_CB, 'to show/hide the sequence alignment beside the tree (rectangular layout only)'));
9244
10134
  }
10135
+ if (_basicTreeProperties.domainArchitectures) {
10136
+ opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
10137
+ }
9245
10138
  if (_timeInfo && _timeInfo.type) {
9246
10139
  opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
9247
10140
  + (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
@@ -9319,6 +10212,35 @@ function (root, d3, forester, phyloXml) {
9319
10212
  return h;
9320
10213
  }
9321
10214
 
10215
+ // The desktop's domain controls, shown only while the Domain
10216
+ // Architectures toggle is on (syncDomainControls): the track width,
10217
+ // the E-value threshold with its readout, the label mode, the glow.
10218
+ function makeDomainControls() {
10219
+ let h = '<fieldset id="' + DOMAIN_CONTROLS + '" style="display:none">';
10220
+ h = h.concat('<legend>Domain Architectures</legend>');
10221
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel">Track width</span>');
10222
+ h = h.concat(makeButton('−', DOMAIN_WIDTH_DEC, 'narrow the domain tracks (hold to repeat)'));
10223
+ h = h.concat(makeButton('+', DOMAIN_WIDTH_INC, 'widen the domain tracks (hold to repeat)'));
10224
+ h = h.concat('</div>');
10225
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel">E-value ≤</span>');
10226
+ h = h.concat(makeButton('−', DOMAIN_EVALUE_DEC, 'Decrease the E-value threshold by a factor of 10'));
10227
+ h = h.concat('<span class="aptx-domreadout" id="' + DOMAIN_EVALUE_READOUT
10228
+ + '" title="domains with an E-value up to this are drawn"></span>');
10229
+ h = h.concat(makeButton('+', DOMAIN_EVALUE_INC, 'Increase the E-value threshold by a factor of 10'));
10230
+ h = h.concat('</div>');
10231
+ h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + DOMAIN_LABELS_SELECT + '">Labels</label>');
10232
+ h = h.concat('<select name="' + DOMAIN_LABELS_SELECT + '" id="' + DOMAIN_LABELS_SELECT
10233
+ + '" title="where the domain names go: on the boxes (rectangular layout only), in a legend, or nowhere">');
10234
+ h = h.concat('<option value="domains">On domains</option>');
10235
+ h = h.concat('<option value="legend">Legend</option>');
10236
+ h = h.concat('<option value="none">None</option>');
10237
+ h = h.concat('</select></div>');
10238
+ h = h.concat('<div class="aptx-checkgrid">'
10239
+ + makeCheckboxItem('Glow', DOMAIN_GLOW_CB, 'a soft glow in each domain\'s own colour around its box') + '</div>');
10240
+ h = h.concat('</fieldset>');
10241
+ return h;
10242
+ }
10243
+
9322
10244
  function makeSliders() {
9323
10245
  let h = "";
9324
10246
  h = h.concat('<fieldset>');
@@ -9500,6 +10422,7 @@ function (root, d3, forester, phyloXml) {
9500
10422
  setCheckboxValue(VIS_CB, _state.showVisualizations);
9501
10423
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
9502
10424
  setCheckboxValue(MSA_CB, _state.showMsa);
10425
+ syncDomainControls();
9503
10426
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
9504
10427
  setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
9505
10428
  setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);