archaeopteryx 3.21.0 → 3.23.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 +255 -9
- package/archaeopteryx.d.ts +24 -0
- package/archaeopteryx.js +1797 -38
- package/forester.js +762 -2
- 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.23.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.23.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';
|
|
@@ -248,6 +248,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
248
248
|
const LEGEND_LABEL_COLOR = 'legendLabelColor';
|
|
249
249
|
const LEGEND_NODE_SHAPE = 'legendNodeShape';
|
|
250
250
|
const LEGEND_DOMAINS = 'legendDomains';
|
|
251
|
+
const LEGEND_GENES = 'legendGenes';
|
|
251
252
|
const NH_EXPORT_FORMAT = 'Newick';
|
|
252
253
|
const NEXUS_EXPORT_FORMAT = 'Nexus';
|
|
253
254
|
const NODE_SIZE_MAX = 9;
|
|
@@ -380,6 +381,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
380
381
|
const DOMAIN_EVALUE_READOUT = 'domain_evalue';
|
|
381
382
|
const DOMAIN_LABELS_SELECT = 'domain_labels';
|
|
382
383
|
const DOMAIN_GLOW_CB = 'domain_glow_cb';
|
|
384
|
+
const GENES_CB = 'genes_cb';
|
|
385
|
+
const GENE_CONTROLS = 'gene_controls';
|
|
386
|
+
const GENE_WIDTH_DEC = 'gene_width_dec';
|
|
387
|
+
const GENE_WIDTH_INC = 'gene_width_inc';
|
|
388
|
+
const GENE_ANCHOR_SELECT = 'gene_anchor';
|
|
389
|
+
const GENE_FAMILY_SELECT = 'gene_family';
|
|
390
|
+
const GENE_LABELS_SELECT = 'gene_labels';
|
|
383
391
|
const SCALE_AXIS_CB = 'scaleaxis_cb';
|
|
384
392
|
const SCALE_GRID_CB = 'scalegrid_cb';
|
|
385
393
|
const MSA_SCROLL_ID = 'aptxmsascroll';
|
|
@@ -428,6 +436,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
428
436
|
const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
|
|
429
437
|
const BRANCH_SCALE_TIME_BUTTON = 'branch_scale_time_b';
|
|
430
438
|
const BRANCH_SCALE_DIV_BUTTON = 'branch_scale_div_b';
|
|
439
|
+
const CLOCK_PLOT_BUTTON = 'clock_plot_b';
|
|
440
|
+
const CLOCK_PLOT_DIALOG = 'aptx_clock_plot';
|
|
431
441
|
const ABOUT_DIALOG = 'aptx_about';
|
|
432
442
|
const TREE_PROPERTIES_DIALOG = 'aptx_tree_props';
|
|
433
443
|
const PROG_NAME = 'progname';
|
|
@@ -605,6 +615,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
605
615
|
const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
|
|
606
616
|
let _domain = null; // {width, palette, next, legendFrac}, set per launch
|
|
607
617
|
let _domainReserve = 0; // horizontal px reserved for the tracks, set with _w
|
|
618
|
+
// The gene track (genome regions) shares the domain track's widths, box
|
|
619
|
+
// heights, backbone colour and legend geometry: the two are the same
|
|
620
|
+
// kind of thing, a backbone with boxes beside each tip.
|
|
621
|
+
const GENE_LABEL_MODES = ['none', 'genes', 'legend'];
|
|
622
|
+
const GENE_KB_BAR_TARGET_PX = 80; // the track's own scale bar aims at this length
|
|
623
|
+
const GENE_CAP_W = 1.5; // px: the bar that marks a contig end
|
|
624
|
+
const GENE_LANE_MIN_H = 3; // px: a lane of two is never thinner
|
|
625
|
+
let _gene = null; // {width, radialWidth, palette, legendFrac, cache}, set per launch
|
|
626
|
+
let _geneReserve = 0; // horizontal px reserved for the gene track, set with _w
|
|
608
627
|
// ------ heat map (the desktop's MATRIX annotation columns) ------
|
|
609
628
|
const HEATMAP_TRACK_GAP = 8; // px between what is left of the tree and the matrix
|
|
610
629
|
const HEATMAP_PREF_COL_W = 14; // a column never grows past this
|
|
@@ -664,6 +683,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
664
683
|
let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
|
|
665
684
|
let _timeTree = false; // forester.isTimeTree: never re-rooted
|
|
666
685
|
let _branchScaleAvailable = false; // the tree states time AND divergence, so the switch is offered
|
|
686
|
+
let _clockPlotKind = null; // forester.clockPlotKind: which clock plot the tree has, if any
|
|
667
687
|
let _clusterH = 0; // the cluster layout's vertical extent, set per render
|
|
668
688
|
let _docListenersBound = false; // page-level key/wheel handlers bind once, not per launch
|
|
669
689
|
let _docListeners = []; // ...and destroy() can take every one of them down again
|
|
@@ -1184,7 +1204,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1184
1204
|
// the viewport: measured box=[0,13 1497x755] against
|
|
1185
1205
|
// view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
|
|
1186
1206
|
// no by precisely 254px and the overview appeared after every Fit.
|
|
1187
|
-
maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
|
|
1207
|
+
maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve + _geneReserve;
|
|
1188
1208
|
minY -= heatmapTopReserve(); // the dendrogram band stands above the first row
|
|
1189
1209
|
if (scaleBarShown()) {
|
|
1190
1210
|
maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
|
|
@@ -1466,7 +1486,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
1466
1486
|
return hsvToHex({h: hsv.h, s: Math.max(hsv.s, HOVER_GLOW_SAT_FLOOR), v: Math.max(hsv.v, HOVER_GLOW_VAL_FLOOR)});
|
|
1467
1487
|
}
|
|
1468
1488
|
|
|
1469
|
-
|
|
1489
|
+
// The glow's three discs, in `g`, in the colour of node d.
|
|
1490
|
+
function paintHoverGlow(g, d) {
|
|
1491
|
+
let dia = Math.max(HOVER_GLOW_MIN_DIA, (_state.nodeSizeDefault || 0) * 3);
|
|
1492
|
+
let fill = hoverGlowColor(d);
|
|
1493
|
+
HOVER_GLOW_RADII.forEach(function (radius, i) {
|
|
1494
|
+
g.append('circle')
|
|
1495
|
+
.attr('fill-opacity', HOVER_GLOW_ALPHAS[i])
|
|
1496
|
+
.attr('stroke', 'none')
|
|
1497
|
+
.attr('fill', fill)
|
|
1498
|
+
.attr('r', (dia * radius) / 2);
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Lights node d. `also` lights more nodes with it: one point of the clock
|
|
1503
|
+
// plot can stand for several tips. They are drawn INSIDE the one glow
|
|
1504
|
+
// group, placed from d, so whatever hides, moves or strips the glow
|
|
1505
|
+
// (hideHoverGlow, an export) takes all of them.
|
|
1506
|
+
function showHoverGlow(d, also) {
|
|
1470
1507
|
if (!_svgGroup || !d || d.x === undefined) {
|
|
1471
1508
|
return;
|
|
1472
1509
|
}
|
|
@@ -1474,23 +1511,21 @@ function (root, d3, forester, phyloXml) {
|
|
|
1474
1511
|
if (g.empty()) {
|
|
1475
1512
|
g = _svgGroup.append('g').attr('class', 'aptx-hoverglow')
|
|
1476
1513
|
.style('pointer-events', 'none');
|
|
1477
|
-
HOVER_GLOW_RADII.forEach(function (unused, i) {
|
|
1478
|
-
g.append('circle')
|
|
1479
|
-
.attr('fill-opacity', HOVER_GLOW_ALPHAS[i])
|
|
1480
|
-
.attr('stroke', 'none');
|
|
1481
|
-
});
|
|
1482
1514
|
}
|
|
1483
|
-
|
|
1515
|
+
g.selectAll('*').remove();
|
|
1484
1516
|
let p = layoutPointXY(d);
|
|
1485
|
-
let fill = hoverGlowColor(d);
|
|
1486
1517
|
g.attr('transform', 'translate(' + p[0] + ',' + p[1] + ')')
|
|
1487
1518
|
.style('display', null)
|
|
1488
1519
|
.raise();
|
|
1489
|
-
g
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
return
|
|
1493
|
-
}
|
|
1520
|
+
paintHoverGlow(g, d);
|
|
1521
|
+
(also || []).forEach(function (n) {
|
|
1522
|
+
if (!n || n === d || n.x === undefined) {
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
let q = layoutPointXY(n);
|
|
1526
|
+
paintHoverGlow(g.append('g').attr('class', 'aptx-hoverglow-also')
|
|
1527
|
+
.attr('transform', 'translate(' + (q[0] - p[0]) + ',' + (q[1] - p[1]) + ')'), n);
|
|
1528
|
+
});
|
|
1494
1529
|
}
|
|
1495
1530
|
|
|
1496
1531
|
function hideHoverGlow() {
|
|
@@ -1501,6 +1536,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1501
1536
|
|
|
1502
1537
|
function mouseover(event, d) {
|
|
1503
1538
|
showHoverGlow(d);
|
|
1539
|
+
markClockPlot(d);
|
|
1504
1540
|
// Start empty so the previous node's text cannot flash while this one
|
|
1505
1541
|
// fades in; mousemove fills it in immediately after. Clearing here
|
|
1506
1542
|
// rather than on the way out is what keeps the tooltip from collapsing
|
|
@@ -1814,6 +1850,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1814
1850
|
|
|
1815
1851
|
function mouseout() {
|
|
1816
1852
|
hideHoverGlow();
|
|
1853
|
+
markClockPlot(null);
|
|
1817
1854
|
// Fade only. Emptying the tooltip here collapsed it to nothing but its
|
|
1818
1855
|
// padding and border -- a small rounded pill -- and THAT is what sat
|
|
1819
1856
|
// there fading out afterwards. The content is cleared on the next
|
|
@@ -2900,8 +2937,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
2900
2937
|
// the alignment track reserves its window on the right, so the
|
|
2901
2938
|
// tree and labels compress to make room rather than overlapping
|
|
2902
2939
|
_msaReserve = 0;
|
|
2903
|
-
// and the domain tracks reserve their column past the labels
|
|
2940
|
+
// and the domain tracks reserve their column past the labels,
|
|
2941
|
+
// the gene track its own past theirs
|
|
2904
2942
|
_domainReserve = domainReserve();
|
|
2943
|
+
_geneReserve = geneReserve();
|
|
2905
2944
|
// The heat map is budgeted FIRST of the two right-hand tracks: it
|
|
2906
2945
|
// wants a fixed, finite width (its columns at their preferred size
|
|
2907
2946
|
// and no more), while the alignment's band is a WINDOW that scrolls
|
|
@@ -2913,7 +2952,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
2913
2952
|
let vp0 = svgSize();
|
|
2914
2953
|
let vw0 = Math.min(_displayWidth, (vp0 && vp0.w) ? vp0.w : _displayWidth);
|
|
2915
2954
|
let hband = Math.max(HEATMAP_MIN_BAND_PX, Math.round(vw0 * HEATMAP_MAX_VIEWPORT_FRACTION));
|
|
2916
|
-
let hmax = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
|
|
2955
|
+
let hmax = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve - _geneReserve
|
|
2917
2956
|
- HEATMAP_TRACK_GAP - HEATMAP_MIN_TREE_PX;
|
|
2918
2957
|
hband = Math.max(HEATMAP_MIN_BAND_PX, Math.min(hband, hmax));
|
|
2919
2958
|
_heatmapReserve = HEATMAP_TRACK_GAP + Math.min(cols * HEATMAP_PREF_COL_W, hband);
|
|
@@ -2928,12 +2967,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
2928
2967
|
let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
|
|
2929
2968
|
// a wide alignment must not squeeze the tree itself away: the
|
|
2930
2969
|
// band yields until the tree keeps its minimum share
|
|
2931
|
-
let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
|
|
2970
|
+
let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve - _geneReserve
|
|
2932
2971
|
- _heatmapReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
|
|
2933
2972
|
band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
|
|
2934
2973
|
_msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
|
|
2935
2974
|
}
|
|
2936
|
-
_w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve -
|
|
2975
|
+
_w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve - _geneReserve
|
|
2976
|
+
- _heatmapReserve;
|
|
2937
2977
|
if (_w < 1) {
|
|
2938
2978
|
_w = 1;
|
|
2939
2979
|
}
|
|
@@ -3487,11 +3527,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
3487
3527
|
drawScaleBar();
|
|
3488
3528
|
drawDomainArchitectures();
|
|
3489
3529
|
drawDomainLegend();
|
|
3530
|
+
drawGeneTrack();
|
|
3531
|
+
drawGeneLegend();
|
|
3490
3532
|
drawMsaTrack();
|
|
3491
3533
|
drawHeatmapTrack();
|
|
3492
3534
|
drawTimeOverlays();
|
|
3493
3535
|
rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
|
|
3494
3536
|
updateSearchHitNavigation();
|
|
3537
|
+
renderClockPlot(false); // an open clock plot follows the view, the colours and the selection
|
|
3495
3538
|
noteViewChange();
|
|
3496
3539
|
}
|
|
3497
3540
|
|
|
@@ -5651,6 +5694,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
5651
5694
|
'domainLabels',
|
|
5652
5695
|
'domainGlow',
|
|
5653
5696
|
'domainEvalueExponent',
|
|
5697
|
+
'showGenes',
|
|
5698
|
+
'geneAnchor',
|
|
5699
|
+
'geneFamily',
|
|
5700
|
+
'geneLabels',
|
|
5654
5701
|
'showScaleAxis',
|
|
5655
5702
|
'showScaleGrid',
|
|
5656
5703
|
'showSupportDots',
|
|
@@ -5961,6 +6008,43 @@ function (root, d3, forester, phyloXml) {
|
|
|
5961
6008
|
+ ' with a missing or impossible from / to / E-value ignored');
|
|
5962
6009
|
}
|
|
5963
6010
|
}
|
|
6011
|
+
// Likewise a tree whose tips carry genome regions -- genes as located
|
|
6012
|
+
// sequences -- draws its gene track from the start, unless the caller
|
|
6013
|
+
// decided. The anchor family and the family namespace are chosen from
|
|
6014
|
+
// the tree ('auto') unless named; the label mode is the domain
|
|
6015
|
+
// track's.
|
|
6016
|
+
if (_state.showGenes === undefined) {
|
|
6017
|
+
_state.showGenes = _basicTreeProperties.geneRegions === true;
|
|
6018
|
+
}
|
|
6019
|
+
if (_state.geneLabels === undefined) {
|
|
6020
|
+
_state.geneLabels = 'genes';
|
|
6021
|
+
} else if (GENE_LABEL_MODES.indexOf(_state.geneLabels) < 0) {
|
|
6022
|
+
throw new Error(ERROR + '"geneLabels" must be "none", "genes" or "legend"');
|
|
6023
|
+
}
|
|
6024
|
+
if (_state.geneAnchor === undefined) {
|
|
6025
|
+
_state.geneAnchor = 'auto';
|
|
6026
|
+
} else if (typeof _state.geneAnchor !== 'string' || _state.geneAnchor.length === 0) {
|
|
6027
|
+
throw new Error(ERROR + '"geneAnchor" must be a family ref such as "pgfam:PGF_00019355", "auto" or "none"');
|
|
6028
|
+
}
|
|
6029
|
+
if (_state.geneFamily === undefined) {
|
|
6030
|
+
_state.geneFamily = 'auto';
|
|
6031
|
+
} else if (typeof _state.geneFamily !== 'string' || _state.geneFamily.length === 0) {
|
|
6032
|
+
throw new Error(ERROR + '"geneFamily" must be an annotation namespace such as "pgfam", or "auto"');
|
|
6033
|
+
}
|
|
6034
|
+
_gene = {width: null, radialWidth: null, palette: null, legendFrac: null, cache: new WeakMap()};
|
|
6035
|
+
if (_basicTreeProperties.geneRegions) {
|
|
6036
|
+
// a location outside the grammar and a gene on a second contig
|
|
6037
|
+
// are left out, never fatal -- said once, here
|
|
6038
|
+
let gs = forester.geneRegionStats(_treeData);
|
|
6039
|
+
if (gs.ignored > 0) {
|
|
6040
|
+
console.warn(MESSAGE + gs.ignored + ' sequence location' + (gs.ignored === 1 ? '' : 's')
|
|
6041
|
+
+ ' not in the form contig:start-end(+) ignored');
|
|
6042
|
+
}
|
|
6043
|
+
if (gs.otherContigs > 0) {
|
|
6044
|
+
console.warn(MESSAGE + gs.otherContigs + ' gene' + (gs.otherContigs === 1 ? '' : 's')
|
|
6045
|
+
+ ' on a genome\'s other contigs not drawn: the gene track shows one contig per genome');
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
5964
6048
|
// Likewise a dated tree draws its scale axis from the start -- a
|
|
5965
6049
|
// time axis of geologic ICS bands or calendar years, decided from the
|
|
5966
6050
|
// <date> elements by forester.timeAxisInfo -- again unless the caller
|
|
@@ -5973,6 +6057,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
5973
6057
|
// it arrived showing. An Auspice build arrives in the time view; a
|
|
5974
6058
|
// BEAST or Newick file arrives stating its own branch lengths.
|
|
5975
6059
|
_branchScaleAvailable = _treeData ? forester.hasTimeAndDivergence(_treeData) : false;
|
|
6060
|
+
_clockPlotKind = _treeData ? forester.clockPlotKind(_treeData) : null;
|
|
5976
6061
|
_state.branchScale = _treeData ? forester.branchLengthScale(_treeData) : 'divergence';
|
|
5977
6062
|
if (_state.showScaleAxis === undefined) {
|
|
5978
6063
|
// on for a tree SHOWN in time; a dated tree that opens in
|
|
@@ -6059,6 +6144,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
6059
6144
|
return null;
|
|
6060
6145
|
}
|
|
6061
6146
|
let s = n.sequences[0];
|
|
6147
|
+
// On a tree of genomes a tip's located sequences -- its
|
|
6148
|
+
// contig record and its genes -- describe the region drawn
|
|
6149
|
+
// beside it, not the tip: "S01 chromosome" is no name for
|
|
6150
|
+
// S01, and a gene's product even less so. They are no
|
|
6151
|
+
// label, so the node name stands.
|
|
6152
|
+
if (_basicTreeProperties.geneRegions && forester.parseSequenceLocation(s.location)) {
|
|
6153
|
+
return null;
|
|
6154
|
+
}
|
|
6062
6155
|
let l = '';
|
|
6063
6156
|
if (_state.showSequenceSymbol) {
|
|
6064
6157
|
l = joinFrag(l, s.symbol);
|
|
@@ -6408,6 +6501,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
6408
6501
|
document.querySelectorAll('dialog.aptx-dialog').forEach(function (dlg) {
|
|
6409
6502
|
dlg.remove();
|
|
6410
6503
|
});
|
|
6504
|
+
_clockPlot = null;
|
|
6505
|
+
_clockPlotKind = null;
|
|
6411
6506
|
if (_msaNav) {
|
|
6412
6507
|
_msaNav.remove();
|
|
6413
6508
|
_msaNav = null;
|
|
@@ -7374,7 +7469,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7374
7469
|
// once flung the whole tree off-screen after three X+ presses).
|
|
7375
7470
|
return {
|
|
7376
7471
|
horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve
|
|
7377
|
-
- _domainReserve - _heatmapReserve),
|
|
7472
|
+
- _domainReserve - _geneReserve - _heatmapReserve),
|
|
7378
7473
|
vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
|
|
7379
7474
|
- bottomOverlayReserve() - topOverlayReserve())
|
|
7380
7475
|
};
|
|
@@ -8722,9 +8817,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
8722
8817
|
}
|
|
8723
8818
|
|
|
8724
8819
|
// A faint dashed guide from each tip across to its row, as the
|
|
8725
|
-
// alignment track draws -- skipped when the domain tracks are
|
|
8726
|
-
// between, where it would run straight through their boxes.
|
|
8727
|
-
if (_domainReserve === 0) {
|
|
8820
|
+
// alignment track draws -- skipped when the domain or gene tracks are
|
|
8821
|
+
// in between, where it would run straight through their boxes.
|
|
8822
|
+
if (_domainReserve === 0 && _geneReserve === 0) {
|
|
8728
8823
|
let guideFont = _state.externalNodeFontSize + 'px ' + _state.defaultFont;
|
|
8729
8824
|
let guideGap = _state.nodeLabelGap;
|
|
8730
8825
|
let guideEnd = originX - 3;
|
|
@@ -9899,6 +9994,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
9899
9994
|
s.domainGlow = _state.domainGlow === true;
|
|
9900
9995
|
s.domainEvalue = _state.domainEvalueExponent;
|
|
9901
9996
|
}
|
|
9997
|
+
if (_basicTreeProperties.geneRegions === true) {
|
|
9998
|
+
s.genes = _state.showGenes === true;
|
|
9999
|
+
s.geneLabels = _state.geneLabels;
|
|
10000
|
+
// the EFFECTIVE choices, so that a link reproduces the figure
|
|
10001
|
+
// even where an automatic choice would have moved
|
|
10002
|
+
s.geneAnchor = geneAnchor() || 'none';
|
|
10003
|
+
s.geneFamily = geneFamilyNamespace() || 'auto';
|
|
10004
|
+
}
|
|
9902
10005
|
if (_basicTreeProperties.branchLengths === true) {
|
|
9903
10006
|
s.scaleAxis = _state.showScaleAxis === true;
|
|
9904
10007
|
s.scaleGrid = _state.showScaleGrid === true;
|
|
@@ -10061,6 +10164,21 @@ function (root, d3, forester, phyloXml) {
|
|
|
10061
10164
|
_domain.palette = null; // the drawn set changed: the palette is dealt again
|
|
10062
10165
|
}
|
|
10063
10166
|
}
|
|
10167
|
+
if (typeof s.genes === 'boolean') {
|
|
10168
|
+
_state.showGenes = s.genes;
|
|
10169
|
+
}
|
|
10170
|
+
if (GENE_LABEL_MODES.indexOf(s.geneLabels) >= 0) {
|
|
10171
|
+
_state.geneLabels = s.geneLabels;
|
|
10172
|
+
}
|
|
10173
|
+
if (typeof s.geneFamily === 'string' && s.geneFamily.length > 0 && s.geneFamily !== _state.geneFamily) {
|
|
10174
|
+
_state.geneFamily = s.geneFamily;
|
|
10175
|
+
if (_gene) {
|
|
10176
|
+
_gene.palette = null; // the families change with the namespace
|
|
10177
|
+
}
|
|
10178
|
+
}
|
|
10179
|
+
if (typeof s.geneAnchor === 'string' && s.geneAnchor.length > 0) {
|
|
10180
|
+
_state.geneAnchor = s.geneAnchor;
|
|
10181
|
+
}
|
|
10064
10182
|
// timeAxis / timeGrid: the names before the axis also measured
|
|
10065
10183
|
// distance, still read so that older links keep their axis
|
|
10066
10184
|
let axis = typeof s.scaleAxis === 'boolean' ? s.scaleAxis : s.timeAxis;
|
|
@@ -10073,8 +10191,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
10073
10191
|
}
|
|
10074
10192
|
if (!radialDisplay()) {
|
|
10075
10193
|
_radialLabelsHorizontal = false;
|
|
10076
|
-
} else if (_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures)
|
|
10077
|
-
|
|
10194
|
+
} else if ((_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures)
|
|
10195
|
+
|| (_state.showGenes && _basicTreeProperties.geneRegions)) {
|
|
10196
|
+
_radialLabelsHorizontal = false; // the domain boxes and gene arrows ride the spokes, so the labels must too
|
|
10078
10197
|
}
|
|
10079
10198
|
if (_vis) {
|
|
10080
10199
|
if (s.colorBy === 'none') {
|
|
@@ -10154,6 +10273,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
10154
10273
|
setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
|
|
10155
10274
|
setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
|
|
10156
10275
|
syncDomainControls();
|
|
10276
|
+
syncGeneControls();
|
|
10157
10277
|
setSliderValue(FONT_SIZE_SLIDER, _state.externalNodeFontSize);
|
|
10158
10278
|
setSliderValue(NODE_SIZE_SLIDER, _state.nodeSizeDefault);
|
|
10159
10279
|
setSliderValue(BRANCH_WIDTH_SLIDER, _state.branchWidthDefault);
|
|
@@ -10226,10 +10346,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
10226
10346
|
// The heat map's four keys and the alignment logo went in exactly that
|
|
10227
10347
|
// way, while the README promised a link reproduced the figure.
|
|
10228
10348
|
const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine',
|
|
10229
|
-
'heatmapOrder', 'scale'];
|
|
10349
|
+
'heatmapOrder', 'scale', 'geneAnchor', 'geneFamily', 'geneLabels'];
|
|
10230
10350
|
const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
|
|
10231
10351
|
const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
|
|
10232
|
-
const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow',
|
|
10352
|
+
const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow', 'genes',
|
|
10233
10353
|
'scaleAxis', 'scaleGrid', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
|
|
10234
10354
|
// Lists of strings, comma-joined. A property ref carries ':' but never a
|
|
10235
10355
|
// comma, and encodeViewValue leaves both readable in a hash.
|
|
@@ -10509,6 +10629,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
10509
10629
|
if (scaleGroup) {
|
|
10510
10630
|
scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
|
|
10511
10631
|
}
|
|
10632
|
+
syncClockPlotControl();
|
|
10512
10633
|
}
|
|
10513
10634
|
|
|
10514
10635
|
// The tree was EDITED (a node deleted). Whether it has two layouts is asked
|
|
@@ -10527,6 +10648,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
10527
10648
|
forester.captureDivergence(_treeData);
|
|
10528
10649
|
}
|
|
10529
10650
|
_branchScaleAvailable = forester.hasTimeAndDivergence(_treeData);
|
|
10651
|
+
_clockPlotKind = forester.clockPlotKind(_treeData);
|
|
10530
10652
|
if (was && _branchScaleAvailable && _state.branchScale === 'divergence') {
|
|
10531
10653
|
forester.applyDivergenceBranchLengths(_treeData);
|
|
10532
10654
|
} else if (was) {
|
|
@@ -10904,6 +11026,833 @@ function (root, d3, forester, phyloXml) {
|
|
|
10904
11026
|
shell.dialog.showModal();
|
|
10905
11027
|
}
|
|
10906
11028
|
|
|
11029
|
+
// ===================== Clock plot =====================
|
|
11030
|
+
// The tips of the tree on view as points, each one's date against its
|
|
11031
|
+
// divergence from the root, with a least-squares line through them (and
|
|
11032
|
+
// the ancestors' points too, when asked for): the picture TempEst,
|
|
11033
|
+
// TreeTime and Auspice's clock view draw, here as a
|
|
11034
|
+
// panel beside the tree rather than a layout of it, so an outlier is seen
|
|
11035
|
+
// on the plot and in the tree at once (Christian, 2026-10-02). What is
|
|
11036
|
+
// plotted, which trees have a plot and what the line is fitted to are
|
|
11037
|
+
// forester.clockPlotData's; this is the drawing and the link:
|
|
11038
|
+
//
|
|
11039
|
+
// - a point wears its node's colour in the tree (found, selected, the
|
|
11040
|
+
// Color visualization, the file's own styles);
|
|
11041
|
+
// - pointing at one lights its node in the tree, and pointing at a node
|
|
11042
|
+
// in the tree rings its point;
|
|
11043
|
+
// - a click selects or deselects the node, a drag selects the tips in
|
|
11044
|
+
// the box -- the tree's own selection, so everything that reads it
|
|
11045
|
+
// (the labels, the downloads, the host page) sees it;
|
|
11046
|
+
// - tips drawn on one spot are one dot, and the dot answers for all of
|
|
11047
|
+
// them: named together, lit together, selected together.
|
|
11048
|
+
//
|
|
11049
|
+
// The plot follows the view: in a subtree it is the clade's, with the
|
|
11050
|
+
// clade's own line. Modeless, and it can be dragged by its title: the
|
|
11051
|
+
// point is to work in the tree with it open.
|
|
11052
|
+
const CLOCK_PLOT_WHAT = 'clock plot: each tip\'s date against its divergence from the root, with a'
|
|
11053
|
+
+ ' line through the tips (its slope is the rate); a panel linked to the tree';
|
|
11054
|
+
const CLOCK_PLOT_W = 440;
|
|
11055
|
+
const CLOCK_PLOT_H = 300;
|
|
11056
|
+
const CLOCK_PLOT_PAD = {top: 10, right: 14, bottom: 38, left: 58};
|
|
11057
|
+
const CLOCK_PLOT_HIT_PX = 7; // how near the pointer has to be to a point
|
|
11058
|
+
const CLOCK_PLOT_SAME_PX = 1; // points nearer each other than this are one dot on the screen
|
|
11059
|
+
const CLOCK_PLOT_NAMES_MAX = 5; // how many of a dot's tips the readout names
|
|
11060
|
+
const CLOCK_PLOT_DRAG_PX = 4; // a press that moves further than this is a box, not a click
|
|
11061
|
+
const CLOCK_PLOT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
11062
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11063
|
+
let _clockPlot = null; // the open panel, or null
|
|
11064
|
+
// What the two checkboxes said last, kept while the page lives. Tips only
|
|
11065
|
+
// to begin with (Christian, 2026-10-02): the line is theirs, and a deep
|
|
11066
|
+
// root crowds them into a corner (dengue: a root in 1319, tips from 1944).
|
|
11067
|
+
let _clockPlotOptions = {line: true, internal: false};
|
|
11068
|
+
|
|
11069
|
+
// The button is shown where the tree has a plot, and lit while the panel
|
|
11070
|
+
// is open. (A tree that loses its plot -- the tip that made it deleted --
|
|
11071
|
+
// loses the panel at its next render: see renderClockPlot.)
|
|
11072
|
+
function syncClockPlotControl() {
|
|
11073
|
+
let button = byId(CLOCK_PLOT_BUTTON);
|
|
11074
|
+
if (button) {
|
|
11075
|
+
button.style.display = _clockPlotKind ? '' : 'none';
|
|
11076
|
+
button.classList.toggle('aptx-lit', !!_clockPlot);
|
|
11077
|
+
}
|
|
11078
|
+
}
|
|
11079
|
+
|
|
11080
|
+
function toggleClockPlot() {
|
|
11081
|
+
if (_clockPlot) {
|
|
11082
|
+
closeClockPlot();
|
|
11083
|
+
} else {
|
|
11084
|
+
showClockPlot();
|
|
11085
|
+
}
|
|
11086
|
+
}
|
|
11087
|
+
|
|
11088
|
+
// Closes the panel NOW. A dialog's own `close` event arrives in a later
|
|
11089
|
+
// task -- much later in a tab the browser is throttling -- and while the
|
|
11090
|
+
// viewer still believed the panel open, a press of the button "closed"
|
|
11091
|
+
// the closed panel again and did nothing (seen on the live demo,
|
|
11092
|
+
// 2026-10-02). So nothing here waits for the event.
|
|
11093
|
+
function closeClockPlot() {
|
|
11094
|
+
let cp = _clockPlot;
|
|
11095
|
+
if (!cp) {
|
|
11096
|
+
return;
|
|
11097
|
+
}
|
|
11098
|
+
_clockPlot = null;
|
|
11099
|
+
hideHoverGlow();
|
|
11100
|
+
if (cp.dialog.open) {
|
|
11101
|
+
cp.dialog.close();
|
|
11102
|
+
}
|
|
11103
|
+
cp.dialog.remove();
|
|
11104
|
+
syncClockPlotControl();
|
|
11105
|
+
}
|
|
11106
|
+
|
|
11107
|
+
function clockSvg(parent, tag, cls) {
|
|
11108
|
+
let el = document.createElementNS(SVG_NS, tag);
|
|
11109
|
+
if (cls) {
|
|
11110
|
+
el.setAttribute('class', cls);
|
|
11111
|
+
}
|
|
11112
|
+
parent.appendChild(el);
|
|
11113
|
+
return el;
|
|
11114
|
+
}
|
|
11115
|
+
|
|
11116
|
+
function showClockPlot() {
|
|
11117
|
+
if (!_clockPlotKind || !_treeData) {
|
|
11118
|
+
return;
|
|
11119
|
+
}
|
|
11120
|
+
let shell = makeDialogShell(CLOCK_PLOT_DIALOG, 'Clock plot', CLOCK_PLOT_W + 26);
|
|
11121
|
+
let body = shell.body;
|
|
11122
|
+
body.classList.add('aptx-clock');
|
|
11123
|
+
|
|
11124
|
+
let svg = clockSvg(body, 'svg', 'aptx-clock-svg');
|
|
11125
|
+
svg.setAttribute('width', CLOCK_PLOT_W);
|
|
11126
|
+
svg.setAttribute('height', CLOCK_PLOT_H);
|
|
11127
|
+
svg.setAttribute('viewBox', '0 0 ' + CLOCK_PLOT_W + ' ' + CLOCK_PLOT_H);
|
|
11128
|
+
svg.setAttribute('role', 'img');
|
|
11129
|
+
svg.setAttribute('aria-label', 'Clock plot: each tip\'s date against its divergence from the root');
|
|
11130
|
+
let clip = clockSvg(clockSvg(svg, 'defs'), 'clipPath');
|
|
11131
|
+
clip.id = CLOCK_PLOT_DIALOG + '_clip';
|
|
11132
|
+
let clipRect = clockSvg(clip, 'rect');
|
|
11133
|
+
clipRect.setAttribute('x', CLOCK_PLOT_PAD.left);
|
|
11134
|
+
clipRect.setAttribute('y', CLOCK_PLOT_PAD.top);
|
|
11135
|
+
clipRect.setAttribute('width', CLOCK_PLOT_W - CLOCK_PLOT_PAD.left - CLOCK_PLOT_PAD.right);
|
|
11136
|
+
clipRect.setAttribute('height', CLOCK_PLOT_H - CLOCK_PLOT_PAD.top - CLOCK_PLOT_PAD.bottom);
|
|
11137
|
+
// an opaque ground under the points: the dialog is frosted glass, and
|
|
11138
|
+
// the tree's colours showing through would read as data
|
|
11139
|
+
let ground = clockSvg(svg, 'rect', 'aptx-clock-ground');
|
|
11140
|
+
['x', 'y', 'width', 'height'].forEach(function (a) {
|
|
11141
|
+
ground.setAttribute(a, clipRect.getAttribute(a));
|
|
11142
|
+
});
|
|
11143
|
+
let axes = clockSvg(svg, 'g', 'aptx-clock-axes');
|
|
11144
|
+
let inside = clockSvg(svg, 'g');
|
|
11145
|
+
inside.setAttribute('clip-path', 'url(#' + clip.id + ')');
|
|
11146
|
+
let internal = clockSvg(inside, 'g', 'aptx-clock-internal');
|
|
11147
|
+
let line = clockSvg(inside, 'line', 'aptx-clock-line');
|
|
11148
|
+
let tips = clockSvg(inside, 'g', 'aptx-clock-tips');
|
|
11149
|
+
let ring = clockSvg(svg, 'circle', 'aptx-clock-ring');
|
|
11150
|
+
ring.setAttribute('r', 6.5);
|
|
11151
|
+
ring.style.display = 'none';
|
|
11152
|
+
let box = clockSvg(svg, 'rect', 'aptx-clock-box');
|
|
11153
|
+
box.style.display = 'none';
|
|
11154
|
+
|
|
11155
|
+
let options = document.createElement('div');
|
|
11156
|
+
options.className = 'aptx-clock-options';
|
|
11157
|
+
body.appendChild(options);
|
|
11158
|
+
let check = function (label, key, title) {
|
|
11159
|
+
let row = document.createElement('label');
|
|
11160
|
+
row.className = 'aptx-reps-choice';
|
|
11161
|
+
row.title = title;
|
|
11162
|
+
let input = document.createElement('input');
|
|
11163
|
+
input.type = 'checkbox';
|
|
11164
|
+
input.checked = _clockPlotOptions[key];
|
|
11165
|
+
input.addEventListener('change', function () {
|
|
11166
|
+
_clockPlotOptions[key] = input.checked;
|
|
11167
|
+
renderClockPlot(true);
|
|
11168
|
+
});
|
|
11169
|
+
let span = document.createElement('span');
|
|
11170
|
+
span.textContent = label;
|
|
11171
|
+
row.appendChild(input);
|
|
11172
|
+
row.appendChild(span);
|
|
11173
|
+
options.appendChild(row);
|
|
11174
|
+
return input;
|
|
11175
|
+
};
|
|
11176
|
+
check('Regression line', 'line', 'to show/hide the least-squares line through the tips');
|
|
11177
|
+
check('Internal nodes', 'internal', 'to show/hide the internal nodes: their dates were inferred,'
|
|
11178
|
+
+ ' and they take no part in the line');
|
|
11179
|
+
let deselect = document.createElement('button');
|
|
11180
|
+
deselect.type = 'button';
|
|
11181
|
+
deselect.className = 'aptx-reps-button aptx-clock-deselect';
|
|
11182
|
+
deselect.textContent = 'Deselect all';
|
|
11183
|
+
deselect.title = 'deselect every selected node of the tree';
|
|
11184
|
+
deselect.addEventListener('click', function () {
|
|
11185
|
+
if (_selectedNodes.size > 0) {
|
|
11186
|
+
_selectedNodes = new Set();
|
|
11187
|
+
selectionChangedByClockPlot();
|
|
11188
|
+
}
|
|
11189
|
+
});
|
|
11190
|
+
options.appendChild(deselect);
|
|
11191
|
+
|
|
11192
|
+
let stats = document.createElement('div');
|
|
11193
|
+
stats.className = 'aptx-clock-stats';
|
|
11194
|
+
body.appendChild(stats);
|
|
11195
|
+
let note = document.createElement('p');
|
|
11196
|
+
note.className = 'aptx-shortcuts-note aptx-clock-note';
|
|
11197
|
+
body.appendChild(note);
|
|
11198
|
+
|
|
11199
|
+
_clockPlot = {dialog: shell.dialog, svg: svg, axes: axes, internal: internal, line: line, tips: tips,
|
|
11200
|
+
ring: ring, box: box, stats: stats, note: note, deselect: deselect,
|
|
11201
|
+
data: null, marks: [], seen: null, hover: null, lays: 0, x: null, y: null};
|
|
11202
|
+
// the shell's close control, and anything else that closes the dialog
|
|
11203
|
+
let closed = function () {
|
|
11204
|
+
if (_clockPlot && _clockPlot.dialog === shell.dialog) {
|
|
11205
|
+
closeClockPlot();
|
|
11206
|
+
}
|
|
11207
|
+
};
|
|
11208
|
+
shell.dialog.querySelector('.aptx-dialog-close').addEventListener('click', closed);
|
|
11209
|
+
shell.dialog.addEventListener('close', closed);
|
|
11210
|
+
bindClockPlotPointer(_clockPlot);
|
|
11211
|
+
// modeless, as the cheat sheet is: the tree stays in reach
|
|
11212
|
+
shell.dialog.show();
|
|
11213
|
+
renderClockPlot(true);
|
|
11214
|
+
placeClockPlot(shell.dialog);
|
|
11215
|
+
makeDialogDraggable(shell.dialog);
|
|
11216
|
+
syncClockPlotControl();
|
|
11217
|
+
}
|
|
11218
|
+
|
|
11219
|
+
// In the tree area's top right corner, clear of the control panel where
|
|
11220
|
+
// there is room, and inside the window.
|
|
11221
|
+
//
|
|
11222
|
+
// The page around the viewer may keep something of its own in that corner,
|
|
11223
|
+
// above everything -- the demo page's tree chooser sits there -- and the
|
|
11224
|
+
// panel opened under it, its close control out of reach (seen on the live
|
|
11225
|
+
// demo, 2026-10-02; no harness has a host page). So the panel's top edge is
|
|
11226
|
+
// asked what is on it, and the panel goes below whatever is not ours.
|
|
11227
|
+
function placeClockPlot(dialog) {
|
|
11228
|
+
let area = _container ? _container.getBoundingClientRect() : null;
|
|
11229
|
+
let w = dialog.offsetWidth;
|
|
11230
|
+
let h = dialog.offsetHeight;
|
|
11231
|
+
let right = area ? Math.min(area.right, window.innerWidth) : window.innerWidth;
|
|
11232
|
+
let top = area ? Math.max(area.top, 0) : 0;
|
|
11233
|
+
let lowest = Math.max(6, window.innerHeight - h - 6);
|
|
11234
|
+
let put = function (y) {
|
|
11235
|
+
setStyles(dialog, {
|
|
11236
|
+
'position': 'fixed',
|
|
11237
|
+
'margin': '0',
|
|
11238
|
+
'left': Math.max(6, right - w - 14) + 'px',
|
|
11239
|
+
'top': Math.max(6, Math.min(lowest, y)) + 'px'
|
|
11240
|
+
});
|
|
11241
|
+
};
|
|
11242
|
+
put(top + 14);
|
|
11243
|
+
let box = dialog.getBoundingClientRect();
|
|
11244
|
+
let under = 0;
|
|
11245
|
+
[box.left + 12, (box.left + box.right) / 2, box.right - 14].forEach(function (px) {
|
|
11246
|
+
let over = document.elementFromPoint(px, box.top + 14);
|
|
11247
|
+
if (!over || dialog.contains(over) || (_container && _container.contains(over))) {
|
|
11248
|
+
return;
|
|
11249
|
+
}
|
|
11250
|
+
// the whole of what is in the way, not the link or label inside it
|
|
11251
|
+
while (over.parentElement && over.parentElement !== document.body
|
|
11252
|
+
&& !over.parentElement.contains(dialog)) {
|
|
11253
|
+
over = over.parentElement;
|
|
11254
|
+
}
|
|
11255
|
+
under = Math.max(under, over.getBoundingClientRect().bottom);
|
|
11256
|
+
});
|
|
11257
|
+
if (under > 0) {
|
|
11258
|
+
put(under + 8);
|
|
11259
|
+
}
|
|
11260
|
+
}
|
|
11261
|
+
|
|
11262
|
+
// A modeless dialog moved by its title bar, kept inside the window.
|
|
11263
|
+
function makeDialogDraggable(dialog) {
|
|
11264
|
+
let bar = dialog.querySelector('.aptx-dialog-title');
|
|
11265
|
+
if (!bar) {
|
|
11266
|
+
return;
|
|
11267
|
+
}
|
|
11268
|
+
bar.classList.add('aptx-dialog-drag');
|
|
11269
|
+
bar.addEventListener('pointerdown', function (e) {
|
|
11270
|
+
if (e.button !== 0 || (e.target.closest && e.target.closest('button'))) {
|
|
11271
|
+
return;
|
|
11272
|
+
}
|
|
11273
|
+
let start = dialog.getBoundingClientRect();
|
|
11274
|
+
let dx = e.clientX - start.left;
|
|
11275
|
+
let dy = e.clientY - start.top;
|
|
11276
|
+
let move = function (ev) {
|
|
11277
|
+
setStyles(dialog, {
|
|
11278
|
+
'position': 'fixed',
|
|
11279
|
+
'margin': '0',
|
|
11280
|
+
'left': Math.max(0, Math.min(window.innerWidth - 40, ev.clientX - dx)) + 'px',
|
|
11281
|
+
'top': Math.max(0, Math.min(window.innerHeight - 30, ev.clientY - dy)) + 'px'
|
|
11282
|
+
});
|
|
11283
|
+
};
|
|
11284
|
+
let up = function () {
|
|
11285
|
+
document.removeEventListener('pointermove', move);
|
|
11286
|
+
document.removeEventListener('pointerup', up);
|
|
11287
|
+
document.removeEventListener('pointercancel', up);
|
|
11288
|
+
};
|
|
11289
|
+
document.addEventListener('pointermove', move);
|
|
11290
|
+
document.addEventListener('pointerup', up);
|
|
11291
|
+
document.addEventListener('pointercancel', up);
|
|
11292
|
+
e.preventDefault();
|
|
11293
|
+
});
|
|
11294
|
+
}
|
|
11295
|
+
|
|
11296
|
+
// A number of the plot at three significant digits; very small or very
|
|
11297
|
+
// large in exponent form, which is how a rate is usually written.
|
|
11298
|
+
function clockNumber(v, digits) {
|
|
11299
|
+
if (typeof v !== 'number' || !isFinite(v)) {
|
|
11300
|
+
return '';
|
|
11301
|
+
}
|
|
11302
|
+
let sig = digits || 3;
|
|
11303
|
+
let a = Math.abs(v);
|
|
11304
|
+
if (a !== 0 && (a < 1e-4 || a >= 1e7)) {
|
|
11305
|
+
return v.toExponential(sig - 1);
|
|
11306
|
+
}
|
|
11307
|
+
return String(Number(v.toPrecision(sig)));
|
|
11308
|
+
}
|
|
11309
|
+
|
|
11310
|
+
// A date of the plot: a calendar year to two decimals (about four days),
|
|
11311
|
+
// anything else as a number.
|
|
11312
|
+
function clockDate(v, calendar) {
|
|
11313
|
+
return calendar ? String(forester.roundNumber(v, 2)) : clockNumber(v, 5);
|
|
11314
|
+
}
|
|
11315
|
+
|
|
11316
|
+
function clockTicksX(from, to, calendar) {
|
|
11317
|
+
if (calendar) {
|
|
11318
|
+
if ((to - from) >= 3) {
|
|
11319
|
+
return forester.calendarTickYears(from, to).map(function (y) {
|
|
11320
|
+
return {value: y, label: String(y)};
|
|
11321
|
+
});
|
|
11322
|
+
}
|
|
11323
|
+
let months = forester.calendarTickMonths(from, to);
|
|
11324
|
+
if (months.length >= 2) {
|
|
11325
|
+
return months.map(function (m) {
|
|
11326
|
+
return {value: m.value, label: CLOCK_PLOT_MONTHS[m.month - 1] + ' ' + m.year};
|
|
11327
|
+
});
|
|
11328
|
+
}
|
|
11329
|
+
}
|
|
11330
|
+
return d3.ticks(from, to, 6).map(function (v) {
|
|
11331
|
+
return {value: v, label: String(Number(v.toPrecision(12)))};
|
|
11332
|
+
});
|
|
11333
|
+
}
|
|
11334
|
+
|
|
11335
|
+
// The colour a node wears in the tree, for its point; null where it wears
|
|
11336
|
+
// none, and the point takes the panel's ink.
|
|
11337
|
+
function clockPointColor(node, tip) {
|
|
11338
|
+
let found = getFoundColor(node);
|
|
11339
|
+
if (found || !tip) {
|
|
11340
|
+
return found;
|
|
11341
|
+
}
|
|
11342
|
+
let vis = _state.showVisualizations ? visualizationColorFor(node) : null;
|
|
11343
|
+
if (vis) {
|
|
11344
|
+
return vis;
|
|
11345
|
+
}
|
|
11346
|
+
let style = nodeStyle(node);
|
|
11347
|
+
if (style && (style.nodeColor || style.fontColor)) {
|
|
11348
|
+
return style.nodeColor || style.fontColor;
|
|
11349
|
+
}
|
|
11350
|
+
if (_state.useVisualStyles && node.color) {
|
|
11351
|
+
return 'rgb(' + node.color.red + ',' + node.color.green + ',' + node.color.blue + ')';
|
|
11352
|
+
}
|
|
11353
|
+
return null;
|
|
11354
|
+
}
|
|
11355
|
+
|
|
11356
|
+
/**
|
|
11357
|
+
* Draws the open clock plot for the tree on view. Called at the end of
|
|
11358
|
+
* every render, so it has to cost next to nothing when what it shows has
|
|
11359
|
+
* not changed. The points are worked out and laid down again only for
|
|
11360
|
+
* another tree, another clade, a tree on view with a different number of
|
|
11361
|
+
* nodes (one deleted) or a checkbox; any other render -- a zoom step, a
|
|
11362
|
+
* selection -- costs a count of the nodes and one pass over the points
|
|
11363
|
+
* for their colours, with a DOM write only where one changed.
|
|
11364
|
+
*
|
|
11365
|
+
* Until review (2026-10-02) the points were worked out afresh on every
|
|
11366
|
+
* render and only the DRAWING was skipped, while this comment claimed
|
|
11367
|
+
* the opposite: 5 ms a render on a 7,199-node build, where the count
|
|
11368
|
+
* takes 0.2.
|
|
11369
|
+
*
|
|
11370
|
+
* @param force work everything out and lay it down again
|
|
11371
|
+
*/
|
|
11372
|
+
function renderClockPlot(force) {
|
|
11373
|
+
let cp = _clockPlot;
|
|
11374
|
+
if (!cp) {
|
|
11375
|
+
return;
|
|
11376
|
+
}
|
|
11377
|
+
if (!cp.dialog.isConnected) { // the viewer was torn down under it
|
|
11378
|
+
_clockPlot = null;
|
|
11379
|
+
return;
|
|
11380
|
+
}
|
|
11381
|
+
let top = _treeData ? (topNode() || forester.getTreeRoot(_treeData)) : null;
|
|
11382
|
+
let count = 0;
|
|
11383
|
+
if (top) {
|
|
11384
|
+
forester.preOrderTraversalAll(top, function () {
|
|
11385
|
+
++count;
|
|
11386
|
+
});
|
|
11387
|
+
}
|
|
11388
|
+
let seen = cp.seen; // (another tree is another top: no need to ask for both)
|
|
11389
|
+
if (force || !seen || seen.top !== top || seen.count !== count) {
|
|
11390
|
+
let data = top ? forester.clockPlotData(_treeData, top) : null;
|
|
11391
|
+
if (!data) {
|
|
11392
|
+
closeClockPlot(); // the tree no longer has a plot (a tip deleted)
|
|
11393
|
+
return;
|
|
11394
|
+
}
|
|
11395
|
+
cp.seen = {top: top, count: count};
|
|
11396
|
+
cp.data = data;
|
|
11397
|
+
layClockPlot(cp, data);
|
|
11398
|
+
}
|
|
11399
|
+
colorClockPlot(cp);
|
|
11400
|
+
// The render that brought us here put the tree's glow out (the node
|
|
11401
|
+
// may have moved). The pointer is still on its point, so the node is
|
|
11402
|
+
// lit again where it now is: a click on a point redraws the tree, and
|
|
11403
|
+
// left the point ringed and its node dark until the pointer moved to
|
|
11404
|
+
// another point and back.
|
|
11405
|
+
if (cp.hover) {
|
|
11406
|
+
lightClockNodes(cp.hover);
|
|
11407
|
+
}
|
|
11408
|
+
}
|
|
11409
|
+
|
|
11410
|
+
function layClockPlot(cp, data) {
|
|
11411
|
+
let calendar = data.forward && !!_timeInfo && _timeInfo.type === 'calendar';
|
|
11412
|
+
let shown = data.points.filter(function (p) {
|
|
11413
|
+
return p.tip || _clockPlotOptions.internal;
|
|
11414
|
+
});
|
|
11415
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
11416
|
+
shown.forEach(function (p) {
|
|
11417
|
+
x0 = Math.min(x0, p.date);
|
|
11418
|
+
x1 = Math.max(x1, p.date);
|
|
11419
|
+
y0 = Math.min(y0, p.div);
|
|
11420
|
+
y1 = Math.max(y1, p.div);
|
|
11421
|
+
});
|
|
11422
|
+
let padX = ((x1 - x0) * 0.04) || 0.5;
|
|
11423
|
+
let padY = ((y1 - y0) * 0.06) || (Math.abs(y1) * 0.06) || 1e-6;
|
|
11424
|
+
let left = CLOCK_PLOT_PAD.left;
|
|
11425
|
+
let right = CLOCK_PLOT_W - CLOCK_PLOT_PAD.right;
|
|
11426
|
+
let top = CLOCK_PLOT_PAD.top;
|
|
11427
|
+
let bottom = CLOCK_PLOT_H - CLOCK_PLOT_PAD.bottom;
|
|
11428
|
+
// time runs left to right, whichever way its numbers do
|
|
11429
|
+
let x = d3.scaleLinear().domain([x0 - padX, x1 + padX]).range(data.forward ? [left, right] : [right, left]);
|
|
11430
|
+
let y = d3.scaleLinear().domain([y0 - padY, y1 + padY]).range([bottom, top]);
|
|
11431
|
+
cp.x = x;
|
|
11432
|
+
cp.y = y;
|
|
11433
|
+
|
|
11434
|
+
// --- the frame, the ticks and what each axis measures
|
|
11435
|
+
while (cp.axes.firstChild) {
|
|
11436
|
+
cp.axes.removeChild(cp.axes.firstChild);
|
|
11437
|
+
}
|
|
11438
|
+
let seg = function (cls, xa, ya, xb, yb) {
|
|
11439
|
+
let l = clockSvg(cp.axes, 'line', cls);
|
|
11440
|
+
l.setAttribute('x1', xa);
|
|
11441
|
+
l.setAttribute('y1', ya);
|
|
11442
|
+
l.setAttribute('x2', xb);
|
|
11443
|
+
l.setAttribute('y2', yb);
|
|
11444
|
+
};
|
|
11445
|
+
let text = function (cls, s, tx, ty, anchor) {
|
|
11446
|
+
let t = clockSvg(cp.axes, 'text', cls);
|
|
11447
|
+
t.setAttribute('x', tx);
|
|
11448
|
+
t.setAttribute('y', ty);
|
|
11449
|
+
t.setAttribute('text-anchor', anchor);
|
|
11450
|
+
t.textContent = s;
|
|
11451
|
+
return t;
|
|
11452
|
+
};
|
|
11453
|
+
clockTicksX(x.domain()[0], x.domain()[1], calendar).forEach(function (tick) {
|
|
11454
|
+
let px = x(tick.value);
|
|
11455
|
+
seg('aptx-clock-grid', px, top, px, bottom);
|
|
11456
|
+
text('aptx-clock-tick', tick.label, px, bottom + 13, 'middle');
|
|
11457
|
+
});
|
|
11458
|
+
d3.ticks(y.domain()[0], y.domain()[1], 5).forEach(function (v) {
|
|
11459
|
+
let py = y(v);
|
|
11460
|
+
seg('aptx-clock-grid', left, py, right, py);
|
|
11461
|
+
text('aptx-clock-tick', String(Number(v.toPrecision(12))), left - 6, py + 3.5, 'end');
|
|
11462
|
+
});
|
|
11463
|
+
seg('aptx-clock-frame', left, bottom, right, bottom);
|
|
11464
|
+
seg('aptx-clock-frame', left, top, left, bottom);
|
|
11465
|
+
let unit = data.unit && !calendar ? ' (' + data.unit + ')' : '';
|
|
11466
|
+
text('aptx-clock-title', (data.forward ? 'Date' : 'Age') + unit, (left + right) / 2, CLOCK_PLOT_H - 6, 'middle');
|
|
11467
|
+
let yt = text('aptx-clock-title', 'Divergence (subs/site)', 0, 0, 'middle');
|
|
11468
|
+
yt.setAttribute('transform', 'translate(12,' + ((top + bottom) / 2) + ') rotate(-90)');
|
|
11469
|
+
|
|
11470
|
+
// --- the points: ancestors under the line, tips over it
|
|
11471
|
+
cp.marks = [];
|
|
11472
|
+
[cp.internal, cp.tips].forEach(function (g) {
|
|
11473
|
+
while (g.firstChild) {
|
|
11474
|
+
g.removeChild(g.firstChild);
|
|
11475
|
+
}
|
|
11476
|
+
});
|
|
11477
|
+
shown.forEach(function (p) {
|
|
11478
|
+
let c = clockSvg(p.tip ? cp.tips : cp.internal, 'circle');
|
|
11479
|
+
c.setAttribute('cx', x(p.date));
|
|
11480
|
+
c.setAttribute('cy', y(p.div));
|
|
11481
|
+
c.setAttribute('r', p.tip ? 3 : 2);
|
|
11482
|
+
cp.marks.push({p: p, el: c, px: x(p.date), py: y(p.div), key: ''});
|
|
11483
|
+
});
|
|
11484
|
+
|
|
11485
|
+
// --- the line, across the frame (clipped to it)
|
|
11486
|
+
let fit = data.fit;
|
|
11487
|
+
if (fit && _clockPlotOptions.line) {
|
|
11488
|
+
let d = x.domain();
|
|
11489
|
+
cp.line.setAttribute('x1', x(d[0]));
|
|
11490
|
+
cp.line.setAttribute('y1', y(fit.intercept + (fit.slope * d[0])));
|
|
11491
|
+
cp.line.setAttribute('x2', x(d[1]));
|
|
11492
|
+
cp.line.setAttribute('y2', y(fit.intercept + (fit.slope * d[1])));
|
|
11493
|
+
cp.line.style.display = '';
|
|
11494
|
+
} else {
|
|
11495
|
+
cp.line.style.display = 'none';
|
|
11496
|
+
}
|
|
11497
|
+
// every point may have moved from under the pointer: what it is on
|
|
11498
|
+
// now is known at its next move
|
|
11499
|
+
cp.hover = null;
|
|
11500
|
+
clockRing(cp, null);
|
|
11501
|
+
|
|
11502
|
+
// --- what the line says
|
|
11503
|
+
while (cp.stats.firstChild) {
|
|
11504
|
+
cp.stats.removeChild(cp.stats.firstChild);
|
|
11505
|
+
}
|
|
11506
|
+
let tipCount = data.points.reduce(function (n, p) {
|
|
11507
|
+
return n + (p.tip ? 1 : 0);
|
|
11508
|
+
}, 0);
|
|
11509
|
+
let per = ' subs/site per ' + (calendar ? 'year' : (data.unit || 'unit of time'));
|
|
11510
|
+
if (fit) {
|
|
11511
|
+
treePropRow(cp.stats, 'Rate', clockNumber(fit.rate) + per);
|
|
11512
|
+
treePropRow(cp.stats, 'Root date, by the line', fit.rootDate === null
|
|
11513
|
+
? 'none: divergence does not rise with time' : clockDate(fit.rootDate, calendar));
|
|
11514
|
+
treePropRow(cp.stats, 'Root date, in the tree', clockDate(data.root.date, calendar));
|
|
11515
|
+
treePropRow(cp.stats, 'R²', fit.r2 === null ? 'none: every tip has one divergence' : clockNumber(fit.r2));
|
|
11516
|
+
} else {
|
|
11517
|
+
treePropRow(cp.stats, 'Line', 'none: it takes three tips, not all on one date');
|
|
11518
|
+
}
|
|
11519
|
+
treePropRow(cp.stats, 'Tips', countNumber(tipCount) + (_in_subtree ? ' (the clade on view)' : ''));
|
|
11520
|
+
cp.note.textContent = (data.fromRates
|
|
11521
|
+
? 'Divergence here is each branch’s time × its clock rate: the points show the model’s rates, not a measurement of their own. '
|
|
11522
|
+
: '')
|
|
11523
|
+
+ 'The line is fitted to the tips only. Tips share ancestry, so R² describes the fit and is no test.'
|
|
11524
|
+
+ ' Click a point to select its node; drag to select the tips in a box.';
|
|
11525
|
+
// what was laid down, for the browser harness: read here, where it is
|
|
11526
|
+
// computed, not off the DOM
|
|
11527
|
+
cp.dialog._aptxClockPlot = {data: data, shown: shown.length, calendar: calendar, lays: ++cp.lays};
|
|
11528
|
+
}
|
|
11529
|
+
|
|
11530
|
+
// One pass: a point whose colour or highlight differs from what it wears
|
|
11531
|
+
// is repainted, and a highlighted one is lifted over its neighbours.
|
|
11532
|
+
function colorClockPlot(cp) {
|
|
11533
|
+
for (let i = 0; i < cp.marks.length; ++i) {
|
|
11534
|
+
let m = cp.marks[i];
|
|
11535
|
+
let hit = getFoundColor(m.p.node) !== null;
|
|
11536
|
+
let color = clockPointColor(m.p.node, m.p.tip);
|
|
11537
|
+
let key = (color || '') + (hit ? '*' : '');
|
|
11538
|
+
if (key === m.key) {
|
|
11539
|
+
continue;
|
|
11540
|
+
}
|
|
11541
|
+
m.key = key;
|
|
11542
|
+
m.el.style.fill = color || '';
|
|
11543
|
+
m.el.style.stroke = hit ? makeFoundOutlineColor(m.p.node) : '';
|
|
11544
|
+
m.el.setAttribute('r', hit ? 4 : (m.p.tip ? 3 : 2));
|
|
11545
|
+
m.el.setAttribute('class', hit ? 'aptx-clock-hit' : '');
|
|
11546
|
+
if (hit) {
|
|
11547
|
+
m.el.parentNode.appendChild(m.el);
|
|
11548
|
+
}
|
|
11549
|
+
}
|
|
11550
|
+
cp.deselect.disabled = _selectedNodes.size === 0;
|
|
11551
|
+
}
|
|
11552
|
+
|
|
11553
|
+
// The node a point's node is seen AS in the tree: itself, or the
|
|
11554
|
+
// collapsed clade that holds it (the highest one, which is the one drawn).
|
|
11555
|
+
function clockTreeNode(node) {
|
|
11556
|
+
let seen = node;
|
|
11557
|
+
let top = topNode();
|
|
11558
|
+
for (let n = node.parent; n && n !== _root; n = n.parent) {
|
|
11559
|
+
if (isCollapsed(n)) {
|
|
11560
|
+
seen = n;
|
|
11561
|
+
}
|
|
11562
|
+
if (n === top) {
|
|
11563
|
+
break;
|
|
11564
|
+
}
|
|
11565
|
+
}
|
|
11566
|
+
return seen;
|
|
11567
|
+
}
|
|
11568
|
+
|
|
11569
|
+
/**
|
|
11570
|
+
* The points under the pointer: the nearest one within reach, and every
|
|
11571
|
+
* other of its kind drawn on the same spot. Tips with one date and one
|
|
11572
|
+
* divergence -- identical sequences sampled on one day -- are one dot on
|
|
11573
|
+
* the screen (778 of the 2,985 tips of a measles build share theirs with
|
|
11574
|
+
* another), and a dot that answered for only the first of them left the
|
|
11575
|
+
* rest beyond pointing, naming or deselecting (code review; Christian,
|
|
11576
|
+
* 2026-10-02: the dot stands for all of them). The SAME SPOT, not
|
|
11577
|
+
* everything within reach: in a dense cloud that is hundreds of tips,
|
|
11578
|
+
* and a click meant for one dot would take them all.
|
|
11579
|
+
*
|
|
11580
|
+
* @returns {Array} the marks, in the tree's own order, top to bottom;
|
|
11581
|
+
* empty where the pointer is on none
|
|
11582
|
+
*/
|
|
11583
|
+
function clockMarksAt(cp, px, py) {
|
|
11584
|
+
let best = null;
|
|
11585
|
+
let bestD = CLOCK_PLOT_HIT_PX * CLOCK_PLOT_HIT_PX;
|
|
11586
|
+
for (let i = 0; i < cp.marks.length; ++i) {
|
|
11587
|
+
let m = cp.marks[i];
|
|
11588
|
+
let dx = m.px - px;
|
|
11589
|
+
let dy = m.py - py;
|
|
11590
|
+
let d = (dx * dx) + (dy * dy);
|
|
11591
|
+
// a tip wins over an ancestor at the same distance: it is the datum
|
|
11592
|
+
if (d < bestD || (d === bestD && best && m.p.tip && !best.p.tip)) {
|
|
11593
|
+
best = m;
|
|
11594
|
+
bestD = d;
|
|
11595
|
+
}
|
|
11596
|
+
}
|
|
11597
|
+
if (!best) {
|
|
11598
|
+
return [];
|
|
11599
|
+
}
|
|
11600
|
+
// (the points were laid down from a walk that takes the last child
|
|
11601
|
+
// first, so the tree's order is theirs backwards)
|
|
11602
|
+
return cp.marks.filter(function (m) {
|
|
11603
|
+
return m.p.tip === best.p.tip && Math.abs(m.px - best.px) < CLOCK_PLOT_SAME_PX
|
|
11604
|
+
&& Math.abs(m.py - best.py) < CLOCK_PLOT_SAME_PX;
|
|
11605
|
+
}).reverse();
|
|
11606
|
+
}
|
|
11607
|
+
|
|
11608
|
+
function sameClockMarks(a, b) {
|
|
11609
|
+
if (!a || !b || a.length !== b.length) {
|
|
11610
|
+
return a === b;
|
|
11611
|
+
}
|
|
11612
|
+
return a.every(function (m, i) {
|
|
11613
|
+
return m === b[i];
|
|
11614
|
+
});
|
|
11615
|
+
}
|
|
11616
|
+
|
|
11617
|
+
// Lights, in the tree, the nodes of the points under the pointer: each as
|
|
11618
|
+
// the tree shows it (clockTreeNode), each once.
|
|
11619
|
+
function lightClockNodes(marks) {
|
|
11620
|
+
let nodes = [];
|
|
11621
|
+
marks.forEach(function (m) {
|
|
11622
|
+
let n = clockTreeNode(m.p.node);
|
|
11623
|
+
if (nodes.indexOf(n) < 0) {
|
|
11624
|
+
nodes.push(n);
|
|
11625
|
+
}
|
|
11626
|
+
});
|
|
11627
|
+
showHoverGlow(nodes[0], nodes.slice(1));
|
|
11628
|
+
}
|
|
11629
|
+
|
|
11630
|
+
function clockRing(cp, mark) {
|
|
11631
|
+
if (!mark) {
|
|
11632
|
+
cp.ring.style.display = 'none';
|
|
11633
|
+
return;
|
|
11634
|
+
}
|
|
11635
|
+
cp.ring.setAttribute('cx', mark.px);
|
|
11636
|
+
cp.ring.setAttribute('cy', mark.py);
|
|
11637
|
+
cp.ring.style.display = '';
|
|
11638
|
+
}
|
|
11639
|
+
|
|
11640
|
+
// A node pointed at IN THE TREE rings its point, where it has one.
|
|
11641
|
+
function markClockPlot(node) {
|
|
11642
|
+
let cp = _clockPlot;
|
|
11643
|
+
if (!cp) {
|
|
11644
|
+
return;
|
|
11645
|
+
}
|
|
11646
|
+
let mark = null;
|
|
11647
|
+
if (node) {
|
|
11648
|
+
for (let i = 0; i < cp.marks.length; ++i) {
|
|
11649
|
+
if (cp.marks[i].p.node === node) {
|
|
11650
|
+
mark = cp.marks[i];
|
|
11651
|
+
break;
|
|
11652
|
+
}
|
|
11653
|
+
}
|
|
11654
|
+
}
|
|
11655
|
+
clockRing(cp, mark);
|
|
11656
|
+
}
|
|
11657
|
+
|
|
11658
|
+
function clockSigned(v) {
|
|
11659
|
+
return (v > 0 ? '+' : '') + clockNumber(v, 3);
|
|
11660
|
+
}
|
|
11661
|
+
|
|
11662
|
+
// One value where the points agree, "least - greatest" where they do not
|
|
11663
|
+
// (points a fraction of a pixel apart are one dot and may differ a hair).
|
|
11664
|
+
function clockSpan(values, format) {
|
|
11665
|
+
let lo = Math.min.apply(null, values);
|
|
11666
|
+
let hi = Math.max.apply(null, values);
|
|
11667
|
+
return lo === hi ? format(lo) : (format(lo) + ' \u2013 ' + format(hi));
|
|
11668
|
+
}
|
|
11669
|
+
|
|
11670
|
+
// What the readout says of the points under the pointer: of one, what it
|
|
11671
|
+
// is; of several on one spot, how many, the names of the first few, and
|
|
11672
|
+
// what they share.
|
|
11673
|
+
function clockPointText(marks) {
|
|
11674
|
+
let p = marks[0].p;
|
|
11675
|
+
if (marks.length === 1) {
|
|
11676
|
+
let text = (p.node.name ? 'Name: ' + p.node.name : (p.tip ? 'Tip' : 'Internal node')) + '<br>';
|
|
11677
|
+
let date = dateText(p.node.date);
|
|
11678
|
+
if (date) {
|
|
11679
|
+
text += 'Date: ' + date + '<br>';
|
|
11680
|
+
}
|
|
11681
|
+
text += 'Divergence: ' + clockNumber(p.div, 4) + '<br>';
|
|
11682
|
+
if (typeof p.residual === 'number') {
|
|
11683
|
+
text += 'Off the line: ' + clockSigned(p.residual) + '<br>';
|
|
11684
|
+
}
|
|
11685
|
+
if (!p.tip) {
|
|
11686
|
+
text += 'Tips below: ' + forester.getAllExternalNodes(p.node).length + '<br>';
|
|
11687
|
+
}
|
|
11688
|
+
return text;
|
|
11689
|
+
}
|
|
11690
|
+
let text = marks.length + (p.tip ? ' tips here' : ' internal nodes here') + '<br>';
|
|
11691
|
+
let named = marks.filter(function (m) {
|
|
11692
|
+
return !!m.p.node.name;
|
|
11693
|
+
});
|
|
11694
|
+
named.slice(0, CLOCK_PLOT_NAMES_MAX).forEach(function (m) {
|
|
11695
|
+
text += 'Name: ' + m.p.node.name + '<br>';
|
|
11696
|
+
});
|
|
11697
|
+
if (named.length > CLOCK_PLOT_NAMES_MAX) {
|
|
11698
|
+
text += 'Name: \u2026 and ' + (named.length - CLOCK_PLOT_NAMES_MAX) + ' more<br>';
|
|
11699
|
+
}
|
|
11700
|
+
let unit = (p.node.date && p.node.date.unit) ? ' ' + p.node.date.unit : '';
|
|
11701
|
+
text += 'Date: ' + clockSpan(marks.map(function (m) { return m.p.date; }), String) + unit + '<br>';
|
|
11702
|
+
text += 'Divergence: ' + clockSpan(marks.map(function (m) { return m.p.div; }), function (v) {
|
|
11703
|
+
return clockNumber(v, 4);
|
|
11704
|
+
}) + '<br>';
|
|
11705
|
+
if (marks.every(function (m) { return typeof m.p.residual === 'number'; })) {
|
|
11706
|
+
text += 'Off the line: ' + clockSpan(marks.map(function (m) { return m.p.residual; }), clockSigned) + '<br>';
|
|
11707
|
+
}
|
|
11708
|
+
return text;
|
|
11709
|
+
}
|
|
11710
|
+
|
|
11711
|
+
function selectionChangedByClockPlot() {
|
|
11712
|
+
update(null, 0, true);
|
|
11713
|
+
document.dispatchEvent(new Event('selected_nodes_changed_event'));
|
|
11714
|
+
}
|
|
11715
|
+
|
|
11716
|
+
function bindClockPlotPointer(cp) {
|
|
11717
|
+
let svg = cp.svg;
|
|
11718
|
+
let press = null; // {x, y, boxed} while the button is down
|
|
11719
|
+
let at = function (e) {
|
|
11720
|
+
let r = svg.getBoundingClientRect();
|
|
11721
|
+
return [(e.clientX - r.left) * (CLOCK_PLOT_W / (r.width || CLOCK_PLOT_W)),
|
|
11722
|
+
(e.clientY - r.top) * (CLOCK_PLOT_H / (r.height || CLOCK_PLOT_H))];
|
|
11723
|
+
};
|
|
11724
|
+
let readout = function (e, marks) {
|
|
11725
|
+
if (!_node_mouseover_div) {
|
|
11726
|
+
return;
|
|
11727
|
+
}
|
|
11728
|
+
if (!marks) {
|
|
11729
|
+
_node_mouseover_div.interrupt().style('opacity', 1e-6);
|
|
11730
|
+
return;
|
|
11731
|
+
}
|
|
11732
|
+
let tip = _node_mouseover_div.node();
|
|
11733
|
+
tip.classList.remove('aptx-light', 'aptx-dark');
|
|
11734
|
+
if (_panelTheme) {
|
|
11735
|
+
tip.classList.add('aptx-' + _panelTheme);
|
|
11736
|
+
}
|
|
11737
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(clockPointText(marks))));
|
|
11738
|
+
_node_mouseover_div.interrupt().style('opacity', 0.95);
|
|
11739
|
+
placeHoverReadout(tip, e);
|
|
11740
|
+
};
|
|
11741
|
+
// cp.hover: the points the pointer is on (null for none), kept for the
|
|
11742
|
+
// renders in between
|
|
11743
|
+
let point = function (e) {
|
|
11744
|
+
let xy = at(e);
|
|
11745
|
+
let found = clockMarksAt(cp, xy[0], xy[1]);
|
|
11746
|
+
let marks = found.length > 0 ? found : null;
|
|
11747
|
+
if (!sameClockMarks(marks, cp.hover)) {
|
|
11748
|
+
cp.hover = marks;
|
|
11749
|
+
clockRing(cp, marks ? marks[0] : null);
|
|
11750
|
+
if (marks) {
|
|
11751
|
+
lightClockNodes(marks);
|
|
11752
|
+
} else {
|
|
11753
|
+
hideHoverGlow();
|
|
11754
|
+
}
|
|
11755
|
+
svg.style.cursor = marks ? 'pointer' : '';
|
|
11756
|
+
}
|
|
11757
|
+
readout(e, marks);
|
|
11758
|
+
};
|
|
11759
|
+
svg.addEventListener('pointerdown', function (e) {
|
|
11760
|
+
if (e.button !== 0) {
|
|
11761
|
+
return;
|
|
11762
|
+
}
|
|
11763
|
+
let xy = at(e);
|
|
11764
|
+
press = {x: xy[0], y: xy[1], boxed: false};
|
|
11765
|
+
try {
|
|
11766
|
+
svg.setPointerCapture(e.pointerId); // the box follows the pointer out of the drawing
|
|
11767
|
+
} catch {
|
|
11768
|
+
// no pointer capture available; a release outside is then missed
|
|
11769
|
+
}
|
|
11770
|
+
e.preventDefault();
|
|
11771
|
+
});
|
|
11772
|
+
svg.addEventListener('pointermove', function (e) {
|
|
11773
|
+
if (!press) {
|
|
11774
|
+
point(e);
|
|
11775
|
+
return;
|
|
11776
|
+
}
|
|
11777
|
+
let xy = at(e);
|
|
11778
|
+
if (!press.boxed && Math.hypot(xy[0] - press.x, xy[1] - press.y) > CLOCK_PLOT_DRAG_PX) {
|
|
11779
|
+
press.boxed = true;
|
|
11780
|
+
readout(e, null);
|
|
11781
|
+
clockRing(cp, null);
|
|
11782
|
+
hideHoverGlow();
|
|
11783
|
+
cp.hover = null;
|
|
11784
|
+
}
|
|
11785
|
+
if (press.boxed) {
|
|
11786
|
+
cp.box.setAttribute('x', Math.min(press.x, xy[0]));
|
|
11787
|
+
cp.box.setAttribute('y', Math.min(press.y, xy[1]));
|
|
11788
|
+
cp.box.setAttribute('width', Math.abs(xy[0] - press.x));
|
|
11789
|
+
cp.box.setAttribute('height', Math.abs(xy[1] - press.y));
|
|
11790
|
+
cp.box.style.display = '';
|
|
11791
|
+
}
|
|
11792
|
+
});
|
|
11793
|
+
let release = function (e, cancelled) {
|
|
11794
|
+
if (!press) {
|
|
11795
|
+
return;
|
|
11796
|
+
}
|
|
11797
|
+
let was = press;
|
|
11798
|
+
press = null;
|
|
11799
|
+
cp.box.style.display = 'none';
|
|
11800
|
+
if (cancelled) {
|
|
11801
|
+
return;
|
|
11802
|
+
}
|
|
11803
|
+
let xy = at(e);
|
|
11804
|
+
if (was.boxed) {
|
|
11805
|
+
// the tips in the box join the selection; an ancestor is
|
|
11806
|
+
// selected by a click, where it is one node and meant
|
|
11807
|
+
let xa = Math.min(was.x, xy[0]), xb = Math.max(was.x, xy[0]);
|
|
11808
|
+
let ya = Math.min(was.y, xy[1]), yb = Math.max(was.y, xy[1]);
|
|
11809
|
+
let added = 0;
|
|
11810
|
+
cp.marks.forEach(function (m) {
|
|
11811
|
+
if (m.p.tip && m.px >= xa && m.px <= xb && m.py >= ya && m.py <= yb
|
|
11812
|
+
&& !_selectedNodes.has(m.p.node)) {
|
|
11813
|
+
_selectedNodes.add(m.p.node);
|
|
11814
|
+
++added;
|
|
11815
|
+
}
|
|
11816
|
+
});
|
|
11817
|
+
if (added > 0) {
|
|
11818
|
+
selectionChangedByClockPlot();
|
|
11819
|
+
}
|
|
11820
|
+
return;
|
|
11821
|
+
}
|
|
11822
|
+
// a click selects every node of the dot; where all of them are
|
|
11823
|
+
// selected already, it deselects them
|
|
11824
|
+
let marks = clockMarksAt(cp, xy[0], xy[1]);
|
|
11825
|
+
if (marks.length > 0) {
|
|
11826
|
+
let all = marks.every(function (m) {
|
|
11827
|
+
return _selectedNodes.has(m.p.node);
|
|
11828
|
+
});
|
|
11829
|
+
marks.forEach(function (m) {
|
|
11830
|
+
if (all) {
|
|
11831
|
+
_selectedNodes.delete(m.p.node);
|
|
11832
|
+
} else {
|
|
11833
|
+
_selectedNodes.add(m.p.node);
|
|
11834
|
+
}
|
|
11835
|
+
});
|
|
11836
|
+
selectionChangedByClockPlot();
|
|
11837
|
+
}
|
|
11838
|
+
};
|
|
11839
|
+
svg.addEventListener('pointerup', function (e) {
|
|
11840
|
+
release(e, false);
|
|
11841
|
+
});
|
|
11842
|
+
svg.addEventListener('pointercancel', function (e) {
|
|
11843
|
+
release(e, true);
|
|
11844
|
+
});
|
|
11845
|
+
svg.addEventListener('pointerleave', function (e) {
|
|
11846
|
+
if (!press) {
|
|
11847
|
+
cp.hover = null;
|
|
11848
|
+
clockRing(cp, null);
|
|
11849
|
+
hideHoverGlow();
|
|
11850
|
+
readout(e, null);
|
|
11851
|
+
svg.style.cursor = '';
|
|
11852
|
+
}
|
|
11853
|
+
});
|
|
11854
|
+
}
|
|
11855
|
+
|
|
10907
11856
|
// ===================== Protein domain architectures =====================
|
|
10908
11857
|
// The desktop's domain display, ported from its RenderableDomainArchitecture
|
|
10909
11858
|
// and TreePanel (the spec is section D1 of the repo's TODO.md). Each tip's
|
|
@@ -11742,11 +12691,715 @@ function (root, d3, forester, phyloXml) {
|
|
|
11742
12691
|
});
|
|
11743
12692
|
}
|
|
11744
12693
|
|
|
11745
|
-
// =====================
|
|
11746
|
-
//
|
|
11747
|
-
//
|
|
11748
|
-
//
|
|
11749
|
-
//
|
|
12694
|
+
// ===================== Genome regions (the gene track) =====================
|
|
12695
|
+
// A tip that is a genome can carry a region of it: its genes as located
|
|
12696
|
+
// sequences with their families, one per gene, plus the contig's record
|
|
12697
|
+
// (forester's gene model, test/gene_test.js). Drawn as the domain track
|
|
12698
|
+
// is drawn -- a backbone per tip in one aligned column past the labels in
|
|
12699
|
+
// the rectangular layout, riding the tip's spoke in the radial ones --
|
|
12700
|
+
// but each gene is an ARROW pointing by strand, coloured by family, and
|
|
12701
|
+
// the rows are ANCHORED: the anchor family's gene sits at the same x in
|
|
12702
|
+
// every row, pointing right, the neighbours following, so gene order
|
|
12703
|
+
// compares across genomes (Christian, 2026-10-01). One scale for the
|
|
12704
|
+
// whole tree -- f px per base pair, from the track width and the extent
|
|
12705
|
+
// the rows share -- so distances compare too. A bar at the end of a
|
|
12706
|
+
// backbone is a contig end; the track's own kb bar sits under the rows.
|
|
12707
|
+
|
|
12708
|
+
function genesShown() {
|
|
12709
|
+
return _gene !== null
|
|
12710
|
+
&& _state.showGenes === true
|
|
12711
|
+
&& _basicTreeProperties.geneRegions === true
|
|
12712
|
+
&& _state.showExternalLabels
|
|
12713
|
+
// in a radial layout the arrows ride the spokes, so only radial
|
|
12714
|
+
// labels go with them
|
|
12715
|
+
&& (!radialDisplay() || !_radialLabelsHorizontal);
|
|
12716
|
+
}
|
|
12717
|
+
|
|
12718
|
+
// The region a tip carries, computed once per node: its sequences never
|
|
12719
|
+
// change after load, and a subtree view shows the same nodes.
|
|
12720
|
+
function geneRegion(d) {
|
|
12721
|
+
if (_gene.cache.has(d)) {
|
|
12722
|
+
return _gene.cache.get(d);
|
|
12723
|
+
}
|
|
12724
|
+
let r = forester.geneRegionOf(d);
|
|
12725
|
+
_gene.cache.set(d, r);
|
|
12726
|
+
return r;
|
|
12727
|
+
}
|
|
12728
|
+
|
|
12729
|
+
// Every region of the WHOLE tree, in its order. The anchor, the family
|
|
12730
|
+
// namespace and the palette are decided on the tree, never on the view,
|
|
12731
|
+
// so that entering a subtree moves nothing under the reader.
|
|
12732
|
+
function geneTreeRegions() {
|
|
12733
|
+
let out = [];
|
|
12734
|
+
if (!_treeData) {
|
|
12735
|
+
return out;
|
|
12736
|
+
}
|
|
12737
|
+
forester.preOrderTraversalAll(forester.getTreeRoot(_treeData), function (n) {
|
|
12738
|
+
if (!n.children) {
|
|
12739
|
+
let r = geneRegion(n);
|
|
12740
|
+
if (r) {
|
|
12741
|
+
out.push(r);
|
|
12742
|
+
}
|
|
12743
|
+
}
|
|
12744
|
+
});
|
|
12745
|
+
return out;
|
|
12746
|
+
}
|
|
12747
|
+
|
|
12748
|
+
// The namespace the colours and the anchor follow: the one named in the
|
|
12749
|
+
// config or the view, else the one most of the genes carry.
|
|
12750
|
+
function geneFamilyNamespace() {
|
|
12751
|
+
if (_state.geneFamily && _state.geneFamily !== 'auto') {
|
|
12752
|
+
return _state.geneFamily;
|
|
12753
|
+
}
|
|
12754
|
+
let all = forester.geneFamilyNamespaces(geneTreeRegions());
|
|
12755
|
+
return all.length > 0 ? all[0].ns : null;
|
|
12756
|
+
}
|
|
12757
|
+
|
|
12758
|
+
// The anchor family: 'none' is none, 'auto' the tree's best (single-copy
|
|
12759
|
+
// in the most genomes, at least half of them), a ref is itself.
|
|
12760
|
+
function geneAnchor() {
|
|
12761
|
+
let a = _state.geneAnchor;
|
|
12762
|
+
if (a === 'none') {
|
|
12763
|
+
return null;
|
|
12764
|
+
}
|
|
12765
|
+
if (!a || a === 'auto') {
|
|
12766
|
+
return forester.geneDefaultAnchor(geneTreeRegions(), geneFamilyNamespace());
|
|
12767
|
+
}
|
|
12768
|
+
return a;
|
|
12769
|
+
}
|
|
12770
|
+
|
|
12771
|
+
// The track width: the domain track's rules (a quarter of the viewport
|
|
12772
|
+
// at first use in the rectangular layouts, a width of the radial
|
|
12773
|
+
// layouts' own, the same step buttons), kept apart from the domain
|
|
12774
|
+
// track's so each is sized for what it draws.
|
|
12775
|
+
function geneRectangularWidth() {
|
|
12776
|
+
if (!_gene.width) {
|
|
12777
|
+
let vp = svgSize();
|
|
12778
|
+
let vw = Math.min(_displayWidth, (vp && vp.w) ? vp.w : _displayWidth);
|
|
12779
|
+
_gene.width = Math.max(DOMAIN_WIDTH_MIN, Math.round(vw * DOMAIN_WIDTH_VIEWPORT_FRACTION));
|
|
12780
|
+
}
|
|
12781
|
+
return _gene.width;
|
|
12782
|
+
}
|
|
12783
|
+
|
|
12784
|
+
function geneTrackWidth() {
|
|
12785
|
+
if (!radialDisplay()) {
|
|
12786
|
+
return geneRectangularWidth();
|
|
12787
|
+
}
|
|
12788
|
+
if (!_gene.radialWidth) {
|
|
12789
|
+
let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
|
|
12790
|
+
if (!(r > 0)) {
|
|
12791
|
+
return geneRectangularWidth(); // no radius yet (before the first radial draw)
|
|
12792
|
+
}
|
|
12793
|
+
_gene.radialWidth = Math.max(DOMAIN_WIDTH_MIN, Math.min(geneRectangularWidth(), DOMAIN_RADIAL_WIDTH_FRACTION * r));
|
|
12794
|
+
}
|
|
12795
|
+
return _gene.radialWidth;
|
|
12796
|
+
}
|
|
12797
|
+
|
|
12798
|
+
function setGeneTrackWidth(w) {
|
|
12799
|
+
if (radialDisplay()) {
|
|
12800
|
+
_gene.radialWidth = w;
|
|
12801
|
+
} else {
|
|
12802
|
+
_gene.width = w;
|
|
12803
|
+
}
|
|
12804
|
+
}
|
|
12805
|
+
|
|
12806
|
+
// The rectangular layout reserves the track's column past the domain
|
|
12807
|
+
// tracks' (both counted wherever _w is); the radial layouts fit it into
|
|
12808
|
+
// the ring (fitRadialExtent).
|
|
12809
|
+
function geneReserve() {
|
|
12810
|
+
if (!genesShown() || radialDisplay()) {
|
|
12811
|
+
return 0;
|
|
12812
|
+
}
|
|
12813
|
+
return DOMAIN_TRACK_START_GAP + geneTrackWidth() + DOMAIN_TRACK_END_GAP;
|
|
12814
|
+
}
|
|
12815
|
+
|
|
12816
|
+
function geneRadialExtent() {
|
|
12817
|
+
if (!genesShown() || !radialDisplay()) {
|
|
12818
|
+
return 0;
|
|
12819
|
+
}
|
|
12820
|
+
return DOMAIN_RADIAL_GAP + geneTrackWidth() + DOMAIN_TRACK_END_GAP;
|
|
12821
|
+
}
|
|
12822
|
+
|
|
12823
|
+
// Colours: the families SHARED over the whole tree (in two genomes or
|
|
12824
|
+
// more), sorted by code unit, take the palette in order -- dealt at load
|
|
12825
|
+
// and when the namespace changes. A family met in one genome only is
|
|
12826
|
+
// grey, so conserved order stands out; a gene with no family is lighter
|
|
12827
|
+
// grey.
|
|
12828
|
+
function genePalette() {
|
|
12829
|
+
if (!_gene.palette) {
|
|
12830
|
+
let names = forester.geneFamilySummary(geneTreeRegions(), geneFamilyNamespace()).names;
|
|
12831
|
+
_gene.palette = Object.create(null);
|
|
12832
|
+
names.forEach(function (name, i) {
|
|
12833
|
+
_gene.palette[name] = i;
|
|
12834
|
+
});
|
|
12835
|
+
}
|
|
12836
|
+
return _gene.palette;
|
|
12837
|
+
}
|
|
12838
|
+
|
|
12839
|
+
function geneColor(family) {
|
|
12840
|
+
if (!family) {
|
|
12841
|
+
return forester.GENE_NO_FAMILY_COLOR;
|
|
12842
|
+
}
|
|
12843
|
+
let palette = genePalette();
|
|
12844
|
+
if (palette[family] === undefined) {
|
|
12845
|
+
return forester.GENE_SINGLETON_COLOR;
|
|
12846
|
+
}
|
|
12847
|
+
return forester.domainQualitativeColor(palette[family]);
|
|
12848
|
+
}
|
|
12849
|
+
|
|
12850
|
+
// What a gene is called where there is room for one word: its symbol,
|
|
12851
|
+
// else its gene name.
|
|
12852
|
+
function geneLabel(g) {
|
|
12853
|
+
return g.symbol || g.geneName || '';
|
|
12854
|
+
}
|
|
12855
|
+
|
|
12856
|
+
// ---- hover and click: the readout the domains, heat map and alignment use
|
|
12857
|
+
function geneInfoAt(event) {
|
|
12858
|
+
let t = event.target;
|
|
12859
|
+
return (t && t.__aptxGene) ? t.__aptxGene : null;
|
|
12860
|
+
}
|
|
12861
|
+
|
|
12862
|
+
let _geneHovered = null;
|
|
12863
|
+
|
|
12864
|
+
function geneHover(event) {
|
|
12865
|
+
let info = geneInfoAt(event);
|
|
12866
|
+
if (!info) {
|
|
12867
|
+
geneHoverOut();
|
|
12868
|
+
return;
|
|
12869
|
+
}
|
|
12870
|
+
let tip_el = _node_mouseover_div.node();
|
|
12871
|
+
if (info === _geneHovered) {
|
|
12872
|
+
placeHoverReadout(tip_el, event); // follows the pointer; the text is unchanged
|
|
12873
|
+
return;
|
|
12874
|
+
}
|
|
12875
|
+
_geneHovered = info;
|
|
12876
|
+
let g = info.gene;
|
|
12877
|
+
let title = geneLabel(g) || g.product || '(unnamed)';
|
|
12878
|
+
let txt = 'Gene: ' + title + '<br>';
|
|
12879
|
+
if (g.product && g.product !== title) {
|
|
12880
|
+
txt += 'Product: ' + g.product + '<br>';
|
|
12881
|
+
}
|
|
12882
|
+
if (info.family) {
|
|
12883
|
+
txt += 'Family: ' + info.family + '<br>';
|
|
12884
|
+
}
|
|
12885
|
+
txt += 'Location: ' + forester.geneLocationText(g) + (info.flipped ? ' (the row is mirrored)' : '') + '<br>';
|
|
12886
|
+
txt += 'Length: ' + countNumber(g.length) + ' bp<br>';
|
|
12887
|
+
if (info.tip) {
|
|
12888
|
+
txt += 'Tip: ' + info.tip + '<br>';
|
|
12889
|
+
}
|
|
12890
|
+
if (g.accession) {
|
|
12891
|
+
txt += 'Accession' + (g.accession.source ? ' [' + g.accession.source + ']' : '') + ': ' + g.accession.value + '<br>';
|
|
12892
|
+
}
|
|
12893
|
+
let ref = forester.geneReference(g);
|
|
12894
|
+
if (ref) {
|
|
12895
|
+
// the readout follows the pointer, so the ARROW is the link and
|
|
12896
|
+
// the readout says so (a "label: value" row, as the domains do it)
|
|
12897
|
+
txt += 'Click: the ' + ref.site + ' entry';
|
|
12898
|
+
}
|
|
12899
|
+
tip_el.classList.remove('aptx-light', 'aptx-dark');
|
|
12900
|
+
if (_panelTheme) {
|
|
12901
|
+
tip_el.classList.add('aptx-' + _panelTheme);
|
|
12902
|
+
}
|
|
12903
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
|
|
12904
|
+
placeHoverReadout(tip_el, event);
|
|
12905
|
+
_node_mouseover_div.transition().duration(100).style('opacity', 0.95);
|
|
12906
|
+
}
|
|
12907
|
+
|
|
12908
|
+
function geneHoverOut() {
|
|
12909
|
+
_geneHovered = null;
|
|
12910
|
+
_node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
|
|
12911
|
+
}
|
|
12912
|
+
|
|
12913
|
+
let _geneOpenedAt = 0;
|
|
12914
|
+
|
|
12915
|
+
function geneClicked(event) {
|
|
12916
|
+
let info = geneInfoAt(event);
|
|
12917
|
+
if (!info) {
|
|
12918
|
+
return;
|
|
12919
|
+
}
|
|
12920
|
+
event.stopPropagation(); // the arrow's click, linked or not: never the node menu behind it
|
|
12921
|
+
let ref = forester.geneReference(info.gene);
|
|
12922
|
+
if (!ref) {
|
|
12923
|
+
return;
|
|
12924
|
+
}
|
|
12925
|
+
let now = Date.now();
|
|
12926
|
+
if ((now - _geneOpenedAt) < 500) { // a double click arrives as two clicks
|
|
12927
|
+
return;
|
|
12928
|
+
}
|
|
12929
|
+
_geneOpenedAt = now;
|
|
12930
|
+
openExternal(ref.url);
|
|
12931
|
+
}
|
|
12932
|
+
|
|
12933
|
+
function geneDblClicked(event) {
|
|
12934
|
+
if (geneInfoAt(event)) {
|
|
12935
|
+
event.stopPropagation();
|
|
12936
|
+
event.preventDefault();
|
|
12937
|
+
}
|
|
12938
|
+
}
|
|
12939
|
+
|
|
12940
|
+
// ---- the track
|
|
12941
|
+
function drawGeneTrack() {
|
|
12942
|
+
if (!_svgGroup) {
|
|
12943
|
+
return;
|
|
12944
|
+
}
|
|
12945
|
+
let existing = _svgGroup.select('g.aptx-genes');
|
|
12946
|
+
// A region goes WITH ITS NAME, and is left out when the crowding rule
|
|
12947
|
+
// took the name (the domain track's rule, for the same reason)
|
|
12948
|
+
let tips = (genesShown() && _root) ? displayedTips().filter(function (d) {
|
|
12949
|
+
if (d._labelDropped) {
|
|
12950
|
+
return false;
|
|
12951
|
+
}
|
|
12952
|
+
return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
|
|
12953
|
+
}) : [];
|
|
12954
|
+
if (tips.length > 0 && !radialDisplay()) {
|
|
12955
|
+
tips.sort(function (p, q) {
|
|
12956
|
+
return p.x - q.x;
|
|
12957
|
+
});
|
|
12958
|
+
}
|
|
12959
|
+
let regions = tips.map(geneRegion);
|
|
12960
|
+
let ns = tips.length > 0 ? geneFamilyNamespace() : null;
|
|
12961
|
+
let anchor = tips.length > 0 ? geneAnchor() : null;
|
|
12962
|
+
let laid = tips.length > 0 ? forester.geneTrackRows(regions, anchor, ns) : null;
|
|
12963
|
+
let W = tips.length > 0 ? geneTrackWidth() : 0;
|
|
12964
|
+
let f = (laid && laid.extent && laid.extent.max > laid.extent.min)
|
|
12965
|
+
? (W / (laid.extent.max - laid.extent.min)) * DOMAIN_SCALE_HEADROOM : 0;
|
|
12966
|
+
if (!laid || !(f > 0) || !isFinite(f)) {
|
|
12967
|
+
existing.remove();
|
|
12968
|
+
geneHoverOut(); // the arrow under the pointer just stopped existing
|
|
12969
|
+
return;
|
|
12970
|
+
}
|
|
12971
|
+
// [the backbones and caps, the arrows, the symbols, the kb bar], kept
|
|
12972
|
+
// between redraws and moved to the end of the tree group
|
|
12973
|
+
let g = existing.node();
|
|
12974
|
+
if (!g) {
|
|
12975
|
+
g = document.createElementNS(d3.namespaces.svg, 'g');
|
|
12976
|
+
g.setAttribute('class', 'aptx-genes');
|
|
12977
|
+
g.style.pointerEvents = 'none';
|
|
12978
|
+
['g', 'g', 'g', 'g'].forEach(function (tag) {
|
|
12979
|
+
g.appendChild(document.createElementNS(d3.namespaces.svg, tag));
|
|
12980
|
+
});
|
|
12981
|
+
}
|
|
12982
|
+
_svgGroup.node().appendChild(g);
|
|
12983
|
+
let underLayer = g.children[0];
|
|
12984
|
+
let bodyLayer = g.children[1];
|
|
12985
|
+
let labelLayer = g.children[2];
|
|
12986
|
+
let rulerLayer = g.children[3];
|
|
12987
|
+
// the group ignores the mouse so the tree underneath stays clickable;
|
|
12988
|
+
// the ARROWS alone take it back
|
|
12989
|
+
bodyLayer.style.pointerEvents = 'auto';
|
|
12990
|
+
if (!bodyLayer.__aptxWired) {
|
|
12991
|
+
bodyLayer.__aptxWired = true;
|
|
12992
|
+
bodyLayer.addEventListener('mousemove', geneHover);
|
|
12993
|
+
bodyLayer.addEventListener('mouseout', geneHoverOut);
|
|
12994
|
+
bodyLayer.addEventListener('click', geneClicked);
|
|
12995
|
+
bodyLayer.addEventListener('dblclick', geneDblClicked);
|
|
12996
|
+
}
|
|
12997
|
+
|
|
12998
|
+
// Where each tip's region goes: the start of the track along the
|
|
12999
|
+
// row, the top and height of its arrows, and in the radial layouts
|
|
13000
|
+
// the transform that turns it onto its spoke. Past the domain tracks
|
|
13001
|
+
// when those are shown.
|
|
13002
|
+
let labelSpace = tipLabelSpace();
|
|
13003
|
+
let rows = [];
|
|
13004
|
+
let columnStart = null;
|
|
13005
|
+
if (_state.circularDisplay) {
|
|
13006
|
+
let r0 = _radial.maxRad + labelSpace + domainRadialExtent() + DOMAIN_RADIAL_GAP;
|
|
13007
|
+
let h = domainBoxHeight((r0 * 2 * Math.PI) / tips.length);
|
|
13008
|
+
tips.forEach(function (d) {
|
|
13009
|
+
rows.push({d: d, start: r0, y1: -h / 2, h: h,
|
|
13010
|
+
transform: 'rotate(' + labelAngleDeg(d) + ')', labelsOn: false});
|
|
13011
|
+
});
|
|
13012
|
+
} else if (_state.unrootedDisplay) {
|
|
13013
|
+
let h = domainBoxHeight((Math.PI * 2 * _unroot.maxRad) / tips.length);
|
|
13014
|
+
let start = labelSpace + domainRadialExtent() + DOMAIN_RADIAL_GAP;
|
|
13015
|
+
tips.forEach(function (d) {
|
|
13016
|
+
rows.push({d: d, start: start, y1: -h / 2, h: h,
|
|
13017
|
+
transform: 'translate(' + d.ux + ',' + d.uy + ') rotate(' + labelAngleDeg(d) + ')', labelsOn: false});
|
|
13018
|
+
});
|
|
13019
|
+
} else {
|
|
13020
|
+
let n = tips.length;
|
|
13021
|
+
let pitch = n > 1 ? (tips[n - 1].x - tips[0].x) / (n - 1) : _state.externalNodeFontSize;
|
|
13022
|
+
let h = domainBoxHeight(pitch / 2);
|
|
13023
|
+
columnStart = _w + _state.nodeLabelGap + labelSpace + _domainReserve + DOMAIN_TRACK_START_GAP;
|
|
13024
|
+
let labelsOn = _state.geneLabels === 'genes';
|
|
13025
|
+
tips.forEach(function (d) {
|
|
13026
|
+
rows.push({d: d, start: columnStart, y1: d.x - (h / 2), h: h, transform: null, labelsOn: labelsOn});
|
|
13027
|
+
});
|
|
13028
|
+
}
|
|
13029
|
+
|
|
13030
|
+
// ---- collect: the backbones and caps as path data (one path for the
|
|
13031
|
+
// whole track in the rectangular layout, one per row in the radial
|
|
13032
|
+
// ones, each in its row's turned frame -- the domain track's lesson
|
|
13033
|
+
// about turned coordinates and PDF size), one path element per
|
|
13034
|
+
// arrow (it is what the mouse finds), the symbols that fit
|
|
13035
|
+
let under = []; // {d, transform}
|
|
13036
|
+
let trackParts = [];
|
|
13037
|
+
let bodies = []; // {d, fill, stroke, transform, info, link}
|
|
13038
|
+
let names = [];
|
|
13039
|
+
let lowest = -Infinity;
|
|
13040
|
+
rows.forEach(function (row, i) {
|
|
13041
|
+
let lr = laid.rows[i];
|
|
13042
|
+
if (!lr) {
|
|
13043
|
+
return;
|
|
13044
|
+
}
|
|
13045
|
+
let geo = forester.geneRowGeometry(lr, row.start, f, laid.extent.min);
|
|
13046
|
+
let h = row.h;
|
|
13047
|
+
let y1 = row.y1;
|
|
13048
|
+
let parts = row.transform ? [] : trackParts;
|
|
13049
|
+
parts.push(roundRectPath(geo.backbone.x, y1 + (h / 2) - 0.5, geo.backbone.w, 1, 0));
|
|
13050
|
+
if (geo.capLeft) {
|
|
13051
|
+
parts.push(roundRectPath(geo.backbone.x - (GENE_CAP_W / 2), y1, GENE_CAP_W, h, 0));
|
|
13052
|
+
}
|
|
13053
|
+
if (geo.capRight) {
|
|
13054
|
+
parts.push(roundRectPath(geo.backbone.x + geo.backbone.w - (GENE_CAP_W / 2), y1, GENE_CAP_W, h, 0));
|
|
13055
|
+
}
|
|
13056
|
+
if (row.transform) {
|
|
13057
|
+
under.push({d: parts.join(''), transform: row.transform});
|
|
13058
|
+
}
|
|
13059
|
+
if (y1 + h > lowest) {
|
|
13060
|
+
lowest = y1 + h;
|
|
13061
|
+
}
|
|
13062
|
+
// once per ROW: displayNodeName walks the node's properties when
|
|
13063
|
+
// a label-ref visualization is on
|
|
13064
|
+
let rowTip = displayNodeName(row.d) || '';
|
|
13065
|
+
// two lanes share the height where genes overlap
|
|
13066
|
+
let laneH = lr.lanes > 1 ? Math.max(GENE_LANE_MIN_H, h / 2) : h;
|
|
13067
|
+
let fs = Math.min(_state.externalNodeFontSize, laneH - 2);
|
|
13068
|
+
let font = fs + 'px ' + FONT_DEFAULTS;
|
|
13069
|
+
geo.genes.forEach(function (b) {
|
|
13070
|
+
let fam = forester.geneFamilyOf(b.gene, ns);
|
|
13071
|
+
let base = geneColor(fam);
|
|
13072
|
+
let y = (lr.lanes > 1 && b.lane === 1) ? (y1 + h - laneH) : y1;
|
|
13073
|
+
let d = forester.geneArrowPath(b.x, y, b.w, laneH, b.dir);
|
|
13074
|
+
if (d === '') {
|
|
13075
|
+
return;
|
|
13076
|
+
}
|
|
13077
|
+
let info = {gene: b.gene, family: fam, tip: rowTip, flipped: lr.flip};
|
|
13078
|
+
bodies.push({d: d, fill: base, stroke: forester.domainDarken(base, 0.3), transform: row.transform,
|
|
13079
|
+
info: info, link: !!forester.geneReference(b.gene)});
|
|
13080
|
+
let label = geneLabel(b.gene);
|
|
13081
|
+
let head = Math.min(laneH / 2, b.w);
|
|
13082
|
+
if (row.labelsOn && label && fs > 4 && legendTextWidth(label, font) <= b.w - head - 4) {
|
|
13083
|
+
// centred on the body, which the head is not part of
|
|
13084
|
+
names.push({x: b.x + ((b.dir > 0 ? 0 : head) + ((b.w - head) / 2)), y: y + (laneH / 2), font: font,
|
|
13085
|
+
fill: forester.domainLabelInk(base), text: label});
|
|
13086
|
+
}
|
|
13087
|
+
});
|
|
13088
|
+
});
|
|
13089
|
+
if (trackParts.length > 0) {
|
|
13090
|
+
under.push({d: trackParts.join(''), transform: null});
|
|
13091
|
+
}
|
|
13092
|
+
|
|
13093
|
+
// ---- draw: bottom to top, reusing the elements already there
|
|
13094
|
+
let paths = sizeLayer(underLayer, 'path', under.length);
|
|
13095
|
+
under.forEach(function (u, k) {
|
|
13096
|
+
paths[k].setAttribute('d', u.d);
|
|
13097
|
+
if (u.transform) {
|
|
13098
|
+
paths[k].setAttribute('transform', u.transform);
|
|
13099
|
+
} else {
|
|
13100
|
+
paths[k].removeAttribute('transform');
|
|
13101
|
+
}
|
|
13102
|
+
paths[k].style.fill = DOMAIN_BACKBONE_COLOR;
|
|
13103
|
+
});
|
|
13104
|
+
geneHoverOut(); // a redraw can take the hovered arrow away without a mouseout
|
|
13105
|
+
let arrows = sizeLayer(bodyLayer, 'path', bodies.length);
|
|
13106
|
+
bodies.forEach(function (b, k) {
|
|
13107
|
+
let el = arrows[k];
|
|
13108
|
+
el.setAttribute('d', b.d);
|
|
13109
|
+
if (b.transform) {
|
|
13110
|
+
el.setAttribute('transform', b.transform);
|
|
13111
|
+
} else {
|
|
13112
|
+
el.removeAttribute('transform');
|
|
13113
|
+
}
|
|
13114
|
+
el.style.fill = b.fill;
|
|
13115
|
+
el.style.stroke = b.stroke;
|
|
13116
|
+
el.style.strokeWidth = 1;
|
|
13117
|
+
el.style.strokeLinejoin = 'round';
|
|
13118
|
+
el.__aptxGene = b.info;
|
|
13119
|
+
el.style.cursor = b.link ? 'pointer' : 'default'; // a hand only where the click goes somewhere
|
|
13120
|
+
});
|
|
13121
|
+
let texts = sizeLayer(labelLayer, 'text', names.length);
|
|
13122
|
+
names.forEach(function (t, k) {
|
|
13123
|
+
let el = texts[k];
|
|
13124
|
+
el.setAttribute('x', t.x);
|
|
13125
|
+
el.setAttribute('y', t.y);
|
|
13126
|
+
el.setAttribute('dy', '0.35em');
|
|
13127
|
+
el.setAttribute('text-anchor', 'middle');
|
|
13128
|
+
el.style.font = t.font;
|
|
13129
|
+
el.style.fill = t.fill;
|
|
13130
|
+
el.textContent = t.text;
|
|
13131
|
+
});
|
|
13132
|
+
|
|
13133
|
+
// the kb bar under the rows, rectangular only: a round number of base
|
|
13134
|
+
// pairs at this scale, with end ticks, the label after it
|
|
13135
|
+
let ruler = d3.select(rulerLayer);
|
|
13136
|
+
ruler.selectAll('*').remove();
|
|
13137
|
+
let bar = (columnStart !== null && isFinite(lowest)) ? forester.geneScaleBar(f, GENE_KB_BAR_TARGET_PX) : null;
|
|
13138
|
+
if (bar) {
|
|
13139
|
+
let ink = _state.labelColorDefault;
|
|
13140
|
+
let y = lowest + 12;
|
|
13141
|
+
let x0 = columnStart;
|
|
13142
|
+
let x1 = columnStart + bar.px;
|
|
13143
|
+
let fs = Math.max(9, Math.min(_state.externalNodeFontSize, 12));
|
|
13144
|
+
ruler.append('path')
|
|
13145
|
+
.attr('d', 'M' + x0 + ',' + (y - 3) + 'v6M' + x0 + ',' + y + 'H' + x1 + 'M' + x1 + ',' + (y - 3) + 'v6')
|
|
13146
|
+
.style('fill', 'none').style('stroke', ink).style('stroke-width', 1);
|
|
13147
|
+
ruler.append('text')
|
|
13148
|
+
.attr('x', x1 + 5).attr('y', y).attr('dy', '0.35em')
|
|
13149
|
+
.style('font', fs + 'px ' + FONT_DEFAULTS).style('fill', ink)
|
|
13150
|
+
.text(bar.label);
|
|
13151
|
+
}
|
|
13152
|
+
}
|
|
13153
|
+
|
|
13154
|
+
// The gene legend, in "Legend" mode only: the shared families drawn in
|
|
13155
|
+
// the view, in first-appearance order, each with its commonest product
|
|
13156
|
+
// and the number of genomes carrying it; then, when there are any, the
|
|
13157
|
+
// grey rows for the families unique to one genome and the genes without
|
|
13158
|
+
// a family. Its own card with the domain legend's manners: home
|
|
13159
|
+
// bottom-right, a drag keeps its place, a double-click sends it home.
|
|
13160
|
+
function drawGeneLegend() {
|
|
13161
|
+
if (!_baseSvg) {
|
|
13162
|
+
return;
|
|
13163
|
+
}
|
|
13164
|
+
_baseSvg.selectAll('g.' + LEGEND_GENES).remove();
|
|
13165
|
+
if (!_root || !genesShown() || _state.geneLabels !== 'legend') {
|
|
13166
|
+
return;
|
|
13167
|
+
}
|
|
13168
|
+
let tips = displayedTips().filter(function (d) {
|
|
13169
|
+
return d.x !== undefined;
|
|
13170
|
+
}).sort(function (p, q) {
|
|
13171
|
+
return p.x - q.x;
|
|
13172
|
+
});
|
|
13173
|
+
let ns = geneFamilyNamespace();
|
|
13174
|
+
let summary = forester.geneFamilySummary(tips.map(geneRegion), ns, new Set(Object.keys(genePalette())));
|
|
13175
|
+
let size = svgSize();
|
|
13176
|
+
if ((summary.legend.length === 0 && summary.singletons === 0 && summary.unfamilied === 0) || !size) {
|
|
13177
|
+
return;
|
|
13178
|
+
}
|
|
13179
|
+
const FS = Math.max(11, _state.externalNodeFontSize || 11);
|
|
13180
|
+
const PAD = 9;
|
|
13181
|
+
const ROW = FS + 6;
|
|
13182
|
+
const SWATCH = 10;
|
|
13183
|
+
const GAP = 6;
|
|
13184
|
+
const rowFont = FS + 'px ' + FONT_DEFAULTS;
|
|
13185
|
+
const titleFont = '600 ' + (FS + 1) + 'px ' + FONT_DEFAULTS;
|
|
13186
|
+
const ink = _state.labelColorDefault;
|
|
13187
|
+
const frame = _state.branchColorDefault;
|
|
13188
|
+
let title = 'Gene families' + (ns ? ' (' + ns + ')' : '');
|
|
13189
|
+
let rows = summary.legend.map(function (r) {
|
|
13190
|
+
let what = r.product || r.symbol || r.family;
|
|
13191
|
+
return {
|
|
13192
|
+
text: clipTextToWidth(what + ' (' + r.tips + ')', rowFont, DOMAIN_LEGEND_ROW_MAX_PX),
|
|
13193
|
+
color: geneColor(r.family)
|
|
13194
|
+
};
|
|
13195
|
+
});
|
|
13196
|
+
if (summary.singletons > 0) {
|
|
13197
|
+
rows.push({text: 'unique to one genome (' + summary.singletons + ')', color: forester.GENE_SINGLETON_COLOR});
|
|
13198
|
+
}
|
|
13199
|
+
if (summary.unfamilied > 0) {
|
|
13200
|
+
rows.push({text: 'no family (' + summary.unfamilied + ')', color: forester.GENE_NO_FAMILY_COLOR});
|
|
13201
|
+
}
|
|
13202
|
+
let width = legendTextWidth(title, titleFont);
|
|
13203
|
+
rows.forEach(function (r) {
|
|
13204
|
+
width = Math.max(width, SWATCH + GAP + legendTextWidth(r.text, rowFont));
|
|
13205
|
+
});
|
|
13206
|
+
width += 2 * PAD;
|
|
13207
|
+
let height = PAD + ROW + (rows.length * ROW) + PAD - 2;
|
|
13208
|
+
let x, y;
|
|
13209
|
+
if (_gene.legendFrac) {
|
|
13210
|
+
x = _gene.legendFrac.fx * size.w;
|
|
13211
|
+
y = _gene.legendFrac.fy * size.h;
|
|
13212
|
+
} else {
|
|
13213
|
+
x = size.w - width - DOMAIN_LEGEND_INSET;
|
|
13214
|
+
y = size.h - height - DOMAIN_LEGEND_INSET;
|
|
13215
|
+
}
|
|
13216
|
+
x = Math.max(0, Math.min(size.w - 20, x));
|
|
13217
|
+
y = Math.max(0, Math.min(size.h - 20, y));
|
|
13218
|
+
|
|
13219
|
+
let g = _baseSvg.append('g').attr('class', LEGEND_GENES)
|
|
13220
|
+
.style('cursor', 'move')
|
|
13221
|
+
.call(d3.drag()
|
|
13222
|
+
.on('start', function (event) {
|
|
13223
|
+
if (event.sourceEvent) {
|
|
13224
|
+
event.sourceEvent.stopPropagation(); // not a pan of the tree
|
|
13225
|
+
}
|
|
13226
|
+
})
|
|
13227
|
+
.on('drag', function (event) {
|
|
13228
|
+
x += event.dx;
|
|
13229
|
+
y += event.dy;
|
|
13230
|
+
_gene.legendFrac = {fx: x / size.w, fy: y / size.h};
|
|
13231
|
+
drawGeneLegend();
|
|
13232
|
+
}))
|
|
13233
|
+
.on('dblclick', function (event) {
|
|
13234
|
+
event.stopPropagation();
|
|
13235
|
+
_gene.legendFrac = null;
|
|
13236
|
+
drawGeneLegend();
|
|
13237
|
+
});
|
|
13238
|
+
g.append('title').text('drag to move; double-click to send it back to the corner');
|
|
13239
|
+
g.append('rect')
|
|
13240
|
+
.attr('x', x).attr('y', y)
|
|
13241
|
+
.attr('width', width).attr('height', height)
|
|
13242
|
+
.attr('rx', 5)
|
|
13243
|
+
.style('fill', _state.backgroundColorDefault)
|
|
13244
|
+
.style('fill-opacity', 0.92)
|
|
13245
|
+
.style('stroke', frame)
|
|
13246
|
+
.style('stroke-opacity', 0.5);
|
|
13247
|
+
let baseline = y + PAD + FS;
|
|
13248
|
+
g.append('text')
|
|
13249
|
+
.attr('x', x + PAD).attr('y', baseline)
|
|
13250
|
+
.style('font', titleFont)
|
|
13251
|
+
.style('fill', ink)
|
|
13252
|
+
.text(title);
|
|
13253
|
+
rows.forEach(function (r) {
|
|
13254
|
+
baseline += ROW;
|
|
13255
|
+
g.append('rect')
|
|
13256
|
+
.attr('x', x + PAD).attr('y', baseline - SWATCH + 1)
|
|
13257
|
+
.attr('width', SWATCH).attr('height', SWATCH)
|
|
13258
|
+
.attr('rx', 2)
|
|
13259
|
+
.style('fill', r.color);
|
|
13260
|
+
g.append('text')
|
|
13261
|
+
.attr('x', x + PAD + SWATCH + GAP).attr('y', baseline)
|
|
13262
|
+
.style('font', rowFont)
|
|
13263
|
+
.style('fill', ink)
|
|
13264
|
+
.text(r.text);
|
|
13265
|
+
});
|
|
13266
|
+
}
|
|
13267
|
+
|
|
13268
|
+
// ---- the controls follow the toggle: hidden without it; while shown the
|
|
13269
|
+
// step buttons, the two selects and the label mode say where things stand
|
|
13270
|
+
function fillSelect(sel, options, value) {
|
|
13271
|
+
sel.textContent = '';
|
|
13272
|
+
options.forEach(function (o) {
|
|
13273
|
+
let opt = document.createElement('option');
|
|
13274
|
+
opt.value = o.value;
|
|
13275
|
+
opt.textContent = o.text;
|
|
13276
|
+
if (o.title) {
|
|
13277
|
+
opt.title = o.title;
|
|
13278
|
+
}
|
|
13279
|
+
sel.appendChild(opt);
|
|
13280
|
+
});
|
|
13281
|
+
sel.value = value;
|
|
13282
|
+
}
|
|
13283
|
+
|
|
13284
|
+
function geneFamilyShortName(c) {
|
|
13285
|
+
return c.symbol || c.product || c.family;
|
|
13286
|
+
}
|
|
13287
|
+
|
|
13288
|
+
function syncGeneControls() {
|
|
13289
|
+
let fs = byId(GENE_CONTROLS);
|
|
13290
|
+
if (!fs) {
|
|
13291
|
+
return;
|
|
13292
|
+
}
|
|
13293
|
+
let on = _state.showGenes === true && _basicTreeProperties.geneRegions === true && _gene !== null;
|
|
13294
|
+
setCheckboxValue(GENES_CB, _state.showGenes === true);
|
|
13295
|
+
fs.style.display = on ? '' : 'none';
|
|
13296
|
+
if (!on) {
|
|
13297
|
+
return;
|
|
13298
|
+
}
|
|
13299
|
+
let w = geneTrackWidth();
|
|
13300
|
+
(w > DOMAIN_WIDTH_MIN ? enableButton : disableButton)(byId(GENE_WIDTH_DEC));
|
|
13301
|
+
(w < DOMAIN_WIDTH_MAX ? enableButton : disableButton)(byId(GENE_WIDTH_INC));
|
|
13302
|
+
let regions = geneTreeRegions();
|
|
13303
|
+
let ns = geneFamilyNamespace();
|
|
13304
|
+
// the anchor: Auto (naming its pick), every family that can anchor
|
|
13305
|
+
// two genomes or more -- most first -- and None
|
|
13306
|
+
let sel = byId(GENE_ANCHOR_SELECT);
|
|
13307
|
+
if (sel) {
|
|
13308
|
+
let auto = forester.geneDefaultAnchor(regions, ns);
|
|
13309
|
+
let cands = forester.geneAnchorCandidates(regions, ns);
|
|
13310
|
+
let byFamily = Object.create(null);
|
|
13311
|
+
cands.forEach(function (c) {
|
|
13312
|
+
byFamily[c.family] = c;
|
|
13313
|
+
});
|
|
13314
|
+
let options = [{value: 'auto', text: 'Auto' + (auto ? ' (' + geneFamilyShortName(byFamily[auto]) + ')' : ' (none)'),
|
|
13315
|
+
title: auto ? auto : 'no family is single-copy in half the genomes'}];
|
|
13316
|
+
cands.forEach(function (c) {
|
|
13317
|
+
if (c.single >= 2 || c.family === _state.geneAnchor) {
|
|
13318
|
+
options.push({value: c.family, text: geneFamilyShortName(c) + ' (' + c.single + '/' + regions.length + ')',
|
|
13319
|
+
title: c.family + (c.product && c.symbol ? ' — ' + c.product : '')});
|
|
13320
|
+
}
|
|
13321
|
+
});
|
|
13322
|
+
if (_state.geneAnchor !== 'auto' && _state.geneAnchor !== 'none' && !byFamily[_state.geneAnchor]) {
|
|
13323
|
+
options.push({value: _state.geneAnchor, text: _state.geneAnchor + ' (not in this tree)', title: _state.geneAnchor});
|
|
13324
|
+
}
|
|
13325
|
+
options.push({value: 'none', text: 'None (left-aligned)', title: 'no anchor: every row starts at its left end'});
|
|
13326
|
+
fillSelect(sel, options, _state.geneAnchor);
|
|
13327
|
+
}
|
|
13328
|
+
// the namespace: offered when the genes carry more than one
|
|
13329
|
+
let nss = forester.geneFamilyNamespaces(regions);
|
|
13330
|
+
let row = byId(GENE_FAMILY_SELECT + '_row');
|
|
13331
|
+
let fsel = byId(GENE_FAMILY_SELECT);
|
|
13332
|
+
if (fsel && row) {
|
|
13333
|
+
let options = nss.map(function (x) {
|
|
13334
|
+
return {value: x.ns, text: x.ns + ' (' + countNumber(x.genes) + ' genes)', title: 'annotations with the prefix ' + x.ns + ':'};
|
|
13335
|
+
});
|
|
13336
|
+
if (ns && !nss.some(function (x) {
|
|
13337
|
+
return x.ns === ns;
|
|
13338
|
+
})) {
|
|
13339
|
+
options.push({value: ns, text: ns + ' (not in this tree)', title: ns});
|
|
13340
|
+
}
|
|
13341
|
+
fillSelect(fsel, options, ns || '');
|
|
13342
|
+
row.style.display = options.length > 1 ? '' : 'none';
|
|
13343
|
+
}
|
|
13344
|
+
setValue(GENE_LABELS_SELECT, _state.geneLabels);
|
|
13345
|
+
}
|
|
13346
|
+
|
|
13347
|
+
function genesCbClicked() {
|
|
13348
|
+
_state.showGenes = getCheckboxValue(GENES_CB);
|
|
13349
|
+
if (_state.showGenes && radialDisplay()) {
|
|
13350
|
+
_radialLabelsHorizontal = false; // the arrows ride the spokes, so the labels must too
|
|
13351
|
+
syncZoomRowButtons();
|
|
13352
|
+
}
|
|
13353
|
+
syncGeneControls();
|
|
13354
|
+
scheduleUpdate(null, 0);
|
|
13355
|
+
}
|
|
13356
|
+
|
|
13357
|
+
function geneWidthStep(grow) {
|
|
13358
|
+
let w = geneTrackWidth();
|
|
13359
|
+
if (grow ? w >= DOMAIN_WIDTH_MAX : w <= DOMAIN_WIDTH_MIN) {
|
|
13360
|
+
return;
|
|
13361
|
+
}
|
|
13362
|
+
setGeneTrackWidth(w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK));
|
|
13363
|
+
syncGeneControls();
|
|
13364
|
+
if (radialDisplay()) {
|
|
13365
|
+
scheduleUpdate(null, 0, true);
|
|
13366
|
+
afterUpdate(zoomToFit);
|
|
13367
|
+
} else {
|
|
13368
|
+
scheduleUpdate(null, 0);
|
|
13369
|
+
}
|
|
13370
|
+
}
|
|
13371
|
+
|
|
13372
|
+
function geneAnchorChanged() {
|
|
13373
|
+
let v = getValue(GENE_ANCHOR_SELECT);
|
|
13374
|
+
if (!v || v === _state.geneAnchor) {
|
|
13375
|
+
return;
|
|
13376
|
+
}
|
|
13377
|
+
_state.geneAnchor = v;
|
|
13378
|
+
scheduleUpdate(null, 0, true);
|
|
13379
|
+
}
|
|
13380
|
+
|
|
13381
|
+
function geneFamilyChanged() {
|
|
13382
|
+
let v = getValue(GENE_FAMILY_SELECT);
|
|
13383
|
+
if (!v || v === geneFamilyNamespace()) {
|
|
13384
|
+
return;
|
|
13385
|
+
}
|
|
13386
|
+
_state.geneFamily = v;
|
|
13387
|
+
_state.geneAnchor = 'auto'; // the old anchor was a family of the old namespace
|
|
13388
|
+
_gene.palette = null; // the families change with the namespace
|
|
13389
|
+
syncGeneControls();
|
|
13390
|
+
scheduleUpdate(null, 0, true);
|
|
13391
|
+
}
|
|
13392
|
+
|
|
13393
|
+
function geneLabelsChanged() {
|
|
13394
|
+
_state.geneLabels = getValue(GENE_LABELS_SELECT);
|
|
13395
|
+
scheduleUpdate(null, 0, true);
|
|
13396
|
+
}
|
|
13397
|
+
|
|
13398
|
+
// ===================== Time axis =====================
|
|
13399
|
+
// The desktop's time overlays, drawn beneath a rectangular PHYLOGRAM of
|
|
13400
|
+
// a dated tree: the two-band ICS geologic axis with a "Ma before
|
|
13401
|
+
// present" ruler (or a calendar-year ruler), HPD age bars on dated
|
|
13402
|
+
// internal nodes and sepia fossil-range (FAD/LAD) bars on dated tips.
|
|
11750
13403
|
// The tree layout itself never changes -- time is an overlay anchored to
|
|
11751
13404
|
// the tree's own branch scale, so the bands line up with the branches
|
|
11752
13405
|
// even on a fossil-only tree whose youngest tip is far from the present.
|
|
@@ -12506,7 +14159,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
12506
14159
|
return;
|
|
12507
14160
|
}
|
|
12508
14161
|
let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
|
|
12509
|
-
let outer = maxRad + labelSpace + domainRadialExtent() + heatmapRadialExtent();
|
|
14162
|
+
let outer = maxRad + labelSpace + domainRadialExtent() + geneRadialExtent() + heatmapRadialExtent();
|
|
12510
14163
|
let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
|
|
12511
14164
|
let scale = 0.9 * (Math.min(W, H) / (2 * outer));
|
|
12512
14165
|
if (!isFinite(scale) || scale <= 0) {
|
|
@@ -13565,12 +15218,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
13565
15218
|
search0();
|
|
13566
15219
|
search1();
|
|
13567
15220
|
}
|
|
13568
|
-
if (radialDisplay() && _state.showDomainArchitectures && _basicTreeProperties.domainArchitectures)
|
|
13569
|
-
|
|
15221
|
+
if (radialDisplay() && ((_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures)
|
|
15222
|
+
|| (_state.showGenes && _basicTreeProperties.geneRegions))) {
|
|
15223
|
+
// the domain boxes and gene arrows ride the tips' spokes, so the labels must too
|
|
13570
15224
|
_radialLabelsHorizontal = false;
|
|
13571
15225
|
}
|
|
13572
15226
|
syncZoomRowButtons();
|
|
13573
15227
|
afterUpdate(syncDomainControls); // the track width buttons follow the layout's own width
|
|
15228
|
+
afterUpdate(syncGeneControls);
|
|
13574
15229
|
zoomToFit();
|
|
13575
15230
|
}
|
|
13576
15231
|
|
|
@@ -14454,6 +16109,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
14454
16109
|
for (let i = 0; i < btns.length; ++i) {
|
|
14455
16110
|
btns[i].innerHTML = panelThemeIcon(); // a drawn glyph, not a text character
|
|
14456
16111
|
}
|
|
16112
|
+
// a modeless dialog (the cheat sheet, the clock plot) stays open across
|
|
16113
|
+
// the switch, and took the theme only when it was made
|
|
16114
|
+
document.querySelectorAll('dialog.aptx-dialog').forEach(function (dlg) {
|
|
16115
|
+
dlg.classList.remove('aptx-light', 'aptx-dark');
|
|
16116
|
+
if (_panelTheme === 'light' || _panelTheme === 'dark') {
|
|
16117
|
+
dlg.classList.add('aptx-' + _panelTheme);
|
|
16118
|
+
}
|
|
16119
|
+
});
|
|
14457
16120
|
applyTreeTheme();
|
|
14458
16121
|
}
|
|
14459
16122
|
|
|
@@ -14502,7 +16165,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
14502
16165
|
const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
|
|
14503
16166
|
// Download too (Christian, 2026-09-28): four rows, and not something a
|
|
14504
16167
|
// session uses often.
|
|
14505
|
-
const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['View & Tools', 'Sizes', 'Domain Architectures', 'Download'];
|
|
16168
|
+
const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['View & Tools', 'Sizes', 'Domain Architectures', 'Genome regions', 'Download'];
|
|
14506
16169
|
|
|
14507
16170
|
function loadPanelSections() {
|
|
14508
16171
|
if (_panelSections) {
|
|
@@ -15200,6 +16863,29 @@ function (root, d3, forester, phyloXml) {
|
|
|
15200
16863
|
+ ' color:var(--p-accent-ink); }'
|
|
15201
16864
|
+ '.aptx-reps-primary { font-weight:600; }'
|
|
15202
16865
|
+ '.aptx-reps-button:focus-visible { outline:none; box-shadow:0 0 0 3px var(--p-accent-weak); }'
|
|
16866
|
+
+ '.aptx-reps-button:disabled { opacity:0.4; cursor:default; pointer-events:none; }'
|
|
16867
|
+
// the clock plot: a drawing in the panel's own inks, so it turns
|
|
16868
|
+
// with the theme; a point's colour, where it has one, is the
|
|
16869
|
+
// node's in the tree and is set on the point itself
|
|
16870
|
+
+ '.aptx-dialog-drag { cursor:move; touch-action:none; user-select:none; -webkit-user-select:none; }'
|
|
16871
|
+
+ '.aptx-clock.aptx-dialog-body { overflow:visible; }'
|
|
16872
|
+
+ '.aptx-clock-svg { display:block; touch-action:none; user-select:none; -webkit-user-select:none; }'
|
|
16873
|
+
+ '.aptx-clock-svg text { font-size:10px; fill:var(--p-muted); font-variant-numeric:tabular-nums; }'
|
|
16874
|
+
+ '.aptx-clock-svg text.aptx-clock-title { font-size:10.5px; fill:var(--p-ink); }'
|
|
16875
|
+
+ '.aptx-clock-ground { fill:var(--p-surface2); }'
|
|
16876
|
+
+ '.aptx-clock-grid { stroke:var(--p-line); stroke-width:1; shape-rendering:crispEdges; }'
|
|
16877
|
+
+ '.aptx-clock-frame { stroke:var(--p-line-strong); stroke-width:1; shape-rendering:crispEdges; }'
|
|
16878
|
+
+ '.aptx-clock-line { stroke:var(--p-accent); stroke-width:1.6; }'
|
|
16879
|
+
+ '.aptx-clock-internal circle { fill:var(--p-faint); fill-opacity:0.55; }'
|
|
16880
|
+
+ '.aptx-clock-tips circle { fill:var(--p-ink); fill-opacity:0.8; }'
|
|
16881
|
+
+ '.aptx-clock-svg circle.aptx-clock-hit { fill-opacity:1; stroke-width:1.2; }'
|
|
16882
|
+
+ '.aptx-clock-ring { fill:none; stroke:var(--p-accent); stroke-width:1.8; pointer-events:none; }'
|
|
16883
|
+
+ '.aptx-clock-box { fill:var(--p-accent-weak); stroke:var(--p-accent); stroke-width:1;'
|
|
16884
|
+
+ ' shape-rendering:crispEdges; pointer-events:none; }'
|
|
16885
|
+
+ '.aptx-clock-options { display:flex; align-items:center; gap:14px; margin:6px 0 8px; }'
|
|
16886
|
+
+ '.aptx-clock-options .aptx-clock-deselect { margin-left:auto; height:22px; padding:0 9px; }'
|
|
16887
|
+
+ '.aptx-clock-stats .aptx-dialog-key { flex-basis:38%; }'
|
|
16888
|
+
+ '.aptx-clock-note { margin-top:8px; }'
|
|
15203
16889
|
+ '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
|
|
15204
16890
|
+ ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
|
|
15205
16891
|
// The hover tooltip, on the same palette.
|
|
@@ -15309,6 +16995,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
15309
16995
|
+ '.aptx-panel .aptx-seg:hover { color:var(--p-accent-ink); background:var(--p-accent-weak); }'
|
|
15310
16996
|
+ '.aptx-panel .aptx-seg:has(> input:checked) { background:var(--p-accent); color:#fff; }'
|
|
15311
16997
|
+ '.aptx-panel .aptx-seg:has(> input:disabled) { opacity:0.4; cursor:default; }'
|
|
16998
|
+
// the Clock plot button sits in the mode bar beside the segments
|
|
16999
|
+
// and matches their height; lit while its panel is open
|
|
17000
|
+
+ '.aptx-panel .aptx-modebar input[type=button].aptx-clockbtn { height:auto; margin:0; padding:3px 8px;'
|
|
17001
|
+
+ ' font-weight:600; color:var(--p-muted); border-radius:7px; }'
|
|
17002
|
+
+ '.aptx-panel .aptx-modebar input[type=button].aptx-clockbtn.aptx-lit { background:var(--p-accent);'
|
|
17003
|
+
+ ' border-color:var(--p-accent); color:#fff; }'
|
|
15312
17004
|
+ '.aptx-panel .aptx-actions { margin-left:auto; display:flex; align-items:center; gap:5px; }'
|
|
15313
17005
|
+ '.aptx-panel .aptx-theme-btn, .aptx-panel .aptx-info-btn { flex:none; width:20px; height:20px; display:grid; place-items:center; padding:0; border:1px solid var(--p-line-strong); border-radius:6px; background:var(--p-surface2); color:var(--p-muted); cursor:pointer; font-size:12px; line-height:1; }'
|
|
15314
17006
|
+ '.aptx-panel .aptx-theme-btn:hover { background:var(--p-accent-weak); color:var(--p-accent-ink); border-color:var(--p-accent); }'
|
|
@@ -15832,6 +17524,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
15832
17524
|
if (s.type) {
|
|
15833
17525
|
text += '- Type: ' + s.type + '<br>';
|
|
15834
17526
|
}
|
|
17527
|
+
// The annotations, which this dialog never listed and which a
|
|
17528
|
+
// gene's family lives in (<annotation ref="pgfam:...">): the
|
|
17529
|
+
// ref, with the description when there is one.
|
|
17530
|
+
(s.annotations || []).forEach(function (an) {
|
|
17531
|
+
if (!an || (!an.ref && !an.desc)) {
|
|
17532
|
+
return;
|
|
17533
|
+
}
|
|
17534
|
+
text += '- Annotation: ' + (an.ref ? an.ref + (an.desc ? ' (' + an.desc + ')' : '') : an.desc) + '<br>';
|
|
17535
|
+
});
|
|
15835
17536
|
// The domain architecture, which phyloXML has always carried
|
|
15836
17537
|
// and this dialog has never shown: a reader could find a node
|
|
15837
17538
|
// by searching for one of its domains -- forester indexes them
|
|
@@ -16287,6 +17988,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
16287
17988
|
statOf(st.tipsWithDomains, st.tips, 'tips')
|
|
16288
17989
|
+ (bp.maxDomainArchitectureLength > 0
|
|
16289
17990
|
? (', up to ' + countNumber(bp.maxDomainArchitectureLength) + ' residues') : ''));
|
|
17991
|
+
carries('Genome regions', st.tipsWithGenes > 0,
|
|
17992
|
+
statOf(st.tipsWithGenes, st.tips, 'tips')
|
|
17993
|
+
+ (bp.maxGeneRegionLength > 0
|
|
17994
|
+
? (', up to ' + countNumber(bp.maxGeneRegionLength) + ' bp') : ''));
|
|
16290
17995
|
carries('Dates', st.nodesWithDate > 0, statOf(st.nodesWithDate, st.nodes, 'nodes'));
|
|
16291
17996
|
carries('Distributions', st.tipsWithDistribution > 0,
|
|
16292
17997
|
statOf(st.tipsWithDistribution, st.tips, 'tips'));
|
|
@@ -16559,6 +18264,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
16559
18264
|
|
|
16560
18265
|
c0.insertAdjacentHTML('beforeend',makeDomainControls());
|
|
16561
18266
|
|
|
18267
|
+
c0.insertAdjacentHTML('beforeend',makeGeneControls());
|
|
18268
|
+
|
|
16562
18269
|
c0.insertAdjacentHTML('beforeend',makeHeatmapControls());
|
|
16563
18270
|
|
|
16564
18271
|
c0.insertAdjacentHTML('beforeend',makeZoomControl());
|
|
@@ -16688,12 +18395,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
16688
18395
|
on(DOMAIN_EVALUE_INC, 'click', function () { domainEvalueStep(1); });
|
|
16689
18396
|
on(DOMAIN_LABELS_SELECT, 'change', domainLabelsChanged);
|
|
16690
18397
|
on(DOMAIN_GLOW_CB, 'click', domainGlowCbClicked);
|
|
18398
|
+
on(GENES_CB, 'click', genesCbClicked);
|
|
18399
|
+
onHoldRepeat(GENE_WIDTH_DEC, function () { geneWidthStep(false); });
|
|
18400
|
+
onHoldRepeat(GENE_WIDTH_INC, function () { geneWidthStep(true); });
|
|
18401
|
+
on(GENE_ANCHOR_SELECT, 'change', geneAnchorChanged);
|
|
18402
|
+
on(GENE_FAMILY_SELECT, 'change', geneFamilyChanged);
|
|
18403
|
+
on(GENE_LABELS_SELECT, 'change', geneLabelsChanged);
|
|
16691
18404
|
on(SCALE_AXIS_CB, 'click', scaleAxisCbClicked);
|
|
16692
18405
|
on(SCALE_GRID_CB, 'click', scaleGridCbClicked);
|
|
16693
18406
|
|
|
16694
18407
|
on(LAYOUT_RECT_BUTTON, 'click', layoutButtonClicked);
|
|
16695
18408
|
on(BRANCH_SCALE_TIME_BUTTON, 'click', branchScaleButtonClicked);
|
|
16696
18409
|
on(BRANCH_SCALE_DIV_BUTTON, 'click', branchScaleButtonClicked);
|
|
18410
|
+
on(CLOCK_PLOT_BUTTON, 'click', toggleClockPlot);
|
|
16697
18411
|
|
|
16698
18412
|
on(LAYOUT_CIRC_BUTTON, 'click', layoutButtonClicked);
|
|
16699
18413
|
on(LAYOUT_UNROOTED_BUTTON, 'click', layoutButtonClicked);
|
|
@@ -17137,6 +18851,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
17137
18851
|
h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
|
|
17138
18852
|
'lay the tree out by DIVERGENCE (substitutions per site: as the tree records it, or on a BEAST tree each branch\'s time x its clock rate)'));
|
|
17139
18853
|
h = h.concat('</div>');
|
|
18854
|
+
// The clock plot, where the tree has one (forester.clockPlotKind).
|
|
18855
|
+
// Beside the switch because it draws the same two quantities
|
|
18856
|
+
// against each other; a button of its own because it opens a
|
|
18857
|
+
// panel and leaves the layout alone.
|
|
18858
|
+
h = h.concat('<input type="button" class="aptx-clockbtn" value="Clock plot" name="' + CLOCK_PLOT_BUTTON
|
|
18859
|
+
+ '" id="' + CLOCK_PLOT_BUTTON + '" title="' + CLOCK_PLOT_WHAT + '">');
|
|
17140
18860
|
h = h.concat('</div>');
|
|
17141
18861
|
h = h.concat('</fieldset>');
|
|
17142
18862
|
return h;
|
|
@@ -17237,6 +18957,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
17237
18957
|
if (_basicTreeProperties.domainArchitectures) {
|
|
17238
18958
|
opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
|
|
17239
18959
|
}
|
|
18960
|
+
if (_basicTreeProperties.geneRegions) {
|
|
18961
|
+
opts.push(makeCheckboxItem('Genes', GENES_CB, 'to show/hide the genome regions beside the tips: each gene an arrow on a backbone, coloured by family, the rows lined up on one family'));
|
|
18962
|
+
}
|
|
17240
18963
|
if (heatmapAvailable()) {
|
|
17241
18964
|
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)'));
|
|
17242
18965
|
}
|
|
@@ -17375,6 +19098,40 @@ function (root, d3, forester, phyloXml) {
|
|
|
17375
19098
|
return h;
|
|
17376
19099
|
}
|
|
17377
19100
|
|
|
19101
|
+
// The gene track's controls, shown only while the Genes toggle is on
|
|
19102
|
+
// (syncGeneControls): the track width, the anchor family, the family
|
|
19103
|
+
// namespace the colours follow (shown when the genes carry more than
|
|
19104
|
+
// one), and the label mode. The two selects are filled per tree by
|
|
19105
|
+
// syncGeneControls, since their options come from the genes.
|
|
19106
|
+
function makeGeneControls() {
|
|
19107
|
+
let h = '<fieldset id="' + GENE_CONTROLS + '" style="display:none">';
|
|
19108
|
+
h = h.concat('<legend>Genome regions</legend>');
|
|
19109
|
+
h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel">Track width</span>');
|
|
19110
|
+
h = h.concat(makeButton('−', GENE_WIDTH_DEC, 'narrow the gene track (hold to repeat)'));
|
|
19111
|
+
h = h.concat(makeButton('+', GENE_WIDTH_INC, 'widen the gene track (hold to repeat)'));
|
|
19112
|
+
h = h.concat('</div>');
|
|
19113
|
+
h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + GENE_ANCHOR_SELECT + '">Anchor</label>');
|
|
19114
|
+
h = h.concat('<select name="' + GENE_ANCHOR_SELECT + '" id="' + GENE_ANCHOR_SELECT
|
|
19115
|
+
+ '" title="the family the rows line up on: its gene sits at the same place in every genome, pointing'
|
|
19116
|
+
+ ' right, and the neighbours follow (a genome with it on the other strand is mirrored). Auto picks the'
|
|
19117
|
+
+ ' family single-copy in the most genomes; None left-aligns the rows"></select></div>');
|
|
19118
|
+
h = h.concat('<div class="aptx-domrow" id="' + GENE_FAMILY_SELECT + '_row"><label class="aptx-domlabel" for="'
|
|
19119
|
+
+ GENE_FAMILY_SELECT + '">Family</label>');
|
|
19120
|
+
h = h.concat('<select name="' + GENE_FAMILY_SELECT + '" id="' + GENE_FAMILY_SELECT
|
|
19121
|
+
+ '" title="which family annotation colours the genes and anchors the rows (a BV-BRC export carries'
|
|
19122
|
+
+ ' both PGfams and PLfams)"></select></div>');
|
|
19123
|
+
h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + GENE_LABELS_SELECT + '">Labels</label>');
|
|
19124
|
+
h = h.concat('<select name="' + GENE_LABELS_SELECT + '" id="' + GENE_LABELS_SELECT
|
|
19125
|
+
+ '" title="where the gene symbols go: on the arrows (rectangular layout only), in a legend of the'
|
|
19126
|
+
+ ' families, or nowhere">');
|
|
19127
|
+
h = h.concat('<option value="genes">On genes</option>');
|
|
19128
|
+
h = h.concat('<option value="legend">Legend</option>');
|
|
19129
|
+
h = h.concat('<option value="none">None</option>');
|
|
19130
|
+
h = h.concat('</select></div>');
|
|
19131
|
+
h = h.concat('</fieldset>');
|
|
19132
|
+
return h;
|
|
19133
|
+
}
|
|
19134
|
+
|
|
17378
19135
|
// Shown only while the Heat Map toggle is on (syncHeatmapControls).
|
|
17379
19136
|
// The desktop's View > Order Matrix Columns, minus its Manual mode --
|
|
17380
19137
|
// that one means "the order you dragged the rows into", and there is
|
|
@@ -17621,6 +19378,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
17621
19378
|
setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
|
|
17622
19379
|
setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
|
|
17623
19380
|
syncDomainControls();
|
|
19381
|
+
syncGeneControls();
|
|
17624
19382
|
syncHeatmapControls();
|
|
17625
19383
|
setCheckboxValue(SCALE_AXIS_CB, _state.showScaleAxis);
|
|
17626
19384
|
setCheckboxValue(SCALE_GRID_CB, _state.showScaleGrid);
|
|
@@ -18083,7 +19841,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
18083
19841
|
// colour and shape legends, the domain legend, the heat-map ring's scale.
|
|
18084
19842
|
// On screen they sit where the window has room; in a full-size figure the
|
|
18085
19843
|
// window is gone, so they are stacked beside the tree instead.
|
|
18086
|
-
const WINDOW_LEGEND_CLASSES = [LEGEND_LABEL_COLOR, LEGEND_NODE_SHAPE, LEGEND_DOMAINS,
|
|
19844
|
+
const WINDOW_LEGEND_CLASSES = [LEGEND_LABEL_COLOR, LEGEND_NODE_SHAPE, LEGEND_DOMAINS, LEGEND_GENES,
|
|
19845
|
+
'aptx-heatmap-ringlegend'];
|
|
18087
19846
|
const FULL_SIZE_LEGEND_STACK_GAP = 12;
|
|
18088
19847
|
|
|
18089
19848
|
// The enlarged drawing, cropped to what is actually drawn: the tree
|