var imageOutputBinding = new OutputBinding(); $.extend(imageOutputBinding, { find: function(scope) { return $(scope).find('.shiny-image-output, .shiny-plot-output'); }, renderValue: function(el, data) { // The overall strategy: // * Clear out existing image and event handlers. // * Create new image. // * Create various event handlers. // * Bind those event handlers to events. // * Insert the new image. var outputId = this.getId(el); var $el = $(el); var img; // Get existing img element if present. var $img = $el.find('img'); if ($img.length === 0) { // If a img element is not already present, that means this is either // the first time renderValue() has been called, or this is after an // error. img = document.createElement('img'); $el.append(img); $img = $(img); } else { // Trigger custom 'reset' event for any existing images in the div img = $img[0]; $img.trigger('reset'); } if (!data) { $el.empty(); return; } // If value is undefined, return alternate. Sort of like ||, except it won't // return alternate for other falsy values (0, false, null). function OR(value, alternate) { if (value === undefined) return alternate; return value; } var opts = { clickId: $el.data('click-id'), clickClip: OR(strToBool($el.data('click-clip')), true), dblclickId: $el.data('dblclick-id'), dblclickClip: OR(strToBool($el.data('dblclick-clip')), true), dblclickDelay: OR($el.data('dblclick-delay'), 400), hoverId: $el.data('hover-id'), hoverClip: OR(strToBool($el.data('hover-clip')), true), hoverDelayType: OR($el.data('hover-delay-type'), 'debounce'), hoverDelay: OR($el.data('hover-delay'), 300), hoverNullOutside: OR(strToBool($el.data('hover-null-outside')), false), brushId: $el.data('brush-id'), brushClip: OR(strToBool($el.data('brush-clip')), true), brushDelayType: OR($el.data('brush-delay-type'), 'debounce'), brushDelay: OR($el.data('brush-delay'), 300), brushFill: OR($el.data('brush-fill'), '#666'), brushStroke: OR($el.data('brush-stroke'), '#000'), brushOpacity: OR($el.data('brush-opacity'), 0.3), brushDirection: OR($el.data('brush-direction'), 'xy'), brushResetOnNew: OR(strToBool($el.data('brush-reset-on-new')), false), coordmap: data.coordmap }; // Copy items from data to img. Don't set the coordmap as an attribute. $.each(data, function(key, value) { if (value === null || key === 'coordmap') { return; } img.setAttribute(key, value); }); // Unset any attributes in the current img that were not provided in the // new data. for (var i=0; i max) newval = max; else if (newval < min) newval = min; } return newval; } // Create scale and inverse-scale functions for a single direction (x or y). function scaler1D(domainMin, domainMax, rangeMin, rangeMax, logbase) { return { scale: function(val, clip) { if (logbase) val = Math.log(val) / Math.log(logbase); return mapLinear(val, domainMin, domainMax, rangeMin, rangeMax, clip); }, scaleInv: function(val, clip) { var res = mapLinear(val, rangeMin, rangeMax, domainMin, domainMax, clip); if (logbase) res = Math.pow(logbase, res); return res; } }; } // Modify panel, adding scale and inverse-scale functions that take objects // like {x:1, y:3}, and also add clip function. function addScaleFuns(panel) { var d = panel.domain; var r = panel.range; var xlog = (panel.log && panel.log.x) ? panel.log.x : null; var ylog = (panel.log && panel.log.y) ? panel.log.y : null; var xscaler = scaler1D(d.left, d.right, r.left, r.right, xlog); var yscaler = scaler1D(d.bottom, d.top, r.bottom, r.top, ylog); // Given an object of form {x:1, y:2}, or {x:1, xmin:2:, ymax: 3}, convert // from data coordinates to img. Whether a value is converted as x or y // depends on the first character of the key. panel.scaleDataToImg = function(val, clip) { return mapValues(val, (value, key) => { const prefix = key.substring(0, 1); if (prefix === "x") { return xscaler.scale(value, clip); } else if (prefix === "y") { return yscaler.scale(value, clip); } return null; }); }; panel.scaleImgToData = function(val, clip) { return mapValues(val, (value, key) => { const prefix = key.substring(0, 1); if (prefix === "x") { return xscaler.scaleInv(value, clip); } else if (prefix === "y") { return yscaler.scaleInv(value, clip); } return null; }); }; // Given a scaled offset (in img pixels), clip it to the nearest panel region. panel.clipImg = function(offset_img) { var newOffset = { x: offset_img.x, y: offset_img.y }; var bounds = panel.range; if (offset_img.x > bounds.right) newOffset.x = bounds.right; else if (offset_img.x < bounds.left) newOffset.x = bounds.left; if (offset_img.y > bounds.bottom) newOffset.y = bounds.bottom; else if (offset_img.y < bounds.top) newOffset.y = bounds.top; return newOffset; }; } // Add the functions to each panel object. for (var i=0; i (not including padding // and border). // 2. img: The pixel coordinates of the image data. A common case is on a // HiDPI device, where the source PNG image could be 1000 pixels wide but // be displayed in 500 CSS pixels. Another case is when the image has // additional scaling due to CSS transforms or width. // 3. data: The coordinates in the data space. This is a bit more complicated // than the other two, because there can be multiple panels (as in facets). imageutils.initCoordmap = function($el, coordmap) { const el = $el[0]; const $img = $el.find("img"); // If we didn't get any panels, create a dummy one where the domain and range // are simply the pixel dimensions. // that we modify. if (coordmap.panels.length === 0) { let bounds = { top: 0, left: 0, right: el.clientWidth - 1, bottom: el.clientHeight - 1 }; coordmap.panels[0] = { domain: bounds, range: bounds, mapping: {} }; } // Add scaling functions to each panel imageutils.initPanelScales(coordmap.panels); // This returns the offset of the mouse in CSS pixels relative to the img, // but not including the padding or border, if present. coordmap.mouseOffsetCss = function(mouseEvent) { const img_origin = findOrigin($img); // The offset of the mouse from the upper-left corner of the img, in // pixels. return { x: mouseEvent.pageX - img_origin.x, y: mouseEvent.pageY - img_origin.y }; }; // Given an offset in an img in CSS pixels, return the corresponding offset // in source image pixels. The offset_css can have properties like "x", // "xmin", "y", and "ymax" -- anything that starts with "x" and "y". If the // img content is 1000 pixels wide, but is scaled to 400 pixels on screen, // and the input is x:400, then this will return x:1000. coordmap.scaleCssToImg = function(offset_css) { const pixel_scaling = coordmap.imgToCssScalingRatio(); const result = mapValues(offset_css, (value, key) => { const prefix = key.substring(0, 1); if (prefix === "x") { return offset_css[key] / pixel_scaling.x; } else if (prefix === "y") { return offset_css[key] / pixel_scaling.y; } return null; }); return result; }; // Given an offset in an img, in source image pixels, return the // corresponding offset in CSS pixels. If the img content is 1000 pixels // wide, but is scaled to 400 pixels on screen, and the input is x:1000, // then this will return x:400. coordmap.scaleImgToCss = function(offset_img) { const pixel_scaling = coordmap.imgToCssScalingRatio(); const result = mapValues(offset_img, (value, key) => { const prefix = key.substring(0, 1); if (prefix === "x") { return offset_img[key] * pixel_scaling.x; } else if (prefix === "y") { return offset_img[key] * pixel_scaling.y; } return null; }); return result; }; // Returns the x and y ratio the image content is scaled to on screen. If // the image data is 1000 pixels wide and is scaled to 300 pixels on screen, // then this returns 0.3. (Note the 300 pixels refers to CSS pixels.) coordmap.imgToCssScalingRatio = function() { const img_dims = findDims($img); return { x: img_dims.x / coordmap.dims.width, y: img_dims.y / coordmap.dims.height }; }; coordmap.cssToImgScalingRatio = function() { const res = coordmap.imgToCssScalingRatio(); return { x: 1 / res.x, y: 1 / res.y }; }; // Given an offset in css pixels, return an object representing which panel // it's in. The `expand` argument tells it to expand the panel area by that // many pixels. It's possible for an offset to be within more than one // panel, because of the `expand` value. If that's the case, find the // nearest panel. coordmap.getPanelCss = function(offset_css, expand = 0) { const offset_img = coordmap.scaleCssToImg(offset_css); const x = offset_img.x; const y = offset_img.y; // Convert expand from css pixels to img pixels const cssToImgRatio = coordmap.cssToImgScalingRatio(); const expand_img = { x: expand * cssToImgRatio.x, y: expand * cssToImgRatio.y }; const matches = []; // Panels that match const dists = []; // Distance of offset to each matching panel let b; for (var i=0; i= b.left - expand_img.x && y <= b.bottom + expand_img.y && y >= b.top - expand_img.y) { matches.push(coordmap.panels[i]); // Find distance from edges for x and y var xdist = 0; var ydist = 0; if (x > b.right && x <= b.right + expand_img.x) { xdist = x - b.right; } else if (x < b.left && x >= b.left - expand_img.x) { xdist = x - b.left; } if (y > b.bottom && y <= b.bottom + expand_img.y) { ydist = y - b.bottom; } else if (y < b.top && y >= b.top - expand_img.y) { ydist = y - b.top; } // Cartesian distance dists.push(Math.sqrt( Math.pow(xdist, 2) + Math.pow(ydist, 2) )); } } if (matches.length) { // Find shortest distance var min_dist = Math.min.apply(null, dists); for (i=0; i max) { shiftAmount = max - maxval; } else if (minval < min) { shiftAmount = min - minval; } var newvals = []; for (var i=0; i 2 || Math.abs(pending_e.offsetY - e.offsetY) > 2) { triggerPendingMousedown2(); scheduleMousedown2(e); } else { // The second click was close to the first one. If it happened // within specified delay, trigger our custom 'dblclick2' event. pending_e = null; triggerEvent('dblclick2', e); } } } // IE8 needs a special hack because when you do a double-click it doesn't // trigger the click event twice - it directly triggers dblclick. function dblclickIE8(e) { e.which = 1; // In IE8, e.which is 0 instead of 1. ??? triggerEvent('dblclick2', e); } return { mousedown: mousedown, dblclickIE8: dblclickIE8 }; }; // ---------------------------------------------------------- // Handler creators for click, hover, brush. // Each of these returns an object with a few public members. These public // members are callbacks that are meant to be bound to events on $el with // the same name (like 'mousedown'). // ---------------------------------------------------------- imageutils.createClickHandler = function(inputId, clip, coordmap) { var clickInfoSender = coordmap.mouseCoordinateSender(inputId, clip); return { mousedown: function(e) { // Listen for left mouse button only if (e.which !== 1) return; clickInfoSender(e); }, onResetImg: function() { clickInfoSender(null); }, onResize: null }; }; imageutils.createHoverHandler = function(inputId, delay, delayType, clip, nullOutside, coordmap) { var sendHoverInfo = coordmap.mouseCoordinateSender(inputId, clip, nullOutside); var hoverInfoSender; if (delayType === 'throttle') hoverInfoSender = new Throttler(null, sendHoverInfo, delay); else hoverInfoSender = new Debouncer(null, sendHoverInfo, delay); // What to do when mouse exits the image var mouseout; if (nullOutside) mouseout = function() { hoverInfoSender.normalCall(null); }; else mouseout = function() {}; return { mousemove: function(e) { hoverInfoSender.normalCall(e); }, mouseout: mouseout, onResetImg: function() { hoverInfoSender.immediateCall(null); }, onResize: null }; }; // Returns a brush handler object. This has three public functions: // mousedown, mousemove, and onResetImg. imageutils.createBrushHandler = function(inputId, $el, opts, coordmap, outputId) { // Parameter: expand the area in which a brush can be started, by this // many pixels in all directions. (This should probably be a brush option) var expandPixels = 20; // Represents the state of the brush var brush = imageutils.createBrush($el, opts, coordmap, expandPixels); // Brush IDs can span multiple image/plot outputs. When an output is brushed, // if a brush with the same ID is active on a different image/plot, it must // be dismissed (but without sending any data to the server). We implement // this by sending the shiny-internal:brushed event to all plots, and letting // each plot decide for itself what to do. // // The decision to have the event sent to each plot (as opposed to a single // event triggered on, say, the document) was made to make cleanup easier; // listening on an event on the document would prevent garbage collection // of plot outputs that are removed from the document. $el.on("shiny-internal:brushed.image_output", function(e, coords) { // If the new brush shares our ID but not our output element ID, we // need to clear our brush (if any). if (coords.brushId === inputId && coords.outputId !== outputId) { $el.data("mostRecentBrush", false); brush.reset(); } }); // Set cursor to one of 7 styles. We need to set the cursor on the whole // el instead of the brush div, because the brush div has // 'pointer-events:none' so that it won't intercept pointer events. // If `style` is null, don't add a cursor style. function setCursorStyle(style) { $el.removeClass('crosshair grabbable grabbing ns-resize ew-resize nesw-resize nwse-resize'); if (style) $el.addClass(style); } function sendBrushInfo() { var coords = brush.boundsData(); // We're in a new or reset state if (isNaN(coords.xmin)) { exports.setInputValue(inputId, null); // Must tell other brushes to clear. imageOutputBinding.find(document).trigger("shiny-internal:brushed", { brushId: inputId, outputId: null }); return; } var panel = brush.getPanel(); // Add the panel (facet) variables, if present $.extend(coords, panel.panel_vars); coords.pixelratio = coordmap.cssToImgScalingRatio(); // Add variable name mappings coords.mapping = panel.mapping; // Add scaling information coords.domain = panel.domain; coords.range = panel.range; coords.log = panel.log; coords.direction = opts.brushDirection; coords.brushId = inputId; coords.outputId = outputId; // Send data to server exports.setInputValue(inputId, coords); $el.data("mostRecentBrush", true); imageOutputBinding.find(document).trigger("shiny-internal:brushed", coords); } var brushInfoSender; if (opts.brushDelayType === 'throttle') { brushInfoSender = new Throttler(null, sendBrushInfo, opts.brushDelay); } else { brushInfoSender = new Debouncer(null, sendBrushInfo, opts.brushDelay); } function mousedown(e) { // This can happen when mousedown inside the graphic, then mouseup // outside, then mousedown inside. Just ignore the second // mousedown. if (brush.isBrushing() || brush.isDragging() || brush.isResizing()) return; // Listen for left mouse button only if (e.which !== 1) return; // In general, brush uses css pixels, and coordmap uses img pixels. const offset_css = coordmap.mouseOffsetCss(e); // Ignore mousedown events outside of plotting region, expanded by // a number of pixels specified in expandPixels. if (opts.brushClip && !coordmap.isInPanelCss(offset_css, expandPixels)) return; brush.up({ x: NaN, y: NaN }); brush.down(offset_css); if (brush.isInResizeArea(offset_css)) { brush.startResizing(offset_css); // Attach the move and up handlers to the window so that they respond // even when the mouse is moved outside of the image. $(document) .on('mousemove.image_brush', mousemoveResizing) .on('mouseup.image_brush', mouseupResizing); } else if (brush.isInsideBrush(offset_css)) { brush.startDragging(offset_css); setCursorStyle('grabbing'); // Attach the move and up handlers to the window so that they respond // even when the mouse is moved outside of the image. $(document) .on('mousemove.image_brush', mousemoveDragging) .on('mouseup.image_brush', mouseupDragging); } else { const panel = coordmap.getPanelCss(offset_css, expandPixels); brush.startBrushing(panel.clipImg(coordmap.scaleCssToImg(offset_css))); // Attach the move and up handlers to the window so that they respond // even when the mouse is moved outside of the image. $(document) .on('mousemove.image_brush', mousemoveBrushing) .on('mouseup.image_brush', mouseupBrushing); } } // This sets the cursor style when it's in the el function mousemove(e) { // In general, brush uses css pixels, and coordmap uses img pixels. const offset_css = coordmap.mouseOffsetCss(e); if (!(brush.isBrushing() || brush.isDragging() || brush.isResizing())) { // Set the cursor depending on where it is if (brush.isInResizeArea(offset_css)) { const r = brush.whichResizeSides(offset_css); if ((r.left && r.top) || (r.right && r.bottom)) { setCursorStyle('nwse-resize'); } else if ((r.left && r.bottom) || (r.right && r.top)) { setCursorStyle('nesw-resize'); } else if (r.left || r.right) { setCursorStyle('ew-resize'); } else if (r.top || r.bottom) { setCursorStyle('ns-resize'); } } else if (brush.isInsideBrush(offset_css)) { setCursorStyle('grabbable'); } else if (coordmap.isInPanelCss(offset_css, expandPixels)) { setCursorStyle('crosshair'); } else { setCursorStyle(null); } } } // mousemove handlers while brushing or dragging function mousemoveBrushing(e) { brush.brushTo(coordmap.mouseOffsetCss(e)); brushInfoSender.normalCall(); } function mousemoveDragging(e) { brush.dragTo(coordmap.mouseOffsetCss(e)); brushInfoSender.normalCall(); } function mousemoveResizing(e) { brush.resizeTo(coordmap.mouseOffsetCss(e)); brushInfoSender.normalCall(); } // mouseup handlers while brushing or dragging function mouseupBrushing(e) { // Listen for left mouse button only if (e.which !== 1) return; $(document) .off('mousemove.image_brush') .off('mouseup.image_brush'); brush.up(coordmap.mouseOffsetCss(e)); brush.stopBrushing(); setCursorStyle('crosshair'); // If the brush didn't go anywhere, hide the brush, clear value, // and return. if (brush.down().x === brush.up().x && brush.down().y === brush.up().y) { brush.reset(); brushInfoSender.immediateCall(); return; } // Send info immediately on mouseup, but only if needed. If we don't // do the pending check, we might send the same data twice (with // with difference nonce). if (brushInfoSender.isPending()) brushInfoSender.immediateCall(); } function mouseupDragging(e) { // Listen for left mouse button only if (e.which !== 1) return; $(document) .off('mousemove.image_brush') .off('mouseup.image_brush'); brush.up(coordmap.mouseOffsetCss(e)); brush.stopDragging(); setCursorStyle('grabbable'); if (brushInfoSender.isPending()) brushInfoSender.immediateCall(); } function mouseupResizing(e) { // Listen for left mouse button only if (e.which !== 1) return; $(document) .off('mousemove.image_brush') .off('mouseup.image_brush'); brush.up(coordmap.mouseOffsetCss(e)); brush.stopResizing(); if (brushInfoSender.isPending()) brushInfoSender.immediateCall(); } // Brush maintenance: When an image is re-rendered, the brush must either // be removed (if brushResetOnNew) or imported (if !brushResetOnNew). The // "mostRecentBrush" bit is to ensure that when multiple outputs share the // same brush ID, inactive brushes don't send null values up to the server. // This should be called when the img (not the el) is reset function onResetImg() { if (opts.brushResetOnNew) { if ($el.data("mostRecentBrush")) { brush.reset(); brushInfoSender.immediateCall(); } } } if (!opts.brushResetOnNew) { if ($el.data("mostRecentBrush")) { // Importing an old brush must happen after the image data has loaded // and the DOM element has the updated size. If importOldBrush() // is called before this happens, then the css-img coordinate mappings // will give the wrong result, and the brush will have the wrong // position. $el.find("img").one("load.shiny-image-interaction", function() { brush.importOldBrush(); brushInfoSender.immediateCall(); }); } } function onResize() { brush.onResize(); brushInfoSender.immediateCall(); } return { mousedown: mousedown, mousemove: mousemove, onResetImg: onResetImg, onResize: onResize }; }; // Returns an object that represents the state of the brush. This gets wrapped // in a brushHandler, which provides various event listeners. imageutils.createBrush = function($el, opts, coordmap, expandPixels) { // Number of pixels outside of brush to allow start resizing var resizeExpand = 10; var el = $el[0]; var $div = null; // The div representing the brush var state = {}; // Aliases for conciseness const cssToImg = coordmap.scaleCssToImg; const imgToCss = coordmap.scaleImgToCss; reset(); function reset() { // Current brushing/dragging/resizing state state.brushing = false; state.dragging = false; state.resizing = false; // Offset of last mouse down and up events (in CSS pixels) state.down = { x: NaN, y: NaN }; state.up = { x: NaN, y: NaN }; // Which side(s) we're currently resizing state.resizeSides = { left: false, right: false, top: false, bottom: false }; // Bounding rectangle of the brush, in CSS pixel and data dimensions. We // need to record data dimensions along with pixel dimensions so that when // a new plot is sent, we can re-draw the brush div with the appropriate // coords. state.boundsCss = { xmin: NaN, xmax: NaN, ymin: NaN, ymax: NaN }; state.boundsData = { xmin: NaN, xmax: NaN, ymin: NaN, ymax: NaN }; // Panel object that the brush is in state.panel = null; // The bounds at the start of a drag/resize (in CSS pixels) state.changeStartBounds = { xmin: NaN, xmax: NaN, ymin: NaN, ymax: NaN }; if ($div) $div.remove(); } // If there's an existing brush div, use that div to set the new brush's // settings, provided that the x, y, and panel variables have the same names, // and there's a panel with matching panel variable values. function importOldBrush() { var oldDiv = $el.find('#' + el.id + '_brush'); if (oldDiv.length === 0) return; var oldBoundsData = oldDiv.data('bounds-data'); var oldPanel = oldDiv.data('panel'); if (!oldBoundsData || !oldPanel) return; // Find a panel that has matching vars; if none found, we can't restore. // The oldPanel and new panel must match on their mapping vars, and the // values. for (var i=0; i= bounds.xmin && offset_css.y <= bounds.ymax && offset_css.y >= bounds.ymin; } // Return true if offset is inside a region to start a resize function isInResizeArea(offset_css) { var sides = whichResizeSides(offset_css); return sides.left || sides.right || sides.top || sides.bottom; } // Return an object representing which resize region(s) the cursor is in. function whichResizeSides(offset_css) { const b = state.boundsCss; // Bounds with expansion const e = { xmin: b.xmin - resizeExpand, xmax: b.xmax + resizeExpand, ymin: b.ymin - resizeExpand, ymax: b.ymax + resizeExpand }; const res = { left: false, right: false, top: false, bottom: false }; if ((opts.brushDirection === 'xy' || opts.brushDirection === 'x') && (offset_css.y <= e.ymax && offset_css.y >= e.ymin)) { if (offset_css.x < b.xmin && offset_css.x >= e.xmin) res.left = true; else if (offset_css.x > b.xmax && offset_css.x <= e.xmax) res.right = true; } if ((opts.brushDirection === 'xy' || opts.brushDirection === 'y') && (offset_css.x <= e.xmax && offset_css.x >= e.xmin)) { if (offset_css.y < b.ymin && offset_css.y >= e.ymin) res.top = true; else if (offset_css.y > b.ymax && offset_css.y <= e.ymax) res.bottom = true; } return res; } // Sets the bounds of the brush (in CSS pixels), given a box and optional // panel. This will fit the box bounds into the panel, so we don't brush // outside of it. This knows whether we're brushing in the x, y, or xy // directions, and sets bounds accordingly. If no box is passed in, just // return current bounds. function boundsCss(box_css) { if (box_css === undefined) { return $.extend({}, state.boundsCss); } let min_css = { x: box_css.xmin, y: box_css.ymin }; let max_css = { x: box_css.xmax, y: box_css.ymax }; const panel = state.panel; const panelBounds_img = panel.range; if (opts.brushClip) { min_css = imgToCss(panel.clipImg(cssToImg(min_css))); max_css = imgToCss(panel.clipImg(cssToImg(max_css))); } if (opts.brushDirection === 'xy') { // No change } else if (opts.brushDirection === 'x') { // Extend top and bottom of plotting area min_css.y = imgToCss({y: panelBounds_img.top }).y; max_css.y = imgToCss({y: panelBounds_img.bottom}).y; } else if (opts.brushDirection === 'y') { min_css.x = imgToCss({x: panelBounds_img.left }).x; max_css.x = imgToCss({x: panelBounds_img.right}).x; } state.boundsCss = { xmin: min_css.x, xmax: max_css.x, ymin: min_css.y, ymax: max_css.y }; // Positions in data space const min_data = state.panel.scaleImgToData(cssToImg(min_css)); const max_data = state.panel.scaleImgToData(cssToImg(max_css)); // For reversed scales, the min and max can be reversed, so use findBox // to ensure correct order. state.boundsData = imageutils.findBox(min_data, max_data); // Round to 14 significant digits to avoid spurious changes in FP values // (#1634). state.boundsData = mapValues(state.boundsData, val => roundSignif(val, 14)); // We also need to attach the data bounds and panel as data attributes, so // that if the image is re-sent, we can grab the data bounds to create a new // brush. This should be fast because it doesn't actually modify the DOM. $div.data('bounds-data', state.boundsData); $div.data('panel', state.panel); return undefined; } // Get or set the bounds of the brush using coordinates in the data space. function boundsData(box_data) { if (box_data === undefined) { return $.extend({}, state.boundsData); } const box_css = imgToCss(state.panel.scaleDataToImg(box_data)); // The scaling function can reverse the direction of the axes, so we need to // find the min and max again. boundsCss({ xmin: Math.min(box_css.xmin, box_css.xmax), xmax: Math.max(box_css.xmin, box_css.xmax), ymin: Math.min(box_css.ymin, box_css.ymax), ymax: Math.max(box_css.ymin, box_css.ymax) }); return undefined; } function getPanel() { return state.panel; } // Add a new div representing the brush. function addDiv() { if ($div) $div.remove(); // Start hidden; we'll show it when movement occurs $div = $(document.createElement('div')) .attr('id', el.id + '_brush') .css({ 'background-color': opts.brushFill, 'opacity': opts.brushOpacity, 'pointer-events': 'none', 'position': 'absolute' }) .hide(); var borderStyle = '1px solid ' + opts.brushStroke; if (opts.brushDirection === 'xy') { $div.css({ 'border': borderStyle }); } else if (opts.brushDirection === 'x') { $div.css({ 'border-left': borderStyle, 'border-right': borderStyle }); } else if (opts.brushDirection === 'y') { $div.css({ 'border-top': borderStyle, 'border-bottom': borderStyle }); } $el.append($div); $div.offset({x:0, y:0}).width(0).outerHeight(0); } // Update the brush div to reflect the current brush bounds. function updateDiv() { // Need parent offset relative to page to calculate mouse offset // relative to page. const img_offset_css = findOrigin($el.find("img")); const b = state.boundsCss; $div.offset({ top: img_offset_css.y + b.ymin, left: img_offset_css.x + b.xmin }) .outerWidth(b.xmax - b.xmin + 1) .outerHeight(b.ymax - b.ymin + 1); } function down(offset_css) { if (offset_css === undefined) return state.down; state.down = offset_css; return undefined; } function up(offset_css) { if (offset_css === undefined) return state.up; state.up = offset_css; return undefined; } function isBrushing() { return state.brushing; } function startBrushing() { state.brushing = true; addDiv(); state.panel = coordmap.getPanelCss(state.down, expandPixels); boundsCss(imageutils.findBox(state.down, state.down)); updateDiv(); } function brushTo(offset_css) { boundsCss(imageutils.findBox(state.down, offset_css)); $div.show(); updateDiv(); } function stopBrushing() { state.brushing = false; // Save the final bounding box of the brush boundsCss(imageutils.findBox(state.down, state.up)); } function isDragging() { return state.dragging; } function startDragging() { state.dragging = true; state.changeStartBounds = $.extend({}, state.boundsCss); } function dragTo(offset_css) { // How far the brush was dragged const dx = offset_css.x - state.down.x; const dy = offset_css.y - state.down.y; // Calculate what new positions would be, before clipping. const start = state.changeStartBounds; let newBounds_css = { xmin: start.xmin + dx, xmax: start.xmax + dx, ymin: start.ymin + dy, ymax: start.ymax + dy }; // Clip to the plotting area if (opts.brushClip) { const panelBounds_img = state.panel.range; const newBounds_img = cssToImg(newBounds_css); // Convert to format for shiftToRange let xvals_img = [ newBounds_img.xmin, newBounds_img.xmax ]; let yvals_img = [ newBounds_img.ymin, newBounds_img.ymax ]; xvals_img = imageutils.shiftToRange(xvals_img, panelBounds_img.left, panelBounds_img.right); yvals_img = imageutils.shiftToRange(yvals_img, panelBounds_img.top, panelBounds_img.bottom); // Convert back to bounds format newBounds_css = imgToCss({ xmin: xvals_img[0], xmax: xvals_img[1], ymin: yvals_img[0], ymax: yvals_img[1] }); } boundsCss(newBounds_css); updateDiv(); } function stopDragging() { state.dragging = false; } function isResizing() { return state.resizing; } function startResizing() { state.resizing = true; state.changeStartBounds = $.extend({}, state.boundsCss); state.resizeSides = whichResizeSides(state.down); } function resizeTo(offset_css) { // How far the brush was dragged const d_css = { x: offset_css.x - state.down.x, y: offset_css.y - state.down.y }; const d_img = cssToImg(d_css); // Calculate what new positions would be, before clipping. const b_img = cssToImg(state.changeStartBounds); const panelBounds_img = state.panel.range; if (state.resizeSides.left) { const xmin_img = imageutils.shiftToRange(b_img.xmin + d_img.x, panelBounds_img.left, b_img.xmax)[0]; b_img.xmin = xmin_img; } else if (state.resizeSides.right) { const xmax_img = imageutils.shiftToRange(b_img.xmax + d_img.x, b_img.xmin, panelBounds_img.right)[0]; b_img.xmax = xmax_img; } if (state.resizeSides.top) { const ymin_img = imageutils.shiftToRange(b_img.ymin + d_img.y, panelBounds_img.top, b_img.ymax)[0]; b_img.ymin = ymin_img; } else if (state.resizeSides.bottom) { const ymax_img = imageutils.shiftToRange(b_img.ymax + d_img.y, b_img.ymin, panelBounds_img.bottom)[0]; b_img.ymax = ymax_img; } boundsCss(imgToCss(b_img)); updateDiv(); } function stopResizing() { state.resizing = false; } return { reset: reset, importOldBrush: importOldBrush, isInsideBrush: isInsideBrush, isInResizeArea: isInResizeArea, whichResizeSides: whichResizeSides, onResize: onResize, // A callback when the wrapper div or img is resized. boundsCss: boundsCss, boundsData: boundsData, getPanel: getPanel, down: down, up: up, isBrushing: isBrushing, startBrushing: startBrushing, brushTo: brushTo, stopBrushing: stopBrushing, isDragging: isDragging, startDragging: startDragging, dragTo: dragTo, stopDragging: stopDragging, isResizing: isResizing, startResizing: startResizing, resizeTo: resizeTo, stopResizing: stopResizing }; }; exports.resetBrush = function(brushId) { exports.setInputValue(brushId, null); imageOutputBinding.find(document).trigger("shiny-internal:brushed", { brushId: brushId, outputId: null }); }; // ----------------------------------------------------------------------- // Utility functions for finding dimensions and locations of DOM elements // ----------------------------------------------------------------------- // Returns the ratio that an element has been scaled (for example, by CSS // transforms) in the x and y directions. function findScalingRatio($el) { const boundingRect = $el[0].getBoundingClientRect(); return { x: boundingRect.width / $el.outerWidth(), y: boundingRect.height / $el.outerHeight() }; } function findOrigin($el) { const offset = $el.offset(); const scaling_ratio = findScalingRatio($el); // Find the size of the padding and border, for the top and left. This is // before any transforms. const paddingBorder = { left: parseInt($el.css("border-left-width")) + parseInt($el.css("padding-left")), top: parseInt($el.css("border-top-width")) + parseInt($el.css("padding-top")) }; // offset() returns the upper left corner of the element relative to the // page, but it includes padding and border. Here we find the upper left // of the element, not including padding and border. return { x: offset.left + scaling_ratio.x * paddingBorder.left, y: offset.top + scaling_ratio.y * paddingBorder.top }; } // Find the dimensions of a tag, after transforms, and without padding and // border. function findDims($el) { // If there's any padding/border, we need to find the ratio of the actual // element content compared to the element plus padding and border. const content_ratio = { x: $el.width() / $el.outerWidth(), y: $el.height() / $el.outerHeight() }; // Get the dimensions of the element _after_ any CSS transforms. This // includes the padding and border. const bounding_rect = $el[0].getBoundingClientRect(); // Dimensions of the element after any CSS transforms, and without // padding/border. return { x: content_ratio.x * bounding_rect.width, y: content_ratio.y * bounding_rect.height }; }