public/js/kablammo.js
import * as d3 from 'd3';
import _ from 'underscore';
import Grapher from 'grapher';
import * as Helpers from './visualisation_helpers';
/**
* Renders Kablammo visualization
*
* JSON received from server side is modified as JSON expected by kablammo's
* graph.js. All the relevant information including a SVG container where
* visual needs to be rendered, is delegated to graph.js. graph.js renders
* kablammo visualization and has all event handlers for events performed on
* the visual.
*
* Event handlers related to downloading and viewing of alignments and images
* have been extracted from grapher.js and interface.js and directly included
* here.
*/
class Graph {
static canCollapse() {
return true;
}
static name() {
return 'Graphical overview of aligning region(s)';
}
static className() {
return 'kablammo';
}
static graphId(props) {
return 'kablammo_'+props.query.number+'_'+props.hit.number;
}
static dataName(props) {
return `Kablammo_query-${props.query.number}_${props.query.id}_${props.hit.id}`;
}
constructor($svgContainer, props) {
this._zoom_scale_by = 1.4;
this._padding_x = 12;
this._padding_y = 50;
this._canvas_height = $svgContainer.height();
this._canvas_width = $svgContainer.width();
this._results = Helpers.get_seq_type(props.algorithm);
this._query_id = props.query.id;
this._subject_id = props.hit.id;
this._query_length = props.query.length;
this._subject_length = props.hit.length;
this._show_numbers = props.showHSPCrumbs;
// this._hsps = this.toKablammo(props.hit.hsps, props.query);
this._hsps = props.hit.hsps;
this._maxBitScore = props.query.hits[0].hsps[0].bit_score;
this.svgContainer_d3 = d3.select($svgContainer[0]);
this._svg = {};
this._svg.jq = $(this._svg.raw);
this._scales = this._create_scales();
this.use_complement_coords = false;
this._axis_ticks = 10;
this._initiate();
this.bindHoverHandler($svgContainer);
}
bindHoverHandler ($svgContainer) {
// Raise polygon on hover.
$svgContainer.find('polygon').hover(
function () {
var $g = $(this).parent();
$g.parent().append($g);
}
);
}
_initiate() {
this._svg.d3 =
this.svgContainer_d3.insert('svg', ':first-child')
.attr('height', this._canvas_height)
.attr('width', this._canvas_width);
this._svg.raw = this._svg.d3._groups[0][0];
this._render_graph();
}
_rotate_axis_labels(text, text_anchor, dx, dy) {
text.style('text-anchor', text_anchor)
.attr('x', dx)
.attr('y', dy)
// When axis orientation is "bottom", d3 automataically applies a 0.71em
// dy offset to labels. As Inkscape does not seem to properly interpret
// such values, force them to be zero. When calling this function, then,
// you must compensate by adding 0.71em worth of offset to the dy value
// you provide.
.attr('dx', 0)
.attr('dy', 0)
.attr('transform', 'rotate(-90)');
}
_create_axis(scale, orientation, height, text_anchor, dx, dy, seq_type) {
var formatter = Helpers.tick_formatter(scale, seq_type);
var tvalues = scale.ticks();
var axis;
tvalues.pop();
if (orientation === 'top') {
axis = d3.axisTop(scale)
} else {
axis = d3.axisBottom(scale)
}
axis.ticks(this._axis_ticks)
.tickValues(tvalues.concat(scale.domain()))
.tickFormat(formatter)
var container = this._svg.d3.append('g')
.attr('class', 'axis')
.attr('transform', 'translate(0,' + height + ')')
.call(axis);
this._rotate_axis_labels(container.selectAll('text'), text_anchor, dx, dy);
return container;
}
_is_domain_within_orig(original_domain, new_domain) {
return original_domain[0] <= new_domain[0] && original_domain[1] >= new_domain[1];
}
_zoom_scale(scale, original_domain, zoom_from, scale_by) {
var l = scale.domain()[0];
var r = scale.domain()[1];
l = zoom_from - (zoom_from - l) / scale_by;
r = zoom_from + (r - zoom_from) / scale_by;
l = Math.round(l);
r = Math.round(r);
if(r - l < this._axis_ticks)
return;
var new_domain = [l, r];
if(this._is_domain_within_orig(original_domain, new_domain))
scale.domain(new_domain);
else
scale.domain(original_domain);
}
_pan_scale(existing_scale, original_domain, delta) {
var scale = (existing_scale.domain()[1] - existing_scale.domain()[0]) / (existing_scale.range()[1] - existing_scale.range()[0]);
var scaled_delta = -delta * scale;
var domain = existing_scale.domain();
var l = domain[0] + scaled_delta;
var r = domain[1] + scaled_delta;
var new_domain = [l, r];
if(this._is_domain_within_orig(original_domain, new_domain))
existing_scale.domain(new_domain);
}
_render_polygons() {
var self = this;
// Remove all existing child elements.
this._svg.d3.selectAll('*').remove();
this._polygons = this._svg.d3.selectAll('polygon')
.data(this._hsps.slice().reverse())
.enter()
.append('g')
.attr('class','polygon');
this._polygons.append('polygon')
.attr('class', 'hit')
.attr('fill', function(hsp) {
return self.determine_colour(hsp.bit_score / self._maxBitScore);
}).attr('points', function(hsp) {
// We create query_x_points such that the 0th element will *always* be
// on the left of the 1st element, regardless of whether the axis is
// drawn normally (i.e., ltr) or reversed (i.e., rtl). We do the same
// for subject_x_points. As our parsing code guarantees start < end, we
// decide on this ordering based on the reading frame, because it
// determines whether our axis will be reversed or not.
var query_x_points = [self._scales.query.scale(hsp.qstart), self._scales.query.scale(hsp.qend)];
var subject_x_points = [self._scales.subject.scale(hsp.sstart), self._scales.subject.scale(hsp.send)];
// Axis will be rendered with 5' end on right and 3' end on left, so we
// must reverse the order of vertices for the polygon we will render to
// prevent the polygon from "crossing over" itself.
if(!self.use_complement_coords) {
if(hsp.qframe < 0)
query_x_points.reverse();
if(hsp.sframe < 0)
subject_x_points.reverse();
}
var points = [
[query_x_points[0], self._scales.query.height + 1],
[subject_x_points[0], self._scales.subject.height - 1],
[subject_x_points[1], self._scales.subject.height - 1],
[query_x_points[1], self._scales.query.height + 1],
];
return points.map(function(point) {
return point[0] + ',' + point[1];
}).join(' ');
});
if (self._show_numbers) {
this._polygons.append('text')
.attr('x', function(hsp) {
var query_x_points = [self._scales.query.scale(hsp.qstart), self._scales.query.scale(hsp.qend)];
var subject_x_points = [self._scales.subject.scale(hsp.sstart), self._scales.subject.scale(hsp.send)];
var middle1 = (query_x_points[0] + subject_x_points[0]) * 0.5;
var middle2 = (query_x_points[1] + subject_x_points[1]) * 0.5;
return (middle2 + middle1) * 0.5;
})
.attr('y', function(hsp) {
var a = self._scales.query.height;
var b = self._scales.subject.height;
var middle = ( b - a ) / 2;
return a + middle + 2; // for font-height 10px
})
.text(function(hsp) {
return Helpers.toLetters(hsp.number);
});
}
}
_overlaps(s1, e1, s2, e2) {
return Math.min(e1, e2) > Math.max(s1, s2);
}
_rects_overlap(rect1, rect2, padding) {
padding = padding || 0;
return this._overlaps(
rect1.left - padding,
rect1.right + padding,
rect2.left,
rect2.right
) && this._overlaps(
rect1.top - padding,
rect1.bottom + padding,
rect2.top,
rect2.bottom
);
}
_render_axes() {
var query_axis = this._create_axis(this._scales.query.scale, 'top',
this._scales.query.height, 'start', '9px', '2px',
this._results.query_seq_type);
var subject_axis = this._create_axis(this._scales.subject.scale, 'bottom',
this._scales.subject.height, 'end', '-11px', '3px',
this._results.subject_seq_type);
}
_render_graph() {
this._render_polygons();
this._render_axes();
}
_find_nearest_scale(point) {
var nearest = null;
var smallest_distance = Number.MAX_VALUE;
var self = this;
Object.keys(this._scales).forEach(function(scale_name) {
var scale = self._scales[scale_name].scale;
var scale_height = self._scales[scale_name].height;
var delta = Math.abs(scale_height - point[1]);
if(delta < smallest_distance) {
nearest = scale;
smallest_distance = delta;
}
});
return nearest;
}
_create_scales() {
var query_range = [this._padding_x, this._canvas_width - this._padding_x];
var subject_range = [this._padding_x, this._canvas_width - this._padding_x];
// If we wish to show the HSPs relative to the original (input or DB)
// sequence rather than its complement (i.e., use_complement_coords = false),
// even when the HSPs lie on the complement, then we must display the axis
// with its 5' end on the right and 3' end on the left. In this case, you can
// imagine the invisible complementary strand (with its 5' end on left and 3'
// end on right) floating above the rendered original strand, with the hits
// actually falling on the complementary strand.
//
// If we show the HSPs relative to the complementary strand (i.e.,
// use_complement_coords = true), then we *always* wish to show the axis with
// its 5' end on the left and 3' end on the right.
//
// Regardless of whether this value is true or falase, the rendered polygons
// will be precisely the same (meaning down to the pixel -- they will be
// *identical*). Only the direction of the axis, and the coordinates of
// points falling along it, change.
if(!this.use_complement_coords) {
if(this._hsps[0].qframe < 0)
query_range.reverse();
if(this._hsps[0].sframe < 0)
subject_range.reverse();
}
var query_scale = d3.scaleLinear()
.domain([1, this._query_length])
.range(query_range);
var subject_scale = d3.scaleLinear()
.domain([1, this._subject_length])
.range(subject_range);
query_scale.original_domain = query_scale.domain();
subject_scale.original_domain = subject_scale.domain();
var query_height = this._padding_y;
var subject_height = this._canvas_height - this._padding_y;
var scales = {
subject: { height: subject_height, scale: subject_scale },
query: { height: query_height, scale: query_scale },
};
return scales;
}
_rgba_to_rgb(rgba, matte_rgb) {
// Algorithm taken from http://stackoverflow.com/a/2049362/1691611.
var normalize = function (colour) {
return colour.map(function (channel) { return channel / 255; });
};
var denormalize = function (colour) {
return colour.map(function (channel) { return Math.round(Math.min(255, channel * 255)); });
};
var norm = normalize(rgba.slice(0, 3));
matte_rgb = normalize(matte_rgb);
var alpha = rgba[3] / 255;
var rgb = [
(alpha * norm[0]) + (1 - alpha) * matte_rgb[0],
(alpha * norm[1]) + (1 - alpha) * matte_rgb[1],
(alpha * norm[2]) + (1 - alpha) * matte_rgb[2],
];
return denormalize(rgb);
}
/**
* Determines colour of a hsp based on normalized bit-score.
*
* Taken from grapher.js
*/
determine_colour(level) {
var graph_colour = { r: 199, g: 79, b: 20 };
var matte_colour = { r: 255, g: 255, b: 255 };
var min_opacity = 0.3;
var opacity = ((1 - min_opacity) * level) + min_opacity;
var rgb = this._rgba_to_rgb([
graph_colour.r,
graph_colour.g,
graph_colour.b,
255 * opacity
], [
matte_colour.r,
matte_colour.g,
matte_colour.b,
]);
return 'rgb(' + rgb.join(',') + ')';
}
}
var Kablammo = Grapher(Graph);
export default Kablammo;