mirror of
https://github.com/rstudio/shiny.git
synced 2026-02-08 21:55:02 -05:00
* wait for image to be loaded in browser before initializing handlers
reverts similar behavior in 3354a47e8a
* default the height and width to the image clientHeight and clientWidth
* use raw image clientWidth and clientHeight instead of container clientWidth and clientHeight
prevents being able to brush on non image areas
1596 lines
50 KiB
JavaScript
1596 lines
50 KiB
JavaScript
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<img.attributes.length; i++) {
|
|
var attrib = img.attributes[i];
|
|
// Need to check attrib.specified on IE because img.attributes contains
|
|
// all possible attributes on IE.
|
|
if (attrib.specified && !data.hasOwnProperty(attrib.name)) {
|
|
img.removeAttribute(attrib.name);
|
|
}
|
|
}
|
|
|
|
if (!opts.coordmap) {
|
|
opts.coordmap = {
|
|
panels: [],
|
|
dims: {
|
|
height: null,
|
|
width: null
|
|
}
|
|
};
|
|
}
|
|
|
|
// Remove event handlers that were added in previous runs of this function.
|
|
$el.off('.image_output');
|
|
$img.off('.image_output');
|
|
|
|
// When the image loads, initialize all the interaction handlers. When the
|
|
// value of src is set, the browser may not load the image immediately,
|
|
// even if it's a data URL. If we try to initialize this stuff
|
|
// immediately, it can cause problems because we use we need the raw image
|
|
// height and width
|
|
$img.off("load.shiny-image-interaction");
|
|
$img.on("load.shiny-image-interaction", function() {
|
|
|
|
imageutils.initCoordmap($el, opts.coordmap);
|
|
|
|
// This object listens for mousedowns, and triggers mousedown2 and dblclick2
|
|
// events as appropriate.
|
|
var clickInfo = imageutils.createClickInfo($el, opts.dblclickId, opts.dblclickDelay);
|
|
|
|
$el.on('mousedown.image_output', clickInfo.mousedown);
|
|
|
|
if (browser.isIE && browser.IEVersion === 8) {
|
|
$el.on('dblclick.image_output', clickInfo.dblclickIE8);
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Register the various event handlers
|
|
// ----------------------------------------------------------
|
|
if (opts.clickId) {
|
|
var clickHandler = imageutils.createClickHandler(opts.clickId,
|
|
opts.clickClip, opts.coordmap);
|
|
$el.on('mousedown2.image_output', clickHandler.mousedown);
|
|
|
|
$el.on('resize.image_output', clickHandler.onResize);
|
|
|
|
// When img is reset, do housekeeping: clear $el's mouse listener and
|
|
// call the handler's onResetImg callback.
|
|
$img.on('reset.image_output', clickHandler.onResetImg);
|
|
}
|
|
|
|
if (opts.dblclickId) {
|
|
// We'll use the clickHandler's mousedown function, but register it to
|
|
// our custom 'dblclick2' event.
|
|
var dblclickHandler = imageutils.createClickHandler(opts.dblclickId,
|
|
opts.clickClip, opts.coordmap);
|
|
$el.on('dblclick2.image_output', dblclickHandler.mousedown);
|
|
|
|
$el.on('resize.image_output', dblclickHandler.onResize);
|
|
$img.on('reset.image_output', dblclickHandler.onResetImg);
|
|
}
|
|
|
|
if (opts.hoverId) {
|
|
var hoverHandler = imageutils.createHoverHandler(opts.hoverId,
|
|
opts.hoverDelay, opts.hoverDelayType, opts.hoverClip,
|
|
opts.hoverNullOutside, opts.coordmap);
|
|
$el.on('mousemove.image_output', hoverHandler.mousemove);
|
|
$el.on('mouseout.image_output', hoverHandler.mouseout);
|
|
|
|
$el.on('resize.image_output', hoverHandler.onResize);
|
|
$img.on('reset.image_output', hoverHandler.onResetImg);
|
|
}
|
|
|
|
if (opts.brushId) {
|
|
// Make image non-draggable (Chrome, Safari)
|
|
$img.css('-webkit-user-drag', 'none');
|
|
// Firefox, IE<=10
|
|
$img.on('dragstart', function() { return false; });
|
|
|
|
// Disable selection of image and text when dragging in IE<=10
|
|
$el.on('selectstart.image_output', function() { return false; });
|
|
|
|
var brushHandler = imageutils.createBrushHandler(opts.brushId, $el, opts,
|
|
opts.coordmap, outputId);
|
|
$el.on('mousedown.image_output', brushHandler.mousedown);
|
|
$el.on('mousemove.image_output', brushHandler.mousemove);
|
|
|
|
$el.on('resize.image_output', brushHandler.onResize);
|
|
$img.on('reset.image_output', brushHandler.onResetImg);
|
|
}
|
|
|
|
if (opts.clickId || opts.dblclickId || opts.hoverId || opts.brushId) {
|
|
$el.addClass('crosshair');
|
|
}
|
|
|
|
if (data.error)
|
|
console.log('Error on server extracting coordmap: ' + data.error);
|
|
|
|
});
|
|
},
|
|
|
|
renderError: function(el, err) {
|
|
$(el).find('img').trigger('reset');
|
|
OutputBinding.prototype.renderError.call(this, el, err);
|
|
},
|
|
|
|
clearError: function(el) {
|
|
// Remove all elements except img and the brush; this is usually just
|
|
// error messages.
|
|
$(el).contents().filter(function() {
|
|
return this.tagName !== "IMG" &&
|
|
this.id !== el.id + '_brush';
|
|
}).remove();
|
|
|
|
OutputBinding.prototype.clearError.call(this, el);
|
|
},
|
|
|
|
resize: function(el, width, height) {
|
|
$(el).find("img").trigger("resize");
|
|
}
|
|
});
|
|
outputBindings.register(imageOutputBinding, 'shiny.imageOutput');
|
|
|
|
|
|
var imageutils = {};
|
|
|
|
|
|
// Modifies the panel objects in a coordmap, adding scaleImgToData(),
|
|
// scaleDataToImg(), and clipImg() functions to each one. The panel objects
|
|
// use img and data coordinates only; they do not use css coordinates. The
|
|
// domain is in data coordinates; the range is in img coordinates.
|
|
imageutils.initPanelScales = function(panels) {
|
|
// Map a value x from a domain to a range. If clip is true, clip it to the
|
|
// range.
|
|
function mapLinear(x, domainMin, domainMax, rangeMin, rangeMax, clip) {
|
|
// By default, clip to range
|
|
clip = clip || true;
|
|
|
|
var factor = (rangeMax - rangeMin) / (domainMax - domainMin);
|
|
var val = x - domainMin;
|
|
var newval = (val * factor) + rangeMin;
|
|
|
|
if (clip) {
|
|
var max = Math.max(rangeMax, rangeMin);
|
|
var min = Math.min(rangeMax, rangeMin);
|
|
if (newval > 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<panels.length; i++) {
|
|
var panel = panels[i];
|
|
addScaleFuns(panel);
|
|
}
|
|
};
|
|
|
|
|
|
// This adds functions to the coordmap object to handle various
|
|
// coordinate-mapping tasks, and send information to the server. The input
|
|
// coordmap is an array of objects, each of which represents a panel. coordmap
|
|
// must be an array, even if empty, so that it can be modified in place; when
|
|
// empty, we add a dummy panel to the array. It also calls initPanelScales,
|
|
// which modifies each panel object to have scaleImgToData, scaleDataToImg,
|
|
// and clip functions.
|
|
//
|
|
// There are three coordinate spaces which we need to translate between:
|
|
//
|
|
// 1. css: The pixel coordinates in the web browser, also known as CSS pixels.
|
|
// The origin is the upper-left corner of the <img> (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 $img = $el.find("img");
|
|
const img = $img[0];
|
|
|
|
// 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: img.clientWidth - 1,
|
|
bottom: img.clientHeight - 1
|
|
};
|
|
|
|
coordmap.panels[0] = {
|
|
domain: bounds,
|
|
range: bounds,
|
|
mapping: {}
|
|
};
|
|
}
|
|
|
|
// if no dim height and width values are found, set them to the image height and width
|
|
coordmap.dims.height = coordmap.dims.height || img.clientHeight;
|
|
coordmap.dims.width = coordmap.dims.width || img.clientWidth;
|
|
|
|
// 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<coordmap.panels.length; i++) {
|
|
b = coordmap.panels[i].range;
|
|
|
|
if (x <= b.right + expand_img.x &&
|
|
x >= 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<matches.length; i++) {
|
|
if (dists[i] === min_dist) {
|
|
return matches[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Is an offset (in css pixels) in a panel? If supplied, `expand` tells us
|
|
// to expand the panels by that many pixels in all directions.
|
|
coordmap.isInPanelCss = function(offset_css, expand = 0) {
|
|
if (coordmap.getPanelCss(offset_css, expand))
|
|
return true;
|
|
|
|
return false;
|
|
};
|
|
|
|
// Returns a function that sends mouse coordinates, scaled to data space.
|
|
// If that function is passed a null event, it will send null.
|
|
coordmap.mouseCoordinateSender = function(inputId, clip, nullOutside) {
|
|
if (clip === undefined) clip = true;
|
|
if (nullOutside === undefined) nullOutside = false;
|
|
|
|
return function(e) {
|
|
if (e === null) {
|
|
exports.setInputValue(inputId, null);
|
|
return;
|
|
}
|
|
const coords = {};
|
|
const coords_css = coordmap.mouseOffsetCss(e);
|
|
// If outside of plotting region
|
|
if (!coordmap.isInPanelCss(coords_css)) {
|
|
if (nullOutside) {
|
|
exports.setInputValue(inputId, null);
|
|
return;
|
|
}
|
|
if (clip)
|
|
return;
|
|
|
|
coords.coords_css = coords_css;
|
|
coords.coords_img = coordmap.scaleCssToImg(coords_css);
|
|
|
|
exports.setInputValue(inputId, coords, {priority: "event"});
|
|
return;
|
|
}
|
|
const panel = coordmap.getPanelCss(coords_css);
|
|
|
|
const coords_img = coordmap.scaleCssToImg(coords_css);
|
|
const coords_data = panel.scaleImgToData(coords_img);
|
|
coords.x = coords_data.x;
|
|
coords.y = coords_data.y;
|
|
coords.coords_css = coords_css;
|
|
coords.coords_img = coords_img;
|
|
|
|
coords.img_css_ratio = coordmap.cssToImgScalingRatio();
|
|
|
|
// Add the panel (facet) variables, if present
|
|
$.extend(coords, panel.panel_vars);
|
|
|
|
// Add variable name mappings
|
|
coords.mapping = panel.mapping;
|
|
|
|
// Add scaling information
|
|
coords.domain = panel.domain;
|
|
coords.range = panel.range;
|
|
coords.log = panel.log;
|
|
|
|
exports.setInputValue(inputId, coords, {priority: "event"});
|
|
};
|
|
};
|
|
};
|
|
|
|
|
|
// Given two sets of x/y coordinates, return an object representing the min
|
|
// and max x and y values. (This could be generalized to any number of
|
|
// points).
|
|
imageutils.findBox = function(offset1, offset2) {
|
|
return {
|
|
xmin: Math.min(offset1.x, offset2.x),
|
|
xmax: Math.max(offset1.x, offset2.x),
|
|
ymin: Math.min(offset1.y, offset2.y),
|
|
ymax: Math.max(offset1.y, offset2.y)
|
|
};
|
|
};
|
|
|
|
// Shift an array of values so that they are within a min and max. The vals
|
|
// will be shifted so that they maintain the same spacing internally. If the
|
|
// range in vals is larger than the range of min and max, the result might not
|
|
// make sense.
|
|
imageutils.shiftToRange = function(vals, min, max) {
|
|
if (!(vals instanceof Array))
|
|
vals = [vals];
|
|
|
|
var maxval = Math.max.apply(null, vals);
|
|
var minval = Math.min.apply(null, vals);
|
|
var shiftAmount = 0;
|
|
if (maxval > max) {
|
|
shiftAmount = max - maxval;
|
|
} else if (minval < min) {
|
|
shiftAmount = min - minval;
|
|
}
|
|
|
|
var newvals = [];
|
|
for (var i=0; i<vals.length; i++) {
|
|
newvals[i] = vals[i] + shiftAmount;
|
|
}
|
|
return newvals;
|
|
};
|
|
|
|
|
|
|
|
// This object provides two public event listeners: mousedown, and
|
|
// dblclickIE8.
|
|
// We need to make sure that, when the image is listening for double-
|
|
// clicks, that a double-click doesn't trigger two click events. We'll
|
|
// trigger custom mousedown2 and dblclick2 events with this mousedown
|
|
// listener.
|
|
imageutils.createClickInfo = function($el, dblclickId, dblclickDelay) {
|
|
var clickTimer = null;
|
|
var pending_e = null; // A pending mousedown2 event
|
|
|
|
// Create a new event of type eventType (like 'mousedown2'), and trigger
|
|
// it with the information stored in this.e.
|
|
function triggerEvent(newEventType, e) {
|
|
// Extract important info from e and construct a new event with type
|
|
// eventType.
|
|
var e2 = $.Event(newEventType, {
|
|
which: e.which,
|
|
pageX: e.pageX,
|
|
pageY: e.pageY,
|
|
offsetX: e.offsetX,
|
|
offsetY: e.offsetY
|
|
});
|
|
|
|
$el.trigger(e2);
|
|
}
|
|
|
|
function triggerPendingMousedown2() {
|
|
// It's possible that between the scheduling of a mousedown2 and the
|
|
// time this callback is executed, someone else triggers a
|
|
// mousedown2, so check for that.
|
|
if (pending_e) {
|
|
triggerEvent('mousedown2', pending_e);
|
|
pending_e = null;
|
|
}
|
|
}
|
|
|
|
// Set a timer to trigger a mousedown2 event, using information from the
|
|
// last recorded mousdown event.
|
|
function scheduleMousedown2(e) {
|
|
pending_e = e;
|
|
|
|
clickTimer = setTimeout(function() {
|
|
triggerPendingMousedown2();
|
|
}, dblclickDelay);
|
|
}
|
|
|
|
function mousedown(e) {
|
|
// Listen for left mouse button only
|
|
if (e.which !== 1) return;
|
|
|
|
// If no dblclick listener, immediately trigger a mousedown2 event.
|
|
if (!dblclickId) {
|
|
triggerEvent('mousedown2', e);
|
|
return;
|
|
}
|
|
|
|
// If there's a dblclick listener, make sure not to count this as a
|
|
// click on the first mousedown; we need to wait for the dblclick
|
|
// delay before we can be sure this click was a single-click.
|
|
if (pending_e === null) {
|
|
scheduleMousedown2(e);
|
|
|
|
} else {
|
|
clearTimeout(clickTimer);
|
|
|
|
// If second click is too far away, it doesn't count as a double
|
|
// click. Instead, immediately trigger a mousedown2 for the previous
|
|
// click, and set this click as a new first click.
|
|
if (pending_e &&
|
|
Math.abs(pending_e.offsetX - e.offsetX) > 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.coords_css = brush.boundsCss();
|
|
coords.coords_img = coordmap.scaleCssToImg(coords.coords_css);
|
|
|
|
coords.img_css_ratio = 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 <img> 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<coordmap.panels.length; i++){
|
|
var curPanel = coordmap.panels[i];
|
|
|
|
if (equal(oldPanel.mapping, curPanel.mapping) &&
|
|
equal(oldPanel.panel_vars, curPanel.panel_vars)) {
|
|
// We've found a matching panel
|
|
state.panel = coordmap.panels[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If we didn't find a matching panel, remove the old div and return
|
|
if (state.panel === null) {
|
|
oldDiv.remove();
|
|
return;
|
|
}
|
|
|
|
$div = oldDiv;
|
|
|
|
boundsData(oldBoundsData);
|
|
updateDiv();
|
|
}
|
|
|
|
// This will reposition the brush div when the image is resized, maintaining
|
|
// the same data coordinates. Note that the "resize" here refers to the
|
|
// wrapper div/img being resized; elsewhere, "resize" refers to the brush
|
|
// div being resized.
|
|
function onResize() {
|
|
const bounds_data = boundsData();
|
|
// Check to see if we have valid boundsData
|
|
for (let val in bounds_data) {
|
|
if (isnan(bounds_data[val])) return;
|
|
}
|
|
|
|
boundsData(bounds_data);
|
|
updateDiv();
|
|
}
|
|
|
|
// Return true if the offset is inside min/max coords
|
|
function isInsideBrush(offset_css) {
|
|
var bounds = state.boundsCss;
|
|
return offset_css.x <= bounds.xmax && offset_css.x >= 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
|
|
};
|
|
}
|