public/js/hits_overview.js
import * as d3 from 'd3';
import _ from 'underscore';
import Grapher from 'grapher';
import * as Helpers from './visualisation_helpers';
import Utils from './utils';
class Graph {
static canCollapse() {
return true;
}
static name() {
return 'Graphical overview of aligning hit sequences to the query';
}
static className() {
return 'alignment-overview';
}
static graphId(props) {
return 'alignment_'+props.query.number;
}
static dataName(props) {
return 'Alignment-Overview-'+props.query.id;
}
constructor($svgContainer, props) {
this.svg_container = $svgContainer;
var $queryDiv = $svgContainer.parents('.resultn');
var hits = this.extractData(props.query.hits, props.query.number);
this.graphIt($queryDiv, $svgContainer, 0, 20, null, hits);
}
extractData(query_hits, number) {
var hits = [];
query_hits.map(function (hit) {
var _hsps = [];
var hsps = hit.hsps;
_.each(hsps, function (hsp) {
var _hsp = {};
_hsp.hspEvalue = hsp.evalue;
_hsp.hspStart = hsp.qstart;
_hsp.hspEnd = hsp.qend;
_hsp.hspFrame = hsp.sframe;
_hsp.hspId = 'Query_' + number + '_hit_' + hit.number + '_hsp_' + hsp.number;
_hsp.hspIdentity = hsp.identity;
_hsp.hspGaps = hsp.gaps;
_hsp.hspPositives = hsp.positives;
_hsp.hspLength = hsp.length;
_hsps.push(_hsp);
});
_hsps.hitId = hit.id;
_hsps.hitDef = 'Query_'+number+'_hit_'+hit.number;
_hsps.hitEvalue = hit.hsps[0].evalue;
hits.push(_hsps);
});
return hits;
}
setupClick($graphDiv) {
$('a', $graphDiv).click(function (evt) {
evt.preventDefault();
evt.stopPropagation();
window.location.hash = $(this).attr('href');
});
}
graphControls($queryDiv, $graphDiv, isInit, opts, hits) {
var MIN_HITS_TO_SHOW = 20;
var totalHits, shownHits, lessButton, moreButton;
var countHits = function () {
totalHits = hits.length;
shownHits = $queryDiv.find('.ghit > g').length;
};
var setupButtons = function($queryDiv, $graphDiv) {
$graphDiv
.append(
$('<button/>')
.addClass('btn btn-link text-sm text-seqblue hover:text-seqorange cursor-pointer more')
.attr('type', 'button')
.attr('data-parent-query', $queryDiv.attr('id'))
.html('View More ')
.append(
$('<i/>')
.html(' ')
.addClass('fa fa-angle-double-down')
),
$('<button/>')
.addClass('btn btn-link text-sm text-seqblue hover:text-seqorange cursor-pointer less')
.attr('type', 'button')
.attr('data-parent-query', $queryDiv.attr('id'))
.html('View Less ')
.append(
$('<i/>')
.html(' ')
.addClass('fa fa-angle-double-up')
)
);
lessButton = $('.less', $graphDiv);
moreButton = $('.more', $graphDiv);
};
var initButtons = function () {
countHits();
if (totalHits === MIN_HITS_TO_SHOW ||
shownHits < MIN_HITS_TO_SHOW) {
lessButton.hide();
moreButton.hide();
}
else if (shownHits === totalHits) {
moreButton.hide();
lessButton.show();
}
else if (shownHits === MIN_HITS_TO_SHOW) {
lessButton.hide();
moreButton.show();
}
else {
lessButton.show();
moreButton.show();
}
};
// Setup view buttons' state properly if called for first time.
if (isInit === true) {
setupButtons($queryDiv, $graphDiv);
initButtons();
}
moreButton.on('click', _.bind(function (e) {
countHits();
this.graphIt($queryDiv, $graphDiv, shownHits, MIN_HITS_TO_SHOW, opts, hits);
initButtons();
e.stopPropagation();
},this));
lessButton.on('click', _.bind(function (e) {
countHits();
var diff = shownHits - MIN_HITS_TO_SHOW;
// Decrease number of shown hits by defined constant.
if (diff >= MIN_HITS_TO_SHOW) {
this.graphIt($queryDiv, $graphDiv, shownHits, -MIN_HITS_TO_SHOW, opts, hits);
initButtons();
}
else if (diff !== 0) {
// Ensure a certain number of hits always stay in graph.
this.graphIt($queryDiv, $graphDiv, shownHits, MIN_HITS_TO_SHOW - shownHits, opts, hits);
initButtons();
}
e.stopPropagation();
},this));
}
drawLegend(svg, options, width, height, hits) {
var svg_legend = svg.append('g')
.attr('transform',
'translate(0,' + (height - 1.75 * options.margin) + ')');
svg_legend.append('rect')
.attr('x', 7.5 * (width - 2 * options.margin) / 10)
.attr('width', 2 * (width - 4 * options.margin) / 10)
.attr('height', options.legend)
.attr('fill', 'url(#legend-grad)');
svg_legend.append('text')
.attr('class',' legend-text')
.attr('transform', 'translate(0, ' +options.legend +')')
.attr('x', 9.5 * (width - 2 * options.margin) / 10 + options.margin / 2)
.text('Weaker hits');
// .text(function() {
// return Helpers.prettify_evalue(hits[hits.length-1].hitEvalue);
// })
svg_legend.append('text')
.attr('class',' legend-text')
.attr('transform', 'translate(0, ' + options.legend + ')')
.attr('x', 6.7 * (width - 2 * options.margin) / 10 - options.margin / 2)
.text('Stronger hits');
// .text(function () {
// return Helpers.prettify_evalue(hits[0].hitEvalue);
// })
svg.append('linearGradient')
.attr('id', 'legend-grad')
.selectAll('stop')
.data([
{offset: '0%', color: '#000'},
{offset: '45%', color: '#c74f14'},
{offset: '100%', color: '#f6bea2'}
])
.enter()
.append('stop')
.attr('offset', function (d) {
return d.offset;
})
.attr('stop-color', function (d) {
return d.color;
});
}
graphIt($queryDiv, $graphDiv, index, howMany, opts, inhits) {
/* barHeight: Height of each hit track.
* legend: Height reserved for the overview legend.
* margin: Margin around the svg element.
*/
var defaults = {
barHeight: 4,
legend: inhits.length > 1 ? 3 : 0,
margin: 20
},
options = $.extend(defaults, opts);
var hits = inhits.slice(0 , index + howMany);
// Don't draw anything when no hits are obtained.
if (hits.length < 1) return false;
if (index !== 0) {
// Currently, we have no good way to extend pre-existing graph
// and hence, are removing the old one and redrawing.
$graphDiv.find('svg').remove();
}
var queryLen = $queryDiv.data().queryLen;
var q_i = $queryDiv.attr('id');
var width = $graphDiv.width();
var height = hits.length * (options.barHeight) +
2 * options.legend + 4 * options.margin;
// var height = $graphDiv.height();
var SEQ_TYPES = {
blastn: 'nucleic_acid',
blastp: 'amino_acid',
blastx: 'nucleic_acid',
tblastx: 'nucleic_acid',
tblastn: 'amino_acid'
};
var svg = d3.select($graphDiv[0])
.selectAll('svg')
.data([hits])
.enter()
.insert('svg', ':first-child')
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', 'translate(' + options.margin / 2 + ', ' + (1.5 * options.margin) + ')');
var x = d3.scaleLinear().range([0, width - options.margin]);
x.domain([1, queryLen]);
var algorithm = $queryDiv.data().algorithm;
var formatter = Helpers.tick_formatter(x, SEQ_TYPES[algorithm]);
var _tValues = x.ticks(11);
_tValues.pop();
var xAxis = d3.axisBottom(x)
.tickValues(_tValues.concat([1, queryLen]))
.tickFormat(formatter);
// Attach the axis to DOM (<svg> element)
var container = svg.append('g')
.attr('transform', 'translate(0, ' + options.margin + ')')
.append('g')
.attr('class', 'x axis')
.call(xAxis);
// Vertical alignment of ticks
container.selectAll('text')
.attr('x','25px')
.attr('y','2px')
.attr('transform','rotate(-90)');
var y = d3.scaleBand()
.range([0, height - 3 * options.margin - 2 * options.legend], 0.3);
y.domain(hits.map(function (d) {
return d.hitId;
}));
var gradScale = d3.scaleLog()
.domain([
d3.min([1e-5, d3.min(hits.map(function (d) {
if (parseFloat(d.hitEvalue) === 0.0) return undefined;
return d.hitEvalue;
}))
]),
d3.max(hits.map(function (d) {
return d.hitEvalue;
}))
])
.range([0,0.8]);
svg.append('g')
.attr('class', 'ghit')
.attr('transform', 'translate(0, ' + 1.65 * (options.margin - options.legend) + ')')
.selectAll('.hits')
.data(hits)
.enter()
.append('g')
.each(function (d,i) {
// TODO: Avoid too many variables and improve naming.
d3.select(this)
.selectAll('.hsp')
.data(d).enter()
.append('a')
.each(function (v, j) {
// Drawing the HSPs connector line using the same
// color as that of the hit track (using lookahead).
var yHspline = y(d.hitId) + options.barHeight / 2;
var hsplineColor = d3.hsl(20, 0.82, gradScale(v.hspEvalue));
if (j+1 < d.length) {
if (d[j].hspEnd <= d[j+1].hspStart) {
d3.select(this.parentNode)
.append('line')
.attr('x1', x(d[j].hspEnd))
.attr('y1', yHspline)
.attr('x2', x(d[j+1].hspStart))
.attr('y2', yHspline)
.attr('stroke', hsplineColor);
}
else if (d[j].hspStart > d[j+1].hspEnd) {
d3.select(this.parentNode)
.append('line')
.attr('x1', x(d[j+1].hspEnd))
.attr('y1', yHspline)
.attr('x2', x(d[j].hspStart))
.attr('y2', yHspline)
.attr('stroke', hsplineColor);
}
}
var alt_tooltip = d.hitId + '<br>E value: ' + Helpers.prettify_evalue(v.hspEvalue) +
`<br>Identities: ${Utils.inPercentage(v.hspIdentity, v.hspLength)}`;
// if chosen algorithm was blastn, the tooltip won't show the Positives% value in the tooltip
if (algorithm != 'blastn'){
alt_tooltip += `<br>Positives: ${Utils.inPercentage(v.hspPositives, v.hspLength)}`;
}
alt_tooltip += `, Gaps: ${Utils.inPercentage(v.hspGaps, v.hspLength)}`;
// Draw the rectangular hit tracks itself.
d3.select(this)
.attr('xlink:href', '#' + q_i + '_hit_' + (i+1))
.append('rect')
.attr('title', alt_tooltip)
.attr('class','bar')
.attr('x', function (d) {
return x(d.hspStart);
})
.attr('y', y(d.hitId))
.attr('width', function (d) {
return x(d.hspEnd - d.hspStart + 1);
})
.attr('height', options.barHeight)
.attr('fill', d3.rgb(hsplineColor));
});
});
// Draw legend only when more than one hit present
if (hits.length > 1) {
this.drawLegend(svg, options, width, height, inhits);
}
// Bind listener events once all the graphical elements have
// been drawn for first time.
if (index === 0) {
this.graphControls($queryDiv, $graphDiv, true, opts, inhits);
}
// Ensure clicking on 'rect' takes user to the relevant hit on all
// browsers.
this.setupClick($graphDiv);
}
}
var HitsOverview = Grapher(Graph);
export default HitsOverview;