archaeopteryx 3.3.0 → 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 +143 -11
- package/archaeopteryx.d.ts +100 -10
- package/archaeopteryx.js +2059 -277
- package/forester.js +288 -2
- package/package.json +2 -2
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';
|
|
@@ -371,6 +371,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
371
371
|
const INTERNAL_LABEL_CB = 'intl_cb';
|
|
372
372
|
const LABEL_COLOR_SELECT_MENU = 'lcs_menu';
|
|
373
373
|
const MIDPOINT_ROOT_BUTTON = 'midpointr_b';
|
|
374
|
+
const UNCOLLAPSE_ALL_BUTTON = 'uncollapse_all_b';
|
|
375
|
+
const TREE_PREV_BUTTON = 'tree_prev_b';
|
|
376
|
+
const TREE_NEXT_BUTTON = 'tree_next_b';
|
|
377
|
+
const TREE_SELECT = 'tree_select';
|
|
374
378
|
// The desktop Archaeopteryx logo (forester/archaeopteryx_icon_assets/
|
|
375
379
|
// archaeopteryx-anime.svg), inlined so the library stays a single file.
|
|
376
380
|
// Gradient ids are prefixed: they were generic enough to collide with an
|
|
@@ -412,8 +416,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
412
416
|
const SEARCH_MODE_SELECT_1 = 'sms1';
|
|
413
417
|
const SEARCH_VALUE2_0 = 'sv2_0';
|
|
414
418
|
const SEARCH_VALUE2_1 = 'sv2_1';
|
|
415
|
-
const SEARCH_DATALIST_0 = 'sdl0';
|
|
416
|
-
const SEARCH_DATALIST_1 = 'sdl1';
|
|
417
419
|
const SEARCH_AUTOCOMPLETE_CAP = 2000;
|
|
418
420
|
const SEARCH_COMBINE_SELECT = 'scmb';
|
|
419
421
|
const SEARCH_COMBINE_ROW = 'scmb_row';
|
|
@@ -500,6 +502,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
500
502
|
let _floatGroup = null;
|
|
501
503
|
let _floatStrips = {}; // class -> {top, height} in tree coordinates
|
|
502
504
|
let _treeData = null;
|
|
505
|
+
let _trees = []; // every tree of the launch (one, or all a file held); _treeData is _trees[_treeIndex]
|
|
506
|
+
let _treeIndex = 0;
|
|
507
|
+
let _launchConfig = null; // the config as launch() received it, for showTree()
|
|
508
|
+
let _viewOps = {order: null, root: null}; // the tree operations a shared view replays: ladderize direction, midpoint rooting
|
|
509
|
+
let _lastViewEncoded; // the view last reported to onViewChange; undefined = not settled yet
|
|
510
|
+
let _viewNotifyPending = false;
|
|
503
511
|
let _treeFn = null;
|
|
504
512
|
let _vis = null; // the automatic visualizations: candidates, scales, choices
|
|
505
513
|
let _w = null;
|
|
@@ -591,13 +599,32 @@ function (root, d3, forester, phyloXml) {
|
|
|
591
599
|
let _zoomed_x_or_y = false;
|
|
592
600
|
let _node_mouseover_div;
|
|
593
601
|
|
|
594
|
-
|
|
602
|
+
// The displayed tree's own root: the single child of _root, which is the
|
|
603
|
+
// parsed tree's invisible wrapper (the <phylogeny> element, or the node
|
|
604
|
+
// above a Newick root) or, in a subtree view, the fake node holding the
|
|
605
|
+
// clade. Null if _root is not such a wrapper.
|
|
606
|
+
function topNode() {
|
|
607
|
+
return (_root && _root.children && _root.children.length === 1) ? _root.children[0] : null;
|
|
608
|
+
}
|
|
595
609
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
610
|
+
function branchLengthScaling(nodes, width) {
|
|
611
|
+
// Distances start at the displayed tree's root. When the whole tree
|
|
612
|
+
// is shown and the file gives that root a branch length, the branch
|
|
613
|
+
// is drawn to scale (Christian, 2026-09-13; it used to be replaced by
|
|
614
|
+
// half the average branch length). A subtree's root starts at 0
|
|
615
|
+
// whatever its branch was: the branch above it is not on screen, and
|
|
616
|
+
// its parent's distance is from another layout -- that stale sum
|
|
617
|
+
// used to draw a root branch as long as the clade's distance from
|
|
618
|
+
// the original root.
|
|
619
|
+
let top = topNode();
|
|
599
620
|
forester.preOrderTraversalAll(_root, function (n) {
|
|
600
|
-
|
|
621
|
+
if (n === _root) {
|
|
622
|
+
n.distToRoot = 0;
|
|
623
|
+
} else if (n === top) {
|
|
624
|
+
n.distToRoot = _in_subtree ? 0 : bl(n);
|
|
625
|
+
} else {
|
|
626
|
+
n.distToRoot = n.parent.distToRoot + bl(n);
|
|
627
|
+
}
|
|
601
628
|
});
|
|
602
629
|
let distsToRoot = nodes.map(function (n) {
|
|
603
630
|
return n.distToRoot;
|
|
@@ -612,15 +639,33 @@ function (root, d3, forester, phyloXml) {
|
|
|
612
639
|
return yScale;
|
|
613
640
|
|
|
614
641
|
function bl(node) {
|
|
615
|
-
|
|
616
|
-
return 0;
|
|
617
|
-
} else if (!node.parent || !node.parent.parent) {
|
|
618
|
-
return _basicTreeProperties.averageBranchLength * 0.5;
|
|
619
|
-
}
|
|
620
|
-
return node.branch_length;
|
|
642
|
+
return (!node.branch_length || node.branch_length < 0) ? 0 : node.branch_length;
|
|
621
643
|
}
|
|
622
644
|
}
|
|
623
645
|
|
|
646
|
+
// The root stub (Christian, 2026-09-13): a short branch into the
|
|
647
|
+
// displayed tree's root, drawn by putting the wrapper node ROOT_STUB_PX
|
|
648
|
+
// to the left of it so their link has that length. Shown in the
|
|
649
|
+
// rectangular layouts for a rooted tree -- a phyloXML rooted="false" or
|
|
650
|
+
// a Nexus [&U] tree shows none -- and in every subtree view, whose root
|
|
651
|
+
// is a clade's, whatever the tree says. When the whole tree is a
|
|
652
|
+
// phylogram and the file gives the root a length, that branch is drawn
|
|
653
|
+
// to scale instead. The radial layouts never draw a stub: the wrapper
|
|
654
|
+
// sits on the root.
|
|
655
|
+
const ROOT_STUB_PX = 12;
|
|
656
|
+
|
|
657
|
+
function placeRootStub() {
|
|
658
|
+
let top = topNode();
|
|
659
|
+
if (!top) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (_state.phylogram && !_in_subtree && top.y > _root.y) {
|
|
663
|
+
return; // a real root branch, to scale
|
|
664
|
+
}
|
|
665
|
+
let rooted = _in_subtree || (_treeData && _treeData.rooted !== false);
|
|
666
|
+
_root.y = (radialDisplay() || !rooted) ? top.y : top.y - ROOT_STUB_PX;
|
|
667
|
+
}
|
|
668
|
+
|
|
624
669
|
// Current zoom scale (k) from the zoom behavior's stored transform.
|
|
625
670
|
function currentZoomScale() {
|
|
626
671
|
return _baseSvg ? d3.zoomTransform(_baseSvg.node()).k : 1;
|
|
@@ -1032,6 +1077,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
1032
1077
|
// view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
|
|
1033
1078
|
// no by precisely 254px and the overview appeared after every Fit.
|
|
1034
1079
|
maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
|
|
1080
|
+
if (scaleBarShown()) {
|
|
1081
|
+
maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
|
|
1082
|
+
}
|
|
1035
1083
|
let h = maxY - minY;
|
|
1036
1084
|
return {x: minX, y: minY, width: maxX - minX, height: h > 0 ? h : 1};
|
|
1037
1085
|
}
|
|
@@ -1103,7 +1151,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1103
1151
|
// collapsed clade has no drawn position.
|
|
1104
1152
|
let hits = [];
|
|
1105
1153
|
if (_root) {
|
|
1106
|
-
|
|
1154
|
+
forEachDisplayed(function (n) {
|
|
1107
1155
|
let c = getFoundColor(n);
|
|
1108
1156
|
if (c) {
|
|
1109
1157
|
hits.push({node: n, color: c});
|
|
@@ -1420,6 +1468,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
1420
1468
|
if (item.danger) {
|
|
1421
1469
|
b.className = 'aptx-menu-danger';
|
|
1422
1470
|
}
|
|
1471
|
+
if (item.disabled) {
|
|
1472
|
+
b.disabled = true; // offered but not applicable here, as the desktop greys it
|
|
1473
|
+
}
|
|
1423
1474
|
b.addEventListener('click', function (e) {
|
|
1424
1475
|
e.stopPropagation();
|
|
1425
1476
|
removeNodeMenu();
|
|
@@ -2001,8 +2052,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
2001
2052
|
// its scales from that: the whole tree at launch and after an edit, the
|
|
2002
2053
|
// displayed subtree on every view change.
|
|
2003
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);
|
|
2004
2066
|
_vis.candidates.forEach(function (c) {
|
|
2005
|
-
let s = forester.visualizationSummary(c,
|
|
2067
|
+
let s = forester.visualizationSummary(c, tips);
|
|
2006
2068
|
c.values = s.values;
|
|
2007
2069
|
c.counts = s.counts;
|
|
2008
2070
|
c.coverage = s.coverage;
|
|
@@ -2625,17 +2687,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
2625
2687
|
- bottomOverlayReserve());
|
|
2626
2688
|
_treeFn = _treeFn.size([_clusterH, _w]);
|
|
2627
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
|
+
};
|
|
2628
2697
|
_treeFn = _treeFn.separation(function separation(a, b) {
|
|
2629
|
-
return a
|
|
2698
|
+
return (leafWeight(a) + leafWeight(b)) / 2;
|
|
2630
2699
|
});
|
|
2631
2700
|
|
|
2632
|
-
let uncollsed_nodes = forester.calcSumOfExternalDescendants(_root);
|
|
2633
2701
|
// d3 v7: d3.cluster() lays out a d3.hierarchy rather than mutating our
|
|
2634
2702
|
// own nodes in place (as d3 v3's d3.layout.cluster().nodes() did). Run
|
|
2635
2703
|
// the layout on a hierarchy, then copy the computed x/y/depth back onto
|
|
2636
|
-
// the forester nodes so the rest of the renderer is unchanged.
|
|
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.
|
|
2637
2706
|
let hierarchy = d3.hierarchy(_root, function (d) {
|
|
2638
|
-
return d.children;
|
|
2707
|
+
return isCollapsed(d) ? null : d.children;
|
|
2639
2708
|
});
|
|
2640
2709
|
_treeFn(hierarchy);
|
|
2641
2710
|
hierarchy.each(function (hn) {
|
|
@@ -2646,6 +2715,48 @@ function (root, d3, forester, phyloXml) {
|
|
|
2646
2715
|
let nodes = hierarchy.descendants().map(function (hn) {
|
|
2647
2716
|
return hn.data;
|
|
2648
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
|
+
}
|
|
2649
2760
|
|
|
2650
2761
|
// The dim gate: only while a hit is actually DRAWN, so the tree never
|
|
2651
2762
|
// washes out with nothing emphasised (a 0-hit search, or every hit
|
|
@@ -2668,6 +2779,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
2668
2779
|
if (_state.phylogram === true) {
|
|
2669
2780
|
_yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
|
|
2670
2781
|
}
|
|
2782
|
+
placeRootStub();
|
|
2671
2783
|
|
|
2672
2784
|
if (_state.circularDisplay) {
|
|
2673
2785
|
let maxY = 0;
|
|
@@ -2691,17 +2803,26 @@ function (root, d3, forester, phyloXml) {
|
|
|
2691
2803
|
// branchLengthScaling just wrote) and are one constant step per
|
|
2692
2804
|
// level in cladogram mode; the radial budget matches circular's.
|
|
2693
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;
|
|
2694
2813
|
let lengthOf;
|
|
2814
|
+
let pxPerUnit = 0; // the phylogram's scale, for the scale bar
|
|
2695
2815
|
if (_state.phylogram) {
|
|
2696
2816
|
let maxDist = 0;
|
|
2697
|
-
forester.preOrderTraversal(
|
|
2698
|
-
if (!n.children && n.distToRoot > maxDist) {
|
|
2699
|
-
maxDist = n.distToRoot;
|
|
2817
|
+
forester.preOrderTraversal(top, function (n) {
|
|
2818
|
+
if (!n.children && n.distToRoot - top.distToRoot > maxDist) {
|
|
2819
|
+
maxDist = n.distToRoot - top.distToRoot;
|
|
2700
2820
|
}
|
|
2701
2821
|
});
|
|
2702
2822
|
let factor = maxDist > 0 ? budget / maxDist : 1;
|
|
2823
|
+
pxPerUnit = maxDist > 0 ? factor : 0;
|
|
2703
2824
|
lengthOf = function (n) {
|
|
2704
|
-
return Math.max(0, n.distToRoot -
|
|
2825
|
+
return Math.max(0, n.distToRoot - n.parent.distToRoot) * factor;
|
|
2705
2826
|
};
|
|
2706
2827
|
} else {
|
|
2707
2828
|
let maxDepth = 0;
|
|
@@ -2710,12 +2831,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
2710
2831
|
maxDepth = nodes[i].depth;
|
|
2711
2832
|
}
|
|
2712
2833
|
}
|
|
2713
|
-
|
|
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));
|
|
2714
2836
|
lengthOf = function () {
|
|
2715
2837
|
return step;
|
|
2716
2838
|
};
|
|
2717
2839
|
}
|
|
2718
|
-
_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
|
+
}
|
|
2719
2848
|
} else {
|
|
2720
2849
|
_radial = null;
|
|
2721
2850
|
_unroot = null;
|
|
@@ -2736,6 +2865,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
2736
2865
|
});
|
|
2737
2866
|
}
|
|
2738
2867
|
}
|
|
2868
|
+
syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
|
|
2739
2869
|
|
|
2740
2870
|
updateButtonEnabledState();
|
|
2741
2871
|
|
|
@@ -2795,6 +2925,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
2795
2925
|
// d3 v4+ no longer folds entered nodes into the update selection, so
|
|
2796
2926
|
// merge them before the shared styling/positioning below.
|
|
2797
2927
|
node = nodeEnter.merge(node);
|
|
2928
|
+
drawCollapsedClades(node);
|
|
2798
2929
|
|
|
2799
2930
|
// Create the optional children only where they will show something,
|
|
2800
2931
|
// and drop them where they will not. Must run BEFORE node.transition()
|
|
@@ -3218,12 +3349,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
3218
3349
|
}
|
|
3219
3350
|
|
|
3220
3351
|
hideHoverGlow(); // the hovered node may have moved; the next mouseover re-shows it
|
|
3352
|
+
drawScaleBar();
|
|
3221
3353
|
drawDomainArchitectures();
|
|
3222
3354
|
drawDomainLegend();
|
|
3223
3355
|
drawMsaTrack();
|
|
3224
3356
|
drawTimeOverlays();
|
|
3225
3357
|
rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
|
|
3226
3358
|
updateSearchHitNavigation();
|
|
3359
|
+
noteViewChange();
|
|
3227
3360
|
}
|
|
3228
3361
|
|
|
3229
3362
|
// A node is drawn as a shape only when there is a reason to show one: it
|
|
@@ -3257,11 +3390,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
3257
3390
|
let styled = _state.nodeSizeDefault > 0 && node.parent && !node.hasVis
|
|
3258
3391
|
&& styleOf !== null && !!styleOf.nodeColor;
|
|
3259
3392
|
|
|
3260
|
-
//
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
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;
|
|
3265
3402
|
};
|
|
3266
3403
|
|
|
3267
3404
|
let makeBranchWidth = function (link) {
|
|
@@ -3460,7 +3597,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3460
3597
|
if (isNodeFound(n)) {
|
|
3461
3598
|
hits.push(n);
|
|
3462
3599
|
}
|
|
3463
|
-
if (n.children) {
|
|
3600
|
+
if (n.children && !isCollapsed(n)) { // a hit inside a collapsed clade has no position to step to
|
|
3464
3601
|
for (let i = 0; i < n.children.length; ++i) {
|
|
3465
3602
|
walk(n.children[i]);
|
|
3466
3603
|
}
|
|
@@ -3728,6 +3865,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3728
3865
|
}
|
|
3729
3866
|
|
|
3730
3867
|
let makeNodeLabel = function (phynode) {
|
|
3868
|
+
if (isCollapsed(phynode)) {
|
|
3869
|
+
return ''; // a collapsed clade carries its own label (drawCollapsedClades)
|
|
3870
|
+
}
|
|
3731
3871
|
if (!_state.showExternalLabels && !(phynode.children)) {
|
|
3732
3872
|
return null;
|
|
3733
3873
|
}
|
|
@@ -4329,9 +4469,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
4329
4469
|
'ladderizeTree',
|
|
4330
4470
|
'nhExportWriteConfidences',
|
|
4331
4471
|
'nodeLabels',
|
|
4472
|
+
'onViewChange',
|
|
4332
4473
|
'pngExportScale',
|
|
4333
4474
|
'rootOffset',
|
|
4334
4475
|
'supportDotMinimum',
|
|
4476
|
+
'view',
|
|
4335
4477
|
'zoomToFitUponWindowResize'
|
|
4336
4478
|
];
|
|
4337
4479
|
|
|
@@ -4548,7 +4690,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
4548
4690
|
throw new Error(ERROR + '"domainEvalueExponent" must be an integer from '
|
|
4549
4691
|
+ forester.DOMAIN_EVALUE_EXPONENT_MIN + ' to ' + forester.DOMAIN_EVALUE_EXPONENT_MAX);
|
|
4550
4692
|
}
|
|
4551
|
-
_domain = {width: null, palette: null, next: 0, legendFrac: null};
|
|
4693
|
+
_domain = {width: null, radialWidth: null, palette: null, next: 0, legendFrac: null};
|
|
4552
4694
|
if (_basicTreeProperties.domainArchitectures) {
|
|
4553
4695
|
// malformed domains are skipped, never fatal -- said once, here
|
|
4554
4696
|
let ignored = forester.domainArchitectureStats(_treeData).ignored;
|
|
@@ -4790,6 +4932,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
4790
4932
|
throw new Error(ERROR + 'internalNumericLabels must be "auto", "confidence" or "label"');
|
|
4791
4933
|
}
|
|
4792
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
|
+
}
|
|
4793
4945
|
if (_settings.nodeLabels === undefined) {
|
|
4794
4946
|
_settings.nodeLabels = null;
|
|
4795
4947
|
} else if (_settings.nodeLabels !== null && typeof _settings.nodeLabels !== 'object') {
|
|
@@ -4892,6 +5044,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
4892
5044
|
if (_settings.ladderizeTree) {
|
|
4893
5045
|
ladderizeSubtree(_root, true); // one pass: a definite arrangement
|
|
4894
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
|
+
}
|
|
4895
5053
|
if (_state.searchAinitialValue) {
|
|
4896
5054
|
search0();
|
|
4897
5055
|
}
|
|
@@ -4905,6 +5063,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
4905
5063
|
|
|
4906
5064
|
search0();
|
|
4907
5065
|
search1();
|
|
5066
|
+
settleViewNotification();
|
|
4908
5067
|
}
|
|
4909
5068
|
|
|
4910
5069
|
function bindDoc(type, fn, opts) {
|
|
@@ -4922,6 +5081,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
4922
5081
|
// resolves once the tree is drawn: at once for a small tree,
|
|
4923
5082
|
// after the deferred first draw for a big one
|
|
4924
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
|
+
},
|
|
4925
5102
|
destroy: destroyViewer
|
|
4926
5103
|
};
|
|
4927
5104
|
}
|
|
@@ -4974,6 +5151,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
4974
5151
|
_root = null;
|
|
4975
5152
|
_root_const = null;
|
|
4976
5153
|
_treeData = null;
|
|
5154
|
+
_trees = [];
|
|
5155
|
+
_treeIndex = 0;
|
|
5156
|
+
_launchConfig = null;
|
|
5157
|
+
_lastViewEncoded = undefined;
|
|
5158
|
+
_viewOps = {order: null, root: null};
|
|
4977
5159
|
_basicTreeProperties = null;
|
|
4978
5160
|
_baseSvg = null;
|
|
4979
5161
|
_svgGroup = null;
|
|
@@ -5029,9 +5211,39 @@ function (root, d3, forester, phyloXml) {
|
|
|
5029
5211
|
if (phylo === undefined || phylo === null) {
|
|
5030
5212
|
throw new Error(ERROR + 'input tree is undefined or null');
|
|
5031
5213
|
}
|
|
5032
|
-
|
|
5033
|
-
|
|
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');
|
|
5034
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];
|
|
5035
5247
|
let cfg = readConfig(config);
|
|
5036
5248
|
requireForester();
|
|
5037
5249
|
requireD3();
|
|
@@ -5039,7 +5251,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
5039
5251
|
|
|
5040
5252
|
let previousContainer = _container; // for the different-container teardown below
|
|
5041
5253
|
_treeData = phylo;
|
|
5254
|
+
_trees = trees;
|
|
5255
|
+
_treeIndex = index;
|
|
5256
|
+
_launchConfig = config;
|
|
5042
5257
|
_container = containerEl;
|
|
5258
|
+
assignViewIds(phylo);
|
|
5043
5259
|
_zoomListener = d3.zoom()
|
|
5044
5260
|
.scaleExtent([0.1, 10])
|
|
5045
5261
|
.filter(function (event) {
|
|
@@ -5180,10 +5396,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
5180
5396
|
}
|
|
5181
5397
|
|
|
5182
5398
|
return makeViewerHandle();
|
|
5399
|
+
}
|
|
5183
5400
|
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
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
|
+
}
|
|
5187
5417
|
|
|
5188
5418
|
archaeopteryx.parsePhyloXML = function (data) {
|
|
5189
5419
|
requirePhyloXml();
|
|
@@ -5240,8 +5470,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
5240
5470
|
return forester.parseAuspiceJson(data);
|
|
5241
5471
|
};
|
|
5242
5472
|
|
|
5243
|
-
// A Nexus file can hold several trees; the FIRST one
|
|
5244
|
-
// 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).
|
|
5245
5476
|
archaeopteryx.parseNexus = function (data, mode, legacyMode) {
|
|
5246
5477
|
requireForester();
|
|
5247
5478
|
let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
|
|
@@ -5255,6 +5486,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
5255
5486
|
function calcMaxExtLabel() {
|
|
5256
5487
|
_maxLabelLength = _state.nodeLabelGap;
|
|
5257
5488
|
forester.preOrderTraversal(_root, function (d) {
|
|
5489
|
+
if (isCollapsed(d)) {
|
|
5490
|
+
_maxLabelLength = Math.max(collapsedLabel(d).length, _maxLabelLength);
|
|
5491
|
+
}
|
|
5258
5492
|
if (!d.children) {
|
|
5259
5493
|
let l = makeNodeLabel(d);
|
|
5260
5494
|
if (l) {
|
|
@@ -5589,6 +5823,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
5589
5823
|
}
|
|
5590
5824
|
|
|
5591
5825
|
function switchToSubtree(node) {
|
|
5826
|
+
if (node.collapsed) {
|
|
5827
|
+
node.collapsed = false; // a subtree view shows what the clade holds
|
|
5828
|
+
}
|
|
5592
5829
|
if (node.parent) {
|
|
5593
5830
|
if (!(node.children)) {
|
|
5594
5831
|
if (node.parent.parent) {
|
|
@@ -5665,6 +5902,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
5665
5902
|
// "Switch to ..." matches the desktop's wording for this action.
|
|
5666
5903
|
items.push({separator: true});
|
|
5667
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); }});
|
|
5668
5913
|
}
|
|
5669
5914
|
if (d.parent && d.children) {
|
|
5670
5915
|
// redrawn with no transition: animating a swap sends the two
|
|
@@ -6025,7 +6270,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6025
6270
|
updateMsaScrollbar(0, 1, 0);
|
|
6026
6271
|
return;
|
|
6027
6272
|
}
|
|
6028
|
-
let tips =
|
|
6273
|
+
let tips = displayedTips().filter(function (d) {
|
|
6029
6274
|
return d.x !== undefined;
|
|
6030
6275
|
}).sort(function (p, q) {
|
|
6031
6276
|
return p.x - q.x;
|
|
@@ -6525,187 +6770,1214 @@ function (root, d3, forester, phyloXml) {
|
|
|
6525
6770
|
scheduleUpdate(null, 0);
|
|
6526
6771
|
}
|
|
6527
6772
|
|
|
6528
|
-
// =====================
|
|
6529
|
-
//
|
|
6530
|
-
//
|
|
6531
|
-
//
|
|
6532
|
-
//
|
|
6533
|
-
//
|
|
6534
|
-
//
|
|
6535
|
-
//
|
|
6536
|
-
//
|
|
6537
|
-
//
|
|
6538
|
-
//
|
|
6539
|
-
//
|
|
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
|
|
6540
6791
|
|
|
6541
|
-
function
|
|
6542
|
-
return _state.
|
|
6543
|
-
&& _basicTreeProperties.domainArchitectures === true
|
|
6544
|
-
&& _basicTreeProperties.maxDomainArchitectureLength > 0
|
|
6545
|
-
&& _state.showExternalLabels
|
|
6546
|
-
// in a radial layout the boxes ride the spokes, so only radial
|
|
6547
|
-
// labels go with them
|
|
6548
|
-
&& (!radialDisplay() || !_radialLabelsHorizontal);
|
|
6792
|
+
function collapseAllowed() {
|
|
6793
|
+
return !_state.unrootedDisplay;
|
|
6549
6794
|
}
|
|
6550
6795
|
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6796
|
+
function isCollapsed(d) {
|
|
6797
|
+
return d.collapsed === true && !!d.children && collapseAllowed();
|
|
6798
|
+
}
|
|
6799
|
+
|
|
6800
|
+
// visits the displayed tree: a collapsed node, not what it hides
|
|
6801
|
+
function forEachDisplayed(fn) {
|
|
6802
|
+
if (!_root) {
|
|
6803
|
+
return;
|
|
6559
6804
|
}
|
|
6560
|
-
|
|
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);
|
|
6561
6813
|
}
|
|
6562
6814
|
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
|
|
6569
|
-
if (r > 0) {
|
|
6570
|
-
w = Math.min(w, DOMAIN_RADIAL_WIDTH_FRACTION * r);
|
|
6815
|
+
function displayedTips() {
|
|
6816
|
+
let out = [];
|
|
6817
|
+
forEachDisplayed(function (n) {
|
|
6818
|
+
if (!n.children) {
|
|
6819
|
+
out.push(n);
|
|
6571
6820
|
}
|
|
6572
|
-
}
|
|
6573
|
-
return
|
|
6821
|
+
});
|
|
6822
|
+
return out;
|
|
6574
6823
|
}
|
|
6575
6824
|
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6825
|
+
function displayedLeafCount() {
|
|
6826
|
+
let count = 0;
|
|
6827
|
+
forEachDisplayed(function (n) {
|
|
6828
|
+
if (!n.children || isCollapsed(n)) {
|
|
6829
|
+
count++;
|
|
6830
|
+
}
|
|
6831
|
+
});
|
|
6832
|
+
return count;
|
|
6581
6833
|
}
|
|
6582
6834
|
|
|
6583
|
-
|
|
6584
|
-
|
|
6585
|
-
|
|
6586
|
-
|
|
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;
|
|
6587
6843
|
}
|
|
6588
6844
|
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
function domainReserve() {
|
|
6593
|
-
if (!domainsShown() || radialDisplay()) {
|
|
6594
|
-
return 0;
|
|
6845
|
+
function collapseToggle(d) {
|
|
6846
|
+
if (!collapseAllowed() || !d.children || !d.parent || !d.parent.parent) {
|
|
6847
|
+
return;
|
|
6595
6848
|
}
|
|
6596
|
-
|
|
6849
|
+
d.collapsed = !d.collapsed;
|
|
6850
|
+
calcMaxExtLabel();
|
|
6851
|
+
scheduleUpdate(null, 0);
|
|
6597
6852
|
}
|
|
6598
6853
|
|
|
6599
|
-
function
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
}
|
|
6603
|
-
|
|
6854
|
+
function uncollapse(node) {
|
|
6855
|
+
forester.preOrderTraversal(node, function (n) {
|
|
6856
|
+
n.collapsed = false;
|
|
6857
|
+
});
|
|
6858
|
+
calcMaxExtLabel();
|
|
6859
|
+
scheduleUpdate(null, 0);
|
|
6604
6860
|
}
|
|
6605
6861
|
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
// can shift); a name met later takes the next unused colour and keeps
|
|
6610
|
-
// it. Never derived from the name's characters -- that let SH2 and SH3
|
|
6611
|
-
// collide on the desktop.
|
|
6612
|
-
function domainPalette() {
|
|
6613
|
-
if (!_domain.palette) {
|
|
6614
|
-
let names = forester.domainSummary(forester.getTreeRoot(_treeData), _state.domainEvalueExponent).names;
|
|
6615
|
-
_domain.palette = Object.create(null);
|
|
6616
|
-
names.forEach(function (name, i) {
|
|
6617
|
-
_domain.palette[name] = i;
|
|
6618
|
-
});
|
|
6619
|
-
_domain.next = names.length;
|
|
6862
|
+
function uncollapseAll() {
|
|
6863
|
+
if (_root) {
|
|
6864
|
+
uncollapse(_root);
|
|
6620
6865
|
}
|
|
6621
|
-
return _domain.palette;
|
|
6622
6866
|
}
|
|
6623
6867
|
|
|
6624
|
-
function
|
|
6625
|
-
|
|
6626
|
-
|
|
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();
|
|
6627
6889
|
}
|
|
6628
|
-
let
|
|
6629
|
-
|
|
6630
|
-
|
|
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;
|
|
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;
|
|
6908
|
+
}
|
|
6631
6909
|
}
|
|
6632
|
-
|
|
6910
|
+
let prefix = forester.commonNamePrefix(d, forester.nodeLabelProperty(_treeData));
|
|
6911
|
+
prefix = prefix ? prefix.replace(/[\s_\-.:|/]+$/, '') : '';
|
|
6912
|
+
return prefix.length >= 2 ? prefix : '';
|
|
6633
6913
|
}
|
|
6634
6914
|
|
|
6635
|
-
function
|
|
6636
|
-
|
|
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};
|
|
6637
6927
|
}
|
|
6638
6928
|
|
|
6639
|
-
function
|
|
6640
|
-
|
|
6641
|
-
|
|
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 + ']';
|
|
6642
6936
|
}
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
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;
|
|
6646
6946
|
}
|
|
6647
|
-
let
|
|
6648
|
-
|
|
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
|
+
}
|
|
6649
6958
|
});
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
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
|
+
}
|
|
6653
6980
|
}
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
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');
|
|
6667
7051
|
}
|
|
6668
|
-
|
|
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;
|
|
6669
7090
|
}
|
|
6670
|
-
let
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
let pitch = n > 1 ? (tips[n - 1].x - tips[0].x) / (n - 1) : _state.externalNodeFontSize;
|
|
6696
|
-
let h = domainBoxHeight(pitch / 2);
|
|
6697
|
-
let start = _w + _state.nodeLabelGap + labelSpace + DOMAIN_TRACK_START_GAP;
|
|
6698
|
-
let labelsOn = _state.domainLabels === 'domains';
|
|
6699
|
-
tips.forEach(function (d) {
|
|
6700
|
-
drawOneArchitecture(g, forester.domainArchitectureOf(d), start, d.x - (h / 2), h, f, labelsOn, gradientFor);
|
|
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
|
+
}
|
|
6701
7116
|
});
|
|
6702
7117
|
}
|
|
7118
|
+
return out;
|
|
6703
7119
|
}
|
|
6704
7120
|
|
|
6705
|
-
function
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
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);
|
|
6709
7981
|
}
|
|
6710
7982
|
|
|
6711
7983
|
// One architecture, in the desktop's order: the backbone, then per
|
|
@@ -6782,7 +8054,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6782
8054
|
// rows in the order the tips are on screen -- top to bottom, or
|
|
6783
8055
|
// clockwise -- not the data's order, which a ladderized display
|
|
6784
8056
|
// does not follow
|
|
6785
|
-
let tips =
|
|
8057
|
+
let tips = displayedTips().filter(function (d) {
|
|
6786
8058
|
return d.x !== undefined;
|
|
6787
8059
|
}).sort(function (p, q) {
|
|
6788
8060
|
return p.x - q.x;
|
|
@@ -6918,9 +8190,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
6918
8190
|
if (grow ? w >= DOMAIN_WIDTH_MAX : w <= DOMAIN_WIDTH_MIN) {
|
|
6919
8191
|
return;
|
|
6920
8192
|
}
|
|
6921
|
-
|
|
8193
|
+
setDomainTrackWidth(w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK));
|
|
6922
8194
|
syncDomainControls();
|
|
6923
|
-
|
|
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
|
+
}
|
|
6924
8203
|
}
|
|
6925
8204
|
|
|
6926
8205
|
function domainEvalueStep(delta) {
|
|
@@ -6969,7 +8248,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
6969
8248
|
|
|
6970
8249
|
// Search B starts hidden to keep the panel compact; one click (or a
|
|
6971
8250
|
// configured initial value) reveals it, and it stays revealed.
|
|
6972
|
-
|
|
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() {
|
|
6973
8254
|
let wrap = byId(SEARCH_B_WRAP);
|
|
6974
8255
|
if (wrap) {
|
|
6975
8256
|
wrap.style.display = '';
|
|
@@ -6978,6 +8259,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
6978
8259
|
if (tgl) {
|
|
6979
8260
|
tgl.style.display = 'none';
|
|
6980
8261
|
}
|
|
8262
|
+
}
|
|
8263
|
+
|
|
8264
|
+
function revealSearchB() {
|
|
8265
|
+
showSearchB();
|
|
6981
8266
|
let f = byId(SEARCH_FIELD_1);
|
|
6982
8267
|
if (f) {
|
|
6983
8268
|
f.focus();
|
|
@@ -7014,7 +8299,81 @@ function (root, d3, forester, phyloXml) {
|
|
|
7014
8299
|
// The navigation bar is fixed at the viewport bottom, so the track's
|
|
7015
8300
|
// own bottom rows (conservation, consensus, ruler) must end above it
|
|
7016
8301
|
// or the bar covers them -- the old bare slider did exactly that.
|
|
7017
|
-
|
|
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);
|
|
7018
8377
|
}
|
|
7019
8378
|
|
|
7020
8379
|
function hexToRgbTriple(hex) {
|
|
@@ -7050,7 +8409,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7050
8409
|
// average branch length (see bl()), which would shift every band.
|
|
7051
8410
|
let anchor = null;
|
|
7052
8411
|
let maxTipX = 0;
|
|
7053
|
-
|
|
8412
|
+
forEachDisplayed(function (n) {
|
|
7054
8413
|
if (n.children) {
|
|
7055
8414
|
return;
|
|
7056
8415
|
}
|
|
@@ -7088,7 +8447,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7088
8447
|
let sc = info.type === 'calendar' ? -corr : corr;
|
|
7089
8448
|
|
|
7090
8449
|
// ---- HPD age bars (internal) + fossil range bars (tips) ----
|
|
7091
|
-
|
|
8450
|
+
forEachDisplayed(function (d) {
|
|
7092
8451
|
if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
|
|
7093
8452
|
|| d.y === undefined) {
|
|
7094
8453
|
return;
|
|
@@ -7322,7 +8681,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7322
8681
|
setZoomScale(1);
|
|
7323
8682
|
update(_root, 0);
|
|
7324
8683
|
_zoomed_x_or_y = true;
|
|
7325
|
-
const uncollsed_nodes =
|
|
8684
|
+
const uncollsed_nodes = displayedLeafCount();
|
|
7326
8685
|
_displayHeight = _state.externalNodeFontSize * (uncollsed_nodes * 1.3);
|
|
7327
8686
|
const min = 40;
|
|
7328
8687
|
if (_displayHeight < min) {
|
|
@@ -7395,13 +8754,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
7395
8754
|
if (_treeFn.visData.ladderize === undefined) {
|
|
7396
8755
|
_treeFn.visData.ladderize = true;
|
|
7397
8756
|
}
|
|
7398
|
-
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
|
|
7399
8759
|
_treeFn.visData.ladderize = !_treeFn.visData.ladderize;
|
|
7400
|
-
|
|
7401
|
-
let orderBtn = byId(LADDERIZE_BUTTON);
|
|
7402
|
-
if (orderBtn) {
|
|
7403
|
-
orderBtn.innerHTML = makeGlyph(_treeFn.visData.ladderize ? 'ladderize_asc' : 'ladderize_desc');
|
|
7404
|
-
}
|
|
8760
|
+
syncLadderizeGlyph();
|
|
7405
8761
|
update(null, 0);
|
|
7406
8762
|
}
|
|
7407
8763
|
}
|
|
@@ -7424,6 +8780,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7424
8780
|
{
|
|
7425
8781
|
label: 'Midpoint re-root', action: function () {
|
|
7426
8782
|
forester.midpointRoot(_root);
|
|
8783
|
+
_viewOps.root = 'midpoint'; // what a shared view replays
|
|
7427
8784
|
zoomToFit();
|
|
7428
8785
|
}
|
|
7429
8786
|
},
|
|
@@ -7440,6 +8797,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7440
8797
|
_root = _root_const;
|
|
7441
8798
|
_in_subtree = false;
|
|
7442
8799
|
}
|
|
8800
|
+
clearCollapsedFlags(_root); // the launch state has nothing collapsed
|
|
7443
8801
|
|
|
7444
8802
|
_basicTreeProperties = forester.collectBasicTreeProperties(_root);
|
|
7445
8803
|
|
|
@@ -7585,12 +8943,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
7585
8943
|
}
|
|
7586
8944
|
|
|
7587
8945
|
function resetSearch0() {
|
|
8946
|
+
closeSuggestions();
|
|
7588
8947
|
setValue(SEARCH_FIELD_0, '');
|
|
7589
8948
|
setValue(SEARCH_VALUE2_0, '');
|
|
7590
8949
|
runSearches();
|
|
7591
8950
|
}
|
|
7592
8951
|
|
|
7593
8952
|
function resetSearch1() {
|
|
8953
|
+
closeSuggestions();
|
|
7594
8954
|
setValue(SEARCH_FIELD_1, '');
|
|
7595
8955
|
setValue(SEARCH_VALUE2_1, '');
|
|
7596
8956
|
runSearches();
|
|
@@ -7695,24 +9055,252 @@ function (root, d3, forester, phyloXml) {
|
|
|
7695
9055
|
if (idx === 0) { search0(); } else { search1(); }
|
|
7696
9056
|
}
|
|
7697
9057
|
|
|
7698
|
-
//
|
|
7699
|
-
// 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
|
+
|
|
7700
9075
|
function updateSearchAutocomplete(idx) {
|
|
7701
|
-
|
|
7702
|
-
let dl = byId(dlId);
|
|
7703
|
-
let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
|
|
7704
|
-
if (!dl || !input) return;
|
|
9076
|
+
closeSuggestions();
|
|
7705
9077
|
let spec = currentSearchSpec(idx);
|
|
7706
|
-
let
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
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;
|
|
9267
|
+
}
|
|
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;
|
|
7714
9279
|
}
|
|
7715
|
-
input.
|
|
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
|
+
});
|
|
7716
9304
|
}
|
|
7717
9305
|
|
|
7718
9306
|
|
|
@@ -7745,6 +9333,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7745
9333
|
_radialLabelsHorizontal = false;
|
|
7746
9334
|
}
|
|
7747
9335
|
syncZoomRowButtons();
|
|
9336
|
+
afterUpdate(syncDomainControls); // the track width buttons follow the layout's own width
|
|
7748
9337
|
zoomToFit();
|
|
7749
9338
|
}
|
|
7750
9339
|
|
|
@@ -7944,6 +9533,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
7944
9533
|
scheduleUpdate(null, 0);
|
|
7945
9534
|
}
|
|
7946
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
|
+
|
|
7947
9557
|
function dynaHideCbClicked() {
|
|
7948
9558
|
_state.dynahide = getCheckboxValue(DYNAHIDE_CB);
|
|
7949
9559
|
resetVis();
|
|
@@ -8008,6 +9618,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
8008
9618
|
|
|
8009
9619
|
function searchOptionsCaseSenstiveCbClicked() {
|
|
8010
9620
|
_state.searchIsCaseSensitive = getCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB);
|
|
9621
|
+
closeSuggestions(); // an open list was filtered the other way; the next keystroke refilters
|
|
8011
9622
|
search0();
|
|
8012
9623
|
search1();
|
|
8013
9624
|
}
|
|
@@ -8502,6 +10113,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
8502
10113
|
case 'ladderize_asc': sw = 8; cap = 'round'; body = glyphLadderize(true); break;
|
|
8503
10114
|
case 'ladderize_desc': sw = 8; cap = 'round'; body = glyphLadderize(false); break;
|
|
8504
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;
|
|
8505
10119
|
case 'sun': body = glyphSun(); break;
|
|
8506
10120
|
case 'moon': body = glyphMoon(); break;
|
|
8507
10121
|
default: throw new Error('unknown control-panel glyph: ' + kind);
|
|
@@ -8511,6 +10125,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
8511
10125
|
+ ' stroke-linecap="' + cap + '" stroke-linejoin="' + join + '">' + body + '</svg>';
|
|
8512
10126
|
}
|
|
8513
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
|
+
|
|
8514
10142
|
function injectPanelStyles() {
|
|
8515
10143
|
loadPanelTheme();
|
|
8516
10144
|
if (document.getElementById(PANEL_STYLE_ID)) {
|
|
@@ -8547,16 +10175,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
8547
10175
|
+ '.aptx-panel * { box-sizing:border-box; }'
|
|
8548
10176
|
+ '@media (prefers-color-scheme:dark){ .aptx-panel:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
|
|
8549
10177
|
+ '.aptx-panel.aptx-dark {' + dark + '}'
|
|
8550
|
-
// The node menu
|
|
8551
|
-
// panel), so
|
|
8552
|
-
// same light / dark rules.
|
|
8553
|
-
+ '.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 {'
|
|
8554
10182
|
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
8555
10183
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
8556
10184
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
8557
10185
|
+ '}'
|
|
8558
|
-
+ '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
|
|
8559
|
-
+ '.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 + '}'
|
|
8560
10188
|
// The menu itself: fixed 11px type, so it does NOT scale with the
|
|
8561
10189
|
// tree's zoom the way the old svg-drawn menu did.
|
|
8562
10190
|
+ '.aptx-node-menu { position:absolute; z-index:1000; min-width:196px; max-width:280px; padding:4px;'
|
|
@@ -8575,9 +10203,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
8575
10203
|
+ ' transition:background .1s,color .1s; }'
|
|
8576
10204
|
+ '.aptx-node-menu button:hover, .aptx-node-menu button:focus-visible {'
|
|
8577
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; }'
|
|
8578
10208
|
+ '.aptx-node-menu button.aptx-menu-danger:hover, .aptx-node-menu button.aptx-menu-danger:focus-visible {'
|
|
8579
10209
|
+ ' background:#e5484d; color:#fff; }'
|
|
8580
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); }'
|
|
8581
10227
|
// The node-data dialog, on the same palette as the panel and the menu.
|
|
8582
10228
|
+ '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
|
|
8583
10229
|
+ ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
@@ -8651,6 +10297,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
8651
10297
|
+ '.aptx-dialog-line + .aptx-dialog-line { border-top:1px solid var(--p-line); }'
|
|
8652
10298
|
+ '.aptx-dialog-key { flex:0 0 42%; color:var(--p-muted); }'
|
|
8653
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); }'
|
|
8654
10306
|
+ '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
|
|
8655
10307
|
+ ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
|
|
8656
10308
|
// The hover tooltip, on the same palette.
|
|
@@ -8702,13 +10354,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
8702
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; }'
|
|
8703
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; }'
|
|
8704
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; }'
|
|
8705
10362
|
+ '.aptx-panel .aptx-panel-title { font-size:12px; }'
|
|
8706
10363
|
+ '.aptx-panel fieldset { border:0; border-top:1px solid var(--p-line); margin:0; padding:6px 12px; min-width:0; }'
|
|
8707
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); }'
|
|
8708
10365
|
+ '.aptx-panel label { cursor:pointer; }'
|
|
8709
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; }'
|
|
8710
10367
|
// checkbox/radio + label as one item (used by the Display Data grid and the inline P/A/C and search-option rows)
|
|
8711
|
-
|
|
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; }'
|
|
8712
10375
|
+ '.aptx-panel .aptx-check > input { margin:0; flex:none; }'
|
|
8713
10376
|
+ '.aptx-panel .aptx-check > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }'
|
|
8714
10377
|
+ '.aptx-panel .aptx-checkgrid { display:grid; grid-template-columns:1fr 1fr; gap:3px 10px; }'
|
|
@@ -8725,7 +10388,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
8725
10388
|
+ '.aptx-panel .aptx-search-menus { display:flex; gap:4px; margin:2px 0 3px; }'
|
|
8726
10389
|
+ '.aptx-panel .aptx-search-menus select { flex:1 1 0; width:auto; min-width:0; height:24px; font:inherit; }'
|
|
8727
10390
|
// red cue for an unmatchable query (bad regex / non-number); box-shadow, since the value boxes carry an inline outline:none
|
|
8728
|
-
|
|
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; }'
|
|
8729
10394
|
+ '.aptx-panel .aptx-combine { display:flex; align-items:center; gap:8px; margin:6px 0 2px; }'
|
|
8730
10395
|
+ '.aptx-panel .aptx-combine .aptx-field-label { margin:0; flex:none; }'
|
|
8731
10396
|
+ '.aptx-panel .aptx-combine select { flex:1 1 auto; min-width:0; height:26px; font:inherit; }'
|
|
@@ -8797,6 +10462,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
8797
10462
|
+ '.aptx-panel .aptx-seg .aptx-glyph { height:13px; }'
|
|
8798
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; }'
|
|
8799
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; }'
|
|
8800
10469
|
// --- collapsible sections, internal scroll, whole-panel hide ---
|
|
8801
10470
|
// the panel is a child of the tree's container now, so it can be held
|
|
8802
10471
|
// to that container's height rather than the whole viewport's
|
|
@@ -9375,7 +11044,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
9375
11044
|
[['Website', WEBSITE, 'cmzmasek.github.io/archaeopteryx-js'],
|
|
9376
11045
|
['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
|
|
9377
11046
|
['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
|
|
9378
|
-
['License', LICENSE_WEBSITE, LICENSE_NAME]
|
|
11047
|
+
['License', LICENSE_WEBSITE, LICENSE_NAME],
|
|
11048
|
+
['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']].forEach(function (row) {
|
|
9379
11049
|
let line = document.createElement('div');
|
|
9380
11050
|
line.className = 'aptx-dialog-line';
|
|
9381
11051
|
let key = document.createElement('span');
|
|
@@ -9384,9 +11054,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
9384
11054
|
let val = document.createElement('span');
|
|
9385
11055
|
val.className = 'aptx-dialog-val';
|
|
9386
11056
|
let a = document.createElement('a');
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
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
|
+
}
|
|
9390
11070
|
a.textContent = row[2];
|
|
9391
11071
|
val.appendChild(a);
|
|
9392
11072
|
line.appendChild(key);
|
|
@@ -9601,9 +11281,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
9601
11281
|
}
|
|
9602
11282
|
|
|
9603
11283
|
|
|
9604
|
-
on(SEARCH_FIELD_0, 'keyup',
|
|
11284
|
+
on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
|
|
9605
11285
|
|
|
9606
|
-
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);
|
|
9607
11292
|
|
|
9608
11293
|
on(SEARCH_VALUE2_0, 'keyup', search0);
|
|
9609
11294
|
|
|
@@ -9659,6 +11344,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
9659
11344
|
on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
|
|
9660
11345
|
on(MSA_CB, 'click', msaCbClicked);
|
|
9661
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
|
+
});
|
|
9662
11357
|
onHoldRepeat(DOMAIN_WIDTH_DEC, function () { domainWidthStep(false); });
|
|
9663
11358
|
onHoldRepeat(DOMAIN_WIDTH_INC, function () { domainWidthStep(true); });
|
|
9664
11359
|
on(DOMAIN_EVALUE_DEC, 'click', function () { domainEvalueStep(-1); });
|
|
@@ -9827,12 +11522,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
9827
11522
|
_docListenersBound = true;
|
|
9828
11523
|
|
|
9829
11524
|
bindDoc('keyup', function (e) {
|
|
9830
|
-
// The
|
|
9831
|
-
//
|
|
9832
|
-
//
|
|
9833
|
-
//
|
|
9834
|
-
//
|
|
9835
|
-
// 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.
|
|
9836
11530
|
if (isTypingTarget(e.target)) {
|
|
9837
11531
|
return; // nothing fires from inside a text box
|
|
9838
11532
|
}
|
|
@@ -9849,7 +11543,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
9849
11543
|
});
|
|
9850
11544
|
|
|
9851
11545
|
bindDoc('keydown', function (e) {
|
|
9852
|
-
if (
|
|
11546
|
+
if (e.defaultPrevented) {
|
|
11547
|
+
return;
|
|
11548
|
+
}
|
|
11549
|
+
shortcutKeyDown(e); // the Ctrl / Cmd combos (SHORTCUTS)
|
|
11550
|
+
if (e.defaultPrevented || isTypingTarget(e.target)) {
|
|
9853
11551
|
return;
|
|
9854
11552
|
}
|
|
9855
11553
|
if (e.keyCode === VK_PAGE_UP) {
|
|
@@ -9938,13 +11636,23 @@ function (root, d3, forester, phyloXml) {
|
|
|
9938
11636
|
// so tree-file text needs no escaping here. Both can be very long, so CSS
|
|
9939
11637
|
// clamps each to a few lines; the caller (enableTreeDescExpand) adds the
|
|
9940
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.
|
|
9941
11642
|
function makeTreeDesc() {
|
|
9942
|
-
let
|
|
11643
|
+
let several = _trees.length > 1;
|
|
11644
|
+
let name = (_treeData.name && !several) ? String(_treeData.name).trim() : '';
|
|
9943
11645
|
let desc = _treeData.description ? String(_treeData.description).trim() : '';
|
|
9944
|
-
if (!name && !desc) {
|
|
11646
|
+
if (!name && !desc && !several) {
|
|
9945
11647
|
return null;
|
|
9946
11648
|
}
|
|
9947
11649
|
let fieldset = document.createElement('fieldset');
|
|
11650
|
+
if (several) {
|
|
11651
|
+
fieldset.appendChild(makeTreePicker());
|
|
11652
|
+
}
|
|
11653
|
+
if (!name && !desc) {
|
|
11654
|
+
return fieldset;
|
|
11655
|
+
}
|
|
9948
11656
|
let block = document.createElement('div');
|
|
9949
11657
|
block.className = TREE_DESC;
|
|
9950
11658
|
let tooltip = '';
|
|
@@ -9967,6 +11675,35 @@ function (root, d3, forester, phyloXml) {
|
|
|
9967
11675
|
return fieldset;
|
|
9968
11676
|
}
|
|
9969
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
|
+
|
|
9970
11707
|
// Make the tree name/description block a keyboard-accessible
|
|
9971
11708
|
// expand/collapse control, but only while its text is actually clamped
|
|
9972
11709
|
// (otherwise the pointer cursor + click would be a misleading no-op).
|
|
@@ -10176,6 +11913,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
10176
11913
|
h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
|
|
10177
11914
|
h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
|
|
10178
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'));
|
|
10179
11917
|
h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 'midpoint re-root'));
|
|
10180
11918
|
h = h.concat('</div>');
|
|
10181
11919
|
h = h.concat('</fieldset>');
|
|
@@ -10296,7 +12034,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
10296
12034
|
let val2 = idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1;
|
|
10297
12035
|
let reset = idx === 0 ? RESET_SEARCH_A_BTN : RESET_SEARCH_B_BTN;
|
|
10298
12036
|
let resetTip = idx === 0 ? RESET_SEARCH_A_BTN_TOOLTIP : RESET_SEARCH_B_BTN_TOOLTIP;
|
|
10299
|
-
let dl = idx === 0 ? SEARCH_DATALIST_0 : SEARCH_DATALIST_1;
|
|
10300
12037
|
let h = "";
|
|
10301
12038
|
h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
|
|
10302
12039
|
h = h.concat('<div class="aptx-search-menus">');
|
|
@@ -10308,7 +12045,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
10308
12045
|
h = h.concat('<input class="aptx-search-value2" style="display:none" title="upper bound of the range" type="text" name="' + val2 + '" id="' + val2 + '">');
|
|
10309
12046
|
h = h.concat(makeButton('R', reset, resetTip));
|
|
10310
12047
|
h = h.concat('</div>');
|
|
10311
|
-
h = h.concat('<datalist id="' + dl + '"></datalist>');
|
|
10312
12048
|
return h;
|
|
10313
12049
|
}
|
|
10314
12050
|
|
|
@@ -10493,11 +12229,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
10493
12229
|
// definite result -- with it on, ladderizing a tree that is already
|
|
10494
12230
|
// ladderized REVERSES it, so the outcome depends on the order the tree
|
|
10495
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.
|
|
10496
12235
|
function ladderizeSubtree(n, largestFirst, alternateIfUnchanged) {
|
|
10497
12236
|
let changed = forester.ladderize(n, largestFirst);
|
|
10498
12237
|
if (alternateIfUnchanged && !changed) {
|
|
10499
12238
|
forester.ladderize(n, !largestFirst);
|
|
12239
|
+
return !largestFirst;
|
|
10500
12240
|
}
|
|
12241
|
+
return largestFirst;
|
|
10501
12242
|
}
|
|
10502
12243
|
|
|
10503
12244
|
function setDisplayTypeButtons() {
|
|
@@ -10516,6 +12257,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
10516
12257
|
|
|
10517
12258
|
function updateButtonEnabledState() {
|
|
10518
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));
|
|
10519
12262
|
if (_in_subtree) {
|
|
10520
12263
|
enableButton(byId(RETURN_TO_SUPERTREE_BUTTON_BY_ONE));
|
|
10521
12264
|
enableButton(byId(RETURN_TO_SUPERTREE_BUTTON));
|
|
@@ -10888,8 +12631,65 @@ function (root, d3, forester, phyloXml) {
|
|
|
10888
12631
|
// Convenience methods for loading tree on HTML page
|
|
10889
12632
|
// --------------------------------------------------------------
|
|
10890
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
|
+
|
|
10891
12690
|
/**
|
|
10892
|
-
*
|
|
12691
|
+
* Parses tree data for an HTML page: the FIRST tree the data holds
|
|
12692
|
+
* (parseTrees returns them all).
|
|
10893
12693
|
*
|
|
10894
12694
|
* @param location - file name (only its extension is used; the content is
|
|
10895
12695
|
* sniffed too, so a pasted tree with no name works)
|
|
@@ -10900,41 +12700,21 @@ function (root, d3, forester, phyloXml) {
|
|
|
10900
12700
|
* position and is still accepted (and ignored) there,
|
|
10901
12701
|
* with the mode following it.
|
|
10902
12702
|
* @param legacyMode - the mode, when the retired flag occupies `mode`
|
|
10903
|
-
* @returns {*}
|
|
12703
|
+
* @returns {*} the first tree
|
|
10904
12704
|
*/
|
|
10905
12705
|
archaeopteryx.parseTree = function (location, data, mode, legacyMode) {
|
|
10906
|
-
|
|
10907
|
-
|
|
10908
|
-
|
|
10909
|
-
|
|
10910
|
-
|
|
10911
|
-
|
|
10912
|
-
|
|
10913
|
-
|
|
10914
|
-
|
|
10915
|
-
|
|
10916
|
-
|
|
10917
|
-
|
|
10918
|
-
internalNumericLabels = PARSE_DEFAULTS.internalNumericLabels;
|
|
10919
|
-
}
|
|
10920
|
-
let tree;
|
|
10921
|
-
let loc = location ? String(location).toLowerCase() : '';
|
|
10922
|
-
// Nexus announces itself with "#NEXUS" on the first line, so the
|
|
10923
|
-
// content decides; the filename alone is enough too.
|
|
10924
|
-
if ((forester.isString(data) && /^\s*#nexus\b/i.test(data))
|
|
10925
|
-
|| /\.(nex|nexus)$/.test(loc)) {
|
|
10926
|
-
tree = archaeopteryx.parseNexus(data, internalNumericLabels);
|
|
10927
|
-
} else if ((forester.isString(data) && /^\s*\{/.test(data)) || /\.json$/.test(loc)) {
|
|
10928
|
-
tree = archaeopteryx.parseAuspiceJson(data);
|
|
10929
|
-
} else if ((forester.isString(data) && /^\s*</.test(data))
|
|
10930
|
-
|| loc.substr(-3, 3) === 'xml') {
|
|
10931
|
-
// No other supported format can start with '<', so content alone
|
|
10932
|
-
// is enough -- pasted phyloXML has no filename to go by.
|
|
10933
|
-
tree = archaeopteryx.parsePhyloXML(data);
|
|
10934
|
-
} else {
|
|
10935
|
-
tree = archaeopteryx.parseNewHampshire(data, internalNumericLabels);
|
|
10936
|
-
}
|
|
10937
|
-
return tree;
|
|
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));
|
|
10938
12718
|
};
|
|
10939
12719
|
|
|
10940
12720
|
// Show the viewer's own "working" card in a container. For the part of a
|
|
@@ -11007,9 +12787,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
11007
12787
|
+ ' "internalNumericLabels".');
|
|
11008
12788
|
}
|
|
11009
12789
|
let c = config || {};
|
|
11010
|
-
let
|
|
12790
|
+
let trees;
|
|
11011
12791
|
try {
|
|
11012
|
-
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,
|
|
11013
12795
|
effectiveInternalLabelsMode(c));
|
|
11014
12796
|
} catch (e) {
|
|
11015
12797
|
// Worth catching only to say that it was the parse, not the launch,
|
|
@@ -11023,7 +12805,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
11023
12805
|
// launch() already reports its own failures well enough; wrapping them
|
|
11024
12806
|
// added nothing but a prefix, and swallowing them left the caller with
|
|
11025
12807
|
// a blank page and no way to find out why.
|
|
11026
|
-
return archaeopteryx.launch(container,
|
|
12808
|
+
return archaeopteryx.launch(container, trees, config);
|
|
11027
12809
|
};
|
|
11028
12810
|
|
|
11029
12811
|
|