UNPKG

plotly.js

Version:

The open source javascript graphing library that powers plotly

1,308 lines (1,130 loc) 70.8 kB
/** * Copyright 2012-2020, 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 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 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; // 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); Lib.throttle( gd._fullLayout._uid + constants.HOVERID, constants.HOVERMINTIME, function() { _hover(gd, evt, subplot, noHoverEvent); } ); }; /* * 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 pointsData = hoverItems.map(function(hoverItem) { 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, 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, eventData: hoverItem.eventData || false, hovertemplateLabels: hoverItem.hovertemplateLabels || false, }; }); var container3 = d3.select(opts.container); var 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(pointsData, fullOpts, opts.gd); // 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 = opts.gd._fullLayout._invScaleX; var scaleY = opts.gd._fullLayout._invScaleY; alignHoverText(hoverLabel, fullOpts.rotateLabels, scaleX, scaleY); return multiHover ? hoverLabel : hoverLabel.node(); }; // The actual implementation is here: function _hover(gd, evt, subplot, noHoverEvent) { 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; var plots = fullLayout._plots || []; var plotinfo = plots[subplot]; var hasCartesian = fullLayout._has('cartesian'); // 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; var xaArray = new Array(len); var yaArray = new Array(len); var supportsCompare = false; for(var i = 0; i < len; i++) { var 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; } } var hovermode = evt.hovermode || fullLayout.hovermode; 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 === -1 ? Infinity : fullLayout.hoverdistance; var spikedistance = fullLayout.spikedistance === -1 ? Infinity : fullLayout.spikedistance; // 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 { for(curvenum = 0; curvenum < gd.calcdata.length; curvenum++) { cd = gd.calcdata[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 = !evt.target; 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 = evt.target.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; 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); } // within one trace mode can sometimes be overridden mode = hovermode; if(helpers.isUnifiedHover(mode)) { mode = mode.charAt(0); } // 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]; } 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 if(customXVal !== undefined && customYVal !== undefined) { xval = customXVal; yval = customYVal; } else { xval = xvalArray[subploti]; yval = yvalArray[subploti]; } // 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, fullLayout._hoverlayer); 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', 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) { var resultPoint = null; var minDistance = Infinity; var thisSpikeDistance; for(var i = 0; i < pointsData.length; i++) { thisSpikeDistance = pointsData[i].spikeDistance; 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, outerContainer: fullLayout._paperdiv, event: evt }; var oldspikepoints = gd._spikepoints; var newspikepoints = { vLinePoint: spikePoints.vLinePoint, hLinePoint: spikePoints.hLinePoint }; gd._spikepoints = newspikepoints; // 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); spikePoints.hLinePoint = fillSpikePoint(tmpHPoint); var tmpVPointData = hoverData.filter(function(point) { return point.xa.showspikes; }); var tmpVPoint = selectClosestPoint(tmpVPointData, spikedistance); 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); } } hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); // If in compare mode, select every point at position if( helpers.isXYhover(mode) && hoverData[0].length !== 0 && hoverData[0].trace.type !== 'splom' // TODO: add support for splom ) { var hd = hoverData[0]; var cd0 = hd.cd[hd.index]; var isGrouped = (fullLayout.boxmode === 'group' || fullLayout.violinmode === 'group'); var xVal = hd.xVal; var ax = hd.xa; if(ax.type === 'category') xVal = ax._categoriesMap[xVal]; if(ax.type === 'date') xVal = ax.d2c(xVal); if(cd0 && cd0.t && cd0.t.posLetter === ax._id && isGrouped) { xVal += cd0.t.dPos; } var yVal = hd.yVal; ax = hd.ya; if(ax.type === 'category') yVal = ax._categoriesMap[yVal]; if(ax.type === 'date') yVal = ax.d2c(yVal); if(cd0 && cd0.t && cd0.t.posLetter === ax._id && isGrouped) { yVal += cd0.t.dPos; } findHoverPoints(xVal, yVal); // Remove duplicated hoverData points // note that d3 also filters identical points in the rendering steps var repeated = {}; hoverData = hoverData.filter(function(hd) { var key = hoverDataKey(hd); if(!repeated[key]) { repeated[key] = true; return repeated[key]; } }); } // lastly, emit custom hover/unhover events var oldhoverdata = gd._hoverdata; var 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 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; } 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 labelOpts = { hovermode: hovermode, rotateLabels: rotateLabels, bgColor: bgColor, container: fullLayout._hoverlayer, outerContainer: fullLayout._paperdiv, commonLabelOpts: fullLayout.hoverlabel, hoverdistance: fullLayout.hoverdistance }; var hoverLabels = createHoverText(hoverData, labelOpts, gd); if(!helpers.isUnifiedHover(hovermode)) { hoverAvoidOverlaps(hoverLabels, rotateLabels ? 'xa' : 'ya', fullLayout); alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY); } // 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 || 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.ya || ''].join(','); } var EXTRA_STRING_REGEX = /<extra>([\s\S]*)<\/extra>/; function createHoverText(hoverData, 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 || {}; // 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.charAt(0) === '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 = ( (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(); 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 commonLabelFont = { family: commonLabelOpts.font.family || fontFamily, size: commonLabelOpts.font.size || fontSize, color: commonLabelOpts.font.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 = ltext.node().getBoundingClientRect(); 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; if(lx < halfWidth) { lx = halfWidth; lpath.attr('d', 'M-' + (halfWidth - HOVERARROWSIZE) + ',0' + 'L-' + (halfWidth - HOVERARROWSIZE * 2) + ',' + topsign + HOVERARROWSIZE + 'H' + (HOVERTEXTPAD + tbb.width / 2) + 'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) + 'H-' + halfWidth + 'V' + topsign + HOVERARROWSIZE + 'Z'); } else if(lx > (fullLayout.width - halfWidth)) { lx = fullLayout.width - halfWidth; lpath.attr('d', 'M' + (halfWidth - HOVERARROWSIZE) + ',0' + 'L' + halfWidth + ',' + topsign + HOVERARROWSIZE + 'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) + 'H-' + halfWidth + 'V' + topsign + HOVERARROWSIZE + 'H' + (halfWidth - HOVERARROWSIZE * 2) + 'Z'); } else { 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'); } } 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'); 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 = dummy.node().getBoundingClientRect(); 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)); // remove the "close but not quite" points // because of error bars, only take up to a space hoverData = filterClosePoints(hoverData); }); function filterClosePoints(hoverData) { return hoverData.filter(function(d) { return (d.zLabelVal !== undefined) || (d[commonAttr] || '').split(' ')[0] === t00; }); } // Show a single hover label if(helpers.isUnifiedHover(hovermode)) { // Delete leftover hover labels from other hovermodes container.selectAll('g.hovertext').remove(); // similarly to compare mode, we remove the "close but not quite together" points if((t0 !== undefined) && (c0.distance <= opts.hoverdistance)) hoverData = filterClosePoints(hoverData); // Return early if nothing is hovered on if(hoverData.length === 0) return; // mock legend var mockLayoutIn = { showlegend: true, legend: { title: {text: t0, font: fullLayout.hoverlabel.font}, font: fullLayout.hoverlabel.font, bgcolor: fullLayout.hoverlabel.bgcolor, bordercolor: fullLayout.hoverlabel.bordercolor, borderwidth: 1, tracegroupgap: 7, traceorder: fullLayout.legend ? fullLayout.legend.traceorder : undefined, orientation: 'v' } }; var mockLayoutOut = {}; legendSupplyDefaults(mockLayoutIn, mockLayoutOut, gd._fullData); var legendOpts = mockLayoutOut.legend; // prepare items for the legend legendOpts.entries = []; for(var j = 0; j < hoverData.length; j++) { var texts = getHoverLabelText(hoverData[j], true, hovermode, fullLayout, t0); var text = texts[0]; var name = texts[1]; var pt = hoverData[j]; 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; legendOpts.entries.push([pt]); } legendOpts.entries.sort(function(a, b) { return a[0].trace.index - b[0].trace.index;}); legendOpts.layer = container; // Draw unified hover label legendDraw(gd, legendOpts); // Position the hover var ly = Lib.mean(hoverData.map(function(c) {return (c.y0 + c.y1) / 2;})); var lx = Lib.mean(hoverData.map(function(c) {return (c.x0 + c.x1) / 2;})); var legendContainer = container.select('g.legend'); var tbb = legendContainer.node().getBoundingClientRect(); lx += xa._offset; ly += ya._offset - tbb.height / 2; // Change horizontal alignment to end up on screen var txWidth = tbb.width + 2 * HOVERTEXTPAD; var anchorStartOK = lx + txWidth <= outerWidth; var anchorEndOK = lx - txWidth >= 0; if(!anchorStartOK && anchorEndOK) { lx -= txWidth; } else { lx += 2 * HOVERTEXTPAD; } // Change vertical alignement to end up on screen var txHeight = tbb.height + 2 * HOVERTEXTPAD; var overflowTop = ly <= outerTop; var overflowBottom = ly + txHeight >= outerHeight; var canFit = txHeight <= outerHeight; if(canFit) { if(overflowTop) { ly = ya._offset + 2 * HOVERTEXTPAD; } else if(overflowBottom) { ly = outerHeight - txHeight; } } legendContainer.attr('transform', strTranslate(lx, ly)); return legendContainer; } // show all the individual labels // first create the objects var hoverLabels = container.selectAll('g.hovertext') .data(hoverData, function(d) { // N.B. when multiple items have the same result key-function value, // only the first of those items in hoverData gets rendered return hoverDataKey(d); }); 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', ''); var dColor = d.color; if(Array.isArray(dColor)) { dColor = dColor[d.eventData[0].pointNumber]; } // combine possible non-opaque trace color with bgColor var color0 = d.bgcolor || dColor; // color for 'nums' part of the label var numsColor = Color.combine( Color.opacity(color0) ? color0 : Color.defaultLine, bgColor ); // color for 'name' part of the label var nameColor = Color.combine( Color.opacity(dColor) ? dColor : Color.defaultLine, bgColor ); // find a contrasting color for border and text var contrastColor = d.borderColor || Color.contrast(numsColor); var texts = getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g); var text = texts[0]; var name = texts[1]; // main label var tx = g.select('text.nums') .call(Drawing.font, d.fontFamily || fontFamily, d.fontSize || fontSize, d.fontColor || contrastColor) .text(text) .attr('data-notex', 1) .call(svgTextUtils.positionText, 0, 0) .call(svgTextUtils.convertToTspans, gd); var tx2 = g.select('text.name'); var tx2width = 0; var tx2height = 0; // secondary label for non-empty 'name' if(name && name !== text) { tx2.call(Drawing.font, d.fontFamily || fontFamily, d.fontSize || fontSize, nameColor) .text(name) .attr('data-notex', 1) .call(svgTextUtils.positionText, 0, 0) .call(svgTextUtils.convertToTspans, gd); var t2bb = tx2.node().getBoundingClientRect(); tx2width = t2bb.width + 2 * HOVERTEXTPAD; tx2height = t2bb.height + 2 * HOVERTEXTPAD; } else { tx2.remove(); g.select('rect').remove(); } g.select('path').style({ fill: numsColor, stroke: contrastColor }); var tbb = tx.node().getBoundingClientRect(); var htx = d.xa._offset + (d.x0 + d.x1) / 2; var hty = d.ya._offset + (d.y0 + d.y1) / 2; var dx = Math.abs(d.x1 - d.x0); var dy = Math.abs(d.y1 - d.y0); var txTotalWidth = tbb.width + HOVERARROWSIZE + HOVERTEXTPAD + tx2width; var anchorStartOK, anchorEndOK; d.ty0 = outerTop - tbb.top; d.bx = tbb.width + 2 * HOVERTEXTPAD; d.by = Math.max(tbb.height + 2 * HOVERTEXTPAD, tx2height); 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'; var txHalfWidth = txTotalWidth / 2; var overflowR = htx + txHalfWidth - outerWidth; var overflowL = htx - txHalfWidth; if(overflowR > 0) htx -= overflowR; if(overflowL < 0) htx += -overflowL; } } tx.attr('text-anchor', d.anchor); if(tx2width) tx2.attr('text-anchor', d.anchor); g.attr('transform', strTranslate(htx, hty) + (rotateLabels ? strRotate(YANGLE) : '')); }); return hoverLabels; } function getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g) { var name = ''; var text = ''; // to get custom 'name' labels pass cleanPoint if(d.nameOverride !== undefined) d.name = d.nameOverride; if(d.name) { if(d.trace._meta) { d.name = Lib.templateString(d.name, d.trace._meta); } name = plainText(d.name, d.nameLength); } if(d.zLabel !== undefined) { if(d.xLabel !== undefined) text += 'x: ' + d.xLabel + '<br>'; if(d.yLabel !== undefined) text += 'y: ' + d.yLabel + '<br>'; if(d.trace.type !== 'choropleth' && d.trace.type !== 'choroplethmapbox') { text += (text ? 'z: ' : '') + d.zLabel; } } else if(showCommonLabel && d[hovermode.charAt(0) + 'Label'] === t0) { text = d[(hovermode.charAt(0) === 'x' ? 'y' : 'x') + 'Label'] || ''; } else if(d.xLabel === undefined) { if(d.yLabel !== undefined && d.trace.type !== 'scattercarpet') { text = d.yLabel; } } else if(d.yLabel === undefined) text = d.xLabel; else text = '(' + d.xLabel + ', ' + d.yLabel + ')'; if((d.text || d.text === 0) && !Array.isArray(d.text)) { text += (text ? '<br>' : '') + d.text; } // 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 += (text ? '<br>' : '') + d.extraText; // if 'text' is empty at this point, // and hovertemplate is not defined, // put 'name' in main label and don't show secondary label if(g && text === '' && !d.hovertemplate) { // if 'name' is also empty, remove entire label if(name === '') g.remove(); text = name; } // hovertemplate var d3locale = fullLayout._d3locale; var hovertemplate = d.hovertemplate || false; var hovertemplateLabels = d.hovertemplateLabels || d; var eventData = d.eventData[0] || {}; if(hovertemplate) { text = Lib.hovertemplateString( hovertemplate, hovertemplateLabels, d3locale, eventData, d.trace._meta ); text = text.replace(EXTRA_STRING_REGEX, function(match, extra) { // assign name for secondary text label name = plainText(extra, d.nameLength); // remove from main text label return ''; }); } return [text, name]; } // Make groups of touching points, and within each group // move each point so that no labels