plotly.js
Version:
The open source javascript graphing library that powers plotly
1,315 lines (1,133 loc) • 88.6 kB
JavaScript
'use strict';
var d3 = require('@plotly/d3');
var isNumeric = require('fast-isnumeric');
var tinycolor = require('tinycolor2');
var Lib = require('../../lib');
var pushUnique = Lib.pushUnique;
var strTranslate = Lib.strTranslate;
var strRotate = Lib.strRotate;
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 zindexSeparator = require('../../plots/cartesian/constants').zindexSeparator;
var Registry = require('../../registry');
var helpers = require('./helpers');
var constants = require('./constants');
var legendSupplyDefaults = require('../legend/defaults');
var legendDraw = require('../legend/draw');
// 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;
var multipleHoverPoints = {
box: true,
ohlc: true,
violin: true,
candlestick: true
};
var cartesianScatterPoints = {
scatter: true,
scattergl: true,
splom: true
};
function distanceSort(a, b) {
return a.distance - b.distance;
}
// 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, noHoverEvent) {
gd = Lib.getGraphDiv(gd);
// The 'target' property changes when bubbling out of Shadow DOM.
// Throttling can delay reading the target, so we save the current value.
var eventTarget = evt.target;
Lib.throttle(
gd._fullLayout._uid + constants.HOVERID,
constants.HOVERMINTIME,
function() { _hover(gd, evt, subplot, noHoverEvent, eventTarget); }
);
};
/*
* Draw a single hover item or an array of 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 <svg> or <g> 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
* opts can have optional keys:
* - anchorIndex:
the index of the hover item used as an anchor for positioning.
The other hover items will be pushed up or down to prevent overlap.
*/
exports.loneHover = function loneHover(hoverItems, opts) {
var multiHover = true;
if(!Array.isArray(hoverItems)) {
multiHover = false;
hoverItems = [hoverItems];
}
var gd = opts.gd;
var gTop = getTopOffset(gd);
var gLeft = getLeftOffset(gd);
var pointsData = hoverItems.map(function(hoverItem) {
var _x0 = hoverItem._x0 || hoverItem.x0 || hoverItem.x || 0;
var _x1 = hoverItem._x1 || hoverItem.x1 || hoverItem.x || 0;
var _y0 = hoverItem._y0 || hoverItem.y0 || hoverItem.y || 0;
var _y1 = hoverItem._y1 || hoverItem.y1 || hoverItem.y || 0;
var eventData = hoverItem.eventData;
if(eventData) {
var x0 = Math.min(_x0, _x1);
var x1 = Math.max(_x0, _x1);
var y0 = Math.min(_y0, _y1);
var y1 = Math.max(_y0, _y1);
var trace = hoverItem.trace;
if(Registry.traceIs(trace, 'gl3d')) {
var container = gd._fullLayout[trace.scene]._scene.container;
var dx = container.offsetLeft;
var dy = container.offsetTop;
x0 += dx;
x1 += dx;
y0 += dy;
y1 += dy;
}
eventData.bbox = {
x0: x0 + gLeft,
x1: x1 + gLeft,
y0: y0 + gTop,
y1: y1 + gTop
};
if(opts.inOut_bbox) {
opts.inOut_bbox.push(eventData.bbox);
}
} else {
eventData = false;
}
return {
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,
fontWeight: hoverItem.fontWeight,
fontStyle: hoverItem.fontStyle,
fontVariant: hoverItem.fontVariant,
nameLength: hoverItem.nameLength,
textAlign: hoverItem.textAlign,
// filler to make createHoverText happy
trace: hoverItem.trace || {
index: 0,
hoverinfo: ''
},
xa: {_offset: 0},
ya: {_offset: 0},
index: 0,
hovertemplate: hoverItem.hovertemplate || false,
hovertemplateLabels: hoverItem.hovertemplateLabels || false,
eventData: eventData
};
});
var rotateLabels = false;
var hoverText = createHoverText(pointsData, {
gd: gd,
hovermode: 'closest',
rotateLabels: rotateLabels,
bgColor: opts.bgColor || Color.background,
container: d3.select(opts.container),
outerContainer: opts.outerContainer || opts.container
});
var hoverLabel = hoverText.hoverLabels;
// Fix vertical overlap
var tooltipSpacing = 5;
var lastBottomY = 0;
var anchor = 0;
hoverLabel
.sort(function(a, b) {return a.y0 - b.y0;})
.each(function(d, i) {
var topY = d.y0 - d.by / 2;
if((topY - tooltipSpacing) < lastBottomY) {
d.offset = (lastBottomY - topY) + tooltipSpacing;
} else {
d.offset = 0;
}
lastBottomY = topY + d.by + d.offset;
if(i === opts.anchorIndex || 0) anchor = d.offset;
})
.each(function(d) {
d.offset -= anchor;
});
var scaleX = gd._fullLayout._invScaleX;
var scaleY = gd._fullLayout._invScaleY;
alignHoverText(hoverLabel, rotateLabels, scaleX, scaleY);
return multiHover ? hoverLabel : hoverLabel.node();
};
// The actual implementation is here:
function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
if(!subplot) subplot = 'xy';
if(typeof subplot === 'string') {
// drop zindex from subplot id
subplot = subplot.split(zindexSeparator)[0];
}
// if the user passed in an array of subplots,
// use those instead of finding overlayed plots
var subplots = Array.isArray(subplot) ? subplot : [subplot];
var spId;
var fullLayout = gd._fullLayout;
var hoversubplots = fullLayout.hoversubplots;
var plots = fullLayout._plots || [];
var plotinfo = plots[subplot];
var hasCartesian = fullLayout._has('cartesian');
var hovermode = evt.hovermode || fullLayout.hovermode;
var hovermodeHasX = (hovermode || '').charAt(0) === 'x';
var hovermodeHasY = (hovermode || '').charAt(0) === 'y';
var firstXaxis;
var firstYaxis;
if(hasCartesian && (hovermodeHasX || hovermodeHasY) && hoversubplots === 'axis') {
var subplotsLength = subplots.length;
for(var p = 0; p < subplotsLength; p++) {
spId = subplots[p];
if(plots[spId]) {
// 'cartesian' case
firstXaxis = Axes.getFromId(gd, spId, 'x');
firstYaxis = Axes.getFromId(gd, spId, 'y');
var subplotsWith = (
hovermodeHasX ? firstXaxis : firstYaxis
)._subplotsWith;
if(subplotsWith && subplotsWith.length) {
for(var q = 0; q < subplotsWith.length; q++) {
pushUnique(subplots, subplotsWith[q]);
}
}
}
}
}
// list of all overlaid subplots to look at
if(plotinfo && hoversubplots !== 'single') {
var overlayedSubplots = plotinfo.overlays.map(function(pi) {
return pi.id;
});
subplots = subplots.concat(overlayedSubplots);
}
var len = subplots.length;
var xaArray = new Array(len);
var yaArray = new Array(len);
var supportsCompare = false;
for(var i = 0; i < len; i++) {
spId = subplots[i];
if(plots[spId]) {
// 'cartesian' case
supportsCompare = true;
xaArray[i] = plots[spId].xaxis;
yaArray[i] = plots[spId].yaxis;
} else if(fullLayout[spId] && fullLayout[spId]._subplot) {
// other subplot types
var _subplot = fullLayout[spId]._subplot;
xaArray[i] = _subplot.xaxis;
yaArray[i] = _subplot.yaxis;
} else {
Lib.warn('Unrecognized subplot: ' + spId);
return;
}
}
if(hovermode && !supportsCompare) hovermode = 'closest';
if(['x', 'y', 'closest', 'x unified', 'y unified'].indexOf(hovermode) === -1 || !gd.calcdata ||
gd.querySelector('.zoombox') || gd._dragging) {
return dragElement.unhoverRaw(gd, evt);
}
var hoverdistance = fullLayout.hoverdistance;
if(hoverdistance === -1) hoverdistance = Infinity;
var spikedistance = fullLayout.spikedistance;
if(spikedistance === -1) spikedistance = Infinity;
// 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
var searchData = [];
// [x|y]valArray: the axis values of the hover event
// mapped onto each of the currently selected overlaid subplots
var xvalArray, yvalArray;
var itemnum, curvenum, cd, trace, subplotId, subploti, _mode,
xval, yval, pointData, closedataPreviousLength;
// spikePoints: the set of candidate points we've found to draw spikes to
var spikePoints = {
hLinePoint: null,
vLinePoint: null
};
// does subplot have one (or more) horizontal traces?
// This is used to determine whether we rotate the labels or not
var hasOneHorizontalTrace = false;
// 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) {
trace = cd[0].trace;
if(cd[0].trace.hoverinfo !== 'skip') {
searchData.push(cd);
if(trace.orientation === 'h') {
hasOneHorizontalTrace = true;
}
}
}
}
} else {
// take into account zorder
var zorderedCalcdata = gd.calcdata.slice();
zorderedCalcdata.sort(function(a, b) {
var aZorder = a[0].trace.zorder || 0;
var bZorder = b[0].trace.zorder || 0;
return aZorder - bZorder;
});
for(curvenum = 0; curvenum < zorderedCalcdata.length; curvenum++) {
cd = zorderedCalcdata[curvenum];
trace = cd[0].trace;
if(trace.hoverinfo !== 'skip' && helpers.isTraceInSubplots(trace, subplots)) {
searchData.push(cd);
if(trace.orientation === 'h') {
hasOneHorizontalTrace = true;
}
}
}
// [x|y]px: the pixels (from top left) of the mouse location
// on the currently selected plot area
// add pointerX|Y property for drawing the spikes in spikesnap 'cursor' situation
var hasUserCalledHover = !eventTarget;
var 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 = eventTarget.getBoundingClientRect();
xpx = evt.clientX - dbb.left;
ypx = evt.clientY - dbb.top;
fullLayout._calcInverseTransform(gd);
var transformedCoords = Lib.apply3DTransform(fullLayout._invTransform)(xpx, ypx);
xpx = transformedCoords[0];
ypx = transformedCoords[1];
// in case hover was called from mouseout into hovertext,
// it's possible you're not actually over the plot anymore
if(xpx < 0 || xpx > xaArray[0]._length || ypx < 0 || ypx > yaArray[0]._length) {
return dragElement.unhoverRaw(gd, evt);
}
}
evt.pointerX = xpx + xaArray[0]._offset;
evt.pointerY = ypx + yaArray[0]._offset;
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)
function findHoverPoints(customXVal, customYVal) {
for(curvenum = 0; curvenum < searchData.length; curvenum++) {
cd = searchData[curvenum];
// filter out invisible or broken data
if(!cd || !cd[0] || !cd[0].trace) continue;
trace = cd[0].trace;
if(trace.visible !== true || trace._length === 0) continue;
// 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;
// within one trace mode can sometimes be overridden
_mode = hovermode;
if(helpers.isUnifiedHover(_mode)) {
_mode = _mode.charAt(0);
}
if(trace.type === 'splom') {
// splom traces do not generate overlay subplots,
// it is safe to assume here splom traces correspond to the 0th subplot
subploti = 0;
subplotId = subplots[subploti];
} else {
subplotId = helpers.getSubplot(trace);
subploti = subplots.indexOf(subplotId);
}
// 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],
// max distances for hover and spikes - for points that want to show but do not
// want to override other points, set distance/spikeDistance equal to max*Distance
// and it will not get filtered out but it will be guaranteed to have a greater
// distance than any point that calculated a real distance.
maxHoverDistance: hoverdistance,
maxSpikeDistance: spikedistance,
// point properties - override all of these
index: false, // point index in trace - only used by plotly.js hoverdata consumers
distance: Math.min(distance, hoverdistance), // pixel distance or pseudo-distance
// distance/pseudo-distance for spikes. This distance should always be calculated
// as if in "closest" mode, and should only be set if this point should
// generate a spike.
spikeDistance: Infinity,
// in some cases the spikes have different positioning from the hover label
// they don't need x0/x1, just one position
xSpike: undefined,
ySpike: undefined,
// where and how to display the hover label
color: Color.defaultLine, // trace color
name: trace.name,
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;
}
// add ref to splom scene
if(fullLayout._splomScenes && fullLayout._splomScenes[trace.uid]) {
pointData.scene = fullLayout._splomScenes[trace.uid];
}
// 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 if(customXVal !== undefined && customYVal !== undefined) {
xval = customXVal;
yval = customYVal;
} else {
xval = xvalArray[subploti];
yval = yvalArray[subploti];
}
closedataPreviousLength = hoverData.length;
// Now if there is range to look in, find the points to hover.
if(hoverdistance !== 0) {
if(trace._module && trace._module.hoverPoints) {
var newPoints = trace._module.hoverPoints(pointData, xval, yval, _mode, {
finiteRange: true,
hoverLayer: fullLayout._hoverlayer,
// options for splom when hovering on same axis
hoversubplots: hoversubplots,
gd: gd
});
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, some
// traces like box & violin make multiple hover labels at once)
if(hovermode === 'closest' && hoverData.length > closedataPreviousLength) {
hoverData.splice(0, closedataPreviousLength);
distance = hoverData[0].distance;
}
// Now if there is range to look in, find the points to draw the spikelines
// Do it only if there is no hoverData
if(hasCartesian && (spikedistance !== 0)) {
if(hoverData.length === 0) {
pointData.distance = spikedistance;
pointData.index = false;
var closestPoints = trace._module.hoverPoints(pointData, xval, yval, 'closest', {
hoverLayer: fullLayout._hoverlayer
});
if(closestPoints) {
closestPoints = closestPoints.filter(function(point) {
// some hover points, like scatter fills, do not allow spikes,
// so will generate a hover point but without a valid spikeDistance
return point.spikeDistance <= spikedistance;
});
}
if(closestPoints && closestPoints.length) {
var tmpPoint;
var closestVPoints = closestPoints.filter(function(point) {
return point.xa.showspikes && point.xa.spikesnap !== 'hovered data';
});
if(closestVPoints.length) {
var closestVPt = closestVPoints[0];
if(isNumeric(closestVPt.x0) && isNumeric(closestVPt.y0)) {
tmpPoint = fillSpikePoint(closestVPt);
if(!spikePoints.vLinePoint || (spikePoints.vLinePoint.spikeDistance > tmpPoint.spikeDistance)) {
spikePoints.vLinePoint = tmpPoint;
}
}
}
var closestHPoints = closestPoints.filter(function(point) {
return point.ya.showspikes && point.ya.spikesnap !== 'hovered data';
});
if(closestHPoints.length) {
var closestHPt = closestHPoints[0];
if(isNumeric(closestHPt.x0) && isNumeric(closestHPt.y0)) {
tmpPoint = fillSpikePoint(closestHPt);
if(!spikePoints.hLinePoint || (spikePoints.hLinePoint.spikeDistance > tmpPoint.spikeDistance)) {
spikePoints.hLinePoint = tmpPoint;
}
}
}
}
}
}
}
}
findHoverPoints();
function selectClosestPoint(pointsData, spikedistance, spikeOnWinning) {
var resultPoint = null;
var minDistance = Infinity;
var thisSpikeDistance;
for(var i = 0; i < pointsData.length; i++) {
if(firstXaxis && firstXaxis._id !== pointsData[i].xa._id) continue;
if(firstYaxis && firstYaxis._id !== pointsData[i].ya._id) continue;
thisSpikeDistance = pointsData[i].spikeDistance;
if(spikeOnWinning && i === 0) thisSpikeDistance = -Infinity;
if(thisSpikeDistance <= minDistance && thisSpikeDistance <= spikedistance) {
resultPoint = pointsData[i];
minDistance = thisSpikeDistance;
}
}
return resultPoint;
}
function fillSpikePoint(point) {
if(!point) return null;
return {
xa: point.xa,
ya: point.ya,
x: point.xSpike !== undefined ? point.xSpike : (point.x0 + point.x1) / 2,
y: point.ySpike !== undefined ? point.ySpike : (point.y0 + point.y1) / 2,
distance: point.distance,
spikeDistance: point.spikeDistance,
curveNumber: point.trace.index,
color: point.color,
pointNumber: point.index
};
}
var spikelineOpts = {
fullLayout: fullLayout,
container: fullLayout._hoverlayer,
event: evt
};
var oldspikepoints = gd._spikepoints;
var newspikepoints = {
vLinePoint: spikePoints.vLinePoint,
hLinePoint: spikePoints.hLinePoint
};
gd._spikepoints = newspikepoints;
var sortHoverData = function() {
// When sorting keep the points in the main subplot at the top
// then add points in other subplots
var hoverDataInSubplot = hoverData.filter(function(a) {
return (
(firstXaxis && firstXaxis._id === a.xa._id) &&
(firstYaxis && firstYaxis._id === a.ya._id)
);
});
var hoverDataOutSubplot = hoverData.filter(function(a) {
return !(
(firstXaxis && firstXaxis._id === a.xa._id) &&
(firstYaxis && firstYaxis._id === a.ya._id)
);
});
hoverDataInSubplot.sort(distanceSort);
hoverDataOutSubplot.sort(distanceSort);
hoverData = hoverDataInSubplot.concat(hoverDataOutSubplot);
// move period positioned points and box/bar-like traces to the end of the list
hoverData = orderRangePoints(hoverData, hovermode);
};
sortHoverData();
var axLetter = hovermode.charAt(0);
var spikeOnWinning = (axLetter === 'x' || axLetter === 'y') && hoverData[0] && cartesianScatterPoints[hoverData[0].trace.type];
// Now if it is not restricted by spikedistance option, set the points to draw the spikelines
if(hasCartesian && (spikedistance !== 0)) {
if(hoverData.length !== 0) {
var tmpHPointData = hoverData.filter(function(point) {
return point.ya.showspikes;
});
var tmpHPoint = selectClosestPoint(tmpHPointData, spikedistance, spikeOnWinning);
spikePoints.hLinePoint = fillSpikePoint(tmpHPoint);
var tmpVPointData = hoverData.filter(function(point) {
return point.xa.showspikes;
});
var tmpVPoint = selectClosestPoint(tmpVPointData, spikedistance, spikeOnWinning);
spikePoints.vLinePoint = fillSpikePoint(tmpVPoint);
}
}
// if hoverData is empty check for the spikes to draw and quit if there are none
if(hoverData.length === 0) {
var result = dragElement.unhoverRaw(gd, evt);
if(hasCartesian && ((spikePoints.hLinePoint !== null) || (spikePoints.vLinePoint !== null))) {
if(spikesChanged(oldspikepoints)) {
createSpikelines(gd, spikePoints, spikelineOpts);
}
}
return result;
}
if(hasCartesian) {
if(spikesChanged(oldspikepoints)) {
createSpikelines(gd, spikePoints, spikelineOpts);
}
}
if(
helpers.isXYhover(_mode) &&
hoverData[0].length !== 0 &&
hoverData[0].trace.type !== 'splom' // TODO: add support for splom
) {
// pick winning point
var winningPoint = hoverData[0];
// discard other points
if(multipleHoverPoints[winningPoint.trace.type]) {
hoverData = hoverData.filter(function(d) {
return d.trace.index === winningPoint.trace.index;
});
} else {
hoverData = [winningPoint];
}
var initLen = hoverData.length;
var winX = getCoord('x', winningPoint, fullLayout);
var winY = getCoord('y', winningPoint, fullLayout);
// in compare mode, select every point at position
findHoverPoints(winX, winY);
var finalPoints = [];
var seen = {};
var id = 0;
var insert = function(newHd) {
var key = multipleHoverPoints[newHd.trace.type] ? hoverDataKey(newHd) : newHd.trace.index;
if(!seen[key]) {
id++;
seen[key] = id;
finalPoints.push(newHd);
} else {
var oldId = seen[key] - 1;
var oldHd = finalPoints[oldId];
if(oldId > 0 &&
Math.abs(newHd.distance) <
Math.abs(oldHd.distance)
) {
// replace with closest
finalPoints[oldId] = newHd;
}
}
};
var k;
// insert the winnig point(s) first
for(k = 0; k < initLen; k++) {
insert(hoverData[k]);
}
// override from the end
for(k = hoverData.length - 1; k > initLen - 1; k--) {
insert(hoverData[k]);
}
hoverData = finalPoints;
sortHoverData();
}
// lastly, emit custom hover/unhover events
var oldhoverdata = gd._hoverdata;
var newhoverdata = [];
var gTop = getTopOffset(gd);
var gLeft = getLeftOffset(gd);
// 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 eventData = helpers.makeEventData(pt, pt.trace, pt.cd);
if(pt.hovertemplate !== false) {
var ht = false;
if(pt.cd[pt.index] && pt.cd[pt.index].ht) {
ht = pt.cd[pt.index].ht;
}
pt.hovertemplate = ht || pt.trace.hovertemplate || false;
}
if(pt.xa && pt.ya) {
var _x0 = pt.x0 + pt.xa._offset;
var _x1 = pt.x1 + pt.xa._offset;
var _y0 = pt.y0 + pt.ya._offset;
var _y1 = pt.y1 + pt.ya._offset;
var x0 = Math.min(_x0, _x1);
var x1 = Math.max(_x0, _x1);
var y0 = Math.min(_y0, _y1);
var y1 = Math.max(_y0, _y1);
eventData.bbox = {
x0: x0 + gLeft,
x1: x1 + gLeft,
y0: y0 + gTop,
y1: y1 + gTop
};
}
pt.eventData = [eventData];
newhoverdata.push(eventData);
}
gd._hoverdata = newhoverdata;
var rotateLabels = (
(hovermode === 'y' && (searchData.length > 1 || hoverData.length > 1)) ||
(hovermode === 'closest' && hasOneHorizontalTrace && hoverData.length > 1)
);
var bgColor = Color.combine(
fullLayout.plot_bgcolor || Color.background,
fullLayout.paper_bgcolor
);
var hoverText = createHoverText(hoverData, {
gd: gd,
hovermode: hovermode,
rotateLabels: rotateLabels,
bgColor: bgColor,
container: fullLayout._hoverlayer,
outerContainer: fullLayout._paper.node(),
commonLabelOpts: fullLayout.hoverlabel,
hoverdistance: fullLayout.hoverdistance
});
var hoverLabels = hoverText.hoverLabels;
if(!helpers.isUnifiedHover(hovermode)) {
hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabelBoundingBox);
alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY);
} // TODO: tagName hack is needed to appease geo.js's hack of using eventTarget=true
// we should improve the "fx" API so other plots can use it without these hack.
if(eventTarget && eventTarget.tagName) {
var hasClickToShow = Registry.getComponentMethod('annotations', 'hasClickToShow')(gd, newhoverdata);
overrideCursor(d3.select(eventTarget), hasClickToShow ? 'pointer' : '');
}
// don't emit events if called manually
if(!eventTarget || noHoverEvent || !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 hoverDataKey(d) {
return [d.trace.index, d.index, d.x0, d.y0, d.name, d.attr, d.xa ? d.xa._id : '', d.ya ? d.ya._id : ''].join(',');
}
var EXTRA_STRING_REGEX = /<extra>([\s\S]*)<\/extra>/;
function createHoverText(hoverData, opts) {
var gd = opts.gd;
var fullLayout = gd._fullLayout;
var hovermode = opts.hovermode;
var rotateLabels = opts.rotateLabels;
var bgColor = opts.bgColor;
var container = opts.container;
var outerContainer = opts.outerContainer;
var commonLabelOpts = opts.commonLabelOpts || {};
// Early exit if no labels are drawn
if(hoverData.length === 0) return [[]];
// 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 fontWeight = opts.fontWeight || fullLayout.font.weight;
var fontStyle = opts.fontStyle || fullLayout.font.style;
var fontVariant = opts.fontVariant || fullLayout.font.variant;
var fontTextcase = opts.fontTextcase || fullLayout.font.textcase;
var fontLineposition = opts.fontLineposition || fullLayout.font.lineposition;
var fontShadow = opts.fontShadow || fullLayout.font.shadow;
var c0 = hoverData[0];
var xa = c0.xa;
var ya = c0.ya;
var axLetter = hovermode.charAt(0);
var axLabel = axLetter + 'Label';
var t0 = c0[axLabel];
// search in array for the label
if(t0 === undefined && xa.type === 'multicategory') {
for(var q = 0; q < hoverData.length; q++) {
t0 = hoverData[q][axLabel];
if(t0 !== undefined) break;
}
}
var outerContainerBB = getBoundingClientRect(gd, outerContainer);
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 = (
(t0 !== undefined) &&
(c0.distance <= opts.hoverdistance) &&
(hovermode === 'x' || hovermode === 'y')
);
// all hover traces hoverinfo must contain the hovermode
// to have common labels
if(showCommonLabel) {
var allHaveZ = true;
var i, traceHoverinfo;
for(i = 0; i < hoverData.length; i++) {
if(allHaveZ && hoverData[i].zLabel === undefined) allHaveZ = false;
traceHoverinfo = hoverData[i].hoverinfo || hoverData[i].trace.hoverinfo;
if(traceHoverinfo) {
var parts = Array.isArray(traceHoverinfo) ? traceHoverinfo : traceHoverinfo.split('+');
if(parts.indexOf('all') === -1 &&
parts.indexOf(hovermode) === -1) {
showCommonLabel = false;
break;
}
}
}
// xyz labels put all info in their main label, so have no need of a common label
if(allHaveZ) showCommonLabel = false;
}
var commonLabel = container.selectAll('g.axistext')
.data(showCommonLabel ? [0] : []);
commonLabel.enter().append('g')
.classed('axistext', true);
commonLabel.exit().remove();
// set rect (without arrow) behind label below for later collision detection
var commonLabelRect = {
minX: 0,
maxX: 0,
minY: 0,
maxY: 0
};
commonLabel.each(function() {
var label = d3.select(this);
var lpath = Lib.ensureSingle(label, 'path', '', function(s) {
s.style({'stroke-width': '1px'});
});
var ltext = Lib.ensureSingle(label, 'text', '', function(s) {
// prohibit tex interpretation until we can handle
// tex and regular text together
s.attr('data-notex', 1);
});
var commonBgColor = commonLabelOpts.bgcolor || Color.defaultLine;
var commonStroke = commonLabelOpts.bordercolor || Color.contrast(commonBgColor);
var contrastColor = Color.contrast(commonBgColor);
var commonLabelOptsFont = commonLabelOpts.font;
var commonLabelFont = {
weight: commonLabelOptsFont.weight || fontWeight,
style: commonLabelOptsFont.style || fontStyle,
variant: commonLabelOptsFont.variant || fontVariant,
textcase: commonLabelOptsFont.textcase || fontTextcase,
lineposition: commonLabelOptsFont.lineposition || fontLineposition,
shadow: commonLabelOptsFont.shadow || fontShadow,
family: commonLabelOptsFont.family || fontFamily,
size: commonLabelOptsFont.size || fontSize,
color: commonLabelOptsFont.color || contrastColor
};
lpath.style({
fill: commonBgColor,
stroke: commonStroke
});
ltext.text(t0)
.call(Drawing.font, commonLabelFont)
.call(svgTextUtils.positionText, 0, 0)
.call(svgTextUtils.convertToTspans, gd);
label.attr('transform', '');
var tbb = getBoundingClientRect(gd, ltext.node());
var lx, ly;
if(hovermode === 'x') {
var topsign = xa.side === 'top' ? '-' : '';
ltext.attr('text-anchor', 'middle')
.call(svgTextUtils.positionText, 0, (xa.side === 'top' ?
(outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD) :
(outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD)));
lx = xa._offset + (c0.x0 + c0.x1) / 2;
ly = ya._offset + (xa.side === 'top' ? 0 : ya._length);
var halfWidth = tbb.width / 2 + HOVERTEXTPAD;
var tooltipMidX = lx;
if(lx < halfWidth) {
tooltipMidX = halfWidth;
} else if(lx > (fullLayout.width - halfWidth)) {
tooltipMidX = fullLayout.width - halfWidth;
}
lpath.attr('d', 'M' + (lx - tooltipMidX) + ',0' +
'L' + (lx - tooltipMidX + HOVERARROWSIZE) + ',' + topsign + HOVERARROWSIZE +
'H' + halfWidth +
'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) +
'H' + (-halfWidth) +
'V' + topsign + HOVERARROWSIZE +
'H' + (lx - tooltipMidX - HOVERARROWSIZE) +
'Z');
lx = tooltipMidX;
commonLabelRect.minX = lx - halfWidth;
commonLabelRect.maxX = lx + halfWidth;
if(xa.side === 'top') {
// label on negative y side
commonLabelRect.minY = ly - (HOVERTEXTPAD * 2 + tbb.height);
commonLabelRect.maxY = ly - HOVERTEXTPAD;
} else {
commonLabelRect.minY = ly + HOVERTEXTPAD;
commonLabelRect.maxY = ly + (HOVERTEXTPAD * 2 + tbb.height);
}
} else {
var anchor;
var sgn;
var leftsign;
if(ya.side === 'right') {
anchor = 'start';
sgn = 1;
leftsign = '';
lx = xa._offset + xa._length;
} else {
anchor = 'end';
sgn = -1;
leftsign = '-';
lx = xa._offset;
}
ly = ya._offset + (c0.y0 + c0.y1) / 2;
ltext.attr('text-anchor', anchor);
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');
commonLabelRect.minY = ly - (HOVERTEXTPAD + tbb.height / 2);
commonLabelRect.maxY = ly + (HOVERTEXTPAD + tbb.height / 2);
if(ya.side === 'right') {
commonLabelRect.minX = lx + HOVERARROWSIZE;
commonLabelRect.maxX = lx + HOVERARROWSIZE + (HOVERTEXTPAD * 2 + tbb.width);
} else {
// label on negative x side
commonLabelRect.minX = lx - HOVERARROWSIZE - (HOVERTEXTPAD * 2 + tbb.width);
commonLabelRect.maxX = lx - HOVERARROWSIZE;
}
var halfHeight = tbb.height / 2;
var lty = outerTop - tbb.top - halfHeight;
var clipId = 'clip' + fullLayout._uid + 'commonlabel' + ya._id;
var clipPath;
if(lx < (tbb.width + 2 * HOVERTEXTPAD + HOVERARROWSIZE)) {
clipPath = 'M-' + (HOVERARROWSIZE + HOVERTEXTPAD) + '-' + halfHeight +
'h-' + (tbb.width - HOVERTEXTPAD) +
'V' + halfHeight +
'h' + (tbb.width - HOVERTEXTPAD) + 'Z';
var ltx = tbb.width - lx + HOVERTEXTPAD;
svgTextUtils.positionText(ltext, ltx, lty);
// shift each line (except the longest) so that start-of-line
// is always visible
if(anchor === 'end') {
ltext.selectAll('tspan').each(function() {
var s = d3.select(this);
var dummy = Drawing.tester.append('text')
.text(s.text())
.call(Drawing.font, commonLabelFont);
var dummyBB = getBoundingClientRect(gd, dummy.node());
if(Math.round(dummyBB.width) < Math.round(tbb.width)) {
s.attr('x', ltx - dummyBB.width);
}
dummy.remove();
});
}
} else {
svgTextUtils.positionText(ltext, sgn * (HOVERTEXTPAD + HOVERARROWSIZE), lty);
clipPath = null;
}
var textClip = fullLayout._topclips.selectAll('#' + clipId).data(clipPath ? [0] : []);
textClip.enter().append('clipPath').attr('id', clipId).append('path');
textClip.exit().remove();
textClip.select('path').attr('d', clipPath);
Drawing.setClipUrl(ltext, clipPath ? clipId : null, gd);
}
label.attr('transform', strTranslate(lx, ly));
});
// Show a single hover label
if(helpers.isUnifiedHover(hovermode)) {
// Delete leftover hover labels from other hovermodes
container.selectAll('g.hovertext').remove();
var groupedHoverData = hoverData.filter(function(data) {return data.hoverinfo !== 'none';});
// Return early if nothing is hovered on
if(groupedHoverData.length === 0) return [];
// mock legend
var hoverlabel = fullLayout.hoverlabel;
var font = hoverlabel.font;
var mockLayoutIn = {
showlegend: true,
legend: {
title: {text: t0, font: font},
font: font,
bgcolor: hoverlabel.bgcolor,
bordercolor: hoverlabel.bordercolor,
borderwidth: 1,
tracegroupgap: 7,
traceorder: fullLayout.legend ? fullLayout.legend.traceorder : undefined,
orientation: 'v'
}
};
var mockLayoutOut = {
font: font
};
legendSupplyDefaults(mockLayoutIn, mockLayoutOut, gd._fullData);
var mockLegend = mockLayoutOut.legend;
// prepare items for the legend
mockLegend.entries = [];
for(var j = 0; j < groupedHoverData.length; j++) {
var pt = groupedHoverData[j];
if(pt.hoverinfo === 'none') continue;
var texts = getHoverLabelText(pt, true, hovermode, fullLayout, t0);
var text = texts[0];
var name = texts[1];
pt.name = name;
if(name !== '') {
pt.text = name + ' : ' + text;
} else {
pt.text = text;
}
// pass through marker's calcdata to style legend items
var cd = pt.cd[pt.index];
if(cd) {
if(cd.mc) pt.mc = cd.mc;
if(cd.mcc) pt.mc = cd.mcc;
if(cd.mlc) pt.mlc = cd.mlc;
if(cd.mlcc) pt.mlc = cd.mlcc;
if(cd.mlw) pt.mlw = cd.mlw;
if(cd.mrc) pt.mrc = cd.mrc;
if(cd.dir) pt.dir = cd.dir;
}
pt._distinct = true;
mockLegend.entries.push([pt]);
}
mockLegend.entries.sort(function(a, b) { return a[0].trace.index - b[0].trace.index;});
mockLegend.layer = container;
// Draw unified hover label
mockLegend._inHover = true;
mockLegend._groupTitleFont = hoverlabel.grouptitlefont;
legendDraw(gd, mockLegend);
// Position the hover
var legendContainer = container.select('g.legend');
var tbb = getBoundingClientRect(gd, legendContainer.node());
var tWidth = tbb.width + 2 * HOVERTEXTPAD;
var tHeight = tbb.height + 2 * HOVERTEXTPAD;
var winningPoint = groupedHoverData[0];
var avgX = (winningPoint.x0 + winningPoint.x1) / 2;
var avgY = (winningPoint.y0 + winningPoint.y1) / 2;
// When a scatter (or e.g. heatmap) point wins, it's OK for the hovelabel to occlude the bar and other points.
var pointWon = !(
Registry.traceIs(winningPoint.trace, 'bar-like') ||
Registry.traceIs(winningPoint.trace, 'box-violin')
);
var lyBottom, lyTop;
if(axLetter === 'y') {
if(pointWon) {
lyTop = avgY - HOVERTEXTPAD;
lyBottom = avgY + HOVERTEXTPAD;
} else {
lyTop = Math.min.apply(null, groupedHoverData.map(function(c) { return Math.min(c.y0, c.y1); }));
lyBottom = Math.max.apply(null, groupedHoverData.map(function(c) { return Math.max(c.y0, c.y1); }));
}
} else {
lyTop = lyBottom = Lib.mean(groupedHoverData.map(function(c) { return (c.y0 + c.y1) / 2; })) - tHeight / 2;
}
var lxRight, lxLeft;
if(axLetter === 'x') {
if(pointWon) {
lxRight = avgX + HOVERTEXTPAD;
lxLeft = avgX - HOVERTEXTPAD;
} else {
lxRight = Math.max.apply(null, groupedHoverData.map(function(c) { return Math.max(c.x0, c.x1); }));
lxLeft = Math.min.apply(null, groupedHoverData.map(function(c) { return Math.min(c.x0, c.x1); }));
}
} else {
lxRight = lxLeft = Lib.mean(groupedHoverData.map(function(c) { return (c.x0 + c.x1) / 2; })) - tWidth / 2;
}
var xOffset = xa._offset;
var yOffset = ya._o