').addClass('tiles');
+ $('#right').append( tracks_div.append(this.track_collection_container) );
+
+ self.model.get('tracks').each(function(track) {
+ self.add_track(track);
+ });
+
+ // -- Render help and tool parameter tree in center panel. --
+
+ // Help includes text and a close button.
+ var help_div = $(this.helpText).addClass('help'),
+ close_button = create_icon_buttons_menu([
+ {
+ title: 'Close',
+ icon_class: 'cross-circle',
+ on_click: function() {
+ $('.tooltip').remove();
+ help_div.remove();
+ }
+ }
+ ]);
+
+ help_div.prepend(close_button.$el.css('float', 'right'));
+ $('#center').append(help_div);
+
+ // Parameter tree:
+ this.tool_param_tree_view.render();
+ $('#center').append(this.tool_param_tree_view.$el);
+
+ // Set up handler for tree node clicks.
+ this.handle_node_clicks();
+
+ // Set up visualization menu.
+ var menu = create_icon_buttons_menu(
+ [
+ // Save.
+ /*
+ { icon_class: 'disk--arrow', title: 'Save', on_click: function() {
+ // Show saving dialog box
+ show_modal("Saving...", "progress");
+
+ viz.save().success(function(vis_info) {
+ hide_modal();
+ viz.set({
+ 'id': vis_info.vis_id,
+ 'has_changes': false
+ });
+ })
+ .error(function() {
+ show_modal( "Could Not Save", "Could not save visualization. Please try again later.",
+ { "Close" : hide_modal } );
+ });
+ } },
+ */
+ // Change track modes.
+ {
+ icon_class: 'chevron-expand',
+ title: 'Set display mode'
+ },
+ // Close viz.
+ {
+ icon_class: 'cross-circle',
+ title: 'Close',
+ on_click: function() {
+ window.location = "${h.url_for( controller='visualization', action='list' )}";
+ }
+ }
+ ],
+ {
+ tooltip_config: {placement: 'bottom'}
+ });
+
+ // Create mode selection popup. Mode selection changes default mode and mode for all tracks.
+ var modes = ['Squish', 'Pack'],
+ mode_mapping = {};
+ _.each(modes, function(mode) {
+ mode_mapping[mode] = function() {
+ self.model.set('default_mode', mode);
+ self.model.get('tracks').each(function(track) {
+ track.set('mode', mode);
+ });
+ };
+ });
+
+ make_popupmenu(menu.$el.find('.chevron-expand'), mode_mapping);
+
+ menu.$el.attr("style", "float: right");
+ $("#right .unified-panel-header-inner").append(menu.$el);
+ },
+
+ get_base_color: function(base) {
+ return this.config.get_value(base.toLowerCase() + '_color') ||
+ this.config.get_value('n_color');
+ },
+
+ run_tool_on_dataset: function(settings) {
+ var tool = this.model.get('tool'),
+ tool_name = tool.get('name'),
+ dataset = this.model.get('dataset');
+ tool.set_input_values(settings.get('values'));
+ $.when(tool.rerun(dataset)).then(function(outputs) {
+ // TODO.
+ });
+
+ show_modal('Running ' + tool_name + ' on complete dataset',
+ tool_name + ' is running on dataset \'' +
+ dataset.get('name') + '\'. Outputs are in the dataset\'s history.',
+ {
+ 'Ok': function() { hide_modal(); }
+ });
+ },
+
+ /**
+ * Add track to model and view.
+ */
+ add_track: function(pm_track) {
+ var self = this,
+ param_tree = this.model.get('parameter_tree');
+
+ // Add track to model.
+ self.model.add_track(pm_track);
+
+ var track_view = new SweepsterTrackView({
+ model: pm_track,
+ canvas_manager: self.canvas_manager
+ });
+ track_view.on('run_on_dataset', self.run_tool_on_dataset, self);
+ self.track_collection_container.append(track_view.$el);
+ track_view.$el.hover(function() {
+ var settings_leaf = param_tree.get_leaf(pm_track.get('settings').get('values'));
+ var connected_node_ids = _.pluck(param_tree.get_connected_nodes(settings_leaf), 'id');
+
+ // TODO: can do faster with enter?
+ d3.select(self.tool_param_tree_view.$el[0]).selectAll("g.node")
+ .filter(function(d) {
+ return _.find(connected_node_ids, function(id) { return id === d.id; }) !== undefined;
+ }).style('fill', '#f00');
+ },
+ function() {
+ d3.select(self.tool_param_tree_view.$el[0]).selectAll("g.node").style('fill', '#000');
+ });
+ return pm_track;
+ },
+
+ /**
+ * Sets up handling when tree nodes are clicked. When a node is clicked, the tool is run for each of
+ * the settings defined by the node's subtree and tracks are added for each run.
+ */
+ handle_node_clicks: function() {
+ // When node clicked in tree, run tool and add tracks to model.
+ var self = this,
+ param_tree = this.model.get('parameter_tree'),
+ regions = this.model.get('regions'),
+ node = d3.select(this.tool_param_tree_view.$el[0]).selectAll("g.node");
+ node.on("click", function(d, i) {
+ // Get all settings corresponding to node.
+ var tool = self.model.get('tool'),
+ dataset = self.model.get('dataset'),
+ all_settings = param_tree.get_node_settings(d),
+ run_jobs_deferred = $.Deferred();
+
+ // Do not allow 10+ jobs to be run.
+ if (all_settings.length >= 10) {
+ show_modal("Whoa there cowboy!",
+ "You clicked on a node to try " + self.model.get('tool').get('name') +
+ " with " + all_settings.length +
+ " different combinations of settings. You can only run 10 jobs at a time.",
+ {
+ "Ok": function() { hide_modal(); run_jobs_deferred.resolve(false); }
+ });
+ }
+ else {
+ run_jobs_deferred.resolve(true);
+ }
+
+ // Take action when deferred resolves.
+ $.when(run_jobs_deferred).then(function(run_jobs) {
+ if (!run_jobs) { return; }
+
+ // Create and add tracks for each settings group.
+ var new_tracks = _.map(all_settings, function(settings) {
+ var pm_track = new SweepsterTrack({
+ settings: settings,
+ regions: regions,
+ mode: self.model.get('default_mode')
+ });
+ self.add_track(pm_track);
+ return pm_track;
+ });
+
+ // For each track, run tool using track's settings and update track.
+ _.each(new_tracks, function(pm_track, index) {
+ setTimeout(function() {
+ // Set inputs and run tool.
+ tool.set_input_values(pm_track.get('settings').get('values'));
+ $.when(tool.rerun(dataset, regions)).then(function(output) {
+ // HACKish: output is an HDA with track config attribute. To create a track
+ // that works correctly with Backbone relational, it is necessary to
+ // use a modified version of the track config.
+ var dataset = output.first(),
+ track_config = dataset.get('track_config');
+ // Set dataset to be the tool's output.
+ track_config.dataset = dataset;
+ // Set tool to null so that it is not unpacked; unpacking it messes with
+ // the tool parameters and parameter tree.
+ track_config.tool = null;
+
+ track_config.prefs = self.config.to_key_value_dict();
+
+ // Create and add track for output dataset.
+ var track_obj = tracks.object_from_template(track_config, self, null);
+ track_obj.init_for_tool_data();
+
+ pm_track.set('track', track_obj);
+ });
+ }, index * 10000);
+ });
+ });
+ });
+ }
+});
+
+return {
+ SweepsterVisualization: SweepsterVisualization,
+ SweepsterVisualizationView: SweepsterVisualizationView
+};
+
+});
\ No newline at end of file
diff --git a/client/scripts/viz/trackster.js b/client/scripts/viz/trackster.js
new file mode 100644
index 00000000000..5f237df6a31
--- /dev/null
+++ b/client/scripts/viz/trackster.js
@@ -0,0 +1,574 @@
+/**
+ * Top-level trackster code, used for creating/loading visualizations and user interface elements.
+ */
+
+// global variables
+var ui = null;
+var view = null;
+var browser_router = null;
+
+// load required libraries
+require(
+[
+ // load js libraries
+ 'utils/utils',
+ 'libs/jquery/jquery.event.drag',
+ 'libs/jquery/jquery.event.hover',
+ 'libs/jquery/jquery.mousewheel',
+ 'libs/jquery/jquery-ui',
+ 'libs/jquery/select2',
+ 'libs/farbtastic',
+ 'libs/jquery/jquery.form',
+ 'libs/jquery/jquery.rating',
+ 'mvc/ui'
+], function(mod_utils)
+{
+ // load css
+ mod_utils.cssLoadFile("static/style/jquery.rating.css");
+ mod_utils.cssLoadFile("static/style/autocomplete_tagging.css");
+ mod_utils.cssLoadFile("static/style/jquery-ui/smoothness/jquery-ui.css");
+ mod_utils.cssLoadFile("static/style/library.css");
+ mod_utils.cssLoadFile("static/style/trackster.css");
+});
+
+// trackster viewer
+define( ["libs/underscore", "base", "viz/trackster/tracks", "viz/visualization"],
+ function(_, base, tracks, visualization) {
+
+/**
+ * User interface controls for trackster
+ */
+var TracksterUI = base.Base.extend({
+ initialize: function( baseURL ) {
+ this.baseURL = baseURL;
+ },
+
+ /**
+ * Save visualization, returning a Deferred object for the remote call to save.
+ */
+ save_viz: function() {
+ // show dialog
+ Galaxy.modal.show({title: "Saving...", body: "progress" });
+
+ // Save bookmarks.
+ var bookmarks = [];
+ $(".bookmark").each(function() {
+ bookmarks.push({
+ position: $(this).children(".position").text(),
+ annotation: $(this).children(".annotation").text()
+ });
+ });
+
+ // FIXME: give unique IDs to Drawables and save overview as ID.
+ var overview_track_name = (view.overview_drawable ? view.overview_drawable.config.get_value('name') : null),
+ viz_config = {
+ 'view': view.to_dict(),
+ 'viewport': { 'chrom': view.chrom, 'start': view.low , 'end': view.high, 'overview': overview_track_name },
+ 'bookmarks': bookmarks
+ };
+
+ // Make call to save visualization.
+ return $.ajax({
+ url: galaxy_config.root + "visualization/save",
+ type: "POST",
+ dataType: "json",
+ data: {
+ 'id' : view.vis_id,
+ 'title' : view.config.get_value('name'),
+ 'dbkey' : view.dbkey,
+ 'type' : 'trackster',
+ 'vis_json' : JSON.stringify(viz_config)
+ }
+ }).success(function(vis_info) {
+ Galaxy.modal.hide();
+ view.vis_id = vis_info.vis_id;
+ view.has_changes = false;
+
+ // Needed to set URL when first saving a visualization.
+ window.history.pushState({}, "", vis_info.url + window.location.hash);
+ }).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(); } }
+ });
+ });
+ },
+
+ /**
+ * Create button menu
+ */
+ createButtonMenu: function() {
+ var self = this,
+ menu = 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", { 'f-dbkey': view.dbkey },
+ function(new_tracks) {
+ _.each(new_tracks, function(track) {
+ view.add_drawable( tracks.object_from_template(track, view, view) );
+ });
+ });
+ } },
+ { icon_class: 'block--plus', title: 'Add group', on_click: function() {
+ view.add_drawable( new tracks.DrawableGroup(view, view, { name: "New Group" }) );
+ } },
+ { icon_class: 'bookmarks', title: 'Bookmarks', on_click: function() {
+ // HACK -- use style to determine if panel is hidden and hide/show accordingly.
+ force_right_panel(($("div#right").css("right") == "0px" ? "hide" : "show"));
+ } },
+ {
+ icon_class: 'globe',
+ title: 'Circster',
+ on_click: function() {
+ window.location = self.baseURL + 'visualization/circster?id=' + view.vis_id;
+ }
+ },
+ { icon_class: 'disk--arrow', title: 'Save', on_click: function() {
+ self.save_viz();
+ } },
+ {
+ icon_class: 'cross-circle',
+ title: 'Close',
+ on_click: function() {
+ self.handle_unsaved_changes(view);
+ }
+ }
+ ],
+ {
+ tooltip_config: { placement: 'bottom' }
+ });
+ this.buttonMenu = menu;
+ return menu;
+ },
+
+ /**
+ * Use a popup to select a dataset of create bookmarks from
+ */
+ add_bookmarks: function() {
+ var self = this,
+ baseURL = this.baseURL;
+
+ // show modal while loading history
+ Galaxy.modal.show({title: "Select dataset for new bookmarks", body: "progress" });
+
+ $.ajax({
+ url: this.baseURL + "/visualization/list_histories",
+ data: { "f-dbkey": view.dbkey },
+ error: function() { alert( "Grid failed" ); },
+ success: function(table_html) {
+
+ // show modal to select bookmarks
+ Galaxy.modal.show(
+ {
+ title : "Select dataset for new bookmarks",
+ body : table_html,
+ buttons :
+ {
+ "Cancel": function()
+ {
+ Galaxy.modal.hide();
+ },
+
+ "Insert": function()
+ {
+ // Just use the first selected
+ $('input[name=id]:checked,input[name=ldda_ids]:checked').first().each(function()
+ {
+ var data, id = $(this).val();
+ if ($(this).attr("name") === "id")
+ data = { hda_id: id };
+ else
+ data = { ldda_id: id};
+
+ $.ajax({
+ url: this.baseURL + "/visualization/bookmarks_from_dataset",
+ data: data,
+ dataType: "json"
+ }).then( function(data) {
+ for( i = 0; i < data.data.length; i++ ) {
+ var row = data.data[i];
+ self.add_bookmark( row[0], row[1] );
+ }
+ });
+ });
+ Galaxy.modal.hide();
+ }
+ }
+ });
+ }
+ });
+ },
+
+ /**
+ * Add bookmark.
+ */
+ add_bookmark: function(position, annotation, editable) {
+ // Create HTML.
+ var bookmarks_container = $("#right .unified-panel-body"),
+ new_bookmark = $("
").addClass("bookmark").appendTo(bookmarks_container);
+
+ var position_div = $("
").addClass("position").appendTo(new_bookmark),
+ position_link = $("
").text(position).appendTo(position_div).click(function() {
+ view.go_to(position);
+ return false;
+ }),
+ annotation_div = $("
").text(annotation).appendTo(new_bookmark);
+
+ // If editable, enable bookmark deletion and annotation editing.
+ if (editable) {
+ var delete_icon_container = $("
").addClass("delete-icon-container").prependTo(new_bookmark).click(function (){
+ // Remove bookmark.
+ new_bookmark.slideUp("fast");
+ new_bookmark.remove();
+ view.has_changes = true;
+ return false;
+ }),
+ delete_icon = $("
").addClass("icon-button delete").appendTo(delete_icon_container);
+ annotation_div.make_text_editable({
+ num_rows: 3,
+ use_textarea: true,
+ help_text: "Edit bookmark note"
+ }).addClass("annotation");
+ }
+
+ view.has_changes = true;
+ return new_bookmark;
+ },
+
+ /**
+ * Create a complete Trackster visualization. Returns view.
+ */
+ create_visualization: function(view_config, viewport_config, drawables_config, bookmarks_config, editable) {
+
+ // Create view.
+ var self = this,
+ view = new tracks.TracksterView(_.extend(view_config, {header: false}));
+ view.editor = true;
+ $.when( view.load_chroms_deferred ).then(function(chrom_info) {
+ // Viewport config.
+ if (viewport_config) {
+ var chrom = viewport_config.chrom,
+ start = viewport_config.start,
+ end = viewport_config.end,
+ overview_drawable_name = viewport_config.overview;
+
+ if (chrom && (start !== undefined) && end) {
+ view.change_chrom(chrom, start, end);
+ }
+ else {
+ // No valid viewport, so use first chromosome.
+ view.change_chrom(chrom_info[0].chrom);
+ }
+ }
+ else {
+ // No viewport, so use first chromosome.
+ view.change_chrom(chrom_info[0].chrom);
+ }
+
+ // Add drawables to view.
+ if (drawables_config) {
+ // FIXME: can from_dict() be used to create view and add drawables?
+ var drawable_config,
+ drawable_type,
+ drawable;
+ for (var i = 0; i < drawables_config.length; i++) {
+ view.add_drawable( tracks.object_from_template( drawables_config[i], view, view ) );
+ }
+ }
+
+ // Set overview.
+ var overview_drawable;
+ for (var i = 0; i < view.drawables.length; i++) {
+ if (view.drawables[i].config.get_value('name') === overview_drawable_name) {
+ view.set_overview(view.drawables[i]);
+ break;
+ }
+ }
+
+ // Load bookmarks.
+ if (bookmarks_config) {
+ var bookmark;
+ for (var i = 0; i < bookmarks_config.length; i++) {
+ bookmark = bookmarks_config[i];
+ self.add_bookmark(bookmark['position'], bookmark['annotation'], editable);
+ }
+ }
+
+ // View has no changes as of yet.
+ view.has_changes = false;
+ });
+
+ // Final initialization.
+ this.set_up_router({view: view});
+
+ return view;
+ },
+
+ /**
+ * Set up location router to use hashes as track browser locations.
+ */
+ set_up_router : function(options)
+ {
+ new visualization.TrackBrowserRouter(options);
+ Backbone.history.start();
+ },
+
+ /**
+ * Set up keyboard navigation for a visualization.
+ */
+ init_keyboard_nav: function(view) {
+ // Keyboard navigation. Scroll ~7% of height when scrolling up/down.
+ $(document).keyup(function(e) {
+ // Do not navigate if arrow keys used in input element.
+ if ($(e.srcElement).is(':input')) {
+ return;
+ }
+
+ // Key codes: left == 37, up == 38, right == 39, down == 40
+ switch(e.which) {
+ case 37:
+ view.move_fraction(0.25);
+ break;
+ case 38:
+ var change = Math.round(view.viewport_container.height()/15.0);
+ view.viewport_container.scrollTop( view.viewport_container.scrollTop() - 20);
+ break;
+ case 39:
+ view.move_fraction(-0.25);
+ break;
+ case 40:
+ var change = Math.round(view.viewport_container.height()/15.0);
+ view.viewport_container.scrollTop( view.viewport_container.scrollTop() + 20);
+ break;
+ }
+ });
+ },
+
+ /**
+ * Handle unsaved changes in visualization.
+ */
+ handle_unsaved_changes: function(view) {
+ if (view.has_changes) {
+ var self = this;
+ Galaxy.modal.show({
+ title: "Close visualization",
+ body: "There are unsaved changes to your visualization which will be lost if you do not save them.",
+ buttons: {
+ "Cancel": function() { Galaxy.modal.hide(); },
+ "Leave without Saving" : function() {
+ $(window).off('beforeunload');
+ window.location = galaxy_config.root + 'visualization';
+ },
+ "Save" : function() {
+ $.when(self.save_viz()).then(function() {
+ window.location = galaxy_config.root + 'visualization';
+ });
+ }
+ }
+ });
+
+ }
+ else {
+ window.location = galaxy_config.root + 'visualization';
+ }
+ }
+
+});
+
+var TracksterView = base.Backbone.View.extend(
+{
+ // initalize trackster
+ initialize : function ()
+ {
+ // load ui
+ ui = new TracksterUI(galaxy_config.root);
+
+ // create button menu
+ ui.createButtonMenu();
+
+ // attach the button menu to the panel header and float it left
+ ui.buttonMenu.$el.attr("style", "float: right");
+
+ // add to center panel
+ $("#center .unified-panel-header-inner").append(ui.buttonMenu.$el);
+
+ // configure right panel
+ $("#right .unified-panel-title").append("Bookmarks");
+ $("#right .unified-panel-icons").append("");
+
+ // resize view when showing/hiding right panel (bookmarks for now).
+ $("#right-border").click(function() { view.resize_window(); });
+
+ // hide right panel
+ force_right_panel("hide");
+
+ // check if id is available
+ if (galaxy_config.app.id)
+ this.view_existing();
+ else
+ this.view_new();
+ },
+
+ // view
+ view_existing : function ()
+ {
+ // get config
+ var viz_config = galaxy_config.app.viz_config;
+
+ // view
+ view = ui.create_visualization(
+ {
+ container: $("#center .unified-panel-body"),
+ name: viz_config.title,
+ vis_id: viz_config.vis_id,
+ dbkey: viz_config.dbkey
+ }, viz_config.viewport, viz_config.tracks, viz_config.bookmarks, true);
+
+ // initialize editor
+ this.init_editor();
+ },
+
+ // view
+ view_new : function ()
+ {
+ // availability of default database key
+ /*if (galaxy_config.app.default_dbkey !== undefined)
+ {
+ this.create_browser("Unnamed", galaxy_config.app.default_dbkey);
+ return;
+ }*/
+
+ // reference this
+ var self = this;
+
+ // ajax
+ $.ajax(
+ {
+ url: galaxy_config.root + "api/genomes?chrom_info=True",
+ data: {},
+ error: function() { alert( "Couldn't create new browser." ); },
+ success: function(response)
+ {
+ // show dialog
+ Galaxy.modal.show({
+ title : "New Visualization",
+ body : self.template_view_new(response),
+ buttons : {
+ "Cancel": function() { window.location = galaxy_config.root + "visualization/list"; },
+ "Create": function() { self.create_browser($("#new-title").val(), $("#new-dbkey").val()); Galaxy.modal.hide(); }
+ }
+ });
+
+ // select default
+ if (galaxy_config.app.default_dbkey) {
+ $("#new-dbkey").val( galaxy_config.app.default_dbkey );
+ }
+
+ // change focus
+ $("#new-title").focus();
+ $("select[name='dbkey']").select2();
+
+ // to support the large number of options for dbkey, enable scrolling in overlay.
+ $("#overlay").css("overflow", "auto");
+ }
+ });
+ },
+
+ // new browser form
+ template_view_new: function(response)
+ {
+ // start template
+ var html = '
';
+
+ // return
+ return html;
+ },
+
+ // create
+ create_browser : function(name, dbkey)
+ {
+ $(document).trigger("convert_to_values");
+
+ view = ui.create_visualization (
+ {
+ container: $("#center .unified-panel-body"),
+ name: name,
+ dbkey: dbkey
+ }, galaxy_config.app.gene_region);
+
+ // initialize editor
+ this.init_editor();
+
+ // modify view setting
+ view.editor = true;
+ },
+
+ // initialization for editor-specific functions.
+ init_editor : function ()
+ {
+ // set title
+ $("#center .unified-panel-title").text(view.config.get_value('name') + " (" + view.dbkey + ")");
+
+ // add dataset
+ if (galaxy_config.app.add_dataset)
+ $.ajax({
+ url: galaxy_config.root + "api/datasets/" + galaxy_config.app.add_dataset,
+ data: { hda_ldda: 'hda', data_type: 'track_config' },
+ dataType: "json",
+ success: function(track_data) { view.add_drawable( tracks.object_from_template(track_data, view, view) ); }
+ });
+
+ // initialize icons
+ $("#add-bookmark-button").click(function()
+ {
+ // add new bookmark.
+ var position = view.chrom + ":" + view.low + "-" + view.high,
+ annotation = "Bookmark description";
+ return ui.add_bookmark(position, annotation, true);
+ });
+
+ // initialize keyboard
+ ui.init_keyboard_nav(view);
+
+ $(window).on('beforeunload', function() {
+ if (view.has_changes) {
+ return "There are unsaved changes to your visualization that will be lost if you leave this page.";
+ }
+ });
+ }
+});
+
+return {
+ TracksterUI: TracksterUI,
+ GalaxyApp : TracksterView
+};
+
+});
\ No newline at end of file
diff --git a/client/scripts/viz/trackster/filters.js b/client/scripts/viz/trackster/filters.js
new file mode 100644
index 00000000000..9c619de4af0
--- /dev/null
+++ b/client/scripts/viz/trackster/filters.js
@@ -0,0 +1,628 @@
+define( ["libs/underscore"], function(_) {
+
+var extend = _.extend;
+
+/**
+ * Filters that enable users to show/hide data points dynamically.
+ */
+var Filter = function(obj_dict) {
+ this.manager = null;
+ this.name = obj_dict.name;
+ // Index into payload to filter.
+ this.index = obj_dict.index;
+ this.tool_id = obj_dict.tool_id;
+ // Name to use for filter when building expression for tool.
+ this.tool_exp_name = obj_dict.tool_exp_name;
+};
+
+extend(Filter.prototype, {
+ /**
+ * Convert filter to dictionary.
+ */
+ to_dict: function() {
+ return {
+ name: this.name,
+ index: this.index,
+ tool_id: this.tool_id,
+ tool_exp_name: this.tool_exp_name
+ };
+ }
+});
+
+/**
+ * Creates an action icon.
+ */
+var create_action_icon = function(title, css_class, on_click_fn) {
+ return $("
").attr("href", "javascript:void(0);").attr("title", title)
+ .addClass("icon-button").addClass(css_class).tooltip()
+ .click(on_click_fn);
+};
+
+/**
+ * Number filters have a min, max as well as a low, high; low and high are used
+ */
+var NumberFilter = function(obj_dict) {
+ //
+ // Attribute init.
+ //
+ Filter.call(this, obj_dict);
+ // Filter low/high. These values are used to filter elements.
+ this.low = ('low' in obj_dict ? obj_dict.low : -Number.MAX_VALUE);
+ this.high = ('high' in obj_dict ? obj_dict.high : Number.MAX_VALUE);
+ // Slide min/max. These values are used to set/update slider.
+ this.min = ('min' in obj_dict ? obj_dict.min : Number.MAX_VALUE);
+ this.max = ('max' in obj_dict ? obj_dict.max : -Number.MAX_VALUE);
+ // UI elements associated with filter.
+ this.container = null;
+ this.slider = null;
+ this.slider_label = null;
+
+ //
+ // Create HTML.
+ //
+
+ // Function that supports inline text editing of slider values.
+ // Enable users to edit parameter's value via a text box.
+ var edit_slider_values = function(container, span, slider) {
+ container.click(function() {
+ var cur_value = span.text(),
+ max = parseFloat(slider.slider("option", "max")),
+ input_size = (max <= 1 ? 4 : max <= 1000000 ? max.toString().length : 6),
+ multi_value = false,
+ slider_row = $(this).parents(".slider-row");
+
+ // Row now has input.
+ slider_row.addClass("input");
+
+ // Increase input size if there are two values.
+ if (slider.slider("option", "values")) {
+ input_size = 2*input_size + 1;
+ multi_value = true;
+ }
+ span.text("");
+ // Temporary input for changing value.
+ $("
").attr("size", input_size).attr("maxlength", input_size)
+ .attr("value", cur_value).appendTo(span).focus().select()
+ .click(function(e) {
+ // Don't want click to propogate up to values_span and restart everything.
+ e.stopPropagation();
+ }).blur(function() {
+ $(this).remove();
+ span.text(cur_value);
+ slider_row.removeClass("input");
+ }).keyup(function(e) {
+ if (e.keyCode === 27) {
+ // Escape key.
+ $(this).trigger("blur");
+ } else if (e.keyCode === 13) {
+ //
+ // Enter/return key initiates callback. If new value(s) are in slider range,
+ // change value (which calls slider's change() function).
+ //
+ var slider_min = slider.slider("option", "min"),
+ slider_max = slider.slider("option", "max"),
+ invalid = function(a_val) {
+ return (isNaN(a_val) || a_val > slider_max || a_val < slider_min);
+ },
+ new_value = $(this).val();
+ if (!multi_value) {
+ new_value = parseFloat(new_value);
+ if (invalid(new_value)) {
+ alert("Parameter value must be in the range [" + slider_min + "-" + slider_max + "]");
+ return $(this);
+ }
+ }
+ else { // Multi value.
+ new_value = new_value.split("-");
+ new_value = [parseFloat(new_value[0]), parseFloat(new_value[1])];
+ if (invalid(new_value[0]) || invalid(new_value[1])) {
+ alert("Parameter value must be in the range [" + slider_min + "-" + slider_max + "]");
+ return $(this);
+ }
+ }
+
+ // Updating the slider also updates slider values and removes input.
+ slider.slider((multi_value ? "values" : "value"), new_value);
+ slider_row.removeClass("input");
+ }
+ });
+ });
+ };
+
+ var filter = this;
+
+ filter.parent_div = $("
").addClass("filter-row slider-row");
+
+ // Set up filter label (name, values).
+ var filter_label = $("
").addClass("elt-label").appendTo(filter.parent_div),
+ name_span = $("
").addClass("slider-name").text(filter.name + " ").appendTo(filter_label),
+ values_span = $("
").text(this.low + "-" + this.high),
+ values_span_container = $("
").addClass("slider-value").appendTo(filter_label).append("[").append(values_span).append("]");
+ filter.values_span = values_span;
+
+ // Set up slider for filter.
+ var slider_div = $("
").addClass("slider").appendTo(filter.parent_div);
+ filter.control_element = $("
").attr("id", filter.name + "-filter-control").appendTo(slider_div);
+ filter.control_element.slider({
+ range: true,
+ min: this.min,
+ max: this.max,
+ step: this.get_slider_step(this.min, this.max),
+ values: [this.low, this.high],
+ slide: function(event, ui) {
+ filter.slide(event, ui);
+ },
+ change: function(event, ui) {
+ filter.control_element.slider("option", "slide").call(filter.control_element, event, ui);
+ }
+ });
+ filter.slider = filter.control_element;
+ filter.slider_label = values_span;
+
+ // Enable users to edit slider values via text box.
+ edit_slider_values(values_span_container, values_span, filter.control_element);
+
+ // Set up filter display controls.
+ var display_controls_div = $("
").addClass("display-controls").appendTo(filter.parent_div);
+ this.transparency_icon = create_action_icon("Use filter for data transparency", "layer-transparent",
+ function() {
+ if (filter.manager.alpha_filter !== filter) {
+ // Setting this filter as the alpha filter.
+ filter.manager.alpha_filter = filter;
+ // Update UI for new filter.
+ filter.manager.parent_div.find(".layer-transparent").removeClass("active").hide();
+ filter.transparency_icon.addClass("active").show();
+ }
+ else {
+ // Clearing filter as alpha filter.
+ filter.manager.alpha_filter = null;
+ filter.transparency_icon.removeClass("active");
+ }
+ filter.manager.track.request_draw({ force: true, clear_after: true });
+ } )
+ .appendTo(display_controls_div).hide();
+ this.height_icon = create_action_icon("Use filter for data height", "arrow-resize-090",
+ function() {
+ if (filter.manager.height_filter !== filter) {
+ // Setting this filter as the height filter.
+ filter.manager.height_filter = filter;
+ // Update UI for new filter.
+ filter.manager.parent_div.find(".arrow-resize-090").removeClass("active").hide();
+ filter.height_icon.addClass("active").show();
+ }
+ else {
+ // Clearing filter as alpha filter.
+ filter.manager.height_filter = null;
+ filter.height_icon.removeClass("active");
+ }
+ filter.manager.track.request_draw({ force: true, clear_after: true });
+ } )
+ .appendTo(display_controls_div).hide();
+ filter.parent_div.hover( function() {
+ filter.transparency_icon.show();
+ filter.height_icon.show();
+ },
+ function() {
+ if (filter.manager.alpha_filter !== filter) {
+ filter.transparency_icon.hide();
+ }
+ if (filter.manager.height_filter !== filter) {
+ filter.height_icon.hide();
+ }
+ } );
+
+ // Add to clear floating layout.
+ $("
").appendTo(filter.parent_div);
+};
+extend(NumberFilter.prototype, {
+ /**
+ * Convert filter to dictionary.
+ */
+ to_dict: function() {
+ var obj_dict = Filter.prototype.to_dict.call(this);
+ return extend(obj_dict, {
+ type: 'number',
+ min: this.min,
+ max: this.max,
+ low: this.low,
+ high: this.high
+ });
+ },
+ /**
+ * Return a copy of filter.
+ */
+ copy: function() {
+ return new NumberFilter(
+ {
+ name: this.name,
+ index: this.index,
+ tool_id: this.tool_id,
+ tool_exp_name: this.tool_exp_name
+ });
+ },
+ /**
+ * Get step for slider.
+ */
+ // FIXME: make this a "static" function.
+ get_slider_step: function(min, max) {
+ var range = max - min;
+ return (range <= 2 ? 0.01 : 1);
+ },
+ /**
+ * Handle slide events.
+ */
+ slide: function(event, ui) {
+ var values = ui.values;
+
+ // Set new values in UI.
+ this.values_span.text(values[0] + "-" + values[1]);
+
+ // Set new values in filter.
+ this.low = values[0];
+ this.high = values[1];
+
+ // Set timeout to update if filter low, high are stable.
+ var self = this;
+ setTimeout(function() {
+ if (values[0] === self.low && values[1] === self.high) {
+ self.manager.track.request_draw({ force: true, clear_after: true });
+ }
+ }, 25);
+
+ },
+ /**
+ * Returns true if filter can be applied to element.
+ */
+ applies_to: function(element) {
+ if (element.length > this.index) {
+ return true;
+ }
+ return false;
+ },
+ /**
+ * Helper function: returns true if value in in filter's [low, high] range.
+ */
+ _keep_val: function(val) {
+ return (isNaN(val) || (val >= this.low && val <= this.high));
+ },
+ /**
+ * Returns true if (a) element's value(s) is in [low, high] (range is inclusive)
+ * or (b) if value is non-numeric and hence unfilterable.
+ */
+ keep: function(element) {
+ if ( !this.applies_to( element ) ) {
+ // No element to filter on.
+ return true;
+ }
+
+ // Keep value function.
+ var filter = this;
+
+ // Do filtering.
+ var to_filter = element[this.index];
+ if (to_filter instanceof Array) {
+ var returnVal = true;
+ for (var i = 0; i < to_filter.length; i++) {
+ if (!this._keep_val(to_filter[i])) {
+ // Exclude element.
+ returnVal = false;
+ break;
+ }
+ }
+ return returnVal;
+ }
+ else {
+ return this._keep_val(element[this.index]);
+ }
+ },
+ /**
+ * Update filter's min and max values based on element's values.
+ */
+ update_attrs: function(element) {
+ var updated = false;
+ if (!this.applies_to(element) ) {
+ return updated;
+ }
+
+ //
+ // Update filter's min, max based on element values.
+ //
+
+ // Make value(s) into an Array.
+ var values = element[this.index];
+ if (!(values instanceof Array)) {
+ values = [values];
+ }
+
+ // Loop through values and update min, max.
+ for (var i = 0; i < values.length; i++) {
+ var value = values[i];
+ if (value < this.min) {
+ this.min = Math.floor(value);
+ updated = true;
+ }
+ if (value > this.max) {
+ this.max = Math.ceil(value);
+ updated = true;
+ }
+ }
+ return updated;
+ },
+ /**
+ * Update filter's slider.
+ */
+ update_ui_elt: function () {
+ // Only show filter if min < max because filter is not useful otherwise. This
+ // covers all corner cases, such as when min, max have not been defined and
+ // when min == max.
+ if (this.min < this.max) {
+ this.parent_div.show();
+ }
+ else {
+ this.parent_div.hide();
+ }
+
+ var
+ slider_min = this.slider.slider("option", "min"),
+ slider_max = this.slider.slider("option", "max");
+ if (this.min < slider_min || this.max > slider_max) {
+ // Update slider min, max, step.
+ this.slider.slider("option", "min", this.min);
+ this.slider.slider("option", "max", this.max);
+ this.slider.slider("option", "step", this.get_slider_step(this.min, this.max));
+ // Refresh slider:
+ // TODO: do we want to keep current values or reset to min/max?
+ // Currently we reset values:
+ this.slider.slider("option", "values", [this.min, this.max]);
+ // To use the current values.
+ //var values = this.slider.slider( "option", "values" );
+ //this.slider.slider( "option", "values", values );
+ }
+ }
+});
+
+/**
+ * Manages a set of filters.
+ */
+var FiltersManager = function(track, obj_dict) {
+ this.track = track;
+ this.alpha_filter = null;
+ this.height_filter = null;
+ this.filters = [];
+
+ //
+ // Create HTML.
+ //
+
+ //
+ // Create parent div.
+ //
+ this.parent_div = $("
").addClass("filters").hide();
+ // Disable dragging, double clicking, keys on div so that actions on slider do not impact viz.
+ this.parent_div.bind("drag", function(e) {
+ e.stopPropagation();
+ }).click(function(e) {
+ e.stopPropagation();
+ }).bind("dblclick", function(e) {
+ e.stopPropagation();
+ }).bind("keydown", function(e) {
+ e.stopPropagation();
+ });
+
+ //
+ // Restore state from dict.
+ //
+ if (obj_dict && 'filters' in obj_dict) { // Second condition needed for backward compatibility.
+ var
+ alpha_filter_name = ('alpha_filter' in obj_dict ? obj_dict.alpha_filter : null),
+ height_filter_name = ('height_filter' in obj_dict ? obj_dict.height_filter : null),
+ filters_dict = obj_dict.filters,
+ filter;
+ for (var i = 0; i < filters_dict.length; i++) {
+ if (filters_dict[i].type === 'number') {
+ filter = new NumberFilter(filters_dict[i]);
+ this.add_filter(filter);
+ if (filter.name === alpha_filter_name) {
+ this.alpha_filter = filter;
+ filter.transparency_icon.addClass("active").show();
+ }
+ if (filter.name === height_filter_name) {
+ this.height_filter = filter;
+ filter.height_icon.addClass("active").show();
+ }
+ }
+ else {
+ console.log("ERROR: unsupported filter: ", name, type);
+ }
+ }
+
+
+ if ('visible' in obj_dict && obj_dict.visible) {
+ this.parent_div.show();
+ }
+ }
+
+ // Add button to filter complete dataset.
+ if (this.filters.length !== 0) {
+ var run_buttons_row = $("
").addClass("param-row").appendTo(this.parent_div);
+ var run_on_dataset_button = $("
").attr("value", "Run on complete dataset").appendTo(run_buttons_row);
+ var filter_manager = this;
+ run_on_dataset_button.click( function() {
+ filter_manager.run_on_dataset();
+ });
+ }
+
+};
+
+extend(FiltersManager.prototype, {
+ // HTML manipulation and inspection.
+ show: function() { this.parent_div.show(); },
+ hide: function() { this.parent_div.hide(); },
+ toggle: function() { this.parent_div.toggle(); },
+ visible: function() { return this.parent_div.is(":visible"); },
+ /**
+ * Returns dictionary for manager.
+ */
+ to_dict: function() {
+ var obj_dict = {},
+ filter_dicts = [],
+ filter;
+
+ // Include individual filter states.
+ for (var i = 0; i < this.filters.length; i++) {
+ filter = this.filters[i];
+ filter_dicts.push(filter.to_dict());
+ }
+ obj_dict.filters = filter_dicts;
+
+ // Include transparency, height filters.
+ obj_dict.alpha_filter = (this.alpha_filter ? this.alpha_filter.name : null);
+ obj_dict.height_filter = (this.height_filter ? this.height_filter.name : null);
+
+ // Include visibility.
+ obj_dict.visible = this.parent_div.is(":visible");
+
+ return obj_dict;
+ },
+ /**
+ * Return a copy of the manager.
+ */
+ copy: function(new_track) {
+ var copy = new FiltersManager(new_track);
+ for (var i = 0; i < this.filters.length; i++) {
+ copy.add_filter(this.filters[i].copy());
+ }
+ return copy;
+ },
+ /**
+ * Add a filter to the manager.
+ */
+ add_filter: function(filter) {
+ filter.manager = this;
+ this.parent_div.append(filter.parent_div);
+ this.filters.push(filter);
+ },
+ /**
+ * Remove all filters from manager.
+ */
+ remove_all: function() {
+ this.filters = [];
+ this.parent_div.children().remove();
+ },
+ /**
+ * Initialize filters.
+ */
+ init_filters: function() {
+ for (var i = 0; i < this.filters.length; i++) {
+ var filter = this.filters[i];
+ filter.update_ui_elt();
+ }
+ },
+ /**
+ * Clear filters so that they do not impact track display.
+ */
+ clear_filters: function() {
+ for (var i = 0; i < this.filters.length; i++) {
+ var filter = this.filters[i];
+ filter.slider.slider("option", "values", [filter.min, filter.max]);
+ }
+ this.alpha_filter = null;
+ this.height_filter = null;
+
+ // Hide icons for setting filters.
+ this.parent_div.find(".icon-button").hide();
+ },
+ run_on_dataset: function() {
+ // Get or create dictionary item.
+ var get_or_create_dict_item = function(dict, key, new_item) {
+ // Add new item to dict if
+ if (!(key in dict)) {
+ dict[key] = new_item;
+ }
+ return dict[key];
+ };
+
+ //
+ // Find and group active filters. Active filters are those being used to hide data.
+ // Filters with the same tool id are grouped.
+ //
+ var active_filters = {},
+ filter,
+ tool_filter_conditions;
+ for (var i = 0; i < this.filters.length; i++) {
+ filter = this.filters[i];
+ if (filter.tool_id) {
+ // Add filtering conditions if filter low/high are set.
+ if (filter.min !== filter.low) {
+ tool_filter_conditions = get_or_create_dict_item(active_filters, filter.tool_id, []);
+ tool_filter_conditions[tool_filter_conditions.length] = filter.tool_exp_name + " >= " + filter.low;
+ }
+ if (filter.max !== filter.high) {
+ tool_filter_conditions = get_or_create_dict_item(active_filters, filter.tool_id, []);
+ tool_filter_conditions[tool_filter_conditions.length] = filter.tool_exp_name + " <= " + filter.high;
+ }
+ }
+ }
+
+ //
+ // Use tools to run filters.
+ //
+
+ // Create list of (tool_id, tool_filters) tuples.
+ var active_filters_list = [];
+ for (var tool_id in active_filters) {
+ active_filters_list[active_filters_list.length] = [tool_id, active_filters[tool_id]];
+ }
+
+ // Invoke recursive function to run filters; this enables chaining of filters via
+ // iteratively application.
+ (function run_filter(input_dataset_id, filters) {
+ var
+ // Set up filtering info and params.
+ filter_tuple = filters[0],
+ tool_id = filter_tuple[0],
+ tool_filters = filter_tuple[1],
+ tool_filter_str = "(" + tool_filters.join(") and (") + ")",
+ url_params = {
+ cond: tool_filter_str,
+ input: input_dataset_id,
+ target_dataset_id: input_dataset_id,
+ tool_id: tool_id
+ };
+
+ // Remove current filter.
+ filters = filters.slice(1);
+
+ $.getJSON(run_tool_url, url_params, function(response) {
+ if (response.error) {
+ // General error.
+ Galaxy.modal.show({
+ title: "Filter Dataset",
+ body : "Error running tool " + tool_id,
+ buttons : { "Close" : Galaxy.modal.hide() }
+ });
+ }
+ else if (filters.length === 0) {
+ // No more filters to run.
+ Galaxy.modal.show({
+ title: "Filtering Dataset",
+ body: "Filter(s) are running on the complete dataset. Outputs are in dataset's history.",
+ buttons: { "Close" : Galaxy.modal.hide() }
+ });
+ }
+ else {
+ // More filters to run.
+ run_filter(response.dataset_id, filters);
+ }
+ });
+
+ })(this.track.dataset_id, active_filters_list);
+ }
+});
+
+return {
+ FiltersManager: FiltersManager,
+ NumberFilter: NumberFilter
+};
+
+});
diff --git a/client/scripts/viz/trackster/painters.js b/client/scripts/viz/trackster/painters.js
new file mode 100644
index 00000000000..28a02aea56a
--- /dev/null
+++ b/client/scripts/viz/trackster/painters.js
@@ -0,0 +1,1599 @@
+define( ["libs/underscore"], function( _ ) {
+
+/**
+ * Compute the type of overlap between two regions. They are assumed to be on the same chrom/contig.
+ * The overlap is computed relative to the second region; hence, OVERLAP_START indicates that the first
+ * region overlaps the start (but not the end) of the second region.
+ * NOTE: Coordinates are assumed to be in BED format: half open (start is closed, end is open).
+ */
+var BEFORE = 1001, CONTAINS = 1002, OVERLAP_START = 1003, OVERLAP_END = 1004, CONTAINED_BY = 1005, AFTER = 1006;
+var compute_overlap = function(first_region, second_region) {
+ var
+ first_start = first_region[0], first_end = first_region[1],
+ second_start = second_region[0], second_end = second_region[1],
+ overlap;
+ if (first_start < second_start) {
+ if (first_end <= second_start) {
+ overlap = BEFORE;
+ }
+ else if (first_end <= second_end) {
+ overlap = OVERLAP_START;
+ }
+ else { // first_end > second_end
+ overlap = CONTAINS;
+ }
+ }
+ else { // first_start >= second_start
+ if (first_start > second_end) {
+ overlap = AFTER;
+ }
+ else if (first_end <= second_end) {
+ overlap = CONTAINED_BY;
+ }
+ else {
+ overlap = OVERLAP_END;
+ }
+ }
+
+ return overlap;
+};
+
+/**
+ * Returns true if regions overlap.
+ */
+var is_overlap = function(first_region, second_region) {
+ var overlap = compute_overlap(first_region, second_region);
+ return (overlap !== BEFORE && overlap !== AFTER);
+};
+
+/**
+ * Draw a dashed line on a canvas using filled rectangles. This function is based on:
+ * http://vetruvet.blogspot.com/2010/10/drawing-dashed-lines-on-html5-canvas.html
+ * However, that approach uses lines, which don't seem to render as well, so use
+ * rectangles instead.
+ */
+var dashedLine = function(ctx, x1, y1, x2, y2, dashLen) {
+ if (dashLen === undefined) { dashLen = 4; }
+ var dX = x2 - x1;
+ var dY = y2 - y1;
+ var dashes = Math.floor(Math.sqrt(dX * dX + dY * dY) / dashLen);
+ var dashX = dX / dashes;
+ var dashY = dY / dashes;
+ var q;
+
+ for (q = 0; q < dashes; q++, x1 += dashX, y1 += dashY) {
+ if (q % 2 !== 0) {
+ continue;
+ }
+ ctx.fillRect(x1, y1, dashLen, 1);
+ }
+};
+
+/**
+ * Draw an isosceles triangle that points down.
+ */
+var drawDownwardEquilateralTriangle = function(ctx, down_vertex_x, down_vertex_y, side_len) {
+ // Compute other two points of triangle.
+ var
+ x1 = down_vertex_x - side_len/2,
+ x2 = down_vertex_x + side_len/2,
+ y = down_vertex_y - Math.sqrt( side_len*3/2 );
+
+ // Draw and fill.
+ ctx.beginPath();
+ ctx.moveTo(x1, y);
+ ctx.lineTo(x2, y);
+ ctx.lineTo(down_vertex_x, down_vertex_y);
+ ctx.lineTo(x1, y);
+
+ ctx.strokeStyle = this.fillStyle;
+ ctx.fill();
+ ctx.stroke();
+ ctx.closePath();
+};
+
+/**
+ * Base class for all scalers. Scalers produce values that are used to change (scale) drawing attributes.
+ */
+var Scaler = function(default_val) {
+ this.default_val = (default_val ? default_val : 1);
+};
+
+/**
+ * Produce a scaling value.
+ */
+Scaler.prototype.gen_val = function(input) {
+ return this.default_val;
+};
+
+/**
+ * Results from painter.draw()
+ */
+var DrawResults = function(options) {
+ this.incomplete_features = options.incomplete_features;
+ this.feature_mapper = options.feature_mapper;
+};
+
+/**
+ * Base class for painters
+ *
+ * -- Mode and prefs are both optional
+ */
+var Painter = function(data, view_start, view_end, prefs, mode) {
+ // Data and data properties
+ this.data = data;
+ // View
+ this.view_start = view_start;
+ this.view_end = view_end;
+ // Drawing prefs
+ this.prefs = _.extend({}, this.default_prefs, prefs);
+ this.mode = mode;
+};
+
+Painter.prototype.default_prefs = {};
+
+/**
+ * Draw on the context using a rectangle of width x height using scale w_scale.
+ */
+Painter.prototype.draw = function(ctx, width, height, w_scale) {};
+
+var LinePainter = function(data, view_start, view_end, prefs, mode) {
+ Painter.call( this, data, view_start, view_end, prefs, mode );
+ if ( this.prefs.min_value === undefined ) {
+ this.prefs.min_value = _.min( _.map(this.data, function(d) { return d[1]; }) ) || 0;
+ }
+ if ( this.prefs.max_value === undefined ) {
+ this.prefs.max_value = _.max( _.map(this.data, function(d) { return d[1]; }) ) || 0;
+ }
+};
+
+LinePainter.prototype.default_prefs = { min_value: undefined, max_value: undefined, mode: "Histogram", color: "#000", overflow_color: "#F66" };
+
+LinePainter.prototype.draw = function(ctx, width, height, w_scale) {
+ var in_path = false,
+ min_value = this.prefs.min_value,
+ max_value = this.prefs.max_value,
+ vertical_range = max_value - min_value,
+ height_px = height,
+ view_start = this.view_start,
+ mode = this.mode,
+ data = this.data;
+
+ ctx.save();
+
+ // Pixel position of 0 on the y axis
+ var y_zero = Math.round( height + min_value / vertical_range * height );
+
+ // Horizontal line to denote x-axis
+ if ( mode !== "Intensity" ) {
+ ctx.fillStyle = "#aaa";
+ ctx.fillRect( 0, y_zero, width, 1 );
+ }
+
+ ctx.beginPath();
+ var x_scaled, y, delta_x_px;
+ if (data.length > 1) {
+ delta_x_px = Math.ceil((data[1][0] - data[0][0]) * w_scale);
+ }
+ else {
+ delta_x_px = 10;
+ }
+
+ // Painter color can be in either block_color (FeatureTrack) or color pref (LineTrack).
+ var painter_color = this.prefs.block_color || this.prefs.color,
+ // Extract RGB from preference color.
+ pref_color = parseInt( painter_color.slice(1), 16 ),
+ pref_r = (pref_color & 0xff0000) >> 16,
+ pref_g = (pref_color & 0x00ff00) >> 8,
+ pref_b = pref_color & 0x0000ff,
+ top_overflow = false,
+ bot_overflow = false;
+
+
+ // Paint track.
+ for (var i = 0, len = data.length; i < len; i++) {
+ // Reset attributes for next point.
+ ctx.fillStyle = ctx.strokeStyle = painter_color;
+ top_overflow = bot_overflow = false;
+
+ x_scaled = Math.ceil((data[i][0] - view_start) * w_scale);
+ y = data[i][1];
+
+ // Process Y (scaler) value.
+ if (y === null) {
+ if (in_path && mode === "Filled") {
+ ctx.lineTo(x_scaled, height_px);
+ }
+ in_path = false;
+ continue;
+ }
+
+ // Bound Y value by min, max.
+ if (y < min_value) {
+ bot_overflow = true;
+ y = min_value;
+ }
+ else if (y > max_value) {
+ top_overflow = true;
+ y = max_value;
+ }
+
+ // Draw point.
+ if (mode === "Histogram") {
+ // y becomes the bar height in pixels, which is the negated for canvas coords
+ y = Math.round( y / vertical_range * height_px );
+ ctx.fillRect(x_scaled, y_zero, delta_x_px, - y );
+ }
+ else if (mode === "Intensity") {
+ var
+ saturation = (y - min_value) / vertical_range,
+ // Range is [pref_color, 255] where saturation = 0 --> 255 and saturation = 1 --> pref color
+ new_r = Math.round( pref_r + (255 - pref_r) * (1 - saturation) ),
+ new_g = Math.round( pref_g + (255 - pref_g) * (1 - saturation) ),
+ new_b = Math.round( pref_b + (255 - pref_b) * (1 - saturation) );
+ ctx.fillStyle = "rgb(" + new_r + "," + new_g + "," + new_b + ")";
+ ctx.fillRect(x_scaled, 0, delta_x_px, height_px);
+ }
+ else { // mode is Coverage/Line or Filled.
+
+ // Scale Y value.
+ y = Math.round( height_px - (y - min_value) / vertical_range * height_px );
+ if (in_path) {
+ ctx.lineTo(x_scaled, y);
+ }
+ else {
+ in_path = true;
+ if (mode === "Filled") {
+ ctx.moveTo(x_scaled, height_px);
+ ctx.lineTo(x_scaled, y);
+ }
+ else {
+ ctx.moveTo(x_scaled, y);
+ // Use this approach (note: same as for filled) to draw line from 0 to
+ // first data point.
+ //ctx.moveTo(x_scaled, height_px);
+ //ctx.lineTo(x_scaled, y);
+ }
+ }
+ }
+
+ // Draw lines at boundaries if overflowing min or max
+ ctx.fillStyle = this.prefs.overflow_color;
+ if (top_overflow || bot_overflow) {
+ var overflow_x;
+ if (mode === "Histogram" || mode === "Intensity") {
+ overflow_x = delta_x_px;
+ }
+ else { // Line and Filled, which are points
+ x_scaled -= 2; // Move it over to the left so it's centered on the point
+ overflow_x = 4;
+ }
+ if (top_overflow) {
+ ctx.fillRect(x_scaled, 0, overflow_x, 3);
+ }
+ if (bot_overflow) {
+ ctx.fillRect(x_scaled, height_px - 3, overflow_x, 3);
+ }
+ }
+ ctx.fillStyle = painter_color;
+ }
+ if (mode === "Filled") {
+ if (in_path) {
+ ctx.lineTo( x_scaled, y_zero );
+ ctx.lineTo( 0, y_zero );
+ }
+ ctx.fill();
+ }
+ else {
+ ctx.stroke();
+ }
+
+ ctx.restore();
+};
+
+/**
+ * Mapper that contains information about feature locations and data.
+ */
+var FeaturePositionMapper = function(slot_height) {
+ this.feature_positions = {};
+ this.slot_height = slot_height;
+ this.translation = 0;
+ this.y_translation = 0;
+};
+
+/**
+ * Map feature data to a position defined by
.
+ */
+FeaturePositionMapper.prototype.map_feature_data = function(feature_data, slot, x_start, x_end) {
+ if (!this.feature_positions[slot]) {
+ this.feature_positions[slot] = [];
+ }
+ this.feature_positions[slot].push({
+ data: feature_data,
+ x_start: x_start,
+ x_end: x_end
+ });
+};
+
+/**
+ * Get feature data for position
+ */
+FeaturePositionMapper.prototype.get_feature_data = function(x, y) {
+ // Find slot using Y.
+ var slot = Math.floor( (y-this.y_translation)/this.slot_height ),
+ feature_dict;
+
+ // May not be over a slot due to padding, margin, etc.
+ if (!this.feature_positions[slot]) {
+ return null;
+ }
+
+ // Find feature using X.
+ x += this.translation;
+ for (var i = 0; i < this.feature_positions[slot].length; i++) {
+ feature_dict = this.feature_positions[slot][i];
+ if (x >= feature_dict.x_start && x <= feature_dict.x_end) {
+ return feature_dict.data;
+ }
+ }
+};
+
+/**
+ * Abstract object for painting feature tracks. Subclasses must implement draw_element() for painting to work.
+ * Painter uses a 0-based, half-open coordinate system; start coordinate is closed--included--and the end is open.
+ * This coordinate system matches the BED format.
+ */
+var FeaturePainter = function(data, view_start, view_end, prefs, mode, alpha_scaler, height_scaler) {
+ Painter.call(this, data, view_start, view_end, prefs, mode);
+ this.alpha_scaler = (alpha_scaler ? alpha_scaler : new Scaler());
+ this.height_scaler = (height_scaler ? height_scaler : new Scaler());
+ this.max_label_length = 200;
+};
+
+FeaturePainter.prototype.default_prefs = { block_color: "#FFF", connector_color: "#FFF" };
+
+_.extend(FeaturePainter.prototype, {
+ get_required_height: function(rows_required, width) {
+ // y_scale is the height per row
+ var required_height = this.get_row_height(),
+ y_scale = required_height,
+ mode = this.mode;
+ // If using a packing mode, need to multiply by the number of slots used
+ if (mode === "no_detail" || mode === "Squish" || mode === "Pack") {
+ required_height = rows_required * y_scale;
+ }
+ return required_height + this.get_top_padding(width);
+ },
+
+ /** Extra padding before first row of features */
+ get_top_padding: function(width) {
+ return 0;
+ },
+
+ /**
+ * Draw data on ctx using slots and within the rectangle defined by width and height. Returns
+ * a FeaturePositionMapper object with information about where features were drawn.
+ */
+ draw: function(ctx, width, height, w_scale, slots) {
+ var data = this.data,
+ view_start = this.view_start,
+ view_end = this.view_end;
+
+ ctx.save();
+
+ ctx.fillStyle = this.prefs.block_color;
+ ctx.textAlign = "right";
+
+ var y_scale = this.get_row_height(),
+ feature_mapper = new FeaturePositionMapper(y_scale),
+ x_draw_coords,
+ incomplete_features = [];
+
+ for (var i = 0, len = data.length; i < len; i++) {
+ var feature = data[i],
+ feature_uid = feature[0],
+ feature_start = feature[1],
+ feature_end = feature[2],
+ // Slot valid only if features are slotted and this feature is slotted;
+ // feature may not be due to lack of space.
+ slot = (slots && slots[feature_uid] !== undefined ? slots[feature_uid].slot : null);
+
+ // Draw feature if (a) mode is dense or feature is slotted (as it must be for all non-dense modes) and
+ // (b) there's overlap between the feature and drawing region.
+ if ( (this.mode === "Dense" || slot !== null) && ( feature_start < view_end && feature_end > view_start ) ) {
+ x_draw_coords = this.draw_element(ctx, this.mode, feature, slot, view_start, view_end, w_scale, y_scale, width);
+ feature_mapper.map_feature_data(feature, slot, x_draw_coords[0], x_draw_coords[1]);
+
+ // Add to incomplete features if it's not drawn completely in region.
+ if (feature_start < view_start || feature_end > view_end) {
+ incomplete_features.push(feature);
+ }
+ }
+ }
+
+ ctx.restore();
+
+ feature_mapper.y_translation = this.get_top_padding(width);
+ return new DrawResults({
+ incomplete_features: incomplete_features,
+ feature_mapper: feature_mapper
+ });
+ },
+
+ /**
+ * Abstract function for drawing an individual feature.
+ */
+ draw_element: function(ctx, mode, feature, slot, tile_low, tile_high, w_scale, y_scale, width ) {
+ return [0, 0];
+ }
+});
+
+// Constants specific to feature tracks moved here (HACKING, these should
+// basically all be configuration options)
+var DENSE_TRACK_HEIGHT = 10,
+ NO_DETAIL_TRACK_HEIGHT = 3,
+ SQUISH_TRACK_HEIGHT = 5,
+ PACK_TRACK_HEIGHT = 10,
+ NO_DETAIL_FEATURE_HEIGHT = 1,
+ DENSE_FEATURE_HEIGHT = 9,
+ SQUISH_FEATURE_HEIGHT = 3,
+ PACK_FEATURE_HEIGHT = 9,
+ LABEL_SPACING = 2,
+ CONNECTOR_COLOR = "#ccc";
+
+var LinkedFeaturePainter = function(data, view_start, view_end, prefs, mode, alpha_scaler, height_scaler) {
+ FeaturePainter.call(this, data, view_start, view_end, prefs, mode, alpha_scaler, height_scaler);
+ // Whether to draw a single connector in the background that spans the entire feature (the intron fishbone)
+ this.draw_background_connector = true;
+ // Whether to call draw_connector for every pair of blocks
+ this.draw_individual_connectors = false;
+};
+
+_.extend(LinkedFeaturePainter.prototype, FeaturePainter.prototype, {
+
+ /**
+ * Height of a single row, depends on mode
+ */
+ get_row_height: function() {
+ var mode = this.mode, height;
+ if (mode === "Dense") {
+ height = DENSE_TRACK_HEIGHT;
+ }
+ else if (mode === "no_detail") {
+ height = NO_DETAIL_TRACK_HEIGHT;
+ }
+ else if (mode === "Squish") {
+ height = SQUISH_TRACK_HEIGHT;
+ }
+ else { // mode === "Pack"
+ height = PACK_TRACK_HEIGHT;
+ }
+ return height;
+ },
+
+ /**
+ * Draw a feature. Returns an array with feature's start and end X coordinates.
+ */
+ draw_element: function(ctx, mode, feature, slot, tile_low, tile_high, w_scale, y_scale, width) {
+ var feature_uid = feature[0],
+ feature_start = feature[1],
+ feature_end = feature[2],
+ feature_name = feature[3],
+ feature_strand = feature[4],
+ // -0.5 to offset region between bases.
+ f_start = Math.floor( Math.max(0, (feature_start - tile_low - 0.5) * w_scale) ),
+ f_end = Math.ceil( Math.min(width, Math.max(0, (feature_end - tile_low - 0.5) * w_scale)) ),
+ draw_start = f_start,
+ draw_end = f_end,
+ y_start = (mode === "Dense" ? 0 : (0 + slot)) * y_scale + this.get_top_padding(width),
+ thickness, y_start, thick_start = null, thick_end = null,
+ // TODO: is there any reason why block, label color cannot be set at the Painter level?
+ // For now, assume '.' === '+'
+ block_color = (!feature_strand || feature_strand === "+" || feature_strand === "." ? this.prefs.block_color : this.prefs.reverse_strand_color);
+ label_color = this.prefs.label_color;
+
+ // Set global alpha.
+ ctx.globalAlpha = this.alpha_scaler.gen_val(feature);
+
+ // In dense mode, put all data in top slot.
+ if (mode === "Dense") {
+ slot = 1;
+ }
+
+ if (mode === "no_detail") {
+ // No details for feature, so only one way to display.
+ ctx.fillStyle = block_color;
+ ctx.fillRect(f_start, y_start + 5, f_end - f_start, NO_DETAIL_FEATURE_HEIGHT);
+ }
+ else { // Mode is either Squish or Pack:
+ // Feature details.
+ var feature_ts = feature[5],
+ feature_te = feature[6],
+ feature_blocks = feature[7],
+ // Whether we are drawing full height or squished features
+ full_height = true;
+
+ if (feature_ts && feature_te) {
+ thick_start = Math.floor( Math.max(0, (feature_ts - tile_low) * w_scale) );
+ thick_end = Math.ceil( Math.min(width, Math.max(0, (feature_te - tile_low) * w_scale)) );
+ }
+
+ // Set vars that depend on mode.
+ var thin_height, thick_height;
+ if (mode === "Squish" ) {
+ thin_height = 1;
+ thick_height = SQUISH_FEATURE_HEIGHT;
+ full_height = false;
+ } else if ( mode === "Dense" ) {
+ thin_height = 5;
+ thick_height = DENSE_FEATURE_HEIGHT;
+ } else { // mode === "Pack"
+ thin_height = 5;
+ thick_height = PACK_FEATURE_HEIGHT;
+ }
+
+ // Draw feature/feature blocks + connectors.
+ if (!feature_blocks) {
+ // If there are no blocks, treat the feature as one big exon.
+ ctx.fillStyle = block_color;
+ ctx.fillRect(f_start, y_start + 1, f_end - f_start, thick_height);
+ // If strand is specified, draw arrows over feature
+ if ( feature_strand && full_height ) {
+ if (feature_strand === "+") {
+ ctx.fillStyle = ctx.canvas.manager.get_pattern( 'right_strand_inv' );
+ } else if (feature_strand === "-") {
+ ctx.fillStyle = ctx.canvas.manager.get_pattern( 'left_strand_inv' );
+ }
+ ctx.fillRect(f_start, y_start + 1, f_end - f_start, thick_height);
+ }
+ } else {
+ //
+ // There are feature blocks and mode is either Squish or Pack.
+ //
+ // Approach: (a) draw whole feature as connector/intron and (b) draw blocks as
+ // needed. This ensures that whole feature, regardless of whether it starts with
+ // a block, is visible.
+ //
+
+ // Compute y axis start position and height
+ var cur_y_start, cur_height;
+ if (mode === "Squish" || mode === "Dense") {
+ cur_y_start = y_start + Math.floor(SQUISH_FEATURE_HEIGHT/2) + 1;
+ cur_height = 1;
+ }
+ else { // mode === "Pack"
+ if (feature_strand) {
+ cur_y_start = y_start;
+ cur_height = thick_height;
+ }
+ else {
+ cur_y_start += (SQUISH_FEATURE_HEIGHT/2) + 1;
+ cur_height = 1;
+ }
+ }
+
+ // Draw whole feature as connector/intron.
+ if ( this.draw_background_connector ) {
+ if (mode === "Squish" || mode === "Dense") {
+ ctx.fillStyle = CONNECTOR_COLOR;
+ }
+ else { // mode === "Pack"
+ if (feature_strand) {
+ if (feature_strand === "+") {
+ ctx.fillStyle = ctx.canvas.manager.get_pattern( 'right_strand' );
+ } else if (feature_strand === "-") {
+ ctx.fillStyle = ctx.canvas.manager.get_pattern( 'left_strand' );
+ }
+ }
+ else {
+ ctx.fillStyle = CONNECTOR_COLOR;
+ }
+ }
+ ctx.fillRect(f_start, cur_y_start, f_end - f_start, cur_height);
+ }
+
+ // Draw blocks.
+ var start_and_height;
+ for (var k = 0, k_len = feature_blocks.length; k < k_len; k++) {
+ var block = feature_blocks[k],
+ // -0.5 to offset block between bases.
+ block_start = Math.floor( Math.max(0, (block[0] - tile_low - 0.5) * w_scale) ),
+ block_end = Math.ceil( Math.min(width, Math.max((block[1] - tile_low - 0.5) * w_scale)) ),
+ last_block_start, last_block_end;
+
+ // Skip drawing if block not on tile.
+ if (block_start > block_end) { continue; }
+
+ // Draw thin block.
+ ctx.fillStyle = block_color;
+ ctx.fillRect(block_start, y_start + (thick_height-thin_height)/2 + 1, block_end - block_start, thin_height);
+
+ // If block intersects with thick region, draw block as thick.
+ // - No thick is sometimes encoded as thick_start == thick_end, so don't draw in that case
+ if (thick_start !== undefined && feature_te > feature_ts && !(block_start > thick_end || block_end < thick_start) ) {
+ var block_thick_start = Math.max(block_start, thick_start),
+ block_thick_end = Math.min(block_end, thick_end);
+ ctx.fillRect(block_thick_start, y_start + 1, block_thick_end - block_thick_start, thick_height);
+ if ( feature_blocks.length === 1 && mode === "Pack") {
+ // Exactly one block means we have no introns, but do have a distinct "thick" region,
+ // draw arrows over it if in pack mode.
+ if (feature_strand === "+") {
+ ctx.fillStyle = ctx.canvas.manager.get_pattern( 'right_strand_inv' );
+ } else if (feature_strand === "-") {
+ ctx.fillStyle = ctx.canvas.manager.get_pattern( 'left_strand_inv' );
+ }
+ // If region is wide enough in pixels, pad a bit
+ if ( block_thick_start + 14 < block_thick_end ) {
+ block_thick_start += 2;
+ block_thick_end -= 2;
+ }
+ ctx.fillRect(block_thick_start, y_start + 1, block_thick_end - block_thick_start, thick_height);
+ }
+ }
+ // Draw individual connectors if required
+ if ( this.draw_individual_connectors && last_block_start ) {
+ this.draw_connector( ctx, last_block_start, last_block_end, block_start, block_end, y_start );
+ }
+ last_block_start = block_start;
+ last_block_end = block_end;
+ }
+
+ // FIXME: Height scaling only works in Pack mode right now.
+ if (mode === "Pack") {
+ // Reset alpha so height scaling is not impacted by alpha scaling.
+ ctx.globalAlpha = 1;
+
+ // Height scaling: draw white lines to reduce height according to height scale factor.
+ ctx.fillStyle = "white"; // TODO: set this to background color.
+ var
+ hscale_factor = this.height_scaler.gen_val(feature),
+ // Ceil ensures that min height is >= 1.
+ new_height = Math.ceil(thick_height * hscale_factor),
+ ws_height = Math.round( (thick_height-new_height)/2 );
+ if (hscale_factor !== 1) {
+ ctx.fillRect(f_start, cur_y_start + 1, f_end - f_start, ws_height);
+ ctx.fillRect(f_start, cur_y_start + thick_height - ws_height + 1, f_end - f_start, ws_height);
+ }
+ }
+ }
+
+ // Reset alpha so that label is not transparent.
+ ctx.globalAlpha = 1;
+
+ // Draw label for Pack mode.
+ if (feature_name && mode === "Pack" && feature_start > tile_low) {
+ ctx.fillStyle = label_color;
+ // FIXME: assumption here that the entire view starts at 0
+ if (tile_low === 0 && f_start - ctx.measureText(feature_name).width < 0) {
+ ctx.textAlign = "left";
+ ctx.fillText(feature_name, f_end + LABEL_SPACING, y_start + 8, this.max_label_length);
+ draw_end += ctx.measureText(feature_name).width + LABEL_SPACING;
+ } else {
+ ctx.textAlign = "right";
+ ctx.fillText(feature_name, f_start - LABEL_SPACING, y_start + 8, this.max_label_length);
+ draw_start -= ctx.measureText(feature_name).width + LABEL_SPACING;
+ }
+ //ctx.fillStyle = block_color;
+ }
+ }
+
+ // Reset global alpha.
+ ctx.globalAlpha = 1;
+
+ return [draw_start, draw_end];
+ }
+});
+
+var ReadPainter = function(data, view_start, view_end, prefs, mode, alpha_scaler, height_scaler, ref_seq, base_color_fn) {
+ FeaturePainter.call(this, data, view_start, view_end, prefs, mode, alpha_scaler, height_scaler);
+ this.ref_seq = (ref_seq ? ref_seq.data : null);
+ this.base_color_fn = base_color_fn;
+};
+
+_.extend(ReadPainter.prototype, FeaturePainter.prototype, {
+ /**
+ * Returns height based on mode.
+ */
+ get_row_height: function() {
+ var height, mode = this.mode;
+ if (mode === "Dense") {
+ height = DENSE_TRACK_HEIGHT;
+ }
+ else if (mode === "Squish") {
+ height = SQUISH_TRACK_HEIGHT;
+ }
+ else { // mode === "Pack"
+ height = PACK_TRACK_HEIGHT;
+ if (this.prefs.show_insertions) {
+ height *= 2;
+ }
+ }
+ return height;
+ },
+
+ /**
+ * Parse CIGAR string to get (a) a list of contiguous drawing blocks (MD=X) and
+ * (b) an array of [ op_index, op_len ] pairs where op_index is an index into the
+ * string 'MIDNSHP=X' Return value is a dictionary with two entries, blocks and cigar
+ */
+ _parse_cigar: function(cigar_str) {
+ var cigar_ops = 'MIDNSHP=X';
+
+ // Parse cigar.
+ var blocks = [ [0, 0] ],
+ cur_block = blocks[0],
+ base_pos = 0,
+
+ // Parse cigar operations out and update/create blocks as needed.
+ parsed_cigar = _.map(cigar_str.match(/[0-9]+[MIDNSHP=X]/g), function(op) {
+ // Get operation length, character.
+ var op_len = parseInt(op.slice(0, -1), 10),
+ op_char = op.slice(-1);
+
+ // Update drawing block.
+ if (op_char === 'N') {
+ // At skip, so need to start new block if current block represents
+ // drawing area.
+ if (cur_block[1] !== 0) {
+ cur_block = [base_pos + op_len, base_pos + op_len];
+ blocks.push(cur_block);
+ }
+ }
+ else if ('ISHP'.indexOf(op_char) === -1) {
+ // Operation is M,D,=,X.
+ cur_block[1] += op_len;
+ base_pos += op_len;
+ }
+
+ // Return parsed cigar.
+ return [ cigar_ops.indexOf(op_char), op_len ];
+ });
+
+ return {
+ blocks: blocks,
+ cigar: parsed_cigar
+ };
+ },
+
+ /**
+ * Draw a single read from reference-based read sequence and cigar.
+ */
+ draw_read: function(ctx, mode, w_scale, y_start, tile_low, tile_high, feature_start, cigar, strand, read_seq) {
+ // Helper function to update base and sequnence offsets.
+ var update_base_offset = function(offset, cig_op, cig_len) {
+ if ('M=NXD'.indexOf(cig_op) !== -1) {
+ offset += cig_len;
+ }
+ return offset;
+ },
+ update_seq_offset = function(offset, cig_op, cig_len) {
+ if ('IX'.indexOf(cig_op) !== -1) {
+ offset += cig_len;
+ }
+ return offset;
+ },
+ // Gets drawing coordinate for a sequence coordinate. Assumes closure variables w_scale and tile_low.
+ get_draw_coord = function(sequence_coord) {
+ // -0.5 to offset sequence between bases.
+ return Math.floor( Math.max(0, (sequence_coord - tile_low - 0.5) * w_scale) );
+ };
+
+ ctx.textAlign = "center";
+ var tile_region = [tile_low, tile_high],
+ base_offset = 0,
+ seq_offset = 0,
+ gap = Math.round(w_scale/2),
+ char_width_px = ctx.canvas.manager.char_width_px,
+ block_color = (strand === "+" ? this.prefs.detail_block_color : this.prefs.reverse_strand_color),
+ pack_mode = (mode === 'Pack'),
+ draw_height = (pack_mode ? PACK_FEATURE_HEIGHT : SQUISH_FEATURE_HEIGHT),
+ rect_y = y_start + 1,
+ paint_utils = new ReadPainterUtils(ctx, draw_height, w_scale, mode),
+ drawing_blocks = [],
+ s_start,
+ s_end;
+
+ // Keep list of items that need to be drawn on top of initial drawing layer.
+ var draw_last = [];
+
+ // Parse cigar and get drawing blocks.
+ var t = this._parse_cigar(cigar);
+ cigar = t.cigar;
+ drawing_blocks = t.blocks;
+
+ // Draw blocks.
+ for (var i = 0; i < drawing_blocks.length; i++) {
+ var block = drawing_blocks[i];
+
+ if (is_overlap([feature_start + block[0], feature_start + block[1]], tile_region)) {
+ s_start = get_draw_coord(feature_start + block[0]);
+ s_end = get_draw_coord(feature_start + block[1]);
+
+ // Make sure that block is drawn even if it too small to be rendered officially; in this case,
+ // read is drawn at 1px.
+ // TODO: need to ensure that s_start, s_end are calculated the same for both slotting
+ // and drawing.
+ if (s_start === s_end) {
+ s_end += 1;
+ }
+
+ // Draw read base as rectangle.
+ ctx.fillStyle = block_color;
+ ctx.fillRect(s_start, rect_y, s_end - s_start, draw_height);
+ }
+ }
+
+ // Draw read features.
+ for (var cig_id = 0, len = cigar.length; cig_id < len; cig_id++) {
+ var cig = cigar[cig_id],
+ cig_op = "MIDNSHP=X"[ cig[0] ],
+ cig_len = cig[1];
+
+ var seq_start = feature_start + base_offset;
+ s_start = get_draw_coord(seq_start);
+ s_end = get_draw_coord(seq_start + cig_len);
+
+ // Skip feature if it's not in tile.
+ if (!is_overlap([seq_start, seq_start + cig_len], tile_region)) {
+ // Update offsets.
+ base_offset = update_base_offset(base_offset, cig_op, cig_len);
+ seq_offset = update_seq_offset(seq_offset, cig_op, cig_len);
+ continue;
+ }
+
+ // Make sure that read is drawn even if it too small to be rendered officially; in this case,
+ // read is drawn at 1px.
+ // TODO: need to ensure that s_start, s_end are calculated the same for both slotting
+ // and drawing.
+ if (s_start === s_end) {
+ s_end += 1;
+ }
+
+ // Draw read feature.
+ switch (cig_op) {
+ case "H": // Hard clipping.
+ case "S": // Soft clipping.
+ case "P": // Padding.
+ // Sequence not present and not related to alignment; do nothing.
+ break;
+ case "M": // "Match".
+ // Because it's not known whether there is a match, ignore.
+ base_offset += cig_len;
+ break;
+ case "=": // Match with reference.
+ case "X": // Mismatch with reference.
+ //
+ // Draw sequence and/or variants.
+ //
+
+ // Get sequence to draw.
+ var cur_seq = '';
+ if (cig_op === 'X') {
+ // Get sequence from read_seq.
+ cur_seq = read_seq.slice(seq_offset, seq_offset + cig_len);
+ }
+ else if (this.ref_seq) { // && cig_op === '='
+ // Use reference sequence.
+ cur_seq = this.ref_seq.slice(
+ // If read starts after tile start, slice at read start.
+ Math.max(0, seq_start - tile_low),
+ // If read ends before tile end, slice at read end.
+ Math.min(seq_start - tile_low + cig_len, tile_high - tile_low)
+ );
+ }
+
+ // Draw sequence. Because cur_seq starts and read/tile start, go to there to start writing.
+ var start_pos = Math.max(seq_start, tile_low);
+ for (var c = 0; c < cur_seq.length; c++) {
+ // Draw base if showing all (i.e. not showing differences) or there is a mismatch.
+ if (cur_seq && !this.prefs.show_differences || cig_op === 'X') {
+ // Draw base.
+ var c_start = Math.floor( Math.max(0, (start_pos + c - tile_low) * w_scale) );
+ ctx.fillStyle = this.base_color_fn(cur_seq[c]);
+ if (pack_mode && w_scale > char_width_px) {
+ ctx.fillText(cur_seq[c], c_start, y_start + 9);
+ }
+ // Require a minimum w_scale so that variants are only drawn when somewhat zoomed in.
+ else if (w_scale > 0.05) {
+ ctx.fillRect(c_start - gap, rect_y, Math.max( 1, Math.round(w_scale) ), draw_height);
+ }
+ }
+ }
+
+ // Move forward in sequence only if sequence used to get mismatches.
+ if (cig_op === 'X') { seq_offset += cig_len; }
+ base_offset += cig_len;
+
+ break;
+ case "N": // Skipped bases.
+ ctx.fillStyle = CONNECTOR_COLOR;
+ ctx.fillRect(s_start, rect_y + (draw_height - 1)/2, s_end - s_start, 1);
+ // No change in seq_offset because sequence not used when skipping.
+ base_offset += cig_len;
+ break;
+ case "D": // Deletion.
+ paint_utils.draw_deletion(s_start, rect_y, cig_len);
+ base_offset += cig_len;
+ break;
+ case "I": // Insertion.
+ // Check to see if sequence should be drawn at all by looking at the overlap between
+ // the sequence region and the tile region.
+ var insert_x_coord = s_start - gap;
+
+ if (is_overlap([seq_start, seq_start + cig_len], tile_region)) {
+ var seq = read_seq.slice(seq_offset, seq_offset + cig_len);
+ // Insertion point is between the sequence start and the previous base: (-gap) moves
+ // back from sequence start to insertion point.
+ if (this.prefs.show_insertions) {
+ //
+ // Show inserted sequence above, centered on insertion point.
+ //
+
+ // Draw sequence.
+ // X center is offset + start -
+ var x_center = s_start - (s_end - s_start)/2;
+ if ( (mode === "Pack" || this.mode === "Auto") && read_seq !== undefined && w_scale > char_width_px) {
+ // Draw sequence container.
+ ctx.fillStyle = "yellow";
+ ctx.fillRect(x_center - gap, y_start - 9, s_end - s_start, 9);
+ draw_last[draw_last.length] = {type: "triangle", data: [insert_x_coord, y_start + 4, 5]};
+ ctx.fillStyle = CONNECTOR_COLOR;
+ // Based on overlap b/t sequence and tile, get sequence to be drawn.
+ switch( compute_overlap( [seq_start, seq_start + cig_len], tile_region ) ) {
+ case(OVERLAP_START):
+ seq = seq.slice(tile_low-seq_start);
+ break;
+ case(OVERLAP_END):
+ seq = seq.slice(0, seq_start-tile_high);
+ break;
+ case(CONTAINED_BY):
+ // All of sequence drawn.
+ break;
+ case(CONTAINS):
+ seq = seq.slice(tile_low-seq_start, seq_start-tile_high);
+ break;
+ }
+ // Draw sequence.
+ for (var c = 0, str_len = seq.length; c < str_len; c++) {
+ var c_start = Math.floor( Math.max(0, (seq_start + c - tile_low) * w_scale) );
+ ctx.fillText(seq[c], c_start - (s_end - s_start)/2, y_start);
+ }
+ }
+ else {
+ // Draw block.
+ ctx.fillStyle = "yellow";
+ // TODO: This is a pretty hack-ish way to fill rectangle based on mode.
+ ctx.fillRect(x_center, y_start + (this.mode !== "Dense" ? 2 : 5),
+ s_end - s_start, (mode !== "Dense" ? SQUISH_FEATURE_HEIGHT : DENSE_FEATURE_HEIGHT));
+ }
+ }
+ else {
+ if ( (mode === "Pack" || this.mode === "Auto") && read_seq !== undefined && w_scale > char_width_px) {
+ // Show insertions with a single number at the insertion point.
+ draw_last.push( { type: "text", data: [seq.length, insert_x_coord, y_start + 9] } );
+ }
+ else {
+ // TODO: probably can merge this case with code above.
+ }
+ }
+ }
+ seq_offset += cig_len;
+ // No change to base offset because insertions are drawn above sequence/read.
+ break;
+ }
+ }
+
+ //
+ // Draw last items.
+ //
+ ctx.fillStyle = "yellow";
+ var item, type, data;
+ for (var i = 0; i < draw_last.length; i++) {
+ item = draw_last[i];
+ type = item.type;
+ data = item.data;
+ if (type === "text") {
+ ctx.save();
+ ctx.font = "bold " + ctx.font;
+ ctx.fillText(data[0], data[1], data[2]);
+ ctx.restore();
+ }
+ else if (type === "triangle") {
+ drawDownwardEquilateralTriangle(ctx, data[0], data[1], data[2]);
+ }
+ }
+ },
+
+ /**
+ * Draw a complete read pair
+ */
+ draw_element: function(ctx, mode, feature, slot, tile_low, tile_high, w_scale, y_scale, width ) {
+ // All features need a start, end, and vertical center.
+ var feature_uid = feature[0],
+ feature_start = feature[1],
+ feature_end = feature[2],
+ feature_name = feature[3],
+ // -0.5 to put element between bases.
+ f_start = Math.floor( Math.max(-0.5 * w_scale, (feature_start - tile_low - 0.5) * w_scale) ),
+ f_end = Math.ceil( Math.min(width, Math.max(0, (feature_end - tile_low - 0.5) * w_scale)) ),
+ y_start = (mode === "Dense" ? 0 : (0 + slot)) * y_scale,
+ draw_height = (mode === 'Pack' ? PACK_FEATURE_HEIGHT : SQUISH_FEATURE_HEIGHT),
+ label_color = this.prefs.label_color;
+
+ // Draw read.
+ if (feature[5] instanceof Array) {
+ // Read is paired.
+ var connector = true;
+
+ // Draw left/forward read.
+ if (feature[4][1] >= tile_low && feature[4][0] <= tile_high && feature[4][2]) {
+ this.draw_read(ctx, mode, w_scale, y_start, tile_low, tile_high, feature[4][0], feature[4][2], feature[4][3], feature[4][4]);
+ }
+ else {
+ connector = false;
+ }
+
+ // Draw right/reverse read.
+ if (feature[5][1] >= tile_low && feature[5][0] <= tile_high && feature[5][2]) {
+ this.draw_read(ctx, mode, w_scale, y_start, tile_low, tile_high, feature[5][0], feature[5][2], feature[5][3], feature[5][4]);
+ }
+ else {
+ connector = false;
+ }
+
+ // Draw connector if both reads were drawn.
+ // TODO: currently, there is no way to connect reads drawn on different tiles; to connect reads on different tiles, data manager
+ // code is needed to join mate pairs from different regions. Alternatively, requesting multiple regions of data at once would
+ // make it possible to put together more easily.
+ // -0.5 to position connector correctly between reads.
+ var b1_end = Math.ceil( Math.min(width, Math.max(-0.5 * w_scale, (feature[4][1] - tile_low - 0.5) * w_scale)) ),
+ b2_start = Math.floor( Math.max(-0.5 * w_scale, (feature[5][0] - tile_low - 0.5) * w_scale) );
+ if (connector && b2_start > b1_end) {
+ ctx.fillStyle = CONNECTOR_COLOR;
+ var line_height = y_start + 1 + (draw_height - 1)/2;
+ dashedLine(ctx, b1_end, line_height, b2_start, line_height);
+ }
+ } else {
+ // Read is single.
+ this.draw_read(ctx, mode, w_scale, y_start, tile_low, tile_high, feature_start, feature[4], feature[5], feature[6]);
+ }
+ if (mode === "Pack" && feature_start >= tile_low && feature_name !== ".") {
+ // Draw label.
+ ctx.fillStyle = this.prefs.label_color;
+ if (tile_low === 0 && f_start - ctx.measureText(feature_name).width < 0) {
+ ctx.textAlign = "left";
+ ctx.fillText(feature_name, f_end + LABEL_SPACING, y_start + 9, this.max_label_length);
+ } else {
+ ctx.textAlign = "right";
+ ctx.fillText(feature_name, f_start - LABEL_SPACING, y_start + 9, this.max_label_length);
+ }
+ }
+
+ // FIXME: provide actual coordinates for drawn read.
+ return [0,0];
+ }
+});
+
+var ArcLinkedFeaturePainter = function(data, view_start, view_end, prefs, mode, alpha_scaler, height_scaler) {
+ LinkedFeaturePainter.call(this, data, view_start, view_end, prefs, mode, alpha_scaler, height_scaler);
+ // Need to know the longest feature length for adding spacing
+ this.longest_feature_length = this.calculate_longest_feature_length();
+ this.draw_background_connector = false;
+ this.draw_individual_connectors = true;
+};
+
+_.extend(ArcLinkedFeaturePainter.prototype, FeaturePainter.prototype, LinkedFeaturePainter.prototype, {
+
+ calculate_longest_feature_length: function () {
+ var longest_feature_length = 0;
+ for (var i = 0, len = this.data.length; i < len; i++) {
+ var feature = this.data[i], feature_start = feature[1], feature_end = feature[2];
+ longest_feature_length = Math.max( longest_feature_length, feature_end - feature_start );
+ }
+ return longest_feature_length;
+ },
+
+ get_top_padding: function( width ) {
+ var view_range = this.view_end - this.view_start,
+ w_scale = width / view_range;
+ return Math.min( 128, Math.ceil( ( this.longest_feature_length / 2 ) * w_scale ) );
+ },
+
+ draw_connector: function( ctx, block1_start, block1_end, block2_start, block2_end, y_start ) {
+ // Arc drawing -- from closest endpoints
+ var x_center = ( block1_end + block2_start ) / 2,
+ radius = block2_start - x_center;
+ // For full half circles
+ var angle1 = Math.PI, angle2 = 0;
+ if ( radius > 0 ) {
+ ctx.beginPath();
+ ctx.arc( x_center, y_start, block2_start - x_center, Math.PI, 0 );
+ ctx.stroke();
+ }
+ }
+});
+
+// Color stuff from less.js
+
+var Color = function (rgb, a) {
+ /**
+ * The end goal here, is to parse the arguments
+ * into an integer triplet, such as `128, 255, 0`
+ *
+ * This facilitates operations and conversions.
+ */
+ if (Array.isArray(rgb)) {
+ this.rgb = rgb;
+ } else if (rgb.length == 6) {
+ this.rgb = rgb.match(/.{2}/g).map(function (c) {
+ return parseInt(c, 16);
+ });
+ } else if (rgb.length == 7) {
+ this.rgb = rgb.substring(1,7).match(/.{2}/g).map(function (c) {
+ return parseInt(c, 16);
+ });
+ } else {
+ this.rgb = rgb.split('').map(function (c) {
+ return parseInt(c + c, 16);
+ });
+ }
+ this.alpha = typeof(a) === 'number' ? a : 1;
+};
+Color.prototype = {
+ eval: function () { return this; },
+
+ //
+ // If we have some transparency, the only way to represent it
+ // is via `rgba`. Otherwise, we use the hex representation,
+ // which has better compatibility with older browsers.
+ // Values are capped between `0` and `255`, rounded and zero-padded.
+ //
+ toCSS: function () {
+ if (this.alpha < 1.0) {
+ return "rgba(" + this.rgb.map(function (c) {
+ return Math.round(c);
+ }).concat(this.alpha).join(', ') + ")";
+ } else {
+ return '#' + this.rgb.map(function (i) {
+ i = Math.round(i);
+ i = (i > 255 ? 255 : (i < 0 ? 0 : i)).toString(16);
+ return i.length === 1 ? '0' + i : i;
+ }).join('');
+ }
+ },
+
+ toHSL: function () {
+ var r = this.rgb[0] / 255,
+ g = this.rgb[1] / 255,
+ b = this.rgb[2] / 255,
+ a = this.alpha;
+
+ var max = Math.max(r, g, b), min = Math.min(r, g, b);
+ var h, s, l = (max + min) / 2, d = max - min;
+
+ if (max === min) {
+ h = s = 0;
+ } else {
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+ switch (max) {
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+ case g: h = (b - r) / d + 2; break;
+ case b: h = (r - g) / d + 4; break;
+ }
+ h /= 6;
+ }
+ return { h: h * 360, s: s, l: l, a: a };
+ },
+
+ toARGB: function () {
+ var argb = [Math.round(this.alpha * 255)].concat(this.rgb);
+ return '#' + argb.map(function (i) {
+ i = Math.round(i);
+ i = (i > 255 ? 255 : (i < 0 ? 0 : i)).toString(16);
+ return i.length === 1 ? '0' + i : i;
+ }).join('');
+ },
+
+ mix: function (color2, weight) {
+ color1 = this;
+
+ var p = weight; // .value / 100.0;
+ var w = p * 2 - 1;
+ var a = color1.toHSL().a - color2.toHSL().a;
+
+ var w1 = (((w * a == -1) ? w : (w + a) / (1 + w * a)) + 1) / 2.0;
+ var w2 = 1 - w1;
+
+ var rgb = [color1.rgb[0] * w1 + color2.rgb[0] * w2,
+ color1.rgb[1] * w1 + color2.rgb[1] * w2,
+ color1.rgb[2] * w1 + color2.rgb[2] * w2];
+
+ var alpha = color1.alpha * p + color2.alpha * (1 - p);
+
+ return new Color(rgb, alpha);
+ }
+};
+
+
+// End colors from less.js
+
+var LinearRamp = function( start_color, end_color, start_value, end_value ) {
+ /**
+ * Simple linear gradient
+ */
+ this.start_color = new Color( start_color );
+ this.end_color = new Color( end_color );
+ this.start_value = start_value;
+ this.end_value = end_value;
+ this.value_range = end_value - start_value;
+};
+
+LinearRamp.prototype.map_value = function( value ) {
+ value = Math.max( value, this.start_value );
+ value = Math.min( value, this.end_value );
+ value = ( value - this.start_value ) / this.value_range;
+ // HACK: just red for now
+ // return "hsl(0,100%," + (value * 100) + "%)"
+ return this.start_color.mix( this.end_color, 1 - value ).toCSS();
+};
+
+var SplitRamp = function( start_color, middle_color, end_color, start_value, end_value ) {
+ /**
+ * Two gradients split away from 0
+ */
+ this.positive_ramp = new LinearRamp( middle_color, end_color, 0, end_value );
+ this.negative_ramp = new LinearRamp( middle_color, start_color, 0, -start_value );
+ this.start_value = start_value;
+ this.end_value = end_value;
+};
+
+SplitRamp.prototype.map_value = function( value ) {
+ value = Math.max( value, this.start_value );
+ value = Math.min( value, this.end_value );
+ if ( value >= 0 ) {
+ return this.positive_ramp.map_value( value );
+ } else {
+ return this.negative_ramp.map_value( -value );
+ }
+};
+
+var DiagonalHeatmapPainter = function(data, view_start, view_end, prefs, mode) {
+ Painter.call( this, data, view_start, view_end, prefs, mode );
+ var i, len;
+ if ( this.prefs.min_value === undefined ) {
+ var min_value = Infinity;
+ for (i = 0, len = this.data.length; i < len; i++) {
+ min_value = Math.min( min_value, this.data[i][5] );
+ }
+ this.prefs.min_value = min_value;
+ }
+ if ( this.prefs.max_value === undefined ) {
+ var max_value = -Infinity;
+ for (i = 0, len = this.data.length; i < len; i++) {
+ max_value = Math.max( max_value, this.data[i][5] );
+ }
+ this.prefs.max_value = max_value;
+ }
+};
+
+DiagonalHeatmapPainter.prototype.default_prefs = {
+ min_value: undefined,
+ max_value: undefined,
+ mode: "Heatmap",
+ pos_color: "#FF8C00",
+ neg_color: "#4169E1"
+};
+
+DiagonalHeatmapPainter.prototype.draw = function(ctx, width, height, w_scale) {
+ var
+ min_value = this.prefs.min_value,
+ max_value = this.prefs.max_value,
+ value_range = max_value - min_value,
+ height_px = height,
+ view_start = this.view_start,
+ mode = this.mode,
+ data = this.data,
+ invsqrt2 = 1 / Math.sqrt(2);
+
+ var ramp = ( new SplitRamp( this.prefs.neg_color, "#FFFFFF", this.prefs.pos_color, min_value, max_value ) );
+
+ var d, s1, e1, s2, e2, value;
+
+ var scale = function( p ) { return ( p - view_start ) * w_scale; };
+
+ ctx.save();
+
+ // Draw into triangle, then rotate and scale
+ ctx.rotate(-45 * Math.PI / 180);
+ ctx.scale( invsqrt2, invsqrt2 );
+
+ // Paint track.
+ for (var i = 0, len = data.length; i < len; i++) {
+
+ d = data[i];
+
+ // Ensure the cell is visible
+ // if ( )
+
+ s1 = scale( d[1] );
+ e1 = scale( d[2] );
+ s2 = scale( d[4] );
+ e2 = scale( d[5] );
+ value = d[6];
+
+ ctx.fillStyle = ( ramp.map_value( value ) );
+
+ ctx.fillRect( s1, s2, ( e1 - s1 ), ( e2 - s2 ) );
+ }
+
+ ctx.restore();
+};
+
+/**
+ * Utilities for painting reads.
+ */
+var ReadPainterUtils = function(ctx, row_height, px_per_base, mode) {
+ this.ctx = ctx;
+ this.row_height = row_height;
+ this.px_per_base = px_per_base;
+ this.draw_details = (mode === 'Pack' || mode === 'Auto') && (px_per_base >= ctx.canvas.manager.char_width_px);
+ this.delete_details_thickness = 0.2;
+};
+
+_.extend(ReadPainterUtils.prototype, {
+ /**
+ * Draw deletion of base(s).
+ * @param draw_detail if true, drawing in detail and deletion is drawn more subtly
+ */
+ draw_deletion: function(x, y, len) {
+ this.ctx.fillStyle = "black";
+ var thickness = (this.draw_details ? this.delete_details_thickness : 1) * this.row_height;
+ y += 0.5 * ( this.row_height - thickness );
+ this.ctx.fillRect(x, y, len * this.px_per_base, thickness);
+ }
+});
+
+/**
+ * Paints variant data onto canvas.
+ */
+var VariantPainter = function(data, view_start, view_end, prefs, mode, base_color_fn) {
+ Painter.call(this, data, view_start, view_end, prefs, mode);
+ this.base_color_fn = base_color_fn;
+ this.divider_height = 1;
+};
+
+_.extend(VariantPainter.prototype, Painter.prototype, {
+ /**
+ * Height of a single row, depends on mode
+ */
+ get_row_height: function() {
+ var mode = this.mode, height;
+ if (mode === "Dense") {
+ height = DENSE_TRACK_HEIGHT;
+ }
+ else if (mode === "Squish") {
+ height = SQUISH_TRACK_HEIGHT;
+ }
+ else { // mode === "Pack"
+ height = PACK_TRACK_HEIGHT;
+ }
+ return height;
+ },
+
+ /**
+ * Returns required height to draw a particular number of samples in a given mode.
+ */
+ get_required_height: function(num_samples) {
+ // FIXME: for single-sample data, height should be summary_height when zoomed out and
+ // row_height when zoomed in.
+ var height = this.prefs.summary_height;
+
+ // If showing sample data, height is summary + divider + samples.
+ if (num_samples > 1 && this.prefs.show_sample_data) {
+ height += this.divider_height + num_samples * this.get_row_height();
+ }
+ return height;
+ },
+
+ /**
+ * Draw on the context using a rectangle of width x height with scale w_scale.
+ */
+ draw: function(ctx, width, height, w_scale) {
+ ctx.save();
+
+ // Functions for detection insertions (TODO) and deletions.
+
+ /** Returns dictionary of information about a deletion; returns null if there no deletion.
+ * Dict attributes:
+ * -start: where the deletion starts relative to reference start
+ * -len: how long the deletion is
+ */
+ var get_deletion_info = function(ref, alt) {
+ var ref_len = ref.length,
+ alt_len = alt.length,
+ start = 0,
+ len = 1,
+ is_delete = false;
+ if (alt === '-') {
+ is_delete = true;
+ len = ref.length;
+ }
+ else if (ref.indexOf(alt) === 0 && ref_len > alt_len) {
+ is_delete = true;
+ len = ref_len = alt_len;
+ start += alt_len;
+ }
+
+ return ( is_delete ? { start: start, len: len } : null );
+ };
+
+
+ // Draw.
+ var locus_data,
+ pos,
+ id,
+ ref,
+ alt,
+ qual,
+ filter,
+ sample_gts,
+ allele_counts,
+ variant,
+ draw_x_start,
+ char_x_start,
+ draw_y_start,
+ genotype,
+ // Always draw variants at least 1 pixel wide.
+ base_px = Math.max(1, Math.floor(w_scale)),
+ // Determine number of samples.
+ num_samples = (this.data.length ? this.data[0][7].split(',').length : 0),
+ row_height = (this.mode === 'Squish' ? SQUISH_TRACK_HEIGHT : PACK_TRACK_HEIGHT),
+ // If zoomed out, fill the whole row with feature to make it easier to read;
+ // when zoomed in, use feature height so that there are gaps in sample rows.
+ feature_height = (w_scale < 0.1 ?
+ row_height :
+ (this.mode === 'Squish' ? SQUISH_FEATURE_HEIGHT : PACK_FEATURE_HEIGHT)
+ ),
+ draw_summary = true,
+ paint_utils = new ReadPainterUtils(ctx, row_height, w_scale, this.mode),
+ j;
+
+ // If there's a single sample, update drawing variables.
+ if (num_samples === 1) {
+ row_height = feature_height =
+ (w_scale < ctx.canvas.manager.char_width_px ? this.prefs.summary_height : row_height);
+ paint_utils.row_height = row_height;
+ // No summary when there's a single sample.
+ draw_summary = false;
+ }
+
+ // Draw divider between summary and samples.
+ if (this.prefs.show_sample_data && draw_summary) {
+ ctx.fillStyle = '#F3F3F3';
+ ctx.globalAlpha = 1;
+ ctx.fillRect(0, this.prefs.summary_height - this.divider_height, width, this.divider_height);
+ }
+
+ // Draw variants.
+ ctx.textAlign = "center";
+ for (var i = 0; i < this.data.length; i++) {
+ // Get locus data.
+ locus_data = this.data[i];
+ pos = locus_data[1];
+ ref = locus_data[3];
+ alt = [ locus_data[4].split(',') ];
+ sample_gts = locus_data[7].split(',');
+ allele_counts = locus_data.slice(8);
+
+ // Process alterate values to derive information about each alt.
+ alt = _.map(_.flatten(alt), function(a) {
+ var type,
+ alt_info = {},
+ delete_info = get_deletion_info(ref, a);
+ if (delete_info) {
+ type = 'deletion';
+ _.extend(alt_info, delete_info);
+ }
+ // TODO: test for insertion.
+ else { // SNP.
+ type = 'snp';
+ }
+
+ return _.extend(alt_info, {
+ type: type,
+ value: a,
+ });
+ });
+
+ // Only draw locus data if it's in viewing region.
+ if (pos < this.view_start || pos > this.view_end) {
+ continue;
+ }
+
+ // Compute start for drawing variants marker, text.
+ draw_x_start = Math.floor( Math.max(-0.5 * w_scale, (pos - this.view_start - 0.5) * w_scale) );
+ char_x_start = Math.floor( Math.max(0, (pos - this.view_start) * w_scale) );
+
+ // Draw summary.
+ if (draw_summary) {
+ ctx.fillStyle = '#999999';
+ ctx.globalAlpha = 1;
+ // Draw background for summary.
+ ctx.fillRect(draw_x_start, 0, base_px, this.prefs.summary_height);
+ draw_y_start = this.prefs.summary_height;
+ // Draw allele fractions onto summary.
+ for (j = 0; j < alt.length; j++) {
+ ctx.fillStyle = ( alt[j].type === 'deletion' ? 'black' : this.base_color_fn(alt[j].value) );
+ allele_frac = allele_counts / sample_gts.length;
+ draw_height = Math.ceil(this.prefs.summary_height * allele_frac);
+ ctx.fillRect(draw_x_start, draw_y_start - draw_height, base_px, draw_height);
+ draw_y_start -= draw_height;
+ }
+ }
+
+ // Done drawing if not showing samples data.
+ if (!this.prefs.show_sample_data) { continue; }
+
+ // Draw sample genotype(s).
+ draw_y_start = (draw_summary ? this.prefs.summary_height + this.divider_height : 0);
+ for (j = 0; j < sample_gts.length; j++, draw_y_start += row_height) {
+ genotype = (sample_gts[j] ? sample_gts[j].split(/\/|\|/) : ['0', '0']);
+
+ // Get variant to draw and set drawing properties.
+ variant = null;
+ if (genotype[0] === genotype[1]) {
+ if (genotype[0] === '.') {
+ // TODO: draw uncalled variant.
+ }
+ else if (genotype[0] !== '0') {
+ // Homozygous for variant.
+ variant = alt[ parseInt(genotype[0], 10) - 1 ];
+ ctx.globalAlpha = 1;
+ }
+ // else reference
+ }
+ else { // Heterozygous for variant.
+ variant = (genotype[0] !== '0' ? genotype[0] : genotype[1]);
+ variant = alt[ parseInt(variant, 10) - 1 ];
+ ctx.globalAlpha = 0.5;
+ }
+
+ // If there's a variant, draw it.
+ if (variant) {
+ if (variant.type === 'snp') {
+ var snp = variant.value;
+ ctx.fillStyle = this.base_color_fn(snp);
+ if (paint_utils.draw_details) {
+ ctx.fillText(snp, char_x_start, draw_y_start + row_height);
+ }
+ else {
+ ctx.fillRect(draw_x_start, draw_y_start + 1, base_px, feature_height);
+ }
+ }
+ else if (variant.type === 'deletion') {
+ paint_utils.draw_deletion(draw_x_start + base_px * variant.start, draw_y_start + 1, variant.len);
+ }
+ else {
+ // TODO: handle insertions.
+ }
+ }
+ }
+ }
+
+ ctx.restore();
+ }
+});
+
+return {
+ Scaler: Scaler,
+ LinePainter: LinePainter,
+ LinkedFeaturePainter: LinkedFeaturePainter,
+ ReadPainter: ReadPainter,
+ ArcLinkedFeaturePainter: ArcLinkedFeaturePainter,
+ DiagonalHeatmapPainter: DiagonalHeatmapPainter,
+ VariantPainter: VariantPainter
+};
+
+
+});
diff --git a/client/scripts/viz/trackster/slotting.js b/client/scripts/viz/trackster/slotting.js
new file mode 100644
index 00000000000..ec6481ec413
--- /dev/null
+++ b/client/scripts/viz/trackster/slotting.js
@@ -0,0 +1,205 @@
+define( ["libs/underscore"], function( _ ) {
+
+var extend = _.extend;
+
+// HACK: LABEL_SPACING is currently duplicated between here and painters
+var LABEL_SPACING = 2,
+ PACK_SPACING = 5;
+
+/**
+ * Hold slotting information for a feature.
+ */
+var SlottedInfo = function(slot, feature) {
+ this.slot = slot;
+ this.feature = feature;
+};
+
+/**
+ * FeatureSlotter determines slots in which to draw features for vertical
+ * packing.
+ *
+ * This implementation is incremental, any feature assigned a slot will be
+ * retained for slotting future features.
+ */
+var FeatureSlotter = function (w_scale, mode, max_rows, measureText) {
+ this.slots = {};
+ this.start_end_dct = {};
+ this.w_scale = w_scale;
+ this.mode = mode;
+ this.include_label = (mode === "Pack");
+ this.max_rows = max_rows;
+ this.measureText = measureText;
+};
+
+/**
+ * Slot a set of features, `this.slots` will be updated with slots by id, and
+ * the largest slot required for the passed set of features is returned
+ */
+extend( FeatureSlotter.prototype, {
+ /**
+ * Get drawing coordinate for a feature.
+ */
+ _get_draw_coords: function(feature) {
+ // Get initial draw coordinates using w_scale.
+ var draw_start = Math.floor(feature[1] * this.w_scale),
+ draw_end = Math.ceil(feature[2] * this.w_scale),
+ f_name = feature[3],
+ text_align;
+
+ // Update start, end drawing locations to include feature name.
+ // Try to put the name on the left, if not, put on right.
+ if (f_name !== undefined && this.include_label ) {
+ // Add gap for label spacing and extra pack space padding
+ // TODO: Fix constants
+ var text_len = this.measureText(f_name).width + (LABEL_SPACING + PACK_SPACING);
+ if (draw_start - text_len >= 0) {
+ draw_start -= text_len;
+ text_align = "left";
+ } else {
+ draw_end += text_len;
+ text_align = "right";
+ }
+ }
+
+ /*
+ if (slot_num < 0) {
+
+ TODO: this is not yet working --
+ console.log(feature_uid, "looking for slot with text on the right");
+ // Slot not found. If text was on left, try on right and see
+ // if slot can be found.
+ // TODO: are there any checks we need to do to ensure that text
+ // will fit on tile?
+ if (text_align === "left") {
+ draw_start -= text_len;
+ draw_end -= text_len;
+ text_align = "right";
+ slot_num = find_slot(draw_start, draw_end);
+ }
+ if (slot_num >= 0) {
+ console.log(feature_uid, "found slot with text on the right");
+ }
+
+ }
+ */
+
+ return [draw_start, draw_end];
+ },
+
+ /**
+ * Find the first slot such that current feature doesn't overlap any other features in that slot.
+ * Returns -1 if no slot was found.
+ */
+ _find_slot: function(draw_coords) {
+ // TODO: Use a data structure for faster searching of available slots.
+ var draw_start = draw_coords[0],
+ draw_end = draw_coords[1];
+ for (var slot_num = 0; slot_num <= this.max_rows; slot_num++) {
+ var has_overlap = false,
+ slot = this.start_end_dct[slot_num];
+ if (slot !== undefined) {
+ // Iterate through features already in slot to see if current feature will fit.
+ for (var k = 0, k_len = slot.length; k < k_len; k++) {
+ var s_e = slot[k];
+ if (draw_end > s_e[0] && draw_start < s_e[1]) {
+ // There is overlap
+ has_overlap = true;
+ break;
+ }
+ }
+ }
+ if (!has_overlap) {
+ return slot_num;
+ }
+ }
+ return -1;
+ },
+
+ /**
+ * Slot features.
+ */
+ slot_features: function( features ) {
+ var start_end_dct = this.start_end_dct,
+ undone = [],
+ highest_slot = 0,
+ feature,
+ feature_uid;
+
+ // Loop through features to (a) find those that are not yet slotted and (b) update
+ // those that are slotted if new information is availabe. For (a), features already
+ // slotted (based on slotting from other tiles) will retain their current slot.
+ for (var i = 0, len = features.length; i < len; i++) {
+ feature = features[i];
+ feature_uid = feature[0];
+ var slotted_info = this.slots[feature_uid];
+
+ // Separate and handle slotted vs. unslotted features.
+ if (slotted_info) {
+ // Feature is slotted; if feature now has larger start/end coordinates,
+ // update drawing coordinates.
+ if (feature[1] < slotted_info.feature[1] || slotted_info.feature[2] < feature[2]) {
+ console.log(feature[3], slotted_info.slot, this._find_slot(this._get_draw_coords(feature)));
+ // Feature has changed (e.g. a single read now has its pair), so recalculate its
+ // drawing coordinates.
+ var old_draw_coords = this._get_draw_coords(slotted_info.feature),
+ new_draw_coords = this._get_draw_coords(feature),
+ slotted_coords = this.start_end_dct[slotted_info.slot];
+ for (var k = 0; k < slotted_coords.length; k++) {
+ var dc = slotted_coords[k];
+ if (dc[0] === old_draw_coords[0] && dc[1] === old_draw_coords[1]) {
+ // Replace old drawing coordinates with new ones.
+ slotted_coords[k] = new_draw_coords;
+ }
+ }
+ }
+ highest_slot = Math.max(highest_slot, this.slots[feature_uid].slot);
+ }
+ else {
+ undone.push(i);
+ }
+ }
+
+ // Slot unslotted features.
+
+ // Do slotting.
+ for (var i = 0, len = undone.length; i < len; i++) {
+ feature = features[undone[i]];
+ feature_uid = feature[0];
+ var draw_coords = this._get_draw_coords(feature);
+
+ // Find slot.
+ var slot_num = this._find_slot(draw_coords);
+
+ // Do slotting.
+ if (slot_num >= 0) {
+ // Add current feature to slot.
+ if (start_end_dct[slot_num] === undefined) {
+ start_end_dct[slot_num] = [];
+ }
+ start_end_dct[slot_num].push(draw_coords);
+ this.slots[feature_uid] = new SlottedInfo(slot_num, feature);
+ highest_slot = Math.max(highest_slot, slot_num);
+ }
+ }
+
+ // Debugging: view slots data.
+ /*
+ for (var i = 0; i < MAX_FEATURE_DEPTH; i++) {
+ var slot = start_end_dct[i];
+ if (slot !== undefined) {
+ console.log(i, "*************");
+ for (var k = 0, k_len = slot.length; k < k_len; k++) {
+ console.log("\t", slot[k][0], slot[k][1]);
+ }
+ }
+ }
+ */
+ return highest_slot + 1;
+ }
+});
+
+return {
+ FeatureSlotter: FeatureSlotter
+};
+
+});
diff --git a/client/scripts/viz/trackster/tracks.js b/client/scripts/viz/trackster/tracks.js
new file mode 100644
index 00000000000..aa720e43d5f
--- /dev/null
+++ b/client/scripts/viz/trackster/tracks.js
@@ -0,0 +1,4238 @@
+define( ["libs/underscore", "viz/visualization", "viz/viz_views", "viz/trackster/util",
+ "viz/trackster/slotting", "viz/trackster/painters", "viz/trackster/filters",
+ "mvc/data", "mvc/tools", "utils/config" ],
+ function(_, visualization, viz_views, util, slotting, painters, filters_mod, data, tools_mod, config_mod) {
+
+var extend = _.extend;
+
+// ---- Web UI specific utilities ----
+
+/**
+ * Dictionary of HTML element-JavaScript object relationships.
+ */
+// TODO: probably should separate moveable objects from containers.
+var html_elt_js_obj_dict = {};
+
+/**
+ * Designates an HTML as a container.
+ */
+var is_container = function(element, obj) {
+ html_elt_js_obj_dict[element.attr("id")] = obj;
+};
+
+/**
+ * Make `element` moveable within parent and sibling elements by dragging `handle` (a selector).
+ * Function manages JS objects, containers as well.
+ *
+ * @param element HTML element to make moveable
+ * @param handle_class classname that denotes HTML element to be used as handle
+ * @param container_selector selector used to identify possible containers for this element
+ * @param element_js_obj JavaScript object associated with element; used
+ */
+var moveable = function(element, handle_class, container_selector, element_js_obj) {
+ // HACK: set default value for container selector.
+ container_selector = ".group";
+
+ // Register element with its object.
+ html_elt_js_obj_dict[element.attr("id")] = element_js_obj;
+
+ // Need to provide selector for handle, not class.
+ element.bind( "drag", { handle: "." + handle_class, relative: true }, function ( e, d ) {
+ var element = $(this),
+ parent = $(this).parent(),
+ // Only sorting amongst tracks and groups.
+ children = parent.children('.track,.group'),
+ this_obj = html_elt_js_obj_dict[$(this).attr("id")],
+ child,
+ container,
+ top,
+ bottom,
+ i;
+
+ //
+ // Enable three types of dragging: (a) out of container; (b) into container;
+ // (c) sibling movement, aka sorting. Handle in this order for simplicity.
+ //
+
+ // Handle dragging out of container.
+ container = $(this).parents(container_selector);
+ if (container.length !== 0) {
+ top = container.position().top;
+ bottom = top + container.outerHeight();
+ var cur_container = html_elt_js_obj_dict[container.attr("id")];
+ if (d.offsetY < top) {
+ // Moving above container.
+ $(this).insertBefore(container);
+ cur_container.remove_drawable(this_obj);
+ cur_container.container.add_drawable_before(this_obj, cur_container);
+ return;
+ }
+ else if (d.offsetY > bottom) {
+ // Moving below container.
+ $(this).insertAfter(container);
+ cur_container.remove_drawable(this_obj);
+ cur_container.container.add_drawable(this_obj);
+ return;
+ }
+ }
+
+ // Handle dragging into container. Child is appended to container's content_div.
+ container = null;
+ for ( i = 0; i < children.length; i++ ) {
+ child = $(children.get(i));
+ top = child.position().top;
+ bottom = top + child.outerHeight();
+ // Dragging into container if child is a container and offset is inside container.
+ if ( child.is(container_selector) && this !== child.get(0) &&
+ d.offsetY >= top && d.offsetY <= bottom ) {
+ // Append/prepend based on where offsetY is closest to and return.
+ if (d.offsetY - top < bottom - d.offsetY) {
+ child.find(".content-div").prepend(this);
+ }
+ else {
+ child.find(".content-div").append(this);
+ }
+ // Update containers. Object may not have container if it is being moved quickly.
+ if (this_obj.container) {
+ this_obj.container.remove_drawable(this_obj);
+ }
+ html_elt_js_obj_dict[child.attr("id")].add_drawable(this_obj);
+ return;
+ }
+ }
+
+ // Handle sibling movement, aka sorting.
+
+ // Determine new position
+ for ( i = 0; i < children.length; i++ ) {
+ child = $(children.get(i));
+ if ( d.offsetY < child.position().top &&
+ // Cannot move tracks above reference track or intro div.
+ !(child.hasClass("reference-track") || child.hasClass("intro")) ) {
+ break;
+ }
+ }
+
+ // If not already in the right place, move. Need
+ // to handle the end specially since we don't have
+ // insert at index
+ if ( i === children.length ) {
+ if ( this !== children.get(i - 1) ) {
+ parent.append(this);
+ html_elt_js_obj_dict[parent.attr("id")].move_drawable(this_obj, i);
+ }
+ }
+ else if ( this !== children.get(i) ) {
+ $(this).insertBefore( children.get(i) );
+ // Need to adjust insert position if moving down because move is changing
+ // indices of all list items.
+ html_elt_js_obj_dict[parent.attr("id")].move_drawable(this_obj, (d.deltaY > 0 ? i-1 : i) );
+ }
+ }).bind("dragstart", function() {
+ $(this).addClass('dragging');
+ }).bind("dragend", function() {
+ $(this).removeClass('dragging');
+ });
+};
+
+/**
+ * Init constants & functions used throughout trackster.
+ */
+var
+ // Padding at the top of tracks for error messages
+ ERROR_PADDING = 20,
+ // Maximum number of rows un a slotted track
+ MAX_FEATURE_DEPTH = 100,
+ // Minimum width for window for squish to be used.
+ MIN_SQUISH_VIEW_WIDTH = 12000,
+ // Number of pixels per tile, not including left offset.
+ TILE_SIZE = 400,
+ DEFAULT_DATA_QUERY_WAIT = 5000,
+ // Maximum number of chromosomes that are selectable at any one time.
+ MAX_CHROMS_SELECTABLE = 100,
+ DATA_ERROR = "Cannot display dataset due to an error. ",
+ DATA_NOCONVERTER = "A converter for this dataset is not installed. Please check your datatypes_conf.xml file.",
+ DATA_NONE = "No data for this chrom/contig.",
+ DATA_PENDING = "Preparing data. This can take a while for a large dataset. " +
+ "If the visualization is saved and closed, preparation will continue in the background.",
+ DATA_CANNOT_RUN_TOOL = "Tool cannot be rerun: ",
+ DATA_LOADING = "Loading data...",
+ DATA_OK = "Ready for display",
+ TILE_CACHE_SIZE = 10,
+ DATA_CACHE_SIZE = 20,
+
+ // Numerical/continuous data display modes.
+ CONTINUOUS_DATA_MODES = ["Histogram", "Line", "Filled", "Intensity"];
+
+/**
+ * Round a number to a given number of decimal places.
+ */
+function round(num, places) {
+ // Default rounding is to integer.
+ if (!places) {
+ places = 0;
+ }
+
+ var val = Math.pow(10, places);
+ return Math.round(num * val) / val;
+}
+
+/**
+ * Drawables hierarchy:
+ *
+ * Drawable
+ * --> DrawableCollection
+ * --> DrawableGroup
+ * --> View
+ * --> Track
+ */
+
+/**
+ * Base class for all drawable objects. Drawable objects are associated with a view and live in a
+ * container. They have the following HTML elements and structure:
+ *
+ *
+ *
+ *
+ * They optionally have a drag handle class.
+ */
+var Drawable = function(view, container, obj_dict) {
+ if (!Drawable.id_counter) { Drawable.id_counter = 0; }
+ this.id = Drawable.id_counter++;
+ this.view = view;
+ this.container = container;
+ this.drag_handle_class = obj_dict.drag_handle_class;
+ this.is_overview = false;
+ this.action_icons = {};
+
+ // -- Set up drawable configuration. --
+
+ this.config = config_mod.ConfigSettingCollection.from_models_and_saved_values(this.config_params, obj_dict.prefs);
+
+ // If there's no saved name, use object name.
+ if (!this.config.get_value('name')) {
+ this.config.set_value('name', obj_dict.name);
+ }
+ if (this.config_onchange) {
+ this.config.on('change', this.config_onchange, this);
+ }
+
+ // Build Drawable HTML and behaviors.
+ this.container_div = this.build_container_div();
+ this.header_div = null;
+
+ // Use opt-out policy on header creation because this is the more frequent approach:
+ // unless flag set, create header.
+ if (obj_dict.header !== false) {
+ var header_view = new viz_views.TrackHeaderView({
+ model: this,
+ id: this.id
+ });
+
+ this.header_div = header_view.$el;
+ this.container_div.append(this.header_div);
+
+ // Show icons when users is hovering over track.
+ var icons_div = header_view.icons_div;
+ this.action_icons = header_view.action_icons;
+ this.container_div.hover(
+ function() { icons_div.show(); }, function() { icons_div.hide(); }
+ );
+ }
+};
+
+Drawable.prototype.action_icons_def = [
+ // Hide/show drawable content.
+ // FIXME: make this an odict for easier lookup.
+ {
+ name: "toggle_icon",
+ title: "Hide/show content",
+ css_class: "toggle",
+ on_click_fn: function(drawable) {
+ if ( drawable.config.get_value('content_visible') ) {
+ drawable.action_icons.toggle_icon.addClass("toggle-expand").removeClass("toggle");
+ drawable.hide_contents();
+ drawable.config.set_value('content_visible', false);
+ }
+ else {
+ drawable.action_icons.toggle_icon.addClass("toggle").removeClass("toggle-expand");
+ drawable.config.set_value('content_visible', true);
+ drawable.show_contents();
+ }
+ }
+ },
+ // Edit settings.
+ {
+ name: "settings_icon",
+ title: "Edit settings",
+ css_class: "gear",
+ on_click_fn: function(drawable) {
+ var view = new config_mod.ConfigSettingCollectionView({
+ collection: drawable.config
+ });
+ view.render_in_modal('Configure Track');
+ }
+ },
+ // Remove.
+ {
+ name: "remove_icon",
+ title: "Remove",
+ css_class: "remove-icon",
+ on_click_fn: function(drawable) {
+ // Tooltip for remove icon must be deleted when drawable is deleted.
+ $(".tooltip").remove();
+ drawable.remove();
+ }
+ }
+];
+
+extend(Drawable.prototype, {
+ config_params: [
+ { key: 'name', label: 'Name', type: 'text', default_value: '' },
+ { key: 'content_visible', type: 'bool', default_value: true, hidden: true }
+ ],
+
+ config_onchange: function() {},
+
+ init: function() {},
+
+ changed: function() {
+ this.view.changed();
+ },
+
+ can_draw: function() {
+ if (this.enabled && this.config.get_value('content_visible')) {
+ return true;
+ }
+
+ return false;
+ },
+
+ request_draw: function() {},
+
+ _draw: function(options) {},
+
+ /**
+ * Returns representation of object in a dictionary for easy saving.
+ * Use from_dict to recreate object.
+ */
+ to_dict: function() {},
+
+ /**
+ * Set drawable name.
+ */
+ set_name: function(new_name) {
+ this.old_name = this.config.get_value('name');
+ this.config.set_value('name', new_name);
+ },
+
+ /**
+ * Revert track name; currently name can be reverted only once.
+ */
+ revert_name: function() {
+ if (this.old_name) {
+ this.config.set_value('name', this.old_name);
+ }
+ },
+
+ /**
+ * Remove drawable (a) from its container and (b) from the HTML.
+ */
+ remove: function() {
+ this.changed();
+
+ this.container.remove_drawable(this);
+ var view = this.view;
+ this.container_div.hide(0, function() {
+ $(this).remove();
+ // HACK: is there a better way to update the view?
+ view.update_intro_div();
+ });
+ },
+
+ /**
+ * Build drawable's container div; this is the parent div for all drawable's elements.
+ */
+ build_container_div: function() {},
+
+ /**
+ * Update icons.
+ */
+ update_icons: function() {},
+
+ /**
+ * Hide drawable's contents.
+ */
+ hide_contents: function () {},
+
+ /**
+ * Show drawable's contents.
+ */
+ show_contents: function() {},
+
+ /**
+ * Returns a shallow copy of all drawables in this drawable.
+ */
+ get_drawables: function() {}
+});
+
+/**
+ * A collection of drawable objects.
+ */
+var DrawableCollection = function(view, container, obj_dict) {
+ Drawable.call(this, view, container, obj_dict);
+
+ // Attribute init.
+ this.obj_type = obj_dict.obj_type;
+ this.drawables = [];
+};
+
+extend(DrawableCollection.prototype, Drawable.prototype, {
+ /**
+ * Unpack and add drawables to the collection.
+ */
+ unpack_drawables: function(drawables_array) {
+ // Add drawables to collection.
+ this.drawables = [];
+ var drawable;
+ for (var i = 0; i < drawables_array.length; i++) {
+ drawable = object_from_template(drawables_array[i], this.view, this);
+ this.add_drawable(drawable);
+ }
+ },
+
+ /**
+ * Init each drawable in the collection.
+ */
+ init: function() {
+ for (var i = 0; i < this.drawables.length; i++) {
+ this.drawables[i].init();
+ }
+ },
+
+ /**
+ * Draw each drawable in the collection.
+ */
+ _draw: function(options) {
+ for (var i = 0; i < this.drawables.length; i++) {
+ this.drawables[i]._draw(options);
+ }
+ },
+
+ /**
+ * Returns representation of object in a dictionary for easy saving.
+ * Use from_dict to recreate object.
+ */
+ to_dict: function() {
+ var dictified_drawables = [];
+ for (var i = 0; i < this.drawables.length; i++) {
+ dictified_drawables.push(this.drawables[i].to_dict());
+ }
+ return {
+ prefs: this.config.to_key_value_dict(),
+ obj_type: this.obj_type,
+ drawables: dictified_drawables
+ };
+ },
+
+ /**
+ * Add a drawable to the end of the collection.
+ */
+ add_drawable: function(drawable) {
+ this.drawables.push(drawable);
+ drawable.container = this;
+ this.changed();
+ },
+
+ /**
+ * Add a drawable before another drawable.
+ */
+ add_drawable_before: function(drawable, other) {
+ this.changed();
+ var index = this.drawables.indexOf(other);
+ if (index !== -1) {
+ this.drawables.splice(index, 0, drawable);
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Replace one drawable with another.
+ */
+ replace_drawable: function(old_drawable, new_drawable, update_html) {
+ var index = this.drawables.indexOf(old_drawable);
+ if (index !== -1) {
+ this.drawables[index] = new_drawable;
+ if (update_html) {
+ old_drawable.container_div.replaceWith(new_drawable.container_div);
+ }
+ this.changed();
+ }
+ return index;
+ },
+
+ /**
+ * Remove drawable from this collection.
+ */
+ remove_drawable: function(drawable) {
+ var index = this.drawables.indexOf(drawable);
+ if (index !== -1) {
+ // Found drawable to remove.
+ this.drawables.splice(index, 1);
+ drawable.container = null;
+ this.changed();
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Move drawable to another location in collection.
+ */
+ move_drawable: function(drawable, new_position) {
+ var index = this.drawables.indexOf(drawable);
+ if (index !== -1) {
+ // Remove from current position:
+ this.drawables.splice(index, 1);
+ // insert into new position:
+ this.drawables.splice(new_position, 0, drawable);
+ this.changed();
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Returns all drawables in this drawable.
+ */
+ get_drawables: function() {
+ return this.drawables;
+ },
+
+ /**
+ * Returns all tracks in collection.
+ */
+ get_tracks: function(track_type) {
+ // Initialize queue with copy of drawables array.
+ var queue = this.drawables.slice(0),
+ tracks = [],
+ drawable;
+ while (queue.length !== 0) {
+ drawable = queue.shift();
+ if (drawable instanceof track_type) {
+ tracks.push(drawable);
+ }
+ else if (drawable.drawables) {
+ queue = queue.concat(drawable.drawables);
+ }
+ }
+ return tracks;
+ }
+});
+
+/**
+ * A group of drawables that are moveable, visible.
+ */
+var DrawableGroup = function(view, container, obj_dict) {
+ extend(obj_dict, {
+ obj_type: "DrawableGroup",
+ drag_handle_class: "group-handle"
+ });
+ DrawableCollection.call(this, view, container, obj_dict);
+
+ // Set up containers/moving for group: register both container_div and content div as container
+ // because both are used as containers (container div to recognize container, content_div to
+ // store elements). Group can be moved.
+ this.content_div = $("
").addClass("content-div").attr("id", "group_" + this.id + "_content_div").appendTo(this.container_div);
+ is_container(this.container_div, this);
+ is_container(this.content_div, this);
+ moveable(this.container_div, this.drag_handle_class, ".group", this);
+
+ // Set up filters.
+ this.filters_manager = new filters_mod.FiltersManager(this);
+ this.header_div.after(this.filters_manager.parent_div);
+
+ // HACK: add div to clear floating elements.
+ this.filters_manager.parent_div.after( $("
") );
+
+ // For saving drawables' filter managers when group-level filtering is done:
+ this.saved_filters_managers = [];
+
+ // Add drawables.
+ if ('drawables' in obj_dict) {
+ this.unpack_drawables(obj_dict.drawables);
+ }
+
+ // Restore filters.
+ if ('filters' in obj_dict) {
+ // FIXME: Pass collection_dict to DrawableCollection/Drawable will make this easier.
+ var old_manager = this.filters_manager;
+ this.filters_manager = new filters_mod.FiltersManager(this, obj_dict.filters);
+ old_manager.parent_div.replaceWith(this.filters_manager.parent_div);
+
+ if (obj_dict.filters.visible) {
+ this.setup_multitrack_filtering();
+ }
+ }
+};
+
+extend(DrawableGroup.prototype, Drawable.prototype, DrawableCollection.prototype, {
+ action_icons_def: [
+ Drawable.prototype.action_icons_def[0],
+ Drawable.prototype.action_icons_def[1],
+ // Replace group with composite track.
+ {
+ name: "composite_icon",
+ title: "Show composite track",
+ css_class: "layers-stack",
+ on_click_fn: function(group) {
+ $(".tooltip").remove();
+ group.show_composite_track();
+ }
+ },
+ // Toggle track filters.
+ {
+ name: "filters_icon",
+ title: "Filters",
+ css_class: "ui-slider-050",
+ on_click_fn: function(group) {
+ // TODO: update Tooltip text.
+ if (group.filters_manager.visible()) {
+ // Hiding filters.
+ group.filters_manager.clear_filters();
+ group._restore_filter_managers();
+ // TODO: maintain current filter by restoring and setting saved manager's
+ // settings to current/shared manager's settings.
+ // TODO: need to restore filter managers when moving drawable outside group.
+ }
+ else {
+ // Showing filters.
+ group.setup_multitrack_filtering();
+ group.request_draw({ clear_tile_cache: true });
+ }
+ group.filters_manager.toggle();
+ }
+ },
+ Drawable.prototype.action_icons_def[2]
+ ],
+
+ build_container_div: function() {
+ var container_div = $("
").addClass("group").attr("id", "group_" + this.id);
+ if (this.container) {
+ this.container.content_div.append(container_div);
+ }
+ return container_div;
+ },
+
+ hide_contents: function () {
+ this.tiles_div.hide();
+ },
+
+ show_contents: function() {
+ // Show the contents div and labels (if present)
+ this.tiles_div.show();
+ // Request a redraw of the content
+ this.request_draw();
+ },
+
+ update_icons: function() {
+ //
+ // Handle update when there are no tracks.
+ //
+ var num_drawables = this.drawables.length;
+ if (num_drawables === 0) {
+ this.action_icons.composite_icon.hide();
+ this.action_icons.filters_icon.hide();
+ }
+ else if (num_drawables === 1) {
+ this.action_icons.composite_icon.toggle(this.drawables[0] instanceof CompositeTrack);
+ this.action_icons.filters_icon.hide();
+ }
+ else { // There are 2 or more tracks.
+
+ //
+ // Determine if a composite track can be created. Current criteria:
+ // (a) all tracks are line tracks;
+ // OR
+ // FIXME: this is not enabled right now because it has not been well tested:
+ // (b) there is a single FeatureTrack.
+ //
+
+ // All tracks the same?
+ var i, j, drawable,
+ same_type = true,
+ a_type = this.drawables[0].get_type(),
+ num_feature_tracks = 0;
+ for (i = 0; i < num_drawables; i++) {
+ drawable = this.drawables[i];
+ if (drawable.get_type() !== a_type) {
+ can_composite = false;
+ break;
+ }
+ if (drawable instanceof FeatureTrack) {
+ num_feature_tracks++;
+ }
+ }
+
+ if (same_type && this.drawables[0] instanceof LineTrack) {
+ this.action_icons.composite_icon.show();
+ }
+ else {
+ this.action_icons.composite_icon.hide();
+ $(".tooltip").remove();
+ }
+
+ //
+ // Set up group-level filtering and update filter icon.
+ //
+ if (num_feature_tracks > 1 && num_feature_tracks === this.drawables.length) {
+ //
+ // Find shared filters.
+ //
+ var shared_filters = {},
+ filter;
+
+ // Init shared filters with filters from first drawable.
+ drawable = this.drawables[0];
+ for (j = 0; j < drawable.filters_manager.filters.length; j++) {
+ filter = drawable.filters_manager.filters[j];
+ shared_filters[filter.name] = [filter];
+ }
+
+ // Create lists of shared filters.
+ for (i = 1; i < this.drawables.length; i++) {
+ drawable = this.drawables[i];
+ for (j = 0; j < drawable.filters_manager.filters.length; j++) {
+ filter = drawable.filters_manager.filters[j];
+ if (filter.name in shared_filters) {
+ shared_filters[filter.name].push(filter);
+ }
+ }
+ }
+
+ //
+ // Create filters for shared filters manager. Shared filters manager is group's
+ // manager.
+ //
+ this.filters_manager.remove_all();
+ var
+ filters,
+ new_filter,
+ min,
+ max;
+ for (var filter_name in shared_filters) {
+ filters = shared_filters[filter_name];
+ if (filters.length === num_feature_tracks) {
+ // Add new filter.
+ // FIXME: can filter.copy() be used?
+ new_filter = new filters_mod.NumberFilter( {
+ name: filters[0].name,
+ index: filters[0].index
+ } );
+ this.filters_manager.add_filter(new_filter);
+ }
+ }
+
+ // Show/hide icon based on filter availability.
+ this.action_icons.filters_icon.toggle(this.filters_manager.filters.length > 0);
+ }
+ else {
+ this.action_icons.filters_icon.hide();
+ }
+ }
+ },
+
+ /**
+ * Restore individual track filter managers.
+ */
+ _restore_filter_managers: function() {
+ for (var i = 0; i < this.drawables.length; i++) {
+ this.drawables[i].filters_manager = this.saved_filters_managers[i];
+ }
+ this.saved_filters_managers = [];
+ },
+
+ /**
+ *
+ */
+ setup_multitrack_filtering: function() {
+ // Save tracks' managers and set up shared manager.
+ if (this.filters_manager.filters.length > 0) {
+ // For all tracks, save current filter manager and set manager to shared (this object's) manager.
+ this.saved_filters_managers = [];
+ for (var i = 0; i < this.drawables.length; i++) {
+ drawable = this.drawables[i];
+ this.saved_filters_managers.push(drawable.filters_manager);
+ drawable.filters_manager = this.filters_manager;
+ }
+
+ //TODO: hide filters icons for each drawable?
+ }
+ this.filters_manager.init_filters();
+ },
+
+ /**
+ * Replace group with a single composite track that includes all group's tracks.
+ */
+ show_composite_track: function() {
+ var composite_track = new CompositeTrack(this.view, this.view, {
+ name: this.config.get_value('name'),
+ drawables: this.drawables
+ });
+ var index = this.container.replace_drawable(this, composite_track, true);
+ composite_track.request_draw();
+ },
+
+ add_drawable: function(drawable) {
+ DrawableCollection.prototype.add_drawable.call(this, drawable);
+ this.update_icons();
+ },
+
+ remove_drawable: function(drawable) {
+ DrawableCollection.prototype.remove_drawable.call(this, drawable);
+ this.update_icons();
+ },
+
+ to_dict: function() {
+ // If filters are visible, need to restore original filter managers before converting to dict.
+ if (this.filters_manager.visible()) {
+ this._restore_filter_managers();
+ }
+
+ var obj_dict = extend(DrawableCollection.prototype.to_dict.call(this), { "filters": this.filters_manager.to_dict() });
+
+ // Setup multi-track filtering again.
+ if (this.filters_manager.visible()) {
+ this.setup_multitrack_filtering();
+ }
+
+ return obj_dict;
+ },
+
+ request_draw: function(options) {
+ _.each(this.drawables, function(d) {
+ d.request_draw(options);
+ });
+ }
+});
+
+/**
+ * View object manages a trackster visualization, including tracks and user interactions.
+ * Events triggered:
+ * navigate: when browser view changes to a new locations
+ */
+var TracksterView = Backbone.View.extend({
+
+ initialize: function(obj_dict) {
+ extend(obj_dict, {
+ obj_type: "View"
+ });
+ DrawableCollection.call(this, "View", obj_dict.container, obj_dict);
+ this.chrom = null;
+ this.vis_id = obj_dict.vis_id;
+ this.dbkey = obj_dict.dbkey;
+ this.label_tracks = [];
+ this.tracks_to_be_redrawn = [];
+ this.max_low = 0;
+ this.max_high = 0;
+ this.zoom_factor = 3;
+ this.min_separation = 30;
+ this.has_changes = false;
+ // Deferred object that indicates when view's chrom data has been loaded.
+ this.load_chroms_deferred = null;
+ this.render();
+ this.canvas_manager = new visualization.CanvasManager( this.container.get(0).ownerDocument );
+ this.reset();
+
+ // Define track configuration
+ this.config = config_mod.ConfigSettingCollection.from_models_and_saved_values( [
+ { key: 'name', label: 'Name', type: 'text', default_value: '' },
+ { key: 'a_color', label: 'A Color', type: 'color', default_value: "#FF0000" },
+ { key: 'c_color', label: 'C Color', type: 'color', default_value: "#00FF00" },
+ { key: 'g_color', label: 'G Color', type: 'color', default_value: "#0000FF" },
+ { key: 't_color', label: 'T Color', type: 'color', default_value: "#FF00FF" },
+ { key: 'n_color', label: 'N Color', type: 'color', default_value: "#AAAAAA" }
+ ], { name: obj_dict.name });
+ },
+
+ render: function() {
+ // Attribute init.
+ this.requested_redraw = false;
+
+ // Create DOM elements
+ var parent_element = this.container,
+ view = this;
+ // Top container for things that are fixed at the top
+ this.top_container = $("
").addClass("top-container").appendTo(parent_element);
+ // Browser content, primary tracks are contained in here
+ this.browser_content_div = $("
").addClass("content").appendTo(parent_element);
+ // Bottom container for things that are fixed at the bottom
+ this.bottom_container = $("
").addClass("bottom-container").appendTo(parent_element);
+ // Label track fixed at top
+ this.top_labeltrack = $("
").addClass("top-labeltrack").appendTo(this.top_container);
+ // Viewport for dragging tracks in center
+ this.viewport_container = $("
").addClass("viewport-container").attr("id", "viewport-container").appendTo(this.browser_content_div);
+ // Alias viewport_container as content_div so that it matches function of DrawableCollection/Group content_div.
+ this.content_div = this.viewport_container;
+ is_container(this.viewport_container, view);
+ // Introduction div shown when there are no tracks.
+ this.intro_div = $("
").addClass("intro").appendTo(this.viewport_container);
+ var add_tracks_button = $("
").text("Add Datasets to Visualization").addClass("action-button").appendTo(this.intro_div).click(function () {
+ visualization.select_datasets(galaxy_config.root + "visualization/list_current_history_datasets", galaxy_config.root + "api/datasets", { 'f-dbkey': view.dbkey }, function(tracks) {
+ _.each(tracks, function(track) {
+ view.add_drawable( object_from_template(track, view, view) );
+ });
+ });
+ });
+ // Navigation at top
+ this.nav_container = $("
").addClass("trackster-nav-container").prependTo(this.top_container);
+ this.nav = $("
").addClass("trackster-nav").appendTo(this.nav_container);
+ // Overview (scrollbar and overview plot) at bottom
+ this.overview = $("
").addClass("overview").appendTo(this.bottom_container);
+ this.overview_viewport = $("
").addClass("overview-viewport").appendTo(this.overview);
+ this.overview_close = $(" ").attr("title", "Close overview")
+ .addClass("icon-button overview-close tooltip")
+ .hide()
+ .appendTo(this.overview_viewport);
+ this.overview_highlight = $("
").addClass("overview-highlight").hide().appendTo(this.overview_viewport);
+ this.overview_box_background = $("
").addClass("overview-boxback").appendTo(this.overview_viewport);
+ this.overview_box = $("
").addClass("overview-box").appendTo(this.overview_viewport);
+ this.default_overview_height = this.overview_box.height();
+
+ this.nav_controls = $("
").addClass("nav-controls").appendTo(this.nav);
+ this.chrom_select = $(" ").attr({ "name": "chrom"}).addClass('chrom-nav').append("Loading ").appendTo(this.nav_controls);
+ var submit_nav = function(e) {
+ if (e.type === "focusout" || (e.keyCode || e.which) === 13 || (e.keyCode || e.which) === 27 ) {
+ if ((e.keyCode || e.which) !== 27) { // Not escape key
+ view.go_to( $(this).val() );
+ }
+ $(this).hide();
+ $(this).val('');
+ view.location_span.show();
+ view.chrom_select.show();
+ }
+
+ // Suppress key presses so that they do impact viz.
+ e.stopPropagation();
+ };
+ this.nav_input = $(" ").addClass("nav-input").hide().bind("keyup focusout", submit_nav).appendTo(this.nav_controls);
+ this.location_span = $(" ").addClass("location").attr('title', 'Click to change location').tooltip( { placement: 'bottom' } ).appendTo(this.nav_controls);
+ this.location_span.click(function() {
+ view.location_span.hide();
+ view.chrom_select.hide();
+ view.nav_input.val(view.chrom + ":" + view.low + "-" + view.high);
+ view.nav_input.css("display", "inline-block");
+ view.nav_input.select();
+ view.nav_input.focus();
+ // Set up autocomplete for tracks' features.
+ view.nav_input.autocomplete({
+ source: function(request, response) {
+ // Using current text, query each track and create list of all matching features.
+ var all_features = [],
+ feature_search_deferreds = $.map(view.get_tracks(FeatureTrack), function(t) {
+ return t.data_manager.search_features(request.term).success(function(dataset_features) {
+ all_features = all_features.concat(dataset_features);
+ });
+ });
+
+ // When all searching is done, fill autocomplete.
+ $.when.apply($, feature_search_deferreds).done(function() {
+ response($.map(all_features, function(feature) {
+ return {
+ label: feature[0],
+ value: feature[1]
+ };
+ }));
+ });
+ },
+ minLength: 2
+ });
+ });
+ if (this.vis_id !== undefined) {
+ this.hidden_input = $(" ").attr("type", "hidden").val(this.vis_id).appendTo(this.nav_controls);
+ }
+
+ this.zo_link = $(" ").attr("id", "zoom-out").attr("title", "Zoom out").tooltip( {placement: 'bottom'} )
+ .click(function() { view.zoom_out(); }).appendTo(this.nav_controls);
+ this.zi_link = $(" ").attr("id", "zoom-in").attr("title", "Zoom in").tooltip( {placement: 'bottom'} )
+ .click(function() { view.zoom_in(); }).appendTo(this.nav_controls);
+
+ // Get initial set of chroms.
+ this.load_chroms_deferred = this.load_chroms({low: 0});
+ this.chrom_select.bind("change", function() {
+ view.change_chrom(view.chrom_select.val());
+ });
+
+ /*
+ this.browser_content_div.bind("mousewheel", function( e, delta ) {
+ if (Math.abs(delta) < 0.5) {
+ return;
+ }
+ if (delta > 0) {
+ view.zoom_in(e.pageX, this.viewport_container);
+ } else {
+ view.zoom_out();
+ }
+ e.preventDefault();
+ });
+ */
+
+ // Blur tool/filter inputs when user clicks on content div.
+ this.browser_content_div.click(function( e ) {
+ $(this).find("input").trigger("blur");
+ });
+
+ // Double clicking zooms in
+ this.browser_content_div.bind("dblclick", function( e ) {
+ view.zoom_in(e.pageX, this.viewport_container);
+ });
+
+ // Dragging the overview box (~ horizontal scroll bar)
+ this.overview_box.bind("dragstart", function( e, d ) {
+ this.current_x = d.offsetX;
+ }).bind("drag", function( e, d ) {
+ var delta = d.offsetX - this.current_x;
+ this.current_x = d.offsetX;
+ var delta_chrom = Math.round(delta / view.viewport_container.width() * (view.max_high - view.max_low) );
+ view.move_delta(-delta_chrom);
+ });
+
+ this.overview_close.click(function() {
+ view.reset_overview();
+ });
+
+ // Dragging in the viewport scrolls
+ this.viewport_container.bind( "draginit", function( e, d ) {
+ // Disable interaction if started in scrollbar (for webkit)
+ if ( e.clientX > view.viewport_container.width() - 16 ) {
+ return false;
+ }
+ }).bind( "dragstart", function( e, d ) {
+ d.original_low = view.low;
+ d.current_height = e.clientY;
+ d.current_x = d.offsetX;
+ }).bind( "drag", function( e, d ) {
+ var container = $(this);
+ var delta = d.offsetX - d.current_x;
+ var new_scroll = container.scrollTop() - (e.clientY - d.current_height);
+ container.scrollTop(new_scroll);
+ d.current_height = e.clientY;
+ d.current_x = d.offsetX;
+ var delta_chrom = Math.round(delta / view.viewport_container.width() * (view.high - view.low));
+ view.move_delta(delta_chrom);
+ });
+ /*
+ FIXME: Do not do this for now because it's too jittery. Some kind of gravity approach is
+ needed here because moving left/right should be difficult.
+
+ // Also capture mouse wheel for left/right scrolling
+ }).bind( 'mousewheel', function( e, d, dx, dy ) {
+ // Only handle x axis scrolling; y axis scrolling is
+ // handled by the browser when the event bubbles up.
+ if (dx) {
+ var delta_chrom = Math.round( - dx / view.viewport_container.width() * (view.high - view.low) );
+ view.move_delta( delta_chrom );
+ }
+ });
+ */
+
+ // Dragging in the top label track allows selecting a region to zoom in on selected region.
+ this.top_labeltrack.bind( "dragstart", function( e, d ) {
+ return $("
").addClass('zoom-area').css(
+ "height", view.browser_content_div.height() + view.top_labeltrack.height() + 1
+ ).appendTo( $(this) );
+ }).bind( "drag", function( e, d ) {
+ $( d.proxy ).css({ left: Math.min( e.pageX, d.startX ) - view.container.offset().left, width: Math.abs( e.pageX - d.startX ) });
+ var min = Math.min(e.pageX, d.startX ) - view.container.offset().left,
+ max = Math.max(e.pageX, d.startX ) - view.container.offset().left,
+ span = (view.high - view.low),
+ width = view.viewport_container.width();
+ view.update_location( Math.round(min / width * span) + view.low,
+ Math.round(max / width * span) + view.low );
+ }).bind( "dragend", function( e, d ) {
+ var min = Math.min(e.pageX, d.startX),
+ max = Math.max(e.pageX, d.startX),
+ span = (view.high - view.low),
+ width = view.viewport_container.width(),
+ old_low = view.low;
+ view.low = Math.round(min / width * span) + old_low;
+ view.high = Math.round(max / width * span) + old_low;
+ $(d.proxy).remove();
+ view.request_redraw();
+ });
+
+ // FIXME: this is still wonky for embedded visualizations.
+ /*
+ // For vertical alignment, track mouse with simple line.
+ var mouse_tracker_div = $('
').addClass('mouse-pos').appendTo(parent_element);
+
+ // Show tracker only when hovering over view.
+ parent_element.hover(
+ function() {
+ mouse_tracker_div.show();
+ parent_element.mousemove(function(e) {
+ mouse_tracker_div.css({
+ // -1 makes line appear next to the mouse w/o preventing mouse actions.
+ left: e.pageX - parent_element.offset().left - 1
+ });
+ });
+ },
+ function() {
+ parent_element.off('mousemove');
+ mouse_tracker_div.hide();
+ }
+ );
+ */
+
+ this.add_label_track( new LabelTrack( this, { content_div: this.top_labeltrack } ) );
+
+ $(window).bind("resize", function() {
+ // Stop previous timer.
+ if (this.resize_timer) {
+ clearTimeout(this.resize_timer);
+ }
+
+ // When function activated, resize window and redraw.
+ this.resize_timer = setTimeout(function () {
+ view.resize_window();
+ }, 500 );
+ });
+ $(document).bind("redraw", function() { view.redraw(); });
+
+ this.reset();
+ $(window).trigger("resize");
+ },
+
+ get_base_color: function(base) {
+ return this.config.get_value(base.toLowerCase() + '_color') ||
+ this.config.get_value('n_color');
+ }
+
+});
+
+// FIXME: need to use this approach to enable inheritance of DrawableCollection functions.
+extend( TracksterView.prototype, DrawableCollection.prototype, {
+
+ changed: function() {
+ this.has_changes = true;
+ },
+
+ /** Add or remove intro div depending on view state. */
+ update_intro_div: function() {
+ this.intro_div.toggle(this.drawables.length === 0);
+ },
+
+ /**
+ * Triggers navigate events as needed. If there is a delay,
+ * then event is triggered only after navigation has stopped.
+ */
+ trigger_navigate: function(new_chrom, new_low, new_high, delay) {
+ // Stop previous timer.
+ if (this.timer) {
+ clearTimeout(this.timer);
+ }
+
+ if (delay) {
+ // To aggregate calls, use timer and only navigate once
+ // location has stabilized.
+ var self = this;
+ this.timer = setTimeout(function () {
+ self.trigger("navigate", new_chrom + ":" + new_low + "-" + new_high);
+ }, 500 );
+ }
+ else {
+ view.trigger("navigate", new_chrom + ":" + new_low + "-" + new_high);
+ }
+ },
+
+ update_location: function(low, high) {
+ this.location_span.text( commatize(low) + ' - ' + commatize(high) );
+ this.nav_input.val( this.chrom + ':' + commatize(low) + '-' + commatize(high) );
+
+ // Update location. Only update when there is a valid chrom; when loading vis, there may
+ // not be a valid chrom.
+ var chrom = view.chrom_select.val();
+ if (chrom !== "") {
+ this.trigger_navigate(chrom, view.low, view.high, true);
+ }
+ },
+
+ /**
+ * Load chrom data for the view. Returns a jQuery Deferred.
+ */
+ // FIXME: instead of loading chrom data, should load and store genome object.
+ load_chroms: function(url_parms) {
+ url_parms.num = MAX_CHROMS_SELECTABLE;
+
+ var
+ view = this,
+ chrom_data = $.Deferred();
+ $.ajax({
+ url: galaxy_config.root + "api/genomes/" + this.dbkey,
+ data: url_parms,
+ dataType: "json",
+ success: function (result) {
+ // Do nothing if could not load chroms.
+ if (result.chrom_info.length === 0) {
+ return;
+ }
+
+ // Load chroms.
+ if (result.reference) {
+ var ref_track = new ReferenceTrack(view);
+ view.add_label_track(ref_track);
+ view.reference_track = ref_track;
+ }
+ view.chrom_data = result.chrom_info;
+ var chrom_options = 'Select Chrom/Contig ';
+ for (var i = 0, len = view.chrom_data.length; i < len; i++) {
+ var chrom = view.chrom_data[i].chrom;
+ chrom_options += '' + chrom + ' ';
+ }
+ if (result.prev_chroms) {
+ chrom_options += 'Previous ' + MAX_CHROMS_SELECTABLE + ' ';
+ }
+ if (result.next_chroms) {
+ chrom_options += 'Next ' + MAX_CHROMS_SELECTABLE + ' ';
+ }
+ view.chrom_select.html(chrom_options);
+ view.chrom_start_index = result.start_index;
+
+ chrom_data.resolve(result.chrom_info);
+ },
+ error: function() {
+ alert("Could not load chroms for this dbkey: " + view.dbkey);
+ }
+ });
+ return chrom_data;
+ },
+
+ change_chrom: function(chrom, low, high) {
+ var view = this;
+ // If chrom data is still loading, wait for it.
+ if (!view.chrom_data) {
+ view.load_chroms_deferred.then(function() {
+ view.change_chrom(chrom, low, high);
+ });
+ return;
+ }
+
+ // Don't do anything if chrom is "None" (hackish but some browsers already have this set), or null/blank
+ if (!chrom || chrom === "None") {
+ return;
+ }
+
+ //
+ // If user is navigating to previous/next set of chroms, load new chrom set and return.
+ //
+ if (chrom === "previous") {
+ view.load_chroms({low: this.chrom_start_index - MAX_CHROMS_SELECTABLE});
+ return;
+ }
+ if (chrom === "next") {
+ view.load_chroms({low: this.chrom_start_index + MAX_CHROMS_SELECTABLE});
+ return;
+ }
+
+ //
+ // User is loading a particular chrom. Look first in current set; if not in current set, load new
+ // chrom set.
+ //
+ var found = $.grep(view.chrom_data, function(v, i) {
+ return v.chrom === chrom;
+ })[0];
+ if (found === undefined) {
+ // Try to load chrom and then change to chrom.
+ view.load_chroms({'chrom': chrom}, function() { view.change_chrom(chrom, low, high); });
+ return;
+ }
+ else {
+ // Switching to local chrom.
+ if (chrom !== view.chrom) {
+ view.chrom = chrom;
+ view.chrom_select.val(view.chrom);
+ view.max_high = found.len-1; // -1 because we're using 0-based indexing.
+ view.reset();
+
+ for (var i = 0, len = view.drawables.length; i < len; i++) {
+ var drawable = view.drawables[i];
+ if (drawable.init) {
+ drawable.init();
+ }
+ }
+ if (view.reference_track) {
+ view.reference_track.init();
+ }
+ }
+
+ // Resolve low, high.
+ if (low === undefined && high === undefined) {
+ // Both are undefined, so view is whole chromosome.
+ view.low = 0;
+ view.high = view.max_high;
+ }
+ else {
+ // Low and/or high is defined.
+ view.low = (low !== undefined ? Math.max(low, 0) : 0);
+ if (high === undefined) {
+ // Center visualization around low.
+ // HACK: max resolution is currently 30 bases.
+ view.low = Math.max(view.low - 15, 0);
+ view.high = view.low + 30;
+ }
+ else {
+ // High is defined.
+ view.high = Math.min(high, view.max_high);
+ }
+ }
+
+ view.request_redraw();
+ }
+ },
+
+ /**
+ * Change viewing region to that denoted by string. General format of string is:
+ *
+ * [ {separator}[-] ]
+ *
+ * where separator can be whitespace or a colon. Examples:
+ *
+ * chr22
+ * chr1:100-200
+ * chr7 89999
+ * chr8 90000 990000
+ */
+ go_to: function(str) {
+ // Remove commas.
+ str = str.replace(/,/g, '');
+
+ // Replace colons and hyphens with space for easy parsing.
+ str = str.replace(/:|\-/g, ' ');
+
+ // Parse new location.
+ var chrom_pos = str.split(/\s+/),
+ chrom = chrom_pos[0],
+ new_low = (chrom_pos[1] ? parseInt(chrom_pos[1], 10) : undefined),
+ new_high = (chrom_pos[2] ? parseInt(chrom_pos[2], 10) : undefined);
+
+ this.change_chrom(chrom, new_low, new_high);
+ },
+
+ move_fraction: function(fraction) {
+ var view = this;
+ var span = view.high - view.low;
+ this.move_delta(fraction * span);
+ },
+
+ move_delta: function(delta_chrom) {
+ //
+ // Update low, high.
+ //
+
+ var view = this;
+ var current_chrom_span = view.high - view.low;
+ // Check for left and right boundaries
+ if (view.low - delta_chrom < view.max_low) {
+ view.low = view.max_low;
+ view.high = view.max_low + current_chrom_span;
+ } else if (view.high - delta_chrom > view.max_high) {
+ view.high = view.max_high;
+ view.low = view.max_high - current_chrom_span;
+ } else {
+ view.high -= delta_chrom;
+ view.low -= delta_chrom;
+ }
+
+ //
+ // Redraw view.
+ //
+
+ // Redraw without requesting more data immediately.
+ view.request_redraw({ data_fetch: false });
+
+ // Set up timeout to redraw with more data when moving stops.
+ if (this.redraw_on_move_fn) {
+ clearTimeout(this.redraw_on_move_fn);
+ }
+
+ this.redraw_on_move_fn = setTimeout(function() {
+ view.request_redraw();
+ }, 200);
+
+ // Navigate.
+ var chrom = view.chrom_select.val();
+ this.trigger_navigate(chrom, view.low, view.high, true);
+ },
+
+ /**
+ * Add a drawable to the view.
+ */
+ add_drawable: function(drawable) {
+ DrawableCollection.prototype.add_drawable.call(this, drawable);
+ drawable.init();
+ this.changed();
+ this.update_intro_div();
+
+ // When drawable config changes, mark view as changed. This
+ // captures most (all?) state change that needs to be saved.
+ var self = this;
+ drawable.config.on('change', function() {
+ self.changed();
+ });
+ },
+
+ add_label_track: function (label_track) {
+ label_track.view = this;
+ label_track.init();
+ this.label_tracks.push(label_track);
+ },
+
+ /**
+ * Remove drawable from the view.
+ */
+ remove_drawable: function(drawable, hide) {
+ DrawableCollection.prototype.remove_drawable.call(this, drawable);
+ if (hide) {
+ var view = this;
+ drawable.container_div.hide(0, function() {
+ $(this).remove();
+ view.update_intro_div();
+ });
+ }
+ },
+
+ reset: function() {
+ this.low = this.max_low;
+ this.high = this.max_high;
+ this.viewport_container.find(".yaxislabel").remove();
+ },
+
+ /**
+ * Request that view redraw one or more of view's drawables. If drawable is not specified,
+ * all drawables are redrawn.
+ */
+ request_redraw: function(options, drawable) {
+ var view = this,
+ // Either redrawing a single drawable or all view's drawables.
+ track_list = (drawable ? [drawable] : view.drawables);
+
+ // Add/update tracks in track list to redraw list.
+ _.each(track_list, function(track) {
+ var track_options = _.find(view.tracks_to_be_redrawn, function(to) {
+ return to[0] === track;
+ });
+
+ if (track_options) {
+ // Track already in list; update options.
+ track_options[1] = options;
+ }
+ else {
+ // Track not in list yet.
+ view.tracks_to_be_redrawn.push([track, options]);
+ }
+ });
+
+ // Set up redraw if it has not been requested since last redraw.
+ if (!this.requested_redraw) {
+ requestAnimationFrame(function() { view._redraw(); });
+ this.requested_redraw = true;
+ }
+ },
+
+ /**
+ * Redraws view and tracks.
+ * NOTE: this method should never be called directly; request_redraw() should be used so
+ * that requestAnimationFrame can manage redrawing.
+ */
+ _redraw: function() {
+ // TODO: move this code to function that does location setting.
+
+ // Clear because requested redraw is being handled now.
+ this.requested_redraw = false;
+
+ var low = this.low,
+ high = this.high;
+
+ if (low < this.max_low) {
+ low = this.max_low;
+ }
+ if (high > this.max_high) {
+ high = this.max_high;
+ }
+ var span = this.high - this.low;
+ if (this.high !== 0 && span < this.min_separation) {
+ high = low + this.min_separation;
+ }
+ this.low = Math.floor(low);
+ this.high = Math.ceil(high);
+
+ this.update_location(this.low, this.high);
+
+ // -- Drawing code --
+
+ // Resolution is a pixel density.
+ this.resolution_px_b = this.viewport_container.width() / (this.high - this.low);
+
+ // Overview
+ var left_px = ( this.low / (this.max_high - this.max_low) * this.overview_viewport.width() ) || 0;
+ var width_px = ( (this.high - this.low)/(this.max_high - this.max_low) * this.overview_viewport.width() ) || 0;
+ var min_width_px = 13;
+
+ this.overview_box.css({ left: left_px, width: Math.max(min_width_px, width_px) }).show();
+ if (width_px < min_width_px) {
+ this.overview_box.css("left", left_px - (min_width_px - width_px)/2);
+ }
+ if (this.overview_highlight) {
+ this.overview_highlight.css({ left: left_px, width: width_px });
+ }
+
+ // Draw data tracks.
+ _.each(this.tracks_to_be_redrawn, function(track_options) {
+ var track = track_options[0],
+ options = track_options[1];
+ if (track) {
+ track._draw(options);
+ }
+ });
+ this.tracks_to_be_redrawn = [];
+
+ // Draw label tracks.
+ _.each(this.label_tracks, function(label_track) {
+ label_track._draw();
+ });
+ },
+
+ zoom_in: function (point, container) {
+ if (this.max_high === 0 || this.high - this.low <= this.min_separation) {
+ return;
+ }
+ var span = this.high - this.low,
+ cur_center = span / 2 + this.low,
+ new_half = (span / this.zoom_factor) / 2;
+ if (point) {
+ cur_center = point / this.viewport_container.width() * (this.high - this.low) + this.low;
+ }
+ this.low = Math.round(cur_center - new_half);
+ this.high = Math.round(cur_center + new_half);
+
+ this.changed();
+ this.request_redraw();
+ },
+
+ zoom_out: function () {
+ if (this.max_high === 0) {
+ return;
+ }
+ var span = this.high - this.low,
+ cur_center = span / 2 + this.low,
+ new_half = (span * this.zoom_factor) / 2;
+ this.low = Math.round(cur_center - new_half);
+ this.high = Math.round(cur_center + new_half);
+ this.changed();
+ this.request_redraw();
+ },
+
+ /** Resize viewport. Use this method if header/footer content has changed in size. */
+ resize_viewport: function() {
+ this.viewport_container.height( this.container.height() - this.top_container.height() - this.bottom_container.height() );
+ },
+
+ /** Called when window is resized. */
+ resize_window: function() {
+ this.resize_viewport();
+ this.request_redraw();
+ },
+
+ /** Show a Drawable in the overview. */
+ set_overview: function(drawable) {
+ if (this.overview_drawable) {
+ // If drawable to be set as overview is already in overview, do nothing.
+ // Otherwise, remove overview.
+ if (this.overview_drawable.dataset.id === drawable.dataset.id) {
+ return;
+ }
+ this.overview_viewport.find(".track").remove();
+ }
+
+ // Set new overview.
+ var
+ overview_drawable = drawable.copy( { content_div: this.overview_viewport } ),
+ view = this;
+ overview_drawable.header_div.hide();
+ overview_drawable.is_overview = true;
+ view.overview_drawable = overview_drawable;
+ this.overview_drawable.postdraw_actions = function() {
+ view.overview_highlight.show().height(view.overview_drawable.content_div.height());
+ view.overview_viewport.height(view.overview_drawable.content_div.height() + view.overview_box.outerHeight());
+ view.overview_close.show();
+ view.resize_window();
+ };
+ view.overview_drawable.request_draw();
+ this.changed();
+ },
+
+ /** Close and reset overview. */
+ reset_overview: function() {
+ // Update UI.
+ $(".tooltip").remove();
+ this.overview_viewport.find(".track-tile").remove();
+ this.overview_viewport.height(this.default_overview_height);
+ this.overview_box.height(this.default_overview_height);
+ this.overview_close.hide();
+ this.overview_highlight.hide();
+ view.resize_window();
+ view.overview_drawable = null;
+ }
+});
+
+/**
+ * Encapsulation of a tool that users can apply to tracks/datasets.
+ */
+var TracksterTool = tools_mod.Tool.extend({
+ defaults: {
+ track: null
+ },
+
+ initialize: function(options) {
+ tools_mod.Tool.prototype.initialize.call(this, options);
+
+ // Restore tool visibility from state; default to hidden.
+ var hidden = true;
+ if (options.tool_state !== undefined && options.tool_state.hidden !== undefined) {
+ hidden = options.tool_state.hidden;
+ }
+ this.set('hidden', hidden);
+
+ // FIXME: need to restore tool values from options.tool_state
+
+ // HACK: remove some inputs because Trackster does yet not work with them.
+ this.remove_inputs( [ 'data', 'hidden_data', 'conditional' ] );
+ },
+
+ state_dict: function(options) {
+ return _.extend( this.get_inputs_dict(), { hidden: !this.is_visible() } );
+ }
+});
+
+/**
+ * View renders tool parameter HTML and updates parameter value as it is changed in the HTML.
+ */
+ var ToolParameterView = Backbone.View.extend({
+
+ events: {
+ 'change :input': 'update_value'
+ },
+
+ render: function() {
+ var param_div = this.$el.addClass("param-row"),
+ param = this.model;
+
+ // Param label.
+ var label_div = $("").addClass("param-label").text(param.get('label')).appendTo(param_div);
+ // Param HTML.
+ var html_div = $("
").addClass("param-input").html(param.get('html')).appendTo(param_div);
+ // Set initial value.
+ html_div.find(":input").val(param.get('value'));
+
+ // Add to clear floating layout.
+ $("
").appendTo(param_div);
+ },
+
+ update_value: function(update_event) {
+ this.model.set_value($(update_event.target).val());
+ }
+ });
+
+/**
+ * View for TracksterTool.
+ */
+var TracksterToolView = Backbone.View.extend({
+
+ initialize: function(options) {
+ this.model.on('change:hidden', this.set_visible, this);
+ },
+
+ /**
+ * Render tool UI.
+ */
+ render: function() {
+ var self = this;
+ tool = this.model,
+ parent_div = this.$el.addClass("dynamic-tool").hide();
+
+ // Prevent div events from propogating to other elements.
+ parent_div.bind("drag", function(e) {
+ e.stopPropagation();
+ }).click(function(e) {
+ e.stopPropagation();
+ }).bind("dblclick", function(e) {
+ e.stopPropagation();
+ }).keydown(function(e) { e.stopPropagation(); });
+
+ // Add name, inputs.
+ var name_div = $("
").appendTo(parent_div).text(tool.get('name'));
+ tool.get('inputs').each(function(param) {
+ // Render parameter.
+ var param_view = new ToolParameterView({ model: param });
+ param_view.render();
+ parent_div.append(param_view.$el);
+ });
+
+ // Highlight value for inputs for easy replacement.
+ parent_div.find("input").click(function() { $(this).select(); });
+
+ // Add buttons for running on dataset, region.
+ var run_tool_row = $("
").addClass("param-row").appendTo(parent_div);
+ var run_on_dataset_button = $("
").attr("value", "Run on complete dataset").appendTo(run_tool_row);
+ var run_on_region_button = $("
").attr("value", "Run on visible region").appendTo(run_tool_row);
+ run_on_region_button.click( function() {
+ // Run tool to create new track.
+ self.run_on_region();
+ });
+ run_on_dataset_button.click( function() {
+ self.run_on_dataset();
+ });
+
+ if (tool.is_visible()) {
+ this.$el.show();
+ }
+ },
+
+ /**
+ * Show or hide tool depending on tool visibility state.
+ */
+ set_visible: function() {
+ this.$el.toggle(this.model.is_visible());
+ },
+
+ /**
+ * Update tool parameters.
+ */
+ update_params: function() {
+ for (var i = 0; i < this.params.length; i++) {
+ this.params[i].update_value();
+ }
+ },
+
+ /**
+ * Run tool on dataset. Output is placed in dataset's history and no changes to viz are made.
+ */
+ run_on_dataset: function() {
+ var tool = this.model;
+ this.run(
+ // URL params.
+ {
+ target_dataset_id: this.model.get('track').dataset.id,
+ action: 'rerun',
+ tool_id: tool.id
+ },
+ null,
+ // Success callback.
+ function(track_data) {
+ Galaxy.modal.show({title: tool.get('name') + " is Running", body: tool.get('name') + " is running on the complete dataset. Tool outputs are in dataset's history.", buttons : {'Close' : function() { Galaxy.modal.hide(); } } });
+ }
+ );
+ },
+
+ /**
+ * Run dataset on visible region. This creates a new track and sets the track's contents
+ * to the tool's output.
+ */
+ run_on_region: function() {
+ //
+ // Create track for tool's output immediately to provide user feedback.
+ //
+ var track = this.model.get('track'),
+ tool = this.model,
+ region = new visualization.GenomeRegion({
+ chrom: track.view.chrom,
+ start: track.view.low,
+ end: track.view.high
+ }),
+ url_params =
+ {
+ target_dataset_id: track.dataset.id,
+ action: 'rerun',
+ tool_id: tool.id,
+ regions: [
+ region.toJSON()
+ ]
+ },
+ current_track = track,
+ // Set name of track to include tool name, parameters, and region used.
+ track_name = tool.get('name') +
+ current_track.tool_region_and_parameters_str(region),
+ container;
+
+ // If track not in a group, create a group for it and add new track to group. If track
+ // already in group, add track to group.
+ if (current_track.container === view) {
+ // Create new group.
+ var group = new DrawableGroup(view, view, { name: track.config.get_value('name') });
+
+ // Replace track with group.
+ var index = current_track.container.replace_drawable(current_track, group, false);
+
+ // Update HTML.
+ // FIXME: this is ugly way to replace a track with a group -- make this easier via
+ // a Drawable or DrawableCollection function.
+ group.container_div.insertBefore(current_track.view.content_div.children()[index]);
+ group.add_drawable(current_track);
+ current_track.container_div.appendTo(group.content_div);
+ container = group;
+ }
+ else {
+ // Use current group.
+ container = current_track.container;
+ }
+
+ // Create and init new track.
+ var new_track = new current_track.constructor(view, container, {
+ name: track_name,
+ hda_ldda: "hda"
+ });
+ new_track.init_for_tool_data();
+ new_track.change_mode(current_track.mode);
+ new_track.set_filters_manager(current_track.filters_manager.copy(new_track));
+ new_track.update_icons();
+ container.add_drawable(new_track);
+ new_track.tiles_div.text("Starting job.");
+
+ // Run tool.
+ this.run(url_params, new_track,
+ // Success callback.
+ function(track_data) {
+ new_track.set_dataset(new data.Dataset(track_data));
+ new_track.tiles_div.text("Running job.");
+ new_track.init();
+ }
+ );
+ },
+
+ /**
+ * Run tool using a set of URL params and a success callback.
+ */
+ run: function(url_params, new_track, success_callback) {
+ // Run tool.
+ url_params.inputs = this.model.get_inputs_dict();
+ var ss_deferred = new util.ServerStateDeferred({
+ ajax_settings: {
+ url: galaxy_config.root + "api/tools",
+ data: JSON.stringify(url_params),
+ dataType: "json",
+ contentType: 'application/json',
+ type: "POST"
+ },
+ interval: 2000,
+ success_fn: function(response) {
+ return response !== "pending";
+ }
+ });
+
+ // Start with this status message.
+ //new_track.container_div.addClass("pending");
+ //new_track.content_div.html(DATA_PENDING);
+
+ $.when(ss_deferred.go()).then(function(response) {
+ if (response === "no converter") {
+ // No converter available for input datasets, so cannot run tool.
+ new_track.container_div.addClass("error");
+ new_track.content_div.text(DATA_NOCONVERTER);
+ }
+ else if (response.error) {
+ // General error.
+ new_track.container_div.addClass("error");
+ new_track.content_div.text(DATA_CANNOT_RUN_TOOL + response.message);
+ }
+ else {
+ // Job submitted and running.
+ success_callback(response);
+ }
+ });
+ }
+
+});
+
+/**
+ * Generates scale values based on filter and feature's value for filter.
+ */
+var FilterScaler = function(filter, default_val) {
+ painters.Scaler.call(this, default_val);
+ this.filter = filter;
+};
+
+FilterScaler.prototype.gen_val = function(feature_data) {
+ // If filter is not initalized yet, return default val.
+ if (this.filter.high === Number.MAX_VALUE || this.filter.low === -Number.MAX_VALUE || this.filter.low === this.filter.high) {
+ return this.default_val;
+ }
+
+ // Scaling value is ratio of (filter's value compared to low) to (complete filter range).
+ return ( ( parseFloat(feature_data[this.filter.index]) - this.filter.low ) / ( this.filter.high - this.filter.low ) );
+};
+
+/**
+ * Tiles drawn by tracks.
+ */
+var Tile = function(track, region, w_scale, canvas, data) {
+ this.track = track;
+ this.region = region;
+ this.low = region.get('start');
+ this.high = region.get('end');
+ this.w_scale = w_scale;
+ this.canvas = canvas;
+ // Wrap element in div for background and to provide container for tile-specific elements.
+ this.html_elt = $("
").append(canvas);
+ this.data = data;
+ this.stale = false;
+};
+
+/**
+ * Perform pre-display actions.
+ */
+Tile.prototype.predisplay_actions = function() {};
+
+var LineTrackTile = function(track, region, w_scale, canvas, data) {
+ Tile.call(this, track, region, w_scale, canvas, data);
+};
+LineTrackTile.prototype.predisplay_actions = function() {};
+
+var FeatureTrackTile = function(track, region, w_scale, canvas, data, mode, message, all_slotted,
+ feature_mapper, incomplete_features, seq_data) {
+ // Attribute init.
+ Tile.call(this, track, region, w_scale, canvas, data);
+ this.mode = mode;
+ this.all_slotted = all_slotted;
+ this.feature_mapper = feature_mapper;
+ this.has_icons = false;
+ this.incomplete_features = incomplete_features;
+ // Features drawn based on data from other tiles.
+ this.other_tiles_features_drawn = {};
+ this.seq_data = seq_data;
+
+ // Add message + action icons to tile's html.
+ /*
+ This does not work right now because a random set of reads is returned by the server.
+ When the server can respond with more data systematically, renable these icons.
+ if (message) {
+ this.has_icons = true;
+
+ var
+ tile = this;
+ canvas = this.html_elt.children()[0],
+ message_div = $("
").addClass("tile-message")
+ // -1 to account for border.
+ .css({'height': ERROR_PADDING, 'width': canvas.width}).prependTo(this.html_elt);
+
+ // Handle message; only message currently is that only the first N elements are displayed.
+ var tile_region = new visualization.GenomeRegion({
+ chrom: track.view.chrom,
+ start: this.low,
+ end: this.high
+ }),
+ num_features = data.length,
+ more_down_icon = $("
").addClass("icon more-down")
+ .attr("title", "For speed, only the first " + num_features + " features in this region were obtained from server. Click to get more data including depth")
+ .tooltip().appendTo(message_div),
+ more_across_icon = $("
").addClass("icon more-across")
+ .attr("title", "For speed, only the first " + num_features + " features in this region were obtained from server. Click to get more data excluding depth")
+ .tooltip().appendTo(message_div);
+
+ // Set up actions for icons.
+ more_down_icon.click(function() {
+ // Mark tile as stale, request more data, and redraw track.
+ tile.stale = true;
+ track.data_manager.get_more_data(tile_region, track.mode, 1 / tile.w_scale, {}, track.data_manager.DEEP_DATA_REQ);
+ $(".tooltip").hide();
+ track.request_draw();
+ }).dblclick(function(e) {
+ // Do not propogate as this would normally zoom in.
+ e.stopPropagation();
+ });
+
+ more_across_icon.click(function() {
+ // Mark tile as stale, request more data, and redraw track.
+ tile.stale = true;
+ track.data_manager.get_more_data(tile_region, track.mode, 1 / tile.w_scale, {}, track.data_manager.BROAD_DATA_REQ);
+ $(".tooltip").hide();
+ track.request_draw();
+ }).dblclick(function(e) {
+ // Do not propogate as this would normally zoom in.
+ e.stopPropagation();
+ });
+ }
+ */
+};
+extend(FeatureTrackTile.prototype, Tile.prototype);
+
+/**
+ * Sets up support for popups.
+ */
+FeatureTrackTile.prototype.predisplay_actions = function() {
+ /*
+ FIXME: use a canvas library to handle popups.
+ //
+ // Add support for popups.
+ //
+ var tile = this,
+ popups = {};
+
+ // Only show popups in Pack mode.
+ if (tile.mode !== "Pack") { return; }
+
+ $(this.html_elt).hover(
+ function() {
+ this.hovered = true;
+ $(this).mousemove();
+ },
+ function() {
+ this.hovered = false;
+ // Clear popup if it is still hanging around (this is probably not needed)
+ $(this).parents(".track-content").children(".overlay").children(".feature-popup").remove();
+ } ).mousemove(function (e) {
+ // Use the hover plugin to get a delay before showing popup
+ if ( !this.hovered ) { return; }
+ // Get feature data for position.
+ var
+ this_offset = $(this).offset(),
+ offsetX = e.pageX - this_offset.left,
+ offsetY = e.pageY - this_offset.top,
+ feature_data = tile.feature_mapper.get_feature_data(offsetX, offsetY),
+ feature_uid = (feature_data ? feature_data[0] : null);
+ // Hide visible popup if not over a feature or over a different feature.
+ $(this).parents(".track-content").children(".overlay").children(".feature-popup").each(function() {
+ if ( !feature_uid ||
+ $(this).attr("id") !== feature_uid.toString() ) {
+ $(this).remove();
+ }
+ });
+
+ if (feature_data) {
+ // Get or create popup.
+ var popup = popups[feature_uid];
+ if (!popup) {
+ // Create feature's popup element.
+ var feature_dict = {
+ name: feature_data[3],
+ start: feature_data[1],
+ end: feature_data[2],
+ strand: feature_data[4]
+ },
+ filters = tile.track.filters_manager.filters,
+ filter;
+
+ // Add filter values to feature dict.
+ for (var i = 0; i < filters.length; i++) {
+ filter = filters[i];
+ feature_dict[filter.name] = feature_data[filter.index];
+ }
+
+ // Build popup.
+ popup = $("
").attr("id", feature_uid).addClass("feature-popup");
+ var table = $("
"),
+ key, value, row;
+ for (key in feature_dict) {
+ value = feature_dict[key];
+ row = $("
").appendTo(table);
+ $("
").appendTo(row).text(key);
+ $("
").attr("align", "left").appendTo(row)
+ .text(typeof(value) === 'number' ? round(value, 2) : value);
+ }
+ popup.append( $("