mirror of
https://github.com/rstudio/shiny.git
synced 2026-02-08 05:35:07 -05:00
1570 lines
48 KiB
JavaScript
1570 lines
48 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');
|
|
|
|
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 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<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 offset_css = coordmap.mouseOffsetCss(e);
|
|
// If outside of plotting region
|
|
if (!coordmap.isInPanelCss(offset_css)) {
|
|
if (nullOutside) {
|
|
exports.setInputValue(inputId, null);
|
|
return;
|
|
}
|
|
if (clip)
|
|
return;
|
|
}
|
|
if (clip && !coordmap.isInPanelCss(offset_css)) return;
|
|
|
|
const panel = coordmap.getPanelCss(offset_css);
|
|
|
|
const coords = panel.scaleImgToData(coordmap.scaleCssToImg(offset_css));
|
|
|
|
coords.pixelratio = 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.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 <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
|
|
};
|
|
}
|