PageRenderTime 9ms CodeModel.GetById 6ms app.highlight 84ms RepoModel.GetById 3ms app.codeStats 0ms

/static/scripts/trackster.js

https://bitbucket.org/cistrome/cistrome-harvard/
JavaScript | 1716 lines | 1167 code | 116 blank | 433 comment | 156 complexity | 4ba25aa395361e2eecd6279cebcda7ab MD5 | raw file

Large files files are truncated, but you can click here to view the full file

   1/* Trackster
   2    2010-2011: James Taylor, Kanwei Li, Jeremy Goecks
   3*/
   4
   5var class_module = function(require, exports) {
   6    
   7// Module is a placeholder for a more complete inheritence approach
   8    
   9/** Simple extend function for inheritence */
  10var extend = function() {
  11    var target = arguments[0];
  12    for ( var i = 1; i < arguments.length; i++ ) {
  13        var other = arguments[i];
  14        for ( key in other ) {
  15            target[key] = other[key];
  16        }
  17    }
  18    return target;
  19};
  20
  21exports.extend = extend;
  22
  23// end class_module encapsulation
  24};
  25
  26/**
  27 * Find browser's requestAnimationFrame method or fallback on a setTimeout 
  28 */
  29var requestAnimationFrame = (function(){
  30    return  window.requestAnimationFrame       || 
  31            window.webkitRequestAnimationFrame || 
  32            window.mozRequestAnimationFrame    || 
  33            window.oRequestAnimationFrame      || 
  34            window.msRequestAnimationFrame     || 
  35            function( callback, element ) {
  36              window.setTimeout(callback, 1000 / 60);
  37            };
  38})();
  39
  40
  41/**
  42 * Compute the type of overlap between two regions. They are assumed to be on the same chrom/contig.
  43 * The overlap is computed relative to the second region; hence, OVERLAP_START indicates that the first
  44 * region overlaps the start (but not the end) of the second region.
  45 */
  46var BEFORE = 1001, CONTAINS = 1002, OVERLAP_START = 1003, OVERLAP_END = 1004, CONTAINED_BY = 1005, AFTER = 1006;
  47var compute_overlap = function(first_region, second_region) {
  48    var 
  49        first_start = first_region[0], first_end = first_region[1],
  50        second_start = second_region[0], second_end = second_region[1],
  51        overlap;
  52    if (first_start < second_start) {
  53        if (first_end < second_start) {
  54            overlap = BEFORE;
  55        }
  56        else if (first_end <= second_end) {
  57            overlap = OVERLAP_START;
  58        }
  59        else { // first_end > second_end
  60            overlap = CONTAINS;
  61        }
  62    }
  63    else { // first_start >= second_start
  64        if (first_start > second_end) {
  65            overlap = AFTER;
  66        }
  67        else if (first_end <= second_end) {
  68            overlap = CONTAINED_BY;
  69        }
  70        else {
  71            overlap = OVERLAP_END;
  72        }
  73    }
  74    
  75    return overlap;
  76};
  77/**
  78 * Returns true if regions overlap.
  79 */
  80var is_overlap = function(first_region, second_region) {
  81    var overlap = compute_overlap(first_region, second_region);
  82    return (overlap !== BEFORE && overlap !== AFTER);
  83};
  84
  85// Encapsulate -- anything to be availabe outside this block is added to exports
  86var trackster_module = function(require, exports) {
  87
  88var extend = require('class').extend,
  89    slotting = require('slotting'),
  90    painters = require('painters');
  91    
  92    
  93// ---- Canvas management and extensions ----
  94
  95/**
  96 * Canvas manager is used to create canvases, for browsers, this deals with
  97 * backward comparibility using excanvas, as well as providing a pattern cache
  98 */
  99var CanvasManager = function( document, default_font ) {
 100    this.document = document;
 101    this.default_font = default_font !== undefined ? default_font : "9px Monaco, Lucida Console, monospace";
 102    
 103    this.dummy_canvas = this.new_canvas();
 104    this.dummy_context = this.dummy_canvas.getContext('2d');
 105    this.dummy_context.font = this.default_font;
 106    
 107    this.char_width_px = this.dummy_context.measureText("A").width;
 108    
 109    this.patterns = {};
 110
 111    // FIXME: move somewhere to make this more general
 112    this.load_pattern( 'right_strand', "/visualization/strand_right.png" );
 113    this.load_pattern( 'left_strand', "/visualization/strand_left.png" );
 114    this.load_pattern( 'right_strand_inv', "/visualization/strand_right_inv.png" );
 115    this.load_pattern( 'left_strand_inv', "/visualization/strand_left_inv.png" );
 116}
 117
 118extend( CanvasManager.prototype, {
 119    load_pattern: function( key, path ) {
 120        var patterns = this.patterns,
 121            dummy_context = this.dummy_context,
 122            image = new Image();
 123        // FIXME: where does image_path come from? not in browser.mako...
 124        image.src = image_path + path;
 125        image.onload = function() {
 126            patterns[key] = dummy_context.createPattern( image, "repeat" );
 127        }
 128    },
 129    get_pattern: function( key ) {
 130        return this.patterns[key];
 131    },
 132    new_canvas: function() {
 133        var canvas = this.document.createElement("canvas");
 134        // If using excanvas in IE, we need to explicately attach the canvas
 135        // methods to the DOM element
 136        if (window.G_vmlCanvasManager) { G_vmlCanvasManager.initElement(canvas); }
 137        // Keep a reference back to the manager
 138        canvas.manager = this;
 139        return canvas;
 140    }
 141});
 142
 143// ---- Web UI specific utilities ----
 144
 145/**
 146 * Dictionary of HTML element-JavaScript object relationships.
 147 */
 148// TODO: probably should separate moveable objects from containers.
 149var html_elt_js_obj_dict = {};
 150
 151/**
 152 * Designates an HTML as a container.
 153 */
 154var is_container = function(element, obj) {
 155    html_elt_js_obj_dict[element.attr("id")] = obj;
 156};
 157
 158/** 
 159 * Make `element` moveable within parent and sibling elements by dragging `handle` (a selector).
 160 * Function manages JS objects, containers as well.
 161 *
 162 * @param element HTML element to make moveable
 163 * @param handle_class classname that denotes HTML element to be used as handle
 164 * @param container_selector selector used to identify possible containers for this element
 165 * @param element_js_obj JavaScript object associated with element; used 
 166 */
 167var moveable = function(element, handle_class, container_selector, element_js_obj) {
 168    // HACK: set default value for container selector.
 169    container_selector = ".group";
 170    var css_border_props = {};
 171
 172    // Register element with its object.
 173    html_elt_js_obj_dict[element.attr("id")] = element_js_obj;
 174    
 175    // Need to provide selector for handle, not class.
 176    element.bind( "drag", { handle: "." + handle_class, relative: true }, function ( e, d ) {
 177        var element = $(this);
 178        var 
 179            parent = $(this).parent(),
 180            children = parent.children(),
 181            this_obj = html_elt_js_obj_dict[$(this).attr("id")],
 182            child,
 183            container,
 184            top,
 185            bottom,
 186            i;
 187            
 188        //
 189        // Enable three types of dragging: (a) out of container; (b) into container; 
 190        // (c) sibling movement, aka sorting. Handle in this order for simplicity.
 191        //
 192        
 193        // Handle dragging out of container.
 194        container = $(this).parents(container_selector);
 195        if (container.length !== 0) {
 196            top = container.position().top;
 197            bottom = top + container.outerHeight();
 198            if (d.offsetY < top) {
 199                // Moving above container.
 200                $(this).insertBefore(container);
 201                var cur_container = html_elt_js_obj_dict[container.attr("id")];
 202                cur_container.remove_drawable(this_obj);
 203                cur_container.container.add_drawable_before(this_obj, cur_container);
 204                return;
 205            }
 206            else if (d.offsetY > bottom) {
 207                // Moving below container.
 208                $(this).insertAfter(container);
 209                var cur_container = html_elt_js_obj_dict[container.attr("id")];
 210                cur_container.remove_drawable(this_obj);
 211                cur_container.container.add_drawable(this_obj);
 212                return;
 213            }            
 214        }
 215        
 216        // Handle dragging into container. Child is appended to container's content_div.
 217        container = null;
 218        for ( i = 0; i < children.length; i++ ) {
 219            child = $(children.get(i));
 220            top = child.position().top;
 221            bottom = top + child.outerHeight();
 222            // Dragging into container if child is a container and offset is inside container.
 223            if ( child.is(container_selector) && this !== child.get(0) && 
 224                 d.offsetY >= top && d.offsetY <= bottom ) {
 225                // Append/prepend based on where offsetY is closest to and return.
 226                if (d.offsetY - top < bottom - d.offsetY) {
 227                    child.find(".content-div").prepend(this);
 228                }
 229                else {
 230                    child.find(".content-div").append(this);
 231                }
 232                // Update containers. Object may not have container if it is being moved quickly.
 233                if (this_obj.container) {
 234                    this_obj.container.remove_drawable(this_obj);                    
 235                }
 236                html_elt_js_obj_dict[child.attr("id")].add_drawable(this_obj);
 237                return;
 238            }
 239        }
 240
 241        // Handle sibling movement, aka sorting.
 242        
 243        // Determine new position
 244        for ( i = 0; i < children.length; i++ ) {
 245            if ( d.offsetY < $(children.get(i)).position().top ) {
 246                break;
 247            }
 248        }
 249        // If not already in the right place, move. Need 
 250        // to handle the end specially since we don't have 
 251        // insert at index
 252        if ( i === children.length ) {
 253            if ( this !== children.get(i - 1) ) {
 254                parent.append(this);
 255                html_elt_js_obj_dict[parent.attr("id")].move_drawable(this_obj, i);
 256            }
 257        }
 258        else if ( this !== children.get(i) ) {
 259            $(this).insertBefore( children.get(i) );
 260            // Need to adjust insert position if moving down because move is changing 
 261            // indices of all list items.
 262            html_elt_js_obj_dict[parent.attr("id")].move_drawable(this_obj, (d.deltaY > 0 ? i-1 : i) );
 263        }
 264    }).bind("dragstart", function() {
 265        css_border_props["border-top"] = element.css("border-top");
 266        css_border_props["border-bottom"] = element.css("border-bottom");
 267        $(this).css({
 268            "border-top": "1px solid blue",
 269            "border-bottom": "1px solid blue"
 270        });
 271    }).bind("dragend", function() {
 272        $(this).css(css_border_props);
 273    });
 274};
 275
 276// TODO: do we need to export?
 277exports.moveable = moveable;
 278
 279/**
 280 * Init constants & functions used throughout trackster.
 281 */
 282var 
 283    // Minimum height of a track's contents; this must correspond to the .track-content's minimum height.
 284    MIN_TRACK_HEIGHT = 16,
 285    // FIXME: font size may not be static
 286    CHAR_HEIGHT_PX = 9,
 287    // Padding at the top of tracks for error messages
 288    ERROR_PADDING = 20,
 289    SUMMARY_TREE_TOP_PADDING = CHAR_HEIGHT_PX + 2,
 290    // Maximum number of rows un a slotted track
 291    MAX_FEATURE_DEPTH = 100,
 292    // Minimum width for window for squish to be used.
 293    MIN_SQUISH_VIEW_WIDTH = 12000,
 294    
 295    // Other constants.
 296    DENSITY = 200,
 297    RESOLUTION = 5,
 298    FEATURE_LEVELS = 10,
 299    DEFAULT_DATA_QUERY_WAIT = 5000,
 300    // Maximum number of chromosomes that are selectable at any one time.
 301    MAX_CHROMS_SELECTABLE = 100,
 302    DATA_ERROR = "There was an error in indexing this dataset. ",
 303    DATA_NOCONVERTER = "A converter for this dataset is not installed. Please check your datatypes_conf.xml file.",
 304    DATA_NONE = "No data for this chrom/contig.",
 305    DATA_PENDING = "Currently indexing... please wait",
 306    DATA_CANNOT_RUN_TOOL = "Tool cannot be rerun: ",
 307    DATA_LOADING = "Loading data...",
 308    DATA_OK = "Ready for display",
 309    CACHED_TILES_FEATURE = 10,
 310    CACHED_TILES_LINE = 5,
 311    CACHED_DATA = 5;
 312
 313function round_1000(num) {
 314    return Math.round(num * 1000) / 1000;    
 315};
 316
 317/**
 318 * Generic cache that handles key/value pairs.
 319 */ 
 320var Cache = function( num_elements ) {
 321    this.num_elements = num_elements;
 322    this.clear();
 323};
 324extend(Cache.prototype, {
 325    get: function(key) {
 326        var index = this.key_ary.indexOf(key);
 327        if (index !== -1) {
 328            if (this.obj_cache[key].stale) {
 329                // Object is stale, so remove key and object.
 330                this.key_ary.splice(index, 1);
 331                delete this.obj_cache[key];
 332            }
 333            else {
 334                this.move_key_to_end(key, index);
 335            }
 336        }
 337        return this.obj_cache[key];
 338    },
 339    set: function(key, value) {
 340        if (!this.obj_cache[key]) {
 341            if (this.key_ary.length >= this.num_elements) {
 342                // Remove first element
 343                var deleted_key = this.key_ary.shift();
 344                delete this.obj_cache[deleted_key];
 345            }
 346            this.key_ary.push(key);
 347        }
 348        this.obj_cache[key] = value;
 349        return value;
 350    },
 351    // Move key to end of cache. Keys are removed from the front, so moving a key to the end 
 352    // delays the key's removal.
 353    move_key_to_end: function(key, index) {
 354        this.key_ary.splice(index, 1);
 355        this.key_ary.push(key);
 356    },
 357    clear: function() {
 358        this.obj_cache = {};
 359        this.key_ary = [];
 360    },
 361    // Returns the number of elements in the cache.
 362    size: function() {
 363        return this.key_ary.length;
 364    }
 365});
 366
 367/**
 368 * Data manager for a track.
 369 */
 370var DataManager = function(num_elements, track, subset) {
 371    Cache.call(this, num_elements);
 372    this.track = track;
 373    this.subset = (subset !== undefined ? subset : true);
 374};
 375extend(DataManager.prototype, Cache.prototype, {
 376    /**
 377     * Load data from server; returns AJAX object so that use of Deferred is possible.
 378     */
 379    load_data: function(low, high, mode, resolution, extra_params) {
 380        // Setup data request params.
 381        var 
 382            chrom = this.track.view.chrom,
 383            params = {"chrom": chrom, "low": low, "high": high, "mode": mode, 
 384                      "resolution": resolution, "dataset_id" : this.track.dataset_id, 
 385                      "hda_ldda": this.track.hda_ldda};
 386        $.extend(params, extra_params);
 387        
 388        // Add track filters to params.
 389        if (this.track.filters_manager) {
 390            var filter_names = [];
 391            var filters = this.track.filters_manager.filters;
 392            for (var i = 0; i < filters.length; i++) {
 393                filter_names[filter_names.length] = filters[i].name;
 394            }
 395            params.filter_cols = JSON.stringify(filter_names);
 396        }
 397                        
 398        // Do request.
 399        var manager = this;
 400        return $.getJSON(this.track.data_url, params, function (result) {
 401            manager.set_data(low, high, mode, result);
 402        });
 403    },
 404    /**
 405     * Get track data.
 406     */
 407    get_data: function(low, high, mode, resolution, extra_params) {
 408        // Debugging:
 409        //console.log("get_data", low, high, mode);
 410        /*
 411        console.log("cache contents:")
 412        for (var i = 0; i < this.key_ary.length; i++) {
 413            console.log("\t", this.key_ary[i], this.obj_cache[this.key_ary[i]]);
 414        }
 415        */
 416        
 417        // Look for entry and return if found.
 418        var entry = this.get_data_from_cache(low, high, mode);
 419        if (entry) { return entry; }
 420
 421        //
 422        // If data supports subsetting:
 423        // Look in cache for data that can be used. Data can be reused if it
 424        // has the requested data and is not summary tree and has details.
 425        // TODO: this logic could be improved if the visualization knew whether
 426        // the data was "index" or "data." Also could slice the data so that
 427        // only data points in request are returned.
 428        //
 429        
 430        /* Disabling for now, more detailed data is never loaded for line tracks
 431        TODO: can using resolution in the key solve this problem?
 432        if (this.subset) {
 433            var key, split_key, entry_low, entry_high, mode, entry;
 434            for (var i = 0; i < this.key_ary.length; i++) {
 435                key = this.key_ary[i];
 436                split_key = this.split_key(key);
 437                entry_low = split_key[0];
 438                entry_high = split_key[1];
 439            
 440                if (low >= entry_low && high <= entry_high) {
 441                    // This track has the range of data needed; check other attributes.
 442                    entry = this.obj_cache[key];
 443                    if (entry.dataset_type !== "summary_tree" && entry.extra_info !== "no_detail") {
 444                        // Data is usable.
 445                        this.move_key_to_end(key, i);
 446                        return entry;
 447                    }
 448                }
 449            }
 450        }
 451        */
 452                
 453        // Load data from server. The deferred is immediately saved until the
 454        // data is ready, it then replaces itself with the actual data
 455        entry = this.load_data(low, high, mode, resolution, extra_params);
 456        this.set_data(low, high, mode, entry);
 457        return entry
 458    },
 459    /** "Deep" data request; used as a parameter for DataManager.get_more_data() */
 460    DEEP_DATA_REQ: "deep",
 461    /** "Broad" data request; used as a parameter for DataManager.get_more_data() */
 462    BROAD_DATA_REQ: "breadth",
 463    /**
 464     * Gets more data for a region using either a depth-first or a breadth-first approach.
 465     */
 466    get_more_data: function(low, high, mode, resolution, extra_params, req_type) {
 467        //
 468        // Get current data from cache and mark as stale.
 469        //
 470        var cur_data = this.get_data_from_cache(low, high, mode);
 471        if (!cur_data) {
 472            console.log("ERROR: no current data for: ", this.track, low, high, mode, resolution, extra_params);
 473            return;
 474        }
 475        cur_data.stale = true;
 476        
 477        //
 478        // Set parameters based on request type.
 479        //
 480        var query_low = low;
 481        if (req_type === this.DEEP_DATA_REQ) {
 482            // Use same interval but set start_val to skip data that's already in cur_data.
 483            $.extend(extra_params, {start_val: cur_data.data.length + 1});
 484        }
 485        else if (req_type === this.BROAD_DATA_REQ) {
 486            // To get past an area of extreme feature depth, set query low to be after either
 487            // (a) the maximum high or HACK/FIXME (b) the end of the last feature returned.
 488            query_low = (cur_data.max_high ? cur_data.max_high : cur_data.data[cur_data.data.length - 1][2]) + 1;
 489        }
 490        
 491        //
 492        // Get additional data, append to current data, and set new data. Use a custom deferred object
 493        // to signal when new data is available.
 494        //
 495        var 
 496            data_manager = this,
 497            new_data_request = this.load_data(query_low, high, mode, resolution, extra_params)
 498            new_data_available = $.Deferred();
 499        // load_data sets cache to new_data_request, but use custom deferred object so that signal and data
 500        // is all data, not just new data.
 501        this.set_data(low, high, mode, new_data_available);
 502        $.when(new_data_request).then(function(result) {
 503            // Update data and message.
 504            if (result.data) {
 505                result.data = cur_data.data.concat(result.data);
 506                if (result.max_low) {
 507                    result.max_low = cur_data.max_low;
 508                }
 509                if (result.message) {
 510                    // HACK: replace number in message with current data length. Works but is ugly.
 511                    result.message = result.message.replace(/[0-9]+/, result.data.length);
 512                }
 513            }
 514            data_manager.set_data(low, high, mode, result);
 515            new_data_available.resolve(result);
 516        });
 517        return new_data_available;
 518    },
 519    /**
 520     * Gets data from the cache.
 521     */
 522    get_data_from_cache: function(low, high, mode) {
 523        return this.get(this.gen_key(low, high, mode));
 524    },
 525    /**
 526     * Sets data in the cache.
 527     */
 528    set_data: function(low, high, mode, result) {
 529        //console.log("set_data", low, high, mode, result);
 530        return this.set(this.gen_key(low, high, mode), result);
 531    },
 532    /**
 533     * Generate key for cache.
 534     */
 535    // TODO: use chrom in key so that (a) data is not thrown away when changing chroms and (b)
 536    // manager does not need to be cleared when changing chroms.
 537    // TODO: use resolution in key b/c summary tree data is dependent on resolution -- is this 
 538    // necessary, i.e. will resolution change but not low/high/mode?
 539    gen_key: function(low, high, mode) {
 540        var key = low + "_" + high + "_" + mode;
 541        return key;
 542    },
 543    /**
 544     * Split key from cache into array with format [low, high, mode]
 545     */
 546    split_key: function(key) {
 547        return key.split("_");
 548    }
 549});
 550
 551var ReferenceTrackDataManager = function(num_elements, track, subset) {
 552    DataManager.call(this, num_elements, track, subset);
 553};
 554extend(ReferenceTrackDataManager.prototype, DataManager.prototype, Cache.prototype, {
 555    load_data: function(chrom, low, high, mode, resolution, extra_params) {
 556        if (resolution > 1) {
 557            // Now that data is pre-fetched before draw, we don't load reference tracks
 558            // unless it's at the bottom level
 559            return;
 560        }
 561        return DataManager.prototype.load_data.call(this, chrom, low, high, mode, resolution, extra_params);
 562    }
 563});
 564
 565/**
 566 * Drawables hierarchy:
 567 *
 568 * Drawable
 569 *    --> DrawableCollection
 570 *        --> DrawableGroup
 571 *        --> View
 572 *    --> Track
 573 */
 574
 575/**
 576 * Base interface for all drawable objects.
 577 */
 578var Drawable = function(name, view, prefs, parent_element, drag_handle_class, container) {
 579    this.name = name;
 580    this.view = view;
 581    this.parent_element = parent_element;
 582    this.drag_handle_class = drag_handle_class;
 583    this.container = container;
 584    this.config = new DrawableConfig({
 585        track: this,
 586        params: [ 
 587            { key: 'name', label: 'Name', type: 'text', default_value: name }
 588        ],
 589        saved_values: prefs,
 590        onchange: function() {
 591            this.track.set_name(this.track.config.values.name);
 592        }
 593    });
 594    this.prefs = this.config.values;
 595};
 596
 597extend(Drawable.prototype, {
 598    init: function() {},
 599    request_draw: function() {},
 600    _draw: function() {},
 601    to_json: function() {},
 602    make_name_popup_menu: function() {},
 603    /**
 604     * Set drawable name.
 605     */ 
 606    set_name: function(new_name) {
 607        this.old_name = this.name;
 608        this.name = new_name;
 609        this.name_div.text(this.name);
 610    },
 611    /**
 612     * Revert track name; currently name can be reverted only once.
 613     */
 614    revert_name: function() {
 615        this.name = this.old_name;
 616        this.name_div.text(this.name);
 617    },
 618    /**
 619     * Remove drawable (a) from its container and (b) from the HTML.
 620     */
 621    remove: function() {
 622        this.container.remove_drawable(this);
 623        
 624        this.container_div.fadeOut('slow', function() { 
 625            $(this).remove();
 626            // HACK: is there a better way to update the view?
 627            view.update_intro_div();
 628            view.has_changes = true;
 629        });
 630    }
 631});
 632
 633/**
 634 * A collection of drawable objects.
 635 */
 636var DrawableCollection = function(obj_type, name, view, prefs, parent_element, drag_handle_class, container) {
 637    Drawable.call(this, name, view, prefs, parent_element, drag_handle_class, container);
 638    
 639    // Attribute init.
 640    this.obj_type = obj_type;
 641    this.drawables = [];
 642};
 643extend(DrawableCollection.prototype, Drawable.prototype, {
 644    /**
 645     * Init each drawable in the collection.
 646     */
 647    init: function() {
 648        for (var i = 0; i < this.drawables.length; i++) {
 649            this.drawables[i].init();
 650        }
 651    },    
 652    /**
 653     * Draw each drawable in the collection.
 654     */
 655    _draw: function() {
 656        for (var i = 0; i < this.drawables.length; i++) {
 657            this.drawables[i]._draw();
 658        }
 659    },
 660    /**
 661     * Returns jsonified representation of collection.
 662     */
 663    to_json: function() {
 664        var jsonified_drawables = [];
 665        for (var i = 0; i < this.drawables.length; i++) {
 666            jsonified_drawables.push(this.drawables[i].to_json());
 667        }
 668        return {
 669            name: this.name,
 670            prefs: this.prefs,
 671            obj_type: this.obj_type,
 672            drawables: jsonified_drawables
 673            };
 674    },
 675    /**
 676     * Add a drawable to the end of the collection.
 677     */
 678    add_drawable: function(drawable) {
 679        this.drawables.push(drawable);
 680        drawable.container = this;
 681    },
 682    /**
 683     * Add a drawable before another drawable.
 684     */
 685    add_drawable_before: function(drawable, other) {
 686        var index = this.drawables.indexOf(other);
 687        if (index != -1) {
 688            this.drawables.splice(index, 0, drawable);
 689            return true;
 690        }
 691        return false;
 692    },
 693    /**
 694     * Remove drawable from this collection.
 695     */
 696    remove_drawable: function(drawable) {
 697        var index = this.drawables.indexOf(drawable);
 698        if (index != -1) {
 699            // Found drawable to remove.
 700            this.drawables.splice(index, 1);
 701            drawable.container = null;
 702            return true;        
 703        }
 704        return false;
 705    },
 706    /**
 707     * Move drawable to another location in collection.
 708     */
 709    move_drawable: function(drawable, new_position) {
 710        var index = this.drawables.indexOf(drawable);
 711        if (index != -1) {
 712            // Remove from current position:
 713            this.drawables.splice(index, 1);
 714            // insert into new position:
 715            this.drawables.splice(new_position, 0, drawable);
 716            return true;
 717        }
 718        return false;
 719    }
 720});
 721
 722/**
 723 * A group of drawables that are moveable, visible.
 724 */
 725var DrawableGroup = function(name, view, prefs, parent_element, container) {
 726    DrawableCollection.call(this, "DrawableGroup", name, view, prefs, parent_element, "group-handle", container);
 727        
 728    // HTML elements.
 729    if (!DrawableGroup.id_counter) { DrawableGroup.id_counter = 0; }
 730    var group_id = DrawableGroup.id_counter++
 731    this.container_div = $("<div/>").addClass("group").attr("id", "group_" + group_id).appendTo(this.parent_element);
 732    this.header_div = $("<div/>").addClass("track-header").appendTo(this.container_div);
 733    this.header_div.append($("<div/>").addClass(this.drag_handle_class));
 734    this.name_div = $("<div/>").addClass("group-name menubutton popup").text(this.name).appendTo(this.header_div);    
 735    this.content_div = $("<div/>").addClass("content-div").attr("id", "group_" + group_id + "_content_div").appendTo(this.container_div);
 736    
 737    // Set up containers/moving for group: register both container and content div as container
 738    // because both are used as containers. Group can be moved.
 739    is_container(this.container_div, this);
 740    is_container(this.content_div, this);
 741    moveable(this.container_div, this.drag_handle_class, ".group", this);
 742    
 743    this.make_name_popup_menu();
 744};
 745
 746extend(DrawableGroup.prototype, Drawable.prototype, DrawableCollection.prototype, {
 747    /**
 748     * Make popup menu for group.
 749     */
 750    make_name_popup_menu: function() {
 751        var group = this;
 752        
 753        var group_dropdown = {};
 754                
 755        //
 756        // Edit config option.
 757        //
 758        group_dropdown["Edit configuration"] = function() {
 759            var cancel_fn = function() { hide_modal(); $(window).unbind("keypress.check_enter_esc"); },
 760                ok_fn = function() { 
 761                    group.config.update_from_form( $(".dialog-box") );
 762                    hide_modal(); 
 763                    $(window).unbind("keypress.check_enter_esc"); 
 764                },
 765                check_enter_esc = function(e) {
 766                    if ((e.keyCode || e.which) === 27) { // Escape key
 767                        cancel_fn();
 768                    } else if ((e.keyCode || e.which) === 13) { // Enter key
 769                        ok_fn();
 770                    }
 771                };
 772
 773            $(window).bind("keypress.check_enter_esc", check_enter_esc);        
 774            show_modal("Configure Group", group.config.build_form(), {
 775                "Cancel": cancel_fn,
 776                "OK": ok_fn
 777            });
 778        };
 779        
 780        //
 781        // Remove option.
 782        //
 783        group_dropdown.Remove = function() {
 784            group.remove();
 785        };
 786        
 787        make_popupmenu(group.name_div, group_dropdown);
 788    }
 789});
 790
 791/**
 792 * View object manages complete viz view, including tracks and user interactions.
 793 */
 794var View = function(container, title, vis_id, dbkey) {
 795    DrawableCollection.call(this, "View");
 796    this.container = container;
 797    this.chrom = null;
 798    this.vis_id = vis_id;
 799    this.dbkey = dbkey;
 800    this.title = title;
 801    // Alias tracks to point at drawables. TODO: changes tracks to 'drawables' or something similar.
 802    this.tracks = this.drawables;
 803    this.label_tracks = [];
 804    this.tracks_to_be_redrawn = [];
 805    this.max_low = 0;
 806    this.max_high = 0;
 807    this.zoom_factor = 3;
 808    this.min_separation = 30;
 809    this.has_changes = false;
 810    // Deferred object that indicates when view's chrom data has been loaded.
 811    this.load_chroms_deferred = null;
 812    this.init();
 813    this.canvas_manager = new CanvasManager( container.get(0).ownerDocument );
 814    this.reset();
 815};
 816extend( View.prototype, DrawableCollection.prototype, {
 817    init: function() {
 818        // Create DOM elements
 819        var parent_element = this.container,
 820            view = this;
 821        // Top container for things that are fixed at the top
 822        this.top_container = $("<div/>").addClass("top-container").appendTo(parent_element);
 823        // Content container, primary tracks are contained in here
 824        this.content_div = $("<div/>").addClass("content").css("position", "relative").appendTo(parent_element);
 825        // Bottom container for things that are fixed at the bottom
 826        this.bottom_container = $("<div/>").addClass("bottom-container").appendTo(parent_element);
 827        // Label track fixed at top 
 828        this.top_labeltrack = $("<div/>").addClass("top-labeltrack").appendTo(this.top_container);        
 829        // Viewport for dragging tracks in center    
 830        this.viewport_container = $("<div/>").addClass("viewport-container").attr("id", "viewport-container").appendTo(this.content_div);
 831        is_container(this.viewport_container, view);
 832        // Introduction div shown when there are no tracks.
 833        this.intro_div = $("<div/>").addClass("intro");
 834        var add_tracks_button = $("<div/>").text("Add Datasets to Visualization").addClass("action-button").appendTo(this.intro_div).click(function () {
 835            add_tracks();
 836        });
 837        // Another label track at bottom
 838        this.nav_labeltrack = $("<div/>").addClass("nav-labeltrack").appendTo(this.bottom_container);
 839        // Navigation at top
 840        this.nav_container = $("<div/>").addClass("nav-container").prependTo(this.top_container);
 841        this.nav = $("<div/>").addClass("nav").appendTo(this.nav_container);
 842        // Overview (scrollbar and overview plot) at bottom
 843        this.overview = $("<div/>").addClass("overview").appendTo(this.bottom_container);
 844        this.overview_viewport = $("<div/>").addClass("overview-viewport").appendTo(this.overview);
 845        this.overview_close = $("<a href='javascript:void(0);'>Close Overview</a>").addClass("overview-close").hide().appendTo(this.overview_viewport);
 846        this.overview_highlight = $("<div/>").addClass("overview-highlight").hide().appendTo(this.overview_viewport);
 847        this.overview_box_background = $("<div/>").addClass("overview-boxback").appendTo(this.overview_viewport);
 848        this.overview_box = $("<div/>").addClass("overview-box").appendTo(this.overview_viewport);
 849        this.default_overview_height = this.overview_box.height();
 850        
 851        this.nav_controls = $("<div/>").addClass("nav-controls").appendTo(this.nav);
 852        this.chrom_select = $("<select/>").attr({ "name": "chrom"}).css("width", "15em").addClass("no-autocomplete").append("<option value=''>Loading</option>").appendTo(this.nav_controls);
 853        var submit_nav = function(e) {
 854            if (e.type === "focusout" || (e.keyCode || e.which) === 13 || (e.keyCode || e.which) === 27 ) {
 855                if ((e.keyCode || e.which) !== 27) { // Not escape key
 856                    view.go_to( $(this).val() );
 857                }
 858                $(this).hide();
 859                $(this).val('');
 860                view.location_span.show();
 861                view.chrom_select.show();
 862            }
 863        };
 864        this.nav_input = $("<input/>").addClass("nav-input").hide().bind("keyup focusout", submit_nav).appendTo(this.nav_controls);
 865        this.location_span = $("<span/>").addClass("location").appendTo(this.nav_controls);
 866        this.location_span.click(function() {
 867            view.location_span.hide();
 868            view.chrom_select.hide();
 869            view.nav_input.val(view.chrom + ":" + view.low + "-" + view.high);
 870            view.nav_input.css("display", "inline-block");
 871            view.nav_input.select();
 872            view.nav_input.focus();
 873        });
 874        if (this.vis_id !== undefined) {
 875            this.hidden_input = $("<input/>").attr("type", "hidden").val(this.vis_id).appendTo(this.nav_controls);
 876        }
 877        this.zo_link = $("<a id='zoom-out' />").click(function() { view.zoom_out(); view.request_redraw(); }).appendTo(this.nav_controls);
 878        this.zi_link = $("<a id='zoom-in' />").click(function() { view.zoom_in(); view.request_redraw(); }).appendTo(this.nav_controls);        
 879        
 880        // Get initial set of chroms.
 881        this.load_chroms_deferred = this.load_chroms({low: 0});
 882        this.chrom_select.bind("change", function() {
 883            view.change_chrom(view.chrom_select.val());
 884        });
 885                
 886        /*
 887        this.content_div.bind("mousewheel", function( e, delta ) {
 888            if (Math.abs(delta) < 0.5) {
 889                return;
 890            }
 891            if (delta > 0) {
 892                view.zoom_in(e.pageX, this.viewport_container);
 893            } else {
 894                view.zoom_out();
 895            }
 896            e.preventDefault();
 897        });
 898        */
 899        
 900        // Blur tool/filter inputs when user clicks on content div.
 901        this.content_div.click(function( e ) {
 902            $(this).find("input").trigger("blur"); 
 903        });
 904
 905        // Double clicking zooms in
 906        this.content_div.bind("dblclick", function( e ) {
 907            view.zoom_in(e.pageX, this.viewport_container);
 908        });
 909
 910        // Dragging the overview box (~ horizontal scroll bar)
 911        this.overview_box.bind("dragstart", function( e, d ) {
 912            this.current_x = d.offsetX;
 913        }).bind("drag", function( e, d ) {
 914            var delta = d.offsetX - this.current_x;
 915            this.current_x = d.offsetX;
 916            var delta_chrom = Math.round(delta / view.viewport_container.width() * (view.max_high - view.max_low) );
 917            view.move_delta(-delta_chrom);
 918        });
 919        
 920        this.overview_close.click(function() {
 921            view.reset_overview();
 922        });
 923        
 924        // Dragging in the viewport scrolls
 925        this.viewport_container.bind( "draginit", function( e, d ) {
 926            // Disable interaction if started in scrollbar (for webkit)
 927            if ( e.clientX > view.viewport_container.width() - 16 ) {
 928                return false;
 929            }
 930        }).bind( "dragstart", function( e, d ) {
 931            d.original_low = view.low;
 932            d.current_height = e.clientY;
 933            d.current_x = d.offsetX;
 934        }).bind( "drag", function( e, d ) {
 935            var container = $(this);
 936            var delta = d.offsetX - d.current_x;
 937            var new_scroll = container.scrollTop() - (e.clientY - d.current_height);
 938            container.scrollTop(new_scroll);
 939            d.current_height = e.clientY;
 940            d.current_x = d.offsetX;
 941            var delta_chrom = Math.round(delta / view.viewport_container.width() * (view.high - view.low));
 942            view.move_delta(delta_chrom);
 943        // Also capture mouse wheel for left/right scrolling
 944        }).bind( 'mousewheel', function( e, d, dx, dy ) { 
 945            // Only act on x axis scrolling if we see if, y will be i
 946            // handled by the browser when the event bubbles up
 947            if ( dx ) {
 948                var delta_chrom = Math.round( - dx / view.viewport_container.width() * (view.high - view.low) );
 949                view.move_delta( delta_chrom );
 950            }
 951        });
 952       
 953        // Dragging in the top label track allows selecting a region
 954        // to zoom in 
 955        this.top_labeltrack.bind( "dragstart", function( e, d ) {
 956            return $("<div />").css( { 
 957                "height": view.content_div.height() + view.top_labeltrack.height() 
 958                            + view.nav_labeltrack.height() + 1, 
 959                "top": "0px", 
 960                "position": "absolute", 
 961                "background-color": "#ccf", 
 962                "opacity": 0.5, 
 963                 "z-index": 1000
 964            } ).appendTo( $(this) );
 965        }).bind( "drag", function( e, d ) {
 966            $( d.proxy ).css({ left: Math.min( e.pageX, d.startX ), width: Math.abs( e.pageX - d.startX ) });
 967            var min = Math.min(e.pageX, d.startX ) - view.container.offset().left,
 968                max = Math.max(e.pageX, d.startX ) - view.container.offset().left,
 969                span = (view.high - view.low),
 970                width = view.viewport_container.width();
 971            view.update_location( Math.round(min / width * span) + view.low, 
 972                                  Math.round(max / width * span) + view.low );
 973        }).bind( "dragend", function( e, d ) {
 974            var min = Math.min(e.pageX, d.startX),
 975                max = Math.max(e.pageX, d.startX),
 976                span = (view.high - view.low),
 977                width = view.viewport_container.width(),
 978                old_low = view.low;
 979            view.low = Math.round(min / width * span) + old_low;
 980            view.high = Math.round(max / width * span) + old_low;
 981            $(d.proxy).remove();
 982            view.request_redraw();
 983        });
 984        
 985        this.add_label_track( new LabelTrack( this, this.top_labeltrack ) );
 986        this.add_label_track( new LabelTrack( this, this.nav_labeltrack ) );
 987        
 988        $(window).bind("resize", function() { view.resize_window(); });
 989        $(document).bind("redraw", function() { view.redraw(); });
 990        
 991        this.reset();
 992        $(window).trigger("resize");
 993        this.update_intro_div();
 994    },
 995    /** Add or remove intro div depending on view state. */
 996    update_intro_div: function() {
 997        if (this.num_tracks === 0) {
 998            this.intro_div.appendTo(this.viewport_container);
 999        }
1000        else {
1001            this.intro_div.remove();
1002        }
1003    },
1004    update_location: function(low, high) {
1005        this.location_span.text( commatize(low) + ' - ' + commatize(high) );
1006        this.nav_input.val( this.chrom + ':' + commatize(low) + '-' + commatize(high) );
1007    },
1008    /**
1009     * Load chrom data for the view. Returns a jQuery Deferred.
1010     */
1011    load_chroms: function(url_parms) {
1012        url_parms['num'] = MAX_CHROMS_SELECTABLE;
1013        $.extend( url_parms, (this.vis_id !== undefined ? { vis_id: this.vis_id } : { dbkey: this.dbkey } ) );
1014        var 
1015            view = this,
1016            chrom_data = $.Deferred();
1017        $.ajax({
1018            url: chrom_url, 
1019            data: url_parms,
1020            dataType: "json",
1021            success: function (result) {
1022                // Show error if could not load chroms.
1023                if (result.chrom_info.length === 0) {
1024                    alert("Invalid chromosome: " + url_parms.chrom);
1025                    return;
1026                }
1027                
1028                // Load chroms.
1029                if (result.reference) {
1030                    view.add_label_track( new ReferenceTrack(view) );
1031                }
1032                view.chrom_data = result.chrom_info;
1033                var chrom_options = '<option value="">Select Chrom/Contig</option>';
1034                for (var i = 0, len = view.chrom_data.length; i < len; i++) {
1035                    var chrom = view.chrom_data[i].chrom;
1036                    chrom_options += '<option value="' + chrom + '">' + chrom + '</option>';
1037                }
1038                if (result.prev_chroms) {
1039                    chrom_options += '<option value="previous">Previous ' + MAX_CHROMS_SELECTABLE + '</option>';
1040                }
1041                if (result.next_chroms) {
1042                    chrom_options += '<option value="next">Next ' + MAX_CHROMS_SELECTABLE + '</option>';
1043                }
1044                view.chrom_select.html(chrom_options);
1045                view.chrom_start_index = result.start_index;
1046                
1047                chrom_data.resolve(result);
1048            },
1049            error: function() {
1050                alert("Could not load chroms for this dbkey:", view.dbkey);
1051            }
1052        });
1053        
1054        return chrom_data;
1055    },
1056    change_chrom: function(chrom, low, high) {
1057        // Don't do anything if chrom is "None" (hackish but some browsers already have this set), or null/blank
1058        if (!chrom || chrom === "None") {
1059            return;
1060        }
1061        
1062        var view = this;
1063        
1064        //
1065        // If user is navigating to previous/next set of chroms, load new chrom set and return.
1066        //
1067        if (chrom === "previous") {
1068            view.load_chroms({low: this.chrom_start_index - MAX_CHROMS_SELECTABLE});
1069            return;
1070        }
1071        if (chrom === "next") {
1072            view.load_chroms({low: this.chrom_start_index + MAX_CHROMS_SELECTABLE});
1073            return;
1074        }
1075    
1076        //
1077        // User is loading a particular chrom. Look first in current set; if not in current set, load new
1078        // chrom set.
1079        //
1080        var found = $.grep(view.chrom_data, function(v, i) {
1081            return v.chrom === chrom;
1082        })[0];
1083        if (found === undefined) {
1084            // Try to load chrom and then change to chrom.
1085            view.load_chroms({'chrom': chrom}, function() { view.change_chrom(chrom, low, high); });
1086            return;
1087        }
1088        else {
1089            // Switching to local chrom.
1090            if (chrom !== view.chrom) {
1091                view.chrom = chrom;
1092                view.chrom_select.val(view.chrom);
1093                view.max_high = found.len-1; // -1 because we're using 0-based indexing.
1094                view.reset();
1095                view.request_redraw(true);
1096
1097                for (var track_id = 0, len = view.tracks.length; track_id < len; track_id++) {
1098                    var track = view.tracks[track_id];
1099                    if (track.init) {
1100                        track.init();
1101                    }
1102                }
1103            }
1104            if (low !== undefined && high !== undefined) {
1105                view.low = Math.max(low, 0);
1106                view.high = Math.min(high, view.max_high);
1107            }
1108            view.reset_overview();
1109            view.request_redraw();
1110        }
1111    },
1112    go_to: function(str) {
1113        var view = this,
1114            new_low, 
1115            new_high,
1116            chrom_pos = str.split(":"),
1117            chrom = chrom_pos[0],
1118            pos = chrom_pos[1];
1119        
1120        if (pos !== undefined) {
1121            try {
1122                var pos_split = pos.split("-");
1123                new_low = parseInt(pos_split[0].replace(/,/g, ""), 10);
1124                new_high = parseInt(pos_split[1].replace(/,/g, ""), 10);
1125            } catch (e) {
1126                return false;
1127            }
1128        }
1129        view.change_chrom(chrom, new_low, new_high);
1130    },
1131    move_fraction : function( fraction ) {
1132        var view = this;
1133        var span = view.high - view.low;
1134        this.move_delta( fraction * span );
1135    },
1136    move_delta: function(delta_chrom) {
1137        var view = this;
1138        var current_chrom_span = view.high - view.low;
1139        // Check for left and right boundaries
1140        if (view.low - delta_chrom < view.max_low) {
1141            view.low = view.max_low;
1142            view.high = view.max_low + current_chrom_span;
1143        } else if (view.high - delta_chrom > view.max_high) {
1144            view.high = view.max_high;
1145            view.low = view.max_high - current_chrom_span;
1146        } else {
1147            view.high -= delta_chrom;
1148            view.low -= delta_chrom;
1149        }
1150        view.request_redraw();
1151    },
1152    /**
1153     * Add a drawable to the view.
1154     */
1155    add_drawable: function(drawable) {
1156        DrawableCollection.prototype.add_drawable.call(this, drawable);
1157        if (drawable.init) { drawable.init(); }
1158        this.has_changes = true;
1159        this.update_intro_div();
1160    },
1161    add_label_track: function (label_track) {
1162        label_track.view = this;
1163        this.label_tracks.push(label_track);
1164    },
1165    /**
1166     * Remove drawable from the view.
1167     */
1168    remove_drawable: function(drawable, hide) {
1169        DrawableCollection.prototype.remove_drawable.call(this, drawable);
1170        if (hide) {
1171            var view = this;
1172            drawable.container_div.fadeOut('slow', function() { 
1173                $(this).remove();
1174                view.update_intro_div(); 
1175            });
1176            this.has_changes = true;
1177        }
1178    },
1179    reset: function() {
1180        this.low = this.max_low;
1181        this.high = this.max_high;
1182        this.viewport_container.find(".yaxislabel").remove();
1183    },
1184    /**
1185     * Request that view redraw some or all tracks. If a track is not specificied, redraw all tracks.
1186     */
1187    request_redraw: function(nodraw, force, clear_after, track) {
1188        var 
1189            view = this,
1190            // Either redrawing a single track or all view's tracks.
1191            track_list = (track ? [track] : view.tracks),
1192            track_index;
1193        
1194        // Add/update tracks in track list to redraw list.
1195        var track;
1196        for (var i = 0; i < track_list.length; i++) {
1197            track = track_list[i];
1198            
1199            // Because list elements are arrays, need to look for track index manually.
1200            track_index = -1;
1201            for (var j = 0; j < view.tracks_to_be_redrawn.length; j++) {
1202                if (view.tracks_to_be_redrawn[j][0] === track) {
1203                    track_index = j;
1204                    break;
1205                }
1206            }
1207            
1208            // Add track to list or update draw parameters.
1209            if (track_index < 0) {
1210                // Track not in list yet.
1211                view.tracks_to_be_redrawn.push([track, force, clear_after]);
1212            }
1213            else {
1214                // Track already in list; update force and clear_after.
1215                view.tracks_to_be_redrawn[i][1] = force;
1216                view.tracks_to_be_redrawn[i][2] = clear_after;
1217            }
1218        }
1219
1220        // Set up redraw.
1221        requestAnimationFrame(function() { view._redraw(nodraw) });
1222    },
1223    /**
1224     * Redraws view and tracks.
1225     * NOTE: this method should never be called directly; request_redraw() should be used so
1226     * that requestAnimationFrame can manage redrawing.
1227     */
1228    _redraw: function(nodraw) {
1229        
1230        var low = this.low,
1231            high = this.high;
1232        
1233        if (low < this.max_low) {
1234            low = this.max_low;
1235        }
1236        if (high > this.max_high) {
1237            high = this.max_high;
1238        }
1239        var span = this.high - this.low;
1240        if (this.high !== 0 && span < this.min_separation) {
1241            high = low + this.min_separation;
1242        }
1243        this.low = Math.floor(low);
1244        this.high = Math.ceil(high);
1245        
1246        // 10^log10(range / DENSITY) Close approximation for browser window, assuming DENSITY = window width
1247        this.resolution = Math.pow( RESOLUTION, Math.ceil( Math.log( (this.high - this.low) / DENSITY ) / Math.log(RESOLUTION) ) );
1248        this.zoom_res = Math.pow( FEATURE_LEVELS, Math.max(0,Math.ceil( Math.log( this.resolution, FEATURE_LEVELS ) / Math.log(FEATURE_LEVELS) )));
1249        
1250        // Overview
1251        var left_px = ( this.low / (this.max_high - this.max_low) * this.overview_viewport.width() ) || 0;
1252        var width_px = ( (this.high - this.low)/(this.max_high - this.max_low) * this.overview_viewport.width() ) || 0;
1253        var min_width_px = 13;
1254        
1255        this.overview_box.css({ left: left_px, width: Math.max(min_width_px, width_px) }).show();
1256        if (width_px < min_width_px) {
1257            this.overview_box.css("left", left_px - (min_width_px - width_px)/2);
1258        }
1259        if (this.overview_highlight) {
1260            this.overview_highlight.css({ left: left_px, width: width_px });
1261        }
1262        
1263        this.update_location(this.low, this.high);
1264        if (!nodraw) {
1265            var track, force, clear_after;
1266            for (var i = 0, len = this.tracks_to_be_redrawn.length; i < len; i++) {
1267                track = this.tracks_to_be_redrawn[i][0];
1268                force = this.tracks_to_be_redrawn[i][1];
1269                clear_after = this.tracks_to_be_redrawn[i][2];
1270                if (track) {
1271                    track._draw(force, clear_after);
1272                }
1273            }
1274            this.tracks_to_be_redrawn = [];
1275            for (i = 0, len = this.label_tracks.length; i < len; i++) {
1276                this.label_tracks[i]._draw();
1277            }
1278        }
1279    },
1280    zoom_in: function (point, container) {
1281        if (this.max_high === 0 || this.high - this.low < this.min_separation) {
1282            return;
1283        }
1284        var span = this.high - this.low,
1285            cur_center = span / 2 + this.low,
1286            new_half = (span / this.zoom_factor) / 2;
1287        if (point) {
1288            cur_center = point / this.viewport_container.width() * (this.high - this.low) + this.low;
1289        }
1290        this.low = Math.round(cur_center - new_half);
1291        this.high = Math.round(cur_center + new_half);
1292        this.request_redraw();
1293    },
1294    zoom_out: function ()

Large files files are truncated, but you can click here to view the full file