mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
1151 lines
42 KiB
JavaScript
1151 lines
42 KiB
JavaScript
// load required libraries
|
|
require(
|
|
[
|
|
'utils/utils',
|
|
'mvc/ui/icon-button',
|
|
'libs/farbtastic',
|
|
], function(mod_utils, mod_icon_btn)
|
|
{
|
|
// load css
|
|
mod_utils.cssLoadFile("static/style/circster.css");
|
|
});
|
|
|
|
define(["libs/underscore", "libs/d3", "viz/visualization", "utils/config"], function(_, d3, visualization, config) {
|
|
|
|
/**
|
|
* Utility class for working with SVG.
|
|
*/
|
|
var SVGUtils = Backbone.Model.extend({
|
|
|
|
/**
|
|
* Returns true if element is visible.
|
|
*/
|
|
is_visible: function(svg_elt, svg) {
|
|
var eltBRect = svg_elt.getBoundingClientRect(),
|
|
svgBRect = $('svg')[0].getBoundingClientRect();
|
|
|
|
if (// To the left of screen?
|
|
eltBRect.right < 0 ||
|
|
// To the right of screen?
|
|
eltBRect.left > svgBRect.right ||
|
|
// Above screen?
|
|
eltBRect.bottom < 0 ||
|
|
// Below screen?
|
|
eltBRect.top > svgBRect.bottom) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Mixin for using ticks.
|
|
*/
|
|
var UsesTicks = {
|
|
drawTicks: function(parent_elt, data, dataHandler, textTransform, horizontal) {
|
|
// Set up group elements for chroms and for each tick.
|
|
var ticks = parent_elt.append("g")
|
|
.selectAll("g")
|
|
.data(data)
|
|
.enter().append("g")
|
|
.selectAll("g")
|
|
.data(dataHandler)
|
|
.enter().append("g")
|
|
.attr("class", "tick")
|
|
.attr("transform", function(d) {
|
|
return "rotate(" + (d.angle * 180 / Math.PI - 90) + ")" +
|
|
"translate(" + d.radius + ",0)";
|
|
});
|
|
|
|
// Add line + text for ticks.
|
|
var tick_coords = [],
|
|
text_coords = [],
|
|
text_anchor = function(d) {
|
|
return d.angle > Math.PI ? "end" : null;
|
|
};
|
|
if (horizontal) {
|
|
tick_coords = [0, 0, 0, -4];
|
|
text_coords = [4, 0, "", ".35em"];
|
|
text_anchor = null;
|
|
}
|
|
else {
|
|
tick_coords = [1, 0, 4, 0];
|
|
text_coords = [0, 4, ".35em", ""];
|
|
|
|
}
|
|
|
|
ticks.append("line")
|
|
.attr("x1", tick_coords[0])
|
|
.attr("y1", tick_coords[1])
|
|
.attr("x2", tick_coords[2])
|
|
.attr("y1", tick_coords[3])
|
|
.style("stroke", "#000");
|
|
|
|
return ticks.append("text")
|
|
.attr("x", text_coords[0])
|
|
.attr("y", text_coords[1])
|
|
.attr("dx", text_coords[2])
|
|
.attr("dy", text_coords[3])
|
|
.attr("text-anchor", text_anchor)
|
|
.attr("transform", textTransform)
|
|
.text(function(d) { return d.label; });
|
|
},
|
|
|
|
/**
|
|
* Format number for display at a tick.
|
|
*/
|
|
formatNum: function(num, sigDigits) {
|
|
// Use default of 2 sig. digits.
|
|
if (sigDigits === undefined)
|
|
sigDigits = 2;
|
|
|
|
// Verify input number
|
|
if (num === null)
|
|
return null;
|
|
|
|
// Calculate return value
|
|
var rval = null;
|
|
if (Math.abs(num) < 1) {
|
|
rval = num.toPrecision(sigDigits);
|
|
}
|
|
else {
|
|
// Use round to turn string from toPrecision() back into a number.
|
|
var roundedNum = Math.round(num.toPrecision(sigDigits));
|
|
|
|
// Use abbreviations.
|
|
num = Math.abs(num);
|
|
if (num < 1000) {
|
|
rval = roundedNum;
|
|
}
|
|
else if (num < 1000000) {
|
|
// Use K.
|
|
rval = Math.round((roundedNum/1000).toPrecision(3)).toFixed(0) + 'K';
|
|
}
|
|
else if (num < 1000000000) {
|
|
// Use M.
|
|
rval = Math.round((roundedNum/1000000).toPrecision(3)).toFixed(0) + 'M';
|
|
}
|
|
}
|
|
|
|
return rval;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A label track.
|
|
*/
|
|
var CircsterLabelTrack = Backbone.Model.extend({});
|
|
|
|
/**
|
|
* Renders a full circster visualization.
|
|
*/
|
|
var CircsterView = Backbone.View.extend({
|
|
className: 'circster',
|
|
|
|
initialize: function(options) {
|
|
this.genome = options.genome;
|
|
this.label_arc_height = 50;
|
|
this.scale = 1;
|
|
this.circular_views = null;
|
|
this.chords_views = null;
|
|
|
|
// When tracks added to/removed from model, update view.
|
|
this.model.get('drawables').on('add', this.add_track, this);
|
|
this.model.get('drawables').on('remove', this.remove_track, this);
|
|
|
|
// When config settings change, update view.
|
|
var vis_config = this.model.get('config');
|
|
vis_config.get('arc_dataset_height').on('change:value', this.update_track_bounds, this);
|
|
vis_config.get('track_gap').on('change:value', this.update_track_bounds, this);
|
|
},
|
|
|
|
// HACKs: using track_type for circular/chord distinction in the functions below for now.
|
|
|
|
/**
|
|
* Returns tracks to be rendered using circular view.
|
|
*/
|
|
get_circular_tracks: function() {
|
|
return this.model.get('drawables').filter(function(track) {
|
|
return track.get('track_type') !== 'DiagonalHeatmapTrack';
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Returns tracks to be rendered using chords view.
|
|
*/
|
|
get_chord_tracks: function() {
|
|
return this.model.get('drawables').filter(function(track) {
|
|
return track.get('track_type') === 'DiagonalHeatmapTrack';
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Returns a list of circular tracks' radius bounds.
|
|
*/
|
|
get_tracks_bounds: function() {
|
|
var circular_tracks = this.get_circular_tracks(),
|
|
dataset_arc_height = this.model.get('config').get_value('arc_dataset_height'),
|
|
track_gap = this.model.get('config').get_value('track_gap'),
|
|
// Subtract 20 to make sure chrom labels are on screen.
|
|
min_dimension = Math.min(this.$el.width(), this.$el.height()) - 20,
|
|
// Compute radius start based on model, will be centered
|
|
// and fit entirely inside element by default.
|
|
radius_start = min_dimension / 2 -
|
|
circular_tracks.length * (dataset_arc_height + track_gap) +
|
|
// Add track_gap back in because no gap is needed for last track.
|
|
track_gap -
|
|
this.label_arc_height,
|
|
|
|
// Compute range of track starting radii.
|
|
tracks_start_radii = d3.range(radius_start, min_dimension / 2, dataset_arc_height + track_gap);
|
|
|
|
// Map from track start to bounds.
|
|
var self = this;
|
|
return _.map(tracks_start_radii, function(radius) {
|
|
return [radius, radius + dataset_arc_height];
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Renders circular tracks, chord tracks, and label tracks.
|
|
*/
|
|
render: function() {
|
|
var self = this,
|
|
width = self.$el.width(),
|
|
height = self.$el.height(),
|
|
circular_tracks = this.get_circular_tracks(),
|
|
chords_tracks = this.get_chord_tracks(),
|
|
total_gap = self.model.get('config').get_value('total_gap'),
|
|
tracks_bounds = this.get_tracks_bounds(),
|
|
|
|
// Set up SVG element.
|
|
svg = d3.select(self.$el[0])
|
|
.append("svg")
|
|
.attr("width", width)
|
|
.attr("height", height)
|
|
.attr("pointer-events", "all")
|
|
// Set up zooming, dragging.
|
|
.append('svg:g')
|
|
.call(d3.behavior.zoom().on('zoom', function() {
|
|
// Do zoom, drag.
|
|
var scale = d3.event.scale;
|
|
svg.attr("transform",
|
|
"translate(" + d3.event.translate + ")" +
|
|
" scale(" + scale + ")");
|
|
|
|
// Propagate scale changes to views.
|
|
if (self.scale !== scale) {
|
|
// Use timeout to wait for zooming/dragging to stop before rendering more detail.
|
|
if (self.zoom_drag_timeout) {
|
|
clearTimeout(self.zoom_drag_timeout);
|
|
}
|
|
self.zoom_drag_timeout = setTimeout(function() {
|
|
// Render more detail in tracks' visible elements.
|
|
// FIXME: do not do this right now; it is not fully implemented--e.g. data bounds
|
|
// are not updated when new data is fetched--and fetching more detailed quantitative
|
|
// data is not that useful.
|
|
/*
|
|
_.each(self.circular_views, function(view) {
|
|
view.update_scale(scale);
|
|
});
|
|
*/
|
|
}, 400);
|
|
}
|
|
}))
|
|
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")")
|
|
.append('svg:g').attr('class', 'tracks');
|
|
|
|
// -- Render circular tracks. --
|
|
|
|
// Create a view for each track in the visualization and render.
|
|
this.circular_views = circular_tracks.map(function(track, index) {
|
|
var view = new CircsterBigWigTrackView({
|
|
el: svg.append('g')[0],
|
|
track: track,
|
|
radius_bounds: tracks_bounds[index],
|
|
genome: self.genome,
|
|
total_gap: total_gap
|
|
});
|
|
|
|
view.render();
|
|
|
|
return view;
|
|
});
|
|
|
|
// -- Render chords tracks. --
|
|
|
|
this.chords_views = chords_tracks.map(function(track) {
|
|
var view = new CircsterChromInteractionsTrackView({
|
|
el: svg.append('g')[0],
|
|
track: track,
|
|
radius_bounds: tracks_bounds[0],
|
|
genome: self.genome,
|
|
total_gap: total_gap
|
|
});
|
|
|
|
view.render();
|
|
|
|
return view;
|
|
});
|
|
|
|
// -- Render label track. --
|
|
|
|
// Track bounds are:
|
|
// (a) outer radius of last circular track;
|
|
// (b)
|
|
var outermost_radius = this.circular_views[this.circular_views.length-1].radius_bounds[1],
|
|
track_bounds = [
|
|
outermost_radius,
|
|
outermost_radius + this.label_arc_height
|
|
];
|
|
this.label_track_view = new CircsterChromLabelTrackView({
|
|
el: svg.append('g')[0],
|
|
track: new CircsterLabelTrack(),
|
|
radius_bounds: track_bounds,
|
|
genome: self.genome,
|
|
total_gap: total_gap
|
|
});
|
|
|
|
this.label_track_view.render();
|
|
},
|
|
|
|
/**
|
|
* Render a single track on the outside of the current visualization.
|
|
*/
|
|
add_track: function(new_track) {
|
|
var total_gap = this.model.get('config').get_value('total_gap');
|
|
|
|
if (new_track.get('track_type') === 'DiagonalHeatmapTrack') {
|
|
// Added chords track.
|
|
var innermost_radius_bounds = this.circular_views[0].radius_bounds,
|
|
new_view = new CircsterChromInteractionsTrackView({
|
|
el: d3.select('g.tracks').append('g')[0],
|
|
track: new_track,
|
|
radius_bounds: innermost_radius_bounds,
|
|
genome: this.genome,
|
|
total_gap: total_gap
|
|
});
|
|
new_view.render();
|
|
this.chords_views.push(new_view);
|
|
}
|
|
else {
|
|
// Added circular track.
|
|
|
|
// Recompute and update circular track bounds.
|
|
var new_track_bounds = this.get_tracks_bounds();
|
|
_.each(this.circular_views, function(track_view, i) {
|
|
track_view.update_radius_bounds(new_track_bounds[i]);
|
|
});
|
|
|
|
// Update chords tracks.
|
|
_.each(this.chords_views, function(track_view) {
|
|
track_view.update_radius_bounds(new_track_bounds[0]);
|
|
});
|
|
|
|
// Render new track.
|
|
var track_index = this.circular_views.length,
|
|
track_view = new CircsterBigWigTrackView({
|
|
el: d3.select('g.tracks').append('g')[0],
|
|
track: new_track,
|
|
radius_bounds: new_track_bounds[track_index],
|
|
genome: this.genome,
|
|
total_gap: total_gap
|
|
});
|
|
track_view.render();
|
|
this.circular_views.push(track_view);
|
|
|
|
// Update label track.
|
|
/*
|
|
FIXME: should never have to update label track because vis always expands to fit area
|
|
within label track.
|
|
var track_bounds = new_track_bounds[ new_track_bounds.length-1 ];
|
|
track_bounds[1] = track_bounds[0];
|
|
this.label_track_view.update_radius_bounds(track_bounds);
|
|
*/
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Remove a track from the view.
|
|
*/
|
|
remove_track: function(track, tracks, options) {
|
|
// -- Remove track from view. --
|
|
var track_view = this.circular_views[options.index];
|
|
this.circular_views.splice(options.index, 1);
|
|
track_view.$el.remove();
|
|
|
|
// Recompute and update track bounds.
|
|
var new_track_bounds = this.get_tracks_bounds();
|
|
_.each(this.circular_views, function(track_view, i) {
|
|
track_view.update_radius_bounds(new_track_bounds[i]);
|
|
});
|
|
},
|
|
|
|
update_track_bounds: function() {
|
|
// Recompute and update track bounds.
|
|
var new_track_bounds = this.get_tracks_bounds();
|
|
_.each(this.circular_views, function(track_view, i) {
|
|
track_view.update_radius_bounds(new_track_bounds[i]);
|
|
});
|
|
|
|
// Update chords tracks.
|
|
_.each(this.chords_views, function(track_view) {
|
|
track_view.update_radius_bounds(new_track_bounds[0]);
|
|
});
|
|
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Renders a track in a Circster visualization.
|
|
*/
|
|
var CircsterTrackView = Backbone.View.extend({
|
|
tagName: 'g',
|
|
|
|
/* ----------------------- Public Methods ------------------------- */
|
|
|
|
initialize: function(options) {
|
|
this.bg_stroke = '#ddd';
|
|
// Fill color when loading data.
|
|
this.loading_bg_fill = '#ffc';
|
|
// Fill color when data has been loaded.
|
|
this.bg_fill = '#ddd';
|
|
this.total_gap = options.total_gap;
|
|
this.track = options.track;
|
|
this.radius_bounds = options.radius_bounds;
|
|
this.genome = options.genome;
|
|
this.chroms_layout = this._chroms_layout();
|
|
this.data_bounds = [];
|
|
this.scale = 1;
|
|
this.parent_elt = d3.select(this.$el[0]);
|
|
},
|
|
|
|
/**
|
|
* Get fill color from config.
|
|
*/
|
|
get_fill_color: function() {
|
|
var color = this.track.get('config').get_value('block_color');
|
|
if (!color) { color = this.track.get('config').get_value('color'); }
|
|
return color;
|
|
},
|
|
|
|
/**
|
|
* Render track's data by adding SVG elements to parent.
|
|
*/
|
|
render: function() {
|
|
// -- Create track group element. --
|
|
var track_parent_elt = this.parent_elt;
|
|
|
|
// -- Render background arcs. --
|
|
var genome_arcs = this.chroms_layout,
|
|
arc_gen = d3.svg.arc()
|
|
.innerRadius(this.radius_bounds[0])
|
|
.outerRadius(this.radius_bounds[1]),
|
|
|
|
// Attach data to group element.
|
|
chroms_elts = track_parent_elt.selectAll('g')
|
|
.data(genome_arcs).enter().append('svg:g'),
|
|
|
|
// Draw chrom arcs/paths.
|
|
chroms_paths = chroms_elts.append('path')
|
|
.attr("d", arc_gen)
|
|
.attr('class', 'chrom-background')
|
|
.style("stroke", this.bg_stroke)
|
|
.style("fill", this.loading_bg_fill);
|
|
|
|
// Append titles to paths.
|
|
chroms_paths.append("title").text(function(d) { return d.data.chrom; });
|
|
|
|
// -- Render track data and, when track data is rendered, apply preferences and update chrom_elts fill. --
|
|
|
|
var self = this,
|
|
data_manager = self.track.get('data_manager'),
|
|
// If track has a data manager, get deferred that resolves when data is ready.
|
|
data_ready_deferred = (data_manager ? data_manager.data_is_ready() : true );
|
|
|
|
// When data is ready, render track.
|
|
$.when(data_ready_deferred).then(function() {
|
|
$.when(self._render_data(track_parent_elt)).then(function() {
|
|
chroms_paths.style("fill", self.bg_fill);
|
|
|
|
// Render labels after data is available so that data attributes are available.
|
|
self.render_labels();
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Render track labels.
|
|
*/
|
|
render_labels: function() {},
|
|
|
|
/**
|
|
* Update radius bounds.
|
|
*/
|
|
update_radius_bounds: function(radius_bounds) {
|
|
// Update bounds.
|
|
this.radius_bounds = radius_bounds;
|
|
|
|
// -- Update background arcs. --
|
|
var new_d = d3.svg.arc()
|
|
.innerRadius(this.radius_bounds[0])
|
|
.outerRadius(this.radius_bounds[1]);
|
|
|
|
this.parent_elt.selectAll('g>path.chrom-background').transition().duration(1000).attr('d', new_d);
|
|
|
|
this._transition_chrom_data();
|
|
|
|
this._transition_labels();
|
|
},
|
|
|
|
/**
|
|
* Update view scale. This fetches more data if scale is increased.
|
|
*/
|
|
update_scale: function(new_scale) {
|
|
// -- Update scale and return if new scale is less than old scale. --
|
|
|
|
var old_scale = this.scale;
|
|
this.scale = new_scale;
|
|
if (new_scale <= old_scale) {
|
|
return;
|
|
}
|
|
|
|
// -- Scale increased, so render visible data with more detail. --
|
|
|
|
var self = this,
|
|
utils = new SVGUtils();
|
|
|
|
// Select all chrom data and filter to operate on those that are visible.
|
|
this.parent_elt.selectAll('path.chrom-data').filter(function(d, i) {
|
|
return utils.is_visible(this);
|
|
}).each(function(d, i) {
|
|
// -- Now operating on a single path element representing chromosome data. --
|
|
|
|
var path_elt = d3.select(this),
|
|
chrom = path_elt.attr('chrom'),
|
|
chrom_region = self.genome.get_chrom_region(chrom),
|
|
data_manager = self.track.get('data_manager'),
|
|
data_deferred;
|
|
|
|
// If can't get more detailed data, return.
|
|
if (!data_manager.can_get_more_detailed_data(chrom_region)) {
|
|
return;
|
|
}
|
|
|
|
// -- Get more detailed data. --
|
|
data_deferred = self.track.get('data_manager').get_more_detailed_data(chrom_region, 'Coverage', 0, new_scale);
|
|
|
|
// When more data is available, use new data to redraw path.
|
|
$.when(data_deferred).then(function(data) {
|
|
// Remove current data path.
|
|
path_elt.remove();
|
|
|
|
// Update data bounds with new data.
|
|
self._update_data_bounds();
|
|
|
|
// Find chromosome arc to draw data on.
|
|
var chrom_arc = _.find(self.chroms_layout, function(layout) {
|
|
return layout.data.chrom === chrom;
|
|
});
|
|
|
|
// Add new data path and apply preferences.
|
|
var color = self.get_fill_color();
|
|
self._render_chrom_data(self.parent_elt, chrom_arc, data).style('stroke', color).style('fill', color);
|
|
});
|
|
});
|
|
|
|
return self;
|
|
},
|
|
|
|
/* ----------------------- Internal Methods ------------------------- */
|
|
|
|
/**
|
|
* Transitions chrom data to new values (e.g new radius or data bounds).
|
|
*/
|
|
_transition_chrom_data: function() {
|
|
var track = this.track,
|
|
chrom_arcs = this.chroms_layout,
|
|
chrom_data_paths = this.parent_elt.selectAll('g>path.chrom-data'),
|
|
num_paths = chrom_data_paths[0].length;
|
|
|
|
if (num_paths > 0) {
|
|
var self = this;
|
|
$.when(track.get('data_manager').get_genome_wide_data(this.genome)).then(function(genome_wide_data) {
|
|
// Map chrom data to path data, filtering out null values.
|
|
var path_data = _.reject( _.map(genome_wide_data, function(chrom_data, i) {
|
|
var rval = null,
|
|
path_fn = self._get_path_function(chrom_arcs[i], chrom_data);
|
|
if (path_fn) {
|
|
rval = path_fn(chrom_data.data);
|
|
}
|
|
return rval;
|
|
}), function(p_data) { return p_data === null; } );
|
|
|
|
// Transition each path for data and color.
|
|
var color = track.get('config').get_value('color');
|
|
chrom_data_paths.each(function(path, index) {
|
|
d3.select(this).transition().duration(1000)
|
|
.style('stroke', color).style('fill', color)
|
|
.attr('d', path_data[index]);
|
|
});
|
|
});
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Transition labels to new values (e.g new radius or data bounds).
|
|
*/
|
|
_transition_labels: function() {},
|
|
|
|
/**
|
|
* Update data bounds. If there are new_bounds, use them; otherwise use
|
|
* default data bounds.
|
|
*/
|
|
_update_data_bounds: function(new_bounds) {
|
|
var old_bounds = this.data_bounds;
|
|
this.data_bounds = new_bounds ||
|
|
this.get_data_bounds(this.track.get('data_manager').get_genome_wide_data(this.genome));
|
|
this._transition_chrom_data();
|
|
},
|
|
|
|
/**
|
|
* Render data as elements attached to svg.
|
|
*/
|
|
_render_data: function(svg) {
|
|
var self = this,
|
|
chrom_arcs = this.chroms_layout,
|
|
track = this.track,
|
|
rendered_deferred = $.Deferred();
|
|
|
|
// When genome-wide data is available, render data.
|
|
$.when(track.get('data_manager').get_genome_wide_data(this.genome)).then(function(genome_wide_data) {
|
|
// Set bounds.
|
|
self.data_bounds = self.get_data_bounds(genome_wide_data);
|
|
|
|
// Set min, max value in config so that they can be adjusted. Make this silent
|
|
// because these attributes are watched for changes and the viz is updated
|
|
// accordingly (set up in initialize). Because we are setting up, we don't want
|
|
// the watch to trigger events here.
|
|
track.get('config').set_value('min_value', self.data_bounds[0], {silent: true});
|
|
track.get('config').set_value('max_value', self.data_bounds[1], {silent: true});
|
|
|
|
// Merge chroms layout with data.
|
|
layout_and_data = _.zip(chrom_arcs, genome_wide_data),
|
|
|
|
// Render each chromosome's data.
|
|
chroms_data_layout = _.map(layout_and_data, function(chrom_info) {
|
|
var chrom_arc = chrom_info[0],
|
|
data = chrom_info[1];
|
|
return self._render_chrom_data(svg, chrom_arc, data);
|
|
});
|
|
|
|
// Apply prefs to all track data.
|
|
var color = self.get_fill_color();
|
|
self.parent_elt.selectAll('path.chrom-data').style('stroke', color).style('fill', color);
|
|
|
|
rendered_deferred.resolve(svg);
|
|
});
|
|
|
|
return rendered_deferred;
|
|
},
|
|
|
|
/**
|
|
* Render a chromosome data and attach elements to svg.
|
|
*/
|
|
_render_chrom_data: function(svg, chrom_arc, data) {},
|
|
|
|
/**
|
|
* Returns data for creating a path for the given data using chrom_arc and data bounds.
|
|
*/
|
|
_get_path_function: function(chrom_arc, chrom_data) {},
|
|
|
|
/**
|
|
* Returns arc layouts for genome's chromosomes/contigs. Arcs are arranged in a circle
|
|
* separated by gaps.
|
|
*/
|
|
_chroms_layout: function() {
|
|
// Setup chroms layout using pie.
|
|
var chroms_info = this.genome.get_chroms_info(),
|
|
pie_layout = d3.layout.pie().value(function(d) { return d.len; }).sort(null),
|
|
init_arcs = pie_layout(chroms_info),
|
|
gap_per_chrom = 2 * Math.PI * this.total_gap / chroms_info.length,
|
|
chrom_arcs = _.map(init_arcs, function(arc, index) {
|
|
// For short chroms, endAngle === startAngle.
|
|
var new_endAngle = arc.endAngle - gap_per_chrom;
|
|
arc.endAngle = (new_endAngle > arc.startAngle ? new_endAngle : arc.startAngle);
|
|
return arc;
|
|
});
|
|
return chrom_arcs;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Render chromosome labels.
|
|
*/
|
|
var CircsterChromLabelTrackView = CircsterTrackView.extend({
|
|
|
|
initialize: function(options) {
|
|
CircsterTrackView.prototype.initialize.call(this, options);
|
|
// Use a single arc for rendering data.
|
|
this.innerRadius = this.radius_bounds[0];
|
|
this.radius_bounds[0] = this.radius_bounds[1];
|
|
this.bg_stroke = '#fff';
|
|
this.bg_fill = '#fff';
|
|
|
|
// Minimum arc distance for labels to be applied.
|
|
this.min_arc_len = 0.05;
|
|
},
|
|
|
|
/**
|
|
* Render labels.
|
|
*/
|
|
_render_data: function(svg) {
|
|
// -- Add chromosome label where it will fit; an alternative labeling mechanism
|
|
// would be nice for small chromosomes. --
|
|
var self = this,
|
|
chrom_arcs = svg.selectAll('g');
|
|
|
|
chrom_arcs.selectAll('path')
|
|
.attr('id', function(d) { return 'label-' + d.data.chrom; });
|
|
|
|
chrom_arcs.append("svg:text")
|
|
.filter(function(d) {
|
|
return d.endAngle - d.startAngle > self.min_arc_len;
|
|
})
|
|
.attr('text-anchor', 'middle')
|
|
.append("svg:textPath")
|
|
.attr("class", "chrom-label")
|
|
.attr("xlink:href", function(d) { return "#label-" + d.data.chrom; })
|
|
.attr('startOffset', '25%')
|
|
.text(function(d) {
|
|
return d.data.chrom;
|
|
});
|
|
|
|
// -- Add ticks to denote chromosome length. --
|
|
|
|
/** Returns an array of tick angles and labels, given a chrom arc. */
|
|
var chromArcTicks = function(d) {
|
|
var k = (d.endAngle - d.startAngle) / d.value,
|
|
ticks = d3.range(0, d.value, 25000000).map(function(v, i) {
|
|
return {
|
|
radius: self.innerRadius,
|
|
angle: v * k + d.startAngle,
|
|
label: i === 0 ? 0 : (i % 3 ? null : self.formatNum(v))
|
|
};
|
|
});
|
|
|
|
// If there are fewer that 4 ticks, label last tick so that at least one non-zero tick is labeled.
|
|
if (ticks.length < 4) {
|
|
ticks[ticks.length-1].label = self.formatNum(
|
|
Math.round( ( ticks[ticks.length-1].angle - d.startAngle ) / k )
|
|
);
|
|
}
|
|
|
|
return ticks;
|
|
};
|
|
|
|
/** Rotate and move text as needed. */
|
|
var textTransform = function(d) {
|
|
return d.angle > Math.PI ? "rotate(180)translate(-16)" : null;
|
|
};
|
|
|
|
// Filter chroms for only those large enough for display.
|
|
var visibleChroms = _.filter(this.chroms_layout, function(c) { return c.endAngle - c.startAngle > self.min_arc_len; });
|
|
|
|
this.drawTicks(this.parent_elt, visibleChroms, chromArcTicks, textTransform);
|
|
}
|
|
});
|
|
_.extend(CircsterChromLabelTrackView.prototype, UsesTicks);
|
|
|
|
/**
|
|
* View for quantitative track in Circster.
|
|
*/
|
|
var CircsterQuantitativeTrackView = CircsterTrackView.extend({
|
|
|
|
initialize: function(options) {
|
|
CircsterTrackView.prototype.initialize.call(this, options);
|
|
|
|
// When config settings change, update view.
|
|
var track_config = this.track.get('config');
|
|
track_config.get('min_value').on('change:value', this._update_min_max, this);
|
|
track_config.get('max_value').on('change:value', this._update_min_max, this);
|
|
track_config.get('color').on('change:value', this._transition_chrom_data, this);
|
|
},
|
|
|
|
/**
|
|
* Update track when min and/or max are changed.
|
|
*/
|
|
_update_min_max: function() {
|
|
var track_config = this.track.get('config'),
|
|
new_bounds = [track_config.get_value('min_value'), track_config.get_value('max_value')];
|
|
this._update_data_bounds(new_bounds);
|
|
|
|
// FIXME: this works to update tick/text bounds, but there's probably a better way to do this
|
|
// by updating the data itself.
|
|
this.parent_elt.selectAll('.min_max').text(function(d, i) {
|
|
return new_bounds[i];
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Returns quantile for an array of numbers.
|
|
*/
|
|
_quantile: function(numbers, quantile) {
|
|
numbers.sort(d3.ascending);
|
|
return d3.quantile(numbers, quantile);
|
|
},
|
|
|
|
/**
|
|
* Renders quantitative data with the form [x, value] and assumes data is equally spaced across
|
|
* chromosome. Attachs a dict with track and chrom name information to DOM element.
|
|
*/
|
|
_render_chrom_data: function(svg, chrom_arc, chrom_data) {
|
|
var path_data = this._get_path_function(chrom_arc, chrom_data);
|
|
|
|
if (!path_data) { return null; }
|
|
|
|
// There is path data, so render as path.
|
|
var parent = svg.datum(chrom_data.data),
|
|
path = parent.append('path')
|
|
.attr('class', 'chrom-data')
|
|
.attr('chrom', chrom_arc.data.chrom)
|
|
.attr('d', path_data);
|
|
|
|
return path;
|
|
},
|
|
|
|
/**
|
|
* Returns function for creating a path across the chrom arc.
|
|
*/
|
|
_get_path_function: function(chrom_arc, chrom_data) {
|
|
// If no chrom data, return null.
|
|
if (typeof chrom_data === "string" || !chrom_data.data || chrom_data.data.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Radius scaler.
|
|
var radius = d3.scale.linear()
|
|
.domain(this.data_bounds)
|
|
.range(this.radius_bounds)
|
|
.clamp(true);
|
|
|
|
// Scaler for placing data points across arc.
|
|
var angle = d3.scale.linear()
|
|
.domain([0, chrom_data.data.length])
|
|
.range([chrom_arc.startAngle, chrom_arc.endAngle]);
|
|
|
|
// Use line generator to create area.
|
|
var line = d3.svg.line.radial()
|
|
.interpolate("linear")
|
|
.radius(function(d) { return radius(d[1]); })
|
|
.angle(function(d, i) { return angle(i); });
|
|
|
|
return d3.svg.area.radial()
|
|
.interpolate(line.interpolate())
|
|
.innerRadius(radius(0))
|
|
.outerRadius(line.radius())
|
|
.angle(line.angle());
|
|
},
|
|
|
|
/**
|
|
* Render track min, max using ticks.
|
|
*/
|
|
render_labels: function() {
|
|
var self = this,
|
|
// Keep counter of visible chroms.
|
|
textTransform = function() {
|
|
return "rotate(90)";
|
|
};
|
|
|
|
// FIXME:
|
|
// (1) using min_max class below is needed for _update_min_max, which could be improved.
|
|
// (2) showing config on tick click should be replaced by proper track config icon.
|
|
|
|
// Draw min, max on first chrom only.
|
|
var ticks = this.drawTicks(this.parent_elt, [ this.chroms_layout[0] ],
|
|
this._data_bounds_ticks_fn(), textTransform, true)
|
|
.classed('min_max', true);
|
|
|
|
// Show config when ticks are clicked on.
|
|
_.each(ticks, function(tick) {
|
|
$(tick).click(function() {
|
|
var view = new config.ConfigSettingCollectionView({
|
|
collection: self.track.get('config')
|
|
});
|
|
view.render_in_modal('Configure Track');
|
|
});
|
|
});
|
|
|
|
|
|
|
|
/*
|
|
// Filter for visible chroms, then for every third chrom so that labels attached to only every
|
|
// third chrom.
|
|
var visibleChroms = _.filter(this.chroms_layout, function(c) { return c.endAngle - c.startAngle > 0.08; }),
|
|
labeledChroms = _.filter(visibleChroms, function(c, i) { return i % 3 === 0; });
|
|
this.drawTicks(this.parent_elt, labeledChroms, this._data_bounds_ticks_fn(), textTransform, true);
|
|
*/
|
|
},
|
|
|
|
/**
|
|
* Transition labels to new values (e.g new radius or data bounds).
|
|
*/
|
|
_transition_labels: function() {
|
|
// FIXME: (a) pull out function for getting labeled chroms? and (b) function used in transition below
|
|
// is copied from UseTicks mixin, so pull out and make generally available.
|
|
|
|
// If there are no data bounds, nothing to transition.
|
|
if (this.data_bounds.length === 0) { return; }
|
|
|
|
// Transition labels to new radius bounds.
|
|
var self = this,
|
|
visibleChroms = _.filter(this.chroms_layout, function(c) { return c.endAngle - c.startAngle > 0.08; }),
|
|
labeledChroms = _.filter(visibleChroms, function(c, i) { return i % 3 === 0; }),
|
|
new_data = _.flatten( _.map(labeledChroms, function(c) {
|
|
return self._data_bounds_ticks_fn()(c);
|
|
}));
|
|
this.parent_elt.selectAll('g.tick').data(new_data).transition().attr("transform", function(d) {
|
|
return "rotate(" + (d.angle * 180 / Math.PI - 90) + ")" +
|
|
"translate(" + d.radius + ",0)";
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Get function for locating data bounds ticks.
|
|
*/
|
|
_data_bounds_ticks_fn: function() {
|
|
// Closure vars.
|
|
var self = this;
|
|
visibleChroms = 0;
|
|
|
|
// Return function for locating ticks based on chrom arc data.
|
|
return function(d) {
|
|
// Set up data to display min, max ticks.
|
|
return [
|
|
{
|
|
radius: self.radius_bounds[0],
|
|
angle: d.startAngle,
|
|
label: self.formatNum(self.data_bounds[0])
|
|
},
|
|
{
|
|
radius: self.radius_bounds[1],
|
|
angle: d.startAngle,
|
|
label: self.formatNum(self.data_bounds[1])
|
|
}
|
|
];
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Returns an array with two values denoting the minimum and maximum
|
|
* values for the track.
|
|
*/
|
|
get_data_bounds: function(data) {}
|
|
|
|
});
|
|
_.extend(CircsterQuantitativeTrackView.prototype, UsesTicks);
|
|
|
|
/**
|
|
* Bigwig track view in Circster.
|
|
*/
|
|
var CircsterBigWigTrackView = CircsterQuantitativeTrackView.extend({
|
|
|
|
get_data_bounds: function(data) {
|
|
// Set max across dataset by extracting all values, flattening them into a
|
|
// single array, and getting third quartile.
|
|
var values = _.flatten( _.map(data, function(d) {
|
|
if (d) {
|
|
// Each data point has the form [position, value], so return all values.
|
|
return _.map(d.data, function(p) {
|
|
// Null is used for a lack of data; resolve null to 0 for comparison.
|
|
return parseInt(p[1], 10) || 0;
|
|
});
|
|
}
|
|
else {
|
|
return 0;
|
|
}
|
|
}) );
|
|
|
|
// For max, use 98% quantile in attempt to avoid very large values. However, this max may be 0
|
|
// for sparsely populated data, so use max in that case.
|
|
return [ _.min(values), this._quantile(values, 0.98) || _.max(values) ];
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Chromosome interactions track view in Circster.
|
|
*/
|
|
var CircsterChromInteractionsTrackView = CircsterTrackView.extend({
|
|
|
|
render: function() {
|
|
var self = this;
|
|
|
|
// When data is ready, render track.
|
|
$.when(self.track.get('data_manager').data_is_ready()).then(function() {
|
|
// When data has been fetched, render track.
|
|
$.when(self.track.get('data_manager').get_genome_wide_data(self.genome)).then(function(genome_wide_data) {
|
|
var chord_data = [],
|
|
chroms_info = self.genome.get_chroms_info();
|
|
// Convert chromosome data into chord data.
|
|
_.each(genome_wide_data, function(chrom_data, index) {
|
|
// Map each interaction into chord data.
|
|
var cur_chrom = chroms_info[index].chrom;
|
|
var chrom_chord_data = _.map(chrom_data.data, function(datum) {
|
|
// Each datum is an interaction/chord.
|
|
var source_angle = self._get_region_angle(cur_chrom, datum[1]),
|
|
target_angle = self._get_region_angle(datum[3], datum[4]);
|
|
return {
|
|
source: {
|
|
startAngle: source_angle,
|
|
endAngle: source_angle + 0.01
|
|
},
|
|
target: {
|
|
startAngle: target_angle,
|
|
endAngle: target_angle + 0.01
|
|
}
|
|
};
|
|
});
|
|
|
|
chord_data = chord_data.concat(chrom_chord_data);
|
|
});
|
|
|
|
self.parent_elt.append("g")
|
|
.attr("class", "chord")
|
|
.selectAll("path")
|
|
.data(chord_data)
|
|
.enter().append("path")
|
|
.style("fill", self.get_fill_color())
|
|
.attr("d", d3.svg.chord().radius(self.radius_bounds[0]))
|
|
.style("opacity", 1);
|
|
});
|
|
});
|
|
},
|
|
|
|
update_radius_bounds: function(radius_bounds) {
|
|
this.radius_bounds = radius_bounds;
|
|
this.parent_elt.selectAll("path").transition().attr("d", d3.svg.chord().radius(this.radius_bounds[0]));
|
|
},
|
|
|
|
/**
|
|
* Returns radians for a genomic position.
|
|
*/
|
|
_get_region_angle: function(chrom, position) {
|
|
// Find chrom angle data
|
|
var chrom_angle_data = _.find(this.chroms_layout, function(chrom_layout) {
|
|
return chrom_layout.data.chrom === chrom;
|
|
});
|
|
|
|
// Return angle at position.
|
|
return chrom_angle_data.endAngle -
|
|
(
|
|
(chrom_angle_data.endAngle - chrom_angle_data.startAngle) *
|
|
(chrom_angle_data.data.len - position) / chrom_angle_data.data.len
|
|
);
|
|
}
|
|
|
|
});
|
|
|
|
// circster app loader
|
|
var Circster = Backbone.View.extend(
|
|
{
|
|
initialize: function ()
|
|
{
|
|
// -- Configure visualization --
|
|
var genome = new visualization.Genome(galaxy_config.app.genome),
|
|
vis = new visualization.GenomeVisualization(galaxy_config.app.viz_config);
|
|
|
|
// Add Circster-specific config options.
|
|
vis.get('config').add([
|
|
{ key: 'arc_dataset_height', label: 'Arc Dataset Height', type: 'int', value: 25, view: 'circster' },
|
|
{ key: 'track_gap', label: 'Gap Between Tracks', type: 'int', value: 5, view: 'circster' },
|
|
{ key: 'total_gap', label: 'Gap [0-1]', type: 'float', value: 0.4, view: 'circster', hidden: true }
|
|
]);
|
|
|
|
var viz_view = new CircsterView(
|
|
{
|
|
// view pane
|
|
el : $('#center .unified-panel-body'),
|
|
genome : genome,
|
|
model : vis
|
|
});
|
|
|
|
// Render vizualization
|
|
viz_view.render();
|
|
|
|
// setup title
|
|
$('#center .unified-panel-header-inner').append(galaxy_config.app.viz_config.title + " " + galaxy_config.app.viz_config.dbkey);
|
|
|
|
// setup menu
|
|
var menu = mod_icon_btn.create_icon_buttons_menu([
|
|
{
|
|
icon_class: 'plus-button', title: 'Add tracks', on_click: function()
|
|
{
|
|
visualization.select_datasets(galaxy_config.root + "visualization/list_current_history_datasets", galaxy_config.root + "api/datasets", vis.get('dbkey'), function(tracks)
|
|
{
|
|
vis.add_tracks(tracks);
|
|
});
|
|
}
|
|
},
|
|
{
|
|
icon_class: 'gear', title: 'Settings', on_click: function()
|
|
{
|
|
var view = new config.ConfigSettingCollectionView({
|
|
collection: vis.get('config')
|
|
});
|
|
view.render_in_modal('Configure Visualization');
|
|
}
|
|
},
|
|
{
|
|
icon_class: 'disk--arrow', title: 'Save', on_click: function()
|
|
{
|
|
// show saving dialog box
|
|
Galaxy.modal.show({title: "Saving...", body: "progress" });
|
|
|
|
// send to server
|
|
$.ajax({
|
|
url: galaxy_config.root + "visualization/save",
|
|
type: "POST",
|
|
dataType: "json",
|
|
data: {
|
|
'id' : vis.get('vis_id'),
|
|
'title' : vis.get('title'),
|
|
'dbkey' : vis.get('dbkey'),
|
|
'type' : 'trackster',
|
|
'vis_json' : JSON.stringify(vis)
|
|
}
|
|
}).success(function(vis_info) {
|
|
Galaxy.modal.hide();
|
|
vis.set('vis_id', vis_info.vis_id);
|
|
}).error(function() {
|
|
// show dialog
|
|
Galaxy.modal.show({
|
|
title : "Could Not Save",
|
|
body : "Could not save visualization. Please try again later.",
|
|
buttons : { "Cancel": function() { Galaxy.modal.hide(); } }
|
|
});
|
|
});
|
|
}
|
|
},
|
|
{
|
|
icon_class: 'cross-circle', title: 'Close', on_click: function()
|
|
{
|
|
window.location = galaxy_config.root + "visualization/list";
|
|
}
|
|
}], { tooltip_config: { placement: 'bottom' } });
|
|
|
|
// add menu
|
|
menu.$el.attr("style", "float: right");
|
|
$("#center .unified-panel-header-inner").append(menu.$el);
|
|
|
|
// manual tooltip config because default gravity is S and cannot be changed
|
|
$(".menu-button").tooltip( { placement: 'bottom' } );
|
|
}
|
|
});
|
|
|
|
// Module exports.
|
|
return {
|
|
GalaxyApp: Circster
|
|
};
|
|
|
|
});
|