archaeopteryx 3.10.0 → 3.12.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 +331 -8
- package/archaeopteryx.d.ts +23 -0
- package/archaeopteryx.js +1597 -58
- package/forester.js +862 -9
- package/package.json +2 -2
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.12.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.12.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';
|
|
@@ -363,6 +363,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
363
363
|
const DOWNLOAD_BUTTON = 'dl_b';
|
|
364
364
|
const DYNAHIDE_CB = 'dynahide_cb';
|
|
365
365
|
const MSA_CB = 'msa_cb';
|
|
366
|
+
const MSA_LOGO_CB = 'msa_logo_cb';
|
|
367
|
+
const HEATMAP_CB = 'heatmap_cb';
|
|
368
|
+
const HEATMAP_CONTROLS = 'heatmap_controls';
|
|
369
|
+
const HEATMAP_ORDER_SELECT = 'heatmap_order';
|
|
370
|
+
const HEATMAP_REORDER_BUTTON = 'heatmap_reorder';
|
|
371
|
+
const HEATMAP_REORDER_DIALOG = 'heatmap_reorder_dialog';
|
|
366
372
|
const DOMAINS_CB = 'domains_cb';
|
|
367
373
|
const DOMAIN_CONTROLS = 'domain_controls';
|
|
368
374
|
const DOMAIN_WIDTH_DEC = 'domain_width_dec';
|
|
@@ -542,7 +548,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
542
548
|
const MSA_MAX_VIEWPORT_FRACTION = 0.6;// nor grows past this share of the display
|
|
543
549
|
const MSA_CONS_BAR_H = 22; // conservation bar band height
|
|
544
550
|
const MSA_CONS_TOP_GAP = 4;
|
|
545
|
-
const
|
|
551
|
+
const MSA_CONS_ROW_H = 13; // the consensus letter row
|
|
552
|
+
const MSA_RULER_H = 17; // the column ruler under it
|
|
553
|
+
const MSA_LOGO_H = 46; // the sequence logo's stack, full height
|
|
554
|
+
const MSA_LOGO_CAP_H = 11; // ... and the caption line above it
|
|
555
|
+
const MSA_LOGO_FONT_PX = 24; // the size logo glyphs are measured at, then scaled
|
|
546
556
|
const MSA_NAV_RESERVE = 46; // ... plus the navigation bar floating at the viewport bottom
|
|
547
557
|
const MSA_MIN_TREE_PX = 220; // the tree itself never shrinks below this
|
|
548
558
|
let _msaColOffset = 0; // first shown alignment column (0-based)
|
|
@@ -572,11 +582,47 @@ function (root, d3, forester, phyloXml) {
|
|
|
572
582
|
const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
|
|
573
583
|
let _domain = null; // {width, palette, next, legendFrac}, set per launch
|
|
574
584
|
let _domainReserve = 0; // horizontal px reserved for the tracks, set with _w
|
|
585
|
+
// ------ heat map (the desktop's MATRIX annotation columns) ------
|
|
586
|
+
const HEATMAP_TRACK_GAP = 8; // px between what is left of the tree and the matrix
|
|
587
|
+
const HEATMAP_PREF_COL_W = 14; // a column never grows past this
|
|
588
|
+
const HEATMAP_MIN_COL_W = 3; // ... nor shrinks below it: past that the matrix is windowed
|
|
589
|
+
const HEATMAP_MIN_BAND_PX = 60; // the window never shrinks below this
|
|
590
|
+
const HEATMAP_MAX_VIEWPORT_FRACTION = 0.45; // nor grows past this share of the display
|
|
591
|
+
const HEATMAP_MIN_TREE_PX = 220; // the tree itself never shrinks below this
|
|
592
|
+
const HEATMAP_MIN_COLUMNS = 2; // one column is a stripe, not a matrix: the toggle is not offered
|
|
593
|
+
const HEATMAP_LABEL_MAX_PX = 96; // a turned column label is clipped to this
|
|
594
|
+
const HEATMAP_LABEL_MIN_COL_W = 7; // ... and dropped where the columns are thinner than this
|
|
595
|
+
const HEATMAP_SCALE_BAR_W = 148; // the gradient in the legend
|
|
596
|
+
const HEATMAP_SCALE_BAR_H = 8;
|
|
597
|
+
const HEATMAP_SCALE_RESERVE = 30; // vertical room for that legend and its numbers
|
|
598
|
+
const HEATMAP_HEAD_GAP = 5; // between the last row and the turned labels
|
|
599
|
+
const HEATMAP_NOT_ASSESSED = 'not assessed'; // what a blank cell says -- it does NOT say zero
|
|
600
|
+
const HEATMAP_RADIAL_GAP = 7; // px from the label ring to the first column ring
|
|
601
|
+
const HEATMAP_RADIAL_END_GAP = 8; // ... and kept free past the last one
|
|
602
|
+
const HEATMAP_RADIAL_MAX_FRACTION = 1.2; // the ring stack, against the tree's own radius
|
|
603
|
+
const HEATMAP_RADIAL_MIN_RING = 1; // a ring never thinner than this
|
|
604
|
+
const HEATMAP_RADIAL_MIN_NAMED_RING = 7; // ... nor named when it is thinner than this
|
|
605
|
+
// Neighbouring sectors OVERLAP by this much rather than meeting exactly.
|
|
606
|
+
// Two antialiased edges that share a coordinate each cover about half the
|
|
607
|
+
// boundary pixel, and a half over a half does not composite back to a
|
|
608
|
+
// solid edge -- it leaves the background showing through as a dotted
|
|
609
|
+
// line all round the rings. Measured on the sparse demo: 6987 pixels
|
|
610
|
+
// within 1.2px of a ring boundary came out background-coloured.
|
|
611
|
+
const HEATMAP_RING_BLEED = 1.2;
|
|
612
|
+
const HEATMAP_BLANK = 'blank'; // the run-merger's stand-in for a cell nobody filled in
|
|
613
|
+
const HEATMAP_DENDRO_GAP = 5; // px between the dendrogram's leaves and the first row
|
|
614
|
+
const HEATMAP_DENDRO_MIN_BAND = 24; // the band it is drawn in, from the label font
|
|
615
|
+
const HEATMAP_DENDRO_MAX_BAND = 72;
|
|
616
|
+
let _heatmapReserve = 0; // horizontal px reserved for the matrix, set with _w
|
|
617
|
+
let _heatmapModel = null; // forester.heatmapColumns of the WHOLE tree, cached per launch
|
|
618
|
+
let _heatmapColor = null; // the one scale every column is painted on
|
|
619
|
+
let _heatmapColOffset = 0; // first shown column, while the matrix is windowed
|
|
620
|
+
let _heatmapGeom = null; // the last draw's geometry, for the hover readout
|
|
575
621
|
// ------ time axis (the desktop's geologic / calendar overlays) ------
|
|
576
622
|
const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
|
|
577
623
|
const TIME_CAL_RESERVE = 26; // the calendar year ruler
|
|
578
624
|
const TIME_BAND_ROW_H = 13;
|
|
579
|
-
const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue,
|
|
625
|
+
const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, the usual HPD tint
|
|
580
626
|
const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
|
|
581
627
|
let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
|
|
582
628
|
let _timeTree = false; // forester.isTimeTree: never re-rooted
|
|
@@ -1102,6 +1148,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1102
1148
|
// view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
|
|
1103
1149
|
// no by precisely 254px and the overview appeared after every Fit.
|
|
1104
1150
|
maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
|
|
1151
|
+
minY -= heatmapTopReserve(); // the dendrogram band stands above the first row
|
|
1105
1152
|
if (scaleBarShown()) {
|
|
1106
1153
|
maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
|
|
1107
1154
|
}
|
|
@@ -1426,6 +1473,30 @@ function (root, d3, forester, phyloXml) {
|
|
|
1426
1473
|
.style('opacity', 0.95); // only the fade -- the rest is set at creation
|
|
1427
1474
|
}
|
|
1428
1475
|
|
|
1476
|
+
// Puts a hover readout beside the pointer -- to its right and below --
|
|
1477
|
+
// flipping to the other side when that would leave the window. The readout's
|
|
1478
|
+
// OWN measured size decides, because .aptx-tip is width:max-content and so
|
|
1479
|
+
// is as wide as whatever it happens to be saying. Guessing instead, with a
|
|
1480
|
+
// fixed shift, left a 181px readout floating a hundred pixels clear of the
|
|
1481
|
+
// cell it described; a matrix that hugs the right edge flips on every cell,
|
|
1482
|
+
// so the guess was what the reader saw every time.
|
|
1483
|
+
const HOVER_READOUT_GAP = 14;
|
|
1484
|
+
|
|
1485
|
+
function placeHoverReadout(el, event) {
|
|
1486
|
+
let w = el.offsetWidth;
|
|
1487
|
+
let h = el.offsetHeight;
|
|
1488
|
+
let left = event.pageX + HOVER_READOUT_GAP;
|
|
1489
|
+
if ((left + w) > (window.scrollX + window.innerWidth)) {
|
|
1490
|
+
left = Math.max(window.scrollX + 2, event.pageX - HOVER_READOUT_GAP - w);
|
|
1491
|
+
}
|
|
1492
|
+
let top = event.pageY + HOVER_READOUT_GAP;
|
|
1493
|
+
if ((top + h) > (window.scrollY + window.innerHeight)) {
|
|
1494
|
+
top = Math.max(window.scrollY + 2, event.pageY - HOVER_READOUT_GAP - h);
|
|
1495
|
+
}
|
|
1496
|
+
el.style.left = left + 'px';
|
|
1497
|
+
el.style.top = top + 'px';
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1429
1500
|
function mousemove(event, d) {
|
|
1430
1501
|
|
|
1431
1502
|
let mo_text = nodeDataText(d);
|
|
@@ -1436,10 +1507,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
1436
1507
|
if (_panelTheme) {
|
|
1437
1508
|
tip.classList.add('aptx-' + _panelTheme);
|
|
1438
1509
|
}
|
|
1439
|
-
_node_mouseover_div
|
|
1440
|
-
|
|
1441
|
-
.style('left', (event.pageX + 14) + 'px')
|
|
1442
|
-
.style('top', (event.pageY + 14) + 'px');
|
|
1510
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(mo_text)));
|
|
1511
|
+
placeHoverReadout(tip, event); // this one used to run off the edge instead
|
|
1443
1512
|
}
|
|
1444
1513
|
|
|
1445
1514
|
// ----------------------------
|
|
@@ -2731,6 +2800,22 @@ function (root, d3, forester, phyloXml) {
|
|
|
2731
2800
|
_msaReserve = 0;
|
|
2732
2801
|
// and the domain tracks reserve their column past the labels
|
|
2733
2802
|
_domainReserve = domainReserve();
|
|
2803
|
+
// The heat map is budgeted FIRST of the two right-hand tracks: it
|
|
2804
|
+
// wants a fixed, finite width (its columns at their preferred size
|
|
2805
|
+
// and no more), while the alignment's band is a WINDOW that scrolls
|
|
2806
|
+
// and so can give way without losing anything. Budgeting the
|
|
2807
|
+
// alignment first left an 18-column matrix a 60px sliver.
|
|
2808
|
+
_heatmapReserve = 0;
|
|
2809
|
+
if (heatmapShown() && !radialDisplay()) {
|
|
2810
|
+
let cols = heatmapModel().refs.length;
|
|
2811
|
+
let vp0 = svgSize();
|
|
2812
|
+
let vw0 = Math.min(_displayWidth, (vp0 && vp0.w) ? vp0.w : _displayWidth);
|
|
2813
|
+
let hband = Math.max(HEATMAP_MIN_BAND_PX, Math.round(vw0 * HEATMAP_MAX_VIEWPORT_FRACTION));
|
|
2814
|
+
let hmax = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
|
|
2815
|
+
- HEATMAP_TRACK_GAP - HEATMAP_MIN_TREE_PX;
|
|
2816
|
+
hband = Math.max(HEATMAP_MIN_BAND_PX, Math.min(hband, hmax));
|
|
2817
|
+
_heatmapReserve = HEATMAP_TRACK_GAP + Math.min(cols * HEATMAP_PREF_COL_W, hband);
|
|
2818
|
+
}
|
|
2734
2819
|
if (msaShown()) {
|
|
2735
2820
|
let fullPx = _basicTreeProperties.maxMolSeqLength * MSA_COL_WIDTH;
|
|
2736
2821
|
// budgeted from the VIEWPORT: sizing it from the zoomed layout
|
|
@@ -2741,11 +2826,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
2741
2826
|
let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
|
|
2742
2827
|
// a wide alignment must not squeeze the tree itself away: the
|
|
2743
2828
|
// band yields until the tree keeps its minimum share
|
|
2744
|
-
let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
|
|
2829
|
+
let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
|
|
2830
|
+
- _heatmapReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
|
|
2745
2831
|
band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
|
|
2746
2832
|
_msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
|
|
2747
2833
|
}
|
|
2748
|
-
_w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve;
|
|
2834
|
+
_w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve - _heatmapReserve;
|
|
2749
2835
|
if (_w < 1) {
|
|
2750
2836
|
_w = 1;
|
|
2751
2837
|
}
|
|
@@ -2757,7 +2843,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
2757
2843
|
|
|
2758
2844
|
_clusterH = Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
|
|
2759
2845
|
- bottomOverlayReserve());
|
|
2760
|
-
|
|
2846
|
+
let _topReserve = topOverlayReserve();
|
|
2847
|
+
_treeFn = _treeFn.size([Math.max(20, _clusterH - _topReserve), _w]);
|
|
2761
2848
|
|
|
2762
2849
|
// A collapsed clade is a leaf that asks for more than one row: the
|
|
2763
2850
|
// separation between neighbouring leaves is the mean of their
|
|
@@ -2780,7 +2867,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
2780
2867
|
});
|
|
2781
2868
|
_treeFn(hierarchy);
|
|
2782
2869
|
hierarchy.each(function (hn) {
|
|
2783
|
-
hn.data.x = hn.x;
|
|
2870
|
+
hn.data.x = hn.x + _topReserve;
|
|
2784
2871
|
hn.data.y = hn.y;
|
|
2785
2872
|
hn.data.depth = hn.depth;
|
|
2786
2873
|
});
|
|
@@ -3270,7 +3357,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3270
3357
|
}
|
|
3271
3358
|
|
|
3272
3359
|
// circular: a thin dashed connector from each external node out to the
|
|
3273
|
-
// common label ring
|
|
3360
|
+
// common label ring, so every label lines up at the same radius.
|
|
3274
3361
|
_svgGroup.selectAll('g.aptx-radial-conn').remove();
|
|
3275
3362
|
if (_state.circularDisplay) {
|
|
3276
3363
|
// tips while their labels show; a collapsed clade always (its label
|
|
@@ -3316,6 +3403,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3316
3403
|
drawDomainArchitectures();
|
|
3317
3404
|
drawDomainLegend();
|
|
3318
3405
|
drawMsaTrack();
|
|
3406
|
+
drawHeatmapTrack();
|
|
3319
3407
|
drawTimeOverlays();
|
|
3320
3408
|
rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
|
|
3321
3409
|
updateSearchHitNavigation();
|
|
@@ -4844,6 +4932,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
4844
4932
|
const STATE_KEYS = [
|
|
4845
4933
|
'layout',
|
|
4846
4934
|
'showMsa',
|
|
4935
|
+
'showMsaLogo',
|
|
4936
|
+
'showHeatmap',
|
|
4937
|
+
'heatmapColumnOrder',
|
|
4938
|
+
'heatmapManualOrder',
|
|
4847
4939
|
'showDomainArchitectures',
|
|
4848
4940
|
'domainLabels',
|
|
4849
4941
|
'domainGlow',
|
|
@@ -5076,6 +5168,45 @@ function (root, d3, forester, phyloXml) {
|
|
|
5076
5168
|
_state.showMsa = _basicTreeProperties.alignedMolSeqs === true
|
|
5077
5169
|
&& _basicTreeProperties.maxMolSeqLength > 0;
|
|
5078
5170
|
}
|
|
5171
|
+
// The heat map is offered whenever the tree carries two or more
|
|
5172
|
+
// numeric per-tip fields, which is common enough that turning it on
|
|
5173
|
+
// by itself would be an opinion about the tree rather than a service
|
|
5174
|
+
// -- the alignment track, by contrast, is on when there IS one.
|
|
5175
|
+
if (_state.showHeatmap === undefined) {
|
|
5176
|
+
_state.showHeatmap = false;
|
|
5177
|
+
}
|
|
5178
|
+
// The logo is a summary of the alignment, not a second view of the
|
|
5179
|
+
// tree, so it waits to be asked for: an alignment opens showing its
|
|
5180
|
+
// residues, which is what a reader came for.
|
|
5181
|
+
if (_state.showMsaLogo === undefined) {
|
|
5182
|
+
_state.showMsaLogo = false;
|
|
5183
|
+
}
|
|
5184
|
+
// The columns' order. Left unset it is resolved from the data on first
|
|
5185
|
+
// use (heatmapMode / forester.heatmapDefaultOrder): a clustered order,
|
|
5186
|
+
// because reading block structure is what a heat map beside a tree is
|
|
5187
|
+
// FOR, with the distance chosen by what the values are. An explicit
|
|
5188
|
+
// value always wins and is never re-derived -- including 'document',
|
|
5189
|
+
// which keeps the order the producer wrote the fields in.
|
|
5190
|
+
if (_state.heatmapColumnOrder === undefined) {
|
|
5191
|
+
_state.heatmapColumnOrder = null;
|
|
5192
|
+
} else if (_state.heatmapColumnOrder !== null
|
|
5193
|
+
&& forester.HEATMAP_ORDER_MODES.indexOf(_state.heatmapColumnOrder) < 0) {
|
|
5194
|
+
throw new Error(ERROR + '"heatmapColumnOrder" must be one of '
|
|
5195
|
+
+ forester.HEATMAP_ORDER_MODES.join(', '));
|
|
5196
|
+
}
|
|
5197
|
+
// The reader's own arrangement, as a list of refs. It is only read
|
|
5198
|
+
// while the order is 'manual', and a ref it names that the tree has
|
|
5199
|
+
// not got is ignored rather than fatal -- an order outliving one of
|
|
5200
|
+
// its columns is ordinary (see forester.heatmapManualOrder).
|
|
5201
|
+
if (_state.heatmapManualOrder === undefined) {
|
|
5202
|
+
_state.heatmapManualOrder = null;
|
|
5203
|
+
} else if (_state.heatmapManualOrder !== null
|
|
5204
|
+
&& !(Array.isArray(_state.heatmapManualOrder)
|
|
5205
|
+
&& _state.heatmapManualOrder.every(function (r) {
|
|
5206
|
+
return typeof r === 'string';
|
|
5207
|
+
}))) {
|
|
5208
|
+
throw new Error(ERROR + '"heatmapManualOrder" must be an array of property refs');
|
|
5209
|
+
}
|
|
5079
5210
|
// A tree with protein domain architectures on its tips draws them
|
|
5080
5211
|
// from the start (the desktop switches its toggle on at load), again
|
|
5081
5212
|
// unless the caller decided. The track width is set at the first
|
|
@@ -5729,6 +5860,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
5729
5860
|
_radialRotation = 0;
|
|
5730
5861
|
_radialLabelsHorizontal = false;
|
|
5731
5862
|
_msaColOffset = 0;
|
|
5863
|
+
_heatmapColOffset = 0;
|
|
5864
|
+
_heatmapModel = null; // the columns and the scale belong to THIS tree
|
|
5865
|
+
_heatmapColor = null;
|
|
5866
|
+
_heatmapGeom = null;
|
|
5732
5867
|
// ---- synchronous: everything that can throw, and the cheap setup --
|
|
5733
5868
|
//
|
|
5734
5869
|
// initializeSettings validates values and throws on a bad one, so it
|
|
@@ -6463,6 +6598,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
6463
6598
|
if (edited && _domain) {
|
|
6464
6599
|
_domain.palette = null; // the tree changed: the domain names are dealt their colours again
|
|
6465
6600
|
}
|
|
6601
|
+
if (edited) {
|
|
6602
|
+
_heatmapModel = null; // ... and the heat map's columns and scale are derived from it again
|
|
6603
|
+
_heatmapColor = null;
|
|
6604
|
+
}
|
|
6466
6605
|
if (!_vis) {
|
|
6467
6606
|
return;
|
|
6468
6607
|
}
|
|
@@ -6501,8 +6640,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
6501
6640
|
// part of the real spans (an anchor ratio taken from anything else
|
|
6502
6641
|
// once flung the whole tree off-screen after three X+ presses).
|
|
6503
6642
|
return {
|
|
6504
|
-
horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve
|
|
6505
|
-
|
|
6643
|
+
horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve
|
|
6644
|
+
- _domainReserve - _heatmapReserve),
|
|
6645
|
+
vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
|
|
6646
|
+
- bottomOverlayReserve() - topOverlayReserve())
|
|
6506
6647
|
};
|
|
6507
6648
|
}
|
|
6508
6649
|
|
|
@@ -6695,6 +6836,64 @@ function (root, d3, forester, phyloXml) {
|
|
|
6695
6836
|
&& _basicTreeProperties.maxMolSeqLength > 0;
|
|
6696
6837
|
}
|
|
6697
6838
|
|
|
6839
|
+
// The logo replaces the conservation bar and the consensus row rather
|
|
6840
|
+
// than joining them: a stack's height IS the column's conservation and its
|
|
6841
|
+
// top letter IS the consensus, so drawing all three would say the same
|
|
6842
|
+
// thing three times and take three times the room.
|
|
6843
|
+
function msaLogoShown() {
|
|
6844
|
+
return _state.showMsaLogo === true && msaShown();
|
|
6845
|
+
}
|
|
6846
|
+
|
|
6847
|
+
// What the strip under the alignment needs. The conservation form is the
|
|
6848
|
+
// 56 px this was a constant for; the logo form is taller and drops the
|
|
6849
|
+
// consensus row it subsumes.
|
|
6850
|
+
function msaBottomReserve() {
|
|
6851
|
+
return MSA_CONS_TOP_GAP
|
|
6852
|
+
+ (msaLogoShown() ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H)
|
|
6853
|
+
+ MSA_RULER_H;
|
|
6854
|
+
}
|
|
6855
|
+
|
|
6856
|
+
// Logo letters are drawn at a fixed size and then scaled to their cell and
|
|
6857
|
+
// to their share of the stack, so what is needed is how much ink this face
|
|
6858
|
+
// actually puts on the page for THIS letter -- measured, not assumed: a
|
|
6859
|
+
// 0.6 advance and a 0.72 cap height are true of some monospace faces and
|
|
6860
|
+
// not others, and a letter scaled by the wrong constant overflows its
|
|
6861
|
+
// column.
|
|
6862
|
+
//
|
|
6863
|
+
// Per letter, because a logo's letters must each fill their slice exactly:
|
|
6864
|
+
// measured off one glyph, Q and G hung their descenders through the
|
|
6865
|
+
// baseline and across the ruler, and every letter without a descender sat
|
|
6866
|
+
// a little short. What is scaled to the slice is the glyph's INK BOX
|
|
6867
|
+
// (ascent + descent), and the baseline is then placed so that box lands
|
|
6868
|
+
// inside the slice.
|
|
6869
|
+
let _logoGlyphs = {};
|
|
6870
|
+
|
|
6871
|
+
function logoGlyphMetrics(ch) {
|
|
6872
|
+
if (_logoGlyphs[ch]) {
|
|
6873
|
+
return _logoGlyphs[ch];
|
|
6874
|
+
}
|
|
6875
|
+
if (!_legendMeasureCtx) {
|
|
6876
|
+
_legendMeasureCtx = document.createElement('canvas').getContext('2d');
|
|
6877
|
+
}
|
|
6878
|
+
_legendMeasureCtx.font = MSA_LOGO_FONT_PX + 'px monospace';
|
|
6879
|
+
let m = _legendMeasureCtx.measureText(ch);
|
|
6880
|
+
let ascent = m.actualBoundingBoxAscent;
|
|
6881
|
+
let descent = m.actualBoundingBoxDescent;
|
|
6882
|
+
if (!(ascent > 0)) {
|
|
6883
|
+
ascent = MSA_LOGO_FONT_PX * 0.72; // a face that will not say
|
|
6884
|
+
descent = 0;
|
|
6885
|
+
}
|
|
6886
|
+
if (!(descent > 0)) {
|
|
6887
|
+
descent = 0; // no descender, or none reported
|
|
6888
|
+
}
|
|
6889
|
+
_logoGlyphs[ch] = {
|
|
6890
|
+
width: m.width || (MSA_LOGO_FONT_PX * 0.6),
|
|
6891
|
+
ascent: ascent,
|
|
6892
|
+
descent: descent
|
|
6893
|
+
};
|
|
6894
|
+
return _logoGlyphs[ch];
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6698
6897
|
function msaRowSeq(d) {
|
|
6699
6898
|
if (d.sequences && d.sequences.length > 0) {
|
|
6700
6899
|
let s = d.sequences[0];
|
|
@@ -6885,12 +7084,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
6885
7084
|
.attr('stroke', ink).attr('stroke-opacity', 0.7).attr('stroke-width', 1);
|
|
6886
7085
|
}
|
|
6887
7086
|
|
|
6888
|
-
//
|
|
6889
|
-
//
|
|
6890
|
-
//
|
|
6891
|
-
|
|
7087
|
+
// Scored over the visible WINDOW only -- the whole alignment would
|
|
7088
|
+
// cost rows x total per redraw; indices below are window-relative.
|
|
7089
|
+
// The rows are the tips ON SCREEN: enter a clade and the summary is
|
|
7090
|
+
// that clade's, which is the whole point of putting it next to a tree.
|
|
7091
|
+
let windowRows = tips.map(function (t) {
|
|
6892
7092
|
return msaRowSeq(t).slice(offset, offset + visible);
|
|
6893
|
-
})
|
|
7093
|
+
});
|
|
7094
|
+
let logo = msaLogoShown() ? forester.msaLogo(windowRows, visible, isNuc) : null;
|
|
7095
|
+
let cons = logo ? null : forester.msaConservation(windowRows, visible, 'identity', isNuc);
|
|
6894
7096
|
// A faint dashed guide from each tip -- from the end of its label, or
|
|
6895
7097
|
// from the node itself when the label is hidden -- across to its row
|
|
6896
7098
|
// in the track, so a row can be read back to its sequence without
|
|
@@ -6926,34 +7128,91 @@ function (root, d3, forester, phyloXml) {
|
|
|
6926
7128
|
}
|
|
6927
7129
|
|
|
6928
7130
|
// ---- conservation, consensus, ruler: a FLOATING strip ----
|
|
6929
|
-
let
|
|
7131
|
+
let stripH = msaBottomReserve();
|
|
7132
|
+
let strip = floatStripGroup('aptx-msa-strip', _clusterH, stripH);
|
|
6930
7133
|
strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
|
|
6931
|
-
.attr('width', trackW + 4).attr('height',
|
|
7134
|
+
.attr('width', trackW + 4).attr('height', stripH)
|
|
6932
7135
|
.attr('fill', _state.backgroundColorDefault);
|
|
6933
7136
|
let consTop = _clusterH + MSA_CONS_TOP_GAP;
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
6939
|
-
if (score <= 0) {
|
|
6940
|
-
continue;
|
|
7137
|
+
// A caption on the panel's own background, so it stays readable over
|
|
7138
|
+
// whatever the band happens to draw underneath it.
|
|
7139
|
+
let caption = function (txt, y) {
|
|
7140
|
+
if (trackW <= 170) {
|
|
7141
|
+
return;
|
|
6941
7142
|
}
|
|
6942
|
-
let
|
|
6943
|
-
let
|
|
6944
|
-
let
|
|
6945
|
-
strip.append('rect').attr('x',
|
|
6946
|
-
.attr('width',
|
|
6947
|
-
.attr('fill',
|
|
6948
|
-
|
|
6949
|
-
if (trackW > 170) {
|
|
6950
|
-
strip.append('text').attr('x', Math.round(originX) + trackW - 3).attr('y', consTop + 9)
|
|
7143
|
+
let font = '8px ' + FONT_DEFAULTS;
|
|
7144
|
+
let w = legendTextWidth(txt, font) + 6;
|
|
7145
|
+
let xe = Math.round(originX) + trackW - 3;
|
|
7146
|
+
strip.append('rect').attr('x', xe - w).attr('y', y - 8)
|
|
7147
|
+
.attr('width', w).attr('height', 11)
|
|
7148
|
+
.attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
|
|
7149
|
+
strip.append('text').attr('x', xe - 3).attr('y', y)
|
|
6951
7150
|
.attr('text-anchor', 'end')
|
|
6952
7151
|
.style('font-size', '8px').style('fill', ink).style('fill-opacity', 0.9)
|
|
6953
|
-
.text(
|
|
6954
|
-
}
|
|
6955
|
-
|
|
6956
|
-
|
|
7152
|
+
.text(txt);
|
|
7153
|
+
};
|
|
7154
|
+
if (logo) {
|
|
7155
|
+
// Each column a stack of letters: total height the column's
|
|
7156
|
+
// information content in bits, each letter's share its frequency.
|
|
7157
|
+
// Least frequent at the BOTTOM, so the eye reads the consensus
|
|
7158
|
+
// off the top of the stack.
|
|
7159
|
+
//
|
|
7160
|
+
// The caption gets a line of its own above the stacks (hence the
|
|
7161
|
+
// CAP_H here). Laid over them it washed out the very letters it
|
|
7162
|
+
// was labelling, which is a poor trade for eleven pixels.
|
|
7163
|
+
let floor = consTop + MSA_LOGO_CAP_H + MSA_LOGO_H;
|
|
7164
|
+
strip.append('line')
|
|
7165
|
+
.attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
|
|
7166
|
+
.attr('y1', floor).attr('y2', floor)
|
|
7167
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
|
|
7168
|
+
for (let i = 0; i < visible; ++i) {
|
|
7169
|
+
let col = logo.columns[i];
|
|
7170
|
+
if (!col || !(col.height > 0)) {
|
|
7171
|
+
continue;
|
|
7172
|
+
}
|
|
7173
|
+
let x0 = Math.round(originX + (i * cw));
|
|
7174
|
+
let x1 = Math.round(originX + ((i + 1) * cw));
|
|
7175
|
+
let y = floor;
|
|
7176
|
+
for (let k = col.letters.length - 1; k >= 0; --k) {
|
|
7177
|
+
let letter = col.letters[k];
|
|
7178
|
+
let hpx = (letter.h / logo.maxBits) * MSA_LOGO_H;
|
|
7179
|
+
if (hpx < 0.5) {
|
|
7180
|
+
continue; // under half a pixel of ink: nothing to draw
|
|
7181
|
+
}
|
|
7182
|
+
let glyph = logoGlyphMetrics(letter.ch);
|
|
7183
|
+
let sy = hpx / (glyph.ascent + glyph.descent);
|
|
7184
|
+
let rgb = forester.msaResidueRgb(letter.ch, isNuc);
|
|
7185
|
+
strip.append('text')
|
|
7186
|
+
.attr('transform', 'translate(' + (x0 + ((x1 - x0) / 2)) + ','
|
|
7187
|
+
+ (y - (glyph.descent * sy)) + ')'
|
|
7188
|
+
+ ' scale(' + (Math.max(1, x1 - x0) / glyph.width) + ',' + sy + ')')
|
|
7189
|
+
.attr('text-anchor', 'middle')
|
|
7190
|
+
.style('font-family', 'monospace')
|
|
7191
|
+
.style('font-size', MSA_LOGO_FONT_PX + 'px')
|
|
7192
|
+
.style('fill', rgb === null ? ink : 'rgb(' + rgb.join(',') + ')')
|
|
7193
|
+
.text(letter.ch);
|
|
7194
|
+
y -= hpx;
|
|
7195
|
+
}
|
|
7196
|
+
}
|
|
7197
|
+
caption('Sequence logo \u2014 0 to ' + logo.maxBits.toFixed(1)
|
|
7198
|
+
+ ' bits (n = ' + n + ')', consTop + 8);
|
|
7199
|
+
} else {
|
|
7200
|
+
strip.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
|
|
7201
|
+
.attr('width', trackW).attr('height', MSA_CONS_BAR_H)
|
|
7202
|
+
.attr('fill', ink).attr('fill-opacity', 0.08);
|
|
7203
|
+
for (let i = 0; i < visible; ++i) {
|
|
7204
|
+
let score = cons.scores[i] || 0;
|
|
7205
|
+
if (score <= 0) {
|
|
7206
|
+
continue;
|
|
7207
|
+
}
|
|
7208
|
+
let bh = Math.max(1, Math.round(score * MSA_CONS_BAR_H));
|
|
7209
|
+
let x0 = Math.round(originX + (i * cw));
|
|
7210
|
+
let x1 = Math.round(originX + ((i + 1) * cw));
|
|
7211
|
+
strip.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
|
|
7212
|
+
.attr('width', Math.max(1, x1 - x0)).attr('height', bh)
|
|
7213
|
+
.attr('fill', ink).attr('fill-opacity', 0.7);
|
|
7214
|
+
}
|
|
7215
|
+
caption('Consensus identity (n = ' + n + ')', consTop + 9);
|
|
6957
7216
|
for (let i = 0; i < visible; ++i) {
|
|
6958
7217
|
let cc = cons.consensus[i];
|
|
6959
7218
|
if (!cc) {
|
|
@@ -6970,7 +7229,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6970
7229
|
|
|
6971
7230
|
// the 1-based column ruler: absolute column numbers at nice steps,
|
|
6972
7231
|
// and always the first and last column when their edge is in view
|
|
6973
|
-
let rulerY = consTop +
|
|
7232
|
+
let rulerY = consTop + (logo ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H);
|
|
6974
7233
|
strip.append('line').attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
|
|
6975
7234
|
.attr('y1', rulerY).attr('y2', rulerY)
|
|
6976
7235
|
.attr('stroke', ink).attr('stroke-opacity', 0.8).attr('stroke-width', 1);
|
|
@@ -7214,13 +7473,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
7214
7473
|
if (_panelTheme) {
|
|
7215
7474
|
tip_el.classList.add('aptx-' + _panelTheme);
|
|
7216
7475
|
}
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
let left = (event.pageX + 290) > window.innerWidth ? (event.pageX - 280) : (event.pageX + 14);
|
|
7220
|
-
_node_mouseover_div
|
|
7221
|
-
.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)))
|
|
7222
|
-
.style('left', left + 'px')
|
|
7223
|
-
.style('top', (event.pageY + 14) + 'px');
|
|
7476
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
|
|
7477
|
+
placeHoverReadout(tip_el, event);
|
|
7224
7478
|
_node_mouseover_div.transition().duration(100).style('opacity', 0.95);
|
|
7225
7479
|
}
|
|
7226
7480
|
|
|
@@ -7233,6 +7487,1089 @@ function (root, d3, forester, phyloXml) {
|
|
|
7233
7487
|
scheduleUpdate(null, 0);
|
|
7234
7488
|
}
|
|
7235
7489
|
|
|
7490
|
+
function msaLogoCbClicked() {
|
|
7491
|
+
_state.showMsaLogo = getCheckboxValue(MSA_LOGO_CB);
|
|
7492
|
+
scheduleUpdate(null, 0);
|
|
7493
|
+
}
|
|
7494
|
+
|
|
7495
|
+
// ===================== Heat map =====================
|
|
7496
|
+
// The desktop's MATRIX annotation columns: one cell per (tip, numeric
|
|
7497
|
+
// per-tip property) to the right of the labels, every column painted on
|
|
7498
|
+
// ONE colour scale so that a cell can be read across columns -- the
|
|
7499
|
+
// matrix half of a clustergram.
|
|
7500
|
+
//
|
|
7501
|
+
// Which refs are columns, the order they take and what the scale spans
|
|
7502
|
+
// are forester.heatmapColumns, derived once over the WHOLE tree:
|
|
7503
|
+
// candidacy is decided on the tree, never per view, so diving into a
|
|
7504
|
+
// subtree narrows the rows and leaves the columns and the scale alone.
|
|
7505
|
+
// A colour therefore means the same number in every view.
|
|
7506
|
+
//
|
|
7507
|
+
// A cell nobody filled in is never painted as the scale's low end: on a
|
|
7508
|
+
// presence/absence matrix that would state the opposite of what the file
|
|
7509
|
+
// says. It is drawn as an OUTLINED empty box, with a key beside the scale
|
|
7510
|
+
// -- leaving it bare is not enough, see the measurement at the draw.
|
|
7511
|
+
//
|
|
7512
|
+
// Rectangular and CIRCULAR: the columns stand on the tips' common edge,
|
|
7513
|
+
// which a circular fan has as a ring (drawHeatmapRings) and an unrooted one
|
|
7514
|
+
// has not got at all -- every tip there ends at its own radius.
|
|
7515
|
+
|
|
7516
|
+
function heatmapModel() {
|
|
7517
|
+
if (!_heatmapModel && _treeData) {
|
|
7518
|
+
_heatmapModel = forester.heatmapColumns(_treeData);
|
|
7519
|
+
}
|
|
7520
|
+
return _heatmapModel;
|
|
7521
|
+
}
|
|
7522
|
+
|
|
7523
|
+
// Offered when the tree holds at least two numeric per-tip refs that
|
|
7524
|
+
// actually carry values: one column is a stripe rather than a matrix,
|
|
7525
|
+
// and a scale needs something to span.
|
|
7526
|
+
function heatmapAvailable() {
|
|
7527
|
+
let m = heatmapModel();
|
|
7528
|
+
return !!m && m.refs.length >= HEATMAP_MIN_COLUMNS && m.min !== null && m.max !== null;
|
|
7529
|
+
}
|
|
7530
|
+
|
|
7531
|
+
// Circular draws the columns as RINGS (drawHeatmapRings); unrooted cannot,
|
|
7532
|
+
// because its tips end at different radii so a column has no ring to be.
|
|
7533
|
+
function heatmapShown() {
|
|
7534
|
+
return _state.showHeatmap === true && _state.unrootedDisplay !== true && heatmapAvailable();
|
|
7535
|
+
}
|
|
7536
|
+
|
|
7537
|
+
// The one scale, spanning the whole tree's values. Built lazily and kept
|
|
7538
|
+
// with the model, so every redraw and every view paints the same colours.
|
|
7539
|
+
function heatmapColorOf(v) {
|
|
7540
|
+
let m = heatmapModel();
|
|
7541
|
+
if (!m || v === null) {
|
|
7542
|
+
return null;
|
|
7543
|
+
}
|
|
7544
|
+
if (!_heatmapColor) {
|
|
7545
|
+
_heatmapColor = (m.max > m.min)
|
|
7546
|
+
? d3.scaleLinear().range(VIS_COLOR_RAMP).domain([m.min, (m.min + m.max) / 2, m.max])
|
|
7547
|
+
: function () {
|
|
7548
|
+
// one value everywhere: a flat matrix, not a gradient
|
|
7549
|
+
// over a zero-width domain (which d3 maps to its last stop)
|
|
7550
|
+
return VIS_COLOR_RAMP[1];
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
return _heatmapColor(v);
|
|
7554
|
+
}
|
|
7555
|
+
|
|
7556
|
+
// Enough digits to tell two cells apart, without a tail of noise on data
|
|
7557
|
+
// that is plainly integral.
|
|
7558
|
+
function heatmapNum(v) {
|
|
7559
|
+
if (v === null || v === undefined) {
|
|
7560
|
+
return '';
|
|
7561
|
+
}
|
|
7562
|
+
if (v !== 0 && (Math.abs(v) >= 100000 || Math.abs(v) < 0.001)) {
|
|
7563
|
+
return v.toExponential(2);
|
|
7564
|
+
}
|
|
7565
|
+
return String(Math.round(v * 1000) / 1000);
|
|
7566
|
+
}
|
|
7567
|
+
|
|
7568
|
+
// A column is as wide as the band allows, never wider than is useful and
|
|
7569
|
+
// never thinner than is legible -- past that the matrix is windowed and
|
|
7570
|
+
// scrolled, as a long alignment is.
|
|
7571
|
+
function heatmapColWidth() {
|
|
7572
|
+
let m = heatmapModel();
|
|
7573
|
+
if (!m || m.refs.length < 1) {
|
|
7574
|
+
return HEATMAP_PREF_COL_W;
|
|
7575
|
+
}
|
|
7576
|
+
let band = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
|
|
7577
|
+
return Math.max(HEATMAP_MIN_COL_W, Math.min(HEATMAP_PREF_COL_W, band / m.refs.length));
|
|
7578
|
+
}
|
|
7579
|
+
|
|
7580
|
+
function heatmapLabelsFit() {
|
|
7581
|
+
return heatmapColWidth() >= HEATMAP_LABEL_MIN_COL_W;
|
|
7582
|
+
}
|
|
7583
|
+
|
|
7584
|
+
function heatmapLabelFont() {
|
|
7585
|
+
return Math.max(7, Math.min(11, Math.round(heatmapColWidth()))) + 'px ' + FONT_DEFAULTS;
|
|
7586
|
+
}
|
|
7587
|
+
|
|
7588
|
+
// The turned column labels' band: the longest label, clipped. Asked for
|
|
7589
|
+
// before the draw (bottomOverlayReserve sizes the layout with it), so it
|
|
7590
|
+
// is derived from the model and the reserve, never from the drawing.
|
|
7591
|
+
function heatmapHeadHeight() {
|
|
7592
|
+
if (!heatmapLabelsFit()) {
|
|
7593
|
+
return 0;
|
|
7594
|
+
}
|
|
7595
|
+
let font = heatmapLabelFont();
|
|
7596
|
+
let w = 0;
|
|
7597
|
+
heatmapModel().refs.forEach(function (r) {
|
|
7598
|
+
w = Math.max(w, legendTextWidth(r.label, font));
|
|
7599
|
+
});
|
|
7600
|
+
return Math.ceil(Math.min(w, HEATMAP_LABEL_MAX_PX));
|
|
7601
|
+
}
|
|
7602
|
+
|
|
7603
|
+
// What the strip actually paints: the turned names and the scale.
|
|
7604
|
+
function heatmapStripHeight() {
|
|
7605
|
+
return HEATMAP_HEAD_GAP + heatmapHeadHeight() + HEATMAP_SCALE_RESERVE;
|
|
7606
|
+
}
|
|
7607
|
+
|
|
7608
|
+
// What the layout keeps clear for it. The alignment's navigation bar is
|
|
7609
|
+
// fixed at the viewport bottom and would cover whatever ends under it, so
|
|
7610
|
+
// when that bar is out the strip has to end above it -- as the alignment's
|
|
7611
|
+
// own strip does (MSA_NAV_RESERVE).
|
|
7612
|
+
function heatmapBottomReserve() {
|
|
7613
|
+
if (!heatmapShown() || radialDisplay()) {
|
|
7614
|
+
return 0; // the rings carry their own names and legend
|
|
7615
|
+
}
|
|
7616
|
+
return heatmapStripHeight() + (msaShown() ? MSA_NAV_RESERVE : 0);
|
|
7617
|
+
}
|
|
7618
|
+
|
|
7619
|
+
// The mode in force: what the caller or the reader chose, else the one the
|
|
7620
|
+
// data asks for.
|
|
7621
|
+
//
|
|
7622
|
+
// The resolved choice is cached on the MODEL, which is where it belongs --
|
|
7623
|
+
// it is a fact about this tree's values and it dies with them. It is not
|
|
7624
|
+
// written back into _state, but not because _state would leak it: every
|
|
7625
|
+
// launch AND every tree switch rebuilds _state from the original config
|
|
7626
|
+
// (switchToTree -> launchInto -> initializeState), so a write-back could
|
|
7627
|
+
// not reach the next tree. Checked, because the comment that used to sit
|
|
7628
|
+
// here claimed exactly that protection and it would have been fiction.
|
|
7629
|
+
function heatmapMode() {
|
|
7630
|
+
if (_state.heatmapColumnOrder) {
|
|
7631
|
+
return _state.heatmapColumnOrder;
|
|
7632
|
+
}
|
|
7633
|
+
let m = heatmapModel();
|
|
7634
|
+
if (!m) {
|
|
7635
|
+
return 'document';
|
|
7636
|
+
}
|
|
7637
|
+
if (!m._autoMode) {
|
|
7638
|
+
m._autoMode = forester.heatmapDefaultOrder(_treeData, m.refs);
|
|
7639
|
+
}
|
|
7640
|
+
return m._autoMode;
|
|
7641
|
+
}
|
|
7642
|
+
|
|
7643
|
+
// How far this tree's own tips agree about the column order (cached with
|
|
7644
|
+
// the model, since it is a fact about the same values).
|
|
7645
|
+
function heatmapInputAgreement() {
|
|
7646
|
+
let m = heatmapModel();
|
|
7647
|
+
if (!m) {
|
|
7648
|
+
return null;
|
|
7649
|
+
}
|
|
7650
|
+
if (!m._agreement) {
|
|
7651
|
+
m._agreement = forester.heatmapInputOrderAgreement(_treeData, m.refs);
|
|
7652
|
+
}
|
|
7653
|
+
return m._agreement;
|
|
7654
|
+
}
|
|
7655
|
+
|
|
7656
|
+
// The columns in the order the reader asked for, with the dendrogram
|
|
7657
|
+
// behind that order when the order came from a clustering.
|
|
7658
|
+
function heatmapOrdered() {
|
|
7659
|
+
let m = heatmapModel();
|
|
7660
|
+
if (!m) {
|
|
7661
|
+
return null;
|
|
7662
|
+
}
|
|
7663
|
+
let mode = heatmapMode();
|
|
7664
|
+
// The order is kept ON the model, not in a cache beside it. The
|
|
7665
|
+
// desktop's equivalent was keyed on (refs, mode) and a subtree, an
|
|
7666
|
+
// edit or a re-import changes the VALUES while leaving both alone, so
|
|
7667
|
+
// a hit handed back an order of the old data and a dendrogram drawn
|
|
7668
|
+
// over the new -- found by code review, fixed there by invalidating at
|
|
7669
|
+
// the one place every such path goes through. Hanging it on the model
|
|
7670
|
+
// makes that structural instead: whatever drops the model drops this
|
|
7671
|
+
// with it, and there is no second thing to remember to clear.
|
|
7672
|
+
// the manual list is part of the key: it changes while the mode
|
|
7673
|
+
// stays 'manual', and a cache blind to that would redraw the old
|
|
7674
|
+
// arrangement after every move
|
|
7675
|
+
let manual = _state.heatmapManualOrder;
|
|
7676
|
+
if (!m._order || m._order.mode !== mode || m._order.manual !== manual) {
|
|
7677
|
+
let o = forester.heatmapOrder(_treeData, m.refs, mode, manual);
|
|
7678
|
+
m._order = {mode: mode, manual: manual, columns: o.columns, dendrogram: o.dendrogram};
|
|
7679
|
+
}
|
|
7680
|
+
return m._order;
|
|
7681
|
+
}
|
|
7682
|
+
|
|
7683
|
+
// How many columns the band can hold: every column when they fit, a window
|
|
7684
|
+
// otherwise. Derived from the reserve, which update() sets before it asks
|
|
7685
|
+
// for either overlay reserve, so the draw and the layout agree.
|
|
7686
|
+
function heatmapVisibleColumns() {
|
|
7687
|
+
let m = heatmapModel();
|
|
7688
|
+
if (!m || m.refs.length < 1) {
|
|
7689
|
+
return 0;
|
|
7690
|
+
}
|
|
7691
|
+
let bandPx = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
|
|
7692
|
+
let visible = Math.max(1, Math.min(m.refs.length, Math.floor(bandPx / heatmapColWidth())));
|
|
7693
|
+
return isFinite(visible) ? visible : 0;
|
|
7694
|
+
}
|
|
7695
|
+
|
|
7696
|
+
// The dendrogram to draw, or null. A WINDOWED matrix gets none: the tree
|
|
7697
|
+
// describes every column, and drawn over some of them its merges would
|
|
7698
|
+
// reach columns that are not on screen -- a picture asserting a grouping
|
|
7699
|
+
// the reader cannot see.
|
|
7700
|
+
function heatmapDendrogram() {
|
|
7701
|
+
if (!heatmapShown() || radialDisplay()) {
|
|
7702
|
+
return null; // a dendrogram over a ring would have to bend; it earns nothing
|
|
7703
|
+
}
|
|
7704
|
+
let o = heatmapOrdered();
|
|
7705
|
+
if (!o || !o.dendrogram || o.columns.length < 3) {
|
|
7706
|
+
return null;
|
|
7707
|
+
}
|
|
7708
|
+
return (heatmapVisibleColumns() === o.columns.length) ? o.dendrogram : null;
|
|
7709
|
+
}
|
|
7710
|
+
|
|
7711
|
+
// Deliberately short: a shape to read a grouping off, not a scale anyone
|
|
7712
|
+
// reads a number from.
|
|
7713
|
+
function heatmapDendroBand() {
|
|
7714
|
+
return Math.max(HEATMAP_DENDRO_MIN_BAND,
|
|
7715
|
+
Math.min(HEATMAP_DENDRO_MAX_BAND, 3 * (_state.externalNodeFontSize + 2)));
|
|
7716
|
+
}
|
|
7717
|
+
|
|
7718
|
+
// Vertical room kept ABOVE the first row for the dendrogram. The band is
|
|
7719
|
+
// drawn at negative y, with the tree's own group, so it zooms and pans
|
|
7720
|
+
// with the cells it belongs to; the layout just has to leave it the space
|
|
7721
|
+
// and the fit has to start below it.
|
|
7722
|
+
function heatmapTopReserve() {
|
|
7723
|
+
return heatmapDendrogram() ? (heatmapDendroBand() + HEATMAP_DENDRO_GAP) : 0;
|
|
7724
|
+
}
|
|
7725
|
+
|
|
7726
|
+
function topOverlayReserve() {
|
|
7727
|
+
return heatmapTopReserve();
|
|
7728
|
+
}
|
|
7729
|
+
|
|
7730
|
+
function drawHeatmapTrack() {
|
|
7731
|
+
if (!_svgGroup) {
|
|
7732
|
+
return;
|
|
7733
|
+
}
|
|
7734
|
+
_svgGroup.selectAll('g.aptx-heatmap').remove();
|
|
7735
|
+
if (_baseSvg) {
|
|
7736
|
+
_baseSvg.selectAll('g.aptx-heatmap-ringlegend').remove();
|
|
7737
|
+
}
|
|
7738
|
+
if (_floatGroup) {
|
|
7739
|
+
_floatGroup.selectAll('g.aptx-heatmap-strip').remove();
|
|
7740
|
+
delete _floatStrips['aptx-heatmap-strip'];
|
|
7741
|
+
}
|
|
7742
|
+
if (!heatmapShown() || !_root) {
|
|
7743
|
+
_heatmapGeom = null;
|
|
7744
|
+
return;
|
|
7745
|
+
}
|
|
7746
|
+
if (heatmapCircular()) {
|
|
7747
|
+
drawHeatmapRings();
|
|
7748
|
+
drawHeatmapRingLegend();
|
|
7749
|
+
return;
|
|
7750
|
+
}
|
|
7751
|
+
let model = heatmapModel();
|
|
7752
|
+
let ordered = heatmapOrdered();
|
|
7753
|
+
let columns = ordered ? ordered.columns : model.refs;
|
|
7754
|
+
let tips = displayedTips().filter(function (d) {
|
|
7755
|
+
return d.x !== undefined;
|
|
7756
|
+
}).sort(function (p, q) {
|
|
7757
|
+
return p.x - q.x;
|
|
7758
|
+
});
|
|
7759
|
+
let total = columns.length;
|
|
7760
|
+
if (tips.length < 1 || total < 1) {
|
|
7761
|
+
_heatmapGeom = null;
|
|
7762
|
+
return;
|
|
7763
|
+
}
|
|
7764
|
+
let cw = heatmapColWidth();
|
|
7765
|
+
let bandPx = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
|
|
7766
|
+
let visible = Math.max(1, Math.min(total, Math.floor(bandPx / cw)));
|
|
7767
|
+
if (!isFinite(visible)) {
|
|
7768
|
+
// half-initialized globals (mid-launch) can make the window NaN;
|
|
7769
|
+
// draw nothing rather than feeding it downstream
|
|
7770
|
+
_heatmapGeom = null;
|
|
7771
|
+
return;
|
|
7772
|
+
}
|
|
7773
|
+
let maxOffset = Math.max(0, total - visible);
|
|
7774
|
+
if (_heatmapColOffset > maxOffset) {
|
|
7775
|
+
_heatmapColOffset = maxOffset;
|
|
7776
|
+
}
|
|
7777
|
+
if (_heatmapColOffset < 0) {
|
|
7778
|
+
_heatmapColOffset = 0;
|
|
7779
|
+
}
|
|
7780
|
+
let offset = _heatmapColOffset;
|
|
7781
|
+
// The matrix ends where the alignment track begins, or at the canvas
|
|
7782
|
+
// edge when there is none: the fitted view has shifted the layout
|
|
7783
|
+
// right by rootOffset, and both reserves were taken off _w already.
|
|
7784
|
+
let originX = _displayWidth - _settings.rootOffset - _msaReserve - _heatmapReserve + HEATMAP_TRACK_GAP;
|
|
7785
|
+
let g = _svgGroup.append('g').attr('class', 'aptx-heatmap');
|
|
7786
|
+
let ink = _state.branchColorDefault;
|
|
7787
|
+
|
|
7788
|
+
// Row bands: each shared boundary derived ONCE, as the midpoint
|
|
7789
|
+
// between adjacent tip rows -- deriving it per row from y +- half a
|
|
7790
|
+
// row height rounds a pixel apart and paints a seam across the grid.
|
|
7791
|
+
let n = tips.length;
|
|
7792
|
+
let pad = n > 1 ? ((tips[n - 1].x - tips[0].x) / (n - 1)) / 2
|
|
7793
|
+
: Math.max(4, _state.externalNodeFontSize / 2);
|
|
7794
|
+
let bounds = new Array(n + 1);
|
|
7795
|
+
bounds[0] = Math.max(0, tips[0].x - pad);
|
|
7796
|
+
for (let r = 1; r < n; ++r) {
|
|
7797
|
+
bounds[r] = (tips[r - 1].x + tips[r].x) / 2;
|
|
7798
|
+
}
|
|
7799
|
+
bounds[n] = Math.min(_clusterH, tips[n - 1].x + pad);
|
|
7800
|
+
|
|
7801
|
+
// A cell nobody filled in is drawn in the background colour and OUTLINED.
|
|
7802
|
+
// Leaving it bare is not enough: measured on the sparse demo, a bare
|
|
7803
|
+
// blank stands at a contrast ratio of 13.4 against the scale's low end
|
|
7804
|
+
// in the light theme but 1.08 in the dark one -- that is to say, in the
|
|
7805
|
+
// dark theme "not assessed" and "zero" were the same picture, which is
|
|
7806
|
+
// the one confusion this whole design exists to prevent.
|
|
7807
|
+
let blanks = 0;
|
|
7808
|
+
// Blanks merge into one outlined band only where a cell is too narrow
|
|
7809
|
+
// to outline anyway: at a readable column width a run of them must stay
|
|
7810
|
+
// a run of CELLS, or its top and bottom edges read as a drawn rule
|
|
7811
|
+
// across the matrix rather than as missing data.
|
|
7812
|
+
let mergeBlanks = cw < HEATMAP_LABEL_MIN_COL_W;
|
|
7813
|
+
for (let r = 0; r < n; ++r) {
|
|
7814
|
+
let d = tips[r];
|
|
7815
|
+
let cy = Math.round(bounds[r]);
|
|
7816
|
+
let rh = Math.max(1, Math.round(bounds[r + 1]) - cy);
|
|
7817
|
+
// consecutive cells of the SAME colour merge into one rect: on a
|
|
7818
|
+
// presence/absence matrix that is most of them, and it is what
|
|
7819
|
+
// keeps a wide matrix drawable
|
|
7820
|
+
let runStart = -1;
|
|
7821
|
+
let runFill = null;
|
|
7822
|
+
let flush = function (endI) {
|
|
7823
|
+
if (runStart < 0) {
|
|
7824
|
+
return;
|
|
7825
|
+
}
|
|
7826
|
+
let x0 = Math.round(originX + (runStart * cw));
|
|
7827
|
+
let x1 = Math.round(originX + (endI * cw));
|
|
7828
|
+
let rect = g.append('rect').attr('x', x0).attr('y', cy)
|
|
7829
|
+
.attr('width', Math.max(1, x1 - x0)).attr('height', rh);
|
|
7830
|
+
if (runFill === HEATMAP_BLANK) {
|
|
7831
|
+
rect.attr('fill', _state.backgroundColorDefault)
|
|
7832
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
|
|
7833
|
+
} else {
|
|
7834
|
+
rect.attr('fill', runFill);
|
|
7835
|
+
}
|
|
7836
|
+
runStart = -1;
|
|
7837
|
+
runFill = null;
|
|
7838
|
+
};
|
|
7839
|
+
for (let i = 0; i < visible; ++i) {
|
|
7840
|
+
let v = forester.heatmapValue(d, columns[offset + i].ref);
|
|
7841
|
+
let fill = (v === null) ? HEATMAP_BLANK : heatmapColorOf(v);
|
|
7842
|
+
if (fill === HEATMAP_BLANK) {
|
|
7843
|
+
blanks++;
|
|
7844
|
+
}
|
|
7845
|
+
if (runFill === fill && (mergeBlanks || fill !== HEATMAP_BLANK)) {
|
|
7846
|
+
continue; // the run extends
|
|
7847
|
+
}
|
|
7848
|
+
flush(i);
|
|
7849
|
+
runStart = i;
|
|
7850
|
+
runFill = fill;
|
|
7851
|
+
}
|
|
7852
|
+
flush(visible);
|
|
7853
|
+
}
|
|
7854
|
+
|
|
7855
|
+
let rowsTop = Math.round(bounds[0]);
|
|
7856
|
+
let rowsBottom = Math.round(bounds[n]);
|
|
7857
|
+
let trackW = Math.round(originX + (visible * cw)) - Math.round(originX);
|
|
7858
|
+
// The frame: without it a blank cell is indistinguishable from the
|
|
7859
|
+
// space beside the matrix, and "not assessed" stops being visible.
|
|
7860
|
+
g.append('rect')
|
|
7861
|
+
.attr('x', Math.round(originX)).attr('y', rowsTop)
|
|
7862
|
+
.attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
|
|
7863
|
+
.attr('fill', 'none')
|
|
7864
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.55).attr('stroke-width', 1);
|
|
7865
|
+
|
|
7866
|
+
// ---- the clustering behind the column order, above the grid ----
|
|
7867
|
+
// Drawn with the cells, in the tree's own group, so it zooms, pans and
|
|
7868
|
+
// exports with them. Heights are LINEAR in the merge distance, so a
|
|
7869
|
+
// block that joins low really is drawn tighter than one that joins
|
|
7870
|
+
// high; spacing merges evenly by rank would read more clearly and
|
|
7871
|
+
// would say something false.
|
|
7872
|
+
let dendro = heatmapDendrogram();
|
|
7873
|
+
if (dendro && dendro.height.length >= 1 && dendro.order.length === visible && offset === 0) {
|
|
7874
|
+
let band = heatmapDendroBand();
|
|
7875
|
+
let baseline = rowsTop - HEATMAP_DENDRO_GAP;
|
|
7876
|
+
let maxH = 0;
|
|
7877
|
+
dendro.height.forEach(function (h) {
|
|
7878
|
+
if (isFinite(h) && h > maxH) {
|
|
7879
|
+
maxH = h;
|
|
7880
|
+
}
|
|
7881
|
+
});
|
|
7882
|
+
let slot = new Array(dendro.order.length);
|
|
7883
|
+
dendro.order.forEach(function (leaf, k) {
|
|
7884
|
+
slot[leaf] = k;
|
|
7885
|
+
});
|
|
7886
|
+
let clusterX = new Array(dendro.height.length);
|
|
7887
|
+
let clusterY = new Array(dendro.height.length);
|
|
7888
|
+
// R's node-id convention: negative = the singleton of that index,
|
|
7889
|
+
// positive = the cluster formed at that stage
|
|
7890
|
+
let nodeX = function (id) {
|
|
7891
|
+
return (id < 0) ? (originX + ((slot[-id - 1] + 0.5) * cw)) : clusterX[id - 1];
|
|
7892
|
+
};
|
|
7893
|
+
let nodeY = function (id) {
|
|
7894
|
+
return (id < 0) ? baseline : clusterY[id - 1];
|
|
7895
|
+
};
|
|
7896
|
+
let path = '';
|
|
7897
|
+
for (let s = 0; s < dendro.height.length; ++s) {
|
|
7898
|
+
let h = dendro.height[s];
|
|
7899
|
+
// a merge with no finite height -- two columns that share no
|
|
7900
|
+
// assessed tip, so no distance was ever computed -- goes to the
|
|
7901
|
+
// top of the band, above every measurable merge, which is where
|
|
7902
|
+
// it belongs: it joined last
|
|
7903
|
+
let ym = (!isFinite(h) || !(maxH > 0)) ? (baseline - band) : (baseline - ((h / maxH) * band));
|
|
7904
|
+
let x1 = nodeX(dendro.left[s]);
|
|
7905
|
+
let y1 = nodeY(dendro.left[s]);
|
|
7906
|
+
let x2 = nodeX(dendro.right[s]);
|
|
7907
|
+
let y2 = nodeY(dendro.right[s]);
|
|
7908
|
+
path += 'M' + x1.toFixed(1) + ',' + y1.toFixed(1)
|
|
7909
|
+
+ 'L' + x1.toFixed(1) + ',' + ym.toFixed(1)
|
|
7910
|
+
+ 'L' + x2.toFixed(1) + ',' + ym.toFixed(1)
|
|
7911
|
+
+ 'L' + x2.toFixed(1) + ',' + y2.toFixed(1);
|
|
7912
|
+
clusterX[s] = (x1 + x2) / 2;
|
|
7913
|
+
clusterY[s] = ym;
|
|
7914
|
+
}
|
|
7915
|
+
g.append('path').attr('class', 'aptx-heatmap-dendro')
|
|
7916
|
+
.attr('d', path)
|
|
7917
|
+
.attr('fill', 'none')
|
|
7918
|
+
.attr('stroke', ink)
|
|
7919
|
+
.attr('stroke-width', Math.max(1, _state.branchWidthDefault))
|
|
7920
|
+
.style('pointer-events', 'none');
|
|
7921
|
+
}
|
|
7922
|
+
|
|
7923
|
+
// A faint dashed guide from each tip across to its row, as the
|
|
7924
|
+
// alignment track draws -- skipped when the domain tracks are in
|
|
7925
|
+
// between, where it would run straight through their boxes.
|
|
7926
|
+
if (_domainReserve === 0) {
|
|
7927
|
+
let guideFont = _state.externalNodeFontSize + 'px ' + _state.defaultFont;
|
|
7928
|
+
let guideGap = _state.nodeLabelGap;
|
|
7929
|
+
let guideEnd = originX - 3;
|
|
7930
|
+
for (let r = 0; r < n; ++r) {
|
|
7931
|
+
let d = tips[r];
|
|
7932
|
+
let label = d._extLabelText || '';
|
|
7933
|
+
let from;
|
|
7934
|
+
if (label) {
|
|
7935
|
+
let labelX = (_state.phylogram && _state.alignPhylogram && _yScale)
|
|
7936
|
+
? d.y - _yScale(d.distToRoot) + _w + guideGap
|
|
7937
|
+
: d.y + guideGap;
|
|
7938
|
+
from = labelX + legendTextWidth(label, guideFont) + 5;
|
|
7939
|
+
} else {
|
|
7940
|
+
from = d.y + makeNodeSize(d) + 4;
|
|
7941
|
+
}
|
|
7942
|
+
if (guideEnd - from < 6) {
|
|
7943
|
+
continue;
|
|
7944
|
+
}
|
|
7945
|
+
g.append('line')
|
|
7946
|
+
.attr('x1', from).attr('x2', guideEnd)
|
|
7947
|
+
.attr('y1', d.x).attr('y2', d.x)
|
|
7948
|
+
.attr('stroke', ink)
|
|
7949
|
+
.attr('stroke-width', 1)
|
|
7950
|
+
.attr('stroke-dasharray', '2,3')
|
|
7951
|
+
.style('stroke-opacity', 0.35)
|
|
7952
|
+
.style('pointer-events', 'none');
|
|
7953
|
+
}
|
|
7954
|
+
}
|
|
7955
|
+
|
|
7956
|
+
// ---- column names and the shared scale: a FLOATING strip ----
|
|
7957
|
+
// the strip is placed against its FULL reserve, so that a stuck strip
|
|
7958
|
+
// clears the alignment's navigation bar, but paints only its content
|
|
7959
|
+
let strip = floatStripGroup('aptx-heatmap-strip', _clusterH, heatmapBottomReserve());
|
|
7960
|
+
strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
|
|
7961
|
+
.attr('width', trackW + 4).attr('height', heatmapStripHeight())
|
|
7962
|
+
.attr('fill', _state.backgroundColorDefault);
|
|
7963
|
+
let labelTop = _clusterH + HEATMAP_HEAD_GAP;
|
|
7964
|
+
let headH = heatmapHeadHeight();
|
|
7965
|
+
if (headH > 0) {
|
|
7966
|
+
let font = heatmapLabelFont();
|
|
7967
|
+
for (let i = 0; i < visible; ++i) {
|
|
7968
|
+
let col = columns[offset + i];
|
|
7969
|
+
strip.append('text')
|
|
7970
|
+
.attr('transform', 'translate(' + (originX + (i * cw) + (cw / 2)) + ','
|
|
7971
|
+
+ labelTop + ') rotate(-90)')
|
|
7972
|
+
.attr('dy', '0.32em')
|
|
7973
|
+
.attr('text-anchor', 'end')
|
|
7974
|
+
.style('font', font)
|
|
7975
|
+
.style('fill', _state.labelColorDefault)
|
|
7976
|
+
.text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
|
|
7977
|
+
}
|
|
7978
|
+
}
|
|
7979
|
+
|
|
7980
|
+
// the scale itself: the ramp the cells were painted from, its two
|
|
7981
|
+
// ends named, and the blank cell shown for what it is
|
|
7982
|
+
let scaleTop = labelTop + headH + 6;
|
|
7983
|
+
let barW = Math.round(Math.max(40, Math.min(HEATMAP_SCALE_BAR_W, trackW * 0.45)));
|
|
7984
|
+
let gradId = 'aptx-heatmap-ramp';
|
|
7985
|
+
let grad = strip.append('defs').append('linearGradient').attr('id', gradId)
|
|
7986
|
+
.attr('x1', 0).attr('y1', 0).attr('x2', 1).attr('y2', 0);
|
|
7987
|
+
VIS_COLOR_RAMP.forEach(function (c, i) {
|
|
7988
|
+
grad.append('stop')
|
|
7989
|
+
.attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
|
|
7990
|
+
.attr('stop-color', c);
|
|
7991
|
+
});
|
|
7992
|
+
strip.append('rect')
|
|
7993
|
+
.attr('x', Math.round(originX)).attr('y', scaleTop)
|
|
7994
|
+
.attr('width', barW).attr('height', HEATMAP_SCALE_BAR_H)
|
|
7995
|
+
.attr('fill', 'url(#' + gradId + ')')
|
|
7996
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
|
|
7997
|
+
let numY = scaleTop + HEATMAP_SCALE_BAR_H + 10;
|
|
7998
|
+
strip.append('text').attr('x', Math.round(originX)).attr('y', numY)
|
|
7999
|
+
.attr('text-anchor', 'start')
|
|
8000
|
+
.style('font-size', '9px').style('fill', ink)
|
|
8001
|
+
.text(heatmapNum(model.min));
|
|
8002
|
+
strip.append('text').attr('x', Math.round(originX) + barW).attr('y', numY)
|
|
8003
|
+
.attr('text-anchor', 'end')
|
|
8004
|
+
.style('font-size', '9px').style('fill', ink)
|
|
8005
|
+
.text(heatmapNum(model.max));
|
|
8006
|
+
// the caption: what the matrix is showing, and -- when it is windowed
|
|
8007
|
+
// -- that there is more of it than this
|
|
8008
|
+
let caption = (visible < total)
|
|
8009
|
+
? 'Columns ' + (offset + 1) + '–' + (offset + visible) + ' of ' + total + ', one scale (scroll over the matrix)'
|
|
8010
|
+
: total + ' columns, one scale';
|
|
8011
|
+
let capFont = '9px ' + FONT_DEFAULTS;
|
|
8012
|
+
let cursor = Math.round(originX) + barW + 10;
|
|
8013
|
+
let rightEdge = Math.round(originX) + trackW;
|
|
8014
|
+
// the blank's own key comes FIRST: it names a convention the reader
|
|
8015
|
+
// cannot guess, where the caption only repeats what is on screen
|
|
8016
|
+
if (blanks > 0) {
|
|
8017
|
+
let keyW = HEATMAP_SCALE_BAR_H + 4 + legendTextWidth(HEATMAP_NOT_ASSESSED, capFont);
|
|
8018
|
+
if ((rightEdge - cursor) >= keyW) {
|
|
8019
|
+
strip.append('rect').attr('x', cursor).attr('y', scaleTop)
|
|
8020
|
+
.attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
|
|
8021
|
+
.attr('fill', _state.backgroundColorDefault)
|
|
8022
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
|
|
8023
|
+
strip.append('text').attr('x', cursor + HEATMAP_SCALE_BAR_H + 4)
|
|
8024
|
+
.attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
|
|
8025
|
+
.attr('text-anchor', 'start')
|
|
8026
|
+
.style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
|
|
8027
|
+
.text(HEATMAP_NOT_ASSESSED);
|
|
8028
|
+
cursor += keyW + 10;
|
|
8029
|
+
}
|
|
8030
|
+
}
|
|
8031
|
+
if ((rightEdge - cursor) > 60) {
|
|
8032
|
+
strip.append('text').attr('x', cursor).attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
|
|
8033
|
+
.attr('text-anchor', 'start')
|
|
8034
|
+
.style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
|
|
8035
|
+
.text(clipTextToWidth(caption, capFont, rightEdge - cursor));
|
|
8036
|
+
}
|
|
8037
|
+
|
|
8038
|
+
// hover + wheel surface over the rows
|
|
8039
|
+
g.append('rect').attr('class', 'aptx-heatmap-hover')
|
|
8040
|
+
.attr('x', Math.round(originX)).attr('y', rowsTop)
|
|
8041
|
+
.attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
|
|
8042
|
+
.attr('fill', 'transparent')
|
|
8043
|
+
.on('mousemove', heatmapHoverMove)
|
|
8044
|
+
.on('mouseout', heatmapHoverOut)
|
|
8045
|
+
.on('wheel', function (event) {
|
|
8046
|
+
if (visible >= total) {
|
|
8047
|
+
return; // nothing to scroll: leave the wheel to the page
|
|
8048
|
+
}
|
|
8049
|
+
event.preventDefault();
|
|
8050
|
+
event.stopPropagation();
|
|
8051
|
+
let delta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
|
|
8052
|
+
if (delta === 0) {
|
|
8053
|
+
return;
|
|
8054
|
+
}
|
|
8055
|
+
let step = Math.max(1, Math.round(visible / 10));
|
|
8056
|
+
heatmapScrollTo(_heatmapColOffset + (delta > 0 ? step : -step));
|
|
8057
|
+
});
|
|
8058
|
+
_heatmapGeom = {
|
|
8059
|
+
originX: originX, cw: cw, offset: offset, visible: visible, total: total,
|
|
8060
|
+
bounds: bounds, tips: tips, refs: columns, min: model.min, max: model.max
|
|
8061
|
+
};
|
|
8062
|
+
placeFloatingOverlays();
|
|
8063
|
+
}
|
|
8064
|
+
|
|
8065
|
+
|
|
8066
|
+
// ===================== the matrix, as rings =====================
|
|
8067
|
+
// The circular layout's analogue of the track: one concentric RING per
|
|
8068
|
+
// column, just past the tips and their labels, each tip's cell an arc over
|
|
8069
|
+
// that tip's own angular slice. The desktop's paintAnnotationColumnsCircular,
|
|
8070
|
+
// in polar coordinates rather than along an axis.
|
|
8071
|
+
//
|
|
8072
|
+
// Circular only, never unrooted: a column has to be a ring, and an unrooted
|
|
8073
|
+
// fan ends every tip at a different radius, so there is no ring for one to
|
|
8074
|
+
// sit on. (The domain tracks ride each tip's own spoke there, which works
|
|
8075
|
+
// for a bar and not for a matrix.)
|
|
8076
|
+
|
|
8077
|
+
// _radial is set by the circular layout and nulled by every other, so it
|
|
8078
|
+
// IS the test; heatmapShown() has already refused the unrooted one. A
|
|
8079
|
+
// separate circularDisplay check here looked like a third lock and could
|
|
8080
|
+
// not fire -- sabotaging it away changed nothing anywhere.
|
|
8081
|
+
function heatmapCircular() {
|
|
8082
|
+
return heatmapShown() && !!_radial;
|
|
8083
|
+
}
|
|
8084
|
+
|
|
8085
|
+
// A ring is as thick as the others, and the stack is capped against the
|
|
8086
|
+
// tree's own radius: a wide matrix must not turn the tree into a dot at
|
|
8087
|
+
// the middle of a dartboard.
|
|
8088
|
+
function heatmapRingWidth() {
|
|
8089
|
+
let m = heatmapModel();
|
|
8090
|
+
if (!m || m.refs.length < 1 || !_radial) {
|
|
8091
|
+
return HEATMAP_PREF_COL_W;
|
|
8092
|
+
}
|
|
8093
|
+
let budget = _radial.maxRad * HEATMAP_RADIAL_MAX_FRACTION;
|
|
8094
|
+
return Math.max(HEATMAP_RADIAL_MIN_RING,
|
|
8095
|
+
Math.min(HEATMAP_PREF_COL_W, budget / m.refs.length));
|
|
8096
|
+
}
|
|
8097
|
+
|
|
8098
|
+
// How far past the tip ring the matrix reaches, for the fit to allow for.
|
|
8099
|
+
function heatmapRadialExtent() {
|
|
8100
|
+
if (!heatmapCircular()) {
|
|
8101
|
+
return 0;
|
|
8102
|
+
}
|
|
8103
|
+
return HEATMAP_RADIAL_GAP + (heatmapModel().refs.length * heatmapRingWidth())
|
|
8104
|
+
+ HEATMAP_RADIAL_END_GAP;
|
|
8105
|
+
}
|
|
8106
|
+
|
|
8107
|
+
// one cell: the ring between r0 and r1, over the angles a0 to a1
|
|
8108
|
+
function heatmapSector(a0, a1, r0, r1) {
|
|
8109
|
+
let p = function (a, r) {
|
|
8110
|
+
let xy = polarXY(a, r);
|
|
8111
|
+
return xy[0].toFixed(1) + ',' + xy[1].toFixed(1);
|
|
8112
|
+
};
|
|
8113
|
+
let large = (a1 - a0) > Math.PI ? 1 : 0;
|
|
8114
|
+
return 'M' + p(a0, r0)
|
|
8115
|
+
+ 'A' + r0.toFixed(1) + ',' + r0.toFixed(1) + ' 0 ' + large + ' 1 ' + p(a1, r0)
|
|
8116
|
+
+ 'L' + p(a1, r1)
|
|
8117
|
+
+ 'A' + r1.toFixed(1) + ',' + r1.toFixed(1) + ' 0 ' + large + ' 0 ' + p(a0, r1)
|
|
8118
|
+
+ 'Z';
|
|
8119
|
+
}
|
|
8120
|
+
|
|
8121
|
+
function drawHeatmapRings() {
|
|
8122
|
+
let model = heatmapModel();
|
|
8123
|
+
let ordered = heatmapOrdered();
|
|
8124
|
+
let columns = ordered ? ordered.columns : model.refs;
|
|
8125
|
+
let tips = displayedTips().filter(function (d) {
|
|
8126
|
+
return d.x !== undefined;
|
|
8127
|
+
}).sort(function (p, q) {
|
|
8128
|
+
return p.x - q.x;
|
|
8129
|
+
});
|
|
8130
|
+
let n = tips.length;
|
|
8131
|
+
if (n < 1 || columns.length < 1) {
|
|
8132
|
+
_heatmapGeom = null;
|
|
8133
|
+
return;
|
|
8134
|
+
}
|
|
8135
|
+
let g = _svgGroup.append('g').attr('class', 'aptx-heatmap');
|
|
8136
|
+
let ink = _state.branchColorDefault;
|
|
8137
|
+
let ringW = heatmapRingWidth();
|
|
8138
|
+
let start = _radial.maxRad + tipLabelSpace() + HEATMAP_RADIAL_GAP;
|
|
8139
|
+
|
|
8140
|
+
// Angular boundaries, derived ONCE between neighbours, exactly as the
|
|
8141
|
+
// rectangular rows are: a per-tip half-step would round apart and leave
|
|
8142
|
+
// hairline seams all round the ring.
|
|
8143
|
+
let ang = tips.map(function (d) {
|
|
8144
|
+
return radialAngle(d.x);
|
|
8145
|
+
});
|
|
8146
|
+
let pad = n > 1 ? (ang[n - 1] - ang[0]) / (n - 1) / 2 : Math.PI / 8;
|
|
8147
|
+
let edge = new Array(n + 1);
|
|
8148
|
+
edge[0] = ang[0] - pad;
|
|
8149
|
+
for (let i = 1; i < n; ++i) {
|
|
8150
|
+
edge[i] = (ang[i - 1] + ang[i]) / 2;
|
|
8151
|
+
}
|
|
8152
|
+
edge[n] = ang[n - 1] + pad;
|
|
8153
|
+
|
|
8154
|
+
let blanks = 0;
|
|
8155
|
+
for (let c = 0; c < columns.length; ++c) {
|
|
8156
|
+
let r0 = start + (c * ringW);
|
|
8157
|
+
let r1 = r0 + ringW;
|
|
8158
|
+
let ref = columns[c].ref;
|
|
8159
|
+
// consecutive tips of the same colour merge into one sector, which
|
|
8160
|
+
// is what keeps a big circular tree drawable
|
|
8161
|
+
let runStart = -1;
|
|
8162
|
+
let runFill = null;
|
|
8163
|
+
let flush = function (endI) {
|
|
8164
|
+
if (runStart < 0) {
|
|
8165
|
+
return;
|
|
8166
|
+
}
|
|
8167
|
+
// Bled outward and clockwise only: the rings are drawn inner to
|
|
8168
|
+
// outer and each ring's cells in tip order, so the neighbour
|
|
8169
|
+
// that covers the overlap is always the one drawn next.
|
|
8170
|
+
// A BLANK is not bled -- it is background-coloured, so a seam
|
|
8171
|
+
// beside it shows nothing, and bleeding it would push its
|
|
8172
|
+
// outward OUTLINE under the next ring and cost it an edge.
|
|
8173
|
+
let bleed = (runFill === HEATMAP_BLANK) ? 0 : HEATMAP_RING_BLEED;
|
|
8174
|
+
let a1 = edge[endI] + (bleed / Math.max(1, r1));
|
|
8175
|
+
let path = g.append('path')
|
|
8176
|
+
.attr('d', heatmapSector(edge[runStart], a1, r0, r1 + bleed));
|
|
8177
|
+
if (runFill === HEATMAP_BLANK) {
|
|
8178
|
+
path.attr('fill', _state.backgroundColorDefault)
|
|
8179
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
|
|
8180
|
+
} else {
|
|
8181
|
+
path.attr('fill', runFill);
|
|
8182
|
+
}
|
|
8183
|
+
runStart = -1;
|
|
8184
|
+
runFill = null;
|
|
8185
|
+
};
|
|
8186
|
+
for (let i = 0; i < n; ++i) {
|
|
8187
|
+
let v = forester.heatmapValue(tips[i], ref);
|
|
8188
|
+
let fill = (v === null) ? HEATMAP_BLANK : heatmapColorOf(v);
|
|
8189
|
+
if (fill === HEATMAP_BLANK) {
|
|
8190
|
+
blanks++;
|
|
8191
|
+
}
|
|
8192
|
+
if (runFill === fill && (ringW < HEATMAP_LABEL_MIN_COL_W || fill !== HEATMAP_BLANK)) {
|
|
8193
|
+
continue;
|
|
8194
|
+
}
|
|
8195
|
+
flush(i);
|
|
8196
|
+
runStart = i;
|
|
8197
|
+
runFill = fill;
|
|
8198
|
+
}
|
|
8199
|
+
flush(n);
|
|
8200
|
+
}
|
|
8201
|
+
|
|
8202
|
+
// the stack's own edges, so a blank reads as a hole in a grid rather
|
|
8203
|
+
// than as a gap in the drawing
|
|
8204
|
+
[start, start + (columns.length * ringW)].forEach(function (r) {
|
|
8205
|
+
g.append('path')
|
|
8206
|
+
.attr('d', heatmapSector(edge[0], edge[n], r - 0.5, r + 0.5))
|
|
8207
|
+
.attr('fill', ink).attr('fill-opacity', 0.5);
|
|
8208
|
+
});
|
|
8209
|
+
|
|
8210
|
+
// Each ring's name, laid along the ring at the fan's seam -- the gap
|
|
8211
|
+
// the layout already leaves between the last tip and the first. It is
|
|
8212
|
+
// the one place a label can sit without covering a cell.
|
|
8213
|
+
if (ringW >= HEATMAP_RADIAL_MIN_NAMED_RING) {
|
|
8214
|
+
let seam = edge[n] + ((2 * Math.PI) - (edge[n] - edge[0])) / 2;
|
|
8215
|
+
let font = Math.max(7, Math.min(10, Math.round(ringW))) + 'px ' + FONT_DEFAULTS;
|
|
8216
|
+
columns.forEach(function (col, c) {
|
|
8217
|
+
let r = start + (c * ringW) + (ringW / 2);
|
|
8218
|
+
let xy = polarXY(seam, r);
|
|
8219
|
+
// TANGENTIAL: along the ring, not out along the spoke. polarXY
|
|
8220
|
+
// puts angle a at screen direction a - 90, so the tangent is a
|
|
8221
|
+
// itself -- rotating by a - 90 instead laid every name out
|
|
8222
|
+
// along the seam, where rings are one ring-width apart and the
|
|
8223
|
+
// names are several times that: measured, 44 overlapping pairs
|
|
8224
|
+
// out of 18 names. Flipped where it would otherwise read
|
|
8225
|
+
// upside down.
|
|
8226
|
+
let deg = ((seam * 180 / Math.PI) % 360 + 360) % 360;
|
|
8227
|
+
let flip = (deg > 90 && deg < 270);
|
|
8228
|
+
g.append('text')
|
|
8229
|
+
.attr('transform', 'translate(' + xy[0].toFixed(1) + ',' + xy[1].toFixed(1) + ') '
|
|
8230
|
+
+ 'rotate(' + (deg + (flip ? 180 : 0)).toFixed(1) + ')')
|
|
8231
|
+
.attr('text-anchor', 'middle')
|
|
8232
|
+
.attr('dy', '0.32em')
|
|
8233
|
+
.style('font', font)
|
|
8234
|
+
.style('fill', _state.labelColorDefault)
|
|
8235
|
+
.text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
|
|
8236
|
+
});
|
|
8237
|
+
}
|
|
8238
|
+
|
|
8239
|
+
// the hover surface: the whole annulus, hit-tested in polar coordinates
|
|
8240
|
+
g.append('path')
|
|
8241
|
+
.attr('class', 'aptx-heatmap-hover')
|
|
8242
|
+
.attr('d', heatmapSector(edge[0], edge[n], start, start + (columns.length * ringW)))
|
|
8243
|
+
.attr('fill', 'transparent')
|
|
8244
|
+
.on('mousemove', heatmapHoverMove)
|
|
8245
|
+
.on('mouseout', heatmapHoverOut);
|
|
8246
|
+
|
|
8247
|
+
_heatmapGeom = {
|
|
8248
|
+
circular: true, start: start, ringW: ringW, edge: edge, tips: tips,
|
|
8249
|
+
refs: columns, min: model.min, max: model.max, blanks: blanks
|
|
8250
|
+
};
|
|
8251
|
+
}
|
|
8252
|
+
|
|
8253
|
+
|
|
8254
|
+
// The scale, for the circular layout. The rectangular track hangs its
|
|
8255
|
+
// legend under the matrix, which a ring has no "under": this is the same
|
|
8256
|
+
// content -- the ramp, its two ends, the blank key, the column count -- as
|
|
8257
|
+
// a card in the corner the tree leaves free. Drawn in screen coordinates
|
|
8258
|
+
// like the domain legend, so it neither turns nor scales with the fan.
|
|
8259
|
+
function drawHeatmapRingLegend() {
|
|
8260
|
+
if (!_baseSvg) {
|
|
8261
|
+
return;
|
|
8262
|
+
}
|
|
8263
|
+
_baseSvg.selectAll('g.aptx-heatmap-ringlegend').remove();
|
|
8264
|
+
let size = svgSize();
|
|
8265
|
+
let m = heatmapModel();
|
|
8266
|
+
if (!heatmapCircular() || !size || !m) {
|
|
8267
|
+
return;
|
|
8268
|
+
}
|
|
8269
|
+
const PAD = 9;
|
|
8270
|
+
const BAR_W = 128;
|
|
8271
|
+
const FS = 9;
|
|
8272
|
+
let ink = _state.labelColorDefault;
|
|
8273
|
+
let frame = _state.branchColorDefault;
|
|
8274
|
+
let capFont = FS + 'px ' + FONT_DEFAULTS;
|
|
8275
|
+
let blanks = _heatmapGeom && _heatmapGeom.blanks > 0;
|
|
8276
|
+
let caption = m.refs.length + ' columns, one scale';
|
|
8277
|
+
let width = (2 * PAD) + Math.max(BAR_W, legendTextWidth(caption, capFont),
|
|
8278
|
+
blanks ? (HEATMAP_SCALE_BAR_H + 4 + legendTextWidth(HEATMAP_NOT_ASSESSED, capFont)) : 0);
|
|
8279
|
+
let rows = 2 + (blanks ? 1 : 0);
|
|
8280
|
+
let height = (2 * PAD) + HEATMAP_SCALE_BAR_H + (rows * (FS + 4));
|
|
8281
|
+
let x = PAD;
|
|
8282
|
+
let y = size.h - height - PAD; // bottom-left: the overview box lives bottom-right
|
|
8283
|
+
|
|
8284
|
+
let g = _baseSvg.append('g').attr('class', 'aptx-heatmap-ringlegend')
|
|
8285
|
+
.style('pointer-events', 'none');
|
|
8286
|
+
g.append('rect').attr('x', x).attr('y', y).attr('width', width).attr('height', height)
|
|
8287
|
+
.attr('rx', 5)
|
|
8288
|
+
.style('fill', _state.backgroundColorDefault).style('fill-opacity', 0.92)
|
|
8289
|
+
.style('stroke', frame).style('stroke-opacity', 0.5);
|
|
8290
|
+
|
|
8291
|
+
let gradId = 'aptx-heatmap-ringramp';
|
|
8292
|
+
let grad = g.append('defs').append('linearGradient').attr('id', gradId)
|
|
8293
|
+
.attr('x1', 0).attr('y1', 0).attr('x2', 1).attr('y2', 0);
|
|
8294
|
+
VIS_COLOR_RAMP.forEach(function (c, i) {
|
|
8295
|
+
grad.append('stop')
|
|
8296
|
+
.attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
|
|
8297
|
+
.attr('stop-color', c);
|
|
8298
|
+
});
|
|
8299
|
+
let barY = y + PAD;
|
|
8300
|
+
g.append('rect').attr('x', x + PAD).attr('y', barY)
|
|
8301
|
+
.attr('width', BAR_W).attr('height', HEATMAP_SCALE_BAR_H)
|
|
8302
|
+
.attr('fill', 'url(#' + gradId + ')')
|
|
8303
|
+
.attr('stroke', frame).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
|
|
8304
|
+
let line = barY + HEATMAP_SCALE_BAR_H + FS + 2;
|
|
8305
|
+
g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
|
|
8306
|
+
.style('font', capFont).style('fill', ink).text(heatmapNum(m.min));
|
|
8307
|
+
g.append('text').attr('x', x + PAD + BAR_W).attr('y', line).attr('text-anchor', 'end')
|
|
8308
|
+
.style('font', capFont).style('fill', ink).text(heatmapNum(m.max));
|
|
8309
|
+
if (blanks) {
|
|
8310
|
+
line += FS + 4;
|
|
8311
|
+
g.append('rect').attr('x', x + PAD).attr('y', line - HEATMAP_SCALE_BAR_H)
|
|
8312
|
+
.attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
|
|
8313
|
+
.attr('fill', _state.backgroundColorDefault)
|
|
8314
|
+
.attr('stroke', frame).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
|
|
8315
|
+
g.append('text').attr('x', x + PAD + HEATMAP_SCALE_BAR_H + 4).attr('y', line)
|
|
8316
|
+
.attr('text-anchor', 'start')
|
|
8317
|
+
.style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
|
|
8318
|
+
.text(HEATMAP_NOT_ASSESSED);
|
|
8319
|
+
}
|
|
8320
|
+
line += FS + 4;
|
|
8321
|
+
g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
|
|
8322
|
+
.style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
|
|
8323
|
+
.text(caption);
|
|
8324
|
+
}
|
|
8325
|
+
|
|
8326
|
+
function heatmapScrollTo(offset) {
|
|
8327
|
+
if (!_heatmapGeom) {
|
|
8328
|
+
return;
|
|
8329
|
+
}
|
|
8330
|
+
let max = Math.max(0, _heatmapGeom.total - _heatmapGeom.visible);
|
|
8331
|
+
let next = Math.max(0, Math.min(max, Math.round(offset)));
|
|
8332
|
+
if (next !== _heatmapColOffset) {
|
|
8333
|
+
_heatmapColOffset = next;
|
|
8334
|
+
drawHeatmapTrack();
|
|
8335
|
+
}
|
|
8336
|
+
}
|
|
8337
|
+
|
|
8338
|
+
// The hover readout, as on the desktop: the tip, the column and its
|
|
8339
|
+
// value, and the scale the colour was taken from -- so a colour can be
|
|
8340
|
+
// turned back into a number without reading the legend off the bottom.
|
|
8341
|
+
function heatmapHoverMove(event) {
|
|
8342
|
+
if (!_heatmapGeom) {
|
|
8343
|
+
return;
|
|
8344
|
+
}
|
|
8345
|
+
let p = d3.pointer(event, _svgGroup.node());
|
|
8346
|
+
let i;
|
|
8347
|
+
let r = -1;
|
|
8348
|
+
if (_heatmapGeom.circular) {
|
|
8349
|
+
// the same two questions in polar form: which ring, and which slice
|
|
8350
|
+
let rad = Math.sqrt((p[0] * p[0]) + (p[1] * p[1]));
|
|
8351
|
+
i = Math.floor((rad - _heatmapGeom.start) / _heatmapGeom.ringW);
|
|
8352
|
+
if (i < 0 || i >= _heatmapGeom.refs.length) {
|
|
8353
|
+
heatmapHoverOut();
|
|
8354
|
+
return;
|
|
8355
|
+
}
|
|
8356
|
+
// polarXY turns an angle a into (cos(a - pi/2), sin(a - pi/2)),
|
|
8357
|
+
// so the angle comes back the same way round
|
|
8358
|
+
let a = Math.atan2(p[1], p[0]) + (Math.PI / 2);
|
|
8359
|
+
let edge = _heatmapGeom.edge;
|
|
8360
|
+
let n = _heatmapGeom.tips.length;
|
|
8361
|
+
for (let k = 0; k < n; ++k) {
|
|
8362
|
+
// the fan can be laid anywhere on the circle (it rotates), so
|
|
8363
|
+
// the comparison is made modulo a full turn rather than on the
|
|
8364
|
+
// raw angle, which need not lie in the same period
|
|
8365
|
+
let lo = edge[k];
|
|
8366
|
+
let hi = edge[k + 1];
|
|
8367
|
+
let d = ((a - lo) % (2 * Math.PI) + (2 * Math.PI)) % (2 * Math.PI);
|
|
8368
|
+
if (d < (hi - lo)) {
|
|
8369
|
+
r = k;
|
|
8370
|
+
break;
|
|
8371
|
+
}
|
|
8372
|
+
}
|
|
8373
|
+
} else {
|
|
8374
|
+
i = Math.floor((p[0] - _heatmapGeom.originX) / _heatmapGeom.cw);
|
|
8375
|
+
if (i < 0 || i >= _heatmapGeom.visible) {
|
|
8376
|
+
heatmapHoverOut();
|
|
8377
|
+
return;
|
|
8378
|
+
}
|
|
8379
|
+
for (let k = 0; k < _heatmapGeom.tips.length; ++k) {
|
|
8380
|
+
if (p[1] >= _heatmapGeom.bounds[k] && p[1] < _heatmapGeom.bounds[k + 1]) {
|
|
8381
|
+
r = k;
|
|
8382
|
+
break;
|
|
8383
|
+
}
|
|
8384
|
+
}
|
|
8385
|
+
}
|
|
8386
|
+
if (r < 0) {
|
|
8387
|
+
heatmapHoverOut();
|
|
8388
|
+
return;
|
|
8389
|
+
}
|
|
8390
|
+
let tip = _heatmapGeom.tips[r];
|
|
8391
|
+
let col = _heatmapGeom.refs[(_heatmapGeom.offset || 0) + i];
|
|
8392
|
+
let v = forester.heatmapValue(tip, col.ref);
|
|
8393
|
+
let txt = 'Tip: ' + (displayNodeName(tip) || '?') + '<br>'
|
|
8394
|
+
+ col.label + ': ' + (v === null ? HEATMAP_NOT_ASSESSED : heatmapNum(v)) + '<br>'
|
|
8395
|
+
+ 'Scale: ' + heatmapNum(_heatmapGeom.min) + ' – ' + heatmapNum(_heatmapGeom.max);
|
|
8396
|
+
let tip_el = _node_mouseover_div.node();
|
|
8397
|
+
tip_el.classList.remove('aptx-light', 'aptx-dark');
|
|
8398
|
+
if (_panelTheme) {
|
|
8399
|
+
tip_el.classList.add('aptx-' + _panelTheme);
|
|
8400
|
+
}
|
|
8401
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
|
|
8402
|
+
placeHoverReadout(tip_el, event);
|
|
8403
|
+
_node_mouseover_div.transition().duration(100).style('opacity', 0.95);
|
|
8404
|
+
}
|
|
8405
|
+
|
|
8406
|
+
function heatmapHoverOut() {
|
|
8407
|
+
_node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
|
|
8408
|
+
}
|
|
8409
|
+
|
|
8410
|
+
|
|
8411
|
+
// The reader's own column order, as the desktop's Annotation Fields arrows
|
|
8412
|
+
// set it. Theirs also chooses WHICH fields are columns and of what type;
|
|
8413
|
+
// ours takes every numeric field automatically, so this dialog is about
|
|
8414
|
+
// order and nothing else.
|
|
8415
|
+
//
|
|
8416
|
+
// Moving anything here makes the order Manual, and Manual never re-sorts --
|
|
8417
|
+
// a sorting mode that quietly undid a move would make the move pointless.
|
|
8418
|
+
// The same rule as the desktop's resolveEdited, minus its "did the order
|
|
8419
|
+
// actually change" test, which it needs only because its dialog does other
|
|
8420
|
+
// things too.
|
|
8421
|
+
function showHeatmapReorderDialog() {
|
|
8422
|
+
let ordered = heatmapOrdered();
|
|
8423
|
+
if (!ordered || ordered.columns.length < 2) {
|
|
8424
|
+
return;
|
|
8425
|
+
}
|
|
8426
|
+
let shell = makeDialogShell(HEATMAP_REORDER_DIALOG, 'Order columns', 320);
|
|
8427
|
+
let body = shell.body;
|
|
8428
|
+
repsElement(body, 'div', 'aptx-reps-lead',
|
|
8429
|
+
'Drag a column, or use the arrows. Your order is kept until you choose another.');
|
|
8430
|
+
let list = repsElement(body, 'div', 'aptx-reorder');
|
|
8431
|
+
let working = ordered.columns.slice();
|
|
8432
|
+
|
|
8433
|
+
function rowAt(y) {
|
|
8434
|
+
let rows = Array.prototype.slice.call(list.children);
|
|
8435
|
+
for (let i = 0; i < rows.length; ++i) {
|
|
8436
|
+
let b = rows[i].getBoundingClientRect();
|
|
8437
|
+
if (y < b.top + (b.height / 2)) {
|
|
8438
|
+
return i;
|
|
8439
|
+
}
|
|
8440
|
+
}
|
|
8441
|
+
return rows.length;
|
|
8442
|
+
}
|
|
8443
|
+
|
|
8444
|
+
function move(from, to) {
|
|
8445
|
+
if (to < 0 || to >= working.length || from === to) {
|
|
8446
|
+
return false;
|
|
8447
|
+
}
|
|
8448
|
+
working.splice(to, 0, working.splice(from, 1)[0]);
|
|
8449
|
+
draw();
|
|
8450
|
+
return true;
|
|
8451
|
+
}
|
|
8452
|
+
|
|
8453
|
+
function draw() {
|
|
8454
|
+
list.textContent = '';
|
|
8455
|
+
working.forEach(function (col, i) {
|
|
8456
|
+
let row = repsElement(list, 'div', 'aptx-reorder-row');
|
|
8457
|
+
row.tabIndex = 0;
|
|
8458
|
+
repsElement(row, 'span', 'aptx-reorder-grip', '≡');
|
|
8459
|
+
repsElement(row, 'span', 'aptx-reorder-name', col.label || col.ref).title = col.ref;
|
|
8460
|
+
let up = repsElement(row, 'button', 'aptx-reorder-move', '↑');
|
|
8461
|
+
let down = repsElement(row, 'button', 'aptx-reorder-move', '↓');
|
|
8462
|
+
up.type = 'button';
|
|
8463
|
+
down.type = 'button';
|
|
8464
|
+
up.title = 'Move up';
|
|
8465
|
+
down.title = 'Move down';
|
|
8466
|
+
up.disabled = (i === 0);
|
|
8467
|
+
down.disabled = (i === working.length - 1);
|
|
8468
|
+
up.addEventListener('click', function () {
|
|
8469
|
+
if (move(i, i - 1)) {
|
|
8470
|
+
list.children[i - 1].focus();
|
|
8471
|
+
}
|
|
8472
|
+
});
|
|
8473
|
+
down.addEventListener('click', function () {
|
|
8474
|
+
if (move(i, i + 1)) {
|
|
8475
|
+
list.children[i + 1].focus();
|
|
8476
|
+
}
|
|
8477
|
+
});
|
|
8478
|
+
// the arrows reach the same places from the keyboard, so a row
|
|
8479
|
+
// can be moved without a pointer at all
|
|
8480
|
+
row.addEventListener('keydown', function (event) {
|
|
8481
|
+
let d = (event.key === 'ArrowUp') ? -1 : ((event.key === 'ArrowDown') ? 1 : 0);
|
|
8482
|
+
if (d === 0) {
|
|
8483
|
+
return;
|
|
8484
|
+
}
|
|
8485
|
+
event.preventDefault();
|
|
8486
|
+
if (move(i, i + d)) {
|
|
8487
|
+
list.children[i + d].focus();
|
|
8488
|
+
}
|
|
8489
|
+
});
|
|
8490
|
+
row.addEventListener('pointerdown', function (event) {
|
|
8491
|
+
if (event.target.classList.contains('aptx-reorder-move')) {
|
|
8492
|
+
return; // the arrows are not a drag handle
|
|
8493
|
+
}
|
|
8494
|
+
event.preventDefault();
|
|
8495
|
+
row.classList.add('aptx-reorder-dragging');
|
|
8496
|
+
// The LIVE node is moved rather than the list redrawn: a
|
|
8497
|
+
// redraw would replace the element this drag is following.
|
|
8498
|
+
// Its position is read back from the DOM each time instead
|
|
8499
|
+
// of being tracked in a variable -- the first cut kept an
|
|
8500
|
+
// index, updated it, and then used it to work out where to
|
|
8501
|
+
// insert, by which point it no longer meant what the
|
|
8502
|
+
// calculation assumed.
|
|
8503
|
+
let onMove = function (ev) {
|
|
8504
|
+
let kids = Array.prototype.slice.call(list.children);
|
|
8505
|
+
let from = kids.indexOf(row);
|
|
8506
|
+
let to = rowAt(ev.clientY);
|
|
8507
|
+
if (to > from) {
|
|
8508
|
+
to -= 1; // taking the row out shifts everything after it up one
|
|
8509
|
+
}
|
|
8510
|
+
if (to === from || to < 0 || to >= kids.length) {
|
|
8511
|
+
return;
|
|
8512
|
+
}
|
|
8513
|
+
working.splice(to, 0, working.splice(from, 1)[0]);
|
|
8514
|
+
let others = kids.filter(function (k) {
|
|
8515
|
+
return k !== row;
|
|
8516
|
+
});
|
|
8517
|
+
list.insertBefore(row, others[to] || null);
|
|
8518
|
+
Array.prototype.slice.call(list.children).forEach(function (r, k) {
|
|
8519
|
+
let btns = r.querySelectorAll('.aptx-reorder-move');
|
|
8520
|
+
btns[0].disabled = (k === 0);
|
|
8521
|
+
btns[1].disabled = (k === list.children.length - 1);
|
|
8522
|
+
});
|
|
8523
|
+
};
|
|
8524
|
+
let onUp = function () {
|
|
8525
|
+
row.classList.remove('aptx-reorder-dragging');
|
|
8526
|
+
window.removeEventListener('pointermove', onMove);
|
|
8527
|
+
window.removeEventListener('pointerup', onUp);
|
|
8528
|
+
draw();
|
|
8529
|
+
};
|
|
8530
|
+
window.addEventListener('pointermove', onMove);
|
|
8531
|
+
window.addEventListener('pointerup', onUp);
|
|
8532
|
+
});
|
|
8533
|
+
});
|
|
8534
|
+
}
|
|
8535
|
+
draw();
|
|
8536
|
+
|
|
8537
|
+
let actions = repsElement(body, 'div', 'aptx-reps-actions');
|
|
8538
|
+
repsButton(actions, 'Automatic', false, function () {
|
|
8539
|
+
// back to whatever the data asks for: the arrangement is dropped,
|
|
8540
|
+
// not merely overridden
|
|
8541
|
+
_state.heatmapManualOrder = null;
|
|
8542
|
+
_state.heatmapColumnOrder = null;
|
|
8543
|
+
shell.dialog.close();
|
|
8544
|
+
syncHeatmapControls();
|
|
8545
|
+
scheduleUpdate(null, 0);
|
|
8546
|
+
});
|
|
8547
|
+
repsButton(actions, 'Cancel', false, function () {
|
|
8548
|
+
shell.dialog.close();
|
|
8549
|
+
});
|
|
8550
|
+
repsButton(actions, 'Apply', true, function () {
|
|
8551
|
+
_state.heatmapManualOrder = working.map(function (c) {
|
|
8552
|
+
return c.ref;
|
|
8553
|
+
});
|
|
8554
|
+
_state.heatmapColumnOrder = 'manual';
|
|
8555
|
+
shell.dialog.close();
|
|
8556
|
+
syncHeatmapControls();
|
|
8557
|
+
scheduleUpdate(null, 0);
|
|
8558
|
+
});
|
|
8559
|
+
shell.dialog.showModal();
|
|
8560
|
+
// showModal() would otherwise land on the close button: focus belongs
|
|
8561
|
+
// on the list, where the arrow keys already move a row
|
|
8562
|
+
if (list.firstChild) {
|
|
8563
|
+
list.firstChild.focus();
|
|
8564
|
+
}
|
|
8565
|
+
}
|
|
8566
|
+
|
|
8567
|
+
function heatmapCbClicked() {
|
|
8568
|
+
_state.showHeatmap = getCheckboxValue(HEATMAP_CB);
|
|
8569
|
+
syncHeatmapControls();
|
|
8570
|
+
scheduleUpdate(null, 0);
|
|
8571
|
+
}
|
|
8572
|
+
|
|
7236
8573
|
// ===================== Collapsed clades =====================
|
|
7237
8574
|
// Collapsing is DISPLAY state on the node (d.collapsed) and nothing
|
|
7238
8575
|
// else: forester never sees it, the writers never see it, and the data
|
|
@@ -7486,7 +8823,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7486
8823
|
// The wedge's reach: the clade's nearest and farthest tips, in the
|
|
7487
8824
|
// layout's x. One edge runs to the nearest tip and the other to the
|
|
7488
8825
|
// farthest, so the shape itself shows how uneven the clade's branch
|
|
7489
|
-
// lengths are
|
|
8826
|
+
// lengths are. Christian chose this over the
|
|
7490
8827
|
// desktop's symmetric triangle on an average base (2026-09-14). A
|
|
7491
8828
|
// phylogram measures the tips; a cladogram, where every leaf sits on
|
|
7492
8829
|
// the tip column, gives the wedge one depth step.
|
|
@@ -7728,6 +9065,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
7728
9065
|
}
|
|
7729
9066
|
if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
|
|
7730
9067
|
s.msa = _state.showMsa === true;
|
|
9068
|
+
if (_state.showMsaLogo === true) {
|
|
9069
|
+
s.msaLogo = true;
|
|
9070
|
+
}
|
|
9071
|
+
}
|
|
9072
|
+
if (heatmapAvailable()) {
|
|
9073
|
+
s.heatmap = _state.showHeatmap === true;
|
|
9074
|
+
s.heatmapOrder = heatmapMode();
|
|
9075
|
+
if (_state.heatmapManualOrder) {
|
|
9076
|
+
s.heatmapManual = _state.heatmapManualOrder.slice();
|
|
9077
|
+
}
|
|
7731
9078
|
}
|
|
7732
9079
|
if (_basicTreeProperties.domainArchitectures === true) {
|
|
7733
9080
|
s.domains = _state.showDomainArchitectures === true;
|
|
@@ -7849,6 +9196,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
7849
9196
|
if (typeof s.msa === 'boolean') {
|
|
7850
9197
|
_state.showMsa = s.msa;
|
|
7851
9198
|
}
|
|
9199
|
+
if (typeof s.msaLogo === 'boolean') {
|
|
9200
|
+
_state.showMsaLogo = s.msaLogo;
|
|
9201
|
+
}
|
|
9202
|
+
if (typeof s.heatmap === 'boolean') {
|
|
9203
|
+
_state.showHeatmap = s.heatmap;
|
|
9204
|
+
}
|
|
9205
|
+
if (Array.isArray(s.heatmapManual) && s.heatmapManual.every(function (r) {
|
|
9206
|
+
return typeof r === 'string';
|
|
9207
|
+
})) {
|
|
9208
|
+
_state.heatmapManualOrder = s.heatmapManual.slice();
|
|
9209
|
+
}
|
|
9210
|
+
if (forester.HEATMAP_ORDER_MODES.indexOf(s.heatmapOrder) >= 0) {
|
|
9211
|
+
_state.heatmapColumnOrder = s.heatmapOrder;
|
|
9212
|
+
}
|
|
7852
9213
|
if (typeof s.domains === 'boolean') {
|
|
7853
9214
|
_state.showDomainArchitectures = s.domains;
|
|
7854
9215
|
}
|
|
@@ -7948,6 +9309,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
7948
9309
|
}
|
|
7949
9310
|
});
|
|
7950
9311
|
setCheckboxValue(MSA_CB, _state.showMsa === true);
|
|
9312
|
+
setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
|
|
9313
|
+
setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
|
|
9314
|
+
syncHeatmapControls();
|
|
7951
9315
|
setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
|
|
7952
9316
|
setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
|
|
7953
9317
|
setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
|
|
@@ -8019,10 +9383,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
8019
9383
|
// Lists are comma-joined, booleans 1 / 0, the two searches flattened
|
|
8020
9384
|
// to a / af / am / a2 and b / bf / bm / b2. Made for a URL hash, where
|
|
8021
9385
|
// none of these characters needs escaping.
|
|
8022
|
-
|
|
9386
|
+
// Every key getViewState writes has to be here, or a view survives
|
|
9387
|
+
// getViewState/applyViewState (what an embedder uses) and is silently
|
|
9388
|
+
// dropped from the URL hash (what "Copy link to this view" produces).
|
|
9389
|
+
// The heat map's four keys and the alignment logo went in exactly that
|
|
9390
|
+
// way, while the README promised a link reproduced the figure.
|
|
9391
|
+
const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine',
|
|
9392
|
+
'heatmapOrder'];
|
|
8023
9393
|
const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
|
|
8024
9394
|
const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
|
|
8025
|
-
const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', '
|
|
9395
|
+
const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow',
|
|
9396
|
+
'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
|
|
9397
|
+
// Lists of strings, comma-joined. A property ref carries ':' but never a
|
|
9398
|
+
// comma, and encodeViewValue leaves both readable in a hash.
|
|
9399
|
+
const VIEW_LIST_KEYS = ['heatmapManual'];
|
|
8026
9400
|
const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
|
|
8027
9401
|
|
|
8028
9402
|
function encodeViewValue(v) {
|
|
@@ -8056,6 +9430,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
8056
9430
|
if (Array.isArray(state.collapsed) && state.collapsed.length > 0) {
|
|
8057
9431
|
put('collapsed', state.collapsed.join(','));
|
|
8058
9432
|
}
|
|
9433
|
+
VIEW_LIST_KEYS.forEach(function (k) {
|
|
9434
|
+
let v = state[k];
|
|
9435
|
+
if (Array.isArray(v) && v.length > 0) {
|
|
9436
|
+
parts.push(k + '=' + v.map(encodeViewValue).join(','));
|
|
9437
|
+
}
|
|
9438
|
+
});
|
|
8059
9439
|
VIEW_BOOL_KEYS.forEach(function (k) {
|
|
8060
9440
|
if (typeof state[k] === 'boolean') {
|
|
8061
9441
|
put(k, state[k] ? 1 : 0);
|
|
@@ -8122,6 +9502,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
8122
9502
|
return parseInt(x, 10);
|
|
8123
9503
|
});
|
|
8124
9504
|
}
|
|
9505
|
+
VIEW_LIST_KEYS.forEach(function (k) {
|
|
9506
|
+
if (raw[k] !== undefined) {
|
|
9507
|
+
let list = raw[k].split(',').filter(function (x) {
|
|
9508
|
+
return x.length > 0;
|
|
9509
|
+
});
|
|
9510
|
+
if (list.length > 0) {
|
|
9511
|
+
state[k] = list;
|
|
9512
|
+
}
|
|
9513
|
+
}
|
|
9514
|
+
});
|
|
8125
9515
|
VIEW_SEARCH_KEYS.forEach(function (sk) {
|
|
8126
9516
|
let p = sk[1];
|
|
8127
9517
|
if (raw[p]) {
|
|
@@ -8439,8 +9829,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
8439
9829
|
}
|
|
8440
9830
|
|
|
8441
9831
|
// The room the longest tip label takes: the same estimate the layout is
|
|
8442
|
-
// sized against (there are no font metrics in the model).
|
|
8443
|
-
|
|
9832
|
+
// sized against (there are no font metrics in the model). Used by the
|
|
9833
|
+
// domain tracks and by the heat map's rings, which both start past it --
|
|
9834
|
+
// hence the name, which used to say "domain".
|
|
9835
|
+
function tipLabelSpace() {
|
|
8444
9836
|
return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
|
|
8445
9837
|
}
|
|
8446
9838
|
|
|
@@ -8604,7 +9996,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
8604
9996
|
// Where each tip's architecture goes: its start along the track, the
|
|
8605
9997
|
// top and height of its boxes, and in the radial layouts the
|
|
8606
9998
|
// transform that turns (and for unrooted moves) it onto its spoke.
|
|
8607
|
-
let labelSpace =
|
|
9999
|
+
let labelSpace = tipLabelSpace();
|
|
8608
10000
|
let rows = [];
|
|
8609
10001
|
if (_state.circularDisplay) {
|
|
8610
10002
|
// every bar starts on one ring past the labels and rides its
|
|
@@ -8910,6 +10302,60 @@ function (root, d3, forester, phyloXml) {
|
|
|
8910
10302
|
setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
|
|
8911
10303
|
}
|
|
8912
10304
|
|
|
10305
|
+
// The control follows the toggle: hidden without it, and hidden in the
|
|
10306
|
+
// radial layouts, where no matrix is drawn to order.
|
|
10307
|
+
function syncHeatmapControls() {
|
|
10308
|
+
let fs = byId(HEATMAP_CONTROLS);
|
|
10309
|
+
if (!fs) {
|
|
10310
|
+
return;
|
|
10311
|
+
}
|
|
10312
|
+
fs.style.display = heatmapShown() ? '' : 'none';
|
|
10313
|
+
if (!heatmapAvailable()) {
|
|
10314
|
+
return;
|
|
10315
|
+
}
|
|
10316
|
+
setValue(HEATMAP_ORDER_SELECT, heatmapMode());
|
|
10317
|
+
let reorder = byId(HEATMAP_REORDER_BUTTON);
|
|
10318
|
+
if (reorder) {
|
|
10319
|
+
// two columns can only be in one order up to a flip, which the
|
|
10320
|
+
// arrows would make look like a choice
|
|
10321
|
+
reorder.disabled = !heatmapShown() || heatmapModel().refs.length < 2;
|
|
10322
|
+
}
|
|
10323
|
+
// phyloXML gives every node its own property list, so tips CAN list the
|
|
10324
|
+
// same columns in different orders -- and then "as in the input" is a
|
|
10325
|
+
// consensus rather than a transcription. Say which, for THIS tree:
|
|
10326
|
+
// nobody can tell by looking at the matrix.
|
|
10327
|
+
let sel = byId(HEATMAP_ORDER_SELECT);
|
|
10328
|
+
let opt = sel ? sel.querySelector('option[value="document"]') : null;
|
|
10329
|
+
if (opt) {
|
|
10330
|
+
let a = heatmapInputAgreement();
|
|
10331
|
+
opt.title = 'the order the input lists the columns in'
|
|
10332
|
+
+ ((!a || a.tips < 1) ? ''
|
|
10333
|
+
: (a.conflicting === 0)
|
|
10334
|
+
? ' \u2014 every tip here lists them the same way, so this is exactly that order'
|
|
10335
|
+
: ' \u2014 ' + a.conflicting + ' of ' + a.tips
|
|
10336
|
+
+ ' tips here list them in a different order, so this is the order most of them agree on');
|
|
10337
|
+
}
|
|
10338
|
+
}
|
|
10339
|
+
|
|
10340
|
+
function heatmapOrderChanged() {
|
|
10341
|
+
let v = getValue(HEATMAP_ORDER_SELECT);
|
|
10342
|
+
if (forester.HEATMAP_ORDER_MODES.indexOf(v) < 0 || v === heatmapMode()) {
|
|
10343
|
+
return;
|
|
10344
|
+
}
|
|
10345
|
+
if (v === 'manual' && !_state.heatmapManualOrder) {
|
|
10346
|
+
// Manual with nothing arranged yet freezes what is on screen. The
|
|
10347
|
+
// alternative -- an empty list, which means "no opinion" -- would
|
|
10348
|
+
// silently re-derive the order the reader just asked to keep.
|
|
10349
|
+
let o = heatmapOrdered();
|
|
10350
|
+
_state.heatmapManualOrder = o ? o.columns.map(function (c) {
|
|
10351
|
+
return c.ref;
|
|
10352
|
+
}) : null;
|
|
10353
|
+
}
|
|
10354
|
+
_state.heatmapColumnOrder = v;
|
|
10355
|
+
_heatmapColOffset = 0; // a different order: the old window means nothing
|
|
10356
|
+
scheduleUpdate(null, 0);
|
|
10357
|
+
}
|
|
10358
|
+
|
|
8913
10359
|
function domainsCbClicked() {
|
|
8914
10360
|
_state.showDomainArchitectures = getCheckboxValue(DOMAINS_CB);
|
|
8915
10361
|
if (_state.showDomainArchitectures && radialDisplay()) {
|
|
@@ -9046,7 +10492,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
9046
10492
|
// own bottom rows (conservation, consensus, ruler) must end above it
|
|
9047
10493
|
// or the bar covers them -- the old bare slider did exactly that.
|
|
9048
10494
|
// The scale bar sits in the same band, under the tree's last row.
|
|
9049
|
-
return Math.max(msaShown() ?
|
|
10495
|
+
return Math.max(msaShown() ? msaBottomReserve() + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
|
|
10496
|
+
heatmapBottomReserve(),
|
|
9050
10497
|
scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
|
|
9051
10498
|
}
|
|
9052
10499
|
|
|
@@ -9430,7 +10877,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
9430
10877
|
return;
|
|
9431
10878
|
}
|
|
9432
10879
|
let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
|
|
9433
|
-
let outer = maxRad + labelSpace + domainRadialExtent();
|
|
10880
|
+
let outer = maxRad + labelSpace + domainRadialExtent() + heatmapRadialExtent();
|
|
9434
10881
|
let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
|
|
9435
10882
|
let scale = 0.9 * (Math.min(W, H) / (2 * outer));
|
|
9436
10883
|
if (!isFinite(scale) || scale <= 0) {
|
|
@@ -9947,6 +11394,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
9947
11394
|
_radialRotation = 0;
|
|
9948
11395
|
_radialLabelsHorizontal = false;
|
|
9949
11396
|
_msaColOffset = 0;
|
|
11397
|
+
_heatmapColOffset = 0;
|
|
9950
11398
|
syncZoomRowButtons();
|
|
9951
11399
|
refreshVisualizations(false);
|
|
9952
11400
|
// Esc resets to the launch state -- the auto-applied colour, when its
|
|
@@ -10513,6 +11961,17 @@ function (root, d3, forester, phyloXml) {
|
|
|
10513
11961
|
if (msaCb) {
|
|
10514
11962
|
msaCb.disabled = radialDisplay();
|
|
10515
11963
|
}
|
|
11964
|
+
let msaLogoCb = byId(MSA_LOGO_CB);
|
|
11965
|
+
if (msaLogoCb) {
|
|
11966
|
+
// nothing to summarize while the alignment itself is not drawn
|
|
11967
|
+
msaLogoCb.disabled = radialDisplay() || _state.showMsa !== true;
|
|
11968
|
+
}
|
|
11969
|
+
let heatCb = byId(HEATMAP_CB);
|
|
11970
|
+
if (heatCb) {
|
|
11971
|
+
// circular draws the columns as rings; only unrooted cannot
|
|
11972
|
+
heatCb.disabled = _state.unrootedDisplay === true;
|
|
11973
|
+
}
|
|
11974
|
+
syncHeatmapControls();
|
|
10516
11975
|
let timeCb = byId(TIME_AXIS_CB);
|
|
10517
11976
|
if (timeCb) {
|
|
10518
11977
|
timeCb.disabled = radialDisplay();
|
|
@@ -11619,6 +13078,30 @@ function (root, d3, forester, phyloXml) {
|
|
|
11619
13078
|
+ '.aptx-reps-error:empty { display:none; }'
|
|
11620
13079
|
+ '.aptx-reps-line { padding:1px 0; }'
|
|
11621
13080
|
+ '.aptx-reps-question { margin-top:9px; font-weight:600; }'
|
|
13081
|
+
+ '.aptx-reorder { margin-top:8px; max-height:300px; overflow:auto; border:1px solid var(--p-line);'
|
|
13082
|
+
+ ' border-radius:7px; padding:3px; background:var(--p-surface2); touch-action:none; }'
|
|
13083
|
+
// the panel's own scrollbar treatment: a native one comes out bright
|
|
13084
|
+
// white inside a dark dialog
|
|
13085
|
+
+ '.aptx-reorder::-webkit-scrollbar { width:9px; }'
|
|
13086
|
+
+ '.aptx-reorder::-webkit-scrollbar-thumb { background:var(--p-line-strong); border-radius:9px;'
|
|
13087
|
+
+ ' border:2px solid var(--p-surface2); }'
|
|
13088
|
+
+ '.aptx-reorder::-webkit-scrollbar-track { background:transparent; }'
|
|
13089
|
+
+ '.aptx-reorder-row { display:flex; align-items:center; gap:7px; padding:3px 5px; border-radius:5px;'
|
|
13090
|
+
+ ' cursor:grab; user-select:none; border:1px solid transparent; }'
|
|
13091
|
+
+ '.aptx-reorder-row:hover { background:var(--p-accent-weak); }'
|
|
13092
|
+
+ '.aptx-reorder-row:focus-visible { outline:none; border-color:var(--p-accent);'
|
|
13093
|
+
+ ' box-shadow:0 0 0 2px var(--p-accent-weak); }'
|
|
13094
|
+
+ '.aptx-reorder-dragging { cursor:grabbing; background:var(--p-accent-weak);'
|
|
13095
|
+
+ ' border-color:var(--p-accent); opacity:0.9; }'
|
|
13096
|
+
+ '.aptx-reorder-grip { flex:none; color:var(--p-faint); font-size:12px; line-height:1; }'
|
|
13097
|
+
+ '.aptx-reorder-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;'
|
|
13098
|
+
+ ' white-space:nowrap; font-size:11px; color:var(--p-ink); }'
|
|
13099
|
+
+ '.aptx-reorder-move { flex:none; width:20px; height:20px; padding:0; line-height:1; font:inherit;'
|
|
13100
|
+
+ ' font-size:11px; color:var(--p-muted); background:transparent; border:1px solid transparent;'
|
|
13101
|
+
+ ' border-radius:5px; cursor:pointer; }'
|
|
13102
|
+
+ '.aptx-reorder-move:hover:not(:disabled) { background:var(--p-surface); color:var(--p-accent-ink);'
|
|
13103
|
+
+ ' border-color:var(--p-accent); }'
|
|
13104
|
+
+ '.aptx-reorder-move:disabled { opacity:0.3; cursor:default; }'
|
|
11622
13105
|
+ '.aptx-reps-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }'
|
|
11623
13106
|
+ '.aptx-reps-button { font:inherit; font-size:11px; height:26px; padding:0 12px; color:var(--p-ink);'
|
|
11624
13107
|
+ ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; cursor:pointer;'
|
|
@@ -11782,6 +13265,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
11782
13265
|
+ '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
|
|
11783
13266
|
+ '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
|
|
11784
13267
|
+ '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
|
|
13268
|
+
// ... except one that has a WORD on it: the rule above sizes the
|
|
13269
|
+
// domain track's tiny plus and minus, and it clipped this to "Reorc"
|
|
13270
|
+
+ '.aptx-panel .aptx-domrow input[type=button].aptx-widebtn { width:auto; flex:1 1 auto;'
|
|
13271
|
+
+ ' min-width:0; padding:0 8px; }'
|
|
11785
13272
|
+ '.aptx-panel .aptx-domrow select { flex:1 1 auto; min-width:0; }'
|
|
11786
13273
|
+ '.aptx-panel .aptx-domreadout { flex:1 1 auto; text-align:center; font-size:12px; font-variant-numeric:tabular-nums; color:var(--p-ink); }'
|
|
11787
13274
|
+ '.aptx-panel .aptx-glyph { height:14px; width:auto; display:block; overflow:visible; }'
|
|
@@ -12664,6 +14151,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
12664
14151
|
|
|
12665
14152
|
c0.insertAdjacentHTML('beforeend',makeDomainControls());
|
|
12666
14153
|
|
|
14154
|
+
c0.insertAdjacentHTML('beforeend',makeHeatmapControls());
|
|
14155
|
+
|
|
12667
14156
|
c0.insertAdjacentHTML('beforeend',makeZoomControl());
|
|
12668
14157
|
|
|
12669
14158
|
|
|
@@ -12777,6 +14266,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
12777
14266
|
|
|
12778
14267
|
on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
|
|
12779
14268
|
on(MSA_CB, 'click', msaCbClicked);
|
|
14269
|
+
on(MSA_LOGO_CB, 'click', msaLogoCbClicked);
|
|
14270
|
+
on(HEATMAP_CB, 'click', heatmapCbClicked);
|
|
14271
|
+
on(HEATMAP_ORDER_SELECT, 'change', heatmapOrderChanged);
|
|
14272
|
+
on(HEATMAP_REORDER_BUTTON, 'click', showHeatmapReorderDialog);
|
|
12780
14273
|
on(DOMAINS_CB, 'click', domainsCbClicked);
|
|
12781
14274
|
on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
|
|
12782
14275
|
on(TREE_PREV_BUTTON, 'click', function () {
|
|
@@ -13318,10 +14811,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
13318
14811
|
// labels -- they live here
|
|
13319
14812
|
if (_basicTreeProperties.alignedMolSeqs && _basicTreeProperties.maxMolSeqLength > 0) {
|
|
13320
14813
|
opts.push(makeCheckboxItem('Alignment', MSA_CB, 'to show/hide the sequence alignment beside the tree (rectangular layout only)'));
|
|
14814
|
+
opts.push(makeCheckboxItem('Sequence Logo', MSA_LOGO_CB, 'to summarize the alignment under it as a sequence logo: each column a stack of letters, as tall as that column\'s information content and shared out by residue frequency. Over the tips currently on screen, so entering a clade summarizes that clade'));
|
|
13321
14815
|
}
|
|
13322
14816
|
if (_basicTreeProperties.domainArchitectures) {
|
|
13323
14817
|
opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
|
|
13324
14818
|
}
|
|
14819
|
+
if (heatmapAvailable()) {
|
|
14820
|
+
opts.push(makeCheckboxItem('Heat Map', HEATMAP_CB, 'to show/hide a cell per tip and numeric field beside the tree, every column on one color scale; concentric rings in the circular layout (not in the unrooted one, where a column has no ring to be)'));
|
|
14821
|
+
}
|
|
13325
14822
|
if (_timeInfo && _timeInfo.type) {
|
|
13326
14823
|
opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
|
|
13327
14824
|
+ (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
|
|
@@ -13430,6 +14927,45 @@ function (root, d3, forester, phyloXml) {
|
|
|
13430
14927
|
return h;
|
|
13431
14928
|
}
|
|
13432
14929
|
|
|
14930
|
+
// Shown only while the Heat Map toggle is on (syncHeatmapControls).
|
|
14931
|
+
// The desktop's View > Order Matrix Columns, minus its Manual mode --
|
|
14932
|
+
// that one means "the order you dragged the rows into", and there is
|
|
14933
|
+
// no drag-to-reorder dialog here to drag them in.
|
|
14934
|
+
function makeHeatmapControls() {
|
|
14935
|
+
let h = '<fieldset id="' + HEATMAP_CONTROLS + '" style="display:none">';
|
|
14936
|
+
h = h.concat('<legend>Heat Map</legend>');
|
|
14937
|
+
h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + HEATMAP_ORDER_SELECT
|
|
14938
|
+
+ '">Order columns</label>');
|
|
14939
|
+
h = h.concat('<select name="' + HEATMAP_ORDER_SELECT + '" id="' + HEATMAP_ORDER_SELECT
|
|
14940
|
+
+ '" title="how the matrix columns are ordered; a clustered order also draws the clustering above the matrix">');
|
|
14941
|
+
// "input", not "file": the columns can come from a pasted tree or
|
|
14942
|
+
// a joined table as easily as from a file, and a reader looking at
|
|
14943
|
+
// a tree they dropped in would fairly ask "what file?". The title
|
|
14944
|
+
// is filled in per tree by syncHeatmapControls, because whether
|
|
14945
|
+
// this really IS the input's order depends on the input.
|
|
14946
|
+
h = h.concat('<option value="document">As in the input</option>');
|
|
14947
|
+
h = h.concat('<option value="clustered" title="columns whose values agree across the tips sit together:'
|
|
14948
|
+
+ ' complete-linkage clustering on Euclidean distance, the default of R\'s heat maps. A tip missing'
|
|
14949
|
+
+ ' either value is left out of that pair, never read as 0.">Clustered (co-occurrence)</option>');
|
|
14950
|
+
h = h.concat('<option value="clustered-presence" title="columns found in the same tips sit together: a tip'
|
|
14951
|
+
+ ' where BOTH columns are 0 is left out of that pair, so two rare genes no longer look alike merely'
|
|
14952
|
+
+ ' for being rare. Complete-linkage clustering on the Bray-Curtis dissimilarity. For values that are'
|
|
14953
|
+
+ ' 0 or more.">Clustered (ignoring shared absence)</option>');
|
|
14954
|
+
h = h.concat('<option value="alphabetical" title="by column name, ignoring case">Alphabetical</option>');
|
|
14955
|
+
h = h.concat('<option value="frequency" title="highest mean value first, over the tips that have a value'
|
|
14956
|
+
+ ' -- on 0/1 data, the fraction of tips carrying it">Frequency</option>');
|
|
14957
|
+
h = h.concat('<option value="manual" title="your own order, set with Reorder columns below. Nothing'
|
|
14958
|
+
+ ' re-sorts it.">Manual</option>');
|
|
14959
|
+
h = h.concat('</select></div>');
|
|
14960
|
+
h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel"></span>');
|
|
14961
|
+
h = h.concat('<input type="button" class="aptx-widebtn" value="Reorder columns…" name="'
|
|
14962
|
+
+ HEATMAP_REORDER_BUTTON + '" id="' + HEATMAP_REORDER_BUTTON
|
|
14963
|
+
+ '" title="drag the columns into the order you want; doing so sets Manual">');
|
|
14964
|
+
h = h.concat('</div>');
|
|
14965
|
+
h = h.concat('</fieldset>');
|
|
14966
|
+
return h;
|
|
14967
|
+
}
|
|
14968
|
+
|
|
13433
14969
|
function makeSliders() {
|
|
13434
14970
|
let h = "";
|
|
13435
14971
|
h = h.concat('<fieldset>');
|
|
@@ -13610,7 +15146,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
13610
15146
|
setCheckboxValue(VIS_CB, _state.showVisualizations);
|
|
13611
15147
|
setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
|
|
13612
15148
|
setCheckboxValue(MSA_CB, _state.showMsa);
|
|
15149
|
+
setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
|
|
15150
|
+
setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
|
|
13613
15151
|
syncDomainControls();
|
|
15152
|
+
syncHeatmapControls();
|
|
13614
15153
|
setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
|
|
13615
15154
|
setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
|
|
13616
15155
|
setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);
|