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';
/**
* 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
Query= {this.props.query.id}
{this.props.query.title}
{meta}
;
}
hitsListJSX() {
return
;
}
noHitsJSX() {
return
****** No BLAST hits found ******
;
}
render() {
return (
{this.headerJSX()}
{this.numhits() && this.hitsListJSX() || this.noHitsJSX()}
);
}
}
/**
* 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 (
);
}
}
/**
* 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
# |
Similar sequences |
{hasName && Species | }
{!this.props.imported_xml && Query coverage (%) | }
Total score |
E value |
Identity (%) |
{
_.map(this.props.query.hits, _.bind(function (hit) {
return (
{hit.number + '.'} |
{hit.id} {hit.title}
|
{hasName &&
{hit.sciname}
|
}
{!this.props.imported_xml && {hit.qcovs} | }
{hit.total_score} |
{Utils.inExponential(hit.hsps[0].evalue)} |
{Utils.inPercentage(hit.hsps[0].identity, hit.hsps[0].length)} |
);
}, this))
}
;
}
render() {
return (
this.collapsePreferences.toggleCollapse()}>
{this.collapsePreferences.renderCollapseIcon()}
{this.name}
{!this.state.collapsed && this.tableJSX()}
);
}
}