archaeopteryx 3.9.0 → 3.11.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 +280 -4
- package/archaeopteryx.d.ts +17 -0
- package/archaeopteryx.js +1474 -30
- package/forester.js +1017 -15
- 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.11.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.11.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,11 @@ 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 HEATMAP_CB = 'heatmap_cb';
|
|
367
|
+
const HEATMAP_CONTROLS = 'heatmap_controls';
|
|
368
|
+
const HEATMAP_ORDER_SELECT = 'heatmap_order';
|
|
369
|
+
const HEATMAP_REORDER_BUTTON = 'heatmap_reorder';
|
|
370
|
+
const HEATMAP_REORDER_DIALOG = 'heatmap_reorder_dialog';
|
|
366
371
|
const DOMAINS_CB = 'domains_cb';
|
|
367
372
|
const DOMAIN_CONTROLS = 'domain_controls';
|
|
368
373
|
const DOMAIN_WIDTH_DEC = 'domain_width_dec';
|
|
@@ -404,6 +409,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
404
409
|
const PHYLOGRAM_ALIGNED_BUTTON = 'phya_b';
|
|
405
410
|
const PHYLOGRAM_BUTTON = 'phy_b';
|
|
406
411
|
const PHYLOGRAM_CLADOGRAM_CONTROLGROUP = 'phy_cla_g';
|
|
412
|
+
const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
|
|
413
|
+
const BRANCH_SCALE_TIME_BUTTON = 'branch_scale_time_b';
|
|
414
|
+
const BRANCH_SCALE_DIV_BUTTON = 'branch_scale_div_b';
|
|
407
415
|
const ABOUT_DIALOG = 'aptx_about';
|
|
408
416
|
const PROG_NAME = 'progname';
|
|
409
417
|
const PROGNAMELINK = 'prognamelink';
|
|
@@ -569,14 +577,51 @@ function (root, d3, forester, phyloXml) {
|
|
|
569
577
|
const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
|
|
570
578
|
let _domain = null; // {width, palette, next, legendFrac}, set per launch
|
|
571
579
|
let _domainReserve = 0; // horizontal px reserved for the tracks, set with _w
|
|
580
|
+
// ------ heat map (the desktop's MATRIX annotation columns) ------
|
|
581
|
+
const HEATMAP_TRACK_GAP = 8; // px between what is left of the tree and the matrix
|
|
582
|
+
const HEATMAP_PREF_COL_W = 14; // a column never grows past this
|
|
583
|
+
const HEATMAP_MIN_COL_W = 3; // ... nor shrinks below it: past that the matrix is windowed
|
|
584
|
+
const HEATMAP_MIN_BAND_PX = 60; // the window never shrinks below this
|
|
585
|
+
const HEATMAP_MAX_VIEWPORT_FRACTION = 0.45; // nor grows past this share of the display
|
|
586
|
+
const HEATMAP_MIN_TREE_PX = 220; // the tree itself never shrinks below this
|
|
587
|
+
const HEATMAP_MIN_COLUMNS = 2; // one column is a stripe, not a matrix: the toggle is not offered
|
|
588
|
+
const HEATMAP_LABEL_MAX_PX = 96; // a turned column label is clipped to this
|
|
589
|
+
const HEATMAP_LABEL_MIN_COL_W = 7; // ... and dropped where the columns are thinner than this
|
|
590
|
+
const HEATMAP_SCALE_BAR_W = 148; // the gradient in the legend
|
|
591
|
+
const HEATMAP_SCALE_BAR_H = 8;
|
|
592
|
+
const HEATMAP_SCALE_RESERVE = 30; // vertical room for that legend and its numbers
|
|
593
|
+
const HEATMAP_HEAD_GAP = 5; // between the last row and the turned labels
|
|
594
|
+
const HEATMAP_NOT_ASSESSED = 'not assessed'; // what a blank cell says -- it does NOT say zero
|
|
595
|
+
const HEATMAP_RADIAL_GAP = 7; // px from the label ring to the first column ring
|
|
596
|
+
const HEATMAP_RADIAL_END_GAP = 8; // ... and kept free past the last one
|
|
597
|
+
const HEATMAP_RADIAL_MAX_FRACTION = 1.2; // the ring stack, against the tree's own radius
|
|
598
|
+
const HEATMAP_RADIAL_MIN_RING = 1; // a ring never thinner than this
|
|
599
|
+
const HEATMAP_RADIAL_MIN_NAMED_RING = 7; // ... nor named when it is thinner than this
|
|
600
|
+
// Neighbouring sectors OVERLAP by this much rather than meeting exactly.
|
|
601
|
+
// Two antialiased edges that share a coordinate each cover about half the
|
|
602
|
+
// boundary pixel, and a half over a half does not composite back to a
|
|
603
|
+
// solid edge -- it leaves the background showing through as a dotted
|
|
604
|
+
// line all round the rings. Measured on the sparse demo: 6987 pixels
|
|
605
|
+
// within 1.2px of a ring boundary came out background-coloured.
|
|
606
|
+
const HEATMAP_RING_BLEED = 1.2;
|
|
607
|
+
const HEATMAP_BLANK = 'blank'; // the run-merger's stand-in for a cell nobody filled in
|
|
608
|
+
const HEATMAP_DENDRO_GAP = 5; // px between the dendrogram's leaves and the first row
|
|
609
|
+
const HEATMAP_DENDRO_MIN_BAND = 24; // the band it is drawn in, from the label font
|
|
610
|
+
const HEATMAP_DENDRO_MAX_BAND = 72;
|
|
611
|
+
let _heatmapReserve = 0; // horizontal px reserved for the matrix, set with _w
|
|
612
|
+
let _heatmapModel = null; // forester.heatmapColumns of the WHOLE tree, cached per launch
|
|
613
|
+
let _heatmapColor = null; // the one scale every column is painted on
|
|
614
|
+
let _heatmapColOffset = 0; // first shown column, while the matrix is windowed
|
|
615
|
+
let _heatmapGeom = null; // the last draw's geometry, for the hover readout
|
|
572
616
|
// ------ time axis (the desktop's geologic / calendar overlays) ------
|
|
573
617
|
const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
|
|
574
618
|
const TIME_CAL_RESERVE = 26; // the calendar year ruler
|
|
575
619
|
const TIME_BAND_ROW_H = 13;
|
|
576
|
-
const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue,
|
|
620
|
+
const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, the usual HPD tint
|
|
577
621
|
const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
|
|
578
622
|
let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
|
|
579
623
|
let _timeTree = false; // forester.isTimeTree: never re-rooted
|
|
624
|
+
let _branchScaleAvailable = false; // the tree states time AND divergence, so the switch is offered
|
|
580
625
|
let _clusterH = 0; // the cluster layout's vertical extent, set per render
|
|
581
626
|
let _docListenersBound = false; // page-level key/wheel handlers bind once, not per launch
|
|
582
627
|
let _docListeners = []; // ...and destroy() can take every one of them down again
|
|
@@ -1098,6 +1143,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1098
1143
|
// view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
|
|
1099
1144
|
// no by precisely 254px and the overview appeared after every Fit.
|
|
1100
1145
|
maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
|
|
1146
|
+
minY -= heatmapTopReserve(); // the dendrogram band stands above the first row
|
|
1101
1147
|
if (scaleBarShown()) {
|
|
1102
1148
|
maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
|
|
1103
1149
|
}
|
|
@@ -1422,6 +1468,30 @@ function (root, d3, forester, phyloXml) {
|
|
|
1422
1468
|
.style('opacity', 0.95); // only the fade -- the rest is set at creation
|
|
1423
1469
|
}
|
|
1424
1470
|
|
|
1471
|
+
// Puts a hover readout beside the pointer -- to its right and below --
|
|
1472
|
+
// flipping to the other side when that would leave the window. The readout's
|
|
1473
|
+
// OWN measured size decides, because .aptx-tip is width:max-content and so
|
|
1474
|
+
// is as wide as whatever it happens to be saying. Guessing instead, with a
|
|
1475
|
+
// fixed shift, left a 181px readout floating a hundred pixels clear of the
|
|
1476
|
+
// cell it described; a matrix that hugs the right edge flips on every cell,
|
|
1477
|
+
// so the guess was what the reader saw every time.
|
|
1478
|
+
const HOVER_READOUT_GAP = 14;
|
|
1479
|
+
|
|
1480
|
+
function placeHoverReadout(el, event) {
|
|
1481
|
+
let w = el.offsetWidth;
|
|
1482
|
+
let h = el.offsetHeight;
|
|
1483
|
+
let left = event.pageX + HOVER_READOUT_GAP;
|
|
1484
|
+
if ((left + w) > (window.scrollX + window.innerWidth)) {
|
|
1485
|
+
left = Math.max(window.scrollX + 2, event.pageX - HOVER_READOUT_GAP - w);
|
|
1486
|
+
}
|
|
1487
|
+
let top = event.pageY + HOVER_READOUT_GAP;
|
|
1488
|
+
if ((top + h) > (window.scrollY + window.innerHeight)) {
|
|
1489
|
+
top = Math.max(window.scrollY + 2, event.pageY - HOVER_READOUT_GAP - h);
|
|
1490
|
+
}
|
|
1491
|
+
el.style.left = left + 'px';
|
|
1492
|
+
el.style.top = top + 'px';
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1425
1495
|
function mousemove(event, d) {
|
|
1426
1496
|
|
|
1427
1497
|
let mo_text = nodeDataText(d);
|
|
@@ -1432,10 +1502,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
1432
1502
|
if (_panelTheme) {
|
|
1433
1503
|
tip.classList.add('aptx-' + _panelTheme);
|
|
1434
1504
|
}
|
|
1435
|
-
_node_mouseover_div
|
|
1436
|
-
|
|
1437
|
-
.style('left', (event.pageX + 14) + 'px')
|
|
1438
|
-
.style('top', (event.pageY + 14) + 'px');
|
|
1505
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(mo_text)));
|
|
1506
|
+
placeHoverReadout(tip, event); // this one used to run off the edge instead
|
|
1439
1507
|
}
|
|
1440
1508
|
|
|
1441
1509
|
// ----------------------------
|
|
@@ -2727,6 +2795,22 @@ function (root, d3, forester, phyloXml) {
|
|
|
2727
2795
|
_msaReserve = 0;
|
|
2728
2796
|
// and the domain tracks reserve their column past the labels
|
|
2729
2797
|
_domainReserve = domainReserve();
|
|
2798
|
+
// The heat map is budgeted FIRST of the two right-hand tracks: it
|
|
2799
|
+
// wants a fixed, finite width (its columns at their preferred size
|
|
2800
|
+
// and no more), while the alignment's band is a WINDOW that scrolls
|
|
2801
|
+
// and so can give way without losing anything. Budgeting the
|
|
2802
|
+
// alignment first left an 18-column matrix a 60px sliver.
|
|
2803
|
+
_heatmapReserve = 0;
|
|
2804
|
+
if (heatmapShown() && !radialDisplay()) {
|
|
2805
|
+
let cols = heatmapModel().refs.length;
|
|
2806
|
+
let vp0 = svgSize();
|
|
2807
|
+
let vw0 = Math.min(_displayWidth, (vp0 && vp0.w) ? vp0.w : _displayWidth);
|
|
2808
|
+
let hband = Math.max(HEATMAP_MIN_BAND_PX, Math.round(vw0 * HEATMAP_MAX_VIEWPORT_FRACTION));
|
|
2809
|
+
let hmax = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
|
|
2810
|
+
- HEATMAP_TRACK_GAP - HEATMAP_MIN_TREE_PX;
|
|
2811
|
+
hband = Math.max(HEATMAP_MIN_BAND_PX, Math.min(hband, hmax));
|
|
2812
|
+
_heatmapReserve = HEATMAP_TRACK_GAP + Math.min(cols * HEATMAP_PREF_COL_W, hband);
|
|
2813
|
+
}
|
|
2730
2814
|
if (msaShown()) {
|
|
2731
2815
|
let fullPx = _basicTreeProperties.maxMolSeqLength * MSA_COL_WIDTH;
|
|
2732
2816
|
// budgeted from the VIEWPORT: sizing it from the zoomed layout
|
|
@@ -2737,11 +2821,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
2737
2821
|
let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
|
|
2738
2822
|
// a wide alignment must not squeeze the tree itself away: the
|
|
2739
2823
|
// band yields until the tree keeps its minimum share
|
|
2740
|
-
let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
|
|
2824
|
+
let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
|
|
2825
|
+
- _heatmapReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
|
|
2741
2826
|
band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
|
|
2742
2827
|
_msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
|
|
2743
2828
|
}
|
|
2744
|
-
_w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve;
|
|
2829
|
+
_w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve - _heatmapReserve;
|
|
2745
2830
|
if (_w < 1) {
|
|
2746
2831
|
_w = 1;
|
|
2747
2832
|
}
|
|
@@ -2753,7 +2838,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
2753
2838
|
|
|
2754
2839
|
_clusterH = Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
|
|
2755
2840
|
- bottomOverlayReserve());
|
|
2756
|
-
|
|
2841
|
+
let _topReserve = topOverlayReserve();
|
|
2842
|
+
_treeFn = _treeFn.size([Math.max(20, _clusterH - _topReserve), _w]);
|
|
2757
2843
|
|
|
2758
2844
|
// A collapsed clade is a leaf that asks for more than one row: the
|
|
2759
2845
|
// separation between neighbouring leaves is the mean of their
|
|
@@ -2776,7 +2862,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
2776
2862
|
});
|
|
2777
2863
|
_treeFn(hierarchy);
|
|
2778
2864
|
hierarchy.each(function (hn) {
|
|
2779
|
-
hn.data.x = hn.x;
|
|
2865
|
+
hn.data.x = hn.x + _topReserve;
|
|
2780
2866
|
hn.data.y = hn.y;
|
|
2781
2867
|
hn.data.depth = hn.depth;
|
|
2782
2868
|
});
|
|
@@ -3266,7 +3352,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3266
3352
|
}
|
|
3267
3353
|
|
|
3268
3354
|
// circular: a thin dashed connector from each external node out to the
|
|
3269
|
-
// common label ring
|
|
3355
|
+
// common label ring, so every label lines up at the same radius.
|
|
3270
3356
|
_svgGroup.selectAll('g.aptx-radial-conn').remove();
|
|
3271
3357
|
if (_state.circularDisplay) {
|
|
3272
3358
|
// tips while their labels show; a collapsed clade always (its label
|
|
@@ -3312,6 +3398,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3312
3398
|
drawDomainArchitectures();
|
|
3313
3399
|
drawDomainLegend();
|
|
3314
3400
|
drawMsaTrack();
|
|
3401
|
+
drawHeatmapTrack();
|
|
3315
3402
|
drawTimeOverlays();
|
|
3316
3403
|
rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
|
|
3317
3404
|
updateSearchHitNavigation();
|
|
@@ -4840,6 +4927,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
4840
4927
|
const STATE_KEYS = [
|
|
4841
4928
|
'layout',
|
|
4842
4929
|
'showMsa',
|
|
4930
|
+
'showHeatmap',
|
|
4931
|
+
'heatmapColumnOrder',
|
|
4932
|
+
'heatmapManualOrder',
|
|
4843
4933
|
'showDomainArchitectures',
|
|
4844
4934
|
'domainLabels',
|
|
4845
4935
|
'domainGlow',
|
|
@@ -5072,6 +5162,39 @@ function (root, d3, forester, phyloXml) {
|
|
|
5072
5162
|
_state.showMsa = _basicTreeProperties.alignedMolSeqs === true
|
|
5073
5163
|
&& _basicTreeProperties.maxMolSeqLength > 0;
|
|
5074
5164
|
}
|
|
5165
|
+
// The heat map is offered whenever the tree carries two or more
|
|
5166
|
+
// numeric per-tip fields, which is common enough that turning it on
|
|
5167
|
+
// by itself would be an opinion about the tree rather than a service
|
|
5168
|
+
// -- the alignment track, by contrast, is on when there IS one.
|
|
5169
|
+
if (_state.showHeatmap === undefined) {
|
|
5170
|
+
_state.showHeatmap = false;
|
|
5171
|
+
}
|
|
5172
|
+
// The columns' order. Left unset it is resolved from the data on first
|
|
5173
|
+
// use (heatmapMode / forester.heatmapDefaultOrder): a clustered order,
|
|
5174
|
+
// because reading block structure is what a heat map beside a tree is
|
|
5175
|
+
// FOR, with the distance chosen by what the values are. An explicit
|
|
5176
|
+
// value always wins and is never re-derived -- including 'document',
|
|
5177
|
+
// which keeps the order the producer wrote the fields in.
|
|
5178
|
+
if (_state.heatmapColumnOrder === undefined) {
|
|
5179
|
+
_state.heatmapColumnOrder = null;
|
|
5180
|
+
} else if (_state.heatmapColumnOrder !== null
|
|
5181
|
+
&& forester.HEATMAP_ORDER_MODES.indexOf(_state.heatmapColumnOrder) < 0) {
|
|
5182
|
+
throw new Error(ERROR + '"heatmapColumnOrder" must be one of '
|
|
5183
|
+
+ forester.HEATMAP_ORDER_MODES.join(', '));
|
|
5184
|
+
}
|
|
5185
|
+
// The reader's own arrangement, as a list of refs. It is only read
|
|
5186
|
+
// while the order is 'manual', and a ref it names that the tree has
|
|
5187
|
+
// not got is ignored rather than fatal -- an order outliving one of
|
|
5188
|
+
// its columns is ordinary (see forester.heatmapManualOrder).
|
|
5189
|
+
if (_state.heatmapManualOrder === undefined) {
|
|
5190
|
+
_state.heatmapManualOrder = null;
|
|
5191
|
+
} else if (_state.heatmapManualOrder !== null
|
|
5192
|
+
&& !(Array.isArray(_state.heatmapManualOrder)
|
|
5193
|
+
&& _state.heatmapManualOrder.every(function (r) {
|
|
5194
|
+
return typeof r === 'string';
|
|
5195
|
+
}))) {
|
|
5196
|
+
throw new Error(ERROR + '"heatmapManualOrder" must be an array of property refs');
|
|
5197
|
+
}
|
|
5075
5198
|
// A tree with protein domain architectures on its tips draws them
|
|
5076
5199
|
// from the start (the desktop switches its toggle on at load), again
|
|
5077
5200
|
// unless the caller decided. The track width is set at the first
|
|
@@ -5111,6 +5234,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
5111
5234
|
// showTimeAxis explicitly.
|
|
5112
5235
|
_timeInfo = _treeData ? forester.timeAxisInfo(forester.getTreeRoot(_treeData)) : null;
|
|
5113
5236
|
_timeTree = _treeData ? forester.isTimeTree(_treeData) : false;
|
|
5237
|
+
// Whether this tree says two different things -- when its nodes sit in
|
|
5238
|
+
// time AND its branches measure something else -- and which of the two
|
|
5239
|
+
// it arrived showing. An Auspice build arrives in the time view; a
|
|
5240
|
+
// BEAST or Newick file arrives stating its own branch lengths.
|
|
5241
|
+
_branchScaleAvailable = _treeData ? forester.hasTimeAndDivergence(_treeData) : false;
|
|
5242
|
+
_state.branchScale = _treeData ? forester.branchLengthScale(_treeData) : 'divergence';
|
|
5114
5243
|
if (_state.showTimeAxis === undefined) {
|
|
5115
5244
|
_state.showTimeAxis = !!(_timeInfo && _timeInfo.type);
|
|
5116
5245
|
}
|
|
@@ -5651,6 +5780,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
5651
5780
|
// and a tree whose dates carry a unit is never touched. The desktop
|
|
5652
5781
|
// converts at the same point, next to its internal-label policy
|
|
5653
5782
|
// (their HeightDateConverter, 0.11.151).
|
|
5783
|
+
// Each node's cumulative divergence is recorded from the branch
|
|
5784
|
+
// lengths AS LOADED, before anything rewrites them, so the time <->
|
|
5785
|
+
// divergence switch is reversible rather than one-way. Auspice states
|
|
5786
|
+
// divergence outright (nextstrain:div) and is unaffected; every other
|
|
5787
|
+
// tree states it as its branch lengths, which the time view overwrites.
|
|
5788
|
+
trees.forEach(function (t) {
|
|
5789
|
+
forester.captureDivergence(t);
|
|
5790
|
+
});
|
|
5654
5791
|
forester.convertLoadedHeightsToDates(trees);
|
|
5655
5792
|
// the two view keys are checked up here with the other arguments,
|
|
5656
5793
|
// before anything is touched (and where Node can test it)
|
|
@@ -5711,6 +5848,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
5711
5848
|
_radialRotation = 0;
|
|
5712
5849
|
_radialLabelsHorizontal = false;
|
|
5713
5850
|
_msaColOffset = 0;
|
|
5851
|
+
_heatmapColOffset = 0;
|
|
5852
|
+
_heatmapModel = null; // the columns and the scale belong to THIS tree
|
|
5853
|
+
_heatmapColor = null;
|
|
5854
|
+
_heatmapGeom = null;
|
|
5714
5855
|
// ---- synchronous: everything that can throw, and the cheap setup --
|
|
5715
5856
|
//
|
|
5716
5857
|
// initializeSettings validates values and throws on a bad one, so it
|
|
@@ -6445,6 +6586,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
6445
6586
|
if (edited && _domain) {
|
|
6446
6587
|
_domain.palette = null; // the tree changed: the domain names are dealt their colours again
|
|
6447
6588
|
}
|
|
6589
|
+
if (edited) {
|
|
6590
|
+
_heatmapModel = null; // ... and the heat map's columns and scale are derived from it again
|
|
6591
|
+
_heatmapColor = null;
|
|
6592
|
+
}
|
|
6448
6593
|
if (!_vis) {
|
|
6449
6594
|
return;
|
|
6450
6595
|
}
|
|
@@ -6483,8 +6628,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
6483
6628
|
// part of the real spans (an anchor ratio taken from anything else
|
|
6484
6629
|
// once flung the whole tree off-screen after three X+ presses).
|
|
6485
6630
|
return {
|
|
6486
|
-
horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve
|
|
6487
|
-
|
|
6631
|
+
horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve
|
|
6632
|
+
- _domainReserve - _heatmapReserve),
|
|
6633
|
+
vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
|
|
6634
|
+
- bottomOverlayReserve() - topOverlayReserve())
|
|
6488
6635
|
};
|
|
6489
6636
|
}
|
|
6490
6637
|
|
|
@@ -7196,13 +7343,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
7196
7343
|
if (_panelTheme) {
|
|
7197
7344
|
tip_el.classList.add('aptx-' + _panelTheme);
|
|
7198
7345
|
}
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
let left = (event.pageX + 290) > window.innerWidth ? (event.pageX - 280) : (event.pageX + 14);
|
|
7202
|
-
_node_mouseover_div
|
|
7203
|
-
.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)))
|
|
7204
|
-
.style('left', left + 'px')
|
|
7205
|
-
.style('top', (event.pageY + 14) + 'px');
|
|
7346
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
|
|
7347
|
+
placeHoverReadout(tip_el, event);
|
|
7206
7348
|
_node_mouseover_div.transition().duration(100).style('opacity', 0.95);
|
|
7207
7349
|
}
|
|
7208
7350
|
|
|
@@ -7215,6 +7357,1084 @@ function (root, d3, forester, phyloXml) {
|
|
|
7215
7357
|
scheduleUpdate(null, 0);
|
|
7216
7358
|
}
|
|
7217
7359
|
|
|
7360
|
+
// ===================== Heat map =====================
|
|
7361
|
+
// The desktop's MATRIX annotation columns: one cell per (tip, numeric
|
|
7362
|
+
// per-tip property) to the right of the labels, every column painted on
|
|
7363
|
+
// ONE colour scale so that a cell can be read across columns -- the
|
|
7364
|
+
// matrix half of a clustergram.
|
|
7365
|
+
//
|
|
7366
|
+
// Which refs are columns, the order they take and what the scale spans
|
|
7367
|
+
// are forester.heatmapColumns, derived once over the WHOLE tree:
|
|
7368
|
+
// candidacy is decided on the tree, never per view, so diving into a
|
|
7369
|
+
// subtree narrows the rows and leaves the columns and the scale alone.
|
|
7370
|
+
// A colour therefore means the same number in every view.
|
|
7371
|
+
//
|
|
7372
|
+
// A cell nobody filled in is never painted as the scale's low end: on a
|
|
7373
|
+
// presence/absence matrix that would state the opposite of what the file
|
|
7374
|
+
// says. It is drawn as an OUTLINED empty box, with a key beside the scale
|
|
7375
|
+
// -- leaving it bare is not enough, see the measurement at the draw.
|
|
7376
|
+
//
|
|
7377
|
+
// Rectangular and CIRCULAR: the columns stand on the tips' common edge,
|
|
7378
|
+
// which a circular fan has as a ring (drawHeatmapRings) and an unrooted one
|
|
7379
|
+
// has not got at all -- every tip there ends at its own radius.
|
|
7380
|
+
|
|
7381
|
+
function heatmapModel() {
|
|
7382
|
+
if (!_heatmapModel && _treeData) {
|
|
7383
|
+
_heatmapModel = forester.heatmapColumns(_treeData);
|
|
7384
|
+
}
|
|
7385
|
+
return _heatmapModel;
|
|
7386
|
+
}
|
|
7387
|
+
|
|
7388
|
+
// Offered when the tree holds at least two numeric per-tip refs that
|
|
7389
|
+
// actually carry values: one column is a stripe rather than a matrix,
|
|
7390
|
+
// and a scale needs something to span.
|
|
7391
|
+
function heatmapAvailable() {
|
|
7392
|
+
let m = heatmapModel();
|
|
7393
|
+
return !!m && m.refs.length >= HEATMAP_MIN_COLUMNS && m.min !== null && m.max !== null;
|
|
7394
|
+
}
|
|
7395
|
+
|
|
7396
|
+
// Circular draws the columns as RINGS (drawHeatmapRings); unrooted cannot,
|
|
7397
|
+
// because its tips end at different radii so a column has no ring to be.
|
|
7398
|
+
function heatmapShown() {
|
|
7399
|
+
return _state.showHeatmap === true && _state.unrootedDisplay !== true && heatmapAvailable();
|
|
7400
|
+
}
|
|
7401
|
+
|
|
7402
|
+
// The one scale, spanning the whole tree's values. Built lazily and kept
|
|
7403
|
+
// with the model, so every redraw and every view paints the same colours.
|
|
7404
|
+
function heatmapColorOf(v) {
|
|
7405
|
+
let m = heatmapModel();
|
|
7406
|
+
if (!m || v === null) {
|
|
7407
|
+
return null;
|
|
7408
|
+
}
|
|
7409
|
+
if (!_heatmapColor) {
|
|
7410
|
+
_heatmapColor = (m.max > m.min)
|
|
7411
|
+
? d3.scaleLinear().range(VIS_COLOR_RAMP).domain([m.min, (m.min + m.max) / 2, m.max])
|
|
7412
|
+
: function () {
|
|
7413
|
+
// one value everywhere: a flat matrix, not a gradient
|
|
7414
|
+
// over a zero-width domain (which d3 maps to its last stop)
|
|
7415
|
+
return VIS_COLOR_RAMP[1];
|
|
7416
|
+
};
|
|
7417
|
+
}
|
|
7418
|
+
return _heatmapColor(v);
|
|
7419
|
+
}
|
|
7420
|
+
|
|
7421
|
+
// Enough digits to tell two cells apart, without a tail of noise on data
|
|
7422
|
+
// that is plainly integral.
|
|
7423
|
+
function heatmapNum(v) {
|
|
7424
|
+
if (v === null || v === undefined) {
|
|
7425
|
+
return '';
|
|
7426
|
+
}
|
|
7427
|
+
if (v !== 0 && (Math.abs(v) >= 100000 || Math.abs(v) < 0.001)) {
|
|
7428
|
+
return v.toExponential(2);
|
|
7429
|
+
}
|
|
7430
|
+
return String(Math.round(v * 1000) / 1000);
|
|
7431
|
+
}
|
|
7432
|
+
|
|
7433
|
+
// A column is as wide as the band allows, never wider than is useful and
|
|
7434
|
+
// never thinner than is legible -- past that the matrix is windowed and
|
|
7435
|
+
// scrolled, as a long alignment is.
|
|
7436
|
+
function heatmapColWidth() {
|
|
7437
|
+
let m = heatmapModel();
|
|
7438
|
+
if (!m || m.refs.length < 1) {
|
|
7439
|
+
return HEATMAP_PREF_COL_W;
|
|
7440
|
+
}
|
|
7441
|
+
let band = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
|
|
7442
|
+
return Math.max(HEATMAP_MIN_COL_W, Math.min(HEATMAP_PREF_COL_W, band / m.refs.length));
|
|
7443
|
+
}
|
|
7444
|
+
|
|
7445
|
+
function heatmapLabelsFit() {
|
|
7446
|
+
return heatmapColWidth() >= HEATMAP_LABEL_MIN_COL_W;
|
|
7447
|
+
}
|
|
7448
|
+
|
|
7449
|
+
function heatmapLabelFont() {
|
|
7450
|
+
return Math.max(7, Math.min(11, Math.round(heatmapColWidth()))) + 'px ' + FONT_DEFAULTS;
|
|
7451
|
+
}
|
|
7452
|
+
|
|
7453
|
+
// The turned column labels' band: the longest label, clipped. Asked for
|
|
7454
|
+
// before the draw (bottomOverlayReserve sizes the layout with it), so it
|
|
7455
|
+
// is derived from the model and the reserve, never from the drawing.
|
|
7456
|
+
function heatmapHeadHeight() {
|
|
7457
|
+
if (!heatmapLabelsFit()) {
|
|
7458
|
+
return 0;
|
|
7459
|
+
}
|
|
7460
|
+
let font = heatmapLabelFont();
|
|
7461
|
+
let w = 0;
|
|
7462
|
+
heatmapModel().refs.forEach(function (r) {
|
|
7463
|
+
w = Math.max(w, legendTextWidth(r.label, font));
|
|
7464
|
+
});
|
|
7465
|
+
return Math.ceil(Math.min(w, HEATMAP_LABEL_MAX_PX));
|
|
7466
|
+
}
|
|
7467
|
+
|
|
7468
|
+
// What the strip actually paints: the turned names and the scale.
|
|
7469
|
+
function heatmapStripHeight() {
|
|
7470
|
+
return HEATMAP_HEAD_GAP + heatmapHeadHeight() + HEATMAP_SCALE_RESERVE;
|
|
7471
|
+
}
|
|
7472
|
+
|
|
7473
|
+
// What the layout keeps clear for it. The alignment's navigation bar is
|
|
7474
|
+
// fixed at the viewport bottom and would cover whatever ends under it, so
|
|
7475
|
+
// when that bar is out the strip has to end above it -- as the alignment's
|
|
7476
|
+
// own strip does (MSA_NAV_RESERVE).
|
|
7477
|
+
function heatmapBottomReserve() {
|
|
7478
|
+
if (!heatmapShown() || radialDisplay()) {
|
|
7479
|
+
return 0; // the rings carry their own names and legend
|
|
7480
|
+
}
|
|
7481
|
+
return heatmapStripHeight() + (msaShown() ? MSA_NAV_RESERVE : 0);
|
|
7482
|
+
}
|
|
7483
|
+
|
|
7484
|
+
// The mode in force: what the caller or the reader chose, else the one the
|
|
7485
|
+
// data asks for.
|
|
7486
|
+
//
|
|
7487
|
+
// The resolved choice is cached on the MODEL, which is where it belongs --
|
|
7488
|
+
// it is a fact about this tree's values and it dies with them. It is not
|
|
7489
|
+
// written back into _state, but not because _state would leak it: every
|
|
7490
|
+
// launch AND every tree switch rebuilds _state from the original config
|
|
7491
|
+
// (switchToTree -> launchInto -> initializeState), so a write-back could
|
|
7492
|
+
// not reach the next tree. Checked, because the comment that used to sit
|
|
7493
|
+
// here claimed exactly that protection and it would have been fiction.
|
|
7494
|
+
function heatmapMode() {
|
|
7495
|
+
if (_state.heatmapColumnOrder) {
|
|
7496
|
+
return _state.heatmapColumnOrder;
|
|
7497
|
+
}
|
|
7498
|
+
let m = heatmapModel();
|
|
7499
|
+
if (!m) {
|
|
7500
|
+
return 'document';
|
|
7501
|
+
}
|
|
7502
|
+
if (!m._autoMode) {
|
|
7503
|
+
m._autoMode = forester.heatmapDefaultOrder(_treeData, m.refs);
|
|
7504
|
+
}
|
|
7505
|
+
return m._autoMode;
|
|
7506
|
+
}
|
|
7507
|
+
|
|
7508
|
+
// How far this tree's own tips agree about the column order (cached with
|
|
7509
|
+
// the model, since it is a fact about the same values).
|
|
7510
|
+
function heatmapInputAgreement() {
|
|
7511
|
+
let m = heatmapModel();
|
|
7512
|
+
if (!m) {
|
|
7513
|
+
return null;
|
|
7514
|
+
}
|
|
7515
|
+
if (!m._agreement) {
|
|
7516
|
+
m._agreement = forester.heatmapInputOrderAgreement(_treeData, m.refs);
|
|
7517
|
+
}
|
|
7518
|
+
return m._agreement;
|
|
7519
|
+
}
|
|
7520
|
+
|
|
7521
|
+
// The columns in the order the reader asked for, with the dendrogram
|
|
7522
|
+
// behind that order when the order came from a clustering.
|
|
7523
|
+
function heatmapOrdered() {
|
|
7524
|
+
let m = heatmapModel();
|
|
7525
|
+
if (!m) {
|
|
7526
|
+
return null;
|
|
7527
|
+
}
|
|
7528
|
+
let mode = heatmapMode();
|
|
7529
|
+
// The order is kept ON the model, not in a cache beside it. The
|
|
7530
|
+
// desktop's equivalent was keyed on (refs, mode) and a subtree, an
|
|
7531
|
+
// edit or a re-import changes the VALUES while leaving both alone, so
|
|
7532
|
+
// a hit handed back an order of the old data and a dendrogram drawn
|
|
7533
|
+
// over the new -- found by code review, fixed there by invalidating at
|
|
7534
|
+
// the one place every such path goes through. Hanging it on the model
|
|
7535
|
+
// makes that structural instead: whatever drops the model drops this
|
|
7536
|
+
// with it, and there is no second thing to remember to clear.
|
|
7537
|
+
// the manual list is part of the key: it changes while the mode
|
|
7538
|
+
// stays 'manual', and a cache blind to that would redraw the old
|
|
7539
|
+
// arrangement after every move
|
|
7540
|
+
let manual = _state.heatmapManualOrder;
|
|
7541
|
+
if (!m._order || m._order.mode !== mode || m._order.manual !== manual) {
|
|
7542
|
+
let o = forester.heatmapOrder(_treeData, m.refs, mode, manual);
|
|
7543
|
+
m._order = {mode: mode, manual: manual, columns: o.columns, dendrogram: o.dendrogram};
|
|
7544
|
+
}
|
|
7545
|
+
return m._order;
|
|
7546
|
+
}
|
|
7547
|
+
|
|
7548
|
+
// How many columns the band can hold: every column when they fit, a window
|
|
7549
|
+
// otherwise. Derived from the reserve, which update() sets before it asks
|
|
7550
|
+
// for either overlay reserve, so the draw and the layout agree.
|
|
7551
|
+
function heatmapVisibleColumns() {
|
|
7552
|
+
let m = heatmapModel();
|
|
7553
|
+
if (!m || m.refs.length < 1) {
|
|
7554
|
+
return 0;
|
|
7555
|
+
}
|
|
7556
|
+
let bandPx = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
|
|
7557
|
+
let visible = Math.max(1, Math.min(m.refs.length, Math.floor(bandPx / heatmapColWidth())));
|
|
7558
|
+
return isFinite(visible) ? visible : 0;
|
|
7559
|
+
}
|
|
7560
|
+
|
|
7561
|
+
// The dendrogram to draw, or null. A WINDOWED matrix gets none: the tree
|
|
7562
|
+
// describes every column, and drawn over some of them its merges would
|
|
7563
|
+
// reach columns that are not on screen -- a picture asserting a grouping
|
|
7564
|
+
// the reader cannot see.
|
|
7565
|
+
function heatmapDendrogram() {
|
|
7566
|
+
if (!heatmapShown() || radialDisplay()) {
|
|
7567
|
+
return null; // a dendrogram over a ring would have to bend; it earns nothing
|
|
7568
|
+
}
|
|
7569
|
+
let o = heatmapOrdered();
|
|
7570
|
+
if (!o || !o.dendrogram || o.columns.length < 3) {
|
|
7571
|
+
return null;
|
|
7572
|
+
}
|
|
7573
|
+
return (heatmapVisibleColumns() === o.columns.length) ? o.dendrogram : null;
|
|
7574
|
+
}
|
|
7575
|
+
|
|
7576
|
+
// Deliberately short: a shape to read a grouping off, not a scale anyone
|
|
7577
|
+
// reads a number from.
|
|
7578
|
+
function heatmapDendroBand() {
|
|
7579
|
+
return Math.max(HEATMAP_DENDRO_MIN_BAND,
|
|
7580
|
+
Math.min(HEATMAP_DENDRO_MAX_BAND, 3 * (_state.externalNodeFontSize + 2)));
|
|
7581
|
+
}
|
|
7582
|
+
|
|
7583
|
+
// Vertical room kept ABOVE the first row for the dendrogram. The band is
|
|
7584
|
+
// drawn at negative y, with the tree's own group, so it zooms and pans
|
|
7585
|
+
// with the cells it belongs to; the layout just has to leave it the space
|
|
7586
|
+
// and the fit has to start below it.
|
|
7587
|
+
function heatmapTopReserve() {
|
|
7588
|
+
return heatmapDendrogram() ? (heatmapDendroBand() + HEATMAP_DENDRO_GAP) : 0;
|
|
7589
|
+
}
|
|
7590
|
+
|
|
7591
|
+
function topOverlayReserve() {
|
|
7592
|
+
return heatmapTopReserve();
|
|
7593
|
+
}
|
|
7594
|
+
|
|
7595
|
+
function drawHeatmapTrack() {
|
|
7596
|
+
if (!_svgGroup) {
|
|
7597
|
+
return;
|
|
7598
|
+
}
|
|
7599
|
+
_svgGroup.selectAll('g.aptx-heatmap').remove();
|
|
7600
|
+
if (_baseSvg) {
|
|
7601
|
+
_baseSvg.selectAll('g.aptx-heatmap-ringlegend').remove();
|
|
7602
|
+
}
|
|
7603
|
+
if (_floatGroup) {
|
|
7604
|
+
_floatGroup.selectAll('g.aptx-heatmap-strip').remove();
|
|
7605
|
+
delete _floatStrips['aptx-heatmap-strip'];
|
|
7606
|
+
}
|
|
7607
|
+
if (!heatmapShown() || !_root) {
|
|
7608
|
+
_heatmapGeom = null;
|
|
7609
|
+
return;
|
|
7610
|
+
}
|
|
7611
|
+
if (heatmapCircular()) {
|
|
7612
|
+
drawHeatmapRings();
|
|
7613
|
+
drawHeatmapRingLegend();
|
|
7614
|
+
return;
|
|
7615
|
+
}
|
|
7616
|
+
let model = heatmapModel();
|
|
7617
|
+
let ordered = heatmapOrdered();
|
|
7618
|
+
let columns = ordered ? ordered.columns : model.refs;
|
|
7619
|
+
let tips = displayedTips().filter(function (d) {
|
|
7620
|
+
return d.x !== undefined;
|
|
7621
|
+
}).sort(function (p, q) {
|
|
7622
|
+
return p.x - q.x;
|
|
7623
|
+
});
|
|
7624
|
+
let total = columns.length;
|
|
7625
|
+
if (tips.length < 1 || total < 1) {
|
|
7626
|
+
_heatmapGeom = null;
|
|
7627
|
+
return;
|
|
7628
|
+
}
|
|
7629
|
+
let cw = heatmapColWidth();
|
|
7630
|
+
let bandPx = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
|
|
7631
|
+
let visible = Math.max(1, Math.min(total, Math.floor(bandPx / cw)));
|
|
7632
|
+
if (!isFinite(visible)) {
|
|
7633
|
+
// half-initialized globals (mid-launch) can make the window NaN;
|
|
7634
|
+
// draw nothing rather than feeding it downstream
|
|
7635
|
+
_heatmapGeom = null;
|
|
7636
|
+
return;
|
|
7637
|
+
}
|
|
7638
|
+
let maxOffset = Math.max(0, total - visible);
|
|
7639
|
+
if (_heatmapColOffset > maxOffset) {
|
|
7640
|
+
_heatmapColOffset = maxOffset;
|
|
7641
|
+
}
|
|
7642
|
+
if (_heatmapColOffset < 0) {
|
|
7643
|
+
_heatmapColOffset = 0;
|
|
7644
|
+
}
|
|
7645
|
+
let offset = _heatmapColOffset;
|
|
7646
|
+
// The matrix ends where the alignment track begins, or at the canvas
|
|
7647
|
+
// edge when there is none: the fitted view has shifted the layout
|
|
7648
|
+
// right by rootOffset, and both reserves were taken off _w already.
|
|
7649
|
+
let originX = _displayWidth - _settings.rootOffset - _msaReserve - _heatmapReserve + HEATMAP_TRACK_GAP;
|
|
7650
|
+
let g = _svgGroup.append('g').attr('class', 'aptx-heatmap');
|
|
7651
|
+
let ink = _state.branchColorDefault;
|
|
7652
|
+
|
|
7653
|
+
// Row bands: each shared boundary derived ONCE, as the midpoint
|
|
7654
|
+
// between adjacent tip rows -- deriving it per row from y +- half a
|
|
7655
|
+
// row height rounds a pixel apart and paints a seam across the grid.
|
|
7656
|
+
let n = tips.length;
|
|
7657
|
+
let pad = n > 1 ? ((tips[n - 1].x - tips[0].x) / (n - 1)) / 2
|
|
7658
|
+
: Math.max(4, _state.externalNodeFontSize / 2);
|
|
7659
|
+
let bounds = new Array(n + 1);
|
|
7660
|
+
bounds[0] = Math.max(0, tips[0].x - pad);
|
|
7661
|
+
for (let r = 1; r < n; ++r) {
|
|
7662
|
+
bounds[r] = (tips[r - 1].x + tips[r].x) / 2;
|
|
7663
|
+
}
|
|
7664
|
+
bounds[n] = Math.min(_clusterH, tips[n - 1].x + pad);
|
|
7665
|
+
|
|
7666
|
+
// A cell nobody filled in is drawn in the background colour and OUTLINED.
|
|
7667
|
+
// Leaving it bare is not enough: measured on the sparse demo, a bare
|
|
7668
|
+
// blank stands at a contrast ratio of 13.4 against the scale's low end
|
|
7669
|
+
// in the light theme but 1.08 in the dark one -- that is to say, in the
|
|
7670
|
+
// dark theme "not assessed" and "zero" were the same picture, which is
|
|
7671
|
+
// the one confusion this whole design exists to prevent.
|
|
7672
|
+
let blanks = 0;
|
|
7673
|
+
// Blanks merge into one outlined band only where a cell is too narrow
|
|
7674
|
+
// to outline anyway: at a readable column width a run of them must stay
|
|
7675
|
+
// a run of CELLS, or its top and bottom edges read as a drawn rule
|
|
7676
|
+
// across the matrix rather than as missing data.
|
|
7677
|
+
let mergeBlanks = cw < HEATMAP_LABEL_MIN_COL_W;
|
|
7678
|
+
for (let r = 0; r < n; ++r) {
|
|
7679
|
+
let d = tips[r];
|
|
7680
|
+
let cy = Math.round(bounds[r]);
|
|
7681
|
+
let rh = Math.max(1, Math.round(bounds[r + 1]) - cy);
|
|
7682
|
+
// consecutive cells of the SAME colour merge into one rect: on a
|
|
7683
|
+
// presence/absence matrix that is most of them, and it is what
|
|
7684
|
+
// keeps a wide matrix drawable
|
|
7685
|
+
let runStart = -1;
|
|
7686
|
+
let runFill = null;
|
|
7687
|
+
let flush = function (endI) {
|
|
7688
|
+
if (runStart < 0) {
|
|
7689
|
+
return;
|
|
7690
|
+
}
|
|
7691
|
+
let x0 = Math.round(originX + (runStart * cw));
|
|
7692
|
+
let x1 = Math.round(originX + (endI * cw));
|
|
7693
|
+
let rect = g.append('rect').attr('x', x0).attr('y', cy)
|
|
7694
|
+
.attr('width', Math.max(1, x1 - x0)).attr('height', rh);
|
|
7695
|
+
if (runFill === HEATMAP_BLANK) {
|
|
7696
|
+
rect.attr('fill', _state.backgroundColorDefault)
|
|
7697
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
|
|
7698
|
+
} else {
|
|
7699
|
+
rect.attr('fill', runFill);
|
|
7700
|
+
}
|
|
7701
|
+
runStart = -1;
|
|
7702
|
+
runFill = null;
|
|
7703
|
+
};
|
|
7704
|
+
for (let i = 0; i < visible; ++i) {
|
|
7705
|
+
let v = forester.heatmapValue(d, columns[offset + i].ref);
|
|
7706
|
+
let fill = (v === null) ? HEATMAP_BLANK : heatmapColorOf(v);
|
|
7707
|
+
if (fill === HEATMAP_BLANK) {
|
|
7708
|
+
blanks++;
|
|
7709
|
+
}
|
|
7710
|
+
if (runFill === fill && (mergeBlanks || fill !== HEATMAP_BLANK)) {
|
|
7711
|
+
continue; // the run extends
|
|
7712
|
+
}
|
|
7713
|
+
flush(i);
|
|
7714
|
+
runStart = i;
|
|
7715
|
+
runFill = fill;
|
|
7716
|
+
}
|
|
7717
|
+
flush(visible);
|
|
7718
|
+
}
|
|
7719
|
+
|
|
7720
|
+
let rowsTop = Math.round(bounds[0]);
|
|
7721
|
+
let rowsBottom = Math.round(bounds[n]);
|
|
7722
|
+
let trackW = Math.round(originX + (visible * cw)) - Math.round(originX);
|
|
7723
|
+
// The frame: without it a blank cell is indistinguishable from the
|
|
7724
|
+
// space beside the matrix, and "not assessed" stops being visible.
|
|
7725
|
+
g.append('rect')
|
|
7726
|
+
.attr('x', Math.round(originX)).attr('y', rowsTop)
|
|
7727
|
+
.attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
|
|
7728
|
+
.attr('fill', 'none')
|
|
7729
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.55).attr('stroke-width', 1);
|
|
7730
|
+
|
|
7731
|
+
// ---- the clustering behind the column order, above the grid ----
|
|
7732
|
+
// Drawn with the cells, in the tree's own group, so it zooms, pans and
|
|
7733
|
+
// exports with them. Heights are LINEAR in the merge distance, so a
|
|
7734
|
+
// block that joins low really is drawn tighter than one that joins
|
|
7735
|
+
// high; spacing merges evenly by rank would read more clearly and
|
|
7736
|
+
// would say something false.
|
|
7737
|
+
let dendro = heatmapDendrogram();
|
|
7738
|
+
if (dendro && dendro.height.length >= 1 && dendro.order.length === visible && offset === 0) {
|
|
7739
|
+
let band = heatmapDendroBand();
|
|
7740
|
+
let baseline = rowsTop - HEATMAP_DENDRO_GAP;
|
|
7741
|
+
let maxH = 0;
|
|
7742
|
+
dendro.height.forEach(function (h) {
|
|
7743
|
+
if (isFinite(h) && h > maxH) {
|
|
7744
|
+
maxH = h;
|
|
7745
|
+
}
|
|
7746
|
+
});
|
|
7747
|
+
let slot = new Array(dendro.order.length);
|
|
7748
|
+
dendro.order.forEach(function (leaf, k) {
|
|
7749
|
+
slot[leaf] = k;
|
|
7750
|
+
});
|
|
7751
|
+
let clusterX = new Array(dendro.height.length);
|
|
7752
|
+
let clusterY = new Array(dendro.height.length);
|
|
7753
|
+
// R's node-id convention: negative = the singleton of that index,
|
|
7754
|
+
// positive = the cluster formed at that stage
|
|
7755
|
+
let nodeX = function (id) {
|
|
7756
|
+
return (id < 0) ? (originX + ((slot[-id - 1] + 0.5) * cw)) : clusterX[id - 1];
|
|
7757
|
+
};
|
|
7758
|
+
let nodeY = function (id) {
|
|
7759
|
+
return (id < 0) ? baseline : clusterY[id - 1];
|
|
7760
|
+
};
|
|
7761
|
+
let path = '';
|
|
7762
|
+
for (let s = 0; s < dendro.height.length; ++s) {
|
|
7763
|
+
let h = dendro.height[s];
|
|
7764
|
+
// a merge with no finite height -- two columns that share no
|
|
7765
|
+
// assessed tip, so no distance was ever computed -- goes to the
|
|
7766
|
+
// top of the band, above every measurable merge, which is where
|
|
7767
|
+
// it belongs: it joined last
|
|
7768
|
+
let ym = (!isFinite(h) || !(maxH > 0)) ? (baseline - band) : (baseline - ((h / maxH) * band));
|
|
7769
|
+
let x1 = nodeX(dendro.left[s]);
|
|
7770
|
+
let y1 = nodeY(dendro.left[s]);
|
|
7771
|
+
let x2 = nodeX(dendro.right[s]);
|
|
7772
|
+
let y2 = nodeY(dendro.right[s]);
|
|
7773
|
+
path += 'M' + x1.toFixed(1) + ',' + y1.toFixed(1)
|
|
7774
|
+
+ 'L' + x1.toFixed(1) + ',' + ym.toFixed(1)
|
|
7775
|
+
+ 'L' + x2.toFixed(1) + ',' + ym.toFixed(1)
|
|
7776
|
+
+ 'L' + x2.toFixed(1) + ',' + y2.toFixed(1);
|
|
7777
|
+
clusterX[s] = (x1 + x2) / 2;
|
|
7778
|
+
clusterY[s] = ym;
|
|
7779
|
+
}
|
|
7780
|
+
g.append('path').attr('class', 'aptx-heatmap-dendro')
|
|
7781
|
+
.attr('d', path)
|
|
7782
|
+
.attr('fill', 'none')
|
|
7783
|
+
.attr('stroke', ink)
|
|
7784
|
+
.attr('stroke-width', Math.max(1, _state.branchWidthDefault))
|
|
7785
|
+
.style('pointer-events', 'none');
|
|
7786
|
+
}
|
|
7787
|
+
|
|
7788
|
+
// A faint dashed guide from each tip across to its row, as the
|
|
7789
|
+
// alignment track draws -- skipped when the domain tracks are in
|
|
7790
|
+
// between, where it would run straight through their boxes.
|
|
7791
|
+
if (_domainReserve === 0) {
|
|
7792
|
+
let guideFont = _state.externalNodeFontSize + 'px ' + _state.defaultFont;
|
|
7793
|
+
let guideGap = _state.nodeLabelGap;
|
|
7794
|
+
let guideEnd = originX - 3;
|
|
7795
|
+
for (let r = 0; r < n; ++r) {
|
|
7796
|
+
let d = tips[r];
|
|
7797
|
+
let label = d._extLabelText || '';
|
|
7798
|
+
let from;
|
|
7799
|
+
if (label) {
|
|
7800
|
+
let labelX = (_state.phylogram && _state.alignPhylogram && _yScale)
|
|
7801
|
+
? d.y - _yScale(d.distToRoot) + _w + guideGap
|
|
7802
|
+
: d.y + guideGap;
|
|
7803
|
+
from = labelX + legendTextWidth(label, guideFont) + 5;
|
|
7804
|
+
} else {
|
|
7805
|
+
from = d.y + makeNodeSize(d) + 4;
|
|
7806
|
+
}
|
|
7807
|
+
if (guideEnd - from < 6) {
|
|
7808
|
+
continue;
|
|
7809
|
+
}
|
|
7810
|
+
g.append('line')
|
|
7811
|
+
.attr('x1', from).attr('x2', guideEnd)
|
|
7812
|
+
.attr('y1', d.x).attr('y2', d.x)
|
|
7813
|
+
.attr('stroke', ink)
|
|
7814
|
+
.attr('stroke-width', 1)
|
|
7815
|
+
.attr('stroke-dasharray', '2,3')
|
|
7816
|
+
.style('stroke-opacity', 0.35)
|
|
7817
|
+
.style('pointer-events', 'none');
|
|
7818
|
+
}
|
|
7819
|
+
}
|
|
7820
|
+
|
|
7821
|
+
// ---- column names and the shared scale: a FLOATING strip ----
|
|
7822
|
+
// the strip is placed against its FULL reserve, so that a stuck strip
|
|
7823
|
+
// clears the alignment's navigation bar, but paints only its content
|
|
7824
|
+
let strip = floatStripGroup('aptx-heatmap-strip', _clusterH, heatmapBottomReserve());
|
|
7825
|
+
strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
|
|
7826
|
+
.attr('width', trackW + 4).attr('height', heatmapStripHeight())
|
|
7827
|
+
.attr('fill', _state.backgroundColorDefault);
|
|
7828
|
+
let labelTop = _clusterH + HEATMAP_HEAD_GAP;
|
|
7829
|
+
let headH = heatmapHeadHeight();
|
|
7830
|
+
if (headH > 0) {
|
|
7831
|
+
let font = heatmapLabelFont();
|
|
7832
|
+
for (let i = 0; i < visible; ++i) {
|
|
7833
|
+
let col = columns[offset + i];
|
|
7834
|
+
strip.append('text')
|
|
7835
|
+
.attr('transform', 'translate(' + (originX + (i * cw) + (cw / 2)) + ','
|
|
7836
|
+
+ labelTop + ') rotate(-90)')
|
|
7837
|
+
.attr('dy', '0.32em')
|
|
7838
|
+
.attr('text-anchor', 'end')
|
|
7839
|
+
.style('font', font)
|
|
7840
|
+
.style('fill', _state.labelColorDefault)
|
|
7841
|
+
.text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
|
|
7842
|
+
}
|
|
7843
|
+
}
|
|
7844
|
+
|
|
7845
|
+
// the scale itself: the ramp the cells were painted from, its two
|
|
7846
|
+
// ends named, and the blank cell shown for what it is
|
|
7847
|
+
let scaleTop = labelTop + headH + 6;
|
|
7848
|
+
let barW = Math.round(Math.max(40, Math.min(HEATMAP_SCALE_BAR_W, trackW * 0.45)));
|
|
7849
|
+
let gradId = 'aptx-heatmap-ramp';
|
|
7850
|
+
let grad = strip.append('defs').append('linearGradient').attr('id', gradId)
|
|
7851
|
+
.attr('x1', 0).attr('y1', 0).attr('x2', 1).attr('y2', 0);
|
|
7852
|
+
VIS_COLOR_RAMP.forEach(function (c, i) {
|
|
7853
|
+
grad.append('stop')
|
|
7854
|
+
.attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
|
|
7855
|
+
.attr('stop-color', c);
|
|
7856
|
+
});
|
|
7857
|
+
strip.append('rect')
|
|
7858
|
+
.attr('x', Math.round(originX)).attr('y', scaleTop)
|
|
7859
|
+
.attr('width', barW).attr('height', HEATMAP_SCALE_BAR_H)
|
|
7860
|
+
.attr('fill', 'url(#' + gradId + ')')
|
|
7861
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
|
|
7862
|
+
let numY = scaleTop + HEATMAP_SCALE_BAR_H + 10;
|
|
7863
|
+
strip.append('text').attr('x', Math.round(originX)).attr('y', numY)
|
|
7864
|
+
.attr('text-anchor', 'start')
|
|
7865
|
+
.style('font-size', '9px').style('fill', ink)
|
|
7866
|
+
.text(heatmapNum(model.min));
|
|
7867
|
+
strip.append('text').attr('x', Math.round(originX) + barW).attr('y', numY)
|
|
7868
|
+
.attr('text-anchor', 'end')
|
|
7869
|
+
.style('font-size', '9px').style('fill', ink)
|
|
7870
|
+
.text(heatmapNum(model.max));
|
|
7871
|
+
// the caption: what the matrix is showing, and -- when it is windowed
|
|
7872
|
+
// -- that there is more of it than this
|
|
7873
|
+
let caption = (visible < total)
|
|
7874
|
+
? 'Columns ' + (offset + 1) + '–' + (offset + visible) + ' of ' + total + ', one scale (scroll over the matrix)'
|
|
7875
|
+
: total + ' columns, one scale';
|
|
7876
|
+
let capFont = '9px ' + FONT_DEFAULTS;
|
|
7877
|
+
let cursor = Math.round(originX) + barW + 10;
|
|
7878
|
+
let rightEdge = Math.round(originX) + trackW;
|
|
7879
|
+
// the blank's own key comes FIRST: it names a convention the reader
|
|
7880
|
+
// cannot guess, where the caption only repeats what is on screen
|
|
7881
|
+
if (blanks > 0) {
|
|
7882
|
+
let keyW = HEATMAP_SCALE_BAR_H + 4 + legendTextWidth(HEATMAP_NOT_ASSESSED, capFont);
|
|
7883
|
+
if ((rightEdge - cursor) >= keyW) {
|
|
7884
|
+
strip.append('rect').attr('x', cursor).attr('y', scaleTop)
|
|
7885
|
+
.attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
|
|
7886
|
+
.attr('fill', _state.backgroundColorDefault)
|
|
7887
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
|
|
7888
|
+
strip.append('text').attr('x', cursor + HEATMAP_SCALE_BAR_H + 4)
|
|
7889
|
+
.attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
|
|
7890
|
+
.attr('text-anchor', 'start')
|
|
7891
|
+
.style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
|
|
7892
|
+
.text(HEATMAP_NOT_ASSESSED);
|
|
7893
|
+
cursor += keyW + 10;
|
|
7894
|
+
}
|
|
7895
|
+
}
|
|
7896
|
+
if ((rightEdge - cursor) > 60) {
|
|
7897
|
+
strip.append('text').attr('x', cursor).attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
|
|
7898
|
+
.attr('text-anchor', 'start')
|
|
7899
|
+
.style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
|
|
7900
|
+
.text(clipTextToWidth(caption, capFont, rightEdge - cursor));
|
|
7901
|
+
}
|
|
7902
|
+
|
|
7903
|
+
// hover + wheel surface over the rows
|
|
7904
|
+
g.append('rect').attr('class', 'aptx-heatmap-hover')
|
|
7905
|
+
.attr('x', Math.round(originX)).attr('y', rowsTop)
|
|
7906
|
+
.attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
|
|
7907
|
+
.attr('fill', 'transparent')
|
|
7908
|
+
.on('mousemove', heatmapHoverMove)
|
|
7909
|
+
.on('mouseout', heatmapHoverOut)
|
|
7910
|
+
.on('wheel', function (event) {
|
|
7911
|
+
if (visible >= total) {
|
|
7912
|
+
return; // nothing to scroll: leave the wheel to the page
|
|
7913
|
+
}
|
|
7914
|
+
event.preventDefault();
|
|
7915
|
+
event.stopPropagation();
|
|
7916
|
+
let delta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
|
|
7917
|
+
if (delta === 0) {
|
|
7918
|
+
return;
|
|
7919
|
+
}
|
|
7920
|
+
let step = Math.max(1, Math.round(visible / 10));
|
|
7921
|
+
heatmapScrollTo(_heatmapColOffset + (delta > 0 ? step : -step));
|
|
7922
|
+
});
|
|
7923
|
+
_heatmapGeom = {
|
|
7924
|
+
originX: originX, cw: cw, offset: offset, visible: visible, total: total,
|
|
7925
|
+
bounds: bounds, tips: tips, refs: columns, min: model.min, max: model.max
|
|
7926
|
+
};
|
|
7927
|
+
placeFloatingOverlays();
|
|
7928
|
+
}
|
|
7929
|
+
|
|
7930
|
+
|
|
7931
|
+
// ===================== the matrix, as rings =====================
|
|
7932
|
+
// The circular layout's analogue of the track: one concentric RING per
|
|
7933
|
+
// column, just past the tips and their labels, each tip's cell an arc over
|
|
7934
|
+
// that tip's own angular slice. The desktop's paintAnnotationColumnsCircular,
|
|
7935
|
+
// in polar coordinates rather than along an axis.
|
|
7936
|
+
//
|
|
7937
|
+
// Circular only, never unrooted: a column has to be a ring, and an unrooted
|
|
7938
|
+
// fan ends every tip at a different radius, so there is no ring for one to
|
|
7939
|
+
// sit on. (The domain tracks ride each tip's own spoke there, which works
|
|
7940
|
+
// for a bar and not for a matrix.)
|
|
7941
|
+
|
|
7942
|
+
// _radial is set by the circular layout and nulled by every other, so it
|
|
7943
|
+
// IS the test; heatmapShown() has already refused the unrooted one. A
|
|
7944
|
+
// separate circularDisplay check here looked like a third lock and could
|
|
7945
|
+
// not fire -- sabotaging it away changed nothing anywhere.
|
|
7946
|
+
function heatmapCircular() {
|
|
7947
|
+
return heatmapShown() && !!_radial;
|
|
7948
|
+
}
|
|
7949
|
+
|
|
7950
|
+
// A ring is as thick as the others, and the stack is capped against the
|
|
7951
|
+
// tree's own radius: a wide matrix must not turn the tree into a dot at
|
|
7952
|
+
// the middle of a dartboard.
|
|
7953
|
+
function heatmapRingWidth() {
|
|
7954
|
+
let m = heatmapModel();
|
|
7955
|
+
if (!m || m.refs.length < 1 || !_radial) {
|
|
7956
|
+
return HEATMAP_PREF_COL_W;
|
|
7957
|
+
}
|
|
7958
|
+
let budget = _radial.maxRad * HEATMAP_RADIAL_MAX_FRACTION;
|
|
7959
|
+
return Math.max(HEATMAP_RADIAL_MIN_RING,
|
|
7960
|
+
Math.min(HEATMAP_PREF_COL_W, budget / m.refs.length));
|
|
7961
|
+
}
|
|
7962
|
+
|
|
7963
|
+
// How far past the tip ring the matrix reaches, for the fit to allow for.
|
|
7964
|
+
function heatmapRadialExtent() {
|
|
7965
|
+
if (!heatmapCircular()) {
|
|
7966
|
+
return 0;
|
|
7967
|
+
}
|
|
7968
|
+
return HEATMAP_RADIAL_GAP + (heatmapModel().refs.length * heatmapRingWidth())
|
|
7969
|
+
+ HEATMAP_RADIAL_END_GAP;
|
|
7970
|
+
}
|
|
7971
|
+
|
|
7972
|
+
// one cell: the ring between r0 and r1, over the angles a0 to a1
|
|
7973
|
+
function heatmapSector(a0, a1, r0, r1) {
|
|
7974
|
+
let p = function (a, r) {
|
|
7975
|
+
let xy = polarXY(a, r);
|
|
7976
|
+
return xy[0].toFixed(1) + ',' + xy[1].toFixed(1);
|
|
7977
|
+
};
|
|
7978
|
+
let large = (a1 - a0) > Math.PI ? 1 : 0;
|
|
7979
|
+
return 'M' + p(a0, r0)
|
|
7980
|
+
+ 'A' + r0.toFixed(1) + ',' + r0.toFixed(1) + ' 0 ' + large + ' 1 ' + p(a1, r0)
|
|
7981
|
+
+ 'L' + p(a1, r1)
|
|
7982
|
+
+ 'A' + r1.toFixed(1) + ',' + r1.toFixed(1) + ' 0 ' + large + ' 0 ' + p(a0, r1)
|
|
7983
|
+
+ 'Z';
|
|
7984
|
+
}
|
|
7985
|
+
|
|
7986
|
+
function drawHeatmapRings() {
|
|
7987
|
+
let model = heatmapModel();
|
|
7988
|
+
let ordered = heatmapOrdered();
|
|
7989
|
+
let columns = ordered ? ordered.columns : model.refs;
|
|
7990
|
+
let tips = displayedTips().filter(function (d) {
|
|
7991
|
+
return d.x !== undefined;
|
|
7992
|
+
}).sort(function (p, q) {
|
|
7993
|
+
return p.x - q.x;
|
|
7994
|
+
});
|
|
7995
|
+
let n = tips.length;
|
|
7996
|
+
if (n < 1 || columns.length < 1) {
|
|
7997
|
+
_heatmapGeom = null;
|
|
7998
|
+
return;
|
|
7999
|
+
}
|
|
8000
|
+
let g = _svgGroup.append('g').attr('class', 'aptx-heatmap');
|
|
8001
|
+
let ink = _state.branchColorDefault;
|
|
8002
|
+
let ringW = heatmapRingWidth();
|
|
8003
|
+
let start = _radial.maxRad + tipLabelSpace() + HEATMAP_RADIAL_GAP;
|
|
8004
|
+
|
|
8005
|
+
// Angular boundaries, derived ONCE between neighbours, exactly as the
|
|
8006
|
+
// rectangular rows are: a per-tip half-step would round apart and leave
|
|
8007
|
+
// hairline seams all round the ring.
|
|
8008
|
+
let ang = tips.map(function (d) {
|
|
8009
|
+
return radialAngle(d.x);
|
|
8010
|
+
});
|
|
8011
|
+
let pad = n > 1 ? (ang[n - 1] - ang[0]) / (n - 1) / 2 : Math.PI / 8;
|
|
8012
|
+
let edge = new Array(n + 1);
|
|
8013
|
+
edge[0] = ang[0] - pad;
|
|
8014
|
+
for (let i = 1; i < n; ++i) {
|
|
8015
|
+
edge[i] = (ang[i - 1] + ang[i]) / 2;
|
|
8016
|
+
}
|
|
8017
|
+
edge[n] = ang[n - 1] + pad;
|
|
8018
|
+
|
|
8019
|
+
let blanks = 0;
|
|
8020
|
+
for (let c = 0; c < columns.length; ++c) {
|
|
8021
|
+
let r0 = start + (c * ringW);
|
|
8022
|
+
let r1 = r0 + ringW;
|
|
8023
|
+
let ref = columns[c].ref;
|
|
8024
|
+
// consecutive tips of the same colour merge into one sector, which
|
|
8025
|
+
// is what keeps a big circular tree drawable
|
|
8026
|
+
let runStart = -1;
|
|
8027
|
+
let runFill = null;
|
|
8028
|
+
let flush = function (endI) {
|
|
8029
|
+
if (runStart < 0) {
|
|
8030
|
+
return;
|
|
8031
|
+
}
|
|
8032
|
+
// Bled outward and clockwise only: the rings are drawn inner to
|
|
8033
|
+
// outer and each ring's cells in tip order, so the neighbour
|
|
8034
|
+
// that covers the overlap is always the one drawn next.
|
|
8035
|
+
// A BLANK is not bled -- it is background-coloured, so a seam
|
|
8036
|
+
// beside it shows nothing, and bleeding it would push its
|
|
8037
|
+
// outward OUTLINE under the next ring and cost it an edge.
|
|
8038
|
+
let bleed = (runFill === HEATMAP_BLANK) ? 0 : HEATMAP_RING_BLEED;
|
|
8039
|
+
let a1 = edge[endI] + (bleed / Math.max(1, r1));
|
|
8040
|
+
let path = g.append('path')
|
|
8041
|
+
.attr('d', heatmapSector(edge[runStart], a1, r0, r1 + bleed));
|
|
8042
|
+
if (runFill === HEATMAP_BLANK) {
|
|
8043
|
+
path.attr('fill', _state.backgroundColorDefault)
|
|
8044
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
|
|
8045
|
+
} else {
|
|
8046
|
+
path.attr('fill', runFill);
|
|
8047
|
+
}
|
|
8048
|
+
runStart = -1;
|
|
8049
|
+
runFill = null;
|
|
8050
|
+
};
|
|
8051
|
+
for (let i = 0; i < n; ++i) {
|
|
8052
|
+
let v = forester.heatmapValue(tips[i], ref);
|
|
8053
|
+
let fill = (v === null) ? HEATMAP_BLANK : heatmapColorOf(v);
|
|
8054
|
+
if (fill === HEATMAP_BLANK) {
|
|
8055
|
+
blanks++;
|
|
8056
|
+
}
|
|
8057
|
+
if (runFill === fill && (ringW < HEATMAP_LABEL_MIN_COL_W || fill !== HEATMAP_BLANK)) {
|
|
8058
|
+
continue;
|
|
8059
|
+
}
|
|
8060
|
+
flush(i);
|
|
8061
|
+
runStart = i;
|
|
8062
|
+
runFill = fill;
|
|
8063
|
+
}
|
|
8064
|
+
flush(n);
|
|
8065
|
+
}
|
|
8066
|
+
|
|
8067
|
+
// the stack's own edges, so a blank reads as a hole in a grid rather
|
|
8068
|
+
// than as a gap in the drawing
|
|
8069
|
+
[start, start + (columns.length * ringW)].forEach(function (r) {
|
|
8070
|
+
g.append('path')
|
|
8071
|
+
.attr('d', heatmapSector(edge[0], edge[n], r - 0.5, r + 0.5))
|
|
8072
|
+
.attr('fill', ink).attr('fill-opacity', 0.5);
|
|
8073
|
+
});
|
|
8074
|
+
|
|
8075
|
+
// Each ring's name, laid along the ring at the fan's seam -- the gap
|
|
8076
|
+
// the layout already leaves between the last tip and the first. It is
|
|
8077
|
+
// the one place a label can sit without covering a cell.
|
|
8078
|
+
if (ringW >= HEATMAP_RADIAL_MIN_NAMED_RING) {
|
|
8079
|
+
let seam = edge[n] + ((2 * Math.PI) - (edge[n] - edge[0])) / 2;
|
|
8080
|
+
let font = Math.max(7, Math.min(10, Math.round(ringW))) + 'px ' + FONT_DEFAULTS;
|
|
8081
|
+
columns.forEach(function (col, c) {
|
|
8082
|
+
let r = start + (c * ringW) + (ringW / 2);
|
|
8083
|
+
let xy = polarXY(seam, r);
|
|
8084
|
+
// TANGENTIAL: along the ring, not out along the spoke. polarXY
|
|
8085
|
+
// puts angle a at screen direction a - 90, so the tangent is a
|
|
8086
|
+
// itself -- rotating by a - 90 instead laid every name out
|
|
8087
|
+
// along the seam, where rings are one ring-width apart and the
|
|
8088
|
+
// names are several times that: measured, 44 overlapping pairs
|
|
8089
|
+
// out of 18 names. Flipped where it would otherwise read
|
|
8090
|
+
// upside down.
|
|
8091
|
+
let deg = ((seam * 180 / Math.PI) % 360 + 360) % 360;
|
|
8092
|
+
let flip = (deg > 90 && deg < 270);
|
|
8093
|
+
g.append('text')
|
|
8094
|
+
.attr('transform', 'translate(' + xy[0].toFixed(1) + ',' + xy[1].toFixed(1) + ') '
|
|
8095
|
+
+ 'rotate(' + (deg + (flip ? 180 : 0)).toFixed(1) + ')')
|
|
8096
|
+
.attr('text-anchor', 'middle')
|
|
8097
|
+
.attr('dy', '0.32em')
|
|
8098
|
+
.style('font', font)
|
|
8099
|
+
.style('fill', _state.labelColorDefault)
|
|
8100
|
+
.text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
|
|
8101
|
+
});
|
|
8102
|
+
}
|
|
8103
|
+
|
|
8104
|
+
// the hover surface: the whole annulus, hit-tested in polar coordinates
|
|
8105
|
+
g.append('path')
|
|
8106
|
+
.attr('class', 'aptx-heatmap-hover')
|
|
8107
|
+
.attr('d', heatmapSector(edge[0], edge[n], start, start + (columns.length * ringW)))
|
|
8108
|
+
.attr('fill', 'transparent')
|
|
8109
|
+
.on('mousemove', heatmapHoverMove)
|
|
8110
|
+
.on('mouseout', heatmapHoverOut);
|
|
8111
|
+
|
|
8112
|
+
_heatmapGeom = {
|
|
8113
|
+
circular: true, start: start, ringW: ringW, edge: edge, tips: tips,
|
|
8114
|
+
refs: columns, min: model.min, max: model.max, blanks: blanks
|
|
8115
|
+
};
|
|
8116
|
+
}
|
|
8117
|
+
|
|
8118
|
+
|
|
8119
|
+
// The scale, for the circular layout. The rectangular track hangs its
|
|
8120
|
+
// legend under the matrix, which a ring has no "under": this is the same
|
|
8121
|
+
// content -- the ramp, its two ends, the blank key, the column count -- as
|
|
8122
|
+
// a card in the corner the tree leaves free. Drawn in screen coordinates
|
|
8123
|
+
// like the domain legend, so it neither turns nor scales with the fan.
|
|
8124
|
+
function drawHeatmapRingLegend() {
|
|
8125
|
+
if (!_baseSvg) {
|
|
8126
|
+
return;
|
|
8127
|
+
}
|
|
8128
|
+
_baseSvg.selectAll('g.aptx-heatmap-ringlegend').remove();
|
|
8129
|
+
let size = svgSize();
|
|
8130
|
+
let m = heatmapModel();
|
|
8131
|
+
if (!heatmapCircular() || !size || !m) {
|
|
8132
|
+
return;
|
|
8133
|
+
}
|
|
8134
|
+
const PAD = 9;
|
|
8135
|
+
const BAR_W = 128;
|
|
8136
|
+
const FS = 9;
|
|
8137
|
+
let ink = _state.labelColorDefault;
|
|
8138
|
+
let frame = _state.branchColorDefault;
|
|
8139
|
+
let capFont = FS + 'px ' + FONT_DEFAULTS;
|
|
8140
|
+
let blanks = _heatmapGeom && _heatmapGeom.blanks > 0;
|
|
8141
|
+
let caption = m.refs.length + ' columns, one scale';
|
|
8142
|
+
let width = (2 * PAD) + Math.max(BAR_W, legendTextWidth(caption, capFont),
|
|
8143
|
+
blanks ? (HEATMAP_SCALE_BAR_H + 4 + legendTextWidth(HEATMAP_NOT_ASSESSED, capFont)) : 0);
|
|
8144
|
+
let rows = 2 + (blanks ? 1 : 0);
|
|
8145
|
+
let height = (2 * PAD) + HEATMAP_SCALE_BAR_H + (rows * (FS + 4));
|
|
8146
|
+
let x = PAD;
|
|
8147
|
+
let y = size.h - height - PAD; // bottom-left: the overview box lives bottom-right
|
|
8148
|
+
|
|
8149
|
+
let g = _baseSvg.append('g').attr('class', 'aptx-heatmap-ringlegend')
|
|
8150
|
+
.style('pointer-events', 'none');
|
|
8151
|
+
g.append('rect').attr('x', x).attr('y', y).attr('width', width).attr('height', height)
|
|
8152
|
+
.attr('rx', 5)
|
|
8153
|
+
.style('fill', _state.backgroundColorDefault).style('fill-opacity', 0.92)
|
|
8154
|
+
.style('stroke', frame).style('stroke-opacity', 0.5);
|
|
8155
|
+
|
|
8156
|
+
let gradId = 'aptx-heatmap-ringramp';
|
|
8157
|
+
let grad = g.append('defs').append('linearGradient').attr('id', gradId)
|
|
8158
|
+
.attr('x1', 0).attr('y1', 0).attr('x2', 1).attr('y2', 0);
|
|
8159
|
+
VIS_COLOR_RAMP.forEach(function (c, i) {
|
|
8160
|
+
grad.append('stop')
|
|
8161
|
+
.attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
|
|
8162
|
+
.attr('stop-color', c);
|
|
8163
|
+
});
|
|
8164
|
+
let barY = y + PAD;
|
|
8165
|
+
g.append('rect').attr('x', x + PAD).attr('y', barY)
|
|
8166
|
+
.attr('width', BAR_W).attr('height', HEATMAP_SCALE_BAR_H)
|
|
8167
|
+
.attr('fill', 'url(#' + gradId + ')')
|
|
8168
|
+
.attr('stroke', frame).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
|
|
8169
|
+
let line = barY + HEATMAP_SCALE_BAR_H + FS + 2;
|
|
8170
|
+
g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
|
|
8171
|
+
.style('font', capFont).style('fill', ink).text(heatmapNum(m.min));
|
|
8172
|
+
g.append('text').attr('x', x + PAD + BAR_W).attr('y', line).attr('text-anchor', 'end')
|
|
8173
|
+
.style('font', capFont).style('fill', ink).text(heatmapNum(m.max));
|
|
8174
|
+
if (blanks) {
|
|
8175
|
+
line += FS + 4;
|
|
8176
|
+
g.append('rect').attr('x', x + PAD).attr('y', line - HEATMAP_SCALE_BAR_H)
|
|
8177
|
+
.attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
|
|
8178
|
+
.attr('fill', _state.backgroundColorDefault)
|
|
8179
|
+
.attr('stroke', frame).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
|
|
8180
|
+
g.append('text').attr('x', x + PAD + HEATMAP_SCALE_BAR_H + 4).attr('y', line)
|
|
8181
|
+
.attr('text-anchor', 'start')
|
|
8182
|
+
.style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
|
|
8183
|
+
.text(HEATMAP_NOT_ASSESSED);
|
|
8184
|
+
}
|
|
8185
|
+
line += FS + 4;
|
|
8186
|
+
g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
|
|
8187
|
+
.style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
|
|
8188
|
+
.text(caption);
|
|
8189
|
+
}
|
|
8190
|
+
|
|
8191
|
+
function heatmapScrollTo(offset) {
|
|
8192
|
+
if (!_heatmapGeom) {
|
|
8193
|
+
return;
|
|
8194
|
+
}
|
|
8195
|
+
let max = Math.max(0, _heatmapGeom.total - _heatmapGeom.visible);
|
|
8196
|
+
let next = Math.max(0, Math.min(max, Math.round(offset)));
|
|
8197
|
+
if (next !== _heatmapColOffset) {
|
|
8198
|
+
_heatmapColOffset = next;
|
|
8199
|
+
drawHeatmapTrack();
|
|
8200
|
+
}
|
|
8201
|
+
}
|
|
8202
|
+
|
|
8203
|
+
// The hover readout, as on the desktop: the tip, the column and its
|
|
8204
|
+
// value, and the scale the colour was taken from -- so a colour can be
|
|
8205
|
+
// turned back into a number without reading the legend off the bottom.
|
|
8206
|
+
function heatmapHoverMove(event) {
|
|
8207
|
+
if (!_heatmapGeom) {
|
|
8208
|
+
return;
|
|
8209
|
+
}
|
|
8210
|
+
let p = d3.pointer(event, _svgGroup.node());
|
|
8211
|
+
let i;
|
|
8212
|
+
let r = -1;
|
|
8213
|
+
if (_heatmapGeom.circular) {
|
|
8214
|
+
// the same two questions in polar form: which ring, and which slice
|
|
8215
|
+
let rad = Math.sqrt((p[0] * p[0]) + (p[1] * p[1]));
|
|
8216
|
+
i = Math.floor((rad - _heatmapGeom.start) / _heatmapGeom.ringW);
|
|
8217
|
+
if (i < 0 || i >= _heatmapGeom.refs.length) {
|
|
8218
|
+
heatmapHoverOut();
|
|
8219
|
+
return;
|
|
8220
|
+
}
|
|
8221
|
+
// polarXY turns an angle a into (cos(a - pi/2), sin(a - pi/2)),
|
|
8222
|
+
// so the angle comes back the same way round
|
|
8223
|
+
let a = Math.atan2(p[1], p[0]) + (Math.PI / 2);
|
|
8224
|
+
let edge = _heatmapGeom.edge;
|
|
8225
|
+
let n = _heatmapGeom.tips.length;
|
|
8226
|
+
for (let k = 0; k < n; ++k) {
|
|
8227
|
+
// the fan can be laid anywhere on the circle (it rotates), so
|
|
8228
|
+
// the comparison is made modulo a full turn rather than on the
|
|
8229
|
+
// raw angle, which need not lie in the same period
|
|
8230
|
+
let lo = edge[k];
|
|
8231
|
+
let hi = edge[k + 1];
|
|
8232
|
+
let d = ((a - lo) % (2 * Math.PI) + (2 * Math.PI)) % (2 * Math.PI);
|
|
8233
|
+
if (d < (hi - lo)) {
|
|
8234
|
+
r = k;
|
|
8235
|
+
break;
|
|
8236
|
+
}
|
|
8237
|
+
}
|
|
8238
|
+
} else {
|
|
8239
|
+
i = Math.floor((p[0] - _heatmapGeom.originX) / _heatmapGeom.cw);
|
|
8240
|
+
if (i < 0 || i >= _heatmapGeom.visible) {
|
|
8241
|
+
heatmapHoverOut();
|
|
8242
|
+
return;
|
|
8243
|
+
}
|
|
8244
|
+
for (let k = 0; k < _heatmapGeom.tips.length; ++k) {
|
|
8245
|
+
if (p[1] >= _heatmapGeom.bounds[k] && p[1] < _heatmapGeom.bounds[k + 1]) {
|
|
8246
|
+
r = k;
|
|
8247
|
+
break;
|
|
8248
|
+
}
|
|
8249
|
+
}
|
|
8250
|
+
}
|
|
8251
|
+
if (r < 0) {
|
|
8252
|
+
heatmapHoverOut();
|
|
8253
|
+
return;
|
|
8254
|
+
}
|
|
8255
|
+
let tip = _heatmapGeom.tips[r];
|
|
8256
|
+
let col = _heatmapGeom.refs[(_heatmapGeom.offset || 0) + i];
|
|
8257
|
+
let v = forester.heatmapValue(tip, col.ref);
|
|
8258
|
+
let txt = 'Tip: ' + (displayNodeName(tip) || '?') + '<br>'
|
|
8259
|
+
+ col.label + ': ' + (v === null ? HEATMAP_NOT_ASSESSED : heatmapNum(v)) + '<br>'
|
|
8260
|
+
+ 'Scale: ' + heatmapNum(_heatmapGeom.min) + ' – ' + heatmapNum(_heatmapGeom.max);
|
|
8261
|
+
let tip_el = _node_mouseover_div.node();
|
|
8262
|
+
tip_el.classList.remove('aptx-light', 'aptx-dark');
|
|
8263
|
+
if (_panelTheme) {
|
|
8264
|
+
tip_el.classList.add('aptx-' + _panelTheme);
|
|
8265
|
+
}
|
|
8266
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
|
|
8267
|
+
placeHoverReadout(tip_el, event);
|
|
8268
|
+
_node_mouseover_div.transition().duration(100).style('opacity', 0.95);
|
|
8269
|
+
}
|
|
8270
|
+
|
|
8271
|
+
function heatmapHoverOut() {
|
|
8272
|
+
_node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
|
|
8273
|
+
}
|
|
8274
|
+
|
|
8275
|
+
|
|
8276
|
+
// The reader's own column order, as the desktop's Annotation Fields arrows
|
|
8277
|
+
// set it. Theirs also chooses WHICH fields are columns and of what type;
|
|
8278
|
+
// ours takes every numeric field automatically, so this dialog is about
|
|
8279
|
+
// order and nothing else.
|
|
8280
|
+
//
|
|
8281
|
+
// Moving anything here makes the order Manual, and Manual never re-sorts --
|
|
8282
|
+
// a sorting mode that quietly undid a move would make the move pointless.
|
|
8283
|
+
// The same rule as the desktop's resolveEdited, minus its "did the order
|
|
8284
|
+
// actually change" test, which it needs only because its dialog does other
|
|
8285
|
+
// things too.
|
|
8286
|
+
function showHeatmapReorderDialog() {
|
|
8287
|
+
let ordered = heatmapOrdered();
|
|
8288
|
+
if (!ordered || ordered.columns.length < 2) {
|
|
8289
|
+
return;
|
|
8290
|
+
}
|
|
8291
|
+
let shell = makeDialogShell(HEATMAP_REORDER_DIALOG, 'Order columns', 320);
|
|
8292
|
+
let body = shell.body;
|
|
8293
|
+
repsElement(body, 'div', 'aptx-reps-lead',
|
|
8294
|
+
'Drag a column, or use the arrows. Your order is kept until you choose another.');
|
|
8295
|
+
let list = repsElement(body, 'div', 'aptx-reorder');
|
|
8296
|
+
let working = ordered.columns.slice();
|
|
8297
|
+
|
|
8298
|
+
function rowAt(y) {
|
|
8299
|
+
let rows = Array.prototype.slice.call(list.children);
|
|
8300
|
+
for (let i = 0; i < rows.length; ++i) {
|
|
8301
|
+
let b = rows[i].getBoundingClientRect();
|
|
8302
|
+
if (y < b.top + (b.height / 2)) {
|
|
8303
|
+
return i;
|
|
8304
|
+
}
|
|
8305
|
+
}
|
|
8306
|
+
return rows.length;
|
|
8307
|
+
}
|
|
8308
|
+
|
|
8309
|
+
function move(from, to) {
|
|
8310
|
+
if (to < 0 || to >= working.length || from === to) {
|
|
8311
|
+
return false;
|
|
8312
|
+
}
|
|
8313
|
+
working.splice(to, 0, working.splice(from, 1)[0]);
|
|
8314
|
+
draw();
|
|
8315
|
+
return true;
|
|
8316
|
+
}
|
|
8317
|
+
|
|
8318
|
+
function draw() {
|
|
8319
|
+
list.textContent = '';
|
|
8320
|
+
working.forEach(function (col, i) {
|
|
8321
|
+
let row = repsElement(list, 'div', 'aptx-reorder-row');
|
|
8322
|
+
row.tabIndex = 0;
|
|
8323
|
+
repsElement(row, 'span', 'aptx-reorder-grip', '≡');
|
|
8324
|
+
repsElement(row, 'span', 'aptx-reorder-name', col.label || col.ref).title = col.ref;
|
|
8325
|
+
let up = repsElement(row, 'button', 'aptx-reorder-move', '↑');
|
|
8326
|
+
let down = repsElement(row, 'button', 'aptx-reorder-move', '↓');
|
|
8327
|
+
up.type = 'button';
|
|
8328
|
+
down.type = 'button';
|
|
8329
|
+
up.title = 'Move up';
|
|
8330
|
+
down.title = 'Move down';
|
|
8331
|
+
up.disabled = (i === 0);
|
|
8332
|
+
down.disabled = (i === working.length - 1);
|
|
8333
|
+
up.addEventListener('click', function () {
|
|
8334
|
+
if (move(i, i - 1)) {
|
|
8335
|
+
list.children[i - 1].focus();
|
|
8336
|
+
}
|
|
8337
|
+
});
|
|
8338
|
+
down.addEventListener('click', function () {
|
|
8339
|
+
if (move(i, i + 1)) {
|
|
8340
|
+
list.children[i + 1].focus();
|
|
8341
|
+
}
|
|
8342
|
+
});
|
|
8343
|
+
// the arrows reach the same places from the keyboard, so a row
|
|
8344
|
+
// can be moved without a pointer at all
|
|
8345
|
+
row.addEventListener('keydown', function (event) {
|
|
8346
|
+
let d = (event.key === 'ArrowUp') ? -1 : ((event.key === 'ArrowDown') ? 1 : 0);
|
|
8347
|
+
if (d === 0) {
|
|
8348
|
+
return;
|
|
8349
|
+
}
|
|
8350
|
+
event.preventDefault();
|
|
8351
|
+
if (move(i, i + d)) {
|
|
8352
|
+
list.children[i + d].focus();
|
|
8353
|
+
}
|
|
8354
|
+
});
|
|
8355
|
+
row.addEventListener('pointerdown', function (event) {
|
|
8356
|
+
if (event.target.classList.contains('aptx-reorder-move')) {
|
|
8357
|
+
return; // the arrows are not a drag handle
|
|
8358
|
+
}
|
|
8359
|
+
event.preventDefault();
|
|
8360
|
+
row.classList.add('aptx-reorder-dragging');
|
|
8361
|
+
// The LIVE node is moved rather than the list redrawn: a
|
|
8362
|
+
// redraw would replace the element this drag is following.
|
|
8363
|
+
// Its position is read back from the DOM each time instead
|
|
8364
|
+
// of being tracked in a variable -- the first cut kept an
|
|
8365
|
+
// index, updated it, and then used it to work out where to
|
|
8366
|
+
// insert, by which point it no longer meant what the
|
|
8367
|
+
// calculation assumed.
|
|
8368
|
+
let onMove = function (ev) {
|
|
8369
|
+
let kids = Array.prototype.slice.call(list.children);
|
|
8370
|
+
let from = kids.indexOf(row);
|
|
8371
|
+
let to = rowAt(ev.clientY);
|
|
8372
|
+
if (to > from) {
|
|
8373
|
+
to -= 1; // taking the row out shifts everything after it up one
|
|
8374
|
+
}
|
|
8375
|
+
if (to === from || to < 0 || to >= kids.length) {
|
|
8376
|
+
return;
|
|
8377
|
+
}
|
|
8378
|
+
working.splice(to, 0, working.splice(from, 1)[0]);
|
|
8379
|
+
let others = kids.filter(function (k) {
|
|
8380
|
+
return k !== row;
|
|
8381
|
+
});
|
|
8382
|
+
list.insertBefore(row, others[to] || null);
|
|
8383
|
+
Array.prototype.slice.call(list.children).forEach(function (r, k) {
|
|
8384
|
+
let btns = r.querySelectorAll('.aptx-reorder-move');
|
|
8385
|
+
btns[0].disabled = (k === 0);
|
|
8386
|
+
btns[1].disabled = (k === list.children.length - 1);
|
|
8387
|
+
});
|
|
8388
|
+
};
|
|
8389
|
+
let onUp = function () {
|
|
8390
|
+
row.classList.remove('aptx-reorder-dragging');
|
|
8391
|
+
window.removeEventListener('pointermove', onMove);
|
|
8392
|
+
window.removeEventListener('pointerup', onUp);
|
|
8393
|
+
draw();
|
|
8394
|
+
};
|
|
8395
|
+
window.addEventListener('pointermove', onMove);
|
|
8396
|
+
window.addEventListener('pointerup', onUp);
|
|
8397
|
+
});
|
|
8398
|
+
});
|
|
8399
|
+
}
|
|
8400
|
+
draw();
|
|
8401
|
+
|
|
8402
|
+
let actions = repsElement(body, 'div', 'aptx-reps-actions');
|
|
8403
|
+
repsButton(actions, 'Automatic', false, function () {
|
|
8404
|
+
// back to whatever the data asks for: the arrangement is dropped,
|
|
8405
|
+
// not merely overridden
|
|
8406
|
+
_state.heatmapManualOrder = null;
|
|
8407
|
+
_state.heatmapColumnOrder = null;
|
|
8408
|
+
shell.dialog.close();
|
|
8409
|
+
syncHeatmapControls();
|
|
8410
|
+
scheduleUpdate(null, 0);
|
|
8411
|
+
});
|
|
8412
|
+
repsButton(actions, 'Cancel', false, function () {
|
|
8413
|
+
shell.dialog.close();
|
|
8414
|
+
});
|
|
8415
|
+
repsButton(actions, 'Apply', true, function () {
|
|
8416
|
+
_state.heatmapManualOrder = working.map(function (c) {
|
|
8417
|
+
return c.ref;
|
|
8418
|
+
});
|
|
8419
|
+
_state.heatmapColumnOrder = 'manual';
|
|
8420
|
+
shell.dialog.close();
|
|
8421
|
+
syncHeatmapControls();
|
|
8422
|
+
scheduleUpdate(null, 0);
|
|
8423
|
+
});
|
|
8424
|
+
shell.dialog.showModal();
|
|
8425
|
+
// showModal() would otherwise land on the close button: focus belongs
|
|
8426
|
+
// on the list, where the arrow keys already move a row
|
|
8427
|
+
if (list.firstChild) {
|
|
8428
|
+
list.firstChild.focus();
|
|
8429
|
+
}
|
|
8430
|
+
}
|
|
8431
|
+
|
|
8432
|
+
function heatmapCbClicked() {
|
|
8433
|
+
_state.showHeatmap = getCheckboxValue(HEATMAP_CB);
|
|
8434
|
+
syncHeatmapControls();
|
|
8435
|
+
scheduleUpdate(null, 0);
|
|
8436
|
+
}
|
|
8437
|
+
|
|
7218
8438
|
// ===================== Collapsed clades =====================
|
|
7219
8439
|
// Collapsing is DISPLAY state on the node (d.collapsed) and nothing
|
|
7220
8440
|
// else: forester never sees it, the writers never see it, and the data
|
|
@@ -7468,7 +8688,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7468
8688
|
// The wedge's reach: the clade's nearest and farthest tips, in the
|
|
7469
8689
|
// layout's x. One edge runs to the nearest tip and the other to the
|
|
7470
8690
|
// farthest, so the shape itself shows how uneven the clade's branch
|
|
7471
|
-
// lengths are
|
|
8691
|
+
// lengths are. Christian chose this over the
|
|
7472
8692
|
// desktop's symmetric triangle on an average base (2026-09-14). A
|
|
7473
8693
|
// phylogram measures the tips; a cladogram, where every leaf sits on
|
|
7474
8694
|
// the tip column, gives the wedge one depth step.
|
|
@@ -7711,6 +8931,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
7711
8931
|
if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
|
|
7712
8932
|
s.msa = _state.showMsa === true;
|
|
7713
8933
|
}
|
|
8934
|
+
if (heatmapAvailable()) {
|
|
8935
|
+
s.heatmap = _state.showHeatmap === true;
|
|
8936
|
+
s.heatmapOrder = heatmapMode();
|
|
8937
|
+
if (_state.heatmapManualOrder) {
|
|
8938
|
+
s.heatmapManual = _state.heatmapManualOrder.slice();
|
|
8939
|
+
}
|
|
8940
|
+
}
|
|
7714
8941
|
if (_basicTreeProperties.domainArchitectures === true) {
|
|
7715
8942
|
s.domains = _state.showDomainArchitectures === true;
|
|
7716
8943
|
s.domainLabels = _state.domainLabels;
|
|
@@ -7831,6 +9058,17 @@ function (root, d3, forester, phyloXml) {
|
|
|
7831
9058
|
if (typeof s.msa === 'boolean') {
|
|
7832
9059
|
_state.showMsa = s.msa;
|
|
7833
9060
|
}
|
|
9061
|
+
if (typeof s.heatmap === 'boolean') {
|
|
9062
|
+
_state.showHeatmap = s.heatmap;
|
|
9063
|
+
}
|
|
9064
|
+
if (Array.isArray(s.heatmapManual) && s.heatmapManual.every(function (r) {
|
|
9065
|
+
return typeof r === 'string';
|
|
9066
|
+
})) {
|
|
9067
|
+
_state.heatmapManualOrder = s.heatmapManual.slice();
|
|
9068
|
+
}
|
|
9069
|
+
if (forester.HEATMAP_ORDER_MODES.indexOf(s.heatmapOrder) >= 0) {
|
|
9070
|
+
_state.heatmapColumnOrder = s.heatmapOrder;
|
|
9071
|
+
}
|
|
7834
9072
|
if (typeof s.domains === 'boolean') {
|
|
7835
9073
|
_state.showDomainArchitectures = s.domains;
|
|
7836
9074
|
}
|
|
@@ -7930,6 +9168,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
7930
9168
|
}
|
|
7931
9169
|
});
|
|
7932
9170
|
setCheckboxValue(MSA_CB, _state.showMsa === true);
|
|
9171
|
+
setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
|
|
9172
|
+
syncHeatmapControls();
|
|
7933
9173
|
setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
|
|
7934
9174
|
setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
|
|
7935
9175
|
setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
|
|
@@ -8152,7 +9392,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
8152
9392
|
{key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
|
|
8153
9393
|
{key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
|
|
8154
9394
|
{key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
|
|
8155
|
-
{key: 'x', label: 'X', shift: true, what: 'Time
|
|
9395
|
+
{key: 'x', label: 'X', shift: true, what: 'Time / divergence scale, or the time axis', run: toggleTimeAxis},
|
|
8156
9396
|
{key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
|
|
8157
9397
|
{key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
|
|
8158
9398
|
{key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
|
|
@@ -8222,9 +9462,45 @@ function (root, d3, forester, phyloXml) {
|
|
|
8222
9462
|
}
|
|
8223
9463
|
}
|
|
8224
9464
|
|
|
8225
|
-
|
|
8226
|
-
|
|
9465
|
+
/**
|
|
9466
|
+
* Redraws the tree with its branch lengths taken from the chosen metric:
|
|
9467
|
+
* TIME (the gaps between the nodes' dates) or DIVERGENCE (what the file's
|
|
9468
|
+
* own branch lengths measured, recorded at load). Both metrics stay on the
|
|
9469
|
+
* tree, so this is reversible and lossless -- a round trip restores the
|
|
9470
|
+
* loaded branch lengths exactly.
|
|
9471
|
+
*
|
|
9472
|
+
* @param scale 'time' or 'divergence'
|
|
9473
|
+
*/
|
|
9474
|
+
function setBranchScale(scale) {
|
|
9475
|
+
if (!_branchScaleAvailable || !_treeData || _state.branchScale === scale) {
|
|
9476
|
+
return;
|
|
9477
|
+
}
|
|
9478
|
+
_state.branchScale = scale;
|
|
9479
|
+
if (scale === 'time') {
|
|
9480
|
+
forester.applyTimeBranchLengths(_treeData);
|
|
9481
|
+
} else {
|
|
9482
|
+
forester.applyDivergenceBranchLengths(_treeData);
|
|
9483
|
+
}
|
|
9484
|
+
// the branch lengths ARE the layout, so everything measured from them
|
|
9485
|
+
// has to be measured again
|
|
9486
|
+
_basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
|
|
9487
|
+
setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, scale === 'time');
|
|
9488
|
+
setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, scale !== 'time');
|
|
9489
|
+
zoomToFit();
|
|
9490
|
+
}
|
|
9491
|
+
|
|
9492
|
+
function branchScaleButtonClicked() {
|
|
9493
|
+
setBranchScale(getCheckboxValue(BRANCH_SCALE_TIME_BUTTON) ? 'time' : 'divergence');
|
|
9494
|
+
}
|
|
9495
|
+
|
|
9496
|
+
// Shift+X. The switch takes this key over where the tree offers it, as was
|
|
9497
|
+
// always intended; on every other tree it still works the Time Axis
|
|
9498
|
+
// checkbox, so the key never does nothing.
|
|
8227
9499
|
function toggleTimeAxis() {
|
|
9500
|
+
if (_branchScaleAvailable) {
|
|
9501
|
+
setBranchScale(_state.branchScale === 'time' ? 'divergence' : 'time');
|
|
9502
|
+
return;
|
|
9503
|
+
}
|
|
8228
9504
|
let cb = byId(TIME_AXIS_CB);
|
|
8229
9505
|
if (!cb || cb.disabled) {
|
|
8230
9506
|
return;
|
|
@@ -8385,8 +9661,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
8385
9661
|
}
|
|
8386
9662
|
|
|
8387
9663
|
// The room the longest tip label takes: the same estimate the layout is
|
|
8388
|
-
// sized against (there are no font metrics in the model).
|
|
8389
|
-
|
|
9664
|
+
// sized against (there are no font metrics in the model). Used by the
|
|
9665
|
+
// domain tracks and by the heat map's rings, which both start past it --
|
|
9666
|
+
// hence the name, which used to say "domain".
|
|
9667
|
+
function tipLabelSpace() {
|
|
8390
9668
|
return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
|
|
8391
9669
|
}
|
|
8392
9670
|
|
|
@@ -8550,7 +9828,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
8550
9828
|
// Where each tip's architecture goes: its start along the track, the
|
|
8551
9829
|
// top and height of its boxes, and in the radial layouts the
|
|
8552
9830
|
// transform that turns (and for unrooted moves) it onto its spoke.
|
|
8553
|
-
let labelSpace =
|
|
9831
|
+
let labelSpace = tipLabelSpace();
|
|
8554
9832
|
let rows = [];
|
|
8555
9833
|
if (_state.circularDisplay) {
|
|
8556
9834
|
// every bar starts on one ring past the labels and rides its
|
|
@@ -8856,6 +10134,60 @@ function (root, d3, forester, phyloXml) {
|
|
|
8856
10134
|
setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
|
|
8857
10135
|
}
|
|
8858
10136
|
|
|
10137
|
+
// The control follows the toggle: hidden without it, and hidden in the
|
|
10138
|
+
// radial layouts, where no matrix is drawn to order.
|
|
10139
|
+
function syncHeatmapControls() {
|
|
10140
|
+
let fs = byId(HEATMAP_CONTROLS);
|
|
10141
|
+
if (!fs) {
|
|
10142
|
+
return;
|
|
10143
|
+
}
|
|
10144
|
+
fs.style.display = heatmapShown() ? '' : 'none';
|
|
10145
|
+
if (!heatmapAvailable()) {
|
|
10146
|
+
return;
|
|
10147
|
+
}
|
|
10148
|
+
setValue(HEATMAP_ORDER_SELECT, heatmapMode());
|
|
10149
|
+
let reorder = byId(HEATMAP_REORDER_BUTTON);
|
|
10150
|
+
if (reorder) {
|
|
10151
|
+
// two columns can only be in one order up to a flip, which the
|
|
10152
|
+
// arrows would make look like a choice
|
|
10153
|
+
reorder.disabled = !heatmapShown() || heatmapModel().refs.length < 2;
|
|
10154
|
+
}
|
|
10155
|
+
// phyloXML gives every node its own property list, so tips CAN list the
|
|
10156
|
+
// same columns in different orders -- and then "as in the input" is a
|
|
10157
|
+
// consensus rather than a transcription. Say which, for THIS tree:
|
|
10158
|
+
// nobody can tell by looking at the matrix.
|
|
10159
|
+
let sel = byId(HEATMAP_ORDER_SELECT);
|
|
10160
|
+
let opt = sel ? sel.querySelector('option[value="document"]') : null;
|
|
10161
|
+
if (opt) {
|
|
10162
|
+
let a = heatmapInputAgreement();
|
|
10163
|
+
opt.title = 'the order the input lists the columns in'
|
|
10164
|
+
+ ((!a || a.tips < 1) ? ''
|
|
10165
|
+
: (a.conflicting === 0)
|
|
10166
|
+
? ' \u2014 every tip here lists them the same way, so this is exactly that order'
|
|
10167
|
+
: ' \u2014 ' + a.conflicting + ' of ' + a.tips
|
|
10168
|
+
+ ' tips here list them in a different order, so this is the order most of them agree on');
|
|
10169
|
+
}
|
|
10170
|
+
}
|
|
10171
|
+
|
|
10172
|
+
function heatmapOrderChanged() {
|
|
10173
|
+
let v = getValue(HEATMAP_ORDER_SELECT);
|
|
10174
|
+
if (forester.HEATMAP_ORDER_MODES.indexOf(v) < 0 || v === heatmapMode()) {
|
|
10175
|
+
return;
|
|
10176
|
+
}
|
|
10177
|
+
if (v === 'manual' && !_state.heatmapManualOrder) {
|
|
10178
|
+
// Manual with nothing arranged yet freezes what is on screen. The
|
|
10179
|
+
// alternative -- an empty list, which means "no opinion" -- would
|
|
10180
|
+
// silently re-derive the order the reader just asked to keep.
|
|
10181
|
+
let o = heatmapOrdered();
|
|
10182
|
+
_state.heatmapManualOrder = o ? o.columns.map(function (c) {
|
|
10183
|
+
return c.ref;
|
|
10184
|
+
}) : null;
|
|
10185
|
+
}
|
|
10186
|
+
_state.heatmapColumnOrder = v;
|
|
10187
|
+
_heatmapColOffset = 0; // a different order: the old window means nothing
|
|
10188
|
+
scheduleUpdate(null, 0);
|
|
10189
|
+
}
|
|
10190
|
+
|
|
8859
10191
|
function domainsCbClicked() {
|
|
8860
10192
|
_state.showDomainArchitectures = getCheckboxValue(DOMAINS_CB);
|
|
8861
10193
|
if (_state.showDomainArchitectures && radialDisplay()) {
|
|
@@ -8966,7 +10298,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
8966
10298
|
return _state.showTimeAxis === true && !radialDisplay()
|
|
8967
10299
|
&& _state.phylogram === true
|
|
8968
10300
|
&& _timeInfo !== null && _timeInfo.type !== null
|
|
8969
|
-
&& _basicTreeProperties.branchLengths === true
|
|
10301
|
+
&& _basicTreeProperties.branchLengths === true
|
|
10302
|
+
// The axis is an overlay calibrated to the tree's own branch scale,
|
|
10303
|
+
// so it must be hidden when the branches are showing DIVERGENCE
|
|
10304
|
+
// and divergence is a different measure -- calendar years against
|
|
10305
|
+
// substitutions reads as a confident lie. Only then: where the two
|
|
10306
|
+
// metrics agree (a BEAST time tree, whose branch lengths ARE time)
|
|
10307
|
+
// there is no switch and the axis is calibrated correctly, which
|
|
10308
|
+
// is what makes a converted BEAST tree show its years at all.
|
|
10309
|
+
&& !(_branchScaleAvailable && _state.branchScale === 'divergence');
|
|
8970
10310
|
}
|
|
8971
10311
|
|
|
8972
10312
|
function timeAxisBottomReserve() {
|
|
@@ -8985,6 +10325,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
8985
10325
|
// or the bar covers them -- the old bare slider did exactly that.
|
|
8986
10326
|
// The scale bar sits in the same band, under the tree's last row.
|
|
8987
10327
|
return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
|
|
10328
|
+
heatmapBottomReserve(),
|
|
8988
10329
|
scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
|
|
8989
10330
|
}
|
|
8990
10331
|
|
|
@@ -9368,7 +10709,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
9368
10709
|
return;
|
|
9369
10710
|
}
|
|
9370
10711
|
let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
|
|
9371
|
-
let outer = maxRad + labelSpace + domainRadialExtent();
|
|
10712
|
+
let outer = maxRad + labelSpace + domainRadialExtent() + heatmapRadialExtent();
|
|
9372
10713
|
let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
|
|
9373
10714
|
let scale = 0.9 * (Math.min(W, H) / (2 * outer));
|
|
9374
10715
|
if (!isFinite(scale) || scale <= 0) {
|
|
@@ -9885,6 +11226,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
9885
11226
|
_radialRotation = 0;
|
|
9886
11227
|
_radialLabelsHorizontal = false;
|
|
9887
11228
|
_msaColOffset = 0;
|
|
11229
|
+
_heatmapColOffset = 0;
|
|
9888
11230
|
syncZoomRowButtons();
|
|
9889
11231
|
refreshVisualizations(false);
|
|
9890
11232
|
// Esc resets to the launch state -- the auto-applied colour, when its
|
|
@@ -10451,6 +11793,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
10451
11793
|
if (msaCb) {
|
|
10452
11794
|
msaCb.disabled = radialDisplay();
|
|
10453
11795
|
}
|
|
11796
|
+
let heatCb = byId(HEATMAP_CB);
|
|
11797
|
+
if (heatCb) {
|
|
11798
|
+
// circular draws the columns as rings; only unrooted cannot
|
|
11799
|
+
heatCb.disabled = _state.unrootedDisplay === true;
|
|
11800
|
+
}
|
|
11801
|
+
syncHeatmapControls();
|
|
10454
11802
|
let timeCb = byId(TIME_AXIS_CB);
|
|
10455
11803
|
if (timeCb) {
|
|
10456
11804
|
timeCb.disabled = radialDisplay();
|
|
@@ -11557,6 +12905,30 @@ function (root, d3, forester, phyloXml) {
|
|
|
11557
12905
|
+ '.aptx-reps-error:empty { display:none; }'
|
|
11558
12906
|
+ '.aptx-reps-line { padding:1px 0; }'
|
|
11559
12907
|
+ '.aptx-reps-question { margin-top:9px; font-weight:600; }'
|
|
12908
|
+
+ '.aptx-reorder { margin-top:8px; max-height:300px; overflow:auto; border:1px solid var(--p-line);'
|
|
12909
|
+
+ ' border-radius:7px; padding:3px; background:var(--p-surface2); touch-action:none; }'
|
|
12910
|
+
// the panel's own scrollbar treatment: a native one comes out bright
|
|
12911
|
+
// white inside a dark dialog
|
|
12912
|
+
+ '.aptx-reorder::-webkit-scrollbar { width:9px; }'
|
|
12913
|
+
+ '.aptx-reorder::-webkit-scrollbar-thumb { background:var(--p-line-strong); border-radius:9px;'
|
|
12914
|
+
+ ' border:2px solid var(--p-surface2); }'
|
|
12915
|
+
+ '.aptx-reorder::-webkit-scrollbar-track { background:transparent; }'
|
|
12916
|
+
+ '.aptx-reorder-row { display:flex; align-items:center; gap:7px; padding:3px 5px; border-radius:5px;'
|
|
12917
|
+
+ ' cursor:grab; user-select:none; border:1px solid transparent; }'
|
|
12918
|
+
+ '.aptx-reorder-row:hover { background:var(--p-accent-weak); }'
|
|
12919
|
+
+ '.aptx-reorder-row:focus-visible { outline:none; border-color:var(--p-accent);'
|
|
12920
|
+
+ ' box-shadow:0 0 0 2px var(--p-accent-weak); }'
|
|
12921
|
+
+ '.aptx-reorder-dragging { cursor:grabbing; background:var(--p-accent-weak);'
|
|
12922
|
+
+ ' border-color:var(--p-accent); opacity:0.9; }'
|
|
12923
|
+
+ '.aptx-reorder-grip { flex:none; color:var(--p-faint); font-size:12px; line-height:1; }'
|
|
12924
|
+
+ '.aptx-reorder-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;'
|
|
12925
|
+
+ ' white-space:nowrap; font-size:11px; color:var(--p-ink); }'
|
|
12926
|
+
+ '.aptx-reorder-move { flex:none; width:20px; height:20px; padding:0; line-height:1; font:inherit;'
|
|
12927
|
+
+ ' font-size:11px; color:var(--p-muted); background:transparent; border:1px solid transparent;'
|
|
12928
|
+
+ ' border-radius:5px; cursor:pointer; }'
|
|
12929
|
+
+ '.aptx-reorder-move:hover:not(:disabled) { background:var(--p-surface); color:var(--p-accent-ink);'
|
|
12930
|
+
+ ' border-color:var(--p-accent); }'
|
|
12931
|
+
+ '.aptx-reorder-move:disabled { opacity:0.3; cursor:default; }'
|
|
11560
12932
|
+ '.aptx-reps-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }'
|
|
11561
12933
|
+ '.aptx-reps-button { font:inherit; font-size:11px; height:26px; padding:0 12px; color:var(--p-ink);'
|
|
11562
12934
|
+ ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; cursor:pointer;'
|
|
@@ -11720,6 +13092,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
11720
13092
|
+ '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
|
|
11721
13093
|
+ '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
|
|
11722
13094
|
+ '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
|
|
13095
|
+
// ... except one that has a WORD on it: the rule above sizes the
|
|
13096
|
+
// domain track's tiny plus and minus, and it clipped this to "Reorc"
|
|
13097
|
+
+ '.aptx-panel .aptx-domrow input[type=button].aptx-widebtn { width:auto; flex:1 1 auto;'
|
|
13098
|
+
+ ' min-width:0; padding:0 8px; }'
|
|
11723
13099
|
+ '.aptx-panel .aptx-domrow select { flex:1 1 auto; min-width:0; }'
|
|
11724
13100
|
+ '.aptx-panel .aptx-domreadout { flex:1 1 auto; text-align:center; font-size:12px; font-variant-numeric:tabular-nums; color:var(--p-ink); }'
|
|
11725
13101
|
+ '.aptx-panel .aptx-glyph { height:14px; width:auto; display:block; overflow:visible; }'
|
|
@@ -12602,6 +13978,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
12602
13978
|
|
|
12603
13979
|
c0.insertAdjacentHTML('beforeend',makeDomainControls());
|
|
12604
13980
|
|
|
13981
|
+
c0.insertAdjacentHTML('beforeend',makeHeatmapControls());
|
|
13982
|
+
|
|
12605
13983
|
c0.insertAdjacentHTML('beforeend',makeZoomControl());
|
|
12606
13984
|
|
|
12607
13985
|
|
|
@@ -12715,6 +14093,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
12715
14093
|
|
|
12716
14094
|
on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
|
|
12717
14095
|
on(MSA_CB, 'click', msaCbClicked);
|
|
14096
|
+
on(HEATMAP_CB, 'click', heatmapCbClicked);
|
|
14097
|
+
on(HEATMAP_ORDER_SELECT, 'change', heatmapOrderChanged);
|
|
14098
|
+
on(HEATMAP_REORDER_BUTTON, 'click', showHeatmapReorderDialog);
|
|
12718
14099
|
on(DOMAINS_CB, 'click', domainsCbClicked);
|
|
12719
14100
|
on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
|
|
12720
14101
|
on(TREE_PREV_BUTTON, 'click', function () {
|
|
@@ -12736,6 +14117,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
12736
14117
|
on(TIME_GRID_CB, 'click', timeGridCbClicked);
|
|
12737
14118
|
|
|
12738
14119
|
on(LAYOUT_RECT_BUTTON, 'click', layoutButtonClicked);
|
|
14120
|
+
on(BRANCH_SCALE_TIME_BUTTON, 'click', branchScaleButtonClicked);
|
|
14121
|
+
on(BRANCH_SCALE_DIV_BUTTON, 'click', branchScaleButtonClicked);
|
|
12739
14122
|
|
|
12740
14123
|
on(LAYOUT_CIRC_BUTTON, 'click', layoutButtonClicked);
|
|
12741
14124
|
on(LAYOUT_UNROOTED_BUTTON, 'click', layoutButtonClicked);
|
|
@@ -13156,6 +14539,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
13156
14539
|
h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, 'phylogram display (uses branch length values) with aligned labels'));
|
|
13157
14540
|
h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, ' cladogram display (ignores branch length values)'));
|
|
13158
14541
|
h = h.concat('</div>');
|
|
14542
|
+
// The branch SCALE, when the tree states two different things: its
|
|
14543
|
+
// nodes' dates, and its branches' divergence. Hidden otherwise,
|
|
14544
|
+
// which is most trees.
|
|
14545
|
+
h = h.concat('<div class="' + BRANCH_SCALE_CONTROLGROUP + ' aptx-segmented">');
|
|
14546
|
+
h = h.concat(makeSegment('Time', BRANCH_SCALE_TIME_BUTTON, 'branch_scale_radio',
|
|
14547
|
+
'branch lengths measure TIME (the nodes\' dates)'));
|
|
14548
|
+
h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
|
|
14549
|
+
'branch lengths measure DIVERGENCE (substitutions, as the file states them)'));
|
|
14550
|
+
h = h.concat('</div>');
|
|
13159
14551
|
h = h.concat('</div>');
|
|
13160
14552
|
h = h.concat('</fieldset>');
|
|
13161
14553
|
return h;
|
|
@@ -13249,6 +14641,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
13249
14641
|
if (_basicTreeProperties.domainArchitectures) {
|
|
13250
14642
|
opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
|
|
13251
14643
|
}
|
|
14644
|
+
if (heatmapAvailable()) {
|
|
14645
|
+
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)'));
|
|
14646
|
+
}
|
|
13252
14647
|
if (_timeInfo && _timeInfo.type) {
|
|
13253
14648
|
opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
|
|
13254
14649
|
+ (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
|
|
@@ -13357,6 +14752,45 @@ function (root, d3, forester, phyloXml) {
|
|
|
13357
14752
|
return h;
|
|
13358
14753
|
}
|
|
13359
14754
|
|
|
14755
|
+
// Shown only while the Heat Map toggle is on (syncHeatmapControls).
|
|
14756
|
+
// The desktop's View > Order Matrix Columns, minus its Manual mode --
|
|
14757
|
+
// that one means "the order you dragged the rows into", and there is
|
|
14758
|
+
// no drag-to-reorder dialog here to drag them in.
|
|
14759
|
+
function makeHeatmapControls() {
|
|
14760
|
+
let h = '<fieldset id="' + HEATMAP_CONTROLS + '" style="display:none">';
|
|
14761
|
+
h = h.concat('<legend>Heat Map</legend>');
|
|
14762
|
+
h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + HEATMAP_ORDER_SELECT
|
|
14763
|
+
+ '">Order columns</label>');
|
|
14764
|
+
h = h.concat('<select name="' + HEATMAP_ORDER_SELECT + '" id="' + HEATMAP_ORDER_SELECT
|
|
14765
|
+
+ '" title="how the matrix columns are ordered; a clustered order also draws the clustering above the matrix">');
|
|
14766
|
+
// "input", not "file": the columns can come from a pasted tree or
|
|
14767
|
+
// a joined table as easily as from a file, and a reader looking at
|
|
14768
|
+
// a tree they dropped in would fairly ask "what file?". The title
|
|
14769
|
+
// is filled in per tree by syncHeatmapControls, because whether
|
|
14770
|
+
// this really IS the input's order depends on the input.
|
|
14771
|
+
h = h.concat('<option value="document">As in the input</option>');
|
|
14772
|
+
h = h.concat('<option value="clustered" title="columns whose values agree across the tips sit together:'
|
|
14773
|
+
+ ' complete-linkage clustering on Euclidean distance, the default of R\'s heat maps. A tip missing'
|
|
14774
|
+
+ ' either value is left out of that pair, never read as 0.">Clustered (co-occurrence)</option>');
|
|
14775
|
+
h = h.concat('<option value="clustered-presence" title="columns found in the same tips sit together: a tip'
|
|
14776
|
+
+ ' where BOTH columns are 0 is left out of that pair, so two rare genes no longer look alike merely'
|
|
14777
|
+
+ ' for being rare. Complete-linkage clustering on the Bray-Curtis dissimilarity. For values that are'
|
|
14778
|
+
+ ' 0 or more.">Clustered (ignoring shared absence)</option>');
|
|
14779
|
+
h = h.concat('<option value="alphabetical" title="by column name, ignoring case">Alphabetical</option>');
|
|
14780
|
+
h = h.concat('<option value="frequency" title="highest mean value first, over the tips that have a value'
|
|
14781
|
+
+ ' -- on 0/1 data, the fraction of tips carrying it">Frequency</option>');
|
|
14782
|
+
h = h.concat('<option value="manual" title="your own order, set with Reorder columns below. Nothing'
|
|
14783
|
+
+ ' re-sorts it.">Manual</option>');
|
|
14784
|
+
h = h.concat('</select></div>');
|
|
14785
|
+
h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel"></span>');
|
|
14786
|
+
h = h.concat('<input type="button" class="aptx-widebtn" value="Reorder columns…" name="'
|
|
14787
|
+
+ HEATMAP_REORDER_BUTTON + '" id="' + HEATMAP_REORDER_BUTTON
|
|
14788
|
+
+ '" title="drag the columns into the order you want; doing so sets Manual">');
|
|
14789
|
+
h = h.concat('</div>');
|
|
14790
|
+
h = h.concat('</fieldset>');
|
|
14791
|
+
return h;
|
|
14792
|
+
}
|
|
14793
|
+
|
|
13360
14794
|
function makeSliders() {
|
|
13361
14795
|
let h = "";
|
|
13362
14796
|
h = h.concat('<fieldset>');
|
|
@@ -13537,7 +14971,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
13537
14971
|
setCheckboxValue(VIS_CB, _state.showVisualizations);
|
|
13538
14972
|
setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
|
|
13539
14973
|
setCheckboxValue(MSA_CB, _state.showMsa);
|
|
14974
|
+
setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
|
|
13540
14975
|
syncDomainControls();
|
|
14976
|
+
syncHeatmapControls();
|
|
13541
14977
|
setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
|
|
13542
14978
|
setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
|
|
13543
14979
|
setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);
|
|
@@ -13631,6 +15067,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
13631
15067
|
disableCheckbox('#' + PHYLOGRAM_BUTTON);
|
|
13632
15068
|
disableCheckbox('#' + PHYLOGRAM_ALIGNED_BUTTON);
|
|
13633
15069
|
}
|
|
15070
|
+
setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
|
|
15071
|
+
setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
|
|
15072
|
+
// most trees state only one thing, and a control offering a choice
|
|
15073
|
+
// that does not exist is worse than no control
|
|
15074
|
+
let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
|
|
15075
|
+
if (scaleGroup) {
|
|
15076
|
+
scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
|
|
15077
|
+
}
|
|
13634
15078
|
}
|
|
13635
15079
|
|
|
13636
15080
|
|