public/js/query.js
import React, { Component, createRef } from 'react';
import _ from 'underscore';
import HitsOverview from './hits_overview';
import LengthDistribution from './length_distribution'; // length distribution of hits
import Utils from './utils';
import { fastqToFasta } from './fastq_to_fasta';
import CollapsePreferences from './collapse_preferences';
import './jquery_world';
/**
* Query component displays query defline, graphical overview, length
* distribution, and hits table.
*/
export class ReportQuery extends Component {
// Each update cycle will cause all previous queries to be re-rendered.
// We avoid that by implementing shouldComponentUpdate life-cycle hook.
// The trick is to simply check if the components has recieved props
// before.
shouldComponentUpdate() {
// If the component has received props before, query property will
// be set on it. If it is, we return false so that the component
// is not re-rendered. If the query property is not set, we return
// true: this must be the first time react is trying to render the
// component.
return !this.props.query;
}
// Kind of public API //
/**
* Returns the id of query.
*/
domID() {
return 'Query_' + this.props.query.number;
}
queryLength() {
return this.props.query.length;
}
/**
* Returns number of hits.
*/
numhits() {
return this.props.query.hits.length;
}
headerJSX() {
var meta = `Length: ${this.queryLength().toLocaleString()}`;
if (this.props.showQueryCrumbs) {
meta = `query ${this.props.query.number}. ${meta}`;
}
return <div className="section-header border-b border-seqorange justify-between w-full flex flex-col sm:flex-row gap-4">
<h3 className="text-base cursor-pointer flex flex-col sm:flex-row items-start">
<strong>Query=<span className="ml-1">{this.props.query.id}</span></strong>
<span className="ml-1">{this.props.query.title}</span>
</h3>
<span className="label first-letter:capitalize text-sm text-right font-normal text-inherit pt-0 px-0">{meta}</span>
</div>;
}
hitsListJSX() {
return <div className="pt-0 px-0 pb-px">
<HitsOverview key={'GO_' + this.props.query.number} query={this.props.query} program={this.props.program} collapsed={this.props.veryBig} />
<LengthDistribution key={'LD_' + this.props.query.id} query={this.props.query} algorithm={this.props.program} />
<HitsTable key={'HT_' + this.props.query.number} query={this.props.query} imported_xml={this.props.imported_xml} />
</div>;
}
noHitsJSX() {
return <div className="pt-0 px-0 pb-px">
<strong> ****** No BLAST hits found ****** </strong>
</div>;
}
render() {
return (
<div className="resultn mt-1.5" id={this.domID()}
data-query-len={this.props.query.length}
data-algorithm={this.props.program}>
{this.headerJSX()}
{this.numhits() && this.hitsListJSX() || this.noHitsJSX()}
</div>
);
}
}
/**
* Query widget for Search component.
*/
export class SearchQueryWidget extends Component {
constructor(props) {
super(props);
this.state = {
value: $('input#input_sequence').val() || ''
};
this.value = this.value.bind(this);
this.clear = this.clear.bind(this);
this.focus = this.focus.bind(this);
this.isEmpty = this.isEmpty.bind(this);
this.textarea = this.textarea.bind(this);
this.controls = this.controls.bind(this);
this.handleInput = this.handleInput.bind(this);
this.hideShowButton = this.hideShowButton.bind(this);
this.indicateError = this.indicateError.bind(this);
this.indicateNormal = this.indicateNormal.bind(this);
this.type = this.type.bind(this);
this.guessSequenceType = this.guessSequenceType.bind(this);
this.preProcessSequence = this.preProcessSequence.bind(this);
this.notify = this.notify.bind(this);
this.textareaRef = createRef();
this.controlsRef = createRef();
}
// LIFECYCLE Methods
componentDidMount() {
$('body').click(function () {
$('[data-notifications] [data-role=notification].active').hide('drop', { direction: 'up' }).removeClass('active');
});
}
componentDidUpdate() {
this.hideShowButton();
this.preProcessSequence();
this.props.onSequenceChanged(this.residuesCount());
var type = this.type();
if (!type || type !== this._type) {
this._type = type;
this.notify(type);
this.props.onSequenceTypeChanged(type);
}
}
// Kind of public API. //
/**
* Returns query sequence if no argument is provided (or null or undefined
* is provided as argument). Otherwise, sets query sequence to the given
* value and returns `this`.
*
* Default/initial state of query sequence is an empty string. Caller must
* explicitly provide empty string as argument to "reset" query sequence.
*/
value(val) {
if (val == null) {
// i.e., val is null or undefined
return this.state.value;
}
else {
this.setState({
value: val
});
return this;
}
}
residuesCount() {
const sequence = this.value();
const lines = sequence.split('\n');
const residuesCount = lines.reduce((count, line) => {
if (!line.startsWith('>')) {
return count + line.length;
}
return count;
}, 0);
return residuesCount;
}
/**
* Clears textarea. Returns `this`.
*
* Clearing textarea also causes it to be focussed.
*/
clear() {
return this.value('').focus();
}
/**
* Focuses textarea. Returns `this`.
*/
focus() {
this.textarea().focus();
return this;
}
/**
* Returns true if query is absent ('', undefined, null), false otherwise.
*/
isEmpty() {
return !this.value();
}
// Internal helpers. //
textarea() {
return $(this.textareaRef.current);
}
controls() {
return $(this.controlsRef.current);
}
handleInput(evt) {
this.value(evt.target.value);
}
/**
* Hides or shows 'clear sequence' button.
*
* Rendering the 'clear sequence' button takes into account presence or
* absence of a scrollbar.
*
* Called by `componentDidUpdate`.
*/
hideShowButton() {
if (!this.isEmpty()) {
// Calculation below is based on -
// http://chris-spittles.co.uk/jquery-calculate-scrollbar-width/
// FIXME: can reflow be avoided here?
var textareaNode = this.textarea()[0];
var sequenceControlsRight = textareaNode.offsetWidth - textareaNode.clientWidth;
this.controls().css('right', sequenceControlsRight + 17);
this.controls().removeClass('hidden');
}
else {
// FIXME: what are lines 1, 2, & 3 doing here?
this.textarea().parent().removeClass('has-error');
this.$sequenceFile = $('#sequence-file');
this.$sequenceFile.empty();
this.controls().addClass('hidden');
}
}
/**
* Put red border around textarea.
*/
indicateError() {
this.textarea().parent().addClass('has-error');
}
/**
* Put normal blue border around textarea.
*/
indicateNormal() {
this.textarea().parent().removeClass('has-error');
}
/**
* Returns type of the query sequence (nucleotide, protein, mixed).
*
* Query widget supports executing a callback when the query type changes.
* Components interested in query type should register a callback instead
* of directly calling this method.
*/
type() {
let sequence = this.value().trim();
// FASTQ detected, but we don't know if conversion has succeeded yet
// will notify separately if it does
if (sequence.startsWith('@') ) { return undefined; }
var sequences = sequence.split(/>.*/);
var type, tmp;
for (var i = 0; i < sequences.length; i++) {
tmp = this.guessSequenceType(sequences[i]);
// could not guess the sequence type; try the next sequence
if (!tmp) { continue; }
if (!type) {
// successfully guessed the type of atleast one sequence
type = tmp;
}
else if (tmp !== type) {
// user has mixed different type of sequences
return 'mixed';
}
}
return type;
}
preProcessSequence() {
var sequence = this.value();
var updatedSequence = fastqToFasta(sequence);
if (sequence !== updatedSequence) {
this.value(updatedSequence);
this.notify('fastq');
}
}
/**
* Guesses and returns the type of the given sequence (nucleotide,
* protein).
*/
guessSequenceType(sequence) {
// remove 'noisy' characters
sequence = sequence.replace(/[^A-Z]/gi, ''); // non-letter characters
sequence = sequence.replace(/[NX]/gi, ''); // ambiguous characters
// can't determine the type of ultrashort queries
if (sequence.length < 10) {
return undefined;
}
// count the number of putative NA
var putative_NA_count = 0;
for (var i = 0; i < sequence.length; i++) {
if (sequence[i].match(/[ACGTU]/i)) {
putative_NA_count += 1;
}
}
var threshold = 0.9 * sequence.length;
return putative_NA_count > threshold ? 'nucleotide' : 'protein';
}
notify(type) {
this.indicateNormal();
clearTimeout(this.notification_timeout);
// $('[data-notifications] [data-role=notification].active').hide().removeClass('active');
if (type) {
$('#' + type + '-sequence-notification').show('drop', { direction: 'up' }).addClass('active');
this.notification_timeout = setTimeout(function () {
$('[data-notifications] [data-role=notification].active').hide('drop', { direction: 'up' }).removeClass('active');
}, 5000);
if (type === 'mixed') {
this.indicateError();
}
}
}
render() {
return (
<div className="relative">
<div
className="sequence">
<textarea
id="sequence" ref={this.textareaRef}
className="block w-full p-4 text-gray-900 border border-gray-300 rounded-l-lg rounded-tr-lg bg-gray-50 text-sm font-mono min-h-52 resize-y"
name="sequence" value={this.state.value}
rows="6"
required="required"
placeholder="Paste query sequence(s) or drag file
containing query sequence(s) in FASTA format here ..."
spellCheck="false" autoFocus
onChange={this.handleInput}>
</textarea>
</div>
<div
className="hidden absolute top-2 right-2"
ref={this.controlsRef}>
<button
type="button"
className="border border-gray-300 rounded bg-white hover:bg-gray-200" id="btn-sequence-clear"
title="Clear query sequence(s)."
onClick={this.clear}>
<span id="sequence-file"></span>
<i className="fa fa-times w-6 h-6 p-1"></i>
<span className="sr-only">Clear query sequence(s).</span>
</button>
</div>
</div>
);
}
}
/**
* Renders summary of all hits per query in a tabular form.
*/
class HitsTable extends Component {
constructor(props) {
super(props);
this.name = 'Hit sequences producing significant alignments';
this.collapsePreferences = new CollapsePreferences(this);
this.state = {
collapsed: this.collapsePreferences.preferenceStoredAsCollapsed()
};
}
tableJSX() {
var hasName = _.every(this.props.query.hits, function (hit) {
return hit.sciname !== '';
});
// Width of sequence column is 55% when species name is not shown and
// query coverage is.
var seqwidth = 55;
// If we are going to show species name, then reduce the width of
// sequence column by the width of species column.
if (hasName) seqwidth -= 15;
// If we are not going to show query coverage (i.e. for imported XML),
// then increase the width of sequence column by the width of coverage
// column.
if (this.props.imported_xml) seqwidth += 15;
return <table
className="table table-hover table-condensed tabular-view text-sm min-w-full mb-0">
<thead>
<tr className="text-neutral-500">
<th className="text-left font-normal">#</th>
<th style={{ width: `${seqwidth}%` }} className="text-left font-normal">Similar sequences</th>
{hasName && <th className="text-left font-normal w-2/12">Species</th>}
{!this.props.imported_xml && <th className="text-right font-normal w-2/12">Query coverage (%)</th>}
<th className="text-right font-normal w-1/12">Total score</th>
<th className="text-right font-normal w-1/12">E value</th>
<th className="text-right font-normal w-1/12">Identity (%)</th>
</tr>
</thead>
<tbody>
{
_.map(this.props.query.hits, _.bind(function (hit) {
return (
<tr key={hit.number}>
<td className="text-left">{hit.number + '.'}</td>
<td>
<a href={'#Query_' + this.props.query.number + '_hit_' + hit.number}
className="text-sm text-seqblue hover:text-seqorange cursor-pointer pe-1 line-clamp-1 tooltip-item" title={`${hit.id} ${hit.title}`}>{hit.id} {hit.title}</a>
</td>
{hasName &&
<td className="pe-1 line-clamp-1 tooltip-item" title={hit.sciname}>
{hit.sciname}
</td>
}
{!this.props.imported_xml && <td className="text-right">{hit.qcovs}</td>}
<td className="pe-1 text-right">{hit.total_score}</td>
<td className="pe-1 text-right">{Utils.inExponential(hit.hsps[0].evalue)}</td>
<td className="pe-1 text-right">{Utils.inPercentage(hit.hsps[0].identity, hit.hsps[0].length)}</td>
</tr>
);
}, this))
}
</tbody>
</table>;
}
render() {
return (
<div className="table-hit-overview">
<h4 className="caption text-sm" onClick={() => this.collapsePreferences.toggleCollapse()}>
{this.collapsePreferences.renderCollapseIcon()}
<span> {this.name}</span>
</h4>
<div id={'Query_' + this.props.query.number + 'HT_' + this.props.query.number} className="overflow-auto md:overflow-hidden">
{!this.state.collapsed && this.tableJSX()}
</div>
</div>
);
}
}