diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index f81436159da..cf11a1a7f90 100644 --- a/src/components/annotations/annotation_defaults.js +++ b/src/components/annotations/annotation_defaults.js @@ -11,8 +11,8 @@ var Lib = require('../../lib'); var Color = require('../color'); +var Fx = require('../fx'); var Axes = require('../../plots/cartesian/axes'); -var constants = require('../../plots/cartesian/constants'); var attributes = require('./attributes'); @@ -118,8 +118,8 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op Color.opacity(bgColor) ? Color.rgb(bgColor) : Color.defaultLine); var hoverBorder = coerce('hoverlabel.bordercolor', Color.contrast(hoverBG)); Lib.coerceFont(coerce, 'hoverlabel.font', { - family: constants.HOVERFONT, - size: constants.HOVERFONTSIZE, + family: Fx.constants.HOVERFONT, + size: Fx.constants.HOVERFONTSIZE, color: hoverBorder }); } diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 371af5ef0da..6123d9fc0b5 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -15,9 +15,9 @@ var Plotly = require('../../plotly'); var Plots = require('../../plots/plots'); var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); -var Fx = require('../../plots/cartesian/graph_interact'); var Color = require('../color'); var Drawing = require('../drawing'); +var Fx = require('../fx'); var svgTextUtils = require('../../lib/svg_text_utils'); var setCursor = require('../../lib/setcursor'); var dragElement = require('../dragelement'); diff --git a/src/components/fx/click.js b/src/components/fx/click.js new file mode 100644 index 00000000000..0fb3ed79828 --- /dev/null +++ b/src/components/fx/click.js @@ -0,0 +1,27 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var Registry = require('../../registry'); + +module.exports = function click(gd, evt) { + var annotationsDone = Registry.getComponentMethod('annotations', 'onClick')(gd, gd._hoverdata); + + function emitClick() { gd.emit('plotly_click', {points: gd._hoverdata, event: evt}); } + + if(gd._hoverdata && evt && evt.target) { + if(annotationsDone && annotationsDone.then) { + annotationsDone.then(emitClick); + } + else emitClick(); + + // why do we get a double event without this??? + if(evt.stopImmediatePropagation) evt.stopImmediatePropagation(); + } +}; diff --git a/src/components/fx/constants.js b/src/components/fx/constants.js new file mode 100644 index 00000000000..03d726e42da --- /dev/null +++ b/src/components/fx/constants.js @@ -0,0 +1,26 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = { + // max pixels away from mouse to allow a point to highlight + MAXDIST: 20, + + // hover labels for multiple horizontal bars get tilted by this angle + YANGLE: 60, + + // size and display constants for hover text + HOVERARROWSIZE: 6, // pixel size of hover arrows + HOVERTEXTPAD: 3, // pixels padding around text + HOVERFONTSIZE: 13, + HOVERFONT: 'Arial, sans-serif', + + // minimum time (msec) between hover calls + HOVERMINTIME: 50, +}; diff --git a/src/components/fx/helpers.js b/src/components/fx/helpers.js new file mode 100644 index 00000000000..683009efe30 --- /dev/null +++ b/src/components/fx/helpers.js @@ -0,0 +1,81 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var constants = require('./constants'); + +// look for either subplot or xaxis and yaxis attributes +exports.getSubplot = function getSubplot(trace) { + return trace.subplot || (trace.xaxis + trace.yaxis) || trace.geo; +}; + +// convenience functions for mapping all relevant axes +exports.flat = function flat(subplots, v) { + var out = []; + for(var i = subplots.length; i > 0; i--) out.push(v); + return out; +}; + +exports.p2c = function p2c(axArray, v) { + var out = []; + for(var i = 0; i < axArray.length; i++) out.push(axArray[i].p2c(v)); + return out; +}; + +exports.getDistanceFunction = function getDistanceFunction(mode, dx, dy, dxy) { + if(mode === 'closest') return dxy || quadrature(dx, dy); + return mode === 'x' ? dx : dy; +}; + +exports.getClosest = function getClosest(cd, distfn, pointData) { + // do we already have a point number? (array mode only) + if(pointData.index !== false) { + if(pointData.index >= 0 && pointData.index < cd.length) { + pointData.distance = 0; + } + else pointData.index = false; + } + else { + // apply the distance function to each data point + // this is the longest loop... if this bogs down, we may need + // to create pre-sorted data (by x or y), not sure how to + // do this for 'closest' + for(var i = 0; i < cd.length; i++) { + var newDistance = distfn(cd[i]); + if(newDistance <= pointData.distance) { + pointData.index = i; + pointData.distance = newDistance; + } + } + } + return pointData; +}; + +// for bar charts and others with finite-size objects: you must be inside +// it to see its hover info, so distance is infinite outside. +// But make distance inside be at least 1/4 MAXDIST, and a little bigger +// for bigger bars, to prioritize scatter and smaller bars over big bars +// +// note that for closest mode, two inbox's will get added in quadrature +// args are (signed) difference from the two opposite edges +// count one edge as in, so that over continuous ranges you never get a gap +exports.inbox = function inbox(v0, v1) { + if(v0 * v1 < 0 || v0 === 0) { + return constants.MAXDIST * (0.6 - 0.3 / Math.max(3, Math.abs(v0 - v1))); + } + return Infinity; +}; + +function quadrature(dx, dy) { + return function(di) { + var x = dx(di), + y = dy(di); + return Math.sqrt(x * x + y * y); + }; +} diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js new file mode 100644 index 00000000000..803b9e33e89 --- /dev/null +++ b/src/components/fx/hover.js @@ -0,0 +1,1287 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var d3 = require('d3'); +var isNumeric = require('fast-isnumeric'); +var tinycolor = require('tinycolor2'); + +var Lib = require('../../lib'); +var Events = require('../../lib/events'); +var svgTextUtils = require('../../lib/svg_text_utils'); +var overrideCursor = require('../../lib/override_cursor'); +var Drawing = require('../drawing'); +var Color = require('../color'); +var dragElement = require('../dragelement'); +var Axes = require('../../plots/cartesian/axes'); +var Registry = require('../../registry'); + +var helpers = require('./helpers'); +var constants = require('./constants'); + +// hover labels for multiple horizontal bars get tilted by some angle, +// then need to be offset differently if they overlap +var YANGLE = constants.YANGLE; +var YA_RADIANS = Math.PI * YANGLE / 180; + +// expansion of projected height +var YFACTOR = 1 / Math.sin(YA_RADIANS); + +// to make the appropriate post-rotation x offset, +// you need both x and y offsets +var YSHIFTX = Math.cos(YA_RADIANS); +var YSHIFTY = Math.sin(YA_RADIANS); + +// size and display constants for hover text +var HOVERARROWSIZE = constants.HOVERARROWSIZE; +var HOVERTEXTPAD = constants.HOVERTEXTPAD; + +// fx.hover: highlight data on hover +// evt can be a mousemove event, or an object with data about what points +// to hover on +// {xpx,ypx[,hovermode]} - pixel locations from top left +// (with optional overriding hovermode) +// {xval,yval[,hovermode]} - data values +// [{curveNumber,(pointNumber|xval and/or yval)}] - +// array of specific points to highlight +// pointNumber is a single integer if gd.data[curveNumber] is 1D, +// or a two-element array if it's 2D +// xval and yval are data values, +// 1D data may specify either or both, +// 2D data must specify both +// subplot is an id string (default "xy") +// makes use of gl.hovermode, which can be: +// x (find the points with the closest x values, ie a column), +// closest (find the single closest point) +// internally there are two more that occasionally get used: +// y (pick out a row - only used for multiple horizontal bar charts) +// array (used when the user specifies an explicit +// array of points to hover on) +// +// We wrap the hovers in a timer, to limit their frequency. +// The actual rendering is done by private function _hover. +exports.hover = function hover(gd, evt, subplot) { + if(typeof gd === 'string') gd = document.getElementById(gd); + if(gd._lastHoverTime === undefined) gd._lastHoverTime = 0; + + // If we have an update queued, discard it now + if(gd._hoverTimer !== undefined) { + clearTimeout(gd._hoverTimer); + gd._hoverTimer = undefined; + } + // Is it more than 100ms since the last update? If so, force + // an update now (synchronously) and exit + if(Date.now() > gd._lastHoverTime + constants.HOVERMINTIME) { + _hover(gd, evt, subplot); + gd._lastHoverTime = Date.now(); + return; + } + // Queue up the next hover for 100ms from now (if no further events) + gd._hoverTimer = setTimeout(function() { + _hover(gd, evt, subplot); + gd._lastHoverTime = Date.now(); + gd._hoverTimer = undefined; + }, constants.HOVERMINTIME); +}; + +/* + * Draw a single hover item in a pre-existing svg container somewhere + * hoverItem should have keys: + * - x and y (or x0, x1, y0, and y1): + * the pixel position to mark, relative to opts.container + * - xLabel, yLabel, zLabel, text, and name: + * info to go in the label + * - color: + * the background color for the label. + * - idealAlign (optional): + * 'left' or 'right' for which side of the x/y box to try to put this on first + * - borderColor (optional): + * color for the border, defaults to strongest contrast with color + * - fontFamily (optional): + * string, the font for this label, defaults to constants.HOVERFONT + * - fontSize (optional): + * the label font size, defaults to constants.HOVERFONTSIZE + * - fontColor (optional): + * defaults to borderColor + * opts should have keys: + * - bgColor: + * the background color this is against, used if the trace is + * non-opaque, and for the name, which goes outside the box + * - container: + * a or element to add the hover label to + * - outerContainer: + * normally a parent of `container`, sets the bounding box to use to + * constrain the hover label and determine whether to show it on the left or right + */ +exports.loneHover = function loneHover(hoverItem, opts) { + var pointData = { + color: hoverItem.color || Color.defaultLine, + x0: hoverItem.x0 || hoverItem.x || 0, + x1: hoverItem.x1 || hoverItem.x || 0, + y0: hoverItem.y0 || hoverItem.y || 0, + y1: hoverItem.y1 || hoverItem.y || 0, + xLabel: hoverItem.xLabel, + yLabel: hoverItem.yLabel, + zLabel: hoverItem.zLabel, + text: hoverItem.text, + name: hoverItem.name, + idealAlign: hoverItem.idealAlign, + + // optional extra bits of styling + borderColor: hoverItem.borderColor, + fontFamily: hoverItem.fontFamily, + fontSize: hoverItem.fontSize, + fontColor: hoverItem.fontColor, + + // filler to make createHoverText happy + trace: { + index: 0, + hoverinfo: '' + }, + xa: {_offset: 0}, + ya: {_offset: 0}, + index: 0 + }; + + var container3 = d3.select(opts.container), + outerContainer3 = opts.outerContainer ? + d3.select(opts.outerContainer) : container3; + + var fullOpts = { + hovermode: 'closest', + rotateLabels: false, + bgColor: opts.bgColor || Color.background, + container: container3, + outerContainer: outerContainer3 + }; + + var hoverLabel = createHoverText([pointData], fullOpts); + alignHoverText(hoverLabel, fullOpts.rotateLabels); + + return hoverLabel.node(); +}; + +// The actual implementation is here: +function _hover(gd, evt, subplot) { + if(subplot === 'pie') { + gd.emit('plotly_hover', { + event: evt.originalEvent, + points: [evt] + }); + return; + } + + if(!subplot) subplot = 'xy'; + + // if the user passed in an array of subplots, + // use those instead of finding overlayed plots + var subplots = Array.isArray(subplot) ? subplot : [subplot]; + + var fullLayout = gd._fullLayout, + plots = fullLayout._plots || [], + plotinfo = plots[subplot]; + + // list of all overlaid subplots to look at + if(plotinfo) { + var overlayedSubplots = plotinfo.overlays.map(function(pi) { + return pi.id; + }); + + subplots = subplots.concat(overlayedSubplots); + } + + var len = subplots.length, + xaArray = new Array(len), + yaArray = new Array(len); + + for(var i = 0; i < len; i++) { + var spId = subplots[i]; + + // 'cartesian' case + var plotObj = plots[spId]; + if(plotObj) { + + // TODO make sure that fullLayout_plots axis refs + // get updated properly so that we don't have + // to use Axes.getFromId in general. + + xaArray[i] = Axes.getFromId(gd, plotObj.xaxis._id); + yaArray[i] = Axes.getFromId(gd, plotObj.yaxis._id); + continue; + } + + // other subplot types + var _subplot = fullLayout[spId]._subplot; + xaArray[i] = _subplot.xaxis; + yaArray[i] = _subplot.yaxis; + } + + var hovermode = evt.hovermode || fullLayout.hovermode; + + if(['x', 'y', 'closest'].indexOf(hovermode) === -1 || !gd.calcdata || + gd.querySelector('.zoombox') || gd._dragging) { + return dragElement.unhoverRaw(gd, evt); + } + + // hoverData: the set of candidate points we've found to highlight + var hoverData = [], + + // searchData: the data to search in. Mostly this is just a copy of + // gd.calcdata, filtered to the subplot and overlays we're on + // but if a point array is supplied it will be a mapping + // of indicated curves + searchData = [], + + // [x|y]valArray: the axis values of the hover event + // mapped onto each of the currently selected overlaid subplots + xvalArray, + yvalArray, + + // used in loops + itemnum, + curvenum, + cd, + trace, + subplotId, + subploti, + mode, + xval, + yval, + pointData, + closedataPreviousLength; + + // Figure out what we're hovering on: + // mouse location or user-supplied data + + if(Array.isArray(evt)) { + // user specified an array of points to highlight + hovermode = 'array'; + for(itemnum = 0; itemnum < evt.length; itemnum++) { + cd = gd.calcdata[evt[itemnum].curveNumber||0]; + if(cd[0].trace.hoverinfo !== 'skip') { + searchData.push(cd); + } + } + } + else { + for(curvenum = 0; curvenum < gd.calcdata.length; curvenum++) { + cd = gd.calcdata[curvenum]; + trace = cd[0].trace; + if(trace.hoverinfo !== 'skip' && subplots.indexOf(helpers.getSubplot(trace)) !== -1) { + searchData.push(cd); + } + } + + // [x|y]px: the pixels (from top left) of the mouse location + // on the currently selected plot area + var hasUserCalledHover = !evt.target, + xpx, ypx; + + if(hasUserCalledHover) { + if('xpx' in evt) xpx = evt.xpx; + else xpx = xaArray[0]._length / 2; + + if('ypx' in evt) ypx = evt.ypx; + else ypx = yaArray[0]._length / 2; + } + else { + // fire the beforehover event and quit if it returns false + // note that we're only calling this on real mouse events, so + // manual calls to fx.hover will always run. + if(Events.triggerHandler(gd, 'plotly_beforehover', evt) === false) { + return; + } + + var dbb = evt.target.getBoundingClientRect(); + + xpx = evt.clientX - dbb.left; + ypx = evt.clientY - dbb.top; + + // in case hover was called from mouseout into hovertext, + // it's possible you're not actually over the plot anymore + if(xpx < 0 || xpx > dbb.width || ypx < 0 || ypx > dbb.height) { + return dragElement.unhoverRaw(gd, evt); + } + } + + if('xval' in evt) xvalArray = helpers.flat(subplots, evt.xval); + else xvalArray = helpers.p2c(xaArray, xpx); + + if('yval' in evt) yvalArray = helpers.flat(subplots, evt.yval); + else yvalArray = helpers.p2c(yaArray, ypx); + + if(!isNumeric(xvalArray[0]) || !isNumeric(yvalArray[0])) { + Lib.warn('Fx.hover failed', evt, gd); + return dragElement.unhoverRaw(gd, evt); + } + } + + // the pixel distance to beat as a matching point + // in 'x' or 'y' mode this resets for each trace + var distance = Infinity; + + // find the closest point in each trace + // this is minimum dx and/or dy, depending on mode + // and the pixel position for the label (labelXpx, labelYpx) + for(curvenum = 0; curvenum < searchData.length; curvenum++) { + cd = searchData[curvenum]; + + // filter out invisible or broken data + if(!cd || !cd[0] || !cd[0].trace || cd[0].trace.visible !== true) continue; + + trace = cd[0].trace; + + // Explicitly bail out for these two. I don't know how to otherwise prevent + // the rest of this function from running and failing + if(['carpet', 'contourcarpet'].indexOf(trace._module.name) !== -1) continue; + + subplotId = helpers.getSubplot(trace); + subploti = subplots.indexOf(subplotId); + + // within one trace mode can sometimes be overridden + mode = hovermode; + + // container for new point, also used to pass info into module.hoverPoints + pointData = { + // trace properties + cd: cd, + trace: trace, + xa: xaArray[subploti], + ya: yaArray[subploti], + name: (gd.data.length > 1 || trace.hoverinfo.indexOf('name') !== -1) ? trace.name : undefined, + // point properties - override all of these + index: false, // point index in trace - only used by plotly.js hoverdata consumers + distance: Math.min(distance, constants.MAXDIST), // pixel distance or pseudo-distance + color: Color.defaultLine, // trace color + x0: undefined, + x1: undefined, + y0: undefined, + y1: undefined, + xLabelVal: undefined, + yLabelVal: undefined, + zLabelVal: undefined, + text: undefined + }; + + // add ref to subplot object (non-cartesian case) + if(fullLayout[subplotId]) { + pointData.subplot = fullLayout[subplotId]._subplot; + } + + closedataPreviousLength = hoverData.length; + + // for a highlighting array, figure out what + // we're searching for with this element + if(mode === 'array') { + var selection = evt[curvenum]; + if('pointNumber' in selection) { + pointData.index = selection.pointNumber; + mode = 'closest'; + } + else { + mode = ''; + if('xval' in selection) { + xval = selection.xval; + mode = 'x'; + } + if('yval' in selection) { + yval = selection.yval; + mode = mode ? 'closest' : 'y'; + } + } + } + else { + xval = xvalArray[subploti]; + yval = yvalArray[subploti]; + } + + // Now find the points. + if(trace._module && trace._module.hoverPoints) { + var newPoints = trace._module.hoverPoints(pointData, xval, yval, mode); + if(newPoints) { + var newPoint; + for(var newPointNum = 0; newPointNum < newPoints.length; newPointNum++) { + newPoint = newPoints[newPointNum]; + if(isNumeric(newPoint.x0) && isNumeric(newPoint.y0)) { + hoverData.push(cleanPoint(newPoint, hovermode)); + } + } + } + } + else { + Lib.log('Unrecognized trace type in hover:', trace); + } + + // in closest mode, remove any existing (farther) points + // and don't look any farther than this latest point (or points, if boxes) + if(hovermode === 'closest' && hoverData.length > closedataPreviousLength) { + hoverData.splice(0, closedataPreviousLength); + distance = hoverData[0].distance; + } + } + + // nothing left: remove all labels and quit + if(hoverData.length === 0) return dragElement.unhoverRaw(gd, evt); + + hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); + + // lastly, emit custom hover/unhover events + var oldhoverdata = gd._hoverdata, + newhoverdata = []; + + // pull out just the data that's useful to + // other people and send it to the event + for(itemnum = 0; itemnum < hoverData.length; itemnum++) { + var pt = hoverData[itemnum]; + + var out = { + data: pt.trace._input, + fullData: pt.trace, + curveNumber: pt.trace.index, + pointNumber: pt.index + }; + + if(pt.trace._module.eventData) out = pt.trace._module.eventData(out, pt); + else { + out.x = pt.xVal; + out.y = pt.yVal; + out.xaxis = pt.xa; + out.yaxis = pt.ya; + + if(pt.zLabelVal !== undefined) out.z = pt.zLabelVal; + } + + newhoverdata.push(out); + } + + gd._hoverdata = newhoverdata; + + if(hoverChanged(gd, evt, oldhoverdata) && fullLayout._hasCartesian) { + var spikelineOpts = { + hovermode: hovermode, + fullLayout: fullLayout, + container: fullLayout._hoverlayer, + outerContainer: fullLayout._paperdiv + }; + createSpikelines(hoverData, spikelineOpts); + } + + // if there's more than one horz bar trace, + // rotate the labels so they don't overlap + var rotateLabels = hovermode === 'y' && searchData.length > 1; + + var bgColor = Color.combine( + fullLayout.plot_bgcolor || Color.background, + fullLayout.paper_bgcolor + ); + + var labelOpts = { + hovermode: hovermode, + rotateLabels: rotateLabels, + bgColor: bgColor, + container: fullLayout._hoverlayer, + outerContainer: fullLayout._paperdiv + }; + + var hoverLabels = createHoverText(hoverData, labelOpts); + + hoverAvoidOverlaps(hoverData, rotateLabels ? 'xa' : 'ya'); + + alignHoverText(hoverLabels, rotateLabels); + + // TODO: tagName hack is needed to appease geo.js's hack of using evt.target=true + // we should improve the "fx" API so other plots can use it without these hack. + if(evt.target && evt.target.tagName) { + var hasClickToShow = Registry.getComponentMethod('annotations', 'hasClickToShow')(gd, newhoverdata); + overrideCursor(d3.select(evt.target), hasClickToShow ? 'pointer' : ''); + } + + // don't emit events if called manually + if(!evt.target || !hoverChanged(gd, evt, oldhoverdata)) return; + + if(oldhoverdata) { + gd.emit('plotly_unhover', { + event: evt, + points: oldhoverdata + }); + } + + gd.emit('plotly_hover', { + event: evt, + points: gd._hoverdata, + xaxes: xaArray, + yaxes: yaArray, + xvals: xvalArray, + yvals: yvalArray + }); +} + +function createHoverText(hoverData, opts) { + var hovermode = opts.hovermode, + rotateLabels = opts.rotateLabels, + bgColor = opts.bgColor, + container = opts.container, + outerContainer = opts.outerContainer, + + // opts.fontFamily/Size are used for the common label + // and as defaults for each hover label, though the individual labels + // can override this. + fontFamily = opts.fontFamily || constants.HOVERFONT, + fontSize = opts.fontSize || constants.HOVERFONTSIZE, + + c0 = hoverData[0], + xa = c0.xa, + ya = c0.ya, + commonAttr = hovermode === 'y' ? 'yLabel' : 'xLabel', + t0 = c0[commonAttr], + t00 = (String(t0) || '').split(' ')[0], + outerContainerBB = outerContainer.node().getBoundingClientRect(), + outerTop = outerContainerBB.top, + outerWidth = outerContainerBB.width, + outerHeight = outerContainerBB.height; + + // show the common label, if any, on the axis + // never show a common label in array mode, + // even if sometimes there could be one + var showCommonLabel = c0.distance <= constants.MAXDIST && + (hovermode === 'x' || hovermode === 'y'); + + // all hover traces hoverinfo must contain the hovermode + // to have common labels + var i, traceHoverinfo; + for(i = 0; i < hoverData.length; i++) { + traceHoverinfo = hoverData[i].trace.hoverinfo; + var parts = traceHoverinfo.split('+'); + if(parts.indexOf('all') === -1 && + parts.indexOf(hovermode) === -1) { + showCommonLabel = false; + break; + } + } + + var commonLabel = container.selectAll('g.axistext') + .data(showCommonLabel ? [0] : []); + commonLabel.enter().append('g') + .classed('axistext', true); + commonLabel.exit().remove(); + + commonLabel.each(function() { + var label = d3.select(this), + lpath = label.selectAll('path').data([0]), + ltext = label.selectAll('text').data([0]); + + lpath.enter().append('path') + .style({fill: Color.defaultLine, 'stroke-width': '1px', stroke: Color.background}); + ltext.enter().append('text') + .call(Drawing.font, fontFamily, fontSize, Color.background) + // prohibit tex interpretation until we can handle + // tex and regular text together + .attr('data-notex', 1); + + ltext.text(t0) + .call(svgTextUtils.convertToTspans) + .call(Drawing.setPosition, 0, 0) + .selectAll('tspan.line') + .call(Drawing.setPosition, 0, 0); + label.attr('transform', ''); + + var tbb = ltext.node().getBoundingClientRect(); + if(hovermode === 'x') { + ltext.attr('text-anchor', 'middle') + .call(Drawing.setPosition, 0, (xa.side === 'top' ? + (outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD) : + (outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD))) + .selectAll('tspan.line') + .attr({ + x: ltext.attr('x'), + y: ltext.attr('y') + }); + + var topsign = xa.side === 'top' ? '-' : ''; + lpath.attr('d', 'M0,0' + + 'L' + HOVERARROWSIZE + ',' + topsign + HOVERARROWSIZE + + 'H' + (HOVERTEXTPAD + tbb.width / 2) + + 'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) + + 'H-' + (HOVERTEXTPAD + tbb.width / 2) + + 'V' + topsign + HOVERARROWSIZE + 'H-' + HOVERARROWSIZE + 'Z'); + + label.attr('transform', 'translate(' + + (xa._offset + (c0.x0 + c0.x1) / 2) + ',' + + (ya._offset + (xa.side === 'top' ? 0 : ya._length)) + ')'); + } + else { + ltext.attr('text-anchor', ya.side === 'right' ? 'start' : 'end') + .call(Drawing.setPosition, + (ya.side === 'right' ? 1 : -1) * (HOVERTEXTPAD + HOVERARROWSIZE), + outerTop - tbb.top - tbb.height / 2) + .selectAll('tspan.line') + .attr({ + x: ltext.attr('x'), + y: ltext.attr('y') + }); + + var leftsign = ya.side === 'right' ? '' : '-'; + lpath.attr('d', 'M0,0' + + 'L' + leftsign + HOVERARROWSIZE + ',' + HOVERARROWSIZE + + 'V' + (HOVERTEXTPAD + tbb.height / 2) + + 'h' + leftsign + (HOVERTEXTPAD * 2 + tbb.width) + + 'V-' + (HOVERTEXTPAD + tbb.height / 2) + + 'H' + leftsign + HOVERARROWSIZE + 'V-' + HOVERARROWSIZE + 'Z'); + + label.attr('transform', 'translate(' + + (xa._offset + (ya.side === 'right' ? xa._length : 0)) + ',' + + (ya._offset + (c0.y0 + c0.y1) / 2) + ')'); + } + // remove the "close but not quite" points + // because of error bars, only take up to a space + hoverData = hoverData.filter(function(d) { + return (d.zLabelVal !== undefined) || + (d[commonAttr] || '').split(' ')[0] === t00; + }); + }); + + // show all the individual labels + + // first create the objects + var hoverLabels = container.selectAll('g.hovertext') + .data(hoverData, function(d) { + return [d.trace.index, d.index, d.x0, d.y0, d.name, d.attr, d.xa, d.ya || ''].join(','); + }); + hoverLabels.enter().append('g') + .classed('hovertext', true) + .each(function() { + var g = d3.select(this); + // trace name label (rect and text.name) + g.append('rect') + .call(Color.fill, Color.addOpacity(bgColor, 0.8)); + g.append('text').classed('name', true); + // trace data label (path and text.nums) + g.append('path') + .style('stroke-width', '1px'); + g.append('text').classed('nums', true) + .call(Drawing.font, fontFamily, fontSize); + }); + hoverLabels.exit().remove(); + + // then put the text in, position the pointer to the data, + // and figure out sizes + hoverLabels.each(function(d) { + var g = d3.select(this).attr('transform', ''), + name = '', + text = '', + // combine possible non-opaque trace color with bgColor + baseColor = Color.opacity(d.color) ? + d.color : Color.defaultLine, + traceColor = Color.combine(baseColor, bgColor), + + // find a contrasting color for border and text + contrastColor = d.borderColor || Color.contrast(traceColor); + + // to get custom 'name' labels pass cleanPoint + if(d.nameOverride !== undefined) d.name = d.nameOverride; + + if(d.name && d.zLabelVal === undefined) { + // strip out our pseudo-html elements from d.name (if it exists at all) + name = svgTextUtils.plainText(d.name || ''); + + if(name.length > 15) name = name.substr(0, 12) + '...'; + } + + // used by other modules (initially just ternary) that + // manage their own hoverinfo independent of cleanPoint + // the rest of this will still apply, so such modules + // can still put things in (x|y|z)Label, text, and name + // and hoverinfo will still determine their visibility + if(d.extraText !== undefined) text += d.extraText; + + if(d.zLabel !== undefined) { + if(d.xLabel !== undefined) text += 'x: ' + d.xLabel + '
'; + if(d.yLabel !== undefined) text += 'y: ' + d.yLabel + '
'; + text += (text ? 'z: ' : '') + d.zLabel; + } + else if(showCommonLabel && d[hovermode + 'Label'] === t0) { + text = d[(hovermode === 'x' ? 'y' : 'x') + 'Label'] || ''; + } + else if(d.xLabel === undefined) { + if(d.yLabel !== undefined) text = d.yLabel; + } + else if(d.yLabel === undefined) text = d.xLabel; + else text = '(' + d.xLabel + ', ' + d.yLabel + ')'; + + if(d.text && !Array.isArray(d.text)) text += (text ? '
' : '') + d.text; + + // if 'text' is empty at this point, + // put 'name' in main label and don't show secondary label + if(text === '') { + // if 'name' is also empty, remove entire label + if(name === '') g.remove(); + text = name; + } + + // main label + var tx = g.select('text.nums') + .call(Drawing.font, + d.fontFamily || fontFamily, + d.fontSize || fontSize, + d.fontColor || contrastColor) + .call(Drawing.setPosition, 0, 0) + .text(text) + .attr('data-notex', 1) + .call(svgTextUtils.convertToTspans); + tx.selectAll('tspan.line') + .call(Drawing.setPosition, 0, 0); + + var tx2 = g.select('text.name'), + tx2width = 0; + + // secondary label for non-empty 'name' + if(name && name !== text) { + tx2.call(Drawing.font, + d.fontFamily || fontFamily, + d.fontSize || fontSize, + traceColor) + .text(name) + .call(Drawing.setPosition, 0, 0) + .attr('data-notex', 1) + .call(svgTextUtils.convertToTspans); + tx2.selectAll('tspan.line') + .call(Drawing.setPosition, 0, 0); + tx2width = tx2.node().getBoundingClientRect().width + 2 * HOVERTEXTPAD; + } + else { + tx2.remove(); + g.select('rect').remove(); + } + + g.select('path') + .style({ + fill: traceColor, + stroke: contrastColor + }); + var tbb = tx.node().getBoundingClientRect(), + htx = d.xa._offset + (d.x0 + d.x1) / 2, + hty = d.ya._offset + (d.y0 + d.y1) / 2, + dx = Math.abs(d.x1 - d.x0), + dy = Math.abs(d.y1 - d.y0), + txTotalWidth = tbb.width + HOVERARROWSIZE + HOVERTEXTPAD + tx2width, + anchorStartOK, + anchorEndOK; + + d.ty0 = outerTop - tbb.top; + d.bx = tbb.width + 2 * HOVERTEXTPAD; + d.by = tbb.height + 2 * HOVERTEXTPAD; + d.anchor = 'start'; + d.txwidth = tbb.width; + d.tx2width = tx2width; + d.offset = 0; + + if(rotateLabels) { + d.pos = htx; + anchorStartOK = hty + dy / 2 + txTotalWidth <= outerHeight; + anchorEndOK = hty - dy / 2 - txTotalWidth >= 0; + if((d.idealAlign === 'top' || !anchorStartOK) && anchorEndOK) { + hty -= dy / 2; + d.anchor = 'end'; + } else if(anchorStartOK) { + hty += dy / 2; + d.anchor = 'start'; + } else d.anchor = 'middle'; + } + else { + d.pos = hty; + anchorStartOK = htx + dx / 2 + txTotalWidth <= outerWidth; + anchorEndOK = htx - dx / 2 - txTotalWidth >= 0; + if((d.idealAlign === 'left' || !anchorStartOK) && anchorEndOK) { + htx -= dx / 2; + d.anchor = 'end'; + } else if(anchorStartOK) { + htx += dx / 2; + d.anchor = 'start'; + } else d.anchor = 'middle'; + } + + tx.attr('text-anchor', d.anchor); + if(tx2width) tx2.attr('text-anchor', d.anchor); + g.attr('transform', 'translate(' + htx + ',' + hty + ')' + + (rotateLabels ? 'rotate(' + YANGLE + ')' : '')); + }); + + return hoverLabels; +} + +// Make groups of touching points, and within each group +// move each point so that no labels overlap, but the average +// label position is the same as it was before moving. Indicentally, +// this is equivalent to saying all the labels are on equal linear +// springs about their initial position. Initially, each point is +// its own group, but as we find overlaps we will clump the points. +// +// Also, there are hard constraints at the edges of the graphs, +// that push all groups to the middle so they are visible. I don't +// know what happens if the group spans all the way from one edge to +// the other, though it hardly matters - there's just too much +// information then. +function hoverAvoidOverlaps(hoverData, ax) { + var nummoves = 0, + + // make groups of touching points + pointgroups = hoverData + .map(function(d, i) { + var axis = d[ax]; + return [{ + i: i, + dp: 0, + pos: d.pos, + posref: d.posref, + size: d.by * (axis._id.charAt(0) === 'x' ? YFACTOR : 1) / 2, + pmin: axis._offset, + pmax: axis._offset + axis._length + }]; + }) + .sort(function(a, b) { return a[0].posref - b[0].posref; }), + donepositioning, + topOverlap, + bottomOverlap, + i, j, + pti, + sumdp; + + function constrainGroup(grp) { + var minPt = grp[0], + maxPt = grp[grp.length - 1]; + + // overlap with the top - positive vals are overlaps + topOverlap = minPt.pmin - minPt.pos - minPt.dp + minPt.size; + + // overlap with the bottom - positive vals are overlaps + bottomOverlap = maxPt.pos + maxPt.dp + maxPt.size - minPt.pmax; + + // check for min overlap first, so that we always + // see the largest labels + // allow for .01px overlap, so we don't get an + // infinite loop from rounding errors + if(topOverlap > 0.01) { + for(j = grp.length - 1; j >= 0; j--) grp[j].dp += topOverlap; + donepositioning = false; + } + if(bottomOverlap < 0.01) return; + if(topOverlap < -0.01) { + // make sure we're not pushing back and forth + for(j = grp.length - 1; j >= 0; j--) grp[j].dp -= bottomOverlap; + donepositioning = false; + } + if(!donepositioning) return; + + // no room to fix positioning, delete off-screen points + + // first see how many points we need to delete + var deleteCount = 0; + for(i = 0; i < grp.length; i++) { + pti = grp[i]; + if(pti.pos + pti.dp + pti.size > minPt.pmax) deleteCount++; + } + + // start by deleting points whose data is off screen + for(i = grp.length - 1; i >= 0; i--) { + if(deleteCount <= 0) break; + pti = grp[i]; + + // pos has already been constrained to [pmin,pmax] + // so look for points close to that to delete + if(pti.pos > minPt.pmax - 1) { + pti.del = true; + deleteCount--; + } + } + for(i = 0; i < grp.length; i++) { + if(deleteCount <= 0) break; + pti = grp[i]; + + // pos has already been constrained to [pmin,pmax] + // so look for points close to that to delete + if(pti.pos < minPt.pmin + 1) { + pti.del = true; + deleteCount--; + + // shift the whole group minus into this new space + bottomOverlap = pti.size * 2; + for(j = grp.length - 1; j >= 0; j--) grp[j].dp -= bottomOverlap; + } + } + // then delete points that go off the bottom + for(i = grp.length - 1; i >= 0; i--) { + if(deleteCount <= 0) break; + pti = grp[i]; + if(pti.pos + pti.dp + pti.size > minPt.pmax) { + pti.del = true; + deleteCount--; + } + } + } + + // loop through groups, combining them if they overlap, + // until nothing moves + while(!donepositioning && nummoves <= hoverData.length) { + // to avoid infinite loops, don't move more times + // than there are traces + nummoves++; + + // assume nothing will move in this iteration, + // reverse this if it does + donepositioning = true; + i = 0; + while(i < pointgroups.length - 1) { + // the higher (g0) and lower (g1) point group + var g0 = pointgroups[i], + g1 = pointgroups[i + 1], + + // the lowest point in the higher group (p0) + // the highest point in the lower group (p1) + p0 = g0[g0.length - 1], + p1 = g1[0]; + topOverlap = p0.pos + p0.dp + p0.size - p1.pos - p1.dp + p1.size; + + // Only group points that lie on the same axes + if(topOverlap > 0.01 && (p0.pmin === p1.pmin) && (p0.pmax === p1.pmax)) { + // push the new point(s) added to this group out of the way + for(j = g1.length - 1; j >= 0; j--) g1[j].dp += topOverlap; + + // add them to the group + g0.push.apply(g0, g1); + pointgroups.splice(i + 1, 1); + + // adjust for minimum average movement + sumdp = 0; + for(j = g0.length - 1; j >= 0; j--) sumdp += g0[j].dp; + bottomOverlap = sumdp / g0.length; + for(j = g0.length - 1; j >= 0; j--) g0[j].dp -= bottomOverlap; + donepositioning = false; + } + else i++; + } + + // check if we're going off the plot on either side and fix + pointgroups.forEach(constrainGroup); + } + + // now put these offsets into hoverData + for(i = pointgroups.length - 1; i >= 0; i--) { + var grp = pointgroups[i]; + for(j = grp.length - 1; j >= 0; j--) { + var pt = grp[j], + hoverPt = hoverData[pt.i]; + hoverPt.offset = pt.dp; + hoverPt.del = pt.del; + } + } +} + +function alignHoverText(hoverLabels, rotateLabels) { + // finally set the text positioning relative to the data and draw the + // box around it + hoverLabels.each(function(d) { + var g = d3.select(this); + if(d.del) { + g.remove(); + return; + } + var horzSign = d.anchor === 'end' ? -1 : 1, + tx = g.select('text.nums'), + alignShift = {start: 1, end: -1, middle: 0}[d.anchor], + txx = alignShift * (HOVERARROWSIZE + HOVERTEXTPAD), + tx2x = txx + alignShift * (d.txwidth + HOVERTEXTPAD), + offsetX = 0, + offsetY = d.offset; + if(d.anchor === 'middle') { + txx -= d.tx2width / 2; + tx2x -= d.tx2width / 2; + } + if(rotateLabels) { + offsetY *= -YSHIFTY; + offsetX = d.offset * YSHIFTX; + } + + g.select('path').attr('d', d.anchor === 'middle' ? + // middle aligned: rect centered on data + ('M-' + (d.bx / 2) + ',-' + (d.by / 2) + 'h' + d.bx + 'v' + d.by + 'h-' + d.bx + 'Z') : + // left or right aligned: side rect with arrow to data + ('M0,0L' + (horzSign * HOVERARROWSIZE + offsetX) + ',' + (HOVERARROWSIZE + offsetY) + + 'v' + (d.by / 2 - HOVERARROWSIZE) + + 'h' + (horzSign * d.bx) + + 'v-' + d.by + + 'H' + (horzSign * HOVERARROWSIZE + offsetX) + + 'V' + (offsetY - HOVERARROWSIZE) + + 'Z')); + + tx.call(Drawing.setPosition, + txx + offsetX, offsetY + d.ty0 - d.by / 2 + HOVERTEXTPAD) + .selectAll('tspan.line') + .attr({ + x: tx.attr('x'), + y: tx.attr('y') + }); + + if(d.tx2width) { + g.select('text.name, text.name tspan.line') + .call(Drawing.setPosition, + tx2x + alignShift * HOVERTEXTPAD + offsetX, + offsetY + d.ty0 - d.by / 2 + HOVERTEXTPAD); + g.select('rect') + .call(Drawing.setRect, + tx2x + (alignShift - 1) * d.tx2width / 2 + offsetX, + offsetY - d.by / 2 - 1, + d.tx2width, d.by + 2); + } + }); +} + +function cleanPoint(d, hovermode) { + var trace = d.trace || {}; + var cd = d.cd[d.index] || {}; + + d.posref = hovermode === 'y' ? (d.x0 + d.x1) / 2 : (d.y0 + d.y1) / 2; + + // then constrain all the positions to be on the plot + d.x0 = Lib.constrain(d.x0, 0, d.xa._length); + d.x1 = Lib.constrain(d.x1, 0, d.xa._length); + d.y0 = Lib.constrain(d.y0, 0, d.ya._length); + d.y1 = Lib.constrain(d.y1, 0, d.ya._length); + + // and convert the x and y label values into objects + // formatted as text, with font info + var logOffScale; + if(d.xLabelVal !== undefined) { + logOffScale = (d.xa.type === 'log' && d.xLabelVal <= 0); + var xLabelObj = Axes.tickText(d.xa, + d.xa.c2l(logOffScale ? -d.xLabelVal : d.xLabelVal), 'hover'); + if(logOffScale) { + if(d.xLabelVal === 0) d.xLabel = '0'; + else d.xLabel = '-' + xLabelObj.text; + } + // TODO: should we do something special if the axis calendar and + // the data calendar are different? Somehow display both dates with + // their system names? Right now it will just display in the axis calendar + // but users could add the other one as text. + else d.xLabel = xLabelObj.text; + d.xVal = d.xa.c2d(d.xLabelVal); + } + + if(d.yLabelVal !== undefined) { + logOffScale = (d.ya.type === 'log' && d.yLabelVal <= 0); + var yLabelObj = Axes.tickText(d.ya, + d.ya.c2l(logOffScale ? -d.yLabelVal : d.yLabelVal), 'hover'); + if(logOffScale) { + if(d.yLabelVal === 0) d.yLabel = '0'; + else d.yLabel = '-' + yLabelObj.text; + } + // TODO: see above TODO + else d.yLabel = yLabelObj.text; + d.yVal = d.ya.c2d(d.yLabelVal); + } + + if(d.zLabelVal !== undefined) d.zLabel = String(d.zLabelVal); + + // for box means and error bars, add the range to the label + if(!isNaN(d.xerr) && !(d.xa.type === 'log' && d.xerr <= 0)) { + var xeText = Axes.tickText(d.xa, d.xa.c2l(d.xerr), 'hover').text; + if(d.xerrneg !== undefined) { + d.xLabel += ' +' + xeText + ' / -' + + Axes.tickText(d.xa, d.xa.c2l(d.xerrneg), 'hover').text; + } + else d.xLabel += ' ± ' + xeText; + + // small distance penalty for error bars, so that if there are + // traces with errors and some without, the error bar label will + // hoist up to the point + if(hovermode === 'x') d.distance += 1; + } + if(!isNaN(d.yerr) && !(d.ya.type === 'log' && d.yerr <= 0)) { + var yeText = Axes.tickText(d.ya, d.ya.c2l(d.yerr), 'hover').text; + if(d.yerrneg !== undefined) { + d.yLabel += ' +' + yeText + ' / -' + + Axes.tickText(d.ya, d.ya.c2l(d.yerrneg), 'hover').text; + } + else d.yLabel += ' ± ' + yeText; + + if(hovermode === 'y') d.distance += 1; + } + + var infomode = d.trace.hoverinfo; + if(infomode !== 'all') { + infomode = infomode.split('+'); + if(infomode.indexOf('x') === -1) d.xLabel = undefined; + if(infomode.indexOf('y') === -1) d.yLabel = undefined; + if(infomode.indexOf('z') === -1) d.zLabel = undefined; + if(infomode.indexOf('text') === -1) d.text = undefined; + if(infomode.indexOf('name') === -1) d.name = undefined; + } + + function fill(key, calcKey, traceKey) { + if(cd[calcKey]) return d[key] = cd[calcKey]; + + var traceVal = Lib.nestedProperty(trace, traceKey).get(); + if(traceVal) return d[key] = traceVal; + } + + fill('color', 'hbg', 'hoverlabel.bgcolor'); + fill('borderColor', 'hbc', 'hoverlabel.bordercolor'); + fill('fontFamily', 'htf', 'hoverlabel.font.family'); + fill('fontSize', 'hts', 'hoverlabel.font.size'); + fill('fontColor', 'htc', 'hoverlabel.font.color'); + + return d; +} + +function createSpikelines(hoverData, opts) { + var hovermode = opts.hovermode; + var container = opts.container; + var c0 = hoverData[0]; + var xa = c0.xa; + var ya = c0.ya; + var showX = xa.showspikes; + var showY = ya.showspikes; + + // Remove old spikeline items + container.selectAll('.spikeline').remove(); + + if(hovermode !== 'closest' || !(showX || showY)) return; + + var fullLayout = opts.fullLayout; + var xPoint = xa._offset + (c0.x0 + c0.x1) / 2; + var yPoint = ya._offset + (c0.y0 + c0.y1) / 2; + var contrastColor = Color.combine(fullLayout.plot_bgcolor, fullLayout.paper_bgcolor); + var dfltDashColor = tinycolor.readability(c0.color, contrastColor) < 1.5 ? + Color.contrast(contrastColor) : c0.color; + + if(showY) { + var yMode = ya.spikemode; + var yThickness = ya.spikethickness; + var yColor = ya.spikecolor || dfltDashColor; + var yBB = ya._boundingBox; + var xEdge = ((yBB.left + yBB.right) / 2) < xPoint ? yBB.right : yBB.left; + + if(yMode.indexOf('toaxis') !== -1 || yMode.indexOf('across') !== -1) { + var xBase = xEdge; + var xEndSpike = xPoint; + if(yMode.indexOf('across') !== -1) { + xBase = ya._counterSpan[0]; + xEndSpike = ya._counterSpan[1]; + } + + // Background horizontal Line (to y-axis) + container.append('line') + .attr({ + 'x1': xBase, + 'x2': xEndSpike, + 'y1': yPoint, + 'y2': yPoint, + 'stroke-width': yThickness + 2, + 'stroke': contrastColor + }) + .classed('spikeline', true) + .classed('crisp', true); + + // Foreground horizontal line (to y-axis) + container.append('line') + .attr({ + 'x1': xBase, + 'x2': xEndSpike, + 'y1': yPoint, + 'y2': yPoint, + 'stroke-width': yThickness, + 'stroke': yColor, + 'stroke-dasharray': Drawing.dashStyle(ya.spikedash, yThickness) + }) + .classed('spikeline', true) + .classed('crisp', true); + } + // Y axis marker + if(yMode.indexOf('marker') !== -1) { + container.append('circle') + .attr({ + 'cx': xEdge + (ya.side !== 'right' ? yThickness : -yThickness), + 'cy': yPoint, + 'r': yThickness, + 'fill': yColor + }) + .classed('spikeline', true); + } + } + + if(showX) { + var xMode = xa.spikemode; + var xThickness = xa.spikethickness; + var xColor = xa.spikecolor || dfltDashColor; + var xBB = xa._boundingBox; + var yEdge = ((xBB.top + xBB.bottom) / 2) < yPoint ? xBB.bottom : xBB.top; + + if(xMode.indexOf('toaxis') !== -1 || xMode.indexOf('across') !== -1) { + var yBase = yEdge; + var yEndSpike = yPoint; + if(xMode.indexOf('across') !== -1) { + yBase = xa._counterSpan[0]; + yEndSpike = xa._counterSpan[1]; + } + + // Background vertical line (to x-axis) + container.append('line') + .attr({ + 'x1': xPoint, + 'x2': xPoint, + 'y1': yBase, + 'y2': yEndSpike, + 'stroke-width': xThickness + 2, + 'stroke': contrastColor + }) + .classed('spikeline', true) + .classed('crisp', true); + + // Foreground vertical line (to x-axis) + container.append('line') + .attr({ + 'x1': xPoint, + 'x2': xPoint, + 'y1': yBase, + 'y2': yEndSpike, + 'stroke-width': xThickness, + 'stroke': xColor, + 'stroke-dasharray': Drawing.dashStyle(xa.spikedash, xThickness) + }) + .classed('spikeline', true) + .classed('crisp', true); + } + + // X axis marker + if(xMode.indexOf('marker') !== -1) { + container.append('circle') + .attr({ + 'cx': xPoint, + 'cy': yEdge - (xa.side !== 'top' ? xThickness : -xThickness), + 'r': xThickness, + 'fill': xColor + }) + .classed('spikeline', true); + } + } +} + +function hoverChanged(gd, evt, oldhoverdata) { + // don't emit any events if nothing changed + if(!oldhoverdata || oldhoverdata.length !== gd._hoverdata.length) return true; + + for(var i = oldhoverdata.length - 1; i >= 0; i--) { + var oldPt = oldhoverdata[i], + newPt = gd._hoverdata[i]; + if(oldPt.curveNumber !== newPt.curveNumber || + String(oldPt.pointNumber) !== String(newPt.pointNumber)) { + return true; + } + } + return false; +} diff --git a/src/components/fx/index.js b/src/components/fx/index.js new file mode 100644 index 00000000000..dd7a79e9ccc --- /dev/null +++ b/src/components/fx/index.js @@ -0,0 +1,51 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var d3 = require('d3'); +var Lib = require('../../lib'); +var dragElement = require('../dragelement'); +var helpers = require('./helpers'); +var layoutAttributes = require('./layout_attributes'); + +module.exports = { + moduleType: 'component', + name: 'fx', + + constants: require('./constants'), + schema: { + layout: layoutAttributes + }, + + layoutAttributes: layoutAttributes, + supplyLayoutDefaults: require('./layout_defaults'), + + getDistanceFunction: helpers.getDistanceFunction, + getClosest: helpers.getClosest, + inbox: helpers.inbox, + + hover: require('./hover').hover, + unhover: dragElement.unhover, + + loneHover: require('./hover').loneHover, + loneUnhover: loneUnhover, + + click: require('./click') +}; + +function loneUnhover(containerOrSelection) { + // duck type whether the arg is a d3 selection because ie9 doesn't + // handle instanceof like modern browsers do. + var selection = Lib.isD3Selection(containerOrSelection) ? + containerOrSelection : + d3.select(containerOrSelection); + + selection.selectAll('g.hovertext').remove(); + selection.selectAll('.spikeline').remove(); +} diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js new file mode 100644 index 00000000000..37da49ebfc0 --- /dev/null +++ b/src/components/fx/layout_attributes.js @@ -0,0 +1,30 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = { + dragmode: { + valType: 'enumerated', + role: 'info', + values: ['zoom', 'pan', 'select', 'lasso', 'orbit', 'turntable'], + dflt: 'zoom', + description: [ + 'Determines the mode of drag interactions.', + '*select* and *lasso* apply only to scatter traces with', + 'markers or text. *orbit* and *turntable* apply only to', + '3D scenes.' + ].join(' ') + }, + hovermode: { + valType: 'enumerated', + role: 'info', + values: ['x', 'y', 'closest', false], + description: 'Determines the mode of hover interactions.' + } +}; diff --git a/src/components/fx/layout_defaults.js b/src/components/fx/layout_defaults.js new file mode 100644 index 00000000000..13d5d631919 --- /dev/null +++ b/src/components/fx/layout_defaults.js @@ -0,0 +1,46 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var Lib = require('../../lib'); +var layoutAttributes = require('./layout_attributes'); + +module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { + function coerce(attr, dflt) { + return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt); + } + + coerce('dragmode'); + + var hovermodeDflt; + if(layoutOut._has('cartesian')) { + // flag for 'horizontal' plots: + // determines the state of the mode bar 'compare' hovermode button + layoutOut._isHoriz = isHoriz(fullData); + hovermodeDflt = layoutOut._isHoriz ? 'y' : 'x'; + } + else hovermodeDflt = 'closest'; + + coerce('hovermode', hovermodeDflt); +}; + +function isHoriz(fullData) { + var out = true; + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + + if(trace.orientation !== 'h') { + out = false; + break; + } + } + + return out; +} diff --git a/src/core.js b/src/core.js index 3040b7cd4db..4c848e1a37b 100644 --- a/src/core.js +++ b/src/core.js @@ -53,6 +53,7 @@ exports.register(require('./traces/scatter')); // register all registrable components modules exports.register([ + require('./components/fx'), require('./components/legend'), require('./components/annotations'), require('./components/shapes'), @@ -68,7 +69,7 @@ exports.Icons = require('../build/ploticon'); // unofficial 'beta' plot methods, use at your own risk exports.Plots = Plotly.Plots; -exports.Fx = Plotly.Fx; +exports.Fx = require('./components/fx'); exports.Snapshot = require('./snapshot'); exports.PlotSchema = require('./plot_api/plot_schema'); exports.Queue = require('./lib/queue'); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f25b231cd7f..48bea3d137a 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -20,8 +20,8 @@ var Queue = require('../lib/queue'); var Registry = require('../registry'); var Plots = require('../plots/plots'); -var Fx = require('../plots/cartesian/graph_interact'); var Polar = require('../plots/polar'); +var initInteractions = require('../plots/cartesian/graph_interact'); var Drawing = require('../components/drawing'); var ErrorBars = require('../components/errorbars'); @@ -188,7 +188,7 @@ Plotly.plot = function(gd, data, layout, config) { return Lib.syncOrAsync([ subroutines.layoutStyles, drawAxes, - Fx.init + initInteractions ], gd); } @@ -223,7 +223,7 @@ Plotly.plot = function(gd, data, layout, config) { // re-initialize cartesian interaction, // which are sometimes cleared during marginPushers - seq = seq.concat(Fx.init); + seq = seq.concat(initInteractions); return Lib.syncOrAsync(seq, gd); } diff --git a/src/plot_api/subroutines.js b/src/plot_api/subroutines.js index f0a81f76af8..45238db4497 100644 --- a/src/plot_api/subroutines.js +++ b/src/plot_api/subroutines.js @@ -19,7 +19,7 @@ var Color = require('../components/color'); var Drawing = require('../components/drawing'); var Titles = require('../components/titles'); var ModeBar = require('../components/modebar'); - +var initInteractions = require('../plots/cartesian/graph_interact'); exports.layoutStyles = function(gd) { return Lib.syncOrAsync([Plots.doAutoMargin, exports.lsInner], gd); @@ -378,7 +378,7 @@ exports.doModeBar = function(gd) { var subplotIds, i; ModeBar.manage(gd); - Plotly.Fx.init(gd); + initInteractions(gd); subplotIds = Plots.getSubplotIds(fullLayout, 'gl3d'); for(i = 0; i < subplotIds.length; i++) { diff --git a/src/plotly.js b/src/plotly.js index 042166b05bc..1eae9f219a6 100644 --- a/src/plotly.js +++ b/src/plotly.js @@ -24,7 +24,8 @@ exports.defaultConfig = require('./plot_api/plot_config'); // plots exports.Plots = require('./plots/plots'); exports.Axes = require('./plots/cartesian/axes'); -exports.Fx = require('./plots/cartesian/graph_interact'); + +// components exports.ModeBar = require('./components/modebar'); // plot api diff --git a/src/plots/attributes.js b/src/plots/attributes.js index 57eda293a07..d8ec6780af5 100644 --- a/src/plots/attributes.js +++ b/src/plots/attributes.js @@ -8,7 +8,7 @@ 'use strict'; -var constants = require('./cartesian/constants'); +var constants = require('../components/fx/constants'); var fontAttrs = require('./font_attributes'); var extendFlat = require('../lib/extend').extendFlat; diff --git a/src/plots/cartesian/constants.js b/src/plots/cartesian/constants.js index 2934d4ecd0f..d72f7c2fd7b 100644 --- a/src/plots/cartesian/constants.js +++ b/src/plots/cartesian/constants.js @@ -41,21 +41,6 @@ module.exports = { // width of axis drag regions DRAGGERSIZE: 20, - // max pixels away from mouse to allow a point to highlight - MAXDIST: 20, - - // hover labels for multiple horizontal bars get tilted by this angle - YANGLE: 60, - - // size and display constants for hover text - HOVERARROWSIZE: 6, // pixel size of hover arrows - HOVERTEXTPAD: 3, // pixels padding around text - HOVERFONTSIZE: 13, - HOVERFONT: 'Arial, sans-serif', - - // minimum time (msec) between hover calls - HOVERMINTIME: 50, - // max pixels off straight before a lasso select line counts as bent BENDPX: 1.5, diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js index 3e6a9c77eb3..410e693fe09 100644 --- a/src/plots/cartesian/graph_interact.js +++ b/src/plots/cartesian/graph_interact.js @@ -9,67 +9,15 @@ 'use strict'; -var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); -var tinycolor = require('tinycolor2'); -var Lib = require('../../lib'); -var Events = require('../../lib/events'); -var svgTextUtils = require('../../lib/svg_text_utils'); -var Color = require('../../components/color'); -var Drawing = require('../../components/drawing'); +var Fx = require('../../components/fx'); var dragElement = require('../../components/dragelement'); -var overrideCursor = require('../../lib/override_cursor'); -var Registry = require('../../registry'); -var Axes = require('./axes'); var constants = require('./constants'); var dragBox = require('./dragbox'); -var layoutAttributes = require('../layout_attributes'); - -var fx = module.exports = {}; - -// TODO remove this in version 2.0 -// copy on Fx for backward compatible -fx.unhover = dragElement.unhover; - -fx.supplyLayoutDefaults = function(layoutIn, layoutOut, fullData) { - - function coerce(attr, dflt) { - return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt); - } - - coerce('dragmode'); - - var hovermodeDflt; - if(layoutOut._has('cartesian')) { - // flag for 'horizontal' plots: - // determines the state of the mode bar 'compare' hovermode button - var isHoriz = layoutOut._isHoriz = fx.isHoriz(fullData); - hovermodeDflt = isHoriz ? 'y' : 'x'; - } - else hovermodeDflt = 'closest'; - - coerce('hovermode', hovermodeDflt); -}; - -fx.isHoriz = function(fullData) { - var isHoriz = true; - - for(var i = 0; i < fullData.length; i++) { - var trace = fullData[i]; - - if(trace.orientation !== 'h') { - isHoriz = false; - break; - } - } - - return isHoriz; -}; - -fx.init = function(gd) { +module.exports = function initInteractions(gd) { var fullLayout = gd._fullLayout; if(!fullLayout._has('cartesian') || gd._context.staticPlot) return; @@ -118,11 +66,11 @@ fx.init = function(gd) { // changes by the time this is called again. gd._fullLayout._rehover = function() { if(gd._fullLayout._hoversubplot === subplot) { - fx.hover(gd, evt, subplot); + Fx.hover(gd, evt, subplot); } }; - fx.hover(gd, evt, subplot); + Fx.hover(gd, evt, subplot); // Note that we have *not* used the cached fullLayout variable here // since that may be outdated when this is called as a callback later on @@ -149,7 +97,7 @@ fx.init = function(gd) { }; maindrag.onclick = function(evt) { - fx.click(gd, evt); + Fx.click(gd, evt); }; // corner draggers @@ -189,19 +137,19 @@ fx.init = function(gd) { } }); - // In case you mousemove over some hovertext, send it to fx.hover too + // In case you mousemove over some hovertext, send it to Fx.hover too // we do this so that we can put the hover text in front of everything, // but still be able to interact with everything as if it isn't there var hoverLayer = fullLayout._hoverlayer.node(); hoverLayer.onmousemove = function(evt) { evt.target = fullLayout._lasthover; - fx.hover(gd, evt, fullLayout._hoversubplot); + Fx.hover(gd, evt, fullLayout._hoversubplot); }; hoverLayer.onclick = function(evt) { evt.target = fullLayout._lasthover; - fx.click(gd, evt); + Fx.click(gd, evt); }; // also delegate mousedowns... TODO: does this actually work? @@ -209,1367 +157,3 @@ fx.init = function(gd) { fullLayout._lasthover.onmousedown(evt); }; }; - -// hover labels for multiple horizontal bars get tilted by some angle, -// then need to be offset differently if they overlap -var YANGLE = constants.YANGLE, - YA_RADIANS = Math.PI * YANGLE / 180, - - // expansion of projected height - YFACTOR = 1 / Math.sin(YA_RADIANS), - - // to make the appropriate post-rotation x offset, - // you need both x and y offsets - YSHIFTX = Math.cos(YA_RADIANS), - YSHIFTY = Math.sin(YA_RADIANS); - -// convenience functions for mapping all relevant axes -function flat(subplots, v) { - var out = []; - for(var i = subplots.length; i > 0; i--) out.push(v); - return out; -} - -function p2c(axArray, v) { - var out = []; - for(var i = 0; i < axArray.length; i++) out.push(axArray[i].p2c(v)); - return out; -} - -function quadrature(dx, dy) { - return function(di) { - var x = dx(di), - y = dy(di); - return Math.sqrt(x * x + y * y); - }; -} - -// size and display constants for hover text -var HOVERARROWSIZE = constants.HOVERARROWSIZE, - HOVERTEXTPAD = constants.HOVERTEXTPAD; - -// fx.hover: highlight data on hover -// evt can be a mousemove event, or an object with data about what points -// to hover on -// {xpx,ypx[,hovermode]} - pixel locations from top left -// (with optional overriding hovermode) -// {xval,yval[,hovermode]} - data values -// [{curveNumber,(pointNumber|xval and/or yval)}] - -// array of specific points to highlight -// pointNumber is a single integer if gd.data[curveNumber] is 1D, -// or a two-element array if it's 2D -// xval and yval are data values, -// 1D data may specify either or both, -// 2D data must specify both -// subplot is an id string (default "xy") -// makes use of gl.hovermode, which can be: -// x (find the points with the closest x values, ie a column), -// closest (find the single closest point) -// internally there are two more that occasionally get used: -// y (pick out a row - only used for multiple horizontal bar charts) -// array (used when the user specifies an explicit -// array of points to hover on) -// -// We wrap the hovers in a timer, to limit their frequency. -// The actual rendering is done by private functions -// hover() and unhover(). - -fx.hover = function(gd, evt, subplot) { - if(typeof gd === 'string') gd = document.getElementById(gd); - if(gd._lastHoverTime === undefined) gd._lastHoverTime = 0; - - // If we have an update queued, discard it now - if(gd._hoverTimer !== undefined) { - clearTimeout(gd._hoverTimer); - gd._hoverTimer = undefined; - } - // Is it more than 100ms since the last update? If so, force - // an update now (synchronously) and exit - if(Date.now() > gd._lastHoverTime + constants.HOVERMINTIME) { - hover(gd, evt, subplot); - gd._lastHoverTime = Date.now(); - return; - } - // Queue up the next hover for 100ms from now (if no further events) - gd._hoverTimer = setTimeout(function() { - hover(gd, evt, subplot); - gd._lastHoverTime = Date.now(); - gd._hoverTimer = undefined; - }, constants.HOVERMINTIME); -}; - -// The actual implementation is here: - -function hover(gd, evt, subplot) { - if(subplot === 'pie') { - gd.emit('plotly_hover', { - event: evt.originalEvent, - points: [evt] - }); - return; - } - - if(!subplot) subplot = 'xy'; - - // if the user passed in an array of subplots, - // use those instead of finding overlayed plots - var subplots = Array.isArray(subplot) ? subplot : [subplot]; - - var fullLayout = gd._fullLayout, - plots = fullLayout._plots || [], - plotinfo = plots[subplot]; - - // list of all overlaid subplots to look at - if(plotinfo) { - var overlayedSubplots = plotinfo.overlays.map(function(pi) { - return pi.id; - }); - - subplots = subplots.concat(overlayedSubplots); - } - - var len = subplots.length, - xaArray = new Array(len), - yaArray = new Array(len); - - for(var i = 0; i < len; i++) { - var spId = subplots[i]; - - // 'cartesian' case - var plotObj = plots[spId]; - if(plotObj) { - - // TODO make sure that fullLayout_plots axis refs - // get updated properly so that we don't have - // to use Axes.getFromId in general. - - xaArray[i] = Axes.getFromId(gd, plotObj.xaxis._id); - yaArray[i] = Axes.getFromId(gd, plotObj.yaxis._id); - continue; - } - - // other subplot types - var _subplot = fullLayout[spId]._subplot; - xaArray[i] = _subplot.xaxis; - yaArray[i] = _subplot.yaxis; - } - - var hovermode = evt.hovermode || fullLayout.hovermode; - - if(['x', 'y', 'closest'].indexOf(hovermode) === -1 || !gd.calcdata || - gd.querySelector('.zoombox') || gd._dragging) { - return dragElement.unhoverRaw(gd, evt); - } - - // hoverData: the set of candidate points we've found to highlight - var hoverData = [], - - // searchData: the data to search in. Mostly this is just a copy of - // gd.calcdata, filtered to the subplot and overlays we're on - // but if a point array is supplied it will be a mapping - // of indicated curves - searchData = [], - - // [x|y]valArray: the axis values of the hover event - // mapped onto each of the currently selected overlaid subplots - xvalArray, - yvalArray, - - // used in loops - itemnum, - curvenum, - cd, - trace, - subplotId, - subploti, - mode, - xval, - yval, - pointData, - closedataPreviousLength; - - // Figure out what we're hovering on: - // mouse location or user-supplied data - - if(Array.isArray(evt)) { - // user specified an array of points to highlight - hovermode = 'array'; - for(itemnum = 0; itemnum < evt.length; itemnum++) { - cd = gd.calcdata[evt[itemnum].curveNumber||0]; - if(cd[0].trace.hoverinfo !== 'skip') { - searchData.push(cd); - } - } - } - else { - for(curvenum = 0; curvenum < gd.calcdata.length; curvenum++) { - cd = gd.calcdata[curvenum]; - trace = cd[0].trace; - if(trace.hoverinfo !== 'skip' && subplots.indexOf(getSubplot(trace)) !== -1) { - searchData.push(cd); - } - } - - // [x|y]px: the pixels (from top left) of the mouse location - // on the currently selected plot area - var hasUserCalledHover = !evt.target, - xpx, ypx; - - if(hasUserCalledHover) { - if('xpx' in evt) xpx = evt.xpx; - else xpx = xaArray[0]._length / 2; - - if('ypx' in evt) ypx = evt.ypx; - else ypx = yaArray[0]._length / 2; - } - else { - // fire the beforehover event and quit if it returns false - // note that we're only calling this on real mouse events, so - // manual calls to fx.hover will always run. - if(Events.triggerHandler(gd, 'plotly_beforehover', evt) === false) { - return; - } - - var dbb = evt.target.getBoundingClientRect(); - - xpx = evt.clientX - dbb.left; - ypx = evt.clientY - dbb.top; - - // in case hover was called from mouseout into hovertext, - // it's possible you're not actually over the plot anymore - if(xpx < 0 || xpx > dbb.width || ypx < 0 || ypx > dbb.height) { - return dragElement.unhoverRaw(gd, evt); - } - } - - if('xval' in evt) xvalArray = flat(subplots, evt.xval); - else xvalArray = p2c(xaArray, xpx); - - if('yval' in evt) yvalArray = flat(subplots, evt.yval); - else yvalArray = p2c(yaArray, ypx); - - if(!isNumeric(xvalArray[0]) || !isNumeric(yvalArray[0])) { - Lib.warn('Fx.hover failed', evt, gd); - return dragElement.unhoverRaw(gd, evt); - } - } - - // the pixel distance to beat as a matching point - // in 'x' or 'y' mode this resets for each trace - var distance = Infinity; - - // find the closest point in each trace - // this is minimum dx and/or dy, depending on mode - // and the pixel position for the label (labelXpx, labelYpx) - for(curvenum = 0; curvenum < searchData.length; curvenum++) { - cd = searchData[curvenum]; - - // filter out invisible or broken data - if(!cd || !cd[0] || !cd[0].trace || cd[0].trace.visible !== true) continue; - - trace = cd[0].trace; - - // Explicitly bail out for these two. I don't know how to otherwise prevent - // the rest of this function from running and failing - if(['carpet', 'contourcarpet'].indexOf(trace._module.name) !== -1) continue; - - subplotId = getSubplot(trace); - subploti = subplots.indexOf(subplotId); - - // within one trace mode can sometimes be overridden - mode = hovermode; - - // container for new point, also used to pass info into module.hoverPoints - pointData = { - // trace properties - cd: cd, - trace: trace, - xa: xaArray[subploti], - ya: yaArray[subploti], - name: (gd.data.length > 1 || trace.hoverinfo.indexOf('name') !== -1) ? trace.name : undefined, - // point properties - override all of these - index: false, // point index in trace - only used by plotly.js hoverdata consumers - distance: Math.min(distance, constants.MAXDIST), // pixel distance or pseudo-distance - color: Color.defaultLine, // trace color - x0: undefined, - x1: undefined, - y0: undefined, - y1: undefined, - xLabelVal: undefined, - yLabelVal: undefined, - zLabelVal: undefined, - text: undefined - }; - - // add ref to subplot object (non-cartesian case) - if(fullLayout[subplotId]) { - pointData.subplot = fullLayout[subplotId]._subplot; - } - - closedataPreviousLength = hoverData.length; - - // for a highlighting array, figure out what - // we're searching for with this element - if(mode === 'array') { - var selection = evt[curvenum]; - if('pointNumber' in selection) { - pointData.index = selection.pointNumber; - mode = 'closest'; - } - else { - mode = ''; - if('xval' in selection) { - xval = selection.xval; - mode = 'x'; - } - if('yval' in selection) { - yval = selection.yval; - mode = mode ? 'closest' : 'y'; - } - } - } - else { - xval = xvalArray[subploti]; - yval = yvalArray[subploti]; - } - - // Now find the points. - if(trace._module && trace._module.hoverPoints) { - var newPoints = trace._module.hoverPoints(pointData, xval, yval, mode); - if(newPoints) { - var newPoint; - for(var newPointNum = 0; newPointNum < newPoints.length; newPointNum++) { - newPoint = newPoints[newPointNum]; - if(isNumeric(newPoint.x0) && isNumeric(newPoint.y0)) { - hoverData.push(cleanPoint(newPoint, hovermode)); - } - } - } - } - else { - Lib.log('Unrecognized trace type in hover:', trace); - } - - // in closest mode, remove any existing (farther) points - // and don't look any farther than this latest point (or points, if boxes) - if(hovermode === 'closest' && hoverData.length > closedataPreviousLength) { - hoverData.splice(0, closedataPreviousLength); - distance = hoverData[0].distance; - } - } - - // nothing left: remove all labels and quit - if(hoverData.length === 0) return dragElement.unhoverRaw(gd, evt); - - hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); - - // lastly, emit custom hover/unhover events - var oldhoverdata = gd._hoverdata, - newhoverdata = []; - - // pull out just the data that's useful to - // other people and send it to the event - for(itemnum = 0; itemnum < hoverData.length; itemnum++) { - var pt = hoverData[itemnum]; - - var out = { - data: pt.trace._input, - fullData: pt.trace, - curveNumber: pt.trace.index, - pointNumber: pt.index - }; - - if(pt.trace._module.eventData) out = pt.trace._module.eventData(out, pt); - else { - out.x = pt.xVal; - out.y = pt.yVal; - out.xaxis = pt.xa; - out.yaxis = pt.ya; - - if(pt.zLabelVal !== undefined) out.z = pt.zLabelVal; - } - - newhoverdata.push(out); - } - - gd._hoverdata = newhoverdata; - - if(hoverChanged(gd, evt, oldhoverdata) && fullLayout._hasCartesian) { - var spikelineOpts = { - hovermode: hovermode, - fullLayout: fullLayout, - container: fullLayout._hoverlayer, - outerContainer: fullLayout._paperdiv - }; - createSpikelines(hoverData, spikelineOpts); - } - - // if there's more than one horz bar trace, - // rotate the labels so they don't overlap - var rotateLabels = hovermode === 'y' && searchData.length > 1; - - var bgColor = Color.combine( - fullLayout.plot_bgcolor || Color.background, - fullLayout.paper_bgcolor - ); - - var labelOpts = { - hovermode: hovermode, - rotateLabels: rotateLabels, - bgColor: bgColor, - container: fullLayout._hoverlayer, - outerContainer: fullLayout._paperdiv - }; - - var hoverLabels = createHoverText(hoverData, labelOpts); - - hoverAvoidOverlaps(hoverData, rotateLabels ? 'xa' : 'ya'); - - alignHoverText(hoverLabels, rotateLabels); - - // TODO: tagName hack is needed to appease geo.js's hack of using evt.target=true - // we should improve the "fx" API so other plots can use it without these hack. - if(evt.target && evt.target.tagName) { - var hasClickToShow = Registry.getComponentMethod('annotations', 'hasClickToShow')(gd, newhoverdata); - overrideCursor(d3.select(evt.target), hasClickToShow ? 'pointer' : ''); - } - - // don't emit events if called manually - if(!evt.target || !hoverChanged(gd, evt, oldhoverdata)) return; - - if(oldhoverdata) { - gd.emit('plotly_unhover', { - event: evt, - points: oldhoverdata - }); - } - - gd.emit('plotly_hover', { - event: evt, - points: gd._hoverdata, - xaxes: xaArray, - yaxes: yaArray, - xvals: xvalArray, - yvals: yvalArray - }); -} - -// look for either .subplot (currently just ternary) -// or xaxis and yaxis attributes -function getSubplot(trace) { - return trace.subplot || (trace.xaxis + trace.yaxis) || trace.geo; -} - -fx.getDistanceFunction = function(mode, dx, dy, dxy) { - if(mode === 'closest') return dxy || quadrature(dx, dy); - return mode === 'x' ? dx : dy; -}; - -fx.getClosest = function(cd, distfn, pointData) { - // do we already have a point number? (array mode only) - if(pointData.index !== false) { - if(pointData.index >= 0 && pointData.index < cd.length) { - pointData.distance = 0; - } - else pointData.index = false; - } - else { - // apply the distance function to each data point - // this is the longest loop... if this bogs down, we may need - // to create pre-sorted data (by x or y), not sure how to - // do this for 'closest' - for(var i = 0; i < cd.length; i++) { - var newDistance = distfn(cd[i]); - if(newDistance <= pointData.distance) { - pointData.index = i; - pointData.distance = newDistance; - } - } - } - return pointData; -}; - -function cleanPoint(d, hovermode) { - var trace = d.trace || {}; - var cd = d.cd[d.index] || {}; - - d.posref = hovermode === 'y' ? (d.x0 + d.x1) / 2 : (d.y0 + d.y1) / 2; - - // then constrain all the positions to be on the plot - d.x0 = Lib.constrain(d.x0, 0, d.xa._length); - d.x1 = Lib.constrain(d.x1, 0, d.xa._length); - d.y0 = Lib.constrain(d.y0, 0, d.ya._length); - d.y1 = Lib.constrain(d.y1, 0, d.ya._length); - - // and convert the x and y label values into objects - // formatted as text, with font info - var logOffScale; - if(d.xLabelVal !== undefined) { - logOffScale = (d.xa.type === 'log' && d.xLabelVal <= 0); - var xLabelObj = Axes.tickText(d.xa, - d.xa.c2l(logOffScale ? -d.xLabelVal : d.xLabelVal), 'hover'); - if(logOffScale) { - if(d.xLabelVal === 0) d.xLabel = '0'; - else d.xLabel = '-' + xLabelObj.text; - } - // TODO: should we do something special if the axis calendar and - // the data calendar are different? Somehow display both dates with - // their system names? Right now it will just display in the axis calendar - // but users could add the other one as text. - else d.xLabel = xLabelObj.text; - d.xVal = d.xa.c2d(d.xLabelVal); - } - - if(d.yLabelVal !== undefined) { - logOffScale = (d.ya.type === 'log' && d.yLabelVal <= 0); - var yLabelObj = Axes.tickText(d.ya, - d.ya.c2l(logOffScale ? -d.yLabelVal : d.yLabelVal), 'hover'); - if(logOffScale) { - if(d.yLabelVal === 0) d.yLabel = '0'; - else d.yLabel = '-' + yLabelObj.text; - } - // TODO: see above TODO - else d.yLabel = yLabelObj.text; - d.yVal = d.ya.c2d(d.yLabelVal); - } - - if(d.zLabelVal !== undefined) d.zLabel = String(d.zLabelVal); - - // for box means and error bars, add the range to the label - if(!isNaN(d.xerr) && !(d.xa.type === 'log' && d.xerr <= 0)) { - var xeText = Axes.tickText(d.xa, d.xa.c2l(d.xerr), 'hover').text; - if(d.xerrneg !== undefined) { - d.xLabel += ' +' + xeText + ' / -' + - Axes.tickText(d.xa, d.xa.c2l(d.xerrneg), 'hover').text; - } - else d.xLabel += ' ± ' + xeText; - - // small distance penalty for error bars, so that if there are - // traces with errors and some without, the error bar label will - // hoist up to the point - if(hovermode === 'x') d.distance += 1; - } - if(!isNaN(d.yerr) && !(d.ya.type === 'log' && d.yerr <= 0)) { - var yeText = Axes.tickText(d.ya, d.ya.c2l(d.yerr), 'hover').text; - if(d.yerrneg !== undefined) { - d.yLabel += ' +' + yeText + ' / -' + - Axes.tickText(d.ya, d.ya.c2l(d.yerrneg), 'hover').text; - } - else d.yLabel += ' ± ' + yeText; - - if(hovermode === 'y') d.distance += 1; - } - - var infomode = d.trace.hoverinfo; - if(infomode !== 'all') { - infomode = infomode.split('+'); - if(infomode.indexOf('x') === -1) d.xLabel = undefined; - if(infomode.indexOf('y') === -1) d.yLabel = undefined; - if(infomode.indexOf('z') === -1) d.zLabel = undefined; - if(infomode.indexOf('text') === -1) d.text = undefined; - if(infomode.indexOf('name') === -1) d.name = undefined; - } - - function fill(key, calcKey, traceKey) { - if(cd[calcKey]) return d[key] = cd[calcKey]; - - var traceVal = Lib.nestedProperty(trace, traceKey).get(); - if(traceVal) return d[key] = traceVal; - } - - fill('color', 'hbg', 'hoverlabel.bgcolor'); - fill('borderColor', 'hbc', 'hoverlabel.bordercolor'); - fill('fontFamily', 'htf', 'hoverlabel.font.family'); - fill('fontSize', 'hts', 'hoverlabel.font.size'); - fill('fontColor', 'htc', 'hoverlabel.font.color'); - - return d; -} - -/* - * Draw a single hover item in a pre-existing svg container somewhere - * hoverItem should have keys: - * - x and y (or x0, x1, y0, and y1): - * the pixel position to mark, relative to opts.container - * - xLabel, yLabel, zLabel, text, and name: - * info to go in the label - * - color: - * the background color for the label. - * - idealAlign (optional): - * 'left' or 'right' for which side of the x/y box to try to put this on first - * - borderColor (optional): - * color for the border, defaults to strongest contrast with color - * - fontFamily (optional): - * string, the font for this label, defaults to constants.HOVERFONT - * - fontSize (optional): - * the label font size, defaults to constants.HOVERFONTSIZE - * - fontColor (optional): - * defaults to borderColor - * opts should have keys: - * - bgColor: - * the background color this is against, used if the trace is - * non-opaque, and for the name, which goes outside the box - * - container: - * a or element to add the hover label to - * - outerContainer: - * normally a parent of `container`, sets the bounding box to use to - * constrain the hover label and determine whether to show it on the left or right - */ -fx.loneHover = function(hoverItem, opts) { - var pointData = { - color: hoverItem.color || Color.defaultLine, - x0: hoverItem.x0 || hoverItem.x || 0, - x1: hoverItem.x1 || hoverItem.x || 0, - y0: hoverItem.y0 || hoverItem.y || 0, - y1: hoverItem.y1 || hoverItem.y || 0, - xLabel: hoverItem.xLabel, - yLabel: hoverItem.yLabel, - zLabel: hoverItem.zLabel, - text: hoverItem.text, - name: hoverItem.name, - idealAlign: hoverItem.idealAlign, - - // optional extra bits of styling - borderColor: hoverItem.borderColor, - fontFamily: hoverItem.fontFamily, - fontSize: hoverItem.fontSize, - fontColor: hoverItem.fontColor, - - // filler to make createHoverText happy - trace: { - index: 0, - hoverinfo: '' - }, - xa: {_offset: 0}, - ya: {_offset: 0}, - index: 0 - }; - - var container3 = d3.select(opts.container), - outerContainer3 = opts.outerContainer ? - d3.select(opts.outerContainer) : container3; - - var fullOpts = { - hovermode: 'closest', - rotateLabels: false, - bgColor: opts.bgColor || Color.background, - container: container3, - outerContainer: outerContainer3 - }; - - var hoverLabel = createHoverText([pointData], fullOpts); - alignHoverText(hoverLabel, fullOpts.rotateLabels); - - return hoverLabel.node(); -}; - -fx.loneUnhover = function(containerOrSelection) { - // duck type whether the arg is a d3 selection because ie9 doesn't - // handle instanceof like modern browsers do. - var selection = Lib.isD3Selection(containerOrSelection) ? - containerOrSelection : - d3.select(containerOrSelection); - - selection.selectAll('g.hovertext').remove(); - selection.selectAll('.spikeline').remove(); -}; - -function createSpikelines(hoverData, opts) { - var hovermode = opts.hovermode; - var container = opts.container; - var c0 = hoverData[0]; - var xa = c0.xa; - var ya = c0.ya; - var showX = xa.showspikes; - var showY = ya.showspikes; - - // Remove old spikeline items - container.selectAll('.spikeline').remove(); - - if(hovermode !== 'closest' || !(showX || showY)) return; - - var fullLayout = opts.fullLayout; - var xPoint = xa._offset + (c0.x0 + c0.x1) / 2; - var yPoint = ya._offset + (c0.y0 + c0.y1) / 2; - var contrastColor = Color.combine(fullLayout.plot_bgcolor, fullLayout.paper_bgcolor); - var dfltDashColor = tinycolor.readability(c0.color, contrastColor) < 1.5 ? - Color.contrast(contrastColor) : c0.color; - - if(showY) { - var yMode = ya.spikemode; - var yThickness = ya.spikethickness; - var yColor = ya.spikecolor || dfltDashColor; - var yBB = ya._boundingBox; - var xEdge = ((yBB.left + yBB.right) / 2) < xPoint ? yBB.right : yBB.left; - - if(yMode.indexOf('toaxis') !== -1 || yMode.indexOf('across') !== -1) { - var xBase = xEdge; - var xEndSpike = xPoint; - if(yMode.indexOf('across') !== -1) { - xBase = ya._counterSpan[0]; - xEndSpike = ya._counterSpan[1]; - } - - // Background horizontal Line (to y-axis) - container.append('line') - .attr({ - 'x1': xBase, - 'x2': xEndSpike, - 'y1': yPoint, - 'y2': yPoint, - 'stroke-width': yThickness + 2, - 'stroke': contrastColor - }) - .classed('spikeline', true) - .classed('crisp', true); - - // Foreground horizontal line (to y-axis) - container.append('line') - .attr({ - 'x1': xBase, - 'x2': xEndSpike, - 'y1': yPoint, - 'y2': yPoint, - 'stroke-width': yThickness, - 'stroke': yColor, - 'stroke-dasharray': Drawing.dashStyle(ya.spikedash, yThickness) - }) - .classed('spikeline', true) - .classed('crisp', true); - } - // Y axis marker - if(yMode.indexOf('marker') !== -1) { - container.append('circle') - .attr({ - 'cx': xEdge + (ya.side !== 'right' ? yThickness : -yThickness), - 'cy': yPoint, - 'r': yThickness, - 'fill': yColor - }) - .classed('spikeline', true); - } - } - - if(showX) { - var xMode = xa.spikemode; - var xThickness = xa.spikethickness; - var xColor = xa.spikecolor || dfltDashColor; - var xBB = xa._boundingBox; - var yEdge = ((xBB.top + xBB.bottom) / 2) < yPoint ? xBB.bottom : xBB.top; - - if(xMode.indexOf('toaxis') !== -1 || xMode.indexOf('across') !== -1) { - var yBase = yEdge; - var yEndSpike = yPoint; - if(xMode.indexOf('across') !== -1) { - yBase = xa._counterSpan[0]; - yEndSpike = xa._counterSpan[1]; - } - - // Background vertical line (to x-axis) - container.append('line') - .attr({ - 'x1': xPoint, - 'x2': xPoint, - 'y1': yBase, - 'y2': yEndSpike, - 'stroke-width': xThickness + 2, - 'stroke': contrastColor - }) - .classed('spikeline', true) - .classed('crisp', true); - - // Foreground vertical line (to x-axis) - container.append('line') - .attr({ - 'x1': xPoint, - 'x2': xPoint, - 'y1': yBase, - 'y2': yEndSpike, - 'stroke-width': xThickness, - 'stroke': xColor, - 'stroke-dasharray': Drawing.dashStyle(xa.spikedash, xThickness) - }) - .classed('spikeline', true) - .classed('crisp', true); - } - - // X axis marker - if(xMode.indexOf('marker') !== -1) { - container.append('circle') - .attr({ - 'cx': xPoint, - 'cy': yEdge - (xa.side !== 'top' ? xThickness : -xThickness), - 'r': xThickness, - 'fill': xColor - }) - .classed('spikeline', true); - } - } -} - -function createHoverText(hoverData, opts) { - var hovermode = opts.hovermode, - rotateLabels = opts.rotateLabels, - bgColor = opts.bgColor, - container = opts.container, - outerContainer = opts.outerContainer, - - // opts.fontFamily/Size are used for the common label - // and as defaults for each hover label, though the individual labels - // can override this. - fontFamily = opts.fontFamily || constants.HOVERFONT, - fontSize = opts.fontSize || constants.HOVERFONTSIZE, - - c0 = hoverData[0], - xa = c0.xa, - ya = c0.ya, - commonAttr = hovermode === 'y' ? 'yLabel' : 'xLabel', - t0 = c0[commonAttr], - t00 = (String(t0) || '').split(' ')[0], - outerContainerBB = outerContainer.node().getBoundingClientRect(), - outerTop = outerContainerBB.top, - outerWidth = outerContainerBB.width, - outerHeight = outerContainerBB.height; - - // show the common label, if any, on the axis - // never show a common label in array mode, - // even if sometimes there could be one - var showCommonLabel = c0.distance <= constants.MAXDIST && - (hovermode === 'x' || hovermode === 'y'); - - // all hover traces hoverinfo must contain the hovermode - // to have common labels - var i, traceHoverinfo; - for(i = 0; i < hoverData.length; i++) { - traceHoverinfo = hoverData[i].trace.hoverinfo; - var parts = traceHoverinfo.split('+'); - if(parts.indexOf('all') === -1 && - parts.indexOf(hovermode) === -1) { - showCommonLabel = false; - break; - } - } - - var commonLabel = container.selectAll('g.axistext') - .data(showCommonLabel ? [0] : []); - commonLabel.enter().append('g') - .classed('axistext', true); - commonLabel.exit().remove(); - - commonLabel.each(function() { - var label = d3.select(this), - lpath = label.selectAll('path').data([0]), - ltext = label.selectAll('text').data([0]); - - lpath.enter().append('path') - .style({fill: Color.defaultLine, 'stroke-width': '1px', stroke: Color.background}); - ltext.enter().append('text') - .call(Drawing.font, fontFamily, fontSize, Color.background) - // prohibit tex interpretation until we can handle - // tex and regular text together - .attr('data-notex', 1); - - ltext.text(t0) - .call(svgTextUtils.convertToTspans) - .call(Drawing.setPosition, 0, 0) - .selectAll('tspan.line') - .call(Drawing.setPosition, 0, 0); - label.attr('transform', ''); - - var tbb = ltext.node().getBoundingClientRect(); - if(hovermode === 'x') { - ltext.attr('text-anchor', 'middle') - .call(Drawing.setPosition, 0, (xa.side === 'top' ? - (outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD) : - (outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD))) - .selectAll('tspan.line') - .attr({ - x: ltext.attr('x'), - y: ltext.attr('y') - }); - - var topsign = xa.side === 'top' ? '-' : ''; - lpath.attr('d', 'M0,0' + - 'L' + HOVERARROWSIZE + ',' + topsign + HOVERARROWSIZE + - 'H' + (HOVERTEXTPAD + tbb.width / 2) + - 'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) + - 'H-' + (HOVERTEXTPAD + tbb.width / 2) + - 'V' + topsign + HOVERARROWSIZE + 'H-' + HOVERARROWSIZE + 'Z'); - - label.attr('transform', 'translate(' + - (xa._offset + (c0.x0 + c0.x1) / 2) + ',' + - (ya._offset + (xa.side === 'top' ? 0 : ya._length)) + ')'); - } - else { - ltext.attr('text-anchor', ya.side === 'right' ? 'start' : 'end') - .call(Drawing.setPosition, - (ya.side === 'right' ? 1 : -1) * (HOVERTEXTPAD + HOVERARROWSIZE), - outerTop - tbb.top - tbb.height / 2) - .selectAll('tspan.line') - .attr({ - x: ltext.attr('x'), - y: ltext.attr('y') - }); - - var leftsign = ya.side === 'right' ? '' : '-'; - lpath.attr('d', 'M0,0' + - 'L' + leftsign + HOVERARROWSIZE + ',' + HOVERARROWSIZE + - 'V' + (HOVERTEXTPAD + tbb.height / 2) + - 'h' + leftsign + (HOVERTEXTPAD * 2 + tbb.width) + - 'V-' + (HOVERTEXTPAD + tbb.height / 2) + - 'H' + leftsign + HOVERARROWSIZE + 'V-' + HOVERARROWSIZE + 'Z'); - - label.attr('transform', 'translate(' + - (xa._offset + (ya.side === 'right' ? xa._length : 0)) + ',' + - (ya._offset + (c0.y0 + c0.y1) / 2) + ')'); - } - // remove the "close but not quite" points - // because of error bars, only take up to a space - hoverData = hoverData.filter(function(d) { - return (d.zLabelVal !== undefined) || - (d[commonAttr] || '').split(' ')[0] === t00; - }); - }); - - // show all the individual labels - - // first create the objects - var hoverLabels = container.selectAll('g.hovertext') - .data(hoverData, function(d) { - return [d.trace.index, d.index, d.x0, d.y0, d.name, d.attr, d.xa, d.ya || ''].join(','); - }); - hoverLabels.enter().append('g') - .classed('hovertext', true) - .each(function() { - var g = d3.select(this); - // trace name label (rect and text.name) - g.append('rect') - .call(Color.fill, Color.addOpacity(bgColor, 0.8)); - g.append('text').classed('name', true); - // trace data label (path and text.nums) - g.append('path') - .style('stroke-width', '1px'); - g.append('text').classed('nums', true) - .call(Drawing.font, fontFamily, fontSize); - }); - hoverLabels.exit().remove(); - - // then put the text in, position the pointer to the data, - // and figure out sizes - hoverLabels.each(function(d) { - var g = d3.select(this).attr('transform', ''), - name = '', - text = '', - // combine possible non-opaque trace color with bgColor - baseColor = Color.opacity(d.color) ? - d.color : Color.defaultLine, - traceColor = Color.combine(baseColor, bgColor), - - // find a contrasting color for border and text - contrastColor = d.borderColor || Color.contrast(traceColor); - - // to get custom 'name' labels pass cleanPoint - if(d.nameOverride !== undefined) d.name = d.nameOverride; - - if(d.name && d.zLabelVal === undefined) { - // strip out our pseudo-html elements from d.name (if it exists at all) - name = svgTextUtils.plainText(d.name || ''); - - if(name.length > 15) name = name.substr(0, 12) + '...'; - } - - // used by other modules (initially just ternary) that - // manage their own hoverinfo independent of cleanPoint - // the rest of this will still apply, so such modules - // can still put things in (x|y|z)Label, text, and name - // and hoverinfo will still determine their visibility - if(d.extraText !== undefined) text += d.extraText; - - if(d.zLabel !== undefined) { - if(d.xLabel !== undefined) text += 'x: ' + d.xLabel + '
'; - if(d.yLabel !== undefined) text += 'y: ' + d.yLabel + '
'; - text += (text ? 'z: ' : '') + d.zLabel; - } - else if(showCommonLabel && d[hovermode + 'Label'] === t0) { - text = d[(hovermode === 'x' ? 'y' : 'x') + 'Label'] || ''; - } - else if(d.xLabel === undefined) { - if(d.yLabel !== undefined) text = d.yLabel; - } - else if(d.yLabel === undefined) text = d.xLabel; - else text = '(' + d.xLabel + ', ' + d.yLabel + ')'; - - if(d.text && !Array.isArray(d.text)) text += (text ? '
' : '') + d.text; - - // if 'text' is empty at this point, - // put 'name' in main label and don't show secondary label - if(text === '') { - // if 'name' is also empty, remove entire label - if(name === '') g.remove(); - text = name; - } - - // main label - var tx = g.select('text.nums') - .call(Drawing.font, - d.fontFamily || fontFamily, - d.fontSize || fontSize, - d.fontColor || contrastColor) - .call(Drawing.setPosition, 0, 0) - .text(text) - .attr('data-notex', 1) - .call(svgTextUtils.convertToTspans); - tx.selectAll('tspan.line') - .call(Drawing.setPosition, 0, 0); - - var tx2 = g.select('text.name'), - tx2width = 0; - - // secondary label for non-empty 'name' - if(name && name !== text) { - tx2.call(Drawing.font, - d.fontFamily || fontFamily, - d.fontSize || fontSize, - traceColor) - .text(name) - .call(Drawing.setPosition, 0, 0) - .attr('data-notex', 1) - .call(svgTextUtils.convertToTspans); - tx2.selectAll('tspan.line') - .call(Drawing.setPosition, 0, 0); - tx2width = tx2.node().getBoundingClientRect().width + 2 * HOVERTEXTPAD; - } - else { - tx2.remove(); - g.select('rect').remove(); - } - - g.select('path') - .style({ - fill: traceColor, - stroke: contrastColor - }); - var tbb = tx.node().getBoundingClientRect(), - htx = d.xa._offset + (d.x0 + d.x1) / 2, - hty = d.ya._offset + (d.y0 + d.y1) / 2, - dx = Math.abs(d.x1 - d.x0), - dy = Math.abs(d.y1 - d.y0), - txTotalWidth = tbb.width + HOVERARROWSIZE + HOVERTEXTPAD + tx2width, - anchorStartOK, - anchorEndOK; - - d.ty0 = outerTop - tbb.top; - d.bx = tbb.width + 2 * HOVERTEXTPAD; - d.by = tbb.height + 2 * HOVERTEXTPAD; - d.anchor = 'start'; - d.txwidth = tbb.width; - d.tx2width = tx2width; - d.offset = 0; - - if(rotateLabels) { - d.pos = htx; - anchorStartOK = hty + dy / 2 + txTotalWidth <= outerHeight; - anchorEndOK = hty - dy / 2 - txTotalWidth >= 0; - if((d.idealAlign === 'top' || !anchorStartOK) && anchorEndOK) { - hty -= dy / 2; - d.anchor = 'end'; - } else if(anchorStartOK) { - hty += dy / 2; - d.anchor = 'start'; - } else d.anchor = 'middle'; - } - else { - d.pos = hty; - anchorStartOK = htx + dx / 2 + txTotalWidth <= outerWidth; - anchorEndOK = htx - dx / 2 - txTotalWidth >= 0; - if((d.idealAlign === 'left' || !anchorStartOK) && anchorEndOK) { - htx -= dx / 2; - d.anchor = 'end'; - } else if(anchorStartOK) { - htx += dx / 2; - d.anchor = 'start'; - } else d.anchor = 'middle'; - } - - tx.attr('text-anchor', d.anchor); - if(tx2width) tx2.attr('text-anchor', d.anchor); - g.attr('transform', 'translate(' + htx + ',' + hty + ')' + - (rotateLabels ? 'rotate(' + YANGLE + ')' : '')); - }); - - return hoverLabels; -} - -// Make groups of touching points, and within each group -// move each point so that no labels overlap, but the average -// label position is the same as it was before moving. Indicentally, -// this is equivalent to saying all the labels are on equal linear -// springs about their initial position. Initially, each point is -// its own group, but as we find overlaps we will clump the points. -// -// Also, there are hard constraints at the edges of the graphs, -// that push all groups to the middle so they are visible. I don't -// know what happens if the group spans all the way from one edge to -// the other, though it hardly matters - there's just too much -// information then. -function hoverAvoidOverlaps(hoverData, ax) { - var nummoves = 0, - - // make groups of touching points - pointgroups = hoverData - .map(function(d, i) { - var axis = d[ax]; - return [{ - i: i, - dp: 0, - pos: d.pos, - posref: d.posref, - size: d.by * (axis._id.charAt(0) === 'x' ? YFACTOR : 1) / 2, - pmin: axis._offset, - pmax: axis._offset + axis._length - }]; - }) - .sort(function(a, b) { return a[0].posref - b[0].posref; }), - donepositioning, - topOverlap, - bottomOverlap, - i, j, - pti, - sumdp; - - function constrainGroup(grp) { - var minPt = grp[0], - maxPt = grp[grp.length - 1]; - - // overlap with the top - positive vals are overlaps - topOverlap = minPt.pmin - minPt.pos - minPt.dp + minPt.size; - - // overlap with the bottom - positive vals are overlaps - bottomOverlap = maxPt.pos + maxPt.dp + maxPt.size - minPt.pmax; - - // check for min overlap first, so that we always - // see the largest labels - // allow for .01px overlap, so we don't get an - // infinite loop from rounding errors - if(topOverlap > 0.01) { - for(j = grp.length - 1; j >= 0; j--) grp[j].dp += topOverlap; - donepositioning = false; - } - if(bottomOverlap < 0.01) return; - if(topOverlap < -0.01) { - // make sure we're not pushing back and forth - for(j = grp.length - 1; j >= 0; j--) grp[j].dp -= bottomOverlap; - donepositioning = false; - } - if(!donepositioning) return; - - // no room to fix positioning, delete off-screen points - - // first see how many points we need to delete - var deleteCount = 0; - for(i = 0; i < grp.length; i++) { - pti = grp[i]; - if(pti.pos + pti.dp + pti.size > minPt.pmax) deleteCount++; - } - - // start by deleting points whose data is off screen - for(i = grp.length - 1; i >= 0; i--) { - if(deleteCount <= 0) break; - pti = grp[i]; - - // pos has already been constrained to [pmin,pmax] - // so look for points close to that to delete - if(pti.pos > minPt.pmax - 1) { - pti.del = true; - deleteCount--; - } - } - for(i = 0; i < grp.length; i++) { - if(deleteCount <= 0) break; - pti = grp[i]; - - // pos has already been constrained to [pmin,pmax] - // so look for points close to that to delete - if(pti.pos < minPt.pmin + 1) { - pti.del = true; - deleteCount--; - - // shift the whole group minus into this new space - bottomOverlap = pti.size * 2; - for(j = grp.length - 1; j >= 0; j--) grp[j].dp -= bottomOverlap; - } - } - // then delete points that go off the bottom - for(i = grp.length - 1; i >= 0; i--) { - if(deleteCount <= 0) break; - pti = grp[i]; - if(pti.pos + pti.dp + pti.size > minPt.pmax) { - pti.del = true; - deleteCount--; - } - } - } - - // loop through groups, combining them if they overlap, - // until nothing moves - while(!donepositioning && nummoves <= hoverData.length) { - // to avoid infinite loops, don't move more times - // than there are traces - nummoves++; - - // assume nothing will move in this iteration, - // reverse this if it does - donepositioning = true; - i = 0; - while(i < pointgroups.length - 1) { - // the higher (g0) and lower (g1) point group - var g0 = pointgroups[i], - g1 = pointgroups[i + 1], - - // the lowest point in the higher group (p0) - // the highest point in the lower group (p1) - p0 = g0[g0.length - 1], - p1 = g1[0]; - topOverlap = p0.pos + p0.dp + p0.size - p1.pos - p1.dp + p1.size; - - // Only group points that lie on the same axes - if(topOverlap > 0.01 && (p0.pmin === p1.pmin) && (p0.pmax === p1.pmax)) { - // push the new point(s) added to this group out of the way - for(j = g1.length - 1; j >= 0; j--) g1[j].dp += topOverlap; - - // add them to the group - g0.push.apply(g0, g1); - pointgroups.splice(i + 1, 1); - - // adjust for minimum average movement - sumdp = 0; - for(j = g0.length - 1; j >= 0; j--) sumdp += g0[j].dp; - bottomOverlap = sumdp / g0.length; - for(j = g0.length - 1; j >= 0; j--) g0[j].dp -= bottomOverlap; - donepositioning = false; - } - else i++; - } - - // check if we're going off the plot on either side and fix - pointgroups.forEach(constrainGroup); - } - - // now put these offsets into hoverData - for(i = pointgroups.length - 1; i >= 0; i--) { - var grp = pointgroups[i]; - for(j = grp.length - 1; j >= 0; j--) { - var pt = grp[j], - hoverPt = hoverData[pt.i]; - hoverPt.offset = pt.dp; - hoverPt.del = pt.del; - } - } -} - -function alignHoverText(hoverLabels, rotateLabels) { - // finally set the text positioning relative to the data and draw the - // box around it - hoverLabels.each(function(d) { - var g = d3.select(this); - if(d.del) { - g.remove(); - return; - } - var horzSign = d.anchor === 'end' ? -1 : 1, - tx = g.select('text.nums'), - alignShift = {start: 1, end: -1, middle: 0}[d.anchor], - txx = alignShift * (HOVERARROWSIZE + HOVERTEXTPAD), - tx2x = txx + alignShift * (d.txwidth + HOVERTEXTPAD), - offsetX = 0, - offsetY = d.offset; - if(d.anchor === 'middle') { - txx -= d.tx2width / 2; - tx2x -= d.tx2width / 2; - } - if(rotateLabels) { - offsetY *= -YSHIFTY; - offsetX = d.offset * YSHIFTX; - } - - g.select('path').attr('d', d.anchor === 'middle' ? - // middle aligned: rect centered on data - ('M-' + (d.bx / 2) + ',-' + (d.by / 2) + 'h' + d.bx + 'v' + d.by + 'h-' + d.bx + 'Z') : - // left or right aligned: side rect with arrow to data - ('M0,0L' + (horzSign * HOVERARROWSIZE + offsetX) + ',' + (HOVERARROWSIZE + offsetY) + - 'v' + (d.by / 2 - HOVERARROWSIZE) + - 'h' + (horzSign * d.bx) + - 'v-' + d.by + - 'H' + (horzSign * HOVERARROWSIZE + offsetX) + - 'V' + (offsetY - HOVERARROWSIZE) + - 'Z')); - - tx.call(Drawing.setPosition, - txx + offsetX, offsetY + d.ty0 - d.by / 2 + HOVERTEXTPAD) - .selectAll('tspan.line') - .attr({ - x: tx.attr('x'), - y: tx.attr('y') - }); - - if(d.tx2width) { - g.select('text.name, text.name tspan.line') - .call(Drawing.setPosition, - tx2x + alignShift * HOVERTEXTPAD + offsetX, - offsetY + d.ty0 - d.by / 2 + HOVERTEXTPAD); - g.select('rect') - .call(Drawing.setRect, - tx2x + (alignShift - 1) * d.tx2width / 2 + offsetX, - offsetY - d.by / 2 - 1, - d.tx2width, d.by + 2); - } - }); -} - -function hoverChanged(gd, evt, oldhoverdata) { - // don't emit any events if nothing changed - if(!oldhoverdata || oldhoverdata.length !== gd._hoverdata.length) return true; - - for(var i = oldhoverdata.length - 1; i >= 0; i--) { - var oldPt = oldhoverdata[i], - newPt = gd._hoverdata[i]; - if(oldPt.curveNumber !== newPt.curveNumber || - String(oldPt.pointNumber) !== String(newPt.pointNumber)) { - return true; - } - } - return false; -} - -// on click -fx.click = function(gd, evt) { - var annotationsDone = Registry.getComponentMethod('annotations', 'onClick')(gd, gd._hoverdata); - - function emitClick() { gd.emit('plotly_click', {points: gd._hoverdata, event: evt}); } - - if(gd._hoverdata && evt && evt.target) { - if(annotationsDone && annotationsDone.then) { - annotationsDone.then(emitClick); - } - else emitClick(); - - // why do we get a double event without this??? - if(evt.stopImmediatePropagation) evt.stopImmediatePropagation(); - } -}; - - -// for bar charts and others with finite-size objects: you must be inside -// it to see its hover info, so distance is infinite outside. -// But make distance inside be at least 1/4 MAXDIST, and a little bigger -// for bigger bars, to prioritize scatter and smaller bars over big bars - -// note that for closest mode, two inbox's will get added in quadrature -// args are (signed) difference from the two opposite edges -// count one edge as in, so that over continuous ranges you never get a gap -fx.inbox = function(v0, v1) { - if(v0 * v1 < 0 || v0 === 0) { - return constants.MAXDIST * (0.6 - 0.3 / Math.max(3, Math.abs(v0 - v1))); - } - return Infinity; -}; diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 16d760872ae..2909a3cc6da 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -15,9 +15,9 @@ var d3 = require('d3'); var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); +var Fx = require('../../components/fx'); var Plots = require('../plots'); var Axes = require('../cartesian/axes'); -var Fx = require('../cartesian/graph_interact'); var addProjectionsToD3 = require('./projections'); var createGeoScale = require('./set_scale'); diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index 01b1fdb2b56..abd054cf892 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -11,7 +11,7 @@ var Registry = require('../../registry'); var Axes = require('../../plots/cartesian/axes'); -var Fx = require('../../plots/cartesian/graph_interact'); +var Fx = require('../../components/fx'); var createPlot2D = require('gl-plot2d'); var createSpikes = require('gl-spikes2d'); diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index bf35acc3902..e4c44895671 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -15,7 +15,7 @@ var getContext = require('webgl-context'); var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); -var Fx = require('../../plots/cartesian/graph_interact'); +var Fx = require('../../components/fx'); var str2RGBAarray = require('../../lib/str2rgbarray'); var showNoWebGlMsg = require('../../lib/show_no_webgl_msg'); diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index c2c033d8b0d..4043ac267f9 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -169,23 +169,5 @@ module.exports = { valType: 'boolean', role: 'info', description: 'Determines whether or not a legend is drawn.' - }, - dragmode: { - valType: 'enumerated', - role: 'info', - values: ['zoom', 'pan', 'select', 'lasso', 'orbit', 'turntable'], - dflt: 'zoom', - description: [ - 'Determines the mode of drag interactions.', - '*select* and *lasso* apply only to scatter traces with', - 'markers or text. *orbit* and *turntable* apply only to', - '3D scenes.' - ].join(' ') - }, - hovermode: { - valType: 'enumerated', - role: 'info', - values: ['x', 'y', 'closest', false], - description: 'Determines the mode of hover interactions.' } }; diff --git a/src/plots/mapbox/mapbox.js b/src/plots/mapbox/mapbox.js index 6062376404b..30e28eace91 100644 --- a/src/plots/mapbox/mapbox.js +++ b/src/plots/mapbox/mapbox.js @@ -11,7 +11,7 @@ var mapboxgl = require('mapbox-gl'); -var Fx = require('../cartesian/graph_interact'); +var Fx = require('../../components/fx'); var Lib = require('../../lib'); var constants = require('./constants'); var layoutAttributes = require('./layout_attributes'); diff --git a/src/plots/plots.js b/src/plots/plots.js index b70569ff39c..246cb357f9c 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1111,9 +1111,6 @@ plots.supplyLayoutModuleDefaults = function(layoutIn, layoutOut, fullData, trans } } - // should FX be a component? - Plotly.Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData); - var components = Object.keys(Registry.componentsRegistry); for(i = 0; i < components.length; i++) { _module = Registry.componentsRegistry[components[i]]; diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index 3e4cf0a3215..4c1e6a8c6a3 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -21,10 +21,10 @@ var extendFlat = require('../../lib/extend').extendFlat; var Plots = require('../plots'); var Axes = require('../cartesian/axes'); var dragElement = require('../../components/dragelement'); +var Fx = require('../../components/fx'); var Titles = require('../../components/titles'); var prepSelect = require('../cartesian/select'); var constants = require('../cartesian/constants'); -var fx = require('../cartesian/graph_interact'); function Ternary(options, fullLayout) { @@ -619,7 +619,7 @@ proto.initInteractions = function() { // these event handlers must already be set before dragElement.init // so it can stash them and override them. dragger.onmousemove = function(evt) { - fx.hover(gd, evt, _this.id); + Fx.hover(gd, evt, _this.id); gd._fullLayout._lasthover = dragger; gd._fullLayout._hoversubplot = _this.id; }; @@ -631,7 +631,7 @@ proto.initInteractions = function() { }; dragger.onclick = function(evt) { - fx.click(gd, evt); + Fx.click(gd, evt); }; dragElement.init(dragOptions); diff --git a/src/traces/bar/hover.js b/src/traces/bar/hover.js index 377cf4fa0a0..b718dc4b139 100644 --- a/src/traces/bar/hover.js +++ b/src/traces/bar/hover.js @@ -9,7 +9,7 @@ 'use strict'; -var Fx = require('../../plots/cartesian/graph_interact'); +var Fx = require('../../components/fx'); var ErrorBars = require('../../components/errorbars'); var Color = require('../../components/color'); diff --git a/src/traces/box/hover.js b/src/traces/box/hover.js index 76e65c5104f..c565e9d0d47 100644 --- a/src/traces/box/hover.js +++ b/src/traces/box/hover.js @@ -9,8 +9,8 @@ 'use strict'; var Axes = require('../../plots/cartesian/axes'); -var Fx = require('../../plots/cartesian/graph_interact'); var Lib = require('../../lib'); +var Fx = require('../../components/fx'); var Color = require('../../components/color'); module.exports = function hoverPoints(pointData, xval, yval, hovermode) { diff --git a/src/traces/heatmap/hover.js b/src/traces/heatmap/hover.js index d9ea27f4340..e3a870f3cb7 100644 --- a/src/traces/heatmap/hover.js +++ b/src/traces/heatmap/hover.js @@ -9,11 +9,10 @@ 'use strict'; -var Fx = require('../../plots/cartesian/graph_interact'); +var Fx = require('../../components/fx'); var Lib = require('../../lib'); -var MAXDIST = require('../../plots/cartesian/constants').MAXDIST; - +var MAXDIST = Fx.constants.MAXDIST; module.exports = function hoverPoints(pointData, xval, yval, hovermode, contour) { // never let a heatmap override another type as closest point diff --git a/src/traces/pie/plot.js b/src/traces/pie/plot.js index 124e96368e3..6ef897e1748 100644 --- a/src/traces/pie/plot.js +++ b/src/traces/pie/plot.js @@ -10,7 +10,7 @@ var d3 = require('d3'); -var Fx = require('../../plots/cartesian/graph_interact'); +var Fx = require('../../components/fx'); var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); var svgTextUtils = require('../../lib/svg_text_utils'); diff --git a/src/traces/scatter/hover.js b/src/traces/scatter/hover.js index caba649e550..7ceac0db070 100644 --- a/src/traces/scatter/hover.js +++ b/src/traces/scatter/hover.js @@ -10,12 +10,12 @@ 'use strict'; var Lib = require('../../lib'); -var Fx = require('../../plots/cartesian/graph_interact'); -var constants = require('../../plots/cartesian/constants'); +var Fx = require('../../components/fx'); var ErrorBars = require('../../components/errorbars'); var getTraceColor = require('./get_trace_color'); var Color = require('../../components/color'); +var MAXDIST = Fx.constants.MAXDIST; module.exports = function hoverPoints(pointData, xval, yval, hovermode) { var cd = pointData.cd, @@ -148,7 +148,7 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { Lib.extendFlat(pointData, { // never let a 2D override 1D type as closest point - distance: constants.MAXDIST + 10, + distance: MAXDIST + 10, x0: xmin, x1: xmax, y0: yAvg, diff --git a/src/traces/scattergeo/hover.js b/src/traces/scattergeo/hover.js index 44928b875e8..6607a3dbcee 100644 --- a/src/traces/scattergeo/hover.js +++ b/src/traces/scattergeo/hover.js @@ -9,7 +9,7 @@ 'use strict'; -var Fx = require('../../plots/cartesian/graph_interact'); +var Fx = require('../../components/fx'); var Axes = require('../../plots/cartesian/axes'); var BADNUM = require('../../constants/numerical').BADNUM; diff --git a/src/traces/scattermapbox/hover.js b/src/traces/scattermapbox/hover.js index 011425a3ebc..3b3f5434634 100644 --- a/src/traces/scattermapbox/hover.js +++ b/src/traces/scattermapbox/hover.js @@ -9,7 +9,7 @@ 'use strict'; -var Fx = require('../../plots/cartesian/graph_interact'); +var Fx = require('../../components/fx'); var getTraceColor = require('../scatter/get_trace_color'); var BADNUM = require('../../constants/numerical').BADNUM; diff --git a/tasks/test_syntax.js b/tasks/test_syntax.js index 150cac24f4c..b23620bd301 100644 --- a/tasks/test_syntax.js +++ b/tasks/test_syntax.js @@ -176,10 +176,8 @@ function assertCircularDeps() { var circularDeps = res.circular(); var logs = []; - // as of v1.17.0 - 2016/09/08 // see https://github.com/plotly/plotly.js/milestone/9 - // for more details - var MAX_ALLOWED_CIRCULAR_DEPS = 17; + var MAX_ALLOWED_CIRCULAR_DEPS = 12; if(circularDeps.length > MAX_ALLOWED_CIRCULAR_DEPS) { console.log(circularDeps.join('\n')); diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index b6811cade12..bf0aeb220a5 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -5,7 +5,7 @@ var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); var Loggers = require('@src/lib/loggers'); var Axes = require('@src/plots/cartesian/axes'); -var HOVERMINTIME = require('@src/plots/cartesian/constants').HOVERMINTIME; +var HOVERMINTIME = require('@src/components/fx').constants.HOVERMINTIME; var DBLCLICKDELAY = require('@src/constants/interactions').DBLCLICKDELAY; var d3 = require('d3'); diff --git a/test/jasmine/tests/fx_test.js b/test/jasmine/tests/fx_test.js index 4a89b547472..123f84ec38f 100644 --- a/test/jasmine/tests/fx_test.js +++ b/test/jasmine/tests/fx_test.js @@ -1,7 +1,7 @@ var Plotly = require('@lib/index'); var Plots = require('@src/plots/plots'); -var Fx = require('@src/plots/cartesian/graph_interact'); +var Fx = require('@src/components/fx'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index b783fda1c45..8841292a16c 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -17,8 +17,7 @@ var mouseEvent = require('../assets/mouse_event'); var click = require('../assets/click'); var DBLCLICKDELAY = require('@src/constants/interactions').DBLCLICKDELAY; -var HOVERMINTIME = require('@src/plots/cartesian/constants').HOVERMINTIME; - +var HOVERMINTIME = require('@src/components/fx').constants.HOVERMINTIME; function move(fromX, fromY, toX, toY, delay) { return new Promise(function(resolve) { diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index eeb0a665743..ddb987e32bd 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -1,9 +1,9 @@ var d3 = require('d3'); var Plotly = require('@lib/index'); -var Fx = require('@src/plots/cartesian/graph_interact'); -var constants = require('@src/plots/cartesian/constants'); +var Fx = require('@src/components/fx'); var Lib = require('@src/lib'); +var HOVERMINTIME = require('@src/components/fx').constants.HOVERMINTIME; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -554,7 +554,7 @@ describe('hover info', function() { Promise.resolve().then(function() { Fx.hover(gd, event, 'xy'); }) - .then(delay(constants.HOVERMINTIME * 1.1)) + .then(delay(HOVERMINTIME * 1.1)) .then(function() { Fx.unhover(gd); }) @@ -719,7 +719,7 @@ describe('hover after resizing', function() { setTimeout(function() { resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -732,7 +732,7 @@ describe('hover after resizing', function() { expect(hoverText.size()).toEqual(cnt, msg); resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -799,7 +799,7 @@ describe('hover on fill', function() { expect(+transformCoords[1]).toBeCloseTo(labelPos[1], -1.2, labelText + ':y'); resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -879,7 +879,7 @@ describe('hover updates', function() { } resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -935,7 +935,7 @@ describe('hover updates', function() { mouseEvent('mousemove', 394, 285); setTimeout(function() { resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } diff --git a/test/jasmine/tests/hover_spikeline_test.js b/test/jasmine/tests/hover_spikeline_test.js index f8651bbc7ed..0b1a1cc1ea8 100644 --- a/test/jasmine/tests/hover_spikeline_test.js +++ b/test/jasmine/tests/hover_spikeline_test.js @@ -1,7 +1,7 @@ var d3 = require('d3'); var Plotly = require('@lib/index'); -var Fx = require('@src/plots/cartesian/graph_interact'); +var Fx = require('@src/components/fx'); var Lib = require('@src/lib'); var createGraphDiv = require('../assets/create_graph_div'); diff --git a/test/jasmine/tests/scattermapbox_test.js b/test/jasmine/tests/scattermapbox_test.js index 7670ca1d8f2..180bf93618d 100644 --- a/test/jasmine/tests/scattermapbox_test.js +++ b/test/jasmine/tests/scattermapbox_test.js @@ -11,7 +11,7 @@ var customMatchers = require('../assets/custom_matchers'); var mouseEvent = require('../assets/mouse_event'); var click = require('../assets/click'); -var HOVERMINTIME = require('@src/plots/cartesian/constants').HOVERMINTIME; +var HOVERMINTIME = require('@src/components/fx').constants.HOVERMINTIME; function move(fromX, fromY, toX, toY, delay) { return new Promise(function(resolve) {