//! AGPL License. www.openmicroscopy.org

//!  DO NOT EDIT THIS FILE! - Edit under static/figure/js/
//!  figure.js created by $ grunt concat

    
    // Version of the json file we're saving.
    // This only needs to increment when we make breaking changes (not linked to release versions.)
    var VERSION = 5;


    // ------------------------- Figure Model -----------------------------------
    // Has a PanelList as well as other attributes of the Figure
    var FigureModel = Backbone.Model.extend({

        defaults: {
            // 'curr_zoom': 100,
            'canEdit': true,
            'unsaved': false,
            'canvas_width': 13000,
            'canvas_height': 8000,
            // w & h from reportlab.
            'paper_width': 595,
            'paper_height': 842,
            'page_color': 'FFFFFF',
            'page_count': 1,
            'page_col_count': 1,    // pages laid out in grid
            'paper_spacing': 50,    // between each page
            'orientation': 'vertical',
            'page_size': 'A4',       // options [A4, letter, mm, pixels]
            // see http://www.a4papersize.org/a4-paper-size-in-pixels.php
            'width_mm': 210,    // A4 sizes, only used if user chooses page_size: 'mm'
            'height_mm': 297,
            'legend': '',       // Figure legend in markdown format.
            'legend_collapsed': true,   // collapse or expand legend
            'loading_count': 0,         // images being loaded (show spinner if > 0)
        },

        initialize: function() {
            this.panels = new PanelList();      //this.get("shapes"));

            // wrap selection notification in a 'debounce', so that many rapid
            // selection changes only trigger a single re-rendering 
            this.notifySelectionChange = _.debounce( this.notifySelectionChange, 10);
        },

        syncOverride: function(method, model, options, error) {
            this.set("unsaved", true);
        },

        load_from_OMERO: function(fileId, success) {

            var load_url = BASE_WEBFIGURE_URL + "load_web_figure/" + fileId + "/",
                self = this;


            $.getJSON(load_url, function(data){
                data.fileId = fileId;
                self.load_from_JSON(data);
                self.set('unsaved', false);
            });
        },

        load_from_JSON: function(data) {
            var self = this;

            // bring older files up-to-date
            data = self.version_transform(data);

            var name = data.figureName || "UN-NAMED",
                n = {'fileId': data.fileId,
                    'figureName': name,
                    'groupId': data.group ? data.group.id : undefined,
                    'canEdit': data.canEdit,
                    'paper_width': data.paper_width,
                    'paper_height': data.paper_height,
                    'width_mm': data.width_mm,
                    'height_mm': data.height_mm,
                    'page_size': data.page_size || 'letter',
                    'page_count': data.page_count,
                    'paper_spacing': data.paper_spacing,
                    'page_col_count': data.page_col_count,
                    'orientation': data.orientation,
                    'legend': data.legend,
                    'legend_collapsed': data.legend_collapsed,
                    'page_color': data.page_color,
                };

            // For missing attributes, we fill in with defaults
            // so as to clear everything from previous figure.
            n = $.extend({}, self.defaults, n);

            self.set(n);

            _.each(data.panels, function(p){
                p.selected = false;
                self.panels.create(p);
            });

            // wait for undo/redo to handle above, then...
            setTimeout(function() {
                self.trigger("reset_undo_redo");
            }, 50);
        },

        // take Figure_JSON from a previous version,
        // and transform it to latest version
        version_transform: function(json) {
            var v = json.version || 0;
            var self = this;

            // In version 1, we have pixel_size_x and y.
            // Earlier versions only have pixel_size.
            if (v < 1) {
                _.each(json.panels, function(p){
                    var ps = p.pixel_size;
                    p.pixel_size_x = ps;
                    p.pixel_size_y = ps;
                    delete p.pixel_size;
                });
            }
            if (v < 2) {
                console.log("Transforming to VERSION 2");
                _.each(json.panels, function(p){
                    if (p.shapes) {
                        p.shapes = p.shapes.map(function(shape){
                            // Update to OMERO 5.3.0 model of Ellipse
                            if (shape.type === "Ellipse") {
                                shape.x = shape.cx;
                                shape.y = shape.cy;
                                shape.radiusX = shape.rx;
                                shape.radiusY = shape.ry;
                                delete shape.cx;
                                delete shape.cy;
                                delete shape.rx;
                                delete shape.ry;
                            }
                            return shape;
                        });
                    }
                });
            }
            if (v < 3) {
                console.log("Transforming to VERSION 3");
                _.each(json.panels, function(p){
                    if (p.export_dpi) {
                        // rename 'export_dpi' attr to 'min_export_dpi'
                        p.min_export_dpi = p.export_dpi;
                        delete p.export_dpi;
                    }
                    // update strokeWidth to page pixels/coords instead of
                    // image pixels. Scale according to size of panel and zoom
                    if (p.shapes && p.shapes.length > 0) {
                        var panel = new Panel(p);
                        var imagePixelsWidth = panel.getViewportAsRect().width;
                        var pageCoordsWidth = panel.get('width');
                        var strokeWidthScale = pageCoordsWidth/imagePixelsWidth;
                        p.shapes = p.shapes.map(function(shape){
                            var strokeWidth = shape.strokeWidth || 1;
                            strokeWidth = strokeWidth * strokeWidthScale;
                            // Set stroke-width to 0.25, 0.5, 0.75, 1 or greater
                            if (strokeWidth > 0.875) {
                                strokeWidth = parseInt(Math.round(strokeWidth));
                            } else if (strokeWidth > 0.625) {
                                strokeWidth = 0.75;
                            } else if (strokeWidth > 0.375) {
                                strokeWidth = 0.5;
                            } else {
                                strokeWidth = 0.25;
                            }
                            shape.strokeWidth = strokeWidth;
                            return shape;
                        });
                    }
                });
            }

            if (v < 4) {
                console.log("Transforming to VERSION 4");
                _.each(json.panels, function(p){
                    // rename lineWidth to strokeWidth
                    if (p.shapes && p.shapes.length > 0) {
                        p.shapes = p.shapes.map(function(shape){
                            shape.strokeWidth = shape.strokeWidth || shape.lineWidth || 1;
                            if (shape.lineWidth) {
                                delete shape.lineWidth;
                            }
                            return shape;
                        });
                    }
                });
            }

            if (v < 5) {
                console.log("Transforming to VERSION 5");
                // scalebar now has 'units' attribute.
                _.each(json.panels, function(p){
                    // rename lineWidth to strokeWidth
                    if (p.scalebar && !p.scalebar.units) {
                        var units = p.pixel_size_x_unit || "MICROMETER";
                        p.scalebar.units = units;
                    }
                });

                // Re-load timestamp info with no rounding (previous versions rounded to secs)
                // Find IDs of images with deltaT
                var iids = [];
                _.each(json.panels, function(p){
                    if (p.deltaT && iids.indexOf(p.imageId) == -1) {
                        iids.push(p.imageId)
                    }
                });
                console.log('Load timestamps for images', iids);
                if (iids.length > 0) {
                    var tsUrl = BASE_WEBFIGURE_URL + 'timestamps/';
                    tsUrl += '?image=' + iids.join('&image=');
                    $.getJSON(tsUrl, function(data){
                        // Update all panels
                        // NB: By the time that this callback runs, the panels will have been created
                        self.panels.forEach(function(p){
                            var iid = p.get('imageId');
                            if (data[iid] && data[iid].length > 0) {
                                p.set('deltaT', data[iid]);
                            }
                        });
                    });
                }
            }

            return json;
        },

        figure_toJSON: function() {
            // Turn panels into json
            var p_json = [],
                self = this;
            this.panels.each(function(m) {
                p_json.push(m.toJSON());
            });

            var figureJSON = {
                version: VERSION,
                panels: p_json,
                paper_width: this.get('paper_width'),
                paper_height: this.get('paper_height'),
                page_size: this.get('page_size'),
                page_count: this.get('page_count'),
                paper_spacing: this.get('paper_spacing'),
                page_col_count: this.get('page_col_count'),
                height_mm: this.get('height_mm'),
                width_mm: this.get('width_mm'),
                orientation: this.get('orientation'),
                legend: this.get('legend'),
                legend_collapsed: this.get('legend_collapsed'),
                page_color: this.get('page_color'),
            };
            if (this.get('figureName')){
                figureJSON.figureName = this.get('figureName')
            }
            if (this.get('fileId')){
                figureJSON.fileId = this.get('fileId')
            }
            return figureJSON;
        },

        figure_fromJSON: function(data) {
            var parsed = JSON.parse(data);
            delete parsed.fileId;
            this.load_from_JSON(parsed);
            this.set('unsaved', true);
        },

        // handle /recover/ page
        recoverFromLocalStorage: function() {
            var figureObject = recoverFigureFromStorage();
            if (!figureObject) {
                var message = "No valid figure was found in local storage."
                figureConfirmDialog("No Figure found", message, ["OK"]);
            } else {
                this.figure_fromJSON(JSON.stringify(figureObject));
                clearFigureFromStorage();
                var html = `<p>This figure has been recovered from the browser's local storage and
                        the local storage cleared.</p>`;
                figureConfirmDialog(
                    "Figure recovered", html, ["OK"]);
            }
        },

        save_to_OMERO: function(options) {

            var self = this,
                figureJSON = this.figure_toJSON();

            var url = window.SAVE_WEBFIGURE_URL,
                // fileId = self.get('fileId'),
                data = {};

            if (options.fileId) {
                data.fileId = options.fileId;
            }
            if (options.figureName) {
                // Include figure name in JSON saved to file
                figureJSON.figureName = options.figureName;
            }
            data.figureJSON = JSON.stringify(figureJSON);

            // Save
            $.post( url, data)
                .done(function( data ) {
                    var update = {
                        'fileId': +data,
                        'unsaved': false,
                    };
                    if (options.figureName) {
                        update.figureName = options.figureName;
                    }
                    self.set(update);

                    if (options.success) {
                        options.success(data);
                    }
                })
                .error(function(rsp){
                    console.log('Save Error', rsp.responseText);

                    // Save to local storage to avoid data loss
                    saveFigureToStorage(figureJSON);

                    var errorTitle = `Save Error: ${rsp.status}`;
                    var message = `
                        <p>The current figure has failed to Save to OMERO.</p>
                        <p>A copy has been placed in your browser's local storage for this session
                        and can be recovered with File > Local Storage or by reloading the app.</p>
                        <p>Reloading will also check your connection to OMERO.</p>
                    `;
                    var buttons = ['Close', 'Reload in new Tab'];
                    var callback = function(btnText) {
                        if (btnText === "Reload in new Tab") {
                            var recoverUrl = BASE_WEBFIGURE_URL + 'recover/';
                            window.open(WEBLOGIN_URL + '?url=' + recoverUrl, '_blank')
                        }
                    }
                    figureConfirmDialog(errorTitle, message, buttons, callback);
                });
        },

        clearFigure: function() {
            var figureModel = this;
            figureModel.unset('fileId');
            figureModel.unset('groupId');
            figureModel.delete_panels();
            figureModel.unset("figureName");
            figureModel.set(figureModel.defaults);
            figureModel.trigger('reset_undo_redo');
        },

        addImages: function(iIds) {
            this.clearSelected();

            // approx work out number of columns to layout new panels
            var paper_width = this.get('paper_width'),
                paper_height = this.get('paper_height'),
                colCount = Math.ceil(Math.sqrt(iIds.length)),
                rowCount = Math.ceil(iIds.length/colCount),
                centre = {x: paper_width/2, y: paper_height/2},
                px, py, spacer, scale,
                coords = {'px': px,
                          'py': py,
                          'c': centre,
                          'spacer': spacer,
                          'colCount': colCount,
                          'rowCount': rowCount,
                          'paper_width': paper_width};

            // This loop sets up a load of async imports.
            // The first one to return will set all the coords
            // and subsequent ones will update coords to position
            // new image panels appropriately in a grid.
            var invalidIds = [];
            for (var i=0; i<iIds.length; i++) {
                var imgId = iIds[i].replace("|", ""),
                    validId = parseInt(imgId, 10) + "",
                    imgDataUrl = BASE_WEBFIGURE_URL + 'imgData/' + validId + '/';
                if (validId == "NaN") {
                    invalidIds.push(imgId);
                } else {
                    this.importImage(imgDataUrl, coords, undefined, i);
                }
            }
            if (invalidIds.length > 0) {
                var plural = invalidIds.length > 1 ? "s" : "";
                alert("Could not add image with invalid ID" + plural + ": " + invalidIds.join(", "));
            }
        },

        importImage: function(imgDataUrl, coords, baseUrl, index) {

            var self = this,
                callback,
                dataType = "json";

            if (baseUrl) {
                callback = "callback";
                dataType = "jsonp";
            }
            if (index == undefined) {
                index = 0;
            }

            this.set('loading_count', this.get('loading_count') + 1);

            // Get the json data for the image...
            $.ajax({
                url: imgDataUrl,
                jsonp: callback, // 'callback'
                dataType: dataType,
                // work with the response
                success: function( data ) {

                    self.set('loading_count', self.get('loading_count') - 1);

                    coords.spacer = coords.spacer || data.size.width/20;
                    var full_width = (coords.colCount * (data.size.width + coords.spacer)) - coords.spacer,
                        full_height = (coords.rowCount * (data.size.height + coords.spacer)) - coords.spacer;
                    coords.scale = coords.paper_width / (full_width + (2 * coords.spacer));
                    coords.scale = Math.min(coords.scale, 1);    // only scale down
                    // For the FIRST IMAGE ONLY (coords.px etc undefined), we
                    // need to work out where to start (px,py) now that we know size of panel
                    // (assume all panels are same size)
                    coords.px = coords.px || coords.c.x - (full_width * coords.scale)/2;
                    coords.py = coords.py || coords.c.y - (full_height * coords.scale)/2;

                    // calculate panel coordinates from index...
                    var row = parseInt(index / coords.colCount, 10);
                    var col = index % coords.colCount;
                    var panelX = coords.px + ((data.size.width + coords.spacer) * coords.scale * col);
                    var panelY = coords.py + ((data.size.height + coords.spacer) * coords.scale * row);

                    // ****** This is the Data Model ******
                    //-------------------------------------
                    // Any changes here will create a new version
                    // of the model and will also have to be applied
                    // to the 'version_transform()' function so that
                    // older files can be brought up to date.
                    // Also check 'previewSetId()' for changes.
                    var n = {
                        'imageId': data.id,
                        'name': data.meta.imageName,
                        'width': data.size.width * coords.scale,
                        'height': data.size.height * coords.scale,
                        'sizeZ': data.size.z,
                        'theZ': data.rdefs.defaultZ,
                        'sizeT': data.size.t,
                        'theT': data.rdefs.defaultT,
                        'rdefs': {'model': data.rdefs.model},
                        'channels': data.channels,
                        'orig_width': data.size.width,
                        'orig_height': data.size.height,
                        'x': panelX,
                        'y': panelY,
                        'datasetName': data.meta.datasetName,
                        'datasetId': data.meta.datasetId,
                        'pixel_size_x': data.pixel_size.valueX,
                        'pixel_size_y': data.pixel_size.valueY,
                        'pixel_size_x_symbol': data.pixel_size.symbolX,
                        'pixel_size_x_unit': data.pixel_size.unitX,
                        'deltaT': data.deltaT,
                    };
                    if (baseUrl) {
                        n.baseUrl = baseUrl;
                    }
                    // create Panel (and select it)
                    // We do some additional processing in Panel.parse()
                    self.panels.create(n, {'parse': true}).set('selected', true);
                    self.notifySelectionChange();
                },

                error: function(event) {
                    self.set('loading_count', self.get('loading_count') - 1);
                    alert("Image not found on the server, " +
                        "or you don't have permission to access it at " + imgDataUrl);
                },
            });
        },

        // Used to position the #figure within canvas and also to coordinate svg layout.
        getFigureSize: function() {
            var pc = this.get('page_count'),
                cols = this.get('page_col_count'),
                gap = this.get('paper_spacing'),
                pw = this.get('paper_width'),
                ph = this.get('paper_height'),
                rows;
            rows = Math.ceil(pc / cols);
            var w = cols * pw + (cols - 1) * gap,
                h = rows * ph + (rows - 1) * gap;
            return {'w': w, 'h': h, 'cols': cols, 'rows': rows}
        },

        getPageOffset: function(coords) {
            var gap = this.get('paper_spacing'),
                pw = this.get('paper_width'),
                ph = this.get('paper_height');
            var xspacing = gap + pw;
            var yspacing = gap + ph;
            var offset = {};
            if (coords.x !== undefined){
                offset.x = coords.x % xspacing;
            }
            if (coords.y !== undefined){
                offset.y = coords.y % yspacing;
            }
            return offset;
        },

        getDefaultFigureName: function() {
            var d = new Date(),
                dt = d.getFullYear() + "-" + (d.getMonth()+1) + "-" +d.getDate(),
                tm = d.getHours() + ":" + d.getMinutes() + ":" + d.getSeconds();
            return "Figure_" + dt + "_" + tm;
        },

        nudge_right: function() {
            this.nudge('x', 10);
        },

        nudge_left: function() {
            this.nudge('x', -10);
        },

        nudge_down: function() {
            this.nudge('y', 10);
        },

        nudge_up: function() {
            this.nudge('y', -10);
        },

        nudge: function(axis, delta) {
            var selected = this.getSelected(),
                pos;

            selected.forEach(function(p){
                pos = p.get(axis);
                p.save(axis, pos + delta);
            });
        },

        align_left: function() {
            var selected = this.getSelected(),
                x_vals = [];
            selected.forEach(function(p){
                x_vals.push(p.get('x'));
            });
            var min_x = Math.min.apply(window, x_vals);

            selected.forEach(function(p){
                p.save('x', min_x);
            });
        },

        align_top: function() {
            var selected = this.getSelected(),
                y_vals = [];
            selected.forEach(function(p){
                y_vals.push(p.get('y'));
            });
            var min_y = Math.min.apply(window, y_vals);

            selected.forEach(function(p){
                p.save('y', min_y);
            });
        },

        align_grid: function() {
            var sel = this.getSelected(),
                top_left = this.get_top_left_panel(sel),
                top_x = top_left.get('x'),
                top_y = top_left.get('y'),
                grid = [],
                row = [top_left],
                next_panel = top_left;

            // populate the grid, getting neighbouring panel each time
            while (next_panel) {
                c = next_panel.get_centre();
                next_panel = this.get_panel_at(c.x + next_panel.get('width'), c.y, sel);

                // if next_panel is not found, reached end of row. Try start new row...
                if (typeof next_panel == 'undefined') {
                    grid.push(row);
                    // next_panel is below the first of the current row
                    c = row[0].get_centre();
                    next_panel = this.get_panel_at(c.x, c.y + row[0].get('height'), sel);
                    row = [];
                }
                if (next_panel) {
                    row.push(next_panel);
                }
            }

            var spacer = top_left.get('width')/20,
                new_x = top_x,
                new_y = top_y,
                max_h = 0;
            for (var r=0; r<grid.length; r++) {
                row = grid[r];
                for (var c=0; c<row.length; c++) {
                    panel = row[c];
                    panel.save({'x':new_x, 'y':new_y});
                    max_h = Math.max(max_h, panel.get('height'));
                    new_x = new_x + spacer + panel.get('width');
                }
                new_y = new_y + spacer + max_h;
                new_x = top_x;
            }
        },

        get_panel_at: function(x, y, panels) {
            return panels.find(function(p) {
                return ((p.get('x') < x && (p.get('x')+p.get('width')) > x) &&
                        (p.get('y') < y && (p.get('y')+p.get('height')) > y));
            });
        },

        get_top_left_panel: function(panels) {
            // top-left panel is one where x + y is least
            return panels.reduce(function(top_left, p){
                if ((p.get('x') + p.get('y')) < (top_left.get('x') + top_left.get('y'))) {
                    return p;
                } else {
                    return top_left;
                }
            });
        },

        align_size: function(width, height) {
            var sel = this.getSelected(),
                ref = this.get_top_left_panel(sel),
                ref_width = width ? ref.get('width') : false,
                ref_height = height ? ref.get('height') : false,
                new_w, new_h,
                p;

            sel.forEach(function(p){
                if (ref_width && ref_height) {
                    new_w = ref_width;
                    new_h = ref_height;
                } else if (ref_width) {
                    new_w = ref_width;
                    new_h = (ref_width/p.get('width')) * p.get('height');
                } else if (ref_height) {
                    new_h = ref_height;
                    new_w = (ref_height/p.get('height')) * p.get('width');
                }
                p.set({'width':new_w, 'height':new_h});
            });
        },

        // Resize panels so they all show same magnification
        align_magnification: function() {
            var sel = this.getSelected(),
                ref = this.get_top_left_panel(sel),
                ref_pixSize = ref.get('pixel_size_x'),
                targetMag;
            if (!ref_pixSize) {
                alert('Top-left panel has no pixel size set');
                return;
            }

            // This could return an AJAX call if we need to convert units.
            // Whenever we use this below, wrap it with $.when().then()
            var getPixSizeInMicrons = function(m) {
                var unit = m.get("pixel_size_x_unit"),
                    size = m.get("pixel_size_x");
                if (unit === "MICROMETER") {
                    return {'value':size};
                }
                if (!size) {
                    return {'value': size}
                }
                // convert to MICROMETER
                var url = BASE_WEBFIGURE_URL + "unit_conversion/" + size + "/" + unit + "/MICROMETER/";
                return $.getJSON(url);
            }

            // First, get reference pixel size...
            $.when( getPixSizeInMicrons(ref) ).then(function(data){
                ref_pixSize = data.value;
                // E.g. 10 microns / inch
                targetMag = ref_pixSize * ref.getPanelDpi();

                // Loop through all selected, updating size of each...
                sel.forEach(function(p){

                    // ignore the ref panel
                    if (p.cid === ref.cid) return;

                    $.when( getPixSizeInMicrons(p) ).then(function(data){

                        var dpi = p.getPanelDpi(),
                            pixSize = data.value;
                        if (!pixSize) {
                            return;
                        }
                        var panelMag = dpi * pixSize,
                            scale = panelMag / targetMag,
                            new_w = p.get('width') * scale,
                            new_h = p.get('height') * scale;
                        p.set({'width':new_w, 'height':new_h});
                    });
                });
            });
        },

        // This can come from multi-select Rect OR any selected Panel
        // Need to notify ALL panels and Multi-select Rect.
        drag_xy: function(dx, dy, save) {
            if (dx === 0 && dy === 0) return;

            var minX = 10000,
                minY = 10000,
                xy;
            // First we notidy all Panels
            var selected = this.getSelected();
            selected.forEach(function(m){
                xy = m.drag_xy(dx, dy, save);
                minX = Math.min(minX, xy.x);
                minY = Math.min(minY, xy.y);
            });
            // Notify the Multi-select Rect of it's new X and Y
            this.trigger('drag_xy', [minX, minY, save]);
        },


        // This comes from the Multi-Select Rect.
        // Simply delegate to all the Panels
        multiselectdrag: function(x1, y1, w1, h1, x2, y2, w2, h2, save) {
            var selected = this.getSelected();
            selected.forEach(function(m){
                m.multiselectdrag(x1, y1, w1, h1, x2, y2, w2, h2, save);
            });
        },

        // If already selected, do nothing (unless clearOthers is true)
        setSelected: function(item, clearOthers) {
            if ((!item.get('selected')) || clearOthers) {
                this.clearSelected(false);
                item.set('selected', true);
                this.notifySelectionChange();
            }
        },

        select_all:function() {
            this.panels.each(function(p){
                p.set('selected', true);
            });
            this.notifySelectionChange();
        },

        addSelected: function(item) {
            item.set('selected', true);
            this.notifySelectionChange();
        },

        clearSelected: function(trigger) {
            this.panels.each(function(p){
                p.set('selected', false);
            });
            if (trigger !== false) {
                this.notifySelectionChange();
            }
        },

        selectByRegion: function(coords) {
            this.panels.each(function(p){
                if (p.regionOverlaps(coords)) {
                    p.set('selected', true);
                }
            });
            this.notifySelectionChange();
        },

        getSelected: function() {
            return this.panels.getSelected();
        },

        // Go through all selected and destroy them - trigger selection change
        deleteSelected: function() {
            var selected = this.getSelected();
            var model;
            while (model = selected.first()) {
                model.destroy();
            }
            this.notifySelectionChange();
        },

        delete_panels: function() {
            // make list that won't change as we destroy
            var ps = [];
            this.panels.each(function(p){
                ps.push(p);
            });
            for (var i=ps.length-1; i>=0; i--) {
                ps[i].destroy();
            }
            this.notifySelectionChange();
        },

        getCropCoordinates: function() {
            // Get paper size and panel offsets (move to top-left) for cropping
            // returns {'paper_width', 'paper_height', 'dx', 'dy'}
            var margin = 10;

            // get range of all panel coordinates
            var top = Math.min.apply(window, this.panels.map(
                function(p){return p.getBoundingBoxTop();}));
            var left = Math.min.apply(window, this.panels.map(
                function(p){return p.getBoundingBoxLeft();}));
            var right = Math.max.apply(window, this.panels.map(
                function(p){return p.getBoundingBoxRight()}));
            var bottom = Math.max.apply(window, this.panels.map(
                function(p){return p.getBoundingBoxBottom()}));

            // Shift panels to top-left corner
            var dx = margin - left;
            var dy = margin - top;

            return {
                    'paper_width': right - left + (2 * margin),
                    'paper_height': bottom - top + (2 * margin),
                    'dx': dx,
                    'dy': dy
                   };
        },

        notifySelectionChange: function() {
            this.trigger('change:selection');
        }

    });



    // Corresponds to css - allows us to calculate size of labels
    var LINE_HEIGHT = 1.43;

    // ------------------------ Panel -----------------------------------------
    // Simple place-holder for each Panel. Will have E.g. imageId, rendering options etc
    // Attributes can be added as we need them.
    var Panel = Backbone.Model.extend({

        defaults: {
            x: 100,     // coordinates on the 'paper'
            y: 100,
            width: 512,
            height: 512,
            zoom: 100,
            dx: 0,    // pan x & y within viewport
            dy: 0,
            labels: [],
            deltaT: [],     // list of deltaTs (secs) for tIndexes of movie
            rotation: 0,
            selected: false,
            pixel_size_x_symbol: '\xB5m',     // microns by default
            pixel_size_x_unit: 'MICROMETER',
            max_export_dpi: 1000,

            // 'export_dpi' optional value to resample panel on export
            // model includes 'scalebar' object, e.g:
            // scalebar: {length: 10, position: 'bottomleft', color: 'FFFFFF',
            //                show: false, show_label: false; font_size: 10}
        },

        initialize: function() {

        },

        // When we're creating a Panel, we process the data a little here:
        parse: function(data, options) {
            var greyscale = data.rdefs.model === "greyscale";
            delete data.rdefs
            data.channels = data.channels.map(function(ch){
                // channels: use 'lut' for color if set. Don't save 'lut'
                if (ch.lut) {
                    if (ch.lut.length > 0) {
                        ch.color = ch.lut;
                    }
                    delete ch.lut;
                }
                // we don't support greyscale, but instead set active channel grey
                if (greyscale && ch.active) {
                    ch.color = "FFFFFF";
                }
                return ch;
            });
            return data;
        },

        syncOverride: true,

        validate: function(attrs, options) {
            // obviously lots more could be added here...
            if (attrs.theT >= attrs.sizeT) {
                return "theT too big";
            }
            if (attrs.theT < 0) {
                return "theT too small";
            }
            if (attrs.theZ >= attrs.sizeZ) {
                return "theZ too big";
            }
            if (attrs.theZ < 0) {
                return "theZ too small";
            }
            if (attrs.z_start !== undefined) {
                if (attrs.z_start < 0 || attrs.z_start >= attrs.sizeZ) {
                    return "z_start out of Z range"
                }
            }
            if (attrs.z_end !== undefined) {
                if (attrs.z_end < 0 || attrs.z_end >= attrs.sizeZ) {
                    return "z_end out of Z range"
                }
            }
        },

        // Switch some attributes for new image...
        setId: function(data) {

            // we replace these attributes...
            var newData = {'imageId': data.imageId,
                'name': data.name,
                'sizeZ': data.sizeZ,
                'theZ': data.theZ,
                'sizeT': data.sizeT,
                'orig_width': data.orig_width,
                'orig_height': data.orig_height,
                'datasetName': data.datasetName,
                'pixel_size_x': data.pixel_size_x,
                'pixel_size_y': data.pixel_size_y,
                'pixel_size_x_symbol': data.pixel_size_x_symbol,
                'pixel_size_x_unit': data.pixel_size_x_unit,
                'deltaT': data.deltaT,
            };

            // theT is not changed unless we have to...
            if (this.get('theT') >= newData.sizeT) {
                newData.theT = newData.sizeT - 1;
            }

            // Make sure dx and dy are not outside the new image
            if (Math.abs(this.get('dx')) > newData.orig_width/2) {
                newData.dx = 0;
            }
            if (Math.abs(this.get('dy')) > newData.orig_height/2) {
                newData.dy = 0;
            }

            // new Channels are based on new data, but we keep the
            // 'active' state and color from old Channels.
            var newCh = [],
                oldCh = this.get('channels'),
                dataCh = data.channels;
            _.each(dataCh, function(ch, i) {
                var nc = $.extend(true, {}, dataCh[i]);
                nc.active = (i < oldCh.length && oldCh[i].active);
                if (i < oldCh.length) {
                    nc.color = "" + oldCh[i].color;
                }
                newCh.push(nc);
            });

            newData.channels = newCh;

            this.set(newData);
        },

        hide_scalebar: function() {
            // keep all scalebar properties, except 'show'
            var sb = $.extend(true, {}, this.get('scalebar'));
            sb.show = false;
            this.save('scalebar', sb);
        },

        save_scalebar: function(new_sb) {
            // update only the attributes of scalebar we're passed
            var old_sb = $.extend(true, {}, this.get('scalebar') || {});
            var sb = $.extend(true, old_sb, new_sb);
            this.save('scalebar', sb);
        },

        // Simple checking whether shape is in viewport (x, y, width, height)
        // Return true if any of the points in shape are within viewport.
        is_shape_in_viewport: function(shape, viewport) {
            var rect = viewport;
            var isPointInRect = function(x, y) {
                if (x < rect.x) return false;
                if (y < rect.y) return false;
                if (x > rect.x + rect.width) return false;
                if (y > rect.y + rect.height) return false;
                return true;
            }
            var points;
            if (shape.type === "Ellipse") {
                points = [[shape.cx, shape.cy]];
            } else if (shape.type === "Rectangle") {
                points = [[shape.x, shape.y],
                        [shape.x, shape.y + shape.height,],
                        [shape.x + shape.width, shape.y],
                        [shape.x + shape.width, shape.y + shape.height]];
            } else if (shape.type === "Line" || shape.type === "Arrow") {
                points = [[shape.x1, shape.y1],
                        [shape.x2, shape.y2],
                        [(shape.x1 + shape.x2)/2, (shape.y1 + shape.y2)/ 2]];
            } else if (shape.type === "Polyline" || shape.type === "Polygon") {
                points = shape.points.split(' ').map(function(p){
                    return p.split(",");
                });
            }
            if (points) {
                for (var p=0; p<points.length; p++) {
                    if (isPointInRect(points[p][0], points[p][1])) {
                        return true;
                    }
                }
            }
            return false;
        },

        setROIStrokeWidth: function(width) {
            this.setROIAttr('strokeWidth', width);
        },

        setROIColor: function(color) {
            this.setROIAttr('strokeColor', '#' + color);
        },

        setROIAttr: function(attr, value) {
            var old = this.get('shapes');
            if (!old || old.length === 0) {
                return;
            }
            var rois = [];
            old.forEach(function(roi){
                var xtra = {};
                xtra['' + attr] = value;
                rois.push($.extend(true, {}, roi, xtra));
            });
            this.save('shapes', rois);
        },

        // Adds list of shapes to panel (same logic as for labels below)
        add_shapes: function(shapes) {
            var old = this.get('shapes'),
                viewport = this.getViewportAsRect(),
                self = this,
                allAdded = true,
                shps = [];
            if (old) {
                old.forEach(function(sh){
                    shps.push($.extend(true, {}, sh));
                });
            }
            shapes.forEach(function(sh){
                // simple test if shape is in viewport
                if (self.is_shape_in_viewport(sh, viewport)) {
                    shps.push($.extend(true, {}, sh));
                } else {
                    allAdded = false;
                }
            });
            this.save('shapes', shps);
            return allAdded;
        },

        // takes a list of labels, E.g [{'text':"t", 'size':10, 'color':'FF0000', 'position':"top"}]
        add_labels: function(labels) {
            var oldLabs = this.get("labels");
            // Need to clone the list of labels...
            var labs = [];
            for (var i=0; i<oldLabs.length; i++) {
                labs.push( $.extend(true, {}, oldLabs[i]) );
            }
            // ... then add new labels ...
            for (var j=0; j<labels.length; j++) {
                labs.push( $.extend(true, {}, labels[j]) );
            }
            // ... so that we get the changed event triggering OK
            this.save('labels', labs);
        },

        create_labels_from_channels: function(options) {
            var newLabels = [];
            _.each(this.get('channels'), function(c){
                if (c.active) {
                    // If channel has LUT, make grey (visible on black/white bg)
                    var chColor = c.color.endsWith('lut') ? 'BBBBBB' : c.color;
                    newLabels.push({
                        'text': c.label,
                        'size': options.size,
                        'position': options.position,
                        'color': options.color || chColor
                    });
                }
            });
            this.add_labels(newLabels);
        },

        getDeltaT: function() {
            var theT = this.get('theT');
            return this.get('deltaT')[theT] || 0;
        },

        get_time_label_text: function(format) {
            var pad = function(digit) {
                var d = digit + "";
                return d.length === 1 ? ("0"+d) : d;
            };
            var theT = this.get('theT'),
                deltaT = this.get('deltaT')[theT] || 0,
                isNegative = (deltaT < 0),
                text = "", h, m, s;
            deltaT = Math.abs(deltaT);
            if (format === "index") {
                isNegative = false;
                text = "" + (theT + 1);
            } else if (format === "milliseconds") {
                text = Math.round(deltaT*1000) + " ms";
            } else if (format === "secs") {
                text = Math.round(deltaT) + " s";
            } else if (format === "mins:secs") {
                m = parseInt(deltaT / 60);
                s = pad(Math.round(deltaT % 60));
                text = m + ":" + s;
            } else if (format === "mins") {
                text = Math.round(deltaT / 60) + " mins";
            } else if (format === "hrs:mins") {
                h = parseInt(deltaT / 3600);
                m = pad(Math.round((deltaT % 3600) / 60));
                text = h + ":" + m;
            } else if (format === "hrs:mins:secs") {
                h = parseInt(deltaT / 3600);
                m = pad(parseInt((deltaT % 3600) / 60));
                s = pad(Math.round(deltaT % 60));
                text = h + ":" + m + ":" + s;
            }
            if (["0 s", "0:00", "0 mins", "0:00:00"].indexOf(text) > -1) {
                isNegative = false;
            }
            return (isNegative ? '-' : '') + text;
        },

        create_labels_from_time: function(options) {
            
            this.add_labels([{
                    'time': options.format,
                    'size': options.size,
                    'position': options.position,
                    'color': options.color
            }]);
        },

        get_label_key: function(label) {
            var key = (label.text || label.time) + '_' + label.size + '_' + label.color + '_' + label.position;
            key = _.escape(key);
            return key;
        },

        // labels_map is {labelKey: {size:s, text:t, position:p, color:c}} or {labelKey: false} to delete
        // where labelKey specifies the label to edit. "l.text + '_' + l.size + '_' + l.color + '_' + l.position"
        edit_labels: function(labels_map) {

            var oldLabs = this.get('labels');
            // Need to clone the list of labels...
            var labs = [],
                lbl, lbl_key;
            for (var i=0; i<oldLabs.length; i++) {
                lbl = oldLabs[i];
                lbl_key = this.get_label_key(lbl);
                // for existing label that matches...
                if (labels_map.hasOwnProperty(lbl_key)) {
                    if (labels_map[lbl_key]) {
                        // replace with the new label
                        lbl = $.extend(true, {}, labels_map[lbl_key]);
                        labs.push( lbl );
                    }
                    // else 'false' are ignored (deleted)
                } else {
                    // otherwise leave un-edited
                    lbl = $.extend(true, {}, lbl);
                    labs.push( lbl );
                }
            }
            // ... so that we get the changed event triggering OK
            this.save('labels', labs);
        },

        save_channel: function(cIndex, attr, value) {

            var oldChs = this.get('channels');
            // Need to clone the list of channels...
            var chs = [];
            for (var i=0; i<oldChs.length; i++) {
                chs.push( $.extend(true, {}, oldChs[i]) );
            }
            // ... then set new value ...
            chs[cIndex][attr] = value;
            // ... so that we get the changed event triggering OK
            this.save('channels', chs);
        },

        toggle_channel: function(cIndex, active){

            if (typeof active == "undefined"){
                active = !this.get('channels')[cIndex].active;
            }
            this.save_channel(cIndex, 'active', active);
        },

        save_channel_window: function(cIndex, new_w) {
            // save changes to the channel.window. Extend {} so save triggers change
            var w = $.extend(true, {}, this.get('channels')[cIndex].window);
            new_w = $.extend(true, w, new_w);
            this.save_channel(cIndex, 'window', new_w);
        },

        set_z_projection: function(z_projection) {
            var zp = this.get('z_projection'),
                z_start = this.get('z_start'),
                z_end = this.get('z_end'),
                sizeZ = this.get('sizeZ'),
                theZ = this.get('theZ'),
                z_diff = 2;

            // Only allow Z-projection if sizeZ > 1
            // If turning projection on...
            if (z_projection && !zp && sizeZ > 1) {

                // use existing z_diff interval if set
                if (z_start !== undefined && z_end !== undefined) {
                    z_diff = (z_end - z_start)/2;
                    z_diff = Math.round(z_diff);
                }
                // reset z_start & z_end
                z_start = Math.max(theZ - z_diff, 0);
                z_end = Math.min(theZ + z_diff, sizeZ - 1);
                this.set({
                    'z_projection': true,
                    'z_start': z_start,
                    'z_end': z_end
                });
            // If turning z-projection off...
            } else if (!z_projection && zp) {
                // reset theZ for average of z_start & z_end
                if (z_start !== undefined && z_end !== undefined) {
                    theZ = Math.round((z_end + z_start)/ 2 );
                    this.set({'z_projection': false,
                        'theZ': theZ});
                } else {
                    this.set('z_projection', false);
                }
            }
        },

        // When a multi-select rectangle is drawn around several Panels
        // a resize of the rectangle x1, y1, w1, h1 => x2, y2, w2, h2
        // will resize the Panels within it in proportion.
        // This might be during a drag, or drag-stop (save=true)
        multiselectdrag: function(x1, y1, w1, h1, x2, y2, w2, h2, save){

            var shift_x = function(startX) {
                return ((startX - x1)/w1) * w2 + x2;
            };
            var shift_y = function(startY) {
                return ((startY - y1)/h1) * h2 + y2;
            };

            var newX = shift_x( this.get('x') ),
                newY = shift_y( this.get('y') ),
                newW = shift_x( this.get('x')+this.get('width') ) - newX,
                newH = shift_y( this.get('y')+this.get('height') ) - newY;

            // Either set the new coordinates...
            if (save) {
                this.save( {'x':newX, 'y':newY, 'width':newW, 'height':newH} );
            } else {
                // ... Or update the UI Panels
                // both svg and DOM views listen for this...
                this.trigger('drag_resize', [newX, newY, newW, newH] );
            }
        },

        // resize, zoom and pan to show the specified region.
        // new panel will fit inside existing panel
        // coords is {x:x, y:y, width:w, height:h, rotation?:r}
        cropToRoi: function(coords) {
            var targetWH = coords.width/coords.height,
                currentWH = this.get('width')/this.get('height'),
                newW, newH,
                targetCx = Math.round(coords.x + (coords.width/2)),
                targetCy = Math.round(coords.y + (coords.height/2)),
                // centre panel at centre of ROI
                dx = (this.get('orig_width')/2) - targetCx,
                dy = (this.get('orig_height')/2) - targetCy;
            // make panel correct w/h ratio
            if (targetWH < currentWH) {
                // make it thinner
                newH = this.get('height');
                newW = targetWH * newH;
            } else {
                newW = this.get('width');
                newH = newW / targetWH;
            }
            // zoom to correct percentage
            var xPercent = this.get('orig_width') / coords.width,
                yPercent = this.get('orig_height') / coords.height,
                zoom = Math.min(xPercent, yPercent) * 100;

            var toSet = { 'width': newW, 'height': newH, 'dx': dx, 'dy': dy, 'zoom': zoom };
            var rotation = coords.rotation || 0;
            if (!isNaN(rotation)) {
                toSet.rotation = rotation;
            }
            this.save(toSet);
        },

        // returns the current viewport as a Rect {x, y, width, height}
        getViewportAsRect: function(zoom, dx, dy) {
            zoom = zoom !== undefined ? zoom : this.get('zoom');
            dx = dx !== undefined ? dx : this.get('dx');
            dy = dy !== undefined ? dy : this.get('dy');
            var rotation = this.get('rotation');

            var width = this.get('width'),
                height = this.get('height'),
                orig_width = this.get('orig_width'),
                orig_height = this.get('orig_height');

            // find if scaling is limited by width OR height
            var xPercent = width / orig_width,
                yPercent = height / orig_height,
                scale = Math.max(xPercent, yPercent);

            // if not zoomed or panned and panel shape is approx same as image...
            var orig_wh = orig_width / orig_height,
                view_wh = width / height;
            if (dx === 0 && dy === 0 && zoom == 100 && Math.abs(orig_wh - view_wh) < 0.01) {
                // ...ROI is whole image
                return {'x': 0, 'y': 0, 'width': orig_width,
                    'height': orig_height, 'rotation': rotation}
            }

            // Factor in the applied zoom...
            scale = scale * zoom / 100;
            // ...to get roi width & height
            var roiW = width / scale,
                roiH = height / scale;

            // Use offset from image centre to calculate ROI position
            var cX = orig_width/2 - dx,
                cY = orig_height    /2 - dy,
                roiX = cX - (roiW / 2),
                roiY = cY - (roiH / 2);

            return {'x': roiX, 'y': roiY, 'width': roiW,
                'height': roiH, 'rotation': rotation};
        },

        // Drag resizing - notify the PanelView without saving
        drag_resize: function(x, y, w, h) {
            this.trigger('drag_resize', [x, y, w, h] );
        },

        // Drag moving - notify the PanelView & SvgModel with/without saving
        drag_xy: function(dx, dy, save) {
            // Ignore any drag_stop events from simple clicks (no drag)
            if (dx === 0 && dy === 0) {
                return;
            }
            var newX = this.get('x') + dx,
                newY = this.get('y') + dy,
                w = this.get('width'),
                h = this.get('height');

            // Either set the new coordinates...
            if (save) {
                this.save( {'x':newX, 'y':newY} );
            } else {
                // ... Or update the UI Panels
                // both svg and DOM views listen for this...
                this.trigger('drag_resize', [newX, newY, w, h] );
            }

            // we return new X and Y so FigureModel knows where panels are
            return {'x':newX, 'y':newY};
        },

        get_centre: function() {
            return {'x':this.get('x') + (this.get('width')/2),
                'y':this.get('y') + (this.get('height')/2)};
        },

        is_big_image: function() {
            return this.get('orig_width') * this.get('orig_height') > MAX_PLANE_SIZE;
        },

        get_img_src: function(force_no_padding) {
            var chs = this.get('channels');
            var cStrings = chs.map(function(c, i){
                return (c.active ? '' : '-') + (1+i) + "|" + c.window.start + ":" + c.window.end + "$" + c.color;
            });
            var maps_json = chs.map(function(c){
                return {'reverse': {'enabled': !!c.reverseIntensity}};
            });
            var renderString = cStrings.join(","),
                imageId = this.get('imageId'),
                theZ = this.get('theZ'),
                theT = this.get('theT'),
                baseUrl = this.get('baseUrl'),
                // stringify json and remove spaces
                maps = '&maps=' + JSON.stringify(maps_json).replace(/ /g, ""),
                proj = "";
            if (this.get('z_projection')) {
                proj = "&p=intmax|" + this.get('z_start') + ":" + this.get('z_end');
            }
            baseUrl = baseUrl || WEBGATEWAYINDEX.slice(0, -1);  // remove last /

            // If BIG image, render scaled region
            var region = "";
            if (this.is_big_image()) {
                baseUrl = BASE_WEBFIGURE_URL + 'render_scaled_region/';
                var rect = this.getViewportAsRect();
                // Render a region that is 1.5 x larger
                if (!force_no_padding) {
                    var length = Math.max(rect.width, rect.height) * 1.5;
                    rect.x = rect.x - ((length - rect.width) / 2);
                    rect.y = rect.y - ((length - rect.height) / 2);
                    rect.width = length;
                    rect.height = length;
                }
                var coords = [rect.x, rect.y, rect.width, rect.height].map(function(c){return parseInt(c)})
                region = '&region=' + coords.join(',');
            } else {
                baseUrl += '/render_image/';
            }

            return baseUrl + imageId + "/" + theZ + "/" + theT
                    + '/?c=' + renderString + proj + maps + region + "&m=c";
        },

        // Turn coordinates into css object with rotation transform
        _viewport_css: function(img_x, img_y, img_w, img_h, frame_w, frame_h, rotation) {
            var transform_x = 100 * (frame_w/2 - img_x) / img_w,
                transform_y = 100 * (frame_h/2 - img_y) / img_h;
            if (rotation == undefined) {
                rotation = this.get('rotation') || 0;
            }

            var css = {'left':img_x,
                       'top':img_y,
                       'width':img_w,
                       'height':img_h,
                       '-webkit-transform-origin': transform_x + '% ' + transform_y + '%',
                       'transform-origin': transform_x + '% ' + transform_y + '%',
                       '-webkit-transform': 'rotate(' + rotation + 'deg)',
                       'transform': 'rotate(' + rotation + 'deg)'
                   };
            return css;
        },

        // used by the PanelView and ImageViewerView to get the size and
        // offset of the img within it's frame
        get_vp_img_css: function(zoom, frame_w, frame_h, x, y) {

            // For non-big images, we have the full plane in hand
            // css just shows the viewport region
            if (!this.is_big_image()) {
                return this.get_vp_full_plane_css(zoom, frame_w, frame_h, x, y);

            // For 'big' images, we render just the viewport, so the rendered
            // image fully fills the viewport.
            } else {
                return this.get_vp_big_image_css(zoom, frame_w, frame_h, x, y);
            }
        },

        // For BIG images we just render the viewport
        // Rendered image will be filling viewport.
        // If we're zooming image will be larger.
        // If panning, offset from centre by x and y.
        // NB: Reshaping (changing aspect ratio) is buggy (so PanelView hides big image while reshaping)
        get_vp_big_image_css: function(zoom, frame_w, frame_h, x, y) {

            // Used for static rendering, as well as during zoom, panning, panel resizing
            // and panel re-shaping (stretch/squash).

            var zooming = zoom !== this.get('zoom');
            var panning = (x !== undefined && y!== undefined);

            // Need to know what the original offsets are...
            // We know that the image is 1.5 * bigger than viewport
            var length = Math.max(frame_w, frame_h) * 1.5;

            var img_x;
            var img_y;
            var img_w = length;
            var img_h = length;

            // if we're zooming...
            if (zooming) {
                img_w = length * zoom / this.get('zoom');
                img_h = length * zoom / this.get('zoom');
                img_y = y || ((frame_h - img_h) / 2);
                img_x = x || ((frame_w - img_w) / 2);
                return this._viewport_css(img_x, img_y, img_w, img_h, frame_w, frame_h);
            } else {
                img_x = (frame_w - length) / 2;
                img_y = (frame_h - length) / 2;
            }

            // if we're resizing width / height....
            var old_w = parseInt(this.get('width'), 10);
            var old_h = parseInt(this.get('height'), 10);
            frame_w = parseInt(frame_w);
            frame_h = parseInt(frame_h);

            var resizing = old_w !== img_w || old_h !== img_h;
            if (resizing) {

                img_y = (frame_h - img_h) / 2;
                img_x = (frame_w - img_w) / 2;

                // If we're panning...
                if (panning) {
                    // ...we need to simply increment existing offset
                    img_x += x;
                    img_y += y;
                }
            }

            return this._viewport_css(img_x, img_y, img_w, img_h, frame_w, frame_h);
        },

        // get CSS that positions and scales a full image plane so that
        // only the 'viewport' shows in the parent container
        get_vp_full_plane_css: function(zoom, frame_w, frame_h, x, y) {

            var dx = x;
            var dy = y;

            var orig_w = this.get('orig_width'),
                orig_h = this.get('orig_height');
            if (typeof dx == 'undefined') dx = this.get('dx');
            if (typeof dy == 'undefined') dy = this.get('dy');
            zoom = zoom || 100;

            var img_x = 0,
                img_y = 0,
                img_w = frame_w * (zoom/100),
                img_h = frame_h * (zoom/100),
                orig_ratio = orig_w / orig_h,
                vp_ratio = frame_w / frame_h;
            if (Math.abs(orig_ratio - vp_ratio) < 0.01) {
                // ignore...
            // if viewport is wider than orig, offset y
            } else if (orig_ratio < vp_ratio) {
                img_h = img_w / orig_ratio;
            } else {
                img_w = img_h * orig_ratio;
            }
            var vp_scale_x = frame_w / orig_w,
                vp_scale_y = frame_h / orig_h,
                vp_scale = Math.max(vp_scale_x, vp_scale_y);

            // offsets if image is centered
            img_y = (img_h - frame_h)/2;
            img_x = (img_w - frame_w)/2;

            // now shift by dx & dy
            dx = dx * (zoom/100);
            dy = dy * (zoom/100);
            img_x = (dx * vp_scale) - img_x;
            img_y = (dy * vp_scale) - img_y;

            return this._viewport_css(img_x, img_y, img_w, img_h, frame_w, frame_h);
        },

        getPanelDpi: function(w, h, zoom) {
            // page is 72 dpi
            w = w || this.get('width');
            h = h || this.get('height');
            zoom = zoom || this.get('zoom');
            var img_width = this.get_vp_full_plane_css(zoom, w, h).width,  // not viewport width
                orig_width = this.get('orig_width'),
                scaling = orig_width / img_width,
                dpi = scaling * 72;
            return dpi.toFixed(0);
        },

        getBoundingBoxTop: function() {
            // get top of panel including 'top' labels
            var labels = this.get("labels");
            var y = this.get('y');
            // get labels by position
            var top_labels = labels.filter(function(l) {return l.position === 'top'});
            // offset by font-size of each
            y = top_labels.reduce(function(prev, l){
                return prev - (LINE_HEIGHT * l.size);
            }, y);
            return y;
        },

        getBoundingBoxLeft: function() {
            // get left of panel including 'leftvert' labels (ignore
            // left horizontal labels - hard to calculate width)
            var labels = this.get("labels");
            var x = this.get('x');
            // get labels by position
            var left_labels = labels.filter(function(l) {return l.position === 'leftvert'});
            // offset by font-size of each
            x = left_labels.reduce(function(prev, l){
                return prev - (LINE_HEIGHT * l.size);
            }, x);
            return x;
        },

        getBoundingBoxRight: function() {
            // Ignore right (horizontal) labels since we don't know how long they are
            return this.get('x') + this.get('width');
        },

        getBoundingBoxBottom: function() {
            // get bottom of panel including 'bottom' labels
            var labels = this.get("labels");
            var y = this.get('y') + this.get('height');
            // get labels by position
            var bottom_labels = labels.filter(function(l) {return l.position === 'bottom'});
            // offset by font-size of each
            y = bottom_labels.reduce(function(prev, l){
                return prev + (LINE_HEIGHT * l.size);
            }, y);
            return y;
        },

        // True if coords (x,y,width, height) overlap with panel
        regionOverlaps: function(coords) {

            var px = this.get('x'),
                px2 = px + this.get('width'),
                py = this.get('y'),
                py2 = py + this.get('height'),
                cx = coords.x,
                cx2 = cx + coords.width,
                cy = coords.y,
                cy2 = cy + coords.height;
            // overlap needs overlap on x-axis...
            return ((px < cx2) && (cx < px2) && (py < cy2) && (cy < py2));
        },

    });

    // ------------------------ Panel Collection -------------------------
    var PanelList = Backbone.Collection.extend({
        model: Panel,

        getSelected: function() {
            var s = this.filter(function(panel){
                return panel.get('selected');
            });
            return new PanelList(s);
        },

        getAverage: function(attr) {
            return this.getSum(attr) / this.length;
        },

        getAverageWH: function() {
            var sumWH = this.reduce(function(memo, m){
                return memo + (m.get('width')/ m.get('height'));
            }, 0);
            return sumWH / this.length;
        },

        getSum: function(attr) {
            return this.reduce(function(memo, m){
                return memo + (m.get(attr) || 0);
            }, 0);
        },

        getMax: function(attr) {
            return this.reduce(function(memo, m){ return Math.max(memo, m.get(attr)); }, 0);
        },

        getMin: function(attr) {
            return this.reduce(function(memo, m){ return Math.min(memo, m.get(attr)); }, Infinity);
        },

        allTrue: function(attr) {
            return this.reduce(function(memo, m){
                return (memo && m.get(attr));
            }, true);
        },

        // check if all panels have the same value for named attribute
        allEqual: function(attr) {
            var vals = this.pluck(attr);
            return _.max(vals) === _.min(vals);
        },

        // Return the value of named attribute IF it's the same for all panels, otherwise undefined
        getIfEqual: function(attr) {
            var vals = this.pluck(attr);
            if (_.max(vals) === _.min(vals)) {
                return _.max(vals);
            }
        },

        getDeltaTIfEqual: function() {
            var vals = this.map(function(m){ return m.getDeltaT() });
            if (_.max(vals) === _.min(vals)) {
                return _.max(vals);
            }
        },

        createLabelsFromTags: function(options) {
            // Loads Tags for selected images and creates labels
            var image_ids = this.map(function(s){return s.get('imageId')})
            image_ids = "image=" + image_ids.join("&image=");
            // TODO: Use /api/ when annotations is supported
            var url = WEBINDEX_URL + "api/annotations/?type=tag&limit=1000&" + image_ids;
            $.getJSON(url, function(data){
                // Map {iid: {id: 'tag'}, {id: 'names'}}
                var imageTags = data.annotations.reduce(function(prev, t){
                    var iid = t.link.parent.id;
                    if (!prev[iid]) {
                        prev[iid] = {};
                    }
                    prev[iid][t.id] = t.textValue;
                    return prev;
                }, {});
                // Apply tags to panels
                this.forEach(function(p){
                    var iid = p.get('imageId');
                    var labels = _.values(imageTags[iid]).map(function(text){
                        return {
                            'text': text,
                            'size': options.size,
                            'position': options.position,
                            'color': options.color
                        }
                    });

                    p.add_labels(labels);
                });
            }.bind(this));
        }
    });


var ShapeModel = Backbone.Model.extend({

    parse: function(shape) {
        var lowerFirst = function(text) {
            return text[0].toLowerCase() + text.slice(1);
        }
        var rgbint_to_css = function(signed_integer) {
            if (signed_integer < 0) signed_integer = signed_integer >>> 0;
            var intAsHex = signed_integer.toString(16);
            intAsHex = ("00000000" + intAsHex).slice(-8);
            return '#' + intAsHex.substring(0,6);
        }
        shape.id = shape['@id'];
        shape.type = shape['@type'].split('#')[1];
        delete shape['@id']
        delete shape['@type']
        // StrokeWidth - unwrap 'pixel' unit
        if (shape.StrokeWidth) {
            shape.strokeWidth = shape.StrokeWidth.Value;
            delete shape['StrokeWidth'];
        }
        // handle colors:
        _.each(["StrokeColor", "FillColor", ], function(attr) {
            if (shape[attr] !== undefined) {
                shape[lowerFirst(attr)] = rgbint_to_css(shape[attr]);
                delete shape[attr];
            }
        });
        // Convert other attributes
        _.each(["Points", "MarkerEnd", "MarkerStart", "X", "Y", "RadiusX", "RadiusY", "X1", "Y1", "X2", "Y2", "Width", "Height", "TheZ", "TheT"], function(attr) {
            if (shape[attr] !== undefined) {
                shape[lowerFirst(attr)] = shape[attr];
                delete shape[attr];
            }
        });
        // Handle Arrows...
        if (shape.markerEnd === 'Arrow' || shape.markerStart === 'Arrow') {
            shape.type = 'Arrow';
            if (shape.markerEnd !== 'Arrow') {
                // Only marker start is arrow - reverse direction!
                var tmp = {'x1': shape.x1, 'y1': shape.y1, 'x2': shape.x2, 'y2': shape.y2};
                shape.x1 = tmp.x2;
                shape.y1 = tmp.y2;
                shape.x2 = tmp.x1;
                shape.y2 = tmp.y1;
            }
        }
        return shape;
    },

    convertOMEROShape: function() {
        // Converts a shape json from OMERO into format taken by Shape-editor
        // if shape has Arrow head, shape.type = Arrow
        var s = this.toJSON();
        if (s.markerEnd === 'Arrow' || s.markerStart === 'Arrow') {
            s.type = 'Arrow';
            if (s.markerEnd !== 'Arrow') {
                // Only marker start is arrow - reverse direction!
                var tmp = {'x1': s.x1, 'y1': s.y1, 'x2': s.x2, 'y2': s.y2};
                s.x1 = tmp.x2;
                s.y1 = tmp.y2;
                s.x2 = tmp.x1;
                s.y2 = tmp.y1;
            }
        }
        if (s.type === 'Ellipse') {
            // If we have < OMERO 5.3, Ellipse has cx, cy, rx, ry
            if (s.rx !== undefined) {
                s.x = s.cx;
                s.y = s.cy;
                s.radiusX = s.rx;
                s.radiusY = s.ry;
            }
        }
        return s;
    },
});

var ShapeList = Backbone.Collection.extend({
    model: ShapeModel
});

var RoiModel = Backbone.Model.extend({

    initialize: function(data) {
        this.set('id', data['@id']);
        this.shapes = new ShapeList(data.shapes, {'parse': true});
    }
});

var RoiList = Backbone.Collection.extend({
    // url: ROIS_JSON_URL + iid + "/",
    model: RoiModel,

    deselectShapes: function(){
        this.forEach(function(roi){
            roi.shapes.forEach(function(s){
                if (s.get('selected')) {
                    s.set('selected', false)
                }
            });
        });
    },

    selectShape: function(shapeId){
        var shape,
            shapeJson;
        this.forEach(function(roi){
            roi.shapes.forEach(function(s){
                if (s.get('id') === shapeId) {
                    s.set('selected');
                }
            });
        });
        shape = this.getShape(shapeId);
        if (shape) {
            shapeJson = shape.toJSON();
        }
        this.trigger('change:selection', [shapeJson]);
    },

    getShape: function(shapeId){
        var shape;
        this.forEach(function(roi){
            var s = roi.shapes.get(shapeId);
            if (s) {
                shape = s;
            }
        });
        return shape;
    }
});
// --------------- UNDO MANAGER ----------------------

//
// Copyright (C) 2014 University of Dundee & Open Microscopy Environment.
// All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//

/*global Backbone:true */

var UndoManager = Backbone.Model.extend({
    defaults: function(){
        return {
            undo_pointer: -1
        };
    },
    initialize: function(opts) {
        this.figureModel = opts.figureModel;    // need for setting selection etc
        this.figureModel.on("change:figureName change:paper_width change:paper_height change:page_count change:legend",
            this.handleChange, this);
        this.listenTo(this.figureModel, 'reset_undo_redo', this.resetQueue);
        this.undoQueue = [];
        this.undoInProgress = false;
        //this.undo_pointer = -1;
        // Might need to undo/redo multiple panels/objects
        this.undo_functions = [];
        this.redo_functions = [];
    },
    resetQueue: function() {
        this.undoQueue = [];
        this.set('undo_pointer', -1);
        this.canUndo();
    },
    canUndo: function() {
        return this.get('undo_pointer') >= 0;
    },
    undo: function() {
        var pointer = this.get('undo_pointer');
        if (pointer < 0) {
            return;
        }
        this.undoQueue[pointer].undo();
        this.set('undo_pointer',pointer-1); // trigger change
    },
    canRedo: function() {
        return this.get('undo_pointer')+1 < this.undoQueue.length;
    },
    redo: function() {
        var pointer = this.get('undo_pointer');
        if (pointer+1 >= this.undoQueue.length) {
            return;
        }
        this.undoQueue[pointer+1].redo();
        this.set('undo_pointer', pointer+1); // trigger change event
    },
    postEdit: function(undo) {
        var pointer = this.get('undo_pointer');
        // remove any undo ahead of current position
        if (this.undoQueue.length > pointer+1) {
            this.undoQueue = this.undoQueue.slice(0, pointer+1);
        }
        this.undoQueue.push(undo);
        this.set('undo_pointer', pointer+1); // trigger change event
    },

    // START here - Listen to 'add' events...
    listenToCollection: function(collection) {
        var self = this;
        // Add listener to changes in current models
        collection.each(function(m){
            self.listenToModel(m);
        });
        collection.on('add', function(m) {
            // start listening for change events on the model
            self.listenToModel(m);
            if (!self.undoInProgress){
                // post an 'undo'
                self.handleAdd(m, collection);
            }
        });
        collection.on('remove', function(m) {
            if (!self.undoInProgress){
                // post an 'undo'
                self.handleRemove(m, collection);
            }
        });
    },

    handleRemove: function(m, collection) {
        var self = this;
        self.postEdit( {
            name: "Undo Remove",
            undo: function() {
                self.undoInProgress = true;
                collection.add(m);
                self.figureModel.notifySelectionChange();
                self.undoInProgress = false;
            },
            redo: function() {
                self.undoInProgress = true;
                m.destroy();
                self.figureModel.notifySelectionChange();
                self.undoInProgress = false;
            }
        });
    },

    handleAdd: function(m, collection) {
        var self = this;
        self.postEdit( {
            name: "Undo Add",
            undo: function() {
                self.undoInProgress = true;
                m.destroy();
                self.figureModel.notifySelectionChange();
                self.undoInProgress = false;
            },
            redo: function() {
                self.undoInProgress = true;
                collection.add(m);
                self.figureModel.notifySelectionChange();
                self.undoInProgress = false;
            }
        });
    },

    listenToModel: function(model) {
        model.on("change", this.handleChange, this);
    },

    // Here we do most of the work, buiding Undo/Redo Edits when something changes
    handleChange: function(m) {
        var self = this;

        // Make sure we don't listen to changes coming from Undo/Redo
        if (self.undoInProgress) {
            return;     // Don't undo the undo!
        }

        // Ignore changes to certain attributes
        var ignore_attrs = ["selected", "id"];  // change in id when new Panel is saved

        var undo_attrs = {},
            redo_attrs = {},
            a;
        for (a in m.changed) {
            if (ignore_attrs.indexOf(a) < 0) {
                undo_attrs[a] = m.previous(a);
                redo_attrs[a] = m.get(a);
            }
        }

        // in case we only got 'ignorable' changes
        if (_.size(redo_attrs) === 0) {
            return;
        }

        // We add each change to undo_functions array, which may contain several
        // changes that happen at "the same time" (E.g. multi-drag)
        self.undo_functions.push(function(){
            m.save(undo_attrs);
        });
        self.redo_functions.push(function(){
            m.save(redo_attrs);
        });

        // this could maybe moved to FigureModel itself
        var set_selected = function(selected) {
            selected.forEach(function(m, i){
                if (i === 0) {
                    self.figureModel.setSelected(m, true);
                } else {
                    self.figureModel.addSelected(m);
                }
            });
        }

        // This is used to copy the undo/redo_functions lists
        // into undo / redo operations to go into our Edit below
        var createUndo = function(callList) {
            var undos = [];
            for (var u=0; u<callList.length; u++) {
                undos.push(callList[u]);
            }
            // get the currently selected panels
            var selected = self.figureModel.getSelected();
            return function() {
                self.undoInProgress = true;
                for (var u=0; u<undos.length; u++) {
                    undos[u]();
                }
                set_selected(selected);     // restore selection
                self.undoInProgress = false;
            }
        }

        // if we get multiple changes in rapid succession,
        // clear any existing timeout and re-create.
        if (typeof self.createEditTimeout != 'undefined') {
            clearTimeout(self.createEditTimeout);
        }
        // Only the last change will call createEditTimeout
        self.createEditTimeout = setTimeout(function() {
            self.postEdit( {
                name: "Undo...",
                undo: createUndo(self.undo_functions),
                redo: createUndo(self.redo_functions)
            });
            self.undo_functions = [];
            self.redo_functions = [];
        }, 10);
    }
});

var UndoView = Backbone.View.extend({
    
    el: $("#edit_actions"),
    
    events: {
      "click .undo": "undo",
      "click .redo": "redo"
    },

    // NB: requires backbone.mousetrap
    keyboardEvents: {
        'mod+z': 'undo',
        'mod+y': 'redo'
    },
    
    initialize: function() {
      this.model.on('change', this.render, this);
      this.undoEl = $(".undo", this.$el);
      this.redoEl = $(".redo", this.$el);

      this.render();
    },
    
    render: function() {
        if (this.model.canUndo()) {
            this.undoEl.removeClass('disabled');
        } else {
            this.undoEl.addClass('disabled');
        }
        if (this.model.canRedo()) {
            this.redoEl.removeClass('disabled');
        } else {
            this.redoEl.addClass('disabled');
        }
        return this;
    },

    // If modal dialags are visible, we want to ignore undo/redo
    modal_visible: function() {
        return $("div.modal:visible").length > 0;
    },

    undo: function(event) {
        event.preventDefault();
        if (this.modal_visible()) return;
        this.model.undo();
    },
    redo: function(event) {
        event.preventDefault();
        if (this.modal_visible()) return;
        this.model.redo();
    }
});

var ChannelSliderView = Backbone.View.extend({

    template: JST["src/templates/channel_slider_template.html"],

    initialize: function(opts) {
        // This View may apply to a single PanelModel or a list
        this.models = opts.models;
        var self = this;
        this.models.forEach(function(m){
            self.listenTo(m, 'change:channels', self.render);
        });
    },

    events: {
        "keyup .ch_start": "handle_channel_input",
        "keyup .ch_end": "handle_channel_input",
        "click .channel-btn": "toggle_channel",
        "click .dropdown-menu a": "pick_color",
    },

    pick_color: function(e) {
        var color = e.currentTarget.getAttribute('data-color'),
            $colorbtn = $(e.currentTarget).parent().parent(),
            oldcolor = $(e.currentTarget).attr('data-oldcolor'),
            idx = $colorbtn.attr('data-index'),
            self = this;

        if (color == 'colorpicker') {
            FigureColorPicker.show({
                'color': oldcolor,
                'success': function(newColor){
                    // remove # from E.g. #ff00ff
                    newColor = newColor.replace("#", "");
                    self.set_color(idx, newColor);
                }
            });
        } else if (color == 'lutpicker') {
            FigureLutPicker.show({
                success: function(lutName){
                    // LUT names are handled same as color strings
                    self.set_color(idx, lutName);
                }
            });
        } else if (color == 'reverse') {
            var reverse = $('span', e.currentTarget).hasClass('glyphicon-check');
            self.models.forEach(function(m){
                m.save_channel(idx, 'reverseIntensity', !reverse);
            });
        } else {
            this.set_color(idx, color);
        }
        return false;
    },

    set_color: function(idx, color) {
        if (this.models) {
            this.models.forEach(function(m){
                m.save_channel(idx, 'color', color);
            });
        }
    },

    hexToRgb: function hexToRgb(hex) {
        // handle #ff00ff
        var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
        if (result) return {
            r: parseInt(result[1], 16),
            g: parseInt(result[2], 16),
            b: parseInt(result[3], 16)
        };
        // handle #ccc
        result = /^#?([a-f\d]{1})([a-f\d]{1})([a-f\d]{1})$/i.exec(hex);
        if (result) return {
            r: parseInt(result[1]+'0', 16),
            g: parseInt(result[2]+'0', 16),
            b: parseInt(result[3]+'0', 16)
        };
    },

    isDark: function(color) {
        if (color.endsWith('.lut')) {
            return false;
        }
        var c = this.hexToRgb(color);
        var min, max, delta;
        var v, s, h;
        min = Math.min(c.r, c.g, c.b);
        max = Math.max(c.r, c.g, c.b);
        v = max;
        delta = max-min;
        if (max !== 0) {
            s = delta/max;
        }
        else {
            v = 0;
            s = 0;
            h = 0;
        }
        if (delta === 0) {
            h = 0;
        } else if (c.r==max) {
            h = (c.g-c.b)/delta;
        } else if (c.g == max) {
            h = 2 + (c.b-c.r)/delta;
        } else {
            h = 4 +(c.r-c.g)/delta;
        }
        h = h * 60;
        if (h < 0) {
            h += 360;
        }
        h = h/360;
        v = v/255;
        return (v < 0.6 || (h > 0.6 && s > 0.7));
    },

    toggle_channel: function(e) {
        var idx = e.currentTarget.getAttribute('data-index');

        if (this.model) {
            this.model.toggle_channel(idx);
        } else if (this.models) {
            // 'flat' means that some panels have this channel on, some off
            var flat = $('div', e.currentTarget).hasClass('ch-btn-flat');
            this.models.forEach(function(m){
                if(flat) {
                    m.toggle_channel(idx, true);
                } else {
                    m.toggle_channel(idx);
                }
            });
        }
        return false;
    },

    handle_channel_input: function(event) {
        if (event.type === "keyup" && event.which !== 13) {
            return;     // Ignore keyups except 'Enter'
        }
        var idx = event.target.getAttribute('data-idx'),
            startEnd = event.target.getAttribute('data-window');  // 'start' or 'end'
        idx = parseInt(idx, 10);
        var value = parseInt(event.target.value, 10);
        if (isNaN(value)) return;
        // Make sure 'start' < 'end' value
        if (event.target.getAttribute('max') && value > event.target.getAttribute('max')){
            alert("Enter a value less than " + event.target.getAttribute('max'));
            return;
        }
        if (event.target.getAttribute('min') && value < event.target.getAttribute('min')){
            alert("Enter a value greater than " + event.target.getAttribute('min'))
            return;
        }
        var newCh = {};
        newCh[startEnd] = value;
        this.models.forEach(function(m) {
            m.save_channel_window(idx, newCh);
        });
    },

    clear: function() {
        $(".ch_slider").slider("destroy");
        $("#channel_sliders").empty();
        return this;
    },

    render: function() {
        var json,
            self = this;

            // Helper functions for map & reduce below
            var addFn = function (prev, s) {
                return prev + s;
            };
            var getColor = function(idx) {
                return function(ch) {
                    return ch[idx].color;
                }
            }
            var getLabel = function(idx) {
                return function(ch) {
                    return ch[idx].label;
                }
            }
            var getReverse = function(idx) {
                return function(ch) {
                    // For older figures (created pre 5.3.0) might be undefined
                    return ch[idx].reverseIntensity === true;
                }
            }
            var getActive = function(idx) {
                return function(ch) {
                    return ch[idx].active === true;
                }
            }
            var windowFn = function (idx, attr) {
                return function (ch) {
                    return ch[idx].window[attr];
                }
            };
            var allEqualFn = function(prev, value) {
                return value === prev ? prev : undefined;
            };
            var reduceFn = function(fn) {
                return function(prev, curr) {
                    return fn(prev, curr);
                }
            }

            // Comare channels from each Panel Model to see if they are
            // compatible, and compile a summary json.
            var chData = this.models.map(function(m){
                return m.get('channels');
            });
            // images are compatible if all images have same channel count
            var allSameCount = chData.reduce(function(prev, channels){
                return channels.length === prev ? prev : false;
            }, chData[0].length);

            if (!allSameCount) {
                return this;
            }
            $(".ch_slider").slider("destroy");
            this.$el.empty();

            chData[0].forEach(function(d, chIdx) {
                // For each channel, summarise all selected images:
                // Make list of various channel attributes:
                var starts = chData.map(windowFn(chIdx, 'start'));
                var ends = chData.map(windowFn(chIdx, 'end'));
                var mins = chData.map(windowFn(chIdx, 'min'));
                var maxs = chData.map(windowFn(chIdx, 'max'));
                var colors = chData.map(getColor(chIdx));
                var reverses = chData.map(getReverse(chIdx));
                var actives = chData.map(getActive(chIdx));
                var labels = chData.map(getLabel(chIdx));
                // Reduce lists into summary for this channel
                var startAvg = parseInt(starts.reduce(addFn, 0) / starts.length, 10);
                var endAvg = parseInt(ends.reduce(addFn, 0) / ends.length, 10);
                var startsNotEqual = starts.reduce(allEqualFn, starts[0]) === undefined;
                var endsNotEqual = ends.reduce(allEqualFn, ends[0]) === undefined;
                var min = mins.reduce(reduceFn(Math.min));
                var max = maxs.reduce(reduceFn(Math.max));
                var color = colors.reduce(allEqualFn, colors[0]) ? colors[0] : 'ccc';
                // allEqualFn for booleans will return undefined if not or equal
                var label = labels.reduce(allEqualFn, labels[0]) ? labels[0] : ' ';
                var reverse = reverses.reduce(allEqualFn, reverses[0]) ? true : false;
                var active = actives.reduce(allEqualFn, actives[0]);
                var style = {'background-position': '0 0'}
                var sliderClass = '';
                var lutBgPos = FigureLutPicker.getLutBackgroundPosition(color);
                if (color.endsWith('.lut')) {
                    style['background-position'] = lutBgPos;
                    sliderClass = 'lutBg';
                } else if (color.toUpperCase() === "FFFFFF") {
                    color = "ccc";  // white slider would be invisible
                }
                if (reverse) {
                    style.transform = 'scaleX(-1)';
                }
                if (color == "FFFFFF") color = "ccc";  // white slider would be invisible

                // Make sure slider range is increased if needed to include current values
                min = Math.min(min, startAvg);
                max = Math.max(max, endAvg);

                var sliderHtml = self.template({'idx': chIdx,
                                                'label': label,
                                                'startAvg': startAvg,
                                                'startsNotEqual': startsNotEqual,
                                                'endAvg': endAvg,
                                                'endsNotEqual': endsNotEqual,
                                                'active': active,
                                                'lutBgPos': lutBgPos,
                                                'reverse': reverse,
                                                'color': color,
                                                'isDark': this.isDark(color)});
                var $div = $(sliderHtml).appendTo(this.$el);

                $div.find('.ch_slider').slider({
                    range: true,
                    min: min,
                    max: max,
                    values: [startAvg, endAvg],
                    slide: function(event, ui) {
                        $('.ch_start input', $div).val(ui.values[0]);
                        $('.ch_end input', $div).val(ui.values[1]);
                    },
                    stop: function(event, ui) {
                        self.models.forEach(function(m) {
                            m.save_channel_window(chIdx, {'start': ui.values[0], 'end': ui.values[1]});
                        });
                    }
                })
                // Need to add background style to newly created div.ui-slider-range
                .children('.ui-slider-range').css(style)
                .addClass(sliderClass);

            }.bind(this));
        return this;
    }
});


var ChgrpModalView = Backbone.View.extend({

    el: $("#chgrpModal"),

    model: FigureModel,

    imagesByGroup: {},
    omeroGroups: [],

    initialize: function () {

        var self = this;

        // Here we handle init of the dialog when it's shown...
        $("#chgrpModal").bind("show.bs.modal", function () {
            this.enableSubmit(false);
            if (!this.model.get('fileId')) {
                $('#chgrpModal .modal-body').html("<p>Figure not saved. Please Save the Figure first.</p>");
            } else {
                $('#chgrpModal .modal-body').html("");
                self.loadGroups();
                self.loadImageDetails();
            }
        }.bind(this));
    },

    events: {
        "click .chgrpForm input": "inputClicked",
        "submit .chgrpForm": "handleSubmit"
    },

    loadImageDetails: function() {
        var imgIds = this.model.panels.pluck('imageId');
        var url = `${BASE_WEBFIGURE_URL}images_details/?image=${_.uniq(imgIds).join(',')}`;
        $.getJSON(url, function (data) {
            // Sort images by Group
            this.imagesByGroup = data.data.reduce(function(prev, img){
                if (!prev[img.group.id]) {
                    prev[img.group.id] = [];
                }
                prev[img.group.id].push(img);
                return prev;
            }, {});
            this.render();
        }.bind(this));
    },

    loadGroups: function() {
        var url = `${API_BASE_URL_V0}m/experimenters/${USER_ID}/experimentergroups/`;
        $.getJSON(url, function(data){
            this.omeroGroups = data.data.map(group => {return {id: group['@id'], name: group.Name}})
                .filter(group => group.name != 'user');
            this.render();
        }.bind(this));
    },

    inputClicked: function() {
        this.enableSubmit(true);
    },

    // we disable Submit when dialog is shown, enable when Group chosen
    enableSubmit: function (enabled) {
        var $okBtn = $('button[type="submit"]', this.$el);
        if (enabled) {
            $okBtn.prop('disabled', false);
            $okBtn.prop('title', 'Move to Group');
        } else {
            $okBtn.prop('disabled', 'disabled');
            $okBtn.prop('title', 'No Group selected');
        }
    },

    handleSubmit: function (event) {
        event.preventDefault();
        var group_id = parseInt($('input[name="target_group"]:checked', this.$el).val());
        var group_name = this.omeroGroups.filter(group => group.id === group_id)[0].name;
        var fileId = this.model.get('fileId');
        var url = BASE_WEBFIGURE_URL + 'chgrp/';
        this.enableSubmit(false);
        setTimeout(function(){
            $('#chgrpModal .modal-body').append("<p>Moving to Group: " + _.escape(group_name) + "...</p>");
        }, 1000);
        $.post(url, { group_id: group_id, ann_id: fileId})
            .done(function (data) {
                $("#chgrpModal").modal('hide');
                if (data.success) {
                    this.model.set({groupId: group_id});
                    figureConfirmDialog("Success", "Figure moved to Group: " + _.escape(group_name), ["OK"]);
                } else {
                    var errorMsg = data.error || "No error message available";
                    figureConfirmDialog("Move Failed", errorMsg, ["OK"]);
                }
            }.bind(this));
    },

    render: function() {
        var html = '';
        var groupId = this.model.get('groupId');
        var currentGroup = this.omeroGroups.filter(group => group.id === groupId)[0];
        if (currentGroup) {
            html += '<p>This figure is currently in Group: <strong>' + _.escape(currentGroup.name) + '</strong>.</p>';
        }

        var groupCount = Object.keys(this.imagesByGroup).length;
        html += `<p>Images in this figure belong to ${groupCount} Group${groupCount == 1 ? '' : 's'}: `
        html += Object.keys(this.imagesByGroup).map(groupId => {
            var imgIds = this.imagesByGroup[groupId].map(i => i.id);
            var groupName = this.imagesByGroup[groupId][0].group.name;
            return `
                <b>${_.escape(groupName)}</b>
                (<a target="_blank" href="${WEBINDEX_URL}?show=image-${imgIds.join('|image-')}">${imgIds.length} image${imgIds.length == 1 ? '' : 's'}</a>)`
            }
        ).join(", ") + '.</p>';
        html += `<p><b>NB:</b> If a figure contains images from a <i>different</i> group, it is possible that some
            users may be able to open the figure but not see those images in it.</p>`

        var targetGroups = this.omeroGroups.filter(group => group.id != groupId);
        if (targetGroups.length === 0) {
            html += "<p>No other Groups available (You are not a member of any other groups)</p>";
        } else {
            html += "<p>Move to Group...</p>";
            html += targetGroups.map(group => `<div class="radio">
                <label><input type="radio" name="target_group" value="${group.id}">
                    ${_.escape(group.name)}
                </label></div>`)
                .join("\n");
        }
        $('.modal-body', this.$el).html(html);
    }
});


//
// Copyright (C) 2015 University of Dundee & Open Microscopy Environment.
// All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//


// Should only ever have a singleton on this
var ColorPickerView = Backbone.View.extend({

    el: $("#colorpickerModal"),

    // remember picked colors, for picking again
    pickedColors: [],

    initialize:function () {
        
        var sliders = {
            saturation: {
                maxLeft: 200,
                maxTop: 200,
                callLeft: 'setSaturation',
                callTop: 'setBrightness'
            },
            hue: {
                maxLeft: 0,
                maxTop: 200,
                callLeft: false,
                callTop: 'setHue'
            },
            alpha: {
                maxLeft: 0,
                maxTop: 200,
                callLeft: false,
                callTop: 'setAlpha'
            }
        };

        var self = this,
            editingRGB = false;     // flag to prevent update of r,g,b fields

        this.$submit_btn = $("#colorpickerModal .modal-footer button[type='submit']");

        var $cp = $('.demo-auto').colorpicker({
            'sliders': sliders,
            'color': '00ff00',
        });

        // Access the colorpicker object for use below...
        var cp = $cp.data('colorpicker');


        $cp.on('changeColor', function(event){

            // In edge-case of starting with 'black', clicking on Hue slider,
            // default is to stay 'black', but we want to pick the color
            // by setting saturation and brightness.
            var c = event.color;
            if ((c.toHex() === "#000000" || c.toHex() === "#ffffff") &&
                    cp.currentSlider && cp.currentSlider.callTop === "setHue") {
                cp.color.setSaturation(1);
                cp.color.setBrightness(0);
                cp.update(true);
                cp.element.trigger({
                    type: 'changeColor',
                    color: cp.color
                });
                // so we don't do this again until next click
                cp.currentSlider = undefined;
                return;
            }

            // enable form submission & show color
            self.$submit_btn.prop('disabled', false);
            $('.oldNewColors li:first-child').css('background-color', event.color.toHex());

            // update red, green, blue inputs
            if (!editingRGB) {
                var rgb = event.color.toRGB();
                $(".rgb-group input[name='red']").val(rgb.r);
                $(".rgb-group input[name='green']").val(rgb.g);
                $(".rgb-group input[name='blue']").val(rgb.b);
            }
        });

        $(".rgb-group input").bind("change keyup", function(){
            var $this = $(this),
                value = $.trim($this.val());
            // check it's a number between 0 - 255
            if (value == parseInt(value, 10)) {
                value = parseInt(value, 10);
                if (value < 0) {
                    value = 0;
                    $this.val(value);
                }
                else if (value > 255) {
                    value = 255;
                    $this.val(value);
                }
            } else {
                value = 255
                $this.val(value);
            }

            // update colorpicker
            var r = $(".rgb-group input[name='red']").val(),
                g = $(".rgb-group input[name='green']").val(),
                b = $(".rgb-group input[name='blue']").val(),
                rgb = "rgb(" + r + "," + g + "," + b + ")";

            // flag prevents update of r, g, b fields while typing
            editingRGB = true;
            $('.demo-auto').colorpicker('setValue', rgb);
            editingRGB = false;
        });
    },

    
    events: {
        "submit .colorpickerForm": "handleColorpicker",
        "click .pickedColors button": "pickRecentColor",
    },

    // 'Recent colors' buttons have color as their title
    pickRecentColor: function(event) {
        var color = $(event.target).prop('title');
        $('.demo-auto').colorpicker('setValue', color);
    },

    // submit of the form: call the callback and close dialog
    handleColorpicker: function(event) {
        event.preventDefault();

        // var color = $(".colorpickerForm input[name='color']").val();
        var color = $('.demo-auto').colorpicker('getValue');

        // very basic validation (in case user has edited color field manually)
        if (color.length === 0) return;
        if (color[0] != "#") {
            color = "#" + color;
        }
        // E.g. must be #f00 or #ff0000
        if (color.length != 7 && color.length != 4) return;

        // remember for later
        this.pickedColors.push(color);

        if (this.success) {
            this.success(color);
        }

        $("#colorpickerModal").modal('hide');
        return false;
    },

    show: function(options) {

        $("#colorpickerModal").modal('show');

        if (options.color) {
            $('.demo-auto').colorpicker('setValue', options.color);

            // compare old and new colors - init with old color
            $('.oldNewColors li').css('background-color', "#" + options.color);

            // disable submit button until color is chosen
            this.$submit_btn.prop('disabled', 'disabled');
        }

        if (options.pickedColors) {
            this.pickedColors = _.uniq(this.pickedColors.concat(options.pickedColors));
        }

        // save callback to use on submit
        if (options.success) {
            this.success = options.success;
        }

        this.render();
    },

    render:function () {
        
        // this is a list of strings
        var json = {'colors': _.uniq(this.pickedColors)};

        var t = '' +
            '<div class="btn-group">' +
            '<% _.each(colors, function(c, i) { %>' +
                '<button type="button" class="btn btn-default" ' +
                    'title="<%= c %>"' +
                    'style="background-color: <%= c %>"> </button>' +
            '<% if ((i+1)%4 == 0){ %> </div><div class="btn-group"><% } %>' +
            '<% }); %>' +
            '</div>';

        var compiled = _.template(t);
        var html = compiled(json);

        $("#pickedColors").html(html);
    }
});



var CropModalView = Backbone.View.extend({

        el: $("#cropModal"),

        roiTemplate: JST["src/templates/modal_dialogs/crop_modal_roi.html"],

        model: FigureModel,

        roisPageSize: 200,
        roisPage: 0,
        roisCount: 0,
        // not all these ROIs will contain Rects
        roisLoaded: 0,
        // Rectangles from ROIs
        roiRects: [],

        initialize: function() {

            var self = this;

            // Here we handle init of the dialog when it's shown...
            $("#cropModal").bind("show.bs.modal", function(){
                // Clone the 'first' selected panel as our reference for everything
                self.m = self.model.getSelected().head().clone();
                self.listenTo(self.m, 'change:theZ change:theT', self.render);

                self.cropModel.set({'selected': false, 'width': 0, 'height': 0});

                // get selected area...
                var roi = self.m.getViewportAsRect();
                self.applyRotation(roi);

                // Show as ROI *if* it isn't the whole image
                if (roi.x !== 0 || roi.y !== 0
                        || roi.width !== self.m.get('orig_width')
                        || roi.height !== self.m.get('orig_height')) {
                    self.currentROI = roi;
                    self.cropModel.set({
                        'selected': true
                    });
                }

                // ...now zoom out and centre to render whole image
                self.m.set({'zoom': 100, 'dx': 0, 'dy': 0});

                self.zoomToFit();   // includes render()
                // disable submit until user chooses a region/ROI
                self.enableSubmit(false);

                // Reset ROIs from OMERO...
                self.roiRects = [];
                self.roisLoaded = 0;
                self.roisPage = 0;
                self.loadRoiRects();
                // ...along with ROIs from clipboard or on this image in the figure
                self.showClipboardFigureRois();
            });

            // keep track of currently selected ROI
            this.currentROI = {'x':0, 'y': 0, 'width': 0, 'height': 0}

            // used by model underlying Rect.
            // NB: values in cropModel are scaled by zoom percent
            this.cropModel = new Backbone.Model({
                'x':0, 'y': 0, 'width': 0, 'height': 0,
                'selected': false});
            // since resizes & drags don't actually update cropModel automatically, we do it...
            this.cropModel.bind('drag_resize_stop', function(args) {
                this.set({'x': args[0], 'y': args[1], 'width': args[2], 'height': args[3]});
            });
            this.cropModel.bind('drag_xy_stop', function(args) {
                this.set({'x': args[0] + this.get('x'), 'y': args[1] + this.get('y')});
            });

            // we also need to update the scaled ROI coords...
            this.listenTo(this.cropModel, 'change:x change:y change:width change:height', function(m){
                var scale = self.zoom / 100;
                self.currentROI = {
                    'x': m.get('x') / scale,
                    'y': m.get('y') / scale,
                    'width': m.get('width') / scale,
                    'height': m.get('height') / scale
                }
                // No-longer correspond to saved ROI coords
                self.currentRoiId = undefined;
                // Allow submit of dialog if valid ROI
                if (self.regionValid(self.currentROI)) {
                    self.enableSubmit(true);
                } else {
                    self.enableSubmit(false);
                }
            });

            // Now set up Raphael paper...
            this.paper = Raphael("crop_paper", 500, 500);
            this.rect = new RectView({'model':this.cropModel, 'paper': this.paper});
            this.$cropImg = $('.crop_image', this.$el);
        },

        events: {
            "click .roiPickMe": "roiPicked",
            "click .loadRoiRects": "loadRoiRects",
            "mousedown svg": "mousedown",
            "mousemove svg": "mousemove",
            "mouseup svg": "mouseup",
            "submit .cropModalForm": "handleRoiForm"
        },

        // we disable Submit when dialog is shown, enable when region/ROI chosen
        enableSubmit: function(enabled) {
            var $okBtn = $('button[type="submit"]', this.$el);
            if (enabled) {
                $okBtn.prop('disabled', false);
                $okBtn.prop('title', 'Crop selected images to chosen region');
            } else {
                $okBtn.prop('disabled', 'disabled');
                $okBtn.prop('title', 'No valid region selected');
            }
        },

        // Region is only valid if it has width & height > 1 and
        // is at least partially overlapping with the image
        regionValid: function(roi) {

            if (roi.width < 2 || roi.height < 2) return false;
            if (roi.x > this.m.get('orig_width')) return false;
            if (roi.y > this.m.get('orig_height')) return false;
            if (roi.x + roi.width < 0) return false;
            if (roi.y + roi.height < 0) return false;
            return true;
        },

        roiPicked: function(event) {

            var $target = $(event.target),
                $tr = $target.parent();
            // $tr might be first <td> if img clicked or <tr> if td clicked
            // but in either case it will contain the img we need.
            var $roi = $tr.find('img.roi_content'),
                x = parseInt($roi.attr('data-x'), 10),
                y = parseInt($roi.attr('data-y'), 10),
                width = parseInt($roi.attr('data-width'), 10),
                height = parseInt($roi.attr('data-height'), 10),
                rotation = $roi.attr('data-rotation') || 0,
                theT = parseInt($roi.attr('data-theT'), 10),
                theZ = parseInt($roi.attr('data-theZ'), 10);

            // Rectangle ROIs have NO rotation. Copy of crop might have rotation
            rotation = parseInt(rotation);
            this.m.set('rotation', rotation);

            this.m.set({'theT': theT, 'theZ': theZ});

            this.currentROI = {
                'x':x, 'y':y, 'width':width, 'height':height
            }
            // Update coords based on any rotation (if coords come from rotated crop region)
            this.applyRotation(this.currentROI, 1, rotation);

            this.render();

            this.cropModel.set({
                'selected': true
            });

            // Save ROI ID
            this.currentRoiId = $roi.attr('data-roiId');
        },

        applyRotation: function(rect, factor=1, rotation) {
            // Update the x and y coordinates of a Rectangle ROI to take account of rotation of the
            // underlying image around it's centre point. The image is rotated on the canvas, so any
            // Rectangle not at the centre will need to be rotated around the centre, updating rect.x and rect.y.
            if (rotation === undefined) {
                rotation = this.m.get('rotation');
            }
            if (rotation != 0) {
                var img_cx = this.m.get('orig_width') / 2;
                var img_cy = this.m.get('orig_height') / 2;
                var rect_cx = rect.x + (rect.width / 2);
                var rect_cy = rect.y + (rect.height / 2);
                var new_c = rotatePoint(rect_cx, rect_cy, img_cx, img_cy, rotation * factor);
                rect.x = new_c.x - (rect.width / 2);
                rect.y = new_c.y - (rect.height / 2);
            }
        },

        handleRoiForm: function(event) {
            event.preventDefault();
            // var json = this.processForm();
            var self = this,
                r = this.currentROI,
                sel = this.model.getSelected(),
                sameT = sel.allEqual('theT');
                // sameZT = sel.allEqual('theT') && sel.allEqual('theT');

            var getShape = function getShape(z, t) {

                // If all on one T-index, update to the current
                // T-index that we're looking at.
                if (sameT) {
                    t = self.m.get('theT');
                }

                self.applyRotation(r, -1);

                var rv = {'x': r.x,
                        'y': r.y,
                        'width': r.width,
                        'height': r.height,
                        'theZ': self.m.get('theZ'),
                        'theT': t,
                    }
                return rv;
            }

            // IF we have an ROI selected (instead of hand-drawn shape)
            // then try to use appropriate shape for that plane.
            if (this.currentRoiId) {

                getShape = function getShape(currZ, currT) {

                    var tzShapeMap = self.cachedRois[self.currentRoiId],
                        tkeys = _.keys(tzShapeMap).sort(),
                        zkeys, z, t, s;

                    if (tzShapeMap[currT]) {
                        t = currT;
                    } else {
                        t = tkeys[parseInt(tkeys.length/2 ,10)]
                    }
                    zkeys = _.keys(tzShapeMap[t]).sort();
                    if (tzShapeMap[t][currZ]) {
                        z = currZ;
                    } else {
                        z = zkeys[parseInt(zkeys.length/2, 10)]
                    }
                    s = tzShapeMap[t][z]

                    // if we have a range of T values, don't change T!
                    if (!sameT) {
                        t = currT;
                    }

                    return {'x': s.X,
                            'y': s.Y,
                            'width': s.Width,
                            'height': s.Height,
                            'theZ': z,
                            'theT': t,
                        }
                };
            }

            $("#cropModal").modal('hide');

            // prepare callback for below
            function cropAndClose(deleteROIs) {
                // Don't set Z/T if we already have different Z/T indecies.
                sel.each(function(m){
                    var sh = getShape(m.get('theZ'), m.get('theT')),
                        newZ = Math.min(parseInt(sh.theZ, 10), m.get('sizeZ') - 1),
                        newT = Math.min(parseInt(sh.theT, 10), m.get('sizeT') - 1);

                    m.cropToRoi({'x': sh.x, 'y': sh.y, 'width': sh.width, 'height': sh.height});
                    if (deleteROIs) {
                        m.unset('shapes');
                    }
                    // 'save' to trigger 'unsaved': true
                    m.save({ 'theZ': newZ, 'theT': newT, 'rotation': self.m.get('rotation')});
                });
            }

            // If we have ROIs on the image, ask if we want to delete them
            var haveROIs = false,
                plural = sel.length > 0 ? "s" : "";
            sel.each(function(p){
                if (p.get('shapes')) haveROIs = true;
            });
            if (haveROIs) {
                figureConfirmDialog("Delete ROIs?",
                                    "Delete ROIs on the image" + plural + " you are cropping?",
                                    ["Yes", "No", "Cancel"],
                                    function(btnText){
                                        if (btnText == "Cancel") return;
                                        if (btnText == "Yes") {
                                            cropAndClose(true);
                                        } else {
                                            cropAndClose();
                                        }
                                    }
                );
            } else {
                cropAndClose();
            }
        },

        mousedown: function(event) {
            this.dragging = true;
            var os = $(event.target).offset();
            this.clientX_start = event.clientX;
            this.clientY_start = event.clientY;
            this.imageX_start = this.clientX_start - os.left;
            this.imageY_start = this.clientY_start - os.top;
            this.cropModel.set({'x': this.imageX_start, 'y': this.imageY_start, 'width': 0, 'height': 0, 'selected': true})
            return false;
        },

        mouseup: function(event) {
            if (this.dragging) {
                this.dragging = false;
                return false;
            }
        },

        mousemove: function(event) {
            if (this.dragging) {
                var dx = event.clientX - this.clientX_start,
                    dy = event.clientY - this.clientY_start;
                if (event.shiftKey) {
                    // make region square!
                    if (Math.abs(dx) > Math.abs(dy)) {
                        if (dy > 0) dy = Math.abs(dx);
                        else dy = -1 * Math.abs(dx);
                    } else {
                        if (dx > 0) dx = Math.abs(dy);
                        else dx = -1 * Math.abs(dy);
                    }
                }
                var negX = Math.min(0, dx),
                    negY = Math.min(0, dy);
                this.cropModel.set({'x': this.imageX_start + negX,
                    'y': this.imageY_start + negY,
                    'width': Math.abs(dx), 'height': Math.abs(dy)});
                return false;
            }
        },

        showClipboardFigureRois: function() {
            // Show Rectangles from clipboard
            var clipboardRects = [],
                clipboard = this.model.get('clipboard');
            if (clipboard && clipboard.CROP) {
                roi = clipboard.CROP;
                clipboardRects.push({
                    x: roi.x, y: roi.y, width: roi.width, height: roi.height,
                    rotation: roi.rotation
                });
            } else if (clipboard && clipboard.SHAPES) {
                clipboard.SHAPES.forEach(function(roi){
                    if (roi.type === "Rectangle") {
                        clipboardRects.push({
                            x: roi.x, y: roi.y, width: roi.width, height: roi.height
                        });
                    }
                });
            }
            var msg = "No Regions copied to clipboard";
            this.renderRois(clipboardRects, ".roisFromClipboard", msg);

            // Show Rectangles from panels in figure
            var figureRois = [];
            var sel = this.model.getSelected();
            sel.forEach(function(panel) {
                var panelRois = panel.get('shapes');
                if (panelRois) {
                    panelRois.forEach(function(roi){
                        if (roi.type === "Rectangle") {
                            figureRois.push({
                                x: roi.x, y: roi.y, width: roi.width, height: roi.height
                            });
                        }
                    });
                }
            });
            msg = "No Rectangular ROIs on selected panel in figure";
            this.renderRois(figureRois, ".roisFromFigure", msg);
        },

        // Load Rectangles from OMERO and render them
        loadRoiRects: function(event) {
            if (event) {
                event.preventDefault();
            }
            var self = this,
                iid = self.m.get('imageId');
            var offset = this.roisPageSize * this.roisPage;
            var url = BASE_WEBFIGURE_URL + 'roiRectangles/' + iid + '/?limit=' + self.roisPageSize + '&offset=' + offset;
            $.getJSON(url, function(rsp){
                data = rsp.data;
                self.roisLoaded += data.length;
                self.roisPage += 1;
                self.roisCount = rsp.meta.totalCount;
                // get a representative Rect from each ROI.
                // Include a z and t index, trying to pick current z/t if ROI includes a shape there
                var currT = self.m.get('theT'),
                    currZ = self.m.get('theZ');
                var cachedRois = {},    // roiId: shapes (z/t dict)
                    roi, roiId, shape, theT, theZ, z, t, rect, tkeys, zkeys,
                    minT, maxT,
                    shapes; // dict of all shapes by z & t index

                for (var r=0; r<data.length; r++) {
                    roi = data[r];
                    roiId = roi['@id'];
                    shapes = {};
                    minT = undefined;
                    maxT = 0;
                    for (var s=0; s<roi.shapes.length; s++) {
                        shape = roi.shapes[s];
                        var type = shape['@type'].split('#')[1];
                        if (type !== "Rectangle") continue;
                        // Handle null Z/T
                        if (shape.TheZ === undefined) {
                            shape.TheZ = currZ;
                        }
                        theZ = shape.TheZ;
                        if (shape.TheT === undefined) {
                            shape.TheT = currT;
                        }
                        theT = shape.TheT;
                        // Keep track of min/max T for display
                        if (minT === undefined) {minT = theT}
                        else {minT = Math.min(minT, theT)}
                        maxT = Math.max(maxT, theT);

                        // Build our map of shapes[t][z]
                        if (shapes[theT] === undefined) {
                            shapes[theT] = {};
                        }
                        shapes[theT][theZ] = shape;
                    }
                    cachedRois[roiId] = shapes;
                    // get display shape for picking ROI
                    // on current plane or pick median T/Z...
                    tkeys = _.keys(shapes)
                            .map(function(x){return parseInt(x, 10)})
                            .sort(function(a, b){return a - b});    // sort numerically
                    if (tkeys.length === 0) continue;   // no Rectangles
                    if (shapes[currT]) {
                        t = currT;
                    } else {
                        t = tkeys[(tkeys.length/2)>>0]
                    }
                    zkeys = _.keys(shapes[t])
                            .map(function(x){return parseInt(x, 10)})
                            .sort(function(a, b){return a - b});    // sort numerically
                    if (shapes[t][currZ]) {
                        z = currZ;
                    } else {
                        z = zkeys[(zkeys.length/2)>>0]
                    }
                    shape = shapes[t][z]
                    self.roiRects.push({'theZ': shape.TheZ,
                                'theT': shape.TheT,
                                'x': shape.X,
                                'y': shape.Y,
                                'width': shape.Width,
                                'height': shape.Height,
                                'roiId': roiId,
                                'tStart': minT,
                                'tEnd': maxT,
                                'zStart': zkeys[0],
                                'zEnd': zkeys[zkeys.length-1]});
                }
                // Show ROIS from OMERO...
                var msg = "No rectangular ROIs found on this image in OMERO";
                self.renderRois(self.roiRects, ".roisFromOMERO", msg);

                if (self.roisLoaded < self.roisCount) {
                    // Show the 'Load' button if more are available
                    $(".loadRoiRects", this.$el).show();
                } else {
                    $(".loadRoiRects", this.$el).hide();
                }
                $("#cropRoiMessage").html(`Loaded ${self.roisLoaded} / ${self.roisCount} ROIs`);

                self.cachedRois = cachedRois;
            }).error(function(){
                var msg = "No rectangular ROIs found on this image in OMERO";
                self.renderRois([], ".roisFromOMERO", msg);
            });
        },

        renderRois: function(rects, target, msg) {

            var orig_width = this.m.get('orig_width'),
                orig_height = this.m.get('orig_height'),
                origT = this.m.get('theT'),
                origZ = this.m.get('theZ');

            var html = "",
                size = 50,
                rect, src, zoom,
                top, left, div_w, div_h, img_w, img_h;

            // loop through ROIs, using our cloned model to generate src urls
            // first, get the current Z and T of cloned model...
            this.m.set('z_projection', false);      // in case z_projection is true

            for (var r=0; r<rects.length; r++) {
                rect = rects[r];
                let rotation = rect.rotation || 0;
                if (rect.theT > -1) this.m.set('theT', rect.theT, {'silent': true});
                if (rect.theZ > -1) this.m.set('theZ', rect.theZ, {'silent': true});
                src = this.m.get_img_src(true);
                if (rect.width > rect.height) {
                    div_w = size;
                    div_h = (rect.height/rect.width) * div_w;
                } else {
                    div_h = size;
                    div_w = (rect.width/rect.height) * div_h;
                }
                zoom = div_w/rect.width;
                img_w = orig_width * zoom;
                img_h = orig_height * zoom;
                top = -(zoom * rect.y);
                left = -(zoom * rect.x);
                rect.theT = rect.theT !== undefined ? rect.theT : origT;
                rect.theZ = rect.theZ !== undefined ? rect.theZ : origZ;
                let css = this.m._viewport_css(left, top, img_w, img_h, size, size, rotation);

                var json = {
                    'msg': msg,
                    'src': src,
                    'rect': rect,
                    'w': div_w,
                    'h': div_h,
                    'css': css,
                    'top': top,
                    'left': left,
                    'img_w': img_w,
                    'img_h': img_h,
                    'theZ': rect.theZ + 1,
                    'theT': rect.theT + 1,
                    'roiId': rect.roiId,
                    'tStart': false,
                    'zStart': false,
                }
                // set start/end indices (1-based) if we have them
                if (rect.tStart !== undefined) {json.tStart = (+rect.tStart) + 1}
                if (rect.tEnd !== undefined) {json.tEnd = (+rect.tEnd) + 1}
                if (rect.zStart !== undefined) {json.zStart = (+rect.zStart) + 1}
                if (rect.zEnd !== undefined) {json.zEnd = (+rect.zEnd) + 1}
                html += this.roiTemplate(json);
            }
            if (html.length === 0) {
                html = "<tr><td colspan='3' style='color: #999'>" + msg + "</td></tr>";
            }
            $(target + " tbody", this.$el).html(html);

            // reset Z/T as before
            this.m.set({'theT': origT, 'theZ': origZ});
        },

        zoomToFit: function() {
            var max_w = 500,
                max_h = 450,
                w = this.m.get('orig_width'),
                h = this.m.get('orig_height');
                scale = Math.min(max_w/w, max_h/h);
            this.setZoom(scale * 100);
        },

        setZoom: function(percent) {
            this.zoom = percent;
            this.render();
        },

        render: function() {
            var scale = this.zoom / 100,
                w = this.m.get('orig_width'),
                h = this.m.get('orig_height');
            var newW = w * scale,
                newH = h * scale;
            this.m.set('zoom', 100);
            this.m.set('width', newW);
            this.m.set('height', newH);
            var src = this.m.get_img_src(true);
            var css = this.m.get_vp_full_plane_css(100, newW, newH);

            this.paper.setSize(newW, newH);
            $("#crop_paper").css({'height': newH, 'width': newW});
            $("#cropViewer").css({'height': newH, 'width': newW});

            this.$cropImg.css(css)
                    .attr('src', src);

            var roiX = this.currentROI.x * scale,
                roiY = this.currentROI.y * scale,
                roiW = this.currentROI.width * scale,
                roiH = this.currentROI.height * scale;
            this.cropModel.set({
                'x': roiX, 'y': roiY, 'width': roiW, 'height': roiH,
                'selected': true
            });
        }
    });


    // -------------------------- Backbone VIEWS -----------------------------------------


    // var SelectionView = Backbone.View.extend({
    var FigureView = Backbone.View.extend({

        el: $("#body"),

        initialize: function(opts) {

            // Delegate some responsibility to other views
            new AlignmentToolbarView({model: this.model});
            new AddImagesModalView({model: this.model, figureView: this});
            new SetIdModalView({model: this.model});
            new PaperSetupModalView({model: this.model});
            new CropModalView({model: this.model});
            new ChgrpModalView({ model: this.model });
            new RoiModalView({model: this.model});
            new DpiModalView({model: this.model});
            new LegendView({model: this.model});
            new LabelFromMapsModal({model: this.model});

            this.figureFiles = new FileList();
            new FileListView({model:this.figureFiles, figureModel: this.model});

            // set up various elements and we need repeatedly
            this.$main = $('main');
            this.$canvas = $("#canvas");
            this.$canvas_wrapper = $("#canvas_wrapper");
            this.$figure = $("#figure");
            this.$copyBtn = $(".copy");
            this.$pasteBtn = $(".paste");
            this.$saveBtn = $(".save_figure.btn");
            this.$saveOption = $("li.save_figure");
            this.$saveAsOption = $("li.save_as");
            this.$deleteOption = $("li.delete_figure");

            var self = this;

            // Render on changes to the model
            this.model.on('change:paper_width change:paper_height change:page_count', this.render, this);

            // If a panel is added...
            this.model.panels.on("add", this.addOne, this);

            // Don't leave the page with unsaved changes!
            window.onbeforeunload = function() {
                var canEdit = self.model.get('canEdit');
                if (self.model.get("unsaved")) {
                    return "Leave page with unsaved changes?";
                }
            };

            $("#zoom_slider").slider({
                max: 400,
                min: 10,
                value: 75,
                slide: function(event, ui) {
                    self.model.set('curr_zoom', ui.value);
                }
            });

            // respond to zoom changes
            this.listenTo(this.model, 'change:curr_zoom', this.renderZoom);
            this.listenTo(this.model, 'change:selection', this.renderSelectionChange);
            this.listenTo(this.model, 'change:unsaved', this.renderSaveBtn);
            this.listenTo(this.model, 'change:figureName', this.renderFigureName);

            // Full render if page_color changes (might need to update labels etc)
            this.listenTo(this.model, 'change:page_color', this.render);
            this.listenTo(this.model, 'change:page_color', this.renderPanels);

            this.listenTo(this.model, 'change:loading_count', this.renderLoadingSpinner);

            // refresh current UI
            this.renderZoom();

            // 'Auto-render' on init.
            this.render();
            this.renderSelectionChange();

        },

        events: {
            "click .export_pdf": "export_pdf",
            "click .export_options li": "export_options",
            "click .add_panel": "addPanel",
            "click .delete_panel": "deleteSelectedPanels",
            "click .copy": "copy_selected_panels",
            "click .paste": "paste_panels",
            "click .save_figure": "save_figure_event",
            "click .save_as": "save_as_event",
            "click .new_figure": "goto_newfigure",
            "click .open_figure": "open_figure",
            "click .export_json": "export_json",
            "click .import_json": "import_json",
            "click .delete_figure": "delete_figure",
            "click .chgrp_figure": "chgrp_figure",
            "click .local_storage": "local_storage",
            "click .paper_setup": "paper_setup",
            "click .export-options a": "select_export_option",
            "click .zoom-paper-to-fit": "zoom_paper_to_fit",
            "click .about_figure": "show_about_dialog",
            "click .figure-title": "start_editing_name",
            "keyup .figure-title input": "figuretitle_keyup",
            "blur .figure-title input": "stop_editing_name",
            "submit .importJsonForm": "import_json_form"
        },

        keyboardEvents: {
            'backspace': 'deleteSelectedPanels',
            'del': 'deleteSelectedPanels',
            'mod+a': 'select_all',
            'mod+c': 'copy_selected_panels',
            'mod+v': 'paste_panels',
            'mod+s': 'save_figure_event',
            'mod+n': 'goto_newfigure',
            'mod+o': 'open_figure',
            'down' : 'nudge_down',
            'up' : 'nudge_up',
            'left' : 'nudge_left',
            'right' : 'nudge_right',
        },

        // If any modal is visible, we want to ignore keyboard events above
        // All those methods should use this
        modal_visible: function() {
            return $("div.modal:visible").length > 0;
        },

        // choose an export option from the drop-down list
        export_options: function(event) {
            event.preventDefault();

            var $target = $(event.target);

            // Only show check mark on the selected item.
            $(".export_options .glyphicon-ok").css('visibility', 'hidden');
            $(".glyphicon-ok", $target).css('visibility', 'visible');

            // Update text of main export_pdf button.
            var txt = $target.attr('data-export-option');
            $('.export_pdf').text("Export " + txt).attr('data-export-option', txt);

            // Hide download button
            $("#pdf_download").hide();
        },

        paper_setup: function(event) {
            event.preventDefault();

            $("#paperSetupModal").modal();
        },

        show_about_dialog: function(event) {
            event.preventDefault();
            $("#aboutModal").modal();
        },

        // Editing name workflow...
        start_editing_name: function(event) {
            var $this = $(event.target);
            var name = $this.text();
            // escape any double-quotes
            name = name.replace(/"/g, '&quot;');
            $this.html('<input value="' + name + '"/>');
            $('input', $this).focus();
        },
        figuretitle_keyup: function(event) {
            // If user hit Enter, stop editing...
            if (event.which === 13) {
                event.preventDefault();
                this.stop_editing_name();
            }
        },
        stop_editing_name: function() {
            var $this = $(".figure-title input");
            var new_name = $this.val().trim();
            if (new_name.length === 0) {
                alert("Can't have empty name.")
                return;
            }
            $(".figure-title").html(_.escape(new_name));
            // Save name... will renderFigureName only if name changed
            this.model.save('figureName', new_name);

            // clear file list (will be re-fetched when needed)
            this.figureFiles.reset();
        },

        // Heavy lifting of PDF generation handled by OMERO.script...
        export_pdf: function(event){

            event.preventDefault();

            // Status is indicated by showing / hiding 3 buttons
            var figureModel = this.model,
                $create_figure_pdf = $(event.target),
                export_opt = $create_figure_pdf.attr('data-export-option'),
                $pdf_inprogress = $("#pdf_inprogress"),
                $pdf_download = $("#pdf_download"),
                $script_error = $("#script_error"),
                exportOption = "PDF";
            $create_figure_pdf.hide();
            $pdf_download.hide();
            $script_error.hide();
            $pdf_inprogress.show();

            // Map from HTML to script options
            opts = {"PDF": "PDF",
                "PDF & images": "PDF_IMAGES",
                "TIFF": "TIFF",
                "TIFF & images": "TIFF_IMAGES",
                "to OMERO": "OMERO"};
            exportOption = opts[export_opt];

            // Get figure as json
            var figureJSON = this.model.figure_toJSON();

            var url = MAKE_WEBFIGURE_URL,
                data = {
                    figureJSON: JSON.stringify(figureJSON),
                    exportOption: exportOption,
                };

            // Start the Figure_To_Pdf.py script
            $.post( url, data).done(function( data ) {

                // {"status": "in progress", "jobId": "ProcessCallback/64be7a9e-2abb-4a48-9c5e-6d0938e1a3e2 -t:tcp -h 192.168.1.64 -p 64592"}
                var jobId = data.jobId;

                // E.g. Handle 'No Processor Available';
                if (!jobId) {
                    if (data.error) {
                        alert(data.error);
                    } else {
                        alert("Error exporting figure");
                    }
                    $create_figure_pdf.show();
                    $pdf_inprogress.hide();
                    return;
                }

                // Now we keep polling for script completion, every second...

                var i = setInterval(function (){

                    $.getJSON(ACTIVITIES_JSON_URL, function(act_data) {

                            var pdf_job = act_data[jobId];

                            // We're waiting for this flag...
                            if (pdf_job.status == "finished") {
                                clearInterval(i);

                                $create_figure_pdf.show();
                                $pdf_inprogress.hide();

                                // Show result
                                if (pdf_job.results.New_Figure) {
                                    var fa_id = pdf_job.results.New_Figure.id;
                                    if (pdf_job.results.New_Figure.type === "FileAnnotation") {
                                        var fa_download = WEBINDEX_URL + "annotation/" + fa_id + "/";
                                        $pdf_download
                                            .attr({'href': fa_download, 'data-original-title': 'Download Figure'})
                                            .show()
                                            .children('span').prop('class', 'glyphicon glyphicon-download-alt');
                                    } else if (pdf_job.results.New_Figure.type === "Image") {
                                        var fa_download = pdf_job.results.New_Figure.browse_url;
                                        $pdf_download
                                            .attr({'href': fa_download, 'data-original-title': 'Go to Figure Image'})
                                            .show()
                                            .tooltip()
                                            .children('span').prop('class', 'glyphicon glyphicon-share');
                                    }
                                } else if (pdf_job.stderr) {
                                    // Only show any errors if NO result
                                    var stderr_url = WEBINDEX_URL + "get_original_file/" + pdf_job.stderr + "/";
                                    $script_error.attr('href', stderr_url).show();
                                }
                            }

                            if (act_data.inprogress === 0) {
                                clearInterval(i);
                            }

                        }).error(function() {
                            clearInterval(i);
                        });

                }, 1000);
            });
        },

        select_export_option: function(event) {
            event.preventDefault();
            var $a = $(event.target),
                $span = $a.children('span.glyphicon');
            // We take the <span> from the <a> and place it in the <button>
            if ($span.length === 0) $span = $a;  // in case we clicked on <span>
            var $li = $span.parent().parent(),
                $button = $li.parent().prev().prev(),
                option = $span.attr("data-option");
            var $flag = $button.find("span[data-option='" + option + "']");
            if ($flag.length > 0) {
                $flag.remove();
            } else {
                $span = $span.clone();
                $button.append($span);
            }
            $button.trigger('change');      // can listen for this if we want to 'submit' etc
        },

        nudge_right: function(event) {
            event.preventDefault();
            if (this.modal_visible()) return true;
            this.model.nudge_right();
        },

        nudge_left: function(event) {
            event.preventDefault();
            if (this.modal_visible()) return true;
            this.model.nudge_left();
        },

        nudge_down: function(event) {
            event.preventDefault();
            if (this.modal_visible()) return true;
            this.model.nudge_down();
        },

        nudge_up: function(event) {
            event.preventDefault();
            if (this.modal_visible()) return true;
            this.model.nudge_up();
        },

        local_storage: function (event) {
            var buttons = ['Close'];
            let figureObject = recoverFigureFromStorage();
            var message = `<p>Any figure that fails to Save is stored in the browser's local storage.</p>`;
            if (figureObject) {
                buttons = buttons.concat(['Clear Storage', 'Recover Figure']);
                message += `<p>You can Clear local storage or Recover the figure from local storage with the options below:</p>`;
            } else {
                message += `<p>No Figure currently found.</p>`;
            }
            var callback = function (btnText) {
                if (btnText === "Clear Storage") {
                    clearFigureFromStorage();
                } else if (btnText === "Recover Figure") {
                    window.location = BASE_WEBFIGURE_URL + 'recover/';
                }
            }
            figureConfirmDialog("Local Storage", message, buttons, callback);
        },

        goto_newfigure: function(event) {
            if (event) event.preventDefault();
            $(".modal").modal('hide');

            var self = this;
            var callback = function() {
                self.model.clearFigure();
                $('#addImagesModal').modal();
                // navigate will be ignored if we're already on /new
                app.navigate("new/", {trigger: true});
            };

            if (this.model.get("unsaved")) {
                var saveBtnTxt = "Save",
                    canEdit = this.model.get('canEdit');
                if (!canEdit) saveBtnTxt = "Save a Copy";

                figureConfirmDialog("Save Changes to Figure?",
                    "Your changes will be lost if you don't save them",
                    ["Cancel", "Don't Save", saveBtnTxt],
                    function(btnTxt){
                        if (btnTxt === saveBtnTxt) {
                            self.save_figure({success: callback});
                        } else if (btnTxt === "Don't Save") {
                            callback();
                        }
                    });
            } else {
                callback();
            }
        },

        delete_figure: function(event) {
            event.preventDefault();
            var fileId = this.model.get('fileId'),
                figName = this.model.get('figureName');
            if(fileId) {
                this.model.set("unsaved", false);   // prevent "Save?" dialog
                this.figureFiles.deleteFile(fileId, figName);
            }
        },

        chgrp_figure: function (event) {
            event.preventDefault();
            $(".modal").modal('hide');
            $("#chgrpModal").modal('show');
        },

        open_figure: function(event) {
            event.preventDefault();
            $(".modal").modal('hide');

            var self = this,
                currentFileId = self.model.get('fileId');
            var callback = function() {
                // Opening modal will trigger fetch of files
                // Handled in FileListView
                $("#openFigureModal").modal();
            };

            if (this.model.get("unsaved")) {
                var saveBtnTxt = "Save",
                    canEdit = this.model.get('canEdit');
                if (!canEdit) saveBtnTxt = "Save a Copy";

                figureConfirmDialog("Save Changes to Figure?",
                    "Your changes will be lost if you don't save them",
                    ["Cancel", "Don't Save", saveBtnTxt],
                    function(btnTxt){
                        if (btnTxt === saveBtnTxt) {
                            // List files after saving current file
                            self.save_figure({success: callback});
                        } else if (btnTxt === "Don't Save") {
                            self.model.set("unsaved", false);
                            callback();
                        }
                    });
            } else {
                callback();
            }
        },

        save_figure_event: function(event) {
            if (event) {
                event.preventDefault();
            }
            this.$saveBtn.tooltip('hide');
            this.save_figure();
        },

        save_figure: function(options) {
            options = options || {};

            var fileId = this.model.get('fileId'),
                canEdit = this.model.get('canEdit');
            if (fileId && canEdit) {
                // Prevent double-click
                this.$saveBtn.attr('disabled', 'disabled');
                // Save
                options.fileId = fileId;
                this.model.save_to_OMERO(options);
            } else {
                this.save_as(options);
            }

        },

        save_as_event: function(event) {
            if (event) {
                event.preventDefault();
            }
            this.save_as();
        },

        save_as: function(options) {

            // clear file list (will be re-fetched when needed)
            this.figureFiles.reset();

            var self = this;
            options = options || {};
            var defaultName = this.model.get('figureName');
            if (!defaultName) {
                defaultName = this.model.getDefaultFigureName();
            }
            var figureName = prompt("Enter Figure Name", defaultName);

            var nav = function(data){
                app.navigate("file/"+data);
                // in case you've Saved a copy of a file you can't edit
                self.model.set('canEdit', true);
            };
            if (figureName) {
                options.figureName = figureName;
                // On save, go to newly saved page, unless we have callback already
                options.success = options.success || nav;
                // Save
                this.model.save_to_OMERO(options);
            }

        },

        export_json: function(event) {
            event.preventDefault();
            showExportAsJsonModal(this.model.figure_toJSON());
        },
        
        import_json: function(event) {
            event.preventDefault();

            var showImport = function() {
              $('#importJsonModal').modal('show');
            };
            
            app.checkSaveAndClear(function() { showImport()} );
        },

        import_json_form: function(event) {
          event.preventDefault();
          
          var $form = $('.importJsonForm'),
              figureJSON = $('.form-control', $form).val();
          this.model.figure_fromJSON(figureJSON);
            
          $('#importJsonModal').modal('hide');
          $('#importJsonModal textarea').val('');
          this.render();
        },
        
        copy_selected_panels: function(event) {
            event.preventDefault();
            if (this.modal_visible()) return true;
            var s = this.model.getSelected();
            var cd = [];
            s.forEach(function(m) {
                var copy = m.toJSON();
                delete copy.id;
                cd.push(copy);
            });
            this.model.set('clipboard', {'PANELS': cd});
            this.$pasteBtn.removeClass("disabled");
        },

        paste_panels: function(event) {
            event.preventDefault();
            if (this.modal_visible()) return true;
            var clipboard_data = this.model.get('clipboard'),
                clipboard_panels;
            if (clipboard_data && 'PANELS' in clipboard_data){
                clipboard_panels = clipboard_data.PANELS;
            } else if (clipboard_data && 'SHAPES' in clipboard_data) {

                // If we've actually got SHAPES in the clipboard,
                // paste them onto each selected panel...
                clipboard_panels = clipboard_data.SHAPES;
                var sel = this.model.getSelected();
                var allOK = true;
                sel.forEach(function(p){
                    var ok = p.add_shapes(clipboard_panels);
                    if (!ok) {allOK = false;}
                });
                // If any shapes were outside viewport, show message
                var plural = sel.length > 1 ? "s" : "";
                if (!allOK) {
                    figureConfirmDialog("Paste Failure",
                        "Some shapes may be outside the visible 'viewport' of panel" + plural + ". " +
                        "Target image" + plural + " may be too small or zoomed in too much. " +
                        "Try zooming out before pasting again, or paste to a bigger image.",
                        ["OK"]);
                }
                // And we're done...
                return;
            } else {
                return;
            }

            var self = this;
            this.model.clearSelected();

            // first work out the bounding box of clipboard panels
            var top, left, bottom, right;
            _.each(clipboard_panels, function(m, i) {
                var t = m.y,
                    l = m.x,
                    b = t + m.height,
                    r = l + m.width;
                if (i === 0) {
                    top = t; left = l; bottom = b; right = r;
                } else {
                    top = Math.min(top, t);
                    left = Math.min(left, l);
                    bottom = Math.max(bottom, b);
                    right = Math.max(right, r);
                }
            });
            var height = bottom - top,
                width = right - left,
                offset_x = 0,
                offset_y = 0;

            // if pasting a 'row', paste below. Paste 'column' to right.
            if (width > height) {
                offset_y = height + height/20;  // add a spacer
            } else {
                offset_x = width + width/20;
            }

            // apply offset to clipboard data & paste
            // NB: we are modifying the list that is in the clipboard
            _.each(clipboard_panels, function(m) {
                m.x = m.x + offset_x;
                m.y = m.y + offset_y;
                self.model.panels.create(m);
            });
            // only pasted panels are selected - simply trigger...
            this.model.notifySelectionChange();
        },

        select_all: function(event) {
            event.preventDefault();
            if (this.modal_visible()) return true;
            this.model.select_all();
        },

        deleteSelectedPanels: function(event) {
            event.preventDefault();
            if (this.modal_visible()) return true;
            this.model.deleteSelected();
        },

        // User has zoomed the UI - work out new sizes etc...
        // We zoom the main content 'canvas' using css transform: scale()
        // But also need to resize the canvas_wrapper manually.
        renderZoom: function() {
            var curr_zoom = this.model.get('curr_zoom'),
                zoom = curr_zoom * 0.01,
                newWidth = parseInt(this.orig_width * zoom, 10),
                newHeight = parseInt(this.orig_height * zoom, 10),
                scale = "scale("+zoom+", "+zoom+")";

            // We want to stay centered on the same spot...
            var curr_centre = this.getCentre(true);

            // Scale canvas via css
            this.$canvas.css({"transform": scale, "-webkit-transform": scale, "-ms-transform": scale});

            // Scale canvas wrapper manually
            var canvas_w = this.model.get('canvas_width'),
                canvas_h = this.model.get('canvas_height');
            var scaled_w = canvas_w * zoom,
                scaled_h = canvas_h * zoom;
            this.$canvas_wrapper.css({'width':scaled_w+"px", 'height': scaled_h+"px"});
            // and offset the canvas to stay visible
            var margin_top = (scaled_h - canvas_h)/2,
                margin_left = (scaled_w - canvas_w)/2;
            this.$canvas.css({'top': margin_top+"px", "left": margin_left+"px"});

            // ...apply centre from before zooming
            if (curr_centre) {
                this.setCentre(curr_centre);
            }

            // Show zoom level in UI
            $("#zoom_input").val(curr_zoom);
        },

        // Centre the viewport on the middle of the paper
        reCentre: function() {
            var size = this.model.getFigureSize();
            this.setCentre( {'x':size.w/2, 'y':size.h/2} );
        },

        // Get the coordinates on the paper of the viewport center.
        // Used after zoom update (but BEFORE the UI has changed)
        getCentre: function(previous) {
            // Need to know the zoom BEFORE the update
            var m = this.model,
                curr_zoom = m.get('curr_zoom');
            if (previous) {
                curr_zoom = m.previous('curr_zoom');
            }
            if (curr_zoom === undefined) {
                return;
            }
            var viewport_w = this.$main.width(),
                viewport_h = this.$main.height(),
                co = this.$canvas_wrapper.offset(),
                mo = this.$main.offset(),
                offst_left = co.left - mo.left,
                offst_top = co.top - mo.top,
                cx = -offst_left + viewport_w/2,
                cy = -offst_top + viewport_h/2,
                zm_fraction = curr_zoom * 0.01;

            var size = this.model.getFigureSize();
            var paper_left = (m.get('canvas_width') - size.w)/2,
                paper_top = (m.get('canvas_height') - size.h)/2;
            return {'x':(cx/zm_fraction)-paper_left, 'y':(cy/zm_fraction)-paper_top};
        },

        // Scroll viewport to place a specified paper coordinate at the centre
        setCentre: function(cx_cy, speed) {
            var m = this.model,
                size = this.model.getFigureSize(),
                paper_left = (m.get('canvas_width') - size.w)/2,
                paper_top = (m.get('canvas_height') - size.h)/2;
            var curr_zoom = m.get('curr_zoom'),
                zm_fraction = curr_zoom * 0.01,
                cx = (cx_cy.x+paper_left) * zm_fraction,
                cy = (cx_cy.y+paper_top) * zm_fraction,
                viewport_w = this.$main.width(),
                viewport_h = this.$main.height(),
                offst_left = cx - viewport_w/2,
                offst_top = cy - viewport_h/2;
            speed = speed || 0;
            this.$main.animate({
                scrollLeft: offst_left,
                scrollTop: offst_top
            }, speed);
        },

        zoom_paper_to_fit: function(event) {

            var m = this.model,
                size = this.model.getFigureSize(),
                viewport_w = this.$main.width(),
                viewport_h = this.$main.height();

            var zoom_x = viewport_w/(size.w + 100),
                zoom_y = viewport_h/(size.h + 100),
                zm = Math.min(zoom_x, zoom_y);
            zm = (zm * 100) >> 0;

            m.set('curr_zoom', zm) ;
            $("#zoom_slider").slider({ value: zm });

            // seems we sometimes need to wait to workaround bugs
            var self = this;
            setTimeout(function(){
                self.reCentre();
            }, 10);
        },

        // Add a panel to the view
        addOne: function(panel) {
            var page_color = this.model.get('page_color');
            var view = new PanelView({model:panel, page_color:page_color});
            this.$figure.append(view.render().el);
        },

        renderLoadingSpinner: function() {
            if (this.model.get('loading_count') > 0) {
                $("#addImagesSpinner").show();
            } else {
                $("#addImagesSpinner").hide();
            }
        },

        renderFigureName: function() {

            var title = "OMERO.figure",
                figureName = this.model.get('figureName');
            if ((figureName) && (figureName.length > 0)) {
                title += " - " + figureName;
            } else {
                figureName = "";
            }
            $('title').text(title);
            $(".figure-title").text(figureName);
        },

        renderSaveBtn: function() {

            var canEdit = this.model.get('canEdit'),
                noFile = (typeof this.model.get('fileId') == 'undefined'),
                btnText = (canEdit || noFile) ? "Save" : "Can't Save";
            this.$saveBtn.text(btnText);
            if (this.model.get('unsaved') && (canEdit || noFile)) {
                this.$saveBtn.addClass('btn-success').removeClass('btn-default').removeAttr('disabled');
                this.$saveOption.removeClass('disabled');
            } else {
                this.$saveBtn.addClass('btn-default').removeClass('btn-success').attr('disabled', 'disabled');
                this.$saveOption.addClass('disabled');
            }
            if (this.model.get('fileId')) {
                this.$deleteOption.removeClass('disabled');
            } else {
                this.$deleteOption.addClass('disabled');
            }
        },

        renderSelectionChange: function() {
            var $delete_panel = $('.delete_panel', this.$el);
            if (this.model.getSelected().length > 0) {
                $delete_panel.removeAttr("disabled");
                this.$copyBtn.removeClass("disabled");
            } else {
                $delete_panel.attr("disabled", "disabled");
                this.$copyBtn.addClass("disabled");
            }
        },

        renderPanels: function() {
            // Re-render all the panels...
            // Remove and re-add
            $('.imagePanel', this.$figure).remove();
            this.model.panels.forEach(function(panel){
                this.addOne(panel);
            }.bind(this));
        },

        // Render is called on init()
        // Update any changes to sizes of paper or canvas
        render: function() {
            var m = this.model,
                zoom = m.get('curr_zoom') * 0.01;

            var page_w = m.get('paper_width'),
                page_h = m.get('paper_height'),
                page_color = m.get('page_color'),
                size = this.model.getFigureSize(),
                canvas_w = Math.max(m.get('canvas_width'), size.w),
                canvas_h = Math.max(m.get('canvas_height'), size.h),
                page_count = m.get('page_count'),
                paper_spacing = m.get('paper_spacing'),
                figure_left = (canvas_w - size.w)/2,
                figure_top = (canvas_h - size.h)/2;

            var $pages = $(".paper"),
                left, top, row, col;
            if ($pages.length !== page_count) {
                $pages.remove();
                for (var p=0; p<page_count; p++) {
                    row = Math.floor(p/size.cols);
                    col = p % size.cols;
                    top = row * (page_h + paper_spacing);
                    left = col * (page_w + paper_spacing);
                    $("<div class='paper'></div>")
                        .css({'left': left, 'top': top})
                        .prependTo(this.$figure);
                }
                $pages = $(".paper");
            }

            $pages.css({'width': page_w, 'height': page_h, 'background-color': '#' + page_color});

            this.$figure.css({'width': size.w, 'height': size.h,
                    'left': figure_left, 'top': figure_top});

            $("#canvas").css({'width': canvas_w,
                    'height': canvas_h});

            // always want to do this?
            this.zoom_paper_to_fit();

            return this;
        }
    });



    var AlignmentToolbarView = Backbone.View.extend({

        el: $("#alignment-toolbar"),

        model:FigureModel,

        events: {
            "click .aleft": "align_left",
            "click .agrid": "align_grid",
            "click .atop": "align_top",

            "click .awidth": "align_width",
            "click .aheight": "align_height",
            "click .asize": "align_size",
            "click .amagnification": "align_magnification",
        },

        initialize: function() {
            this.listenTo(this.model, 'change:selection', this.render);
            this.$buttons = $("button", this.$el);
        },

        align_left: function(event) {
            event.preventDefault();
            this.model.align_left();
        },

        align_grid: function(event) {
            event.preventDefault();
            this.model.align_grid();
        },

        align_width: function(event) {
            event.preventDefault();
            this.model.align_size(true, false);
        },

        align_height: function(event) {
            event.preventDefault();
            this.model.align_size(false, true);
        },

        align_size: function(event) {
            event.preventDefault();
            this.model.align_size(true, true);
        },

        align_magnification: function(event) {
            event.preventDefault();
            this.model.align_magnification();
        },

        align_top: function(event) {
            event.preventDefault();
            this.model.align_top();
        },

        render: function() {
            if (this.model.getSelected().length > 1) {
                this.$buttons.removeAttr("disabled");
            } else {
                this.$buttons.attr("disabled", "disabled");
            }
        }
    });


//
// Copyright (C) 2014-2021 University of Dundee & Open Microscopy Environment.
// All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//

var FigureFile = Backbone.Model.extend({

    defaults: {
        disabled: false,
    },

    initialize: function() {

        var desc = this.get('description');
        if (desc && desc.imageId) {
            this.set('imageId', desc.imageId);
        } else {
            this.set('imageId', 0);
        }
        if (desc && desc.baseUrl) {
            this.set('baseUrl', desc.baseUrl);
        }
    },

    isVisible: function(filter) {
        if (filter.owner) {
            if (this.get('owner').id !== filter.owner) {
                return false;
            }
        }
        if (filter.group) {
            if (this.get('group').id !== filter.group) {
                return false;
            }
        }
        if (filter.name) {
            // Search for files that have all words in
            var name = this.get('name').toLowerCase();
            var words = $.trim(filter.name).split(" ");
            var visible = words.reduce(function(prev, t){
                return prev && name.indexOf(t) > -1
            }, true);
            return visible;
        }
        return true;
    }
});


var FileList = Backbone.Collection.extend({

    model: FigureFile,

    comparator: 'creationDate',

    initialize: function() {
    },

    disable: function(fileId) {
        // enable all first
        this.where({disabled: true}).forEach(function(f){
            f.set('disabled', false);
        });

        var f = this.get(fileId);
        if (f) {
            f.set('disabled', true);
        }
    },

    deleteFile: function(fileId, name) {
        // may not have fetched files...
        var f = this.get(fileId),   // might be undefined
            msg = "Delete '" + name + "'?",
            self = this;
        if (confirm(msg)) {
            $.post( DELETE_WEBFIGURE_URL, { fileId: fileId })
                .done(function(){
                    self.remove(f);
                    app.navigate("", {trigger: true});
                });
        }
    },

    url: function() {
        return LIST_WEBFIGURES_URL;
    }
});


var FileListView = Backbone.View.extend({

    el: $("#openFigureModal"),

    initialize:function (options) {
        this.$tbody = $('tbody', this.$el);
        this.$fileFilter = $('#file-filter');
        this.owner = USER_ID;
        if (window.IS_PUBLIC_USER) {
            delete this.owner;
        }
        var self = this;
        // we automatically 'sort' on fetch, add etc.
        this.model.bind("sync remove sort", this.render, this);
        this.$fileFilter.val("");

        // we only need this to know the currently opened file
        this.figureModel = options.figureModel;

        $("#openFigureModal").bind("show.bs.modal", function(){
            // When the dialog opens, we load files...
            var currentFileId = self.figureModel.get('fileId');
            if (self.model.length === 0) {
                self.refresh_files();
            } else {
                self.render();
            }
        });
    },

    events: {
        "click .sort-created": "sort_created",
        "click .sort-created-reverse": "sort_created_reverse",
        "click .sort-name": "sort_name",
        "click .sort-name-reverse": "sort_name_reverse",
        "click .pick-owner": "pick_owner",
        "click .pick-group": "pick_group",
        "keyup #file-filter": "filter_files",
        "click .refresh-files": "refresh_files",
    },

    refresh_files: function(event) {
        // will trigger sort & render()
        var loadingHtml = "<tr><td colspan='4' style='text-align:center'><h1><small>Loading Files...</small></h1></td></tr>"
        this.$tbody.html(loadingHtml);
        this.model.fetch();
    },

    filter_files: function(event) {
        // render() will pick the new filter text
        this.render();
    },

    sort_created: function(event) {
        this.render_sort_btn(event);
        this.model.comparator = 'creationDate';
        this.model.sort();
    },

    sort_created_reverse: function(event) {
        this.render_sort_btn(event);
        this.model.comparator = function(left, right) {
            var l = left.get('creationDate'),
                r = right.get('creationDate');
            return l < r ? 1 : l > r ? -1 : 0;
        };
        this.model.sort();
    },

    sort_name: function(event) {
        this.render_sort_btn(event);
        this.model.comparator = 'name';
        this.model.sort();
    },

    sort_name_reverse: function(event) {
        this.render_sort_btn(event);
        this.model.comparator = function(left, right) {
            var l = left.get('name'),
                r = right.get('name');
            return l < r ? 1 : l > r ? -1 : 0;
        };
        this.model.sort();
    },

    render_sort_btn: function(event) {
        $("th .btn-sm", this.$el).addClass('muted');
        $(event.target).removeClass('muted');
    },

    pick_owner: function(event) {
        event.preventDefault()
        var owner = $(event.target).data('id');
        if (owner != -1) {
            this.owner = owner;
        } else {
            delete this.owner;
        }
        this.render();
    },

    pick_group: function (event) {
        event.preventDefault()
        var group = $(event.target).data('id');
        if (group != -1) {
            this.group = group;
        } else {
            delete this.group;
        }
        this.render();
    },

    render:function () {
        var self = this,
            filter = {},
            filterVal = this.$fileFilter.val(),
            currentFileId = this.figureModel.get('fileId');
        if (this.owner && this.owner != -1) {
            filter.owner = this.owner;
        }
        if (this.group && this.group != -1) {
            filter.group = this.group;
        }
        if (filterVal.length > 0) {
            filter.name = filterVal.toLowerCase();
        }
        this.$tbody.empty();
        if (this.model.models.length === 0) {
            var msg = "<tr><td colspan='3'>" +
                "You have no figures. Start by <a href='" + BASE_WEBFIGURE_URL + "new'>creating a new figure</a>" +
                "</td></tr>";
            self.$tbody.html(msg);
        }
        _.each(this.model.models, function (file) {
            if (file.isVisible(filter)) {
                var disabled = currentFileId === file.get('id') ? true: false;
                file.set('disabled', disabled);
                var e = new FileListItemView({model:file}).render().el;
                self.$tbody.prepend(e);
            }
        });
        var owners = this.model.pluck("owner");
        var ownersByName = {};
        owners.forEach(function(o){
            let name = o.firstName + " " + o.lastName;
            ownersByName[name] = o.id;
        });
        var ownersNames = Object.keys(ownersByName);
        // Sort by last name
        ownersNames.sort(function compare(a, b) {
            var aNames = a.split(" "),
                aN = aNames[aNames.length - 1],
                bNames = b.split(" "),
                bN = bNames[bNames.length - 1];
            return aN > bN;
        });
        var ownersHtml = "<li><a class='pick-owner' href='#' data-id='-1'> -- Show All -- </a></li>";
            ownersHtml += "<li class='divider'></li>";
        _.each(ownersNames, function(name) {
            ownersHtml += "<li><a class='pick-owner' data-id='" + ownersByName[name] + "' href='#'>" + _.escape(name) + "</a></li>";
        });
        $("#owner-menu").html(ownersHtml);

        // render groups chooser
        var groups = this.model.pluck("group");
        var groupsByName = {};
        groups.forEach(function (g) {
            groupsByName[g.name] = g.id;
        })
        var groupNames = Object.keys(groupsByName);
        groupNames.sort();
        var groupsHtml = "<li><a class='pick-group' href='#' data-id='-1'> -- Show All -- </a></li><li class='divider'></li>";
        groupsHtml += groupNames.map(function (name) { return "<li><a class='pick-group' data-id='" + groupsByName[name] + "' href='#'>" + _.escape(name) + "</a></li>"}).join('\n');
        $("#group-menu").html(groupsHtml);
        return this;
    }
});

var FileListItemView = Backbone.View.extend({

    tagName:"tr",

    template: JST["src/templates/files/figure_file_item.html"],

    initialize:function () {
        this.model.bind("change", this.render, this);
        this.model.bind("destroy", this.close, this);
    },

    events: {
        "click a": "hide_file_chooser"
    },

    hide_file_chooser: function() {
        $("#openFigureModal").modal('hide');
    },

    formatDate: function(secs) {
        // if secs is a number, create a Date...
        if (secs * 1000) {
            var d = new Date(secs * 1000),
            s = d.toISOString();        // "2014-02-26T23:09:09.415Z"
            s = s.replace("T", " ");
            s = s.substr(0, 16);
            return s;
        }
        // handle string
        return secs;
    },

    render:function () {
        var json = this.model.toJSON(),
            baseUrl = json.baseUrl;
        if (!json.imageId){
            // Description may be json encoded...
            try {
                var d = JSON.parse(json.description);
                // ...with imageId and name (unicode supported)
                if (d.imageId) {
                    json.imageId = d.imageId;
                    // we cache this so we don't have to parse() on each render()
                    this.model.set('imageId', d.imageId);
                }
            } catch (err) {
                console.log('failed to parse json', json.description);
            }
        }
        baseUrl = baseUrl || BASE_WEBFIGURE_URL.slice(0, -1);  // remove last /
        json.thumbSrc = baseUrl + "/render_thumbnail/" + json.imageId + "/";
        json.url = BASE_WEBFIGURE_URL + "file/" + json.id;
        json.formatDate = this.formatDate;
        var h = this.template(json);
        $(this.el).html(h);
        return this;
    }

});

var InfoPanelView = Backbone.View.extend({

    template: JST["src/templates/info_panel_template.html"],
    xywh_template: JST["src/templates/xywh_panel_template.html"],

    initialize: function(opts) {
        this.render = _.debounce(this.render);
        this.figureModel = opts.figureModel;
        this.models = opts.models;
        if (opts.models.length > 1) {
            var self = this;
            this.models.forEach(function(m){
                self.listenTo(m, 'change:x change:y change:width change:height change:imageId change:zoom change:min_export_dpi change:max_export_dpi', self.render);
            });
        } else if (opts.models.length == 1) {
            this.model = opts.models.head();
            this.listenTo(this.model, 'change:x change:y change:width change:height change:zoom change:min_export_dpi change:max_export_dpi', this.render);
            this.listenTo(this.model, 'drag_resize', this.drag_resize);
        }
    },

    events: {
        "click .setId": "setImageId",
        "click .set_dpi": "set_dpi",
        "click .clear_dpi": "clear_dpi",
        "blur .xywh_form input": "handle_xywh",
        "keyup .xywh_form input": "handle_xywh",
        "click .setAspectRatio": "lockAspectRatio"
    },

    handle_xywh: function(event) {
        // Ignore the 2nd blur event generated during render()
        if (this.rendering) {
            return;
        }
        if (event.type === "keyup" && event.which !== 13) {
            return;
        }
        var attr = event.target.getAttribute("name");
        var value = parseInt(event.target.value, 10);
        if (isNaN(value)) {
            return;
        }

        var figsize = this.figureModel.getFigureSize();
        // Avoid re-rendering and losing focus everytime there is a Blur event
        // set(attr, value) will not cause render()
        this.ignoreChange = true;
        var aspectRatioStatus = false;
        this.models.forEach(function(m) {
            if (attr === 'x' || attr ==='y') {
                var old = m.get(attr);
                var coords = {};
                coords[attr] = old;
                var offset = this.figureModel.getPageOffset(coords);
                var newValue = old - offset[attr] + value;
                // Keep panel within figure limits
                if (attr === 'x') {
                    if (newValue > figsize.w || newValue < 0) {
                        this.ignoreChange = false;
                    }
                    newValue = Math.min(figsize.w, newValue);
                } else if (attr === 'y') {
                    if (newValue > figsize.h || newValue < 0) {
                        this.ignoreChange = false;
                    }
                    newValue = Math.min(figsize.h, newValue);
                }
                newValue = Math.max(0, newValue);
                m.set(attr, newValue);
            }
            else {
                if (value < 1) {
                    this.render();
                    return;
                }

                //Check aspect ratio button state
                //If selected, check attribute and value and then recalculate other attribute value
                //Set both values parallely
                var newWidthHeight = {};
                newWidthHeight[attr] = value;

                if ($(".setAspectRatio", this.$el).hasClass("aspectRatioSelected")) {
                    aspectRatioStatus = true;
                    var widthCur = m.get('width');
                    var heightCur = m.get('height');
                    var aspRatio = widthCur/heightCur;

                    if (attr === 'width'){
                        var heightNew = value/aspRatio;
                        newWidthHeight['height'] = heightNew;
                    }
                    else {
                        var widthNew = value * aspRatio;
                        newWidthHeight['width'] = widthNew;
                    }
                    this.ignoreChange = false;
                }
                m.save(newWidthHeight);
            }
        }.bind(this));
        // Timout for ignoreChange
        // Only reset this AFTER render() is called
        setTimeout(function(){
            this.ignoreChange = false;
            // keep locked status of the aspect ratio button the same,
            // when the focus shifts because of a blur event
            if (aspectRatioStatus) {
                $(".setAspectRatio", this.$el).addClass("aspectRatioSelected");
            }
        }.bind(this), 50);

    },

    set_dpi: function(event) {
        event.preventDefault();
        $("#dpiModal").modal('show');
    },

    // remove optional min_export_dpi attribute from selected panels
    clear_dpi: function(event) {
        event.preventDefault();
        this.models.forEach(function(m) {
            m.unset("min_export_dpi");
        });
    },

    setImageId: function(event) {
        event.preventDefault();
        // Simply show dialog - Everything else handled by SetIdModalView
        $("#setIdModal").modal('show');
        $("#setIdModal .imgId").val("").focus();
    },

    lockAspectRatio: function(event) {
        event.preventDefault();
        $(".setAspectRatio", this.$el).toggleClass("aspectRatioSelected");
    },

    // just update x,y,w,h by rendering ONE template
    drag_resize: function(xywh) {
        $("#xywh_table").remove();
        var json = {'x': xywh[0].toFixed(0),
                    'y': xywh[1].toFixed(0),
                    'width': xywh[2].toFixed(0),
                    'height': xywh[3].toFixed(0)};
        var offset = this.figureModel.getPageOffset(json);
        json.x = offset.x;
        json.y = offset.y;
        json.dpi = this.model.getPanelDpi(json.width, json.height);
        json.export_dpi = this.model.get('min_export_dpi');
        this.$el.append(this.xywh_template(json));
    },

    getImageLinks: function(remoteUrl, imageIds, imageNames) {
        // Link if we have a single remote image, E.g. http://jcb-dataviewer.rupress.org/jcb/img_detail/625679/
        var imageLinks = [];
        if (remoteUrl) {
            if (imageIds.length == 1) {
                imageLinks.push({'text': 'Image viewer', 'url': remoteUrl});
            }
        // OR all the images are local...
        } else {
            imageLinks.push({'text': 'Webclient', 'url': WEBINDEX_URL + "?show=image-" + imageIds.join('|image-')});

            // Handle other 'Open With' options
            OPEN_WITH.forEach(function(v){
                var selectedObjs = imageIds.map(function(id, i){
                    return {'id': id, 'name': imageNames[i], 'type': 'image'};
                });
                var enabled = false;
                if (typeof v.isEnabled === "function") {
                    enabled = v.isEnabled(selectedObjs);
                } else if (typeof v.supported_objects === "object" && v.supported_objects.length > 0) {
                    enabled = v.supported_objects.reduce(function(prev, supported){
                        // enabled if plugin supports 'images' or 'image' (if we've selected a single image)
                        return prev || supported === 'images' || (supported === 'image' && imageIds.length === 1);
                    }, false);
                }
                if (!enabled) return;

                // Get the link via url provider...
                var url = v.url + '?image=' + imageIds.join('&image=');
                if (v.getUrl) {
                    url = v.getUrl(selectedObjs, v.url);
                }
                // Ignore any 'Open with OMERO.figure' urls
                if (url.indexOf(BASE_WEBFIGURE_URL) === 0) {
                    return;
                }
                var label = v.label || v.id;
                imageLinks.push({'text': label, 'url': url});
            });
        }
        return imageLinks;
    },

    // render BOTH templates
    render: function() {
        // If event comes from handle_xywh() then we don't need to render()
        if (this.ignoreChange) {
            return;
        }
        // Flag to ignore blur events caused by $el.html() below
        this.rendering = true;
        var json,
            title = this.models.length + " Panels Selected...",
            remoteUrl;

        var imageIds = this.models.pluck('imageId');
        var imageNames = this.models.pluck('name');
        this.models.forEach(function(m) {
            if (m.get('baseUrl')) {
                // only used when a single image is selected
                remoteUrl = m.get('baseUrl') + "/img_detail/" + m.get('imageId') + "/";
            }
            // start with json data from first Panel
            var this_json = m.toJSON();
            // Format floating point values
            _.each(["x", "y", "width", "height"], function(a){
                if (this_json[a] != "-") {
                    this_json[a] = this_json[a].toFixed(0);
                }
            });
            var offset = this.figureModel.getPageOffset(this_json);
            this_json.x = offset.x;
            this_json.y = offset.y;
            this_json.dpi = m.getPanelDpi();
            this_json.channel_labels = this_json.channels.map(function(c){return c.label})
            if (!json) {
                json = this_json;
            } else {
                json.name = title;
                // compare json summary so far with this Panel
                var attrs = ["imageId", "orig_width", "orig_height", "sizeT", "sizeZ", "x", "y", "width", "height", "dpi", "min_export_dpi", "max_export_dpi"];
                _.each(attrs, function(a){
                    if (json[a] != this_json[a]) {
                        if (a === 'x' || a === 'y' || a === 'width' || a === 'height') {
                            json[a] = "";
                        } else {
                            json[a] = "-";
                        }
                    }
                });
                // handle channel names
                if (this_json.channels.length != json.channel_labels.length) {
                    json.channel_labels = ["-"];
                } else {
                    _.each(this_json.channels, function(c, idx){
                        if (json.channel_labels[idx] != c.label) {
                            json.channel_labels[idx] = '-';
                        }
                    });
                }

            }
        }.bind(this));

        json.export_dpi = Math.min(json.dpi, json.max_export_dpi);
        if (!isNaN(json.min_export_dpi)) {
            json.export_dpi = Math.max(json.export_dpi, json.min_export_dpi);
        }

        json.imageLinks = this.getImageLinks(remoteUrl, imageIds, imageNames);

        // all setId if we have a single Id
        json.setImageId = _.uniq(imageIds).length == 1;

        if (json) {
            var html = this.template(json),
                xywh_html = this.xywh_template(json);
            this.$el.html(html + xywh_html);
        }
        this.rendering = false;
        return this;
    }
});


var LabelFromMapsModal = Backbone.View.extend({

    el: $("#labelsFromMapAnns"),

    model: FigureModel,

    // label options: position, size, color
    options: {},

    /**
     * Constructor - listen for dialog opening to load data and render
     */
    initialize: function() {
        // when dialog is shown, load map annotations for selected images
        $("#labelsFromMapAnns").bind("show.bs.modal", function(event){
            // event options from the label form: {position: 'top', size: '12', color: '0000'}
            this.options = event.relatedTarget;
            this.loadMapAnns();
        }.bind(this));
    },

    events: {
        "submit .labelsFromMapAnnsForm": "handleForm",
        "change select": "renderExampleLabel",
        "change input": "renderExampleLabel",
    },

    /**
     * Load the map annotations, then call render()
     */
    loadMapAnns() {
        let imageIds = this.model.getSelected().map(function(m){return m.get('imageId')});
        this.isLoading = true;
        $('select', this.el).html("<option>Loading data...</option>");

        var url = WEBINDEX_URL + "api/annotations/?type=map&image=";
        url += imageIds.join("&image=");

        $.getJSON(url, function(data) {
                this.isLoading = false;
                this.annotations = data.annotations;
                this.render();
            }.bind(this)
        );
    },

    /**
     * Handle submission of the form to create labels and close dialog
     *
     * @param {Object} event
     */
    handleForm: function(event) {
        event.preventDefault();
        if (this.isLoading) return;

        var key = $('select', this.el).val();
        var includeKey = $("input[name='includeKey']").is(':checked');
        var labelSize = this.options.size || "12";
        var labelPosition = this.options.position || "top";
        var labelColor = this.options.color || "000000";

        var imageValues = this.annotations.reduce(function(prev, t){
            var iid = t.link.parent.id;
            if (!prev[iid]) {
                prev[iid] = [];
            }
            t.values.forEach(function(kv) {
                var value = kv[1];
                // If key matches, add to values for the image (no duplicates)
                if (kv[0] === key && prev[iid].indexOf(value) === -1) {
                    prev[iid].push(value);
                }
            });
            return prev;
        }, {});

        this.model.getSelected().forEach(function(p){
            var iid = p.get('imageId');
            if (imageValues[iid]) {
                var labels = imageValues[iid].map(function(value){
                    return {
                        'text': includeKey ? (key + ': ' + value) : value,
                        'size': labelSize,
                        'position': labelPosition,
                        'color': labelColor,
                    }
                });
                p.add_labels(labels);
            }
        });
        $("#labelsFromMapAnns").modal('hide');
        return false;
    },

    /**
     * Renders the Example label based on currently selected Key and includeKey
     */
    renderExampleLabel: function() {
        var key = $('select', this.el).val();
        var includeKey = $("input[name='includeKey']").is(':checked');
        // find first annotation with this value
        var label;
        for (var a=0; a<this.annotations.length; a++) {
            this.annotations[a].values.forEach(function(kv) {
                if (kv[0] === key) {
                    label = kv[1];
                }
            });
            if (label) {
                break;
            }
        }

        if (includeKey) {
            label = key + ": " + label;
        }
        // Handle no annotations on images
        if (this.annotations.length == 0) {
            label = ""
        }

        $("#exampleLabelFromMap").html(_.escape(label));
    },

    /**
     * Renders the <select> for choosing Key. Also calls renderExampleLabel()
     */
    render: function() {
        // Get keys for images {'key' : {iid: true}}
        var keys = {};
        this.annotations.forEach(function(ann) {
            let iid = ann.link.parent.id;
            ann.values.forEach(function(kv){
                var key = kv[0];
                if (!keys[key]) {
                    keys[key] = {};
                }
                keys[key][iid] = true;
            })
        });

        // Make a list of keys (and sort) along with counts of images for each key
        var keyList = [];
        var keyCounts = {};
        for (var key in keys) {
            if (keys.hasOwnProperty(key)) {
                keyList.push(key);
                keyCounts[key] = Object.keys(keys[key]).length;
            }
        }
        keyList.sort(function(a, b) {
            return (a.toUpperCase() < b.toUpperCase()) ? -1 : 1; 
        });

        var html = keyList.map(function(key) {
            return "<option value='" + _.escape(key) + "'>" + _.escape(key) + " (" + keyCounts[key] + ")</option>";
        }).join("");
        if (keyList.length === 0) {
            html = "<option>No Key-Value Pairs found</option>";
        }
        $('select', this.el).html(html);

        this.renderExampleLabel();
    }
});


// Copyright (c) 2015 University of Dundee.

// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.

// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.

// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.


var LegendView = Backbone.View.extend({

        // Use 'body' to handle Menu: File > Add Figure Legend
        el: $("body"),

        model:FigureModel,

        editing: false,

        initialize: function() {

            this.listenTo(this.model,
                'change:x change:legend', this.legendChanged);

            this.render();
        },

        events: {
            "click .edit-legend": "editLegend",
            "click .cancel-legend": "cancelLegend",
            "click .save-legend": "saveLegend",
            "click .collapse-legend": "collapseLegend",
            "click .expand-legend": "expandLegend",
            "click .markdown-info": "markdownInfo",
            "click .panel-body p": "legendClick",
        },

        // Click on the legend <p>. Start editing or follow link
        legendClick: function(event) {
            event.preventDefault();
            // If link, open new window / tab
            if (event.target.nodeName.toLowerCase() == "a") {
                var href = event.target.getAttribute('href');
                window.open(href, '_blank');
                return false;
            // Click on legend text expands then edits legend
            } else {
                if (this.model.get("legend_collapsed")) {
                    this.expandLegend();
                } else {
                    this.editLegend();
                }
            }
        },

        markdownInfo: function(event) {
            event.preventDefault();
            $("#markdownInfoModal").modal('show');
        },

        collapseLegend: function(event) {
            this.renderCollapsed(true);
            this.model.set("legend_collapsed", true);
        },

        expandLegend: function(event) {
            this.renderCollapsed(false);
            this.model.set("legend_collapsed", false);
        },

        saveLegend: function(event) {
            event.preventDefault();
            var legendTxt = $("#js-legend textarea").val();
            this.model.save("legend", legendTxt);
            // This will happen anyway if legend has changed, 
            // but just in case it hasn't....
            this.editing = false;
            this.render();
        },

        editLegend: function(event) {
            if (event) event.preventDefault();
            this.editing = true;
            if (this.model.get("legend_collapsed")) {
                this.model.set("legend_collapsed", false);
            }
            this.render();
        },

        cancelLegend: function(event) {
            event.preventDefault();
            this.editing = false;
            this.render();
        },

        renderCollapsed: function(collapsed) {
            var $panel = $('.panel', self.el);
            if (collapsed) {
                $panel.addClass('legend-collapsed');
                $panel.removeClass('legend-expanded');
            } else {
                $panel.removeClass('legend-collapsed');
                $panel.addClass('legend-expanded');
            }
        },

        // May have chaned by opening a new Figure etc.
        legendChanged: function() {
            this.editing = false;
            this.render();
        },

        render: function() {

            var self = this,
                legendText = this.model.get('legend') || "",
                legendCollapsed = this.model.get('legend_collapsed'),
                $el = $("#js-legend"),
                $edit = $('.edit-legend', $el),
                $save = $('.save-legend', $el),
                $cancel = $('.cancel-legend', $el),
                $panel = $('.panel', $el),
                $legend = $('.legend', $el);

            self.renderCollapsed(legendCollapsed);

            // if we're editing...
            if (self.editing) {
                // show 'cancel' and 'save' buttons
                $edit.hide();
                $save.show();
                $cancel.show();

                $panel.addClass('editing');
                $panel.show();
                var html = '<textarea class="form-control" rows="9" style="resize:none">'
                            + legendText + '</textarea>';
                $legend.html(html);
            } else {
                // ...only show 'edit' button
                $edit.show();
                $save.hide();
                $cancel.hide();

                $panel.removeClass('editing');
                if (legendText.length === 0) {
                    $panel.hide();
                    $edit.text("Add Figure Legend");
                } else {
                    $panel.show();
                    legendText = markdown.toHTML( legendText );
                    $legend.html(legendText);
                    $edit.text("Edit Figure Legend");
                }
            }
        }
    });


//
// Copyright (C) 2016 University of Dundee & Open Microscopy Environment.
// All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//


// Should only ever have a singleton on this
var LutPickerView = Backbone.View.extend({

    el: $("#lutpickerModal"),

    template: JST["src/templates/lut_picker.html"],

    LUT_NAMES: ["16_colors.lut",
                "3-3-2_rgb.lut",
                "5_ramps.lut",
                "6_shades.lut",
                "blue_orange_icb.lut",
                "brgbcmyw.lut",
                "cool.lut",
                "cyan_hot.lut",
                "edges.lut",
                "fire.lut",
                "gem.lut",
                "glasbey.lut",
                "glasbey_inverted.lut",
                "glow.lut",
                "grays.lut",
                "green_fire_blue.lut",
                "hilo.lut",
                "ica.lut",
                "ica2.lut",
                "ica3.lut",
                "ice.lut",
                "magenta_hot.lut",
                "orange_hot.lut",
                "phase.lut",
                "physics.lut",
                "pup_br.lut",
                "pup_nr.lut",
                "rainbow_rgb.lut",
                "red-green.lut",
                "red_hot.lut",
                "royal.lut",
                "sepia.lut",
                "smart.lut",
                "spectrum.lut",
                "thal.lut",
                "thallium.lut",
                "thermal.lut",
                "unionjack.lut",
                "yellow_hot.lut"],

    initialize:function () {
    },

    
    events: {
        "click button[type='submit']": "handleSubmit",
        "click .lutOption": "pickLut",
    },

    handleSubmit: function() {
        this.success(this.pickedLut);
        $("#lutpickerModal").modal('hide');
    },

    pickLut: function(event) {
        var lutName = event.currentTarget.getAttribute('data-lut');
        // Save the name - used in handleSubmit();
        this.pickedLut = lutName;

        // Update preview to show LUT
        var bgPos = this.getLutBackgroundPosition(lutName);
        $(".lutPreview", this.el).css('background-position', bgPos);
        // Enable OK button
        $("button[type='submit']", this.el).removeAttr('disabled');
    },

    loadLuts: function() {
        var url = WEBGATEWAYINDEX + 'luts/';
        var promise = $.getJSON(url);
        return promise;
    },

    getLutBackgroundPosition: function(lutName) {
        var lutIndex = this.LUT_NAMES.indexOf(lutName);
        var css = {};
        if (lutIndex > -1) {
            return '0px -' + ((lutIndex * 50) +2) + 'px';
        } else {
            return '0px 100px';  // hides background
        }
    },

    formatLutName: function(lutName) {
        lutName = lutName.replace(".lut", "");
        lutName = lutName.replace(/_/g, " ");
        // Title case
        lutName = lutName[0].toUpperCase() + lutName.slice(1);
        return lutName;
    },

    show: function(options) {

        $("#lutpickerModal").modal('show');

        // save callback to use on submit
        if (options.success) {
            this.success = options.success;
        }

        this.loadLuts().done(function(data){
            this.luts = data.luts;
            this.render();
        }.bind(this)).fail(function(){
            // E.g. 404 with Older OMERO (pre 5.3.0)
            this.render();
        }.bind(this));
    },

    render:function() {
        
        var html = "",
            luts;
        if (!this.luts) {
            html = "<p>Loading Lookup Tables failed.</p>";
            html += "<p>Your OMERO version does not support LUTs. Please upgrade.</p>";
        } else {
            luts = this.luts.map(function(lut) {
                // Add css background-position to each lut to offset luts_10.png
                return {'bgPos': this.getLutBackgroundPosition(lut.name),
                        'name': lut.name,
                        'displayName': this.formatLutName(lut.name)};
            }.bind(this));
            html = this.template({'luts': luts});
        }
        $(".modal-body", this.el).html(html);
    }
});


// Events, show/hide and rendering for various Modal dialogs.

    var DpiModalView = Backbone.View.extend({

        el: $("#dpiModal"),

        model: FigureModel,

        initialize: function(options) {

            var self = this;
            // when dialog is shown, clear and render
            $("#dpiModal").bind("show.bs.modal", function(){
                self.render();
            });
        },

        events: {
            "submit .dpiModalForm": "handleDpiForm",
        },

        handleDpiForm: function(event) {
            event.preventDefault();

            var minDpiVal = $(".min_export_dpi", this.el).val();
            var minDpi = parseInt(minDpiVal, 10);
            var maxDpiVal = $(".max_export_dpi", this.el).val();
            var maxDpi = parseInt(maxDpiVal, 10);
            var sel = this.model.getSelected();

            // if we have invalid number...
            if (isNaN(maxDpi)) {
                alert("Need to enter valid integer for dpi values");
                return false;
            }

            sel.forEach(function(p) {
                var toset = {max_export_dpi: maxDpi};
                if (!isNaN(minDpi)) {
                    toset.min_export_dpi = minDpi;
                } else {
                    p.unset("min_export_dpi");
                }
                p.save(toset);
            });
            $("#dpiModal").modal('hide');
            return false;
        },

        render: function() {
            var sel = this.model.getSelected();
            var minDpi = sel.getIfEqual('min_export_dpi') || 300;
            var maxDpi = sel.getIfEqual('max_export_dpi') || '-';

            $(".min_export_dpi", this.el).val(minDpi);
            $(".max_export_dpi", this.el).val(maxDpi);
        }
    });

    var PaperSetupModalView = Backbone.View.extend({

        el: $("#paperSetupModal"),

        template: JST["src/templates/modal_dialogs/paper_setup_modal_template.html"],

        model:FigureModel,

        events: {
            "submit .paperSetupForm": "handlePaperSetup",
            "change .paperSizeSelect": "rerender",
            // "keyup #dpi": "rerenderDb",
            "change input": "rerender",
            "click .pageColor": "handlePaperColor",
        },

        handlePaperColor: function(event) {
            event.preventDefault();

            var page_color = $(event.target).val();
            FigureColorPicker.show({
                'color': page_color,
                'pickedColors': ['#000000', '#ffffff', '#eeeeee'],
                'success': function(newColor){
                    // simply update <input>
                    $('.pageColor', this.$el).val(newColor);
                }.bind(this)
            });

            return false;
        },

        initialize: function(options) {

            var self = this;
            $("#paperSetupModal").bind("show.bs.modal", function(){
                self.render();
            });
            // don't update while typing
            // this.rerenderDb = _.debounce(this.rerender, 1000);
        },

        processForm: function() {

            // On form submit, need to work out paper width & height
            var $form = $('form', this.$el),
                dpi = 72,
                pageCount = $('.pageCountSelect', $form).val(),
                size = $('.paperSizeSelect', $form).val(),
                orientation = $form.find('input[name="pageOrientation"]:checked').val(),
                custom_w = parseInt($("#paperWidth").val(), 10),
                custom_h = parseInt($("#paperHeight").val(), 10),
                units = $('.wh_units:first', $form).text(),
                pageColor = $('.pageColor', $form).val().replace('#', ''),
                dx, dy;

            var w_mm, h_m, w_pixels, h_pixels;
            if (size == 'A4') {
                w_mm = 210;
                h_mm = 297;
            } else if (size == 'A3') {
                w_mm = 297;
                h_mm = 420;
            } else if (size == 'A2') {
                w_mm = 420;
                h_mm = 594;
            } else if (size == 'A1') {
                w_mm = 594;
                h_mm = 841;
            } else if (size == 'A0') {
                w_mm = 841;
                h_mm = 1189;
            } else if (size == 'letter') {
                w_mm = 216;
                h_mm = 280;
            } else if (size == 'mm') {
                // get dims from custom fields and units
                w_mm = custom_w;
                h_mm = custom_h;
            } else if (size == 'crop') {
                var coords = this.model.getCropCoordinates();
                w_pixels = coords.paper_width;
                h_pixels = coords.paper_height;
                dx = coords.dx;
                dy = coords.dy;
                // Single page is cropped to include ALL panels
                pageCount = 1;
            }
            if (w_mm && h_mm) {
                // convert mm -> pixels (inch is 25.4 mm)
                w_pixels = Math.round(dpi * w_mm / 25.4);
                h_pixels = Math.round(dpi * h_mm / 25.4);
            } else {
                // convert pixels -> mm
                w_mm = Math.round(w_pixels * 25.4 / dpi);
                h_mm = Math.round(h_pixels * 25.4 / dpi);
            }

            if (orientation == 'horizontal' && size != 'mm') {
                var tmp = w_mm; w_mm = h_mm; h_mm = tmp;
                tmp = w_pixels; w_pixels = h_pixels; h_pixels = tmp;
            }

            var cols = pageCount;
            if (pageCount > 3) {
                cols = Math.ceil(pageCount/2);
            }

            var rv = {
                // 'dpi': dpi,
                'page_size': size,
                'orientation': orientation,
                'width_mm': w_mm,
                'height_mm': h_mm,
                'paper_width': w_pixels,
                'paper_height': h_pixels,
                'page_count': pageCount,
                'page_col_count': cols,
                'page_color': pageColor,
            };
            if (dx !== undefined || dy !== undefined) {
                rv.dx = dx;
                rv.dy = dy;
            }
            return rv;
        },

        handlePaperSetup: function(event) {
            event.preventDefault();
            var json = this.processForm();

            // if 'crop' page to panels
            if (json.page_size === 'crop') {
                this.model.panels.forEach(function(p){
                    p.save({'x': p.get('x') + json.dx,
                            'y': p.get('y') + json.dy});
                });
                // paper is now a 'custom' size (not A4 etc)
                json.page_size = 'mm';
                // don't need these
                delete json.dx;
                delete json.dy;
            }

            this.model.set(json);
            $("#paperSetupModal").modal('hide');
        },

        rerender: function() {
            var json = this.processForm();
            this.render(json);
        },

        render: function(json) {
            json = json || this.model.toJSON();
            // if we're not manually setting mm or pixels, disable
            json.wh_disabled = (json.page_size != 'mm');
            // json.units = json.page_size == 'mm' ? 'mm' : 'pixels';
            // if (json.page_size == "mm") {
            //     json.paper_width = json.width_mm;
            //     json.paper_height = json.height_mm;
            // }

            this.$el.find(".modal-body").html(this.template(json));
        },
    });


    var SetIdModalView = Backbone.View.extend({

        el: $("#setIdModal"),

        template: JST["src/templates/modal_dialogs/preview_Id_change_template.html"],

        model:FigureModel,

        events: {
            "submit .addIdForm": "previewSetId",
            "click .preview": "previewSetId",
            "keyup .imgId": "keyPressed",
            "click .doSetId": "doSetId",
        },

        initialize: function(options) {

            var self = this;

            // when dialog is shown, clear and render
            $("#setIdModal").bind("show.bs.modal", function(){
                delete self.newImg;
                self.render();
            });
        },

        // Only enable submit button when input has a number in it
        keyPressed: function() {
            var idInput = $('input.imgId', this.$el).val(),
                previewBtn = $('button.preview', this.$el),
                re = /^\d+$/;
            if (re.test(idInput)) {
                previewBtn.removeAttr("disabled");
            } else {
                previewBtn.attr("disabled", "disabled");
            }
        },

        // handle adding Images to figure
        previewSetId: function(event) {
            event.preventDefault();

            var self = this,
                idInput = $('input.imgId', this.$el).val();

            // get image Data
            $.getJSON(BASE_WEBFIGURE_URL + 'imgData/' + parseInt(idInput, 10) + '/', function(data){

                // just pick what we need
                var newImg = {
                    'imageId': data.id,
                    'name': data.meta.imageName,
                    // 'width': data.size.width,
                    // 'height': data.size.height,
                    'sizeZ': data.size.z,
                    'theZ': data.rdefs.defaultZ,
                    'sizeT': data.size.t,
                    // 'theT': data.rdefs.defaultT,
                    'channels': data.channels,
                    'orig_width': data.size.width,
                    'orig_height': data.size.height,
                    // 'x': px,
                    // 'y': py,
                    'datasetName': data.meta.datasetName,
                    'pixel_size_x': data.pixel_size.valueX,
                    'pixel_size_y': data.pixel_size.valueY,
                    'pixel_size_x_unit': data.pixel_size.unitX,
                    'pixel_size_x_symbol': data.pixel_size.symbolX,
                    'deltaT': data.deltaT,
                };
                self.newImg = newImg;
                self.render();
            }).fail(function(event) {
                alert("Image ID: " + idInput +
                    " could not be found on the server, or you don't have permission to access it");
            });
        },

        doSetId: function() {

            var self = this,
                sel = this.model.getSelected();

            if (!self.newImg)   return;

            sel.forEach(function(p) {
                p.setId(self.newImg);
            });

        },

        render: function() {

            var sel = this.model.getSelected(),
                selImg,
                json = {};

            if (sel.length < 1) {
                self.selectedImage = null;
                return; // shouldn't happen
            }
            selImg = sel.head();
            json.selImg = selImg.toJSON();
            json.newImg = {};
            json.comp = {};
            json.messages = [];

            json.ok = function(match, match2) {
                if (typeof match == 'undefined') return "-";
                if (typeof match2 != 'undefined') {
                    match = match && match2;
                }
                var m = match ? "ok" : "flag";
                var rv = "<span class='glyphicon glyphicon-" + m + "'></span>";
                return rv;
            };

            // thumbnail
            json.selThumbSrc = WEBGATEWAYINDEX + "render_thumbnail/" + json.selImg.imageId + "/";

            // minor attributes ('info' only)
            var attrs = ["sizeZ", "orig_width", "orig_height"],
                attrName = ['Z size', 'Width', 'Height'];

            if (this.newImg) {
                json.newImg = this.newImg;
                // compare attrs above
                _.each(attrs, function(a, i) {
                    if (json.selImg[a] == json.newImg[a]) {
                        json.comp[a] = true;
                    } else {
                        json.comp[a] = false;
                        json.messages.push({"text":"Mismatch of " + attrName[i] + ": should be OK.",
                            "status": "success"});   // status correspond to css alert class.
                    }
                });
                // special message for sizeT
                if (json.selImg.sizeT != json.newImg.sizeT) {
                    // check if any existing images have theT > new.sizeT
                    var tooSmallT = false;
                    sel.forEach(function(o){
                        if (o.get('theT') > json.newImg.sizeT) tooSmallT = true;
                    });
                    if (tooSmallT) {
                        json.messages.push({"text": "New Image has fewer Timepoints than needed. Check after update.",
                            "status": "danger"});
                    } else {
                        json.messages.push({"text":"Mismatch of Timepoints: should be OK.",
                            "status": "success"});
                    }
                    json.comp.sizeT = false;
                } else {
                    json.comp.sizeT = true;
                }
                // compare channels
                json.comp.channels = json.ok(true);
                var selC = json.selImg.channels,
                    newC = json.newImg.channels,
                    cCount = selC.length;
                if (cCount != newC.length) {
                    json.comp.channels = json.ok(false);
                    json.messages.push({"text":"New Image has " + newC.length + " channels " +
                        "instead of " + cCount + ". Check after update.",
                            "status": "danger"});
                } else {
                    for (var i=0; i<cCount; i++) {
                        if (selC[i].label != newC[i].label) {
                            json.comp.channels = json.ok(false);
                            json.messages.push({"text": "Channel Names mismatch: should be OK.",
                                "status": "success"});
                            break;
                        }
                    }
                }

                // thumbnail
                json.newThumbSrc = WEBGATEWAYINDEX + "render_thumbnail/" + json.newImg.imageId + "/";

                $(".doSetId", this.$el).removeAttr('disabled');
            } else {
                $(".doSetId", this.$el).attr('disabled', 'disabled');
            }

            $(".previewIdChange", this.$el).html(this.template(json));
        }
    });


    var AddImagesModalView = Backbone.View.extend({

        el: $("#addImagesModal"),

        model:FigureModel,

        events: {
            "submit .addImagesForm": "addImages",
            "click .btn-primary": "addImages",
            "keyup .imgIds": "keyPressed",
            "paste .imgIds": "keyPressed",
        },

        initialize: function(options) {
            this.figureView = options.figureView;   // need this for .getCentre()

            var self = this;
            // when the modal dialog is shown, focus the input
            $("#addImagesModal").bind("focus",
                function() {
                    setTimeout(function(){
                        $('input.imgIds', self.$el).focus();
                    },20);
                });
        },

        // Only enable submit button when input has a number in it
        keyPressed: function(event) {
            var idInput = $('input.imgIds', this.$el).val(),
                submitBtn = $('button.btn-primary', this.$el),
                re = /\d.*/;
            // Strangely if 'paste' event, value is not immediately set
            // Try again after short timeout...
            if (event && event.type === "paste") {
                setTimeout(this.keyPressed, 50);
                return;
            }
            if (re.test(idInput)) {
                submitBtn.removeAttr("disabled");
            } else {
                submitBtn.attr("disabled", "disabled");
            }
        },

        // handle adding Images to figure
        addImages: function() {

            var self = this,
                iIds;

            var $input = $('input.imgIds', this.$el),
                submitBtn = $('button.btn-primary', this.$el),
                idInput = $input.val();

            $input.val("");
            submitBtn.attr("disabled", "disabled");

            if (!idInput || idInput.length === 0)    return;

            // test for E.g: http://localhost:8000/webclient/?show=image-25|image-26|image-27
            if (idInput.indexOf('?') > 10) {
                iIds = idInput.split('image-').slice(1);
            } else if (idInput.indexOf('img_detail') > 0) {
                // url of image viewer...
                this.importFromRemote(idInput);
                return;
            } else {
                iIds = idInput.split(',');
            }

            this.model.addImages(iIds);
        },

        importFromRemote: function(img_detail_url) {
            var iid = parseInt(img_detail_url.split('img_detail/')[1], 10),
                baseUrl = img_detail_url.split('/img_detail')[0],
                // http://jcb-dataviewer.rupress.org/jcb/imgData/25069/
                imgDataUrl = baseUrl + '/imgData/' + iid + "/";

            var colCount = 1,
                rowCount = 1,
                paper_width = this.model.get('paper_width'),
                c = this.figureView.getCentre(),
                col = 0,
                row = 0,
                px, py, spacer, scale,
                coords = {'px': px,
                          'py': py,
                          'c': c,
                          'spacer': spacer,
                          'colCount': colCount,
                          'rowCount': rowCount,
                          'col': col,
                          'row': row,
                          'paper_width': paper_width};

            this.model.importImage(imgDataUrl, coords, baseUrl);
        },
    });


    // -------------------------Panel View -----------------------------------
    // A Panel is a <div>, added to the #paper by the FigureView below.
    var PanelView = Backbone.View.extend({
        tagName: "div",
        className: "imagePanel",
        template: JST["src/templates/figure_panel_template.html"],
        label_template: JST["src/templates/labels/label_template.html"],
        label_vertical_template: JST["src/templates/labels/label_vertical_template.html"],
        label_table_template: JST["src/templates/labels/label_table_template.html"],
        scalebar_template: JST["src/templates/scalebar_panel_template.html"],


        initialize: function(opts) {
            // we render on Changes in the model OR selected shape etc.
            this.model.on('destroy', this.remove, this);
            this.listenTo(this.model,
                'change:x change:y change:width change:height change:zoom change:dx change:dy change:rotation',
                this.render_layout);
            this.listenTo(this.model, 'change:scalebar change:pixel_size_x', this.render_scalebar);
            this.listenTo(this.model,
                'change:zoom change:dx change:dy change:width change:height change:channels change:theZ change:theT change:z_start change:z_end change:z_projection change:min_export_dpi',
                this.render_image);
            this.listenTo(this.model, 'change:labels change:theT change:deltaT', this.render_labels);
            this.listenTo(this.model, 'change:shapes', this.render_shapes);

            // During drag or resize, model isn't updated, but we trigger 'drag'
            this.model.on('drag_resize', this.drag_resize, this);

            // Used for rendering labels against page_color background
            if (opts.page_color) {
                this.page_color = opts.page_color;
            }
            this.render();
        },

        events: {
            // "click .img_panel": "select_panel"
        },

        // During drag, we resize etc
        drag_resize: function(xywh) {
            var x = xywh[0],
                y = xywh[1],
                w = xywh[2],
                h = xywh[3];
            if (w == this.model.get('width') && h == this.model.get('height')) {
                // If we're only dragging - simply update position
                this.$el.css({'top': y +'px', 'left': x +'px'});
            } else {
                this.update_resize(x, y, w, h);
                this.render_shapes();
            }
            this.$el.addClass('dragging');
        },

        render_layout: function() {
            var x = this.model.get('x'),
                y = this.model.get('y'),
                w = this.model.get('width'),
                h = this.model.get('height');

            this.update_resize(x, y, w, h);
            this.$el.removeClass('dragging');
        },

        update_resize: function(x, y, w, h) {

            // update layout of panel on the canvas
            this.$el.css({'top': y +'px',
                        'left': x +'px',
                        'width': w +'px',
                        'height': h +'px'});

            // container needs to be square for rotation to vertical
            $('.left_vlabels', this.$el).css('width', 3 * h + 'px');

            // update the img within the panel
            var zoom = this.model.get('zoom'),
                vp_css = this.model.get_vp_img_css(zoom, w, h),
                svg_css = this.model.get_vp_full_plane_css(zoom, w, h),
                panel_scale = svg_css.width / this.model.get('orig_width');

            // If we're resizing a BIG image, layout can be buggy,
            // so we simply hide while resizing
            if (this.model.is_big_image()) {
                if (w !== this.model.get('width') || h !== this.model.get('height')) {
                    vp_css.width = 0;
                    vp_css.height = 0;
                }
            }

            // These two elements are siblings under imgContainer and must
            // will be exactly on top of each other for non-big images.
            this.$img_panel.css(vp_css);
            this.$panel_canvas.css(svg_css);

            // panel_canvas contains the shapeManager svg, which we zoom:
            if (this.shapeManager) {
                this.shapeManager.setZoom(panel_scale * 100);
            }

            // update length of scalebar
            var sb = this.model.get('scalebar');
            if (sb && sb.show) {
                // this.$scalebar.css('width':);
                var physical_length = sb.length;
                // convert units
                var pixel_unit = this.model.get('pixel_size_x_unit');
                var scalebar_unit = sb.units;
                var convert_factor = LENGTH_UNITS[scalebar_unit].microns / LENGTH_UNITS[pixel_unit].microns;
                var sb_pixels = convert_factor * physical_length / this.model.get('pixel_size_x');
                var sb_width = panel_scale * sb_pixels;
                this.$scalebar.css('width', sb_width);
            }
        },

        render_shapes: function() {
            var shapes = this.model.get('shapes'),
                w = this.model.get('orig_width'),
                h = this.model.get('orig_height');
            if (shapes) {
                // init shapeManager if doesn't exist
                if (!this.shapeManager) {
                    var canvasId = this.$panel_canvas.attr('id');
                    this.$panel_canvas.attr({'width': w + 'px', 'height': h + 'px'});
                    var panel_scale = this.$panel_canvas.width() / w;
                    this.shapeManager = new ShapeManager(canvasId, w, h, {'readOnly': true});
                    this.shapeManager.setZoom(panel_scale * 100);
                }
                this.shapeManager.setShapesJson(shapes);
            } else {
                // delete shapes
                if (this.shapeManager) {
                    this.shapeManager.deleteAllShapes();
                }
            }
        },

        render_image: function() {

            // For big images, layout changes will update src to render a new viewport
            // But we don't want the previous image showing while we wait...
            if (this.model.is_big_image()) {
                this.$img_panel.hide();
                $(".glyphicon-refresh", this.$el).show();
            }
            this.$img_panel.one("load", function(){
                $(".glyphicon-refresh", this.$el).hide();
                this.$img_panel.show();
            }.bind(this));

            var src = this.model.get_img_src();
            this.$img_panel.attr('src', src);

            // if a 'reasonable' dpi is set, we don't pixelate
            if (this.model.get('min_export_dpi') > 100) {
                this.$img_panel.removeClass('pixelated');
            } else {
                this.$img_panel.addClass('pixelated');
            }
        },

        render_labels: function() {

            $('.label_layout', this.$el).remove();  // clear existing labels

            var labels = this.model.get('labels'),
                self = this,
                positions = {
                    'top':[], 'bottom':[], 'left':[], 'right':[],
                    'leftvert':[],
                    'topleft':[], 'topright':[],
                    'bottomleft':[], 'bottomright':[]
                };

            // group labels by position
            _.each(labels, function(l) {
                // check if label is dynamic delta-T
                var ljson = $.extend(true, {}, l);
                // If label is same color as page (and is outside of panel)
                if (ljson.color.toLowerCase() == self.page_color.toLowerCase() &&
                        ["top", "bottom", "left", "right", "leftvert"].indexOf(l.position) > -1 ) {
                    // If black -> white, otherwise -> black
                    if (ljson.color === '000000') {
                        ljson.color = 'ffffff';
                    } else {
                        ljson.color = '000000';
                    }
                }
                if (typeof ljson.text == 'undefined' && ljson.time) {
                    ljson.text = self.model.get_time_label_text(ljson.time);
                } else {
                    // Markdown also escapes all labels so they are safe
                    ljson.text = markdown.toHTML(ljson.text);
                }
                positions[l.position].push(ljson);
            });

            // Render template for each position and append to Panel.$el
            var html = "";
            _.each(positions, function(lbls, p) {
                var json = {'position':p, 'labels':lbls};
                if (lbls.length === 0) return;
                if (p == 'leftvert') {  // vertical
                    html += self.label_vertical_template(json);
                } else if (p == 'left' || p == 'right') {
                    html += self.label_table_template(json);
                } else {
                    html += self.label_template(json);
                }
            });
            self.$el.append(html);

            // need to force update of vertical labels layout
            $('.left_vlabels', self.$el).css('width', 3 * self.$el.height() + 'px');

            return this;
        },

        render_scalebar: function() {

            if (this.$scalebar) {
                this.$scalebar.remove();
            }
            var sb = this.model.get('scalebar');
            if (sb && sb.show) {
                var sb_json = {};
                sb_json.position = sb.position;
                sb_json.color = sb.color;
                sb_json.length = sb.length;
                sb_json.font_size = sb.font_size;
                sb_json.show_label = sb.show_label;
                sb_json.symbol = sb.units;

                // Use global LENGTH_UNITS to get symbol for unit.
                if (window.LENGTH_UNITS && window.LENGTH_UNITS[sb.units]){
                    sb_json.symbol = window.LENGTH_UNITS[sb.units].symbol;
                }

                var sb_html = this.scalebar_template(sb_json);
                this.$el.append(sb_html);
            }
            this.$scalebar = $(".scalebar", this.$el);

            // update scalebar size wrt current sizes
            this.render_layout();
        },

        render: function() {
            // This render() is only called when the panel is first created
            // to set up the elements. It then calls other render methods to
            // set sizes, image src, labels, scalebar etc.
            // All subsequent changes to panel attributes are handled directly
            // by other appropriate render methods.

            var json = {'randomId': 'r' + Math.random()};
            var html = this.template(json);
            this.$el.html(html);

            // cache various elements for later...
            this.$img_panel = $(".img_panel", this.$el);
            this.$imgContainer = $(".imgContainer", this.$el);
            this.$panel_canvas = $(".panel_canvas", this.$el);

            // update src, layout etc.
            this.render_image();
            this.render_labels();
            this.render_scalebar();     // also calls render_layout()

            // At this point, element is not ready for Raphael svg
            // If we wait a short time, works fine
            var self = this;
            setTimeout(function(){
                self.render_shapes();
            }, 10);

            return this;
        }
    });


//
// Copyright (C) 2014 University of Dundee & Open Microscopy Environment.
// All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//

var RectView = Backbone.View.extend({

    handle_wh: 6,
    default_line_attrs: {'stroke-width':0, 'stroke': '#4b80f9', 'cursor': 'default', 'fill-opacity':0.01, 'fill': '#fff'},
    selected_line_attrs: {'stroke':'#4b80f9', 'stroke-width':2 },
    handle_attrs: {'stroke':'#4b80f9', 'fill':'#fff', 'cursor': 'default', 'fill-opacity':1.0},

    // make a child on click
    events: {
        //'mousedown': 'selectShape'    // we need to handle this more manually (see below)
    },
    initialize: function(options) {
        // Here we create the shape itself, the drawing handles and
        // bind drag events to all of them to drag/resize the rect.

        var self = this;
        this.paper = options.paper;
        this.handle_wh = options.handle_wh || this.handle_wh;
        this.handles_toFront = options.handles_toFront || false;
        this.disable_handles = options.disable_handles || false;
        this.fixed_ratio = options.fixed_ratio || false;
        // this.manager = options.manager;

        // Set up our 'view' attributes (for rendering without updating model)
        this.x = this.model.get("x");
        this.y = this.model.get("y");
        this.width = this.model.get("width");
        this.height = this.model.get("height");

        // ---- Create Handles -----
        // map of centre-points for each handle
        this.handleIds = {'nw': [this.x, this.y],
            'n': [this.x+this.width/2,this.y],
            'ne': [this.x+this.width,this.y],
            'w': [this.x, this.y+this.height/2],
            'e': [this.x+this.width, this.y+this.height/2],
            'sw': [this.x, this.y+this.height],
            's': [this.x+this.width/2, this.y+this.height],
            'se': [this.x+this.width, this.y+this.height]
        };
        // draw handles
        self.handles = this.paper.set();
        var _handle_drag = function() {
            return function (dx, dy, mouseX, mouseY, event) {
                if (self.disable_handles) return false;
                // on DRAG...

                // If drag on corner handle, retain aspect ratio. dx/dy = aspect
                var keep_ratio = self.fixed_ratio || event.shiftKey;
                if (keep_ratio && this.h_id.length === 2) {     // E.g. handle is corner 'ne' etc
                    if (this.h_id === 'se' || this.h_id === 'nw') {
                        if (Math.abs(dx/dy) > this.aspect) {
                            dy = dx/this.aspect;
                        } else {
                            dx = dy*this.aspect;
                        }
                    } else {
                        if (Math.abs(dx/dy) > this.aspect) {
                            dy = -dx/this.aspect;
                        } else {
                            dx = -dy*this.aspect;
                        }
                    }
                }
                // Use dx & dy to update the location of the handle and the corresponding point of the parent
                var new_x = this.ox + dx;
                var new_y = this.oy + dy;
                var newRect = {
                    x: this.rect.x,
                    y: this.rect.y,
                    width: this.rect.width,
                    height: this.rect.height
                };
                if (this.h_id.indexOf('e') > -1) {    // if we're dragging an 'EAST' handle, update width
                    newRect.width = new_x - self.x + self.handle_wh/2;
                }
                if (this.h_id.indexOf('s') > -1) {    // if we're dragging an 'SOUTH' handle, update height
                    newRect.height = new_y - self.y + self.handle_wh/2;
                }
                if (this.h_id.indexOf('n') > -1) {    // if we're dragging an 'NORTH' handle, update y and height
                    newRect.y = new_y + self.handle_wh/2;
                    newRect.height = this.obottom - new_y;
                }
                if (this.h_id.indexOf('w') > -1) {    // if we're dragging an 'WEST' handle, update x and width
                    newRect.x = new_x + self.handle_wh/2;
                    newRect.width = this.oright - new_x;
                }
                // Don't allow zero sized rect.
                if (newRect.width < 1 || newRect.height < 1) {
                    return false;
                }
                this.rect.x = newRect.x;
                this.rect.y = newRect.y;
                this.rect.width = newRect.width;
                this.rect.height = newRect.height;
                this.rect.model.trigger("drag_resize", [this.rect.x, this.rect.y, this.rect.width, this.rect.height]);
                this.rect.updateShape();
                return false;
            };
        };
        var _handle_drag_start = function() {
            return function () {
                if (self.disable_handles) return false;
                // START drag: simply note the location we started
                this.ox = this.attr("x");
                this.oy = this.attr("y");
                this.oright = self.width + this.ox;
                this.obottom = self.height + this.oy;
                this.aspect = self.model.get('width') / self.model.get('height');
                return false;
            };
        };
        var _handle_drag_end = function() {
            return function() {
                if (self.disable_handles) return false;
                this.rect.model.trigger('drag_resize_stop', [this.rect.x, this.rect.y,
                    this.rect.width, this.rect.height]);
                return false;
            };
        };
        var _stop_event_propagation = function(e) {
            e.stopImmediatePropagation();
        }
        for (var key in this.handleIds) {
            var hx = this.handleIds[key][0];
            var hy = this.handleIds[key][1];
            var handle = this.paper.rect(hx-self.handle_wh/2, hy-self.handle_wh/2, self.handle_wh, self.handle_wh).attr(self.handle_attrs);
            handle.attr({'cursor': key + '-resize'});     // css, E.g. ne-resize
            handle.h_id = key;
            handle.rect = self;

            handle.drag(
                _handle_drag(),
                _handle_drag_start(),
                _handle_drag_end()
            );
            handle.mousedown(_stop_event_propagation);
            self.handles.push(handle);
        }
        self.handles.hide();     // show on selection


        // ----- Create the rect itself ----
        this.element = this.paper.rect();
        this.element.attr( self.default_line_attrs );
        // set "element" to the raphael node (allows Backbone to handle events)
        this.setElement(this.element.node);
        this.delegateEvents(this.events);   // we need to rebind the events

        // Handle drag
        this.element.drag(
            function(dx, dy) {
                // DRAG, update location and redraw
                // TODO - need some way to disable drag if we're not in select state
                //if (manager.getState() !== ShapeManager.STATES.SELECT) {
                //    return;
                //}
                self.x = dx+this.ox;
                self.y = this.oy+dy;
                self.dragging = true;
                self.model.trigger("drag_xy", [dx, dy]);
                self.updateShape();
                return false;
            },
            function() {
                // START drag: note the location of all points (copy list)
                this.ox = this.attr('x');
                this.oy = this.attr('y');
                return false;
            },
            function() {
                // STOP: save current position to model
                self.model.trigger('drag_xy_stop', [self.x-this.ox, self.y-this.oy]);
                self.dragging = false;
                return false;
            }
        );

        // If we're starting DRAG, don't let event propogate up to dragdiv etc.
        // https://groups.google.com/forum/?fromgroups=#!topic/raphaeljs/s06GIUCUZLk
        this.element.mousedown(function(e){
             e.stopImmediatePropagation();
             self.selectShape(e);
        });

        this.updateShape();  // sync position, selection etc.

        // Finally, we need to render when model changes
        this.model.on('change', this.render, this);
        this.model.on('destroy', this.destroy, this);

    },

    // render updates our local attributes from the Model AND updates coordinates
    render: function(event) {
        if (this.dragging) return;
        this.x = this.model.get("x");
        this.y = this.model.get("y");
        this.width = this.model.get("width");
        this.height = this.model.get("height");
        this.updateShape();
    },

    // used to update during drags etc. Also called by render()
    updateShape: function() {
        this.element.attr({'x':this.x, 'y':this.y, 'width':this.width, 'height':this.height});

        // TODO Draw diagonals on init - then simply update here (show if selected)
        // var path1 = "M" + this.x +","+ this.y +"l"+ this.width +","+ this.height,
        //     path2 = "M" + (this.x+this.width) +","+ this.y +"l-"+ this.width +","+ this.height;
        //     // rectangle plus 2 diagonal lines
        //     this.paper.path(path1).attr('stroke', '#4b80f9');
        //     this.paper.path(path2).attr('stroke', '#4b80f9');

        // if (this.manager.selected_shape_id === this.model.get("id")) {
        if (this.model.get('selected')) {
            this.element.attr( this.selected_line_attrs );  //.toFront();
            var self = this;
            // If several Rects get selected at the same time, one with handles_toFront will
            // end up with the handles at the top
            if (this.handles_toFront) {
                setTimeout(function(){
                    self.handles.show().toFront();
                },50);
            } else {
                this.handles.show().toFront();
            }
        } else {
            this.element.attr( this.default_line_attrs );    // this should be the shapes OWN line / fill colour etc.
            this.handles.hide();
        }

        this.handleIds = {'nw': [this.x, this.y],
        'n': [this.x+this.width/2,this.y],
        'ne': [this.x+this.width,this.y],
        'w': [this.x, this.y+this.height/2],
        'e': [this.x+this.width, this.y+this.height/2],
        'sw': [this.x, this.y+this.height],
        's': [this.x+this.width/2, this.y+this.height],
        'se': [this.x+this.width, this.y+this.height]};
        var hnd, h_id, hx, hy;
        for (var h=0, l=this.handles.length; h<l; h++) {
            hnd = this.handles[h];
            h_id = hnd.h_id;
            hx = this.handleIds[h_id][0];
            hy = this.handleIds[h_id][1];
            hnd.attr({'x':hx-this.handle_wh/2, 'y':hy-this.handle_wh/2});
        }
    },

    selectShape: function(event) {
        // pass back to model to update all selection
        this.model.trigger('clicked', [event]);
    },

    // Destroy: remove Raphael elements and event listeners
    destroy: function() {
        this.element.remove();
        this.handles.remove();
        this.model.off('change', this.render, this);
    }
});


    // The 'Right Panel' is the floating Info, Preview etc display.
    // It listens to selection changes on the FigureModel and updates it's display
    // By creating new Sub-Views

    var RightPanelView = Backbone.View.extend({

        initialize: function(opts) {
            // we render on selection Changes in the model
            this.listenTo(this.model, 'change:selection', this.render);

            // this.render();
            new LabelsPanelView({model: this.model});
            new SliderButtonsView({model: this.model});
            new RoisFormView({model: this.model});
        },

        render: function() {
            var selected = this.model.getSelected();

            if (this.vp) {
                this.vp.clear().remove();
                delete this.vp;     // so we don't call clear() on it again.
            }
            if (selected.length > 0) {
                this.vp = new ImageViewerView({models: selected, figureModel: this.model}); // auto-renders on init
                $("#viewportContainer").append(this.vp.el);
            }

            if (this.ipv) {
                this.ipv.remove();
            }
            if (selected.length > 0) {
                this.ipv = new InfoPanelView({models: selected, figureModel: this.model});
                this.ipv.render();
                $("#infoTab").append(this.ipv.el);
            }

            if (this.ctv) {
                this.ctv.clear().remove();
            }
            if (selected.length > 0) {
                this.ctv = new ImageDisplayOptionsView({models: selected});
                $("#channelToggle").empty().append(this.ctv.render().el);
            }
            if (this.csv) {
                this.csv.clear().remove();
            }
            if (selected.length > 0) {
                this.csv = new ChannelSliderView({models: selected});
                $("#channel_sliders").empty().append(this.csv.render().el);
            }
        }
    });


    var RoisFormView = Backbone.View.extend({

        model: FigureModel,

        roisTemplate: JST["src/templates/rois_form_template.html"],

        el: $("#labelsTab"),

        initialize: function(opts) {
            this.listenTo(this.model, 'change:selection', this.render);
            this.listenTo(this.model, 'change:selection', this.addListeners);
            this.render();
        },

        addListeners: function() {
            // when selection changes, we need to listen to selected panels
            var self = this;
            this.model.getSelected().forEach(function(m){
                self.listenTo(m, 'change:shapes', self.render);
            });
        },

        events: {
            "click .edit_rois": "editRois",
            "click .copyROIs": "copyROIs",
            "click .pasteROIs": "pasteROIs",
            "click .deleteROIs": "deleteROIs",
            // triggered by select_dropdown_option below
            "change .shape-color": "changeROIColor",
            "change .line-width": "changeLineWidth",
        },

        changeLineWidth: function() {
            var width = $('button.line-width span:first', this.$el).attr('data-line-width'),
                sel = this.model.getSelected();
            width = parseFloat(width, 10);

            sel.forEach(function(panel){
                panel.setROIStrokeWidth(width);
            });
        },

        changeROIColor: function() {
            var color = $('button.shape-color span:first', this.$el).attr('data-color'),
                sel = this.model.getSelected();

            sel.forEach(function(panel){
                panel.setROIColor(color);
            });
        },

        copyROIs: function(event) {
            event.preventDefault();
            var sel = this.model.getSelected(),
                roiJson = [];

            sel.forEach(function(s){
                var rois = s.get('shapes');
                if (rois) {
                    rois.forEach(function(r){
                        roiJson.push($.extend(true, {}, r));
                    });
                }
            });
            if (roiJson.length > 0) {
                this.model.set('clipboard', {'SHAPES': roiJson});
            }
            this.render();
        },

        rectToPolygon: function(rect, rotation) {
            // rotate Rect around centre point - return points "x,y, x,y, x,y, x,y"
            let cx = rect.x + (rect.width / 2);
            let cy = rect.y + (rect.height / 2);
            // topleft
            let tl = rotatePoint(rect.x, rect.y, cx, cy, rotation);
            // topright
            let tr = rotatePoint(rect.x + rect.width, rect.y, cx, cy, rotation);
            // bottomright
            let br = rotatePoint(rect.x + rect.width, rect.y + rect.height, cx, cy, rotation);
            // bottomleft
            let bl = rotatePoint(rect.x, rect.y + rect.height, cx, cy, rotation);
            return [tl, tr, br, bl].map(point => point.x + ',' + point.y).join(', ');
        },

        pasteROIs: function(event) {
            event.preventDefault();
            var sel = this.model.getSelected(),
                roiJson = this.model.get('clipboard'),
                allOK = true;
            if (!roiJson) {
                return;
            }
            // Paste ROIs onto each selected panel...
            if (roiJson.SHAPES) {
                roiJson = roiJson.SHAPES;
            } else if (roiJson.CROP) {
                // Need to create Rectangle with current color & line width
                var color = $('button.shape-color span:first', this.$el).attr('data-color'),
                    width = parseFloat($('button.line-width span:first', this.$el).attr('data-line-width')),
                    rect = roiJson.CROP;
                // If rotated, need to create a Polygon since Rectangle doesn't support rotation
                if (rect.rotation && !isNaN(rect.rotation)) {
                    // rotate around centre point
                    var points = this.rectToPolygon(rect, -rect.rotation);
                    roiJson = [{
                        type: "Polygon",
                        strokeColor: "#" + color,
                        points: points,
                        strokeWidth: width
                    }]
                } else {
                    roiJson = [{type: "Rectangle",
                            x: rect.x,
                            y: rect.y,
                            width: rect.width,
                            height: rect.height,
                            strokeColor: "#" + color,
                            strokeWidth: width}];
                }
            } else {
                return;
            }
            sel.forEach(function(p){
                var ok = p.add_shapes(roiJson);
                if (!ok) {allOK = false;}
            });
            // If any shapes were outside viewport, show message
            var plural = sel.length > 1 ? "s" : "";
            if (!allOK) {
                figureConfirmDialog("Paste Failure",
                    "Some shapes may be outside the visible 'viewport' of panel" + plural + ". " +
                    "Target image" + plural + " may be too small or zoomed in too much. " +
                    "Try zooming out before pasting again, or paste to a bigger image.",
                    ["OK"]);
            }
            this.render();
        },

        deleteROIs: function(event) {
            event.preventDefault();
            var sel = this.model.getSelected();
            sel.forEach(function(p){
                var ok = p.unset('shapes');
            });
            this.render();
        },

        editRois: function(event) {
            $("#roiModal").modal("show");
            return false;
        },

        render: function() {

            var sel = this.model.getSelected(),
                panelCount = this.model.getSelected().length,
                roiCount = 0,
                clipboard_data = this.model.get('clipboard'),
                canPaste = clipboard_data && ('SHAPES' in clipboard_data || 'CROP' in clipboard_data),
                color,
                width;

            sel.forEach(function(panel){
                var rois = panel.get('shapes');
                if (rois) {
                    roiCount += rois.length;
                    // color & width are false unless all rois are same
                    rois.forEach(function(r){
                        if (color === undefined) {
                            color = r.strokeColor;
                        } else {
                            if (color != r.strokeColor) {
                                color = false;
                            }
                        }
                        if (width === undefined) {
                            width = r.strokeWidth;
                        } else {
                            if (width != r.strokeWidth) {
                                width = false;
                            }
                        }
                    });
                }
            });

            var json = {
                'panelCount': panelCount,
                'color': color ? color.replace('#', '') : 'FFFFFF',
                'lineWidth': width || 2,
                'roiCount': roiCount,
                'canPaste': canPaste,
            }
            $('#edit_rois_form').html(this.roisTemplate(json));
        },

    });


    var LabelsPanelView = Backbone.View.extend({

        model: FigureModel,

        template: JST["src/templates/labels_form_inner_template.html"],

        el: $("#labelsTab"),

        initialize: function(opts) {
            this.listenTo(this.model, 'change:selection', this.render);

            // one-off build 'New Label' form, with same template as used for 'Edit Label' forms
            var json = {'l': {'text':'', 'size':12, 'color':'000000'}, 'position':'top', 'edit':false};
            $('.new-label-form', this.$el).html(this.template(json));
            $('.btn-sm').tooltip({container: 'body', placement:'bottom', toggle:"tooltip"});

            this.render();
        },

        events: {
            "submit .new-label-form": "handle_new_label",
            "click .dropdown-menu a": "select_dropdown_option",
            "click .markdown-info": "markdownInfo",
        },

        markdownInfo: function(event) {
            event.preventDefault();
            $("#markdownInfoModal").modal('show');
        },

        // Handles all the various drop-down menus in the 'New' AND 'Edit Label' forms
        // AND for ROI form (since this is also under the #labelsTab)
        select_dropdown_option: function(event) {
            event.preventDefault();
            var $a = $(event.target),
                $span = $a.children('span');
            // For the Label Text, handle this differently...
            if ($a.attr('data-label')) {
                $('.new-label-form .label-text', this.$el).val( $a.attr('data-label') );
                return;
            }
            // All others, we take the <span> from the <a> and place it in the <button>
            if ($span.length === 0) $span = $a;  // in case we clicked on <span>
            var $li = $span.parent().parent();
            // Don't use $li.parent().prev() since bootstrap inserts a div.dropdown-backdrop on Windows
            var $button = $("button.dropdown-toggle", $li.parent().parent());
            $span = $span.clone();

            if ($span.hasClass('colorpickerOption')) {
                var oldcolor = $a.attr('data-oldcolor');
                FigureColorPicker.show({
                    'color': oldcolor,
                    'success': function(newColor){
                        $span.css({'background-color': newColor, 'background-image': 'none'});
                        // remove # from E.g. #ff00ff
                        newColor = newColor.replace("#", "");
                        $span.attr('data-color', newColor);
                        $('span:first', $button).replaceWith($span);
                        // can listen for this if we want to 'submit' etc
                        $button.trigger('change');
                    }
                });
            } else {
                $('span:first', $button).replaceWith($span);
                $button.trigger('change');      // can listen for this if we want to 'submit' etc
            }
        },

        // submission of the New Label form
        handle_new_label: function(event) {
            var $form = $(event.target),
                label_text = $('.label-text', $form).val(),
                font_size = $('.font-size', $form).text().trim(),
                position = $('.label-position span:first', $form).attr('data-position'),
                color = $('.label-color span:first', $form).attr('data-color');

            if (label_text.length === 0) {
                alert("Please enter some text for the label");
                return false;
            }

            var selected = this.model.getSelected();

            if (label_text == '[channels]') {
                selected.forEach(function(m) {
                    m.create_labels_from_channels({position:position, size:font_size});
                });
                return false;
            }

            if (label_text.slice(0, 5) == '[time') {
                var format = label_text.slice(6, -1);   // 'secs', 'hrs:mins' etc
                selected.forEach(function(m) {
                    m.create_labels_from_time({format: format,
                            position:position,
                            size:font_size,
                            color: color
                    });
                });
                return false;
            }

            if (label_text == '[key-values]') {
                // Load Map Annotations for this image and create labels
                $("#labelsFromMapAnns").modal("show", {
                    position:position,
                    size:font_size,
                    color: color});
                return false;
            }

            if (label_text == '[tags]') {
                // Load Tags for this image and create labels

                selected.createLabelsFromTags({
                            position:position,
                            size:font_size,
                            color: color});
                return false;
            }

            var label = {
                text: label_text,
                size: parseInt(font_size, 10),
                position: position,
                color: color
            };

            selected.forEach(function(m) {
                if (label_text === "[image-name]") {
                    var pathnames = m.get('name').split('/');
                    label.text = pathnames[pathnames.length-1];
                } else if (label_text === "[dataset-name]") {
                    label.text = m.get('datasetName') ? m.get('datasetName') : "No/Many Datasets";
                }
                m.add_labels([label]);
            });
            return false;
        },

        render: function() {

            var selected = this.model.getSelected();

            // html is already in place for 'New Label' form - simply show/hide
            if (selected.length === 0) {
                $(".new-label-form", this.$el).hide();
            } else {
                $(".new-label-form", this.$el).show();
                // if none of the selected panels have time data, disable 'add_time_label's
                var have_time = false, dTs;
                selected.forEach(function(p){
                    dTs = p.get('deltaT');
                    if (dTs && dTs.length > 0) {
                        have_time = true;
                    }
                });
                if (have_time) {
                    $(".add_time_label", this.$el).removeClass('disabled');
                } else {
                    $(".add_time_label", this.$el).addClass('disabled');
                }
            }

            // show selected panels labels below
            var old = this.sel_labels_panel;

            if (selected.length > 0) {
                this.sel_labels_panel = new SelectedPanelsLabelsView({models: selected});
                this.sel_labels_panel.render();
                $("#selected_panels_labels").empty().append(this.sel_labels_panel.$el);
            }
            if (old) {
                old.remove();
            }

            // show scalebar form for selected panels
            var old_sb = this.scalebar_form;
            // if (old_sb) {
            //     old_sb.remove();
            // }
            var $scalebar_form = $("#scalebar_form");

            if (selected.length > 0) {
                this.scalebar_form = new ScalebarFormView({models: selected});
                this.scalebar_form.render();
                $scalebar_form.empty().append(this.scalebar_form.$el);
            }
            if (old_sb) {
                old_sb.remove();
            }

            return this;
        }

    });


    // Created new for each selection change
    var SelectedPanelsLabelsView = Backbone.View.extend({

        template: JST["src/templates/labels_form_template.html"],
        inner_template: JST["src/templates/labels_form_inner_template.html"],

        initialize: function(opts) {

            // prevent rapid repetative rendering, when listening to multiple panels
            this.render = _.debounce(this.render);

            this.models = opts.models;
            var self = this;

            this.models.forEach(function(m){
                self.listenTo(m, 'change:labels change:theT', self.render);
            });
        },

        events: {
            "submit .edit-label-form": "handle_label_edit",
            "change .btn": "form_field_changed",
            "blur .label-text": "form_field_changed",
            "click .delete-label": "handle_label_delete",
        },

        handle_label_delete: function(event) {

            var $form = $(event.target).parent(),
                key = $form.attr('data-key'),
                deleteMap = {};

            // escape the key to handle 'single' and "double" quotes
            key = _.escape(key);
            deleteMap[key] = false;

            this.models.forEach(function(m){
                m.edit_labels(deleteMap);
            });
            return false;
        },

        // Automatically submit the form when a field is changed
        form_field_changed: function(event) {
            $(event.target).closest('form').submit();
        },

        // Use the label 'key' to specify which labels to update
        handle_label_edit: function(event) {

            var $form = $(event.target),
                label_text = $('.label-text', $form).val(),
                font_size = $('.font-size', $form).text().trim(),
                position = $('.label-position span:first', $form).attr('data-position'),
                color = $('.label-color span:first', $form).attr('data-color'),
                key = $form.attr('data-key');

            // the 'key' will now be unescaped, so we need to escape it again to compare with model
            key = _.escape(key);
            var new_label = {text:label_text, size:font_size, position:position, color:color};

            // if we're editing a 'time' label, preserve the 'time' attribute
            if (label_text.slice(0, 5) == '[time') {
                new_label.text = undefined;                 // no 'text'
                new_label.time = label_text.slice(6, -1);   // 'secs', 'hrs:mins' etc
            }

            var newlbls = {};
            newlbls[key] = new_label;

            this.models.forEach(function(m){
                m.edit_labels(newlbls);
            });
            return false;
        },

        render: function() {

            var self = this,
                positions = {'top':{}, 'bottom':{}, 'left':{}, 'leftvert':{}, 'right':{},
                    'topleft':{}, 'topright':{}, 'bottomleft':{}, 'bottomright':{}};
            this.models.forEach(function(m){
                // group labels by position
                _.each(m.get('labels'), function(l) {
                    // remove duplicates by mapping to unique key
                    var key = m.get_label_key(l),
                        ljson = $.extend(true, {}, l);
                        ljson.key = key;
                    if (typeof ljson.text == 'undefined' && ljson.time) {
                        // show time labels as they are in 'new label' form
                        ljson.text = '[time-' + ljson.time + "]"
                    }
                    positions[l.position][key] = ljson;
                });
            });

            this.$el.empty();

            // Render template for each position and append to $el
            var html = "";
            _.each(positions, function(lbls, p) {

                lbls = _.map(lbls, function(label, key){ return label; });

                var json = {'position':p, 'labels':lbls};
                if (lbls.length === 0) return;
                json.inner_template = self.inner_template;
                html += self.template(json);
            });
            self.$el.append(html);

            return this;
        }
    });


    // This simply handles buttons to increment time/z
    // since other views don't have an appropriate container
    var SliderButtonsView = Backbone.View.extend({

        el: $("#viewportContainer"),

        initialize: function(opts) {
            this.model = opts.model;
        },

        events: {
            "click .z-increment": "z_increment",
            "click .z-decrement": "z_decrement",
            "click .time-increment": "time_increment",
            "click .time-decrement": "time_decrement",
        },

        z_increment: function(event) {
            this.model.getSelected().forEach(function(m){
                var newZ = {};
                if (m.get('z_projection')) {
                    newZ.z_start = m.get('z_start') + 1;
                    newZ.z_end = m.get('z_end') + 1;
                } else {
                    newZ.theZ = m.get('theZ') + 1;
                }
                m.set(newZ, {'validate': true});
            });
            return false;
        },
        z_decrement: function(event) {
            this.model.getSelected().forEach(function(m){
                var newZ = {};
                if (m.get('z_projection')) {
                    newZ.z_start = m.get('z_start') - 1;
                    newZ.z_end = m.get('z_end') - 1;
                } else {
                    newZ.theZ = m.get('theZ') - 1;
                }
                m.set(newZ, {'validate': true});
            });
            return false;
        },
        time_increment: function(event) {
            this.model.getSelected().forEach(function(m){
                m.set({'theT': m.get('theT') + 1}, {'validate': true});
            });
            return false;
        },
        time_decrement: function(event) {
            this.model.getSelected().forEach(function(m){
                m.set({'theT': m.get('theT') - 1}, {'validate': true});
            });
            return false;
        },
    });


    var ImageViewerView = Backbone.View.extend({

        template: JST["src/templates/viewport_template.html"],
        inner_template: JST["src/templates/viewport_inner_template.html"],

        className: "imageViewer",

        initialize: function(opts) {

            // prevent rapid repetative rendering, when listening to multiple panels
            this.render = _.debounce(this.render);
            this.figureModel = opts.figureModel;

            this.full_size = 250;

            this.models = opts.models;
            var self = this;

            this.models.forEach(function(m){
                self.listenTo(m,
                    'change:width change:height change:rotation change:z_projection change:z_start change:z_end change:min_export_dpi',
                    self.render);
                self.listenTo(m,
                    'change:channels change:theZ change:theT',
                    self.rerender_image_change);
            });


            $("#vp_zoom_slider").slider({
                // NB: these values are updated on render()
                max: 1000,
                min: 100,
                value: self.zoom_avg,
                slide: function(event, ui) {
                    self.update_img_css(ui.value, 0, 0);
                },
                stop: function( event, ui ) {
                    self.zoom_avg = ui.value;
                    var to_save = {'zoom': ui.value};
                    if (ui.value === 100) {
                        to_save.dx = 0;
                        to_save.dy = 0;
                    }
                    self.models.forEach(function(m){
                        m.save(to_save);
                    });
                    // we don't listenTo zoom change...
                    self.rerender_image_change();
                }
            });
            this.$vp_zoom_value = $("#vp_zoom_value");

            // We nest the ZoomView so we can update it on update_img_css
            this.zmView = new ZoomView({models: this.models,
                                        figureModel: this.figureModel}); // auto-renders on init
            $("#reset-zoom-view").append(this.zmView.el);

            this.render();
        },

        events: {
            "mousedown .vp_img": "mousedown",
            "mousemove .vp_img": "mousemove",
            "mouseup .vp_img": "mouseup",
        },

        mousedown: function(event) {
            this.dragging = true;
            this.dragstart_x = event.clientX;
            this.dragstart_y = event.clientY;
            this.r = this.models.head().get('rotation');
            return false;
        },

        mouseup: function(event) {
            var dx = event.clientX - this.dragstart_x,
                dy = event.clientY - this.dragstart_y;
            if (this.r !== 0) {
                var xy = this.correct_rotation(dx, dy);
                dx = xy.dx;
                dy = xy.dy;
            }
            // Save then re-render
            this.update_img_css(this.zoom_avg, dx, dy, true);
            // Need to re-render for BIG images
            this.rerender_image_change();
            this.dragging = false;
            return false;
        },

        mousemove: function(event) {
            if (this.dragging) {
                var dx = event.clientX - this.dragstart_x,
                    dy = event.clientY - this.dragstart_y;
                if (this.r !== 0) {
                    var xy = this.correct_rotation(dx, dy);
                    dx = xy.dx;
                    dy = xy.dy;
                }
                this.update_img_css(this.zoom_avg, dx, dy);
            }
            return false;
        },

        // if the panel is rotated by css, drag events need to be corrected
        correct_rotation: function(dx, dy) {
            if (dx === 0 && dy === 0) {
                return {'dx': dx, 'dy': dy};
            }
            var length = Math.sqrt(dx * dx + dy * dy),
                ang1 = Math.atan(dy/dx);
            if (dx < 0) {
                ang1 = Math.PI + ang1;
            }
            var angr = this.r * (Math.PI/180),  // deg -> rad
                ang2 = ang1 - angr;
            dx = Math.cos(ang2) * length;
            dy = Math.sin(ang2) * length;
            return {'dx': dx, 'dy': dy};
        },

        // called by the parent View before .remove()
        clear: function() {
            // clean up zoom slider etc
            $( "#vp_zoom_slider" ).slider( "destroy" );
            $("#vp_z_slider").slider("destroy");
            $("#vp_t_slider").slider("destroy");
            this.$vp_zoom_value.text('');

            if (this.zmView) {
                this.zmView.remove();
                delete this.zmView;
            }
            return this;
        },

        // This forces All panels in viewport to have SAME css
        // while zooming / dragging.
        // TODO: Update each panel separately.
        update_img_css: function(zoom, dx, dy, save) {

            var scaled_dx = dx / (zoom/100);
            var scaled_dy = dy / (zoom/100);

            var big_image = this.models.reduce(function(prev, m){
                return prev || m.is_big_image();
            }, false);

            var avg_dx = this.models.getAverage('dx'),
                avg_dy = this.models.getAverage('dy');

            if (this.$vp_img) {
                var frame_w = this.$vp_frame.width() + 2,
                    frame_h = this.$vp_frame.height() + 2,
                    zm_w = this.models.head().get('orig_width') / frame_w,
                    zm_h = this.models.head().get('orig_height') / frame_h,
                    scale = Math.min(zm_w, zm_h);
                scaled_dx = scaled_dx * scale;
                scaled_dy = scaled_dy * scale;
                scaled_dx += avg_dx;
                scaled_dy += avg_dy;

                var offset_x = scaled_dx;
                var offset_y = scaled_dy;
                if (big_image) {
                    // For big images, we simply offset the image that fills the viewport
                    offset_x = dx;
                    offset_y = dy;
                }
                this.$vp_img.css( this.models.head().get_vp_img_css(zoom, frame_w, frame_h, offset_x, offset_y) );
                this.$vp_zoom_value.text(zoom + "%");

                if (save) {
                    if (typeof dx === "undefined") dx = 0;  // rare crazy-dragging case!
                    if (typeof dy === "undefined") dy = 0;
                    this.models.forEach(function(m){
                        m.save({'dx': scaled_dx,
                                'dy': scaled_dy});
                    });
                }
            }

            this.zmView.renderXYWH(zoom, scaled_dx, scaled_dy);
        },

        formatTime: function(seconds) {

            if (typeof seconds === 'undefined') {
                return "";
            }
            var isNegative = seconds < 0;
            seconds = Math.abs(seconds);
            function leftPad(s) {
                s = s + '';
                if (s.split('.')[0].length === 1) return '0' + s;
                return s;
            }
            var hours = parseInt(seconds / 3600);
            var mins = parseInt(seconds % 3600 / 60);
            var secs = (seconds % 60).toFixed(2);
            return (isNegative ? '-' : '') + leftPad(hours) + ":" + leftPad(mins) + ":" + leftPad(secs);
        },

        get_imgs_css: function() {
            // Get img src & positioning css for each panel,
            var imgs_css = [];
            var wh = this.models.getAverageWH();
            if (wh <= 1) {
                var frame_h = this.full_size;
                var frame_w = this.full_size * wh;
            } else {
                var frame_w = this.full_size;
                var frame_h = this.full_size / wh;
            }
            this.models.forEach(function(m){
                var src = m.get_img_src();
                var img_css = m.get_vp_img_css(m.get('zoom'), frame_w, frame_h);
                img_css.src = src;
                // if a 'reasonable' dpi is set, we don't pixelate
                var dpiSet = m.get('min_export_dpi') > 100;
                img_css.pixelated = !dpiSet;
                imgs_css.push(img_css);
            });
            return imgs_css;
        },

        rerender_image_change: function() {
            // Render a change to image without removing old image
            // by adding new image over top!
            var json = {};
            json.imgs_css = this.get_imgs_css();
            json.opacity = 1/json.imgs_css.length;
            var html = this.inner_template(json);
            // We add this over the top of existing images
            // So they remain visible while new image is loading
            // If we're viewing multiple images (with opacity) we
            // don't want to see through to old images
            if (json.imgs_css.length > 1) {
                this.$vp_img.remove();
            }
            this.$vp_frame.append(html);
            // update this to include new images
            this.$vp_img = $(".vp_img", this.$el);

            this.render_z_t_labels();
        },

        render_z_t_labels: function() {
            // Rendering Z/T labels without rendering whole component
            // means we don't re-build z/t sliders (they don't lose focus)
            var sizeZ = this.models.getIfEqual('sizeZ');
            var sizeT = this.models.getIfEqual('sizeT');
            var theT = this.models.getAverage('theT');
            var theZ = this.models.getAverage('theZ');
            var z_projection = this.models.allTrue('z_projection');
            var deltaT = this.models.getDeltaTIfEqual();

            var z_label = theZ + 1;
            if (z_projection) {
                var z_start = Math.round(this.models.getAverage('z_start'));
                var z_end = Math.round(this.models.getAverage('z_end'));
                z_label = (z_start + 1) + "-" + (z_end + 1);
            } else if (!this.models.allEqual('theZ')) {
                z_label = "-";
            }
            $("#vp_z_slider").slider({'value': theZ + 1});
            $("#vp_z_value").text(z_label + "/" + (sizeZ || '-'));

            var t_label = theT + 1;
            var dt_label;
            if (!this.models.allEqual('theT')) {
                t_label = "-";
            }
            $("#vp_t_slider").slider({'value': theT + 1});
            $("#vp_t_value").text(t_label + "/" + (sizeT || '-'));

            if ((deltaT === 0 || deltaT) && sizeT > 1) {
                dt_label = this.formatTime(deltaT);
            } else {
                dt_label = "";
            }
            $("#vp_deltaT").text(dt_label);
        },

        render: function() {

            // render child view
            this.zmView.render();

            // only show viewport if original w / h ratio is same for all models
            var model = this.models.head(),
                self = this;
            var imgs_css;

            // get average viewport frame w/h & zoom
            var wh = this.models.getAverageWH(),
                zoom = this.models.getAverage('zoom'),
                theZ = this.models.getAverage('theZ'),
                z_start = Math.round(this.models.getAverage('z_start')),
                z_end = Math.round(this.models.getAverage('z_end')),
                theT = this.models.getAverage('theT'),
                // deltaT = sum_deltaT/this.models.length,
                sizeZ = this.models.getIfEqual('sizeZ'),
                sizeT = this.models.getIfEqual('sizeT'),
                deltaT = this.models.getDeltaTIfEqual(),
                z_projection = this.models.allTrue('z_projection');

            if (wh <= 1) {
                var frame_h = this.full_size;
                var frame_w = this.full_size * wh;
            } else {
                var frame_w = this.full_size;
                var frame_h = this.full_size / wh;
            }

            imgs_css = this.get_imgs_css();

            // update sliders
            var Z_disabled = false,
                Z_max = sizeZ;
            if (!sizeZ || sizeZ === 1) {    // undefined or 1
                Z_disabled = true;
                Z_max = 1;
            }

            // Destroy any existing slider...
            try {
                // ...but will throw if not already a slider
                $("#vp_z_slider").slider("destroy");
            } catch (e) {}

            if (z_projection) {
                $("#vp_z_slider").slider({
                    orientation: "vertical",
                    range: true,
                    max: Z_max,
                    disabled: Z_disabled,
                    min: 1,             // model is 0-based, UI is 1-based
                    values: [z_start + 1, z_end + 1],
                    slide: function(event, ui) {
                        $("#vp_z_value").text(ui.values[0] + "-" + ui.values[1] + "/" + sizeZ);
                    },
                    stop: function( event, ui ) {
                        self.models.forEach(function(m){
                            m.save({
                                'z_start': ui.values[0] - 1,
                                'z_end': ui.values[1] -1
                            });
                        });
                    }
                });
            } else {
                $("#vp_z_slider").slider({
                    orientation: "vertical",
                    max: sizeZ,
                    disabled: Z_disabled,
                    min: 1,             // model is 0-based, UI is 1-based
                    value: theZ + 1,
                    slide: function(event, ui) {
                        $("#vp_z_value").text(ui.value + "/" + sizeZ);
                    },
                    stop: function( event, ui ) {
                        self.models.forEach(function(m){
                            m.save('theZ', ui.value - 1);
                        });
                    }
                });
            }

            // T-slider should be enabled even if we have a mixture of sizeT values.
            // Slider T_max is the minimum of sizeT values
            // Slider value is average of theT values (but smaller than T_max)
            var T_disabled = false,
                T_slider_max = self.models.getMin('sizeT');
            if (T_slider_max === 1) {
                T_disabled = true;
            }
            var t_slider_value = Math.min(theT, T_slider_max);
            // in case it's already been initialised:
            try {
                $("#vp_t_slider").slider("destroy");
            } catch (e) {}

            $("#vp_t_slider").slider({
                max: T_slider_max,
                disabled: T_disabled,
                min: 1,             // model is 0-based, UI is 1-based
                value: t_slider_value + 1,
                slide: function(event, ui) {
                    var theT = ui.value;
                    $("#vp_t_value").text(theT + "/" + (sizeT || '-'));
                    var dt = self.models.head().get('deltaT')[theT-1];
                    self.models.forEach(function(m){
                        if (m.get('deltaT')[theT-1] != dt) {
                            dt = undefined;
                        }
                    });
                    $("#vp_deltaT").text(self.formatTime(dt));
                },
                stop: function( event, ui ) {
                    self.models.forEach(function(m){
                        m.save('theT', ui.value - 1);
                    });
                }
            });

            var json = {};
            json.inner_template = this.inner_template;
            json.opacity = 1 / imgs_css.length;
            json.imgs_css = imgs_css;
            json.frame_w = frame_w;
            json.frame_h = frame_h;

            // We render without z/t labels...
            var html = this.template(json);
            this.$el.html(html);
            // ...then update them dynamically 
            this.render_z_t_labels();

            this.$vp_frame = $(".vp_frame", this.$el);  // cache for later
            this.$vp_img = $(".vp_img", this.$el);
            this.zoom_avg = zoom >> 0;
            this.$vp_zoom_value.text(this.zoom_avg + "%");
            // zoom may be > 1000 if set by 'crop'

            // We want to be able to zoom Big images to 'actual size' in viewport
            // e.g. where 250 pixels of image is shown in viewport
            var max_width = this.models.getMax('orig_width');
            var max_zoom = parseInt(max_width / 250) * 100;
            max_zoom = Math.max(this.zoom_avg, max_zoom, 1000);

            // Current zoom may be larger due to small crop region
            $("#vp_zoom_slider").slider({'value': this.zoom_avg,
                                         'max': max_zoom});

            return this;
        }
    });


    var ZoomView = Backbone.View.extend({

        template: JST["src/templates/zoom_crop_template.html"],

        initialize: function(opts) {

            this.models = opts.models;
            this.figureModel = opts.figureModel;
            this.render();
        },

        events: {
            "click .reset-zoom-shape": "resetZoomShape",
            "click .crop-btn": "show_crop_dialog",
            "click .copyCropRegion": "copyCropRegion",
            "click .pasteCropRegion": "pasteCropRegion",
        },

        copyCropRegion: function(event) {
            event.preventDefault();
            var rect = this.getXYWH();
            // Shouldn't happen, but just in case...
            if ([rect.x, rect.y, rect.width, rect.height].indexOf("-") > -1) {
                alert("Failed to copy region");
                return;
            }
            this.figureModel.set('clipboard', {'CROP': rect});
        },

        pasteCropRegion: function(event) {
            event.preventDefault();
            var clipboard_data = this.figureModel.get('clipboard'),
                rect;
            if (!clipboard_data) return;

            // First check clipboard for CROP
            if ('CROP' in clipboard_data){
                rect = clipboard_data.CROP;
            } else if ('SHAPES' in clipboard_data){
                // Look for first Rectangle in SHAPES
                shapeJson = clipboard_data.SHAPES;
                shapeJson.forEach(function(shape) {
                    if (!rect && shape.type === "Rectangle") {
                        rect = {x: shape.x, y: shape.y, width: shape.width, height: shape.height};
                    }
                });
                if (!rect) {
                    alert("No Rectangle found in shapes copied to clipboard");
                    return;
                }
            }

            this.models.forEach(function(m){
                m.cropToRoi(rect);
            });
        },

        show_crop_dialog: function(event) {
            event.preventDefault();
            // Simply show dialog - Everything else handled by that
            $("#cropModal").modal('show');
        },

        resetZoomShape: function(event) {
            event.preventDefault();
            this.models.forEach(function(m){
                m.cropToRoi({
                    'x': 0,
                    'y': 0,
                    'width': m.get('orig_width'),
                    'height': m.get('orig_height')
                });
            });
        },

        getXYWH: function(zoom, dx, dy) {

            var x, y, w, h, rotation;
            this.models.forEach(function(m, i){
                var r = m.getViewportAsRect(zoom, dx, dy);
                if (i === 0) {
                    x = r.x;
                    y = r.y;
                    w = r.width;
                    h = r.height;
                    rotation = r.rotation;
                } else {
                    // if any models have different values, return '-'
                    if (x !== r.x) x = "-";
                    if (y !== r.y) y = "-";
                    if (w !== r.width) w = "-";
                    if (h !== r.height) h = "-";
                    if (rotation !== r.rotation) rotation = "-";
                }
            });
            var json = {
                x: (x !== "-" ? parseInt(x, 10) : x),
                y: (y !== "-" ? parseInt(y, 10) : y),
                width: (w !== "-" ? parseInt(w, 10) : w),
                height: (h !== "-" ? parseInt(h, 10) : h),
                rotation: (rotation !== "-" ? parseInt(rotation, 10) : "-"),
            }
            return json;
        },

        // called from the parent view during zoom slider update
        renderXYWH: function(zoom, dx, dy) {

            var json = this.getXYWH(zoom, dx, dy),
                clipboard = this.figureModel.get('clipboard');
            json.canCopyRect = true;
            json.canPasteRect = (clipboard && ('CROP' in clipboard || 'SHAPES' in clipboard));

            if ([json.x, json.y, json.w, json.h].indexOf("-") > -1) {
                json.canCopyRect = false;
            }
            this.$el.html(this.template(json));
        },

        render: function() {
            this.renderXYWH();
        }
    });

    // Options such as Rotation, Z-Projection etc.
    var ImageDisplayOptionsView = Backbone.View.extend({
        tagName: "div",
        template: JST["src/templates/image_display_options_template.html"],

        initialize: function(opts) {
            // This View may apply to a single PanelModel or a list
            this.models = opts.models;
            var self = this;
            this.models.forEach(function(m){
                self.listenTo(m, 'change:channels change:z_projection', self.render);
            });
        },

        events: {
            "click .show-rotation": "show_rotation",
            "click .z-projection": "z_projection",
        },

        z_projection:function(e) {
            // 'flat' means that some panels have z_projection on, some off
            var flat = $(e.currentTarget).hasClass('ch-btn-flat');
            this.models.forEach(function(m){
                var p;
                if (flat) {
                    p = true;
                } else {
                    p = !m.get('z_projection');
                }
                m.set_z_projection(p);
            });
        },

        show_rotation: function(e) {
            var $rc = this.$el.find('.rotation-controls').toggleClass('rotation-controls-shown'),
                self = this;

            if ($rc.hasClass('rotation-controls-shown')) {
                $rc.find('.rotation-slider').slider({
                    orientation: "vertical",
                    max: 360,
                    min: 0,
                    step: 2,
                    value: self.rotation,
                    slide: function(event, ui) {
                        $(".vp_img").css({'-webkit-transform':'rotate(' + ui.value + 'deg)',
                                        'transform':'rotate(' + ui.value + 'deg)'});
                        $(".rotation_value").text(ui.value);
                    },
                    stop: function( event, ui ) {
                        self.rotation = ui.value;
                        self.models.forEach(function(m){
                            m.save('rotation', ui.value);
                        });
                    }
                });
            } else {
                $rc.find('.rotation-slider').slider("destroy");
            }
        },

        clear: function() {
            try {
                this.$el.find('.rotation-slider').slider("destroy");
            } catch (e) {}
            return this;
        },

        render: function() {
            var html,
                max_rotation = 0,
                sum_rotation = 0,
                sum_sizeZ = 0,
                rotation,
                z_projection,
                zp,
                self = this;
            if (this.models) {
                this.models.forEach(function(m, i){
                    rotation = m.get('rotation');
                    max_rotation = Math.max(max_rotation, rotation);
                    sum_rotation += rotation;
                    sum_sizeZ += m.get('sizeZ');
                    // start with a copy of the first image channels
                    if (i === 0) {
                        z_projection = !!m.get('z_projection');
                    } else{
                        zp = !!m.get('z_projection');
                        if (zp !== z_projection) {
                            z_projection = undefined;
                        }
                    }
                });
                var avg_rotation = sum_rotation / this.models.length;
                if (avg_rotation === max_rotation) {
                    rotation = avg_rotation;
                } else {
                    rotation = "-";
                }
                // save this value to init rotation slider etc
                this.rotation = avg_rotation;

                var anyBig = this.models.any(function(m){return m.is_big_image()});
                // if all panels have sizeZ == 1, don't allow z_projection
                // Don't currently support Z_projection on Big images.
                z_projection_disabled = ((sum_sizeZ === this.models.length) || anyBig);

                html = this.template({
                    'z_projection_disabled': z_projection_disabled,
                    'rotation': rotation,
                    'z_projection': z_projection});
                this.$el.html(html);
            }
            return this;
        }
    });


var RoiLoaderView = Backbone.View.extend({

    tagName: 'tbody',

    template: JST["src/templates/modal_dialogs/roi_modal_roi.html"],
    shapeTemplate: JST["src/templates/modal_dialogs/roi_modal_shape.html"],

    initialize: function(options) {
        this.render = _.debounce(this.render);
        this.totalCount = options.totalCount;
        this.panel = options.panel;
        this.listenTo(this.collection, "add", this.render);
    },

    events: {
        "mouseover .roiModalRoiItem": "mouseoverRoiItem",
        "mouseout .roiModalRoiItem": "mouseoutRoiItem",
        "click .roiModalRoiItem": "clickRoiItem",
        "click .addOmeroShape": "addOmeroShape",
    },

    roiIcons: {'Rectangle': 'rect-icon',
               'Ellipse': 'ellipse-icon',
               'Line': 'line-icon',
               'Arrow': 'arrow-icon',
               'Polygon': 'polygon-icon',
               'Polyline': 'polyline-icon'},

    addOmeroShape: function(event) {
        var $tr = $(event.target);
        // $tr.parentsUntil(".roiModalRoiItem")  DIDN'T work!
        // Do it manually...
        while (!$tr.hasClass("roiModalRoiItem")) {
            $tr = $tr.parent();
        }
        // If ROI has a single shape, add it
        if ($tr.attr('data-shapeId')) {
            var shapeId = parseInt($tr.attr('data-shapeId'), 10);
            var shape = this.collection.getShape(shapeId);
            var shapeJson = shape.toJSON();
            this.collection.trigger('shape_add', [shapeJson]);
        }
    },

    removeShapes: function(roiId) {
        var roiData = this.collection.get(roiId).toJSON();
        roiData.shapes.forEach(function(s){
            $(".roiModalRoiItem[data-shapeId='" + s.id + "']", this.$el).remove();
        });
    },

    renderShapes: function(roiId) {
        var roi = this.collection.get(roiId);
        var shapesJson = roi.shapes.map(function(shapeModel){
            var s = shapeModel.toJSON();
            s.tooltip = this.getTooltip(s);
            s.icon = this.roiIcons[s.type];
            return s;
        }.bind(this));
        var html = this.shapeTemplate({'shapes': shapesJson});
        $(".roiModalRoiItem[data-roiId='" + roiId + "']", this.$el).after(html);
    },

    clickRoiItem: function(event) {
        var $tr = $(event.target);
        // $tr.parentsUntil(".roiModalRoiItem")  DIDN'T work!
        // Do it manually...
        while (!$tr.hasClass("roiModalRoiItem")) {
            $tr = $tr.parent();
        }
        // If ROI has a single shape, add it
        if ($tr.attr('data-shapeId')) {
            var shapeId = parseInt($tr.attr('data-shapeId'), 10);
            var shape = this.collection.getShape(shapeId);
            var shapeJson = shape.toJSON();
            this.collection.trigger('shape_click', [shapeJson]);
        } else {
            // Otherwise toggle ROI (show/hide shapes)
            var roiId = parseInt($tr.attr('data-roiId'), 10);
            var $span = $('.toggleRoi', $tr).toggleClass('rotate90');
            if ($span.hasClass('rotate90')) {
                this.renderShapes(roiId);
            } else {
                this.removeShapes(roiId);
            }
        }
    },

    mouseoverRoiItem: function(event) {
        var $tr = $(event.target);
        while (!$tr.hasClass("roiModalRoiItem")) {
            $tr = $tr.parent();
        }
        var shapeId = parseInt($tr.attr('data-shapeId'), 10);
        this.collection.selectShape(shapeId);
    },

    mouseoutRoiItem: function(event) {
        // Simply select nothing
        this.collection.selectShape();
    },

    getTooltip: function(shape) {
        var coords = [];
        if (shape.type === 'Rectangle') {
            coords = ['x', 'y', 'width', 'height'];
        } else if (shape.type === 'Ellipse') {
            coords = ['x', 'y', 'radiusX', 'radiusY'];
        } else if (shape.type === 'Line' || shape.type == 'Arrow') {
            coords = ['x1', 'y1', 'x2', 'y2'];
        }
        coords = coords.map(function(c){
            return c + ": " + shape[c];
        });
        return 'ID: ' + shape.id + ' ' + coords.join(" ")
    },

    render: function() {

        var roiData = this.collection;  //.toJSON();
        this.newPlaneCount = 0;

        var json = roiData.map(function(roi){
            var roiJson = {id: roi.get('id'),
                           shapes: []},
                minT, maxT = 0,
                minZ, maxZ = 0;
            if (roi.shapes && roi.shapes.length > 0) {
                roiJson.shapes = roi.shapes.map(function(shapeModel){
                    var s = shapeModel.convertOMEROShape();
                    s.icon = this.roiIcons[s.type];
                    if (s.theZ !== undefined) {
                        if (minZ === undefined) {
                            minZ = s.theZ
                        } else {
                            minZ = Math.min(minZ, s.theZ);
                        }
                        maxZ = Math.max(maxZ, s.theZ);
                    }
                    if (s.theT !== undefined) {
                        if (minT === undefined) {
                            minT = s.theT
                        } else {
                            minT = Math.min(minT, s.theT);
                        }
                        maxT = Math.max(maxT, s.theT);
                    }
                    s.tooltip = this.getTooltip(s);
                    return s;
                }.bind(this));
            } else {
                return
            }

            roiJson.type = roiJson.shapes[0].type;
            roiJson.icon = roiJson.shapes[0].icon;
            roiJson.minZ = minZ;
            roiJson.maxZ = maxZ;
            roiJson.minT = minT;
            roiJson.maxT = maxT;

            return roiJson;
        }.bind(this));

        // remove any rois from above with no shapes
        json = json.filter(function(j) {return j});

        var html = this.template({
            rois: json,
            showLoadMore: this.totalCount > this.collection.length,
            totalCount: this.totalCount,
            count: this.collection.length,
        });
        this.$el.html(html);

        return this;
    }
});


var RoiModalView = Backbone.View.extend({

        template: JST["src/templates/shapes/shape_toolbar_template.html"],

        roi_zt_buttons_template: JST["src/templates/modal_dialogs/roi_zt_buttons.html"],

        el: $("#roiModal"),

        model:FigureModel,

        // ID for temp shape that we add & remove from shapeManager
        TEMP_SHAPE_ID: -1234,

        // This gets populated when dialog loads
        omeroRoiCount: 0,
        roisLoaded: false,
        roisPageSize: 500,
        roisPage: 0,

        initialize: function() {

            var self = this;

            // We create a new Model and RoiLoaderView.
            // Then listen for selection events etc coming from RoiLoaderView
            this.Rois = new RoiList();
            this.listenTo(this.Rois, "change:selection", this.showTempShape);  // mouseover shape
            this.listenTo(this.Rois, "shape_add", this.addShapeFromOmero);
            this.listenTo(this.Rois, "shape_click", this.showShapePlane);

            // We manually bind Mousetrap keyboardEvents to body so as
            // not to clash with the global keyboardEvents in figure_view.js
            // Bind to 'body' instead of #roiModal since this didn't always work with
            // some events maybe getting lost to Raphael elements??
            var dialog = document.getElementById('body');
            Mousetrap(dialog).bind(['backspace', 'del'], function(event, combo) {
                // Need to ignore if the dialog isn't visible
                if(!self.$el.is(":visible")) return true;
                self.deleteShapes(event);
                return false;
            });
            Mousetrap(dialog).bind('mod+c', function(event, combo) {
                if(!self.$el.is(":visible")) return true;
                self.copyShapes(event);
                return false;
            });
            Mousetrap(dialog).bind('mod+v', function(event, combo) {
                if(!self.$el.is(":visible")) return true;
                self.pasteShapes(event);
                return false;
            });
            Mousetrap(dialog).bind('mod+a', function(event, combo) {
                if(!self.$el.is(":visible")) return true;
                self.selectAllShapes(event);
                return false;
            });

            // Here we handle init of the dialog when it's shown...
            $("#roiModal").bind("show.bs.modal", function(){
                // Clone the 'first' selected panel as our reference for everything
                self.m = self.model.getSelected().head().clone();

                // We don't support Shape editing when rotated!
                self.rotated = self.m.get('rotation') !== 0;
                self.m.set('rotation', 0);

                self.shapeManager.setState("SELECT");
                self.shapeManager.deleteAllShapes();

                // Load any existing shapes on panel
                var shapesJson = self.m.get('shapes');
                if (shapesJson) {
                    self.shapeManager.setShapesJson(shapesJson);
                }

                // Default line width
                self.shapeManager.setStrokeWidth(1);

                // remove any previous OMERO ROIs
                $("#roiModalRoiList table").empty();
                self.roisLoaded = false;

                self.render();
                self.checkForRois();
                self.renderPagination();
            });

            this.shapeManager = new ShapeManager("roi_paper", 1, 1);
            self.shapeManager.setStrokeColor('#FFFFFF');

            this.$roiImg = $('.roi_image', this.$el);
        },

        events: {
            "submit .roiModalForm": "handleRoiForm",
            "click .shape-option .btn": "selectState",
            "click .dropdownSelect a": "select_dropdown_option",
            "change .line-width": "changeLineWidth",
            "change .shape-color": "changeColor",
            // shapeManager triggers on canvas element
            "change:selected .roi_paper": "shapeSelected",
            "new:shape .roi_paper": "shapeSelected",
            "click .copyShape": "copyShapes",
            "click .pasteShape": "pasteShapes",
            "click .deleteShape": "deleteShapes",
            "click .selectAll": "selectAllShapes",
            "click .loadRois": "loadRoisFirstPage",
            "click .roisPrevPage": "roisPrevPage",
            "click .roisNextPage": "roisNextPage",
            "click .roisJumpPage": "roisJumpPage",
            "click .revert_theZ": "revertTheZ",
            "click .revert_theT": "revertTheT",
        },

        revertTheZ: function() {
            var orig_model = this.model.getSelected().head();
            this.m.set('theZ', orig_model.get('theZ'));
            this.renderImagePlane();
        },

        revertTheT: function() {
            var orig_model = this.model.getSelected().head();
            this.m.set('theT', orig_model.get('theT'));
            this.renderImagePlane();
        },

        checkForRois: function() {
            var url = BASE_WEBFIGURE_URL + 'roiCount/' + this.m.get('imageId') + '/';

            var $btn = $(".loadRois", this.$el)
                .attr({'disabled': 'disabled'});
            $btn.parent().attr('title', 'Checking for ROIs...');  // title on parent div - still works if btn disabled
            $.getJSON(url, function(data){
                this.omeroRoiCount = data.roi;
                this.renderToolbar();
            }.bind(this));
        },

        loadRoisFirstPage: function (event) {
            event.preventDefault();
            // hide button and tip
            $(".loadRois", this.$el).prop('disabled', true);
            $("#roiModalTip").hide();
            this.roisPage = 0;
            this.loadRois();
        },

        roisPrevPage: function (event) {
            event.preventDefault();
            this.roisPage -= 1;
            this.loadRois();
        },

        roisNextPage: function (event) {
            event.preventDefault();
            this.roisPage += 1;
            this.loadRois();
        },

        roisJumpPage: function (event) {
            event.preventDefault();
            var page = $(event.target).data('page');
            if (!isNaN(page)) {
                this.roisPage = parseInt(page);
                this.loadRois();
            }
        },

        // Load Shapes from OMERO and render them
        loadRois: function() {
            var iid = this.m.get('imageId');
            var roiUrl = ROIS_JSON_URL + '?image=' + iid + '&limit=' + this.roisPageSize + '&offset=' + (this.roisPageSize * this.roisPage);
            $.getJSON(roiUrl, function(data){
                this.Rois.set(data.data);
                $(".loadRois", this.$el).prop('disabled', false);
                $("#roiModalRoiList table").empty();
                this.roisLoaded = true;
                this.renderToolbar();
                var roiLoaderView = new RoiLoaderView({ collection: this.Rois, panel: this.m, totalCount: this.omeroRoiCount});
                $("#roiModalRoiList table").append(roiLoaderView.el);
                roiLoaderView.render();
                this.renderPagination();
            }.bind(this))
            .fail(function(jqxhr, textStatus, error){
                console.log("fail", arguments);
                alert("Failed to load ROIS: " + textStatus + ", " + error);
            });
        },

        showShapePlane: function(args) {
            var shapeJson = args[0];
            if (shapeJson) {
                var newPlane = {};
                if (shapeJson.theZ !== undefined) {
                    newPlane.theZ = shapeJson.theZ;
                }
                if (shapeJson.theT !== undefined) {
                    newPlane.theT = shapeJson.theT;
                }
                this.m.set(newPlane);
                this.renderImagePlane();
            }
        },

        addShapeFromOmero: function(args) {

            var shapeJson = args[0],
                shape;
            // Remove the temp shape
            this.shapeManager.deleteShapesByIds([this.TEMP_SHAPE_ID]);

            // Paste (will offset if shape exists)
            var viewport = this.m.getViewportAsRect();
            shape = this.shapeManager.pasteShapesJson([shapeJson], viewport);
            if (!shape) {
                alert("Couldn't add shape outside of current view. Try zooming out.");
            }
        },

        showTempShape: function(args) {
            shape = args[0];
            this.shapeManager.deleteShapesByIds([this.TEMP_SHAPE_ID]);
            if (shape) {
                var viewport = this.m.getViewportAsRect();
                shape.id = this.TEMP_SHAPE_ID;
                var ok = this.shapeManager.addShapeJson(shape, viewport);
            }
        },

        copyShapes: function(event) {
            event.preventDefault();
            var shapeJson = this.shapeManager.getSelectedShapesJson();
            if (shapeJson.length > 0) {
                this.model.set('clipboard', {'SHAPES': shapeJson});
            }
            this.renderToolbar();    // to enable paste
        },

        pasteShapes: function(event) {
            event.preventDefault();
            var roiJson = this.model.get('clipboard');
            if (roiJson.SHAPES) {
                roiJson = roiJson.SHAPES;
            } else if (roiJson.CROP) {
                // Need to create Rectangle with current color & line width
                var color = $(".roi_toolbar .shape-color span:first", this.$el).attr('data-color'),
                    width = $(".roi_toolbar .line-width span:first", this.$el).attr('data-line-width'),
                    rect = roiJson.CROP;
                roiJson = [{type: "Rectangle",
                            x: rect.x,
                            y: rect.y,
                            width: rect.width,
                            height: rect.height,
                            strokeColor: "#" + color,
                            lineWidth: width}];
            } else {
                return;
            }

            // paste ROIs, with offset if matching existing shape
            // Constrain pasting to within viewport
            var viewport = this.m.getViewportAsRect();
            var p = this.shapeManager.pasteShapesJson(roiJson, viewport);
            if (!p) {
                this.renderSidebarWarning("Could not paste ROI outside viewport.");
            }
        },

        deleteShapes: function(event) {
            event.preventDefault();
            this.shapeManager.deleteSelectedShapes();
        },

        handleRoiForm: function(event) {
            event.preventDefault();

            var shapesJson = this.shapeManager.getShapesJson();
            shapesJson = shapesJson.filter(function(s){
                // Remove any temporary shapes (from hovering over OMERO shapes)
                return (s.id !== this.TEMP_SHAPE_ID);
            }.bind(this));

            var theZ = this.m.get('theZ'),
                theT = this.m.get('theT');
            this.model.getSelected().forEach(function(panel){

                // We use save() to notify undo/redo queue. TODO - fix!
                panel.save({'shapes': shapesJson, 'theZ': theZ, 'theT': theT});
            });

            $("#roiModal").modal("hide");
            return false;
        },

        shapeSelected: function() {
            // simply re-render toolbar
            this.renderToolbar();
        },

        changeLineWidth: function(event) {
            var lineWidth = $("span:first", event.target).attr('data-line-width');
            lineWidth = parseFloat(lineWidth, 10);
            this.shapeManager.setStrokeWidth(lineWidth);
        },

        changeColor: function(event) {
            var color = $("span:first", event.target).attr('data-color');
            this.shapeManager.setStrokeColor("#" + color);
        },

        // Handles all the various drop-down menus in the toolbar
        select_dropdown_option: function(event) {
            event.preventDefault();
            var $a = $(event.target),
                $span = $a.children('span');
            // Take the <span> from the <a> and place it in the <button>
            if ($span.length === 0) $span = $a;  // in case we clicked on <span>
            var $li = $span.parent().parent();
            // Don't use $li.parent().prev() since bootstrap inserts a div.dropdown-backdrop on Windows
            var $button = $("button.dropdown-toggle", $li.parent().parent());
            $span = $span.clone();

            if ($span.hasClass('colorpickerOption')) {
                var oldcolor = $a.attr('data-oldcolor');
                FigureColorPicker.show({
                    'color': oldcolor,
                    'success': function(newColor){
                        $span.css({'background-color': newColor, 'background-image': 'none'});
                        // remove # from E.g. #ff00ff
                        newColor = newColor.replace("#", "");
                        $span.attr('data-color', newColor);
                        $('span:first', $button).replaceWith($span);
                        // can listen for this if we want to 'submit' etc
                        $button.trigger('change');
                    }
                });
            } else {
                $('span:first', $button).replaceWith($span);
                if ($span.prop('title')) {
                    $button.prop('title', $span.prop('title'));
                }
                $button.trigger('change');      // can listen for this if we want to 'submit' etc
            }
        },

        selectAllShapes: function(event) {
            event.preventDefault();
            // manager triggers shapeSelected, which renders toolbar
            this.shapeManager.selectAllShapes();
        },

        selectState: function(event) {
            var $target = $(event.target),
                newState = $target.attr('data-state');
            if (newState === undefined) {
                // in case we clicked 'span'
                newState = $target.parent().attr('data-state');
            }
            this.shapeManager.setState(newState);
            this.renderToolbar();
        },

        // we disable Submit when dialog is shown, enable when region/ROI chosen
        enableSubmit: function(enabled) {
            var $okBtn = $('button[type="submit"]', this.$el);
            if (enabled) {
                $okBtn.prop('disabled', false);
                $okBtn.prop('title', 'Crop selected images to chosen region');
            } else {
                $okBtn.prop('disabled', 'disabled');
                $okBtn.prop('title', 'No valid region selected');
            }
        },

        renderToolbar: function() {
            // render toolbar
            var state = this.shapeManager.getState(),
                lineW = this.shapeManager.getStrokeWidth(),
                color = this.shapeManager.getStrokeColor(),
                scale = this.zoom,
                sel = this.shapeManager.getSelectedShapes().length > 0,
                toPaste = this.model.get('clipboard'),
                windows = navigator.platform.toUpperCase().indexOf('WIN') > -1,
                lineWidths = [0.25, 0.5, 0.75, 1, 2, 3, 4, 5, 7, 10, 15, 20, 30];
            color = color ? color.replace("#", "") : 'FFFFFF';
            toPaste = (toPaste && (toPaste.SHAPES || toPaste.CROP));

            var json = {'state': state,
                        'lineWidths': lineWidths,
                        'lineWidth': lineW,
                        'color': color,
                        'sel': sel,
                        'cmdKey': windows ? "Ctrl+" : "⌘",
                        'toPaste': toPaste,
                        'zoom': parseInt(scale * 100, 10),
                        'omeroRoiCount': this.omeroRoiCount,
                        'roisLoaded': this.roisLoaded};
            $(".roi_toolbar", this.$el).html(this.template(json));
        },

        renderSidebarWarning: function(text) {
            var html = "<p><span class='label label-warning'>Warning</span> " + text + "</p>";
            $("#roiModalTip").html(html).show().fadeOut(10000);
        },

        // this is called each time the ROI dialog is displayed
        renderSidebar: function() {
            var tips = [
                // "Add ROIs to the image panel by choosing Rectangle, Line, Arrow or Ellipse from the toolbar.",
                "You can copy and paste shapes to duplicate them or move them between panels.",
                "If you copy a region from the Crop dialog (under the 'Preview' tab), you can paste it here to create a new Rectangle."],
                tip;
            if (this.rotated) {
                tip = "<span class='label label-warning'>Warning</span> " +
                      "This image panel is rotated in the figure, but this ROI editor can't work with rotated images. " +
                      "The image is displayed here <b>without</b> rotation, but the ROIs you add will be applied " +
                      "correctly to the image panel in the figure.";
            } else {
                tip = "<span class='label label-primary'>Tip</span> " + tips[parseInt(Math.random() * tips.length, 10)];
            }
            $("#roiModalTip").show().html(tip);
        },

        // for rendering bounding-box viewports for shapes
        getBboxJson: function(bbox, theZ, theT) {
            var size = 50;   // longest side
            var orig_width = this.m.get('orig_width'),
                orig_height = this.m.get('orig_height');
                // origT = this.m.get('theT'),
                // origZ = this.m.get('theZ');
            // theT = (theT !== undefined ? theT : this.m.get('theT'))
            var div_w, div_h;
            // get src for image by temp setting Z & T
            if (theT !== undefined) this.m.set('theT', bbox.theT, {'silent': true});
            if (theZ !== undefined) this.m.set('theZ', bbox.theZ, {'silent': true});
            var src = this.m.get_img_src();
            if (bbox.width > bbox.height) {
                div_w = size;
                div_h = (bbox.height/bbox.width) * div_w;
            } else {
                div_h = size;
                div_w = (bbox.width/bbox.height) * div_h;
            }
            var zoom = div_w/bbox.width;
            var img_w = orig_width * zoom;
            var img_h = orig_height * zoom;
            var top = -(zoom * bbox.y);
            var left = -(zoom * bbox.x);
            // bbox.theT = bbox.theT !== undefined ? bbox.theT : origT;
            // bbox.theZ = bbox.theZ !== undefined ? bbox.theZ : origZ;

            return {
                'src': src,
                'w': div_w,
                'h': div_h,
                'top': top,
                'left': left,
                'img_w': img_w,
                'img_h': img_h
            };
        },

        renderImagePlane: function() {
            var src = this.m.get_img_src();
            this.$roiImg.attr('src', src);

            var orig_model = this.model.getSelected().head();
            var json = {'theZ': this.m.get('theZ'),
                        'theT': this.m.get('theT'),
                        'origZ': orig_model.get('theZ'),
                        'origT': orig_model.get('theT')}
            var html = this.roi_zt_buttons_template(json);
            $("#roi_zt_buttons").html(html);
        },

        renderPagination: function() {
            if (!this.roisLoaded) {
                $("#roiPageControls").html("").hide();
                return;
            }
            var pageCount = Math.ceil(this.omeroRoiCount / this.roisPageSize);
            var html = `<span>${ this.omeroRoiCount} ROIs`
            // Only show pagination controls if needed
            if (pageCount > 1) {
                html += `: page ${ this.roisPage + 1}/${pageCount}</span>
                <div class="btn-group" style="float:right">
                    <button title="Load previous page of ROIs" ${this.roisPage === 0 ? "disabled='disabled'":'' }
                        type="button" class="btn btn-default btn-sm roisPrevPage">
                        Prev
                    </button>
                    <button title="Load next page of ROIs" ${(this.roisPage + 1) >= pageCount ? "disabled='disabled'" : '' }
                        type="button" class="btn btn-default btn-sm roisNextPage">
                        Next
                    </button>
                    <button type="button" class="btn btn-default btn-sm dropdown-toggle" title="Select page" data-toggle="dropdown">
                        <span class="caret"></span>
                    </button>
                    <ul class="dropdown-menu" role="menu">
                    ${
                        _.range(pageCount).map(p => `<li>
                        <a class="roisJumpPage" href="#" data-page="${p}">
                            <span class="glyphicon glyphicon-ok" ${ this.roisPage !== p ? "style='visibility:hidden'" : ""}></span>
                            Page ${p + 1}
                        </a>
                        </li>`).join(`\n`)
                    }
                    </ul>
                </div>`
            } else {
                html += `</span>`;
            }
            $("#roiPageControls").html(html).show();
        },

        render: function() {

            var maxSize = 550,
                frame_w = maxSize,
                frame_h = maxSize,
                wh = this.m.get('width') / this.m.get('height');
            if (wh <= 1) {
                frame_h = maxSize;
                frame_w = maxSize * wh;
            } else {
                frame_w = maxSize;
                frame_h = maxSize / wh;
            }

            // Get css for the image plane
            var css = this.m.get_vp_img_css(this.m.get('zoom'), frame_w, frame_h);
            this.$roiImg.css(css);

            // Get css for the SVG (full plane)
            var svg_css = this.m.get_vp_full_plane_css(this.m.get('zoom'), frame_w, frame_h);
            var w = this.m.get('orig_width'),
                h = this.m.get('orig_height');
            var scale = svg_css.width / w;
            // TODO: add public methods to set w & h
            this.shapeManager._orig_width = w;
            this.shapeManager._orig_height = h;
            this.shapeManager.setZoom(scale * 100);
            $("#roi_paper").css(svg_css);

            $("#roiViewer").css({'width': frame_w + 'px', 'height': frame_h + 'px'});

            this.renderImagePlane();
            this.renderToolbar();
            this.renderSidebar();
        }
    });


// Created new for each selection change
var ScalebarFormView = Backbone.View.extend({

    template: JST["src/templates/scalebar_form_template.html"],

    initialize: function(opts) {

        // prevent rapid repetative rendering, when listening to multiple panels
        this.render = _.debounce(this.render);

        this.models = opts.models;
        var self = this;

        this.models.forEach(function(m){
            self.listenTo(m, 'change:scalebar change:pixel_size_x change:scalebar_label', self.render);
        });

        // this.$el = $("#scalebar_form");
    },

    events: {
        "submit .scalebar_form": "update_scalebar",
        "click .scalebar_label": "update_scalebar",
        "change .btn": "dropdown_btn_changed",
        "click .hide_scalebar": "hide_scalebar",
        "click .pixel_size_display": "edit_pixel_size",
        "keypress .pixel_size_input"  : "enter_pixel_size",
        "blur .pixel_size_input"  : "save_pixel_size",
    },

    // simply show / hide editing field
    edit_pixel_size: function() {
        $('.pixel_size_display', this.$el).hide();
        $(".pixel_size_input", this.$el).css('display','inline-block').focus();
    },
    done_pixel_size: function() {
        $('.pixel_size_display', this.$el).show();
        $(".pixel_size_input", this.$el).css('display','none').focus();
    },

    // If you hit `enter`, set pixel_size
    enter_pixel_size: function(e) {
        if (e.keyCode == 13) {
            this.save_pixel_size(e);
        }
    },

    // on 'blur' or 'enter' we save...
    save_pixel_size: function(e) {
        // save will re-render, but only if number has changed - in case not...
        this.done_pixel_size();

        var val = $(e.target).val();
        if (val.length === 0) return;
        var pixel_size = parseFloat(val);
        if (isNaN(pixel_size)) return;
        this.models.forEach(function(m){
            m.save('pixel_size_x', pixel_size);
        });
    },

    // Automatically submit the form when a dropdown is changed
    dropdown_btn_changed: function(event) {
        $(event.target).closest('form').submit();
    },

    hide_scalebar: function() {
        this.models.forEach(function(m){
            m.hide_scalebar();
        });
    },

    // called when form changes
    update_scalebar: function(event) {

        var $form = $('#scalebar_form form');

        var length = $('.scalebar-length', $form).val(),
            units = $('.scalebar-units span:first', $form).attr('data-unit'),
            position = $('.label-position span:first', $form).attr('data-position'),
            color = $('.label-color span:first', $form).attr('data-color'),
            show_label = $('.scalebar_label', $form).prop('checked'),
            font_size = $('.scalebar_font_size span:first', $form).text().trim();

        this.models.forEach(function(m){
            var old_sb = m.get('scalebar');
            var sb = {show: true};
            if (length != '-') sb.length = parseFloat(length, 10);
            if (units != '-') {
                sb.units = units;
            } else {
                // various images have different units
                // keep existing scalebar units OR use image pixel_size units
                if (old_sb && old_sb.units) {
                    sb.units = old_sb.units;
                } else if (m.get('pixel_size_x_unit')) {
                    sb.units = m.get('pixel_size_x_unit');
                } else {
                    sb.units = "MICROMETER";
                }
            }
            if (position != '-') sb.position = position;
            if (color != '-') sb.color = color;
            sb.show_label = show_label;
            if (font_size != '-') sb.font_size = font_size;

            m.save_scalebar(sb);
        });
        return false;
    },

    render: function() {
        var json = {show: false, show_label: true},
            hidden = false,
            sb;

        // Turn dict into list of units we can sort by size
        var scalebarUnits = ["PICOMETER", "ANGSTROM", "NANOMETER", "MICROMETER",
            "MILLIMETER", "CENTIMETER", "METER", "KILOMETER", "MEGAMETER"]
        var unit_symbols = Object.keys(window.LENGTH_UNITS)
            .filter(function(unit){
                return (scalebarUnits.indexOf(unit) > -1);
            })
            .map(function(unit){
                return $.extend({unit: unit}, window.LENGTH_UNITS[unit]);
            });
        unit_symbols.sort(function(a, b){
            return a.microns > b.microns ? 1 : -1;
        })
        json.unit_symbols = unit_symbols;

        this.models.forEach(function(m){
            // start with json data from first Panel
            if (!json.pixel_size_x) {
                json.pixel_size_x = m.get('pixel_size_x');
                json.pixel_size_symbol = m.get('pixel_size_x_symbol');
                json.pixel_size_unit = m.get('pixel_size_x_unit');
            } else {
                pix_sze = m.get('pixel_size_x');
                // account for floating point imprecision when comparing
                if (json.pixel_size_x != '-' &&
                    json.pixel_size_x.toFixed(10) != pix_sze.toFixed(10)) {
                        json.pixel_size_x = '-';
                }
                if (json.pixel_size_symbol != m.get('pixel_size_x_symbol')) {
                    json.pixel_size_symbol = '-';
                }
                if (json.pixel_size_unit != m.get('pixel_size_x_unit')) {
                    json.pixel_size_unit = '-';
                }
            }
            sb = m.get('scalebar');
            // if panel has scalebar, combine into json
            if (sb) {
                // for first panel, json = sb
                if (!json.length) {
                    json.length = sb.length;
                    json.units = sb.units;
                    json.position = sb.position;
                    json.color = sb.color;
                    json.show_label = sb.show_label;
                    json.font_size = sb.font_size;
                }
                else {
                    // combine attributes. Use '-' if different values found
                    if (json.length != sb.length) json.length = '-';
                    if (json.units != sb.units) json.units = '-';
                    if (json.position != sb.position) json.position = '-';
                    if (json.color != sb.color) json.color = '-';
                    if (!sb.show_label) json.show_label = false;
                    if (json.font_size != sb.font_size) json.font_size = '-';
                }
            }
            // if any panels don't have scalebar - we allow to add
            if(!sb || !sb.show) hidden = true;
        });

        if (this.models.length === 0 || hidden) {
            json.show = true;
        }
        json.length = json.length || 10;
        // If no units chosen, use pixel size units
        json.units = json.units || json.pixel_size_unit;
        json.units_symbol = '-';
        if (json.units !== '-') {
            // find the symbol e.g. 'mm' from units 'MILLIMETER'
            json.units_symbol = LENGTH_UNITS[json.units].symbol;
        }
        json.position = json.position || 'bottomright';
        json.color = json.color || 'FFFFFF';
        json.font_size = json.font_size || 10;
        json.pixel_size_symbol = json.pixel_size_symbol || '-';

        var html = this.template(json);
        this.$el.html(html);

        return this;
    }
});

    // -------------- Selection Overlay Views ----------------------


    // SvgView uses ProxyRectModel to manage Svg Rects (raphael)
    // This converts between zoomed coordiantes of the html DOM panels
    // and the unzoomed SVG overlay.
    // Attributes of this model apply to the SVG canvas and are updated from
    // the PanelModel.
    // The SVG RectView (Raphael) notifies this Model via trigger 'drag' & 'dragStop'
    // and this is delegated to the PanelModel via trigger or set respectively.

    // Used by a couple of different models below
    var getModelCoords = function(coords) {
        var zoom = this.figureModel.get('curr_zoom') * 0.01,
            size = this.figureModel.getFigureSize(),
            paper_top = (this.figureModel.get('canvas_height') - size.h)/2,
            paper_left = (this.figureModel.get('canvas_width') - size.w)/2,
            x = (coords.x/zoom) - paper_left - 1,
            y = (coords.y/zoom) - paper_top - 1,
            w = coords.width/zoom,
            h = coords.height/zoom;
        return {'x':x>>0, 'y':y>>0, 'width':w>>0, 'height':h>>0};
    };

    var ProxyRectModel = Backbone.Model.extend({

        initialize: function(opts) {
            this.panelModel = opts.panel;    // ref to the genuine PanelModel
            this.figureModel = opts.figure;

            this.renderFromModel();

            // Refresh c
            this.listenTo(this.figureModel, 'change:curr_zoom change:paper_width change:paper_height change:page_count', this.renderFromModel);
            this.listenTo(this.panelModel, 'change:x change:y change:width change:height', this.renderFromModel);
            // when PanelModel is being dragged, but NOT by this ProxyRectModel...
            this.listenTo(this.panelModel, 'drag_resize', this.renderFromTrigger);
            this.listenTo(this.panelModel, 'change:selected', this.renderSelection);
            this.panelModel.on('destroy', this.clear, this);
            // listen to a trigger on this Model (triggered from Rect)
            this.listenTo(this, 'drag_xy', this.drag_xy);
            this.listenTo(this, 'drag_xy_stop', this.drag_xy_stop);
            this.listenTo(this, 'drag_resize', this.drag_resize);
            // listen to change to this model - update PanelModel
            this.listenTo(this, 'drag_resize_stop', this.drag_resize_stop);

            // reduce coupling between this and rect by using triggers to handle click.
            this.bind('clicked', function(args) {
                this.handleClick(args[0]);
            });
        },

        // return the SVG x, y, w, h (converting from figureModel)
        getSvgCoords: function(coords) {
            var zoom = this.figureModel.get('curr_zoom') * 0.01,
                size = this.figureModel.getFigureSize(),
                paper_top = (this.figureModel.get('canvas_height') - size.h)/2,
                paper_left = (this.figureModel.get('canvas_width') - size.w)/2,
                rect_x = (paper_left + 1 + coords.x) * zoom,
                rect_y = (paper_top + 1 + coords.y) * zoom,
                rect_w = coords.width * zoom,
                rect_h = coords.height * zoom;
            return {'x':rect_x, 'y':rect_y, 'width':rect_w, 'height':rect_h};
        },

        // return the Model x, y, w, h (converting from SVG coords)
        getModelCoords: getModelCoords,

        // called on trigger from the RectView, on drag of the whole rect OR handle for resize.
        // we simply convert coordinates and delegate to figureModel
        drag_xy: function(xy, save) {
            var zoom = this.figureModel.get('curr_zoom') * 0.01,
                dx = xy[0]/zoom,
                dy = xy[1]/zoom;

            this.figureModel.drag_xy(dx, dy, save);
        },

        // As above, but this time we're saving the changes to the Model
        drag_xy_stop: function(xy) {
            this.drag_xy(xy, true);
        },

        // Called on trigger from the RectView on resize. 
        // Need to convert from Svg coords to Model and notify the PanelModel without saving.
        drag_resize: function(xywh) {
            var coords = this.getModelCoords({'x':xywh[0], 'y':xywh[1], 'width':xywh[2], 'height':xywh[3]});
            this.panelModel.drag_resize(coords.x, coords.y, coords.width, coords.height);
        },

        // As above, but need to update the Model on changes to Rect (drag stop etc)
        drag_resize_stop: function(xywh) {
            var coords = this.getModelCoords({'x':xywh[0], 'y':xywh[1], 'width':xywh[2], 'height':xywh[3]});
            this.panelModel.save(coords);
        },

        // Called when the FigureModel zooms or the PanelModel changes coords.
        // Refreshes the RectView since that listens to changes in this ProxyModel
        renderFromModel: function() {
            this.set( this.getSvgCoords({
                'x': this.panelModel.get('x'),
                'y': this.panelModel.get('y'),
                'width': this.panelModel.get('width'),
                'height': this.panelModel.get('height')
            }) );
        },

        // While the Panel is being dragged (by the multi-select Rect), we need to keep updating
        // from the 'multiselectDrag' trigger on the model. RectView renders on change
        renderFromTrigger:function(xywh) {
            var c = this.getSvgCoords({
                'x': xywh[0],
                'y': xywh[1],
                'width': xywh[2],
                'height': xywh[3]
            });
            this.set( this.getSvgCoords({
                'x': xywh[0],
                'y': xywh[1],
                'width': xywh[2],
                'height': xywh[3]
            }) );
        },

        // When PanelModel changes selection - update and RectView will render change
        renderSelection: function() {
            this.set('selected', this.panelModel.get('selected'));
        },

        // Handle click (mousedown) on the RectView - changing selection.
        handleClick: function(event) {
            if (event.shiftKey) {
                this.figureModel.addSelected(this.panelModel);
            } else {
                this.figureModel.setSelected(this.panelModel);
            }
        },

        clear: function() {
            this.destroy();
        }

    });


    // This model underlies the Rect that is drawn around multi-selected panels
    // (only shown if 2 or more panels selected)
    // On drag or resize, we calculate how to move or resize the seleted panels.
    var MultiSelectRectModel = ProxyRectModel.extend({

        defaults: {
            x: 0,
            y: 0,
            width: 0,
            height: 0
        },

        initialize: function(opts) {
            this.figureModel = opts.figureModel;

            // listen to a trigger on this Model (triggered from Rect)
            this.listenTo(this, 'drag_xy', this.drag_xy);
            this.listenTo(this, 'drag_xy_stop', this.drag_xy_stop);
            this.listenTo(this, 'drag_resize', this.drag_resize);
            this.listenTo(this, 'drag_resize_stop', this.drag_resize_stop);
            this.listenTo(this.figureModel, 'change:selection', this.updateSelection);
            this.listenTo(this.figureModel, 'change:curr_zoom change:paper_height change:paper_width',
                    this.updateSelection);

            // also listen for drag_xy coming from a selected panel
            this.listenTo(this.figureModel, 'drag_xy', this.update_xy);
        },


        // Need to re-draw on selection AND zoom changes
        updateSelection: function() {

            var selected = this.figureModel.getSelected();
            if (selected.length < 1){

                this.set({
                    'x': 0,
                    'y': 0,
                    'width': 0,
                    'height': 0,
                    'selected': false
                });
                return;
            }

            var max_x = 0,
                max_y = 0;

            selected.forEach(function(panel){
                var x = panel.get('x'),
                    y = panel.get('y'),
                    w = panel.get('width'),
                    h = panel.get('height');
                max_x = Math.max(max_x, x+w);
                max_y = Math.max(max_y, y+h);
            });

            min_x = selected.getMin('x');
            min_y = selected.getMin('y');



            this.set( this.getSvgCoords({
                'x': min_x,
                'y': min_y,
                'width': max_x - min_x,
                'height': max_y - min_y
            }) );

            // Rect SVG will be notified and re-render
            this.set('selected', true);
        },


        // Called when we are notified of drag_xy on one of the Panels
        update_xy: function(dxdy) {
            if (! this.get('selected')) return;     // if we're not visible, ignore

            var svgCoords = this.getSvgCoords({
                'x': dxdy[0],
                'y': dxdy[1],
                'width': 0,
                'height': 0,
            });
            this.set({'x':svgCoords.x, 'y':svgCoords.y});
        },

        // RectView drag is delegated to Panels to update coords (don't save)
        drag_xy: function(dxdy, save) {
            // we just get [x,y] but we need [x,y,w,h]...
            var x = dxdy[0] + this.get('x'),
                y = dxdy[1] + this.get('y');
            var xywh = [x, y, this.get('width'), this.get('height')];
            this.notifyModelofDrag(xywh, save);
        },

        // As above, but Save is true since we're done dragging
        drag_xy_stop: function(dxdy, save) {
            this.drag_xy(dxdy, true);
            // Have to keep our proxy model in sync
            this.set({
                'x': dxdy[0] + this.get('x'),
                'y': dxdy[1] + this.get('y')
            });
        },

        // While the multi-select RectView is being dragged, we need to calculate the new coords
        // of all selected Panels, based on the start-coords and the current coords of
        // the multi-select Rect.
        drag_resize: function(xywh, save) {
            this.notifyModelofDrag(xywh, save);
        },

        // RectView dragStop is delegated to Panels to update coords (with save 'true')
        drag_resize_stop: function(xywh) {
            this.notifyModelofDrag(xywh, true);

            this.set({
                'x': xywh[0],
                'y': xywh[1],
                'width': xywh[2],
                'height': xywh[3]
            });
        },

        // While the multi-select RectView is being dragged, we need to calculate the new coords
        // of all selected Panels, based on the start-coords and the current coords of
        // the multi-select Rect.
        notifyModelofDrag: function(xywh, save) {
            var startCoords = this.getModelCoords({
                'x': this.get('x'),
                'y': this.get('y'),
                'width': this.get('width'),
                'height': this.get('height')
            });
            var dragCoords = this.getModelCoords({
                'x': xywh[0],
                'y': xywh[1],
                'width': xywh[2],
                'height': xywh[3]
            });

            // var selected = this.figureModel.getSelected();
            // for (var i=0; i<selected.length; i++) {
            //     selected[i].multiselectdrag(startCoords.x, startCoords.y, startCoords.width, startCoords.height,
            //         dragCoords.x, dragCoords.y, dragCoords.width, dragCoords.height, save);
            this.figureModel.multiselectdrag(startCoords.x, startCoords.y, startCoords.width, startCoords.height,
                    dragCoords.x, dragCoords.y, dragCoords.width, dragCoords.height, save);
            // };
        },

        // Ignore mousedown
        handleClick: function(event) {

        }
    });

    // var ProxyRectModelList = Backbone.Collection.extend({
    //     model: ProxyRectModel
    // });

    var SvgView = Backbone.View.extend({

        initialize: function(opts) {

            var self = this,
                canvas_width = this.model.get('canvas_width'),
                canvas_height = this.model.get('canvas_height');
            this.figureModel = this.model;  // since getModelCoords() expects this.figureModel

            // Create <svg> canvas
            this.raphael_paper = Raphael("canvas_wrapper", canvas_width, canvas_height);

            // this.panelRects = new ProxyRectModelList();
            self.$dragOutline = $("<div style='border: dotted #0a0a0a 1px; position:absolute; z-index:1'></div>")
                .appendTo("#canvas_wrapper");
            self.outlineStyle = self.$dragOutline.get(0).style;


            // Add global mouse event handlers
            self.dragging = false;
            self.drag_start_x = 0;
            self.drag_start_y = 0;
            $("#canvas_wrapper>svg")
                .mousedown(function(event){
                    self.dragging = true;
                    var parentOffset = $(this).parent().offset();
                    //or $(this).offset(); if you really just want the current element's offset
                    self.left = self.drag_start_x = event.pageX - parentOffset.left;
                    self.top = self.drag_start_y = event.pageY - parentOffset.top;
                    self.dx = 0;
                    self.dy = 0;
                    self.$dragOutline.css({
                            'left': self.drag_start_x,
                            'top': self.drag_start_y,
                            'width': 0,
                            'height': 0
                        }).show();
                    // return false;
            })
                .mousemove(function(event){
                    if (self.dragging) {
                        var parentOffset = $(this).parent().offset();
                        //or $(this).offset(); if you really just want the current element's offset
                        self.left = self.drag_start_x;
                        self.top = self.drag_start_y;
                        self.dx = event.pageX - parentOffset.left - self.drag_start_x;
                        self.dy = event.pageY - parentOffset.top - self.drag_start_y;
                        if (self.dx < 0) {
                            self.left = self.left + self.dx;
                            self.dx = Math.abs(self.dx);
                        }
                        if (self.dy < 0) {
                            self.top = self.top + self.dy;
                            self.dy = Math.abs(self.dy);
                        }
                        self.$dragOutline.css({
                            'left': self.left,
                            'top': self.top,
                            'width': self.dx,
                            'height': self.dy
                        });
                        // .show();
                        // self.outlineStyle.left = left + 'px';
                        // self.outlineStyle.top = top + 'px';
                        // self.outlineStyle.width = dx + 'px';
                        // self.outlineStyle.height = dy + 'px';
                    }
                    // return false;
            })
                .mouseup(function(event){
                    if (self.dragging) {
                        self.handleClick(event);
                        self.$dragOutline.hide();
                    }
                    self.dragging = false;
                    // return false;
            });

            // If a panel is added...
            this.model.panels.on("add", this.addOne, this);
            this.listenTo(this.model, 'change:curr_zoom', this.renderZoom);

            var multiSelectRect = new MultiSelectRectModel({figureModel: this.model}),
                rv = new RectView({'model':multiSelectRect, 'paper':this.raphael_paper,
                        'handle_wh':7, 'handles_toFront': true, 'fixed_ratio': true});
            rv.selected_line_attrs = {'stroke-width': 1, 'stroke':'#4b80f9'};

            // set svg size for current window and zoom
            this.renderZoom();
        },

        // A panel has been added - We add a corresponding Raphael Rect 
        addOne: function(m) {

            var rectModel = new ProxyRectModel({panel: m, figure:this.model});
            new RectView({'model':rectModel, 'paper':this.raphael_paper,
                    'handle_wh':5, 'disable_handles': true, 'fixed_ratio': true});
        },

        // TODO
        remove: function() {
            // TODO: remove from svg, remove event handlers etc.
        },

        // We simply re-size the Raphael svg itself - Shapes have their own zoom listeners
        renderZoom: function() {
            var zoom = this.model.get('curr_zoom') * 0.01,
                newWidth = this.model.get('canvas_width') * zoom,
                newHeight = this.model.get('canvas_height') * zoom;

            this.raphael_paper.setSize(newWidth, newHeight);
        },

        getModelCoords: getModelCoords,

        // Any mouse click (mouseup) or dragStop that isn't captured by Panel Rect clears selection
        handleClick: function(event) {
            if (!event.shiftKey) {
                this.model.clearSelected();
            }
            // select panels overlapping with drag outline
            if (this.dx > 0 || this.dy > 0) {
                var coords = this.getModelCoords({x: this.left, y: this.top, width:this.dx, height:this.dy});
                this.model.selectByRegion(coords);
            }
        }
    });


//
// Copyright (C) 2014 University of Dundee & Open Microscopy Environment.
// All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//

// http://www.sitepoint.com/javascript-json-serialization/
JSON.stringify = JSON.stringify || function (obj) {
    var t = typeof (obj);
    if (t != "object" || obj === null) {
        // simple data type
        if (t == "string") obj = '"'+obj+'"';
        return String(obj);
    }
    else {
        // recurse array or object
        var n, v, json = [], arr = (obj && obj.constructor == Array);
        for (n in obj) {
            v = obj[n]; t = typeof(v);
            if (t == "string") v = '"'+v+'"';
            else if (t == "object" && v !== null) v = JSON.stringify(v);
            json.push((arr ? "" : '"' + n + '":') + String(v));
        }
        return (arr ? "[" : "{") + String(json) + (arr ? "]" : "}");
    }
};


// Polyfill for IE
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith
if (!String.prototype.endsWith)
  String.prototype.endsWith = function(searchStr, Position) {
      // This works much better than >= because
      // it compensates for NaN:
      if (!(Position < this.length))
        Position = this.length;
      else
        Position |= 0; // round position
      return this.substr(Position - searchStr.length,
                         searchStr.length) === searchStr;
  };

var showExportAsJsonModal = function(figureJSON) {
    var figureText = JSON.stringify(figureJSON);
    $('#exportJsonModal').modal('show');
    $('#exportJsonModal textarea').text(figureText);
}

var saveFigureToStorage = function (figureJSON) {
    window.sessionStorage.setItem(LOCAL_STORAGE_RECOVERED_FIGURE, JSON.stringify(figureJSON));
}

var clearFigureFromStorage = function() {
    window.sessionStorage.removeItem(LOCAL_STORAGE_RECOVERED_FIGURE);
}

var recoverFigureFromStorage = function() {
    var storage = window.sessionStorage;
    var recoveredFigure = storage.getItem(LOCAL_STORAGE_RECOVERED_FIGURE);
    var figureObject;
    try {
        figureObject = JSON.parse(recoveredFigure);
    } catch (e) {
        console.log("recovered Figure not valid JSON " + recoveredFigure);
    }
    return figureObject;
}

var figureConfirmDialog = function(title, message, buttons, callback) {
    var $confirmModal = $("#confirmModal"),
        $title = $(".modal-title", $confirmModal),
        $body = $(".modal-body", $confirmModal),
        $footer = $(".modal-footer", $confirmModal),
        $btn = $(".btn:first", $footer);

    // Update modal with params
    $title.html(title);
    $body.html('<p>' + message + '<p>');
    $footer.empty();
    _.each(buttons, function(txt){
        $btn.clone().text(txt).appendTo($footer);
    });
    $(".btn", $footer).removeClass('btn-primary')
        .addClass('btn-default')
        .last()
        .removeClass('btn-default')
        .addClass('btn-primary');

    // show modal
    $confirmModal.modal('show');

    // default handler for 'cancel' or 'close'
    $confirmModal.one('hide.bs.modal', function() {
        // remove the other 'one' handler below
        $("#confirmModal .modal-footer .btn").off('click');
        if (callback) {
            callback();
        }
    });

    // handle 'Save' btn click.
    $("#confirmModal .modal-footer .btn").one('click', function(event) {
        // remove the default 'one' handler above
        $confirmModal.off('hide.bs.modal');
        var btnText = $(event.target).text();
        if (callback) {
            callback(btnText);
        }
    });
};

if (OME === undefined) {
    var OME = {};
}

OPEN_WITH = [];

OME.setOpenWithEnabledHandler = function(id, fn) {
    // look for id in OPEN_WITH
    OPEN_WITH.forEach(function(ow){
        if (ow.id === id) {
            ow.isEnabled = function() {
                // wrap fn with try/catch, since error here will break jsTree menu
                var args = Array.from(arguments);
                var enabled = false;
                try {
                    enabled = fn.apply(this, args);
                } catch (e) {
                    // Give user a clue as to what went wrong
                    console.log("Open with " + label + ": " + e);
                }
                return enabled;
            }
        }
    });
};

// Helper can be used by 'open with' plugins to provide
// a url for the selected objects
OME.setOpenWithUrlProvider = function(id, fn) {
    // look for id in OPEN_WITH
    OPEN_WITH.forEach(function(ow){
        if (ow.id === id) {
            ow.getUrl = fn;
        }
    });
};


// Extend the jQuery UI $.slider() function to silence
// keyboard events on the handle, so we don't nudge selected panels
$.prototype.slider_old = $.prototype.slider;
$.prototype.slider = function() {
    var result = $.prototype.slider_old.apply(this, arguments);
    this.find(".ui-slider-handle").bind("keydown", function(){
        return false;
    });
    return result;
}


// Get coordinates for point x, y rotated around cx, cy, by rotation degrees
var rotatePoint = function (x, y, cx, cy, rotation) {
    let length = Math.sqrt(Math.pow((x - cx), 2) + Math.pow((y - cy), 2));
    let rot = Math.atan2((y - cy), (x - cx));
    rot = rot + (rotation * (Math.PI / 180));  // degrees to rad
    let dx = Math.cos(rot) * length;
    let dy = Math.sin(rot) * length;
    return { x: cx + dx, y: cy + dy };
}

$(function(){


    $(".draggable-dialog").draggable();

    $('#previewInfoTabs a').click(function (e) {
        e.preventDefault();
        $(this).tab('show');
    });


    // Header button tooltips
    $('.btn-sm').tooltip({container: 'body', placement:'bottom', toggle:"tooltip"});
    $('.figure-title').tooltip({container: 'body', placement:'bottom', toggle:"tooltip"});
    // Footer button tooltips
    $('.btn-xs').tooltip({container: 'body', placement:'top', toggle:"tooltip"});


    // If we're on Mac, update dropdown menus for keyboard short cuts:
    if (navigator.platform.toUpperCase().indexOf('MAC') > -1) {
        $("ul.dropdown-menu li a span").each(function(){
            var $this = $(this);
                $this.text($this.text().replace("Ctrl+", "⌘"));
        });
    }

    // When we load, setup Open With options
    $.getJSON(WEBGATEWAYINDEX + "open_with/", function(data){
        if (data && data.open_with_options) {
            OPEN_WITH = data.open_with_options;
            // Try to load scripts if specified:
            OPEN_WITH.forEach(function(ow){
                if (ow.script_url) {
                    $.getScript(ow.script_url);
                }
            })
        }
    });

});

//
// Copyright (C) 2014 University of Dundee & Open Microscopy Environment.
// All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//

$(function(){

    window.figureModel = new FigureModel();

    window.FigureColorPicker = new ColorPickerView();
    window.FigureLutPicker = new LutPickerView();

    // Override 'Backbone.sync'...
    Backbone.ajaxSync = Backbone.sync;

    // TODO: - Use the undo/redo queue instead of sync to trigger figureModel.set("unsaved", true);

    // If syncOverride, then instead of actually trying to Save via ajax on model.save(attr, value)
    // We simply set the 'unsaved' flag on the figureModel.
    // This works for FigureModel and also for Panels collection.
    Backbone.getSyncMethod = function(model) {
        if(model.syncOverride || (model.collection && model.collection.syncOverride))
        {
            return function(method, model, options, error) {
                figureModel.set("unsaved", true);
            };
        }
        return Backbone.ajaxSync;
    };

    // Override 'Backbone.sync' to default to localSync,
    // the original 'Backbone.sync' is still available in 'Backbone.ajaxSync'
    Backbone.sync = function(method, model, options, error) {
        return Backbone.getSyncMethod(model).apply(this, [method, model, options, error]);
    };


    var view = new FigureView( {model: figureModel});   // uiState: uiState
    var svgView = new SvgView( {model: figureModel});
    new RightPanelView({model: figureModel});


    // Undo Model and View
    var undoManager = new UndoManager({'figureModel':figureModel}),
    undoView = new UndoView({model:undoManager});
    // Finally, start listening for changes to panels
    undoManager.listenToCollection(figureModel.panels);


    var FigureRouter = Backbone.Router.extend({

        routes: {
            "": "index",
            "new(/)": "newFigure",
            "recover(/)": "recoverFigure",
            "open(/)": "openFigure",
            "file/:id(/)": "loadFigure",
        },

        checkSaveAndClear: function(callback) {

            var doClear = function() {
                figureModel.clearFigure();
                if (callback) {
                    callback();
                }
            };
            if (figureModel.get("unsaved")) {

                var saveBtnTxt = "Save",
                    canEdit = figureModel.get('canEdit');
                if (!canEdit) saveBtnTxt = "Save a Copy";
                // show the confirm dialog...
                figureConfirmDialog("Save Changes to Figure?",
                    "Your changes will be lost if you don't save them",
                    ["Don't Save", saveBtnTxt],
                    function(btnTxt){
                        if (btnTxt === saveBtnTxt) {
                             var options = {};
                            // Save current figure or New figure...
                            var fileId = figureModel.get('fileId');
                            if (fileId && canEdit) {
                                options.fileId = fileId;
                            } else {
                                var defaultName = figureModel.getDefaultFigureName();
                                var figureName = prompt("Enter Figure Name", defaultName);
                                options.figureName = figureName || defaultName;
                            }
                            options.success = doClear;
                            figureModel.save_to_OMERO(options);
                        } else if (btnTxt === "Don't Save") {
                            figureModel.set("unsaved", false);
                            doClear();
                        } else {
                            doClear();
                        }
                    });
            } else {
                doClear();
            }
        },

        index: function() {
            $(".modal").modal('hide'); // hide any existing dialogs
            var cb = function() {
                $('#welcomeModal').modal();
            };
            this.checkSaveAndClear(cb);
        },

        openFigure: function() {
            $(".modal").modal('hide'); // hide any existing dialogs
            var cb = function() {
                $("#openFigureModal").modal();
            };
            this.checkSaveAndClear(cb);
        },

        recoverFigure: function() {
            $(".modal").modal('hide'); // hide any existing dialogs
            figureModel.recoverFromLocalStorage();
        },

        newFigure: function() {
            $(".modal").modal('hide'); // hide any existing dialogs
            var cb = function() {
                $('#addImagesModal').modal();
            };
            // Check for ?image=1&image=2
            if (window.location.search.length > 1) {
                var params = window.location.search.substring(1).split('&');
                var iids = params.reduce(function(prev, param){
                    if (param.split('=')[0] === 'image') {
                        prev.push(param.split('=')[1]);
                    }
                    return prev;
                },[]);
                if (iids.length > 0) {
                    cb = function() {
                        figureModel.addImages(iids);
                    }
                }
            }
            this.checkSaveAndClear(cb);
         },

        loadFigure: function(id) {
            $(".modal").modal('hide'); // hide any existing dialogs
            var fileId = parseInt(id, 10);
            var cb = function() {
                figureModel.load_from_OMERO(fileId);
            };
            this.checkSaveAndClear(cb);
        }
    });

    app = new FigureRouter();
    Backbone.history.start({pushState: true, root: BASE_WEBFIGURE_URL});

    // We want 'a' links (E.g. to open_figure) to use app.navigate
    $(document).on('click', 'a', function (ev) {
        var href = $(this).attr('href');
        // check that links are 'internal' to this app
        if (href.substring(0, BASE_WEBFIGURE_URL.length) === BASE_WEBFIGURE_URL) {
            ev.preventDefault();
            href = href.replace(BASE_WEBFIGURE_URL, "/");
            app.navigate(href, {trigger: true});
        }
    });

});
