archaeopteryx 3.13.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -8
- package/archaeopteryx.js +746 -25
- package/forester.js +415 -3
- package/package.json +1 -1
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.15.0
|
|
24
24
|
// 2026-09-17
|
|
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.15.0';
|
|
107
107
|
const WEBSITE = 'https://cmzmasek.github.io/archaeopteryx-js/';
|
|
108
108
|
const DESKTOP_WEBSITE = 'https://cmzmasek.github.io/archaeopteryx/';
|
|
109
109
|
const SOURCE_WEBSITE = 'https://github.com/cmzmasek/archaeopteryx-js';
|
|
@@ -414,6 +414,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
414
414
|
const BRANCH_SCALE_TIME_BUTTON = 'branch_scale_time_b';
|
|
415
415
|
const BRANCH_SCALE_DIV_BUTTON = 'branch_scale_div_b';
|
|
416
416
|
const ABOUT_DIALOG = 'aptx_about';
|
|
417
|
+
const TREE_PROPERTIES_DIALOG = 'aptx_tree_props';
|
|
417
418
|
const PROG_NAME = 'progname';
|
|
418
419
|
const PROGNAMELINK = 'prognamelink';
|
|
419
420
|
const TREE_DESC = 'tree_desc';
|
|
@@ -1480,6 +1481,26 @@ function (root, d3, forester, phyloXml) {
|
|
|
1480
1481
|
// so the guess was what the reader saw every time.
|
|
1481
1482
|
const HOVER_READOUT_GAP = 14;
|
|
1482
1483
|
|
|
1484
|
+
// One way to open an external page. There were three: window.open + a
|
|
1485
|
+
// win.focus() that throws when a popup blocker hands back null and which
|
|
1486
|
+
// passed no noopener, an <a target="_blank" rel="noopener noreferrer">,
|
|
1487
|
+
// and a third for the domain link. Three security postures for one job,
|
|
1488
|
+
// and a change to the policy would have had to find all of them.
|
|
1489
|
+
//
|
|
1490
|
+
// noopener: the opened page must not get a handle on this window.
|
|
1491
|
+
// noreferrer with it, since a tree can come from anywhere and its URL is
|
|
1492
|
+
// not the external site's business.
|
|
1493
|
+
function openExternal(url) {
|
|
1494
|
+
if (!url) {
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
let win = window.open(url, '_blank', 'noopener,noreferrer');
|
|
1498
|
+
if (win) {
|
|
1499
|
+
win.focus(); // a blocked popup hands back null; that is not an error
|
|
1500
|
+
}
|
|
1501
|
+
return win;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1483
1504
|
function placeHoverReadout(el, event) {
|
|
1484
1505
|
let w = el.offsetWidth;
|
|
1485
1506
|
let h = el.offsetHeight;
|
|
@@ -2318,12 +2339,23 @@ function (root, d3, forester, phyloXml) {
|
|
|
2318
2339
|
|
|
2319
2340
|
let _legendMeasureCtx = null;
|
|
2320
2341
|
|
|
2321
|
-
|
|
2342
|
+
// One lazily made canvas for every text measurement: the legend's widths,
|
|
2343
|
+
// the branch-data line metrics and the sequence logo's glyph boxes each
|
|
2344
|
+
// used to make their own.
|
|
2345
|
+
function measureCtx() {
|
|
2322
2346
|
if (!_legendMeasureCtx) {
|
|
2323
2347
|
_legendMeasureCtx = document.createElement('canvas').getContext('2d');
|
|
2324
2348
|
}
|
|
2325
|
-
_legendMeasureCtx
|
|
2326
|
-
|
|
2349
|
+
return _legendMeasureCtx;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
function legendTextWidth(text, font) {
|
|
2353
|
+
let ctx = measureCtx();
|
|
2354
|
+
if (!ctx) {
|
|
2355
|
+
return 0;
|
|
2356
|
+
}
|
|
2357
|
+
ctx.font = font;
|
|
2358
|
+
return ctx.measureText(text).width;
|
|
2327
2359
|
}
|
|
2328
2360
|
|
|
2329
2361
|
// The card's row order and cap, the desktop's way: rank by count first
|
|
@@ -3177,7 +3209,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
3177
3209
|
.attr('transform', function (d) {
|
|
3178
3210
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3179
3211
|
})
|
|
3180
|
-
|
|
3212
|
+
// Every one of these three reads its offset from branchMarkPlacement,
|
|
3213
|
+
// the same function the occupancy boxes are built from: a box that is
|
|
3214
|
+
// not where the text is hides the wrong marks, and two copies of the
|
|
3215
|
+
// same number drift apart the first time one of them is edited.
|
|
3216
|
+
.attr('dy', branchMarkPlacement('bl', _state.branchDataFontSize).dy)
|
|
3181
3217
|
.attr('x', function (d) {
|
|
3182
3218
|
if (radialDisplay()) {
|
|
3183
3219
|
return 0;
|
|
@@ -3198,7 +3234,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3198
3234
|
.attr('transform', function (d) {
|
|
3199
3235
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3200
3236
|
})
|
|
3201
|
-
.attr('dy', _state.branchDataFontSize)
|
|
3237
|
+
.attr('dy', branchMarkPlacement('conf', _state.branchDataFontSize).dy)
|
|
3202
3238
|
.attr('x', function (d) {
|
|
3203
3239
|
if (radialDisplay()) {
|
|
3204
3240
|
return 0;
|
|
@@ -3234,7 +3270,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3234
3270
|
.attr('transform', function (d) {
|
|
3235
3271
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3236
3272
|
})
|
|
3237
|
-
.attr('dy', '
|
|
3273
|
+
.attr('dy', branchMarkPlacement('event', _state.branchDataFontSize).dy)
|
|
3238
3274
|
.attr('x', function (d) {
|
|
3239
3275
|
if (radialDisplay()) {
|
|
3240
3276
|
return 0;
|
|
@@ -3819,6 +3855,169 @@ function (root, d3, forester, phyloXml) {
|
|
|
3819
3855
|
// a support dot. Returns the nodes that need a <g> -- those with any of
|
|
3820
3856
|
// them, and every collapsed clade (its wedge and label). The rest are
|
|
3821
3857
|
// drawn entirely by drawTreeGeometry's batched paths.
|
|
3858
|
+
// ===================== Crowded branch data =====================
|
|
3859
|
+
// A branch-anchored mark is drawn only when its box does not overlap one
|
|
3860
|
+
// already granted in the same pass. Ported from the desktop's
|
|
3861
|
+
// LabelOccupancy (0.11.161) as a JOINT rule on Christian's word,
|
|
3862
|
+
// 2026-09-25: "match the desktop's new auto-hide rule for crowded branch
|
|
3863
|
+
// data". Neither side retunes it alone.
|
|
3864
|
+
//
|
|
3865
|
+
// It exists because the obvious rule does not work. The desktop built
|
|
3866
|
+
// "hide when the branch is drawn shorter than the text" first and measured
|
|
3867
|
+
// it: on an 825-tip tree a third-of-text threshold hid 285 numbers of
|
|
3868
|
+
// which only 51 collided, and still left 22 pile-ups. A number is
|
|
3869
|
+
// unreadable when it overlaps ANOTHER number, which depends on closeness
|
|
3870
|
+
// in y as much as in x, and branch length only sees x. Measured here
|
|
3871
|
+
// before adopting it: with Auto-hide Labels already ON, 240 of 267 drawn
|
|
3872
|
+
// support numbers overlapped another on flu_h5.xml (90%), 149 of 262 on
|
|
3873
|
+
// Adenoviridae, 13 of 119 on bcl2 -- our auto-hide decimates TIP labels
|
|
3874
|
+
// and had never touched branch data.
|
|
3875
|
+
//
|
|
3876
|
+
// WHAT MUST MATCH, or the two viewers drop different marks:
|
|
3877
|
+
// - the tie-break: PREORDER, first claim wins, so the mark nearer the
|
|
3878
|
+
// root keeps its place. d3's descendants() is preorder already.
|
|
3879
|
+
// - TWO maps, numbers and symbols. The symbol sits ON the branch and the
|
|
3880
|
+
// numbers just above and below it, so one shared map would let a
|
|
3881
|
+
// branch's own symbol block its own numbers.
|
|
3882
|
+
// - a refused claim records NOTHING, so a rejected mark never blocks a
|
|
3883
|
+
// later one.
|
|
3884
|
+
// - a zero-size box is granted and not recorded.
|
|
3885
|
+
// - symbols are never shrunk to fit: in a size-scaled mode the diameter
|
|
3886
|
+
// IS the support, and a smaller dot would report weaker support.
|
|
3887
|
+
//
|
|
3888
|
+
// WHAT CANNOT MATCH, and is not meant to: the COUNTS. The boxes come from
|
|
3889
|
+
// each program's own font metrics, so the same tree at the same size drops
|
|
3890
|
+
// a similar but not identical set. The rule is the contract; the tally is
|
|
3891
|
+
// not.
|
|
3892
|
+
//
|
|
3893
|
+
// Where a branch-anchored mark sits, in the tree group's coordinates.
|
|
3894
|
+
// Mirrors the placement in syncOptionalNodeChildren exactly -- the branch
|
|
3895
|
+
// length back toward the parent and above the branch, the confidence at
|
|
3896
|
+
// the branch midpoint and below it -- because a box that is not where the
|
|
3897
|
+
// text is would hide the wrong marks.
|
|
3898
|
+
// The font's LINE box, which is what the marks are laid out with and what
|
|
3899
|
+
// the desktop measures too (FontMetrics.getHeight()). Not the per-glyph
|
|
3900
|
+
// ink box: two numbers collide when their lines overlap, and ink boxes
|
|
3901
|
+
// would let "1" slide under "0.81".
|
|
3902
|
+
//
|
|
3903
|
+
// Measured from the font rather than assumed. The fallback is what this
|
|
3904
|
+
// font actually reports at 9px -- ascent 9, descent 2, line 11 -- read off
|
|
3905
|
+
// a rendered label's getBBox rather than guessed.
|
|
3906
|
+
let _branchFontCache = null;
|
|
3907
|
+
|
|
3908
|
+
function branchFontMetrics(fontPx) {
|
|
3909
|
+
if (_branchFontCache && _branchFontCache.px === fontPx) {
|
|
3910
|
+
return _branchFontCache;
|
|
3911
|
+
}
|
|
3912
|
+
let ctx = measureCtx();
|
|
3913
|
+
ctx.font = fontPx + 'px ' + FONT_DEFAULTS;
|
|
3914
|
+
let m = ctx.measureText('0');
|
|
3915
|
+
let ascent = m.fontBoundingBoxAscent;
|
|
3916
|
+
let descent = m.fontBoundingBoxDescent;
|
|
3917
|
+
// Both, separately: a canvas that reports an ascent and no descent
|
|
3918
|
+
// would give height = ascent + undefined = NaN, every box would be
|
|
3919
|
+
// refused a claim as zero-sized, and the feature would turn itself off
|
|
3920
|
+
// with no error and a green suite.
|
|
3921
|
+
if (!(ascent > 0)) {
|
|
3922
|
+
ascent = fontPx;
|
|
3923
|
+
}
|
|
3924
|
+
if (!(descent >= 0)) {
|
|
3925
|
+
descent = fontPx * (2 / 9);
|
|
3926
|
+
}
|
|
3927
|
+
_branchFontCache = {px: fontPx, ascent: ascent, descent: descent,
|
|
3928
|
+
height: ascent + descent};
|
|
3929
|
+
return _branchFontCache;
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
// Where a branch mark sits on its branch, read off the rendered elements
|
|
3933
|
+
// rather than inferred: the horizontal anchor and the `dy` the element is
|
|
3934
|
+
// drawn with. Getting either wrong is not a rounding error. A
|
|
3935
|
+
// middle-anchored box modelled as left-anchored is half its own width out,
|
|
3936
|
+
// which once let 44% of the granted numbers still overlap; and a branch
|
|
3937
|
+
// EVENT sits a quarter em ABOVE the line like the branch length, not a
|
|
3938
|
+
// font size below it like the confidence, so modelling it as a confidence
|
|
3939
|
+
// put its box on the wrong side of the branch -- the one side it could
|
|
3940
|
+
// never collide on.
|
|
3941
|
+
function branchMarkPlacement(kind, fontPx) {
|
|
3942
|
+
if (kind === 'conf') {
|
|
3943
|
+
return {mid: true, dy: fontPx};
|
|
3944
|
+
}
|
|
3945
|
+
if (kind === 'event') {
|
|
3946
|
+
// A radial layout draws every mark on ONE point, the branch's
|
|
3947
|
+
// midpoint, so a branch event and a branch length -- both a quarter
|
|
3948
|
+
// em above the line -- printed straight through each other. The
|
|
3949
|
+
// event is lifted a whole line clear of the length. The rectangular
|
|
3950
|
+
// layout already separates them along the branch (the length at the
|
|
3951
|
+
// parent end, the event at the middle), so there they share a line.
|
|
3952
|
+
return {mid: true, dy: radialDisplay()
|
|
3953
|
+
? ((-0.25 * fontPx) - branchFontMetrics(fontPx).height)
|
|
3954
|
+
: (-0.25 * fontPx)};
|
|
3955
|
+
}
|
|
3956
|
+
return {mid: false, dy: -0.25 * fontPx}; // 'bl': start-anchored, a quarter em up
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
// The incoming branch's MIDPOINT and screen direction, in the tree group's
|
|
3960
|
+
// own coordinates -- what a radial mark is actually drawn on, since
|
|
3961
|
+
// branchLabelTransform rotates to the spoke and translates half a branch
|
|
3962
|
+
// back along it. Circular: the leg runs along ONE spoke, so the midpoint is
|
|
3963
|
+
// the mean of the two radii at the same angle (an arc's chord is not on the
|
|
3964
|
+
// leg). Unrooted: the branch is a straight line, so it is the mean of the
|
|
3965
|
+
// two points. Both match the desktop's two call sites of
|
|
3966
|
+
// paintBranchDataRadial.
|
|
3967
|
+
function radialBranchMid(d) {
|
|
3968
|
+
let angle = spokeAngle(d);
|
|
3969
|
+
if (_state.unrootedDisplay) {
|
|
3970
|
+
if (d.ux === undefined) {
|
|
3971
|
+
return null;
|
|
3972
|
+
}
|
|
3973
|
+
let p = (d.parent && d.parent.ux !== undefined) ? d.parent : null;
|
|
3974
|
+
return p ? [0.5 * (d.ux + p.ux), 0.5 * (d.uy + p.uy), angle] : [d.ux, d.uy, angle];
|
|
3975
|
+
}
|
|
3976
|
+
let r = radialRadius(d.y);
|
|
3977
|
+
if (d.parent) {
|
|
3978
|
+
r = 0.5 * (r + radialRadius(d.parent.y));
|
|
3979
|
+
}
|
|
3980
|
+
let pt = polarXY(radialAngle(d.x), r);
|
|
3981
|
+
return [pt[0], pt[1], angle];
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
function branchMarkBox(d, kind, text, fontPx) {
|
|
3985
|
+
let w = legendTextWidth(text, fontPx + 'px ' + FONT_DEFAULTS);
|
|
3986
|
+
let fm = branchFontMetrics(fontPx);
|
|
3987
|
+
let h = fm.height;
|
|
3988
|
+
let at = branchMarkPlacement(kind, fontPx);
|
|
3989
|
+
if (radialDisplay()) {
|
|
3990
|
+
// The mark rides the branch, so its box is the AXIS-ALIGNED bounds
|
|
3991
|
+
// of the rotated rectangle, centred where the text's own centre
|
|
3992
|
+
// lands: forester.rotatedLabelBox, which carries the desktop's
|
|
3993
|
+
// formula and the reasons it is the conservative one.
|
|
3994
|
+
let mid = radialBranchMid(d);
|
|
3995
|
+
if (!mid) {
|
|
3996
|
+
return null;
|
|
3997
|
+
}
|
|
3998
|
+
// `dy` shifts the BASELINE, so the box's centre sits half a line
|
|
3999
|
+
// below it; and the 180-degree flip that keeps the far half of the
|
|
4000
|
+
// fan upright carries this perpendicular offset with it.
|
|
4001
|
+
let off = at.dy + (0.5 * (fm.descent - fm.ascent));
|
|
4002
|
+
if (labelFlip(d)) {
|
|
4003
|
+
off = -off;
|
|
4004
|
+
}
|
|
4005
|
+
// every radial mark is middle-anchored at x=0, so only the
|
|
4006
|
+
// perpendicular offset moves its centre off the branch midpoint
|
|
4007
|
+
return forester.rotatedLabelBox(mid[0] - (Math.sin(mid[2]) * off),
|
|
4008
|
+
mid[1] + (Math.cos(mid[2]) * off), w, h, mid[2]);
|
|
4009
|
+
}
|
|
4010
|
+
// The ROOT's numbers are drawn too, at x=0 in its own group, and they
|
|
4011
|
+
// used to claim nothing -- so the first child's number, landing in
|
|
4012
|
+
// almost the same place, was granted and painted over them. The one
|
|
4013
|
+
// place the pass could not keep its promise.
|
|
4014
|
+
let span = d.parent ? (d.parent.y - d.y) : 0;
|
|
4015
|
+
let x = at.mid
|
|
4016
|
+
? (d.y + (0.5 * span) - (w / 2)) // middle-anchored
|
|
4017
|
+
: (d.y + span + 1); // start-anchored
|
|
4018
|
+
return [x, (d.x + at.dy) - fm.ascent, w, h];
|
|
4019
|
+
}
|
|
4020
|
+
|
|
3822
4021
|
function prepareNodeDrawing(nodes) {
|
|
3823
4022
|
let wantBl = _state.showBranchLengthValues === true;
|
|
3824
4023
|
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
@@ -3840,6 +4039,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
3840
4039
|
d._confText = wantConf ? asText(makeConfidenceValuesLabel(d)) : '';
|
|
3841
4040
|
d._eventText = wantEvent ? asText(makeBranchEventsLabel(d)) : '';
|
|
3842
4041
|
d._suppDot = wantDot && showSupportDot(d);
|
|
4042
|
+
}
|
|
4043
|
+
// Before `drawn` is built, not after: a refused mark used to leave a
|
|
4044
|
+
// childless <g class="node"> behind, and those are transformed on
|
|
4045
|
+
// every animation frame. On the big tree that was ~18,000 empty groups
|
|
4046
|
+
// per redraw, defeating the very thing the `drawn` filter is for.
|
|
4047
|
+
hideCrowdedBranchData(nodes);
|
|
4048
|
+
for (let i = 0, len = nodes.length; i !== len; ++i) {
|
|
4049
|
+
let d = nodes[i];
|
|
3843
4050
|
if (d._extLabelText !== '' || d._blText !== '' || d._confText !== ''
|
|
3844
4051
|
|| d._eventText !== '' || d._suppDot || isCollapsed(d)) {
|
|
3845
4052
|
drawn.push(d);
|
|
@@ -3848,6 +4055,88 @@ function (root, d3, forester, phyloXml) {
|
|
|
3848
4055
|
return drawn;
|
|
3849
4056
|
}
|
|
3850
4057
|
|
|
4058
|
+
// The occupancy pass, in PREORDER -- so the mark nearer the root claims
|
|
4059
|
+
// its space first and keeps it. NOT the order the layout hands out: d3's
|
|
4060
|
+
// descendants() is breadth-first and the viewer reverses it for drawing,
|
|
4061
|
+
// which puts the root LAST and inverts the entire tie-break. That shipped,
|
|
4062
|
+
// and a review caught it; forester.preorderOf derives the right order from
|
|
4063
|
+
// the nodes themselves.
|
|
4064
|
+
//
|
|
4065
|
+
// Runs before anything is drawn, so the decision cannot depend on the
|
|
4066
|
+
// order the DOM happens to be updated in.
|
|
4067
|
+
//
|
|
4068
|
+
// Rides the existing Auto-hide Labels toggle: switch it off and everything
|
|
4069
|
+
// is drawn. Runs in ALL THREE layouts, as the desktop does -- the boxes are
|
|
4070
|
+
// built in the tree group's own coordinates, which is where every mark is
|
|
4071
|
+
// actually drawn, so the circular and unrooted views are measured in their
|
|
4072
|
+
// own space and not in the cluster's. (The tip-label decimation that shares
|
|
4073
|
+
// the toggle is still skipped in unrooted: that one needs even row spacing
|
|
4074
|
+
// to decimate against, and there is none.)
|
|
4075
|
+
function hideCrowdedBranchData(nodes) {
|
|
4076
|
+
if (!_state.dynahide) {
|
|
4077
|
+
return;
|
|
4078
|
+
}
|
|
4079
|
+
// No layout state, no honest boxes. Bailing out draws everything; going
|
|
4080
|
+
// on would put every mark at the origin, where they all overlap and all
|
|
4081
|
+
// but the first would be hidden -- a blank tree, with no error.
|
|
4082
|
+
if ((_state.circularDisplay && !_radial) || (_state.unrootedDisplay && !_unroot)) {
|
|
4083
|
+
return;
|
|
4084
|
+
}
|
|
4085
|
+
let fontPx = _state.branchDataFontSize;
|
|
4086
|
+
let dotCell = 0;
|
|
4087
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
4088
|
+
if (nodes[i]._suppDot) {
|
|
4089
|
+
dotCell = Math.max(dotCell, 2 * supportDotRadius(nodes[i]));
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
let cell = Math.max(fontPx, dotCell);
|
|
4093
|
+
let numbers = forester.labelOccupancy(cell);
|
|
4094
|
+
let symbols = forester.labelOccupancy(cell);
|
|
4095
|
+
let ordered = forester.preorderOf(nodes);
|
|
4096
|
+
for (let i = 0, len = ordered.length; i !== len; ++i) {
|
|
4097
|
+
let d = ordered[i];
|
|
4098
|
+
// Branch length first, then confidence: they sit on opposite sides
|
|
4099
|
+
// of the branch, so they rarely contend, but the order decides
|
|
4100
|
+
// which survives when they do.
|
|
4101
|
+
if (d._blText !== '') {
|
|
4102
|
+
let b = branchMarkBox(d, 'bl', d._blText, fontPx);
|
|
4103
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4104
|
+
d._blText = '';
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
if (d._confText !== '') {
|
|
4108
|
+
let b = branchMarkBox(d, 'conf', d._confText, fontPx);
|
|
4109
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4110
|
+
d._confText = '';
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
// Branch EVENTS are branch-anchored too, half a branch from the
|
|
4114
|
+
// branch length and on the same side of it, so they can land on
|
|
4115
|
+
// the same pixels. Left out of the map, they neither refused a
|
|
4116
|
+
// number nor were refused by one, and the pass reported "no
|
|
4117
|
+
// overlapping marks" while the user could see two.
|
|
4118
|
+
if (d._eventText !== '') {
|
|
4119
|
+
let b = branchMarkBox(d, 'event', d._eventText, fontPx);
|
|
4120
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4121
|
+
d._eventText = '';
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
if (d._suppDot) {
|
|
4125
|
+
// never shrunk to fit: in a size-scaled mode the diameter IS
|
|
4126
|
+
// the support value, and a smaller dot reports weaker support
|
|
4127
|
+
let r = supportDotRadius(d);
|
|
4128
|
+
// showSupportDot already required a parent; a circle's bounds
|
|
4129
|
+
// do not rotate, so the radial case needs only the midpoint
|
|
4130
|
+
let at = radialDisplay()
|
|
4131
|
+
? radialBranchMid(d)
|
|
4132
|
+
: [d.y + (0.5 * (d.parent.y - d.y)), d.x];
|
|
4133
|
+
if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
|
|
4134
|
+
d._suppDot = false;
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
|
|
3851
4140
|
function syncOptionalNodeChildren(node) {
|
|
3852
4141
|
let wantBl = _state.showBranchLengthValues === true;
|
|
3853
4142
|
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
@@ -6378,8 +6667,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6378
6667
|
}
|
|
6379
6668
|
|
|
6380
6669
|
if (url) {
|
|
6381
|
-
|
|
6382
|
-
win.focus();
|
|
6670
|
+
openExternal(url);
|
|
6383
6671
|
} else {
|
|
6384
6672
|
// Not a programming error -- the user clicked and there is
|
|
6385
6673
|
// simply nowhere to go -- so this one gets told in the same
|
|
@@ -6879,11 +7167,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
6879
7167
|
if (_logoGlyphs[ch]) {
|
|
6880
7168
|
return _logoGlyphs[ch];
|
|
6881
7169
|
}
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
_legendMeasureCtx.font = MSA_LOGO_FONT_PX + 'px monospace';
|
|
6886
|
-
let m = _legendMeasureCtx.measureText(ch);
|
|
7170
|
+
let ctx = measureCtx();
|
|
7171
|
+
ctx.font = MSA_LOGO_FONT_PX + 'px monospace';
|
|
7172
|
+
let m = ctx.measureText(ch);
|
|
6887
7173
|
let ascent = m.actualBoundingBoxAscent;
|
|
6888
7174
|
let descent = m.actualBoundingBoxDescent;
|
|
6889
7175
|
if (!(ascent > 0)) {
|
|
@@ -9575,6 +9861,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
9575
9861
|
{key: 'g', label: 'G', shift: true, what: 'Previous search hit', run: function () { stepToFoundNode(-1); }, typing: true},
|
|
9576
9862
|
{key: ',', label: '<', aliases: ['<'], shift: true, what: 'Previous tree of the file', run: function () { stepTree(-1); }},
|
|
9577
9863
|
{key: '.', label: '>', aliases: ['>'], shift: true, what: 'Next tree of the file', run: function () { stepTree(1); }},
|
|
9864
|
+
{key: 'i', label: 'I', what: 'Tree properties and statistics', run: showTreePropertiesDialog},
|
|
9578
9865
|
{key: '/', label: '/', aliases: ['?'], what: 'This list', run: showShortcutsDialog, typing: true}
|
|
9579
9866
|
];
|
|
9580
9867
|
|
|
@@ -9953,6 +10240,114 @@ function (root, d3, forester, phyloXml) {
|
|
|
9953
10240
|
// the later one's shadow and glow used to fall across the earlier body,
|
|
9954
10241
|
// and now lie under it; overlapping shadows of one level no longer
|
|
9955
10242
|
// darken each other.
|
|
10243
|
+
// ===================== Domain hover =====================
|
|
10244
|
+
// Domain architectures are what Archaeopteryx has always been best at, and
|
|
10245
|
+
// until now the boxes said nothing: you could see a domain but not read
|
|
10246
|
+
// its name, its E-value or where it sits, short of opening the node's data
|
|
10247
|
+
// dialog and finding it in a list. Christian, 2026-09-23.
|
|
10248
|
+
//
|
|
10249
|
+
// The readout is the one the heat map and the alignment already use, so a
|
|
10250
|
+
// hover anywhere in the viewer looks and behaves the same, and it is
|
|
10251
|
+
// placed by placeHoverReadout, which measures the box rather than assuming
|
|
10252
|
+
// a size.
|
|
10253
|
+
|
|
10254
|
+
function domainInfoAt(event) {
|
|
10255
|
+
let t = event.target;
|
|
10256
|
+
return (t && t.__aptxDomain) ? t.__aptxDomain : null;
|
|
10257
|
+
}
|
|
10258
|
+
|
|
10259
|
+
let _domainHovered = null;
|
|
10260
|
+
|
|
10261
|
+
function domainHover(event) {
|
|
10262
|
+
let info = domainInfoAt(event);
|
|
10263
|
+
if (!info) {
|
|
10264
|
+
domainHoverOut();
|
|
10265
|
+
return;
|
|
10266
|
+
}
|
|
10267
|
+
// mousemove fires all the way across a box, and a wide domain is
|
|
10268
|
+
// hundreds of pixels. The TEXT only changes when the box does, so
|
|
10269
|
+
// re-escaping it, reparsing it as HTML and restarting the fade sixty
|
|
10270
|
+
// times a second would be work for a byte-identical result -- and the
|
|
10271
|
+
// restarted transition would keep opacity approaching 0.95 without
|
|
10272
|
+
// ever arriving. The readout must still FOLLOW the pointer, so the
|
|
10273
|
+
// placement runs either way.
|
|
10274
|
+
let tip_el = _node_mouseover_div.node();
|
|
10275
|
+
if (info === _domainHovered) {
|
|
10276
|
+
placeHoverReadout(tip_el, event);
|
|
10277
|
+
return;
|
|
10278
|
+
}
|
|
10279
|
+
_domainHovered = info;
|
|
10280
|
+
let ref = forester.domainReference(info);
|
|
10281
|
+
let txt = 'Domain: ' + (info.name || '(unnamed)') + '<br>';
|
|
10282
|
+
let ev = forester.domainEvalueText(info.evalue);
|
|
10283
|
+
if (ev !== null) {
|
|
10284
|
+
txt += 'E-value: ' + ev + '<br>';
|
|
10285
|
+
}
|
|
10286
|
+
txt += 'Residues: ' + info.from + '–' + info.to
|
|
10287
|
+
+ ' (' + ((info.to - info.from) + 1) + ' aa'
|
|
10288
|
+
+ (info.proteinLength > 0 ? (' of ' + info.proteinLength) : '') + ')<br>';
|
|
10289
|
+
if (info.tip) {
|
|
10290
|
+
txt += 'Tip: ' + info.tip + '<br>';
|
|
10291
|
+
}
|
|
10292
|
+
if (info.id) {
|
|
10293
|
+
txt += 'Accession: ' + info.id + '<br>';
|
|
10294
|
+
}
|
|
10295
|
+
if (ref) {
|
|
10296
|
+
// A readout that follows the pointer cannot hold a link you could
|
|
10297
|
+
// reach, so the BOX is the link and the readout says so. Written
|
|
10298
|
+
// as a "label: value" row on purpose: markUpDataLabels renders a
|
|
10299
|
+
// line WITHOUT a colon as a section divider (9px bold uppercase
|
|
10300
|
+
// faint), which is the wrong voice entirely for a call to action.
|
|
10301
|
+
txt += ref.exact ? 'Click: the Pfam entry' : 'Click: to look this domain up at InterPro';
|
|
10302
|
+
}
|
|
10303
|
+
tip_el.classList.remove('aptx-light', 'aptx-dark');
|
|
10304
|
+
if (_panelTheme) {
|
|
10305
|
+
tip_el.classList.add('aptx-' + _panelTheme);
|
|
10306
|
+
}
|
|
10307
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
|
|
10308
|
+
placeHoverReadout(tip_el, event);
|
|
10309
|
+
_node_mouseover_div.transition().duration(100).style('opacity', 0.95);
|
|
10310
|
+
}
|
|
10311
|
+
|
|
10312
|
+
function domainHoverOut() {
|
|
10313
|
+
_domainHovered = null;
|
|
10314
|
+
_node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
|
|
10315
|
+
}
|
|
10316
|
+
|
|
10317
|
+
let _domainOpenedAt = 0;
|
|
10318
|
+
|
|
10319
|
+
function domainClicked(event) {
|
|
10320
|
+
let info = domainInfoAt(event);
|
|
10321
|
+
if (!info) {
|
|
10322
|
+
return;
|
|
10323
|
+
}
|
|
10324
|
+
// A click that LANDED on a box is the box's, whether or not it has
|
|
10325
|
+
// somewhere to send you; letting an unlinkable domain fall through
|
|
10326
|
+
// would open the node menu for whatever node is within five units.
|
|
10327
|
+
event.stopPropagation();
|
|
10328
|
+
let ref = forester.domainReference(info);
|
|
10329
|
+
if (!ref) {
|
|
10330
|
+
return;
|
|
10331
|
+
}
|
|
10332
|
+
// Double-clicking is the natural gesture for "open this", and it
|
|
10333
|
+
// arrives as two clicks: without this it opens two identical tabs.
|
|
10334
|
+
let now = Date.now();
|
|
10335
|
+
if ((now - _domainOpenedAt) < 500) {
|
|
10336
|
+
return;
|
|
10337
|
+
}
|
|
10338
|
+
_domainOpenedAt = now;
|
|
10339
|
+
openExternal(ref.url);
|
|
10340
|
+
}
|
|
10341
|
+
|
|
10342
|
+
// The tree zooms on double click. Over a domain box the gesture means the
|
|
10343
|
+
// box, so the zoom must not also happen behind the tabs that just opened.
|
|
10344
|
+
function domainDblClicked(event) {
|
|
10345
|
+
if (domainInfoAt(event)) {
|
|
10346
|
+
event.stopPropagation();
|
|
10347
|
+
event.preventDefault();
|
|
10348
|
+
}
|
|
10349
|
+
}
|
|
10350
|
+
|
|
9956
10351
|
function drawDomainArchitectures() {
|
|
9957
10352
|
if (!_svgGroup) {
|
|
9958
10353
|
return;
|
|
@@ -9964,6 +10359,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
9964
10359
|
let f = tips.length > 0 ? domainScale() : 0;
|
|
9965
10360
|
if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
|
|
9966
10361
|
existing.remove();
|
|
10362
|
+
domainHoverOut(); // the box under the pointer just stopped existing
|
|
9967
10363
|
return;
|
|
9968
10364
|
}
|
|
9969
10365
|
// [defs, the batched paths, the bodies, the names], kept between
|
|
@@ -9983,6 +10379,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
9983
10379
|
let underLayer = g.children[1];
|
|
9984
10380
|
let bodyLayer = g.children[2];
|
|
9985
10381
|
let labelLayer = g.children[3];
|
|
10382
|
+
// The group ignores the mouse so the tree underneath stays clickable;
|
|
10383
|
+
// the BODIES alone take it back, so a hover lands on a domain and not
|
|
10384
|
+
// on its shadow, its glow or its label.
|
|
10385
|
+
bodyLayer.style.pointerEvents = 'auto';
|
|
10386
|
+
if (!bodyLayer.__aptxWired) {
|
|
10387
|
+
bodyLayer.__aptxWired = true;
|
|
10388
|
+
// mousemove alone, as the heat map and the alignment do it: a
|
|
10389
|
+
// mouseover is always followed by a mousemove, so binding both
|
|
10390
|
+
// only built the readout twice on entry.
|
|
10391
|
+
bodyLayer.addEventListener('mousemove', domainHover);
|
|
10392
|
+
bodyLayer.addEventListener('mouseout', domainHoverOut);
|
|
10393
|
+
bodyLayer.addEventListener('click', domainClicked);
|
|
10394
|
+
bodyLayer.addEventListener('dblclick', domainDblClicked);
|
|
10395
|
+
}
|
|
9986
10396
|
|
|
9987
10397
|
// one vertical gradient per base colour, lighter at the top; the
|
|
9988
10398
|
// defs ride into the SVG / PDF / PNG exports with the boxes
|
|
@@ -10085,6 +10495,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
10085
10495
|
}
|
|
10086
10496
|
let set = row.transform ? pathSet() : track;
|
|
10087
10497
|
let geo = forester.domainBoxes(da, row.start, f, _state.domainEvalueExponent);
|
|
10498
|
+
// once per ROW: displayNodeName walks the node's properties when a
|
|
10499
|
+
// label-ref visualization is on, and a row can hold dozens of boxes
|
|
10500
|
+
let rowTip = displayNodeName(row.d) || '';
|
|
10501
|
+
let rowLength = Number(da.length) || 0;
|
|
10088
10502
|
let h = row.h;
|
|
10089
10503
|
let y1 = row.y1;
|
|
10090
10504
|
set.backbones.push(roundRectPath(geo.backbone.x, y1 + (h / 2) - 0.5, geo.backbone.w, 1, 0));
|
|
@@ -10108,7 +10522,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
10108
10522
|
});
|
|
10109
10523
|
}
|
|
10110
10524
|
bodies.push({x: b.x, y: y1, w: b.w, h: h, r: r, transform: row.transform,
|
|
10111
|
-
fill: gradientFor(base), stroke: forester.domainDarken(base, 0.24)
|
|
10525
|
+
fill: gradientFor(base), stroke: forester.domainDarken(base, 0.24),
|
|
10526
|
+
// what the hover readout says. Carried per BOX rather than
|
|
10527
|
+
// looked up again on hover: the boxes are pooled and
|
|
10528
|
+
// reused between redraws, so an index into the tree would
|
|
10529
|
+
// go stale the moment the tips on screen change.
|
|
10530
|
+
info: {name: b.name, from: b.from, to: b.to, evalue: b.evalue, id: b.id,
|
|
10531
|
+
tip: rowTip, proteinLength: rowLength}});
|
|
10112
10532
|
if (row.labelsOn && b.name && fs > 4 && legendTextWidth(b.name, font) <= b.w - 4) {
|
|
10113
10533
|
names.push({x: b.x + (b.w / 2), y: y1 + (h / 2), font: font,
|
|
10114
10534
|
fill: forester.domainLabelInk(base), text: b.name});
|
|
@@ -10132,6 +10552,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
10132
10552
|
paths[k].style.fill = u.fill;
|
|
10133
10553
|
paths[k].style.fillOpacity = u.opacity;
|
|
10134
10554
|
});
|
|
10555
|
+
// A redraw can take the hovered box away without the pointer moving
|
|
10556
|
+
// -- a keyboard zoom, a panel change applied with Enter, a collapse --
|
|
10557
|
+
// and a removed element fires no mouseout, so the readout would stay
|
|
10558
|
+
// painted over a domain that is no longer there.
|
|
10559
|
+
domainHoverOut();
|
|
10135
10560
|
let rects = sizeLayer(bodyLayer, 'rect', bodies.length);
|
|
10136
10561
|
bodies.forEach(function (b, k) {
|
|
10137
10562
|
let el = rects[k];
|
|
@@ -10149,6 +10574,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
10149
10574
|
el.style.fill = b.fill;
|
|
10150
10575
|
el.style.stroke = b.stroke;
|
|
10151
10576
|
el.style.strokeWidth = 1;
|
|
10577
|
+
el.__aptxDomain = b.info;
|
|
10578
|
+
// only where there is somewhere to go: an unnamed domain with no
|
|
10579
|
+
// accession has no reference, and a hand cursor over it would
|
|
10580
|
+
// promise a link that the click cannot deliver
|
|
10581
|
+
el.style.cursor = forester.domainReference(b.info) ? 'pointer' : 'default';
|
|
10152
10582
|
});
|
|
10153
10583
|
let texts = sizeLayer(labelLayer, 'text', names.length);
|
|
10154
10584
|
names.forEach(function (t, k) {
|
|
@@ -11738,7 +12168,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
11738
12168
|
if (matches.length > SUGGEST_MAX_ROWS) {
|
|
11739
12169
|
let more = document.createElement('div');
|
|
11740
12170
|
more.className = 'aptx-suggest-more';
|
|
11741
|
-
more.textContent = (matches.length - SUGGEST_MAX_ROWS) + ' more
|
|
12171
|
+
more.textContent = (matches.length - SUGGEST_MAX_ROWS) + ' more, keep typing';
|
|
11742
12172
|
box.appendChild(more);
|
|
11743
12173
|
}
|
|
11744
12174
|
document.body.appendChild(box);
|
|
@@ -11934,10 +12364,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
11934
12364
|
if (alignBtn) {
|
|
11935
12365
|
alignBtn.disabled = _state.unrootedDisplay || _basicTreeProperties.branchLengths !== true;
|
|
11936
12366
|
}
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
|
|
12367
|
+
// Auto-hide Labels stays live in every layout. It used to be greyed out
|
|
12368
|
+
// in unrooted, where the tip-label decimation it governs does not run --
|
|
12369
|
+
// but it also governs the crowded branch-data rule, which does run
|
|
12370
|
+
// there, so greying it out left the user unable to switch off something
|
|
12371
|
+
// that was happening.
|
|
11941
12372
|
let msaCb = byId(MSA_CB);
|
|
11942
12373
|
if (msaCb) {
|
|
11943
12374
|
msaCb.disabled = radialDisplay();
|
|
@@ -12165,7 +12596,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
12165
12596
|
}
|
|
12166
12597
|
item.classList.toggle('aptx-check-active', !!active);
|
|
12167
12598
|
item.title = active
|
|
12168
|
-
? item.dataset.baseTitle + '
|
|
12599
|
+
? item.dataset.baseTitle + ', hiding now: 1 in ' + _dynahide_factor
|
|
12600
|
+
+ ' labels shown, and branch data that would overlap'
|
|
12169
12601
|
: item.dataset.baseTitle;
|
|
12170
12602
|
}
|
|
12171
12603
|
|
|
@@ -12795,6 +13227,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
12795
13227
|
}
|
|
12796
13228
|
|
|
12797
13229
|
// The theme toggle, showing the theme it will switch TO.
|
|
13230
|
+
// A circled "i": the tree-properties button. The stem is a stroke and the
|
|
13231
|
+
// tittle a filled dot, so both keep their shape at the 20px the header
|
|
13232
|
+
// button draws them at.
|
|
13233
|
+
function glyphInfo() {
|
|
13234
|
+
return '<circle cx="50" cy="50" r="40"/>'
|
|
13235
|
+
+ glyphLine(50, 45, 50, 71)
|
|
13236
|
+
+ glyphDot(50, 29, 5.5);
|
|
13237
|
+
}
|
|
13238
|
+
|
|
12798
13239
|
function glyphSun() {
|
|
12799
13240
|
let s = glyphDot(50, 50, 20);
|
|
12800
13241
|
for (let i = 0; i < 8; ++i) {
|
|
@@ -12862,6 +13303,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
12862
13303
|
case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
|
|
12863
13304
|
case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
|
|
12864
13305
|
case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
|
|
13306
|
+
case 'info': sw = 7; cap = 'round'; body = glyphInfo(); break;
|
|
12865
13307
|
case 'sun': body = glyphSun(); break;
|
|
12866
13308
|
case 'moon': body = glyphMoon(); break;
|
|
12867
13309
|
default: throw new Error('unknown control-panel glyph: ' + kind);
|
|
@@ -13000,7 +13442,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
13000
13442
|
+ '@media (prefers-reduced-motion:reduce){ .aptx-busy-dot { animation:none;'
|
|
13001
13443
|
+ ' border-color:var(--p-accent); } }'
|
|
13002
13444
|
+ '.aptx-busy-sub { color:var(--p-muted); }'
|
|
13445
|
+
// The same frosted glass as the node menu and the suggestion list:
|
|
13446
|
+
// --p-bg is deliberately translucent (0.86) so panels float over
|
|
13447
|
+
// the tree, but without the blur a dialog puts dense text straight
|
|
13448
|
+
// on top of whatever is behind it -- and over an alignment or a
|
|
13449
|
+
// heat map that is a rainbow. The menus had the blur from the
|
|
13450
|
+
// start; the dialogs were simply missed.
|
|
13003
13451
|
+ '.aptx-dialog { padding:0; border:1px solid var(--p-line-strong); border-radius:12px;'
|
|
13452
|
+
+ ' -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);'
|
|
13004
13453
|
+ ' background:var(--p-bg); color:var(--p-ink); max-width:92vw;'
|
|
13005
13454
|
+ ' box-shadow:0 24px 48px -16px rgba(23,34,46,0.45),0 4px 12px -4px rgba(23,34,46,0.22);'
|
|
13006
13455
|
+ ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
|
|
@@ -13045,6 +13494,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
13045
13494
|
+ '.aptx-dialog-key { flex:0 0 42%; color:var(--p-muted); }'
|
|
13046
13495
|
+ '.aptx-dialog-val { flex:1 1 auto; min-width:0; overflow-wrap:anywhere; }'
|
|
13047
13496
|
// the shortcuts cheat sheet: key chips in the key column
|
|
13497
|
+
+ '.aptx-props .aptx-props-head { font-size:10px; letter-spacing:.08em; text-transform:uppercase; color:var(--p-muted); margin:12px 0 3px; }'
|
|
13498
|
+
+ '.aptx-props > div:first-child { margin-top:0; }'
|
|
13499
|
+
+ '.aptx-props .aptx-dialog-key { flex-basis:46%; }'
|
|
13048
13500
|
+ '.aptx-shortcuts .aptx-dialog-key { flex-basis:38%; display:flex; align-items:center; }'
|
|
13049
13501
|
+ '.aptx-shortcuts .aptx-dialog-val { align-self:center; }'
|
|
13050
13502
|
+ '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
|
|
@@ -13199,7 +13651,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
13199
13651
|
+ '.aptx-panel .aptx-seg:has(> input:checked) { background:var(--p-accent); color:#fff; }'
|
|
13200
13652
|
+ '.aptx-panel .aptx-seg:has(> input:disabled) { opacity:0.4; cursor:default; }'
|
|
13201
13653
|
+ '.aptx-panel .aptx-actions { margin-left:auto; display:flex; align-items:center; gap:5px; }'
|
|
13202
|
-
+ '.aptx-panel .aptx-theme-btn { flex:none; width:20px; height:20px; display:grid; place-items:center; padding:0; border:1px solid var(--p-line-strong); border-radius:6px; background:var(--p-surface2); color:var(--p-muted); cursor:pointer; font-size:12px; line-height:1; }'
|
|
13654
|
+
+ '.aptx-panel .aptx-theme-btn, .aptx-panel .aptx-info-btn { flex:none; width:20px; height:20px; display:grid; place-items:center; padding:0; border:1px solid var(--p-line-strong); border-radius:6px; background:var(--p-surface2); color:var(--p-muted); cursor:pointer; font-size:12px; line-height:1; }'
|
|
13203
13655
|
+ '.aptx-panel .aptx-theme-btn:hover { background:var(--p-accent-weak); color:var(--p-accent-ink); border-color:var(--p-accent); }'
|
|
13204
13656
|
+ '.aptx-panel input[type=range] { -webkit-appearance:none; appearance:none; display:block; width:100%; height:15px; margin:2px 0 9px; padding:0; background:transparent; cursor:pointer; }'
|
|
13205
13657
|
+ '.aptx-panel input[type=range]::-webkit-slider-runnable-track { height:4px; border-radius:999px; background:var(--p-line-strong); }'
|
|
@@ -13354,6 +13806,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
13354
13806
|
let actions = document.createElement('div');
|
|
13355
13807
|
actions.className = 'aptx-actions';
|
|
13356
13808
|
|
|
13809
|
+
let infoBtn = document.createElement('button');
|
|
13810
|
+
infoBtn.type = 'button';
|
|
13811
|
+
// NOT aptx-theme-btn: applyPanelTheme repaints the innerHTML of
|
|
13812
|
+
// every element carrying that class with the sun/moon, so
|
|
13813
|
+
// borrowing it for the chrome replaced this glyph the instant a
|
|
13814
|
+
// theme was applied. The two share their styling through the CSS
|
|
13815
|
+
// selector instead, which is all the class was wanted for.
|
|
13816
|
+
infoBtn.className = 'aptx-info-btn';
|
|
13817
|
+
infoBtn.title = 'Tree properties and statistics ('
|
|
13818
|
+
+ (IS_MAC ? '\u2318I' : 'Ctrl+I') + ')';
|
|
13819
|
+
infoBtn.setAttribute('aria-label', 'Tree properties and statistics');
|
|
13820
|
+
infoBtn.innerHTML = makeGlyph('info');
|
|
13821
|
+
infoBtn.addEventListener('click', function (e) {
|
|
13822
|
+
e.stopPropagation();
|
|
13823
|
+
showTreePropertiesDialog();
|
|
13824
|
+
});
|
|
13825
|
+
actions.appendChild(infoBtn);
|
|
13826
|
+
|
|
13357
13827
|
let progName = header.querySelector('.' + PROGNAMELINK);
|
|
13358
13828
|
if (progName) {
|
|
13359
13829
|
// Clicking the title used to navigate away to the project site;
|
|
@@ -13927,6 +14397,257 @@ function (root, d3, forester, phyloXml) {
|
|
|
13927
14397
|
shell.dialog.showModal();
|
|
13928
14398
|
}
|
|
13929
14399
|
|
|
14400
|
+
// ===================== Tree properties =====================
|
|
14401
|
+
// What the tree IS and what it carries: the desktop's View > Tree
|
|
14402
|
+
// Properties, read-only (Christian, 2026-09-23 -- the editable half waits
|
|
14403
|
+
// for demand). Nothing else in the viewer answers "what is actually in
|
|
14404
|
+
// this file", which is the first question a tree raises.
|
|
14405
|
+
//
|
|
14406
|
+
// Computed FRESH every time it opens rather than cached: the whole of
|
|
14407
|
+
// collectBasicTreeProperties costs 7ms on the 13,246-tip H5N1 demo, and a
|
|
14408
|
+
// cache would go stale the moment the user re-roots or enters a subtree.
|
|
14409
|
+
// It describes what is ON SCREEN, so in a subtree it describes the
|
|
14410
|
+
// subtree, and says so.
|
|
14411
|
+
//
|
|
14412
|
+
// Every value goes in through textContent. Tree names, descriptions and
|
|
14413
|
+
// identifiers are file data, and file data never becomes markup.
|
|
14414
|
+
|
|
14415
|
+
function treePropRow(parent, label, value) {
|
|
14416
|
+
if (value === null || value === undefined || value === '') {
|
|
14417
|
+
return;
|
|
14418
|
+
}
|
|
14419
|
+
let line = document.createElement('div');
|
|
14420
|
+
line.className = 'aptx-dialog-line';
|
|
14421
|
+
let k = document.createElement('span');
|
|
14422
|
+
k.className = 'aptx-dialog-key';
|
|
14423
|
+
k.textContent = label;
|
|
14424
|
+
let v = document.createElement('span');
|
|
14425
|
+
v.className = 'aptx-dialog-val';
|
|
14426
|
+
v.textContent = String(value);
|
|
14427
|
+
line.appendChild(k);
|
|
14428
|
+
line.appendChild(v);
|
|
14429
|
+
parent.appendChild(line);
|
|
14430
|
+
}
|
|
14431
|
+
|
|
14432
|
+
function treePropSection(body, title) {
|
|
14433
|
+
let h = document.createElement('div');
|
|
14434
|
+
h.className = 'aptx-props-head';
|
|
14435
|
+
h.textContent = title;
|
|
14436
|
+
body.appendChild(h);
|
|
14437
|
+
let box = document.createElement('div');
|
|
14438
|
+
body.appendChild(box);
|
|
14439
|
+
return box;
|
|
14440
|
+
}
|
|
14441
|
+
|
|
14442
|
+
function countNumber(n) {
|
|
14443
|
+
return (typeof n === 'number' && isFinite(n)) ? n.toLocaleString() : null;
|
|
14444
|
+
}
|
|
14445
|
+
|
|
14446
|
+
// A number for a fact row: thousands separators for counts, and enough
|
|
14447
|
+
// significant digits for a branch length, which can be 5e-9.
|
|
14448
|
+
function statNumber(v, digits) {
|
|
14449
|
+
if (typeof v !== 'number' || !isFinite(v)) {
|
|
14450
|
+
return null;
|
|
14451
|
+
}
|
|
14452
|
+
if (v === 0 || (Math.abs(v) >= 0.001 && Math.abs(v) < 1e7)) {
|
|
14453
|
+
return String(forester.roundNumber(v, digits === undefined ? 6 : digits));
|
|
14454
|
+
}
|
|
14455
|
+
return v.toExponential(3);
|
|
14456
|
+
}
|
|
14457
|
+
|
|
14458
|
+
// "1,234 of 5,678 tips", the desktop's coverage phrasing.
|
|
14459
|
+
function statOf(part, whole, noun) {
|
|
14460
|
+
if (!(whole > 0)) {
|
|
14461
|
+
return null;
|
|
14462
|
+
}
|
|
14463
|
+
return countNumber(part) + ' of ' + countNumber(whole) + (noun ? (' ' + noun) : '');
|
|
14464
|
+
}
|
|
14465
|
+
|
|
14466
|
+
// n / minimum / median / maximum / mean, the block Christian asked for in
|
|
14467
|
+
// place of a histogram. One shape, used for branch lengths and for each
|
|
14468
|
+
// kind of support, so the two read the same way.
|
|
14469
|
+
function statSpread(box, d, digits) {
|
|
14470
|
+
treePropRow(box, 'Minimum', statNumber(d.min, digits));
|
|
14471
|
+
treePropRow(box, 'Median', statNumber(d.median, digits));
|
|
14472
|
+
treePropRow(box, 'Maximum', statNumber(d.max, digits));
|
|
14473
|
+
treePropRow(box, 'Mean', statNumber(d.mean, digits));
|
|
14474
|
+
}
|
|
14475
|
+
|
|
14476
|
+
function showTreePropertiesDialog() {
|
|
14477
|
+
if (!_root) {
|
|
14478
|
+
return;
|
|
14479
|
+
}
|
|
14480
|
+
let shell = makeDialogShell(TREE_PROPERTIES_DIALOG, 'Tree properties', 420);
|
|
14481
|
+
shell.body.classList.add('aptx-props');
|
|
14482
|
+
// A description is free text and can be a paragraph, and the coverage
|
|
14483
|
+
// section grows with the annotations, so the body scrolls rather than
|
|
14484
|
+
// running off the screen.
|
|
14485
|
+
shell.body.style.maxHeight = '66vh';
|
|
14486
|
+
shell.body.style.overflowY = 'auto';
|
|
14487
|
+
let st = forester.treeStatistics(_root);
|
|
14488
|
+
let bp = forester.collectBasicTreeProperties(_root);
|
|
14489
|
+
let time = forester.timeAxisInfo(_root);
|
|
14490
|
+
let phy = _treeData || {};
|
|
14491
|
+
|
|
14492
|
+
// --- what the tree says it is ---------------------------------
|
|
14493
|
+
let box = treePropSection(shell.body, 'Tree');
|
|
14494
|
+
treePropRow(box, 'Name', phy.name);
|
|
14495
|
+
treePropRow(box, 'Description', phy.description);
|
|
14496
|
+
if (phy.id && phy.id.value) {
|
|
14497
|
+
treePropRow(box, 'Identifier', phy.id.provider
|
|
14498
|
+
? (phy.id.value + ' (' + phy.id.provider + ')') : phy.id.value);
|
|
14499
|
+
}
|
|
14500
|
+
treePropRow(box, 'Type', phy.type);
|
|
14501
|
+
treePropRow(box, 'Branch-length unit', phy.branch_length_unit);
|
|
14502
|
+
// Only when the FILE said so. phyloXML has a rooted attribute and a
|
|
14503
|
+
// Nexus tree statement has [&R]/[&U]; plain Newick says nothing, and a
|
|
14504
|
+
// properties dialog that answered "yes" there would be reporting our
|
|
14505
|
+
// writing default as though the file had stated it.
|
|
14506
|
+
if (typeof phy.rooted === 'boolean') {
|
|
14507
|
+
treePropRow(box, 'Rooted', phy.rooted ? 'yes' : 'no');
|
|
14508
|
+
}
|
|
14509
|
+
if (typeof phy.rerootable === 'boolean') {
|
|
14510
|
+
treePropRow(box, 'Rerootable', phy.rerootable ? 'yes' : 'no');
|
|
14511
|
+
}
|
|
14512
|
+
if (_trees && _trees.length > 1) {
|
|
14513
|
+
treePropRow(box, 'In this file', 'tree ' + (_treeIndex + 1) + ' of ' + _trees.length);
|
|
14514
|
+
}
|
|
14515
|
+
if (_in_subtree) {
|
|
14516
|
+
treePropRow(box, 'Showing', 'a subtree, not the whole tree');
|
|
14517
|
+
}
|
|
14518
|
+
if (!box.firstChild) {
|
|
14519
|
+
treePropRow(box, 'Name', '(the tree carries no name or metadata)');
|
|
14520
|
+
}
|
|
14521
|
+
|
|
14522
|
+
// --- shape ----------------------------------------------------
|
|
14523
|
+
box = treePropSection(shell.body, 'Structure');
|
|
14524
|
+
treePropRow(box, 'Tips', countNumber(st.tips));
|
|
14525
|
+
treePropRow(box, 'Internal nodes', countNumber(st.internal));
|
|
14526
|
+
treePropRow(box, 'Nodes, total', countNumber(st.nodes));
|
|
14527
|
+
treePropRow(box, 'Branches', countNumber(st.branches));
|
|
14528
|
+
treePropRow(box, 'Branching', st.polytomies === 0 ? 'fully binary'
|
|
14529
|
+
: (countNumber(st.polytomies) + (st.polytomies === 1 ? ' polytomy' : ' polytomies')
|
|
14530
|
+
+ ', widest ' + countNumber(st.maxChildren) + ' children'));
|
|
14531
|
+
treePropRow(box, 'Depth', countNumber(st.depth) + ' (root to deepest tip)');
|
|
14532
|
+
// ours, not the desktop's, and worth keeping: the label width is what
|
|
14533
|
+
// decides how much room the tree needs, and whether internal nodes are
|
|
14534
|
+
// named decides whether their labels are worth turning on
|
|
14535
|
+
treePropRow(box, 'Longest tip label', bp.longestNodeName > 0
|
|
14536
|
+
? (countNumber(bp.longestNodeName)
|
|
14537
|
+
+ (bp.longestNodeName === 1 ? ' character' : ' characters')) : null);
|
|
14538
|
+
treePropRow(box, 'Internal nodes named', st.internal > 0
|
|
14539
|
+
? (st.namedInternal > 0 ? statOf(st.namedInternal, st.internal) : 'none') : null);
|
|
14540
|
+
|
|
14541
|
+
// --- branch lengths -------------------------------------------
|
|
14542
|
+
box = treePropSection(shell.body, 'Branch lengths');
|
|
14543
|
+
if (st.branchLengths) {
|
|
14544
|
+
treePropRow(box, 'Branches with a length',
|
|
14545
|
+
statOf(st.branchLengths.n, st.branches));
|
|
14546
|
+
statSpread(box, st.branchLengths);
|
|
14547
|
+
treePropRow(box, 'Sum', statNumber(st.branchLengths.sum) + ' (total tree length)');
|
|
14548
|
+
treePropRow(box, 'Height', statNumber(st.height) + ' (root to farthest tip)');
|
|
14549
|
+
if (st.branchLengths.zero > 0) {
|
|
14550
|
+
treePropRow(box, 'Zero-length', countNumber(st.branchLengths.zero));
|
|
14551
|
+
}
|
|
14552
|
+
if (st.branchLengths.negative > 0) {
|
|
14553
|
+
treePropRow(box, 'Negative', countNumber(st.branchLengths.negative)
|
|
14554
|
+
+ ' (not drawn to scale)');
|
|
14555
|
+
}
|
|
14556
|
+
treePropRow(box, 'Ultrametric', st.ultrametric
|
|
14557
|
+
? 'yes (every tip the same distance from the root)' : 'no');
|
|
14558
|
+
} else {
|
|
14559
|
+
treePropRow(box, 'Branches with a length', 'none, the tree has no lengths');
|
|
14560
|
+
}
|
|
14561
|
+
|
|
14562
|
+
// --- support, one section per kind ----------------------------
|
|
14563
|
+
// A bootstrap and a posterior on the same branch are not on one scale,
|
|
14564
|
+
// so they are never pooled: each type gets its own n and spread.
|
|
14565
|
+
st.support.forEach(function (d) {
|
|
14566
|
+
// 'unknown' is what the Newick and Nexus readers put on a bare
|
|
14567
|
+
// [95]: those formats have one support slot and nowhere to name
|
|
14568
|
+
// what it is, so it is an absence of type rather than a type
|
|
14569
|
+
// called unknown, and it should not head a section as one.
|
|
14570
|
+
let untyped = !d.type || d.type === 'unknown';
|
|
14571
|
+
let title = (st.support.length === 1 && untyped)
|
|
14572
|
+
? 'Support values' : ('Support: ' + (untyped ? 'untyped' : d.type));
|
|
14573
|
+
let sbox = treePropSection(shell.body, title);
|
|
14574
|
+
treePropRow(sbox, 'Branches with support', statOf(d.n, st.branches));
|
|
14575
|
+
statSpread(sbox, d);
|
|
14576
|
+
});
|
|
14577
|
+
|
|
14578
|
+
// --- what came with it ----------------------------------------
|
|
14579
|
+
box = treePropSection(shell.body, 'The tree carries');
|
|
14580
|
+
let carried = 0;
|
|
14581
|
+
function carries(label, present, detail) {
|
|
14582
|
+
if (!present) {
|
|
14583
|
+
return;
|
|
14584
|
+
}
|
|
14585
|
+
++carried;
|
|
14586
|
+
treePropRow(box, label, detail || 'yes');
|
|
14587
|
+
}
|
|
14588
|
+
carries('Taxonomies', st.tipsWithTaxonomy > 0, statOf(st.tipsWithTaxonomy, st.tips, 'tips')
|
|
14589
|
+
+ (st.distinctTaxonomies > 0 ? (', ' + countNumber(st.distinctTaxonomies) + ' distinct') : ''));
|
|
14590
|
+
carries('Taxonomy identifiers', st.tipsWithTaxonomyId > 0,
|
|
14591
|
+
statOf(st.tipsWithTaxonomyId, st.tips, 'tips'));
|
|
14592
|
+
carries('Sequences', st.tipsWithSequence > 0, statOf(st.tipsWithSequence, st.tips, 'tips'));
|
|
14593
|
+
// alignedMolSeqs starts TRUE and is only ever set false, so it means
|
|
14594
|
+
// "no UNALIGNED sequence was seen" -- a tree carrying no sequences at
|
|
14595
|
+
// all reports true. Read alone it told the flu and ammonite demos they
|
|
14596
|
+
// had an alignment. Every other reader in this file pairs it with a
|
|
14597
|
+
// length for the same reason.
|
|
14598
|
+
carries('Molecular sequences', st.tipsWithMolSeq > 0,
|
|
14599
|
+
statOf(st.tipsWithMolSeq, st.tips, 'tips')
|
|
14600
|
+
+ (bp.alignedMolSeqs && bp.maxMolSeqLength > 0
|
|
14601
|
+
? (', aligned, ' + countNumber(bp.maxMolSeqLength) + ' columns') : ''));
|
|
14602
|
+
carries('Domain architectures', st.tipsWithDomains > 0,
|
|
14603
|
+
statOf(st.tipsWithDomains, st.tips, 'tips')
|
|
14604
|
+
+ (bp.maxDomainArchitectureLength > 0
|
|
14605
|
+
? (', up to ' + countNumber(bp.maxDomainArchitectureLength) + ' residues') : ''));
|
|
14606
|
+
carries('Dates', st.nodesWithDate > 0, statOf(st.nodesWithDate, st.nodes, 'nodes'));
|
|
14607
|
+
carries('Distributions', st.tipsWithDistribution > 0,
|
|
14608
|
+
statOf(st.tipsWithDistribution, st.tips, 'tips'));
|
|
14609
|
+
carries('MAD values', bp.madValues);
|
|
14610
|
+
carries('Branch colours', bp.branchColors);
|
|
14611
|
+
if (st.nodesWithEvents > 0) {
|
|
14612
|
+
let parts = [];
|
|
14613
|
+
if (st.duplications > 0) {
|
|
14614
|
+
parts.push(countNumber(st.duplications)
|
|
14615
|
+
+ (st.duplications === 1 ? ' duplication' : ' duplications'));
|
|
14616
|
+
}
|
|
14617
|
+
if (st.speciations > 0) {
|
|
14618
|
+
parts.push(countNumber(st.speciations)
|
|
14619
|
+
+ (st.speciations === 1 ? ' speciation' : ' speciations'));
|
|
14620
|
+
}
|
|
14621
|
+
parts.push(statOf(st.nodesWithEvents, st.nodes, 'nodes'));
|
|
14622
|
+
carries('Events', true, parts.join(', '));
|
|
14623
|
+
}
|
|
14624
|
+
st.propertyRefs.forEach(function (r) {
|
|
14625
|
+
carries('Property ' + r.ref, true, statOf(r.nodes, st.nodes, 'nodes'));
|
|
14626
|
+
});
|
|
14627
|
+
if (carried === 0) {
|
|
14628
|
+
treePropRow(box, 'Annotations', 'none, names and topology only');
|
|
14629
|
+
}
|
|
14630
|
+
|
|
14631
|
+
// --- time -----------------------------------------------------
|
|
14632
|
+
if (time && (time.type || time.dated)) {
|
|
14633
|
+
box = treePropSection(shell.body, 'Time');
|
|
14634
|
+
treePropRow(box, 'Dates', time.type === 'geologic'
|
|
14635
|
+
? 'geologic ages' : (time.type === 'calendar' ? 'calendar years' : 'yes'));
|
|
14636
|
+
treePropRow(box, 'Dated nodes', statOf(st.nodesWithDate, st.nodes, 'nodes')
|
|
14637
|
+
+ (time.dated ? ' (a dated tree)' : ''));
|
|
14638
|
+
if (time.type === 'geologic' && time.rootAge > 0) {
|
|
14639
|
+
treePropRow(box, 'Oldest node', statNumber(time.rootAge, 4) + ' Ma');
|
|
14640
|
+
}
|
|
14641
|
+
if (time.type === 'calendar' && time.presentDate > 0) {
|
|
14642
|
+
treePropRow(box, 'Most recent tip', statNumber(time.presentDate, 4));
|
|
14643
|
+
}
|
|
14644
|
+
treePropRow(box, 'Confidence intervals',
|
|
14645
|
+
(time.hasInternalIntervals || time.hasExternalIntervals) ? 'yes' : null);
|
|
14646
|
+
}
|
|
14647
|
+
|
|
14648
|
+
shell.dialog.showModal();
|
|
14649
|
+
}
|
|
14650
|
+
|
|
13930
14651
|
// The About box behind the panel's title. Everything here is fixed text and
|
|
13931
14652
|
// fixed URLs -- no tree data reaches it -- so the links are built as real
|
|
13932
14653
|
// elements rather than markup.
|
|
@@ -14793,10 +15514,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
14793
15514
|
// "Auto-hide Labels" matches the desktop, which renamed the historical
|
|
14794
15515
|
// "Dyna Hide" because that named the mechanism rather than what the user
|
|
14795
15516
|
// sees. The _state.dynahide field keeps its name internally.
|
|
14796
|
-
opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels
|
|
15517
|
+
opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels, and branch values that would overprint each other, where the tree is too dense for them to be readable', true));
|
|
14797
15518
|
opts.push(makeCheckboxItem('Short Names', SHORTEN_NODE_NAME_CB, 'to shorten long node names'));
|
|
14798
15519
|
if (_basicTreeProperties.confidences) {
|
|
14799
|
-
opts.push(makeCheckboxItem('Support Dots', SUPPORT_DOTS_CB, 'to mark
|
|
15520
|
+
opts.push(makeCheckboxItem('Support Dots', SUPPORT_DOTS_CB, 'to mark a branch with support of at least ' + _settings.supportDotMinimum + '% with a dot at its midpoint, where there is room to draw it'));
|
|
14800
15521
|
}
|
|
14801
15522
|
// Styles and visualizations sit here too, so the Nodes group only
|
|
14802
15523
|
// appears at all on a tree carrying node/branch events
|