diff --git a/build/plotcss.js b/build/plotcss.js index 4594c5d52b7..ca34a733a5f 100644 --- a/build/plotcss.js +++ b/build/plotcss.js @@ -14,6 +14,7 @@ var rules = { "X svg a:hover": "fill:#3c6dc5;", "X .main-svg": "position:absolute;top:0;left:0;pointer-events:none;", "X .main-svg .draglayer": "pointer-events:all;", + "X .cursor-default": "cursor:default;", "X .cursor-pointer": "cursor:pointer;", "X .cursor-crosshair": "cursor:crosshair;", "X .cursor-move": "cursor:move;", diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index c11a0822549..b59c0aacd59 100644 --- a/src/components/annotations/annotation_defaults.js +++ b/src/components/annotations/annotation_defaults.js @@ -12,6 +12,7 @@ var Lib = require('../../lib'); var Color = require('../color'); var Axes = require('../../plots/cartesian/axes'); +var constants = require('../../plots/cartesian/constants'); var attributes = require('./attributes'); @@ -30,7 +31,7 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op if(!(visible || clickToShow)) return annOut; coerce('opacity'); - coerce('bgcolor'); + var bgColor = coerce('bgcolor'); var borderColor = coerce('bordercolor'), borderOpacity = Color.opacity(borderColor); @@ -108,5 +109,18 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op annOut._yclick = (yClick === undefined) ? annOut.y : yClick; } + var hoverText = coerce('hovertext'); + if(hoverText) { + var hoverBG = coerce('hoverlabel.bgcolor', + Color.opacity(bgColor) ? Color.rgb(bgColor) : Color.defaultLine); + var hoverBorder = coerce('hoverlabel.bordercolor', Color.contrast(hoverBG)); + Lib.coerceFont(coerce, 'hoverlabel.font', { + family: constants.HOVERFONT, + size: constants.HOVERFONTSIZE, + color: hoverBorder + }); + } + coerce('captureevents', !!hoverText); + return annOut; }; diff --git a/src/components/annotations/attributes.js b/src/components/annotations/attributes.js index 22567530137..dd07be6ce28 100644 --- a/src/components/annotations/attributes.js +++ b/src/components/annotations/attributes.js @@ -378,6 +378,53 @@ module.exports = { 'is `yclick` rather than the annotation\'s `y` value.' ].join(' ') }, + hovertext: { + valType: 'string', + role: 'info', + description: [ + 'Sets text to appear when hovering over this annotation.', + 'If omitted or blank, no hover label will appear.' + ].join(' ') + }, + hoverlabel: { + bgcolor: { + valType: 'color', + role: 'style', + description: [ + 'Sets the background color of the hover label.', + 'By default uses the annotation\'s `bgcolor` made opaque,', + 'or white if it was transparent.' + ].join(' ') + }, + bordercolor: { + valType: 'color', + role: 'style', + description: [ + 'Sets the border color of the hover label.', + 'By default uses either dark grey or white, for maximum', + 'contrast with `hoverlabel.bgcolor`.' + ].join(' ') + }, + font: extendFlat({}, fontAttrs, { + description: [ + 'Sets the hover label text font.', + 'By default uses the global hover font and size,', + 'with color from `hoverlabel.bordercolor`.' + ].join(' ') + }) + }, + captureevents: { + valType: 'boolean', + role: 'info', + description: [ + 'Determines whether the annotation text box captures mouse move', + 'and click events, or allows those events to pass through to data', + 'points in the plot that may be behind the annotation. By default', + '`captureevents` is *false* unless `hovertext` is provided.', + 'If you use the event `plotly_clickannotation` without `hovertext`', + 'you must explicitly enable `captureevents`.' + ].join(' ') + }, _deprecated: { ref: { diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 2ed9e9429f2..80c5c76a19a 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -15,6 +15,7 @@ var Plotly = require('../../plotly'); var Plots = require('../../plots/plots'); var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); +var Fx = require('../../plots/cartesian/graph_interact'); var Color = require('../color'); var Drawing = require('../drawing'); var svgTextUtils = require('../../lib/svg_text_utils'); @@ -96,7 +97,16 @@ function drawOne(gd, index) { var annGroup = fullLayout._infolayer.append('g') .classed('annotation', true) .attr('data-index', String(index)) - .style('opacity', options.opacity) + .style('opacity', options.opacity); + + // another group for text+background so that they can rotate together + var annTextGroup = annGroup.append('g') + .classed('annotation-text-g', true) + .attr('data-index', String(index)); + + var annTextGroupInner = annTextGroup.append('g') + .style('pointer-events', options.captureevents ? 'all' : null) + .call(setCursor, 'default') .on('click', function() { gd._dragging = false; gd.emit('plotly_clickannotation', { @@ -106,12 +116,33 @@ function drawOne(gd, index) { }); }); - // another group for text+background so that they can rotate together - var annTextGroup = annGroup.append('g') - .classed('annotation-text-g', true) - .attr('data-index', String(index)); - - var annTextGroupInner = annTextGroup.append('g'); + if(options.hovertext) { + annTextGroupInner + .on('mouseover', function() { + var hoverOptions = options.hoverlabel; + var hoverFont = hoverOptions.font; + var bBox = this.getBoundingClientRect(); + var bBoxRef = gd.getBoundingClientRect(); + + Fx.loneHover({ + x0: bBox.left - bBoxRef.left, + x1: bBox.right - bBoxRef.left, + y: (bBox.top + bBox.bottom) / 2 - bBoxRef.top, + text: options.hovertext, + color: hoverOptions.bgcolor, + borderColor: hoverOptions.bordercolor, + fontFamily: hoverFont.family, + fontSize: hoverFont.size, + fontColor: hoverFont.color + }, { + container: fullLayout._hoverlayer.node(), + outerContainer: fullLayout._paper.node() + }); + }) + .on('mouseout', function() { + Fx.loneUnhover(fullLayout._hoverlayer.node()); + }); + } var borderwidth = options.borderwidth, borderpad = options.borderpad, diff --git a/src/components/color/index.js b/src/components/color/index.js index 714d5a05cad..1eb87301bbe 100644 --- a/src/components/color/index.js +++ b/src/components/color/index.js @@ -16,10 +16,14 @@ var color = module.exports = {}; var colorAttrs = require('./attributes'); color.defaults = colorAttrs.defaults; -color.defaultLine = colorAttrs.defaultLine; +var defaultLine = color.defaultLine = colorAttrs.defaultLine; color.lightLine = colorAttrs.lightLine; -color.background = colorAttrs.background; +var background = color.background = colorAttrs.background; +/* + * tinyRGB: turn a tinycolor into an rgb string, but + * unlike the built-in tinycolor.toRgbString this never includes alpha + */ color.tinyRGB = function(tc) { var c = tc.toRgb(); return 'rgb(' + Math.round(c.r) + ', ' + @@ -43,7 +47,7 @@ color.combine = function(front, back) { var fc = tinycolor(front).toRgb(); if(fc.a === 1) return tinycolor(front).toRgbString(); - var bc = tinycolor(back || color.background).toRgb(), + var bc = tinycolor(back || background).toRgb(), bcflat = bc.a === 1 ? bc : { r: 255 * (1 - bc.a) + bc.r * bc.a, g: 255 * (1 - bc.a) + bc.g * bc.a, @@ -57,12 +61,22 @@ color.combine = function(front, back) { return tinycolor(fcflat).toRgbString(); }; +/* + * Create a color that contrasts with cstr. + * + * If cstr is a dark color, we lighten it; if it's light, we darken. + * + * If lightAmount / darkAmount are used, we adjust by these percentages, + * otherwise we go all the way to white or black. + */ color.contrast = function(cstr, lightAmount, darkAmount) { var tc = tinycolor(cstr); - var newColor = tc.isLight() ? - tc.darken(darkAmount) : - tc.lighten(lightAmount); + if(tc.getAlpha() !== 1) tc = tinycolor(color.combine(cstr, background)); + + var newColor = tc.isDark() ? + (lightAmount ? tc.lighten(lightAmount) : background) : + (darkAmount ? tc.darken(darkAmount) : defaultLine); return newColor.toString(); }; diff --git a/src/css/_cursor.scss b/src/css/_cursor.scss index 3efe38ba579..6ebe9984aa8 100644 --- a/src/css/_cursor.scss +++ b/src/css/_cursor.scss @@ -1,3 +1,4 @@ +.cursor-default { cursor: default; } .cursor-pointer { cursor: pointer; } .cursor-crosshair { cursor: crosshair; } .cursor-move { cursor: move; } diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js index f14bb31b5cf..d1307a8cbb5 100644 --- a/src/plots/cartesian/graph_interact.js +++ b/src/plots/cartesian/graph_interact.js @@ -10,7 +10,6 @@ 'use strict'; var d3 = require('d3'); -var tinycolor = require('tinycolor2'); var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); @@ -246,9 +245,7 @@ function quadrature(dx, dy) { // size and display constants for hover text var HOVERARROWSIZE = constants.HOVERARROWSIZE, - HOVERTEXTPAD = constants.HOVERTEXTPAD, - HOVERFONTSIZE = constants.HOVERFONTSIZE, - HOVERFONT = constants.HOVERFONT; + HOVERTEXTPAD = constants.HOVERTEXTPAD; // fx.hover: highlight data on hover // evt can be a mousemove event, or an object with data about what points @@ -755,23 +752,36 @@ function cleanPoint(d, hovermode) { return d; } +/* + * Draw a single hover item in a pre-existing svg container somewhere + * hoverItem should have keys: + * - x and y (or x0, x1, y0, and y1): + * the pixel position to mark, relative to opts.container + * - xLabel, yLabel, zLabel, text, and name: + * info to go in the label + * - color: + * the background color for the label. + * - idealAlign (optional): + * 'left' or 'right' for which side of the x/y box to try to put this on first + * - borderColor (optional): + * color for the border, defaults to strongest contrast with color + * - fontFamily (optional): + * string, the font for this label, defaults to constants.HOVERFONT + * - fontSize (optional): + * the label font size, defaults to constants.HOVERFONTSIZE + * - fontColor (optional): + * defaults to borderColor + * opts should have keys: + * - bgColor: + * the background color this is against, used if the trace is + * non-opaque, and for the name, which goes outside the box + * - container: + * a or element to add the hover label to + * - outerContainer: + * normally a parent of `container`, sets the bounding box to use to + * constrain the hover label and determine whether to show it on the left or right + */ fx.loneHover = function(hoverItem, opts) { - // draw a single hover item in a pre-existing svg container somewhere - // hoverItem should have keys: - // - x and y (or x0, x1, y0, and y1): - // the pixel position to mark, relative to opts.container - // - xLabel, yLabel, zLabel, text, and name: - // info to go in the label - // - color: - // the background color for the label. text & outline color will - // be chosen black or white to contrast with this - // 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 dom element - must be big enough to contain the whole - // hover label var pointData = { color: hoverItem.color || Color.defaultLine, x0: hoverItem.x0 || hoverItem.x || 0, @@ -785,6 +795,12 @@ fx.loneHover = function(hoverItem, opts) { name: hoverItem.name, idealAlign: hoverItem.idealAlign, + // optional extra bits of styling + borderColor: hoverItem.borderColor, + fontFamily: hoverItem.fontFamily, + fontSize: hoverItem.fontSize, + fontColor: hoverItem.fontColor, + // filler to make createHoverText happy trace: { index: 0, @@ -830,6 +846,12 @@ function createHoverText(hoverData, opts) { container = opts.container, outerContainer = opts.outerContainer, + // opts.fontFamily/Size are used for the common label + // and as defaults for each hover label, though the individual labels + // can override this. + fontFamily = opts.fontFamily || constants.HOVERFONT, + fontSize = opts.fontSize || constants.HOVERFONTSIZE, + c0 = hoverData[0], xa = c0.xa, ya = c0.ya, @@ -874,7 +896,7 @@ function createHoverText(hoverData, opts) { lpath.enter().append('path') .style({fill: Color.defaultLine, 'stroke-width': '1px', stroke: Color.background}); ltext.enter().append('text') - .call(Drawing.font, HOVERFONT, HOVERFONTSIZE, Color.background) + .call(Drawing.font, fontFamily, fontSize, Color.background) // prohibit tex interpretation until we can handle // tex and regular text together .attr('data-notex', 1); @@ -955,13 +977,12 @@ function createHoverText(hoverData, opts) { // trace name label (rect and text.name) g.append('rect') .call(Color.fill, Color.addOpacity(bgColor, 0.8)); - g.append('text').classed('name', true) - .call(Drawing.font, HOVERFONT, HOVERFONTSIZE); + 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, HOVERFONT, HOVERFONTSIZE); + .call(Drawing.font, fontFamily, fontSize); }); hoverLabels.exit().remove(); @@ -977,8 +998,7 @@ function createHoverText(hoverData, opts) { traceColor = Color.combine(baseColor, bgColor), // find a contrasting color for border and text - contrastColor = tinycolor(traceColor).getBrightness() > 128 ? - '#000' : Color.background; + contrastColor = d.borderColor || Color.contrast(traceColor); // to get custom 'name' labels pass cleanPoint if(d.nameOverride !== undefined) d.name = d.nameOverride; @@ -1023,7 +1043,10 @@ function createHoverText(hoverData, opts) { // main label var tx = g.select('text.nums') - .style('fill', contrastColor) + .call(Drawing.font, + d.fontFamily || fontFamily, + d.fontSize || fontSize, + d.fontColor || contrastColor) .call(Drawing.setPosition, 0, 0) .text(text) .attr('data-notex', 1) @@ -1036,7 +1059,10 @@ function createHoverText(hoverData, opts) { // secondary label for non-empty 'name' if(name && name !== text) { - tx2.style('fill', traceColor) + tx2.call(Drawing.font, + d.fontFamily || fontFamily, + d.fontSize || fontSize, + traceColor) .text(name) .call(Drawing.setPosition, 0, 0) .attr('data-notex', 1) diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index ee86b379b34..9a24aa600e5 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -5,6 +5,7 @@ var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); var Loggers = require('@src/lib/loggers'); var Axes = require('@src/plots/cartesian/axes'); +var constants = require('@src/plots/cartesian/constants'); var d3 = require('d3'); var customMatchers = require('../assets/custom_matchers'); @@ -12,6 +13,8 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var failTest = require('../assets/fail_test'); var drag = require('../assets/drag'); +var mouseEvent = require('../assets/mouse_event'); +var click = require('../assets/click'); describe('Test annotations', function() { @@ -812,7 +815,7 @@ describe('annotation clicktoshow', function() { }); }); -describe('annotation dragging', function() { +describe('annotation effects', function() { var gd; function textDrag() { return gd.querySelector('.annotation-text-g>g'); } @@ -823,35 +826,30 @@ describe('annotation dragging', function() { jasmine.addMatchers(customMatchers); }); - beforeEach(function(done) { + function makePlot(annotations, config) { gd = createGraphDiv(); + if(!config) config = {editable: true}; + // we've already tested autorange with relayout, so fix the geometry // completely so we know exactly what we're dealing with // plot area is 300x300, and covers data range 100x100 - Plotly.plot(gd, + return Plotly.plot(gd, [{x: [0, 100], y: [0, 100], mode: 'markers'}], { xaxis: {range: [0, 100]}, yaxis: {range: [0, 100]}, width: 500, height: 500, - margin: {l: 100, r: 100, t: 100, b: 100, pad: 0} + margin: {l: 100, r: 100, t: 100, b: 100, pad: 0}, + annotations: annotations }, - {editable: true} - ) - .then(done); - }); + config + ); + } afterEach(destroyGraphDiv); - function initAnnotation(annotation) { - return Plotly.relayout(gd, {annotations: [annotation]}) - .then(function() { - return Plots.previousPromises(gd); - }); - } - function dragAndReplot(node, dx, dy, edge) { return drag(node, dx, dy, edge).then(function() { return Plots.previousPromises(gd); @@ -957,14 +955,14 @@ describe('annotation dragging', function() { } it('respects anchor: auto when paper-referenced without arrow', function(done) { - initAnnotation({ + makePlot([{ x: 0, y: 0, showarrow: false, text: 'blah
blah blah', xref: 'paper', yref: 'paper' - }) + }]) .then(function() { var bbox = textBox().getBoundingClientRect(); @@ -975,7 +973,7 @@ describe('annotation dragging', function() { }); it('also works paper-referenced with explicit anchors and no arrow', function(done) { - initAnnotation({ + makePlot([{ x: 0, y: 0, showarrow: false, @@ -984,7 +982,7 @@ describe('annotation dragging', function() { yref: 'paper', xanchor: 'left', yanchor: 'top' - }) + }]) .then(function() { // with offsets 0, 0 because the anchor doesn't change now return checkDragging(textDrag, 0, 0, 1); @@ -994,7 +992,7 @@ describe('annotation dragging', function() { }); it('works paper-referenced with arrows', function(done) { - initAnnotation({ + makePlot([{ x: 0, y: 0, text: 'blah
blah blah', @@ -1002,7 +1000,7 @@ describe('annotation dragging', function() { yref: 'paper', ax: 30, ay: 30 - }) + }]) .then(function() { return checkDragging(arrowDrag, 0, 0, 1); }) @@ -1012,12 +1010,12 @@ describe('annotation dragging', function() { }); it('works data-referenced with no arrow', function(done) { - initAnnotation({ + makePlot([{ x: 0, y: 0, showarrow: false, text: 'blah
blah blah' - }) + }]) .then(function() { return checkDragging(textDrag, 0, 0, 100); }) @@ -1026,13 +1024,13 @@ describe('annotation dragging', function() { }); it('works data-referenced with arrow', function(done) { - initAnnotation({ + makePlot([{ x: 0, y: 0, text: 'blah
blah blah', ax: 30, ay: -30 - }) + }]) .then(function() { return checkDragging(arrowDrag, 0, 0, 100); }) @@ -1040,33 +1038,17 @@ describe('annotation dragging', function() { .catch(failTest) .then(done); }); -}); - -describe('annotation clip paths', function() { - var gd; - - beforeEach(function(done) { - gd = createGraphDiv(); - - // we've already tested autorange with relayout, so fix the geometry - // completely so we know exactly what we're dealing with - // plot area is 300x300, and covers data range 100x100 - Plotly.plot(gd, [{x: [0, 100], y: [0, 100]}], { - annotations: [ - {x: 50, y: 50, text: 'hi', width: 50}, - {x: 20, y: 20, text: 'bye', height: 40}, - {x: 80, y: 80, text: 'why?'} - ] - }) - .then(done); - }); - - afterEach(destroyGraphDiv); it('should only make the clippaths it needs and delete others', function(done) { - expect(d3.select(gd).selectAll('.annclip').size()).toBe(2); + makePlot([ + {x: 50, y: 50, text: 'hi', width: 50, ax: 0, ay: -20}, + {x: 20, y: 20, text: 'bye', height: 40, showarrow: false}, + {x: 80, y: 80, text: 'why?', ax: 0, ay: -20} + ]).then(function() { + expect(d3.select(gd).selectAll('.annclip').size()).toBe(2); - Plotly.relayout(gd, {'annotations[0].visible': false}) + return Plotly.relayout(gd, {'annotations[0].visible': false}); + }) .then(function() { expect(d3.select(gd).selectAll('.annclip').size()).toBe(1); @@ -1088,4 +1070,146 @@ describe('annotation clip paths', function() { .catch(failTest) .then(done); }); + + it('should register clicks and show hover effects on the text box only', function(done) { + var gdBB, pos0Head, pos0, pos1, pos2Head, pos2, clickData; + + function assertHoverLabel(pos, text, msg) { + return new Promise(function(resolve) { + mouseEvent('mousemove', pos[0], pos[1]); + mouseEvent('mouseover', pos[0], pos[1]); + + setTimeout(function() { + var hoverText = d3.selectAll('g.hovertext'); + expect(hoverText.size()).toEqual(text ? 1 : 0, msg); + + if(text && hoverText.size()) { + expect(hoverText.text()).toEqual(text, msg); + } + + mouseEvent('mouseout', pos[0], pos[1]); + mouseEvent('mousemove', 0, 0); + + setTimeout(resolve, constants.HOVERMINTIME); + }, constants.HOVERMINTIME); + }); + } + + function assertHoverLabels(spec, msg) { + // spec is an array of [pos, text] + // always check that the heads don't have hover effects + // so we only have to explicitly include pos0-2 + spec.push([pos0Head, '']); + spec.push([pos2Head, '']); + var p = Promise.resolve(); + spec.forEach(function(speci, i) { + p = p.then(function() { + return assertHoverLabel(speci[0], speci[1], + msg ? msg + ' (' + i + ')' : i); + }); + }); + return p; + } + + function _click(pos) { + return new Promise(function(resolve) { + click(pos[0], pos[1]); + + setTimeout(function() { + resolve(); + }, constants.HOVERMINTIME); + }); + } + + function assertClickData(data) { + expect(clickData).toEqual(data); + clickData.splice(0, clickData.length); + } + + makePlot([ + {x: 50, y: 50, text: 'hi', width: 50, ax: 0, ay: -40}, + {x: 20, y: 20, text: 'bye', height: 40, showarrow: false}, + {x: 80, y: 80, text: 'why?', ax: 0, ay: -40} + ], {}) // turn off the default editable: true + .then(function() { + clickData = []; + gd.on('plotly_clickannotation', function(evt) { clickData.push(evt); }); + + gdBB = gd.getBoundingClientRect(); + pos0Head = [gdBB.left + 250, gdBB.top + 250]; + pos0 = [pos0Head[0], pos0Head[1] - 40]; + pos1 = [gdBB.left + 160, gdBB.top + 340]; + pos2Head = [gdBB.left + 340, gdBB.top + 160]; + pos2 = [pos2Head[0], pos2Head[1] - 40]; + + return assertHoverLabels([[pos0, ''], [pos1, ''], [pos2, '']]); + }) + // not going to register either of these because captureevents is off + .then(function() { return _click(pos1); }) + .then(function() { return _click(pos2Head); }) + .then(function() { + assertClickData([]); + + return Plotly.relayout(gd, { + 'annotations[1].captureevents': true, + 'annotations[2].captureevents': true + }); + }) + // now we'll register the click on #1, but still not on #2 + // because we're clicking the head, not the text box + .then(function() { return _click(pos1); }) + .then(function() { return _click(pos2Head); }) + .then(function() { + assertClickData([{ + index: 1, + annotation: gd.layout.annotations[1], + fullAnnotation: gd._fullLayout.annotations[1] + }]); + + expect(gd._fullLayout.annotations[0].hoverlabel).toBeUndefined(); + + return Plotly.relayout(gd, {'annotations[0].hovertext': 'bananas'}); + }) + .then(function() { + expect(gd._fullLayout.annotations[0].hoverlabel).toEqual({ + bgcolor: '#444', + bordercolor: '#fff', + font: {family: 'Arial, sans-serif', size: 13, color: '#fff'} + }); + + return assertHoverLabels([[pos0, 'bananas'], [pos1, ''], [pos2, '']], + '0 only'); + }) + // click and hover work together? + // this also tests that hover turns on annotation.captureevents + .then(function() { return _click(pos0); }) + .then(function() { + assertClickData([{ + index: 0, + annotation: gd.layout.annotations[0], + fullAnnotation: gd._fullLayout.annotations[0] + }]); + + return Plotly.relayout(gd, { + 'annotations[0].hoverlabel': { + bgcolor: '#800', + bordercolor: '#008', + font: {family: 'courier', size: 50, color: '#080'} + }, + 'annotations[1].hovertext': 'chicken' + }); + }) + .then(function() { + expect(gd._fullLayout.annotations[0].hoverlabel).toEqual({ + bgcolor: '#800', + bordercolor: '#008', + font: {family: 'courier', size: 50, color: '#080'} + }); + + return assertHoverLabels([[pos0, 'bananas'], [pos1, 'chicken'], [pos2, '']], + '0 and 1'); + }) + .catch(failTest) + .then(done); + }); });