/* Trackster 2010-2011: James Taylor, Kanwei Li, Jeremy Goecks */ var class_module = function(require, exports) { // Module is a placeholder for a more complete inheritence approach /** Simple extend function for inheritence */ var extend = function() { var target = arguments[0]; for ( var i = 1; i < arguments.length; i++ ) { var other = arguments[i]; for ( key in other ) { target[key] = other[key]; } } return target; }; exports.extend = extend; // end class_module encapsulation }; /** * 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. */ 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); }; // Encapsulate -- anything to be availabe outside this block is added to exports var trackster_module = function(require, exports) { var extend = require('class').extend, slotting = require('slotting'), painters = require('painters'); // ---- Canvas management and extensions ---- /** * Canvas manager is used to create canvases, for browsers, this deals with * backward comparibility using excanvas, as well as providing a pattern cache */ var CanvasManager = function( document, default_font ) { this.document = document; this.default_font = default_font !== undefined ? default_font : "9px Monaco, Lucida Console, monospace"; this.dummy_canvas = this.new_canvas(); this.dummy_context = this.dummy_canvas.getContext('2d'); this.dummy_context.font = this.default_font; this.char_width_px = this.dummy_context.measureText("A").width; this.patterns = {}; // FIXME: move somewhere to make this more general this.load_pattern( 'right_strand', "/visualization/strand_right.png" ); this.load_pattern( 'left_strand', "/visualization/strand_left.png" ); this.load_pattern( 'right_strand_inv', "/visualization/strand_right_inv.png" ); this.load_pattern( 'left_strand_inv', "/visualization/strand_left_inv.png" ); } extend( CanvasManager.prototype, { load_pattern: function( key, path ) { var patterns = this.patterns, dummy_context = this.dummy_context, image = new Image(); // FIXME: where does image_path come from? not in browser.mako... image.src = image_path + path; image.onload = function() { patterns[key] = dummy_context.createPattern( image, "repeat" ); } }, get_pattern: function( key ) { return this.patterns[key]; }, new_canvas: function() { var canvas = this.document.createElement("canvas"); // If using excanvas in IE, we need to explicately attach the canvas // methods to the DOM element if (window.G_vmlCanvasManager) { G_vmlCanvasManager.initElement(canvas); } // Keep a reference back to the manager canvas.manager = this; return canvas; } }); // ---- Web UI specific utilities ---- /** * Make `element` sortable in parent by dragging `handle` (a selector) */ var sortable = function( element, handle ) { element.bind( "drag", { handle: handle, relative: true }, function ( e, d ) { var parent = $(this).parent(); var children = parent.children(); var i; // Determine new position for ( i = 0; i < children.length; i++ ) { if ( d.offsetY < $(children.get(i)).position().top ) { 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); } } else if ( this !== children.get(i) ) { $(this).insertBefore( children.get(i) ); } }).bind("dragstart", function() { $(this).css({ "border-top": "1px solid blue", "border-bottom": "1px solid blue" }); }).bind("dragend", function() { $(this).css("border", "0px"); }); }; exports.sortable = sortable; /** * Init constants & functions used throughout trackster. */ var // Drawing constants; track height is (constant) height of track, and feature height is the // height of individual features within tracks. Feature height, then, should always be less // than track height. CHAR_HEIGHT_PX = 9, // FIXME: font size may not be static ERROR_PADDING = 10, // Padding at the top of tracks for error messages SUMMARY_TREE_TOP_PADDING = CHAR_HEIGHT_PX + 2, // 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, // Other constants. DENSITY = 200, RESOLUTION = 5, FEATURE_LEVELS = 10, DEFAULT_DATA_QUERY_WAIT = 5000, // Maximum number of chromosomes that are selectable at any one time. MAX_CHROMS_SELECTABLE = 100, DATA_ERROR = "There was an error in indexing this dataset. ", 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 = "Currently indexing... please wait", DATA_CANNOT_RUN_TOOL = "Tool cannot be rerun: ", DATA_LOADING = "Loading data...", DATA_OK = "Ready for display", CACHED_TILES_FEATURE = 10, CACHED_TILES_LINE = 5, CACHED_DATA = 5; function round_1000(num) { return Math.round(num * 1000) / 1000; }; /** * Generic cache that handles key/value pairs. */ var Cache = function( num_elements ) { this.num_elements = num_elements; this.clear(); }; extend(Cache.prototype, { get: function(key) { var index = this.key_ary.indexOf(key); if (index !== -1) { this.move_key_to_end(key, index); } return this.obj_cache[key]; }, set: function(key, value) { if (!this.obj_cache[key]) { if (this.key_ary.length >= this.num_elements) { // Remove first element var deleted_key = this.key_ary.shift(); delete this.obj_cache[deleted_key]; } this.key_ary.push(key); } this.obj_cache[key] = value; return value; }, // Move key to end of cache. Keys are removed from the front, so moving a key to the end // delays the key's removal. move_key_to_end: function(key, index) { this.key_ary.splice(index, 1); this.key_ary.push(key); }, clear: function() { this.obj_cache = {}; this.key_ary = []; }, // Returns the number of elements in the cache. size: function() { return this.key_ary.length; } }); /** * Data manager for a track. */ var DataManager = function(num_elements, track, subset) { Cache.call(this, num_elements); this.track = track; this.subset = (subset !== undefined ? subset : true); }; extend(DataManager.prototype, Cache.prototype, { /** * Load data from server; returns AJAX object so that use of Deferred is possible. */ load_data: function(chrom, low, high, mode, resolution, extra_params) { // Setup data request params. var params = {"chrom": chrom, "low": low, "high": high, "mode": mode, "resolution": resolution, "dataset_id" : this.track.dataset_id, "hda_ldda": this.track.hda_ldda}; $.extend(params, extra_params); // Add track filters to params. if (this.track.filters_manager) { var filter_names = []; var filters = this.track.filters_manager.filters; for (var i = 0; i < filters.length; i++) { filter_names[filter_names.length] = filters[i].name; } params.filter_cols = JSON.stringify(filter_names); } // Do request. var manager = this; return $.getJSON(this.track.data_url, params, function (result) { manager.set_data(low, high, mode, result); }); }, /** * Get data and do callback. */ get_data: function(chrom, low, high, mode, resolution, extra_params) { // Debugging: //console.log("get_data", low, high, mode); /* console.log("cache contents:") for (var i = 0; i < this.key_ary.length; i++) { console.log("\t", this.key_ary[i], this.obj_cache[this.key_ary[i]]); } */ // Look for entry and return if found. var entry = this.get(this.gen_key(low, high, mode)); if (entry) { return entry; } // // If data supports subsetting: // Look in cache for data that can be used. Data can be reused if it // has the requested data and is not summary tree and has details. // TODO: this logic could be improved if the visualization knew whether // the data was "index" or "data." Also could slice the data so that // only data points in request are returned. // /* Disabling for now, more detailed data is never loaded for line tracks if (this.subset) { var key, split_key, entry_low, entry_high, mode, entry; for (var i = 0; i < this.key_ary.length; i++) { key = this.key_ary[i]; split_key = this.split_key(key); entry_low = split_key[0]; entry_high = split_key[1]; if (low >= entry_low && high <= entry_high) { // This track has the range of data needed; check other attributes. entry = this.obj_cache[key]; if (entry.dataset_type !== "summary_tree" && entry.extra_info !== "no_detail") { // Data is usable. this.move_key_to_end(key, i); return entry; } } } } */ // Load data from server. The deferred is immediately saved until the // data is ready, it then replaces itself with the actual data entry = this.load_data(chrom, low, high, mode, resolution, extra_params); this.set_data( low, high, mode, entry ); return entry }, set_data: function(low, high, mode, result) { //console.log("set_data", low, high, mode, result); return this.set(this.gen_key(low, high, mode), result); }, /** * Generate key for cache. */ gen_key: function(low, high, mode) { var key = low + "_" + high + "_" + mode; return key; }, /** * Split key from cache into array with format [low, high, mode] */ split_key: function(key) { return key.split("_"); } }); var ReferenceTrackDataManager = function(num_elements, track, subset) { DataManager.call(this, num_elements, track, subset); }; extend(ReferenceTrackDataManager.prototype, DataManager.prototype, Cache.prototype, { load_data: function(chrom, low, high, mode, resolution, extra_params) { if (resolution > 1) { // Now that data is pre-fetched before draw, we don't load reference tracks // unless it's at the bottom level return; } return DataManager.prototype.load_data.call(this, chrom, low, high, mode, resolution, extra_params); } }); /** * View object manages complete viz view, including tracks and user interactions. */ var View = function( container, title, vis_id, dbkey, callback ) { this.container = container; this.chrom = null; this.vis_id = vis_id; this.dbkey = dbkey; this.title = title; this.tracks = []; this.label_tracks = []; this.max_low = 0; this.max_high = 0; this.num_tracks = 0; this.track_id_counter = 0; this.zoom_factor = 3; this.min_separation = 30; this.has_changes = false; this.init( callback ); this.canvas_manager = new CanvasManager( container.get(0).ownerDocument ); this.reset(); }; extend( View.prototype, { init: function( callback ) { // 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); // Content container, primary tracks are contained in here this.content_div = $("
").addClass("content").css("position", "relative").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").addClass("viewport-container").appendTo(this.content_div); // Future overlay? this.intro_div = $("
").addClass("intro").text("Select a chrom from the dropdown below").hide(); // Another label track at bottom this.nav_labeltrack = $("
").addClass("nav-labeltrack").appendTo(this.bottom_container); // Navigation at top this.nav_container = $("
").addClass("nav-container").prependTo(this.top_container); this.nav = $("
").addClass("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 = $("Close Overview").addClass("overview-close").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 = $("").addClass("nav-input").hide().bind("keyup focusout", submit_nav).appendTo(this.nav_controls); this.location_span = $("").addClass("location").appendTo(this.nav_controls); this.location_span.bind("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(); }); if (this.vis_id !== undefined) { this.hidden_input = $("").attr("type", "hidden").val(this.vis_id).appendTo(this.nav_controls); } this.zo_link = $("").click(function() { view.zoom_out(); view.redraw(); }).appendTo(this.nav_controls); this.zi_link = $("").click(function() { view.zoom_in(); view.redraw(); }).appendTo(this.nav_controls); // Get initial set of chroms. this.load_chroms({low: 0}, callback); this.chrom_select.bind("change", function() { view.change_chrom(view.chrom_select.val()); }); this.intro_div.show(); /* this.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.content_div.bind("click", function( e ) { $(this).find("input").trigger("blur"); }); // Double clicking zooms in this.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.bind("click", function() { for (var track_id = 0, len = view.tracks.length; track_id < len; track_id++) { view.tracks[track_id].is_overview = false; } $(this).siblings().filter("canvas").remove(); $(this).parent().css("height", view.overview_box.height()); view.overview_highlight.hide(); $(this).hide(); }); // 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); // Also capture mouse wheel for left/right scrolling }).bind( 'mousewheel', function( e, d, dx, dy ) { // Only act on x axis scrolling if we see if, y will be i // 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 this.top_labeltrack.bind( "dragstart", function( e, d ) { return $("
").css( { "height": view.content_div.height() + view.top_labeltrack.height() + view.nav_labeltrack.height() + 1, "top": "0px", "position": "absolute", "background-color": "#ccf", "opacity": 0.5, "z-index": 1000 } ).appendTo( $(this) ); }).bind( "drag", function( e, d ) { $( d.proxy ).css({ left: Math.min( e.pageX, d.startX ), 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.redraw(); }); this.add_label_track( new LabelTrack( this, this.top_labeltrack ) ); this.add_label_track( new LabelTrack( this, this.nav_labeltrack ) ); $(window).bind("resize", function() { view.resize_window(); }); $(document).bind("redraw", function() { view.redraw(); }); this.reset(); $(window).trigger("resize"); }, update_location: function(low, high) { this.location_span.text( commatize(low) + ' - ' + commatize(high) ); this.nav_input.val( this.chrom + ':' + commatize(low) + '-' + commatize(high) ); }, load_chroms: function(url_parms, callback) { url_parms['num'] = MAX_CHROMS_SELECTABLE; $.extend( url_parms, (this.vis_id !== undefined ? { vis_id: this.vis_id } : { dbkey: this.dbkey } ) ); var view = this; $.ajax({ url: chrom_url, data: url_parms, dataType: "json", success: function (result) { // Show error if could not load chroms. if (result.chrom_info.length === 0) { alert("Invalid chromosome: " + url_parms.chrom); return; } // Load chroms. if (result.reference) { view.add_label_track( new ReferenceTrack(view) ); } view.chrom_data = result.chrom_info; var chrom_options = ''; for (var i = 0, len = view.chrom_data.length; i < len; i++) { var chrom = view.chrom_data[i].chrom; chrom_options += ''; } if (result.prev_chroms) { chrom_options += ''; } if (result.next_chroms) { chrom_options += ''; } view.chrom_select.html(chrom_options); if ( callback ) { callback(); } view.chrom_start_index = result.start_index; }, error: function() { alert("Could not load chroms for this dbkey:", view.dbkey); } }); }, change_chrom: function(chrom, low, high) { // Don't do anything if chrom is "None" (hackish but some browsers already have this set), or null/blank if (!chrom || chrom === "None") { return; } var view = this; // // 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; if (!view.chrom) { // No chrom selected view.intro_div.show(); } else { view.intro_div.hide(); } view.chrom_select.val(view.chrom); view.max_high = found.len-1; // -1 because we're using 0-based indexing. view.reset(); view.redraw(true); for (var track_id = 0, len = view.tracks.length; track_id < len; track_id++) { var track = view.tracks[track_id]; if (track.init) { track.init(); } } } if (low !== undefined && high !== undefined) { view.low = Math.max(low, 0); view.high = Math.min(high, view.max_high); } view.reset_overview(); view.redraw(); } }, go_to: function(str) { var view = this, new_low, new_high, chrom_pos = str.split(":"), chrom = chrom_pos[0], pos = chrom_pos[1]; if (pos !== undefined) { try { var pos_split = pos.split("-"); new_low = parseInt(pos_split[0].replace(/,/g, ""), 10); new_high = parseInt(pos_split[1].replace(/,/g, ""), 10); } catch (e) { return false; } } view.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) { 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; } view.redraw(); }, add_track: function(track) { track.view = this; track.track_id = this.track_id_counter; this.tracks.push(track); if (track.init) { track.init(); } track.container_div.attr('id', 'track_' + track.track_id); sortable( track.container_div, '.draghandle' ); this.track_id_counter += 1; this.num_tracks += 1; }, add_label_track: function (label_track) { label_track.view = this; this.label_tracks.push(label_track); }, remove_track: function(track) { this.has_changes = true; track.container_div.fadeOut('slow', function() { $(this).remove(); }); delete this.tracks[this.tracks.indexOf(track)]; this.num_tracks -= 1; }, reset: function() { this.low = this.max_low; this.high = this.max_high; this.viewport_container.find(".yaxislabel").remove(); }, redraw: function(nodraw) { var span = this.high - this.low, low = this.low, high = this.high; if (low < this.max_low) { low = this.max_low; } if (high > this.max_high) { high = this.max_high; } if (this.high !== 0 && span < this.min_separation) { high = low + this.min_separation; } this.low = Math.floor(low); this.high = Math.ceil(high); // 10^log10(range / DENSITY) Close approximation for browser window, assuming DENSITY = window width this.resolution = Math.pow( RESOLUTION, Math.ceil( Math.log( (this.high - this.low) / DENSITY ) / Math.log(RESOLUTION) ) ); this.zoom_res = Math.pow( FEATURE_LEVELS, Math.max(0,Math.ceil( Math.log( this.resolution, FEATURE_LEVELS ) / Math.log(FEATURE_LEVELS) ))); // 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 }); } this.update_location(this.low, this.high); if (!nodraw) { for (var i = 0, len = this.tracks.length; i < len; i++) { if (this.tracks[i] && this.tracks[i].enabled) { this.tracks[i].draw(); } } for (i = 0, len = this.label_tracks.length; i < len; i++) { this.label_tracks[i].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.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.redraw(); }, resize_window: function() { this.viewport_container.height( this.container.height() - this.top_container.height() - this.bottom_container.height() ); this.nav_container.width( this.container.width() ); this.redraw(); }, reset_overview: function() { this.overview_viewport.find("canvas").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(); } }); /** * Encapsulation of a tool that users can apply to tracks/datasets. */ var Tool = function(track, tool_dict) { // // Unpack tool information from dictionary. // this.track = track; this.name = tool_dict.name; this.params = []; var params_dict = tool_dict.params; for (var i = 0; i < params_dict.length; i++) { var param_dict = params_dict[i], name = param_dict.name, label = param_dict.label, html = unescape(param_dict.html), value = param_dict.value, type = param_dict.type; if (type === "number") { this.params[this.params.length] = new NumberParameter(name, label, html, value, param_dict.min, param_dict.max); } else if (type == "select") { this.params[this.params.length] = new ToolParameter(name, label, html, value); } else { console.log("WARNING: unrecognized tool parameter type:", name, type); } } // // Create div elt for tool UI. // this.parent_div = $("
").addClass("dynamic-tool").hide(); // Disable dragging, clicking, double clicking on div so that actions on slider do not impact viz. this.parent_div.bind("drag", function(e) { e.stopPropagation(); }).bind("click", function(e) { e.stopPropagation(); }).bind("dblclick", function(e) { e.stopPropagation(); }); var name_div = $("
").appendTo(this.parent_div).text(this.name); var tool_params = this.params; var tool = this; $.each(this.params, function(index, param) { var param_div = $("
").addClass("param-row").appendTo(tool.parent_div); // Param label. var label_div = $("
").addClass("param-label").text(param.label).appendTo(param_div); // Param HTML. // TODO: either generalize .slider CSS attributes or create new rule for tool input div. var html_div = $("
").addClass("slider").html(param.html).appendTo(param_div); // Set initial value. html_div.find(":input").val(param.value); // Add to clear floating layout. $("
").appendTo(param_div); }); // Highlight value for inputs for easy replacement. this.parent_div.find("input").click(function() { $(this).select() }); // Add 'Go' button. var run_tool_row = $("
").addClass("param-row").appendTo(this.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").css("margin-left", "3em").appendTo(run_tool_row); var tool = this; run_on_region_button.click( function() { tool.run_on_region(); }); run_on_dataset_button.click( function() { tool.run_on_dataset(); }); }; extend(Tool.prototype, { /** * Returns dictionary of parameter name-values. */ get_param_values_dict: function() { var param_dict = {}; this.parent_div.find(":input").each(function() { var name = $(this).attr("name"), value = $(this).val(); param_dict[name] = JSON.stringify(value); }); return param_dict; }, /** * Returns array of parameter values. */ get_param_values: function() { var param_values = []; var param_dict = {}; this.parent_div.find(":input").each(function() { // Only include inputs with names; this excludes Run button. var name = $(this).attr("name"), value = $(this).val(); if (name) { param_values[param_values.length] = value; } }); return param_values; }, /** * 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; tool.run( // URL params. { dataset_id: this.track.original_dataset_id, tool_id: tool.name }, // Success callback. function(track_data) { show_modal(tool.name + " is Running", tool.name + " is running on the complete dataset. Tool outputs are in dataset's history.", { "Close" : hide_modal } ); } ); }, /** * 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 url_params = { dataset_id: this.track.original_dataset_id, chrom: this.track.view.chrom, low: this.track.view.low, high: this.track.view.high, tool_id: this.name }, current_track = this.track, // Set name of track to include tool name, parameters, and region used. track_name = url_params.tool_id + current_track.tool_region_and_parameters_str(url_params.chrom, url_params.low, url_params.high), new_track; // Create and add track. // TODO: add support for other kinds of tool data tracks. if (current_track.track_type === 'FeatureTrack') { new_track = new ToolDataFeatureTrack(track_name, view, current_track.hda_ldda, undefined, {}, {}, current_track); new_track.change_mode(current_track.mode); } this.track.add_track(new_track); new_track.content_div.text("Starting job."); // Run tool. this.run(url_params, // Success callback. function(track_data) { new_track.dataset_id = track_data.dataset_id; new_track.content_div.text("Running job."); new_track.init(); } ); }, /** * Run tool using a set of URL params and a success callback. */ run: function(url_params, success_callback) { // Add tool params to URL params. $.extend(url_params, this.get_param_values_dict()); // Run tool. // TODO: rewrite to use $.when(): var json_run_tool = function() { $.getJSON(rerun_tool_url, url_params, 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 if (response === "pending") { // Converting/indexing input datasets; show message and try again. new_track.container_div.addClass("pending"); new_track.content_div.text("Converting input data so that it can be easily reused."); setTimeout(json_run_tool, 2000); } else { // Job submitted and running. success_callback(response); } }); }; json_run_tool(); } }); /** * Tool parameters. */ var ToolParameter = function(name, label, html, value) { this.name = name; this.label = label; this.html = html; this.value = value; }; var NumberParameter = function(name, label, html, value, min, max) { ToolParameter.call(this, name, label, html, value) this.min = min; this.max = max; }; /** * Filters that enable users to show/hide data points dynamically. */ var Filter = function(name, index, tool_id, tool_exp_name) { this.name = name; // Index into payload to filter. this.index = index; this.tool_id = tool_id; // Name to use for filter when building expression for tool. this.tool_exp_name = tool_exp_name; }; /** * Number filters have a min, max as well as a low, high; low and high are used */ var NumberFilter = function(name, index, tool_id, tool_exp_name) { Filter.call(this, name, index, tool_id, tool_exp_name); // Filter low/high. These values are used to filter elements. this.low = -Number.MAX_VALUE; this.high = Number.MAX_VALUE; // Slide min/max. These values are used to set/update slider. this.min = Number.MAX_VALUE; this.max = -Number.MAX_VALUE; // UI Slider element and label that is associated with filter. this.slider = null; this.slider_label = null; }; extend(NumberFilter.prototype, { /** * Returns true if filter can be applied to element. */ applies_to: function(element) { if (element.length > this.index) { return true; } return false; }, /** * Returns true if (a) element's value 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; } var val = parseInt(element[this.index]); return (isNaN(val) || (val >= this.low && val <= this.high)); }, /** * 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. if (element[this.index] < this.min) { this.min = Math.floor(element[this.index]); updated = true; } if (element[this.index] > this.max) { this.max = Math.ceil(element[this.index]); updated = true; } return updated; }, /** * Update filter's slider. */ update_ui_elt: function () { var get_slider_step = function(min, max) { var range = max - min; return (range <= 2 ? 0.01 : 1); }; 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", 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, filters_list) { // // Unpack filters from dict. // this.track = track; this.filters = []; for (var i = 0; i < filters_list.length; i++) { var filter_dict = filters_list[i], name = filter_dict.name, type = filter_dict.type, index = filter_dict.index, tool_id = filter_dict.tool_id, tool_exp_name = filter_dict.tool_exp_name; if (type === 'int' || type === 'float') { this.filters[i] = new NumberFilter(name, index, tool_id, tool_exp_name); } else { console.log("ERROR: unsupported filter: ", name, type) } } // // Init HTML elements for filters. // // Function that supports inline text editing of slider values for tools, filters. // 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; // 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); }).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); } } slider.slider((multi_value ? "values" : "value"), new_value); } }); }); }; // Create filtering 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(); }).bind("click", function(e) { e.stopPropagation(); }).bind("dblclick", function(e) { e.stopPropagation(); }).bind("keydown", function(e) { e.stopPropagation(); }); var manager = this; $.each(this.filters, function(index, filter) { var filter_div = $("
").addClass("slider-row").appendTo(manager.parent_div); // Set up filter label (name, values). var filter_label = $("
").addClass("slider-label").appendTo(filter_div) var name_span = $("").addClass("slider-name").text(filter.name + " ").appendTo(filter_label); var values_span = $(""); var values_span_container = $("").addClass("slider-value").appendTo(filter_label).append("[").append(values_span).append("]"); // Set up slider for filter. var slider_div = $("
").addClass("slider").appendTo(filter_div); filter.control_element = $("
").attr("id", filter.name + "-filter-control").appendTo(slider_div); var prev_values = [0,0]; filter.control_element.slider({ range: true, min: Number.MAX_VALUE, max: -Number.MIN_VALUE, values: [0, 0], slide: function(event, ui) { // // Always update UI values, but set timeout for doing more--especially drawing-- // so that viz is more responsive. // prev_values = ui.values; values_span.text(ui.values[0] + "-" + ui.values[1]); setTimeout(function() { if (ui.values[0] == prev_values[0] && ui.values[1] == prev_values[1]) { var values = ui.values; // Set new values in UI. values_span.text(values[0] + "-" + values[1]); // Set new values in filter. filter.low = values[0]; filter.high = values[1]; // Redraw track. manager.track.draw(true, true); } }, 50); }, 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); // Add to clear floating layout. $("
").appendTo(filter_div); }); // 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, { /** * Reset filters to min and max. */ reset_filters: function() { for (var i = 0; i < this.filters.length; i++) { filter = this.filters[i]; filter.slider.slider("option", "values", [filter.min, filter.max]); } }, 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, operation; 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. var num_filters = active_filters_list.length; (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. show_modal("Filter Dataset", "Error running tool " + tool_id, { "Close" : hide_modal } ); } else if (filters.length === 0) { // No more filters to run. show_modal("Filtering Dataset", "Filter(s) are running on the complete dataset. Outputs are in dataset's history.", { "Close" : hide_modal } ); } else { // More filters to run. run_filter(response.dataset_id, filters); } }); })(this.track.dataset_id, active_filters_list); } }); /** * Container for track configuration data. */ var TrackConfig = function( options ) { this.track = options.track; this.params = options.params; this.values = {} if ( options.saved_values ) { this.restore_values( options.saved_values ); } this.onchange = options.onchange } extend( TrackConfig.prototype, { restore_values: function( values ) { var track_config = this; $.each( this.params, function( index, param ) { if ( values[ param.key ] !== undefined ) { track_config.values[ param.key ] = values[ param.key ]; } else { track_config.values[ param.key ] = param.default_value; } }); }, build_form: function() { var track_config = this; var container = $("
"); $.each( this.params, function( index, param ) { if ( ! param.hidden ) { var id = 'param_' + index; var row = $("
").appendTo( container ); row.append( $('