archaeopteryx 3.3.0 → 3.4.1
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 +166 -14
- package/archaeopteryx.d.ts +100 -10
- package/archaeopteryx.js +2220 -397
- package/forester.js +387 -3
- 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.1
|
|
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.1';
|
|
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
|
}
|
|
@@ -1099,12 +1147,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
1099
1147
|
// on the desktop: dots in the search colours, so hits outside the
|
|
1100
1148
|
// current viewport can be spotted and steered to. Search results
|
|
1101
1149
|
// re-run update(), which ends here, so the dots track every search.
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1150
|
+
// Displayed nodes only: a hit inside a collapsed clade has no drawn
|
|
1151
|
+
// position, so the clade gets the dot, in its wedge's mark colour.
|
|
1104
1152
|
let hits = [];
|
|
1105
1153
|
if (_root) {
|
|
1106
|
-
|
|
1107
|
-
let c = getFoundColor(n);
|
|
1154
|
+
forEachDisplayed(function (n) {
|
|
1155
|
+
let c = getFoundColor(n) || (isCollapsed(n) ? collapsedMarkColor(n) : null);
|
|
1108
1156
|
if (c) {
|
|
1109
1157
|
hits.push({node: n, color: c});
|
|
1110
1158
|
}
|
|
@@ -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();
|
|
@@ -1946,6 +1997,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1946
1997
|
}
|
|
1947
1998
|
});
|
|
1948
1999
|
_vis.colorNext[vis.id] = next;
|
|
2000
|
+
vis.categoryOnScreen = new Set(vis.values); // what the scale may be asked about (visualizationColorFor)
|
|
1949
2001
|
vis.categoryScale = d3.scaleOrdinal()
|
|
1950
2002
|
.domain(vis.values)
|
|
1951
2003
|
.range(vis.values.map(function (v) {
|
|
@@ -2001,8 +2053,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
2001
2053
|
// its scales from that: the whole tree at launch and after an edit, the
|
|
2002
2054
|
// displayed subtree on every view change.
|
|
2003
2055
|
function summarizeView(root) {
|
|
2056
|
+
// the tips on screen: the view's, minus what collapsed clades hide
|
|
2057
|
+
let tips = [];
|
|
2058
|
+
(function walk(n) {
|
|
2059
|
+
if (!n.children) {
|
|
2060
|
+
tips.push(n);
|
|
2061
|
+
} else if (!isCollapsed(n)) {
|
|
2062
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
2063
|
+
walk(n.children[i]);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
})(root);
|
|
2004
2067
|
_vis.candidates.forEach(function (c) {
|
|
2005
|
-
let s = forester.visualizationSummary(c,
|
|
2068
|
+
let s = forester.visualizationSummary(c, tips);
|
|
2006
2069
|
c.values = s.values;
|
|
2007
2070
|
c.counts = s.counts;
|
|
2008
2071
|
c.coverage = s.coverage;
|
|
@@ -2625,17 +2688,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
2625
2688
|
- bottomOverlayReserve());
|
|
2626
2689
|
_treeFn = _treeFn.size([_clusterH, _w]);
|
|
2627
2690
|
|
|
2691
|
+
// A collapsed clade is a leaf that asks for more than one row: the
|
|
2692
|
+
// separation between neighbouring leaves is the mean of their
|
|
2693
|
+
// weights, a plain tip weighing 1 and a collapsed clade 2r - 1 so
|
|
2694
|
+
// that it spans r rows (collapsedRows).
|
|
2695
|
+
let leafWeight = function (hn) {
|
|
2696
|
+
return isCollapsed(hn.data) ? (2 * collapsedRows(hn.data)) - 1 : 1;
|
|
2697
|
+
};
|
|
2628
2698
|
_treeFn = _treeFn.separation(function separation(a, b) {
|
|
2629
|
-
return a
|
|
2699
|
+
return (leafWeight(a) + leafWeight(b)) / 2;
|
|
2630
2700
|
});
|
|
2631
2701
|
|
|
2632
|
-
let uncollsed_nodes = forester.calcSumOfExternalDescendants(_root);
|
|
2633
2702
|
// d3 v7: d3.cluster() lays out a d3.hierarchy rather than mutating our
|
|
2634
2703
|
// own nodes in place (as d3 v3's d3.layout.cluster().nodes() did). Run
|
|
2635
2704
|
// 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.
|
|
2705
|
+
// the forester nodes so the rest of the renderer is unchanged. A
|
|
2706
|
+
// collapsed node ends the hierarchy: what it hides is not laid out.
|
|
2637
2707
|
let hierarchy = d3.hierarchy(_root, function (d) {
|
|
2638
|
-
return d.children;
|
|
2708
|
+
return isCollapsed(d) ? null : d.children;
|
|
2639
2709
|
});
|
|
2640
2710
|
_treeFn(hierarchy);
|
|
2641
2711
|
hierarchy.each(function (hn) {
|
|
@@ -2646,14 +2716,59 @@ function (root, d3, forester, phyloXml) {
|
|
|
2646
2716
|
let nodes = hierarchy.descendants().map(function (hn) {
|
|
2647
2717
|
return hn.data;
|
|
2648
2718
|
}).reverse();
|
|
2719
|
+
let leaves = hierarchy.leaves();
|
|
2720
|
+
let uncollsed_nodes = leaves.length; // the rows on screen: tips and collapsed clades
|
|
2721
|
+
{
|
|
2722
|
+
// px per row unit: the leaves span the cluster height in
|
|
2723
|
+
// separation units (a collapsed clade counts for its rows)
|
|
2724
|
+
let units = 0;
|
|
2725
|
+
for (let i = 1; i < leaves.length; ++i) {
|
|
2726
|
+
units += (leafWeight(leaves[i - 1]) + leafWeight(leaves[i])) / 2;
|
|
2727
|
+
}
|
|
2728
|
+
_rowUnit = units > 0 ? (leaves[leaves.length - 1].x - leaves[0].x) / units : _clusterH;
|
|
2729
|
+
}
|
|
2730
|
+
_cladogramUnit = hierarchy.height > 0 ? _w / hierarchy.height : _w;
|
|
2731
|
+
|
|
2732
|
+
// The wrapper is laid out as a level of its own, so a cladogram spent
|
|
2733
|
+
// one depth unit on the invisible branch above the root. Pull the
|
|
2734
|
+
// root back to 0 and stretch the rest to the full width; the stub is
|
|
2735
|
+
// placed afterwards (placeRootStub), and a phylogram overrides these
|
|
2736
|
+
// positions anyway.
|
|
2737
|
+
{
|
|
2738
|
+
let top = topNode();
|
|
2739
|
+
if (top && top.y > 0 && _w > top.y) {
|
|
2740
|
+
let unit = top.y;
|
|
2741
|
+
let stretch = _w / (_w - unit);
|
|
2742
|
+
forEachDisplayed(function (n) {
|
|
2743
|
+
if (n !== _root) {
|
|
2744
|
+
n.y = (n.y - unit) * stretch;
|
|
2745
|
+
}
|
|
2746
|
+
});
|
|
2747
|
+
_root.y = 0;
|
|
2748
|
+
_cladogramUnit = unit * stretch;
|
|
2749
|
+
}
|
|
2750
|
+
// a collapsed clade is laid out on the tip column like any leaf;
|
|
2751
|
+
// in a cladogram its apex steps back one level so the wedge ends
|
|
2752
|
+
// where the tips would (a phylogram places it by distance)
|
|
2753
|
+
if (!_state.phylogram) {
|
|
2754
|
+
forEachDisplayed(function (n) {
|
|
2755
|
+
if (isCollapsed(n)) {
|
|
2756
|
+
n.y -= _cladogramUnit;
|
|
2757
|
+
}
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2649
2761
|
|
|
2650
|
-
// The dim gate: only while a hit is actually
|
|
2762
|
+
// The dim gate: only while a hit is actually SHOWN, so the tree never
|
|
2651
2763
|
// washes out with nothing emphasised (a 0-hit search, or every hit
|
|
2652
|
-
//
|
|
2764
|
+
// outside the displayed subtree). A collapsed clade holding a hit
|
|
2765
|
+
// counts as shown: it stays bright, outlined and counting its hits,
|
|
2766
|
+
// so the rest fading points at it (Christian, 2026-09-14: "less
|
|
2767
|
+
// confusing to users" than no dimming at all).
|
|
2653
2768
|
_dimNonMatches = false;
|
|
2654
2769
|
if ((_foundNodes0 && _foundNodes0.size > 0) || (_foundNodes1 && _foundNodes1.size > 0)) {
|
|
2655
2770
|
for (let i = 0, len = nodes.length; i !== len; ++i) {
|
|
2656
|
-
if (isNodeFound(nodes[i])) {
|
|
2771
|
+
if (isNodeFound(nodes[i]) || (isCollapsed(nodes[i]) && collapsedFoundCounts(nodes[i]).found > 0)) {
|
|
2657
2772
|
_dimNonMatches = true;
|
|
2658
2773
|
break;
|
|
2659
2774
|
}
|
|
@@ -2668,12 +2783,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
2668
2783
|
if (_state.phylogram === true) {
|
|
2669
2784
|
_yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
|
|
2670
2785
|
}
|
|
2786
|
+
placeRootStub();
|
|
2671
2787
|
|
|
2672
2788
|
if (_state.circularDisplay) {
|
|
2789
|
+
// the outermost drawn point sets the scale: a node, or the far
|
|
2790
|
+
// edge of a collapsed clade's wedge -- which reaches the clade's
|
|
2791
|
+
// farthest tip, hidden from nodes but not from the drawing, and
|
|
2792
|
+
// would otherwise poke through the ring the labels stand on
|
|
2673
2793
|
let maxY = 0;
|
|
2674
2794
|
for (let i = 0; i < nodes.length; ++i) {
|
|
2675
|
-
|
|
2676
|
-
|
|
2795
|
+
let y = isCollapsed(nodes[i]) ? Math.max(nodes[i].y, collapsedReach(nodes[i])[1]) : nodes[i].y;
|
|
2796
|
+
if (y > maxY) {
|
|
2797
|
+
maxY = y;
|
|
2677
2798
|
}
|
|
2678
2799
|
}
|
|
2679
2800
|
_radial = {
|
|
@@ -2691,17 +2812,26 @@ function (root, d3, forester, phyloXml) {
|
|
|
2691
2812
|
// branchLengthScaling just wrote) and are one constant step per
|
|
2692
2813
|
// level in cladogram mode; the radial budget matches circular's.
|
|
2693
2814
|
let budget = Math.min(_displayWidth, _displayHeight) * 0.42;
|
|
2815
|
+
// The fan is laid out from the displayed tree's OWN root, never
|
|
2816
|
+
// from the wrapper above it: an unrooted display draws no root
|
|
2817
|
+
// branch at all, not the file's real one and not the stub
|
|
2818
|
+
// (Christian, 2026-09-13). Laid out from the wrapper, the root
|
|
2819
|
+
// hung one cladogram step -- or its own branch length -- off the
|
|
2820
|
+
// centre on a spoke leading in from nowhere.
|
|
2821
|
+
let top = topNode() || _root;
|
|
2694
2822
|
let lengthOf;
|
|
2823
|
+
let pxPerUnit = 0; // the phylogram's scale, for the scale bar
|
|
2695
2824
|
if (_state.phylogram) {
|
|
2696
2825
|
let maxDist = 0;
|
|
2697
|
-
forester.preOrderTraversal(
|
|
2698
|
-
if (!n.children && n.distToRoot > maxDist) {
|
|
2699
|
-
maxDist = n.distToRoot;
|
|
2826
|
+
forester.preOrderTraversal(top, function (n) {
|
|
2827
|
+
if (!n.children && n.distToRoot - top.distToRoot > maxDist) {
|
|
2828
|
+
maxDist = n.distToRoot - top.distToRoot;
|
|
2700
2829
|
}
|
|
2701
2830
|
});
|
|
2702
2831
|
let factor = maxDist > 0 ? budget / maxDist : 1;
|
|
2832
|
+
pxPerUnit = maxDist > 0 ? factor : 0;
|
|
2703
2833
|
lengthOf = function (n) {
|
|
2704
|
-
return Math.max(0, n.distToRoot -
|
|
2834
|
+
return Math.max(0, n.distToRoot - n.parent.distToRoot) * factor;
|
|
2705
2835
|
};
|
|
2706
2836
|
} else {
|
|
2707
2837
|
let maxDepth = 0;
|
|
@@ -2710,12 +2840,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
2710
2840
|
maxDepth = nodes[i].depth;
|
|
2711
2841
|
}
|
|
2712
2842
|
}
|
|
2713
|
-
|
|
2843
|
+
// depths count from the wrapper, so the tree's own levels are one fewer
|
|
2844
|
+
let step = budget / Math.max(1, maxDepth - (top === _root ? 0 : 1));
|
|
2714
2845
|
lengthOf = function () {
|
|
2715
2846
|
return step;
|
|
2716
2847
|
};
|
|
2717
2848
|
}
|
|
2718
|
-
_unroot = forester.equalAngleLayout(
|
|
2849
|
+
_unroot = forester.equalAngleLayout(top, UNROOTED_START_ANGLE + _radialRotation, lengthOf);
|
|
2850
|
+
_unroot.pxPerUnit = pxPerUnit;
|
|
2851
|
+
if (top !== _root) {
|
|
2852
|
+
// the wrapper sits on the root, so their link has no length
|
|
2853
|
+
_root.ux = top.ux;
|
|
2854
|
+
_root.uy = top.uy;
|
|
2855
|
+
_root.uangle = top.uangle;
|
|
2856
|
+
}
|
|
2719
2857
|
} else {
|
|
2720
2858
|
_radial = null;
|
|
2721
2859
|
_unroot = null;
|
|
@@ -2736,6 +2874,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
2736
2874
|
});
|
|
2737
2875
|
}
|
|
2738
2876
|
}
|
|
2877
|
+
syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
|
|
2739
2878
|
|
|
2740
2879
|
updateButtonEnabledState();
|
|
2741
2880
|
|
|
@@ -2795,6 +2934,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
2795
2934
|
// d3 v4+ no longer folds entered nodes into the update selection, so
|
|
2796
2935
|
// merge them before the shared styling/positioning below.
|
|
2797
2936
|
node = nodeEnter.merge(node);
|
|
2937
|
+
drawCollapsedClades(node);
|
|
2798
2938
|
|
|
2799
2939
|
// Create the optional children only where they will show something,
|
|
2800
2940
|
// and drop them where they will not. Must run BEFORE node.transition()
|
|
@@ -3022,9 +3162,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
3022
3162
|
// Dim Non-Matches: one opacity on the node GROUP dims its labels, dot,
|
|
3023
3163
|
// shape and branch-data numbers together; the branch lines are separate
|
|
3024
3164
|
// path.link elements and keep their full colour, as on the desktop.
|
|
3025
|
-
// Hits and selected nodes (getFoundColor) are never dimmed
|
|
3165
|
+
// Hits and selected nodes (getFoundColor) are never dimmed -- and
|
|
3166
|
+
// neither is a collapsed clade holding one: its wedge and label live in
|
|
3167
|
+
// the clade node's group, and the clade node itself is never a hit.
|
|
3026
3168
|
node.style('opacity', function (d) {
|
|
3027
|
-
|
|
3169
|
+
if (!_dimNonMatches || getFoundColor(d) || (isCollapsed(d) && collapsedHoldsHighlight(d))) {
|
|
3170
|
+
return null;
|
|
3171
|
+
}
|
|
3172
|
+
return DIM_NON_MATCH_OPACITY;
|
|
3028
3173
|
});
|
|
3029
3174
|
|
|
3030
3175
|
let nodeUpdate = animateOrSet(node, transitionDuration)
|
|
@@ -3172,43 +3317,75 @@ function (root, d3, forester, phyloXml) {
|
|
|
3172
3317
|
// _svgGroup, so that name put them in the way of selectAll('path.link')
|
|
3173
3318
|
// -- the main link data-join, and the overview's miniature.
|
|
3174
3319
|
_svgGroup.selectAll('g.aptx-align-ext').remove();
|
|
3175
|
-
|
|
3176
|
-
|
|
3320
|
+
let alignedRect = !radialDisplay() && _state.phylogram && _state.alignPhylogram;
|
|
3321
|
+
let tipGuides = alignedRect && _state.showExternalLabels
|
|
3322
|
+
&& (_state.showNodeName || _state.showTaxonomy || _state.showSequence);
|
|
3323
|
+
// a collapsed clade's label always shows, so its guide does too: from
|
|
3324
|
+
// past the wedge's farthest tip to the label column
|
|
3325
|
+
let collapsedGuides = alignedRect ? nodes.filter(function (d) {
|
|
3326
|
+
return isCollapsed(d) && !(_state.dynahide && d.hide);
|
|
3327
|
+
}) : [];
|
|
3328
|
+
if (tipGuides || collapsedGuides.length > 0) {
|
|
3177
3329
|
let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
|
|
3178
|
-
|
|
3179
|
-
.
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
.
|
|
3186
|
-
|
|
3330
|
+
let guideStyle = function (sel) {
|
|
3331
|
+
return sel.attr('fill', 'none')
|
|
3332
|
+
.attr('stroke-width', 1)
|
|
3333
|
+
.attr('stroke', _state.branchColorDefault)
|
|
3334
|
+
.style('stroke-opacity', 0.25);
|
|
3335
|
+
};
|
|
3336
|
+
if (tipGuides) {
|
|
3337
|
+
guideStyle(ext.selectAll('path.aptx-tip-guide')
|
|
3338
|
+
.data(links.filter(function (d) {
|
|
3339
|
+
return (!d.target.children && !(_state.dynahide && d.target.hide));
|
|
3340
|
+
}))
|
|
3341
|
+
.enter().append('path').attr('class', 'aptx-tip-guide'))
|
|
3342
|
+
.attr('d', function (d) {
|
|
3343
|
+
return connection(d.target);
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
guideStyle(ext.selectAll('path.aptx-collapsed-guide')
|
|
3347
|
+
.data(collapsedGuides)
|
|
3348
|
+
.enter().append('path').attr('class', 'aptx-collapsed-guide'))
|
|
3187
3349
|
.attr('d', function (d) {
|
|
3188
|
-
|
|
3350
|
+
let x1 = collapsedReach(d)[1] + 5;
|
|
3351
|
+
return (_w - x1) > 5 ? 'M' + x1 + ',' + d.x + 'L' + _w + ',' + d.x : null;
|
|
3189
3352
|
});
|
|
3190
3353
|
}
|
|
3191
3354
|
|
|
3192
3355
|
// circular: a thin dashed connector from each external node out to the
|
|
3193
3356
|
// common label ring (so labels line up like iTOL's aligned display).
|
|
3194
3357
|
_svgGroup.selectAll('g.aptx-radial-conn').remove();
|
|
3195
|
-
if (_state.circularDisplay
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
.
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
.
|
|
3358
|
+
if (_state.circularDisplay) {
|
|
3359
|
+
// tips while their labels show; a collapsed clade always (its label
|
|
3360
|
+
// always shows), from its wedge's farthest reach out to the ring
|
|
3361
|
+
let connected = nodes.filter(function (d) {
|
|
3362
|
+
if (_state.dynahide && d.hide) {
|
|
3363
|
+
return false;
|
|
3364
|
+
}
|
|
3365
|
+
if (isCollapsed(d)) {
|
|
3366
|
+
return _radial.maxRad - radialRadius(collapsedReach(d)[1]) > 2;
|
|
3367
|
+
}
|
|
3368
|
+
return !d.children && _state.showExternalLabels;
|
|
3369
|
+
});
|
|
3370
|
+
let innerY = function (d) {
|
|
3371
|
+
return isCollapsed(d) ? collapsedReach(d)[1] : d.y;
|
|
3372
|
+
};
|
|
3373
|
+
if (connected.length > 0) {
|
|
3374
|
+
let conn = _svgGroup.insert('g', 'g').attr('class', 'aptx-radial-conn');
|
|
3375
|
+
conn.selectAll('line')
|
|
3376
|
+
.data(connected)
|
|
3377
|
+
.enter().append('line')
|
|
3378
|
+
.attr('stroke', _state.branchColorDefault)
|
|
3379
|
+
// width/opacity match the rectangular aligned extensions; the
|
|
3380
|
+
// dash alone marks these as connectors (0.5px at 0.3 vanished)
|
|
3381
|
+
.attr('stroke-width', 1)
|
|
3382
|
+
.style('stroke-opacity', 0.4)
|
|
3383
|
+
.style('stroke-dasharray', '2,3')
|
|
3384
|
+
.attr('x1', function (d) { return radialXY(d.x, innerY(d))[0]; })
|
|
3385
|
+
.attr('y1', function (d) { return radialXY(d.x, innerY(d))[1]; })
|
|
3386
|
+
.attr('x2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[0]; })
|
|
3387
|
+
.attr('y2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[1]; });
|
|
3388
|
+
}
|
|
3212
3389
|
}
|
|
3213
3390
|
|
|
3214
3391
|
for (let i = 0, len = nodes.length; i !== len; ++i) {
|
|
@@ -3218,12 +3395,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
3218
3395
|
}
|
|
3219
3396
|
|
|
3220
3397
|
hideHoverGlow(); // the hovered node may have moved; the next mouseover re-shows it
|
|
3398
|
+
drawScaleBar();
|
|
3221
3399
|
drawDomainArchitectures();
|
|
3222
3400
|
drawDomainLegend();
|
|
3223
3401
|
drawMsaTrack();
|
|
3224
3402
|
drawTimeOverlays();
|
|
3225
3403
|
rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
|
|
3226
3404
|
updateSearchHitNavigation();
|
|
3405
|
+
noteViewChange();
|
|
3227
3406
|
}
|
|
3228
3407
|
|
|
3229
3408
|
// A node is drawn as a shape only when there is a reason to show one: it
|
|
@@ -3257,11 +3436,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
3257
3436
|
let styled = _state.nodeSizeDefault > 0 && node.parent && !node.hasVis
|
|
3258
3437
|
&& styleOf !== null && !!styleOf.nodeColor;
|
|
3259
3438
|
|
|
3260
|
-
//
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3439
|
+
// The root is no exception. A 2.x rule gave "a zero-length branch
|
|
3440
|
+
// off the root" a circle so it would not vanish -- but the tree is
|
|
3441
|
+
// laid out from the invisible wrapper above the root, so the node it
|
|
3442
|
+
// matched was the root itself, and only in a phylogram with nothing
|
|
3443
|
+
// coloured: a circle that appeared and disappeared with the display
|
|
3444
|
+
// type and meant nothing. Dropped 2026-09-13; the root is now marked
|
|
3445
|
+
// by its stub (placeRootStub), and wears a circle only for the
|
|
3446
|
+
// reasons any node does.
|
|
3447
|
+
return (visualized || styled) ? _state.nodeSizeDefault : 0;
|
|
3265
3448
|
};
|
|
3266
3449
|
|
|
3267
3450
|
let makeBranchWidth = function (link) {
|
|
@@ -3416,6 +3599,17 @@ function (root, d3, forester, phyloXml) {
|
|
|
3416
3599
|
if (colorModeOf(vis) === 'range') {
|
|
3417
3600
|
return vis.rangeScale ? vis.rangeScale(Number(value)) : null;
|
|
3418
3601
|
}
|
|
3602
|
+
// A value only tips hidden in a collapsed clade carry is not on
|
|
3603
|
+
// screen, so not in the scale's domain -- and a d3 ordinal scale asked
|
|
3604
|
+
// about an unknown value extends its domain and hands out an on-screen
|
|
3605
|
+
// value's colour. Such a value keeps its remembered colour instead
|
|
3606
|
+
// (assigned at launch over the whole tree, or the user's override):
|
|
3607
|
+
// the clade's wedge keeps the colour its tips wore. Christian,
|
|
3608
|
+
// 2026-09-14; reported by the desktop session, which follows.
|
|
3609
|
+
if (vis.categoryOnScreen && !vis.categoryOnScreen.has(value)) {
|
|
3610
|
+
let mem = _vis.colorMemory[vis.id];
|
|
3611
|
+
return (mem && mem[visMemoryKey(vis, value)]) || null;
|
|
3612
|
+
}
|
|
3419
3613
|
return vis.categoryScale(value);
|
|
3420
3614
|
}
|
|
3421
3615
|
|
|
@@ -3456,11 +3650,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
3456
3650
|
if (_root && ((_foundNodes0 && _foundNodes0.size > 0) || (_foundNodes1 && _foundNodes1.size > 0))) {
|
|
3457
3651
|
// natural (top-to-bottom) drawing order -- forester's preorder
|
|
3458
3652
|
// walks children in reverse, which made the NEXT arrow step upward
|
|
3653
|
+
// a collapsed clade holding hits is ONE stop, on its node: the hits
|
|
3654
|
+
// inside have no position of their own to step to
|
|
3459
3655
|
let walk = function (n) {
|
|
3460
|
-
if (isNodeFound(n)) {
|
|
3656
|
+
if (isNodeFound(n) || (isCollapsed(n) && collapsedFoundCounts(n).found > 0)) {
|
|
3461
3657
|
hits.push(n);
|
|
3462
3658
|
}
|
|
3463
|
-
if (n.children) {
|
|
3659
|
+
if (n.children && !isCollapsed(n)) {
|
|
3464
3660
|
for (let i = 0; i < n.children.length; ++i) {
|
|
3465
3661
|
walk(n.children[i]);
|
|
3466
3662
|
}
|
|
@@ -3728,6 +3924,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3728
3924
|
}
|
|
3729
3925
|
|
|
3730
3926
|
let makeNodeLabel = function (phynode) {
|
|
3927
|
+
if (isCollapsed(phynode)) {
|
|
3928
|
+
return ''; // a collapsed clade carries its own label (drawCollapsedClades)
|
|
3929
|
+
}
|
|
3731
3930
|
if (!_state.showExternalLabels && !(phynode.children)) {
|
|
3732
3931
|
return null;
|
|
3733
3932
|
}
|
|
@@ -4329,9 +4528,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
4329
4528
|
'ladderizeTree',
|
|
4330
4529
|
'nhExportWriteConfidences',
|
|
4331
4530
|
'nodeLabels',
|
|
4531
|
+
'onViewChange',
|
|
4332
4532
|
'pngExportScale',
|
|
4333
4533
|
'rootOffset',
|
|
4334
4534
|
'supportDotMinimum',
|
|
4535
|
+
'view',
|
|
4335
4536
|
'zoomToFitUponWindowResize'
|
|
4336
4537
|
];
|
|
4337
4538
|
|
|
@@ -4548,7 +4749,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
4548
4749
|
throw new Error(ERROR + '"domainEvalueExponent" must be an integer from '
|
|
4549
4750
|
+ forester.DOMAIN_EVALUE_EXPONENT_MIN + ' to ' + forester.DOMAIN_EVALUE_EXPONENT_MAX);
|
|
4550
4751
|
}
|
|
4551
|
-
_domain = {width: null, palette: null, next: 0, legendFrac: null};
|
|
4752
|
+
_domain = {width: null, radialWidth: null, palette: null, next: 0, legendFrac: null};
|
|
4552
4753
|
if (_basicTreeProperties.domainArchitectures) {
|
|
4553
4754
|
// malformed domains are skipped, never fatal -- said once, here
|
|
4554
4755
|
let ignored = forester.domainArchitectureStats(_treeData).ignored;
|
|
@@ -4790,6 +4991,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
4790
4991
|
throw new Error(ERROR + 'internalNumericLabels must be "auto", "confidence" or "label"');
|
|
4791
4992
|
}
|
|
4792
4993
|
// custom label-field checkboxes (was launch()'s sixth positional arg)
|
|
4994
|
+
if (_settings.view === undefined || _settings.view === null) {
|
|
4995
|
+
_settings.view = null;
|
|
4996
|
+
} else if (typeof _settings.view !== 'object') {
|
|
4997
|
+
throw new Error(ERROR + '"view" must be a view state object (getViewState / decodeViewState) or null');
|
|
4998
|
+
}
|
|
4999
|
+
if (_settings.onViewChange === undefined || _settings.onViewChange === null) {
|
|
5000
|
+
_settings.onViewChange = null;
|
|
5001
|
+
} else if (typeof _settings.onViewChange !== 'function') {
|
|
5002
|
+
throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
|
|
5003
|
+
}
|
|
4793
5004
|
if (_settings.nodeLabels === undefined) {
|
|
4794
5005
|
_settings.nodeLabels = null;
|
|
4795
5006
|
} else if (_settings.nodeLabels !== null && typeof _settings.nodeLabels !== 'object') {
|
|
@@ -4892,6 +5103,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
4892
5103
|
if (_settings.ladderizeTree) {
|
|
4893
5104
|
ladderizeSubtree(_root, true); // one pass: a definite arrangement
|
|
4894
5105
|
}
|
|
5106
|
+
_viewOps = {order: null, root: null};
|
|
5107
|
+
if (_settings.view) {
|
|
5108
|
+
// the shared view, its searches found before the first draw
|
|
5109
|
+
applyViewState(_settings.view, true);
|
|
5110
|
+
runSearches();
|
|
5111
|
+
}
|
|
4895
5112
|
if (_state.searchAinitialValue) {
|
|
4896
5113
|
search0();
|
|
4897
5114
|
}
|
|
@@ -4905,6 +5122,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
4905
5122
|
|
|
4906
5123
|
search0();
|
|
4907
5124
|
search1();
|
|
5125
|
+
settleViewNotification();
|
|
4908
5126
|
}
|
|
4909
5127
|
|
|
4910
5128
|
function bindDoc(type, fn, opts) {
|
|
@@ -4922,6 +5140,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
4922
5140
|
// resolves once the tree is drawn: at once for a small tree,
|
|
4923
5141
|
// after the deferred first draw for a big one
|
|
4924
5142
|
ready: _readyPromise,
|
|
5143
|
+
// the trees of the launch -- one, or every tree the file held:
|
|
5144
|
+
// how many, which is shown, and showing another (a fresh
|
|
5145
|
+
// viewer for it; the handle returned is the one to keep)
|
|
5146
|
+
getTreeCount: function () {
|
|
5147
|
+
return _trees.length;
|
|
5148
|
+
},
|
|
5149
|
+
getTreeIndex: function () {
|
|
5150
|
+
return _treeIndex;
|
|
5151
|
+
},
|
|
5152
|
+
showTree: showTree,
|
|
5153
|
+
// the view -- layout, display, labels, colours, searches, the
|
|
5154
|
+
// clade and the collapsed clades, sizes -- as a plain object,
|
|
5155
|
+
// and opening one (see also archaeopteryx.encodeViewState /
|
|
5156
|
+
// decodeViewState and the config's view / onViewChange)
|
|
5157
|
+
getViewState: getViewState,
|
|
5158
|
+
applyViewState: function (state) {
|
|
5159
|
+
applyViewState(state, false);
|
|
5160
|
+
},
|
|
4925
5161
|
destroy: destroyViewer
|
|
4926
5162
|
};
|
|
4927
5163
|
}
|
|
@@ -4974,6 +5210,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
4974
5210
|
_root = null;
|
|
4975
5211
|
_root_const = null;
|
|
4976
5212
|
_treeData = null;
|
|
5213
|
+
_trees = [];
|
|
5214
|
+
_treeIndex = 0;
|
|
5215
|
+
_launchConfig = null;
|
|
5216
|
+
_lastViewEncoded = undefined;
|
|
5217
|
+
_viewOps = {order: null, root: null};
|
|
4977
5218
|
_basicTreeProperties = null;
|
|
4978
5219
|
_baseSvg = null;
|
|
4979
5220
|
_svgGroup = null;
|
|
@@ -5029,9 +5270,39 @@ function (root, d3, forester, phyloXml) {
|
|
|
5029
5270
|
if (phylo === undefined || phylo === null) {
|
|
5030
5271
|
throw new Error(ERROR + 'input tree is undefined or null');
|
|
5031
5272
|
}
|
|
5032
|
-
|
|
5033
|
-
|
|
5273
|
+
// One tree, or a list of them (every tree a file holds -- parseTrees):
|
|
5274
|
+
// the list is kept, the first is shown, and the panel's picker and the
|
|
5275
|
+
// handle's showTree() move between them.
|
|
5276
|
+
let trees = Array.isArray(phylo) ? phylo.slice() : [phylo];
|
|
5277
|
+
if (trees.length === 0) {
|
|
5278
|
+
throw new Error(ERROR + 'input tree list is empty');
|
|
5034
5279
|
}
|
|
5280
|
+
trees.forEach(function (t, i) {
|
|
5281
|
+
if (!t || (!t.children) || (t.children.length < 1)) {
|
|
5282
|
+
throw new Error(ERROR + (trees.length > 1 ? 'input tree ' + (i + 1) + ' of ' + trees.length : 'input tree')
|
|
5283
|
+
+ ' is empty or illegally formatted');
|
|
5284
|
+
}
|
|
5285
|
+
});
|
|
5286
|
+
// the two view keys are checked up here with the other arguments,
|
|
5287
|
+
// before anything is touched (and where Node can test it)
|
|
5288
|
+
if (config && config.view !== undefined && config.view !== null && typeof config.view !== 'object') {
|
|
5289
|
+
throw new Error(ERROR + '"view" must be a view state object (getViewState / decodeViewState) or null');
|
|
5290
|
+
}
|
|
5291
|
+
if (config && config.onViewChange !== undefined && config.onViewChange !== null && typeof config.onViewChange !== 'function') {
|
|
5292
|
+
throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
|
|
5293
|
+
}
|
|
5294
|
+
// a view names the tree it was made on; a fresh launch reports no
|
|
5295
|
+
// view until one changes (settleViewNotification)
|
|
5296
|
+
_lastViewEncoded = undefined;
|
|
5297
|
+
let view = config && config.view;
|
|
5298
|
+
let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
|
|
5299
|
+
return launchInto(container, trees, start, config);
|
|
5300
|
+
};
|
|
5301
|
+
|
|
5302
|
+
// The launch proper, for one tree of the list: launch() validates and
|
|
5303
|
+
// starts at the first, showTree() re-enters here for another.
|
|
5304
|
+
function launchInto(container, trees, index, config) {
|
|
5305
|
+
let phylo = trees[index];
|
|
5035
5306
|
let cfg = readConfig(config);
|
|
5036
5307
|
requireForester();
|
|
5037
5308
|
requireD3();
|
|
@@ -5039,7 +5310,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
5039
5310
|
|
|
5040
5311
|
let previousContainer = _container; // for the different-container teardown below
|
|
5041
5312
|
_treeData = phylo;
|
|
5313
|
+
_trees = trees;
|
|
5314
|
+
_treeIndex = index;
|
|
5315
|
+
_launchConfig = config;
|
|
5042
5316
|
_container = containerEl;
|
|
5317
|
+
assignViewIds(phylo);
|
|
5043
5318
|
_zoomListener = d3.zoom()
|
|
5044
5319
|
.scaleExtent([0.1, 10])
|
|
5045
5320
|
.filter(function (event) {
|
|
@@ -5180,10 +5455,28 @@ function (root, d3, forester, phyloXml) {
|
|
|
5180
5455
|
}
|
|
5181
5456
|
|
|
5182
5457
|
return makeViewerHandle();
|
|
5458
|
+
}
|
|
5183
5459
|
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5460
|
+
// Shows another tree of the launched list in the same container under
|
|
5461
|
+
// the same config. The tree opens fresh -- its own presets, a clean
|
|
5462
|
+
// view -- the way a new tab does on the desktop. The panel's picker and
|
|
5463
|
+
// previous / next buttons come here, as does the handle's showTree().
|
|
5464
|
+
function showTree(index) {
|
|
5465
|
+
if (!_container) {
|
|
5466
|
+
throw new Error(ERROR + 'showTree(): nothing is launched');
|
|
5467
|
+
}
|
|
5468
|
+
if (!Number.isInteger(index) || index < 0 || index >= _trees.length) {
|
|
5469
|
+
throw new Error(ERROR + 'showTree(): the index must be 0 to ' + (_trees.length - 1) + ', got ' + index);
|
|
5470
|
+
}
|
|
5471
|
+
if (index === _treeIndex) {
|
|
5472
|
+
return makeViewerHandle();
|
|
5473
|
+
}
|
|
5474
|
+
// collapsing is part of the view, and a switched-to tree opens fresh:
|
|
5475
|
+
// nothing collapsed, now or when you come back (Christian, 2026-09-14)
|
|
5476
|
+
clearCollapsedFlags(_trees[_treeIndex]);
|
|
5477
|
+
clearCollapsedFlags(_trees[index]);
|
|
5478
|
+
return launchInto(_container, _trees, index, _launchConfig);
|
|
5479
|
+
}
|
|
5187
5480
|
|
|
5188
5481
|
archaeopteryx.parsePhyloXML = function (data) {
|
|
5189
5482
|
requirePhyloXml();
|
|
@@ -5240,8 +5533,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
5240
5533
|
return forester.parseAuspiceJson(data);
|
|
5241
5534
|
};
|
|
5242
5535
|
|
|
5243
|
-
// A Nexus file can hold several trees; the FIRST one
|
|
5244
|
-
// alignment from the file's characters
|
|
5536
|
+
// A Nexus file can hold several trees; this returns the FIRST one
|
|
5537
|
+
// (parseTrees returns them all; any alignment from the file's characters
|
|
5538
|
+
// matrix rides along on the tips).
|
|
5245
5539
|
archaeopteryx.parseNexus = function (data, mode, legacyMode) {
|
|
5246
5540
|
requireForester();
|
|
5247
5541
|
let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
|
|
@@ -5255,6 +5549,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
5255
5549
|
function calcMaxExtLabel() {
|
|
5256
5550
|
_maxLabelLength = _state.nodeLabelGap;
|
|
5257
5551
|
forester.preOrderTraversal(_root, function (d) {
|
|
5552
|
+
if (isCollapsed(d)) {
|
|
5553
|
+
_maxLabelLength = Math.max(collapsedLabel(d).length, _maxLabelLength);
|
|
5554
|
+
}
|
|
5258
5555
|
if (!d.children) {
|
|
5259
5556
|
let l = makeNodeLabel(d);
|
|
5260
5557
|
if (l) {
|
|
@@ -5337,134 +5634,21 @@ function (root, d3, forester, phyloXml) {
|
|
|
5337
5634
|
update();
|
|
5338
5635
|
}
|
|
5339
5636
|
|
|
5637
|
+
// The tips under the node as a tab-separated table with a header,
|
|
5638
|
+
// the desktop's columns (forester.externalNodeDataTable), top to
|
|
5639
|
+
// bottom as drawn; it reads back through a metadata-table join.
|
|
5340
5640
|
function downloadExternalNodeDataAll(node) {
|
|
5341
|
-
|
|
5342
|
-
let addSep = function (t) {
|
|
5343
|
-
if (t.length > 0) {
|
|
5344
|
-
t += '\t';
|
|
5345
|
-
}
|
|
5346
|
-
return t;
|
|
5347
|
-
};
|
|
5348
|
-
|
|
5349
|
-
let addSepSame = function (t) {
|
|
5350
|
-
if (t.length > 0) {
|
|
5351
|
-
t += ', ';
|
|
5352
|
-
}
|
|
5353
|
-
return t;
|
|
5354
|
-
};
|
|
5355
|
-
let text_all = '';
|
|
5356
|
-
|
|
5357
5641
|
const ext_nodes = forester.getAllExternalNodes(node).reverse();
|
|
5358
|
-
|
|
5359
5642
|
let filename;
|
|
5360
5643
|
if (ext_nodes.length === 1 && ext_nodes[0].name) {
|
|
5361
|
-
filename = 'External_Node_Data_for_Node_' + ext_nodes[0].name.replace(/\W/g, '_') + '.
|
|
5644
|
+
filename = 'External_Node_Data_for_Node_' + ext_nodes[0].name.replace(/\W/g, '_') + '.tsv';
|
|
5362
5645
|
} else {
|
|
5363
|
-
filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.
|
|
5364
|
-
}
|
|
5365
|
-
|
|
5366
|
-
for (let j = 0, l = ext_nodes.length; j < l; ++j) {
|
|
5367
|
-
let text = '';
|
|
5368
|
-
let n = ext_nodes[j];
|
|
5369
|
-
if (n.name) {
|
|
5370
|
-
text += n.name
|
|
5371
|
-
}
|
|
5372
|
-
|
|
5373
|
-
if (n.properties && (n.properties.length > 0)) {
|
|
5374
|
-
const sorted_properties = n.properties.concat().sort();
|
|
5375
|
-
const l = sorted_properties.length;
|
|
5376
|
-
let properties_text = '';
|
|
5377
|
-
let prev_property_ref = null;
|
|
5378
|
-
for (let pl = 0; pl < l; ++pl) {
|
|
5379
|
-
if (forester.isNodeScopedProperty(sorted_properties[pl])) {
|
|
5380
|
-
if (sorted_properties[pl].ref === prev_property_ref) {
|
|
5381
|
-
properties_text = addSepSame(properties_text);
|
|
5382
|
-
} else {
|
|
5383
|
-
prev_property_ref = sorted_properties[pl].ref;
|
|
5384
|
-
properties_text = addSep(properties_text);
|
|
5385
|
-
}
|
|
5386
|
-
properties_text += sorted_properties[pl].value;
|
|
5387
|
-
}
|
|
5388
|
-
}
|
|
5389
|
-
if (properties_text.length > 0) {
|
|
5390
|
-
text = addSep(text);
|
|
5391
|
-
text += properties_text;
|
|
5392
|
-
}
|
|
5393
|
-
}
|
|
5394
|
-
|
|
5395
|
-
if (n.taxonomies) {
|
|
5396
|
-
let tax_text = '';
|
|
5397
|
-
for (let i = 0; i < n.taxonomies.length; ++i) {
|
|
5398
|
-
let t = n.taxonomies[i];
|
|
5399
|
-
if (t.id) {
|
|
5400
|
-
if (t.id.provider) {
|
|
5401
|
-
tax_text = addSep(tax_text);
|
|
5402
|
-
tax_text += '[' + t.id.provider + ']:' + t.id.value;
|
|
5403
|
-
} else {
|
|
5404
|
-
tax_text = addSep(tax_text);
|
|
5405
|
-
tax_text += t.id.value;
|
|
5406
|
-
}
|
|
5407
|
-
}
|
|
5408
|
-
if (t.code) {
|
|
5409
|
-
tax_text = addSep(tax_text);
|
|
5410
|
-
tax_text += t.code;
|
|
5411
|
-
}
|
|
5412
|
-
if (t.scientific_name) {
|
|
5413
|
-
tax_text = addSep(tax_text);
|
|
5414
|
-
tax_text += t.scientific_name;
|
|
5415
|
-
}
|
|
5416
|
-
if (t.common_name) {
|
|
5417
|
-
tax_text = addSep(tax_text);
|
|
5418
|
-
tax_text += t.common_name;
|
|
5419
|
-
}
|
|
5420
|
-
if (t.rank) {
|
|
5421
|
-
tax_text = addSep(tax_text);
|
|
5422
|
-
tax_text += t.rank;
|
|
5423
|
-
}
|
|
5424
|
-
}
|
|
5425
|
-
text = addSep(text);
|
|
5426
|
-
text += tax_text;
|
|
5427
|
-
}
|
|
5428
|
-
if (n.sequences) {
|
|
5429
|
-
let seq_text = '';
|
|
5430
|
-
for (let i = 0; i < n.sequences.length; ++i) {
|
|
5431
|
-
let s = n.sequences[i];
|
|
5432
|
-
if (s.accession) {
|
|
5433
|
-
if (s.accession.source) {
|
|
5434
|
-
seq_text = addSep(seq_text);
|
|
5435
|
-
seq_text += '[' + s.accession.source + ']:' + s.accession.value;
|
|
5436
|
-
} else {
|
|
5437
|
-
seq_text = addSep(seq_text);
|
|
5438
|
-
seq_text += s.accession.value;
|
|
5439
|
-
}
|
|
5440
|
-
}
|
|
5441
|
-
if (s.symbol) {
|
|
5442
|
-
seq_text = addSep(seq_text);
|
|
5443
|
-
seq_text += s.symbol;
|
|
5444
|
-
}
|
|
5445
|
-
if (s.name) {
|
|
5446
|
-
seq_text = addSep(seq_text);
|
|
5447
|
-
seq_text += s.name;
|
|
5448
|
-
}
|
|
5449
|
-
if (s.gene_name) {
|
|
5450
|
-
seq_text = addSep(seq_text);
|
|
5451
|
-
seq_text += s.gene_name;
|
|
5452
|
-
}
|
|
5453
|
-
if (s.location) {
|
|
5454
|
-
seq_text = addSep(seq_text);
|
|
5455
|
-
seq_text += s.location;
|
|
5456
|
-
}
|
|
5457
|
-
}
|
|
5458
|
-
text = addSep(text);
|
|
5459
|
-
text += seq_text;
|
|
5460
|
-
}
|
|
5461
|
-
if (text.length > 0) {
|
|
5462
|
-
text_all += text + '\n';
|
|
5463
|
-
}
|
|
5646
|
+
filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.tsv';
|
|
5464
5647
|
}
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5648
|
+
const tsv = forester.externalNodeDataTsv(ext_nodes, function (n, i) {
|
|
5649
|
+
return (n.viewId !== undefined) ? n.viewId : i + 1;
|
|
5650
|
+
});
|
|
5651
|
+
saveAs(new Blob([tsv], {type: 'text/tab-separated-values'}), filename);
|
|
5468
5652
|
update();
|
|
5469
5653
|
}
|
|
5470
5654
|
|
|
@@ -5589,6 +5773,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
5589
5773
|
}
|
|
5590
5774
|
|
|
5591
5775
|
function switchToSubtree(node) {
|
|
5776
|
+
if (node.collapsed) {
|
|
5777
|
+
node.collapsed = false; // a subtree view shows what the clade holds
|
|
5778
|
+
}
|
|
5592
5779
|
if (node.parent) {
|
|
5593
5780
|
if (!(node.children)) {
|
|
5594
5781
|
if (node.parent.parent) {
|
|
@@ -5665,6 +5852,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
5665
5852
|
// "Switch to ..." matches the desktop's wording for this action.
|
|
5666
5853
|
items.push({separator: true});
|
|
5667
5854
|
items.push({label: 'Switch to Subtree', action: function () { switchToSubtree(d); }});
|
|
5855
|
+
// the desktop's two collapse entries, with its enabling rules:
|
|
5856
|
+
// the toggle for an internal node that is not the root and not
|
|
5857
|
+
// in the unrooted layout; the subtree uncollapse only when
|
|
5858
|
+
// something below is collapsed
|
|
5859
|
+
items.push({label: 'Collapse/Uncollapse', disabled: !(collapseAllowed() && d.children),
|
|
5860
|
+
action: function () { collapseToggle(d); }});
|
|
5861
|
+
items.push({label: 'Uncollapse Subtree', disabled: !hasCollapsedIn(d),
|
|
5862
|
+
action: function () { uncollapse(d); }});
|
|
5668
5863
|
}
|
|
5669
5864
|
if (d.parent && d.children) {
|
|
5670
5865
|
// redrawn with no transition: animating a swap sends the two
|
|
@@ -5684,7 +5879,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
5684
5879
|
}
|
|
5685
5880
|
if (!_in_subtree && d.parent && d.parent.parent
|
|
5686
5881
|
&& ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
|
|
5687
|
-
items.push({label: 'Reroot', action: function () {
|
|
5882
|
+
items.push({label: 'Reroot', action: function () {
|
|
5883
|
+
rerootKeepingCollapse(function () {
|
|
5884
|
+
forester.reRoot(tree, d, -1);
|
|
5885
|
+
});
|
|
5886
|
+
zoomToFit();
|
|
5887
|
+
}});
|
|
5688
5888
|
}
|
|
5689
5889
|
if (_settings.enableManualNodeSelection) {
|
|
5690
5890
|
items.push({label: 'Select/Deselect Node', action: function () { selectDeselectNode(d); }});
|
|
@@ -6025,7 +6225,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6025
6225
|
updateMsaScrollbar(0, 1, 0);
|
|
6026
6226
|
return;
|
|
6027
6227
|
}
|
|
6028
|
-
let tips =
|
|
6228
|
+
let tips = displayedTips().filter(function (d) {
|
|
6029
6229
|
return d.x !== undefined;
|
|
6030
6230
|
}).sort(function (p, q) {
|
|
6031
6231
|
return p.x - q.x;
|
|
@@ -6525,139 +6725,1250 @@ function (root, d3, forester, phyloXml) {
|
|
|
6525
6725
|
scheduleUpdate(null, 0);
|
|
6526
6726
|
}
|
|
6527
6727
|
|
|
6528
|
-
// =====================
|
|
6529
|
-
//
|
|
6530
|
-
//
|
|
6531
|
-
//
|
|
6532
|
-
//
|
|
6533
|
-
//
|
|
6534
|
-
//
|
|
6535
|
-
//
|
|
6536
|
-
//
|
|
6537
|
-
//
|
|
6538
|
-
//
|
|
6539
|
-
//
|
|
6728
|
+
// ===================== Collapsed clades =====================
|
|
6729
|
+
// Collapsing is DISPLAY state on the node (d.collapsed) and nothing
|
|
6730
|
+
// else: forester never sees it, the writers never see it, and the data
|
|
6731
|
+
// tree keeps every tip. The layout treats a collapsed node as a leaf and
|
|
6732
|
+
// the viewer draws it as a wedge -- apex at the node, its two edges
|
|
6733
|
+
// reaching the clade's nearest and farthest tips so its depth stays
|
|
6734
|
+
// readable, filled in the clade's dominant Color-by colour, its height
|
|
6735
|
+
// growing gently with the tip count -- named by the node's own name,
|
|
6736
|
+
// else by the one Color-by value nearly all its tips share, else by the
|
|
6737
|
+
// tips' common name prefix, always with the tip count, and with
|
|
6738
|
+
// "[found/total]" while a search hits inside it (the desktop's touch).
|
|
6739
|
+
// The CONTROLS are the desktop's exactly (Christian, 2026-09-13): the
|
|
6740
|
+
// node menu's "Collapse/Uncollapse" and "Uncollapse Subtree", the
|
|
6741
|
+
// uncollapse-all button in the tool row with the desktop's glyph, no
|
|
6742
|
+
// collapsing in the unrooted layout, never the root. Every walk of the
|
|
6743
|
+
// DISPLAYED tree goes through forEachDisplayed; a hidden tip keeps its
|
|
6744
|
+
// stale position and must never be drawn from.
|
|
6745
|
+
let _rowUnit = 0; // px per row in the last rectangular layout
|
|
6540
6746
|
|
|
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);
|
|
6747
|
+
function collapseAllowed() {
|
|
6748
|
+
return !_state.unrootedDisplay;
|
|
6549
6749
|
}
|
|
6550
6750
|
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6751
|
+
function isCollapsed(d) {
|
|
6752
|
+
return d.collapsed === true && !!d.children && collapseAllowed();
|
|
6753
|
+
}
|
|
6754
|
+
|
|
6755
|
+
// visits the displayed tree: a collapsed node, not what it hides
|
|
6756
|
+
function forEachDisplayed(fn) {
|
|
6757
|
+
if (!_root) {
|
|
6758
|
+
return;
|
|
6559
6759
|
}
|
|
6560
|
-
|
|
6760
|
+
(function walk(n) {
|
|
6761
|
+
fn(n);
|
|
6762
|
+
if (n.children && !isCollapsed(n)) {
|
|
6763
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
6764
|
+
walk(n.children[i]);
|
|
6765
|
+
}
|
|
6766
|
+
}
|
|
6767
|
+
})(_root);
|
|
6561
6768
|
}
|
|
6562
6769
|
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6770
|
+
function displayedTips() {
|
|
6771
|
+
let out = [];
|
|
6772
|
+
forEachDisplayed(function (n) {
|
|
6773
|
+
if (!n.children) {
|
|
6774
|
+
out.push(n);
|
|
6775
|
+
}
|
|
6776
|
+
});
|
|
6777
|
+
return out;
|
|
6778
|
+
}
|
|
6779
|
+
|
|
6780
|
+
function displayedLeafCount() {
|
|
6781
|
+
let count = 0;
|
|
6782
|
+
forEachDisplayed(function (n) {
|
|
6783
|
+
if (!n.children || isCollapsed(n)) {
|
|
6784
|
+
count++;
|
|
6785
|
+
}
|
|
6786
|
+
});
|
|
6787
|
+
return count;
|
|
6788
|
+
}
|
|
6789
|
+
|
|
6790
|
+
function hasCollapsedIn(node) {
|
|
6791
|
+
let found = false;
|
|
6792
|
+
forester.preOrderTraversal(node, function (n) {
|
|
6793
|
+
if (n.collapsed === true && n.children) {
|
|
6794
|
+
found = true;
|
|
6571
6795
|
}
|
|
6796
|
+
});
|
|
6797
|
+
return found;
|
|
6798
|
+
}
|
|
6799
|
+
|
|
6800
|
+
function collapseToggle(d) {
|
|
6801
|
+
if (!collapseAllowed() || !d.children || !d.parent || !d.parent.parent) {
|
|
6802
|
+
return;
|
|
6572
6803
|
}
|
|
6573
|
-
|
|
6804
|
+
d.collapsed = !d.collapsed;
|
|
6805
|
+
calcMaxExtLabel();
|
|
6806
|
+
refreshVisualizations(false); // the legends describe the tips on screen
|
|
6807
|
+
scheduleUpdate(null, 0);
|
|
6574
6808
|
}
|
|
6575
6809
|
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6810
|
+
function uncollapse(node) {
|
|
6811
|
+
forester.preOrderTraversal(node, function (n) {
|
|
6812
|
+
n.collapsed = false;
|
|
6813
|
+
});
|
|
6814
|
+
calcMaxExtLabel();
|
|
6815
|
+
refreshVisualizations(false);
|
|
6816
|
+
scheduleUpdate(null, 0);
|
|
6581
6817
|
}
|
|
6582
6818
|
|
|
6583
|
-
|
|
6584
|
-
|
|
6585
|
-
|
|
6586
|
-
|
|
6819
|
+
function uncollapseAll() {
|
|
6820
|
+
if (_root) {
|
|
6821
|
+
uncollapse(_root);
|
|
6822
|
+
}
|
|
6587
6823
|
}
|
|
6588
6824
|
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6825
|
+
function clearCollapsedFlags(node) {
|
|
6826
|
+
forester.preOrderTraversal(node, function (n) {
|
|
6827
|
+
n.collapsed = false;
|
|
6828
|
+
});
|
|
6829
|
+
}
|
|
6830
|
+
|
|
6831
|
+
// Runs a re-rooting without turning a collapsed clade inside out. A clade
|
|
6832
|
+
// on the path to the new root has its parent turned into a child, so its
|
|
6833
|
+
// flag would go on hiding a different set of tips -- with a midpoint
|
|
6834
|
+
// inside a collapsed clade, the whole rest of the tree. Such a clade
|
|
6835
|
+
// opens; every other clade keeps its tips and stays collapsed.
|
|
6836
|
+
function rerootKeepingCollapse(reroot) {
|
|
6837
|
+
let flagged = [];
|
|
6838
|
+
forester.preOrderTraversalAll(_root_const || _root, function (n) {
|
|
6839
|
+
if (n.collapsed === true && n.children) {
|
|
6840
|
+
flagged.push({node: n, tips: forester.getAllExternalNodes(n)});
|
|
6841
|
+
}
|
|
6842
|
+
});
|
|
6843
|
+
reroot();
|
|
6844
|
+
let opened = false;
|
|
6845
|
+
flagged.forEach(function (f) {
|
|
6846
|
+
let now = f.node.children ? forester.getAllExternalNodes(f.node) : [];
|
|
6847
|
+
let kept = new Set(f.tips);
|
|
6848
|
+
if (now.length !== f.tips.length || !now.every(function (t) { return kept.has(t); })) {
|
|
6849
|
+
f.node.collapsed = false;
|
|
6850
|
+
opened = true;
|
|
6851
|
+
}
|
|
6852
|
+
});
|
|
6853
|
+
if (opened) {
|
|
6854
|
+
calcMaxExtLabel();
|
|
6855
|
+
refreshVisualizations(false);
|
|
6595
6856
|
}
|
|
6596
|
-
return DOMAIN_TRACK_START_GAP + domainTrackWidth() + DOMAIN_TRACK_END_GAP;
|
|
6597
6857
|
}
|
|
6598
6858
|
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6859
|
+
// What a collapsed clade is marked with for the tips it hides: the colour
|
|
6860
|
+
// of its first search hit, else the selection colour when a tip is
|
|
6861
|
+
// selected, else null. The wedge, the overview dot and the dimming all
|
|
6862
|
+
// read it, so they always agree.
|
|
6863
|
+
function collapsedMarkColor(d) {
|
|
6864
|
+
let tips = forester.getAllExternalNodes(d);
|
|
6865
|
+
let hit = tips.filter(isNodeFound)[0];
|
|
6866
|
+
if (hit) {
|
|
6867
|
+
return getFoundColor(hit);
|
|
6602
6868
|
}
|
|
6603
|
-
return
|
|
6869
|
+
return tips.some(function (t) { return _selectedNodes.has(t); }) ? _state.selectedColorDefault : null;
|
|
6604
6870
|
}
|
|
6605
6871
|
|
|
6606
|
-
//
|
|
6607
|
-
//
|
|
6608
|
-
//
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6872
|
+
// How tall a collapsed clade draws, in rows: 1 for a pair, growing with
|
|
6873
|
+
// the logarithm of the tip count, capped so a huge clade does not eat
|
|
6874
|
+
// the screen. The cluster layout's separation turns it into space.
|
|
6875
|
+
function collapsedRows(d) {
|
|
6876
|
+
let tips = forester.getAllExternalNodes(d).length;
|
|
6877
|
+
return Math.max(1, Math.min(2.5, 1 + (Math.log2(Math.max(2, tips)) / 4)));
|
|
6878
|
+
}
|
|
6879
|
+
|
|
6880
|
+
// The clade's name: the node's own; else the one Color-by value at least
|
|
6881
|
+
// 95% of its tips share (a clade named "Bovine" while you look at
|
|
6882
|
+
// hosts); else the tips' common name prefix, trailing separators
|
|
6883
|
+
// dropped; else nothing.
|
|
6884
|
+
function collapsedName(d) {
|
|
6885
|
+
if (d.name && String(d.name).trim().length > 0) {
|
|
6886
|
+
return String(d.name).trim();
|
|
6887
|
+
}
|
|
6888
|
+
let tips = forester.getAllExternalNodes(d);
|
|
6889
|
+
let vis = currentColorVis();
|
|
6890
|
+
if (vis && tips.length > 0) {
|
|
6891
|
+
let counts = Object.create(null);
|
|
6892
|
+
tips.forEach(function (t) {
|
|
6893
|
+
let v = forester.visualizationNodeValue(t, vis);
|
|
6894
|
+
if (v !== null && v !== undefined) {
|
|
6895
|
+
counts[v] = (counts[v] || 0) + 1;
|
|
6896
|
+
}
|
|
6618
6897
|
});
|
|
6619
|
-
|
|
6898
|
+
let best = null;
|
|
6899
|
+
Object.keys(counts).forEach(function (v) {
|
|
6900
|
+
if (best === null || counts[v] > counts[best]) {
|
|
6901
|
+
best = v;
|
|
6902
|
+
}
|
|
6903
|
+
});
|
|
6904
|
+
if (best !== null && counts[best] >= 0.95 * tips.length) {
|
|
6905
|
+
return best;
|
|
6906
|
+
}
|
|
6620
6907
|
}
|
|
6621
|
-
|
|
6908
|
+
let prefix = forester.commonNamePrefix(d, forester.nodeLabelProperty(_treeData));
|
|
6909
|
+
prefix = prefix ? prefix.replace(/[\s_\-.:|/]+$/, '') : '';
|
|
6910
|
+
return prefix.length >= 2 ? prefix : '';
|
|
6622
6911
|
}
|
|
6623
6912
|
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6913
|
+
// Whether any tip hidden in a collapsed clade is a hit or selected -- the
|
|
6914
|
+
// clade then stays undimmed, as that tip would be if it were drawn.
|
|
6915
|
+
function collapsedHoldsHighlight(d) {
|
|
6916
|
+
return collapsedMarkColor(d) !== null;
|
|
6917
|
+
}
|
|
6918
|
+
|
|
6919
|
+
// Whether every tip a collapsed clade hides is marked: all of them search
|
|
6920
|
+
// hits when the clade holds any hit, else all of them selected. Such a
|
|
6921
|
+
// wedge is filled in the mark colour and its label set in it, bold.
|
|
6922
|
+
function collapsedFullyMarked(d) {
|
|
6923
|
+
let hits = collapsedFoundCounts(d);
|
|
6924
|
+
if (hits.found > 0) {
|
|
6925
|
+
return hits.found === hits.total;
|
|
6631
6926
|
}
|
|
6632
|
-
|
|
6927
|
+
let tips = forester.getAllExternalNodes(d);
|
|
6928
|
+
return tips.length > 0 && tips.every(function (t) { return _selectedNodes.has(t); });
|
|
6633
6929
|
}
|
|
6634
6930
|
|
|
6635
|
-
function
|
|
6636
|
-
|
|
6931
|
+
function collapsedFoundCounts(d) {
|
|
6932
|
+
let found = 0;
|
|
6933
|
+
let total = 0;
|
|
6934
|
+
forester.preOrderTraversal(d, function (n) {
|
|
6935
|
+
if (!n.children) {
|
|
6936
|
+
total++;
|
|
6937
|
+
if (isNodeFound(n)) {
|
|
6938
|
+
found++;
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6941
|
+
});
|
|
6942
|
+
return {found: found, total: total};
|
|
6637
6943
|
}
|
|
6638
6944
|
|
|
6639
|
-
function
|
|
6640
|
-
|
|
6641
|
-
|
|
6945
|
+
function collapsedLabel(d) {
|
|
6946
|
+
let name = collapsedName(d);
|
|
6947
|
+
let total = forester.getAllExternalNodes(d).length;
|
|
6948
|
+
let text = (name ? name + ' · ' : '') + total + (total === 1 ? ' tip' : ' tips');
|
|
6949
|
+
let hits = collapsedFoundCounts(d);
|
|
6950
|
+
if (hits.found > 0) {
|
|
6951
|
+
text += ' [' + hits.found + '/' + hits.total + ']';
|
|
6642
6952
|
}
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6953
|
+
return text;
|
|
6954
|
+
}
|
|
6955
|
+
|
|
6956
|
+
// The colour most of the clade's tips wear under the current Color-by;
|
|
6957
|
+
// the branch colour when nothing is coloured.
|
|
6958
|
+
function collapsedColor(d) {
|
|
6959
|
+
let vis = currentColorVis();
|
|
6960
|
+
if (!vis || !_state.showVisualizations) {
|
|
6961
|
+
return _state.branchColorDefault;
|
|
6646
6962
|
}
|
|
6647
|
-
let
|
|
6648
|
-
|
|
6963
|
+
let counts = Object.create(null);
|
|
6964
|
+
let best = null;
|
|
6965
|
+
forester.getAllExternalNodes(d).forEach(function (t) {
|
|
6966
|
+
let c = makeLabelColor(t);
|
|
6967
|
+
if (c === _state.labelColorDefault) {
|
|
6968
|
+
return; // an uncoloured tip casts no vote
|
|
6969
|
+
}
|
|
6970
|
+
counts[c] = (counts[c] || 0) + 1;
|
|
6971
|
+
if (best === null || counts[c] > counts[best]) {
|
|
6972
|
+
best = c;
|
|
6973
|
+
}
|
|
6649
6974
|
});
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
6975
|
+
return best || _state.branchColorDefault;
|
|
6976
|
+
}
|
|
6977
|
+
|
|
6978
|
+
// The wedge's reach: the clade's nearest and farthest tips, in the
|
|
6979
|
+
// layout's x. One edge runs to the nearest tip and the other to the
|
|
6980
|
+
// farthest, so the shape itself shows how uneven the clade's branch
|
|
6981
|
+
// lengths are (as iTOL draws it). Christian chose this over the
|
|
6982
|
+
// desktop's symmetric triangle on an average base (2026-09-14). A
|
|
6983
|
+
// phylogram measures the tips; a cladogram, where every leaf sits on
|
|
6984
|
+
// the tip column, gives the wedge one depth step.
|
|
6985
|
+
function collapsedReach(d) {
|
|
6986
|
+
if (_state.phylogram && _yScale) {
|
|
6987
|
+
let min = Infinity;
|
|
6988
|
+
let max = -Infinity;
|
|
6989
|
+
forester.preOrderTraversal(d, function (t) {
|
|
6990
|
+
if (!t.children && typeof t.distToRoot === 'number') {
|
|
6991
|
+
if (t.distToRoot < min) { min = t.distToRoot; }
|
|
6992
|
+
if (t.distToRoot > max) { max = t.distToRoot; }
|
|
6993
|
+
}
|
|
6994
|
+
});
|
|
6995
|
+
if (isFinite(min)) {
|
|
6996
|
+
return [Math.max(d.y + 2, _yScale(min)), Math.max(d.y + 2, _yScale(max))];
|
|
6997
|
+
}
|
|
6653
6998
|
}
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6999
|
+
return [d.y + _cladogramUnit, d.y + _cladogramUnit];
|
|
7000
|
+
}
|
|
7001
|
+
|
|
7002
|
+
let _cladogramUnit = 0; // one depth step of the last cladogram layout, px
|
|
7003
|
+
|
|
7004
|
+
// Draws every collapsed clade in the current node selection: a wedge
|
|
7005
|
+
// path and a label as children of the node's own group, so they move
|
|
7006
|
+
// with it, take its clicks and menu, and ride into exports.
|
|
7007
|
+
function drawCollapsedClades(node) {
|
|
7008
|
+
let wedge = node.selectAll('path.aptx-collapsed').data(function (d) {
|
|
7009
|
+
return isCollapsed(d) ? [d] : [];
|
|
7010
|
+
});
|
|
7011
|
+
wedge.exit().remove();
|
|
7012
|
+
wedge = wedge.enter().append('path').attr('class', 'aptx-collapsed').merge(wedge);
|
|
7013
|
+
let label = node.selectAll('text.aptx-collapsed-label').data(function (d) {
|
|
7014
|
+
return isCollapsed(d) ? [d] : [];
|
|
7015
|
+
});
|
|
7016
|
+
label.exit().remove();
|
|
7017
|
+
label = label.enter().append('text').attr('class', 'aptx-collapsed-label').merge(label);
|
|
7018
|
+
|
|
7019
|
+
let fs = _state.externalNodeFontSize;
|
|
7020
|
+
wedge.each(function (d) {
|
|
7021
|
+
let color = collapsedColor(d);
|
|
7022
|
+
let mark = collapsedMarkColor(d);
|
|
7023
|
+
let full = collapsedFullyMarked(d);
|
|
7024
|
+
let h = Math.max(6, collapsedRows(d) * _rowUnit * 0.82);
|
|
7025
|
+
let reach = collapsedReach(d);
|
|
7026
|
+
let path;
|
|
7027
|
+
if (radialDisplay()) {
|
|
7028
|
+
// in the rotated frame: +x along the spoke from the node (the
|
|
7029
|
+
// group's origin, already at the node's radius) -- distances,
|
|
7030
|
+
// never absolute radii, or the wedge lands a whole radius
|
|
7031
|
+
// further out than its label
|
|
7032
|
+
let r0 = radialRadius(d.y);
|
|
7033
|
+
let dr1 = radialRadius(reach[0]) - r0;
|
|
7034
|
+
let dr2 = radialRadius(reach[1]) - r0;
|
|
7035
|
+
path = 'M0,0 L' + dr1 + ',' + (-h / 2) + ' L' + dr2 + ',' + (h / 2) + ' Z';
|
|
7036
|
+
} else {
|
|
7037
|
+
let dx1 = reach[0] - d.y;
|
|
7038
|
+
let dx2 = reach[1] - d.y;
|
|
7039
|
+
path = 'M0,0 L' + dx1 + ',' + (-h / 2) + ' L' + dx2 + ',' + (h / 2) + ' Z';
|
|
7040
|
+
}
|
|
7041
|
+
d3.select(this)
|
|
7042
|
+
.attr('d', path)
|
|
7043
|
+
.attr('transform', radialDisplay() ? 'rotate(' + labelAngleDeg(d) + ')' : null)
|
|
7044
|
+
.style('fill', full ? mark : color)
|
|
7045
|
+
.style('fill-opacity', full ? 0.45 : 0.22)
|
|
7046
|
+
.style('stroke', mark || color)
|
|
7047
|
+
.style('stroke-width', mark ? 1.5 : 1)
|
|
7048
|
+
.style('stroke-opacity', 0.9)
|
|
7049
|
+
.style('stroke-linejoin', 'round');
|
|
7050
|
+
});
|
|
7051
|
+
label.each(function (d) {
|
|
7052
|
+
let far = collapsedReach(d)[1]; // the label clears the farthest tip
|
|
7053
|
+
let allFound = collapsedFullyMarked(d);
|
|
7054
|
+
let ink = allFound ? collapsedMarkColor(d) : _state.labelColorDefault;
|
|
7055
|
+
let t = d3.select(this)
|
|
7056
|
+
.text(collapsedLabel(d))
|
|
7057
|
+
.style('font', (allFound ? '600 ' : '') + fs + 'px ' + FONT_DEFAULTS)
|
|
7058
|
+
.style('fill', ink)
|
|
7059
|
+
.style('pointer-events', 'none');
|
|
7060
|
+
// placed exactly as a tip's label is (update's text.extlabel): on
|
|
7061
|
+
// the common ring in circular, rotated along the spoke or upright;
|
|
7062
|
+
// on the label column in the aligned phylogram; else just past the
|
|
7063
|
+
// wedge, where a tip's label sits past its tip
|
|
7064
|
+
let gap = _state.nodeLabelGap;
|
|
7065
|
+
let flip = radialDisplay() && labelFlip(d);
|
|
7066
|
+
if (_state.circularDisplay && _radial) {
|
|
7067
|
+
t.attr('text-anchor', flip ? 'end' : 'start')
|
|
7068
|
+
.attr('x', flip ? -gap : gap).attr('dy', '0.32em');
|
|
7069
|
+
if (_radialLabelsHorizontal) {
|
|
7070
|
+
let p = radialXY(d.x, d.y);
|
|
7071
|
+
let q = polarXY(radialAngle(d.x), _radial.maxRad);
|
|
7072
|
+
t.attr('transform', 'translate(' + (q[0] - p[0]) + ',' + (q[1] - p[1]) + ')');
|
|
7073
|
+
} else {
|
|
7074
|
+
let off = _radial.maxRad - radialRadius(d.y);
|
|
7075
|
+
t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (flip ? ' rotate(180)' : ''));
|
|
7076
|
+
}
|
|
7077
|
+
} else if (radialDisplay()) {
|
|
7078
|
+
let r = radialRadius(far) - radialRadius(d.y) + gap;
|
|
7079
|
+
t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + r + ',0)' + (flip ? ' rotate(180)' : ''))
|
|
7080
|
+
.attr('text-anchor', flip ? 'end' : 'start')
|
|
7081
|
+
.attr('x', 0).attr('dy', '0.32em');
|
|
7082
|
+
} else {
|
|
7083
|
+
let column = (_state.phylogram && _state.alignPhylogram) ? _w : far;
|
|
7084
|
+
t.attr('transform', null)
|
|
7085
|
+
.attr('text-anchor', 'start')
|
|
7086
|
+
.attr('x', column - d.y + gap)
|
|
7087
|
+
.attr('dy', (0.3 * fs) + 'px');
|
|
7088
|
+
}
|
|
7089
|
+
});
|
|
7090
|
+
}
|
|
7091
|
+
|
|
7092
|
+
// ===================== Shareable views =====================
|
|
7093
|
+
// A view is what a person made of a tree with the panel: the layout and
|
|
7094
|
+
// display type, what is labelled, the colours, the searches, the clade
|
|
7095
|
+
// they switched to, the ones they collapsed, the sizes. It is a plain
|
|
7096
|
+
// object (getViewState), a short "key=value&..." string (encodeViewState
|
|
7097
|
+
// / decodeViewState) that a page keeps in its URL hash, and a launch
|
|
7098
|
+
// config key (view) that opens a tree straight into it; onViewChange
|
|
7099
|
+
// tells the page whenever it changes. A node is named by its viewId, the
|
|
7100
|
+
// launch-time preorder index, which ladderizing and re-rooting leave
|
|
7101
|
+
// alone. NOT in a view: zoom and pan (a view opens fitted), the legend's
|
|
7102
|
+
// position, the node selection.
|
|
7103
|
+
|
|
7104
|
+
const VIEW_SHOW_FLAGS = [
|
|
7105
|
+
['name', NODE_NAME_CB, 'showNodeName'],
|
|
7106
|
+
['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
|
|
7107
|
+
['sequence', SEQUENCE_CB, 'showSequence'],
|
|
7108
|
+
['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
|
|
7109
|
+
['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
|
|
7110
|
+
['external', EXTERNAL_LABEL_CB, 'showExternalLabels'],
|
|
7111
|
+
['internal', INTERNAL_LABEL_CB, 'showInternalLabels'],
|
|
7112
|
+
['nodeEvents', NODE_EVENTS_CB, 'showNodeEvents'],
|
|
7113
|
+
['branchEvents', BRANCH_EVENTS_CB, 'showBranchEvents'],
|
|
7114
|
+
['supportDots', SUPPORT_DOTS_CB, 'showSupportDots'],
|
|
7115
|
+
['shortNames', SHORTEN_NODE_NAME_CB, 'shortenNodeNames'],
|
|
7116
|
+
['autoHide', DYNAHIDE_CB, 'dynahide'],
|
|
7117
|
+
['visualizations', VIS_CB, 'showVisualizations'],
|
|
7118
|
+
['visualStyles', VISUAL_STYLES_CB, 'useVisualStyles']
|
|
7119
|
+
];
|
|
7120
|
+
const RADIAL_ROTATION_STEP = Math.PI / 32; // one press of the rotate buttons
|
|
7121
|
+
|
|
7122
|
+
// Launch-time preorder ids, given once per tree object: a tree shown
|
|
7123
|
+
// again keeps them, so a shared view still names the same nodes.
|
|
7124
|
+
function assignViewIds(phy) {
|
|
7125
|
+
if (phy.viewId !== undefined) {
|
|
7126
|
+
return;
|
|
7127
|
+
}
|
|
7128
|
+
let i = 0;
|
|
7129
|
+
forester.preOrderTraversalAll(phy, function (n) {
|
|
7130
|
+
n.viewId = i++;
|
|
7131
|
+
});
|
|
7132
|
+
}
|
|
7133
|
+
|
|
7134
|
+
function nodeByViewId(id) {
|
|
7135
|
+
let found = null;
|
|
7136
|
+
forester.preOrderTraversalAll(_root_const, function (n) {
|
|
7137
|
+
if (found === null && n.viewId === id) {
|
|
7138
|
+
found = n;
|
|
7139
|
+
}
|
|
7140
|
+
});
|
|
7141
|
+
return found;
|
|
7142
|
+
}
|
|
7143
|
+
|
|
7144
|
+
// The config's custom label checkboxes (nodeLabels) that are offered
|
|
7145
|
+
function customLabelEntries() {
|
|
7146
|
+
let out = [];
|
|
7147
|
+
if (_nodeLabels) {
|
|
7148
|
+
Object.keys(_nodeLabels).forEach(function (key) {
|
|
7149
|
+
let v = _nodeLabels[key];
|
|
7150
|
+
if (v && v.label && v.showButton === true && v.propertyRef && v.description) {
|
|
7151
|
+
out.push({key: key, entry: v});
|
|
7152
|
+
}
|
|
7153
|
+
});
|
|
7154
|
+
}
|
|
7155
|
+
return out;
|
|
7156
|
+
}
|
|
7157
|
+
|
|
7158
|
+
function searchStateOf(idx) {
|
|
7159
|
+
let spec = currentSearchSpec(idx);
|
|
7160
|
+
let value = (spec.value === null || spec.value === undefined) ? '' : String(spec.value);
|
|
7161
|
+
if (value.trim().length === 0) {
|
|
7162
|
+
return null;
|
|
7163
|
+
}
|
|
7164
|
+
let q = {field: spec.field.label, mode: spec.mode, value: value};
|
|
7165
|
+
if (spec.mode === 'range' && spec.value2) {
|
|
7166
|
+
q.value2 = String(spec.value2);
|
|
7167
|
+
}
|
|
7168
|
+
return q;
|
|
7169
|
+
}
|
|
7170
|
+
|
|
7171
|
+
function getViewState() {
|
|
7172
|
+
let s = {};
|
|
7173
|
+
if (_treeIndex > 0) {
|
|
7174
|
+
s.tree = _treeIndex;
|
|
7175
|
+
}
|
|
7176
|
+
s.layout = _state.unrootedDisplay ? 'unrooted' : (_state.circularDisplay ? 'circular' : 'rectangular');
|
|
7177
|
+
s.display = _state.phylogram ? (_state.alignPhylogram ? 'aligned' : 'phylogram') : 'cladogram';
|
|
7178
|
+
if (_viewOps.root) {
|
|
7179
|
+
s.root = _viewOps.root;
|
|
7180
|
+
}
|
|
7181
|
+
if (_viewOps.order) {
|
|
7182
|
+
s.order = _viewOps.order;
|
|
7183
|
+
}
|
|
7184
|
+
if (_in_subtree && _root && _root.children && _root.children[0] && _root.children[0].viewId !== undefined) {
|
|
7185
|
+
s.subtree = _root.children[0].viewId;
|
|
7186
|
+
}
|
|
7187
|
+
let collapsed = [];
|
|
7188
|
+
forester.preOrderTraversalAll(_root_const, function (n) {
|
|
7189
|
+
if (n.collapsed && n.viewId !== undefined) {
|
|
7190
|
+
collapsed.push(n.viewId);
|
|
7191
|
+
}
|
|
7192
|
+
});
|
|
7193
|
+
if (collapsed.length > 0) {
|
|
7194
|
+
s.collapsed = collapsed;
|
|
7195
|
+
}
|
|
7196
|
+
s.colorBy = (_vis && _vis.colorId) || 'none';
|
|
7197
|
+
if (_vis && _vis.shapeId) {
|
|
7198
|
+
s.shapeBy = _vis.shapeId;
|
|
7199
|
+
}
|
|
7200
|
+
s.show = VIEW_SHOW_FLAGS.filter(function (f) {
|
|
7201
|
+
return _state[f[2]] === true;
|
|
7202
|
+
}).map(function (f) {
|
|
7203
|
+
return f[0];
|
|
7204
|
+
});
|
|
7205
|
+
customLabelEntries().forEach(function (c) {
|
|
7206
|
+
if (c.entry.selected === true) {
|
|
7207
|
+
s.show.push('custom:' + c.key);
|
|
7208
|
+
}
|
|
7209
|
+
});
|
|
7210
|
+
s.font = _state.externalNodeFontSize;
|
|
7211
|
+
s.node = _state.nodeSizeDefault;
|
|
7212
|
+
s.branch = _state.branchWidthDefault;
|
|
7213
|
+
let rotation = Math.round(_radialRotation / RADIAL_ROTATION_STEP);
|
|
7214
|
+
if (rotation !== 0) {
|
|
7215
|
+
s.rotation = rotation;
|
|
7216
|
+
}
|
|
7217
|
+
if (_radialLabelsHorizontal) {
|
|
7218
|
+
s.horizontalLabels = true;
|
|
7219
|
+
}
|
|
7220
|
+
if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
|
|
7221
|
+
s.msa = _state.showMsa === true;
|
|
7222
|
+
}
|
|
7223
|
+
if (_basicTreeProperties.domainArchitectures === true) {
|
|
7224
|
+
s.domains = _state.showDomainArchitectures === true;
|
|
7225
|
+
s.domainLabels = _state.domainLabels;
|
|
7226
|
+
s.domainGlow = _state.domainGlow === true;
|
|
7227
|
+
s.domainEvalue = _state.domainEvalueExponent;
|
|
7228
|
+
}
|
|
7229
|
+
if (_timeInfo && _timeInfo.type) {
|
|
7230
|
+
s.timeAxis = _state.showTimeAxis === true;
|
|
7231
|
+
s.timeGrid = _state.timeAxisGrid === true;
|
|
7232
|
+
}
|
|
7233
|
+
let a = searchStateOf(0);
|
|
7234
|
+
let b = searchStateOf(1);
|
|
7235
|
+
if (a) {
|
|
7236
|
+
s.searchA = a;
|
|
7237
|
+
}
|
|
7238
|
+
if (b) {
|
|
7239
|
+
s.searchB = b;
|
|
7240
|
+
}
|
|
7241
|
+
if (a && b) {
|
|
7242
|
+
let combine = getValue(SEARCH_COMBINE_SELECT);
|
|
7243
|
+
if (combine === 'and' || combine === 'or') {
|
|
7244
|
+
s.combine = combine;
|
|
7245
|
+
}
|
|
7246
|
+
}
|
|
7247
|
+
if (_state.searchIsCaseSensitive === true) {
|
|
7248
|
+
s.matchCase = true;
|
|
7249
|
+
}
|
|
7250
|
+
if (_state.searchNegateResult === true) {
|
|
7251
|
+
s.inverse = true;
|
|
7252
|
+
}
|
|
7253
|
+
return s;
|
|
7254
|
+
}
|
|
7255
|
+
|
|
7256
|
+
// Opens a view: the tree operations first (midpoint rooting, the
|
|
7257
|
+
// ladderize direction), then the clade and the collapsed clades by node
|
|
7258
|
+
// id, then everything the panel shows. A key the view leaves out keeps
|
|
7259
|
+
// its current value, except the two searches, which an empty view
|
|
7260
|
+
// clears (a view with no search has none). `initial` is the launch --
|
|
7261
|
+
// initialize() draws afterwards; otherwise the tree is redrawn and
|
|
7262
|
+
// fitted. Anything not understood, a hand-edited hash or a view of some
|
|
7263
|
+
// other tree, is skipped rather than guessed at.
|
|
7264
|
+
function applyViewState(s, initial) {
|
|
7265
|
+
if (!s || typeof s !== 'object') {
|
|
7266
|
+
return;
|
|
7267
|
+
}
|
|
7268
|
+
if (!initial && Number.isInteger(s.tree) && s.tree !== _treeIndex && s.tree >= 0 && s.tree < _trees.length) {
|
|
7269
|
+
let cfg = Object.assign({}, _launchConfig || {});
|
|
7270
|
+
cfg.view = s;
|
|
7271
|
+
launchInto(_container, _trees, s.tree, cfg);
|
|
7272
|
+
return;
|
|
7273
|
+
}
|
|
7274
|
+
if (s.root === 'midpoint' && _viewOps.root !== 'midpoint'
|
|
7275
|
+
&& (_treeData.rerootable === undefined || _treeData.rerootable === true)) {
|
|
7276
|
+
rerootKeepingCollapse(function () {
|
|
7277
|
+
forester.midpointRoot(_root_const);
|
|
7278
|
+
});
|
|
7279
|
+
_viewOps.root = 'midpoint';
|
|
7280
|
+
}
|
|
7281
|
+
if (s.order === 'asc' || s.order === 'desc') {
|
|
7282
|
+
ladderizeSubtree(_root_const, s.order === 'asc', false);
|
|
7283
|
+
_viewOps.order = s.order;
|
|
7284
|
+
if (!_treeFn.visData) {
|
|
7285
|
+
_treeFn.visData = {};
|
|
7286
|
+
}
|
|
7287
|
+
_treeFn.visData.ladderize = s.order !== 'asc'; // the next press goes the other way
|
|
7288
|
+
}
|
|
7289
|
+
_root = _root_const;
|
|
7290
|
+
_in_subtree = false;
|
|
7291
|
+
clearCollapsedFlags(_root_const);
|
|
7292
|
+
if (Number.isInteger(s.subtree)) {
|
|
7293
|
+
let node = nodeByViewId(s.subtree);
|
|
7294
|
+
if (node && node.children && node.parent && node.parent.parent) {
|
|
7295
|
+
_in_subtree = true;
|
|
7296
|
+
_root = {children: [node], x: 0, x0: 0, y: 0, y0: 0};
|
|
7297
|
+
}
|
|
7298
|
+
}
|
|
7299
|
+
if (Array.isArray(s.collapsed) && s.collapsed.length > 0) {
|
|
7300
|
+
let ids = new Set(s.collapsed);
|
|
7301
|
+
forester.preOrderTraversalAll(_root_const, function (n) {
|
|
7302
|
+
if (n.children && n.parent && n.parent.parent && ids.has(n.viewId)) {
|
|
7303
|
+
n.collapsed = true;
|
|
7304
|
+
}
|
|
7305
|
+
});
|
|
7306
|
+
}
|
|
7307
|
+
_basicTreeProperties = forester.collectBasicTreeProperties(_root);
|
|
7308
|
+
|
|
7309
|
+
if (s.layout === 'rectangular' || s.layout === 'circular' || s.layout === 'unrooted') {
|
|
7310
|
+
_state.circularDisplay = s.layout === 'circular';
|
|
7311
|
+
_state.unrootedDisplay = s.layout === 'unrooted';
|
|
7312
|
+
}
|
|
7313
|
+
if (s.display === 'phylogram' || s.display === 'aligned' || s.display === 'cladogram') {
|
|
7314
|
+
let measured = _basicTreeProperties.branchLengths === true;
|
|
7315
|
+
_state.phylogram = measured && s.display !== 'cladogram';
|
|
7316
|
+
_state.alignPhylogram = measured && s.display === 'aligned' && !_state.unrootedDisplay;
|
|
7317
|
+
}
|
|
7318
|
+
if (Array.isArray(s.show)) {
|
|
7319
|
+
let on = new Set(s.show);
|
|
7320
|
+
VIEW_SHOW_FLAGS.forEach(function (f) {
|
|
7321
|
+
_state[f[2]] = on.has(f[0]);
|
|
7322
|
+
});
|
|
7323
|
+
customLabelEntries().forEach(function (c) {
|
|
7324
|
+
c.entry.selected = on.has('custom:' + c.key);
|
|
7325
|
+
});
|
|
7326
|
+
}
|
|
7327
|
+
if (isFinite(s.font)) {
|
|
7328
|
+
setFontSizes(Number(s.font));
|
|
7329
|
+
}
|
|
7330
|
+
if (isFinite(s.node)) {
|
|
7331
|
+
_state.nodeSizeDefault = Math.min(NODE_SIZE_MAX, Math.max(NODE_SIZE_MIN, Number(s.node)));
|
|
7332
|
+
}
|
|
7333
|
+
if (isFinite(s.branch)) {
|
|
7334
|
+
_state.branchWidthDefault = Math.min(BRANCH_WIDTH_MAX, Math.max(BRANCH_WIDTH_MIN, Number(s.branch)));
|
|
7335
|
+
}
|
|
7336
|
+
if (Number.isInteger(s.rotation)) {
|
|
7337
|
+
_radialRotation = (s.rotation * RADIAL_ROTATION_STEP) % (2 * Math.PI);
|
|
7338
|
+
}
|
|
7339
|
+
if (typeof s.horizontalLabels === 'boolean') {
|
|
7340
|
+
_radialLabelsHorizontal = s.horizontalLabels;
|
|
7341
|
+
}
|
|
7342
|
+
if (typeof s.msa === 'boolean') {
|
|
7343
|
+
_state.showMsa = s.msa;
|
|
7344
|
+
}
|
|
7345
|
+
if (typeof s.domains === 'boolean') {
|
|
7346
|
+
_state.showDomainArchitectures = s.domains;
|
|
7347
|
+
}
|
|
7348
|
+
if (s.domainLabels === 'none' || s.domainLabels === 'domains' || s.domainLabels === 'legend') {
|
|
7349
|
+
_state.domainLabels = s.domainLabels;
|
|
7350
|
+
}
|
|
7351
|
+
if (typeof s.domainGlow === 'boolean') {
|
|
7352
|
+
_state.domainGlow = s.domainGlow;
|
|
7353
|
+
}
|
|
7354
|
+
if (Number.isInteger(s.domainEvalue) && s.domainEvalue >= forester.DOMAIN_EVALUE_EXPONENT_MIN
|
|
7355
|
+
&& s.domainEvalue <= forester.DOMAIN_EVALUE_EXPONENT_MAX && s.domainEvalue !== _state.domainEvalueExponent) {
|
|
7356
|
+
_state.domainEvalueExponent = s.domainEvalue;
|
|
7357
|
+
if (_domain) {
|
|
7358
|
+
_domain.palette = null; // the drawn set changed: the palette is dealt again
|
|
7359
|
+
}
|
|
7360
|
+
}
|
|
7361
|
+
if (typeof s.timeAxis === 'boolean') {
|
|
7362
|
+
_state.showTimeAxis = s.timeAxis;
|
|
7363
|
+
}
|
|
7364
|
+
if (typeof s.timeGrid === 'boolean') {
|
|
7365
|
+
_state.timeAxisGrid = s.timeGrid;
|
|
7366
|
+
}
|
|
7367
|
+
if (!radialDisplay()) {
|
|
7368
|
+
_radialLabelsHorizontal = false;
|
|
7369
|
+
} else if (_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
|
|
7370
|
+
_radialLabelsHorizontal = false; // the domain boxes ride the spokes, so the labels must too
|
|
7371
|
+
}
|
|
7372
|
+
if (_vis) {
|
|
7373
|
+
if (s.colorBy === 'none') {
|
|
7374
|
+
_vis.colorId = null;
|
|
7375
|
+
} else if (s.colorBy && _vis.byId[s.colorBy]) {
|
|
7376
|
+
_vis.colorId = s.colorBy;
|
|
7377
|
+
_state.showVisualizations = true;
|
|
7378
|
+
}
|
|
7379
|
+
if (s.shapeBy === 'none') {
|
|
7380
|
+
_vis.shapeId = null;
|
|
7381
|
+
} else if (s.shapeBy && _vis.byId[s.shapeBy] && _vis.byId[s.shapeBy].shape) {
|
|
7382
|
+
_vis.shapeId = s.shapeBy;
|
|
7383
|
+
_state.showVisualizations = true;
|
|
7384
|
+
}
|
|
7385
|
+
}
|
|
7386
|
+
_state.searchIsCaseSensitive = s.matchCase === true;
|
|
7387
|
+
_state.searchNegateResult = s.inverse === true;
|
|
7388
|
+
applySearchState(0, s.searchA);
|
|
7389
|
+
applySearchState(1, s.searchB);
|
|
7390
|
+
setValue(SEARCH_COMBINE_SELECT,
|
|
7391
|
+
(s.searchA && s.searchB && (s.combine === 'and' || s.combine === 'or')) ? s.combine : 'independent');
|
|
7392
|
+
|
|
7393
|
+
refreshVisualizations(false);
|
|
7394
|
+
resetVis();
|
|
7395
|
+
syncViewControls();
|
|
7396
|
+
if (!initial) {
|
|
7397
|
+
runSearches();
|
|
7398
|
+
zoomToFit();
|
|
7399
|
+
}
|
|
7400
|
+
}
|
|
7401
|
+
|
|
7402
|
+
// One search box from its view state: the field by its label, the mode
|
|
7403
|
+
// when the field offers it, the value(s); nothing given clears the box.
|
|
7404
|
+
function applySearchState(idx, q) {
|
|
7405
|
+
let fieldSel = byId(idx === 0 ? SEARCH_FIELD_SELECT_0 : SEARCH_FIELD_SELECT_1);
|
|
7406
|
+
let modeSel = byId(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
|
|
7407
|
+
if (!fieldSel || !modeSel) {
|
|
7408
|
+
return;
|
|
7409
|
+
}
|
|
7410
|
+
let want = (q && typeof q === 'object') ? q : {};
|
|
7411
|
+
let fi = want.field ? _searchFields.findIndex(function (f) {
|
|
7412
|
+
return f.label === want.field;
|
|
7413
|
+
}) : -1;
|
|
7414
|
+
fieldSel.value = String(fi >= 0 ? fi : 0);
|
|
7415
|
+
populateSearchModeMenu(idx);
|
|
7416
|
+
if (want.mode && Array.from(modeSel.options).some(function (o) { return o.value === want.mode; })) {
|
|
7417
|
+
modeSel.value = want.mode;
|
|
7418
|
+
}
|
|
7419
|
+
setValue(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1, want.value ? String(want.value) : '');
|
|
7420
|
+
setValue(idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1, want.value2 ? String(want.value2) : '');
|
|
7421
|
+
updateSearchValue2Visibility(idx);
|
|
7422
|
+
updateSearchAutocomplete(idx);
|
|
7423
|
+
if (idx === 1 && want.value) {
|
|
7424
|
+
showSearchB();
|
|
7425
|
+
}
|
|
7426
|
+
}
|
|
7427
|
+
|
|
7428
|
+
// The panel's controls from the state, after applyViewState
|
|
7429
|
+
function syncViewControls() {
|
|
7430
|
+
setDisplayTypeButtons();
|
|
7431
|
+
syncZoomRowButtons();
|
|
7432
|
+
VIEW_SHOW_FLAGS.forEach(function (f) {
|
|
7433
|
+
setCheckboxValue(f[1], _state[f[2]] === true);
|
|
7434
|
+
});
|
|
7435
|
+
customLabelEntries().forEach(function (c) {
|
|
7436
|
+
if (c.entry.cb_id) {
|
|
7437
|
+
setCheckboxValue(c.entry.cb_id, c.entry.selected === true);
|
|
7438
|
+
}
|
|
7439
|
+
});
|
|
7440
|
+
setCheckboxValue(MSA_CB, _state.showMsa === true);
|
|
7441
|
+
setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
|
|
7442
|
+
setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
|
|
7443
|
+
setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
|
|
7444
|
+
setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
|
|
7445
|
+
syncDomainControls();
|
|
7446
|
+
setSliderValue(FONT_SIZE_SLIDER, _state.externalNodeFontSize);
|
|
7447
|
+
setSliderValue(NODE_SIZE_SLIDER, _state.nodeSizeDefault);
|
|
7448
|
+
setSliderValue(BRANCH_WIDTH_SLIDER, _state.branchWidthDefault);
|
|
7449
|
+
setSelectMenuValue(LABEL_COLOR_SELECT_MENU, (_vis && _vis.colorId) || DEFAULT);
|
|
7450
|
+
setSelectMenuValue(NODE_SHAPE_SELECT_MENU, (_vis && _vis.shapeId) || DEFAULT);
|
|
7451
|
+
setCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB, _state.searchIsCaseSensitive === true);
|
|
7452
|
+
setCheckboxValue(SEARCH_OPTIONS_NEGATE_RES_CB, _state.searchNegateResult === true);
|
|
7453
|
+
syncLadderizeGlyph();
|
|
7454
|
+
}
|
|
7455
|
+
|
|
7456
|
+
// The ladderize glyph shows the direction the NEXT press will use.
|
|
7457
|
+
function syncLadderizeGlyph() {
|
|
7458
|
+
let orderBtn = byId(LADDERIZE_BUTTON);
|
|
7459
|
+
if (orderBtn) {
|
|
7460
|
+
let next = !(_treeFn && _treeFn.visData && _treeFn.visData.ladderize === false);
|
|
7461
|
+
orderBtn.innerHTML = makeGlyph(next ? 'ladderize_asc' : 'ladderize_desc');
|
|
7462
|
+
}
|
|
7463
|
+
}
|
|
7464
|
+
|
|
7465
|
+
// onViewChange: once per settled redraw, only when the view actually
|
|
7466
|
+
// changed. The launch's own view is recorded and not reported -- the
|
|
7467
|
+
// page put the tree up and knows what it asked for -- but a relaunch
|
|
7468
|
+
// that changed it (the tree picker) is reported like any change.
|
|
7469
|
+
function noteViewChange() {
|
|
7470
|
+
if (_initializing || !_settings || typeof _settings.onViewChange !== 'function' || _viewNotifyPending) {
|
|
7471
|
+
return;
|
|
7472
|
+
}
|
|
7473
|
+
_viewNotifyPending = true;
|
|
7474
|
+
let seq = _launchSeq;
|
|
7475
|
+
setTimeout(function () {
|
|
7476
|
+
_viewNotifyPending = false;
|
|
7477
|
+
if (seq !== _launchSeq || !_root || !_container) {
|
|
7478
|
+
return;
|
|
7479
|
+
}
|
|
7480
|
+
reportView(false);
|
|
7481
|
+
}, 0);
|
|
7482
|
+
}
|
|
7483
|
+
|
|
7484
|
+
function settleViewNotification() {
|
|
7485
|
+
if (_settings && typeof _settings.onViewChange === 'function') {
|
|
7486
|
+
reportView(_lastViewEncoded === undefined);
|
|
7487
|
+
}
|
|
7488
|
+
}
|
|
7489
|
+
|
|
7490
|
+
function reportView(silent) {
|
|
7491
|
+
let state = getViewState();
|
|
7492
|
+
let encoded = archaeopteryx.encodeViewState(state);
|
|
7493
|
+
if (encoded === _lastViewEncoded) {
|
|
7494
|
+
return;
|
|
7495
|
+
}
|
|
7496
|
+
_lastViewEncoded = encoded;
|
|
7497
|
+
if (silent) {
|
|
7498
|
+
return;
|
|
7499
|
+
}
|
|
7500
|
+
try {
|
|
7501
|
+
_settings.onViewChange(state, encoded);
|
|
7502
|
+
} catch (e) {
|
|
7503
|
+
console.error(ERROR + 'onViewChange threw: ' + (e && e.message ? e.message : e));
|
|
7504
|
+
}
|
|
7505
|
+
}
|
|
7506
|
+
|
|
7507
|
+
// The string form: "key=value&key=value", the values URL-encoded but
|
|
7508
|
+
// with ':' and ',' kept readable (refs and lists) and spaces as '+'.
|
|
7509
|
+
// Lists are comma-joined, booleans 1 / 0, the two searches flattened
|
|
7510
|
+
// to a / af / am / a2 and b / bf / bm / b2. Made for a URL hash, where
|
|
7511
|
+
// none of these characters needs escaping.
|
|
7512
|
+
const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine'];
|
|
7513
|
+
const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
|
|
7514
|
+
const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
|
|
7515
|
+
const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'domains', 'domainGlow', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
|
|
7516
|
+
const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
|
|
7517
|
+
|
|
7518
|
+
function encodeViewValue(v) {
|
|
7519
|
+
return encodeURIComponent(String(v)).replace(/%3A/gi, ':').replace(/%2C/gi, ',').replace(/%20/g, '+');
|
|
7520
|
+
}
|
|
7521
|
+
|
|
7522
|
+
function decodeViewValue(v) {
|
|
7523
|
+
try {
|
|
7524
|
+
return decodeURIComponent(String(v).replace(/\+/g, '%20'));
|
|
7525
|
+
} catch {
|
|
7526
|
+
return '';
|
|
7527
|
+
}
|
|
7528
|
+
}
|
|
7529
|
+
|
|
7530
|
+
archaeopteryx.encodeViewState = function (state) {
|
|
7531
|
+
if (!state || typeof state !== 'object') {
|
|
7532
|
+
return '';
|
|
7533
|
+
}
|
|
7534
|
+
let parts = [];
|
|
7535
|
+
let put = function (k, v) {
|
|
7536
|
+
if (v !== undefined && v !== null && v !== '') {
|
|
7537
|
+
parts.push(k + '=' + encodeViewValue(v));
|
|
7538
|
+
}
|
|
7539
|
+
};
|
|
7540
|
+
VIEW_INT_KEYS.concat(VIEW_TEXT_KEYS, VIEW_NUMBER_KEYS).forEach(function (k) {
|
|
7541
|
+
put(k, state[k]);
|
|
7542
|
+
});
|
|
7543
|
+
if (Array.isArray(state.show)) {
|
|
7544
|
+
parts.push('show=' + state.show.map(encodeViewValue).join(',')); // an empty list is a fact: nothing shown
|
|
7545
|
+
}
|
|
7546
|
+
if (Array.isArray(state.collapsed) && state.collapsed.length > 0) {
|
|
7547
|
+
put('collapsed', state.collapsed.join(','));
|
|
7548
|
+
}
|
|
7549
|
+
VIEW_BOOL_KEYS.forEach(function (k) {
|
|
7550
|
+
if (typeof state[k] === 'boolean') {
|
|
7551
|
+
put(k, state[k] ? 1 : 0);
|
|
7552
|
+
}
|
|
7553
|
+
});
|
|
7554
|
+
VIEW_SEARCH_KEYS.forEach(function (sk) {
|
|
7555
|
+
let q = state[sk[0]];
|
|
7556
|
+
if (q && typeof q === 'object' && q.value) {
|
|
7557
|
+
put(sk[1], q.value);
|
|
7558
|
+
put(sk[1] + 'f', q.field);
|
|
7559
|
+
put(sk[1] + 'm', q.mode);
|
|
7560
|
+
put(sk[1] + '2', q.value2);
|
|
7561
|
+
}
|
|
7562
|
+
});
|
|
7563
|
+
return parts.join('&');
|
|
7564
|
+
};
|
|
7565
|
+
|
|
7566
|
+
archaeopteryx.decodeViewState = function (text) {
|
|
7567
|
+
if (text === undefined || text === null) {
|
|
7568
|
+
return null;
|
|
7569
|
+
}
|
|
7570
|
+
let s = String(text).replace(/^#/, '');
|
|
7571
|
+
if (s.length === 0) {
|
|
7572
|
+
return null;
|
|
7573
|
+
}
|
|
7574
|
+
let raw = {};
|
|
7575
|
+
s.split('&').forEach(function (pair) {
|
|
7576
|
+
let eq = pair.indexOf('=');
|
|
7577
|
+
if (eq > 0) {
|
|
7578
|
+
raw[decodeViewValue(pair.substring(0, eq))] = decodeViewValue(pair.substring(eq + 1));
|
|
7579
|
+
}
|
|
7580
|
+
});
|
|
7581
|
+
let state = {};
|
|
7582
|
+
VIEW_TEXT_KEYS.forEach(function (k) {
|
|
7583
|
+
if (raw[k] !== undefined && raw[k] !== '') {
|
|
7584
|
+
state[k] = raw[k];
|
|
7585
|
+
}
|
|
7586
|
+
});
|
|
7587
|
+
VIEW_INT_KEYS.forEach(function (k) {
|
|
7588
|
+
if (raw[k] !== undefined && /^-?\d+$/.test(raw[k])) {
|
|
7589
|
+
state[k] = parseInt(raw[k], 10);
|
|
7590
|
+
}
|
|
7591
|
+
});
|
|
7592
|
+
VIEW_NUMBER_KEYS.forEach(function (k) {
|
|
7593
|
+
let n = parseFloat(raw[k]);
|
|
7594
|
+
if (isFinite(n)) {
|
|
7595
|
+
state[k] = n;
|
|
7596
|
+
}
|
|
7597
|
+
});
|
|
7598
|
+
VIEW_BOOL_KEYS.forEach(function (k) {
|
|
7599
|
+
if (raw[k] === '1' || raw[k] === '0') {
|
|
7600
|
+
state[k] = raw[k] === '1';
|
|
7601
|
+
}
|
|
7602
|
+
});
|
|
7603
|
+
if (raw.show !== undefined) {
|
|
7604
|
+
state.show = raw.show.split(',').filter(function (x) {
|
|
7605
|
+
return x.length > 0;
|
|
7606
|
+
});
|
|
7607
|
+
}
|
|
7608
|
+
if (raw.collapsed !== undefined) {
|
|
7609
|
+
state.collapsed = raw.collapsed.split(',').filter(function (x) {
|
|
7610
|
+
return /^\d+$/.test(x);
|
|
7611
|
+
}).map(function (x) {
|
|
7612
|
+
return parseInt(x, 10);
|
|
7613
|
+
});
|
|
7614
|
+
}
|
|
7615
|
+
VIEW_SEARCH_KEYS.forEach(function (sk) {
|
|
7616
|
+
let p = sk[1];
|
|
7617
|
+
if (raw[p]) {
|
|
7618
|
+
let q = {value: raw[p]};
|
|
7619
|
+
if (raw[p + 'f']) {
|
|
7620
|
+
q.field = raw[p + 'f'];
|
|
7621
|
+
}
|
|
7622
|
+
if (raw[p + 'm']) {
|
|
7623
|
+
q.mode = raw[p + 'm'];
|
|
7624
|
+
}
|
|
7625
|
+
if (raw[p + '2']) {
|
|
7626
|
+
q.value2 = raw[p + '2'];
|
|
7627
|
+
}
|
|
7628
|
+
state[sk[0]] = q;
|
|
7629
|
+
}
|
|
7630
|
+
});
|
|
7631
|
+
return Object.keys(state).length > 0 ? state : null;
|
|
7632
|
+
};
|
|
7633
|
+
|
|
7634
|
+
// ===================== Keyboard shortcuts =====================
|
|
7635
|
+
// Ctrl on Windows and Linux, Cmd on macOS (Christian, 2026-09-13: "Ctrl/Cmd
|
|
7636
|
+
// combos are better"). The letters follow the desktop where it has the
|
|
7637
|
+
// action (its Alt+O order, Alt+U uncollapse all, Alt+E expand, Cmd+0 fit,
|
|
7638
|
+
// Cmd+G / Cmd+Shift+G next / previous hit); the layout, display type
|
|
7639
|
+
// and time-axis keys are ours. Combos the browser reserves (new tab,
|
|
7640
|
+
// close, tab switching, and on macOS the screenshot keys) are avoided,
|
|
7641
|
+
// and the few browser functions that are overridden (page zoom, find
|
|
7642
|
+
// next) matter little on a page that is one tree. No key for rooting,
|
|
7643
|
+
// by decision. Plain keys stay as they were: Esc / Home reset, O moves
|
|
7644
|
+
// the overview, PageUp / PageDown size the font. Inside a text box only
|
|
7645
|
+
// the combos that cannot interfere with typing fire.
|
|
7646
|
+
|
|
7647
|
+
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '');
|
|
7648
|
+
const SHORTCUTS_DIALOG = 'aptx_shortcuts';
|
|
7649
|
+
|
|
7650
|
+
// key: what e.key reads (letters lower-case); shift: whether Shift is
|
|
7651
|
+
// held; typing: the combo also fires from inside a text box
|
|
7652
|
+
const SHORTCUTS = [
|
|
7653
|
+
{key: '0', label: '0', what: 'Fit the tree to the window', run: function () { zoomToFit(); }, typing: true},
|
|
7654
|
+
{key: '=', label: '+', aliases: ['+'], what: 'Zoom in', run: function () { zoomInY(); if (!radialDisplay()) { zoomInX(); } }, typing: true},
|
|
7655
|
+
{key: '-', label: '−', aliases: ['_'], what: 'Zoom out', run: function () { zoomOutY(); if (!radialDisplay()) { zoomOutX(); } }, typing: true},
|
|
7656
|
+
{key: 'arrowup', label: '↑', shift: true, what: 'Zoom in vertically', run: function () { zoomInY(); }},
|
|
7657
|
+
{key: 'arrowdown', label: '↓', shift: true, what: 'Zoom out vertically', run: function () { zoomOutY(); }},
|
|
7658
|
+
{key: 'arrowright', label: '→', shift: true, what: 'Zoom in horizontally (circular: rotate)', run: function () { zoomInX(); }},
|
|
7659
|
+
{key: 'arrowleft', label: '←', shift: true, what: 'Zoom out horizontally (circular: rotate back)', run: function () { zoomOutX(); }},
|
|
7660
|
+
{key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
|
|
7661
|
+
{key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
|
|
7662
|
+
{key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
|
|
7663
|
+
{key: 'x', label: 'X', shift: true, what: 'Time axis on / off', run: toggleTimeAxis},
|
|
7664
|
+
{key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
|
|
7665
|
+
{key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
|
|
7666
|
+
{key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
|
|
7667
|
+
{key: 'g', label: 'G', what: 'Next search hit', run: function () { stepToFoundNode(1); }, typing: true},
|
|
7668
|
+
{key: 'g', label: 'G', shift: true, what: 'Previous search hit', run: function () { stepToFoundNode(-1); }, typing: true},
|
|
7669
|
+
{key: ',', label: '<', aliases: ['<'], shift: true, what: 'Previous tree of the file', run: function () { stepTree(-1); }},
|
|
7670
|
+
{key: '.', label: '>', aliases: ['>'], shift: true, what: 'Next tree of the file', run: function () { stepTree(1); }},
|
|
7671
|
+
{key: '/', label: '/', aliases: ['?'], what: 'This list', run: showShortcutsDialog, typing: true}
|
|
7672
|
+
];
|
|
7673
|
+
|
|
7674
|
+
function shortcutMatches(sc, e) {
|
|
7675
|
+
let k = (e.key || '').toLowerCase();
|
|
7676
|
+
if (k !== sc.key && !(sc.aliases && sc.aliases.indexOf(e.key) >= 0)) {
|
|
7677
|
+
return false;
|
|
7678
|
+
}
|
|
7679
|
+
return !!sc.shift === !!e.shiftKey;
|
|
7680
|
+
}
|
|
7681
|
+
|
|
7682
|
+
// The one document-level handler for the combos: keydown, so the
|
|
7683
|
+
// browser's own action is stopped before it runs.
|
|
7684
|
+
function shortcutKeyDown(e) {
|
|
7685
|
+
let mod = IS_MAC ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey);
|
|
7686
|
+
if (!mod || e.altKey || !_root) {
|
|
7687
|
+
return;
|
|
7688
|
+
}
|
|
7689
|
+
let typing = isTypingTarget(e.target);
|
|
7690
|
+
for (let i = 0; i < SHORTCUTS.length; ++i) {
|
|
7691
|
+
let sc = SHORTCUTS[i];
|
|
7692
|
+
if (shortcutMatches(sc, e)) {
|
|
7693
|
+
if (typing && !sc.typing) {
|
|
7694
|
+
return;
|
|
7695
|
+
}
|
|
7696
|
+
e.preventDefault();
|
|
7697
|
+
sc.run();
|
|
7698
|
+
return;
|
|
7699
|
+
}
|
|
7700
|
+
}
|
|
7701
|
+
}
|
|
7702
|
+
|
|
7703
|
+
function cycleLayout() {
|
|
7704
|
+
let next = _state.unrootedDisplay ? 'rectangular' : (_state.circularDisplay ? 'unrooted' : 'circular');
|
|
7705
|
+
setCheckboxValue(LAYOUT_RECT_BUTTON, next === 'rectangular');
|
|
7706
|
+
setCheckboxValue(LAYOUT_CIRC_BUTTON, next === 'circular');
|
|
7707
|
+
setCheckboxValue(LAYOUT_UNROOTED_BUTTON, next === 'unrooted');
|
|
7708
|
+
layoutButtonClicked();
|
|
7709
|
+
}
|
|
7710
|
+
|
|
7711
|
+
// Phylogram, aligned phylogram, cladogram -- skipping what the tree or
|
|
7712
|
+
// the layout rules out (no branch lengths: cladogram only; unrooted has
|
|
7713
|
+
// no aligned phylogram).
|
|
7714
|
+
function cycleDisplayType() {
|
|
7715
|
+
let measured = _basicTreeProperties && _basicTreeProperties.branchLengths === true;
|
|
7716
|
+
if (!measured) {
|
|
7717
|
+
toCladegram();
|
|
7718
|
+
return;
|
|
7719
|
+
}
|
|
7720
|
+
if (_state.phylogram && !_state.alignPhylogram) {
|
|
7721
|
+
if (_state.unrootedDisplay) {
|
|
7722
|
+
toCladegram();
|
|
7723
|
+
} else {
|
|
7724
|
+
toAlignedPhylogram();
|
|
7725
|
+
}
|
|
7726
|
+
} else if (_state.phylogram) {
|
|
7727
|
+
toCladegram();
|
|
7728
|
+
} else {
|
|
7729
|
+
toPhylogram();
|
|
7730
|
+
}
|
|
7731
|
+
}
|
|
7732
|
+
|
|
7733
|
+
// The Time Axis checkbox's toggle; when the distance / time tree switch
|
|
7734
|
+
// arrives it takes over this key.
|
|
7735
|
+
function toggleTimeAxis() {
|
|
7736
|
+
let cb = byId(TIME_AXIS_CB);
|
|
7737
|
+
if (!cb || cb.disabled) {
|
|
7738
|
+
return;
|
|
7739
|
+
}
|
|
7740
|
+
cb.checked = !cb.checked;
|
|
7741
|
+
timeAxisCbClicked();
|
|
7742
|
+
}
|
|
7743
|
+
|
|
7744
|
+
function focusSearch() {
|
|
7745
|
+
let f = byId(SEARCH_FIELD_0);
|
|
7746
|
+
if (f) {
|
|
7747
|
+
f.focus();
|
|
7748
|
+
f.select();
|
|
7749
|
+
}
|
|
7750
|
+
}
|
|
7751
|
+
|
|
7752
|
+
function stepTree(delta) {
|
|
7753
|
+
let i = _treeIndex + delta;
|
|
7754
|
+
if (i >= 0 && i < _trees.length) {
|
|
7755
|
+
showTree(i);
|
|
7756
|
+
}
|
|
7757
|
+
}
|
|
7758
|
+
|
|
7759
|
+
function shortcutLabel(sc) {
|
|
7760
|
+
let parts = [];
|
|
7761
|
+
if (IS_MAC) {
|
|
7762
|
+
parts.push('⌘');
|
|
7763
|
+
if (sc.shift) {
|
|
7764
|
+
parts.push('⇧');
|
|
7765
|
+
}
|
|
7766
|
+
} else {
|
|
7767
|
+
parts.push('Ctrl');
|
|
7768
|
+
if (sc.shift) {
|
|
7769
|
+
parts.push('Shift');
|
|
7770
|
+
}
|
|
7771
|
+
}
|
|
7772
|
+
parts.push(sc.label);
|
|
7773
|
+
return parts;
|
|
7774
|
+
}
|
|
7775
|
+
|
|
7776
|
+
function keyChips(parts) {
|
|
7777
|
+
let holder = document.createElement('span');
|
|
7778
|
+
holder.className = 'aptx-keys';
|
|
7779
|
+
parts.forEach(function (p) {
|
|
7780
|
+
let kbd = document.createElement('kbd');
|
|
7781
|
+
kbd.className = 'aptx-kbd';
|
|
7782
|
+
kbd.textContent = p;
|
|
7783
|
+
holder.appendChild(kbd);
|
|
7784
|
+
});
|
|
7785
|
+
return holder;
|
|
7786
|
+
}
|
|
7787
|
+
|
|
7788
|
+
// The cheat sheet: every combo above, then the plain keys.
|
|
7789
|
+
function showShortcutsDialog() {
|
|
7790
|
+
let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
|
|
7791
|
+
shell.body.classList.add('aptx-shortcuts');
|
|
7792
|
+
let rows = SHORTCUTS.map(function (sc) {
|
|
7793
|
+
return [shortcutLabel(sc), sc.what];
|
|
7794
|
+
}).concat([
|
|
7795
|
+
[['Esc'], 'Back to the whole tree, the launch view'],
|
|
7796
|
+
[['O'], 'Move the overview to the next corner'],
|
|
7797
|
+
[['PageUp'], 'Larger font'],
|
|
7798
|
+
[['PageDown'], 'Smaller font']
|
|
7799
|
+
]);
|
|
7800
|
+
rows.forEach(function (row) {
|
|
7801
|
+
let line = document.createElement('div');
|
|
7802
|
+
line.className = 'aptx-dialog-line';
|
|
7803
|
+
let key = document.createElement('span');
|
|
7804
|
+
key.className = 'aptx-dialog-key';
|
|
7805
|
+
key.appendChild(keyChips(row[0]));
|
|
7806
|
+
let val = document.createElement('span');
|
|
7807
|
+
val.className = 'aptx-dialog-val';
|
|
7808
|
+
val.textContent = row[1];
|
|
7809
|
+
line.appendChild(key);
|
|
7810
|
+
line.appendChild(val);
|
|
7811
|
+
shell.body.appendChild(line);
|
|
7812
|
+
});
|
|
7813
|
+
let note = document.createElement('p');
|
|
7814
|
+
note.className = 'aptx-shortcuts-note';
|
|
7815
|
+
note.textContent = 'The mouse wheel zooms; with Shift it zooms vertically only, with Shift+Alt horizontally, with Ctrl+Shift it sizes the font.';
|
|
7816
|
+
shell.body.appendChild(note);
|
|
7817
|
+
shell.dialog.showModal();
|
|
7818
|
+
}
|
|
7819
|
+
|
|
7820
|
+
// ===================== Protein domain architectures =====================
|
|
7821
|
+
// The desktop's domain display, ported from its RenderableDomainArchitecture
|
|
7822
|
+
// and TreePanel (the spec is section D1 of the repo's TODO.md). Each tip's
|
|
7823
|
+
// architecture is a grey backbone of L residues with a rounded box per
|
|
7824
|
+
// domain the E-value threshold admits: in one aligned column past the
|
|
7825
|
+
// labels in the rectangular layout, riding the tip's spoke in the radial
|
|
7826
|
+
// ones. One scale for the whole tree -- f px per residue, from the track
|
|
7827
|
+
// width and the longest architecture -- so lengths compare across tips.
|
|
7828
|
+
// Which domains, where, which colour and which legend rows are forester's
|
|
7829
|
+
// (tested against the desktop's numbers); this is the SVG, the legend
|
|
7830
|
+
// card and the controls. The boxes take no pointer events: nothing to
|
|
7831
|
+
// hover or click, as on the desktop.
|
|
7832
|
+
|
|
7833
|
+
function domainsShown() {
|
|
7834
|
+
return _state.showDomainArchitectures === true
|
|
7835
|
+
&& _basicTreeProperties.domainArchitectures === true
|
|
7836
|
+
&& _basicTreeProperties.maxDomainArchitectureLength > 0
|
|
7837
|
+
&& _state.showExternalLabels
|
|
7838
|
+
// in a radial layout the boxes ride the spokes, so only radial
|
|
7839
|
+
// labels go with them
|
|
7840
|
+
&& (!radialDisplay() || !_radialLabelsHorizontal);
|
|
7841
|
+
}
|
|
7842
|
+
|
|
7843
|
+
// The track width W of the rectangular layouts: a quarter of the
|
|
7844
|
+
// viewport at first use (the layout's width, if smaller). The radial
|
|
7845
|
+
// layouts keep a width of their own, starting at the smaller of W and a
|
|
7846
|
+
// fifth of the radius, so a long architecture does not run off the
|
|
7847
|
+
// circle at first sight. d+ / d- scale whichever width the current
|
|
7848
|
+
// layout uses by 1.2 / 0.8 and stop at 2000 / 20. (The desktop instead
|
|
7849
|
+
// caps the drawn width at that fifth of the radius on every redraw,
|
|
7850
|
+
// which leaves its buttons stepping a number the cap then discards --
|
|
7851
|
+
// Christian: "all buttons should work as expected".)
|
|
7852
|
+
function domainRectangularWidth() {
|
|
7853
|
+
if (!_domain.width) {
|
|
7854
|
+
let vp = svgSize();
|
|
7855
|
+
let vw = Math.min(_displayWidth, (vp && vp.w) ? vp.w : _displayWidth);
|
|
7856
|
+
_domain.width = Math.max(DOMAIN_WIDTH_MIN, Math.round(vw * DOMAIN_WIDTH_VIEWPORT_FRACTION));
|
|
7857
|
+
}
|
|
7858
|
+
return _domain.width;
|
|
7859
|
+
}
|
|
7860
|
+
|
|
7861
|
+
function domainTrackWidth() {
|
|
7862
|
+
if (!radialDisplay()) {
|
|
7863
|
+
return domainRectangularWidth();
|
|
7864
|
+
}
|
|
7865
|
+
if (!_domain.radialWidth) {
|
|
7866
|
+
let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
|
|
7867
|
+
if (!(r > 0)) {
|
|
7868
|
+
return domainRectangularWidth(); // no radius yet (before the first radial draw)
|
|
7869
|
+
}
|
|
7870
|
+
_domain.radialWidth = Math.max(DOMAIN_WIDTH_MIN, Math.min(domainRectangularWidth(), DOMAIN_RADIAL_WIDTH_FRACTION * r));
|
|
7871
|
+
}
|
|
7872
|
+
return _domain.radialWidth;
|
|
7873
|
+
}
|
|
7874
|
+
|
|
7875
|
+
function setDomainTrackWidth(w) {
|
|
7876
|
+
if (radialDisplay()) {
|
|
7877
|
+
_domain.radialWidth = w;
|
|
7878
|
+
} else {
|
|
7879
|
+
_domain.width = w;
|
|
7880
|
+
}
|
|
7881
|
+
}
|
|
7882
|
+
|
|
7883
|
+
function domainEffectiveWidth() {
|
|
7884
|
+
return domainTrackWidth();
|
|
7885
|
+
}
|
|
7886
|
+
|
|
7887
|
+
// px per residue, one factor for every architecture on the tree; Lmax
|
|
7888
|
+
// counts every domain whatever its E-value, so the threshold never
|
|
7889
|
+
// rescales the tracks
|
|
7890
|
+
function domainScale() {
|
|
7891
|
+
return (domainEffectiveWidth() / _basicTreeProperties.maxDomainArchitectureLength) * DOMAIN_SCALE_HEADROOM;
|
|
7892
|
+
}
|
|
7893
|
+
|
|
7894
|
+
// The room the longest tip label takes: the same estimate the layout is
|
|
7895
|
+
// sized against (there are no font metrics in the model).
|
|
7896
|
+
function domainLabelSpace() {
|
|
7897
|
+
return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
|
|
7898
|
+
}
|
|
7899
|
+
|
|
7900
|
+
// The rectangular layout reserves the tracks' column so the tree and
|
|
7901
|
+
// labels compress to make room, as the alignment track does; the radial
|
|
7902
|
+
// layouts fit the tracks into the ring instead (fitRadialExtent).
|
|
7903
|
+
function domainReserve() {
|
|
7904
|
+
if (!domainsShown() || radialDisplay()) {
|
|
7905
|
+
return 0;
|
|
7906
|
+
}
|
|
7907
|
+
return DOMAIN_TRACK_START_GAP + domainTrackWidth() + DOMAIN_TRACK_END_GAP;
|
|
7908
|
+
}
|
|
7909
|
+
|
|
7910
|
+
function domainRadialExtent() {
|
|
7911
|
+
if (!domainsShown() || !radialDisplay()) {
|
|
7912
|
+
return 0;
|
|
7913
|
+
}
|
|
7914
|
+
return DOMAIN_RADIAL_GAP + domainEffectiveWidth() + DOMAIN_TRACK_END_GAP;
|
|
7915
|
+
}
|
|
7916
|
+
|
|
7917
|
+
// Colours: the names the threshold admits over the WHOLE tree, sorted by
|
|
7918
|
+
// code unit, take the palette in order. Dealt at load, after an edit and
|
|
7919
|
+
// whenever the threshold changes (the drawn set changes then, so colours
|
|
7920
|
+
// can shift); a name met later takes the next unused colour and keeps
|
|
7921
|
+
// it. Never derived from the name's characters -- that let SH2 and SH3
|
|
7922
|
+
// collide on the desktop.
|
|
7923
|
+
function domainPalette() {
|
|
7924
|
+
if (!_domain.palette) {
|
|
7925
|
+
let names = forester.domainSummary(forester.getTreeRoot(_treeData), _state.domainEvalueExponent).names;
|
|
7926
|
+
_domain.palette = Object.create(null);
|
|
7927
|
+
names.forEach(function (name, i) {
|
|
7928
|
+
_domain.palette[name] = i;
|
|
7929
|
+
});
|
|
7930
|
+
_domain.next = names.length;
|
|
7931
|
+
}
|
|
7932
|
+
return _domain.palette;
|
|
7933
|
+
}
|
|
7934
|
+
|
|
7935
|
+
function domainColor(name) {
|
|
7936
|
+
if (!name) {
|
|
7937
|
+
return forester.DOMAIN_UNNAMED_COLOR;
|
|
7938
|
+
}
|
|
7939
|
+
let palette = domainPalette();
|
|
7940
|
+
if (palette[name] === undefined) {
|
|
7941
|
+
palette[name] = _domain.next++;
|
|
7942
|
+
}
|
|
7943
|
+
return forester.domainQualitativeColor(palette[name]);
|
|
7944
|
+
}
|
|
7945
|
+
|
|
7946
|
+
function domainBoxHeight(pitch) {
|
|
7947
|
+
return Math.max(DOMAIN_BOX_MIN_H, Math.min(DOMAIN_BOX_MAX_H, Math.round(pitch)));
|
|
7948
|
+
}
|
|
7949
|
+
|
|
7950
|
+
function drawDomainArchitectures() {
|
|
7951
|
+
if (!_svgGroup) {
|
|
7952
|
+
return;
|
|
7953
|
+
}
|
|
7954
|
+
_svgGroup.selectAll('g.aptx-domains').remove();
|
|
7955
|
+
if (!domainsShown() || !_root) {
|
|
7956
|
+
return;
|
|
7957
|
+
}
|
|
7958
|
+
let tips = displayedTips().filter(function (d) {
|
|
7959
|
+
return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
|
|
7960
|
+
});
|
|
7961
|
+
let f = domainScale();
|
|
7962
|
+
if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
|
|
7963
|
+
return;
|
|
7964
|
+
}
|
|
7965
|
+
let g = _svgGroup.append('g').attr('class', 'aptx-domains').style('pointer-events', 'none');
|
|
7966
|
+
// one vertical gradient per base colour, lighter at the top; the
|
|
7967
|
+
// defs ride into the SVG / PDF / PNG exports with the boxes
|
|
7968
|
+
let defs = g.append('defs');
|
|
7969
|
+
let made = Object.create(null);
|
|
7970
|
+
function gradientFor(base) {
|
|
7971
|
+
let id = 'aptx-dom-' + base.substring(1);
|
|
6661
7972
|
if (!made[id]) {
|
|
6662
7973
|
made[id] = true;
|
|
6663
7974
|
let grad = defs.append('linearGradient').attr('id', id)
|
|
@@ -6782,7 +8093,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6782
8093
|
// rows in the order the tips are on screen -- top to bottom, or
|
|
6783
8094
|
// clockwise -- not the data's order, which a ladderized display
|
|
6784
8095
|
// does not follow
|
|
6785
|
-
let tips =
|
|
8096
|
+
let tips = displayedTips().filter(function (d) {
|
|
6786
8097
|
return d.x !== undefined;
|
|
6787
8098
|
}).sort(function (p, q) {
|
|
6788
8099
|
return p.x - q.x;
|
|
@@ -6918,9 +8229,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
6918
8229
|
if (grow ? w >= DOMAIN_WIDTH_MAX : w <= DOMAIN_WIDTH_MIN) {
|
|
6919
8230
|
return;
|
|
6920
8231
|
}
|
|
6921
|
-
|
|
8232
|
+
setDomainTrackWidth(w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK));
|
|
6922
8233
|
syncDomainControls();
|
|
6923
|
-
|
|
8234
|
+
// a radial layout has no column to reserve, but the fit must take the
|
|
8235
|
+
// new track length into account, so it is refitted
|
|
8236
|
+
if (radialDisplay()) {
|
|
8237
|
+
scheduleUpdate(null, 0, true);
|
|
8238
|
+
afterUpdate(zoomToFit);
|
|
8239
|
+
} else {
|
|
8240
|
+
scheduleUpdate(null, 0);
|
|
8241
|
+
}
|
|
6924
8242
|
}
|
|
6925
8243
|
|
|
6926
8244
|
function domainEvalueStep(delta) {
|
|
@@ -6969,7 +8287,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
6969
8287
|
|
|
6970
8288
|
// Search B starts hidden to keep the panel compact; one click (or a
|
|
6971
8289
|
// configured initial value) reveals it, and it stays revealed.
|
|
6972
|
-
|
|
8290
|
+
// Search B is folded away until wanted: the '+ Search B' link (which
|
|
8291
|
+
// focuses it) or a view that carries a second search.
|
|
8292
|
+
function showSearchB() {
|
|
6973
8293
|
let wrap = byId(SEARCH_B_WRAP);
|
|
6974
8294
|
if (wrap) {
|
|
6975
8295
|
wrap.style.display = '';
|
|
@@ -6978,6 +8298,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
6978
8298
|
if (tgl) {
|
|
6979
8299
|
tgl.style.display = 'none';
|
|
6980
8300
|
}
|
|
8301
|
+
}
|
|
8302
|
+
|
|
8303
|
+
function revealSearchB() {
|
|
8304
|
+
showSearchB();
|
|
6981
8305
|
let f = byId(SEARCH_FIELD_1);
|
|
6982
8306
|
if (f) {
|
|
6983
8307
|
f.focus();
|
|
@@ -7014,7 +8338,81 @@ function (root, d3, forester, phyloXml) {
|
|
|
7014
8338
|
// The navigation bar is fixed at the viewport bottom, so the track's
|
|
7015
8339
|
// own bottom rows (conservation, consensus, ruler) must end above it
|
|
7016
8340
|
// or the bar covers them -- the old bare slider did exactly that.
|
|
7017
|
-
|
|
8341
|
+
// The scale bar sits in the same band, under the tree's last row.
|
|
8342
|
+
return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
|
|
8343
|
+
scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
|
|
8344
|
+
}
|
|
8345
|
+
|
|
8346
|
+
// ===================== Scale bar =====================
|
|
8347
|
+
// A phylogram carries a scale bar at the bottom left of the tree: a
|
|
8348
|
+
// round number of branch-length units (forester.scaleBarLength) with
|
|
8349
|
+
// end ticks and its length written above, drawn in the tree's own
|
|
8350
|
+
// coordinates so it zooms and exports with the tree and its label stays
|
|
8351
|
+
// true. Not in a cladogram (nothing to measure) and not under a time
|
|
8352
|
+
// axis (which is a scale already); in the radial layouts it sits below
|
|
8353
|
+
// the fan at its left edge.
|
|
8354
|
+
const SCALE_BAR_TARGET_PX = 100;
|
|
8355
|
+
const SCALE_BAR_RESERVE = 30;
|
|
8356
|
+
|
|
8357
|
+
function scaleBarShown() {
|
|
8358
|
+
return _state.phylogram === true && _basicTreeProperties.branchLengths === true && !timeAxisShown();
|
|
8359
|
+
}
|
|
8360
|
+
|
|
8361
|
+
// pixels per branch-length unit in the current layout's own coordinates
|
|
8362
|
+
function branchLengthPixelsPerUnit() {
|
|
8363
|
+
if (!_state.phylogram || !_yScale) {
|
|
8364
|
+
return 0;
|
|
8365
|
+
}
|
|
8366
|
+
if (_state.unrootedDisplay) {
|
|
8367
|
+
return (_unroot && _unroot.pxPerUnit) ? _unroot.pxPerUnit : 0;
|
|
8368
|
+
}
|
|
8369
|
+
let unit = _yScale(1) - _yScale(0);
|
|
8370
|
+
if (_state.circularDisplay) {
|
|
8371
|
+
return (_radial && _radial.maxY > 0) ? unit * (_radial.maxRad / _radial.maxY) : 0;
|
|
8372
|
+
}
|
|
8373
|
+
return unit;
|
|
8374
|
+
}
|
|
8375
|
+
|
|
8376
|
+
function drawScaleBar() {
|
|
8377
|
+
if (!_svgGroup) {
|
|
8378
|
+
return;
|
|
8379
|
+
}
|
|
8380
|
+
_svgGroup.selectAll('g.aptx-scalebar').remove();
|
|
8381
|
+
if (!scaleBarShown()) {
|
|
8382
|
+
return;
|
|
8383
|
+
}
|
|
8384
|
+
let bar = forester.scaleBarLength(branchLengthPixelsPerUnit(), SCALE_BAR_TARGET_PX);
|
|
8385
|
+
if (!bar) {
|
|
8386
|
+
return;
|
|
8387
|
+
}
|
|
8388
|
+
let x, y;
|
|
8389
|
+
if (radialDisplay()) {
|
|
8390
|
+
let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
|
|
8391
|
+
let r = (_state.circularDisplay ? _radial.maxRad : _unroot.maxRad) + labelSpace;
|
|
8392
|
+
x = -r;
|
|
8393
|
+
y = r + 24;
|
|
8394
|
+
} else {
|
|
8395
|
+
x = 0;
|
|
8396
|
+
y = _clusterH + SCALE_BAR_RESERVE - 12;
|
|
8397
|
+
}
|
|
8398
|
+
let stroke = _state.branchColorDefault;
|
|
8399
|
+
let width = Math.max(1, _state.branchWidthDefault);
|
|
8400
|
+
let fs = Math.max(9, _state.externalNodeFontSize);
|
|
8401
|
+
let g = _svgGroup.append('g').attr('class', 'aptx-scalebar').style('pointer-events', 'none');
|
|
8402
|
+
g.append('line')
|
|
8403
|
+
.attr('x1', x).attr('y1', y).attr('x2', x + bar.px).attr('y2', y)
|
|
8404
|
+
.style('stroke', stroke).style('stroke-width', width);
|
|
8405
|
+
[x, x + bar.px].forEach(function (tx) {
|
|
8406
|
+
g.append('line')
|
|
8407
|
+
.attr('x1', tx).attr('y1', y - 4).attr('x2', tx).attr('y2', y + 4)
|
|
8408
|
+
.style('stroke', stroke).style('stroke-width', width);
|
|
8409
|
+
});
|
|
8410
|
+
g.append('text')
|
|
8411
|
+
.attr('x', x + (bar.px / 2)).attr('y', y - 6)
|
|
8412
|
+
.attr('text-anchor', 'middle')
|
|
8413
|
+
.style('font', fs + 'px ' + FONT_DEFAULTS)
|
|
8414
|
+
.style('fill', _state.labelColorDefault)
|
|
8415
|
+
.text(bar.label);
|
|
7018
8416
|
}
|
|
7019
8417
|
|
|
7020
8418
|
function hexToRgbTriple(hex) {
|
|
@@ -7050,7 +8448,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7050
8448
|
// average branch length (see bl()), which would shift every band.
|
|
7051
8449
|
let anchor = null;
|
|
7052
8450
|
let maxTipX = 0;
|
|
7053
|
-
|
|
8451
|
+
forEachDisplayed(function (n) {
|
|
7054
8452
|
if (n.children) {
|
|
7055
8453
|
return;
|
|
7056
8454
|
}
|
|
@@ -7088,7 +8486,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7088
8486
|
let sc = info.type === 'calendar' ? -corr : corr;
|
|
7089
8487
|
|
|
7090
8488
|
// ---- HPD age bars (internal) + fossil range bars (tips) ----
|
|
7091
|
-
|
|
8489
|
+
forEachDisplayed(function (d) {
|
|
7092
8490
|
if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
|
|
7093
8491
|
|| d.y === undefined) {
|
|
7094
8492
|
return;
|
|
@@ -7322,7 +8720,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7322
8720
|
setZoomScale(1);
|
|
7323
8721
|
update(_root, 0);
|
|
7324
8722
|
_zoomed_x_or_y = true;
|
|
7325
|
-
const uncollsed_nodes =
|
|
8723
|
+
const uncollsed_nodes = displayedLeafCount();
|
|
7326
8724
|
_displayHeight = _state.externalNodeFontSize * (uncollsed_nodes * 1.3);
|
|
7327
8725
|
const min = 40;
|
|
7328
8726
|
if (_displayHeight < min) {
|
|
@@ -7395,13 +8793,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
7395
8793
|
if (_treeFn.visData.ladderize === undefined) {
|
|
7396
8794
|
_treeFn.visData.ladderize = true;
|
|
7397
8795
|
}
|
|
7398
|
-
ladderizeSubtree(_root, _treeFn.visData.ladderize, true);
|
|
8796
|
+
let largestFirst = ladderizeSubtree(_root, _treeFn.visData.ladderize, true);
|
|
8797
|
+
_viewOps.order = largestFirst ? 'asc' : 'desc'; // what a shared view replays
|
|
7399
8798
|
_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
|
-
}
|
|
8799
|
+
syncLadderizeGlyph();
|
|
7405
8800
|
update(null, 0);
|
|
7406
8801
|
}
|
|
7407
8802
|
}
|
|
@@ -7423,7 +8818,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
7423
8818
|
showNodeMenu([
|
|
7424
8819
|
{
|
|
7425
8820
|
label: 'Midpoint re-root', action: function () {
|
|
7426
|
-
|
|
8821
|
+
rerootKeepingCollapse(function () {
|
|
8822
|
+
forester.midpointRoot(_root);
|
|
8823
|
+
});
|
|
8824
|
+
_viewOps.root = 'midpoint'; // what a shared view replays
|
|
7427
8825
|
zoomToFit();
|
|
7428
8826
|
}
|
|
7429
8827
|
},
|
|
@@ -7440,6 +8838,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7440
8838
|
_root = _root_const;
|
|
7441
8839
|
_in_subtree = false;
|
|
7442
8840
|
}
|
|
8841
|
+
clearCollapsedFlags(_root); // the launch state has nothing collapsed
|
|
7443
8842
|
|
|
7444
8843
|
_basicTreeProperties = forester.collectBasicTreeProperties(_root);
|
|
7445
8844
|
|
|
@@ -7585,12 +8984,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
7585
8984
|
}
|
|
7586
8985
|
|
|
7587
8986
|
function resetSearch0() {
|
|
8987
|
+
closeSuggestions();
|
|
7588
8988
|
setValue(SEARCH_FIELD_0, '');
|
|
7589
8989
|
setValue(SEARCH_VALUE2_0, '');
|
|
7590
8990
|
runSearches();
|
|
7591
8991
|
}
|
|
7592
8992
|
|
|
7593
8993
|
function resetSearch1() {
|
|
8994
|
+
closeSuggestions();
|
|
7594
8995
|
setValue(SEARCH_FIELD_1, '');
|
|
7595
8996
|
setValue(SEARCH_VALUE2_1, '');
|
|
7596
8997
|
runSearches();
|
|
@@ -7695,24 +9096,252 @@ function (root, d3, forester, phyloXml) {
|
|
|
7695
9096
|
if (idx === 0) { search0(); } else { search1(); }
|
|
7696
9097
|
}
|
|
7697
9098
|
|
|
7698
|
-
//
|
|
7699
|
-
// type-ahead
|
|
9099
|
+
// ===================== Search suggestions =====================
|
|
9100
|
+
// The value box's type-ahead: the distinct values of the chosen field,
|
|
9101
|
+
// filtered as you type, in an in-page list under the box drawn like the
|
|
9102
|
+
// node menu in the panel's own theme. It replaces the browser's
|
|
9103
|
+
// <datalist> widget -- the one control the page could not style, which
|
|
9104
|
+
// sat in the panel like a stranger. Off, as before, for numeric fields,
|
|
9105
|
+
// Any Text and the molecular sequence (field.suggest === false), and in
|
|
9106
|
+
// regex mode. The values are gathered once per field or mode change
|
|
9107
|
+
// (updateSearchAutocomplete), not per keystroke: on a big tree that is a
|
|
9108
|
+
// walk of every node.
|
|
9109
|
+
|
|
9110
|
+
const SUGGEST_MAX_ROWS = 10;
|
|
9111
|
+
let _suggestValues = [null, null]; // per box: the field's distinct values, or null when suggestions are off
|
|
9112
|
+
let _suggest = null; // the open list: {idx, input, box, rows, active, onOutside}
|
|
9113
|
+
let _suggestPicked = false; // an Enter that picked a row must not also re-run the search on keyup
|
|
9114
|
+
let _suggestSkipFocus = false; // the focus a pick hands back must not reopen the list
|
|
9115
|
+
|
|
7700
9116
|
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;
|
|
9117
|
+
closeSuggestions();
|
|
7705
9118
|
let spec = currentSearchSpec(idx);
|
|
7706
|
-
let
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
9119
|
+
let on = !spec.field.numeric && spec.field.suggest !== false && spec.mode !== 'regex';
|
|
9120
|
+
_suggestValues[idx] = on ? forester.distinctSearchValues(_root, spec.field, SEARCH_AUTOCOMPLETE_CAP) : null;
|
|
9121
|
+
}
|
|
9122
|
+
|
|
9123
|
+
// The term being typed is the last one: ',' is OR and '+' is AND in the
|
|
9124
|
+
// plain-text modes, and a pick replaces just that term.
|
|
9125
|
+
function suggestTermStart(text) {
|
|
9126
|
+
return Math.max(text.lastIndexOf(','), text.lastIndexOf('+')) + 1;
|
|
9127
|
+
}
|
|
9128
|
+
|
|
9129
|
+
// Suggestions compare the way the search will: verbatim while Match
|
|
9130
|
+
// case is on, folded to lower case otherwise (Christian, 2026-09-13,
|
|
9131
|
+
// joint with the desktop).
|
|
9132
|
+
function suggestFold(s) {
|
|
9133
|
+
return _state.searchIsCaseSensitive === true ? s : s.toLowerCase();
|
|
9134
|
+
}
|
|
9135
|
+
|
|
9136
|
+
// The values the typed term admits, matched the way the box's mode will
|
|
9137
|
+
// match them: a prefix for "starts with", a suffix for "ends with", a
|
|
9138
|
+
// substring otherwise.
|
|
9139
|
+
function suggestMatches(idx, term) {
|
|
9140
|
+
let values = _suggestValues[idx] || [];
|
|
9141
|
+
let mode = getValue(idx === 0 ? SEARCH_MODE_SELECT_0 : SEARCH_MODE_SELECT_1);
|
|
9142
|
+
let needle = suggestFold(term);
|
|
9143
|
+
return values.filter(function (v) {
|
|
9144
|
+
if (needle.length === 0) {
|
|
9145
|
+
return true;
|
|
9146
|
+
}
|
|
9147
|
+
let lv = suggestFold(v);
|
|
9148
|
+
if (mode === 'starts_with') {
|
|
9149
|
+
return lv.indexOf(needle) === 0;
|
|
9150
|
+
}
|
|
9151
|
+
if (mode === 'ends_with') {
|
|
9152
|
+
return lv.length >= needle.length && lv.lastIndexOf(needle) === lv.length - needle.length;
|
|
9153
|
+
}
|
|
9154
|
+
return lv.indexOf(needle) >= 0;
|
|
9155
|
+
});
|
|
9156
|
+
}
|
|
9157
|
+
|
|
9158
|
+
function openSuggestions(idx) {
|
|
9159
|
+
closeSuggestions();
|
|
9160
|
+
let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
|
|
9161
|
+
if (!input || !_suggestValues[idx] || _suggestValues[idx].length === 0) {
|
|
9162
|
+
return;
|
|
9163
|
+
}
|
|
9164
|
+
let text = input.value;
|
|
9165
|
+
let start = suggestTermStart(text);
|
|
9166
|
+
let term = text.substring(start).trim();
|
|
9167
|
+
let matches = suggestMatches(idx, term);
|
|
9168
|
+
// nothing to offer when nothing matches, or the one match is what is already typed
|
|
9169
|
+
if (matches.length === 0 || (matches.length === 1 && suggestFold(matches[0]) === suggestFold(term))) {
|
|
9170
|
+
return;
|
|
9171
|
+
}
|
|
9172
|
+
let box = document.createElement('div');
|
|
9173
|
+
box.className = 'aptx-suggest';
|
|
9174
|
+
if (_panelTheme) {
|
|
9175
|
+
box.classList.add('aptx-' + _panelTheme); // follow the panel's light/dark choice
|
|
9176
|
+
}
|
|
9177
|
+
let needle = suggestFold(term);
|
|
9178
|
+
let rows = [];
|
|
9179
|
+
matches.slice(0, SUGGEST_MAX_ROWS).forEach(function (v) {
|
|
9180
|
+
let b = document.createElement('button');
|
|
9181
|
+
b.type = 'button';
|
|
9182
|
+
b.tabIndex = -1;
|
|
9183
|
+
b.title = v;
|
|
9184
|
+
// the matched part in bold, wherever it sits in the value
|
|
9185
|
+
let at = needle.length > 0 ? suggestFold(v).indexOf(needle) : -1;
|
|
9186
|
+
if (at >= 0) {
|
|
9187
|
+
b.appendChild(document.createTextNode(v.substring(0, at)));
|
|
9188
|
+
let hit = document.createElement('b');
|
|
9189
|
+
hit.textContent = v.substring(at, at + needle.length);
|
|
9190
|
+
b.appendChild(hit);
|
|
9191
|
+
b.appendChild(document.createTextNode(v.substring(at + needle.length)));
|
|
9192
|
+
} else {
|
|
9193
|
+
b.textContent = v;
|
|
9194
|
+
}
|
|
9195
|
+
b.addEventListener('mousedown', function (e) {
|
|
9196
|
+
e.preventDefault(); // the focus stays in the box: no blur, no flicker
|
|
9197
|
+
});
|
|
9198
|
+
b.addEventListener('click', function (e) {
|
|
9199
|
+
e.stopPropagation();
|
|
9200
|
+
pickSuggestion(idx, v);
|
|
9201
|
+
});
|
|
9202
|
+
box.appendChild(b);
|
|
9203
|
+
rows.push(b);
|
|
9204
|
+
});
|
|
9205
|
+
if (matches.length > SUGGEST_MAX_ROWS) {
|
|
9206
|
+
let more = document.createElement('div');
|
|
9207
|
+
more.className = 'aptx-suggest-more';
|
|
9208
|
+
more.textContent = (matches.length - SUGGEST_MAX_ROWS) + ' more — keep typing';
|
|
9209
|
+
box.appendChild(more);
|
|
9210
|
+
}
|
|
9211
|
+
document.body.appendChild(box);
|
|
9212
|
+
// under the box and as wide as it, or above it when the window's
|
|
9213
|
+
// bottom is too close
|
|
9214
|
+
let r = input.getBoundingClientRect();
|
|
9215
|
+
box.style.minWidth = Math.round(r.width) + 'px';
|
|
9216
|
+
let h = box.getBoundingClientRect().height;
|
|
9217
|
+
let below = r.bottom + 3;
|
|
9218
|
+
let top = (below + h > document.documentElement.clientHeight - 8 && r.top - h - 3 > 8) ? r.top - h - 3 : below;
|
|
9219
|
+
box.style.left = Math.round(r.left + window.scrollX) + 'px';
|
|
9220
|
+
box.style.top = Math.round(top + window.scrollY) + 'px';
|
|
9221
|
+
// a press anywhere else closes it -- in the capture phase, since d3's
|
|
9222
|
+
// zoom stops mousedown on the tree from reaching the document
|
|
9223
|
+
let onOutside = function (e) {
|
|
9224
|
+
if (_suggest && !_suggest.box.contains(e.target) && e.target !== input) {
|
|
9225
|
+
closeSuggestions();
|
|
9226
|
+
}
|
|
9227
|
+
};
|
|
9228
|
+
document.addEventListener('mousedown', onOutside, true);
|
|
9229
|
+
_suggest = {idx: idx, input: input, box: box, rows: rows, active: -1, onOutside: onOutside};
|
|
9230
|
+
}
|
|
9231
|
+
|
|
9232
|
+
function closeSuggestions() {
|
|
9233
|
+
if (!_suggest) {
|
|
9234
|
+
return;
|
|
9235
|
+
}
|
|
9236
|
+
document.removeEventListener('mousedown', _suggest.onOutside, true);
|
|
9237
|
+
_suggest.box.remove();
|
|
9238
|
+
_suggest = null;
|
|
9239
|
+
}
|
|
9240
|
+
|
|
9241
|
+
function setActiveSuggestion(i) {
|
|
9242
|
+
if (!_suggest) {
|
|
9243
|
+
return;
|
|
9244
|
+
}
|
|
9245
|
+
_suggest.rows.forEach(function (b, k) {
|
|
9246
|
+
b.classList.toggle('aptx-active', k === i);
|
|
9247
|
+
});
|
|
9248
|
+
_suggest.active = i;
|
|
9249
|
+
}
|
|
9250
|
+
|
|
9251
|
+
function pickSuggestion(idx, value) {
|
|
9252
|
+
let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
|
|
9253
|
+
closeSuggestions();
|
|
9254
|
+
if (!input) {
|
|
9255
|
+
return;
|
|
9256
|
+
}
|
|
9257
|
+
let text = input.value;
|
|
9258
|
+
input.value = text.substring(0, suggestTermStart(text)) + value;
|
|
9259
|
+
_suggestSkipFocus = true;
|
|
9260
|
+
input.focus();
|
|
9261
|
+
runSearches();
|
|
9262
|
+
}
|
|
9263
|
+
|
|
9264
|
+
// Keys in the value box: the arrows open the list and move along it,
|
|
9265
|
+
// Enter picks, Escape and Tab close. An Escape that closed the list must
|
|
9266
|
+
// not also reset the view, exactly as the node menu's does not.
|
|
9267
|
+
function suggestKeydown(idx, e) {
|
|
9268
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
9269
|
+
if (!_suggest) {
|
|
9270
|
+
openSuggestions(idx);
|
|
9271
|
+
if (!_suggest) {
|
|
9272
|
+
return;
|
|
9273
|
+
}
|
|
9274
|
+
}
|
|
9275
|
+
let n = _suggest.rows.length;
|
|
9276
|
+
let a = _suggest.active;
|
|
9277
|
+
// on a list with no row chosen yet, Down starts at the top and Up
|
|
9278
|
+
// at the bottom (the desktop found Up landing one short of it)
|
|
9279
|
+
setActiveSuggestion(e.key === 'ArrowDown' ? (a + 1) % n : (a < 0 ? n - 1 : (a - 1 + n) % n));
|
|
9280
|
+
e.preventDefault();
|
|
9281
|
+
} else if (e.key === 'Enter') {
|
|
9282
|
+
if (_suggest && _suggest.active >= 0) {
|
|
9283
|
+
_suggestPicked = true;
|
|
9284
|
+
pickSuggestion(idx, _suggest.rows[_suggest.active].title);
|
|
9285
|
+
e.preventDefault();
|
|
9286
|
+
} else {
|
|
9287
|
+
closeSuggestions();
|
|
9288
|
+
}
|
|
9289
|
+
} else if (e.key === 'Escape') {
|
|
9290
|
+
if (_suggest) {
|
|
9291
|
+
closeSuggestions();
|
|
9292
|
+
_menuConsumedEsc = true;
|
|
9293
|
+
e.stopPropagation();
|
|
9294
|
+
}
|
|
9295
|
+
} else if (e.key === 'Tab') {
|
|
9296
|
+
closeSuggestions();
|
|
9297
|
+
}
|
|
9298
|
+
}
|
|
9299
|
+
|
|
9300
|
+
// The value box's keyup runs the search. A navigation key changes no
|
|
9301
|
+
// value, and an Enter that just picked a row has already run it.
|
|
9302
|
+
const SUGGEST_NAV_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape', 'Tab', 'Shift',
|
|
9303
|
+
'Control', 'Alt', 'Meta', 'Home', 'End', 'PageUp', 'PageDown', 'CapsLock'];
|
|
9304
|
+
|
|
9305
|
+
function searchValueKeyup(e) {
|
|
9306
|
+
if (SUGGEST_NAV_KEYS.indexOf(e.key) >= 0) {
|
|
9307
|
+
return;
|
|
9308
|
+
}
|
|
9309
|
+
if (e.key === 'Enter' && _suggestPicked) {
|
|
9310
|
+
_suggestPicked = false;
|
|
9311
|
+
return;
|
|
9312
|
+
}
|
|
9313
|
+
runSearches();
|
|
9314
|
+
}
|
|
9315
|
+
|
|
9316
|
+
function wireSuggestions(idx) {
|
|
9317
|
+
let input = byId(idx === 0 ? SEARCH_FIELD_0 : SEARCH_FIELD_1);
|
|
9318
|
+
if (!input) {
|
|
9319
|
+
return;
|
|
7714
9320
|
}
|
|
7715
|
-
input.
|
|
9321
|
+
input.addEventListener('keydown', function (e) {
|
|
9322
|
+
suggestKeydown(idx, e);
|
|
9323
|
+
});
|
|
9324
|
+
input.addEventListener('input', function () {
|
|
9325
|
+
openSuggestions(idx);
|
|
9326
|
+
});
|
|
9327
|
+
input.addEventListener('focus', function () {
|
|
9328
|
+
if (_suggestSkipFocus) {
|
|
9329
|
+
_suggestSkipFocus = false;
|
|
9330
|
+
return;
|
|
9331
|
+
}
|
|
9332
|
+
if (input.value.length > 0) {
|
|
9333
|
+
openSuggestions(idx);
|
|
9334
|
+
}
|
|
9335
|
+
});
|
|
9336
|
+
input.addEventListener('blur', function () {
|
|
9337
|
+
// after a tick: a click on a row keeps the focus, anything else
|
|
9338
|
+
// that took it closes the list
|
|
9339
|
+
setTimeout(function () {
|
|
9340
|
+
if (_suggest && _suggest.idx === idx && document.activeElement !== input) {
|
|
9341
|
+
closeSuggestions();
|
|
9342
|
+
}
|
|
9343
|
+
}, 120);
|
|
9344
|
+
});
|
|
7716
9345
|
}
|
|
7717
9346
|
|
|
7718
9347
|
|
|
@@ -7745,6 +9374,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7745
9374
|
_radialLabelsHorizontal = false;
|
|
7746
9375
|
}
|
|
7747
9376
|
syncZoomRowButtons();
|
|
9377
|
+
afterUpdate(syncDomainControls); // the track width buttons follow the layout's own width
|
|
7748
9378
|
zoomToFit();
|
|
7749
9379
|
}
|
|
7750
9380
|
|
|
@@ -7944,6 +9574,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
7944
9574
|
scheduleUpdate(null, 0);
|
|
7945
9575
|
}
|
|
7946
9576
|
|
|
9577
|
+
// The desktop draws a box around "Auto-hide Labels" while labels are
|
|
9578
|
+
// actually being hidden -- the switch being on is one thing, the tree
|
|
9579
|
+
// being dense enough for it to bite is another. Same here, in the
|
|
9580
|
+
// panel's accent: the checkbox item gets a box and its tooltip says how
|
|
9581
|
+
// many labels are shown. Called on every redraw, since the density
|
|
9582
|
+
// changes with the vertical zoom, the font size and the view.
|
|
9583
|
+
function syncDynahideIndicator(active) {
|
|
9584
|
+
let cb = byId(DYNAHIDE_CB);
|
|
9585
|
+
let item = cb ? cb.closest('.aptx-check') : null;
|
|
9586
|
+
if (!item) {
|
|
9587
|
+
return;
|
|
9588
|
+
}
|
|
9589
|
+
if (item.dataset.baseTitle === undefined) {
|
|
9590
|
+
item.dataset.baseTitle = item.title;
|
|
9591
|
+
}
|
|
9592
|
+
item.classList.toggle('aptx-check-active', !!active);
|
|
9593
|
+
item.title = active
|
|
9594
|
+
? item.dataset.baseTitle + ' — hiding now: 1 in ' + _dynahide_factor + ' labels shown'
|
|
9595
|
+
: item.dataset.baseTitle;
|
|
9596
|
+
}
|
|
9597
|
+
|
|
7947
9598
|
function dynaHideCbClicked() {
|
|
7948
9599
|
_state.dynahide = getCheckboxValue(DYNAHIDE_CB);
|
|
7949
9600
|
resetVis();
|
|
@@ -8008,6 +9659,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
8008
9659
|
|
|
8009
9660
|
function searchOptionsCaseSenstiveCbClicked() {
|
|
8010
9661
|
_state.searchIsCaseSensitive = getCheckboxValue(SEARCH_OPTIONS_CASE_SENSITIVE_CB);
|
|
9662
|
+
closeSuggestions(); // an open list was filtered the other way; the next keystroke refilters
|
|
8011
9663
|
search0();
|
|
8012
9664
|
search1();
|
|
8013
9665
|
}
|
|
@@ -8502,6 +10154,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
8502
10154
|
case 'ladderize_asc': sw = 8; cap = 'round'; body = glyphLadderize(true); break;
|
|
8503
10155
|
case 'ladderize_desc': sw = 8; cap = 'round'; body = glyphLadderize(false); break;
|
|
8504
10156
|
case 'midpoint': sw = 8; cap = 'round'; body = glyphMidpoint(); break;
|
|
10157
|
+
case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
|
|
10158
|
+
case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
|
|
10159
|
+
case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
|
|
8505
10160
|
case 'sun': body = glyphSun(); break;
|
|
8506
10161
|
case 'moon': body = glyphMoon(); break;
|
|
8507
10162
|
default: throw new Error('unknown control-panel glyph: ' + kind);
|
|
@@ -8511,6 +10166,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
8511
10166
|
+ ' stroke-linecap="' + cap + '" stroke-linejoin="' + join + '">' + body + '</svg>';
|
|
8512
10167
|
}
|
|
8513
10168
|
|
|
10169
|
+
// The desktop's UNCOLLAPSE_ALL glyph (ControlButtonIcon.paintUncollapse),
|
|
10170
|
+
// ported point for point into the 100-unit box: the collapsed-clade
|
|
10171
|
+
// triangle, apex at the parent, base toward the tips it opens back out
|
|
10172
|
+
// into -- three of them.
|
|
10173
|
+
// A chevron pointing left or right: the previous / next tree buttons.
|
|
10174
|
+
function glyphChevron(right) {
|
|
10175
|
+
return right ? '<path d="M38,18 L66,50 L38,82"/>' : '<path d="M62,18 L34,50 L62,82"/>';
|
|
10176
|
+
}
|
|
10177
|
+
|
|
10178
|
+
function glyphUncollapseAll() {
|
|
10179
|
+
return '<path d="M6,50 L44,16 L44,84 Z" fill="currentColor" stroke="none"/>'
|
|
10180
|
+
+ '<path d="M56,20 H94 M56,50 H94 M56,80 H94"/>';
|
|
10181
|
+
}
|
|
10182
|
+
|
|
8514
10183
|
function injectPanelStyles() {
|
|
8515
10184
|
loadPanelTheme();
|
|
8516
10185
|
if (document.getElementById(PANEL_STYLE_ID)) {
|
|
@@ -8547,16 +10216,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
8547
10216
|
+ '.aptx-panel * { box-sizing:border-box; }'
|
|
8548
10217
|
+ '@media (prefers-color-scheme:dark){ .aptx-panel:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
|
|
8549
10218
|
+ '.aptx-panel.aptx-dark {' + dark + '}'
|
|
8550
|
-
// The node menu
|
|
8551
|
-
// panel), so
|
|
8552
|
-
// same light / dark rules.
|
|
8553
|
-
+ '.aptx-node-menu {'
|
|
10219
|
+
// The node menu and the search suggestions are separate overlays
|
|
10220
|
+
// (they cannot live inside the panel), so they carry their own
|
|
10221
|
+
// copy of the palette and follow the same light / dark rules.
|
|
10222
|
+
+ '.aptx-node-menu, .aptx-suggest {'
|
|
8554
10223
|
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
8555
10224
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
8556
10225
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
8557
10226
|
+ '}'
|
|
8558
|
-
+ '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
|
|
8559
|
-
+ '.aptx-node-menu.aptx-dark {' + dark + '}'
|
|
10227
|
+
+ '@media (prefers-color-scheme:dark){ .aptx-node-menu:not(.aptx-light):not(.aptx-dark), .aptx-suggest:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
|
|
10228
|
+
+ '.aptx-node-menu.aptx-dark, .aptx-suggest.aptx-dark {' + dark + '}'
|
|
8560
10229
|
// The menu itself: fixed 11px type, so it does NOT scale with the
|
|
8561
10230
|
// tree's zoom the way the old svg-drawn menu did.
|
|
8562
10231
|
+ '.aptx-node-menu { position:absolute; z-index:1000; min-width:196px; max-width:280px; padding:4px;'
|
|
@@ -8575,9 +10244,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
8575
10244
|
+ ' transition:background .1s,color .1s; }'
|
|
8576
10245
|
+ '.aptx-node-menu button:hover, .aptx-node-menu button:focus-visible {'
|
|
8577
10246
|
+ ' background:var(--p-accent); color:#fff; outline:none; }'
|
|
10247
|
+
+ '.aptx-node-menu button:disabled, .aptx-node-menu button:disabled:hover {'
|
|
10248
|
+
+ ' background:transparent; color:var(--p-faint); cursor:default; }'
|
|
8578
10249
|
+ '.aptx-node-menu button.aptx-menu-danger:hover, .aptx-node-menu button.aptx-menu-danger:focus-visible {'
|
|
8579
10250
|
+ ' background:#e5484d; color:#fff; }'
|
|
8580
10251
|
+ '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
|
|
10252
|
+
// The search suggestions: the node menu's chrome under the value box.
|
|
10253
|
+
+ '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
|
|
10254
|
+
+ ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
|
|
10255
|
+
+ ' -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);'
|
|
10256
|
+
+ ' box-shadow:0 12px 30px -12px rgba(23,34,46,0.42),0 2px 6px -2px rgba(23,34,46,0.2);'
|
|
10257
|
+
+ ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
|
|
10258
|
+
+ ' font-size:11px; line-height:1.45; }'
|
|
10259
|
+
+ '.aptx-suggest button { display:block; width:100%; text-align:left; margin:0; padding:4px 9px;'
|
|
10260
|
+
+ ' border:0; border-radius:6px; background:transparent; color:var(--p-ink); font:inherit;'
|
|
10261
|
+
+ ' cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'
|
|
10262
|
+
+ ' transition:background .1s,color .1s; }'
|
|
10263
|
+
+ '.aptx-suggest button b { font-weight:700; color:var(--p-accent); }'
|
|
10264
|
+
+ '.aptx-suggest button:hover, .aptx-suggest button.aptx-active { background:var(--p-accent); color:#fff; }'
|
|
10265
|
+
+ '.aptx-suggest button:hover b, .aptx-suggest button.aptx-active b { color:#fff; }'
|
|
10266
|
+
+ '.aptx-suggest-more { padding:4px 9px 2px; margin-top:3px; border-top:1px solid var(--p-line);'
|
|
10267
|
+
+ ' font-size:10px; color:var(--p-faint); }'
|
|
8581
10268
|
// The node-data dialog, on the same palette as the panel and the menu.
|
|
8582
10269
|
+ '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
|
|
8583
10270
|
+ ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
@@ -8651,6 +10338,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
8651
10338
|
+ '.aptx-dialog-line + .aptx-dialog-line { border-top:1px solid var(--p-line); }'
|
|
8652
10339
|
+ '.aptx-dialog-key { flex:0 0 42%; color:var(--p-muted); }'
|
|
8653
10340
|
+ '.aptx-dialog-val { flex:1 1 auto; min-width:0; overflow-wrap:anywhere; }'
|
|
10341
|
+
// the shortcuts cheat sheet: key chips in the key column
|
|
10342
|
+
+ '.aptx-shortcuts .aptx-dialog-key { flex-basis:38%; display:flex; align-items:center; }'
|
|
10343
|
+
+ '.aptx-shortcuts .aptx-dialog-val { align-self:center; }'
|
|
10344
|
+
+ '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
|
|
10345
|
+
+ '.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; }'
|
|
10346
|
+
+ '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
|
|
8654
10347
|
+ '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
|
|
8655
10348
|
+ ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
|
|
8656
10349
|
// The hover tooltip, on the same palette.
|
|
@@ -8702,13 +10395,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
8702
10395
|
+ '.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
10396
|
+ '.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
10397
|
+ '.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; }'
|
|
10398
|
+
// A file with several trees: previous / picker / next in one row
|
|
10399
|
+
+ '.aptx-panel .aptx-tree-picker { display:flex; align-items:center; gap:4px; min-width:0; }'
|
|
10400
|
+
+ '.aptx-panel .aptx-tree-picker select { flex:1 1 auto; min-width:0; height:24px; }'
|
|
10401
|
+
+ '.aptx-panel .aptx-tree-picker .aptx-gbtn { flex:0 0 auto; min-width:26px; padding:0 4px; margin:0; }'
|
|
10402
|
+
+ '.aptx-panel .aptx-tree-picker + .' + TREE_DESC + ' { margin-top:5px; }'
|
|
8705
10403
|
+ '.aptx-panel .aptx-panel-title { font-size:12px; }'
|
|
8706
10404
|
+ '.aptx-panel fieldset { border:0; border-top:1px solid var(--p-line); margin:0; padding:6px 12px; min-width:0; }'
|
|
8707
10405
|
+ '.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
10406
|
+ '.aptx-panel label { cursor:pointer; }'
|
|
8709
10407
|
+ '.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
10408
|
// checkbox/radio + label as one item (used by the Display Data grid and the inline P/A/C and search-option rows)
|
|
8711
|
-
|
|
10409
|
+
// every item carries an invisible box, so the one that lights up
|
|
10410
|
+
// (Auto-hide Labels while it is hiding) does not shift its neighbours
|
|
10411
|
+
+ '.aptx-panel .aptx-check { display:flex; align-items:center; gap:6px; cursor:pointer; min-width:0;'
|
|
10412
|
+
+ ' padding:1px 4px; margin:-1px -4px; border:1px solid transparent; border-radius:5px;'
|
|
10413
|
+
+ ' transition:border-color .15s,background .15s; }'
|
|
10414
|
+
+ '.aptx-panel .aptx-check.aptx-check-active { border-color:var(--p-accent); background:var(--p-accent-weak); }'
|
|
10415
|
+
+ '.aptx-panel .aptx-check.aptx-check-active > span { color:var(--p-accent-ink); font-weight:600; }'
|
|
8712
10416
|
+ '.aptx-panel .aptx-check > input { margin:0; flex:none; }'
|
|
8713
10417
|
+ '.aptx-panel .aptx-check > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }'
|
|
8714
10418
|
+ '.aptx-panel .aptx-checkgrid { display:grid; grid-template-columns:1fr 1fr; gap:3px 10px; }'
|
|
@@ -8725,7 +10429,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
8725
10429
|
+ '.aptx-panel .aptx-search-menus { display:flex; gap:4px; margin:2px 0 3px; }'
|
|
8726
10430
|
+ '.aptx-panel .aptx-search-menus select { flex:1 1 0; width:auto; min-width:0; height:24px; font:inherit; }'
|
|
8727
10431
|
// red cue for an unmatchable query (bad regex / non-number); box-shadow, since the value boxes carry an inline outline:none
|
|
8728
|
-
|
|
10432
|
+
// specific enough to beat the plain input rule (which used to keep
|
|
10433
|
+
// the border in the line colour, leaving only the ring)
|
|
10434
|
+
+ '.aptx-panel input[type=text].aptx-search-invalid { box-shadow:0 0 0 2px #e5484d; border-color:#e5484d; }'
|
|
8729
10435
|
+ '.aptx-panel .aptx-combine { display:flex; align-items:center; gap:8px; margin:6px 0 2px; }'
|
|
8730
10436
|
+ '.aptx-panel .aptx-combine .aptx-field-label { margin:0; flex:none; }'
|
|
8731
10437
|
+ '.aptx-panel .aptx-combine select { flex:1 1 auto; min-width:0; height:26px; font:inherit; }'
|
|
@@ -8797,6 +10503,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
8797
10503
|
+ '.aptx-panel .aptx-seg .aptx-glyph { height:13px; }'
|
|
8798
10504
|
+ '.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
10505
|
+ '.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); }'
|
|
10506
|
+
// an invalid regex or number stays red WHILE TYPING: without this
|
|
10507
|
+
// the focus rule above, more specific and later, painted the box in
|
|
10508
|
+
// the accent until the focus left it
|
|
10509
|
+
+ '.aptx-panel input[type=text].aptx-search-invalid:focus { border-color:#e5484d; box-shadow:0 0 0 2px #e5484d; }'
|
|
8800
10510
|
// --- collapsible sections, internal scroll, whole-panel hide ---
|
|
8801
10511
|
// the panel is a child of the tree's container now, so it can be held
|
|
8802
10512
|
// to that container's height rather than the whole viewport's
|
|
@@ -9375,7 +11085,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
9375
11085
|
[['Website', WEBSITE, 'cmzmasek.github.io/archaeopteryx-js'],
|
|
9376
11086
|
['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
|
|
9377
11087
|
['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
|
|
9378
|
-
['License', LICENSE_WEBSITE, LICENSE_NAME]
|
|
11088
|
+
['License', LICENSE_WEBSITE, LICENSE_NAME],
|
|
11089
|
+
['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']].forEach(function (row) {
|
|
9379
11090
|
let line = document.createElement('div');
|
|
9380
11091
|
line.className = 'aptx-dialog-line';
|
|
9381
11092
|
let key = document.createElement('span');
|
|
@@ -9384,9 +11095,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
9384
11095
|
let val = document.createElement('span');
|
|
9385
11096
|
val.className = 'aptx-dialog-val';
|
|
9386
11097
|
let a = document.createElement('a');
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
11098
|
+
if (row[1]) {
|
|
11099
|
+
a.href = row[1];
|
|
11100
|
+
a.target = '_blank';
|
|
11101
|
+
a.rel = 'noopener noreferrer';
|
|
11102
|
+
} else {
|
|
11103
|
+
// the shortcuts row opens the cheat sheet in place of the About box
|
|
11104
|
+
a.href = '#';
|
|
11105
|
+
a.addEventListener('click', function (e) {
|
|
11106
|
+
e.preventDefault();
|
|
11107
|
+
shell.dialog.close();
|
|
11108
|
+
showShortcutsDialog();
|
|
11109
|
+
});
|
|
11110
|
+
}
|
|
9390
11111
|
a.textContent = row[2];
|
|
9391
11112
|
val.appendChild(a);
|
|
9392
11113
|
line.appendChild(key);
|
|
@@ -9601,9 +11322,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
9601
11322
|
}
|
|
9602
11323
|
|
|
9603
11324
|
|
|
9604
|
-
on(SEARCH_FIELD_0, 'keyup',
|
|
11325
|
+
on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
|
|
9605
11326
|
|
|
9606
|
-
on(SEARCH_FIELD_1, 'keyup',
|
|
11327
|
+
on(SEARCH_FIELD_1, 'keyup', searchValueKeyup);
|
|
11328
|
+
wireSuggestions(0);
|
|
11329
|
+
wireSuggestions(1);
|
|
11330
|
+
// the list is placed in page coordinates: a scroll or resize moves the box out from under it
|
|
11331
|
+
window.addEventListener('resize', closeSuggestions);
|
|
11332
|
+
document.addEventListener('scroll', closeSuggestions, true);
|
|
9607
11333
|
|
|
9608
11334
|
on(SEARCH_VALUE2_0, 'keyup', search0);
|
|
9609
11335
|
|
|
@@ -9659,6 +11385,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
9659
11385
|
on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
|
|
9660
11386
|
on(MSA_CB, 'click', msaCbClicked);
|
|
9661
11387
|
on(DOMAINS_CB, 'click', domainsCbClicked);
|
|
11388
|
+
on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
|
|
11389
|
+
on(TREE_PREV_BUTTON, 'click', function () {
|
|
11390
|
+
showTree(_treeIndex - 1);
|
|
11391
|
+
});
|
|
11392
|
+
on(TREE_NEXT_BUTTON, 'click', function () {
|
|
11393
|
+
showTree(_treeIndex + 1);
|
|
11394
|
+
});
|
|
11395
|
+
on(TREE_SELECT, 'change', function () {
|
|
11396
|
+
showTree(parseInt(this.value, 10));
|
|
11397
|
+
});
|
|
9662
11398
|
onHoldRepeat(DOMAIN_WIDTH_DEC, function () { domainWidthStep(false); });
|
|
9663
11399
|
onHoldRepeat(DOMAIN_WIDTH_INC, function () { domainWidthStep(true); });
|
|
9664
11400
|
on(DOMAIN_EVALUE_DEC, 'click', function () { domainEvalueStep(-1); });
|
|
@@ -9827,12 +11563,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
9827
11563
|
_docListenersBound = true;
|
|
9828
11564
|
|
|
9829
11565
|
bindDoc('keyup', function (e) {
|
|
9830
|
-
// The
|
|
9831
|
-
//
|
|
9832
|
-
//
|
|
9833
|
-
//
|
|
9834
|
-
//
|
|
9835
|
-
// with it).
|
|
11566
|
+
// The plain keys: Esc/Home reset, O cycles the overview corner,
|
|
11567
|
+
// PageUp/PageDown size the font, and the wheel zooms. The old
|
|
11568
|
+
// Alt+letter combos were near-undiscoverable (macOS labels the
|
|
11569
|
+
// key Option and types glyphs with it); the Ctrl / Cmd combos
|
|
11570
|
+
// that replaced them in 2026-09 live in SHORTCUTS, on keydown.
|
|
9836
11571
|
if (isTypingTarget(e.target)) {
|
|
9837
11572
|
return; // nothing fires from inside a text box
|
|
9838
11573
|
}
|
|
@@ -9849,7 +11584,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
9849
11584
|
});
|
|
9850
11585
|
|
|
9851
11586
|
bindDoc('keydown', function (e) {
|
|
9852
|
-
if (
|
|
11587
|
+
if (e.defaultPrevented) {
|
|
11588
|
+
return;
|
|
11589
|
+
}
|
|
11590
|
+
shortcutKeyDown(e); // the Ctrl / Cmd combos (SHORTCUTS)
|
|
11591
|
+
if (e.defaultPrevented || isTypingTarget(e.target)) {
|
|
9853
11592
|
return;
|
|
9854
11593
|
}
|
|
9855
11594
|
if (e.keyCode === VK_PAGE_UP) {
|
|
@@ -9938,13 +11677,23 @@ function (root, d3, forester, phyloXml) {
|
|
|
9938
11677
|
// so tree-file text needs no escaping here. Both can be very long, so CSS
|
|
9939
11678
|
// clamps each to a few lines; the caller (enableTreeDescExpand) adds the
|
|
9940
11679
|
// click/keyboard expand affordance only when the text actually overflows.
|
|
11680
|
+
// A launch with several trees puts the tree picker in the same
|
|
11681
|
+
// fieldset, above the description; the picker shows the name, so
|
|
11682
|
+
// the name line is left out then.
|
|
9941
11683
|
function makeTreeDesc() {
|
|
9942
|
-
let
|
|
11684
|
+
let several = _trees.length > 1;
|
|
11685
|
+
let name = (_treeData.name && !several) ? String(_treeData.name).trim() : '';
|
|
9943
11686
|
let desc = _treeData.description ? String(_treeData.description).trim() : '';
|
|
9944
|
-
if (!name && !desc) {
|
|
11687
|
+
if (!name && !desc && !several) {
|
|
9945
11688
|
return null;
|
|
9946
11689
|
}
|
|
9947
11690
|
let fieldset = document.createElement('fieldset');
|
|
11691
|
+
if (several) {
|
|
11692
|
+
fieldset.appendChild(makeTreePicker());
|
|
11693
|
+
}
|
|
11694
|
+
if (!name && !desc) {
|
|
11695
|
+
return fieldset;
|
|
11696
|
+
}
|
|
9948
11697
|
let block = document.createElement('div');
|
|
9949
11698
|
block.className = TREE_DESC;
|
|
9950
11699
|
let tooltip = '';
|
|
@@ -9967,6 +11716,35 @@ function (root, d3, forester, phyloXml) {
|
|
|
9967
11716
|
return fieldset;
|
|
9968
11717
|
}
|
|
9969
11718
|
|
|
11719
|
+
// A file with several trees: previous / picker / next in one row,
|
|
11720
|
+
// the picker listing every tree by its name (or its number). Each
|
|
11721
|
+
// opens fresh in the same container (showTree). The end buttons are
|
|
11722
|
+
// disabled at the ends: the panel is rebuilt per tree, so the
|
|
11723
|
+
// state set here is always current.
|
|
11724
|
+
function makeTreePicker() {
|
|
11725
|
+
let n = _trees.length;
|
|
11726
|
+
let row = document.createElement('div');
|
|
11727
|
+
row.className = 'aptx-tree-picker';
|
|
11728
|
+
row.insertAdjacentHTML('beforeend', makeGlyphButton('tree_prev', TREE_PREV_BUTTON, 'previous tree'));
|
|
11729
|
+
let select = document.createElement('select');
|
|
11730
|
+
select.id = TREE_SELECT;
|
|
11731
|
+
select.name = TREE_SELECT;
|
|
11732
|
+
select.title = 'Tree ' + (_treeIndex + 1) + ' of ' + n + ' -- pick another';
|
|
11733
|
+
for (let i = 0; i < n; ++i) {
|
|
11734
|
+
let opt = document.createElement('option');
|
|
11735
|
+
opt.value = String(i);
|
|
11736
|
+
let treeName = _trees[i].name ? String(_trees[i].name).trim() : '';
|
|
11737
|
+
opt.textContent = treeName || ('Tree ' + (i + 1));
|
|
11738
|
+
opt.selected = (i === _treeIndex);
|
|
11739
|
+
select.appendChild(opt);
|
|
11740
|
+
}
|
|
11741
|
+
row.appendChild(select);
|
|
11742
|
+
row.insertAdjacentHTML('beforeend', makeGlyphButton('tree_next', TREE_NEXT_BUTTON, 'next tree'));
|
|
11743
|
+
row.querySelector('#' + TREE_PREV_BUTTON).disabled = (_treeIndex === 0);
|
|
11744
|
+
row.querySelector('#' + TREE_NEXT_BUTTON).disabled = (_treeIndex === n - 1);
|
|
11745
|
+
return row;
|
|
11746
|
+
}
|
|
11747
|
+
|
|
9970
11748
|
// Make the tree name/description block a keyboard-accessible
|
|
9971
11749
|
// expand/collapse control, but only while its text is actually clamped
|
|
9972
11750
|
// (otherwise the pointer cursor + click would be a misleading no-op).
|
|
@@ -10176,6 +11954,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
10176
11954
|
h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
|
|
10177
11955
|
h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
|
|
10178
11956
|
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)'));
|
|
11957
|
+
h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
|
|
10179
11958
|
h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 'midpoint re-root'));
|
|
10180
11959
|
h = h.concat('</div>');
|
|
10181
11960
|
h = h.concat('</fieldset>');
|
|
@@ -10296,7 +12075,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
10296
12075
|
let val2 = idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1;
|
|
10297
12076
|
let reset = idx === 0 ? RESET_SEARCH_A_BTN : RESET_SEARCH_B_BTN;
|
|
10298
12077
|
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
12078
|
let h = "";
|
|
10301
12079
|
h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
|
|
10302
12080
|
h = h.concat('<div class="aptx-search-menus">');
|
|
@@ -10308,7 +12086,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
10308
12086
|
h = h.concat('<input class="aptx-search-value2" style="display:none" title="upper bound of the range" type="text" name="' + val2 + '" id="' + val2 + '">');
|
|
10309
12087
|
h = h.concat(makeButton('R', reset, resetTip));
|
|
10310
12088
|
h = h.concat('</div>');
|
|
10311
|
-
h = h.concat('<datalist id="' + dl + '"></datalist>');
|
|
10312
12089
|
return h;
|
|
10313
12090
|
}
|
|
10314
12091
|
|
|
@@ -10493,11 +12270,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
10493
12270
|
// definite result -- with it on, ladderizing a tree that is already
|
|
10494
12271
|
// ladderized REVERSES it, so the outcome depends on the order the tree
|
|
10495
12272
|
// happened to arrive in.
|
|
12273
|
+
// Returns the direction actually applied (largest first or not): when
|
|
12274
|
+
// the tree already stood that way and alternating was asked for, the
|
|
12275
|
+
// other one.
|
|
10496
12276
|
function ladderizeSubtree(n, largestFirst, alternateIfUnchanged) {
|
|
10497
12277
|
let changed = forester.ladderize(n, largestFirst);
|
|
10498
12278
|
if (alternateIfUnchanged && !changed) {
|
|
10499
12279
|
forester.ladderize(n, !largestFirst);
|
|
12280
|
+
return !largestFirst;
|
|
10500
12281
|
}
|
|
12282
|
+
return largestFirst;
|
|
10501
12283
|
}
|
|
10502
12284
|
|
|
10503
12285
|
function setDisplayTypeButtons() {
|
|
@@ -10516,6 +12298,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
10516
12298
|
|
|
10517
12299
|
function updateButtonEnabledState() {
|
|
10518
12300
|
syncZoomRowButtons(); // layout- and tree-dependent disables track every render
|
|
12301
|
+
// the uncollapse-all button lives only while something is collapsed, as on the desktop
|
|
12302
|
+
((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
|
|
10519
12303
|
if (_in_subtree) {
|
|
10520
12304
|
enableButton(byId(RETURN_TO_SUPERTREE_BUTTON_BY_ONE));
|
|
10521
12305
|
enableButton(byId(RETURN_TO_SUPERTREE_BUTTON));
|
|
@@ -10888,8 +12672,65 @@ function (root, d3, forester, phyloXml) {
|
|
|
10888
12672
|
// Convenience methods for loading tree on HTML page
|
|
10889
12673
|
// --------------------------------------------------------------
|
|
10890
12674
|
|
|
12675
|
+
// The mode as parseTree / parseTrees accept it. The retired brackets
|
|
12676
|
+
// flag used to be the third argument, so a boolean there means the old
|
|
12677
|
+
// four-argument shape and the mode is the fourth; a string is the mode
|
|
12678
|
+
// itself. PARSE_DEFAULTS, not literals -- initializeSettings applies the
|
|
12679
|
+
// same object, and it runs at launch(), AFTER this has already parsed.
|
|
12680
|
+
function resolveInternalLabelsMode(mode, legacyMode) {
|
|
12681
|
+
let m = (typeof mode === 'boolean' || mode === undefined) ? legacyMode : mode;
|
|
12682
|
+
if (typeof m === 'boolean') {
|
|
12683
|
+
// the retired positional flag: true meant "every numeric internal
|
|
12684
|
+
// label is a confidence", which is 'confidence'.
|
|
12685
|
+
return m ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
|
|
12686
|
+
}
|
|
12687
|
+
return m === undefined ? PARSE_DEFAULTS.internalNumericLabels : m;
|
|
12688
|
+
}
|
|
12689
|
+
|
|
12690
|
+
// Every tree the data holds, the format picked from the content and the
|
|
12691
|
+
// location: a Nexus TREES block, a New Hampshire text with one tree per
|
|
12692
|
+
// ';', a phyloXML with several phylogenies; an Auspice dataset is one
|
|
12693
|
+
// tree. Bare numeric internal labels are promoted on every tree.
|
|
12694
|
+
function parseAllTrees(location, data, mode) {
|
|
12695
|
+
requireForester();
|
|
12696
|
+
let loc = location ? String(location).toLowerCase() : '';
|
|
12697
|
+
let text = forester.isString(data) ? data : '';
|
|
12698
|
+
let trees;
|
|
12699
|
+
// Nexus announces itself with "#NEXUS" on the first line, so the
|
|
12700
|
+
// content decides; the filename alone is enough too.
|
|
12701
|
+
if (/^\s*#nexus\b/i.test(text) || /\.(nex|nexus)$/.test(loc)) {
|
|
12702
|
+
trees = forester.parseNexus(data, true, false);
|
|
12703
|
+
if (trees.length === 0) {
|
|
12704
|
+
throw new Error('no tree found in the Nexus data');
|
|
12705
|
+
}
|
|
12706
|
+
trees.forEach(function (t) {
|
|
12707
|
+
promoteInternalLabels(t, mode);
|
|
12708
|
+
});
|
|
12709
|
+
} else if (/^\s*\{/.test(text) || /\.json$/.test(loc)) {
|
|
12710
|
+
trees = [archaeopteryx.parseAuspiceJson(data)];
|
|
12711
|
+
} else if (/^\s*</.test(text) || loc.substr(-3, 3) === 'xml') {
|
|
12712
|
+
// No other supported format can start with '<', so content alone
|
|
12713
|
+
// is enough -- pasted phyloXML has no filename to go by.
|
|
12714
|
+
requirePhyloXml();
|
|
12715
|
+
trees = phyloXml.parse(data, {trim: true, normalize: true});
|
|
12716
|
+
if (trees.length === 0) {
|
|
12717
|
+
throw new Error('no phylogeny found in the phyloXML data');
|
|
12718
|
+
}
|
|
12719
|
+
trees.forEach(function (t) {
|
|
12720
|
+
forester.addParents(t);
|
|
12721
|
+
});
|
|
12722
|
+
} else {
|
|
12723
|
+
trees = forester.parseNewHampshireTrees(data, true, false);
|
|
12724
|
+
trees.forEach(function (t) {
|
|
12725
|
+
promoteInternalLabels(t, mode);
|
|
12726
|
+
});
|
|
12727
|
+
}
|
|
12728
|
+
return trees;
|
|
12729
|
+
}
|
|
12730
|
+
|
|
10891
12731
|
/**
|
|
10892
|
-
*
|
|
12732
|
+
* Parses tree data for an HTML page: the FIRST tree the data holds
|
|
12733
|
+
* (parseTrees returns them all).
|
|
10893
12734
|
*
|
|
10894
12735
|
* @param location - file name (only its extension is used; the content is
|
|
10895
12736
|
* sniffed too, so a pasted tree with no name works)
|
|
@@ -10900,41 +12741,21 @@ function (root, d3, forester, phyloXml) {
|
|
|
10900
12741
|
* position and is still accepted (and ignored) there,
|
|
10901
12742
|
* with the mode following it.
|
|
10902
12743
|
* @param legacyMode - the mode, when the retired flag occupies `mode`
|
|
10903
|
-
* @returns {*}
|
|
12744
|
+
* @returns {*} the first tree
|
|
10904
12745
|
*/
|
|
10905
12746
|
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;
|
|
12747
|
+
return parseAllTrees(location, data, resolveInternalLabelsMode(mode, legacyMode))[0];
|
|
12748
|
+
};
|
|
12749
|
+
|
|
12750
|
+
/**
|
|
12751
|
+
* Every tree the data holds, in file order, as an array: a Nexus TREES
|
|
12752
|
+
* block, a New Hampshire text with several ';'-terminated trees, a
|
|
12753
|
+
* phyloXML with several phylogenies (an Auspice dataset is one tree).
|
|
12754
|
+
* launch() takes the array and shows the first, with a picker in the
|
|
12755
|
+
* control panel for the others. Same arguments as parseTree.
|
|
12756
|
+
*/
|
|
12757
|
+
archaeopteryx.parseTrees = function (location, data, mode, legacyMode) {
|
|
12758
|
+
return parseAllTrees(location, data, resolveInternalLabelsMode(mode, legacyMode));
|
|
10938
12759
|
};
|
|
10939
12760
|
|
|
10940
12761
|
// Show the viewer's own "working" card in a container. For the part of a
|
|
@@ -11007,9 +12828,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
11007
12828
|
+ ' "internalNumericLabels".');
|
|
11008
12829
|
}
|
|
11009
12830
|
let c = config || {};
|
|
11010
|
-
let
|
|
12831
|
+
let trees;
|
|
11011
12832
|
try {
|
|
11012
|
-
tree
|
|
12833
|
+
// every tree the file holds: launch() shows the first and the
|
|
12834
|
+
// panel's picker offers the rest
|
|
12835
|
+
trees = archaeopteryx.parseTrees(fileName, data,
|
|
11013
12836
|
effectiveInternalLabelsMode(c));
|
|
11014
12837
|
} catch (e) {
|
|
11015
12838
|
// Worth catching only to say that it was the parse, not the launch,
|
|
@@ -11023,7 +12846,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
11023
12846
|
// launch() already reports its own failures well enough; wrapping them
|
|
11024
12847
|
// added nothing but a prefix, and swallowing them left the caller with
|
|
11025
12848
|
// a blank page and no way to find out why.
|
|
11026
|
-
return archaeopteryx.launch(container,
|
|
12849
|
+
return archaeopteryx.launch(container, trees, config);
|
|
11027
12850
|
};
|
|
11028
12851
|
|
|
11029
12852
|
|