archaeopteryx 3.14.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -7
- package/archaeopteryx.js +453 -23
- package/forester.js +191 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -257,8 +257,10 @@ the mouse wheel zooms too, and never rotates), **X− / X+ become rotate** (a
|
|
|
257
257
|
direction** flip — labels riding their spokes or standing upright — while
|
|
258
258
|
vertical expansion greys out. **Fit** centres and scales the fan; **Esc**
|
|
259
259
|
also resets rotation and label direction. Unrooted
|
|
260
|
-
additionally greys out the aligned-phylogram option
|
|
261
|
-
|
|
260
|
+
additionally greys out the aligned-phylogram option (there is no common label
|
|
261
|
+
edge). Auto-hide Labels stays live: the tip-label thinning it governs needs
|
|
262
|
+
even row spacing and so does nothing in unrooted, but the crowded-branch-data
|
|
263
|
+
rule it also governs applies in every layout.
|
|
262
264
|
|
|
263
265
|
## Rooting
|
|
264
266
|
|
|
@@ -526,6 +528,16 @@ section holds the controls:
|
|
|
526
528
|
the tree), or `None`.
|
|
527
529
|
* **Glow** — a soft glow in each domain's own colour around its box.
|
|
528
530
|
|
|
531
|
+
**Hovering a domain** reads it out: the domain's name, its E-value (small
|
|
532
|
+
ones keep their exponent, 7.20e-117), the residues it spans with its own length and the protein's,
|
|
533
|
+
the tree tip it belongs to, and its accession where the file carries one.
|
|
534
|
+
**Clicking the box** looks the domain up: straight to the Pfam entry when
|
|
535
|
+
there is an accession, and an InterPro search by name when there is not —
|
|
536
|
+
phyloXML's domain `id` is optional, and a file with names alone (our own
|
|
537
|
+
`apaf.xml` among them) cannot address an entry, since InterPro resolves
|
|
538
|
+
accessions and not Pfam identifiers. Only the domain boxes take the mouse, so
|
|
539
|
+
the tree behind the track stays clickable.
|
|
540
|
+
|
|
529
541
|
In the circular and unrooted layouts the tracks ride each tip's spoke
|
|
530
542
|
outward and carry no names (the legend still works); they need radial
|
|
531
543
|
labels, which switching layouts turns on. A malformed domain — a missing or
|
|
@@ -555,7 +567,7 @@ To find a motif, pick **Molecular Sequence** in a search box: it matches the
|
|
|
555
567
|
residues as written, gap characters included, as the desktop does.
|
|
556
568
|
|
|
557
569
|
**Sequence Logo** (the checkbox under **Alignment**) replaces the conservation
|
|
558
|
-
bar with a **logo**:
|
|
570
|
+
bar with a **logo**: a stack of letters per column, as tall as that column's
|
|
559
571
|
information content in bits and shared out by residue frequency, most frequent
|
|
560
572
|
on top — the display the MEME Suite and WebLogo draw. A conserved column is one
|
|
561
573
|
tall letter, a variable one a short pile, and the caption gives the scale
|
|
@@ -566,7 +578,8 @@ clade's motif rather than the file's, and the caption names how many tips that
|
|
|
566
578
|
is (`n = 12`). Two consequences worth knowing: gaps are not a letter —
|
|
567
579
|
frequencies are taken over the residues present, and the stack is then scaled
|
|
568
580
|
by the column's occupancy, so a column held up by two sequences out of fifty
|
|
569
|
-
draws short rather than perfectly conserved
|
|
581
|
+
draws short rather than perfectly conserved and an all-gap column draws
|
|
582
|
+
nothing; and there is **no small-sample
|
|
570
583
|
correction**, because entering a three-tip clade is a normal thing to do and
|
|
571
584
|
Schneider's correction would subtract more than the maximum and leave the
|
|
572
585
|
column blank. Read `n` and judge.
|
|
@@ -1040,11 +1053,13 @@ each with a checkbox under Display Data to turn it off.
|
|
|
1040
1053
|
Support and branch-length values draw **2 px smaller than the label font**
|
|
1041
1054
|
(never below 6 px), as on the desktop, so they annotate without competing.
|
|
1042
1055
|
And besides the numeric display there are **Support Dots**: a filled dot at
|
|
1043
|
-
the midpoint of
|
|
1056
|
+
the midpoint of a branch whose support is at least 95% (`supportDotMinimum`;
|
|
1044
1057
|
posterior- and bootstrap-scaled trees are told apart automatically). The dot
|
|
1045
1058
|
is always a fixed amount wider than the branch itself, so it tracks the
|
|
1046
1059
|
Branch Width slider instead of sitting at one fixed size. A branch drawn
|
|
1047
|
-
shorter than the dot itself stays clean
|
|
1060
|
+
shorter than the dot itself stays clean, and so does one whose dot would land
|
|
1061
|
+
on a dot already drawn — see the crowding rule below, which is what decides
|
|
1062
|
+
between them rather than either one being shrunk.
|
|
1048
1063
|
|
|
1049
1064
|
The **control panel itself** follows the same idea. It opens showing the
|
|
1050
1065
|
sections that describe the tree — what it can be coloured by, what it shows —
|
|
@@ -1090,6 +1105,37 @@ It describes what is on screen, so inside a subtree it describes the subtree
|
|
|
1090
1105
|
and says so, and it is recomputed each time it opens rather than cached (24 ms
|
|
1091
1106
|
on the 13,246-tip demo). There is no histogram.
|
|
1092
1107
|
|
|
1108
|
+
**Auto-hide Labels** also thins out **crowded branch data**. Support values,
|
|
1109
|
+
branch-length values and support symbols are drawn only where they fit: each
|
|
1110
|
+
mark claims the box it is about to occupy, and one that would overlap a box
|
|
1111
|
+
already claimed in that pass is left out. The claim order is the tree's own,
|
|
1112
|
+
root first, so the mark nearer the root keeps its place and the same tree at
|
|
1113
|
+
the same size always drops the same marks — on screen and in every export.
|
|
1114
|
+
Numbers and symbols are kept in separate maps, since the symbol sits on the
|
|
1115
|
+
branch and the numbers just above and below it. Symbols are never shrunk to
|
|
1116
|
+
fit: where a symbol's size means something, a smaller one would report
|
|
1117
|
+
something else. Switch the toggle off and everything is drawn. Zero needs no
|
|
1118
|
+
special case — a lone zero-length branch overlaps nothing, so its number
|
|
1119
|
+
stays. Branch events take part too. This is the desktop Archaeopteryx's rule,
|
|
1120
|
+
adopted so the two programs thin the same tree the same way; the marks they
|
|
1121
|
+
drop are close but not identical, because the boxes come from each program's
|
|
1122
|
+
own font metrics.
|
|
1123
|
+
|
|
1124
|
+
It applies in **all three layouts**. In the circular and unrooted views a mark
|
|
1125
|
+
rides its branch: it is drawn at the branch's midpoint, turned to the branch's
|
|
1126
|
+
direction and set just off the line, so the box claimed for it is the
|
|
1127
|
+
axis-aligned bounds of that turned rectangle, centred where the text's own
|
|
1128
|
+
centre lands — the desktop's formula, which carries over because both programs
|
|
1129
|
+
measure it in real screen space rather than in the layout's own units. A
|
|
1130
|
+
diagonal label's bounds are wider than its ink, so a crowded number is dropped
|
|
1131
|
+
rather than overprinted. The circular view of `flu_h5.xml` goes from 785 drawn
|
|
1132
|
+
numbers, 743 of them overlapping another, to 87 with none.
|
|
1133
|
+
|
|
1134
|
+
One placement follows from that: in the two radial views every mark is drawn on
|
|
1135
|
+
the same point, the branch's midpoint, so a **branch event** sits a line clear
|
|
1136
|
+
of the branch-length value rather than on top of it. In the rectangular layout
|
|
1137
|
+
they are already apart along the branch and share the line above it.
|
|
1138
|
+
|
|
1093
1139
|
A host with little room to give can
|
|
1094
1140
|
also start the whole panel tighter and narrower with
|
|
1095
1141
|
[`panelDensity: 'compact'`](#configuration), or collapsed to its header bar
|
|
@@ -1128,7 +1174,7 @@ copy-pastable JSON.
|
|
|
1128
1174
|
| `layout` | `'rectangular'` | The starting layout: `'rectangular'`, `'circular'`, or `'unrooted'`. |
|
|
1129
1175
|
| `ladderizeTree` | `true` | Ladderize the tree on load: at each node, the larger clade first (any number of children, so a polytomy sorts too). |
|
|
1130
1176
|
| `showMsa` | tree-derived | Open with the alignment track shown. Default: on when the tree carries an aligned `mol_seq`, off otherwise — an explicit `true`/`false` overrides that. |
|
|
1131
|
-
| `showMsaLogo` | `false` | Open with the alignment summarised as a sequence logo instead of a conservation bar:
|
|
1177
|
+
| `showMsaLogo` | `false` | Open with the alignment summarised as a sequence logo instead of a conservation bar: a stack of letters per column, as tall as its information content, over the tips currently on screen. Only drawn while the alignment track is shown. |
|
|
1132
1178
|
| `showHeatmap` | `false` | Open with the heat map shown. Offered whenever the tree carries two or more numeric per-tip fields, but off unless asked for: almost any annotated tree has such fields, so turning it on by itself would be an opinion about the tree rather than a service. |
|
|
1133
1179
|
| `heatmapColumnOrder` | tree-derived | How the heat map's columns are ordered: `'document'` (as the file lists them), `'clustered'` (Euclidean), `'clustered-presence'` (Bray–Curtis), `'alphabetical'`, `'frequency'`. The clustered modes also draw the dendrogram. Default: a **clustered** order, with the distance chosen from the values — Bray–Curtis where the matrix has zeros to ignore and nothing negative, Euclidean otherwise. An explicit value always wins and is never re-derived. |
|
|
1134
1180
|
| `heatmapManualOrder` | `null` | The heat map's columns in your own order, as an array of property refs (`['meta:recA', 'meta:gyrA', …]`). Only read while `heatmapColumnOrder` is `'manual'`. A ref the tree has not got is ignored, and a column the list does not name follows the ones it does. |
|
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.15.0
|
|
24
24
|
// 2026-09-17
|
|
25
25
|
//
|
|
26
26
|
// Archaeopteryx.js is a software tool for the visualization and
|
|
@@ -103,7 +103,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
103
103
|
// IIFE's own function name -- a plain object says what it is.)
|
|
104
104
|
let archaeopteryx = {};
|
|
105
105
|
|
|
106
|
-
const VERSION = '3.
|
|
106
|
+
const VERSION = '3.15.0';
|
|
107
107
|
const WEBSITE = 'https://cmzmasek.github.io/archaeopteryx-js/';
|
|
108
108
|
const DESKTOP_WEBSITE = 'https://cmzmasek.github.io/archaeopteryx/';
|
|
109
109
|
const SOURCE_WEBSITE = 'https://github.com/cmzmasek/archaeopteryx-js';
|
|
@@ -1481,6 +1481,26 @@ function (root, d3, forester, phyloXml) {
|
|
|
1481
1481
|
// so the guess was what the reader saw every time.
|
|
1482
1482
|
const HOVER_READOUT_GAP = 14;
|
|
1483
1483
|
|
|
1484
|
+
// One way to open an external page. There were three: window.open + a
|
|
1485
|
+
// win.focus() that throws when a popup blocker hands back null and which
|
|
1486
|
+
// passed no noopener, an <a target="_blank" rel="noopener noreferrer">,
|
|
1487
|
+
// and a third for the domain link. Three security postures for one job,
|
|
1488
|
+
// and a change to the policy would have had to find all of them.
|
|
1489
|
+
//
|
|
1490
|
+
// noopener: the opened page must not get a handle on this window.
|
|
1491
|
+
// noreferrer with it, since a tree can come from anywhere and its URL is
|
|
1492
|
+
// not the external site's business.
|
|
1493
|
+
function openExternal(url) {
|
|
1494
|
+
if (!url) {
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
let win = window.open(url, '_blank', 'noopener,noreferrer');
|
|
1498
|
+
if (win) {
|
|
1499
|
+
win.focus(); // a blocked popup hands back null; that is not an error
|
|
1500
|
+
}
|
|
1501
|
+
return win;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1484
1504
|
function placeHoverReadout(el, event) {
|
|
1485
1505
|
let w = el.offsetWidth;
|
|
1486
1506
|
let h = el.offsetHeight;
|
|
@@ -2319,12 +2339,23 @@ function (root, d3, forester, phyloXml) {
|
|
|
2319
2339
|
|
|
2320
2340
|
let _legendMeasureCtx = null;
|
|
2321
2341
|
|
|
2322
|
-
|
|
2342
|
+
// One lazily made canvas for every text measurement: the legend's widths,
|
|
2343
|
+
// the branch-data line metrics and the sequence logo's glyph boxes each
|
|
2344
|
+
// used to make their own.
|
|
2345
|
+
function measureCtx() {
|
|
2323
2346
|
if (!_legendMeasureCtx) {
|
|
2324
2347
|
_legendMeasureCtx = document.createElement('canvas').getContext('2d');
|
|
2325
2348
|
}
|
|
2326
|
-
_legendMeasureCtx
|
|
2327
|
-
|
|
2349
|
+
return _legendMeasureCtx;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
function legendTextWidth(text, font) {
|
|
2353
|
+
let ctx = measureCtx();
|
|
2354
|
+
if (!ctx) {
|
|
2355
|
+
return 0;
|
|
2356
|
+
}
|
|
2357
|
+
ctx.font = font;
|
|
2358
|
+
return ctx.measureText(text).width;
|
|
2328
2359
|
}
|
|
2329
2360
|
|
|
2330
2361
|
// The card's row order and cap, the desktop's way: rank by count first
|
|
@@ -3178,7 +3209,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
3178
3209
|
.attr('transform', function (d) {
|
|
3179
3210
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3180
3211
|
})
|
|
3181
|
-
|
|
3212
|
+
// Every one of these three reads its offset from branchMarkPlacement,
|
|
3213
|
+
// the same function the occupancy boxes are built from: a box that is
|
|
3214
|
+
// not where the text is hides the wrong marks, and two copies of the
|
|
3215
|
+
// same number drift apart the first time one of them is edited.
|
|
3216
|
+
.attr('dy', branchMarkPlacement('bl', _state.branchDataFontSize).dy)
|
|
3182
3217
|
.attr('x', function (d) {
|
|
3183
3218
|
if (radialDisplay()) {
|
|
3184
3219
|
return 0;
|
|
@@ -3199,7 +3234,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3199
3234
|
.attr('transform', function (d) {
|
|
3200
3235
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3201
3236
|
})
|
|
3202
|
-
.attr('dy', _state.branchDataFontSize)
|
|
3237
|
+
.attr('dy', branchMarkPlacement('conf', _state.branchDataFontSize).dy)
|
|
3203
3238
|
.attr('x', function (d) {
|
|
3204
3239
|
if (radialDisplay()) {
|
|
3205
3240
|
return 0;
|
|
@@ -3235,7 +3270,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3235
3270
|
.attr('transform', function (d) {
|
|
3236
3271
|
return radialDisplay() ? branchLabelTransform(d) : null;
|
|
3237
3272
|
})
|
|
3238
|
-
.attr('dy', '
|
|
3273
|
+
.attr('dy', branchMarkPlacement('event', _state.branchDataFontSize).dy)
|
|
3239
3274
|
.attr('x', function (d) {
|
|
3240
3275
|
if (radialDisplay()) {
|
|
3241
3276
|
return 0;
|
|
@@ -3820,6 +3855,169 @@ function (root, d3, forester, phyloXml) {
|
|
|
3820
3855
|
// a support dot. Returns the nodes that need a <g> -- those with any of
|
|
3821
3856
|
// them, and every collapsed clade (its wedge and label). The rest are
|
|
3822
3857
|
// drawn entirely by drawTreeGeometry's batched paths.
|
|
3858
|
+
// ===================== Crowded branch data =====================
|
|
3859
|
+
// A branch-anchored mark is drawn only when its box does not overlap one
|
|
3860
|
+
// already granted in the same pass. Ported from the desktop's
|
|
3861
|
+
// LabelOccupancy (0.11.161) as a JOINT rule on Christian's word,
|
|
3862
|
+
// 2026-09-25: "match the desktop's new auto-hide rule for crowded branch
|
|
3863
|
+
// data". Neither side retunes it alone.
|
|
3864
|
+
//
|
|
3865
|
+
// It exists because the obvious rule does not work. The desktop built
|
|
3866
|
+
// "hide when the branch is drawn shorter than the text" first and measured
|
|
3867
|
+
// it: on an 825-tip tree a third-of-text threshold hid 285 numbers of
|
|
3868
|
+
// which only 51 collided, and still left 22 pile-ups. A number is
|
|
3869
|
+
// unreadable when it overlaps ANOTHER number, which depends on closeness
|
|
3870
|
+
// in y as much as in x, and branch length only sees x. Measured here
|
|
3871
|
+
// before adopting it: with Auto-hide Labels already ON, 240 of 267 drawn
|
|
3872
|
+
// support numbers overlapped another on flu_h5.xml (90%), 149 of 262 on
|
|
3873
|
+
// Adenoviridae, 13 of 119 on bcl2 -- our auto-hide decimates TIP labels
|
|
3874
|
+
// and had never touched branch data.
|
|
3875
|
+
//
|
|
3876
|
+
// WHAT MUST MATCH, or the two viewers drop different marks:
|
|
3877
|
+
// - the tie-break: PREORDER, first claim wins, so the mark nearer the
|
|
3878
|
+
// root keeps its place. d3's descendants() is preorder already.
|
|
3879
|
+
// - TWO maps, numbers and symbols. The symbol sits ON the branch and the
|
|
3880
|
+
// numbers just above and below it, so one shared map would let a
|
|
3881
|
+
// branch's own symbol block its own numbers.
|
|
3882
|
+
// - a refused claim records NOTHING, so a rejected mark never blocks a
|
|
3883
|
+
// later one.
|
|
3884
|
+
// - a zero-size box is granted and not recorded.
|
|
3885
|
+
// - symbols are never shrunk to fit: in a size-scaled mode the diameter
|
|
3886
|
+
// IS the support, and a smaller dot would report weaker support.
|
|
3887
|
+
//
|
|
3888
|
+
// WHAT CANNOT MATCH, and is not meant to: the COUNTS. The boxes come from
|
|
3889
|
+
// each program's own font metrics, so the same tree at the same size drops
|
|
3890
|
+
// a similar but not identical set. The rule is the contract; the tally is
|
|
3891
|
+
// not.
|
|
3892
|
+
//
|
|
3893
|
+
// Where a branch-anchored mark sits, in the tree group's coordinates.
|
|
3894
|
+
// Mirrors the placement in syncOptionalNodeChildren exactly -- the branch
|
|
3895
|
+
// length back toward the parent and above the branch, the confidence at
|
|
3896
|
+
// the branch midpoint and below it -- because a box that is not where the
|
|
3897
|
+
// text is would hide the wrong marks.
|
|
3898
|
+
// The font's LINE box, which is what the marks are laid out with and what
|
|
3899
|
+
// the desktop measures too (FontMetrics.getHeight()). Not the per-glyph
|
|
3900
|
+
// ink box: two numbers collide when their lines overlap, and ink boxes
|
|
3901
|
+
// would let "1" slide under "0.81".
|
|
3902
|
+
//
|
|
3903
|
+
// Measured from the font rather than assumed. The fallback is what this
|
|
3904
|
+
// font actually reports at 9px -- ascent 9, descent 2, line 11 -- read off
|
|
3905
|
+
// a rendered label's getBBox rather than guessed.
|
|
3906
|
+
let _branchFontCache = null;
|
|
3907
|
+
|
|
3908
|
+
function branchFontMetrics(fontPx) {
|
|
3909
|
+
if (_branchFontCache && _branchFontCache.px === fontPx) {
|
|
3910
|
+
return _branchFontCache;
|
|
3911
|
+
}
|
|
3912
|
+
let ctx = measureCtx();
|
|
3913
|
+
ctx.font = fontPx + 'px ' + FONT_DEFAULTS;
|
|
3914
|
+
let m = ctx.measureText('0');
|
|
3915
|
+
let ascent = m.fontBoundingBoxAscent;
|
|
3916
|
+
let descent = m.fontBoundingBoxDescent;
|
|
3917
|
+
// Both, separately: a canvas that reports an ascent and no descent
|
|
3918
|
+
// would give height = ascent + undefined = NaN, every box would be
|
|
3919
|
+
// refused a claim as zero-sized, and the feature would turn itself off
|
|
3920
|
+
// with no error and a green suite.
|
|
3921
|
+
if (!(ascent > 0)) {
|
|
3922
|
+
ascent = fontPx;
|
|
3923
|
+
}
|
|
3924
|
+
if (!(descent >= 0)) {
|
|
3925
|
+
descent = fontPx * (2 / 9);
|
|
3926
|
+
}
|
|
3927
|
+
_branchFontCache = {px: fontPx, ascent: ascent, descent: descent,
|
|
3928
|
+
height: ascent + descent};
|
|
3929
|
+
return _branchFontCache;
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
// Where a branch mark sits on its branch, read off the rendered elements
|
|
3933
|
+
// rather than inferred: the horizontal anchor and the `dy` the element is
|
|
3934
|
+
// drawn with. Getting either wrong is not a rounding error. A
|
|
3935
|
+
// middle-anchored box modelled as left-anchored is half its own width out,
|
|
3936
|
+
// which once let 44% of the granted numbers still overlap; and a branch
|
|
3937
|
+
// EVENT sits a quarter em ABOVE the line like the branch length, not a
|
|
3938
|
+
// font size below it like the confidence, so modelling it as a confidence
|
|
3939
|
+
// put its box on the wrong side of the branch -- the one side it could
|
|
3940
|
+
// never collide on.
|
|
3941
|
+
function branchMarkPlacement(kind, fontPx) {
|
|
3942
|
+
if (kind === 'conf') {
|
|
3943
|
+
return {mid: true, dy: fontPx};
|
|
3944
|
+
}
|
|
3945
|
+
if (kind === 'event') {
|
|
3946
|
+
// A radial layout draws every mark on ONE point, the branch's
|
|
3947
|
+
// midpoint, so a branch event and a branch length -- both a quarter
|
|
3948
|
+
// em above the line -- printed straight through each other. The
|
|
3949
|
+
// event is lifted a whole line clear of the length. The rectangular
|
|
3950
|
+
// layout already separates them along the branch (the length at the
|
|
3951
|
+
// parent end, the event at the middle), so there they share a line.
|
|
3952
|
+
return {mid: true, dy: radialDisplay()
|
|
3953
|
+
? ((-0.25 * fontPx) - branchFontMetrics(fontPx).height)
|
|
3954
|
+
: (-0.25 * fontPx)};
|
|
3955
|
+
}
|
|
3956
|
+
return {mid: false, dy: -0.25 * fontPx}; // 'bl': start-anchored, a quarter em up
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
// The incoming branch's MIDPOINT and screen direction, in the tree group's
|
|
3960
|
+
// own coordinates -- what a radial mark is actually drawn on, since
|
|
3961
|
+
// branchLabelTransform rotates to the spoke and translates half a branch
|
|
3962
|
+
// back along it. Circular: the leg runs along ONE spoke, so the midpoint is
|
|
3963
|
+
// the mean of the two radii at the same angle (an arc's chord is not on the
|
|
3964
|
+
// leg). Unrooted: the branch is a straight line, so it is the mean of the
|
|
3965
|
+
// two points. Both match the desktop's two call sites of
|
|
3966
|
+
// paintBranchDataRadial.
|
|
3967
|
+
function radialBranchMid(d) {
|
|
3968
|
+
let angle = spokeAngle(d);
|
|
3969
|
+
if (_state.unrootedDisplay) {
|
|
3970
|
+
if (d.ux === undefined) {
|
|
3971
|
+
return null;
|
|
3972
|
+
}
|
|
3973
|
+
let p = (d.parent && d.parent.ux !== undefined) ? d.parent : null;
|
|
3974
|
+
return p ? [0.5 * (d.ux + p.ux), 0.5 * (d.uy + p.uy), angle] : [d.ux, d.uy, angle];
|
|
3975
|
+
}
|
|
3976
|
+
let r = radialRadius(d.y);
|
|
3977
|
+
if (d.parent) {
|
|
3978
|
+
r = 0.5 * (r + radialRadius(d.parent.y));
|
|
3979
|
+
}
|
|
3980
|
+
let pt = polarXY(radialAngle(d.x), r);
|
|
3981
|
+
return [pt[0], pt[1], angle];
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
function branchMarkBox(d, kind, text, fontPx) {
|
|
3985
|
+
let w = legendTextWidth(text, fontPx + 'px ' + FONT_DEFAULTS);
|
|
3986
|
+
let fm = branchFontMetrics(fontPx);
|
|
3987
|
+
let h = fm.height;
|
|
3988
|
+
let at = branchMarkPlacement(kind, fontPx);
|
|
3989
|
+
if (radialDisplay()) {
|
|
3990
|
+
// The mark rides the branch, so its box is the AXIS-ALIGNED bounds
|
|
3991
|
+
// of the rotated rectangle, centred where the text's own centre
|
|
3992
|
+
// lands: forester.rotatedLabelBox, which carries the desktop's
|
|
3993
|
+
// formula and the reasons it is the conservative one.
|
|
3994
|
+
let mid = radialBranchMid(d);
|
|
3995
|
+
if (!mid) {
|
|
3996
|
+
return null;
|
|
3997
|
+
}
|
|
3998
|
+
// `dy` shifts the BASELINE, so the box's centre sits half a line
|
|
3999
|
+
// below it; and the 180-degree flip that keeps the far half of the
|
|
4000
|
+
// fan upright carries this perpendicular offset with it.
|
|
4001
|
+
let off = at.dy + (0.5 * (fm.descent - fm.ascent));
|
|
4002
|
+
if (labelFlip(d)) {
|
|
4003
|
+
off = -off;
|
|
4004
|
+
}
|
|
4005
|
+
// every radial mark is middle-anchored at x=0, so only the
|
|
4006
|
+
// perpendicular offset moves its centre off the branch midpoint
|
|
4007
|
+
return forester.rotatedLabelBox(mid[0] - (Math.sin(mid[2]) * off),
|
|
4008
|
+
mid[1] + (Math.cos(mid[2]) * off), w, h, mid[2]);
|
|
4009
|
+
}
|
|
4010
|
+
// The ROOT's numbers are drawn too, at x=0 in its own group, and they
|
|
4011
|
+
// used to claim nothing -- so the first child's number, landing in
|
|
4012
|
+
// almost the same place, was granted and painted over them. The one
|
|
4013
|
+
// place the pass could not keep its promise.
|
|
4014
|
+
let span = d.parent ? (d.parent.y - d.y) : 0;
|
|
4015
|
+
let x = at.mid
|
|
4016
|
+
? (d.y + (0.5 * span) - (w / 2)) // middle-anchored
|
|
4017
|
+
: (d.y + span + 1); // start-anchored
|
|
4018
|
+
return [x, (d.x + at.dy) - fm.ascent, w, h];
|
|
4019
|
+
}
|
|
4020
|
+
|
|
3823
4021
|
function prepareNodeDrawing(nodes) {
|
|
3824
4022
|
let wantBl = _state.showBranchLengthValues === true;
|
|
3825
4023
|
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
@@ -3841,6 +4039,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
3841
4039
|
d._confText = wantConf ? asText(makeConfidenceValuesLabel(d)) : '';
|
|
3842
4040
|
d._eventText = wantEvent ? asText(makeBranchEventsLabel(d)) : '';
|
|
3843
4041
|
d._suppDot = wantDot && showSupportDot(d);
|
|
4042
|
+
}
|
|
4043
|
+
// Before `drawn` is built, not after: a refused mark used to leave a
|
|
4044
|
+
// childless <g class="node"> behind, and those are transformed on
|
|
4045
|
+
// every animation frame. On the big tree that was ~18,000 empty groups
|
|
4046
|
+
// per redraw, defeating the very thing the `drawn` filter is for.
|
|
4047
|
+
hideCrowdedBranchData(nodes);
|
|
4048
|
+
for (let i = 0, len = nodes.length; i !== len; ++i) {
|
|
4049
|
+
let d = nodes[i];
|
|
3844
4050
|
if (d._extLabelText !== '' || d._blText !== '' || d._confText !== ''
|
|
3845
4051
|
|| d._eventText !== '' || d._suppDot || isCollapsed(d)) {
|
|
3846
4052
|
drawn.push(d);
|
|
@@ -3849,6 +4055,88 @@ function (root, d3, forester, phyloXml) {
|
|
|
3849
4055
|
return drawn;
|
|
3850
4056
|
}
|
|
3851
4057
|
|
|
4058
|
+
// The occupancy pass, in PREORDER -- so the mark nearer the root claims
|
|
4059
|
+
// its space first and keeps it. NOT the order the layout hands out: d3's
|
|
4060
|
+
// descendants() is breadth-first and the viewer reverses it for drawing,
|
|
4061
|
+
// which puts the root LAST and inverts the entire tie-break. That shipped,
|
|
4062
|
+
// and a review caught it; forester.preorderOf derives the right order from
|
|
4063
|
+
// the nodes themselves.
|
|
4064
|
+
//
|
|
4065
|
+
// Runs before anything is drawn, so the decision cannot depend on the
|
|
4066
|
+
// order the DOM happens to be updated in.
|
|
4067
|
+
//
|
|
4068
|
+
// Rides the existing Auto-hide Labels toggle: switch it off and everything
|
|
4069
|
+
// is drawn. Runs in ALL THREE layouts, as the desktop does -- the boxes are
|
|
4070
|
+
// built in the tree group's own coordinates, which is where every mark is
|
|
4071
|
+
// actually drawn, so the circular and unrooted views are measured in their
|
|
4072
|
+
// own space and not in the cluster's. (The tip-label decimation that shares
|
|
4073
|
+
// the toggle is still skipped in unrooted: that one needs even row spacing
|
|
4074
|
+
// to decimate against, and there is none.)
|
|
4075
|
+
function hideCrowdedBranchData(nodes) {
|
|
4076
|
+
if (!_state.dynahide) {
|
|
4077
|
+
return;
|
|
4078
|
+
}
|
|
4079
|
+
// No layout state, no honest boxes. Bailing out draws everything; going
|
|
4080
|
+
// on would put every mark at the origin, where they all overlap and all
|
|
4081
|
+
// but the first would be hidden -- a blank tree, with no error.
|
|
4082
|
+
if ((_state.circularDisplay && !_radial) || (_state.unrootedDisplay && !_unroot)) {
|
|
4083
|
+
return;
|
|
4084
|
+
}
|
|
4085
|
+
let fontPx = _state.branchDataFontSize;
|
|
4086
|
+
let dotCell = 0;
|
|
4087
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
4088
|
+
if (nodes[i]._suppDot) {
|
|
4089
|
+
dotCell = Math.max(dotCell, 2 * supportDotRadius(nodes[i]));
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
let cell = Math.max(fontPx, dotCell);
|
|
4093
|
+
let numbers = forester.labelOccupancy(cell);
|
|
4094
|
+
let symbols = forester.labelOccupancy(cell);
|
|
4095
|
+
let ordered = forester.preorderOf(nodes);
|
|
4096
|
+
for (let i = 0, len = ordered.length; i !== len; ++i) {
|
|
4097
|
+
let d = ordered[i];
|
|
4098
|
+
// Branch length first, then confidence: they sit on opposite sides
|
|
4099
|
+
// of the branch, so they rarely contend, but the order decides
|
|
4100
|
+
// which survives when they do.
|
|
4101
|
+
if (d._blText !== '') {
|
|
4102
|
+
let b = branchMarkBox(d, 'bl', d._blText, fontPx);
|
|
4103
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4104
|
+
d._blText = '';
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
if (d._confText !== '') {
|
|
4108
|
+
let b = branchMarkBox(d, 'conf', d._confText, fontPx);
|
|
4109
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4110
|
+
d._confText = '';
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
// Branch EVENTS are branch-anchored too, half a branch from the
|
|
4114
|
+
// branch length and on the same side of it, so they can land on
|
|
4115
|
+
// the same pixels. Left out of the map, they neither refused a
|
|
4116
|
+
// number nor were refused by one, and the pass reported "no
|
|
4117
|
+
// overlapping marks" while the user could see two.
|
|
4118
|
+
if (d._eventText !== '') {
|
|
4119
|
+
let b = branchMarkBox(d, 'event', d._eventText, fontPx);
|
|
4120
|
+
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4121
|
+
d._eventText = '';
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
if (d._suppDot) {
|
|
4125
|
+
// never shrunk to fit: in a size-scaled mode the diameter IS
|
|
4126
|
+
// the support value, and a smaller dot reports weaker support
|
|
4127
|
+
let r = supportDotRadius(d);
|
|
4128
|
+
// showSupportDot already required a parent; a circle's bounds
|
|
4129
|
+
// do not rotate, so the radial case needs only the midpoint
|
|
4130
|
+
let at = radialDisplay()
|
|
4131
|
+
? radialBranchMid(d)
|
|
4132
|
+
: [d.y + (0.5 * (d.parent.y - d.y)), d.x];
|
|
4133
|
+
if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
|
|
4134
|
+
d._suppDot = false;
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
|
|
3852
4140
|
function syncOptionalNodeChildren(node) {
|
|
3853
4141
|
let wantBl = _state.showBranchLengthValues === true;
|
|
3854
4142
|
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
@@ -6379,8 +6667,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6379
6667
|
}
|
|
6380
6668
|
|
|
6381
6669
|
if (url) {
|
|
6382
|
-
|
|
6383
|
-
win.focus();
|
|
6670
|
+
openExternal(url);
|
|
6384
6671
|
} else {
|
|
6385
6672
|
// Not a programming error -- the user clicked and there is
|
|
6386
6673
|
// simply nowhere to go -- so this one gets told in the same
|
|
@@ -6880,11 +7167,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
6880
7167
|
if (_logoGlyphs[ch]) {
|
|
6881
7168
|
return _logoGlyphs[ch];
|
|
6882
7169
|
}
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
_legendMeasureCtx.font = MSA_LOGO_FONT_PX + 'px monospace';
|
|
6887
|
-
let m = _legendMeasureCtx.measureText(ch);
|
|
7170
|
+
let ctx = measureCtx();
|
|
7171
|
+
ctx.font = MSA_LOGO_FONT_PX + 'px monospace';
|
|
7172
|
+
let m = ctx.measureText(ch);
|
|
6888
7173
|
let ascent = m.actualBoundingBoxAscent;
|
|
6889
7174
|
let descent = m.actualBoundingBoxDescent;
|
|
6890
7175
|
if (!(ascent > 0)) {
|
|
@@ -9955,6 +10240,114 @@ function (root, d3, forester, phyloXml) {
|
|
|
9955
10240
|
// the later one's shadow and glow used to fall across the earlier body,
|
|
9956
10241
|
// and now lie under it; overlapping shadows of one level no longer
|
|
9957
10242
|
// darken each other.
|
|
10243
|
+
// ===================== Domain hover =====================
|
|
10244
|
+
// Domain architectures are what Archaeopteryx has always been best at, and
|
|
10245
|
+
// until now the boxes said nothing: you could see a domain but not read
|
|
10246
|
+
// its name, its E-value or where it sits, short of opening the node's data
|
|
10247
|
+
// dialog and finding it in a list. Christian, 2026-09-23.
|
|
10248
|
+
//
|
|
10249
|
+
// The readout is the one the heat map and the alignment already use, so a
|
|
10250
|
+
// hover anywhere in the viewer looks and behaves the same, and it is
|
|
10251
|
+
// placed by placeHoverReadout, which measures the box rather than assuming
|
|
10252
|
+
// a size.
|
|
10253
|
+
|
|
10254
|
+
function domainInfoAt(event) {
|
|
10255
|
+
let t = event.target;
|
|
10256
|
+
return (t && t.__aptxDomain) ? t.__aptxDomain : null;
|
|
10257
|
+
}
|
|
10258
|
+
|
|
10259
|
+
let _domainHovered = null;
|
|
10260
|
+
|
|
10261
|
+
function domainHover(event) {
|
|
10262
|
+
let info = domainInfoAt(event);
|
|
10263
|
+
if (!info) {
|
|
10264
|
+
domainHoverOut();
|
|
10265
|
+
return;
|
|
10266
|
+
}
|
|
10267
|
+
// mousemove fires all the way across a box, and a wide domain is
|
|
10268
|
+
// hundreds of pixels. The TEXT only changes when the box does, so
|
|
10269
|
+
// re-escaping it, reparsing it as HTML and restarting the fade sixty
|
|
10270
|
+
// times a second would be work for a byte-identical result -- and the
|
|
10271
|
+
// restarted transition would keep opacity approaching 0.95 without
|
|
10272
|
+
// ever arriving. The readout must still FOLLOW the pointer, so the
|
|
10273
|
+
// placement runs either way.
|
|
10274
|
+
let tip_el = _node_mouseover_div.node();
|
|
10275
|
+
if (info === _domainHovered) {
|
|
10276
|
+
placeHoverReadout(tip_el, event);
|
|
10277
|
+
return;
|
|
10278
|
+
}
|
|
10279
|
+
_domainHovered = info;
|
|
10280
|
+
let ref = forester.domainReference(info);
|
|
10281
|
+
let txt = 'Domain: ' + (info.name || '(unnamed)') + '<br>';
|
|
10282
|
+
let ev = forester.domainEvalueText(info.evalue);
|
|
10283
|
+
if (ev !== null) {
|
|
10284
|
+
txt += 'E-value: ' + ev + '<br>';
|
|
10285
|
+
}
|
|
10286
|
+
txt += 'Residues: ' + info.from + '–' + info.to
|
|
10287
|
+
+ ' (' + ((info.to - info.from) + 1) + ' aa'
|
|
10288
|
+
+ (info.proteinLength > 0 ? (' of ' + info.proteinLength) : '') + ')<br>';
|
|
10289
|
+
if (info.tip) {
|
|
10290
|
+
txt += 'Tip: ' + info.tip + '<br>';
|
|
10291
|
+
}
|
|
10292
|
+
if (info.id) {
|
|
10293
|
+
txt += 'Accession: ' + info.id + '<br>';
|
|
10294
|
+
}
|
|
10295
|
+
if (ref) {
|
|
10296
|
+
// A readout that follows the pointer cannot hold a link you could
|
|
10297
|
+
// reach, so the BOX is the link and the readout says so. Written
|
|
10298
|
+
// as a "label: value" row on purpose: markUpDataLabels renders a
|
|
10299
|
+
// line WITHOUT a colon as a section divider (9px bold uppercase
|
|
10300
|
+
// faint), which is the wrong voice entirely for a call to action.
|
|
10301
|
+
txt += ref.exact ? 'Click: the Pfam entry' : 'Click: to look this domain up at InterPro';
|
|
10302
|
+
}
|
|
10303
|
+
tip_el.classList.remove('aptx-light', 'aptx-dark');
|
|
10304
|
+
if (_panelTheme) {
|
|
10305
|
+
tip_el.classList.add('aptx-' + _panelTheme);
|
|
10306
|
+
}
|
|
10307
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
|
|
10308
|
+
placeHoverReadout(tip_el, event);
|
|
10309
|
+
_node_mouseover_div.transition().duration(100).style('opacity', 0.95);
|
|
10310
|
+
}
|
|
10311
|
+
|
|
10312
|
+
function domainHoverOut() {
|
|
10313
|
+
_domainHovered = null;
|
|
10314
|
+
_node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
|
|
10315
|
+
}
|
|
10316
|
+
|
|
10317
|
+
let _domainOpenedAt = 0;
|
|
10318
|
+
|
|
10319
|
+
function domainClicked(event) {
|
|
10320
|
+
let info = domainInfoAt(event);
|
|
10321
|
+
if (!info) {
|
|
10322
|
+
return;
|
|
10323
|
+
}
|
|
10324
|
+
// A click that LANDED on a box is the box's, whether or not it has
|
|
10325
|
+
// somewhere to send you; letting an unlinkable domain fall through
|
|
10326
|
+
// would open the node menu for whatever node is within five units.
|
|
10327
|
+
event.stopPropagation();
|
|
10328
|
+
let ref = forester.domainReference(info);
|
|
10329
|
+
if (!ref) {
|
|
10330
|
+
return;
|
|
10331
|
+
}
|
|
10332
|
+
// Double-clicking is the natural gesture for "open this", and it
|
|
10333
|
+
// arrives as two clicks: without this it opens two identical tabs.
|
|
10334
|
+
let now = Date.now();
|
|
10335
|
+
if ((now - _domainOpenedAt) < 500) {
|
|
10336
|
+
return;
|
|
10337
|
+
}
|
|
10338
|
+
_domainOpenedAt = now;
|
|
10339
|
+
openExternal(ref.url);
|
|
10340
|
+
}
|
|
10341
|
+
|
|
10342
|
+
// The tree zooms on double click. Over a domain box the gesture means the
|
|
10343
|
+
// box, so the zoom must not also happen behind the tabs that just opened.
|
|
10344
|
+
function domainDblClicked(event) {
|
|
10345
|
+
if (domainInfoAt(event)) {
|
|
10346
|
+
event.stopPropagation();
|
|
10347
|
+
event.preventDefault();
|
|
10348
|
+
}
|
|
10349
|
+
}
|
|
10350
|
+
|
|
9958
10351
|
function drawDomainArchitectures() {
|
|
9959
10352
|
if (!_svgGroup) {
|
|
9960
10353
|
return;
|
|
@@ -9966,6 +10359,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
9966
10359
|
let f = tips.length > 0 ? domainScale() : 0;
|
|
9967
10360
|
if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
|
|
9968
10361
|
existing.remove();
|
|
10362
|
+
domainHoverOut(); // the box under the pointer just stopped existing
|
|
9969
10363
|
return;
|
|
9970
10364
|
}
|
|
9971
10365
|
// [defs, the batched paths, the bodies, the names], kept between
|
|
@@ -9985,6 +10379,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
9985
10379
|
let underLayer = g.children[1];
|
|
9986
10380
|
let bodyLayer = g.children[2];
|
|
9987
10381
|
let labelLayer = g.children[3];
|
|
10382
|
+
// The group ignores the mouse so the tree underneath stays clickable;
|
|
10383
|
+
// the BODIES alone take it back, so a hover lands on a domain and not
|
|
10384
|
+
// on its shadow, its glow or its label.
|
|
10385
|
+
bodyLayer.style.pointerEvents = 'auto';
|
|
10386
|
+
if (!bodyLayer.__aptxWired) {
|
|
10387
|
+
bodyLayer.__aptxWired = true;
|
|
10388
|
+
// mousemove alone, as the heat map and the alignment do it: a
|
|
10389
|
+
// mouseover is always followed by a mousemove, so binding both
|
|
10390
|
+
// only built the readout twice on entry.
|
|
10391
|
+
bodyLayer.addEventListener('mousemove', domainHover);
|
|
10392
|
+
bodyLayer.addEventListener('mouseout', domainHoverOut);
|
|
10393
|
+
bodyLayer.addEventListener('click', domainClicked);
|
|
10394
|
+
bodyLayer.addEventListener('dblclick', domainDblClicked);
|
|
10395
|
+
}
|
|
9988
10396
|
|
|
9989
10397
|
// one vertical gradient per base colour, lighter at the top; the
|
|
9990
10398
|
// defs ride into the SVG / PDF / PNG exports with the boxes
|
|
@@ -10087,6 +10495,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
10087
10495
|
}
|
|
10088
10496
|
let set = row.transform ? pathSet() : track;
|
|
10089
10497
|
let geo = forester.domainBoxes(da, row.start, f, _state.domainEvalueExponent);
|
|
10498
|
+
// once per ROW: displayNodeName walks the node's properties when a
|
|
10499
|
+
// label-ref visualization is on, and a row can hold dozens of boxes
|
|
10500
|
+
let rowTip = displayNodeName(row.d) || '';
|
|
10501
|
+
let rowLength = Number(da.length) || 0;
|
|
10090
10502
|
let h = row.h;
|
|
10091
10503
|
let y1 = row.y1;
|
|
10092
10504
|
set.backbones.push(roundRectPath(geo.backbone.x, y1 + (h / 2) - 0.5, geo.backbone.w, 1, 0));
|
|
@@ -10110,7 +10522,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
10110
10522
|
});
|
|
10111
10523
|
}
|
|
10112
10524
|
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)
|
|
10525
|
+
fill: gradientFor(base), stroke: forester.domainDarken(base, 0.24),
|
|
10526
|
+
// what the hover readout says. Carried per BOX rather than
|
|
10527
|
+
// looked up again on hover: the boxes are pooled and
|
|
10528
|
+
// reused between redraws, so an index into the tree would
|
|
10529
|
+
// go stale the moment the tips on screen change.
|
|
10530
|
+
info: {name: b.name, from: b.from, to: b.to, evalue: b.evalue, id: b.id,
|
|
10531
|
+
tip: rowTip, proteinLength: rowLength}});
|
|
10114
10532
|
if (row.labelsOn && b.name && fs > 4 && legendTextWidth(b.name, font) <= b.w - 4) {
|
|
10115
10533
|
names.push({x: b.x + (b.w / 2), y: y1 + (h / 2), font: font,
|
|
10116
10534
|
fill: forester.domainLabelInk(base), text: b.name});
|
|
@@ -10134,6 +10552,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
10134
10552
|
paths[k].style.fill = u.fill;
|
|
10135
10553
|
paths[k].style.fillOpacity = u.opacity;
|
|
10136
10554
|
});
|
|
10555
|
+
// A redraw can take the hovered box away without the pointer moving
|
|
10556
|
+
// -- a keyboard zoom, a panel change applied with Enter, a collapse --
|
|
10557
|
+
// and a removed element fires no mouseout, so the readout would stay
|
|
10558
|
+
// painted over a domain that is no longer there.
|
|
10559
|
+
domainHoverOut();
|
|
10137
10560
|
let rects = sizeLayer(bodyLayer, 'rect', bodies.length);
|
|
10138
10561
|
bodies.forEach(function (b, k) {
|
|
10139
10562
|
let el = rects[k];
|
|
@@ -10151,6 +10574,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
10151
10574
|
el.style.fill = b.fill;
|
|
10152
10575
|
el.style.stroke = b.stroke;
|
|
10153
10576
|
el.style.strokeWidth = 1;
|
|
10577
|
+
el.__aptxDomain = b.info;
|
|
10578
|
+
// only where there is somewhere to go: an unnamed domain with no
|
|
10579
|
+
// accession has no reference, and a hand cursor over it would
|
|
10580
|
+
// promise a link that the click cannot deliver
|
|
10581
|
+
el.style.cursor = forester.domainReference(b.info) ? 'pointer' : 'default';
|
|
10154
10582
|
});
|
|
10155
10583
|
let texts = sizeLayer(labelLayer, 'text', names.length);
|
|
10156
10584
|
names.forEach(function (t, k) {
|
|
@@ -11936,10 +12364,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
11936
12364
|
if (alignBtn) {
|
|
11937
12365
|
alignBtn.disabled = _state.unrootedDisplay || _basicTreeProperties.branchLengths !== true;
|
|
11938
12366
|
}
|
|
11939
|
-
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
12367
|
+
// Auto-hide Labels stays live in every layout. It used to be greyed out
|
|
12368
|
+
// in unrooted, where the tip-label decimation it governs does not run --
|
|
12369
|
+
// but it also governs the crowded branch-data rule, which does run
|
|
12370
|
+
// there, so greying it out left the user unable to switch off something
|
|
12371
|
+
// that was happening.
|
|
11943
12372
|
let msaCb = byId(MSA_CB);
|
|
11944
12373
|
if (msaCb) {
|
|
11945
12374
|
msaCb.disabled = radialDisplay();
|
|
@@ -12167,7 +12596,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
12167
12596
|
}
|
|
12168
12597
|
item.classList.toggle('aptx-check-active', !!active);
|
|
12169
12598
|
item.title = active
|
|
12170
|
-
? item.dataset.baseTitle + ', hiding now: 1 in ' + _dynahide_factor
|
|
12599
|
+
? item.dataset.baseTitle + ', hiding now: 1 in ' + _dynahide_factor
|
|
12600
|
+
+ ' labels shown, and branch data that would overlap'
|
|
12171
12601
|
: item.dataset.baseTitle;
|
|
12172
12602
|
}
|
|
12173
12603
|
|
|
@@ -15084,10 +15514,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
15084
15514
|
// "Auto-hide Labels" matches the desktop, which renamed the historical
|
|
15085
15515
|
// "Dyna Hide" because that named the mechanism rather than what the user
|
|
15086
15516
|
// sees. The _state.dynahide field keeps its name internally.
|
|
15087
|
-
opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels
|
|
15517
|
+
opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels, and branch values that would overprint each other, where the tree is too dense for them to be readable', true));
|
|
15088
15518
|
opts.push(makeCheckboxItem('Short Names', SHORTEN_NODE_NAME_CB, 'to shorten long node names'));
|
|
15089
15519
|
if (_basicTreeProperties.confidences) {
|
|
15090
|
-
opts.push(makeCheckboxItem('Support Dots', SUPPORT_DOTS_CB, 'to mark
|
|
15520
|
+
opts.push(makeCheckboxItem('Support Dots', SUPPORT_DOTS_CB, 'to mark a branch with support of at least ' + _settings.supportDotMinimum + '% with a dot at its midpoint, where there is room to draw it'));
|
|
15091
15521
|
}
|
|
15092
15522
|
// Styles and visualizations sit here too, so the Nodes group only
|
|
15093
15523
|
// appears at all on a tree carrying node/branch events
|
package/forester.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.15.0
|
|
24
24
|
// 2026-09-17
|
|
25
25
|
//
|
|
26
26
|
// forester.js is a general suite for dealing with phylogenetic trees.
|
|
@@ -3814,6 +3814,187 @@
|
|
|
3814
3814
|
};
|
|
3815
3815
|
|
|
3816
3816
|
|
|
3817
|
+
// The nodes of `list` in PREORDER -- root first, then each subtree -- which
|
|
3818
|
+
// is the claim order the crowded-data rule needs and is NOT the order the
|
|
3819
|
+
// layout hands out. d3's descendants() is BREADTH-first (r,a,b,a1,a2,b1),
|
|
3820
|
+
// and the viewer reverses it for drawing, which puts the root LAST. Taking
|
|
3821
|
+
// that order for preorder inverts the whole rule: the leaf-most mark wins
|
|
3822
|
+
// and the one nearer the root is dropped, which is the opposite of what
|
|
3823
|
+
// the desktop does. Measured, after a review caught it: "descendants()
|
|
3824
|
+
// r,a,b,a1,a2,b1" and "reversed b1,a2,a1,b,a,r".
|
|
3825
|
+
//
|
|
3826
|
+
// Derived from the node objects rather than from any layout, so it cannot
|
|
3827
|
+
// drift with one: the root is the member whose parent is not itself in the
|
|
3828
|
+
// list, and children are visited in their own order. A node whose parent
|
|
3829
|
+
// is absent (a subtree view) roots its own walk, and anything unreachable
|
|
3830
|
+
// is appended so no mark is silently skipped.
|
|
3831
|
+
forester.preorderOf = function (list) {
|
|
3832
|
+
let present = new Set(list);
|
|
3833
|
+
let out = [];
|
|
3834
|
+
let seen = new Set();
|
|
3835
|
+
function walk(n) {
|
|
3836
|
+
if (seen.has(n)) {
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
seen.add(n);
|
|
3840
|
+
out.push(n);
|
|
3841
|
+
let kids = n.children;
|
|
3842
|
+
if (kids) {
|
|
3843
|
+
for (let i = 0; i < kids.length; ++i) {
|
|
3844
|
+
if (present.has(kids[i])) {
|
|
3845
|
+
walk(kids[i]);
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
for (let i = 0; i < list.length; ++i) {
|
|
3851
|
+
let n = list[i];
|
|
3852
|
+
if (!n.parent || !present.has(n.parent)) {
|
|
3853
|
+
walk(n);
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
for (let i = 0; i < list.length; ++i) {
|
|
3857
|
+
if (!seen.has(list[i])) {
|
|
3858
|
+
out.push(list[i]); // a cycle or a detached node: never dropped
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
return out;
|
|
3862
|
+
};
|
|
3863
|
+
|
|
3864
|
+
// The box to claim for a mark that RIDES ITS BRANCH -- the circular and
|
|
3865
|
+
// unrooted layouts, where a label is turned to the branch's direction
|
|
3866
|
+
// instead of standing along a row. The axis-aligned bounds of the turned
|
|
3867
|
+
// rectangle, centred on (cx, cy):
|
|
3868
|
+
//
|
|
3869
|
+
// rot_w = |w*cos(a)| + |h*sin(a)|
|
|
3870
|
+
// rot_h = |w*sin(a)| + |h*cos(a)|
|
|
3871
|
+
//
|
|
3872
|
+
// The desktop's formula, verbatim (TreePanel.paintBranchDataRadial), and
|
|
3873
|
+
// the second half of the joint rule below. It carries between the two
|
|
3874
|
+
// programs because both measure it in real SCREEN space -- the layout's
|
|
3875
|
+
// own units do not survive the trip, which is why this was parked as a
|
|
3876
|
+
// divergence until forester-71 sent the arithmetic.
|
|
3877
|
+
//
|
|
3878
|
+
// Deliberately conservative: a diagonal label's bounds are wider than its
|
|
3879
|
+
// ink, so a crowded number is dropped rather than overprinted. Exact at
|
|
3880
|
+
// the right angles, worst at 45 degrees. Insensitive to the sign of the
|
|
3881
|
+
// angle and to a half turn, which is what lets the 180-degree flip that
|
|
3882
|
+
// keeps the far half of a fan upright leave the box alone.
|
|
3883
|
+
forester.rotatedLabelBox = function (cx, cy, w, h, angle) {
|
|
3884
|
+
let cos = Math.abs(Math.cos(angle));
|
|
3885
|
+
let sin = Math.abs(Math.sin(angle));
|
|
3886
|
+
let rw = (w * cos) + (h * sin);
|
|
3887
|
+
let rh = (w * sin) + (h * cos);
|
|
3888
|
+
return [cx - (rw / 2), cy - (rh / 2), rw, rh];
|
|
3889
|
+
};
|
|
3890
|
+
|
|
3891
|
+
// The occupancy map behind "auto-hide crowded branch data": a mark is
|
|
3892
|
+
// drawn only when its box overlaps nothing already granted in the same
|
|
3893
|
+
// pass. Ported from the desktop's LabelOccupancy (0.11.161) as a JOINT
|
|
3894
|
+
// rule on Christian's word, 2026-09-25, so neither side retunes it alone.
|
|
3895
|
+
//
|
|
3896
|
+
// Here rather than in the viewer because it is a pure algorithm and the
|
|
3897
|
+
// suite can reach it: the parts that MUST match the desktop -- first claim
|
|
3898
|
+
// wins, a refused claim records nothing, a zero-size box is granted and
|
|
3899
|
+
// not recorded, and boxes that merely touch do not overlap -- are exactly
|
|
3900
|
+
// the parts no screenshot would show going wrong.
|
|
3901
|
+
// A uniform grid rather than all-pairs: a 13,246-tip tree with branch
|
|
3902
|
+
// lengths on offers 18,511 boxes, and 171 million comparisons per redraw
|
|
3903
|
+
// is not a redraw.
|
|
3904
|
+
forester.labelOccupancy = function (cell) {
|
|
3905
|
+
let size = Math.max(4, cell);
|
|
3906
|
+
let cells = new Map();
|
|
3907
|
+
return {
|
|
3908
|
+
// true when nothing already granted overlaps, and then recorded
|
|
3909
|
+
claim: function (x, y, w, h) {
|
|
3910
|
+
if (!(w > 0) || !(h > 0)) {
|
|
3911
|
+
return true; // reserves no space; recording it would only cost comparisons
|
|
3912
|
+
}
|
|
3913
|
+
let c0 = Math.floor(x / size), c1 = Math.floor((x + w) / size);
|
|
3914
|
+
let r0 = Math.floor(y / size), r1 = Math.floor((y + h) / size);
|
|
3915
|
+
for (let c = c0; c <= c1; ++c) {
|
|
3916
|
+
for (let r = r0; r <= r1; ++r) {
|
|
3917
|
+
let bucket = cells.get(c + ':' + r);
|
|
3918
|
+
if (!bucket) {
|
|
3919
|
+
continue;
|
|
3920
|
+
}
|
|
3921
|
+
for (let i = 0; i < bucket.length; ++i) {
|
|
3922
|
+
let b = bucket[i];
|
|
3923
|
+
// strict: boxes that merely touch do not overlap
|
|
3924
|
+
if (x < (b[0] + b[2]) && (x + w) > b[0]
|
|
3925
|
+
&& y < (b[1] + b[3]) && (y + h) > b[1]) {
|
|
3926
|
+
return false;
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
let box = [x, y, w, h];
|
|
3932
|
+
for (let c = c0; c <= c1; ++c) {
|
|
3933
|
+
for (let r = r0; r <= r1; ++r) {
|
|
3934
|
+
let key = c + ':' + r;
|
|
3935
|
+
let bucket = cells.get(key);
|
|
3936
|
+
if (!bucket) {
|
|
3937
|
+
bucket = [];
|
|
3938
|
+
cells.set(key, bucket);
|
|
3939
|
+
}
|
|
3940
|
+
bucket.push(box);
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
return true;
|
|
3944
|
+
}
|
|
3945
|
+
};
|
|
3946
|
+
};
|
|
3947
|
+
|
|
3948
|
+
// Where a protein domain can be read about, as {url, exact} or null.
|
|
3949
|
+
//
|
|
3950
|
+
// An InterPro ENTRY is addressed by ACCESSION and by nothing else --
|
|
3951
|
+
// measured, not assumed: /api/entry/pfam/PF00931/ answers 200 and names
|
|
3952
|
+
// NB-ARC, while /api/entry/pfam/NB-ARC/, /CARD/ and /Death/ are all 404.
|
|
3953
|
+
// phyloXML's <domain id="..."> carries the accession when the annotation
|
|
3954
|
+
// had one, and most files have none (our own apaf.xml names all 166 of its
|
|
3955
|
+
// boxes and gives not one an id), so the fallback is an InterPro text
|
|
3956
|
+
// search.
|
|
3957
|
+
//
|
|
3958
|
+
// The search goes by NAME, never by the id. An id that is not a bare Pfam
|
|
3959
|
+
// accession -- a versioned one from an hmmscan (PF00931.24), or a SMART or
|
|
3960
|
+
// CDD accession -- searches to nothing, while the name is exactly what a
|
|
3961
|
+
// text search is for. A version suffix is stripped for the entry URL
|
|
3962
|
+
// rather than disqualifying it: PF00931.24 IS PF00931.
|
|
3963
|
+
//
|
|
3964
|
+
// Lives here rather than in the viewer so it can be tested: a wrong URL
|
|
3965
|
+
// fails silently in someone else's browser, which is the worst place to
|
|
3966
|
+
// find out.
|
|
3967
|
+
const INTERPRO_ENTRY = 'https://www.ebi.ac.uk/interpro/entry/pfam/';
|
|
3968
|
+
const INTERPRO_SEARCH = 'https://www.ebi.ac.uk/interpro/search/text/';
|
|
3969
|
+
|
|
3970
|
+
forester.domainReference = function (domain) {
|
|
3971
|
+
if (!domain) {
|
|
3972
|
+
return null;
|
|
3973
|
+
}
|
|
3974
|
+
let id = (domain.id || '').trim();
|
|
3975
|
+
let acc = /^(PF\d{5,})(?:\.\d+)?$/i.exec(id);
|
|
3976
|
+
if (acc) {
|
|
3977
|
+
return {url: INTERPRO_ENTRY + acc[1].toUpperCase() + '/', exact: true};
|
|
3978
|
+
}
|
|
3979
|
+
let q = (domain.name || '').trim() || id;
|
|
3980
|
+
return q ? {url: INTERPRO_SEARCH + encodeURIComponent(q) + '/', exact: false} : null;
|
|
3981
|
+
};
|
|
3982
|
+
|
|
3983
|
+
// A domain's E-value for display. It spans many orders of magnitude, so a
|
|
3984
|
+
// small one keeps its exponent; the count of significant digits is the
|
|
3985
|
+
// only thing normalized, because a scan writes 7.2E-117 and 1E-6 and a
|
|
3986
|
+
// readout that mixed those forms would read as two different measures.
|
|
3987
|
+
forester.domainEvalueText = function (e) {
|
|
3988
|
+
if (typeof e !== 'number' || !isFinite(e)) {
|
|
3989
|
+
return null;
|
|
3990
|
+
}
|
|
3991
|
+
if (e === 0) {
|
|
3992
|
+
return '0';
|
|
3993
|
+
}
|
|
3994
|
+
return (Math.abs(e) >= 0.001 && Math.abs(e) < 1e7)
|
|
3995
|
+
? String(forester.roundNumber(e, 6)) : e.toExponential(2);
|
|
3996
|
+
};
|
|
3997
|
+
|
|
3817
3998
|
// Everything the Tree Properties dialog reports, gathered in ONE pass:
|
|
3818
3999
|
// the desktop's TreeFacts.scan, minus its histogram (Christian, 2026-09-23:
|
|
3819
4000
|
// "we don't need a histogram ... but a more detailed stats would be
|
|
@@ -6933,7 +7114,13 @@
|
|
|
6933
7114
|
ignored++;
|
|
6934
7115
|
return;
|
|
6935
7116
|
}
|
|
6936
|
-
|
|
7117
|
+
// `id` is phyloXML's optional domain identifier, which for a Pfam
|
|
7118
|
+
// scan is the accession (PF00931). Carried through because the
|
|
7119
|
+
// hover readout links to the entry with it when it is there, and
|
|
7120
|
+
// has to search by name when it is not -- apaf.xml, our own domain
|
|
7121
|
+
// demo, has names and no ids at all.
|
|
7122
|
+
domains.push({name: d.name ? String(d.name) : '', from: from, to: to,
|
|
7123
|
+
length: to - from + 1, evalue: e, id: d.id ? String(d.id) : ''});
|
|
6937
7124
|
});
|
|
6938
7125
|
domains.sort(function (a, b) {
|
|
6939
7126
|
return a.from - b.from;
|
|
@@ -7035,7 +7222,8 @@
|
|
|
7035
7222
|
if (!(w > 0) || !isFinite(w)) {
|
|
7036
7223
|
return;
|
|
7037
7224
|
}
|
|
7038
|
-
boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to,
|
|
7225
|
+
boxes.push({name: d.name, x: start + ((d.from - 1) * f), w: w, from: d.from, to: d.to,
|
|
7226
|
+
evalue: d.evalue, id: d.id});
|
|
7039
7227
|
});
|
|
7040
7228
|
return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
|
|
7041
7229
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archaeopteryx",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.15.0",
|
|
4
4
|
"description": "Archaeopteryx.js is a software tool for the visualization and analysis of highly annotated phylogenetic trees.",
|
|
5
5
|
"main": "archaeopteryx.js",
|
|
6
6
|
"types": "archaeopteryx.d.ts",
|