PageRenderTime 6ms CodeModel.GetById 4ms app.highlight 39ms RepoModel.GetById 1ms app.codeStats 0ms

/static/scripts/viz/visualization.js

https://bitbucket.org/nicste/ballaxy
JavaScript | 877 lines | 572 code | 98 blank | 207 comment | 51 complexity | 914c43628c0a02d3a9c6ced2aa0b2c63 MD5 | raw file
  1/**
  2 * Model, view, and controller objects for Galaxy visualization framework.
  3 * 
  4 * Required libraries: Backbone, jQuery
  5 *
  6 * Models have no references to views, instead using events to indicate state 
  7 * changes; this is advantageous because multiple views can use the same object 
  8 * and models can be used without views.
  9 */
 10 
 11// --------- Models ---------
 12
 13/**
 14 * Implementation of a server-state based deferred. Server is repeatedly polled, and when
 15 * condition is met, deferred is resolved.
 16 */
 17var ServerStateDeferred = Backbone.Model.extend({
 18    defaults: {
 19        ajax_settings: {},
 20        interval: 1000,
 21        success_fn: function(result) { return true; }
 22    },
 23    
 24    /**
 25     * Returns a deferred that resolves when success function returns true.
 26     */
 27    go: function() {
 28        var deferred = $.Deferred(),
 29            self = this,
 30            ajax_settings = self.get('ajax_settings'),
 31            success_fn = self.get('success_fn'),
 32            interval = self.get('interval'),
 33             _go = function() {
 34                 $.ajax(ajax_settings).success(function(result) {
 35                     if (success_fn(result)) {
 36                         // Result is good, so resolve.
 37                         deferred.resolve(result);
 38                     }
 39                     else {
 40                         // Result not good, try again.
 41                         setTimeout(_go, interval);
 42                     }
 43                 });
 44             };
 45         _go();
 46         return deferred;
 47    }
 48});
 49
 50// TODO: move to Backbone
 51
 52/**
 53 * Canvas manager is used to create canvases, for browsers, this deals with
 54 * backward comparibility using excanvas, as well as providing a pattern cache
 55 */
 56var CanvasManager = function(default_font) {
 57    this.default_font = default_font !== undefined ? default_font : "9px Monaco, Lucida Console, monospace";
 58    
 59    this.dummy_canvas = this.new_canvas();
 60    this.dummy_context = this.dummy_canvas.getContext('2d');
 61    this.dummy_context.font = this.default_font;
 62    
 63    this.char_width_px = this.dummy_context.measureText("A").width;
 64    
 65    this.patterns = {};
 66
 67    // FIXME: move somewhere to make this more general
 68    this.load_pattern( 'right_strand', "/visualization/strand_right.png" );
 69    this.load_pattern( 'left_strand', "/visualization/strand_left.png" );
 70    this.load_pattern( 'right_strand_inv', "/visualization/strand_right_inv.png" );
 71    this.load_pattern( 'left_strand_inv', "/visualization/strand_left_inv.png" );
 72};
 73
 74_.extend( CanvasManager.prototype, {
 75    load_pattern: function( key, path ) {
 76        var patterns = this.patterns,
 77            dummy_context = this.dummy_context,
 78            image = new Image();
 79        image.src = galaxy_paths.attributes.image_path + path;
 80        image.onload = function() {
 81            patterns[key] = dummy_context.createPattern( image, "repeat" );
 82        };
 83    },
 84    get_pattern: function( key ) {
 85        return this.patterns[key];
 86    },
 87    new_canvas: function() {
 88        var canvas = $("<canvas/>")[0];
 89        // If using excanvas in IE, we need to explicately attach the canvas
 90        // methods to the DOM element
 91        if (window.G_vmlCanvasManager) { G_vmlCanvasManager.initElement(canvas); }
 92        // Keep a reference back to the manager
 93        canvas.manager = this;
 94        return canvas;
 95    }
 96});
 97
 98/**
 99 * Generic cache that handles key/value pairs.
100 */ 
101var Cache = Backbone.Model.extend({
102    defaults: {
103        num_elements: 20,
104        obj_cache: null,
105        key_ary: null
106    },
107
108    initialize: function(options) {
109        this.clear();
110    },
111    
112    get_elt: function(key) {
113        var obj_cache = this.attributes.obj_cache,
114            key_ary = this.attributes.key_ary,
115            index = key_ary.indexOf(key);
116        if (index !== -1) {
117            if (obj_cache[key].stale) {
118                // Object is stale, so remove key and object.
119                key_ary.splice(index, 1);
120                delete obj_cache[key];
121            }
122            else {
123                this.move_key_to_end(key, index);
124            }
125        }
126        return obj_cache[key];
127    },
128    
129    set_elt: function(key, value) {
130        var obj_cache = this.attributes.obj_cache,
131            key_ary = this.attributes.key_ary,
132            num_elements = this.attributes.num_elements;
133        if (!obj_cache[key]) {
134            if (key_ary.length >= num_elements) {
135                // Remove first element
136                var deleted_key = key_ary.shift();
137                delete obj_cache[deleted_key];
138            }
139            key_ary.push(key);
140        }
141        obj_cache[key] = value;
142        return value;
143    },
144    
145    // Move key to end of cache. Keys are removed from the front, so moving a key to the end 
146    // delays the key's removal.
147    move_key_to_end: function(key, index) {
148        this.attributes.key_ary.splice(index, 1);
149        this.attributes.key_ary.push(key);
150    },
151    
152    clear: function() {
153        this.attributes.obj_cache = {};
154        this.attributes.key_ary = [];
155    },
156    
157    // Returns the number of elements in the cache.
158    size: function() {
159        return this.attributes.key_ary.length;
160    }
161});
162
163/**
164 * Data manager for genomic data. Data is connected to and queryable by genomic regions.
165 */
166var GenomeDataManager = Cache.extend({
167    defaults: _.extend({}, Cache.prototype.defaults, {
168        dataset: null,
169        filters_manager: null,
170        data_url: null,
171        dataset_state_url: null,
172        genome_wide_summary_data: null,
173        data_mode_compatible: function(entry, mode) { return true; },
174        can_subset: function(entry) { return false; }
175    }),
176
177    /**
178     * Returns deferred that resolves to true when dataset is ready (or false if dataset
179     * cannot be used).
180     */
181    data_is_ready: function() {
182        var dataset = this.get('dataset'),
183            ready_deferred = $.Deferred(),
184            ss_deferred = new ServerStateDeferred({
185                ajax_settings: {
186                    url: this.get('dataset_state_url'),
187                    data: {
188                        dataset_id: dataset.id,
189                        hda_ldda: dataset.get('hda_ldda')
190                    },
191                    dataType: "json"
192                },
193                interval: 5000,
194                success_fn: function(response) { return response !== "pending"; }
195            });
196
197        $.when(ss_deferred.go()).then(function(response) {
198            ready_deferred.resolve(response === "ok" || response === "data" );
199        });
200        return ready_deferred;
201    },
202    
203    /**
204     * Load data from server; returns AJAX object so that use of Deferred is possible.
205     */
206    load_data: function(region, mode, resolution, extra_params) {
207        // Setup data request params.
208        var params = {
209                        "chrom": region.get('chrom'), 
210                        "low": region.get('start'), 
211                        "high": region.get('end'), 
212                        "mode": mode, 
213                        "resolution": resolution
214                     };
215            dataset = this.get('dataset');
216                        
217        // ReferenceDataManager does not have dataset.
218        if (dataset) {
219            params.dataset_id = dataset.id;
220            params.hda_ldda = dataset.get('hda_ldda');
221        }
222        
223        $.extend(params, extra_params);
224        
225        // Add track filters to params.
226        var filters_manager = this.get('filters_manager');
227        if (filters_manager) {
228            var filter_names = [];
229            var filters = filters_manager.filters;
230            for (var i = 0; i < filters.length; i++) {
231                filter_names.push(filters[i].name);
232            }
233            params.filter_cols = JSON.stringify(filter_names);
234        }
235                        
236        // Do request.
237        var manager = this;
238        return $.getJSON(this.get('data_url'), params, function (result) {
239            manager.set_data(region, result);
240        });
241    },
242    
243    /**
244     * Get data from dataset.
245     */
246    get_data: function(region, mode, resolution, extra_params) {
247        // Debugging:
248        //console.log("get_data", low, high, mode);
249        /*
250        console.log("cache contents:")
251        for (var i = 0; i < this.key_ary.length; i++) {
252            console.log("\t", this.key_ary[i], this.obj_cache[this.key_ary[i]]);
253        }
254        */
255                
256        // Look for entry and return if it's a deferred or if data available is compatible with mode.
257        var entry = this.get_elt(region);
258        if ( entry && 
259             ( is_deferred(entry) || this.get('data_mode_compatible')(entry, mode) ) ) {
260            return entry;
261        }
262
263        //
264        // Look in cache for data that can be used. Data can be reused if it
265        // has the requested data and is not summary tree and has details.
266        // TODO: this logic could be improved if the visualization knew whether
267        // the data was "index" or "data."
268        //
269        var key_ary = this.get('key_ary'),
270            obj_cache = this.get('obj_cache'),
271            key, entry_region;
272        for (var i = 0; i < key_ary.length; i++) {
273            key = key_ary[i];
274            entry_region = new GenomeRegion({from_str: key});
275        
276            if (entry_region.contains(region)) {
277                // This entry has data in the requested range. Return if data
278                // is compatible and can be subsetted.
279                entry = obj_cache[key];
280                if ( is_deferred(entry) || 
281                    ( this.get('data_mode_compatible')(entry, mode) && this.get('can_subset')(entry) ) ) {
282                    this.move_key_to_end(key, i);
283                    return entry;
284                }
285            }
286        }
287                
288        // Load data from server. The deferred is immediately saved until the
289        // data is ready, it then replaces itself with the actual data.
290        entry = this.load_data(region, mode, resolution, extra_params);
291        this.set_data(region, entry);
292        return entry;
293    },
294    
295    /**
296     * Alias for set_elt for readbility.
297     */
298    set_data: function(region, entry) {
299        this.set_elt(region, entry);  
300    },
301    
302    /** "Deep" data request; used as a parameter for DataManager.get_more_data() */
303    DEEP_DATA_REQ: "deep",
304    
305    /** "Broad" data request; used as a parameter for DataManager.get_more_data() */
306    BROAD_DATA_REQ: "breadth",
307    
308    /**
309     * Gets more data for a region using either a depth-first or a breadth-first approach.
310     */
311    get_more_data: function(region, mode, resolution, extra_params, req_type) {
312        //
313        // Get current data from cache and mark as stale.
314        //
315        var cur_data = this.get_elt(region);
316        if ( !(cur_data && this.get('data_mode_compatible')(cur_data, mode)) ) {
317            console.log("ERROR: no current data for: ", dataset, region.toString(), mode, resolution, extra_params);
318            return;
319        }
320        cur_data.stale = true;
321        
322        //
323        // Set parameters based on request type.
324        //
325        var query_low = region.get('start');
326        if (req_type === this.DEEP_DATA_REQ) {
327            // Use same interval but set start_val to skip data that's already in cur_data.
328            $.extend(extra_params, {start_val: cur_data.data.length + 1});
329        }
330        else if (req_type === this.BROAD_DATA_REQ) {
331            // To get past an area of extreme feature depth, set query low to be after either
332            // (a) the maximum high or HACK/FIXME (b) the end of the last feature returned.
333            query_low = (cur_data.max_high ? cur_data.max_high : cur_data.data[cur_data.data.length - 1][2]) + 1;
334        }
335        var query_region = region.copy().set('start', query_low);
336        
337        //
338        // Get additional data, append to current data, and set new data. Use a custom deferred object
339        // to signal when new data is available.
340        //
341        var 
342            data_manager = this,
343            new_data_request = this.load_data(query_region, mode, resolution, extra_params),
344            new_data_available = $.Deferred();
345        // load_data sets cache to new_data_request, but use custom deferred object so that signal and data
346        // is all data, not just new data.
347        this.set_data(region, new_data_available);
348        $.when(new_data_request).then(function(result) {
349            // Update data and message.
350            if (result.data) {
351                result.data = cur_data.data.concat(result.data);
352                if (result.max_low) {
353                    result.max_low = cur_data.max_low;
354                }
355                if (result.message) {
356                    // HACK: replace number in message with current data length. Works but is ugly.
357                    result.message = result.message.replace(/[0-9]+/, result.data.length);
358                }
359            }
360            data_manager.set_data(region, result);
361            new_data_available.resolve(result);
362        });
363        return new_data_available;
364    },
365        
366    /**
367     * Get data from the cache.
368     */
369    get_elt: function(region) {
370        return Cache.prototype.get_elt.call(this, region.toString());
371    },
372    
373    /**
374     * Sets data in the cache.
375     */
376    set_elt: function(region, result) {
377        return Cache.prototype.set_elt.call(this, region.toString(), result);
378    }
379});
380
381var ReferenceTrackDataManager = GenomeDataManager.extend({
382    load_data: function(low, high, mode, resolution, extra_params) {
383        if (resolution > 1) {
384            // Now that data is pre-fetched before draw, we don't load reference tracks
385            // unless it's at the bottom level.
386            return { data: null };
387        }
388        return GenomeDataManager.prototype.load_data.call(this, low, high, mode, resolution, extra_params);
389    } 
390});
391 
392/**
393 * A genome build.
394 */
395var Genome = Backbone.Model.extend({
396    defaults: {
397        name: null,
398        key: null,
399        chroms_info: null
400    },
401    
402    get_chroms_info: function() {
403        return this.attributes.chroms_info.chrom_info;  
404    }
405});
406
407/**
408 * A genomic region.
409 */
410var GenomeRegion = Backbone.RelationalModel.extend({
411    defaults: {
412        chrom: null,
413        start: 0,
414        end: 0,
415        DIF_CHROMS: 1000,
416        BEFORE: 1001, 
417        CONTAINS: 1002, 
418        OVERLAP_START: 1003, 
419        OVERLAP_END: 1004, 
420        CONTAINED_BY: 1005, 
421        AFTER: 1006
422    },
423    
424    /**
425     * If from_str specified, use it to initialize attributes.
426     */
427    initialize: function(options) {
428        if (options.from_str) {
429            var pieces = options.from_str.split(':'),
430                chrom = pieces[0],
431                start_end = pieces[1].split('-');
432            this.set({
433                chrom: chrom,
434                start: parseInt(start_end[0], 10),
435                end: parseInt(start_end[1], 10)
436            });
437        }
438    },
439    
440    copy: function() {
441        return new GenomeRegion({
442            chrom: this.get('chrom'),
443            start: this.get('start'),
444            end: this.get('end') 
445        });
446    },
447
448    length: function() {
449        return this.get('end') - this.get('start');
450    },
451    
452    /** Returns region in canonical form chrom:start-end */
453    toString: function() {
454        return this.get('chrom') + ":" + this.get('start') + "-" + this.get('end');
455    },
456    
457    toJSON: function() {
458        return {
459            chrom: this.get('chrom'),
460            start: this.get('start'),
461            end: this.get('end')
462        };
463    },
464    
465    /**
466     * Compute the type of overlap between this region and another region. The overlap is computed relative to the given/second region; 
467     * hence, OVERLAP_START indicates that the first region overlaps the start (but not the end) of the second region.
468     */
469    compute_overlap: function(a_region) {
470        var first_chrom = this.get('chrom'), second_chrom = a_region.get('chrom'),
471            first_start = this.get('start'), second_start = a_region.get('start'),
472            first_end = this.get('end'), second_end = a_region.get('end'),
473            overlap;
474            
475        // Look at chroms.
476        if (first_chrom && second_chrom && first_chrom !== second_chrom) {
477            return this.get('DIF_CHROMS');
478        }
479        
480        // Look at regions.
481        if (first_start < second_start) {
482            if (first_end < second_start) {
483                overlap = this.get('BEFORE');
484            }
485            else if (first_end <= second_end) {
486                overlap = this.get('OVERLAP_START');
487            }
488            else { // first_end > second_end
489                overlap = this.get('CONTAINS');
490            }
491        }
492        else { // first_start >= second_start
493            if (first_start > second_end) {
494                overlap = this.get('AFTER');
495            }
496            else if (first_end <= second_end) {
497                overlap = this.get('CONTAINED_BY');
498            }
499            else {
500                overlap = this.get('OVERLAP_END');
501            }
502        }
503
504        return overlap;
505    },
506    
507    /**
508     * Returns true if this region contains a given region.
509     */
510    contains: function(a_region) {
511        return this.compute_overlap(a_region) === this.get('CONTAINS');  
512    },
513
514    /**
515     * Returns true if regions overlap.
516     */
517    overlaps: function(a_region) {
518        return _.intersection( [this.compute_overlap(a_region)], 
519                               [this.get('DIF_CHROMS'), this.get('BEFORE'), this.get('AFTER')] ).length === 0;  
520    }
521});
522
523var GenomeRegionCollection = Backbone.Collection.extend({
524    model: GenomeRegion
525});
526
527/**
528 * A genome browser bookmark.
529 */
530var BrowserBookmark = Backbone.RelationalModel.extend({
531    defaults: {
532        region: null,
533        note: ''
534    },
535
536    relations: [
537        {
538            type: Backbone.HasOne,
539            key: 'region',
540            relatedModel: 'GenomeRegion'
541        }
542    ]
543});
544
545/**
546 * Bookmarks collection.
547 */
548var BrowserBookmarkCollection = Backbone.Collection.extend({
549    model: BrowserBookmark
550});
551
552/**
553 * Genome-wide summary data.
554 */
555var GenomeWideSummaryData = Backbone.RelationalModel.extend({
556    defaults: {
557        data: null,
558        max: 0  
559    },
560    
561    initialize: function(options) {
562        // Set max across dataset.
563        var max_data = _.max(this.get('data'), function(d) {
564            if (!d || typeof d === 'string') { return 0; }
565            return d[1];
566        });
567        this.attributes.max = (max_data && typeof max_data !== 'string' ? max_data[1] : 0);
568    }
569});
570
571/**
572 * A track of data in a genome visualization.
573 */
574// TODO: rename to Track and merge with Trackster's Track object.
575var BackboneTrack = Dataset.extend({
576
577    initialize: function(options) {
578        // Dataset id is unique ID for now.
579        this.set('id', options.dataset_id);
580    },
581
582    relations: [
583        {
584            type: Backbone.HasOne,
585            key: 'genome_wide_data',
586            relatedModel: 'GenomeWideSummaryData'
587        }
588    ]
589});
590
591/**
592 * A visualization.
593 */
594var Visualization = Backbone.RelationalModel.extend({
595    defaults: {
596        id: '',
597        title: '',
598        type: '',
599        dbkey: '',
600        tracks: null
601    },
602
603    relations: [
604        {
605            type: Backbone.HasMany,
606            key: 'tracks',
607            relatedModel: 'BackboneTrack'
608        }
609    ],
610
611    // Use function because visualization_url changes depending on viz.
612    // FIXME: all visualizations should save to the same URL (and hence
613    // this function won't be needed).
614    url: function() { 
615        return galaxy_paths.get("visualization_url");
616    },
617    
618    /**
619     * POSTs visualization's JSON to its URL using the parameter 'vis_json'
620     * Note: This is necessary because (a) Galaxy requires keyword args and 
621     * (b) Galaxy does not handle PUT now.
622     */
623    save: function() {
624        return $.ajax({
625            url: this.url(),
626            type: "POST",
627            dataType: "json",
628            data: { 
629                vis_json: JSON.stringify(this)
630            }
631        });
632    }
633});
634
635/**
636 * A Genome space visualization.
637 */
638var GenomeVisualization = Visualization.extend({
639    defaults: _.extend({}, Visualization.prototype.defaults, {
640        bookmarks: null,
641        viewport: null
642    })
643});
644
645/**
646 * Configuration data for a Trackster track.
647 */
648var TrackConfig = Backbone.Model.extend({
649    
650});
651
652/**
653 * Layout for a histogram dataset in a circster visualization.
654 */
655var CircsterHistogramDatasetLayout = Backbone.Model.extend({
656    // TODO: should accept genome and dataset and use these to generate layout data.
657    
658    /**
659     * Returns arc layouts for genome's chromosomes/contigs. Arcs are arranged in a circle 
660     * separated by gaps.
661     */
662    chroms_layout: function() {
663        // Setup chroms layout using pie.
664        var chroms_info = this.attributes.genome.get_chroms_info(),
665            pie_layout = d3.layout.pie().value(function(d) { return d.len; }).sort(null),
666            init_arcs = pie_layout(chroms_info),
667            gap_per_chrom = this.attributes.total_gap / chroms_info.length,
668            chrom_arcs = _.map(init_arcs, function(arc, index) {
669                // For short chroms, endAngle === startAngle.
670                var new_endAngle = arc.endAngle - gap_per_chrom;
671                arc.endAngle = (new_endAngle > arc.startAngle ? new_endAngle : arc.startAngle);
672                return arc;
673            });
674            
675            // TODO: remove arcs for chroms that are too small and recompute?
676            
677        return chrom_arcs;
678    },
679    
680    /**
681     * Returns layouts for drawing a chromosome's data. For now, only works with summary tree data.
682     */
683    chrom_data_layout: function(chrom_arc, chrom_data, inner_radius, outer_radius, max) {             
684        // If no chrom data, return null.
685        if (!chrom_data || typeof chrom_data === "string") {
686            return null;
687        }
688        
689        var data = chrom_data[0],
690            delta = chrom_data[3],
691            scale = d3.scale.linear()
692                .domain( [0, max] )
693                .range( [inner_radius, outer_radius] ),                        
694            arc_layout = d3.layout.pie().value(function(d) {
695                return delta;
696            })
697                .startAngle(chrom_arc.startAngle)
698                .endAngle(chrom_arc.endAngle),
699        arcs = arc_layout(data);
700        
701        // Use scale to assign outer radius.
702        _.each(data, function(datum, index) {
703            arcs[index].outerRadius = scale(datum[1]);
704        });
705        
706        return arcs;
707    }
708    
709});
710 
711/**
712 * -- Views --
713 */
714 
715var CircsterView = Backbone.View.extend({
716    className: 'circster',
717    
718    initialize: function(options) {
719        this.width = options.width;
720        this.height = options.height;
721        this.total_gap = options.total_gap;
722        this.genome = options.genome;
723        this.radius_start = options.radius_start;
724        this.dataset_arc_height = options.dataset_arc_height;
725        this.track_gap = 5;
726    },
727    
728    render: function() {
729        var self = this,
730            dataset_arc_height = this.dataset_arc_height;
731
732        // Set up SVG element.
733        var svg = d3.select(self.$el[0])
734              .append("svg")
735                .attr("width", self.width)
736                .attr("height", self.height)
737              .append("g")
738                .attr("transform", "translate(" + self.width / 2 + "," + self.height / 2 + ")");
739
740        // -- Render each dataset in the visualization. --
741        this.model.get('tracks').each(function(track, index) {
742            var dataset = track.get('genome_wide_data');
743
744            var radius_start = self.radius_start + index * (dataset_arc_height + self.track_gap),
745                // Layout chromosome arcs.
746                arcs_layout = new CircsterHistogramDatasetLayout({
747                    genome: self.genome,
748                    total_gap: self.total_gap
749                }),
750                chrom_arcs = arcs_layout.chroms_layout(),
751                
752                // Merge chroms layout with data.
753                layout_and_data = _.zip(chrom_arcs, dataset.get('data')),
754                dataset_max = dataset.get('max'),
755                
756                // Do dataset layout for each chromosome's data using pie layout.
757                chroms_data_layout = _.map(layout_and_data, function(chrom_info) {
758                    var chrom_arc = chrom_info[0],
759                        chrom_data = chrom_info[1];
760                    return arcs_layout.chrom_data_layout(chrom_arc, chrom_data, radius_start, radius_start + dataset_arc_height, dataset_max);
761                });
762            
763            // -- Render. --
764
765            // Draw background arcs for each chromosome.
766            var base_arc = svg.append("g").attr("id", "inner-arc"),
767                arc_gen = d3.svg.arc()
768                    .innerRadius(radius_start)
769                    .outerRadius(radius_start + dataset_arc_height),
770                // Draw arcs.
771                chroms_elts = base_arc.selectAll("#inner-arc>path")
772                    .data(chrom_arcs).enter().append("path")
773                    .attr("d", arc_gen)
774                    .style("stroke", "#ccc")
775                    .style("fill",  "#ccc")
776                    .append("title").text(function(d) { return d.data.chrom; });
777
778            // For each chromosome, draw dataset.
779            var prefs = track.get('prefs'),
780                block_color = prefs.block_color;
781            _.each(chroms_data_layout, function(chrom_layout) {
782                if (!chrom_layout) { return; }
783
784                var group = svg.append("g"),
785                    arc_gen = d3.svg.arc().innerRadius(radius_start),
786                    dataset_elts = group.selectAll("path")
787                        .data(chrom_layout).enter().append("path")
788                        .attr("d", arc_gen)
789                        .style("stroke", block_color)
790                        .style("fill",  block_color);
791            });
792        });        
793    } 
794});
795
796/**
797 * -- Routers --
798 */
799
800/**
801 * Router for track browser.
802 */
803var TrackBrowserRouter = Backbone.Router.extend({    
804    initialize: function(options) {
805        this.view = options.view;
806        
807        // Can't put regular expression in routes dictionary.
808        // NOTE: parentheses are used to denote parameters returned to callback.
809        this.route(/([\w]+)$/, 'change_location');
810        this.route(/([\w]+\:[\d,]+-[\d,]+)$/, 'change_location');
811        
812        // Handle navigate events from view.
813        var self = this;
814        self.view.on("navigate", function(new_loc) {
815            self.navigate(new_loc);
816        });
817    },
818    
819    change_location: function(new_loc) {
820        this.view.go_to(new_loc);
821    }
822});
823
824/**
825 * -- Helper functions.
826 */
827 
828/**
829 * Use a popup grid to add more datasets.
830 */
831var add_datasets = function(dataset_url, add_track_async_url, success_fn) {
832    $.ajax({
833        url: dataset_url,
834        data: { "f-dbkey": view.dbkey },
835        error: function() { alert( "Grid failed" ); },
836        success: function(table_html) {
837            show_modal(
838                "Select datasets for new tracks",
839                table_html, {
840                    "Cancel": function() {
841                        hide_modal();
842                    },
843                    "Add": function() {
844                        var requests = [];
845                        $('input[name=id]:checked,input[name=ldda_ids]:checked').each(function() {
846                            var data,
847                                id = $(this).val();
848                                if ($(this).attr("name") === "id") {
849                                    data = { hda_id: id };
850                                } else {
851                                    data = { ldda_id: id};
852                                }
853                                requests[requests.length] = $.ajax({
854                                    url: add_track_async_url,
855                                    data: data,
856                                    dataType: "json"
857                                });
858                        });
859                        // To preserve order, wait until there are definitions for all tracks and then add 
860                        // them sequentially.
861                        $.when.apply($, requests).then(function() {
862                            // jQuery always returns an Array for arguments, so need to look at first element
863                            // to determine whether multiple requests were made and consequently how to 
864                            // map arguments to track definitions.
865                            var track_defs = (arguments[0] instanceof Array ?  
866                                               $.map(arguments, function(arg) { return arg[0]; }) :
867                                               [ arguments[0] ]
868                                               );
869                            success_fn(track_defs);
870                        });
871                        hide_modal();
872                    }
873                }
874            );
875        }
876    });
877};