diff --git a/src/components/fx/calc.js b/src/components/fx/calc.js index ebeb4887c1c..aaebc599037 100644 --- a/src/components/fx/calc.js +++ b/src/components/fx/calc.js @@ -34,6 +34,8 @@ module.exports = function calc(gd) { fillFn(trace.hoverinfo, cd, 'hi', makeCoerceHoverInfo(trace)); + if(trace.hovertemplate) fillFn(trace.hovertemplate, cd, 'ht'); + if(!trace.hoverlabel) continue; fillFn(trace.hoverlabel.bgcolor, cd, 'hbg'); diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index bc75f3a1711..94598806085 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -126,13 +126,17 @@ exports.loneHover = function loneHover(hoverItem, opts) { fontColor: hoverItem.fontColor, // filler to make createHoverText happy - trace: { + trace: hoverItem.trace || { index: 0, hoverinfo: '' }, xa: {_offset: 0}, ya: {_offset: 0}, - index: 0 + index: 0, + + hovertemplate: hoverItem.hovertemplate || false, + eventData: hoverItem.eventData || false, + hovertemplateLabels: hoverItem.hovertemplateLabels || false, }; var container3 = d3.select(opts.container); @@ -146,7 +150,6 @@ exports.loneHover = function loneHover(hoverItem, opts) { container: container3, outerContainer: outerContainer3 }; - var hoverLabel = createHoverText([pointData], fullOpts, opts.gd); alignHoverText(hoverLabel, fullOpts.rotateLabels); @@ -180,13 +183,17 @@ exports.multiHovers = function multiHovers(hoverItems, opts) { fontColor: hoverItem.fontColor, // filler to make createHoverText happy - trace: { + trace: hoverItem.trace || { index: 0, hoverinfo: '' }, xa: {_offset: 0}, ya: {_offset: 0}, - index: 0 + index: 0, + + hovertemplate: hoverItem.hovertemplate || false, + eventData: hoverItem.eventData || false, + hovertemplateLabels: hoverItem.hovertemplateLabels || false, }; }); @@ -662,7 +669,14 @@ function _hover(gd, evt, subplot, noHoverEvent) { // other people and send it to the event for(itemnum = 0; itemnum < hoverData.length; itemnum++) { var pt = hoverData[itemnum]; - newhoverdata.push(helpers.makeEventData(pt, pt.trace, pt.cd)); + var eventData = helpers.makeEventData(pt, pt.trace, pt.cd); + + var ht = false; + if(pt.cd[pt.index] && pt.cd[pt.index].ht) ht = pt.cd[pt.index].ht; + hoverData[itemnum].hovertemplate = ht || pt.trace.hovertemplate || false; + hoverData[itemnum].eventData = [eventData]; + + newhoverdata.push(eventData); } gd._hoverdata = newhoverdata; @@ -720,6 +734,8 @@ function _hover(gd, evt, subplot, noHoverEvent) { }); } +var EXTRA_STRING_REGEX = /([\s\S]*)<\/extra>/; + function createHoverText(hoverData, opts, gd) { var hovermode = opts.hovermode; var rotateLabels = opts.rotateLabels; @@ -763,11 +779,13 @@ function createHoverText(hoverData, opts, gd) { if(allHaveZ && hoverData[i].zLabel === undefined) allHaveZ = false; traceHoverinfo = hoverData[i].hoverinfo || hoverData[i].trace.hoverinfo; - var parts = Array.isArray(traceHoverinfo) ? traceHoverinfo : traceHoverinfo.split('+'); - if(parts.indexOf('all') === -1 && - parts.indexOf(hovermode) === -1) { - showCommonLabel = false; - break; + if(traceHoverinfo) { + var parts = Array.isArray(traceHoverinfo) ? traceHoverinfo : traceHoverinfo.split('+'); + if(parts.indexOf('all') === -1 && + parts.indexOf(hovermode) === -1) { + showCommonLabel = false; + break; + } } } @@ -950,6 +968,19 @@ function createHoverText(hoverData, opts, gd) { text = name; } + // hovertemplate + var hovertemplate = d.hovertemplate || false; + var hovertemplateLabels = d.hovertemplateLabels || d; + var eventData = d.eventData[0] || {}; + if(hovertemplate) { + text = Lib.hovertemplateString(hovertemplate, hovertemplateLabels, eventData); + + text = text.replace(EXTRA_STRING_REGEX, function(match, extra) { + name = extra; // Assign name for secondary text label + return ''; // Remove from main text label + }); + } + // main label var tx = g.select('text.nums') .call(Drawing.font, @@ -1348,7 +1379,7 @@ function cleanPoint(d, hovermode) { var infomode = d.hoverinfo || d.trace.hoverinfo; - if(infomode !== 'all') { + if(infomode && infomode !== 'all') { infomode = Array.isArray(infomode) ? infomode : infomode.split('+'); if(infomode.indexOf('x') === -1) d.xLabel = undefined; if(infomode.indexOf('y') === -1) d.yLabel = undefined; diff --git a/src/components/fx/hovertemplate_attributes.js b/src/components/fx/hovertemplate_attributes.js new file mode 100644 index 00000000000..2c19336a509 --- /dev/null +++ b/src/components/fx/hovertemplate_attributes.js @@ -0,0 +1,49 @@ +/** +* Copyright 2012-2018, 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 = function(opts, extra) { + opts = opts || {}; + extra = extra || {}; + + var descPart = extra.description ? ' ' + extra.description : ''; + var keys = extra.keys || []; + if(keys.length > 0) { + var quotedKeys = []; + for(var i = 0; i < keys.length; i++) { + quotedKeys[i] = '`' + keys[i] + '`'; + } + descPart = descPart + 'Finally, this trace also supports '; + if(keys.length === 1) { + descPart = 'variable ' + quotedKeys[0]; + } else { + descPart = 'variables ' + quotedKeys.slice(0, -1).join(', ') + ' and ' + quotedKeys.slice(-1) + '.'; + } + } + + var hovertemplate = { + valType: 'string', + role: 'info', + dflt: '', + arrayOk: true, + editType: 'none', + description: [ + 'Template string used for rendering the information that appear on hover box.', + 'Note that this will override `hoverinfo`.', + 'Variables are inserted using %{variable}, for example "y: %{y}".', + 'Numbers are formatted using d3-format\'s syntax %{variable:d3-format}, for example "Price: %{y:$.2f}".', + 'See https://github.com/d3/d3-format/blob/master/README.md#locale_format for details on the formatting syntax.', + 'The variables available in `hovertemplate` are the ones emitted as event data described at this link https://plot.ly/javascript/plotlyjs-events/#event-data.', + 'Additionally, every attributes that can be specified per-point (the ones that are `arrayOk: true`) are available.', + descPart + ].join(' ') + }; + + return hovertemplate; +}; diff --git a/src/lib/index.js b/src/lib/index.js index d0d2e34f802..5b329d5c223 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -979,10 +979,10 @@ lib.numSeparate = function(value, separators, separatethousands) { return x1 + x2; }; -var TEMPLATE_STRING_REGEX = /%{([^\s%{}]*)}/g; +var TEMPLATE_STRING_REGEX = /%{([^\s%{}:]*)(:[^}]*)?}/g; var SIMPLE_PROPERTY_REGEX = /^\w*$/; -/* +/** * Substitute values from an object into a string * * Examples: @@ -994,7 +994,6 @@ var SIMPLE_PROPERTY_REGEX = /^\w*$/; * * @return {string} templated string */ - lib.templateString = function(string, obj) { // Not all that useful, but cache nestedProperty instantiation // just in case it speeds things up *slightly*: @@ -1009,6 +1008,67 @@ lib.templateString = function(string, obj) { }); }; +var TEMPLATE_STRING_FORMAT_SEPARATOR = /^:/; +var numberOfHoverTemplateWarnings = 0; +var maximumNumberOfHoverTemplateWarnings = 10; +/** + * Substitute values from an object into a string and optionally formats them using d3-format, + * or fallback to associated labels. + * + * Examples: + * Lib.templateString('name: %{trace}', {trace: 'asdf'}) --> 'name: asdf' + * Lib.templateString('name: %{trace[0].name}', {trace: [{name: 'asdf'}]}) --> 'name: asdf' + * Lib.templateString('price: %{y:$.2f}', {y: 1}) --> 'price: $1.00' + * + * @param {string} input string containing %{...:...} template strings + * @param {obj} data object containing fallback text when no formatting is specified, ex.: {yLabel: 'formattedYValue'} + * @param {obj} data objects containing substitution values + * + * @return {string} templated string + */ +lib.hovertemplateString = function(string, labels) { + var args = arguments; + // Not all that useful, but cache nestedProperty instantiation + // just in case it speeds things up *slightly*: + var getterCache = {}; + + return string.replace(TEMPLATE_STRING_REGEX, function(match, key, format) { + var obj, value, i; + for(i = 2; i < args.length; i++) { + obj = args[i]; + if(obj.hasOwnProperty(key)) { + value = obj[key]; + break; + } + + if(!SIMPLE_PROPERTY_REGEX.test(key)) { + value = getterCache[key] || lib.nestedProperty(obj, key).get(); + if(value) getterCache[key] = value; + } + if(value !== undefined) break; + } + + if(value === undefined) { + if(numberOfHoverTemplateWarnings < maximumNumberOfHoverTemplateWarnings) { + lib.warn('Variable \'' + key + '\' in hovertemplate could not be found!'); + value = match; + } + + if(numberOfHoverTemplateWarnings === maximumNumberOfHoverTemplateWarnings) { + lib.warn('Too many hovertemplate warnings - additional warnings will be suppressed'); + } + numberOfHoverTemplateWarnings++; + } + + if(format) { + value = d3.format(format.replace(TEMPLATE_STRING_FORMAT_SEPARATOR, ''))(value); + } else { + if(labels.hasOwnProperty(key + 'Label')) value = labels[key + 'Label']; + } + return value; + }); +}; + /* * alphanumeric string sort, tailored for subplot IDs like scene2, scene10, x10y13 etc */ diff --git a/src/plots/plots.js b/src/plots/plots.js index a6d222da003..7d8947ff6b9 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1195,7 +1195,7 @@ plots.supplyTraceDefaults = function(traceIn, traceOut, colorIndex, layout, trac if(_module) { _module.supplyDefaults(traceIn, traceOut, defaultColor, layout); - Lib.coerceHoverinfo(traceIn, traceOut, layout); + if(!traceOut.hovertemplate) Lib.coerceHoverinfo(traceIn, traceOut, layout); } if(!Registry.traceIs(traceOut, 'noOpacity')) coerce('opacity'); diff --git a/src/traces/bar/attributes.js b/src/traces/bar/attributes.js index 3cdf7ec4e98..6e17bd7dc89 100644 --- a/src/traces/bar/attributes.js +++ b/src/traces/bar/attributes.js @@ -9,9 +9,11 @@ 'use strict'; var scatterAttrs = require('../scatter/attributes'); +var hovertemplateAttrs = require('../../components/fx/hovertemplate_attributes'); var colorAttributes = require('../../components/colorscale/attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); var fontAttrs = require('../../plots/font_attributes'); +var constants = require('./constants.js'); var extendFlat = require('../../lib/extend').extendFlat; @@ -60,6 +62,9 @@ module.exports = { text: scatterAttrs.text, hovertext: scatterAttrs.hovertext, + hovertemplate: hovertemplateAttrs({}, { + keys: constants.eventDataKeys + }), textposition: { valType: 'enumerated', diff --git a/src/traces/bar/constants.js b/src/traces/bar/constants.js new file mode 100644 index 00000000000..fc5f769d92a --- /dev/null +++ b/src/traces/bar/constants.js @@ -0,0 +1,14 @@ +/** +* Copyright 2012-2018, 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 = { + eventDataKeys: [] +}; diff --git a/src/traces/bar/defaults.js b/src/traces/bar/defaults.js index 11dbe6d375e..df9666a4cdd 100644 --- a/src/traces/bar/defaults.js +++ b/src/traces/bar/defaults.js @@ -37,6 +37,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('text'); coerce('hovertext'); + coerce('hovertemplate'); var textPosition = coerce('textposition'); diff --git a/src/traces/bar/hover.js b/src/traces/bar/hover.js index 5e5b5d9f9ec..b32ad58b54e 100644 --- a/src/traces/bar/hover.js +++ b/src/traces/bar/hover.js @@ -137,6 +137,7 @@ function hoverPoints(pointData, xval, yval, hovermode) { fillHoverText(di, trace, pointData); Registry.getComponentMethod('errorbars', 'hoverInfo')(di, trace, pointData); + pointData.hovertemplate = trace.hovertemplate; return [pointData]; } diff --git a/src/traces/histogram/attributes.js b/src/traces/histogram/attributes.js index 80bf57e1585..260a4a7b267 100644 --- a/src/traces/histogram/attributes.js +++ b/src/traces/histogram/attributes.js @@ -9,7 +9,9 @@ 'use strict'; var barAttrs = require('../bar/attributes'); +var hovertemplateAttrs = require('../../components/fx/hovertemplate_attributes'); var makeBinAttrs = require('./bin_attributes'); +var constants = require('./constants'); module.exports = { x: { @@ -185,6 +187,10 @@ module.exports = { ].join(' ') }, + hovertemplate: hovertemplateAttrs({}, { + keys: constants.eventDataKeys + }), + marker: barAttrs.marker, selected: barAttrs.selected, diff --git a/src/traces/histogram/constants.js b/src/traces/histogram/constants.js new file mode 100644 index 00000000000..84c1ab61216 --- /dev/null +++ b/src/traces/histogram/constants.js @@ -0,0 +1,14 @@ +/** +* Copyright 2012-2018, 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 = { + eventDataKeys: ['binNumber'] +}; diff --git a/src/traces/histogram/defaults.js b/src/traces/histogram/defaults.js index 23ef933ba18..9d5d3715461 100644 --- a/src/traces/histogram/defaults.js +++ b/src/traces/histogram/defaults.js @@ -56,6 +56,8 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout // autobin(x|y) are only included here to appease Plotly.validate coerce('autobin' + sampleLetter); + coerce('hovertemplate'); + handleStyleDefaults(traceIn, traceOut, coerce, defaultColor, layout); // override defaultColor for error bars with defaultLine diff --git a/src/traces/histogram/hover.js b/src/traces/histogram/hover.js index cf79f533d6a..b4fde83ca04 100644 --- a/src/traces/histogram/hover.js +++ b/src/traces/histogram/hover.js @@ -27,5 +27,7 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { pointData[posLetter + 'Label'] = hoverLabelText(pointData[posLetter + 'a'], di.ph0, di.ph1); } + if(trace.hovermplate) pointData.hovertemplate = trace.hovertemplate; + return pts; }; diff --git a/src/traces/pie/attributes.js b/src/traces/pie/attributes.js index 9a07c28f1e8..1b42870a83d 100644 --- a/src/traces/pie/attributes.js +++ b/src/traces/pie/attributes.js @@ -11,6 +11,7 @@ var colorAttrs = require('../../components/color/attributes'); var fontAttrs = require('../../plots/font_attributes'); var plotAttrs = require('../../plots/attributes'); +var hovertemplateAttrs = require('../../components/fx/hovertemplate_attributes'); var domainAttrs = require('../../plots/domain').attributes; var extendFlat = require('../../lib/extend').extendFlat; @@ -159,6 +160,9 @@ module.exports = { hoverinfo: extendFlat({}, plotAttrs.hoverinfo, { flags: ['label', 'text', 'value', 'percent', 'name'] }), + hovertemplate: hovertemplateAttrs({}, { + keys: ['label', 'color', 'value', 'percent', 'text'] + }), textposition: { valType: 'enumerated', role: 'info', diff --git a/src/traces/pie/defaults.js b/src/traces/pie/defaults.js index 108644c44f9..4b3c156140a 100644 --- a/src/traces/pie/defaults.js +++ b/src/traces/pie/defaults.js @@ -51,6 +51,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout var textData = coerce('text'); var textInfo = coerce('textinfo', Array.isArray(textData) ? 'text+percent' : 'percent'); coerce('hovertext'); + coerce('hovertemplate'); if(textInfo && textInfo !== 'none') { var textPosition = coerce('textposition'), diff --git a/src/traces/pie/event_data.js b/src/traces/pie/event_data.js index efaa3e8824d..272a5109836 100644 --- a/src/traces/pie/event_data.js +++ b/src/traces/pie/event_data.js @@ -24,6 +24,8 @@ module.exports = function eventData(pt, trace) { label: pt.label, color: pt.color, value: pt.v, + percent: pt.percent, + text: pt.text, // pt.v (and pt.i below) for backward compatibility v: pt.v diff --git a/src/traces/pie/plot.js b/src/traces/pie/plot.js index db45d9be2ea..eb41487af80 100644 --- a/src/traces/pie/plot.js +++ b/src/traces/pie/plot.js @@ -99,20 +99,25 @@ module.exports = function plot(gd, cdpie) { // in case we dragged over the pie from another subplot, // or if hover is turned off - if(hoverinfo !== 'none' && hoverinfo !== 'skip' && hoverinfo) { + if(trace2.hovertemplate || (hoverinfo !== 'none' && hoverinfo !== 'skip' && hoverinfo)) { var rInscribed = getInscribedRadiusFraction(pt, cd0); var hoverCenterX = cx + pt.pxmid[0] * (1 - rInscribed); var hoverCenterY = cy + pt.pxmid[1] * (1 - rInscribed); var separators = fullLayout.separators; var thisText = []; - if(hoverinfo.indexOf('label') !== -1) thisText.push(pt.label); - if(hoverinfo.indexOf('text') !== -1) { - var texti = helpers.castOption(trace2.hovertext || trace2.text, pt.pts); + if(hoverinfo && hoverinfo.indexOf('label') !== -1) thisText.push(pt.label); + pt.text = helpers.castOption(trace2.hovertext || trace2.text, pt.pts); + if(hoverinfo && hoverinfo.indexOf('text') !== -1) { + var texti = pt.text; if(texti) thisText.push(texti); } - 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)); + pt.value = pt.v; + pt.valueLabel = helpers.formatPieValue(pt.v, separators); + if(hoverinfo && hoverinfo.indexOf('value') !== -1) thisText.push(pt.valueLabel); + pt.percent = pt.v / cd0.vTotal; + pt.percentLabel = helpers.formatPiePercent(pt.percent, separators); + if(hoverinfo && hoverinfo.indexOf('percent') !== -1) thisText.push(pt.percentLabel); var hoverLabel = trace.hoverlabel; var hoverFont = hoverLabel.font; @@ -122,13 +127,18 @@ module.exports = function plot(gd, cdpie) { x1: hoverCenterX + rInscribed * cd0.r, y: hoverCenterY, text: thisText.join('
'), - name: hoverinfo.indexOf('name') !== -1 ? trace2.name : undefined, + name: (trace2.hovertemplate || hoverinfo.indexOf('name') !== -1) ? trace2.name : undefined, idealAlign: pt.pxmid[0] < 0 ? 'left' : 'right', color: helpers.castOption(hoverLabel.bgcolor, pt.pts) || pt.color, borderColor: helpers.castOption(hoverLabel.bordercolor, pt.pts), fontFamily: helpers.castOption(hoverFont.family, pt.pts), fontSize: helpers.castOption(hoverFont.size, pt.pts), - fontColor: helpers.castOption(hoverFont.color, pt.pts) + fontColor: helpers.castOption(hoverFont.color, pt.pts), + + trace: trace2, + hovertemplate: helpers.castOption(trace2.hovertemplate, pt.pts), + hovertemplateLabels: pt, + eventData: [eventData(pt, trace2)] }, { container: fullLayout2._hoverlayer.node(), outerContainer: fullLayout2._paper.node(), diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index b865858ad66..2bc6eb923bb 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -8,6 +8,7 @@ 'use strict'; +var hovertemplateAttrs = require('../../components/fx/hovertemplate_attributes'); var colorAttributes = require('../../components/colorscale/attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); var fontAttrs = require('../../plots/font_attributes'); @@ -203,6 +204,9 @@ module.exports = { 'or text, then the default is *fills*, otherwise it is *points*.' ].join(' ') }, + hovertemplate: hovertemplateAttrs({}, { + keys: constants.eventDataKeys + }), line: { color: { valType: 'color', diff --git a/src/traces/scatter/constants.js b/src/traces/scatter/constants.js index 788987e5e3e..f356d9196f6 100644 --- a/src/traces/scatter/constants.js +++ b/src/traces/scatter/constants.js @@ -21,5 +21,7 @@ module.exports = { // number of viewport sizes away from the visible region // at which we clip all lines to the perimeter - maxScreensAway: 20 + maxScreensAway: 20, + + eventDataKeys: [] }; diff --git a/src/traces/scatter/defaults.js b/src/traces/scatter/defaults.js index 5668c9ffd9d..772406dbb9f 100644 --- a/src/traces/scatter/defaults.js +++ b/src/traces/scatter/defaults.js @@ -75,7 +75,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout dfltHoverOn.push('fills'); } coerce('hoveron', dfltHoverOn.join('+') || 'points'); - + if(traceOut.hoveron !== 'fills') coerce('hovertemplate'); var errorBarsSupplyDefaults = Registry.getComponentMethod('errorbars', 'supplyDefaults'); errorBarsSupplyDefaults(traceIn, traceOut, defaultColor, {axis: 'y'}); errorBarsSupplyDefaults(traceIn, traceOut, defaultColor, {axis: 'x', inherit: 'y'}); diff --git a/src/traces/scatter/hover.js b/src/traces/scatter/hover.js index 6efe4ca9053..f133f062811 100644 --- a/src/traces/scatter/hover.js +++ b/src/traces/scatter/hover.js @@ -93,7 +93,8 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { y1: yc + rad, yLabelVal: yLabelVal, - spikeDistance: dxy(di) + spikeDistance: dxy(di), + hovertemplate: trace.hovertemplate }); fillHoverText(di, trace, pointData); @@ -177,7 +178,8 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { x1: xmax, y0: yAvg, y1: yAvg, - color: color + color: color, + hovertemplate: '%{name}' }); delete pointData.index; diff --git a/src/traces/scattergl/attributes.js b/src/traces/scattergl/attributes.js index aebdf2b4146..08b715d06ea 100644 --- a/src/traces/scattergl/attributes.js +++ b/src/traces/scattergl/attributes.js @@ -97,3 +97,4 @@ var attrs = module.exports = overrideAll({ }, 'calc', 'nested'); attrs.x.editType = attrs.y.editType = attrs.x0.editType = attrs.y0.editType = 'calc+clearAxisTypes'; +attrs.hovertemplate = scatterAttrs.hovertemplate; diff --git a/src/traces/scattergl/defaults.js b/src/traces/scattergl/defaults.js index 1120edea4f1..01a92a4ef48 100644 --- a/src/traces/scattergl/defaults.js +++ b/src/traces/scattergl/defaults.js @@ -37,6 +37,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('text'); coerce('hovertext'); + coerce('hovertemplate'); coerce('mode', defaultMode); if(subTypes.hasLines(traceOut)) { diff --git a/src/traces/scattergl/index.js b/src/traces/scattergl/index.js index 1fd6f3c23d6..f6c2ba9be1b 100644 --- a/src/traces/scattergl/index.js +++ b/src/traces/scattergl/index.js @@ -816,6 +816,11 @@ function calcHover(pointData, x, y, trace) { di.hi = Array.isArray(hoverinfo) ? hoverinfo[id] : hoverinfo; } + var hovertemplate = trace.hovertemplate; + if(hovertemplate) { + di.ht = Array.isArray(hovertemplate) ? hovertemplate[id] : hovertemplate; + } + var fakeCd = {}; fakeCd[pointData.index] = di; @@ -832,7 +837,9 @@ function calcHover(pointData, x, y, trace) { cd: fakeCd, distance: minDist, - spikeDistance: dxy + spikeDistance: dxy, + + hovertemplate: di.ht }); if(di.htx) pointData.text = di.htx; diff --git a/test/jasmine/assets/check_event_data.js b/test/jasmine/assets/check_event_data.js new file mode 100644 index 00000000000..9df72fd7b1f --- /dev/null +++ b/test/jasmine/assets/check_event_data.js @@ -0,0 +1,42 @@ +var Plotly = require('@lib/index'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var Lib = require('@src/lib'); + +var hover = require('../assets/hover'); + +'use strict'; + +module.exports = function checkEventData(mock, x, y, additionalFields) { + var mockCopy = Lib.extendDeep({}, mock), + gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout) + .then(done); + }); + + afterEach(destroyGraphDiv); + + it('should contain the correct fields', function() { + var hoverData; + + gd.on('plotly_hover', function(data) { + hoverData = data; + }); + + hover(x, y); + + var fields = [ + 'curveNumber', + 'data', 'fullData', + 'xaxis', 'yaxis', 'x', 'y', + ].concat(additionalFields); + + fields.forEach(function(field) { + expect(Object.keys(hoverData.points[0])).toContain(field); + }); + }); +}; diff --git a/test/jasmine/tests/bar_test.js b/test/jasmine/tests/bar_test.js index 388a1aabac8..e950af844ee 100644 --- a/test/jasmine/tests/bar_test.js +++ b/test/jasmine/tests/bar_test.js @@ -17,9 +17,14 @@ var supplyAllDefaults = require('../assets/supply_defaults'); var color = require('../../../src/components/color'); var rgb = color.rgb; +var checkEventData = require('../assets/check_event_data'); +var constants = require('@src/traces/bar/constants'); + var customAssertions = require('../assets/custom_assertions'); var assertClip = customAssertions.assertClip; var assertNodeDisplay = customAssertions.assertNodeDisplay; +var assertHoverLabelContent = customAssertions.assertHoverLabelContent; +var Fx = require('@src/components/fx'); var d3 = require('d3'); @@ -2041,6 +2046,34 @@ describe('bar hover', function() { .catch(failTest) .then(done); }); + + it('should use hovertemplate if specified', function(done) { + gd = createGraphDiv(); + + var mock = Lib.extendDeep({}, require('@mocks/text_chart_arrays')); + mock.data.forEach(function(t) { + t.type = 'bar'; + t.hovertemplate = '%{y}'; + }); + + function _hover() { + var evt = { xpx: 125, ypx: 150 }; + Fx.hover('graph', evt, 'xy'); + } + + Plotly.plot(gd, mock) + .then(_hover) + .then(function() { + assertHoverLabelContent({ + nums: ['1', '2', '1.5'], + name: ['', '', ''], + axis: '0' + }); + // return Plotly.restyle(gd, 'text', ['APPLE', 'BANANA', 'ORANGE']); + }) + .catch(failTest) + .then(done); + }); }); describe('with special width/offset combinations', function() { @@ -2266,6 +2299,11 @@ describe('bar hover', function() { }); }); +describe('event data', function() { + var mock = require('@mocks/stacked_bar'); + checkEventData(mock, 216, 309, constants.eventDataKeys); +}); + function mockBarPlot(dataWithoutTraceType, layout) { var traceTemplate = { type: 'bar' }; diff --git a/test/jasmine/tests/histogram_test.js b/test/jasmine/tests/histogram_test.js index 2cb91b71f26..87e02680ae3 100644 --- a/test/jasmine/tests/histogram_test.js +++ b/test/jasmine/tests/histogram_test.js @@ -13,6 +13,8 @@ var destroyGraphDiv = require('../assets/destroy_graph_div'); var supplyAllDefaults = require('../assets/supply_defaults'); var failTest = require('../assets/fail_test'); +var checkEventData = require('../assets/check_event_data'); +var constants = require('@src/traces/histogram/constants'); describe('Test histogram', function() { 'use strict'; @@ -1070,3 +1072,8 @@ describe('getBinSpanLabelRound', function() { ]); }); }); + +describe('event data', function() { + var mock = require('@mocks/hist_category'); + checkEventData(mock, 100, 200, constants.eventDataKeys); +}); diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index eefb936bb74..dbb12c7754b 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -1549,6 +1549,133 @@ describe('hover info', function() { .toBeWithin(0, 1); // Be robust against floating point arithmetic and subtle future layout changes }); }); + + describe('hovertemplate', function() { + var mockCopy = Lib.extendDeep({}, mock); + + beforeEach(function(done) { + Plotly.plot(createGraphDiv(), mockCopy.data, mockCopy.layout).then(done); + }); + + it('should format labels according to a template string', function(done) { + var gd = document.getElementById('graph'); + Plotly.restyle(gd, 'hovertemplate', '%{y:$.2f}trace 0') + .then(function() { + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(17); + expect(hoverTrace.x).toEqual(0.388); + expect(hoverTrace.y).toEqual(1); + + assertHoverLabelContent({ + nums: '$1.00', + name: 'trace 0', + axis: '0.388' + }); + }) + .catch(failTest) + .then(done); + }); + + it('should format secondary label with extra tag', function(done) { + var gd = document.getElementById('graph'); + Plotly.restyle(gd, 'hovertemplate', 'trace 20 %{y:$.2f}') + .then(function() { + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(17); + expect(hoverTrace.x).toEqual(0.388); + expect(hoverTrace.y).toEqual(1); + + assertHoverLabelContent({ + nums: '', + name: 'trace 20 $1.00', + axis: '0.388' + }); + }) + .catch(failTest) + .then(done); + }); + + it('should support pseudo-html', function(done) { + var gd = document.getElementById('graph'); + Plotly.restyle(gd, 'hovertemplate', '%{y:$.2f}
%{fullData.name}') + .then(function() { + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(17); + expect(hoverTrace.x).toEqual(0.388); + expect(hoverTrace.y).toEqual(1); + + assertHoverLabelContent({ + nums: '$1.00\nPV learning curve.txt', + name: '', + axis: '0.388' + }); + }) + .catch(failTest) + .then(done); + }); + + it('should support array', function(done) { + var gd = document.getElementById('graph'); + var templates = []; + for(var i = 0; i < mockCopy.data[0].y.length; i++) { + templates[i] = 'hovertemplate ' + i + ':%{y:$.2f}'; + } + Plotly.restyle(gd, 'hovertemplate', [templates]) + .then(function() { + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(17); + expect(hoverTrace.x).toEqual(0.388); + expect(hoverTrace.y).toEqual(1); + + assertHoverLabelContent({ + nums: 'hovertemplate 17:$1.00', + name: '', + axis: '0.388' + }); + }) + .catch(failTest) + .then(done); + }); + + it('should contain the axis names', function(done) { + var gd = document.getElementById('graph'); + Plotly.restyle(gd, 'hovertemplate', '%{yaxis.title}:%{y:$.2f}
%{xaxis.title}:%{x:0.4f}') + .then(function() { + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(17); + expect(hoverTrace.x).toEqual(0.388); + expect(hoverTrace.y).toEqual(1); + + assertHoverLabelContent({ + nums: 'Cost ($/W​P):$1.00\nCumulative Production (GW):0.3880', + name: '', + axis: '0.388' + }); + }) + .catch(failTest) + .then(done); + }); + }); }); describe('hover info on stacked subplots', function() { diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 2ffe0710c55..c3e2e6ebb6e 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -2163,6 +2163,66 @@ describe('Test lib.js:', function() { }); }); + describe('hovertemplateString', function() { + it('evaluates attributes', function() { + expect(Lib.hovertemplateString('foo %{bar}', {}, {bar: 'baz'})).toEqual('foo baz'); + }); + + it('evaluates attributes with a dot in their name', function() { + expect(Lib.hovertemplateString('%{marker.size}', {}, {'marker.size': 12}, {marker: {size: 14}})).toEqual('12'); + }); + + it('evaluates nested properties', function() { + expect(Lib.hovertemplateString('foo %{bar.baz}', {}, {bar: {baz: 'asdf'}})).toEqual('foo asdf'); + }); + + it('evaluates array nested properties', function() { + expect(Lib.hovertemplateString('foo %{bar[0].baz}', {}, {bar: [{baz: 'asdf'}]})).toEqual('foo asdf'); + }); + + it('subtitutes multiple matches', function() { + expect(Lib.hovertemplateString('foo %{group} %{trace}', {}, {group: 'asdf', trace: 'jkl;'})).toEqual('foo asdf jkl;'); + }); + + it('replaces missing matches with template string', function() { + expect(Lib.hovertemplateString('foo %{group} %{trace}', {}, {group: 1})).toEqual('foo 1 %{trace}'); + }); + + it('uses the value from the first object with the specified key', function() { + var obj1 = {a: 'first'}, obj2 = {a: 'second', foo: {bar: 'bar'}}; + + // Simple key + expect(Lib.hovertemplateString('foo %{a}', {}, obj1, obj2)).toEqual('foo first'); + expect(Lib.hovertemplateString('foo %{a}', {}, obj2, obj1)).toEqual('foo second'); + + // Nested Keys + expect(Lib.hovertemplateString('foo %{foo.bar}', {}, obj1, obj2)).toEqual('foo bar'); + + // Nested keys with 0 + expect(Lib.hovertemplateString('y: %{y}', {}, {y: 0}, {y: 1})).toEqual('y: 0'); + }); + + it('formats value using d3 mini-language', function() { + expect(Lib.hovertemplateString('a: %{a:.0%}', {}, {a: 0.123})).toEqual('a: 12%'); + expect(Lib.hovertemplateString('b: %{b:2.2f}', {}, {b: 43})).toEqual('b: 43.00'); + }); + + it('looks for default label if no format is provided', function() { + expect(Lib.hovertemplateString('y: %{y}', {yLabel: '0.1'}, {y: 0.123})).toEqual('y: 0.1'); + }); + + it('warns user up to 10 times if a variable cannot be found', function() { + spyOn(Lib, 'warn').and.callThrough(); + Lib.hovertemplateString('%{idontexist}', {}); + expect(Lib.warn.calls.count()).toBe(1); + + for(var i = 0; i < 15; i++) { + Lib.hovertemplateString('%{idontexist}', {}); + } + expect(Lib.warn.calls.count()).toBe(10); + }); + }); + describe('relativeAttr()', function() { it('replaces the last part always', function() { expect(Lib.relativeAttr('annotations[3].x', 'y')).toBe('annotations[3].y'); diff --git a/test/jasmine/tests/pie_test.js b/test/jasmine/tests/pie_test.js index 755ece08bdb..0a9cfd04866 100644 --- a/test/jasmine/tests/pie_test.js +++ b/test/jasmine/tests/pie_test.js @@ -845,7 +845,7 @@ describe('pie hovering', function() { 'curveNumber', 'pointNumber', 'pointNumbers', 'data', 'fullData', 'label', 'color', 'value', - 'i', 'v' + 'i', 'v', 'percent', 'text' ]; expect(Object.keys(hoverData.points[0]).sort()).toEqual(fields.sort()); @@ -1013,6 +1013,72 @@ describe('pie hovering', function() { }) .then(done); }); + + it('should use hovertemplate if specified', function(done) { + Plotly.plot(gd, mockCopy.data, mockCopy.layout) + .then(_hover) + .then(function() { + assertLabel( + ['4', '5', '33.3%'].join('\n'), + ['rgb(31, 119, 180)', 'rgb(255, 255, 255)', 13, 'Arial', 'rgb(255, 255, 255)'], + 'initial' + ); + + return Plotly.restyle(gd, 'hovertemplate', '%{value}'); + }) + .then(_hover) + .then(function() { + assertLabel( + ['5'].join('\n'), + null, + 'hovertemplate %{value}' + ); + + return Plotly.restyle(gd, { + 'text': [['A', 'B', 'C', 'D', 'E']], + 'hovertemplate': '%{text}' + }); + }) + .then(_hover) + .then(function() { + assertLabel( + ['E'].join('\n'), + null, + 'hovertemplate %{text}' + ); + + return Plotly.restyle(gd, 'hovertemplate', '%{percent}'); + }) + .then(_hover) + .then(function() { + assertLabel( + ['33.3%'].join('\n'), + null, + 'hovertemplate %{percent}' + ); + + return Plotly.restyle(gd, 'hovertemplate', '%{label}'); + }) + .then(_hover) + .then(function() { + assertLabel( + ['4'].join('\n'), + null, + 'hovertemplate %{label}' + ); + }) + .then(function() { return Plotly.restyle(gd, 'hovertemplate', [['', '', '', '', 'ht 5 %{percent:0.2%}']]); }) + .then(_hover) + .then(function() { + assertLabel( + ['ht 5 33.33%'].join('\n'), + null, + 'hovertemplate arrayOK' + ); + }) + .catch(fail) + .then(done); + }); }); }); diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index 6e00acd23d7..49c7c33ed54 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -251,6 +251,14 @@ describe('Test Plots', function() { traceOut = supplyTraceDefaults(traceIn, {type: 'scatter'}, 0, layout); expect(traceOut.hoverinfo).toEqual('name'); }); + + it('only if hovertemplate is not defined', function() { + layout._dataLength = 1; + + traceIn = {}; + traceOut = supplyTraceDefaults(traceIn, {type: 'scatter', hovertemplate: '%{y}'}, 0, layout); + expect(traceOut.hoverinfo).toBeUndefined(); + }); }); }); diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index fbb54df2ad5..d88e1c10351 100644 --- a/test/jasmine/tests/scatter_test.js +++ b/test/jasmine/tests/scatter_test.js @@ -14,6 +14,8 @@ var transitions = require('../assets/transitions'); var assertClip = customAssertions.assertClip; var assertNodeDisplay = customAssertions.assertNodeDisplay; var assertMultiNodeOrder = customAssertions.assertMultiNodeOrder; +var checkEventData = require('../assets/check_event_data'); +var constants = require('@src/traces/scatter/constants'); var getOpacity = function(node) { return Number(node.style.opacity); }; var getFillOpacity = function(node) { return Number(node.style['fill-opacity']); }; @@ -1821,3 +1823,17 @@ describe('Test scatter *clipnaxis*:', function() { .then(done); }); }); + +describe('event data', function() { + var mock = require('@mocks/scatter-colorscale-colorbar'); + var mockCopy = Lib.extendDeep({}, mock); + + var marker = mockCopy.data[0].marker; + marker.opacity = []; + marker.symbol = []; + for(var i = 0; i < mockCopy.data[0].y.length; ++i) { + marker.opacity.push(0.5); + marker.symbol.push('square'); + } + checkEventData(mockCopy, 540, 260, constants.eventDataKeys); +});