ideogram
Version:
Chromosome visualization with D3.js
630 lines (538 loc) • 16.3 kB
JavaScript
/**
* @fileoverview Methods for ideogram annotations.
* Annotations are graphical objects that represent features of interest
* located on the chromosomes, e.g. genes or variations. They can
* appear beside a chromosome, overlaid on top of it, or between multiple
* chromosomes.
*/
import * as d3selection from 'd3-selection';
// See https://github.com/d3/d3/issues/2733
import {event as currentEvent} from 'd3-selection';
import * as d3request from 'd3-request';
import * as d3dispatch from 'd3-dispatch';
import * as d3promise from 'd3.promise';
import {BedParser} from './parsers/bed-parser';
var d3 = Object.assign({}, d3selection, d3request, d3dispatch);
d3.promise = d3promise;
/**
* Optional callback, invoked when annotations are drawn
*/
function onDrawAnnots() {
call(this.onDrawAnnotsCallback);
}
/**
* Proccesses genome annotation data.
* Genome annotations represent features like a gene, SNP, etc. as
* a small graphical object on or beside a chromosome.
* Converts raw annotation data from server, which is structured as
* an array of arrays, into a more verbose data structure consisting
* of an array of objects.
* Also adds pixel offset information.
*/
function processAnnotData(rawAnnots) {
var keys,
i, j, k, m, annot, annots, annotsByChr,
chr,
chrModel, ra,
startPx, stopPx, px,
color,
ideo = this;
keys = rawAnnots.keys;
rawAnnots = rawAnnots.annots;
annots = [];
m = -1;
for (i = 0; i < rawAnnots.length; i++) {
annotsByChr = rawAnnots[i];
chr = annotsByChr.chr;
chrModel = ideo.chromosomes[ideo.config.taxid][chr];
if (typeof chrModel === 'undefined') {
console.warn(
'Chromosome "' + chr + '" undefined in ideogram; ' +
annotsByChr.annots.length + ' annotations not shown'
);
continue;
}
m++;
annots.push({chr: annotsByChr.chr, annots: []});
for (j = 0; j < annotsByChr.annots.length; j++) {
ra = annotsByChr.annots[j];
annot = {};
for (k = 0; k < keys.length; k++) {
annot[keys[k]] = ra[k];
}
annot.stop = annot.start + annot.length;
startPx = ideo.convertBpToPx(chrModel, annot.start);
stopPx = ideo.convertBpToPx(chrModel, annot.stop);
px = Math.round((startPx + stopPx) / 2) - 28;
color = ideo.config.annotationsColor;
if (ideo.config.annotationTracks) {
annot.trackIndex = ra[3];
color = ideo.config.annotationTracks[annot.trackIndex].color;
} else {
annot.trackIndex = 0;
}
if ('color' in annot) {
color = annot.color;
}
annot.chr = chr;
annot.chrIndex = i;
annot.px = px;
annot.startPx = startPx - 30;
annot.stopPx = stopPx - 30;
annot.color = color;
annots[m].annots.push(annot);
}
}
return annots;
}
/**
* Initializes various annotation settings. Constructor help function.
*/
function initAnnotSettings() {
if (
this.config.annotationsPath ||
this.config.localAnnotationsPath ||
this.annots || this.config.annotations
) {
if (!this.config.annotationHeight) {
var annotHeight = Math.round(this.config.chrHeight / 100);
this.config.annotationHeight = annotHeight;
}
if (this.config.annotationTracks) {
this.config.numAnnotTracks = this.config.annotationTracks.length;
} else {
this.config.numAnnotTracks = 1;
}
this.config.annotTracksHeight =
this.config.annotationHeight * this.config.numAnnotTracks;
if (typeof this.config.barWidth === 'undefined') {
this.config.barWidth = 3;
}
} else {
this.config.annotTracksHeight = 0;
}
if (typeof this.config.annotationsColor === 'undefined') {
this.config.annotationsColor = '#F00';
}
}
/**
* Requests annotations URL via HTTP, sets ideo.rawAnnots for downstream
* processing.
*
* @param annotsUrl Absolute or relative URL native or BED annotations file
*/
function fetchAnnots(annotsUrl) {
var ideo = this;
if (annotsUrl.slice(0, 4) !== 'http') {
d3.json(
ideo.config.annotationsPath,
function(data) {
ideo.rawAnnots = data;
}
);
return;
}
var tmp = annotsUrl.split('.');
var extension = tmp[tmp.length - 1];
if (extension !== 'bed' && extension !== 'json') {
extension = extension.toUpperCase();
alert(
'This Ideogram.js only supports BED and native format at the ' +
'moment. Sorry, check back soon for ' + extension + ' support!'
);
return;
}
d3.request(annotsUrl, function(data) {
if (extension === 'bed') {
ideo.rawAnnots = new BedParser(data.response, ideo).rawAnnots;
} else {
ideo.rawAnnots = JSON.parse(data.response);
}
});
}
/**
* Draws annotations defined by user
*/
function drawAnnots(friendlyAnnots) {
var ideo = this,
i, j, annot,
rawAnnots = [],
rawAnnot, keys,
chr,
chrs = ideo.chromosomes[ideo.config.taxid]; // TODO: multiorganism
// Occurs when filtering
if ('annots' in friendlyAnnots[0]) {
return ideo.drawProcessedAnnots(friendlyAnnots);
}
for (chr in chrs) {
rawAnnots.push({chr: chr, annots: []});
}
for (i = 0; i < friendlyAnnots.length; i++) {
annot = friendlyAnnots[i];
for (j = 0; j < rawAnnots.length; j++) {
if (annot.chr === rawAnnots[j].chr) {
rawAnnot = [
annot.name,
annot.start,
annot.stop - annot.start
];
if ('color' in annot) {
rawAnnot.push(annot.color);
}
if ('shape' in annot) {
rawAnnot.push(annot.shape);
}
rawAnnots[j].annots.push(rawAnnot);
break;
}
}
}
keys = ['name', 'start', 'length'];
if ('color' in friendlyAnnots[0]) {
keys.push('color');
}
if ('shape' in friendlyAnnots[0]) {
keys.push('shape');
}
ideo.rawAnnots = {keys: keys, annots: rawAnnots};
ideo.annots = ideo.processAnnotData(ideo.rawAnnots);
ideo.drawProcessedAnnots(ideo.annots);
}
/**
* Returns and sets bars used for histogram
*/
function getHistogramBars(annots) {
var t0 = new Date().getTime();
var i, j, chr,
chrModel, chrModels, chrPxStop, px, bp,
chrAnnots, chrName, chrIndex, annot,
bars, bar, barPx, nextBarPx, barWidth,
maxAnnotsPerBar, color, lastBand,
numBins, barAnnots, barCount, height,
firstGet = false,
histogramScaling,
ideo = this;
bars = [];
barWidth = ideo.config.barWidth;
chrModels = ideo.chromosomes[ideo.config.taxid];
color = ideo.config.annotationsColor;
if ('histogramScaling' in ideo.config) {
histogramScaling = ideo.config.histogramScaling;
} else {
histogramScaling = 'relative';
}
if (typeof ideo.maxAnnotsPerBar === 'undefined') {
ideo.maxAnnotsPerBar = {};
firstGet = true;
}
for (chr in chrModels) {
chrModel = chrModels[chr];
chrIndex = chrModel.chrIndex;
lastBand = chrModel.bands[chrModel.bands.length - 1];
chrPxStop = lastBand.px.stop;
numBins = Math.round(chrPxStop / barWidth);
bar = {chr: chr, annots: []};
for (i = 0; i < numBins; i++) {
px = i * barWidth - ideo.bump;
bp = ideo.convertPxToBp(chrModel, px + ideo.bump);
bar.annots.push({
bp: bp,
px: px - ideo.bump,
count: 0,
chrIndex: chrIndex,
chrName: chr,
color: color,
annots: []
});
}
bars.push(bar);
}
for (chr in annots) {
chrAnnots = annots[chr].annots;
chrName = annots[chr].chr;
chrModel = chrModels[chrName];
chrIndex = chrModel.chrIndex - 1;
barAnnots = bars[chrIndex].annots;
for (i = 0; i < chrAnnots.length; i++) {
annot = chrAnnots[i];
px = annot.px - ideo.bump;
for (j = 0; j < barAnnots.length; j++) {
barPx = barAnnots[j].px;
nextBarPx = barPx + barWidth;
if (j === barAnnots.length - 1) {
nextBarPx += barWidth;
}
if (px >= barPx && px < nextBarPx) {
bars[chrIndex].annots[j].count += 1;
bars[chrIndex].annots[j].annots.push(annot);
break;
}
}
}
}
if (firstGet === true || histogramScaling === 'relative') {
maxAnnotsPerBar = 0;
for (i = 0; i < bars.length; i++) {
annots = bars[i].annots;
for (j = 0; j < annots.length; j++) {
barCount = annots[j].count;
if (barCount > maxAnnotsPerBar) {
maxAnnotsPerBar = barCount;
}
}
}
ideo.maxAnnotsPerBar[chr] = maxAnnotsPerBar;
}
// Set each bar's height to be proportional to
// the height of the bar with the most annotations
for (i = 0; i < bars.length; i++) {
annots = bars[i].annots;
for (j = 0; j < annots.length; j++) {
barCount = annots[j].count;
height = (barCount / ideo.maxAnnotsPerBar[chr]) * ideo.config.chrMargin;
// console.log(height)
bars[i].annots[j].height = height;
}
}
var t1 = new Date().getTime();
if (ideo.config.debug) {
console.log('Time spent in getHistogramBars: ' + (t1 - t0) + ' ms');
}
ideo.bars = bars;
return bars;
}
/**
* Fills out annotations data structure such that its top-level list of arrays
* matches that of this ideogram's chromosomes list in order and number
* Fixes https://github.com/eweitz/ideogram/issues/66
*/
function fillAnnots(annots) {
var filledAnnots, chrs, chrArray, i, chr, annot, chrIndex;
filledAnnots = [];
chrs = [];
chrArray = this.chromosomesArray;
for (i = 0; i < chrArray.length; i++) {
chr = chrArray[i].name;
chrs.push(chr);
filledAnnots.push({chr: chr, annots: []});
}
for (i = 0; i < annots.length; i++) {
annot = annots[i];
chrIndex = chrs.indexOf(annot.chr);
if (chrIndex !== -1) {
filledAnnots[chrIndex] = annot;
}
}
return filledAnnots;
}
/**
* Draws genome annotations on chromosomes.
* Annotations can be rendered as either overlaid directly
* on a chromosome, or along one or more "tracks"
* running parallel to each chromosome.
*/
function drawProcessedAnnots(annots) {
var chrWidth, layout,
annotHeight, triangle, circle, r, chrAnnot,
x1, x2, y1, y2,
filledAnnots,
ideo = this;
chrWidth = this.config.chrWidth;
layout = 'tracks';
if (this.config.annotationsLayout) {
layout = this.config.annotationsLayout;
}
if (layout === 'histogram') {
annots = ideo.getHistogramBars(annots);
}
annotHeight = ideo.config.annotationHeight;
triangle =
'l -' + annotHeight + ' ' +
(2 * annotHeight) +
' l ' + (2 * annotHeight) + ' 0 z';
// From http://stackoverflow.com/a/10477334, with a minor change ("m -r, r")
// Circles are supported natively via <circle>, but having it as a path
// simplifies handling triangles, circles and other shapes in the same
// D3 call
r = annotHeight;
circle =
'm -' + r + ', ' + r +
'a ' + r + ',' + r + ' 0 1,0 ' + (r * 2) + ',0' +
'a ' + r + ',' + r + ' 0 1,0 -' + (r * 2) + ',0';
filledAnnots = ideo.fillAnnots(annots);
chrAnnot = d3.selectAll(ideo.selector + ' .chromosome')
.data(filledAnnots)
.selectAll('path.annot')
.data(function(d) {
return d.annots;
})
.enter();
if (layout === 'tracks') {
chrAnnot
.append('g')
.attr('id', function(d) {
return d.id;
})
.attr('class', 'annot')
.attr('transform', function(d) {
var y = ideo.config.chrWidth + (d.trackIndex * annotHeight * 2);
return 'translate(' + d.px + ',' + y + ')';
})
.append('path')
.attr('d', function(d) {
if (!d.shape || d.shape === 'triangle') {
return 'm0,0' + triangle;
} else if (d.shape === 'circle') {
return circle;
}
})
.attr('fill', function(d) {
return d.color;
});
} else if (layout === 'overlay') {
// Overlaid annotations appear directly on chromosomes
chrAnnot.append('polygon')
.attr('id', function(d) {
return d.id;
})
.attr('class', 'annot')
.attr('points', function(d) {
if (d.stopPx - d.startPx > 1) {
x1 = d.startPx;
x2 = d.stopPx;
} else {
x1 = d.px - 0.5;
x2 = d.px + 0.5;
}
y1 = chrWidth;
y2 = 0;
return (
x1 + ',' + y1 + ' ' +
x2 + ',' + y1 + ' ' +
x2 + ',' + y2 + ' ' +
x1 + ',' + y2
);
})
.attr('fill', function(d) {
return d.color;
});
} else if (layout === 'histogram') {
chrAnnot.append('polygon')
// .attr('id', function(d, i) { return d.id; })
.attr('class', 'annot')
.attr('points', function(d) {
x1 = d.px + ideo.bump;
x2 = d.px + ideo.config.barWidth + ideo.bump;
y1 = chrWidth;
y2 = chrWidth + d.height;
var thisChrWidth = ideo.chromosomesArray[d.chrIndex - 1].width;
if (x2 > thisChrWidth) {
x2 = thisChrWidth;
}
return (
x1 + ',' + y1 + ' ' +
x2 + ',' + y1 + ' ' +
x2 + ',' + y2 + ' ' +
x1 + ',' + y2
);
})
.attr('fill', function(d) {
return d.color;
});
}
if (ideo.onDrawAnnotsCallback) {
ideo.onDrawAnnotsCallback();
}
}
/**
* Draws a trapezoid connecting a genomic range on
* one chromosome to a genomic range on another chromosome;
* a syntenic region.
*/
function drawSynteny(syntenicRegions) {
var t0 = new Date().getTime();
var r1, r2,
syntenies,
i, color, opacity,
regionID, regions, syntenicRegion,
ideo = this;
syntenies = d3.select(ideo.selector)
.insert('g', ':first-child')
.attr('class', 'synteny');
for (i = 0; i < syntenicRegions.length; i++) {
regions = syntenicRegions[i];
r1 = regions.r1;
r2 = regions.r2;
color = '#CFC';
if ('color' in regions) {
color = regions.color;
}
opacity = 1;
if ('opacity' in regions) {
opacity = regions.opacity;
}
r1.startPx = this.convertBpToPx(r1.chr, r1.start);
r1.stopPx = this.convertBpToPx(r1.chr, r1.stop);
r2.startPx = this.convertBpToPx(r2.chr, r2.start);
r2.stopPx = this.convertBpToPx(r2.chr, r2.stop);
regionID = (
r1.chr.id + '_' + r1.start + '_' + r1.stop + '_' +
'__' +
r2.chr.id + '_' + r2.start + '_' + r2.stop
);
syntenicRegion = syntenies.append('g')
.attr('class', 'syntenicRegion')
.attr('id', regionID)
.on('click', function() {
var activeRegion = this;
var others = d3.selectAll(ideo.selector + ' .syntenicRegion')
.filter(function() {
return (this !== activeRegion);
});
others.classed('hidden', !others.classed('hidden'));
})
.on('mouseover', function() {
var activeRegion = this;
d3.selectAll(ideo.selector + ' .syntenicRegion')
.filter(function() {
return (this !== activeRegion);
})
.classed('ghost', true);
})
.on('mouseout', function() {
d3.selectAll(ideo.selector + ' .syntenicRegion')
.classed('ghost', false);
});
var chrWidth = ideo.config.chrWidth;
var x1 = this._layout.getChromosomeSetYTranslate(0);
var x2 = this._layout.getChromosomeSetYTranslate(1) - chrWidth;
syntenicRegion.append('polygon')
.attr('points',
x1 + ', ' + r1.startPx + ' ' +
x1 + ', ' + r1.stopPx + ' ' +
x2 + ', ' + r2.stopPx + ' ' +
x2 + ', ' + r2.startPx
)
.attr('style', 'fill: ' + color + '; fill-opacity: ' + opacity);
syntenicRegion.append('line')
.attr('class', 'syntenyBorder')
.attr('x1', x1)
.attr('x2', x2)
.attr('y1', r1.startPx)
.attr('y2', r2.startPx);
syntenicRegion.append('line')
.attr('class', 'syntenyBorder')
.attr('x1', x1)
.attr('x2', x2)
.attr('y1', r1.stopPx)
.attr('y2', r2.stopPx);
}
var t1 = new Date().getTime();
if (ideo.config.debug) {
console.log('Time in drawSyntenicRegions: ' + (t1 - t0) + ' ms');
}
}
export {
onDrawAnnots, processAnnotData, initAnnotSettings, fetchAnnots, drawAnnots,
getHistogramBars, fillAnnots, drawProcessedAnnots, drawSynteny
}