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