public/js/report.js
import './jquery_world'; // for custom $.tooltip function
import React, { Component } from 'react';
import _ from 'underscore';
import Sidebar from './sidebar';
import Circos from './circos';
import { ReportQuery } from './query';
import Hit from './hit';
import HSP from './hsp';
import AlignmentExporter from './alignment_exporter';
import ReportPlugins from 'report_plugins';
/**
* Renders entire report.
*
* Composed of Query and Sidebar components.
*/
class Report extends Component {
constructor(props) {
super(props);
// Properties below are internal state used to render results in small
// slices (see updateState).
this.numUpdates = 0;
this.nextQuery = 0;
this.nextHit = 0;
this.nextHSP = 0;
this.maxHSPs = 3; // max HSPs to render in a cycle
this.state = {
user_warning: null,
download_links: [],
search_id: '',
seqserv_version: '',
program: '',
program_version: '',
submitted_at: '',
queries: [],
results: [],
querydb: [],
params: [],
stats: [],
alignment_blob_url: '',
allQueriesLoaded: false,
cloud_sharing_enabled: false,
};
this.prepareAlignmentOfSelectedHits = this.prepareAlignmentOfSelectedHits.bind(this);
this.prepareAlignmentOfAllHits = this.prepareAlignmentOfAllHits.bind(this);
this.setStateFromJSON = this.setStateFromJSON.bind(this);
this.plugins = new ReportPlugins(this);
}
/**
* Fetch results.
*/
fetchResults() {
const path = location.pathname + '.json' + location.search;
this.pollPeriodically(path, this.setStateFromJSON, this.props.showErrorModal);
}
pollPeriodically(path, callback, errCallback) {
var intervals = [200, 400, 800, 1200, 2000, 3000, 5000];
function poll() {
$.getJSON(path).complete(function (jqXHR) {
switch (jqXHR.status) {
case 202:
var interval;
if (intervals.length === 1) {
interval = intervals[0];
} else {
interval = intervals.shift();
}
setTimeout(poll, interval);
break;
case 200:
callback(jqXHR.responseJSON);
break;
case 400:
case 422:
case 500:
errCallback(jqXHR.responseJSON);
break;
}
});
}
poll();
}
/**
* Calls setState after any required modification to responseJSON.
*/
setStateFromJSON(responseJSON) {
this.lastTimeStamp = Date.now();
// the callback prepares the download link for all alignments
if (responseJSON.user_warning == 'LARGE_RESULT') {
this.setState({user_warning: responseJSON.user_warning, download_links: responseJSON.download_links});
} else {
this.setState(responseJSON, this.prepareAlignmentOfAllHits);
}
}
/**
* Called as soon as the page has loaded and the user sees the loading spinner.
* We use this opportunity to setup services that make use of delegated events
* bound to the window, document, or body.
*/
componentDidMount() {
this.fetchResults();
this.plugins.init();
// This sets up an event handler which enables users to select text from
// hit header without collapsing the hit.
this.preventCollapseOnSelection();
this.toggleTable();
}
/**
* Called for the first time after as BLAST results have been retrieved from
* the server and added to this.state by fetchResults. Only summary overview
* and circos would have been rendered at this point. At this stage we kick
* start iteratively adding 1 HSP to the page every 25 milli-seconds.
*/
componentDidUpdate(prevProps, prevState) {
// Log to console how long the last update take?
// console.log((Date.now() - this.lastTimeStamp) / 1000);
// Lock sidebar in its position on the first update.
if (this.nextQuery == 0 && this.nextHit == 0 && this.nextHSP == 0) {
this.affixSidebar();
}
// Queue next update if we have not rendered all results yet.
if (this.nextQuery < this.state.queries.length) {
// setTimeout is used to clear call stack and space out
// the updates giving the browser a chance to respond
// to user interactions.
setTimeout(() => this.updateState(), 25);
} else {
this.componentFinishedUpdating();
}
this.plugins.componentDidUpdate(prevProps, prevState);
}
/**
* Push next slice of results to React for rendering.
*/
updateState() {
var results = [];
var numHSPsProcessed = 0;
while (this.nextQuery < this.state.queries.length) {
var query = this.state.queries[this.nextQuery];
// We may see a query multiple times during rendering because only
// 3 hsps are rendered in each cycle, but we want to create the
// corresponding Query component only the first time we see it.
if (this.nextHit == 0 && this.nextHSP == 0) {
results.push(
<ReportQuery
key={'Query_' + query.id}
query={query}
program={this.state.program}
querydb={this.state.querydb}
showQueryCrumbs={this.state.queries.length > 1}
non_parse_seqids={this.state.non_parse_seqids}
imported_xml={this.state.imported_xml}
veryBig={this.state.veryBig}
/>
);
results.push(...this.plugins.queryResults(query));
}
while (this.nextHit < query.hits.length) {
var hit = query.hits[this.nextHit];
// We may see a hit multiple times during rendering because only
// 10 hsps are rendered in each cycle, but we want to create the
// corresponding Hit component only the first time we see it.
if (this.nextHSP == 0) {
results.push(
<Hit
key={'Query_' + query.number + '_Hit_' + hit.number}
query={query}
hit={hit}
algorithm={this.state.program}
querydb={this.state.querydb}
selectHit={this.selectHit}
imported_xml={this.state.imported_xml}
non_parse_seqids={this.state.non_parse_seqids}
showQueryCrumbs={this.state.queries.length > 1}
showHitCrumbs={query.hits.length > 1}
veryBig={this.state.veryBig}
onChange={this.prepareAlignmentOfSelectedHits}
{...this.props}
/>
);
}
while (this.nextHSP < hit.hsps.length) {
// Get nextHSP and increment the counter.
var hsp = hit.hsps[this.nextHSP++];
results.push(
<HSP
key={
'Query_' +
query.number +
'_Hit_' +
hit.number +
'_HSP_' +
hsp.number
}
query={query}
hit={hit}
hsp={hsp}
algorithm={this.state.program}
showHSPNumbers={hit.hsps.length > 1}
{...this.props}
/>
);
numHSPsProcessed++;
if (numHSPsProcessed == this.maxHSPs) break;
}
// Are we here because we have iterated over all hsps of a hit,
// or because of the break clause in the inner loop?
if (this.nextHSP == hit.hsps.length) {
this.nextHit = this.nextHit + 1;
this.nextHSP = 0;
}
if (numHSPsProcessed == this.maxHSPs) break;
}
// Are we here because we have iterated over all hits of a query,
// or because of the break clause in the inner loop?
if (this.nextHit == query.hits.length) {
this.nextQuery = this.nextQuery + 1;
this.nextHit = 0;
}
if (numHSPsProcessed == this.maxHSPs) break;
}
// Push the components to react for rendering.
this.numUpdates++;
this.lastTimeStamp = Date.now();
this.setState({
results: this.state.results.concat(results),
veryBig: this.numUpdates >= 250,
});
}
/**
* Called after all results have been rendered.
*/
componentFinishedUpdating() {
if (this.state.allQueriesLoaded) return;
this.shouldShowIndex() && this.setupScrollSpy();
this.setState({ allQueriesLoaded: true });
}
/**
* Returns loading message
*/
loadingJSX() {
return (
<div className="row">
<div className="col-md-6 col-md-offset-3 text-center">
<h1>
<i className="fa fa-cog fa-spin"></i> BLAST-ing
</h1>
<p>
<br />
This can take some time depending on the size of your query and
database(s). The page will update automatically when BLAST is done.
<br />
<br />
You can bookmark the page and come back to it later or share the
link with someone.
<br />
<br />
{ process.env.targetEnv === 'cloud' && <b>If the job takes more than 10 minutes to complete, we will send you an email upon completion.</b> }
</p>
</div>
</div>
);
}
/**
* Return results JSX.
*/
resultsJSX() {
return (
<div className="row" id="results">
<div className="col-md-3 hidden-sm hidden-xs">
<Sidebar
data={this.state}
atLeastOneHit={this.atLeastOneHit()}
shouldShowIndex={this.shouldShowIndex()}
allQueriesLoaded={this.state.allQueriesLoaded}
cloudSharingEnabled={this.state.cloud_sharing_enabled}
/>
</div>
<div className="col-md-9">
{this.overviewJSX()}
{this.circosJSX()}
{this.plugins.generateStats()}
{this.state.results}
</div>
</div>
);
}
warningJSX() {
return(
<div className="container">
<div className="row">
<div className="col-md-6 col-md-offset-3 text-center">
<h1>
<i className="fa fa-exclamation-triangle"></i> Warning
</h1>
<p>
<br />
The BLAST result might be too large to load in the browser. If you have a powerful machine you can try loading the results anyway. Otherwise, you can download the results and view them locally.
</p>
<br />
<p>
{this.state.download_links.map((link, index) => {
return (
<a href={link.url} className="btn btn-secondary" key={'download_link_' + index} >
{link.name}
</a>
);
})}
</p>
<br />
<p>
<a href={location.pathname + '?bypass_file_size_warning=true'} className="btn btn-primary">
View results in browser anyway
</a>
</p>
</div>
</div>
</div>
);
}
/**
* Renders report overview.
*/
overviewJSX() {
return (
<div className="overview">
<p>
<strong>SequenceServer {this.state.seqserv_version}</strong> using{' '}
<strong>{this.state.program_version}</strong>
{this.state.submitted_at &&
`, query submitted on ${this.state.submitted_at}`}
</p>
<p>
<strong> Databases: </strong>
{this.state.querydb
.map((db) => {
return db.title;
})
.join(', ')}{' '}
({this.state.stats.nsequences} sequences,
{this.state.stats.ncharacters} characters)
</p>
<p>
<strong>Parameters: </strong>{' '}
{_.map(this.state.params, function (val, key) {
return key + ' ' + val;
}).join(', ')}
</p>
<p>
Please cite:{' '}
<a href="https://doi.org/10.1093/molbev/msz185">
https://doi.org/10.1093/molbev/msz185
</a>
</p>
</div>
);
}
/**
* Return JSX for circos if we have at least one hit.
*/
circosJSX() {
return this.atLeastTwoHits() ? (
<Circos
queries={this.state.queries}
program={this.state.program}
/>
) : (
<span></span>
);
}
// Controller //
/**
* Returns true if results have been fetched.
*
* A holding message is shown till results are fetched.
*/
isResultAvailable() {
return this.state.queries.length >= 1;
}
/**
* Indicates the response contains a warning message for the user
* in which case we should not render the results and render the
* warning instead.
**/
isUserWarningPresent() {
return this.state.user_warning;
}
/**
* Returns true if we have at least one hit.
*/
atLeastOneHit() {
return this.state.queries.some((query) => query.hits.length > 0);
}
/**
* Does the report have at least two hits? This is used to determine
* whether Circos should be enabled or not.
*/
atLeastTwoHits() {
var hit_num = 0;
return this.state.queries.some((query) => {
hit_num += query.hits.length;
return hit_num > 1;
});
}
/**
* Returns true if index should be shown in the sidebar. Index is shown
* only for 2 and 8 queries.
*/
shouldShowIndex() {
var num_queries = this.state.queries.length;
return num_queries >= 2 && num_queries <= 12;
}
/**
* Prevents folding of hits during text-selection.
*/
preventCollapseOnSelection() {
$('body').on('mousedown', '.hit > .section-header > h4', function (event) {
var $this = $(this);
$this.on('mouseup mousemove', function handler(event) {
if (event.type === 'mouseup') {
// user wants to toggle
var hitID = $this.parents('.hit').attr('id');
$(`div[data-parent-hit=${hitID}]`).toggle();
$this.find('i').toggleClass('fa-minus-square-o fa-plus-square-o');
} else {
// user wants to select
$this.attr('data-toggle', '');
}
$this.off('mouseup mousemove', handler);
});
});
}
/* Handling the fa icon when Hit Table is collapsed */
toggleTable() {
$('body').on(
'mousedown',
'.resultn .caption[data-toggle="collapse"]',
function (event) {
var $this = $(this);
$this.on('mouseup mousemove', function handler(event) {
$this.find('i').toggleClass('fa-minus-square-o fa-plus-square-o');
$this.off('mouseup mousemove', handler);
});
}
);
}
/**
* Affixes the sidebar.
*/
affixSidebar() {
var $sidebar = $('.sidebar');
var sidebarOffset = $sidebar.offset();
if (sidebarOffset) {
$sidebar.affix({
offset: {
top: sidebarOffset.top,
},
});
}
}
/**
* For the query in viewport, highlights corresponding entry in the index.
*/
setupScrollSpy() {
$('body').scrollspy({ target: '.sidebar' });
}
/**
* Event-handler when hit is selected
* Adds glow to hit component.
* Updates number of Fasta that can be downloaded
*/
selectHit(id) {
var checkbox = $('#' + id);
var num_checked = $('.hit-links :checkbox:checked').length;
if (!checkbox || !checkbox.val()) {
return;
}
var $hit = $(checkbox.data('target'));
// Highlight selected hit and enable 'Download FASTA/Alignment of
// selected' links.
if (checkbox.is(':checked')) {
$hit.addClass('glow');
$hit.next('.hsp').addClass('glow');
$('.download-fasta-of-selected').enable();
$('.download-alignment-of-selected').enable();
} else {
$hit.removeClass('glow');
$hit.next('.hsp').removeClass('glow');
$('.download-fasta-of-selected').attr('href', '#').removeAttr('download');
}
var $a = $('.download-fasta-of-selected');
var $b = $('.download-alignment-of-selected');
if (num_checked >= 1) {
$a.find('.text-bold').html(num_checked);
$b.find('.text-bold').html(num_checked);
}
if (num_checked == 0) {
$a.addClass('disabled').find('.text-bold').html('');
$b.addClass('disabled').find('.text-bold').html('');
}
}
populate_hsp_array(hit, query_id){
return hit.hsps.map(hsp => Object.assign(hsp, {hit_id: hit.id, query_id}));
}
prepareAlignmentOfSelectedHits() {
var sequence_ids = $('.hit-links :checkbox:checked').map(function () {
return this.value;
}).get();
if(!sequence_ids.length){
// remove attributes from link if sequence_ids array is empty
$('.download-alignment-of-selected').attr('href', '#').removeAttr('download');
return;
}
if(this.state.alignment_blob_url){
// always revoke existing url if any because this method will always create a new url
window.URL.revokeObjectURL(this.state.alignment_blob_url);
}
var hsps_arr = [];
var aln_exporter = new AlignmentExporter();
const self = this;
_.each(this.state.queries, _.bind(function (query) {
_.each(query.hits, function (hit) {
if (_.indexOf(sequence_ids, hit.id) != -1) {
hsps_arr = hsps_arr.concat(self.populate_hsp_array(hit, query.id));
}
});
}, this));
const filename = 'alignment-' + sequence_ids.length + '_hits.txt';
const blob_url = aln_exporter.prepare_alignments_for_export(hsps_arr, filename);
// set required download attributes for link
$('.download-alignment-of-selected').attr('href', blob_url).attr('download', filename);
// track new url for future removal
this.setState({alignment_blob_url: blob_url});
}
prepareAlignmentOfAllHits() {
// Get number of hits and array of all hsps.
var num_hits = 0;
var hsps_arr = [];
if(!this.state.queries.length){
return;
}
this.state.queries.forEach(
(query) => query.hits.forEach(
(hit) => {
num_hits++;
hsps_arr = hsps_arr.concat(this.populate_hsp_array(hit, query.id));
}
)
);
var aln_exporter = new AlignmentExporter();
var file_name = `alignment-${num_hits}_hits.txt`;
const blob_url = aln_exporter.prepare_alignments_for_export(hsps_arr, file_name);
$('.download-alignment-of-all')
.attr('href', blob_url)
.attr('download', file_name);
return false;
}
render() {
if (this.isUserWarningPresent()) {
return this.warningJSX();
} else if (this.isResultAvailable()) {
return this.resultsJSX();
} else {
return this.loadingJSX();
}
}
}
export default Report;