@gmod/jbrowse
Version:
JBrowse - client-side genome browser
636 lines (546 loc) • 24 kB
JavaScript
define( [
'dojo/_base/declare',
'dojo/_base/array',
'dojo/_base/lang',
'dojo/_base/event',
'dojo/dom-construct',
'dojo/on',
'dojo/mouse',
'JBrowse/View/Track/BlockBased',
'JBrowse/View/Track/_ExportMixin',
'JBrowse/View/Track/_TrackDetailsStatsMixin',
'JBrowse/View/Dialog/SetTrackHeight',
'JBrowse/Util',
'JBrowse/has',
'./Wiggle/_Scale'
],
function(
declare,
array,
lang,
domEvent,
dom,
on,
mouse,
BlockBasedTrack,
ExportMixin,
DetailStatsMixin,
TrackHeightDialog,
Util,
has,
Scale
) {
return declare( [BlockBasedTrack,ExportMixin, DetailStatsMixin ], {
constructor: function( args ) {
this.trackPadding = args.trackPadding || 0;
if( ! ('style' in this.config ) ) {
this.config.style = {};
}
this.store = args.store;
this._setupEventHandlers();
},
_defaultConfig: function() {
return {
maxExportSpan: 500000,
autoscale: 'global',
logScaleOption: true
};
},
_setupEventHandlers: function() {
// make a default click event handler
var eventConf = dojo.clone( this.config.events || {} );
if( ! eventConf.click ) {
// unlike CanvasFeatures, linkTemplate or nothing here... no default contentDialog since no equivalent to defaultFeatureDetail
if ((this.config.style||{}).linkTemplate) {
eventConf.click = { action: "newWindow", url: this.config.style.linkTemplate };
}
}
// process the configuration to set up our event handlers
this.eventHandlers = (function() {
var handlers = dojo.clone( eventConf );
// find conf vars that set events, like `onClick`
for( var key in this.config ) {
var handlerName = key.replace(/^on(?=[A-Z])/, '');
if( handlerName != key )
handlers[ handlerName.toLowerCase() ] = this.config[key];
}
// interpret handlers that are just strings to be URLs that should be opened
for( key in handlers ) {
if( typeof handlers[key] == 'string' )
handlers[key] = { url: handlers[key] };
}
return handlers;
}).call(this);
// only call _makeClickHandler() if we have related settings in config
if (this.eventHandlers.click) this.eventHandlers.click = this._makeClickHandler( this.eventHandlers.click );
},
_getScaling: function( viewArgs, successCallback, errorCallback ) {
this._getScalingStats( viewArgs, dojo.hitch(this, function( stats ) {
//calculate the scaling if necessary
if( ! this.lastScaling || ! this.lastScaling.sameStats(stats) ) {
try {
this.lastScaling = new Scale( this.config, stats );
successCallback( this.lastScaling );
} catch( e ) {
errorCallback(e);
}
} else {
successCallback( this.lastScaling );
}
}), errorCallback );
},
// get the statistics to use for scaling, if necessary, either
// from the global stats for the store, or from the local region
// if config.autoscale is 'local'
_getScalingStats: function( viewArgs, callback, errorCallback ) {
if( ! Scale.prototype.needStats( this.config ) ) {
callback( null );
return null;
}
else if( this.config.autoscale == 'local' ) {
var region = lang.mixin( { scale: viewArgs.scale }, this.browser.view.visibleRegion() );
region.start = Math.ceil( region.start );
region.end = Math.floor( region.end );
return this.getRegionStats.call( this, region, callback, errorCallback );
}
else {
return this.getGlobalStats.call( this, callback, errorCallback );
}
},
getFeatures: function( query, callback, errorCallback ) {
this.store.getFeatures.apply( this.store, arguments );
},
getGlobalStats: function( successCallback, errorCallback ) {
this.store.getGlobalStats( successCallback, errorCallback );
},
getRegionStats: function( region, successCallback, errorCallback ) {
this.store.getRegionStats( region, successCallback, errorCallback );
},
// the canvas width in pixels for a block
_canvasWidth: function( block ) {
return Math.ceil(( block.endBase - block.startBase ) * block.scale);
},
// the canvas height in pixels for a block
_canvasHeight: function() {
return parseInt(( this.config.style || {}).height) || 100;
},
_getBlockFeatures: function( args ) {
var thisB = this;
var blockIndex = args.blockIndex;
var block = args.block;
var leftBase = args.leftBase;
var rightBase = args.rightBase;
var scale = args.scale;
var finishCallback = args.finishCallback || function() {};
var canvasWidth = this._canvasWidth( args.block );
var features = [];
this.getFeatures(
{ ref: this.refSeq.name,
basesPerSpan: 1/scale,
scale: scale,
start: leftBase,
end: rightBase+1
},
function(f) {
if( thisB.filterFeature(f) )
features.push(f);
},
dojo.hitch( this, function(args) {
// if the block has been freed in the meantime,
// don't try to render
if( ! (block.domNode && block.domNode.parentNode ))
return;
var featureRects = array.map( features, function(f) {
return this._featureRect( scale, leftBase, canvasWidth, f );
}, this );
block.features = features; //< TODO: remove this
block.featureRects = featureRects;
block.pixelScores = this._calculatePixelScores( this._canvasWidth(block), features, featureRects );
if (args && args.maskingSpans)
block.maskingSpans = args.maskingSpans; // used for masking
finishCallback();
}),
dojo.hitch( this, function(e) {
console.error( e.stack || ''+e, e );
this._handleError( e, args );
}));
},
// render the actual graph display for the block. should be called only after a scaling
// has been decided upon and stored in this.scaling
renderBlock: function( args ) {
var block = args.block;
// don't render this block again if we have already rendered
// it with this scaling scheme
if( ! this.scaling.compare( block.scaling ) || ! block.pixelScores )
return;
block.scaling = this.scaling;
dom.empty( block.domNode );
try {
dojo.create('canvas').getContext('2d').fillStyle = 'red';
} catch( e ) {
this.fatalError = 'This browser does not support HTML canvas elements.';
this.fillBlockError( args.blockIndex, block, this.fatalError );
return;
}
var features = block.features;
var featureRects = block.featureRects;
var dataScale = this.scaling;
var canvasHeight = this._canvasHeight();
var c = dojo.create(
'canvas',
{ height: canvasHeight,
width: this._canvasWidth(block),
style: {
cursor: 'default',
height: canvasHeight + "px",
width: has('inaccurate-html-width') ? "" : "100%",
"min-width": has('inaccurate-html-width')? "100%":"",
"max-width": has('inaccurate-html-width')? "102%":""
},
innerHTML: 'Your web browser cannot display this type of track.',
className: 'canvas-track'
},
block.domNode
);
var ctx = c.getContext('2d');
var ratio=Util.getResolution(ctx, this.browser.config.highResolutionMode);
// upscale canvas if the two ratios don't match
if (this.browser.config.highResolutionMode!='disabled' && ratio>=1) {
var oldWidth = c.width;
var oldHeight = c.height;
c.width = Math.round(oldWidth * ratio);
c.height = Math.round(oldHeight * ratio);
//c.style.width = oldWidth + 'px';
c.style.height = oldHeight + 'px';
// now scale the context to counter
// the fact that we've manually scaled
// our canvas element
ctx.scale(ratio, ratio);
}
c.startBase = block.startBase;
block.canvas = c;
//Calculate the score for each pixel in the block
var pixels = this._calculatePixelScores( c.width, features, featureRects );
this._draw( block.scale, block.startBase,
block.endBase, block,
c, features,
featureRects, dataScale,
pixels, block.maskingSpans ); // note: spans may be undefined.
this.heightUpdate( c.height/ratio, args.blockIndex );
if( !( c.parentNode && c.parentNode.parentNode )) {
var blockWidth = block.endBase - block.startBase;
c.style.position = "absolute";
c.style.left = (100 * ((c.startBase - block.startBase) / blockWidth)) + "%";
switch (this.config.align) {
case "top":
c.style.top = "0px";
break;
case "bottom":
/* fall through */
default:
c.style.bottom = this.trackPadding + "px";
break;
}
}
},
fillBlock: function( args ) {
var thisB = this;
this.heightUpdate( this._canvasHeight(), args.blockIndex );
// hook updateGraphs onto the end of the block feature fetch
var oldFinish = args.finishCallback || function() {};
args.finishCallback = function() {
thisB.updateGraphs( args, oldFinish );
};
// get the features for this block, and then set in motion the
// updating of the graphs
this._getBlockFeatures( args );
},
updateGraphs: function( viewArgs, callback ) {
var thisB = this;
// update the global scaling
this._getScaling( viewArgs,
function( scaling ) {
thisB.scaling = scaling;
// render all of the blocks that need it
array.forEach( thisB.blocks, function( block, blockIndex ) {
if( block && block.domNode.parentNode )
thisB.renderBlock({
block: block,
blockIndex: blockIndex
});
});
callback();
},
function(e) {
thisB._handleError( e, viewArgs );
});
},
// Draw features
_draw: function(scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale, pixels, spans) {
this._preDraw( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale );
this._drawFeatures( scale, leftBase, rightBase, block, canvas, pixels, dataScale );
if ( spans ) {
this._maskBySpans( scale, leftBase, rightBase, block, canvas, pixels, dataScale, spans );
}
this._postDraw( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale );
},
startZoom: function(destScale, destStart, destEnd) {
},
endZoom: function(destScale, destBlockBases) {
this.clear();
},
/**
* Calculate the left and width, in pixels, of where this feature
* will be drawn on the canvas.
* @private
* @returns {Object} with l, r, and w
*/
_featureRect: function( scale, leftBase, canvasWidth, feature ) {
var fRect = {
w: Math.ceil(( feature.get('end') - feature.get('start') ) * scale ),
l: Math.round(( feature.get('start') - leftBase ) * scale )
};
// if fRect.l is negative (off the left
// side of the canvas), clip off the
// (possibly large!) non-visible
// portion
if( fRect.l < 0 ) {
fRect.w += fRect.l;
fRect.l = 0;
}
// also don't let fRect.w get overly big
fRect.w = Math.min( canvasWidth-fRect.l, fRect.w );
fRect.r = fRect.w + fRect.l;
return fRect;
},
_preDraw: function( canvas ) {
},
/**
* Draw a set of features on the canvas.
* @private
*/
_drawFeatures: function( scale, leftBase, rightBase, block, canvas, features, featureRects ) {
},
// If we are making a boolean track, this will be called. Overwrite.
_maskBySpans: function( scale, leftBase, canvas, spans, pixels ) {
},
_postDraw: function() {
},
_calculatePixelScores: function( canvasWidth, features, featureRects ) {
var scoreType = this.config.scoreType;
var pixelValues = new Array( canvasWidth );
if(!scoreType||scoreType=="maxScore") {
// make an array of the max score at each pixel on the canvas
dojo.forEach( features, function( f, i ) {
var store = f.source;
var fRect = featureRects[i];
var jEnd = fRect.r;
var score = scoreType?f.get(scoreType):f.get('score');
for( var j = Math.round(fRect.l); j < jEnd; j++ ) {
if ( pixelValues[j] && pixelValues[j]['lastUsedStore'] == store ) {
/* Note: if the feature is from a different store, the condition should fail,
* and we will add to the value, rather than adjusting for overlap */
pixelValues[j]['score'] = Math.max( pixelValues[j]['score'], score );
}
else if ( pixelValues[j] ) {
pixelValues[j]['score'] = pixelValues[j]['score'] + score;
pixelValues[j]['lastUsedStore'] = store;
}
else {
pixelValues[j] = { score: score, lastUsedStore: store, feat: f };
}
}
},this);
// when done looping through features, forget the store information.
for (var i=0; i<pixelValues.length; i++) {
if ( pixelValues[i] ) {
delete pixelValues[i]['lastUsedStore'];
}
}
}
else if(scoreType=="avgScore") {
// make an array of the average score at each pixel on the canvas
dojo.forEach( features, function( f, i ) {
var store = f.source;
var fRect = featureRects[i];
var jEnd = fRect.r;
var score = f.get('score');
for( var j = Math.round(fRect.l); j < jEnd; j++ ) {
// bin scores according to store
if ( pixelValues[j] && store in pixelValues[j]['scores'] ) {
pixelValues[j]['scores'][store].push(score);
}
else if ( pixelValues[j] ) {
pixelValues[j]['scores'][store] = [score];
}
else {
pixelValues[j] = { scores: {}, feat: f };
pixelValues[j]['scores'][store] = [score];
}
}
},this);
// when done looping through features, average the scores in the same store then add them all together as the final score
for (var i=0; i<pixelValues.length; i++) {
if ( pixelValues[i] ) {
pixelValues[i]['score'] = 0;
for ( var store in pixelValues[i]['scores']) {
var j, sum = 0, len = pixelValues[i]['scores'][store].length;
for (j = 0; j < len; j++) {
sum += pixelValues[i]['scores'][store][j];
}
pixelValues[i]['score'] += sum / len;
}
delete pixelValues[i]['scores'];
}
}
}
return pixelValues;
},
setViewInfo: function() {
this.inherited(arguments);
this._makeScoreDisplay();
},
_makeScoreDisplay: function() {
var gv = this.browser.view;
var thisB = this;
if( ! this._mouseoverEvent )
this._mouseoverEvent = this.own(
on( this.div, 'mousemove', function( evt ) {
evt = domEvent.fix( evt );
var bpX = gv.absXtoBp( evt.clientX );
thisB.mouseover( bpX, evt );
}))[0];
if( ! this._mouseoutEvent )
this._mouseoutEvent = this.own( on( this.div, mouse.leave, function( evt) {
thisB.mouseover( undefined );
}))[0];
// only add if we have config setting a click eventHandler for this track
if (thisB.eventHandlers.click && ! this._mouseClickEvent)
this._mouseClickEvent = this.own( on ( this.div, "click", thisB.eventHandlers.click ))[0];
// make elements and events to display it
if( ! this.scoreDisplay )
this.scoreDisplay = {
flag: dojo.create(
'div', {
className: 'wiggleValueDisplay',
style: {
position: 'fixed',
display: 'none',
zIndex: 15
}
}, this.div),
pole: dojo.create( 'div', {
className: 'wigglePositionIndicator',
style: {
position: 'fixed',
display: 'none',
zIndex: 15
}
}, this.div )
};
},
mouseover: function( bpX, evt ) {
// if( this._scoreDisplayHideTimeout )
// window.clearTimeout( this._scoreDisplayHideTimeout );
if( bpX === undefined ) {
var thisB = this;
//this._scoreDisplayHideTimeout = window.setTimeout( function() {
thisB.scoreDisplay.flag.style.display = 'none';
thisB.scoreDisplay.pole.style.display = 'none';
//}, 1000 );
}
else {
var block;
array.some(this.blocks, function(b) {
if( b && b.startBase <= bpX && b.endBase >= bpX ) {
block = b;
return true;
}
return false;
});
if( !( block && block.canvas && block.pixelScores && evt ) )
return;
var pixelValues = block.pixelScores;
var canvas = block.canvas;
var cPos = dojo.position( canvas );
var x = evt.pageX;
var cx = evt.pageX - cPos.x;
if( this._showPixelValue( this.scoreDisplay.flag, pixelValues[ Math.round( cx ) ] ) ) {
this.scoreDisplay.flag.style.display = 'block';
this.scoreDisplay.pole.style.display = 'block';
this.scoreDisplay.flag.style.left = evt.clientX+'px';
this.scoreDisplay.flag.style.top = cPos.y+'px';
this.scoreDisplay.pole.style.left = evt.clientX+'px';
this.scoreDisplay.pole.style.height = cPos.h+'px';
this.scoreDisplay.pole.style.top = cPos.y+'px';
}
}
},
_showPixelValue: function( scoreDisplay, score ) {
if( typeof score == 'number' ) {
// display the score with only 6
// significant digits, avoiding
// most confusion about the
// approximative properties of
// IEEE floating point numbers
// parsed out of BigWig files
scoreDisplay.innerHTML = parseFloat( score.toPrecision(6) );
return true;
}
else if( score && typeof score['score'] == 'number' ) {
// "score" may be an object.
scoreDisplay.innerHTML = parseFloat( score['score'].toPrecision(6) );
return true;
}
else {
return false;
}
},
_exportFormats: function() {
return [{name: 'bedGraph', label: 'bedGraph', fileExt: 'bedgraph'}, {name: 'Wiggle', label: 'Wiggle', fileExt: 'wig'}, {name: 'GFF3', label: 'GFF3', fileExt: 'gff3'} ];
},
_trackMenuOptions: function() {
var track = this;
var options = this.inherited(arguments) || [];
options.push({
label: 'Change height',
iconClass: 'jbrowseIconVerticalResize',
action: function() {
new TrackHeightDialog({
height: track._canvasHeight(),
setCallback: function( newHeight ) {
track.trackHeightChanged=true;
track.updateUserStyles({ height: newHeight });
}
}).show();
}
});
if(this.config.logScaleOption) {
options.push({
label: 'Log scale',
type: 'dijit/CheckedMenuItem',
checked: !!(this.config.scale == 'log'),
onClick: function(event) {
if (this.checked) {
track.config.scale = 'log';
} else {
track.config.scale = 'linear';
}
track.browser.publish('/jbrowse/v1/v/tracks/replace', [track.config]);
}
});
}
return options;
},
// this draws either one or two width pixels based on whether there is a fractional devicePixelRatio
_fillRectMod: function( ctx, left, top, width, height ) {
var devicePixelRatio = window.devicePixelRatio || 1;
var drawWidth=width;
// check for fractional devicePixelRatio, and if so, draw wider pixels to avoid subpixel rendering
if( this.browser.config.highResolutionMode!='disabled' && (devicePixelRatio-Math.floor(devicePixelRatio)) > 0 ) {
drawWidth=width+0.3; // Minimal for subpixel gap, heuristic
}
ctx.fillRect( left, top, drawWidth, height );
}
});
});