diff --git a/src/css/figure.css b/src/css/figure.css index 9ae08c934..86528c297 100644 --- a/src/css/figure.css +++ b/src/css/figure.css @@ -50,6 +50,14 @@ font-size: 1.0rem; } + /* e.g. Inset form */ + .form-inline h5 { + display: inline-flex; + margin-right: 10px; + vertical-align: middle; + margin-bottom: 0; + } + header { background: gray; height: 30px; @@ -1004,6 +1012,7 @@ } .colorpicker span, + .inset-color span:first-child, .label-color span:first-child, .shape-color span:first-child { border: solid 1px #bbb; diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index 201f3d9cc..651828493 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -45,6 +45,12 @@ initialize: function() { this.panels = new PanelList(); //this.get("shapes")); + // listen for new Panels added so we can pass in a reference + // to this figureModel... + this.panels.on('add', (panel) => { + panel.setFigureModel(this); + }); + // wrap selection notification in a 'debounce', so that many rapid // selection changes only trigger a single re-rendering this.notifySelectionChange = _.debounce( this.notifySelectionChange, 10); @@ -81,7 +87,7 @@ 'paper_height': data.paper_height, 'width_mm': data.width_mm, 'height_mm': data.height_mm, - 'page_size': data.page_size || 'letter', + 'page_size': data.page_size || 'A4', 'page_count': data.page_count, 'paper_spacing': data.paper_spacing, 'page_col_count': data.page_col_count, diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index ee207fb30..4a32baabd 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -11,6 +11,9 @@ // Attributes can be added as we need them. export var Panel = Backbone.Model.extend({ + // see setFigureModel() below + figureModel: undefined, + defaults: { x: 100, // coordinates on the 'paper' y: 100, @@ -35,7 +38,83 @@ }, initialize: function() { + // listen for changes to the viewport... + this.on("change:zoom change:dx change:dy change:width change:height change:rotation", (panel) => { + // if this panel has 'insetRoiId' it is an "inset" panel so we need to + // notify other panels that contain the corresponding ROI (rectangle) + if (this.get('insetRoiId') && this.figureModel) { + if (!this.silenceTriggers) { + this.figureModel.trigger('zoomPanUpdated', panel); + } + } + }); + // listen for changes to shapes... + this.on("change:shapes", (panel) => { + // notify all other panels via the figureModel... + if (!this.silenceTriggers && this.figureModel) { + this.figureModel.trigger('shapesUpdated', panel); + } + }); + }, + + setFigureModel(figureModel) { + this.figureModel = figureModel; + // listen for *other* panels zooming - might need to update "inset" ROI... + this.listenTo(figureModel, 'zoomPanUpdated', this.handleZoomPanChange); + // listen for *other* panels being deleted - delete corresponding inset ROI... + this.listenTo(figureModel.panels, 'remove', (panel) => { + this.handleZoomPanChange(panel, true) + }); + // listen for *other* panels shape updates - might need to update zoom/pan... + this.listenTo(figureModel, 'shapesUpdated', this.handleShapesChange); + }, + handleShapesChange(panel) { + // The ROI rectangle has been updated on another panel - update viewport to match... + // Update zoom/panel but don't trigger zoomPanUpdated or we could get + // recursive feedback + var insetRoiId = this.get('insetRoiId'); + if (!insetRoiId) return; + + this.silenceTriggers = true; + + // find Rectangle from panel that corresponds to this panel + let rect = (panel.get("shapes") || []).find(shape => shape.id == insetRoiId); + if (rect) { + this.cropToRoi(rect); + } + + this.silenceTriggers = false; + }, + + handleZoomPanChange(panel, panelDeleted) { + // An inset panel has zoomed/panned or deleted. If we have corresponding inset + // Rectangle then update or delete it accordingly... + var insetRoiId = panel.get('insetRoiId'); + if (!insetRoiId) return; + // find Rectangles that have insetRoiId... + let insetShapes = this.get('shapes'); + if (insetShapes) { + insetShapes = insetShapes.filter(sh => (sh.type == 'Rectangle' && sh.id == insetRoiId)); + if (insetShapes.length > 0) { + this.silenceTriggers = true; + // delete or update the "inset" Rectangle + let updated = this.get('shapes'); + if (panelDeleted) { + updated = updated.filter(shape => shape.id != insetRoiId); + } else { + let rect = panel.getViewportAsRect(); + updated = updated.map(shape => { + if (shape.type == 'Rectangle' && shape.id == insetRoiId) { + return {...shape, ...rect} + } + return shape; + }); + } + this.save('shapes', updated); + this.silenceTriggers = false; + } + } }, // When we're creating a Panel, we process the data a little here: @@ -614,7 +693,10 @@ var toSet = { 'width': newW, 'height': newH, 'dx': dx, 'dy': dy, 'zoom': zoom }; var rotation = coords.rotation || 0; + // if the rectangle/viewport is rotated clockwise, the image within the + // viewport is rotated anti-clockwise if (!isNaN(rotation)) { + rotation = -(rotation - 360); toSet.rotation = rotation; } this.save(toSet); @@ -626,6 +708,12 @@ dx = dx !== undefined ? dx : this.get('dx'); dy = dy !== undefined ? dy : this.get('dy'); var rotation = this.get('rotation'); + if (isNaN(rotation)) { + rotation = 0; + }; + // if we have rotated the panel clockwise within the viewport + // it's as if the viewport rectangle has rotated anti-clockwise + rotation = 360 - rotation; var width = this.get('width'), height = this.get('height'), diff --git a/src/js/shapeEditorTest.js b/src/js/shapeEditorTest.js index 419863099..2cb716673 100644 --- a/src/js/shapeEditorTest.js +++ b/src/js/shapeEditorTest.js @@ -200,12 +200,19 @@ $(function() { "strokeWidth": 4}); shapeManager.addShapeJson({"id": 1234, + "rotation": 45, "type": "Rectangle", "strokeColor": "#ff00ff", "strokeWidth": 6, "x": 200, "y": 150, "width": 125, "height": 150}); + shapeManager.addShapeJson({"type": "Rectangle", + "strokeColor": "#ffffff", + "strokeWidth": 3, + "x": 50, "y": 300, + "width": 50, "height": 100}); + shapeManager.addShapeJson({"type": "Ellipse", "x": 200, "y": 150, "radiusX": 125, "radiusY": 50, diff --git a/src/js/shape_editor/rect.js b/src/js/shape_editor/rect.js index 95ee30d8d..ce9af2549 100644 --- a/src/js/shape_editor/rect.js +++ b/src/js/shape_editor/rect.js @@ -51,6 +51,7 @@ var Rect = function Rect(options) { if (options.zoom) { this._zoomFraction = options.zoom / 100; } + this._rotation = options.rotation || 0; this.handle_wh = 6; this.element = this.paper.rect(); @@ -104,6 +105,7 @@ Rect.prototype.toJson = function toJson() { 'area': this._width * this._height, strokeWidth: this._strokeWidth, strokeColor: this._strokeColor, + rotation: this._rotation, }; if (this._id) { rv.id = this._id; @@ -258,6 +260,8 @@ Rect.prototype.drawShape = function drawShape() { "stroke-width": lineW, }); + this.element.transform("r" + this._rotation + "," + (x + (w/2)) + "," + (y + (h/2))); + if (this.isSelected()) { this.handles.show().toFront(); } else { @@ -273,6 +277,7 @@ Rect.prototype.drawShape = function drawShape() { hx = handleIds[h_id][0]; hy = handleIds[h_id][1]; hnd.attr({ x: hx - this.handle_wh / 2, y: hy - this.handle_wh / 2 }); + hnd.transform("r" + this._rotation + "," + (x + (w/2)) + "," + (y + (h/2))); } }; @@ -296,6 +301,22 @@ Rect.prototype.getHandleCoords = function getHandleCoords() { return handleIds; }; +function correct_rotation(dx, dy, rotation) { + if (dx === 0 && dy === 0) { + return {x: dx, y: dy}; + } + var length = Math.sqrt(dx * dx + dy * dy), + ang1 = Math.atan(dy/dx); + if (dx < 0) { + ang1 = Math.PI + ang1; + } + var angr = rotation * (Math.PI/180), // deg -> rad + ang2 = ang1 - angr; + dx = Math.cos(ang2) * length; + dy = Math.sin(ang2) * length; + return {x: dx, y: dy}; +} + // ---- Create Handles ----- Rect.prototype.createHandles = function createHandles() { var self = this, @@ -315,6 +336,12 @@ Rect.prototype.createHandles = function createHandles() { return function (dx, dy, mouseX, mouseY, event) { dx = dx / self._zoomFraction; dy = dy / self._zoomFraction; + // need to handle rotation... + if (self._rotation != 0) { + let xy = correct_rotation(dx, dy, self._rotation); + dx = xy.x; + dy = xy.y; + } // If drag on corner handle, retain aspect ratio. dx/dy = aspect var keep_ratio = self.fixed_ratio || event.shiftKey; @@ -397,6 +424,9 @@ Rect.prototype.createHandles = function createHandles() { // var _stop_event_propagation = function(e) { // e.stopImmediatePropagation(); // } + let cx = handleIds['n'][0]; + let cy = handleIds['e'][1]; + for (var key in handleIds) { var hx = handleIds[key][0]; var hy = handleIds[key][1]; @@ -407,7 +437,8 @@ Rect.prototype.createHandles = function createHandles() { self.handle_wh, self.handle_wh ) - .attr(handle_attrs); + .attr(handle_attrs) + .rotate(self._rotation, cx, cy); handle.attr({ cursor: key + "-resize" }); // css, E.g. ne-resize handle.h_id = key; handle.rect = self; diff --git a/src/js/shape_editor/shape_manager.js b/src/js/shape_editor/shape_manager.js index 78d8daa20..f6c8df0ff 100644 --- a/src/js/shape_editor/shape_manager.js +++ b/src/js/shape_editor/shape_manager.js @@ -418,6 +418,7 @@ ShapeManager.prototype.createShapeJson = function createShapeJson(jsonShape) { options.width = s.width; options.height = s.height; options.area = s.width * s.height; + options.rotation = s.rotation; newShape = new Rect(options); } else if (s.type === "Line") { options.x1 = s.x1; diff --git a/src/js/views/figure_view.js b/src/js/views/figure_view.js index e4e96d986..559bc782b 100644 --- a/src/js/views/figure_view.js +++ b/src/js/views/figure_view.js @@ -24,7 +24,8 @@ showExportAsJsonModal, showModal, hideModals, - hideModal} from "./util"; + hideModal, + updateRoiIds} from "./util"; // This extends Backbone to support keyboardEvents backboneMousetrap(_, Backbone, Mousetrap); @@ -571,6 +572,8 @@ var cd = []; s.forEach(function(m) { var copy = m.toJSON(); + // deep copy (e.g. includes shapes) + copy = JSON.parse(JSON.stringify(copy)); delete copy.id; cd.push(copy); }); @@ -644,6 +647,8 @@ // apply offset to clipboard data & paste // NB: we are modifying the list that is in the clipboard + clipboard_panels = updateRoiIds(clipboard_panels); + _.each(clipboard_panels, function(m) { m.x = m.x + offset_x; m.y = m.y + offset_y; diff --git a/src/js/views/right_panel_view.js b/src/js/views/right_panel_view.js index 0192caef0..5dd479841 100644 --- a/src/js/views/right_panel_view.js +++ b/src/js/views/right_panel_view.js @@ -7,7 +7,7 @@ import _ from "underscore"; import $ from "jquery"; - import {figureConfirmDialog, showModal, rotatePoint} from "./util"; + import {figureConfirmDialog, showModal, rotatePoint, getRandomId} from "./util"; import FigureColorPicker from "../views/colorpicker"; import FigureModel from "../models/figure_model"; @@ -110,11 +110,75 @@ "click .copyROIs": "copyROIs", "click .pasteROIs": "pasteROIs", "click .deleteROIs": "deleteROIs", + "click .create_inset": "createInset", // triggered by select_dropdown_option below "change .shape-color": "changeROIColor", "change .line-width": "changeLineWidth", }, + createInset: function() { + let selected = this.model.getSelected(); + + selected.forEach(panel => { + let randomId = getRandomId(); + // Add Rectangle (square) in centre of viewport + let vp = panel.getViewportAsRect(); + let minSide = Math.min(vp.width, vp.height); + // Square is 1/3 size of the viewport + let rectSize = minSide / 3; + var color = $('.inset-color span:first', this.$el).attr('data-color'); + var position = $('.label-position i:first', this.$el).attr('data-position'); + var strokeWidth = parseFloat($('button.inset-width span:first', this.$el).attr('data-line-width')); + let rect = { + type: "Rectangle", + strokeWidth, + strokeColor: "#" + color, + x: vp.x + ((vp.width - rectSize) / 2), + y: vp.y + ((vp.height - rectSize) / 2), + width: rectSize, + height: rectSize, + id: randomId, + rotation: vp.rotation || 0, + } + panel.add_shapes([rect]); + + // Create duplicate panels + let panelJson = panel.toJSON(); + + // want to make sure new panel is square + let maxSide = Math.min(panelJson.width, panelJson.height); + panelJson.width = maxSide; + panelJson.height = maxSide; + + if (position == "bottom") { + panelJson.y = panelJson.y + 1.1 * panel.get("height"); + } else if (position == "left") { + panelJson.x = panelJson.x - (1.1 * panelJson.width); + } else if (position == "top") { + panelJson.y = panelJson.y - (1.1 * panelJson.height); + } else { + panelJson.x = panelJson.x + 1.1 * panel.get("width"); + } + // cropped to match new Rectangle + let orig_width = panelJson.orig_width; + let orig_height = panelJson.orig_height; + let targetCx = Math.round(rect.x + (rect.width/2)); + let targetCy = Math.round(rect.y + (rect.height/2)); + + panelJson.dx = (orig_width/2) - targetCx; + panelJson.dy = (orig_height/2) - targetCy; + // zoom to correct percentage + var xPercent = orig_width / rect.width; + var yPercent = orig_height / rect.height; + panelJson.zoom = Math.min(xPercent, yPercent) * 100; + panelJson.selected = false; + panelJson.shapes = []; + panelJson.insetRoiId = randomId; + + this.model.panels.create(panelJson); + }); + }, + changeLineWidth: function() { var width = $('button.line-width span:first', this.$el).attr('data-line-width'), sel = this.model.getSelected(); @@ -950,7 +1014,11 @@ var 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}; + rect = { + x: shape.x, y: shape.y, + width: shape.width, height: shape.height, + rotation: shape.rotation + }; } }); if (!rect) { diff --git a/src/js/views/util.js b/src/js/views/util.js index 287500365..bc5f52d0c 100644 --- a/src/js/views/util.js +++ b/src/js/views/util.js @@ -260,3 +260,55 @@ export async function getJson (url) { let cors_headers = { mode: 'cors', credentials: 'include' }; return fetch(url, cors_headers).then(rsp => rsp.json()); } + +export const RANDOM_NUMBER_RANGE = 100000000; + +export function getRandomId() { + return parseInt(Math.random() * RANDOM_NUMBER_RANGE); +} + +export function newIdFromRandomId(oldId) { + return parseInt((oldId * Math.PI) % RANDOM_NUMBER_RANGE); +} + +export function updateRoiIds(panelsJson) { + // If we copy and paste an inset panel AND it's corresponding panel with Rect, + // we don't want changes in viewport/Rect to trigger changes in the panels they + // were copied from - so we update IDs... + + // But, if we ONLY copy/paste a panel containing an Inset Rect, keep the insetRoiId + // so that it continues to sync with the inset panel. + // And if we ONLY copy/paste an inset panel, keep the insetRoiId so that it stays + // in sync with corresponding Rect + + // Find the insetRoiIds that are in BOTH panels and shapes + let insetIdsFromPanels = panelsJson.map(panel => panel.insetRoiId).filter(Boolean); + let insetIdsFromShapes = []; + panelsJson.forEach(panel => { + if (panel.shapes) { + panel.shapes.forEach(shape => { + if (shape.id) { + insetIdsFromShapes.push(shape.id); + } + }); + } + }); + let idsToUpdate = insetIdsFromPanels.filter(roiId => insetIdsFromShapes.includes(roiId)); + + // Update the IDs + let updatedPanels = panelsJson.map(panelJson => { + if (idsToUpdate.includes(panelJson.insetRoiId)) { + panelJson.insetRoiId = newIdFromRandomId(panelJson.insetRoiId); + } + if (panelJson.shapes) { + panelJson.shapes.forEach(shape => { + if (idsToUpdate.includes(shape.id)) { + shape.id = newIdFromRandomId(shape.id); + } + }); + } + return panelJson; + }); + + return updatedPanels; +} diff --git a/src/templates/rois_form.template.html b/src/templates/rois_form.template.html index e64c0d4b7..7957aac39 100644 --- a/src/templates/rois_form.template.html +++ b/src/templates/rois_form.template.html @@ -57,7 +57,7 @@
ROIs @@ -91,4 +91,90 @@
ROIs + +
+
Inset Panel
+ +
+ + +
+ + + +
+ + +
+ + +
<% } %>