diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index f81436159da..1f008942258 100644 --- a/src/components/annotations/annotation_defaults.js +++ b/src/components/annotations/annotation_defaults.js @@ -12,7 +12,6 @@ var Lib = require('../../lib'); var Color = require('../color'); var Axes = require('../../plots/cartesian/axes'); -var constants = require('../../plots/cartesian/constants'); var attributes = require('./attributes'); @@ -113,14 +112,21 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op } var hoverText = coerce('hovertext'); + var globalHoverLabel = fullLayout.hoverlabel || {}; + if(hoverText) { - var hoverBG = coerce('hoverlabel.bgcolor', - Color.opacity(bgColor) ? Color.rgb(bgColor) : Color.defaultLine); - var hoverBorder = coerce('hoverlabel.bordercolor', Color.contrast(hoverBG)); + var hoverBG = coerce('hoverlabel.bgcolor', globalHoverLabel.bgcolor || + (Color.opacity(bgColor) ? Color.rgb(bgColor) : Color.defaultLine) + ); + + var hoverBorder = coerce('hoverlabel.bordercolor', globalHoverLabel.bordercolor || + Color.contrast(hoverBG) + ); + Lib.coerceFont(coerce, 'hoverlabel.font', { - family: constants.HOVERFONT, - size: constants.HOVERFONTSIZE, - color: hoverBorder + family: globalHoverLabel.font.family, + size: globalHoverLabel.font.size, + color: globalHoverLabel.font.color || hoverBorder }); } coerce('captureevents', !!hoverText); diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 71a0b5c308a..25619fa9ebc 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/attributes.js b/src/components/fx/attributes.js new file mode 100644 index 00000000000..65685335015 --- /dev/null +++ b/src/components/fx/attributes.js @@ -0,0 +1,38 @@ +/** +* 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 extendFlat = require('../../lib/extend').extendFlat; +var fontAttrs = require('../../plots/font_attributes'); + +module.exports = { + hoverlabel: { + bgcolor: { + valType: 'color', + role: 'style', + arrayOk: true, + description: [ + 'Sets the background color of the hover labels for this trace' + ].join(' ') + }, + bordercolor: { + valType: 'color', + role: 'style', + arrayOk: true, + description: [ + 'Sets the border color of the hover labels for this trace.' + ].join(' ') + }, + font: { + family: extendFlat({}, fontAttrs.family, { arrayOk: true }), + size: extendFlat({}, fontAttrs.size, { arrayOk: true }), + color: extendFlat({}, fontAttrs.color, { arrayOk: true }) + } + } +}; diff --git a/src/components/fx/calc.js b/src/components/fx/calc.js new file mode 100644 index 00000000000..5d93ccb07d9 --- /dev/null +++ b/src/components/fx/calc.js @@ -0,0 +1,37 @@ +/** +* 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 Registry = require('../../registry'); + +module.exports = function calc(gd) { + var calcdata = gd.calcdata; + + for(var i = 0; i < calcdata.length; i++) { + var cd = calcdata[i]; + var trace = cd[0].trace; + + if(!trace.hoverlabel) continue; + + var mergeFn = Registry.traceIs(trace, '2dMap') ? paste : Lib.mergeArray; + + mergeFn(trace.hoverlabel.bgcolor, cd, 'hbg'); + mergeFn(trace.hoverlabel.bordercolor, cd, 'hbc'); + mergeFn(trace.hoverlabel.font.size, cd, 'hts'); + mergeFn(trace.hoverlabel.font.color, cd, 'htc'); + mergeFn(trace.hoverlabel.font.family, cd, 'htf'); + } +}; + +function paste(traceAttr, cd, cdAttr) { + if(Array.isArray(traceAttr)) { + cd[0][cdAttr] = traceAttr; + } +} 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..37e21d014e0 --- /dev/null +++ b/src/components/fx/constants.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 = { + // 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 + + // pixel size of hover arrows + HOVERARROWSIZE: 6, + // pixels padding around text + HOVERTEXTPAD: 3, + // hover font + HOVERFONTSIZE: 13, + HOVERFONT: 'Arial, sans-serif', + + // minimum time (msec) between hover calls + HOVERMINTIME: 50 +}; diff --git a/src/components/fx/defaults.js b/src/components/fx/defaults.js new file mode 100644 index 00000000000..09119ba1475 --- /dev/null +++ b/src/components/fx/defaults.js @@ -0,0 +1,21 @@ +/** +* 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 attributes = require('./attributes'); +var handleHoverLabelDefaults = require('./hoverlabel_defaults'); + +module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { + function coerce(attr, dflt) { + return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); + } + + handleHoverLabelDefaults(traceIn, traceOut, coerce, layout.hoverlabel); +}; diff --git a/src/components/fx/helpers.js b/src/components/fx/helpers.js new file mode 100644 index 00000000000..c45da2b0ee5 --- /dev/null +++ b/src/components/fx/helpers.js @@ -0,0 +1,85 @@ +/** +* 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 = new Array(subplots.length); + for(var i = 0; i < subplots.length; i++) { + out[i] = v; + } + return out; +}; + +exports.p2c = function p2c(axArray, v) { + var out = new Array(axArray.length); + for(var i = 0; i < axArray.length; i++) { + out[i] = 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..bc5edff9235 --- /dev/null +++ b/src/components/fx/hover.js @@ -0,0 +1,1308 @@ +/** +* 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' || subplot === 'sankey') { + 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, + commonLabelOpts: fullLayout.hoverlabel + }; + + 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; + var rotateLabels = opts.rotateLabels; + var bgColor = opts.bgColor; + var container = opts.container; + var outerContainer = opts.outerContainer; + var commonLabelOpts = opts.commonLabelOpts || {}; + + // opts.fontFamily/Size are used for the common label + // and as defaults for each hover label, though the individual labels + // can override this. + var fontFamily = opts.fontFamily || constants.HOVERFONT; + var fontSize = opts.fontSize || constants.HOVERFONTSIZE; + + var c0 = hoverData[0]; + var xa = c0.xa; + var ya = c0.ya; + var commonAttr = hovermode === 'y' ? 'yLabel' : 'xLabel'; + var t0 = c0[commonAttr]; + var t00 = (String(t0) || '').split(' ')[0]; + var outerContainerBB = outerContainer.node().getBoundingClientRect(); + var outerTop = outerContainerBB.top; + var outerWidth = outerContainerBB.width; + var 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: commonLabelOpts.bgcolor || Color.defaultLine, + stroke: commonLabelOpts.bordercolor || Color.background, + 'stroke-width': '1px' + }); + ltext.enter().append('text') + .call(Drawing.font, + commonLabelOpts.font.family || fontFamily, + commonLabelOpts.font.size || fontSize, + commonLabelOpts.font.color || 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 + var baseColor = Color.opacity(d.color) ? d.color : Color.defaultLine; + var traceColor = Color.combine(baseColor, bgColor); + + // find a contrasting color for border and text + var contrastColor = d.borderColor || Color.contrast(traceColor); + + // to get custom 'name' labels pass cleanPoint + if(d.nameOverride !== undefined) d.name = d.nameOverride; + + if(d.name) { + // 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 cd0 = d.cd[0]; + 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) { + var val; + + if(cd[calcKey]) { + val = cd[calcKey]; + } else if(cd0[calcKey]) { + var arr = cd0[calcKey]; + if(Array.isArray(arr) && Array.isArray(arr[d.index[0]])) { + val = arr[d.index[0]][d.index[1]]; + } + } else { + val = Lib.nestedProperty(trace, traceKey).get(); + } + + if(val) d[key] = val; + } + + 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/hoverlabel_defaults.js b/src/components/fx/hoverlabel_defaults.js new file mode 100644 index 00000000000..85fb682dee0 --- /dev/null +++ b/src/components/fx/hoverlabel_defaults.js @@ -0,0 +1,19 @@ +/** +* 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'); + +module.exports = function handleHoverLabelDefaults(contIn, contOut, coerce, opts) { + opts = opts || {}; + + coerce('hoverlabel.bgcolor', opts.bgcolor); + coerce('hoverlabel.bordercolor', opts.bordercolor); + Lib.coerceFont(coerce, 'hoverlabel.font', opts.font); +}; diff --git a/src/components/fx/index.js b/src/components/fx/index.js new file mode 100644 index 00000000000..545548bcbbd --- /dev/null +++ b/src/components/fx/index.js @@ -0,0 +1,74 @@ +/** +* 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 + }, + + attributes: require('./attributes'), + layoutAttributes: layoutAttributes, + + supplyLayoutGlobalDefaults: require('./layout_global_defaults'), + supplyDefaults: require('./defaults'), + supplyLayoutDefaults: require('./layout_defaults'), + + calc: require('./calc'), + + getDistanceFunction: helpers.getDistanceFunction, + getClosest: helpers.getClosest, + inbox: helpers.inbox, + castHoverOption: castHoverOption, + + 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(); +} + +// Handler for trace-wide vs per-point hover label options +function castHoverOption(trace, ptNumber, attr) { + var labelOpts = trace.hoverlabel || {}; + var val = Lib.nestedProperty(labelOpts, attr).get(); + + if(Array.isArray(val)) { + if(Array.isArray(ptNumber) && Array.isArray(val[ptNumber[0]])) { + return val[ptNumber[0]][ptNumber[1]]; + } else { + return val[ptNumber]; + } + } else { + return val; + } +} diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js new file mode 100644 index 00000000000..f09e0dff0d1 --- /dev/null +++ b/src/components/fx/layout_attributes.js @@ -0,0 +1,60 @@ +/** +* 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 extendFlat = require('../../lib/extend').extendFlat; +var fontAttrs = require('../../plots/font_attributes'); +var constants = require('./constants'); + +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.' + }, + + hoverlabel: { + bgcolor: { + valType: 'color', + role: 'style', + description: [ + 'Sets the background color of all hover labels on graph' + ].join(' ') + }, + bordercolor: { + valType: 'color', + role: 'style', + description: [ + 'Sets the border color of all hover labels on graph.' + ].join(' ') + }, + font: { + family: extendFlat({}, fontAttrs.family, { + dflt: constants.HOVERFONT + }), + size: extendFlat({}, fontAttrs.size, { + dflt: constants.HOVERFONTSIZE + }), + color: extendFlat({}, fontAttrs.color) + } + } +}; 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/components/fx/layout_global_defaults.js b/src/components/fx/layout_global_defaults.js new file mode 100644 index 00000000000..4d00bc48508 --- /dev/null +++ b/src/components/fx/layout_global_defaults.js @@ -0,0 +1,21 @@ +/** +* 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 handleHoverLabelDefaults = require('./hoverlabel_defaults'); +var layoutAttributes = require('./layout_attributes'); + +module.exports = function supplyLayoutGlobalDefaults(layoutIn, layoutOut) { + function coerce(attr, dflt) { + return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt); + } + + handleHoverLabelDefaults(layoutIn, layoutOut, coerce); +}; diff --git a/src/core.js b/src/core.js index 51ceef36582..12a6a7933e4 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 cb79776c665..ecb623db380 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'); @@ -191,7 +191,7 @@ Plotly.plot = function(gd, data, layout, config) { return Lib.syncOrAsync([ subroutines.layoutStyles, drawAxes, - Fx.init + initInteractions ], gd); } @@ -226,7 +226,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); } @@ -1612,7 +1612,9 @@ function _restyle(gd, aobj, _traces) { } else { var moduleAttrs = (contFull._module || {}).attributes || {}; - var valObject = Lib.nestedProperty(moduleAttrs, ai).get() || {}; + var valObject = Lib.nestedProperty(moduleAttrs, ai).get() || + Lib.nestedProperty(Plots.attributes, ai).get() || + {}; // if restyling entire attribute container, assume worse case if(!valObject.valType) { 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 594fba13a6d..54066762e8a 100644 --- a/src/plots/attributes.js +++ b/src/plots/attributes.js @@ -8,6 +8,7 @@ 'use strict'; +var fxAttrs = require('../components/fx/attributes'); module.exports = { type: { @@ -80,6 +81,7 @@ module.exports = { 'But, if `none` is set, click and hover events are still fired.' ].join(' ') }, + hoverlabel: fxAttrs.hoverlabel, stream: { token: { valType: 'string', 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 1eaf0176592..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,1351 +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(['pie', 'sankey'].indexOf(subplot) !== -1) { - 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) { - 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; - } - - 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..68052f8da55 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'); @@ -633,6 +633,9 @@ proto.draw = function() { if(parts.indexOf('name') === -1) selection.name = undefined; } + var trace = this.fullData[selection.trace.index] || {}; + var ptNumber = selection.pointIndex; + Fx.loneHover({ x: selection.screenCoord[0], y: selection.screenCoord[1], @@ -641,7 +644,11 @@ proto.draw = function() { zLabel: selection.traceCoord[2], text: selection.textLabel, name: selection.name, - color: selection.color + color: Fx.castHoverOption(trace, ptNumber, 'bgcolor') || selection.color, + borderColor: Fx.castHoverOption(trace, ptNumber, 'bordercolor'), + fontFamily: Fx.castHoverOption(trace, ptNumber, 'font.family'), + fontSize: Fx.castHoverOption(trace, ptNumber, 'font.size'), + fontColor: Fx.castHoverOption(trace, ptNumber, 'font.color') }, { container: this.svgContainer }); diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index bf35acc3902..eb108e89899 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'); @@ -68,6 +68,7 @@ function render(scene) { var pdata = project(scene.glplot.cameraParams, selection.dataCoordinate); trace = lastPicked.data; var hoverinfo = trace.hoverinfo; + var ptNumber = selection.index; var xVal = formatter('xaxis', selection.traceCoordinate[0]), yVal = formatter('yaxis', selection.traceCoordinate[1]), @@ -91,7 +92,11 @@ function render(scene) { zLabel: zVal, text: selection.textLabel, name: lastPicked.name, - color: lastPicked.color + color: Fx.castHoverOption(trace, ptNumber, 'bgcolor') || lastPicked.color, + borderColor: Fx.castHoverOption(trace, ptNumber, 'bordercolor'), + fontFamily: Fx.castHoverOption(trace, ptNumber, 'font.family'), + fontSize: Fx.castHoverOption(trace, ptNumber, 'font.size'), + fontColor: Fx.castHoverOption(trace, ptNumber, 'font.color') }, { container: svgContainer }); @@ -99,13 +104,13 @@ function render(scene) { var eventData = { points: [{ - x: xVal, - y: yVal, - z: zVal, + x: selection.traceCoordinate[0], + y: selection.traceCoordinate[1], + z: selection.traceCoordinate[2], data: trace._input, fullData: trace, curveNumber: trace.index, - pointNumber: selection.data.index + pointNumber: ptNumber }] }; 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 0e243c4ab69..4efb546fa1d 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -846,6 +846,11 @@ plots.supplyTraceDefaults = function(traceIn, traceOutIndex, layout, traceInInde coerce('legendgroup'); } + Registry.getComponentMethod( + 'fx', + 'supplyDefaults' + )(traceIn, traceOut, defaultColor, layout); + // TODO add per-base-plot-module trace defaults step if(_module) _module.supplyDefaults(traceIn, traceOut, defaultColor, layout); @@ -969,8 +974,15 @@ plots.supplyLayoutGlobalDefaults = function(layoutIn, layoutOut) { coerce('hidesources'); coerce('smith'); - var handleCalendarDefaults = Registry.getComponentMethod('calendars', 'handleDefaults'); - handleCalendarDefaults(layoutIn, layoutOut, 'calendar'); + Registry.getComponentMethod( + 'calendars', + 'handleDefaults' + )(layoutIn, layoutOut, 'calendar'); + + Registry.getComponentMethod( + 'fx', + 'supplyLayoutGlobalDefaults' + )(layoutIn, layoutOut, coerce); }; plots.plotAutoSize = function plotAutoSize(gd, layout, fullLayout) { @@ -1107,9 +1119,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]]; @@ -2075,6 +2084,8 @@ plots.doCalcdata = function(gd, traces) { calcdata[i] = cd; } + Registry.getComponentMethod('fx', 'calc')(gd); + // To handle the case of components using category names as coordinates, we // need to re-supply defaults for these objects now, after calc has // finished populating the category mappings 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/contourgl/convert.js b/src/traces/contourgl/convert.js index 2c3ef01984c..182ec900b7f 100644 --- a/src/traces/contourgl/convert.js +++ b/src/traces/contourgl/convert.js @@ -85,6 +85,7 @@ proto.handlePick = function(pickResult) { proto.update = function(fullTrace, calcTrace) { var calcPt = calcTrace[0]; + this.index = fullTrace.index; this.name = fullTrace.name; this.hoverinfo = fullTrace.hoverinfo; 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/heatmapgl/convert.js b/src/traces/heatmapgl/convert.js index 5bdeed828c8..fbac9353f3e 100644 --- a/src/traces/heatmapgl/convert.js +++ b/src/traces/heatmapgl/convert.js @@ -71,6 +71,7 @@ proto.handlePick = function(pickResult) { proto.update = function(fullTrace, calcTrace) { var calcPt = calcTrace[0]; + this.index = fullTrace.index; this.name = fullTrace.name; this.hoverinfo = fullTrace.hoverinfo; diff --git a/src/traces/mesh3d/convert.js b/src/traces/mesh3d/convert.js index dee2f0647ca..ddbf71feaca 100644 --- a/src/traces/mesh3d/convert.js +++ b/src/traces/mesh3d/convert.js @@ -32,7 +32,7 @@ var proto = Mesh3DTrace.prototype; proto.handlePick = function(selection) { if(selection.object === this.mesh) { - var selectIndex = selection.data.index; + var selectIndex = selection.index = selection.data.index; selection.traceCoordinate = [ this.data.x[selectIndex], diff --git a/src/traces/pie/plot.js b/src/traces/pie/plot.js index 124e96368e3..322618e271d 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'); @@ -124,14 +124,20 @@ module.exports = function plot(gd, cdpie) { if(hoverinfo.indexOf('value') !== -1) thisText.push(helpers.formatPieValue(pt.v, separators)); if(hoverinfo.indexOf('percent') !== -1) thisText.push(helpers.formatPiePercent(pt.v / cd0.vTotal, separators)); + var hoverLabelOpts = trace2.hoverlabel; + Fx.loneHover({ x0: hoverCenterX - rInscribed * cd0.r, x1: hoverCenterX + rInscribed * cd0.r, y: hoverCenterY, text: thisText.join('
'), name: hoverinfo.indexOf('name') !== -1 ? trace2.name : undefined, - color: pt.color, - idealAlign: pt.pxmid[0] < 0 ? 'left' : 'right' + idealAlign: pt.pxmid[0] < 0 ? 'left' : 'right', + color: pt.hbg || hoverLabelOpts.bgcolor || pt.color, + borderColor: pt.hbc || hoverLabelOpts.bordercolor, + fontFamily: pt.htf || hoverLabelOpts.font.family, + fontSize: pt.hts || hoverLabelOpts.font.size, + fontColor: pt.htc || hoverLabelOpts.font.color }, { container: fullLayout2._hoverlayer.node(), outerContainer: fullLayout2._paper.node() diff --git a/src/traces/pointcloud/convert.js b/src/traces/pointcloud/convert.js index 95ac5461cb4..98c25b5b668 100644 --- a/src/traces/pointcloud/convert.js +++ b/src/traces/pointcloud/convert.js @@ -48,7 +48,6 @@ function Pointcloud(scene, uid) { var proto = Pointcloud.prototype; proto.handlePick = function(pickResult) { - var index = this.idToIndex[pickResult.pointId]; // prefer the readout from XY, if present @@ -69,7 +68,7 @@ proto.handlePick = function(pickResult) { }; proto.update = function(options) { - + this.index = options.index; this.textLabels = options.text; this.name = options.name; this.hoverinfo = options.hoverinfo; diff --git a/src/traces/sankey/plot.js b/src/traces/sankey/plot.js index 7b0ce67e60e..69038858a9b 100644 --- a/src/traces/sankey/plot.js +++ b/src/traces/sankey/plot.js @@ -8,10 +8,11 @@ 'use strict'; -var render = require('./render'); -var Fx = require('../../plots/cartesian/graph_interact'); var d3 = require('d3'); +var render = require('./render'); +var Fx = require('../../components/fx'); var Color = require('../../components/color'); +var Lib = require('../../lib'); function renderableValuePresent(d) {return d !== '';} @@ -106,6 +107,13 @@ function linkNonHoveredStyle(d, sankey, visitNodes, sankeyLink) { } } +// does not support array values for now +function castHoverOption(trace, attr) { + var labelOpts = trace.hoverlabel || {}; + var val = Lib.nestedProperty(labelOpts, attr).get(); + return Array.isArray(val) ? false : val; +} + module.exports = function plot(gd, calcData) { var fullLayout = gd._fullLayout; @@ -124,7 +132,7 @@ module.exports = function plot(gd, calcData) { }; var linkHoverFollow = function(element, d) { - + var trace = gd._fullData[d.traceId]; var rootBBox = gd.getBoundingClientRect(); var boundingBox = element.getBoundingClientRect(); var hoverCenterX = boundingBox.left + boundingBox.width / 2; @@ -139,7 +147,11 @@ module.exports = function plot(gd, calcData) { ['Source:', d.link.source.label].join(' '), ['Target:', d.link.target.label].join(' ') ].filter(renderableValuePresent).join('
'), - color: Color.addOpacity(d.tinyColorHue, 1), + color: castHoverOption(trace, 'bgcolor') || Color.addOpacity(d.tinyColorHue, 1), + borderColor: castHoverOption(trace, 'bordercolor'), + fontFamily: castHoverOption(trace, 'font.family'), + fontSize: castHoverOption(trace, 'font.size'), + fontColor: castHoverOption(trace, 'font.color'), idealAlign: d3.event.x < hoverCenterX ? 'right' : 'left' }, { container: fullLayout._hoverlayer.node(), @@ -172,7 +184,7 @@ module.exports = function plot(gd, calcData) { }; var nodeHoverFollow = function(element, d) { - + var trace = gd._fullData[d.traceId]; var nodeRect = d3.select(element).select('.nodeRect'); var rootBBox = gd.getBoundingClientRect(); var boundingBox = nodeRect.node().getBoundingClientRect(); @@ -190,7 +202,11 @@ module.exports = function plot(gd, calcData) { ['Incoming flow count:', d.node.targetLinks.length].join(' '), ['Outgoing flow count:', d.node.sourceLinks.length].join(' ') ].filter(renderableValuePresent).join('
'), - color: d.tinyColorHue, + color: castHoverOption(trace, 'bgcolor') || d.tinyColorHue, + borderColor: castHoverOption(trace, 'bordercolor'), + fontFamily: castHoverOption(trace, 'font.family'), + fontSize: castHoverOption(trace, 'font.size'), + fontColor: castHoverOption(trace, 'font.color'), idealAlign: 'left' }, { container: fullLayout._hoverlayer.node(), 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/scatter3d/convert.js b/src/traces/scatter3d/convert.js index f491d2b057f..0048b841e1a 100644 --- a/src/traces/scatter3d/convert.js +++ b/src/traces/scatter3d/convert.js @@ -67,7 +67,7 @@ proto.handlePick = function(selection) { } else selection.textLabel = ''; - var selectIndex = selection.data.index; + var selectIndex = selection.index = selection.data.index; selection.traceCoordinate = [ this.data.x[selectIndex], this.data.y[selectIndex], 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/convert.js b/src/traces/scattermapbox/convert.js index e4c17f91973..01312110dae 100644 --- a/src/traces/scattermapbox/convert.js +++ b/src/traces/scattermapbox/convert.js @@ -200,7 +200,10 @@ function makeCircleGeoJSON(calcTrace, hash) { if(isBADNUM(lonlat)) continue; var props = {}; - if(colorFn) translate(props, COLOR_PROP, colorFn(calcPt.mc), i); + if(colorFn) { + var mcc = calcPt.mcc = colorFn(calcPt.mc); + translate(props, COLOR_PROP, mcc, i); + } if(sizeFn) translate(props, SIZE_PROP, sizeFn(calcPt.ms), i); features.push({ 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/src/traces/surface/convert.js b/src/traces/surface/convert.js index 1a352a9df6e..90487268a75 100644 --- a/src/traces/surface/convert.js +++ b/src/traces/surface/convert.js @@ -33,7 +33,7 @@ var proto = SurfaceTrace.prototype; proto.handlePick = function(selection) { if(selection.object === this.surface) { - var selectIndex = [ + var selectIndex = selection.index = [ Math.min( Math.round(selection.data.index[0] / this.dataScale - 1)|0, this.data.z[0].length - 1 diff --git a/src/traces/surface/index.js b/src/traces/surface/index.js index 8a9d1efb156..03e64f5762e 100644 --- a/src/traces/surface/index.js +++ b/src/traces/surface/index.js @@ -20,7 +20,7 @@ Surface.plot = require('./convert'); Surface.moduleType = 'trace'; Surface.name = 'surface'; Surface.basePlotModule = require('../../plots/gl3d'); -Surface.categories = ['gl3d', 'noOpacity']; +Surface.categories = ['gl3d', '2dMap', 'noOpacity']; Surface.meta = { description: [ 'The data the describes the coordinates of the surface is set in `z`.', diff --git a/tasks/test_syntax.js b/tasks/test_syntax.js index 150cac24f4c..577e099fb86 100644 --- a/tasks/test_syntax.js +++ b/tasks/test_syntax.js @@ -176,9 +176,7 @@ 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; if(circularDeps.length > MAX_ALLOWED_CIRCULAR_DEPS) { diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index 9bd99ef9d5b..edaf854741b 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..3ffea51d51c 100644 --- a/test/jasmine/tests/fx_test.js +++ b/test/jasmine/tests/fx_test.js @@ -1,74 +1,186 @@ var Plotly = require('@lib/index'); var Plots = require('@src/plots/plots'); -var Fx = require('@src/plots/cartesian/graph_interact'); - var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); - describe('Fx defaults', function() { 'use strict'; - var layoutIn, layoutOut, fullData; + function _supply(data, layout) { + var gd = { + data: data || [], + layout: layout || {} + }; - beforeEach(function() { - layoutIn = {}; - layoutOut = { - _has: Plots._hasPlotType + Plots.supplyDefaults(gd); + + return { + data: gd._fullData, + layout: gd._fullLayout }; - fullData = [{}]; - }); + } it('should default (blank version)', function() { - Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData); + var layoutOut = _supply().layout; expect(layoutOut.hovermode).toBe('closest', 'hovermode to closest'); expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom'); }); it('should default (cartesian version)', function() { - layoutOut._basePlotModules = [{ name: 'cartesian' }]; + var layoutOut = _supply([{ + type: 'bar', + y: [1, 2, 1] + }]) + .layout; - Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData); expect(layoutOut.hovermode).toBe('x', 'hovermode to x'); expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom'); expect(layoutOut._isHoriz).toBe(false, 'isHoriz to false'); }); it('should default (cartesian horizontal version)', function() { - layoutOut._basePlotModules = [{ name: 'cartesian' }]; - fullData[0] = { orientation: 'h' }; + var layoutOut = _supply([{ + type: 'bar', + orientation: 'h', + x: [1, 2, 3], + y: [1, 2, 1] + }]) + .layout; - Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData); expect(layoutOut.hovermode).toBe('y', 'hovermode to y'); expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom'); expect(layoutOut._isHoriz).toBe(true, 'isHoriz to true'); }); it('should default (gl3d version)', function() { - layoutOut._basePlotModules = [{ name: 'gl3d' }]; + var layoutOut = _supply([{ + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [1, 2, 1] + }]) + .layout; - Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData); expect(layoutOut.hovermode).toBe('closest', 'hovermode to closest'); expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom'); }); it('should default (geo version)', function() { - layoutOut._basePlotModules = [{ name: 'geo' }]; + var layoutOut = _supply([{ + type: 'scattergeo', + lon: [1, 2, 3], + lat: [1, 2, 3] + }]) + .layout; - Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData); expect(layoutOut.hovermode).toBe('closest', 'hovermode to closest'); expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom'); }); it('should default (multi plot type version)', function() { - layoutOut._basePlotModules = [{ name: 'cartesian' }, { name: 'gl3d' }]; + var layoutOut = _supply([{ + type: 'bar', + y: [1, 2, 1] + }, { + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [1, 2, 1] + }]) + .layout; - Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData); expect(layoutOut.hovermode).toBe('x', 'hovermode to x'); expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom'); }); + + it('should coerce trace and annotations hoverlabel using global as defaults', function() { + var out = _supply([{ + type: 'bar', + y: [1, 2, 1], + hoverlabel: { + bgcolor: ['red', 'blue', 'black'], + font: { size: 40 } + } + }, { + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [1, 2, 1], + hoverlabel: { + bordercolor: 'yellow', + font: { color: 'red' } + } + }], { + annotations: [{ + x: 0, + y: 1, + text: '1', + hovertext: '1' + }, { + x: 2, + y: 1, + text: '2', + hovertext: '2', + hoverlabel: { + bgcolor: 'red', + font: { + family: 'Gravitas' + } + } + }], + hoverlabel: { + bgcolor: 'white', + bordercolor: 'black', + font: { + family: 'Roboto', + size: 20, + color: 'pink' + } + } + }); + + expect(out.data[0].hoverlabel).toEqual({ + bgcolor: ['red', 'blue', 'black'], + bordercolor: 'black', + font: { + family: 'Roboto', + size: 40, + color: 'pink' + } + }); + + expect(out.data[1].hoverlabel).toEqual({ + bgcolor: 'white', + bordercolor: 'yellow', + font: { + family: 'Roboto', + size: 20, + color: 'red' + } + }); + + expect(out.layout.annotations[0].hoverlabel).toEqual({ + bgcolor: 'white', + bordercolor: 'black', + font: { + family: 'Roboto', + size: 20, + color: 'pink' + } + }); + + expect(out.layout.annotations[1].hoverlabel).toEqual({ + bgcolor: 'red', + bordercolor: 'black', + font: { + family: 'Gravitas', + size: 20, + color: 'pink' + } + }); + }); }); describe('relayout', function() { diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index b783fda1c45..bccd6ae3425 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) { @@ -503,6 +502,21 @@ describe('Test geo interactions', function() { }) .then(done); }); + + it('should show custom \`hoverlabel\' settings', function(done) { + Plotly.restyle(gd, { + 'hoverlabel.bgcolor': 'red', + 'hoverlabel.bordercolor': [['blue', 'black', 'green']] + }) + .then(function() { + mouseEventScatterGeo('mousemove'); + + var path = d3.selectAll('g.hovertext').select('path'); + expect(path.style('fill')).toEqual('rgb(255, 0, 0)', 'bgcolor'); + expect(path.style('stroke')).toEqual('rgb(0, 0, 255)', 'bordecolor[0]'); + }) + .then(done); + }); }); describe('scattergeo hover events', function() { diff --git a/test/jasmine/tests/gl2d_click_test.js b/test/jasmine/tests/gl2d_click_test.js index ac6df004e25..37067efedef 100644 --- a/test/jasmine/tests/gl2d_click_test.js +++ b/test/jasmine/tests/gl2d_click_test.js @@ -1,6 +1,7 @@ var Plotly = require('@lib/index'); var Lib = require('@src/lib'); +var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var customMatchers = require('../assets/custom_matchers'); @@ -27,7 +28,7 @@ var mock3 = { [10, 10.625, 12.5, 15.625, 20], [5.625, 6.25, 8.125, 11.25, 15.625], [2.5, 3.125, 5, 8.125, 12.5], - [0.625, 1.25, 3.125, 6.25, 10.625], + [0.625, 1.25, 3.125, 20, 10.625], [0, 0.625, 2.5, 5.625, 10] ], colorscale: 'Jet', @@ -128,6 +129,22 @@ describe('Test hover and click interactions', function() { expect(pt.pointNumber).toEqual(expected.pointNumber, 'point number'); } + function assertHoverLabelStyle(sel, expected) { + if(sel.node() === null) { + expect(expected.noHoverLabel).toBe(true); + return; + } + + var path = sel.select('path'); + expect(path.style('fill')).toEqual(expected.bgColor, 'bgcolor'); + expect(path.style('stroke')).toEqual(expected.borderColor, 'bordercolor'); + + var text = sel.select('text.nums'); + expect(parseInt(text.style('font-size'))).toEqual(expected.fontSize, 'font.size'); + expect(text.style('font-family').split(',')[0]).toEqual(expected.fontFamily, 'font.family'); + expect(text.style('fill')).toEqual(expected.fontColor, 'font.color'); + } + // returns basic hover/click/unhover runner for one xy position function makeRunner(pos, expected, opts) { opts = opts || {}; @@ -144,6 +161,7 @@ describe('Test hover and click interactions', function() { .then(_hover) .then(function(eventData) { assertEventData(eventData, expected); + assertHoverLabelStyle(d3.select('g.hovertext'), expected); }) .then(_click) .then(function(eventData) { @@ -171,11 +189,28 @@ describe('Test hover and click interactions', function() { it('should output correct event data for scattergl', function(done) { var _mock = Lib.extendDeep({}, mock1); + + _mock.layout.hoverlabel = { + font: { + size: 20, + color: 'yellow' + } + }; + _mock.data[0].hoverlabel = { + bgcolor: 'blue', + bordercolor: _mock.data[0].x.map(function(_, i) { return i % 2 ? 'red' : 'green'; }) + }; + var run = makeRunner([655, 317], { x: 15.772, y: 0.387, curveNumber: 0, - pointNumber: 33 + pointNumber: 33, + bgColor: 'rgb(0, 0, 255)', + borderColor: 'rgb(255, 0, 0)', + fontSize: 20, + fontFamily: 'Arial', + fontColor: 'rgb(255, 255, 0)' }); Plotly.plot(gd, _mock) @@ -192,7 +227,8 @@ describe('Test hover and click interactions', function() { x: 15.772, y: 0.387, curveNumber: 0, - pointNumber: 33 + pointNumber: 33, + noHoverLabel: true }); Plotly.plot(gd, _mock) @@ -204,11 +240,21 @@ describe('Test hover and click interactions', function() { it('should output correct event data for pointcloud', function(done) { var _mock = Lib.extendDeep({}, mock2); + _mock.layout.hoverlabel = { font: {size: 8} }; + _mock.data[2].hoverlabel = { + bgcolor: ['red', 'green', 'blue'] + }; + var run = makeRunner([540, 150], { x: 4.5, y: 9, curveNumber: 2, - pointNumber: 1 + pointNumber: 1, + bgColor: 'rgb(0, 128, 0)', + borderColor: 'rgb(255, 255, 255)', + fontSize: 8, + fontFamily: 'Arial', + fontColor: 'rgb(255, 255, 255)' }); Plotly.plot(gd, _mock) @@ -221,11 +267,24 @@ describe('Test hover and click interactions', function() { var _mock = Lib.extendDeep({}, mock3); _mock.data[0].type = 'heatmapgl'; + _mock.data[0].hoverlabel = { + font: { size: _mock.data[0].z } + }; + + _mock.layout.hoverlabel = { + font: { family: 'Roboto' } + }; + var run = makeRunner([540, 150], { x: 3, y: 3, curveNumber: 0, - pointNumber: [3, 3] + pointNumber: [3, 3], + bgColor: 'rgb(68, 68, 68)', + borderColor: 'rgb(255, 255, 255)', + fontSize: 20, + fontFamily: 'Roboto', + fontColor: 'rgb(255, 255, 255)' }, { noUnHover: true }); @@ -243,7 +302,12 @@ describe('Test hover and click interactions', function() { x: 8, y: 18, curveNumber: 2, - pointNumber: 0 + pointNumber: 0, + bgColor: 'rgb(44, 160, 44)', + borderColor: 'rgb(255, 255, 255)', + fontSize: 13, + fontFamily: 'Arial', + fontColor: 'rgb(255, 255, 255)' }); // after the restyle, autorange changes the y range @@ -251,7 +315,12 @@ describe('Test hover and click interactions', function() { x: 8, y: 18, curveNumber: 2, - pointNumber: 0 + pointNumber: 0, + bgColor: 'rgb(255, 127, 14)', + borderColor: 'rgb(68, 68, 68)', + fontSize: 13, + fontFamily: 'Arial', + fontColor: 'rgb(68, 68, 68)' }); Plotly.plot(gd, _mock) @@ -274,7 +343,12 @@ describe('Test hover and click interactions', function() { x: 8, y: 18, curveNumber: 2, - pointNumber: 0 + pointNumber: 0, + bgColor: 'rgb(44, 160, 44)', + borderColor: 'rgb(255, 255, 255)', + fontSize: 13, + fontFamily: 'Arial', + fontColor: 'rgb(255, 255, 255)' }); // after the restyle, autorange changes the x AND y ranges @@ -285,7 +359,12 @@ describe('Test hover and click interactions', function() { x: 8, y: 18, curveNumber: 2, - pointNumber: 0 + pointNumber: 0, + bgColor: 'rgb(255, 127, 14)', + borderColor: 'rgb(68, 68, 68)', + fontSize: 13, + fontFamily: 'Arial', + fontColor: 'rgb(68, 68, 68)' }); Plotly.plot(gd, _mock) @@ -301,11 +380,20 @@ describe('Test hover and click interactions', function() { it('should output correct event data contourgl', function(done) { var _mock = Lib.extendDeep({}, mock3); + _mock.data[0].hoverlabel = { + font: { size: _mock.data[0].z } + }; + var run = makeRunner([540, 150], { x: 3, y: 3, curveNumber: 0, - pointNumber: [3, 3] + pointNumber: [3, 3], + bgColor: 'rgb(68, 68, 68)', + borderColor: 'rgb(255, 255, 255)', + fontSize: 20, + fontFamily: 'Arial', + fontColor: 'rgb(255, 255, 255)' }, { noUnHover: true }); diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js index dc5a0d70ea9..a20a1dd0fb9 100644 --- a/test/jasmine/tests/gl_plot_interact_test.js +++ b/test/jasmine/tests/gl_plot_interact_test.js @@ -42,9 +42,7 @@ describe('Test gl3d plots', function() { mock2.data[0].surfaceaxis = 2; mock2.layout.showlegend = true; - function mouseEventScatter3d(type, opts) { - mouseEvent(type, 605, 271, opts); - } + var mock3 = require('@mocks/gl3d_autocolorscale'); function assertHoverText(xLabel, yLabel, zLabel, textLabel) { var node = d3.selectAll('g.hovertext'); @@ -60,6 +58,19 @@ describe('Test gl3d plots', function() { } } + function assertHoverLabelStyle(bgColor, borderColor, fontSize, fontFamily, fontColor) { + var node = d3.selectAll('g.hovertext'); + + var path = node.select('path'); + expect(path.style('fill')).toEqual(bgColor, 'bgcolor'); + expect(path.style('stroke')).toEqual(borderColor, 'bordercolor'); + + var text = node.select('text.nums'); + expect(parseInt(text.style('font-size'))).toEqual(fontSize, 'font.size'); + expect(text.style('font-family').split(',')[0]).toEqual(fontFamily, 'font.family'); + expect(text.style('fill')).toEqual(fontColor, 'font.color'); + } + function assertEventData(x, y, z, curveNumber, pointNumber) { expect(Object.keys(ptData)).toEqual([ 'x', 'y', 'z', @@ -83,11 +94,11 @@ describe('Test gl3d plots', function() { destroyGraphDiv(); }); - it('@noCI should display correct hover labels and emit correct event data', function(done) { + it('@noCI should display correct hover labels and emit correct event data (scatter3d case)', function(done) { var _mock = Lib.extendDeep({}, mock2); function _hover() { - mouseEventScatter3d('mouseover'); + mouseEvent('mouseover', 605, 271); return delay(); } @@ -102,10 +113,11 @@ describe('Test gl3d plots', function() { .then(delay) .then(function() { assertHoverText('x: 140.72', 'y: −96.97', 'z: −96.97'); - assertEventData('140.72', '−96.97', '−96.97', 0, 2); + assertEventData(140.72, -96.97, -96.97, 0, 2); + assertHoverLabelStyle('rgb(0, 0, 255)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'); return Plotly.restyle(gd, { - x: [['2016-01-11', '2016-01-12', '2017-01-01', '2017-02']] + x: [['2016-01-11', '2016-01-12', '2017-01-01', '2017-02-01']] }); }) .then(_hover) @@ -148,18 +160,75 @@ describe('Test gl3d plots', function() { .then(_hover) .then(function() { assertHoverText('x: 二 6, 2017', 'y: c', 'z: 100k', 'Clementine'); + + return Plotly.restyle(gd, { + 'hoverlabel.bgcolor': [['red', 'blue', 'green', 'yellow']], + 'hoverlabel.font.size': 20 + }); + }) + .then(_hover) + .then(function() { + assertHoverLabelStyle('rgb(0, 128, 0)', 'rgb(255, 255, 255)', 20, 'Arial', 'rgb(255, 255, 255)'); + + return Plotly.relayout(gd, { + 'hoverlabel.bordercolor': 'yellow', + 'hoverlabel.font.color': 'cyan', + 'hoverlabel.font.family': 'Roboto' + }); + }) + .then(_hover) + .then(function() { + assertHoverLabelStyle('rgb(0, 128, 0)', 'rgb(255, 255, 0)', 20, 'Roboto', 'rgb(0, 255, 255)'); }) .then(done); + }); + + it('@noCI should display correct hover labels and emit correct event data (surface case)', function(done) { + var _mock = Lib.extendDeep({}, mock3); + function _hover() { + mouseEvent('mouseover', 605, 271); + return delay(); + } + + Plotly.plot(gd, _mock) + .then(delay) + .then(function() { + gd.on('plotly_hover', function(eventData) { + ptData = eventData.points[0]; + }); + }) + .then(_hover) + .then(delay) + .then(function() { + assertHoverText('x: 1', 'y: 2', 'z: 43', 'one two'); + assertEventData(1, 2, 43, 0, [1, 2]); + assertHoverLabelStyle('rgb(68, 68, 68)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'); + + Plotly.restyle(gd, { + 'hoverlabel.bgcolor': 'white', + 'hoverlabel.font.size': 9, + 'hoverlabel.font.color': [[ + ['red', 'blue', 'green'], + ['pink', 'purple', 'cyan'], + ['black', 'orange', 'yellow'] + ]] + }); + }) + .then(_hover) + .then(function() { + assertHoverLabelStyle('rgb(255, 255, 255)', 'rgb(68, 68, 68)', 9, 'Arial', 'rgb(0, 255, 255)'); + }) + .then(done); }); - it('@noCI should emit correct event data on click', function(done) { + it('@noCI should emit correct event data on click (scatter3d case)', function(done) { var _mock = Lib.extendDeep({}, mock2); // N.B. gl3d click events are 'mouseover' events // with button 1 pressed function _click() { - mouseEventScatter3d('mouseover', {buttons: 1}); + mouseEvent('mouseover', 605, 271, {buttons: 1}); return delay(); } @@ -173,7 +242,7 @@ describe('Test gl3d plots', function() { .then(_click) .then(delay) .then(function() { - assertEventData('140.72', '−96.97', '−96.97', 0, 2); + assertEventData(140.72, -96.97, -96.97, 0, 2); }) .then(done); }); diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index eeb0a665743..694156e6339 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'); @@ -16,11 +16,8 @@ var fail = require('../assets/fail_test'); describe('hover info', function() { 'use strict'; - var mock = require('@mocks/14.json'), - evt = { - clientX: mock.layout.width / 2, - clientY: mock.layout.height / 2 - }; + var mock = require('@mocks/14.json'); + var evt = { xpx: 355, ypx: 150 }; afterEach(destroyGraphDiv); @@ -447,7 +444,53 @@ describe('hover info', function() { done(); }); + }); + }); + + describe('\'hover info for x/y/z traces', function() { + function _hover(gd, xpx, ypx) { + Fx.hover(gd, { xpx: xpx, ypx: ypx }, 'xy'); + delete gd._lastHoverTime; + } + + function _assert(nameLabel, lines) { + expect(d3.select('g.axistext').size()).toEqual(0, 'no common label'); + + var sel = d3.select('g.hovertext'); + expect(sel.select('text.name').html()).toEqual(nameLabel, 'name label'); + sel.select('text.nums').selectAll('tspan').each(function(_, i) { + expect(d3.select(this).html()).toEqual(lines[i], 'lines ' + i); + }); + } + it('should display correct label content', function(done) { + var gd = createGraphDiv(); + + Plotly.plot(gd, [{ + type: 'heatmap', + y: [0, 1], + z: [[1, 2, 3], [2, 2, 1]], + name: 'one', + }, { + type: 'heatmap', + y: [2, 3], + z: [[1, 2, 3], [2, 2, 1]], + name: 'two' + }], { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0} + }) + .then(function() { + _hover(gd, 250, 100); + _assert('two', ['x: 1', 'y: 3', 'z: 2']); + }) + .then(function() { + _hover(gd, 250, 300); + _assert('one', ['x: 1', 'y: 1', 'z: 2']); + }) + .catch(fail) + .then(done); }); }); @@ -554,7 +597,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 +762,7 @@ describe('hover after resizing', function() { setTimeout(function() { resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -732,7 +775,7 @@ describe('hover after resizing', function() { expect(hoverText.size()).toEqual(cnt, msg); resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -799,7 +842,7 @@ describe('hover on fill', function() { expect(+transformCoords[1]).toBeCloseTo(labelPos[1], -1.2, labelText + ':y'); resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -879,7 +922,7 @@ describe('hover updates', function() { } resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -935,7 +978,7 @@ describe('hover updates', function() { mouseEvent('mousemove', 394, 285); setTimeout(function() { resolve(); - }, constants.HOVERMINTIME); + }, HOVERMINTIME); }); } @@ -984,3 +1027,187 @@ describe('hover updates', function() { }); }); + +describe('Test hover label custom styling:', function() { + afterEach(destroyGraphDiv); + + function assertLabel(className, expectation) { + var g = d3.select('g.' + className); + + var path = g.select('path'); + expect(path.style('fill')).toEqual(expectation.path[0], 'bgcolor'); + expect(path.style('stroke')).toEqual(expectation.path[1], 'bordercolor'); + + var text = g.select({hovertext: 'text.nums', axistext: 'text'}[className]); + expect(parseInt(text.style('font-size'))).toEqual(expectation.text[0], 'font.size'); + expect(text.style('font-family').split(',')[0]).toEqual(expectation.text[1], 'font.family'); + expect(text.style('fill')).toEqual(expectation.text[2], 'font.color'); + } + + function assertPtLabel(expectation) { + assertLabel('hovertext', expectation); + } + + function assertCommonLabel(expectation) { + assertLabel('axistext', expectation); + } + + function _hover(gd, opts) { + Fx.hover(gd, opts); + delete gd._lastHoverTime; + } + + it('should work for x/y cartesian traces', function(done) { + var gd = createGraphDiv(); + + Plotly.plot(gd, [{ + x: [1, 2, 3], + y: [1, 2, 1], + marker: { + color: ['yellow', 'black', 'cyan'] + }, + hoverlabel: { + font: { + color: ['red', 'green', 'blue'], + size: 20 + } + } + }], { + hovermode: 'x', + hoverlabel: { bgcolor: 'white' } + }) + .then(function() { + _hover(gd, { xval: gd._fullData[0].x[0] }); + + assertPtLabel({ + path: ['rgb(255, 255, 255)', 'rgb(68, 68, 68)'], + text: [20, 'Arial', 'rgb(255, 0, 0)'] + }); + assertCommonLabel({ + path: ['rgb(255, 255, 255)', 'rgb(255, 255, 255)'], + text: [13, 'Arial', 'rgb(255, 255, 255)'] + }); + }) + .then(function() { + _hover(gd, { xval: gd._fullData[0].x[1] }); + + assertPtLabel({ + path: ['rgb(255, 255, 255)', 'rgb(68, 68, 68)'], + text: [20, 'Arial', 'rgb(0, 128, 0)'] + }); + assertCommonLabel({ + path: ['rgb(255, 255, 255)', 'rgb(255, 255, 255)'], + text: [13, 'Arial', 'rgb(255, 255, 255)'] + }); + }) + .then(function() { + _hover(gd, { xval: gd._fullData[0].x[2] }); + + assertPtLabel({ + path: ['rgb(255, 255, 255)', 'rgb(68, 68, 68)'], + text: [20, 'Arial', 'rgb(0, 0, 255)'] + }); + assertCommonLabel({ + path: ['rgb(255, 255, 255)', 'rgb(255, 255, 255)'], + text: [13, 'Arial', 'rgb(255, 255, 255)'] + }); + + // test base case + return Plotly.update(gd, { hoverlabel: null }, { hoverlabel: null }); + }) + .then(function() { + _hover(gd, { xval: gd._fullData[0].x[0] }); + + assertPtLabel({ + path: ['rgb(255, 255, 0)', 'rgb(68, 68, 68)'], + text: [13, 'Arial', 'rgb(68, 68, 68)'] + }); + assertCommonLabel({ + path: ['rgb(68, 68, 68)', 'rgb(255, 255, 255)'], + text: [13, 'Arial', 'rgb(255, 255, 255)'] + }); + }) + .then(function() { + _hover(gd, { xval: gd._fullData[0].x[1] }); + + assertPtLabel({ + path: ['rgb(0, 0, 0)', 'rgb(255, 255, 255)'], + text: [13, 'Arial', 'rgb(255, 255, 255)'] + }); + assertCommonLabel({ + path: ['rgb(68, 68, 68)', 'rgb(255, 255, 255)'], + text: [13, 'Arial', 'rgb(255, 255, 255)'] + }); + }) + .then(function() { + _hover(gd, { xval: gd._fullData[0].x[2] }); + + assertPtLabel({ + path: ['rgb(0, 255, 255)', 'rgb(68, 68, 68)'], + text: [13, 'Arial', 'rgb(68, 68, 68)'] + }); + assertCommonLabel({ + path: ['rgb(68, 68, 68)', 'rgb(255, 255, 255)'], + text: [13, 'Arial', 'rgb(255, 255, 255)'] + }); + }) + .catch(fail) + .then(done); + }); + + it('should work for 2d z cartesian traces', function(done) { + var gd = createGraphDiv(); + + Plotly.plot(gd, [{ + type: 'heatmap', + x: [1, 2], + y: [1, 2], + z: [[1, 2], [2, 3]], + hoverlabel: { + font: { + color: 'red', + size: [[10, 20], [21, 11]] + } + } + }], { + hoverlabel: { + bordercolor: 'blue', + font: { family: 'Gravitas'} + } + }) + .then(function() { + _hover(gd, { xval: 1, yval: 1 }); + + assertPtLabel({ + path: ['rgb(68, 68, 68)', 'rgb(0, 0, 255)'], + text: [10, 'Gravitas', 'rgb(255, 0, 0)'] + }); + }) + .then(function() { + _hover(gd, { xval: 2, yval: 1 }); + + assertPtLabel({ + path: ['rgb(68, 68, 68)', 'rgb(0, 0, 255)'], + text: [20, 'Gravitas', 'rgb(255, 0, 0)'] + }); + }) + .then(function() { + _hover(gd, { xval: 1, yval: 2 }); + + assertPtLabel({ + path: ['rgb(68, 68, 68)', 'rgb(0, 0, 255)'], + text: [21, 'Gravitas', 'rgb(255, 0, 0)'] + }); + }) + .then(function() { + _hover(gd, { xval: 2, yval: 2 }); + + assertPtLabel({ + path: ['rgb(68, 68, 68)', 'rgb(0, 0, 255)'], + text: [11, 'Gravitas', 'rgb(255, 0, 0)'] + }); + }) + .catch(fail) + .then(done); + }); +}); diff --git a/test/jasmine/tests/hover_pie_test.js b/test/jasmine/tests/hover_pie_test.js index b12b9194c54..464ece53993 100644 --- a/test/jasmine/tests/hover_pie_test.js +++ b/test/jasmine/tests/hover_pie_test.js @@ -187,23 +187,39 @@ describe('pie hovering', function() { function _hover() { mouseEvent('mouseover', 223, 143); + delete gd._lastHoverTime; } - function assertLabel(expected) { - var labels = d3.selectAll('.hovertext .nums .line'); + function assertLabel(content, style) { + var g = d3.selectAll('.hovertext'); + var lines = g.selectAll('.nums .line'); - expect(labels.size()).toBe(expected.length); + expect(lines.size()).toBe(content.length); - labels.each(function(_, i) { - expect(d3.select(this).text()).toBe(expected[i]); + lines.each(function(_, i) { + expect(d3.select(this).text()).toBe(content[i]); }); + + if(style) { + var path = g.select('path'); + expect(path.style('fill')).toEqual(style[0], 'bgcolor'); + expect(path.style('stroke')).toEqual(style[1], 'bordercolor'); + + var text = g.select('text.nums'); + expect(parseInt(text.style('font-size'))).toEqual(style[2], 'font.size'); + expect(text.style('font-family').split(',')[0]).toEqual(style[3], 'font.family'); + expect(text.style('fill')).toEqual(style[4], 'font.color'); + } } it('should show the default selected values', function(done) { Plotly.plot(gd, mockCopy.data, mockCopy.layout) .then(_hover) .then(function() { - assertLabel(['4', '5', '33.3%']); + assertLabel( + ['4', '5', '33.3%'], + ['rgb(31, 119, 180)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'] + ); return Plotly.restyle(gd, 'text', [['A', 'B', 'C', 'D', 'E']]); }) @@ -224,7 +240,23 @@ describe('pie hovering', function() { .then(_hover) .then(function() { assertLabel(['4', 'SUP', '5', '33.3%']); + + return Plotly.restyle(gd, { + 'hoverlabel.bgcolor': [['red', 'green', 'blue']], + 'hoverlabel.bordercolor': 'yellow', + 'hoverlabel.font.size': [[15, 20, 30]], + 'hoverlabel.font.family': 'Roboto', + 'hoverlabel.font.color': 'blue' + }); + }) + .then(_hover) + .then(function() { + assertLabel( + ['4', 'SUP', '5', '33.3%'], + ['rgb(255, 0, 0)', 'rgb(255, 255, 0)', 15, 'Roboto', 'rgb(0, 0, 255)'] + ); }) + .catch(fail) .then(done); }); 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/mapbox_test.js b/test/jasmine/tests/mapbox_test.js index ae866723a75..d0a155bc72d 100644 --- a/test/jasmine/tests/mapbox_test.js +++ b/test/jasmine/tests/mapbox_test.js @@ -709,6 +709,22 @@ describe('@noCI, mapbox plots', function() { assertMouseMove(blankPos, 0).then(function() { return assertMouseMove(pointPos, 1); }) + .then(function() { + return Plotly.restyle(gd, { + 'hoverlabel.bgcolor': 'yellow', + 'hoverlabel.font.size': [[20, 10, 30]] + }); + }) + .then(function() { + return assertMouseMove(pointPos, 1); + }) + .then(function() { + var path = d3.select('g.hovertext').select('path'); + var text = d3.select('g.hovertext').select('text.nums'); + + expect(path.style('fill')).toEqual('rgb(255, 255, 0)', 'bgcolor'); + expect(text.style('font-size')).toEqual('20px', 'font.size[0]'); + }) .catch(failTest) .then(done); }, LONG_TIMEOUT_INTERVAL); diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index da617cbfdd1..24337538273 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -254,7 +254,7 @@ describe('Test plot api', function() { }); }); - describe('Plotly.restyle', function() { + describe('Plotly.restyle subroutines switchboard', function() { beforeEach(function() { spyOn(PlotlyInternal, 'plot'); spyOn(Plots, 'previousPromises'); @@ -330,6 +330,36 @@ describe('Test plot api', function() { expect(PlotlyInternal.plot).toHaveBeenCalled(); }); + it('should do full replot when arrayOk base attributes are updated', function() { + var gd = { + data: [{x: [1, 2, 3], y: [1, 2, 3]}], + layout: {} + }; + + mockDefaultsAndCalc(gd); + Plotly.restyle(gd, 'hoverlabel.bgcolor', [['red', 'green', 'blue']]); + expect(gd.calcdata).toBeUndefined(); + expect(PlotlyInternal.plot).toHaveBeenCalled(); + + mockDefaultsAndCalc(gd); + PlotlyInternal.plot.calls.reset(); + Plotly.restyle(gd, 'hoverlabel.bgcolor', 'yellow'); + expect(gd.calcdata).toBeUndefined(); + expect(PlotlyInternal.plot).toHaveBeenCalled(); + + mockDefaultsAndCalc(gd); + PlotlyInternal.plot.calls.reset(); + Plotly.restyle(gd, 'hoverlabel.bgcolor', 'blue'); + expect(gd.calcdata).toBeDefined(); + expect(PlotlyInternal.plot).not.toHaveBeenCalled(); + + mockDefaultsAndCalc(gd); + PlotlyInternal.plot.calls.reset(); + Plotly.restyle(gd, 'hoverlabel.bgcolor', [['red', 'blue', 'green']]); + expect(gd.calcdata).toBeUndefined(); + expect(PlotlyInternal.plot).toHaveBeenCalled(); + }); + it('should do full replot when attribute container are updated', function() { var gd = { data: [{x: [1, 2, 3], y: [1, 2, 3]}], diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index a03da2c9c59..3ecd6b24089 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -9,6 +9,7 @@ var Sankey = require('@src/traces/sankey'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); var mouseEvent = require('../assets/mouse_event'); describe('sankey tests', function() { @@ -257,7 +258,6 @@ describe('sankey tests', function() { }); describe('lifecycle methods', function() { - afterEach(destroyGraphDiv); it('Plotly.deleteTraces with two traces removes the deleted plot', function(done) { @@ -311,32 +311,103 @@ describe('sankey tests', function() { done(); }); }); + }); - it('Plotly.plot shows and removes tooltip on node, link', function(done) { + describe('Test hover/click interactions:', function() { + afterEach(destroyGraphDiv); - var gd = createGraphDiv(); - var mockCopy = Lib.extendDeep({}, mock); + function assertLabel(content, style) { + var g = d3.selectAll('.hovertext'); + var lines = g.selectAll('.nums .line'); + var name = g.selectAll('.name'); - Plotly.plot(gd, mockCopy) - .then(function() { + expect(lines.size()).toBe(content.length - 1); - mouseEvent('mousemove', 400, 300); - mouseEvent('mouseover', 400, 300); + lines.each(function(_, i) { + expect(d3.select(this).text()).toBe(content[i]); + }); + + expect(name.text()).toBe(content[content.length - 1]); + + var path = g.select('path'); + expect(path.style('fill')).toEqual(style[0], 'bgcolor'); + expect(path.style('stroke')).toEqual(style[1], 'bordercolor'); - window.setTimeout(function() { - expect(d3.select('.hoverlayer>.hovertext>text').node().innerHTML) - .toEqual('447TWh', 'tooltip present'); + var text = g.select('text.nums'); + expect(parseInt(text.style('font-size'))).toEqual(style[2], 'font.size'); + expect(text.style('font-family').split(',')[0]).toEqual(style[3], 'font.family'); + expect(text.style('fill')).toEqual(style[4], 'font.color'); + } - mouseEvent('mousemove', 450, 300); - mouseEvent('mouseover', 450, 300); + it('should shows the correct hover labels', function(done) { + var gd = createGraphDiv(); + var mockCopy = Lib.extendDeep({}, mock); - window.setTimeout(function() { - expect(d3.select('.hoverlayer>.hovertext>text').node().innerHTML) - .toEqual('46TWh', 'tooltip jumped to link'); - done(); - }, 60); - }, 60); + function _hover(px, py) { + mouseEvent('mousemove', px, py); + mouseEvent('mouseover', px, py); + delete gd._lastHoverTime; + } + + Plotly.plot(gd, mockCopy).then(function() { + _hover(400, 300); + + assertLabel( + ['Solid', 'Incoming flow count: 4', 'Outgoing flow count: 3', '447TWh'], + ['rgb(148, 103, 189)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'] + ); + }) + .then(function() { + _hover(450, 300); + + assertLabel( + ['Source: Solid', 'Target: Industry', '46TWh'], + ['rgb(0, 0, 96)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'] + ); + + return Plotly.relayout(gd, 'hoverlabel.font.family', 'Roboto'); + }) + .then(function() { + _hover(400, 300); + + assertLabel( + ['Solid', 'Incoming flow count: 4', 'Outgoing flow count: 3', '447TWh'], + ['rgb(148, 103, 189)', 'rgb(255, 255, 255)', 13, 'Roboto', 'rgb(255, 255, 255)'] + ); + }) + .then(function() { + _hover(450, 300); + + assertLabel( + ['Source: Solid', 'Target: Industry', '46TWh'], + ['rgb(0, 0, 96)', 'rgb(255, 255, 255)', 13, 'Roboto', 'rgb(255, 255, 255)'] + ); + + return Plotly.restyle(gd, { + 'hoverlabel.bgcolor': 'red', + 'hoverlabel.bordercolor': 'blue', + 'hoverlabel.font.size': 20, + 'hoverlabel.font.color': 'black' }); + }) + .then(function() { + _hover(400, 300); + + assertLabel( + ['Solid', 'Incoming flow count: 4', 'Outgoing flow count: 3', '447TWh'], + ['rgb(255, 0, 0)', 'rgb(0, 0, 255)', 20, 'Roboto', 'rgb(0, 0, 0)'] + ); + }) + .then(function() { + _hover(450, 300); + + assertLabel( + ['Source: Solid', 'Target: Industry', '46TWh'], + ['rgb(255, 0, 0)', 'rgb(0, 0, 255)', 20, 'Roboto', 'rgb(0, 0, 0)'] + ); + }) + .catch(fail) + .then(done); }); }); }); diff --git a/test/jasmine/tests/scattermapbox_test.js b/test/jasmine/tests/scattermapbox_test.js index 7670ca1d8f2..6b65c668f95 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) { @@ -490,6 +490,24 @@ describe('@noCI scattermapbox hover', function() { done(); }); }); + + it('should generate hover label (\'marker.color\' array case)', function(done) { + Plotly.restyle(gd, 'marker.color', [['red', 'blue', 'green']]).then(function() { + var out = hoverPoints(getPointData(gd), 11, 11)[0]; + + expect(out.color).toEqual('red'); + }) + .then(done); + }); + + it('should generate hover label (\'marker.color\' w/ colorscale case)', function(done) { + Plotly.restyle(gd, 'marker.color', [[10, 5, 30]]).then(function() { + var out = hoverPoints(getPointData(gd), 11, 11)[0]; + + expect(out.color).toEqual('rgb(245, 195, 157)'); + }) + .then(done); + }); }); diff --git a/test/jasmine/tests/ternary_test.js b/test/jasmine/tests/ternary_test.js index 4eb9e428cc1..741d45d511b 100644 --- a/test/jasmine/tests/ternary_test.js +++ b/test/jasmine/tests/ternary_test.js @@ -106,7 +106,7 @@ describe('ternary plots', function() { }).then(done); }); - it('should display to hover labels', function() { + it('should display to hover labels', function(done) { var hoverLabels; mouseEvent('mousemove', blankPos[0], blankPos[1]); @@ -121,6 +121,22 @@ describe('ternary plots', function() { expect(rows[0][0].innerHTML).toEqual('Component A: 0.5', 'with correct text'); expect(rows[0][1].innerHTML).toEqual('B: 0.25', 'with correct text'); expect(rows[0][2].innerHTML).toEqual('Component C: 0.25', 'with correct text'); + + Plotly.restyle(gd, { + 'hoverlabel.bordercolor': 'blue', + 'hoverlabel.font.family': [['Gravitas', 'Arial', 'Roboto']] + }) + .then(function() { + delete gd._lastHoverTime; + mouseEvent('mousemove', pointPos[0], pointPos[1]); + + var path = d3.select('g.hovertext').select('path'); + var text = d3.select('g.hovertext').select('text.nums'); + + expect(path.style('stroke')).toEqual('rgb(0, 0, 255)', 'bordercolor'); + expect(text.style('font-family')).toEqual('Gravitas', 'font.family[0]'); + }) + .then(done); }); it('should respond to hover interactions by', function() {