archaeopteryx 3.14.0 → 3.16.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 +166 -18
- package/archaeopteryx.js +1248 -98
- package/forester.js +353 -3
- package/package.json +4 -2
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.16.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.16.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';
|
|
@@ -408,6 +408,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
408
408
|
const VIS_CB = 'vis_cb';
|
|
409
409
|
const LADDERIZE_BUTTON = 'ladderize_b';
|
|
410
410
|
const PHYLOGRAM_ALIGNED_BUTTON = 'phya_b';
|
|
411
|
+
// What the three display types DO, in one place: the tooltip, the reason a
|
|
412
|
+
// type is greyed, and the cheat-sheet row are all this sentence. A control
|
|
413
|
+
// named only after itself ("phylogram display (uses branch length
|
|
414
|
+
// values)") tells a reader who already knows what it is.
|
|
415
|
+
// Each says WHEN it is greyed, whether or not it is greyed now: that is a
|
|
416
|
+
// property of the control, not of the view, and a reader of the sheet in
|
|
417
|
+
// one layout is owed what happens in the others.
|
|
418
|
+
const PHYLOGRAM_WHAT = 'phylogram: branch lengths drawn to scale, so the tips end ragged.'
|
|
419
|
+
+ ' Greyed in the circular layout, which always carries its labels to the outer ring,'
|
|
420
|
+
+ ' so there the aligned phylogram is the one drawn';
|
|
421
|
+
const PHYLOGRAM_ALIGNED_WHAT = 'aligned phylogram: branch lengths to scale, with every tip'
|
|
422
|
+
+ ' carried on to a common column, or in the circular layout to the outer ring.'
|
|
423
|
+
+ ' Greyed in the unrooted layout, which has no column or ring to carry them to';
|
|
424
|
+
const CLADOGRAM_WHAT = 'cladogram: topology only, branch lengths ignored, every tip flush';
|
|
411
425
|
const PHYLOGRAM_BUTTON = 'phy_b';
|
|
412
426
|
const PHYLOGRAM_CLADOGRAM_CONTROLGROUP = 'phy_cla_g';
|
|
413
427
|
const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
|
|
@@ -1481,6 +1495,26 @@ function (root, d3, forester, phyloXml) {
|
|
|
1481
1495
|
// so the guess was what the reader saw every time.
|
|
1482
1496
|
const HOVER_READOUT_GAP = 14;
|
|
1483
1497
|
|
|
1498
|
+
// One way to open an external page. There were three: window.open + a
|
|
1499
|
+
// win.focus() that throws when a popup blocker hands back null and which
|
|
1500
|
+
// passed no noopener, an <a target="_blank" rel="noopener noreferrer">,
|
|
1501
|
+
// and a third for the domain link. Three security postures for one job,
|
|
1502
|
+
// and a change to the policy would have had to find all of them.
|
|
1503
|
+
//
|
|
1504
|
+
// noopener: the opened page must not get a handle on this window.
|
|
1505
|
+
// noreferrer with it, since a tree can come from anywhere and its URL is
|
|
1506
|
+
// not the external site's business.
|
|
1507
|
+
function openExternal(url) {
|
|
1508
|
+
if (!url) {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
let win = window.open(url, '_blank', 'noopener,noreferrer');
|
|
1512
|
+
if (win) {
|
|
1513
|
+
win.focus(); // a blocked popup hands back null; that is not an error
|
|
1514
|
+
}
|
|
1515
|
+
return win;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1484
1518
|
function placeHoverReadout(el, event) {
|
|
1485
1519
|
let w = el.offsetWidth;
|
|
1486
1520
|
let h = el.offsetHeight;
|
|
@@ -2319,12 +2353,23 @@ function (root, d3, forester, phyloXml) {
|
|
|
2319
2353
|
|
|
2320
2354
|
let _legendMeasureCtx = null;
|
|
2321
2355
|
|
|
2322
|
-
|
|
2356
|
+
// One lazily made canvas for every text measurement: the legend's widths,
|
|
2357
|
+
// the branch-data line metrics and the sequence logo's glyph boxes each
|
|
2358
|
+
// used to make their own.
|
|
2359
|
+
function measureCtx() {
|
|
2323
2360
|
if (!_legendMeasureCtx) {
|
|
2324
2361
|
_legendMeasureCtx = document.createElement('canvas').getContext('2d');
|
|
2325
2362
|
}
|
|
2326
|
-
_legendMeasureCtx
|
|
2327
|
-
|
|
2363
|
+
return _legendMeasureCtx;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function legendTextWidth(text, font) {
|
|
2367
|
+
let ctx = measureCtx();
|
|
2368
|
+
if (!ctx) {
|
|
2369
|
+
return 0;
|
|
2370
|
+
}
|
|
2371
|
+
ctx.font = font;
|
|
2372
|
+
return ctx.measureText(text).width;
|
|
2328
2373
|
}
|
|
2329
2374
|
|
|
2330
2375
|
// The card's row order and cap, the desktop's way: rank by count first
|
|
@@ -2935,7 +2980,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
2935
2980
|
return {source: link.source.data, target: link.target.data};
|
|
2936
2981
|
});
|
|
2937
2982
|
_overviewLinks = links; // the overview is built from these, not from the DOM
|
|
2938
|
-
let gap = _state.nodeLabelGap;
|
|
2939
2983
|
|
|
2940
2984
|
if (_state.phylogram === true) {
|
|
2941
2985
|
_yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
|
|
@@ -3017,9 +3061,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
3017
3061
|
}
|
|
3018
3062
|
|
|
3019
3063
|
if (_state.dynahide) {
|
|
3020
|
-
if (
|
|
3021
|
-
//
|
|
3022
|
-
//
|
|
3064
|
+
if (radialDisplay()) {
|
|
3065
|
+
// The every-k-th DECIMATION does not run in either radial
|
|
3066
|
+
// layout: it thins by index against even ROW spacing, which a
|
|
3067
|
+
// fan does not have. An unrooted fan has no rows at all; a
|
|
3068
|
+
// circular one has a ring, whose room is set by its
|
|
3069
|
+
// circumference and not by the display's height. Measured on
|
|
3070
|
+
// Caliciviridae_100 at 1100x850, the index rule kept 48 of 97
|
|
3071
|
+
// names in circular while all 97 fitted the ring with NOT ONE
|
|
3072
|
+
// overlapping pair, and on flu_h5 it kept 59 of 354 where 118
|
|
3073
|
+
// are readable. (118 is the shipped rule measured; 100 was a
|
|
3074
|
+
// greedy estimate in DOM order made before it was built, and
|
|
3075
|
+
// is what this comment said until a review caught it against
|
|
3076
|
+
// README and CHANGELOG.) Both are now thinned by hideCrowdedTipLabels
|
|
3077
|
+
// instead, on whether the names actually overprint.
|
|
3023
3078
|
forester.preOrderTraversal(_root, function (n) {
|
|
3024
3079
|
n.hide = false;
|
|
3025
3080
|
});
|
|
@@ -3031,8 +3086,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
3031
3086
|
});
|
|
3032
3087
|
}
|
|
3033
3088
|
}
|
|
3034
|
-
syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
|
|
3035
|
-
|
|
3036
3089
|
updateButtonEnabledState();
|
|
3037
3090
|
|
|
3038
3091
|
// Only the nodes with something drawn for them individually get a
|
|
@@ -3099,11 +3152,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
3099
3152
|
return makeFoundOutlineColor(d) ? '0.9px' : null;
|
|
3100
3153
|
})
|
|
3101
3154
|
.style('paint-order', 'stroke')
|
|
3155
|
+
// anchor, dy and x all come from nodeLabelPlacement, which the
|
|
3156
|
+
// occupancy box is built from too
|
|
3102
3157
|
.attr('text-anchor', function (d) {
|
|
3103
|
-
|
|
3104
|
-
return labelFlip(d) ? 'end' : 'start';
|
|
3105
|
-
}
|
|
3106
|
-
return d.children ? 'end' : 'start';
|
|
3158
|
+
return nodeLabelPlacement(d).anchor;
|
|
3107
3159
|
})
|
|
3108
3160
|
.attr('transform', function (d) {
|
|
3109
3161
|
if (_state.unrootedDisplay) {
|
|
@@ -3131,42 +3183,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
3131
3183
|
return 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
|
|
3132
3184
|
})
|
|
3133
3185
|
.attr('dy', function (d) {
|
|
3134
|
-
|
|
3135
|
-
return '0.32em';
|
|
3136
|
-
}
|
|
3137
|
-
if (d.children) {
|
|
3138
|
-
// the desktop's above-branch internal label: the baseline
|
|
3139
|
-
// is lifted so the glyph bottoms sit just clear of the
|
|
3140
|
-
// horizontal branch line, instead of straddling it
|
|
3141
|
-
return -(Math.round(0.2 * _state.internalNodeFontSize) + 1) + 'px';
|
|
3142
|
-
}
|
|
3143
|
-
return 0.3 * _state.externalNodeFontSize + 'px';
|
|
3186
|
+
return nodeLabelPlacement(d).dy + 'px';
|
|
3144
3187
|
})
|
|
3145
3188
|
.attr('x', function (d) {
|
|
3146
|
-
|
|
3147
|
-
return labelFlip(d) ? -gap : gap;
|
|
3148
|
-
}
|
|
3149
|
-
if (!(d.children)) {
|
|
3150
|
-
if (_state.phylogram && _state.alignPhylogram) {
|
|
3151
|
-
return (-_yScale(d.distToRoot) + _w + gap);
|
|
3152
|
-
} else {
|
|
3153
|
-
return gap;
|
|
3154
|
-
}
|
|
3155
|
-
} else {
|
|
3156
|
-
// right-aligned so the label ends just left of the node
|
|
3157
|
-
// (the desktop's publication-style placement); a long
|
|
3158
|
-
// label on a node near the root shifts right so its
|
|
3159
|
-
// leading characters stay on the canvas rather than
|
|
3160
|
-
// clipping off the left edge
|
|
3161
|
-
let label = makeNodeLabel(d) || '';
|
|
3162
|
-
let est = label.length * _state.internalNodeFontSize * 0.55;
|
|
3163
|
-
let leftmost = d.y - gap - est;
|
|
3164
|
-
let minX = 2 - _settings.rootOffset;
|
|
3165
|
-
if (leftmost < minX) {
|
|
3166
|
-
return -gap + (minX - leftmost);
|
|
3167
|
-
}
|
|
3168
|
-
return -gap;
|
|
3169
|
-
}
|
|
3189
|
+
return nodeLabelPlacement(d).x;
|
|
3170
3190
|
});
|
|
3171
3191
|
|
|
3172
3192
|
node.select('text.bllabel')
|
|
@@ -3178,7 +3198,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
3178
3198
|
.attr('transform', function (d) {
|
|
3179
3199
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3180
3200
|
})
|
|
3181
|
-
|
|
3201
|
+
// Every one of these three reads its offset from branchMarkPlacement,
|
|
3202
|
+
// the same function the occupancy boxes are built from: a box that is
|
|
3203
|
+
// not where the text is hides the wrong marks, and two copies of the
|
|
3204
|
+
// same number drift apart the first time one of them is edited.
|
|
3205
|
+
.attr('dy', branchMarkPlacement('bl', _state.branchDataFontSize).dy)
|
|
3182
3206
|
.attr('x', function (d) {
|
|
3183
3207
|
if (radialDisplay()) {
|
|
3184
3208
|
return 0;
|
|
@@ -3199,7 +3223,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3199
3223
|
.attr('transform', function (d) {
|
|
3200
3224
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3201
3225
|
})
|
|
3202
|
-
.attr('dy', _state.branchDataFontSize)
|
|
3226
|
+
.attr('dy', branchMarkPlacement('conf', _state.branchDataFontSize).dy)
|
|
3203
3227
|
.attr('x', function (d) {
|
|
3204
3228
|
if (radialDisplay()) {
|
|
3205
3229
|
return 0;
|
|
@@ -3235,7 +3259,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3235
3259
|
.attr('transform', function (d) {
|
|
3236
3260
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3237
3261
|
})
|
|
3238
|
-
.attr('dy', '
|
|
3262
|
+
.attr('dy', branchMarkPlacement('event', _state.branchDataFontSize).dy)
|
|
3239
3263
|
.attr('x', function (d) {
|
|
3240
3264
|
if (radialDisplay()) {
|
|
3241
3265
|
return 0;
|
|
@@ -3326,7 +3350,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3326
3350
|
// a collapsed clade's label always shows, so its guide does too: from
|
|
3327
3351
|
// past the wedge's farthest tip to the label column
|
|
3328
3352
|
let collapsedGuides = alignedRect ? nodes.filter(function (d) {
|
|
3329
|
-
return isCollapsed(d) &&
|
|
3353
|
+
return isCollapsed(d) && nameDrawn(d);
|
|
3330
3354
|
}) : [];
|
|
3331
3355
|
if (tipGuides || collapsedGuides.length > 0) {
|
|
3332
3356
|
let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
|
|
@@ -3339,7 +3363,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3339
3363
|
if (tipGuides) {
|
|
3340
3364
|
guideStyle(ext.selectAll('path.aptx-tip-guide')
|
|
3341
3365
|
.data(links.filter(function (d) {
|
|
3342
|
-
return (!d.target.children &&
|
|
3366
|
+
return (!d.target.children && nameDrawn(d.target));
|
|
3343
3367
|
}))
|
|
3344
3368
|
.enter().append('path').attr('class', 'aptx-tip-guide'))
|
|
3345
3369
|
.attr('d', function (d) {
|
|
@@ -3362,7 +3386,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3362
3386
|
// tips while their labels show; a collapsed clade always (its label
|
|
3363
3387
|
// always shows), from its wedge's farthest reach out to the ring
|
|
3364
3388
|
let connected = nodes.filter(function (d) {
|
|
3365
|
-
if (
|
|
3389
|
+
if (!nameDrawn(d)) {
|
|
3366
3390
|
return false;
|
|
3367
3391
|
}
|
|
3368
3392
|
if (isCollapsed(d)) {
|
|
@@ -3820,6 +3844,420 @@ function (root, d3, forester, phyloXml) {
|
|
|
3820
3844
|
// a support dot. Returns the nodes that need a <g> -- those with any of
|
|
3821
3845
|
// them, and every collapsed clade (its wedge and label). The rest are
|
|
3822
3846
|
// drawn entirely by drawTreeGeometry's batched paths.
|
|
3847
|
+
// ===================== Crowded branch data =====================
|
|
3848
|
+
// A branch-anchored mark is drawn only when its box does not overlap one
|
|
3849
|
+
// already granted in the same pass. Ported from the desktop's
|
|
3850
|
+
// LabelOccupancy (0.11.161) as a JOINT rule on Christian's word,
|
|
3851
|
+
// 2026-09-25: "match the desktop's new auto-hide rule for crowded branch
|
|
3852
|
+
// data". Neither side retunes it alone.
|
|
3853
|
+
//
|
|
3854
|
+
// It exists because the obvious rule does not work. The desktop built
|
|
3855
|
+
// "hide when the branch is drawn shorter than the text" first and measured
|
|
3856
|
+
// it: on an 825-tip tree a third-of-text threshold hid 285 numbers of
|
|
3857
|
+
// which only 51 collided, and still left 22 pile-ups. A number is
|
|
3858
|
+
// unreadable when it overlaps ANOTHER number, which depends on closeness
|
|
3859
|
+
// in y as much as in x, and branch length only sees x. Measured here
|
|
3860
|
+
// before adopting it: with Auto-hide Labels already ON, 240 of 267 drawn
|
|
3861
|
+
// support numbers overlapped another on flu_h5.xml (90%), 149 of 262 on
|
|
3862
|
+
// Adenoviridae, 13 of 119 on bcl2 -- our auto-hide only thinned TIP labels
|
|
3863
|
+
// then, and had never touched branch data.
|
|
3864
|
+
//
|
|
3865
|
+
// WHAT MUST MATCH, or the two viewers drop different marks:
|
|
3866
|
+
// - the tie-break: PREORDER, first claim wins, so the mark nearer the
|
|
3867
|
+
// root keeps its place. d3's descendants() is preorder already.
|
|
3868
|
+
// - TWO maps, numbers and symbols. The symbol sits ON the branch and the
|
|
3869
|
+
// numbers just above and below it, so one shared map would let a
|
|
3870
|
+
// branch's own symbol block its own numbers.
|
|
3871
|
+
// - a refused claim records NOTHING, so a rejected mark never blocks a
|
|
3872
|
+
// later one.
|
|
3873
|
+
// - a zero-size box is granted and not recorded.
|
|
3874
|
+
// - symbols are never shrunk to fit: in a size-scaled mode the diameter
|
|
3875
|
+
// IS the support, and a smaller dot would report weaker support.
|
|
3876
|
+
//
|
|
3877
|
+
// WHAT CANNOT MATCH, and is not meant to: the COUNTS. The boxes come from
|
|
3878
|
+
// each program's own font metrics, so the same tree at the same size drops
|
|
3879
|
+
// a similar but not identical set. The rule is the contract; the tally is
|
|
3880
|
+
// not.
|
|
3881
|
+
//
|
|
3882
|
+
// Where a branch-anchored mark sits, in the tree group's coordinates.
|
|
3883
|
+
// Mirrors the placement in syncOptionalNodeChildren exactly -- the branch
|
|
3884
|
+
// length back toward the parent and above the branch, the confidence at
|
|
3885
|
+
// the branch midpoint and below it -- because a box that is not where the
|
|
3886
|
+
// text is would hide the wrong marks.
|
|
3887
|
+
// The font's LINE box, which is what the marks are laid out with and what
|
|
3888
|
+
// the desktop measures too (FontMetrics.getHeight()). Not the per-glyph
|
|
3889
|
+
// ink box: two numbers collide when their lines overlap, and ink boxes
|
|
3890
|
+
// would let "1" slide under "0.81".
|
|
3891
|
+
//
|
|
3892
|
+
// Measured from the font rather than assumed. The fallback is what this
|
|
3893
|
+
// font actually reports at 9px -- ascent 9, descent 2, line 11 -- read off
|
|
3894
|
+
// a rendered label's getBBox rather than guessed.
|
|
3895
|
+
let _fontMetricsCache = new Map();
|
|
3896
|
+
|
|
3897
|
+
// how many names the radial overlap rule took on the last render, for the
|
|
3898
|
+
// Auto-hide indicator: those layouts thin by overlap, not by index, so
|
|
3899
|
+
// _dynahide_factor says nothing about them
|
|
3900
|
+
let _radialNamesHidden = 0;
|
|
3901
|
+
|
|
3902
|
+
// and how many branch marks the crowding rule refused on the last render.
|
|
3903
|
+
// The switch governs three things -- the every-k-th thinning, the radial
|
|
3904
|
+
// overlap thinning and the branch-data rule -- and the indicator used to
|
|
3905
|
+
// ask only about names, so a view where every name fitted but 201 numbers
|
|
3906
|
+
// were being refused showed a dark toggle over a tree it was thinning.
|
|
3907
|
+
// (Caliciviridae circular; apaf RECTANGULAR did it too, so this half was
|
|
3908
|
+
// never right in any layout.)
|
|
3909
|
+
let _branchMarksHidden = 0;
|
|
3910
|
+
|
|
3911
|
+
function branchFontMetrics(fontPx) {
|
|
3912
|
+
// One cache for every size asked for, not one slot: labels and branch
|
|
3913
|
+
// marks run at different sizes in the same pass, and a single slot
|
|
3914
|
+
// would re-measure on every alternation.
|
|
3915
|
+
let hit = _fontMetricsCache.get(fontPx);
|
|
3916
|
+
if (hit) {
|
|
3917
|
+
return hit;
|
|
3918
|
+
}
|
|
3919
|
+
let ctx = measureCtx();
|
|
3920
|
+
ctx.font = fontPx + 'px ' + FONT_DEFAULTS;
|
|
3921
|
+
let m = ctx.measureText('0');
|
|
3922
|
+
let ascent = m.fontBoundingBoxAscent;
|
|
3923
|
+
let descent = m.fontBoundingBoxDescent;
|
|
3924
|
+
// Both, separately: a canvas that reports an ascent and no descent
|
|
3925
|
+
// would give height = ascent + undefined = NaN, every box would be
|
|
3926
|
+
// refused a claim as zero-sized, and the feature would turn itself off
|
|
3927
|
+
// with no error and a green suite.
|
|
3928
|
+
if (!(ascent > 0)) {
|
|
3929
|
+
ascent = fontPx;
|
|
3930
|
+
}
|
|
3931
|
+
if (!(descent >= 0)) {
|
|
3932
|
+
descent = fontPx * (2 / 9);
|
|
3933
|
+
}
|
|
3934
|
+
let metrics = {px: fontPx, ascent: ascent, descent: descent,
|
|
3935
|
+
height: ascent + descent};
|
|
3936
|
+
_fontMetricsCache.set(fontPx, metrics);
|
|
3937
|
+
return metrics;
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
// Where a branch mark sits on its branch, read off the rendered elements
|
|
3941
|
+
// rather than inferred: the horizontal anchor and the `dy` the element is
|
|
3942
|
+
// drawn with. Getting either wrong is not a rounding error. A
|
|
3943
|
+
// middle-anchored box modelled as left-anchored is half its own width out,
|
|
3944
|
+
// which once let 44% of the granted numbers still overlap; and a branch
|
|
3945
|
+
// EVENT sits a quarter em ABOVE the line like the branch length, not a
|
|
3946
|
+
// font size below it like the confidence, so modelling it as a confidence
|
|
3947
|
+
// put its box on the wrong side of the branch -- the one side it could
|
|
3948
|
+
// never collide on.
|
|
3949
|
+
function branchMarkPlacement(kind, fontPx) {
|
|
3950
|
+
if (kind === 'conf') {
|
|
3951
|
+
return {mid: true, dy: fontPx};
|
|
3952
|
+
}
|
|
3953
|
+
if (kind === 'event') {
|
|
3954
|
+
// A radial layout draws every mark on ONE point, the branch's
|
|
3955
|
+
// midpoint, so a branch event and a branch length -- both a quarter
|
|
3956
|
+
// em above the line -- printed straight through each other. The
|
|
3957
|
+
// event is lifted a whole line clear of the length. The rectangular
|
|
3958
|
+
// layout already separates them along the branch (the length at the
|
|
3959
|
+
// parent end, the event at the middle), so there they share a line.
|
|
3960
|
+
return {mid: true, dy: radialDisplay()
|
|
3961
|
+
? ((-0.25 * fontPx) - branchFontMetrics(fontPx).height)
|
|
3962
|
+
: (-0.25 * fontPx)};
|
|
3963
|
+
}
|
|
3964
|
+
return {mid: false, dy: -0.25 * fontPx}; // 'bl': start-anchored, a quarter em up
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
// The incoming branch's MIDPOINT and screen direction, in the tree group's
|
|
3968
|
+
// own coordinates -- what a radial mark is actually drawn on, since
|
|
3969
|
+
// branchLabelTransform rotates to the spoke and translates half a branch
|
|
3970
|
+
// back along it. Circular: the leg runs along ONE spoke, so the midpoint is
|
|
3971
|
+
// the mean of the two radii at the same angle (an arc's chord is not on the
|
|
3972
|
+
// leg). Unrooted: the branch is a straight line, so it is the mean of the
|
|
3973
|
+
// two points. Both match the desktop's two call sites of
|
|
3974
|
+
// paintBranchDataRadial.
|
|
3975
|
+
function radialBranchMid(d) {
|
|
3976
|
+
let angle = spokeAngle(d);
|
|
3977
|
+
if (_state.unrootedDisplay) {
|
|
3978
|
+
if (d.ux === undefined) {
|
|
3979
|
+
return null;
|
|
3980
|
+
}
|
|
3981
|
+
let p = (d.parent && d.parent.ux !== undefined) ? d.parent : null;
|
|
3982
|
+
return p ? [0.5 * (d.ux + p.ux), 0.5 * (d.uy + p.uy), angle] : [d.ux, d.uy, angle];
|
|
3983
|
+
}
|
|
3984
|
+
let r = radialRadius(d.y);
|
|
3985
|
+
if (d.parent) {
|
|
3986
|
+
r = 0.5 * (r + radialRadius(d.parent.y));
|
|
3987
|
+
}
|
|
3988
|
+
let pt = polarXY(radialAngle(d.x), r);
|
|
3989
|
+
return [pt[0], pt[1], angle];
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
function branchMarkBox(d, kind, text, fontPx) {
|
|
3993
|
+
let w = legendTextWidth(text, fontPx + 'px ' + FONT_DEFAULTS);
|
|
3994
|
+
let fm = branchFontMetrics(fontPx);
|
|
3995
|
+
let h = fm.height;
|
|
3996
|
+
let at = branchMarkPlacement(kind, fontPx);
|
|
3997
|
+
if (radialDisplay()) {
|
|
3998
|
+
// The mark rides the branch, so its box is the AXIS-ALIGNED bounds
|
|
3999
|
+
// of the rotated rectangle, centred where the text's own centre
|
|
4000
|
+
// lands: forester.rotatedLabelBox, which carries the desktop's
|
|
4001
|
+
// formula and the reasons it is the conservative one.
|
|
4002
|
+
let mid = radialBranchMid(d);
|
|
4003
|
+
if (!mid) {
|
|
4004
|
+
return null;
|
|
4005
|
+
}
|
|
4006
|
+
// `dy` shifts the BASELINE, so the box's centre sits half a line
|
|
4007
|
+
// below it; and the 180-degree flip that keeps the far half of the
|
|
4008
|
+
// fan upright carries this perpendicular offset with it.
|
|
4009
|
+
let off = at.dy + (0.5 * (fm.descent - fm.ascent));
|
|
4010
|
+
if (labelFlip(d)) {
|
|
4011
|
+
off = -off;
|
|
4012
|
+
}
|
|
4013
|
+
// every radial mark is middle-anchored at x=0, so only the
|
|
4014
|
+
// perpendicular offset moves its centre off the branch midpoint
|
|
4015
|
+
return forester.rotatedLabelBox(mid[0] - (Math.sin(mid[2]) * off),
|
|
4016
|
+
mid[1] + (Math.cos(mid[2]) * off), w, h, mid[2]);
|
|
4017
|
+
}
|
|
4018
|
+
// The ROOT's numbers are drawn too, at x=0 in its own group, and they
|
|
4019
|
+
// used to claim nothing -- so the first child's number, landing in
|
|
4020
|
+
// almost the same place, was granted and painted over them. The one
|
|
4021
|
+
// place the pass could not keep its promise.
|
|
4022
|
+
let span = d.parent ? (d.parent.y - d.y) : 0;
|
|
4023
|
+
let x = at.mid
|
|
4024
|
+
? (d.y + (0.5 * span) - (w / 2)) // middle-anchored
|
|
4025
|
+
: (d.y + span + 1); // start-anchored
|
|
4026
|
+
return [x, (d.x + at.dy) - fm.ascent, w, h];
|
|
4027
|
+
}
|
|
4028
|
+
|
|
4029
|
+
// ---- the node's own label: one source for drawing it and for reserving
|
|
4030
|
+
// its space ----
|
|
4031
|
+
//
|
|
4032
|
+
// The three attributes below (text-anchor, x, dy) and the occupancy box
|
|
4033
|
+
// that keeps branch numbers off the name are the same decision, so they
|
|
4034
|
+
// are made once here. Modelled after branchMarkPlacement, and for the same
|
|
4035
|
+
// reason: a box that is not where the text is reserves the wrong space,
|
|
4036
|
+
// and two copies of the arithmetic drift the first time one is edited.
|
|
4037
|
+
function nodeLabelPlacement(d) {
|
|
4038
|
+
let gap = _state.nodeLabelGap;
|
|
4039
|
+
if (radialDisplay()) {
|
|
4040
|
+
let flip = labelFlip(d);
|
|
4041
|
+
return {anchor: flip ? 'end' : 'start', x: flip ? -gap : gap,
|
|
4042
|
+
dy: 0.32 * nodeLabelFontSize(d)};
|
|
4043
|
+
}
|
|
4044
|
+
if (!d.children) {
|
|
4045
|
+
let x = (_state.phylogram && _state.alignPhylogram)
|
|
4046
|
+
? (-_yScale(d.distToRoot) + _w + gap)
|
|
4047
|
+
: gap;
|
|
4048
|
+
return {anchor: 'start', x: x, dy: 0.3 * _state.externalNodeFontSize};
|
|
4049
|
+
}
|
|
4050
|
+
// the desktop's above-branch internal label: right-aligned so it ends
|
|
4051
|
+
// just left of the node, and shifted right when a long label on a node
|
|
4052
|
+
// near the root would otherwise clip off the left edge
|
|
4053
|
+
let label = makeNodeLabel(d) || '';
|
|
4054
|
+
let est = label.length * _state.internalNodeFontSize * 0.55;
|
|
4055
|
+
let leftmost = d.y - gap - est;
|
|
4056
|
+
let minX = 2 - _settings.rootOffset;
|
|
4057
|
+
let x = (leftmost < minX) ? (-gap + (minX - leftmost)) : -gap;
|
|
4058
|
+
return {anchor: 'end', x: x,
|
|
4059
|
+
dy: -(Math.round(0.2 * _state.internalNodeFontSize) + 1)};
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
// The size the label is actually drawn at: a per-node style overrides the
|
|
4063
|
+
// internal/external default, and the box has to follow it or a styled
|
|
4064
|
+
// tree reserves the wrong space.
|
|
4065
|
+
function nodeLabelFontSize(d) {
|
|
4066
|
+
let style = nodeStyle(d);
|
|
4067
|
+
if (style && style.fontSize) {
|
|
4068
|
+
return style.fontSize;
|
|
4069
|
+
}
|
|
4070
|
+
return d.children ? _state.internalNodeFontSize : _state.externalNodeFontSize;
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
// The label's rectangle in the tree group's coordinates, as a start point
|
|
4074
|
+
// on the baseline, a direction the text runs in, and its extents.
|
|
4075
|
+
// Returns null when there is no label drawn.
|
|
4076
|
+
function nodeLabelGeometry(d) {
|
|
4077
|
+
let text = d._extLabelText;
|
|
4078
|
+
if (!text) {
|
|
4079
|
+
return null;
|
|
4080
|
+
}
|
|
4081
|
+
let fontPx = nodeLabelFontSize(d);
|
|
4082
|
+
let style = nodeStyle(d);
|
|
4083
|
+
let bold = getFoundColor(d)
|
|
4084
|
+
|| (style && (style.fontStyle === 'bold' || style.fontStyle === 'bold_italic'));
|
|
4085
|
+
let w = legendTextWidth(text, (bold ? 'bold ' : '') + fontPx + 'px ' + FONT_DEFAULTS);
|
|
4086
|
+
let fm = branchFontMetrics(fontPx);
|
|
4087
|
+
let p = nodeLabelPlacement(d);
|
|
4088
|
+
let at = layoutPointXY(d);
|
|
4089
|
+
if (at[0] === undefined || at[1] === undefined) {
|
|
4090
|
+
return null; // not laid out (an unrooted node the layout never reached)
|
|
4091
|
+
}
|
|
4092
|
+
if (!radialDisplay() || _radialLabelsHorizontal) {
|
|
4093
|
+
// upright text, whatever the layout: one axis-aligned rectangle
|
|
4094
|
+
let base = at;
|
|
4095
|
+
if (_state.circularDisplay && _radialLabelsHorizontal && !d.children && _radial) {
|
|
4096
|
+
// the label rides out to its point on the ring, in screen terms
|
|
4097
|
+
base = polarXY(radialAngle(d.x), _radial.maxRad);
|
|
4098
|
+
}
|
|
4099
|
+
let x0 = base[0] + p.x - (p.anchor === 'end' ? w : 0);
|
|
4100
|
+
return {x: x0, y: (base[1] + p.dy) - fm.ascent, w: w, h: fm.height, angle: 0};
|
|
4101
|
+
}
|
|
4102
|
+
// Radial: the text starts a gap beyond the node (or beyond the ring,
|
|
4103
|
+
// for a circular tip) and runs OUTWARD along the spoke -- on both
|
|
4104
|
+
// halves of the fan, because the 180-degree flip that keeps the far
|
|
4105
|
+
// half upright also reverses which end the anchor is.
|
|
4106
|
+
//
|
|
4107
|
+
// Built as an offset FROM THE NODE, not as a distance from the origin.
|
|
4108
|
+
// A circular node lies on the ray its label runs along, so radius
|
|
4109
|
+
// arithmetic happens to work there; an unrooted one does not -- its
|
|
4110
|
+
// spoke angle is the direction its own branch came in at, which has
|
|
4111
|
+
// nothing to do with its bearing from the centre. Measured with the
|
|
4112
|
+
// radius version: unrooted still had 7 of 12 numbers printing through
|
|
4113
|
+
// a name on flu_h5, because the boxes were being reserved in places
|
|
4114
|
+
// no label was.
|
|
4115
|
+
let theta = spokeAngle(d);
|
|
4116
|
+
let along = _state.nodeLabelGap;
|
|
4117
|
+
if (_state.circularDisplay && !d.children && _radial) {
|
|
4118
|
+
along += _radial.maxRad - radialRadius(d.y); // the pull out to the ring
|
|
4119
|
+
}
|
|
4120
|
+
let flip = labelFlip(d);
|
|
4121
|
+
// the baseline offset is perpendicular to the text, and the flip
|
|
4122
|
+
// carries it across with everything else
|
|
4123
|
+
let off = flip ? -p.dy : p.dy;
|
|
4124
|
+
let top = flip ? (off - fm.descent) : (off - fm.ascent);
|
|
4125
|
+
let cos = Math.cos(theta), sin = Math.sin(theta);
|
|
4126
|
+
return {x: at[0] + (cos * along) - (sin * top),
|
|
4127
|
+
y: at[1] + (sin * along) + (cos * top),
|
|
4128
|
+
w: w, h: fm.height, angle: theta};
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
// The label's own outline, as four corners in the tree group's
|
|
4132
|
+
// coordinates: what the turned-box occupancy tests against.
|
|
4133
|
+
function nodeLabelQuad(g) {
|
|
4134
|
+
if (!g || !(g.w > 0) || !(g.h > 0)) {
|
|
4135
|
+
return null;
|
|
4136
|
+
}
|
|
4137
|
+
let cos = Math.cos(g.angle), sin = Math.sin(g.angle);
|
|
4138
|
+
let corners = [[0, 0], [g.w, 0], [g.w, g.h], [0, g.h]];
|
|
4139
|
+
let quad = [];
|
|
4140
|
+
for (let i = 0; i < corners.length; ++i) {
|
|
4141
|
+
quad.push([g.x + (cos * corners[i][0]) - (sin * corners[i][1]),
|
|
4142
|
+
g.y + (sin * corners[i][0]) + (cos * corners[i][1])]);
|
|
4143
|
+
}
|
|
4144
|
+
return quad;
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
// ---- crowded TIP LABELS, in both radial layouts ----
|
|
4148
|
+
//
|
|
4149
|
+
// The desktop's rule: a tip label is drawn only if its own outline overlaps
|
|
4150
|
+
// no label already drawn this pass. Both fans use it; the rectangular
|
|
4151
|
+
// layout keeps the every-k-th thinning, whose row pitch is a real quantity
|
|
4152
|
+
// there.
|
|
4153
|
+
//
|
|
4154
|
+
// Unrooted had nothing at all before this -- its labels sit wherever the
|
|
4155
|
+
// equal-angle layout put the tip. Circular had the rectangular rule, which
|
|
4156
|
+
// reads the display's HEIGHT against the node count and says nothing about
|
|
4157
|
+
// a ring: it kept 48 of 97 names on Caliciviridae where all 97 fit with no
|
|
4158
|
+
// overlap, and 59 of 354 on flu_h5 where 118 are readable.
|
|
4159
|
+
//
|
|
4160
|
+
// A SEARCH HIT is placed without being asked, so it can be drawn across a
|
|
4161
|
+
// name already down -- intended (the hit is what is being looked for), and
|
|
4162
|
+
// measured: a 3-hit search on confidences unrooted puts 3 hits across
|
|
4163
|
+
// non-hits, 8.1 px deep. It is the one case where "drawn only where it
|
|
4164
|
+
// overlaps nothing" does not hold, and the README says so.
|
|
4165
|
+
// Measured at 1100x850 before this, overlapping pairs among the labels
|
|
4166
|
+
// drawn: 172 of 97 labels on Caliciviridae_100, 71 of 42 on confidences,
|
|
4167
|
+
// 10 of 31 on apaf -- clumps of a dozen names printed through each other.
|
|
4168
|
+
//
|
|
4169
|
+
// OUTLINES, not their axis-aligned bounds: a name lying near its spoke has
|
|
4170
|
+
// bounds several times its own area, and on a 48-label ring those bounds
|
|
4171
|
+
// called 28 pairs crowded where a reader sees none.
|
|
4172
|
+
//
|
|
4173
|
+
// Order is the tree's own, root first (forester.preorderOf), which is also
|
|
4174
|
+
// what the desktop's unrooted painter does -- it reaches an internal node
|
|
4175
|
+
// before its subtree -- so both programs keep the same label when two
|
|
4176
|
+
// contend. A search HIT is placed without being asked, so the name being
|
|
4177
|
+
// looked for is always on the screen and everything after it keeps clear
|
|
4178
|
+
// of it (Christian, 2026-09-26: hits are exempt for tip labels, and for
|
|
4179
|
+
// nothing else).
|
|
4180
|
+
// Is this node's name on the screen? Asked by everything drawn to a label
|
|
4181
|
+
// -- the aligned guides, the circular ring connectors -- and it must be
|
|
4182
|
+
// ONE question. It used to be spelled `_state.dynahide && d.hide`, which
|
|
4183
|
+
// is the every-k-th rule and nothing else: when the radial layouts started
|
|
4184
|
+
// hiding by overlap they left `hide` false for everyone, so on flu_h5 in
|
|
4185
|
+
// circular 354 dashed connectors ran out to the ring for 118 names, 236 of
|
|
4186
|
+
// them ending at nothing. `_labelDropped` is set by BOTH rules.
|
|
4187
|
+
function nameDrawn(d) {
|
|
4188
|
+
return !d._labelDropped;
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
function hideCrowdedTipLabels(nodes) {
|
|
4192
|
+
_radialNamesHidden = 0;
|
|
4193
|
+
if (!_state.dynahide || !radialDisplay()) {
|
|
4194
|
+
return;
|
|
4195
|
+
}
|
|
4196
|
+
// no layout, no honest outlines; bailing draws everything
|
|
4197
|
+
if (_state.unrootedDisplay ? !_unroot : !_radial) {
|
|
4198
|
+
return;
|
|
4199
|
+
}
|
|
4200
|
+
let labels = forester.orientedOccupancy(Math.max(4, _state.externalNodeFontSize));
|
|
4201
|
+
let ordered = forester.preorderOf(nodes);
|
|
4202
|
+
for (let i = 0, len = ordered.length; i !== len; ++i) {
|
|
4203
|
+
let d = ordered[i];
|
|
4204
|
+
if (d.children || d._extLabelText === '') {
|
|
4205
|
+
continue;
|
|
4206
|
+
}
|
|
4207
|
+
let quad = nodeLabelQuad(nodeLabelGeometry(d));
|
|
4208
|
+
if (!quad) {
|
|
4209
|
+
continue;
|
|
4210
|
+
}
|
|
4211
|
+
if (getFoundColor(d)) {
|
|
4212
|
+
labels.occupy(quad);
|
|
4213
|
+
} else if (!labels.claim(quad)) {
|
|
4214
|
+
d._extLabelText = '';
|
|
4215
|
+
d._labelDropped = true;
|
|
4216
|
+
++_radialNamesHidden;
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
// The boxes to reserve for a label. An upright label is one rectangle. A
|
|
4222
|
+
// rotated one is reserved as a RUN of short boxes along its baseline,
|
|
4223
|
+
// never as one axis-aligned box around the whole thing: the bounds of a
|
|
4224
|
+
// rotated rectangle grow with its LENGTH, so a 120 px name lying at 30
|
|
4225
|
+
// degrees would reserve several times its own area. Stepping by the line
|
|
4226
|
+
// height bounds the error at one step whatever the name is.
|
|
4227
|
+
//
|
|
4228
|
+
// Honestly measured: the run and the single box draw the SAME numbers on
|
|
4229
|
+
// every tree tried (apaf, Caliciviridae, confidences) bar one -- flu_h5
|
|
4230
|
+
// unrooted, 4 against 3 -- and a purpose-built sparse fixture with 28-
|
|
4231
|
+
// character names could not separate them at all
|
|
4232
|
+
// (test_trees/label_reservation.html). The views that would show the
|
|
4233
|
+
// difference are already saturated, and the ones with room refuse nothing
|
|
4234
|
+
// either way. It is kept for the bound, not for the count: the error of
|
|
4235
|
+
// the single box grows without limit as names get longer, and this is
|
|
4236
|
+
// ten lines.
|
|
4237
|
+
function nodeLabelClaimBoxes(g) {
|
|
4238
|
+
if (!g || !(g.w > 0) || !(g.h > 0)) {
|
|
4239
|
+
return [];
|
|
4240
|
+
}
|
|
4241
|
+
if (g.angle === 0) {
|
|
4242
|
+
return [[g.x, g.y, g.w, g.h]];
|
|
4243
|
+
}
|
|
4244
|
+
let cos = Math.cos(g.angle), sin = Math.sin(g.angle);
|
|
4245
|
+
let step = Math.max(4, g.h);
|
|
4246
|
+
let boxes = [];
|
|
4247
|
+
for (let t = 0; t < g.w; t += step) {
|
|
4248
|
+
let len = Math.min(step, g.w - t);
|
|
4249
|
+
// the four corners of this slice, in tree coordinates
|
|
4250
|
+
let xs = [], ys = [];
|
|
4251
|
+
for (const [a, b] of [[t, 0], [t + len, 0], [t + len, g.h], [t, g.h]]) {
|
|
4252
|
+
xs.push(g.x + (cos * a) - (sin * b));
|
|
4253
|
+
ys.push(g.y + (sin * a) + (cos * b));
|
|
4254
|
+
}
|
|
4255
|
+
let x0 = Math.min(...xs), y0 = Math.min(...ys);
|
|
4256
|
+
boxes.push([x0, y0, Math.max(...xs) - x0, Math.max(...ys) - y0]);
|
|
4257
|
+
}
|
|
4258
|
+
return boxes;
|
|
4259
|
+
}
|
|
4260
|
+
|
|
3823
4261
|
function prepareNodeDrawing(nodes) {
|
|
3824
4262
|
let wantBl = _state.showBranchLengthValues === true;
|
|
3825
4263
|
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
@@ -3836,11 +4274,30 @@ function (root, d3, forester, phyloXml) {
|
|
|
3836
4274
|
}
|
|
3837
4275
|
// Empty is the common case on a big tree: Auto-hide Labels blanks
|
|
3838
4276
|
// them exactly when there are most nodes to blank.
|
|
3839
|
-
|
|
4277
|
+
// WHY the text is empty matters downstream: a label the crowding
|
|
4278
|
+
// rule took away is a different thing from a label the user
|
|
4279
|
+
// switched off, and marks that accompany a name must tell them
|
|
4280
|
+
// apart (drawDomainArchitectures).
|
|
4281
|
+
d._labelDropped = _state.dynahide === true && d.hide === true;
|
|
4282
|
+
d._extLabelText = d._labelDropped ? '' : (makeNodeLabel(d) || '');
|
|
3840
4283
|
d._blText = wantBl ? asText(makeBranchLengthLabel(d)) : '';
|
|
3841
4284
|
d._confText = wantConf ? asText(makeConfidenceValuesLabel(d)) : '';
|
|
3842
4285
|
d._eventText = wantEvent ? asText(makeBranchEventsLabel(d)) : '';
|
|
3843
4286
|
d._suppDot = wantDot && showSupportDot(d);
|
|
4287
|
+
}
|
|
4288
|
+
// Before `drawn` is built, not after: a refused mark used to leave a
|
|
4289
|
+
// childless <g class="node"> behind, and those are transformed on
|
|
4290
|
+
// every animation frame. On the big tree that was ~18,000 empty groups
|
|
4291
|
+
// per redraw, defeating the very thing the `drawn` filter is for.
|
|
4292
|
+
// names first: the ones that survive then reserve their space inside
|
|
4293
|
+
// hideCrowdedBranchData, and the marks are placed against what is left
|
|
4294
|
+
hideCrowdedTipLabels(nodes);
|
|
4295
|
+
hideCrowdedBranchData(nodes);
|
|
4296
|
+
// told after the rules have run, not before: what the unrooted rule
|
|
4297
|
+
// hid is only known once it has looked at the labels
|
|
4298
|
+
syncDynahideIndicator();
|
|
4299
|
+
for (let i = 0, len = nodes.length; i !== len; ++i) {
|
|
4300
|
+
let d = nodes[i];
|
|
3844
4301
|
if (d._extLabelText !== '' || d._blText !== '' || d._confText !== ''
|
|
3845
4302
|
|| d._eventText !== '' || d._suppDot || isCollapsed(d)) {
|
|
3846
4303
|
drawn.push(d);
|
|
@@ -3849,6 +4306,144 @@ function (root, d3, forester, phyloXml) {
|
|
|
3849
4306
|
return drawn;
|
|
3850
4307
|
}
|
|
3851
4308
|
|
|
4309
|
+
// The occupancy pass, in PREORDER -- so the mark nearer the root claims
|
|
4310
|
+
// its space first and keeps it. NOT the order the layout hands out: d3's
|
|
4311
|
+
// descendants() is breadth-first and the viewer reverses it for drawing,
|
|
4312
|
+
// which puts the root LAST and inverts the entire tie-break. That shipped,
|
|
4313
|
+
// and a review caught it; forester.preorderOf derives the right order from
|
|
4314
|
+
// the nodes themselves.
|
|
4315
|
+
//
|
|
4316
|
+
// Runs before anything is drawn, so the decision cannot depend on the
|
|
4317
|
+
// order the DOM happens to be updated in.
|
|
4318
|
+
//
|
|
4319
|
+
// Rides the existing Auto-hide Labels toggle: switch it off and everything
|
|
4320
|
+
// is drawn. Runs in ALL THREE layouts, as the desktop does -- the boxes are
|
|
4321
|
+
// built in the tree group's own coordinates, which is where every mark is
|
|
4322
|
+
// actually drawn, so the circular and unrooted views are measured in their
|
|
4323
|
+
// own space and not in the cluster's. (The tip-label decimation that shares
|
|
4324
|
+
// the toggle runs in the rectangular layout alone; both fans have no rows
|
|
4325
|
+
// to decimate against, and hideCrowdedTipLabels thins their names by
|
|
4326
|
+
// overlap instead, running before this does.)
|
|
4327
|
+
function hideCrowdedBranchData(nodes) {
|
|
4328
|
+
if (!_state.dynahide) {
|
|
4329
|
+
return;
|
|
4330
|
+
}
|
|
4331
|
+
// No layout state, no honest boxes. Bailing out draws everything; going
|
|
4332
|
+
// on would put every mark at the origin, where they all overlap and all
|
|
4333
|
+
// but the first would be hidden -- a blank tree, with no error.
|
|
4334
|
+
if ((_state.circularDisplay && !_radial) || (_state.unrootedDisplay && !_unroot)) {
|
|
4335
|
+
return;
|
|
4336
|
+
}
|
|
4337
|
+
_branchMarksHidden = 0;
|
|
4338
|
+
// Each granted mark's claimed box is kept on the node, as a label's is
|
|
4339
|
+
// (_labelBoxes). Names have had an ink-coverage check since the
|
|
4340
|
+
// reservation went in -- every point of a drawn name must lie inside
|
|
4341
|
+
// the space it claimed -- and branch MARKS never did, which is the
|
|
4342
|
+
// half that failed on a Linux CI runner while the label half passed.
|
|
4343
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
4344
|
+
nodes[i]._markBoxes = null;
|
|
4345
|
+
}
|
|
4346
|
+
let stash = function (d, kind, box) {
|
|
4347
|
+
if (!box) {
|
|
4348
|
+
return;
|
|
4349
|
+
}
|
|
4350
|
+
if (!d._markBoxes) {
|
|
4351
|
+
d._markBoxes = {};
|
|
4352
|
+
}
|
|
4353
|
+
d._markBoxes[kind] = box;
|
|
4354
|
+
};
|
|
4355
|
+
let fontPx = _state.branchDataFontSize;
|
|
4356
|
+
let dotCell = 0;
|
|
4357
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
4358
|
+
if (nodes[i]._suppDot) {
|
|
4359
|
+
dotCell = Math.max(dotCell, 2 * supportDotRadius(nodes[i]));
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
let cell = Math.max(fontPx, dotCell);
|
|
4363
|
+
let numbers = forester.labelOccupancy(cell);
|
|
4364
|
+
let symbols = forester.labelOccupancy(cell);
|
|
4365
|
+
let ordered = forester.preorderOf(nodes);
|
|
4366
|
+
|
|
4367
|
+
// THE NAMES GO DOWN FIRST, and they are not asked: a node label is
|
|
4368
|
+
// drawn whatever else is there, so it reserves its space with occupy
|
|
4369
|
+
// rather than claim. Everything below is then refused against it, and
|
|
4370
|
+
// names outrank numbers -- the desktop's order, and the one a reader
|
|
4371
|
+
// expects, since a name is what the tree is for and a value that has
|
|
4372
|
+
// printed through one is worse than a value not drawn at all.
|
|
4373
|
+
//
|
|
4374
|
+
// Measured before this existed, at 1100x850 with both numbers on:
|
|
4375
|
+
// 44 of 93 numbers printed through a name on flu_h5 in the
|
|
4376
|
+
// rectangular layout, 30 of 55 on confidences, 10 of 15 unrooted.
|
|
4377
|
+
// The 3.15.0 pass kept numbers off each OTHER and off nothing else.
|
|
4378
|
+
for (let i = 0, len = ordered.length; i !== len; ++i) {
|
|
4379
|
+
let boxes = nodeLabelClaimBoxes(nodeLabelGeometry(ordered[i]));
|
|
4380
|
+
// kept on the node, beside the other per-render drawing state, so
|
|
4381
|
+
// the reservation can be compared with the ink it is supposed to
|
|
4382
|
+
// cover: a box that is merely SMALL -- short by a descent, or a
|
|
4383
|
+
// few per cent narrow -- still hides nothing wrongly and still
|
|
4384
|
+
// shows no overlap anywhere a number does not happen to sit. The
|
|
4385
|
+
// desktop pins the same property pixel by pixel.
|
|
4386
|
+
ordered[i]._labelBoxes = boxes.length ? boxes : null;
|
|
4387
|
+
for (let b = 0; b < boxes.length; ++b) {
|
|
4388
|
+
numbers.occupy(boxes[b][0], boxes[b][1], boxes[b][2], boxes[b][3]);
|
|
4389
|
+
symbols.occupy(boxes[b][0], boxes[b][1], boxes[b][2], boxes[b][3]);
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
for (let i = 0, len = ordered.length; i !== len; ++i) {
|
|
4394
|
+
let d = ordered[i];
|
|
4395
|
+
// Branch length first, then confidence: they sit on opposite sides
|
|
4396
|
+
// of the branch, so they rarely contend, but the order decides
|
|
4397
|
+
// which survives when they do.
|
|
4398
|
+
if (d._blText !== '') {
|
|
4399
|
+
let b = branchMarkBox(d, 'bl', d._blText, fontPx);
|
|
4400
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4401
|
+
d._blText = '';
|
|
4402
|
+
++_branchMarksHidden;
|
|
4403
|
+
} else {
|
|
4404
|
+
stash(d, 'bl', b);
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
if (d._confText !== '') {
|
|
4408
|
+
let b = branchMarkBox(d, 'conf', d._confText, fontPx);
|
|
4409
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4410
|
+
d._confText = '';
|
|
4411
|
+
++_branchMarksHidden;
|
|
4412
|
+
} else {
|
|
4413
|
+
stash(d, 'conf', b);
|
|
4414
|
+
}
|
|
4415
|
+
}
|
|
4416
|
+
// Branch EVENTS are branch-anchored too, half a branch from the
|
|
4417
|
+
// branch length and on the same side of it, so they can land on
|
|
4418
|
+
// the same pixels. Left out of the map, they neither refused a
|
|
4419
|
+
// number nor were refused by one, and the pass reported "no
|
|
4420
|
+
// overlapping marks" while the user could see two.
|
|
4421
|
+
if (d._eventText !== '') {
|
|
4422
|
+
let b = branchMarkBox(d, 'event', d._eventText, fontPx);
|
|
4423
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4424
|
+
d._eventText = '';
|
|
4425
|
+
++_branchMarksHidden;
|
|
4426
|
+
} else {
|
|
4427
|
+
stash(d, 'event', b);
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
if (d._suppDot) {
|
|
4431
|
+
// never shrunk to fit: in a size-scaled mode the diameter IS
|
|
4432
|
+
// the support value, and a smaller dot reports weaker support
|
|
4433
|
+
let r = supportDotRadius(d);
|
|
4434
|
+
// showSupportDot already required a parent; a circle's bounds
|
|
4435
|
+
// do not rotate, so the radial case needs only the midpoint
|
|
4436
|
+
let at = radialDisplay()
|
|
4437
|
+
? radialBranchMid(d)
|
|
4438
|
+
: [d.y + (0.5 * (d.parent.y - d.y)), d.x];
|
|
4439
|
+
if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
|
|
4440
|
+
d._suppDot = false;
|
|
4441
|
+
++_branchMarksHidden;
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
|
|
3852
4447
|
function syncOptionalNodeChildren(node) {
|
|
3853
4448
|
let wantBl = _state.showBranchLengthValues === true;
|
|
3854
4449
|
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
@@ -4877,8 +5472,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
4877
5472
|
showTaxonomyButton: 'shown automatically when the tree has taxonomies',
|
|
4878
5473
|
showSequenceButton: 'shown automatically when the tree has sequences',
|
|
4879
5474
|
showBranchColorsButton: 'the Visual Styles checkbox appears when the tree has branch colours or style properties',
|
|
4880
|
-
|
|
4881
|
-
|
|
5475
|
+
// These two are ALWAYS offered, deliberately: they do nothing on a tree
|
|
5476
|
+
// that needs neither, and a control that comes and goes between trees
|
|
5477
|
+
// is worse than one that is simply there. (Both messages used to
|
|
5478
|
+
// promise a conditional appearance the code had already rejected.)
|
|
5479
|
+
showDynahideButton: 'the Auto-hide Labels checkbox is always shown',
|
|
5480
|
+
showShortenNodeNamesButton: 'the Short Names checkbox is always shown; it starts on when the tree has long node names',
|
|
4882
5481
|
showExternalLabelsButton: 'always shown',
|
|
4883
5482
|
showInternalLabelsButton: 'shown automatically when the tree has internal node data',
|
|
4884
5483
|
// the collapse-by-depth / rank / feature feature is gone
|
|
@@ -6379,8 +6978,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6379
6978
|
}
|
|
6380
6979
|
|
|
6381
6980
|
if (url) {
|
|
6382
|
-
|
|
6383
|
-
win.focus();
|
|
6981
|
+
openExternal(url);
|
|
6384
6982
|
} else {
|
|
6385
6983
|
// Not a programming error -- the user clicked and there is
|
|
6386
6984
|
// simply nowhere to go -- so this one gets told in the same
|
|
@@ -6880,11 +7478,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
6880
7478
|
if (_logoGlyphs[ch]) {
|
|
6881
7479
|
return _logoGlyphs[ch];
|
|
6882
7480
|
}
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
_legendMeasureCtx.font = MSA_LOGO_FONT_PX + 'px monospace';
|
|
6887
|
-
let m = _legendMeasureCtx.measureText(ch);
|
|
7481
|
+
let ctx = measureCtx();
|
|
7482
|
+
ctx.font = MSA_LOGO_FONT_PX + 'px monospace';
|
|
7483
|
+
let m = ctx.measureText(ch);
|
|
6888
7484
|
let ascent = m.actualBoundingBoxAscent;
|
|
6889
7485
|
let descent = m.actualBoundingBoxDescent;
|
|
6890
7486
|
if (!(ascent > 0)) {
|
|
@@ -9628,7 +10224,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
9628
10224
|
}
|
|
9629
10225
|
if (_state.phylogram && !_state.alignPhylogram) {
|
|
9630
10226
|
if (_state.unrootedDisplay) {
|
|
9631
|
-
toCladegram();
|
|
10227
|
+
toCladegram(); // unrooted cannot align
|
|
10228
|
+
} else if (_state.circularDisplay) {
|
|
10229
|
+
toCladegram(); // circular is already showing the aligned type
|
|
9632
10230
|
} else {
|
|
9633
10231
|
toAlignedPhylogram();
|
|
9634
10232
|
}
|
|
@@ -9732,6 +10330,274 @@ function (root, d3, forester, phyloXml) {
|
|
|
9732
10330
|
}
|
|
9733
10331
|
|
|
9734
10332
|
// The cheat sheet: every combo above, then the plain keys.
|
|
10333
|
+
// ===================== The control-panel cheat sheet =====================
|
|
10334
|
+
//
|
|
10335
|
+
// Every control the panel is SHOWING, in the order it shows them, each
|
|
10336
|
+
// beside the sentence that already explains it. The desktop's
|
|
10337
|
+
// ControlPanelCheatSheet, and built the same way on one principle: nothing
|
|
10338
|
+
// is written twice. A row's picture is the control's own glyph, cloned;
|
|
10339
|
+
// a row's words are the tooltip the control already carries. So a new
|
|
10340
|
+
// control needs no edit here -- it needs a tooltip, and
|
|
10341
|
+
// test_trees/panel_descriptions.html fails by name for any visible control
|
|
10342
|
+
// without one.
|
|
10343
|
+
//
|
|
10344
|
+
// Two rules of Christian's, 2026-09-25, which the desktop follows too:
|
|
10345
|
+
// name and description are separated by a COLON, never a dash; and no live
|
|
10346
|
+
// VALUES appear -- a label reading "Font size: 6" is cut at the colon, so
|
|
10347
|
+
// the sheet says what the control is and never what it is currently set
|
|
10348
|
+
// to. "A printed 40% is wrong for every reader but the one who printed
|
|
10349
|
+
// it."
|
|
10350
|
+
//
|
|
10351
|
+
// Modeless, and placed beside the panel rather than over it: a sheet you
|
|
10352
|
+
// must close to touch the control it describes is half a sheet.
|
|
10353
|
+
const CHEAT_SHEET_DIALOG = 'aptx_cheatsheet';
|
|
10354
|
+
let _cheatSheetBody = null;
|
|
10355
|
+
|
|
10356
|
+
// this viewer's own panel, not another viewer's on the same page
|
|
10357
|
+
function cheatSheetPanel() {
|
|
10358
|
+
return (_container && _container.querySelector(':scope > .aptx-panel'))
|
|
10359
|
+
|| document.querySelector('.aptx-panel');
|
|
10360
|
+
}
|
|
10361
|
+
|
|
10362
|
+
// The words for one control: its own title, or the title of the <label>
|
|
10363
|
+
// that names it -- which is a SIBLING, not an ancestor, for the select
|
|
10364
|
+
// menus (makeSelectMenu puts it there).
|
|
10365
|
+
function controlDescription(el) {
|
|
10366
|
+
let n = el;
|
|
10367
|
+
for (let i = 0; i < 4 && n; ++i) {
|
|
10368
|
+
let t = (n.getAttribute && (n.getAttribute('title') || n.getAttribute('aria-label'))) || '';
|
|
10369
|
+
if (t.trim() !== '') {
|
|
10370
|
+
return t.trim();
|
|
10371
|
+
}
|
|
10372
|
+
n = n.parentElement;
|
|
10373
|
+
}
|
|
10374
|
+
if (el.id) {
|
|
10375
|
+
let lab = document.querySelector('label[for="' + el.id + '"]');
|
|
10376
|
+
let t = lab ? (lab.getAttribute('title') || lab.getAttribute('aria-label') || '') : '';
|
|
10377
|
+
if (t.trim() !== '') {
|
|
10378
|
+
return t.trim();
|
|
10379
|
+
}
|
|
10380
|
+
}
|
|
10381
|
+
return '';
|
|
10382
|
+
}
|
|
10383
|
+
|
|
10384
|
+
// What the control is called, with any live value cut away. A checkbox is
|
|
10385
|
+
// named by its own text, a menu or slider by the label beside it, a glyph
|
|
10386
|
+
// button by nothing -- its picture is its name, and the row shows that.
|
|
10387
|
+
function controlName(el) {
|
|
10388
|
+
// ONE cut, at the end, over whatever source the name came from.
|
|
10389
|
+
//
|
|
10390
|
+
// It used to be applied only to the two sources that could carry a
|
|
10391
|
+
// value -- a <label for> and an aria-label -- and not to a checkbox's
|
|
10392
|
+
// own text or a button's value. That left "no sheet name contains a
|
|
10393
|
+
// colon" meaning two different things on the two programs: on the
|
|
10394
|
+
// desktop every name IS a cut label, so the rule asserts the cut ran
|
|
10395
|
+
// and can never fire on correct output; here it would ALSO have
|
|
10396
|
+
// constrained what a control may be called, and a checkbox legitimately
|
|
10397
|
+
// labelled "Ratio 1:1" would have failed it. Cutting everywhere makes
|
|
10398
|
+
// the invariant mean the same thing on both sides.
|
|
10399
|
+
//
|
|
10400
|
+
// What the cut is doing here, exactly: our labels read "Color by:" and
|
|
10401
|
+
// "Shape:", so it removes a TRAILING colon, and removing the cut fails
|
|
10402
|
+
// the sheet's value rule by name ("Color by:"). What it is NOT doing
|
|
10403
|
+
// here is removing a value -- nothing follows a colon on this panel,
|
|
10404
|
+
// unlike the desktop's "Font size: 6". So it is exercised but for the
|
|
10405
|
+
// lesser of its two reasons. (Said precisely because the looser
|
|
10406
|
+
// version of this note -- "a no-op, removing it passes every check" --
|
|
10407
|
+
// was true for about an hour and then was not, once the desktop's
|
|
10408
|
+
// sharper value rule was adopted here.)
|
|
10409
|
+
return cutAtValue(rawControlName(el));
|
|
10410
|
+
}
|
|
10411
|
+
|
|
10412
|
+
// "Font size: 6" -> "Font size". The colon is where a value starts, and a
|
|
10413
|
+
// value on a sheet is wrong for everyone but whoever was looking when it
|
|
10414
|
+
// was made.
|
|
10415
|
+
function cutAtValue(name) {
|
|
10416
|
+
return name ? name.split(':')[0].trim() : '';
|
|
10417
|
+
}
|
|
10418
|
+
|
|
10419
|
+
// What the control is called, before the cut. A checkbox is named by its
|
|
10420
|
+
// own text, a menu or slider by the label beside it, a glyph button by
|
|
10421
|
+
// nothing -- its picture is its name, and the row shows that.
|
|
10422
|
+
function rawControlName(el) {
|
|
10423
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
10424
|
+
let span = el.parentElement ? el.parentElement.querySelector('span') : null;
|
|
10425
|
+
if (span && span.textContent.trim() !== '') {
|
|
10426
|
+
return span.textContent.trim();
|
|
10427
|
+
}
|
|
10428
|
+
}
|
|
10429
|
+
if (el.tagName === 'INPUT' && el.value && (el.type === 'button' || el.type === 'submit')) {
|
|
10430
|
+
return el.value.trim();
|
|
10431
|
+
}
|
|
10432
|
+
if (el.id) {
|
|
10433
|
+
let lab = document.querySelector('label[for="' + el.id + '"]');
|
|
10434
|
+
if (lab && lab.textContent.trim() !== '') {
|
|
10435
|
+
return lab.textContent.trim();
|
|
10436
|
+
}
|
|
10437
|
+
}
|
|
10438
|
+
// a menu with no visible label of its own says what it is here
|
|
10439
|
+
let aria = el.getAttribute('aria-label');
|
|
10440
|
+
if (aria && aria.trim() !== '') {
|
|
10441
|
+
return aria.trim();
|
|
10442
|
+
}
|
|
10443
|
+
// and a text button -- the hide toggle, the reset chips -- is its text
|
|
10444
|
+
let own = (el.textContent || '').trim();
|
|
10445
|
+
if (own !== '' && el.tagName === 'BUTTON' && !el.querySelector('svg')) {
|
|
10446
|
+
return own;
|
|
10447
|
+
}
|
|
10448
|
+
// a segmented button drawn as TEXT rather than a glyph (Time / Div) is
|
|
10449
|
+
// named by its own label, which is the only thing on it
|
|
10450
|
+
let seg = el.closest ? el.closest('label.aptx-seg') : null;
|
|
10451
|
+
if (seg && !seg.querySelector('svg')) {
|
|
10452
|
+
let segText = (seg.textContent || '').trim();
|
|
10453
|
+
if (segText !== '') {
|
|
10454
|
+
return segText;
|
|
10455
|
+
}
|
|
10456
|
+
}
|
|
10457
|
+
return '';
|
|
10458
|
+
}
|
|
10459
|
+
|
|
10460
|
+
// What the user actually sees and clicks. A segmented button's <input> is
|
|
10461
|
+
// 0x0 and transparent -- the visible thing is the <label> around it, which
|
|
10462
|
+
// carries the glyph and the tooltip -- so asking the input whether it is
|
|
10463
|
+
// on the screen answered no for all eight of them: the three layouts, the
|
|
10464
|
+
// three display types and the time/divergence pair were absent from the
|
|
10465
|
+
// sheet entirely (Christian, 2026-09-28).
|
|
10466
|
+
function controlSurface(el) {
|
|
10467
|
+
let box = el.getBoundingClientRect();
|
|
10468
|
+
if (box.width > 0 || box.height > 0) {
|
|
10469
|
+
return el;
|
|
10470
|
+
}
|
|
10471
|
+
let label = el.closest ? el.closest('label') : null;
|
|
10472
|
+
if (label) {
|
|
10473
|
+
let lb = label.getBoundingClientRect();
|
|
10474
|
+
if (lb.width > 0 || lb.height > 0) {
|
|
10475
|
+
return label;
|
|
10476
|
+
}
|
|
10477
|
+
}
|
|
10478
|
+
return null;
|
|
10479
|
+
}
|
|
10480
|
+
|
|
10481
|
+
function cheatSheetRows() {
|
|
10482
|
+
let panel = cheatSheetPanel();
|
|
10483
|
+
if (!panel) {
|
|
10484
|
+
return [];
|
|
10485
|
+
}
|
|
10486
|
+
let rows = [];
|
|
10487
|
+
let seen = new Set();
|
|
10488
|
+
let all = panel.querySelectorAll('input, button, select');
|
|
10489
|
+
for (let i = 0; i < all.length; ++i) {
|
|
10490
|
+
let el = all[i];
|
|
10491
|
+
// what the panel is SHOWING: a control the tree gives no data for
|
|
10492
|
+
// is not on the screen and does not belong on the sheet. A
|
|
10493
|
+
// DISABLED one is on the screen, and saying what it is is how a
|
|
10494
|
+
// reader learns why it is greyed.
|
|
10495
|
+
let surface = el.disabled ? (controlSurface(el) || el) : controlSurface(el);
|
|
10496
|
+
if (!surface) {
|
|
10497
|
+
continue;
|
|
10498
|
+
}
|
|
10499
|
+
let words = controlDescription(el);
|
|
10500
|
+
if (words === '' || seen.has(el)) {
|
|
10501
|
+
continue;
|
|
10502
|
+
}
|
|
10503
|
+
seen.add(el);
|
|
10504
|
+
// the glyph belongs to the surface, not to a 0x0 input inside it
|
|
10505
|
+
let svg = surface.querySelector ? surface.querySelector('svg.aptx-glyph') : null;
|
|
10506
|
+
rows.push({name: controlName(el), words: words, glyph: svg});
|
|
10507
|
+
}
|
|
10508
|
+
return rows;
|
|
10509
|
+
}
|
|
10510
|
+
|
|
10511
|
+
function renderCheatSheet(body) {
|
|
10512
|
+
// everything but the standing lead paragraph
|
|
10513
|
+
let keep = body.querySelector('.aptx-cheat-lead');
|
|
10514
|
+
while (body.firstChild) {
|
|
10515
|
+
body.removeChild(body.firstChild);
|
|
10516
|
+
}
|
|
10517
|
+
if (keep) {
|
|
10518
|
+
body.appendChild(keep);
|
|
10519
|
+
}
|
|
10520
|
+
let rows = cheatSheetRows();
|
|
10521
|
+
rows.forEach(function (row) {
|
|
10522
|
+
let line = document.createElement('div');
|
|
10523
|
+
line.className = 'aptx-dialog-line aptx-cheat-line';
|
|
10524
|
+
let key = document.createElement('span');
|
|
10525
|
+
key.className = 'aptx-dialog-key aptx-cheat-key';
|
|
10526
|
+
if (row.glyph) {
|
|
10527
|
+
let g = row.glyph.cloneNode(true);
|
|
10528
|
+
g.removeAttribute('class');
|
|
10529
|
+
g.setAttribute('class', 'aptx-glyph aptx-cheat-glyph');
|
|
10530
|
+
key.appendChild(g);
|
|
10531
|
+
}
|
|
10532
|
+
if (row.name) {
|
|
10533
|
+
let n = document.createElement('span');
|
|
10534
|
+
n.className = 'aptx-cheat-name';
|
|
10535
|
+
n.textContent = row.name;
|
|
10536
|
+
key.appendChild(n);
|
|
10537
|
+
}
|
|
10538
|
+
let val = document.createElement('span');
|
|
10539
|
+
val.className = 'aptx-dialog-val';
|
|
10540
|
+
val.textContent = row.words;
|
|
10541
|
+
line.appendChild(key);
|
|
10542
|
+
line.appendChild(val);
|
|
10543
|
+
body.appendChild(line);
|
|
10544
|
+
});
|
|
10545
|
+
if (rows.length === 0) {
|
|
10546
|
+
let empty = document.createElement('p');
|
|
10547
|
+
empty.className = 'aptx-shortcuts-note';
|
|
10548
|
+
empty.textContent = 'No controls are showing.';
|
|
10549
|
+
body.appendChild(empty);
|
|
10550
|
+
}
|
|
10551
|
+
}
|
|
10552
|
+
|
|
10553
|
+
// The panel is rebuilt on every render, so an open sheet is re-read from
|
|
10554
|
+
// it rather than left describing a panel that has changed underneath.
|
|
10555
|
+
function refreshCheatSheet() {
|
|
10556
|
+
if (_cheatSheetBody && _cheatSheetBody.isConnected) {
|
|
10557
|
+
renderCheatSheet(_cheatSheetBody);
|
|
10558
|
+
}
|
|
10559
|
+
}
|
|
10560
|
+
|
|
10561
|
+
function showCheatSheetDialog() {
|
|
10562
|
+
let shell = makeDialogShell(CHEAT_SHEET_DIALOG, 'Control panel', 460);
|
|
10563
|
+
shell.body.classList.add('aptx-shortcuts');
|
|
10564
|
+
shell.body.classList.add('aptx-cheat');
|
|
10565
|
+
// says what the reader is looking at, as the desktop's sheet does
|
|
10566
|
+
let lead = document.createElement('p');
|
|
10567
|
+
lead.className = 'aptx-cheat-lead';
|
|
10568
|
+
lead.textContent = 'Every control the panel is showing, with the icon it draws and what it does.';
|
|
10569
|
+
shell.body.appendChild(lead);
|
|
10570
|
+
_cheatSheetBody = shell.body;
|
|
10571
|
+
renderCheatSheet(shell.body);
|
|
10572
|
+
shell.dialog.addEventListener('close', function () {
|
|
10573
|
+
if (_cheatSheetBody === shell.body) {
|
|
10574
|
+
_cheatSheetBody = null;
|
|
10575
|
+
}
|
|
10576
|
+
});
|
|
10577
|
+
// modeless, as the colour picker is: the point is to read a row and
|
|
10578
|
+
// click the control it names without closing anything
|
|
10579
|
+
shell.dialog.show();
|
|
10580
|
+
placeBesidePanel(shell.dialog);
|
|
10581
|
+
}
|
|
10582
|
+
|
|
10583
|
+
// Beside the panel, never over it, and inside the window.
|
|
10584
|
+
function placeBesidePanel(dialog) {
|
|
10585
|
+
let panel = cheatSheetPanel();
|
|
10586
|
+
let box = panel ? panel.getBoundingClientRect() : null;
|
|
10587
|
+
let w = dialog.offsetWidth;
|
|
10588
|
+
let h = dialog.offsetHeight;
|
|
10589
|
+
let left = box ? (box.right + 10) : 10;
|
|
10590
|
+
if (left + w > window.innerWidth - 6) {
|
|
10591
|
+
left = box ? (box.left - w - 10) : 6;
|
|
10592
|
+
}
|
|
10593
|
+
setStyles(dialog, {
|
|
10594
|
+
'position': 'fixed',
|
|
10595
|
+
'margin': '0',
|
|
10596
|
+
'left': Math.max(6, Math.min(window.innerWidth - w - 6, left)) + 'px',
|
|
10597
|
+
'top': Math.max(6, Math.min(window.innerHeight - h - 6, box ? box.top : 10)) + 'px'
|
|
10598
|
+
});
|
|
10599
|
+
}
|
|
10600
|
+
|
|
9735
10601
|
function showShortcutsDialog() {
|
|
9736
10602
|
let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
|
|
9737
10603
|
shell.body.classList.add('aptx-shortcuts');
|
|
@@ -9955,17 +10821,146 @@ function (root, d3, forester, phyloXml) {
|
|
|
9955
10821
|
// the later one's shadow and glow used to fall across the earlier body,
|
|
9956
10822
|
// and now lie under it; overlapping shadows of one level no longer
|
|
9957
10823
|
// darken each other.
|
|
10824
|
+
// ===================== Domain hover =====================
|
|
10825
|
+
// Domain architectures are what Archaeopteryx has always been best at, and
|
|
10826
|
+
// until now the boxes said nothing: you could see a domain but not read
|
|
10827
|
+
// its name, its E-value or where it sits, short of opening the node's data
|
|
10828
|
+
// dialog and finding it in a list. Christian, 2026-09-23.
|
|
10829
|
+
//
|
|
10830
|
+
// The readout is the one the heat map and the alignment already use, so a
|
|
10831
|
+
// hover anywhere in the viewer looks and behaves the same, and it is
|
|
10832
|
+
// placed by placeHoverReadout, which measures the box rather than assuming
|
|
10833
|
+
// a size.
|
|
10834
|
+
|
|
10835
|
+
function domainInfoAt(event) {
|
|
10836
|
+
let t = event.target;
|
|
10837
|
+
return (t && t.__aptxDomain) ? t.__aptxDomain : null;
|
|
10838
|
+
}
|
|
10839
|
+
|
|
10840
|
+
let _domainHovered = null;
|
|
10841
|
+
|
|
10842
|
+
function domainHover(event) {
|
|
10843
|
+
let info = domainInfoAt(event);
|
|
10844
|
+
if (!info) {
|
|
10845
|
+
domainHoverOut();
|
|
10846
|
+
return;
|
|
10847
|
+
}
|
|
10848
|
+
// mousemove fires all the way across a box, and a wide domain is
|
|
10849
|
+
// hundreds of pixels. The TEXT only changes when the box does, so
|
|
10850
|
+
// re-escaping it, reparsing it as HTML and restarting the fade sixty
|
|
10851
|
+
// times a second would be work for a byte-identical result -- and the
|
|
10852
|
+
// restarted transition would keep opacity approaching 0.95 without
|
|
10853
|
+
// ever arriving. The readout must still FOLLOW the pointer, so the
|
|
10854
|
+
// placement runs either way.
|
|
10855
|
+
let tip_el = _node_mouseover_div.node();
|
|
10856
|
+
if (info === _domainHovered) {
|
|
10857
|
+
placeHoverReadout(tip_el, event);
|
|
10858
|
+
return;
|
|
10859
|
+
}
|
|
10860
|
+
_domainHovered = info;
|
|
10861
|
+
let ref = forester.domainReference(info);
|
|
10862
|
+
let txt = 'Domain: ' + (info.name || '(unnamed)') + '<br>';
|
|
10863
|
+
let ev = forester.domainEvalueText(info.evalue);
|
|
10864
|
+
if (ev !== null) {
|
|
10865
|
+
txt += 'E-value: ' + ev + '<br>';
|
|
10866
|
+
}
|
|
10867
|
+
txt += 'Residues: ' + info.from + '–' + info.to
|
|
10868
|
+
+ ' (' + ((info.to - info.from) + 1) + ' aa'
|
|
10869
|
+
+ (info.proteinLength > 0 ? (' of ' + info.proteinLength) : '') + ')<br>';
|
|
10870
|
+
if (info.tip) {
|
|
10871
|
+
txt += 'Tip: ' + info.tip + '<br>';
|
|
10872
|
+
}
|
|
10873
|
+
if (info.id) {
|
|
10874
|
+
txt += 'Accession: ' + info.id + '<br>';
|
|
10875
|
+
}
|
|
10876
|
+
if (ref) {
|
|
10877
|
+
// A readout that follows the pointer cannot hold a link you could
|
|
10878
|
+
// reach, so the BOX is the link and the readout says so. Written
|
|
10879
|
+
// as a "label: value" row on purpose: markUpDataLabels renders a
|
|
10880
|
+
// line WITHOUT a colon as a section divider (9px bold uppercase
|
|
10881
|
+
// faint), which is the wrong voice entirely for a call to action.
|
|
10882
|
+
txt += ref.exact ? 'Click: the Pfam entry' : 'Click: to look this domain up at InterPro';
|
|
10883
|
+
}
|
|
10884
|
+
tip_el.classList.remove('aptx-light', 'aptx-dark');
|
|
10885
|
+
if (_panelTheme) {
|
|
10886
|
+
tip_el.classList.add('aptx-' + _panelTheme);
|
|
10887
|
+
}
|
|
10888
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
|
|
10889
|
+
placeHoverReadout(tip_el, event);
|
|
10890
|
+
_node_mouseover_div.transition().duration(100).style('opacity', 0.95);
|
|
10891
|
+
}
|
|
10892
|
+
|
|
10893
|
+
function domainHoverOut() {
|
|
10894
|
+
_domainHovered = null;
|
|
10895
|
+
_node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
|
|
10896
|
+
}
|
|
10897
|
+
|
|
10898
|
+
let _domainOpenedAt = 0;
|
|
10899
|
+
|
|
10900
|
+
function domainClicked(event) {
|
|
10901
|
+
let info = domainInfoAt(event);
|
|
10902
|
+
if (!info) {
|
|
10903
|
+
return;
|
|
10904
|
+
}
|
|
10905
|
+
// A click that LANDED on a box is the box's, whether or not it has
|
|
10906
|
+
// somewhere to send you; letting an unlinkable domain fall through
|
|
10907
|
+
// would open the node menu for whatever node is within five units.
|
|
10908
|
+
event.stopPropagation();
|
|
10909
|
+
let ref = forester.domainReference(info);
|
|
10910
|
+
if (!ref) {
|
|
10911
|
+
return;
|
|
10912
|
+
}
|
|
10913
|
+
// Double-clicking is the natural gesture for "open this", and it
|
|
10914
|
+
// arrives as two clicks: without this it opens two identical tabs.
|
|
10915
|
+
let now = Date.now();
|
|
10916
|
+
if ((now - _domainOpenedAt) < 500) {
|
|
10917
|
+
return;
|
|
10918
|
+
}
|
|
10919
|
+
_domainOpenedAt = now;
|
|
10920
|
+
openExternal(ref.url);
|
|
10921
|
+
}
|
|
10922
|
+
|
|
10923
|
+
// The tree zooms on double click. Over a domain box the gesture means the
|
|
10924
|
+
// box, so the zoom must not also happen behind the tabs that just opened.
|
|
10925
|
+
function domainDblClicked(event) {
|
|
10926
|
+
if (domainInfoAt(event)) {
|
|
10927
|
+
event.stopPropagation();
|
|
10928
|
+
event.preventDefault();
|
|
10929
|
+
}
|
|
10930
|
+
}
|
|
10931
|
+
|
|
9958
10932
|
function drawDomainArchitectures() {
|
|
9959
10933
|
if (!_svgGroup) {
|
|
9960
10934
|
return;
|
|
9961
10935
|
}
|
|
9962
10936
|
let existing = _svgGroup.select('g.aptx-domains');
|
|
10937
|
+
// An architecture goes WITH ITS NAME, and is left out when the name
|
|
10938
|
+
// is. domainsShown() already ties the whole track to external labels
|
|
10939
|
+
// being on; this is the same rule per tip, and it became necessary the
|
|
10940
|
+
// day unrooted started hiding crowded names -- before that every tip
|
|
10941
|
+
// in every layout kept its label, so a nameless architecture could not
|
|
10942
|
+
// happen. Measured on apaf when it could: 7 of 31 tips drew an
|
|
10943
|
+
// architecture with no name beside it, identifiable only by which
|
|
10944
|
+
// spoke it sat on. (The desktop reached the same rule from the other
|
|
10945
|
+
// end: a tip IMAGE painted before its label's claim was left orphaned
|
|
10946
|
+
// under a neighbour's name.)
|
|
9963
10947
|
let tips = (domainsShown() && _root) ? displayedTips().filter(function (d) {
|
|
10948
|
+
// Keyed on "the RULE hid this name", not on "there is no name".
|
|
10949
|
+
// The two part company the moment the user switches the name
|
|
10950
|
+
// fields off: nothing is hidden by the rule then, every label is
|
|
10951
|
+
// empty, and every architecture should still be drawn. Keying on
|
|
10952
|
+
// the empty text took all 31 of apaf's architectures away with the
|
|
10953
|
+
// Node Name checkbox -- shipped, and caught by the desktop
|
|
10954
|
+
// warning us they had met the same reading of their own fix.
|
|
10955
|
+
if (d._labelDropped) {
|
|
10956
|
+
return false;
|
|
10957
|
+
}
|
|
9964
10958
|
return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
|
|
9965
10959
|
}) : [];
|
|
9966
10960
|
let f = tips.length > 0 ? domainScale() : 0;
|
|
9967
10961
|
if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
|
|
9968
10962
|
existing.remove();
|
|
10963
|
+
domainHoverOut(); // the box under the pointer just stopped existing
|
|
9969
10964
|
return;
|
|
9970
10965
|
}
|
|
9971
10966
|
// [defs, the batched paths, the bodies, the names], kept between
|
|
@@ -9985,6 +10980,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
9985
10980
|
let underLayer = g.children[1];
|
|
9986
10981
|
let bodyLayer = g.children[2];
|
|
9987
10982
|
let labelLayer = g.children[3];
|
|
10983
|
+
// The group ignores the mouse so the tree underneath stays clickable;
|
|
10984
|
+
// the BODIES alone take it back, so a hover lands on a domain and not
|
|
10985
|
+
// on its shadow, its glow or its label.
|
|
10986
|
+
bodyLayer.style.pointerEvents = 'auto';
|
|
10987
|
+
if (!bodyLayer.__aptxWired) {
|
|
10988
|
+
bodyLayer.__aptxWired = true;
|
|
10989
|
+
// mousemove alone, as the heat map and the alignment do it: a
|
|
10990
|
+
// mouseover is always followed by a mousemove, so binding both
|
|
10991
|
+
// only built the readout twice on entry.
|
|
10992
|
+
bodyLayer.addEventListener('mousemove', domainHover);
|
|
10993
|
+
bodyLayer.addEventListener('mouseout', domainHoverOut);
|
|
10994
|
+
bodyLayer.addEventListener('click', domainClicked);
|
|
10995
|
+
bodyLayer.addEventListener('dblclick', domainDblClicked);
|
|
10996
|
+
}
|
|
9988
10997
|
|
|
9989
10998
|
// one vertical gradient per base colour, lighter at the top; the
|
|
9990
10999
|
// defs ride into the SVG / PDF / PNG exports with the boxes
|
|
@@ -10087,6 +11096,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
10087
11096
|
}
|
|
10088
11097
|
let set = row.transform ? pathSet() : track;
|
|
10089
11098
|
let geo = forester.domainBoxes(da, row.start, f, _state.domainEvalueExponent);
|
|
11099
|
+
// once per ROW: displayNodeName walks the node's properties when a
|
|
11100
|
+
// label-ref visualization is on, and a row can hold dozens of boxes
|
|
11101
|
+
let rowTip = displayNodeName(row.d) || '';
|
|
11102
|
+
let rowLength = Number(da.length) || 0;
|
|
10090
11103
|
let h = row.h;
|
|
10091
11104
|
let y1 = row.y1;
|
|
10092
11105
|
set.backbones.push(roundRectPath(geo.backbone.x, y1 + (h / 2) - 0.5, geo.backbone.w, 1, 0));
|
|
@@ -10110,7 +11123,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
10110
11123
|
});
|
|
10111
11124
|
}
|
|
10112
11125
|
bodies.push({x: b.x, y: y1, w: b.w, h: h, r: r, transform: row.transform,
|
|
10113
|
-
fill: gradientFor(base), stroke: forester.domainDarken(base, 0.24)
|
|
11126
|
+
fill: gradientFor(base), stroke: forester.domainDarken(base, 0.24),
|
|
11127
|
+
// what the hover readout says. Carried per BOX rather than
|
|
11128
|
+
// looked up again on hover: the boxes are pooled and
|
|
11129
|
+
// reused between redraws, so an index into the tree would
|
|
11130
|
+
// go stale the moment the tips on screen change.
|
|
11131
|
+
info: {name: b.name, from: b.from, to: b.to, evalue: b.evalue, id: b.id,
|
|
11132
|
+
tip: rowTip, proteinLength: rowLength}});
|
|
10114
11133
|
if (row.labelsOn && b.name && fs > 4 && legendTextWidth(b.name, font) <= b.w - 4) {
|
|
10115
11134
|
names.push({x: b.x + (b.w / 2), y: y1 + (h / 2), font: font,
|
|
10116
11135
|
fill: forester.domainLabelInk(base), text: b.name});
|
|
@@ -10134,6 +11153,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
10134
11153
|
paths[k].style.fill = u.fill;
|
|
10135
11154
|
paths[k].style.fillOpacity = u.opacity;
|
|
10136
11155
|
});
|
|
11156
|
+
// A redraw can take the hovered box away without the pointer moving
|
|
11157
|
+
// -- a keyboard zoom, a panel change applied with Enter, a collapse --
|
|
11158
|
+
// and a removed element fires no mouseout, so the readout would stay
|
|
11159
|
+
// painted over a domain that is no longer there.
|
|
11160
|
+
domainHoverOut();
|
|
10137
11161
|
let rects = sizeLayer(bodyLayer, 'rect', bodies.length);
|
|
10138
11162
|
bodies.forEach(function (b, k) {
|
|
10139
11163
|
let el = rects[k];
|
|
@@ -10151,6 +11175,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
10151
11175
|
el.style.fill = b.fill;
|
|
10152
11176
|
el.style.stroke = b.stroke;
|
|
10153
11177
|
el.style.strokeWidth = 1;
|
|
11178
|
+
el.__aptxDomain = b.info;
|
|
11179
|
+
// only where there is somewhere to go: an unnamed domain with no
|
|
11180
|
+
// accession has no reference, and a hand cursor over it would
|
|
11181
|
+
// promise a link that the click cannot deliver
|
|
11182
|
+
el.style.cursor = forester.domainReference(b.info) ? 'pointer' : 'default';
|
|
10154
11183
|
});
|
|
10155
11184
|
let texts = sizeLayer(labelLayer, 'text', names.length);
|
|
10156
11185
|
names.forEach(function (t, k) {
|
|
@@ -11091,7 +12120,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
11091
12120
|
|
|
11092
12121
|
// what the viewer writes onto a tree's nodes, left off a tree cut out of it
|
|
11093
12122
|
const VIEW_NODE_FIELDS = ['viewId', 'collapsed', 'x', 'y', 'x0', 'y0', 'id', 'hide', 'hasVis', 'distToRoot',
|
|
11094
|
-
'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText'
|
|
12123
|
+
'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText',
|
|
12124
|
+
'_labelBoxes', '_labelDropped', '_markBoxes'];
|
|
11095
12125
|
|
|
11096
12126
|
// the whole tree's tip count, counted again only when the tree changes
|
|
11097
12127
|
// (every change makes new _basicTreeProperties)
|
|
@@ -11887,16 +12917,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
11887
12917
|
scheduleUpdate(null, 0);
|
|
11888
12918
|
}
|
|
11889
12919
|
|
|
12920
|
+
// In CIRCULAR these two leave the aligned/unaligned flag alone. That
|
|
12921
|
+
// layout aligns whatever the flag says, so the button shown as chosen
|
|
12922
|
+
// there is "aligned" regardless -- and writing the flag from it would
|
|
12923
|
+
// silently change the user's choice for the layouts that CAN express it.
|
|
12924
|
+
// A user who likes the plain phylogram in rectangular, steps into
|
|
12925
|
+
// circular, flips to the cladogram and back, and returns, would have
|
|
12926
|
+
// found the aligned one waiting.
|
|
11890
12927
|
function toAlignedPhylogram() {
|
|
11891
12928
|
_state.phylogram = true;
|
|
11892
|
-
_state.
|
|
12929
|
+
if (!_state.circularDisplay) {
|
|
12930
|
+
_state.alignPhylogram = true;
|
|
12931
|
+
}
|
|
11893
12932
|
setDisplayTypeButtons();
|
|
11894
12933
|
scheduleUpdate(null, 0);
|
|
11895
12934
|
}
|
|
11896
12935
|
|
|
11897
12936
|
function toCladegram() {
|
|
11898
12937
|
_state.phylogram = false;
|
|
11899
|
-
_state.
|
|
12938
|
+
if (!_state.circularDisplay) {
|
|
12939
|
+
_state.alignPhylogram = false;
|
|
12940
|
+
}
|
|
11900
12941
|
setDisplayTypeButtons();
|
|
11901
12942
|
scheduleUpdate(null, 0);
|
|
11902
12943
|
}
|
|
@@ -11928,18 +12969,40 @@ function (root, d3, forester, phyloXml) {
|
|
|
11928
12969
|
// against; an alignment / time axis is inherently horizontal. Synced
|
|
11929
12970
|
// BEFORE the zoom-row early return, and (via updateButtonEnabledState)
|
|
11930
12971
|
// on every render, so subtree switches keep them honest.
|
|
12972
|
+
// WHICH of the two phylogram buttons is dead depends on the layout,
|
|
12973
|
+
// and it is the one the layout CANNOT do -- not the one it always
|
|
12974
|
+
// does. Circular always carries its external labels out to a common
|
|
12975
|
+
// ring on dashed connectors, whatever is chosen, so the circular
|
|
12976
|
+
// phylogram IS the aligned one: the unavailable option there is the
|
|
12977
|
+
// UNALIGNED phylogram. Unrooted is the other way round -- it has no
|
|
12978
|
+
// common edge to align to, so what it cannot do is align.
|
|
12979
|
+
//
|
|
12980
|
+
// (Greying the aligned button in circular was the first attempt, on
|
|
12981
|
+
// the grounds that picking it changed nothing. True, but backwards:
|
|
12982
|
+
// it disabled the button that describes what the layout does and left
|
|
12983
|
+
// live the one it cannot do, and the panel then showed "phylogram"
|
|
12984
|
+
// selected over a picture whose labels are plainly aligned. Christian
|
|
12985
|
+
// spotted the inconsistency, 2026-09-27.)
|
|
12986
|
+
let measured = _basicTreeProperties.branchLengths === true;
|
|
11931
12987
|
let phyBtn = byId(PHYLOGRAM_BUTTON);
|
|
11932
12988
|
if (phyBtn) {
|
|
11933
|
-
phyBtn.disabled =
|
|
12989
|
+
phyBtn.disabled = !measured || _state.circularDisplay;
|
|
12990
|
+
phyBtn.title = PHYLOGRAM_WHAT;
|
|
11934
12991
|
}
|
|
11935
12992
|
let alignBtn = byId(PHYLOGRAM_ALIGNED_BUTTON);
|
|
11936
12993
|
if (alignBtn) {
|
|
11937
|
-
alignBtn.disabled =
|
|
11938
|
-
|
|
11939
|
-
let dyna = byId(DYNAHIDE_CB);
|
|
11940
|
-
if (dyna) {
|
|
11941
|
-
dyna.disabled = _state.unrootedDisplay;
|
|
12994
|
+
alignBtn.disabled = !measured || _state.unrootedDisplay;
|
|
12995
|
+
alignBtn.title = PHYLOGRAM_ALIGNED_WHAT;
|
|
11942
12996
|
}
|
|
12997
|
+
// Which button is SHOWN as chosen is layout-dependent too, and this
|
|
12998
|
+
// was only re-synced when the display type changed -- so switching
|
|
12999
|
+
// layout left the previous layout's answer on the screen.
|
|
13000
|
+
setDisplayTypeButtons();
|
|
13001
|
+
// Auto-hide Labels stays live in every layout. It used to be greyed out
|
|
13002
|
+
// in unrooted, where the every-k-th tip-label decimation does not run --
|
|
13003
|
+
// but it also governs the crowded branch-data rule, and now the
|
|
13004
|
+
// unrooted tip-label rule, both of which do run there, so greying it
|
|
13005
|
+
// out left the user unable to switch off what they could see.
|
|
11943
13006
|
let msaCb = byId(MSA_CB);
|
|
11944
13007
|
if (msaCb) {
|
|
11945
13008
|
msaCb.disabled = radialDisplay();
|
|
@@ -12156,7 +13219,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
12156
13219
|
// panel's accent: the checkbox item gets a box and its tooltip says how
|
|
12157
13220
|
// many labels are shown. Called on every redraw, since the density
|
|
12158
13221
|
// changes with the vertical zoom, the font size and the view.
|
|
12159
|
-
|
|
13222
|
+
// Lights the toggle while it is actually taking something away, and says
|
|
13223
|
+
// what. The switch governs three rules, and the light has to ask all three
|
|
13224
|
+
// rather than whichever one was written first: the every-k-th thinning
|
|
13225
|
+
// (rectangular, by index against the row pitch), the overlap thinning
|
|
13226
|
+
// (both fans, by whether names would overprint) and the crowded
|
|
13227
|
+
// branch-data rule (every layout). Asking only the first two left it dark
|
|
13228
|
+
// over a circular tree with 201 numbers refused and every name drawn --
|
|
13229
|
+
// and over a RECTANGULAR one with 18 refused, which was never right.
|
|
13230
|
+
function syncDynahideIndicator() {
|
|
12160
13231
|
let cb = byId(DYNAHIDE_CB);
|
|
12161
13232
|
let item = cb ? cb.closest('.aptx-check') : null;
|
|
12162
13233
|
if (!item) {
|
|
@@ -12165,9 +13236,23 @@ function (root, d3, forester, phyloXml) {
|
|
|
12165
13236
|
if (item.dataset.baseTitle === undefined) {
|
|
12166
13237
|
item.dataset.baseTitle = item.title;
|
|
12167
13238
|
}
|
|
12168
|
-
|
|
13239
|
+
let parts = [];
|
|
13240
|
+
if (!radialDisplay() && _dynahide_factor >= 2) {
|
|
13241
|
+
parts.push('1 in ' + _dynahide_factor + ' labels shown');
|
|
13242
|
+
} else if (radialDisplay() && _radialNamesHidden > 0) {
|
|
13243
|
+
parts.push(_radialNamesHidden + ' name' + (_radialNamesHidden === 1 ? '' : 's')
|
|
13244
|
+
+ ' that would overprint');
|
|
13245
|
+
}
|
|
13246
|
+
if (_branchMarksHidden > 0) {
|
|
13247
|
+
// said only when it is happening: the clause used to be printed
|
|
13248
|
+
// unconditionally, which made a promise of every render
|
|
13249
|
+
parts.push(_branchMarksHidden + ' branch value' + (_branchMarksHidden === 1 ? '' : 's')
|
|
13250
|
+
+ ' that would overlap');
|
|
13251
|
+
}
|
|
13252
|
+
let active = _state.dynahide === true && parts.length > 0;
|
|
13253
|
+
item.classList.toggle('aptx-check-active', active);
|
|
12169
13254
|
item.title = active
|
|
12170
|
-
? item.dataset.baseTitle + ', hiding now:
|
|
13255
|
+
? item.dataset.baseTitle + ', hiding now: ' + parts.join(', and ')
|
|
12171
13256
|
: item.dataset.baseTitle;
|
|
12172
13257
|
}
|
|
12173
13258
|
|
|
@@ -12806,6 +13891,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
12806
13891
|
+ glyphDot(50, 29, 5.5);
|
|
12807
13892
|
}
|
|
12808
13893
|
|
|
13894
|
+
// a reference card: the outline, and the rows on it
|
|
13895
|
+
function glyphCheatSheet() {
|
|
13896
|
+
return '<rect x="18" y="12" width="64" height="76" rx="8"/>'
|
|
13897
|
+
+ glyphLine(32, 34, 68, 34)
|
|
13898
|
+
+ glyphLine(32, 50, 68, 50)
|
|
13899
|
+
+ glyphLine(32, 66, 56, 66);
|
|
13900
|
+
}
|
|
13901
|
+
|
|
12809
13902
|
function glyphSun() {
|
|
12810
13903
|
let s = glyphDot(50, 50, 20);
|
|
12811
13904
|
for (let i = 0; i < 8; ++i) {
|
|
@@ -12874,6 +13967,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
12874
13967
|
case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
|
|
12875
13968
|
case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
|
|
12876
13969
|
case 'info': sw = 7; cap = 'round'; body = glyphInfo(); break;
|
|
13970
|
+
case 'cheat_sheet': sw = 7; cap = 'round'; body = glyphCheatSheet(); break;
|
|
12877
13971
|
case 'sun': body = glyphSun(); break;
|
|
12878
13972
|
case 'moon': body = glyphMoon(); break;
|
|
12879
13973
|
default: throw new Error('unknown control-panel glyph: ' + kind);
|
|
@@ -13072,6 +14166,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
13072
14166
|
+ '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
|
|
13073
14167
|
+ '.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; }'
|
|
13074
14168
|
+ '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
|
|
14169
|
+
// the cheat sheet: a scrolling card of glyph + name : description
|
|
14170
|
+
// 31 rows is taller than a short window; the card scrolls rather
|
|
14171
|
+
// than running off the bottom, and the title bar stays put
|
|
14172
|
+
+ '#' + CHEAT_SHEET_DIALOG + ' { max-height:min(78vh, 760px); display:flex; flex-direction:column; }'
|
|
14173
|
+
+ '#' + CHEAT_SHEET_DIALOG + ' .aptx-dialog-body { min-height:0; }'
|
|
14174
|
+
+ '.aptx-cheat-lead { margin:0 0 9px; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
|
|
14175
|
+
+ '.aptx-cheat .aptx-dialog-line { padding:3px 0; align-items:flex-start; }'
|
|
14176
|
+
+ '.aptx-cheat .aptx-cheat-key { flex:0 0 38%; display:flex; align-items:center; gap:6px; color:var(--p-ink); }'
|
|
14177
|
+
+ '.aptx-cheat .aptx-cheat-glyph { width:14px; height:14px; flex:none; color:var(--p-muted); }'
|
|
14178
|
+
+ '.aptx-cheat .aptx-cheat-name { overflow-wrap:anywhere; }'
|
|
14179
|
+
+ '.aptx-cheat .aptx-dialog-val { color:var(--p-muted); line-height:1.35; }'
|
|
14180
|
+
+ '.aptx-cheat .aptx-cheat-key:not(:empty)::after { content:":"; color:var(--p-faint); }'
|
|
13075
14181
|
// representative tips: the input dialog and its result
|
|
13076
14182
|
+ '.aptx-reps { line-height:1.35; }'
|
|
13077
14183
|
+ '.aptx-reps-lead { margin:0 0 4px; }'
|
|
@@ -13143,7 +14249,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
13143
14249
|
+ '.aptx-tip .aptx-dialog-line { padding:1px 0; }'
|
|
13144
14250
|
+ '.aptx-tip .aptx-dialog-line + .aptx-dialog-line { border-top:0; }'
|
|
13145
14251
|
+ '.aptx-tip .aptx-dialog-key { flex:0 0 auto; min-width:96px; }'
|
|
13146
|
-
|
|
14252
|
+
// WRAPS. The title's longest word cannot break, so on a narrow
|
|
14253
|
+
// panel the buttons had nowhere to go and overflowed the padding:
|
|
14254
|
+
// with four of them they ended 2.4 px past the panel's own right
|
|
14255
|
+
// edge, and squeezed the title into two ragged centred lines on
|
|
14256
|
+
// the way. Wrapping puts them on their own right-aligned row
|
|
14257
|
+
// instead, which also gives the title back its full width -- so
|
|
14258
|
+
// "Archaeopteryx.js 3.15.0" sits on ONE line where it used to
|
|
14259
|
+
// break in two.
|
|
14260
|
+
+ '.aptx-panel .' + PROG_NAME + ' { display:flex; align-items:center; flex-wrap:wrap; gap:6px 8px; padding:9px 12px; border-bottom:1px solid var(--p-line); font-weight:600; letter-spacing:-0.01em; }'
|
|
13147
14261
|
+ '.aptx-panel .' + PROGNAMELINK + ',.aptx-panel .' + PROGNAMELINK + ':link,.aptx-panel .' + PROGNAMELINK + ':visited { color:var(--p-accent-ink); text-decoration:none; font-size:12px; border:0; }'
|
|
13148
14262
|
+ '.aptx-panel .' + PROGNAMELINK + ' { background:none; padding:0; margin:0; cursor:pointer;'
|
|
13149
14263
|
+ ' font-family:inherit; font-weight:inherit; letter-spacing:inherit; }'
|
|
@@ -13394,6 +14508,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
13394
14508
|
});
|
|
13395
14509
|
actions.appendChild(infoBtn);
|
|
13396
14510
|
|
|
14511
|
+
let sheetBtn = document.createElement('button');
|
|
14512
|
+
sheetBtn.type = 'button';
|
|
14513
|
+
sheetBtn.className = 'aptx-info-btn';
|
|
14514
|
+
sheetBtn.title = 'Control panel: what each control does';
|
|
14515
|
+
sheetBtn.setAttribute('aria-label', 'Control panel cheat sheet');
|
|
14516
|
+
sheetBtn.innerHTML = makeGlyph('cheat_sheet');
|
|
14517
|
+
sheetBtn.addEventListener('click', function (e) {
|
|
14518
|
+
e.stopPropagation();
|
|
14519
|
+
showCheatSheetDialog();
|
|
14520
|
+
});
|
|
14521
|
+
actions.appendChild(sheetBtn);
|
|
14522
|
+
|
|
13397
14523
|
let progName = header.querySelector('.' + PROGNAMELINK);
|
|
13398
14524
|
if (progName) {
|
|
13399
14525
|
// Clicking the title used to navigate away to the project site;
|
|
@@ -13465,6 +14591,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
13465
14591
|
fitPanelSections(panel, name);
|
|
13466
14592
|
}
|
|
13467
14593
|
savePanelSections();
|
|
14594
|
+
// a folded section's controls are off the screen, so they
|
|
14595
|
+
// leave the cheat sheet: it describes what the panel is
|
|
14596
|
+
// showing, and folding does not go through a tree render
|
|
14597
|
+
refreshCheatSheet();
|
|
13468
14598
|
});
|
|
13469
14599
|
}
|
|
13470
14600
|
// What arrived open counts as opened, oldest at the BOTTOM: a panel is
|
|
@@ -14253,7 +15383,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
14253
15383
|
['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
|
|
14254
15384
|
['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
|
|
14255
15385
|
['License', LICENSE_WEBSITE, LICENSE_NAME],
|
|
14256
|
-
['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']
|
|
15386
|
+
['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')'],
|
|
15387
|
+
['Control panel', null, 'what each control does']].forEach(function (row) {
|
|
14257
15388
|
let line = document.createElement('div');
|
|
14258
15389
|
line.className = 'aptx-dialog-line';
|
|
14259
15390
|
let key = document.createElement('span');
|
|
@@ -14267,12 +15398,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
14267
15398
|
a.target = '_blank';
|
|
14268
15399
|
a.rel = 'noopener noreferrer';
|
|
14269
15400
|
} else {
|
|
14270
|
-
// the
|
|
15401
|
+
// the two rows with no URL open another dialog in place of
|
|
15402
|
+
// this one: the key list, and the control-panel sheet
|
|
15403
|
+
let opener = row[0] === 'Control panel' ? showCheatSheetDialog : showShortcutsDialog;
|
|
14271
15404
|
a.href = '#';
|
|
14272
15405
|
a.addEventListener('click', function (e) {
|
|
14273
15406
|
e.preventDefault();
|
|
14274
15407
|
shell.dialog.close();
|
|
14275
|
-
|
|
15408
|
+
opener();
|
|
14276
15409
|
});
|
|
14277
15410
|
}
|
|
14278
15411
|
a.textContent = row[2];
|
|
@@ -14843,7 +15976,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
14843
15976
|
function makeProgramDesc() {
|
|
14844
15977
|
let h = "";
|
|
14845
15978
|
h = h.concat('<div class=' + PROG_NAME + '>');
|
|
14846
|
-
|
|
15979
|
+
// aria-label without the version: the cheat sheet names a control
|
|
15980
|
+
// by this, and a version is a value -- it belongs in the About box
|
|
15981
|
+
// it opens, not in a row that says what the button is
|
|
15982
|
+
h = h.concat('<button type="button" class="' + PROGNAMELINK + '" aria-label="' + NAME
|
|
15983
|
+
+ '" title="About ' + NAME + '">' + NAME + ' ' + VERSION + '</button>');
|
|
14847
15984
|
h = h.concat('</div>');
|
|
14848
15985
|
return h;
|
|
14849
15986
|
}
|
|
@@ -14994,14 +16131,17 @@ function (root, d3, forester, phyloXml) {
|
|
|
14994
16131
|
// desktop
|
|
14995
16132
|
let layoutGroup = 'layout_control_radio';
|
|
14996
16133
|
h = h.concat('<div class="aptx-segmented">');
|
|
14997
|
-
h = h.concat(makeSegment(makeGlyph('rectangular'), LAYOUT_RECT_BUTTON, layoutGroup,
|
|
14998
|
-
|
|
14999
|
-
h = h.concat(makeSegment(makeGlyph('
|
|
16134
|
+
h = h.concat(makeSegment(makeGlyph('rectangular'), LAYOUT_RECT_BUTTON, layoutGroup,
|
|
16135
|
+
'rectangular layout: the root at the left, the tips in even rows down the page'));
|
|
16136
|
+
h = h.concat(makeSegment(makeGlyph('circular'), LAYOUT_CIRC_BUTTON, layoutGroup,
|
|
16137
|
+
'circular layout: the tree as a fan, every tip label carried out to a common ring'));
|
|
16138
|
+
h = h.concat(makeSegment(makeGlyph('unrooted'), LAYOUT_UNROOTED_BUTTON, layoutGroup,
|
|
16139
|
+
'unrooted layout: equal-angle, drawing no root branch at all'));
|
|
15000
16140
|
h = h.concat('</div>');
|
|
15001
16141
|
h = h.concat('<div class="' + PHYLOGRAM_CLADOGRAM_CONTROLGROUP + ' aptx-segmented">');
|
|
15002
|
-
h = h.concat(makeSegment(makeGlyph('phylogram'), PHYLOGRAM_BUTTON, radioGroup,
|
|
15003
|
-
h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup,
|
|
15004
|
-
h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup,
|
|
16142
|
+
h = h.concat(makeSegment(makeGlyph('phylogram'), PHYLOGRAM_BUTTON, radioGroup, PHYLOGRAM_WHAT));
|
|
16143
|
+
h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, PHYLOGRAM_ALIGNED_WHAT));
|
|
16144
|
+
h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, CLADOGRAM_WHAT));
|
|
15005
16145
|
h = h.concat('</div>');
|
|
15006
16146
|
// The branch SCALE, when the tree states two different things: its
|
|
15007
16147
|
// nodes' dates, and its branches' divergence. Hidden otherwise,
|
|
@@ -15084,10 +16224,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
15084
16224
|
// "Auto-hide Labels" matches the desktop, which renamed the historical
|
|
15085
16225
|
// "Dyna Hide" because that named the mechanism rather than what the user
|
|
15086
16226
|
// sees. The _state.dynahide field keeps its name internally.
|
|
15087
|
-
opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels
|
|
16227
|
+
opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels, and branch values that would overprint a name or each other, where the tree is too dense for them to be readable', true));
|
|
15088
16228
|
opts.push(makeCheckboxItem('Short Names', SHORTEN_NODE_NAME_CB, 'to shorten long node names'));
|
|
15089
16229
|
if (_basicTreeProperties.confidences) {
|
|
15090
|
-
opts.push(makeCheckboxItem('Support Dots', SUPPORT_DOTS_CB, 'to mark
|
|
16230
|
+
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'));
|
|
15091
16231
|
}
|
|
15092
16232
|
// Styles and visualizations sit here too, so the Nodes group only
|
|
15093
16233
|
// appears at all on a tree carrying node/branch events
|
|
@@ -15164,7 +16304,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
15164
16304
|
h = h.concat('<fieldset>');
|
|
15165
16305
|
h = h.concat('<input type="button" value="Download" name="' + DOWNLOAD_BUTTON + '" title="download/export tree in a selected format" id="' + DOWNLOAD_BUTTON + '">');
|
|
15166
16306
|
//h = h.concat('<br>');
|
|
15167
|
-
h = h.concat('<select name="' + EXPORT_FORMAT_SELECT + '" id="' + EXPORT_FORMAT_SELECT
|
|
16307
|
+
h = h.concat('<select name="' + EXPORT_FORMAT_SELECT + '" id="' + EXPORT_FORMAT_SELECT
|
|
16308
|
+
+ '" aria-label="Download format" title="the file format the Download button writes">');
|
|
15168
16309
|
if (pngExportAvailable()) {
|
|
15169
16310
|
h = h.concat('<option value="' + PNG_EXPORT_FORMAT + '">' + PNG_EXPORT_FORMAT + '</option>');
|
|
15170
16311
|
}
|
|
@@ -15314,8 +16455,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
15314
16455
|
let h = "";
|
|
15315
16456
|
h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
|
|
15316
16457
|
h = h.concat('<div class="aptx-search-menus">');
|
|
15317
|
-
h = h.concat('<select class="aptx-search-field" name="' + fsel + '" id="' + fsel
|
|
15318
|
-
|
|
16458
|
+
h = h.concat('<select class="aptx-search-field" name="' + fsel + '" id="' + fsel
|
|
16459
|
+
+ '" aria-label="' + label + ' field" title="the field to search in"></select>');
|
|
16460
|
+
h = h.concat('<select class="aptx-search-mode" name="' + msel + '" id="' + msel
|
|
16461
|
+
+ '" aria-label="' + label + ' match" title="how to match"></select>');
|
|
15319
16462
|
h = h.concat('</div>');
|
|
15320
16463
|
h = h.concat('<div class="aptx-search-row">');
|
|
15321
16464
|
h = h.concat('<input class="aptx-search-value" autocomplete="off" title="' + valTip + '" type="text" name="' + val + '" id="' + val + '">');
|
|
@@ -15522,9 +16665,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
15522
16665
|
}
|
|
15523
16666
|
|
|
15524
16667
|
function setDisplayTypeButtons() {
|
|
15525
|
-
|
|
15526
|
-
|
|
15527
|
-
|
|
16668
|
+
// In circular the labels ARE aligned, whatever alignPhylogram says, so
|
|
16669
|
+
// that is the button shown as chosen: the panel describes the picture.
|
|
16670
|
+
// The flag itself is left alone -- it is the user's choice for the
|
|
16671
|
+
// layouts that can express it, and returning to rectangular restores
|
|
16672
|
+
// what they picked there.
|
|
16673
|
+
let alignedLook = _state.phylogram && (_state.alignPhylogram || _state.circularDisplay);
|
|
16674
|
+
setRadioButtonValue(PHYLOGRAM_BUTTON, _state.phylogram && !alignedLook);
|
|
16675
|
+
setRadioButtonValue(CLADOGRAM_BUTTON, !_state.phylogram);
|
|
16676
|
+
setRadioButtonValue(PHYLOGRAM_ALIGNED_BUTTON, alignedLook);
|
|
15528
16677
|
setCheckboxValue(LAYOUT_CIRC_BUTTON, _state.circularDisplay);
|
|
15529
16678
|
setCheckboxValue(LAYOUT_UNROOTED_BUTTON, _state.unrootedDisplay);
|
|
15530
16679
|
setCheckboxValue(LAYOUT_RECT_BUTTON, !radialDisplay());
|
|
@@ -15545,6 +16694,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
15545
16694
|
|
|
15546
16695
|
function updateButtonEnabledState() {
|
|
15547
16696
|
syncZoomRowButtons(); // layout- and tree-dependent disables track every render
|
|
16697
|
+
refreshCheatSheet(); // an open sheet describes the panel as it is now
|
|
15548
16698
|
// the uncollapse-all button lives only while something is collapsed, as on the desktop
|
|
15549
16699
|
((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
|
|
15550
16700
|
if (_in_subtree) {
|