diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index 1f008942258..0b659244ade 100644 --- a/src/components/annotations/annotation_defaults.js +++ b/src/components/annotations/annotation_defaults.js @@ -10,9 +10,8 @@ 'use strict'; var Lib = require('../../lib'); -var Color = require('../color'); var Axes = require('../../plots/cartesian/axes'); - +var handleAnnotationCommonDefaults = require('./common_defaults'); var attributes = require('./attributes'); @@ -29,26 +28,9 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op if(!(visible || clickToShow)) return annOut; - coerce('opacity'); - var bgColor = coerce('bgcolor'); - - var borderColor = coerce('bordercolor'), - borderOpacity = Color.opacity(borderColor); - - coerce('borderpad'); - - var borderWidth = coerce('borderwidth'); - var showArrow = coerce('showarrow'); - - coerce('text', showArrow ? ' ' : 'new text'); - coerce('textangle'); - Lib.coerceFont(coerce, 'font', fullLayout.font); - - coerce('width'); - coerce('align'); + handleAnnotationCommonDefaults(annIn, annOut, fullLayout, coerce); - var h = coerce('height'); - if(h) coerce('valign'); + var showArrow = annOut.showarrow; // positioning var axLetters = ['x', 'y'], @@ -90,14 +72,8 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op // if you have one coordinate you should have both Lib.noneOrAll(annIn, annOut, ['x', 'y']); + // if you have one part of arrow length you should have both if(showArrow) { - coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); - coerce('arrowhead'); - coerce('arrowsize'); - coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); - coerce('standoff'); - - // if you have one part of arrow length you should have both Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); } @@ -111,25 +87,5 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op annOut._yclick = (yClick === undefined) ? annOut.y : yClick; } - var hoverText = coerce('hovertext'); - var globalHoverLabel = fullLayout.hoverlabel || {}; - - if(hoverText) { - var hoverBG = coerce('hoverlabel.bgcolor', globalHoverLabel.bgcolor || - (Color.opacity(bgColor) ? Color.rgb(bgColor) : Color.defaultLine) - ); - - var hoverBorder = coerce('hoverlabel.bordercolor', globalHoverLabel.bordercolor || - Color.contrast(hoverBG) - ); - - Lib.coerceFont(coerce, 'hoverlabel.font', { - family: globalHoverLabel.font.family, - size: globalHoverLabel.font.size, - color: globalHoverLabel.font.color || hoverBorder - }); - } - coerce('captureevents', !!hoverText); - return annOut; }; diff --git a/src/components/annotations/common_defaults.js b/src/components/annotations/common_defaults.js new file mode 100644 index 00000000000..ece51afec5f --- /dev/null +++ b/src/components/annotations/common_defaults.js @@ -0,0 +1,66 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var Lib = require('../../lib'); +var Color = require('../color'); + +// defaults common to 'annotations' and 'annotations3d' +module.exports = function handleAnnotationCommonDefaults(annIn, annOut, fullLayout, coerce) { + coerce('opacity'); + var bgColor = coerce('bgcolor'); + + var borderColor = coerce('bordercolor'); + var borderOpacity = Color.opacity(borderColor); + + coerce('borderpad'); + + var borderWidth = coerce('borderwidth'); + var showArrow = coerce('showarrow'); + + coerce('text', showArrow ? ' ' : 'new text'); + coerce('textangle'); + Lib.coerceFont(coerce, 'font', fullLayout.font); + + coerce('width'); + coerce('align'); + + var h = coerce('height'); + if(h) coerce('valign'); + + if(showArrow) { + coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); + coerce('arrowhead'); + coerce('arrowsize'); + coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); + coerce('standoff'); + + } + + var hoverText = coerce('hovertext'); + var globalHoverLabel = fullLayout.hoverlabel || {}; + + if(hoverText) { + var hoverBG = coerce('hoverlabel.bgcolor', globalHoverLabel.bgcolor || + (Color.opacity(bgColor) ? Color.rgb(bgColor) : Color.defaultLine) + ); + + var hoverBorder = coerce('hoverlabel.bordercolor', globalHoverLabel.bordercolor || + Color.contrast(hoverBG) + ); + + Lib.coerceFont(coerce, 'hoverlabel.font', { + family: globalHoverLabel.font.family, + size: globalHoverLabel.font.size, + color: globalHoverLabel.font.color || hoverBorder + }); + } + + coerce('captureevents', !!hoverText); +}; diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 1c149d89741..5496471ea7d 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -36,7 +36,8 @@ var drawArrowHead = require('./draw_arrow_head'); module.exports = { draw: draw, - drawOne: drawOne + drawOne: drawOne, + drawRaw: drawRaw }; /* @@ -57,37 +58,61 @@ function draw(gd) { } /* - * drawOne: draw a single annotation, potentially with modifications + * drawOne: draw a single cartesian or paper-ref annotation, potentially with modifications * * index (int): the annotation to draw */ function drawOne(gd, index) { - var layout = gd.layout, - fullLayout = gd._fullLayout, - gs = gd._fullLayout._size; + var fullLayout = gd._fullLayout; + var options = fullLayout.annotations[index] || {}; + var xa = Axes.getFromId(gd, options.xref); + var ya = Axes.getFromId(gd, options.yref); - // remove the existing annotation if there is one - fullLayout._infolayer.selectAll('.annotation[data-index="' + index + '"]').remove(); + drawRaw(gd, options, index, false, xa, ya); +} + +/** + * drawRaw: draw a single annotation, potentially with modifications + * + * @param {DOM element} gd + * @param {object} options : this annotation's fullLayout options + * @param {integer} index : index in 'annotations' container of the annotation to draw + * @param {string} subplotId : id of the annotation's subplot + * - use false for 2d (i.e. cartesian or paper-ref) annotations + * @param {object | undefined} xa : full x-axis object to compute subplot pos-to-px + * @param {object | undefined} ya : ... y-axis + */ +function drawRaw(gd, options, index, subplotId, xa, ya) { + var fullLayout = gd._fullLayout; + var gs = gd._fullLayout._size; + var className; + var annbase; + + if(subplotId) { + className = 'annotation-' + subplotId; + annbase = subplotId + '.annotations[' + index + ']'; + } else { + className = 'annotation'; + annbase = 'annotations[' + index + ']'; + } - // remember a few things about what was already there, - var optionsIn = (layout.annotations || [])[index], - options = fullLayout.annotations[index]; + // remove the existing annotation if there is one + fullLayout._infolayer + .selectAll('.' + className + '[data-index="' + index + '"]') + .remove(); var annClipID = 'clip' + fullLayout._uid + '_ann' + index; // this annotation is gone - quit now after deleting it // TODO: use d3 idioms instead of deleting and redrawing every time - if(!optionsIn || options.visible === false) { + if(!options._input || options.visible === false) { d3.selectAll('#' + annClipID).remove(); return; } - var xa = Axes.getFromId(gd, options.xref), - ya = Axes.getFromId(gd, options.yref), - - // calculated pixel positions - // x & y each will get text, head, and tail as appropriate - annPosPx = {x: {}, y: {}}, + // calculated pixel positions + // x & y each will get text, head, and tail as appropriate + var annPosPx = {x: {}, y: {}}, textangle = +options.textangle || 0; // create the components @@ -95,26 +120,32 @@ function drawOne(gd, index) { // with border/arrow together this could handle a whole bunch of // cleanup at this point, but works for now var annGroup = fullLayout._infolayer.append('g') - .classed('annotation', true) + .classed(className, true) .attr('data-index', String(index)) .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)); + .classed('annotation-text-g', true); 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', { + + var eventData = { index: index, - annotation: optionsIn, + annotation: options._input, fullAnnotation: options, event: d3.event - }); + }; + + if(subplotId) { + eventData.subplotId = subplotId; + } + + gd.emit('plotly_clickannotation', eventData); }); if(options.hovertext) { @@ -170,7 +201,7 @@ function drawOne(gd, index) { var font = options.font; var annText = annTextGroupInner.append('text') - .classed('annotation', true) + .classed('annotation-text', true) .attr('data-unformatted', options.text) .text(options.text); @@ -189,11 +220,11 @@ function drawOne(gd, index) { function drawGraphicalElements() { // if the text has *only* a link, make the whole box into a link - var anchor = annText.selectAll('a'); - if(anchor.size() === 1 && anchor.text() === annText.text()) { + var anchor3 = annText.selectAll('a'); + if(anchor3.size() === 1 && anchor3.text() === annText.text()) { var wholeLink = annTextGroupInner.insert('a', ':first-child').attr({ - 'xlink:xlink:href': anchor.attr('xlink:href'), - 'xlink:xlink:show': anchor.attr('xlink:show') + 'xlink:xlink:href': anchor3.attr('xlink:href'), + 'xlink:xlink:show': anchor3.attr('xlink:show') }) .style({cursor: 'pointer'}); @@ -238,10 +269,13 @@ function drawOne(gd, index) { } var annotationIsOffscreen = false; - ['x', 'y'].forEach(function(axLetter) { - var axRef = options[axLetter + 'ref'] || axLetter, + var letters = ['x', 'y']; + + for(var i = 0; i < letters.length; i++) { + var axLetter = letters[i], + axRef = options[axLetter + 'ref'] || axLetter, tailRef = options['a' + axLetter + 'ref'], - ax = Axes.getFromId(gd, axRef), + ax = {x: xa, y: ya}[axLetter], dimAngle = (textangle + (axLetter === 'x' ? 0 : -90)) * Math.PI / 180, // note that these two can be either positive or negative annSizeFromWidth = outerWidth * Math.cos(dimAngle), @@ -363,7 +397,7 @@ function drawOne(gd, index) { // size/shift are used during dragging options['_' + axLetter + 'size'] = annSize; options['_' + axLetter + 'shift'] = textShift; - }); + } if(annotationIsOffscreen) { annTextGroupInner.remove(); @@ -417,8 +451,6 @@ function drawOne(gd, index) { annTextGroup.attr({transform: 'rotate(' + textangle + ',' + annPosPx.x.text + ',' + annPosPx.y.text + ')'}); - var annbase = 'annotations[' + index + ']'; - /* * add the arrow * uses options[arrowwidth,arrowcolor,arrowhead] for styling @@ -426,8 +458,8 @@ function drawOne(gd, index) { * while the head stays put, dx and dy are the pixel offsets */ var drawArrow = function(dx, dy) { - d3.select(gd) - .selectAll('.annotation-arrow-g[data-index="' + index + '"]') + annGroup + .selectAll('.annotation-arrow-g') .remove(); var headX = annPosPx.x.head, @@ -484,8 +516,7 @@ function drawOne(gd, index) { var arrowGroup = annGroup.append('g') .style({opacity: Color.opacity(arrowColor)}) - .classed('annotation-arrow-g', true) - .attr('data-index', String(index)); + .classed('annotation-arrow-g', true); var arrow = arrowGroup.append('path') .attr('d', 'M' + tailX + ',' + tailY + 'L' + headX + ',' + headY) @@ -496,7 +527,7 @@ function drawOne(gd, index) { // the arrow dragger is a small square right at the head, then a line to the tail, // all expanded by a stroke width of 6px plus the arrow line width - if(gd._context.editable && arrow.node().parentNode) { + if(gd._context.editable && arrow.node().parentNode && !subplotId) { var arrowDragHeadX = headX; var arrowDragHeadY = headY; if(options.standoff) { @@ -505,10 +536,9 @@ function drawOne(gd, index) { arrowDragHeadY += options.standoff * (tailY - headY) / arrowLength; } var arrowDrag = arrowGroup.append('path') - .classed('annotation', true) + .classed('annotation-arrow', true) .classed('anndrag', true) .attr({ - 'data-index': String(index), d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY), transform: 'translate(' + arrowDragHeadX + ',' + arrowDragHeadY + ')' }) @@ -607,7 +637,7 @@ function drawOne(gd, index) { drawArrow(dx, dy); } - else { + else if(!subplotId) { if(xa) update[annbase + '.x'] = options.x + dx / xa._m; else { var widthFraction = options._xsize / gs.w, @@ -635,6 +665,7 @@ function drawOne(gd, index) { ); } } + else return; annTextGroup.attr({ transform: 'translate(' + dx + ',' + dy + ')' + baseTextTransform @@ -661,14 +692,17 @@ function drawOne(gd, index) { options.text = _text; this.attr({'data-unformatted': options.text}); this.call(textLayout); + var update = {}; - update['annotations[' + index + '].text'] = options.text; + update[annbase + '.text'] = options.text; + if(xa && xa.autorange) { update[xa._name + '.autorange'] = true; } if(ya && ya.autorange) { update[ya._name + '.autorange'] = true; } + Plotly.relayout(gd, update); }); } diff --git a/src/components/annotations/index.js b/src/components/annotations/index.js index aea3d914aa6..a3cd7893545 100644 --- a/src/components/annotations/index.js +++ b/src/components/annotations/index.js @@ -22,6 +22,7 @@ module.exports = { calcAutorange: require('./calc_autorange'), draw: drawModule.draw, drawOne: drawModule.drawOne, + drawRaw: drawModule.drawRaw, hasClickToShow: clickModule.hasClickToShow, onClick: clickModule.onClick, diff --git a/src/components/annotations3d/attributes.js b/src/components/annotations3d/attributes.js new file mode 100644 index 00000000000..ac19539d761 --- /dev/null +++ b/src/components/annotations3d/attributes.js @@ -0,0 +1,92 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var annAtts = require('../annotations/attributes'); + +module.exports = { + _isLinkedToArray: 'annotation', + + visible: annAtts.visible, + x: { + valType: 'any', + role: 'info', + description: [ + 'Sets the annotation\'s x position.' + ].join(' ') + }, + y: { + valType: 'any', + role: 'info', + description: [ + 'Sets the annotation\'s y position.' + ].join(' ') + }, + z: { + valType: 'any', + role: 'info', + description: [ + 'Sets the annotation\'s z position.' + ].join(' ') + }, + ax: { + valType: 'number', + role: 'info', + description: [ + 'Sets the x component of the arrow tail about the arrow head (in pixels).' + ].join(' ') + }, + ay: { + valType: 'number', + role: 'info', + description: [ + 'Sets the y component of the arrow tail about the arrow head (in pixels).' + ].join(' ') + }, + + xanchor: annAtts.xanchor, + xshift: annAtts.xshift, + yanchor: annAtts.yanchor, + yshift: annAtts.yshift, + + text: annAtts.text, + textangle: annAtts.textangle, + font: annAtts.font, + width: annAtts.width, + height: annAtts.height, + opacity: annAtts.opacity, + align: annAtts.align, + valign: annAtts.valign, + bgcolor: annAtts.bgcolor, + bordercolor: annAtts.bordercolor, + borderpad: annAtts.borderpad, + borderwidth: annAtts.borderwidth, + showarrow: annAtts.showarrow, + arrowcolor: annAtts.arrowcolor, + arrowhead: annAtts.arrowhead, + arrowsize: annAtts.arrowsize, + arrowwidth: annAtts.arrowwidth, + standoff: annAtts.standoff, + hovertext: annAtts.hovertext, + hoverlabel: annAtts.hoverlabel, + captureevents: annAtts.captureevents + + // maybes later? + // clicktoshow: annAtts.clicktoshow, + // xclick: annAtts.xclick, + // yclick: annAtts.yclick, + + // not needed! + // axref: 'pixel' + // ayref: 'pixel' + // xref: 'x' + // yref: 'y + // zref: 'z' +}; diff --git a/src/components/annotations3d/convert.js b/src/components/annotations3d/convert.js new file mode 100644 index 00000000000..a2cf39345ed --- /dev/null +++ b/src/components/annotations3d/convert.js @@ -0,0 +1,85 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var Lib = require('../../lib'); +var Axes = require('../../plots/cartesian/axes'); +var attributes = require('./attributes'); + +module.exports = function convert(scene) { + var fullSceneLayout = scene.fullSceneLayout; + var anns = fullSceneLayout.annotations; + + for(var i = 0; i < anns.length; i++) { + mockAnnAxes(anns[i], scene); + } + + scene.fullLayout._infolayer + .selectAll('.annotation-' + scene.id) + .remove(); +}; + +function mockAnnAxes(ann, scene) { + var fullSceneLayout = scene.fullSceneLayout; + var domain = fullSceneLayout.domain; + var size = scene.fullLayout._size; + + var base = { + // this gets fill in on render + pdata: null, + + // to get setConvert to not execute cleanly + type: 'linear', + + // don't try to update them on `editable: true` + autorange: false, + + // set infinite range so that annotation draw routine + // does not try to remove 'outside-range' annotations, + // this case is handled in the render loop + range: [-Infinity, Infinity] + }; + + ann._xa = {}; + Lib.extendFlat(ann._xa, base); + Axes.setConvert(ann._xa); + ann._xa._offset = size.l + domain.x[0] * size.w; + ann._xa.l2p = function() { + return 0.5 * (1 + ann.pdata[0] / ann.pdata[3]) * size.w * (domain.x[1] - domain.x[0]); + }; + + ann._ya = {}; + Lib.extendFlat(ann._ya, base); + Axes.setConvert(ann._ya); + ann._ya._offset = size.t + (1 - domain.y[1]) * size.h; + ann._ya.l2p = function() { + return 0.5 * (1 - ann.pdata[1] / ann.pdata[3]) * size.h * (domain.y[1] - domain.y[0]); + }; + + // or do something more similar to 2d + // where Annotations.supplyLayoutDefaults is called after in Plots.doCalcdata + // if category axes are found. + function coerce(attr, dflt) { + return Lib.coerce(ann, ann, attributes, attr, dflt); + } + + function coercePosition(axLetter) { + var axName = axLetter + 'axis'; + + // mock in such way that getFromId grabs correct 3D axis + var gdMock = { _fullLayout: {} }; + gdMock._fullLayout[axName] = fullSceneLayout[axName]; + + return Axes.coercePosition(ann, gdMock, coerce, axLetter, axLetter, 0.5); + } + + coercePosition('x'); + coercePosition('y'); + coercePosition('z'); +} diff --git a/src/components/annotations3d/defaults.js b/src/components/annotations3d/defaults.js new file mode 100644 index 00000000000..a0353d24dc6 --- /dev/null +++ b/src/components/annotations3d/defaults.js @@ -0,0 +1,66 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var Lib = require('../../lib'); +var handleArrayContainerDefaults = require('../../plots/array_container_defaults'); +var handleAnnotationCommonDefaults = require('../annotations/common_defaults'); +var attributes = require('./attributes'); + +module.exports = function handleDefaults(sceneLayoutIn, sceneLayoutOut, opts) { + handleArrayContainerDefaults(sceneLayoutIn, sceneLayoutOut, { + name: 'annotations', + handleItemDefaults: handleAnnotationDefaults, + fullLayout: opts.fullLayout + }); +}; + +function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { + function coerce(attr, dflt) { + return Lib.coerce(annIn, annOut, attributes, attr, dflt); + } + + var visible = coerce('visible', !itemOpts.itemIsNotPlainObject); + if(!visible) return annOut; + + handleAnnotationCommonDefaults(annIn, annOut, opts.fullLayout, coerce); + + // do not use Axes.coercePosition here + // as ax._categories aren't filled in at this stage + coerce('x'); + coerce('y'); + coerce('z'); + + // if you have one coordinate you should all three + Lib.noneOrAll(annIn, annOut, ['x', 'y', 'z']); + + // hard-set here for completeness + annOut.xref = 'x'; + annOut.yref = 'y'; + annOut.zref = 'z'; + + coerce('xanchor'); + coerce('yanchor'); + coerce('xshift'); + coerce('yshift'); + + if(annOut.showarrow) { + annOut.axref = 'pixel'; + annOut.ayref = 'pixel'; + + // TODO maybe default values should be bigger than the 2D case? + coerce('ax', -10); + coerce('ay', -30); + + // if you have one part of arrow length you should have both + Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); + } + + return annOut; +} diff --git a/src/components/annotations3d/draw.js b/src/components/annotations3d/draw.js new file mode 100644 index 00000000000..e916973cad1 --- /dev/null +++ b/src/components/annotations3d/draw.js @@ -0,0 +1,50 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var drawRaw = require('../annotations/draw').drawRaw; +var project = require('../../plots/gl3d/project'); +var axLetters = ['x', 'y', 'z']; + +module.exports = function draw(scene) { + var fullSceneLayout = scene.fullSceneLayout; + var dataScale = scene.dataScale; + var anns = fullSceneLayout.annotations; + + for(var i = 0; i < anns.length; i++) { + var ann = anns[i]; + var annotationIsOffscreen = false; + + for(var j = 0; j < 3; j++) { + var axLetter = axLetters[j]; + var pos = ann[axLetter]; + var ax = fullSceneLayout[axLetter + 'axis']; + var posFraction = ax.r2fraction(pos); + + if(posFraction < 0 || posFraction > 1) { + annotationIsOffscreen = true; + break; + } + } + + if(annotationIsOffscreen) { + scene.fullLayout._infolayer + .select('.annotation-' + scene.id + '[data-index="' + i + '"]') + .remove(); + } else { + ann.pdata = project(scene.glplot.cameraParams, [ + fullSceneLayout.xaxis.r2l(ann.x) * dataScale[0], + fullSceneLayout.yaxis.r2l(ann.y) * dataScale[1], + fullSceneLayout.zaxis.r2l(ann.z) * dataScale[2] + ]); + + drawRaw(scene.graphDiv, ann, i, scene.id, ann._xa, ann._ya); + } + } +}; diff --git a/src/components/annotations3d/index.js b/src/components/annotations3d/index.js new file mode 100644 index 00000000000..c6a582ccd8d --- /dev/null +++ b/src/components/annotations3d/index.js @@ -0,0 +1,26 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = { + moduleType: 'component', + name: 'annotations3d', + + schema: { + layout: { + 'scene.annotations': require('./attributes') + } + }, + + layoutAttributes: require('./attributes'), + handleDefaults: require('./defaults'), + + convert: require('./convert'), + draw: require('./draw') +}; diff --git a/src/core.js b/src/core.js index d742ad9fcc2..f226b443e9a 100644 --- a/src/core.js +++ b/src/core.js @@ -56,6 +56,7 @@ exports.register([ require('./components/fx'), require('./components/legend'), require('./components/annotations'), + require('./components/annotations3d'), require('./components/shapes'), require('./components/images'), require('./components/updatemenus'), diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index 2d7c26cc3f2..faeea322e46 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -184,7 +184,7 @@ module.exports = function setConvert(ax, fullLayout) { ax.d2r = ax.r2d = ax.d2c = ax.r2c = ax.d2l = ax.r2l = cleanNumber; ax.c2d = ax.c2r = ax.l2d = ax.l2r = num; - ax.d2p = ax.r2p = function(v) { return l2p(cleanNumber(v)); }; + ax.d2p = ax.r2p = function(v) { return ax.l2p(cleanNumber(v)); }; ax.p2d = ax.p2r = p2l; } else if(ax.type === 'log') { @@ -198,10 +198,10 @@ module.exports = function setConvert(ax, fullLayout) { ax.c2r = toLog; ax.l2d = fromLog; - ax.d2p = function(v, clip) { return l2p(ax.d2r(v, clip)); }; + ax.d2p = function(v, clip) { return ax.l2p(ax.d2r(v, clip)); }; ax.p2d = function(px) { return fromLog(p2l(px)); }; - ax.r2p = function(v) { return l2p(cleanNumber(v)); }; + ax.r2p = function(v) { return ax.l2p(cleanNumber(v)); }; ax.p2r = p2l; } else if(ax.type === 'date') { @@ -220,7 +220,7 @@ module.exports = function setConvert(ax, fullLayout) { ax.d2c = ax.r2c = ax.d2l = ax.r2l = dt2ms; ax.c2d = ax.c2r = ax.l2d = ax.l2r = ms2dt; - ax.d2p = ax.r2p = function(v, _, calendar) { return l2p(dt2ms(v, 0, calendar)); }; + ax.d2p = ax.r2p = function(v, _, calendar) { return ax.l2p(dt2ms(v, 0, calendar)); }; ax.p2d = ax.p2r = function(px, r, calendar) { return ms2dt(p2l(px), r, calendar); }; } else if(ax.type === 'category') { @@ -236,9 +236,9 @@ module.exports = function setConvert(ax, fullLayout) { ax.r2l = ax.l2r = ax.r2c = ax.c2r = num; - ax.d2p = function(v) { return l2p(getCategoryIndex(v)); }; + ax.d2p = function(v) { return ax.l2p(getCategoryIndex(v)); }; ax.p2d = function(px) { return getCategoryName(p2l(px)); }; - ax.r2p = l2p; + ax.r2p = ax.l2p; ax.p2r = p2l; } diff --git a/src/plots/gl3d/index.js b/src/plots/gl3d/index.js index 66cc89996fc..c30a7228dee 100644 --- a/src/plots/gl3d/index.js +++ b/src/plots/gl3d/index.js @@ -73,6 +73,12 @@ exports.clean = function(newFullData, newFullLayout, oldFullData, oldFullLayout) if(!newFullLayout[oldSceneKey] && !!oldFullLayout[oldSceneKey]._scene) { oldFullLayout[oldSceneKey]._scene.destroy(); + + if(oldFullLayout._infolayer) { + oldFullLayout._infolayer + .selectAll('.annotation-' + oldSceneKey) + .remove(); + } } } }; diff --git a/src/plots/gl3d/layout/defaults.js b/src/plots/gl3d/layout/defaults.js index 1fafd4a49a4..e0fdb42397e 100644 --- a/src/plots/gl3d/layout/defaults.js +++ b/src/plots/gl3d/layout/defaults.js @@ -11,10 +11,11 @@ var Lib = require('../../../lib'); var Color = require('../../../components/color'); +var Registry = require('../../../registry'); var handleSubplotDefaults = require('../../subplot_defaults'); -var layoutAttributes = require('./layout_attributes'); var supplyGl3dAxisLayoutDefaults = require('./axis_defaults'); +var layoutAttributes = require('./layout_attributes'); module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { @@ -33,6 +34,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { type: 'gl3d', attributes: layoutAttributes, handleDefaults: handleGl3dDefaults, + fullLayout: layoutOut, font: layoutOut.font, fullData: fullData, getDfltFromLayout: getDfltFromLayout, @@ -97,6 +99,10 @@ function handleGl3dDefaults(sceneLayoutIn, sceneLayoutOut, coerce, opts) { calendar: opts.calendar }); + Registry.getComponentMethod('annotations3d', 'handleDefaults')( + sceneLayoutIn, sceneLayoutOut, opts + ); + coerce('dragmode', opts.getDfltFromLayout('dragmode')); coerce('hovermode', opts.getDfltFromLayout('hovermode')); } diff --git a/src/plots/gl3d/layout/layout_attributes.js b/src/plots/gl3d/layout/layout_attributes.js index 7b83424f1f7..92e9d8c1ab0 100644 --- a/src/plots/gl3d/layout/layout_attributes.js +++ b/src/plots/gl3d/layout/layout_attributes.js @@ -33,6 +33,8 @@ function makeVector(x, y, z) { } module.exports = { + _arrayAttrRegexps: [/^scene([2-9]|[1-9][0-9]+)?\.annotations/], + bgcolor: { valType: 'color', role: 'style', diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index eb108e89899..4d1c12192c0 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -12,6 +12,7 @@ var createPlot = require('gl-plot3d'); var getContext = require('webgl-context'); +var Registry = require('../../registry'); var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); @@ -29,7 +30,6 @@ var computeTickMarks = require('./layout/tick_marks'); var STATIC_CANVAS, STATIC_CONTEXT; function render(scene) { - var trace; // update size of svg container @@ -127,6 +127,8 @@ function render(scene) { Fx.loneUnhover(svgContainer); scene.graphDiv.emit('plotly_unhover', oldEventData); } + + scene.drawAnnotations(scene); } function initializeGLPlot(scene, fullLayout, canvas, gl) { @@ -269,6 +271,9 @@ function Scene(options, fullLayout) { this.contourLevels = [ [], [], [] ]; + this.convertAnnotations = Registry.getComponentMethod('annotations3d', 'convert'); + this.drawAnnotations = Registry.getComponentMethod('annotations3d', 'draw'); + if(!initializeGLPlot(this, fullLayout)) return; // todo check the necessity for this line } @@ -393,6 +398,9 @@ proto.plot = function(sceneData, fullLayout, layout) { // Save scale this.dataScale = dataScale; + // after computeTraceBounds where ax._categories are filled in + this.convertAnnotations(this); + // Update traces for(i = 0; i < sceneData.length; ++i) { data = sceneData[i]; @@ -452,13 +460,28 @@ proto.plot = function(sceneData, fullLayout, layout) { if(axis.autorange) { sceneBounds[0][i] = Infinity; sceneBounds[1][i] = -Infinity; - for(j = 0; j < this.glplot.objects.length; ++j) { - var objBounds = this.glplot.objects[j].bounds; - sceneBounds[0][i] = Math.min(sceneBounds[0][i], - objBounds[0][i] / dataScale[i]); - sceneBounds[1][i] = Math.max(sceneBounds[1][i], - objBounds[1][i] / dataScale[i]); + + var objects = this.glplot.objects; + var annotations = this.fullSceneLayout.annotations || []; + var axLetter = axis._name.charAt(0); + + for(j = 0; j < objects.length; j++) { + var objBounds = objects[j].bounds; + sceneBounds[0][i] = Math.min(sceneBounds[0][i], objBounds[0][i] / dataScale[i]); + sceneBounds[1][i] = Math.max(sceneBounds[1][i], objBounds[1][i] / dataScale[i]); } + + for(j = 0; j < annotations.length; j++) { + var ann = annotations[j]; + + // N.B. not taking into consideration the arrowhead + if(ann.visible) { + var pos = axis.r2l(ann[axLetter]); + sceneBounds[0][i] = Math.min(sceneBounds[0][i], pos); + sceneBounds[1][i] = Math.max(sceneBounds[1][i], pos); + } + } + if('rangemode' in axis && axis.rangemode === 'tozero') { sceneBounds[0][i] = Math.min(sceneBounds[0][i], 0); sceneBounds[1][i] = Math.max(sceneBounds[1][i], 0); @@ -716,7 +739,7 @@ proto.toImage = function(format) { }; proto.setConvert = function() { - for(var i = 0; i < 3; ++i) { + for(var i = 0; i < 3; i++) { var ax = this.fullSceneLayout[axisProperties[i]]; Axes.setConvert(ax, this.fullLayout); ax.setScale = Lib.noop; diff --git a/test/image/baselines/gl3d_annotations.png b/test/image/baselines/gl3d_annotations.png new file mode 100644 index 00000000000..d59ee820da2 Binary files /dev/null and b/test/image/baselines/gl3d_annotations.png differ diff --git a/test/image/baselines/gl3d_triangle.png b/test/image/baselines/gl3d_triangle.png index dc40d8e4c04..c6411b2704d 100644 Binary files a/test/image/baselines/gl3d_triangle.png and b/test/image/baselines/gl3d_triangle.png differ diff --git a/test/image/mocks/gl3d_annotations.json b/test/image/mocks/gl3d_annotations.json new file mode 100644 index 00000000000..231b1517431 --- /dev/null +++ b/test/image/mocks/gl3d_annotations.json @@ -0,0 +1,78 @@ +{ + "data": [{ + "type": "scatter3d", + "x": ["2017-01-01", "2017-02-10", "2017-03-20"], + "y": ["A", "B", "C"], + "z": [1, 1e3, 1e5] + }], + "layout": { + "scene": { + "camera": { + "eye": {"x": 2.1, "y": 0.1, "z": 0.9} + }, + "xaxis": { + "title": "" + }, + "yaxis": { + "title": "" + }, + "zaxis": { + "type": "log", + "title": "" + }, + "annotations": [{ + "showarrow": false, + "x": "2017-01-01", + "y": "A", + "z": 0, + "text": "Point 1", + "xanchor": "left", + "xshift": 10, + "opacity": 0.7 + }, { + "x": "2017-02-20", + "y": "B", + "z": 4, + "text": "Point 2
watch out!!", + "textangle": 30, + "ax": 30, + "ay": -100, + "font": { + "color": "blue", + "size": 20 + }, + "bgcolor": "#d3d3d3", + "bordercolor": "#000", + "borderwidth": 2, + "borderpad": 10, + "standoff": 12, + "arrowcolor": "blue", + "arrowsize": 3, + "arrowwidth": 1, + "arrowhead": 5 + }, { + "x": "2017-03-20", + "y": "C", + "z": 5, + "ax": 50, + "ay": 0, + "text": "Threshold", + "bordercolor": "#000", + "borderwidth": 2, + "arrowhead": 7, + "width": 100, + "height": 50, + "xanchor": "left", + "yanchor": "bottom", + "align": "right", + "valign": "bottom" + }, { + "x": "2016-12-25", + "y": "A", + "z": 6, + "text": "autorange bump!", + "ax": -50 + }] + } + } +} diff --git a/test/image/mocks/gl3d_triangle.json b/test/image/mocks/gl3d_triangle.json index 60a2a6fa4c3..111f180b5be 100644 --- a/test/image/mocks/gl3d_triangle.json +++ b/test/image/mocks/gl3d_triangle.json @@ -10,6 +10,18 @@ "k":[2] }], "layout": { - "title": "Triangle mesh" + "title": "Triangle mesh", + "scene": { + "annotations": [{ + "x": 2, + "y": 1, + "z": 0, + "ax": -50, + "ay": -300, + "text": "IMPORTANT" + }] + }, + "width": 600, + "height": 500 } } diff --git a/test/jasmine/tests/drawing_test.js b/test/jasmine/tests/drawing_test.js index 3a3137dcd52..810dbbfefe1 100644 --- a/test/jasmine/tests/drawing_test.js +++ b/test/jasmine/tests/drawing_test.js @@ -381,7 +381,7 @@ describe('Drawing', function() { width: 500 }) .then(function() { - var node = d3.select('text.annotation').node(); + var node = d3.select('text.annotation-text').node(); assertBBox(Drawing.bBox(node), { height: 14, width: 27.671875, @@ -395,7 +395,7 @@ describe('Drawing', function() { return Plotly.relayout(gd, 'annotations[0].text', 'HELLO'); }) .then(function() { - var node = d3.select('text.annotation').node(); + var node = d3.select('text.annotation-text').node(); assertBBox(Drawing.bBox(node), { height: 14, width: 41.015625, @@ -409,7 +409,7 @@ describe('Drawing', function() { return Plotly.relayout(gd, 'annotations[0].font.size', 20); }) .then(function() { - var node = d3.select('text.annotation').node(); + var node = d3.select('text.annotation-text').node(); assertBBox(Drawing.bBox(node), { height: 22, width: 66.015625, diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js index a20a1dd0fb9..4372ccf6392 100644 --- a/test/jasmine/tests/gl_plot_interact_test.js +++ b/test/jasmine/tests/gl_plot_interact_test.js @@ -19,7 +19,8 @@ function delay() { }); } -function waitForModeBar() { +// updating the camera requires some waiting +function waitForCamera() { return new Promise(function(resolve) { setTimeout(resolve, 200); }); @@ -836,7 +837,7 @@ describe('Test gl2d plots', function() { expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); }) - .then(waitForModeBar) + .then(waitForCamera) .then(function() { gd.on('plotly_relayout', relayoutCallback); @@ -879,7 +880,7 @@ describe('Test gl2d plots', function() { expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); }) - .then(waitForModeBar) + .then(waitForCamera) .then(function() { // callback count expectation: X and back; Y and back; XY and back expect(relayoutCallback).toHaveBeenCalledTimes(6); @@ -1284,7 +1285,6 @@ describe('Test gl2d interactions', function() { }); it('data-referenced annotations should update on drag', function(done) { - function drag(start, end) { mouseEvent('mousemove', start[0], start[1]); mouseEvent('mousedown', start[0], start[1], { buttons: 1 }); @@ -1329,3 +1329,384 @@ describe('Test gl2d interactions', function() { .then(done); }); }); + +describe('Test gl3d annotations', function() { + var gd; + + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + function assertAnnotationText(expectations, msg) { + var anns = d3.selectAll('g.annotation-text-g'); + + expect(anns.size()).toBe(expectations.length, msg); + + anns.each(function(_, i) { + var tx = d3.select(this).select('text').text(); + expect(tx).toEqual(expectations[i], msg + ' - ann ' + i); + }); + } + + function assertAnnotationsXY(expectations, msg) { + var TOL = 1.5; + var anns = d3.selectAll('g.annotation-text-g'); + + expect(anns.size()).toBe(expectations.length, msg); + + anns.each(function(_, i) { + var ann = d3.select(this).select('g'); + var translate = Drawing.getTranslate(ann); + + expect(translate.x).toBeWithin(expectations[i][0], TOL, msg + ' - ann ' + i + ' x'); + expect(translate.y).toBeWithin(expectations[i][1], TOL, msg + ' - ann ' + i + ' y'); + }); + } + + // more robust (especially on CI) than update camera via mouse events + function updateCamera(x, y, z) { + var scene = gd._fullLayout.scene._scene; + var camera = scene.getCamera(); + + camera.eye = {x: x, y: y, z: z}; + scene.setCamera(camera); + } + + it('should move with camera', function(done) { + Plotly.plot(gd, [{ + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [1, 2, 1] + }], { + scene: { + camera: {eye: {x: 2.1, y: 0.1, z: 0.9}}, + annotations: [{ + text: 'hello', + x: 1, y: 1, z: 1 + }, { + text: 'sup?', + x: 1, y: 1, z: 2 + }, { + text: 'look!', + x: 2, y: 2, z: 1 + }] + } + }) + .then(function() { + assertAnnotationsXY([[262, 199], [257, 135], [325, 233]], 'base 0'); + + return updateCamera(1.5, 2.5, 1.5); + }) + .then(waitForCamera) + .then(function() { + assertAnnotationsXY([[340, 187], [341, 142], [325, 221]], 'after camera update'); + + return updateCamera(2.1, 0.1, 0.9); + }) + .then(waitForCamera) + .then(function() { + assertAnnotationsXY([[262, 199], [257, 135], [325, 233]], 'base 0'); + }) + .catch(fail) + .then(done); + }); + + it('should be removed when beyond the scene axis ranges', function(done) { + var mock = Lib.extendDeep({}, require('@mocks/gl3d_annotations')); + + // replace text with something easier to identify + mock.layout.scene.annotations.forEach(function(ann, i) { ann.text = String(i); }); + + Plotly.plot(gd, mock).then(function() { + assertAnnotationText(['0', '1', '2', '3'], 'base'); + + return Plotly.relayout(gd, 'scene.yaxis.range', [0.5, 1.5]); + }) + .then(function() { + assertAnnotationText(['1'], 'after yaxis range relayout'); + + return Plotly.relayout(gd, 'scene.yaxis.range', null); + }) + .then(function() { + assertAnnotationText(['0', '1', '2', '3'], 'back to base after yaxis range relayout'); + + return Plotly.relayout(gd, 'scene.zaxis.range', [0, 3]); + }) + .then(function() { + assertAnnotationText(['0'], 'after zaxis range relayout'); + + return Plotly.relayout(gd, 'scene.zaxis.range', null); + }) + .then(function() { + assertAnnotationText(['0', '1', '2', '3'], 'back to base after zaxis range relayout'); + }) + .catch(fail) + .then(done); + }); + + it('should be able to add/remove and hide/unhide themselves via relayout', function(done) { + var mock = Lib.extendDeep({}, require('@mocks/gl3d_annotations')); + + // replace text with something easier to identify + mock.layout.scene.annotations.forEach(function(ann, i) { ann.text = String(i); }); + + var annNew = { + x: '2017-03-01', + y: 'C', + z: 3, + text: 'new!' + }; + + Plotly.plot(gd, mock).then(function() { + assertAnnotationText(['0', '1', '2', '3'], 'base'); + + return Plotly.relayout(gd, 'scene.annotations[1].visible', false); + }) + .then(function() { + assertAnnotationText(['0', '2', '3'], 'after [1].visible:false'); + + return Plotly.relayout(gd, 'scene.annotations[1].visible', true); + }) + .then(function() { + assertAnnotationText(['0', '1', '2', '3'], 'back to base (1)'); + + return Plotly.relayout(gd, 'scene.annotations[0]', null); + }) + .then(function() { + assertAnnotationText(['1', '2', '3'], 'after [0] null'); + + return Plotly.relayout(gd, 'scene.annotations[0]', annNew); + }) + .then(function() { + assertAnnotationText(['new!', '1', '2', '3'], 'after add new (1)'); + + return Plotly.relayout(gd, 'scene.annotations', null); + }) + .then(function() { + assertAnnotationText([], 'after rm all'); + + return Plotly.relayout(gd, 'scene.annotations[0]', annNew); + }) + .then(function() { + assertAnnotationText(['new!'], 'after add new (2)'); + }) + .catch(fail) + .then(done); + }); + + it('should work across multiple scenes', function(done) { + function assertAnnotationCntPerScene(id, cnt) { + expect(d3.selectAll('g.annotation-' + id).size()).toEqual(cnt); + } + + Plotly.plot(gd, [{ + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [1, 2, 1] + }, { + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [2, 1, 2], + scene: 'scene2' + }], { + scene: { + annotations: [{ + text: 'hello', + x: 1, y: 1, z: 1 + }] + }, + scene2: { + annotations: [{ + text: 'sup?', + x: 1, y: 1, z: 2 + }, { + text: 'look!', + x: 2, y: 2, z: 1 + }] + } + }) + .then(function() { + assertAnnotationCntPerScene('scene', 1); + assertAnnotationCntPerScene('scene2', 2); + + return Plotly.deleteTraces(gd, [1]); + }) + .then(function() { + assertAnnotationCntPerScene('scene', 1); + assertAnnotationCntPerScene('scene2', 0); + + return Plotly.deleteTraces(gd, [0]); + }) + .then(function() { + assertAnnotationCntPerScene('scene', 0); + assertAnnotationCntPerScene('scene2', 0); + }) + .catch(fail) + .then(done); + }); + + it('should contribute to scene axis autorange', function(done) { + function assertSceneAxisRanges(xRange, yRange, zRange) { + var sceneLayout = gd._fullLayout.scene; + + expect(sceneLayout.xaxis.range).toBeCloseToArray(xRange, 1, 'xaxis range'); + expect(sceneLayout.yaxis.range).toBeCloseToArray(yRange, 1, 'yaxis range'); + expect(sceneLayout.zaxis.range).toBeCloseToArray(zRange, 1, 'zaxis range'); + } + + Plotly.plot(gd, [{ + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [1, 2, 1] + }], { + scene: { + annotations: [{ + text: 'hello', + x: 1, y: 1, z: 3 + }] + } + }) + .then(function() { + assertSceneAxisRanges([0.9375, 3.0625], [0.9375, 3.0625], [0.9375, 3.0625]); + + return Plotly.relayout(gd, 'scene.annotations[0].z', 10); + }) + .then(function() { + assertSceneAxisRanges([0.9375, 3.0625], [0.9375, 3.0625], [0.7187, 10.2813]); + }) + .catch(fail) + .then(done); + }); + + it('should allow text and tail position edits under `editable: true`', function(done) { + function editText(newText, expectation) { + return new Promise(function(resolve) { + gd.once('plotly_relayout', function(eventData) { + expect(eventData).toEqual(expectation); + setTimeout(resolve, 0); + }); + + var clickNode = d3.select('g.annotation-text-g').select('g').node(); + clickNode.dispatchEvent(new window.MouseEvent('click')); + + var editNode = d3.select('.plugin-editable.editable').node(); + editNode.dispatchEvent(new window.FocusEvent('focus')); + + editNode.textContent = newText; + editNode.dispatchEvent(new window.FocusEvent('focus')); + editNode.dispatchEvent(new window.FocusEvent('blur')); + }); + } + + function moveArrowTail(dx, dy, expectation) { + var px = 243; + var py = 150; + + return new Promise(function(resolve) { + gd.once('plotly_relayout', function(eventData) { + expect(eventData).toEqual(expectation); + resolve(); + }); + + mouseEvent('mousemove', px, py); + mouseEvent('mousedown', px, py); + mouseEvent('mousemove', px + dx, py + dy); + mouseEvent('mouseup', px + dx, py + dy); + }); + } + + Plotly.plot(gd, [{ + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [1, 2, 1] + }], { + scene: { + annotations: [{ + text: 'hello', + x: 2, y: 2, z: 2, + font: { size: 30 } + }] + }, + margin: {l: 0, t: 0, r: 0, b: 0}, + width: 500, + height: 500 + }, { + editable: true + }) + .then(function() { + return editText('allo', {'scene.annotations[0].text': 'allo'}); + }) + .then(function() { + return moveArrowTail(-100, -50, { + 'scene.annotations[0].ax': -110, + 'scene.annotations[0].ay': -80 + }); + }) + .catch(fail) + .then(done); + }); + + it('should display hover labels and trigger *plotly_clickannotation* event', function(done) { + function dispatch(eventType) { + var target = d3.select('g.annotation-text-g').select('g').node(); + target.dispatchEvent(new MouseEvent(eventType)); + } + + Plotly.plot(gd, [{ + type: 'scatter3d', + x: [1, 2, 3], + y: [1, 2, 3], + z: [1, 2, 1] + }], { + scene: { + annotations: [{ + text: 'hello', + x: 2, y: 2, z: 2, + ax: 0, ay: -100, + hovertext: 'HELLO', + hoverlabel: { + bgcolor: 'red', + font: { size: 20 } + } + }] + }, + width: 500, + height: 500 + }) + .then(function() { + dispatch('mouseover'); + expect(d3.select('.hovertext').size()).toEqual(1); + }) + .then(function() { + return new Promise(function(resolve, reject) { + gd.once('plotly_clickannotation', function(eventData) { + expect(eventData.index).toEqual(0); + expect(eventData.subplotId).toEqual('scene'); + resolve(); + }); + + setTimeout(function() { + reject('plotly_clickannotation did not get called!'); + }, 100); + + dispatch('click'); + }); + }) + .catch(fail) + .then(done); + }); +});