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);
+ });
+});