From aa58118e3665dd60d3fdda689ae58cf1814f99c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 28 Apr 2017 17:08:36 -0400 Subject: [PATCH 01/25] replace forEach with for-loop over ['x', 'y'] --- src/components/annotations/draw.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 371af5ef0da..1fb2cfdfe02 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -225,10 +225,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), @@ -350,7 +353,7 @@ function drawOne(gd, index) { // size/shift are used during dragging options['_' + axLetter + 'size'] = annSize; options['_' + axLetter + 'shift'] = textShift; - }); + } if(annotationIsOffscreen) { annTextGroupInner.remove(); From 8b8bdeae0c359d0958477b57012b84ea4651aaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 28 Apr 2017 17:14:52 -0400 Subject: [PATCH 02/25] don't (uselessly) add data-index to ann text+bg & arrow groups - one `data-index` attr on the top-level ann group should be enough. --- src/components/annotations/draw.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 1fb2cfdfe02..d3c3bf90d64 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -101,8 +101,7 @@ 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)); + .classed('annotation-text-g', true); var annTextGroupInner = annTextGroup.append('g') .style('pointer-events', options.captureevents ? 'all' : null) @@ -474,8 +473,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) @@ -498,7 +496,6 @@ function drawOne(gd, index) { .classed('annotation', true) .classed('anndrag', true) .attr({ - 'data-index': String(index), d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY), transform: 'translate(' + arrowDragHeadX + ',' + arrowDragHeadY + ')' }) From 1a8f1ed1e855fb9157f728c191b00784fe3f1258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 28 Apr 2017 17:15:47 -0400 Subject: [PATCH 03/25] don't (uselessly) add annotation class to ann text group inner --- src/components/annotations/draw.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index d3c3bf90d64..029b98c4e20 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -168,7 +168,6 @@ function drawOne(gd, index) { var font = options.font; var annText = annTextGroupInner.append('text') - .classed('annotation', true) .attr('data-unformatted', options.text) .text(options.text); @@ -493,7 +492,6 @@ function drawOne(gd, index) { arrowDragHeadY += options.standoff * (tailY - headY) / arrowLength; } var arrowDrag = arrowGroup.append('path') - .classed('annotation', true) .classed('anndrag', true) .attr({ d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY), From f4b14e686c30daf011cf82a675042979dcc4c213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 28 Apr 2017 17:16:41 -0400 Subject: [PATCH 04/25] fine tune annotation arrow selector - instead of looking through the whole graph div --- src/components/annotations/draw.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 029b98c4e20..6d39536f5c0 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -414,8 +414,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, From 4dc16e4cb5e1d5f37f267c170c37e2f506a65bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 28 Apr 2017 17:17:50 -0400 Subject: [PATCH 05/25] use ax.?2? while composing convert methods - so that they can be mutated downstream --- src/plots/cartesian/set_convert.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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; } From 8e6ab7d051ce8d163639d312524248aa2a2a2d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 28 Apr 2017 17:20:35 -0400 Subject: [PATCH 06/25] split annotation drawOne into drawRaw - which expects an option object, xaxis and yaxis - generalize className given to annotation to group and base attribute string (for editable: true) to accommodate 3d annotations - use `_input` ref instead of `gd.layout` to grab user layout options --- src/components/annotations/draw.js | 64 +++++++++++++++++++---------- src/components/annotations/index.js | 1 + 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 6d39536f5c0..4e2dbfad123 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 }; /* @@ -62,32 +63,51 @@ function draw(gd) { * 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, xa, ya); +} + +/* + * drawRaw: draw a single annotation, potentially with modifications + * + * options (object): this annotation's options + * index (int): the annotation to draw + * xa (object | undefined): full x-axis object to compute subplot pos-to-px + * ya (object | undefined): ... y-axis + */ +function drawRaw(gd, options, index, xa, ya) { + var fullLayout = gd._fullLayout; + var gs = gd._fullLayout._size; - // remember a few things about what was already there, - var optionsIn = (layout.annotations || [])[index], - options = fullLayout.annotations[index]; + var className = options._scene ? + 'annotation-' + options._scene : + 'annotation'; + + var annbase = options._scene ? + options._scene + '.annotations[' + index + ']' : + '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,7 +115,7 @@ 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); @@ -110,7 +130,7 @@ function drawOne(gd, index) { gd._dragging = false; gd.emit('plotly_clickannotation', { index: index, - annotation: optionsIn, + annotation: options._input, fullAnnotation: options }); }); @@ -405,8 +425,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 @@ -646,14 +664,18 @@ 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; + + // TODO 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, From c5f6eb2b7973bd558f12bede16f2936ca15aa1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 28 Apr 2017 17:24:55 -0400 Subject: [PATCH 07/25] introduce 3D annotations - add attrs and defaults to scene containers - N.B. x/y/z are always in data coords ax/ay are always in px coords - mock xaxis and yaxis on scene updates so that their l2p method updates the projection data coord in the x-y plane - and annotations handler in render loop --- src/plots/gl3d/layout/defaults.js | 90 +++++++++++++++++++++- src/plots/gl3d/layout/layout_attributes.js | 80 +++++++++++++++++++ src/plots/gl3d/scene.js | 88 ++++++++++++++++++++- 3 files changed, 256 insertions(+), 2 deletions(-) diff --git a/src/plots/gl3d/layout/defaults.js b/src/plots/gl3d/layout/defaults.js index 1fafd4a49a4..aead5d413b9 100644 --- a/src/plots/gl3d/layout/defaults.js +++ b/src/plots/gl3d/layout/defaults.js @@ -11,10 +11,12 @@ var Lib = require('../../../lib'); var Color = require('../../../components/color'); +var Axes = require('../../cartesian/axes'); var handleSubplotDefaults = require('../../subplot_defaults'); -var layoutAttributes = require('./layout_attributes'); +var handleArrayContainerDefaults = require('../../array_container_defaults'); var supplyGl3dAxisLayoutDefaults = require('./axis_defaults'); +var layoutAttributes = require('./layout_attributes'); module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { @@ -97,6 +99,92 @@ function handleGl3dDefaults(sceneLayoutIn, sceneLayoutOut, coerce, opts) { calendar: opts.calendar }); + handleArrayContainerDefaults(sceneLayoutIn, sceneLayoutOut, { + name: 'annotations', + handleItemDefaults: handleAnnotationDefaults, + font: opts.font, + scene: opts.id + }); + coerce('dragmode', opts.getDfltFromLayout('dragmode')); coerce('hovermode', opts.getDfltFromLayout('hovermode')); } + +function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { + + function coerce(attr, dflt) { + return Lib.coerce(annIn, annOut, layoutAttributes.annotations, attr, dflt); + } + + function coercePosition(axLetter) { + var axName = axLetter + 'axis'; + var gdMock = { _fullLayout: {} }; + + // mock in such way that getFromId grabs correct 3D axis + gdMock._fullLayout[axName] = sceneLayout[axName]; + + // hard-set here for completeness + annOut[axLetter + 'ref'] = axLetter; + + return Axes.coercePosition(annOut, gdMock, coerce, axLetter, axLetter, 0.5); + } + + var visible = coerce('visible', !itemOpts.itemIsNotPlainObject); + if(!visible) return annOut; + + coerce('opacity'); + coerce('align'); + 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', opts.font); + + coerce('width'); + coerce('align'); + + var h = coerce('height'); + if(h) coerce('valign'); + + coercePosition('x'); + coercePosition('y'); + coercePosition('z'); + + // if you have one coordinate you should all three + Lib.noneOrAll(annIn, annOut, ['x', 'y', 'z']); + + coerce('xanchor'); + coerce('yanchor'); + coerce('xshift'); + coerce('yshift'); + + if(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']); + + coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); + coerce('arrowhead'); + coerce('arrowsize'); + coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); + coerce('standoff'); + } + + annOut._scene = opts.scene; + + return annOut; +} diff --git a/src/plots/gl3d/layout/layout_attributes.js b/src/plots/gl3d/layout/layout_attributes.js index 7b83424f1f7..bbb44d3895a 100644 --- a/src/plots/gl3d/layout/layout_attributes.js +++ b/src/plots/gl3d/layout/layout_attributes.js @@ -10,6 +10,7 @@ 'use strict'; var gl3dAxisAttrs = require('./axis_attributes'); +var annAtts = require('../../../components/annotations/attributes'); var extendFlat = require('../../../lib/extend').extendFlat; function makeVector(x, y, z) { @@ -33,6 +34,8 @@ function makeVector(x, y, z) { } module.exports = { + _arrayAttrRegexps: [/^scene([2-9]|[1-9][0-9]+)?\.annotations/], + bgcolor: { valType: 'color', role: 'style', @@ -139,6 +142,83 @@ module.exports = { yaxis: gl3dAxisAttrs, zaxis: gl3dAxisAttrs, + annotations: { + _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: 'any', + role: 'info', + description: [ + 'Sets the x component of the arrow tail about the arrow head.' + ].join(' ') + }, + ay: { + valType: 'any', + role: 'info', + description: [ + 'Sets the y component of the arrow tail about the arrow head.' + ].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, + + // maybes later + // clicktoshow: annAtts.clicktoshow, + // xclick: annAtts.xclick, + // yclick: annAtts.yclick, + + // not needed! + // axref: 'pixel' + // ayref: 'pixel' + // xref: 'x' + // yref: 'y + // zref: 'z' + }, + dragmode: { valType: 'enumerated', role: 'info', diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index bf35acc3902..fe5ddbd4b80 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'); @@ -122,6 +123,8 @@ function render(scene) { Fx.loneUnhover(svgContainer); scene.graphDiv.emit('plotly_unhover', oldEventData); } + + scene.handleAnnotations(); } function initializeGLPlot(scene, fullLayout, canvas, gl) { @@ -711,11 +714,94 @@ proto.toImage = function(format) { }; proto.setConvert = function() { - for(var i = 0; i < 3; ++i) { + var i; + + for(i = 0; i < 3; i++) { var ax = this.fullSceneLayout[axisProperties[i]]; Axes.setConvert(ax, this.fullLayout); ax.setScale = Lib.noop; } + + var anns = this.fullSceneLayout.annotations; + for(i = 0; i < anns.length; i++) { + mockAnnAxes(anns[i], this); + } + + this.fullLayout._infolayer + .selectAll('.annotation-' + this.id) + .remove(); }; +proto.handleAnnotations = function() { + var drawAnnotation = Registry.getComponentMethod('annotations', 'drawRaw'); + var fullSceneLayout = this.fullSceneLayout; + var dataScale = this.dataScale; + var anns = fullSceneLayout.annotations; + var axLetters = ['x', 'y', 'z']; + + 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 posFraction = fullSceneLayout[axLetter + 'axis'].r2fraction(ann[axLetter]); + + if(posFraction < 0 || posFraction > 1) { + annotationIsOffscreen = true; + break; + } + } + + if(annotationIsOffscreen) { + this.fullLayout._infolayer + .select('.annotation-' + this.id + '[data-index="' + i + '"]') + .remove(); + } else { + ann.pdata = project(this.glplot.cameraParams, [ + fullSceneLayout.xaxis.d2l(ann.x) * dataScale[0], + fullSceneLayout.yaxis.d2l(ann.y) * dataScale[1], + fullSceneLayout.zaxis.d2l(ann.z) * dataScale[2] + ]); + + drawAnnotation(this.graphDiv, ann, i, ann._xa, ann._ya); + } + } +}; + +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', + + // 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]); + }; +} + module.exports = Scene; From b7ab656d697a2c03b2ab0fee27fcee533496d524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 28 Apr 2017 17:26:39 -0400 Subject: [PATCH 08/25] don't try to edit x,y,z positions for 3D annotations - as this problem is ill-defined, mouse xy -> scene xyz as infinity-many solutions. - N.B. editing arrow tail position (i.e. ax, ay) and annotation text works! --- src/components/annotations/draw.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 4e2dbfad123..b9369e73bf8 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -501,7 +501,7 @@ function drawRaw(gd, options, index, xa, ya) { // 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 && !options._scene) { var arrowDragHeadX = headX; var arrowDragHeadY = headY; if(options.standoff) { @@ -610,7 +610,7 @@ function drawRaw(gd, options, index, xa, ya) { drawArrow(dx, dy); } - else { + else if(!options._scene) { if(xa) update[annbase + '.x'] = options.x + dx / xa._m; else { var widthFraction = options._xsize / gs.w, @@ -638,6 +638,7 @@ function drawRaw(gd, options, index, xa, ya) { ); } } + else return; annTextGroup.attr({ transform: 'translate(' + dx + ',' + dy + ')' + baseTextTransform From 94b940d96e70016b7203a62b2ac062de1909c79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 16 May 2017 10:54:50 -0400 Subject: [PATCH 09/25] rename anchor -> anchor3 so that it doesn't conflict w/ for-loop block --- src/components/annotations/draw.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index c696ba71f26..bb11eda9558 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -207,11 +207,11 @@ function drawRaw(gd, options, index, xa, ya) { 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'}); From ababc49990ea8e6be7e07a5f85ec366e54a1d70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 24 May 2017 10:33:36 -0400 Subject: [PATCH 10/25] Revert "don't (uselessly) add annotation class to ann text group inner" This reverts commit 1a8f1ed1e855fb9157f728c191b00784fe3f1258. --- src/components/annotations/draw.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index bb11eda9558..aac5a099a4e 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -189,6 +189,7 @@ function drawRaw(gd, options, index, xa, ya) { var font = options.font; var annText = annTextGroupInner.append('text') + .classed('annotation', true) .attr('data-unformatted', options.text) .text(options.text); @@ -523,6 +524,7 @@ function drawRaw(gd, options, index, xa, ya) { arrowDragHeadY += options.standoff * (tailY - headY) / arrowLength; } var arrowDrag = arrowGroup.append('path') + .classed('annotation', true) .classed('anndrag', true) .attr({ d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY), From 7cfba365f9ffe8011e7eaddb5253c60bdaffd230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 24 May 2017 19:14:42 -0400 Subject: [PATCH 11/25] fix 3d annotations on type: 'category' axes - coerce (x, y, z) in scene.plot after the ax._categories is filled - use .r2l to update annotation position - could be cleaned up (by e.g. wrapping the 3d annotation default into a required subroutine). --- src/plots/gl3d/layout/defaults.js | 33 +++++++++-------------- src/plots/gl3d/scene.js | 44 +++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/plots/gl3d/layout/defaults.js b/src/plots/gl3d/layout/defaults.js index aead5d413b9..27ec95c1266 100644 --- a/src/plots/gl3d/layout/defaults.js +++ b/src/plots/gl3d/layout/defaults.js @@ -11,7 +11,6 @@ var Lib = require('../../../lib'); var Color = require('../../../components/color'); -var Axes = require('../../cartesian/axes'); var handleSubplotDefaults = require('../../subplot_defaults'); var handleArrayContainerDefaults = require('../../array_container_defaults'); @@ -111,24 +110,10 @@ function handleGl3dDefaults(sceneLayoutIn, sceneLayoutOut, coerce, opts) { } function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { - function coerce(attr, dflt) { return Lib.coerce(annIn, annOut, layoutAttributes.annotations, attr, dflt); } - function coercePosition(axLetter) { - var axName = axLetter + 'axis'; - var gdMock = { _fullLayout: {} }; - - // mock in such way that getFromId grabs correct 3D axis - gdMock._fullLayout[axName] = sceneLayout[axName]; - - // hard-set here for completeness - annOut[axLetter + 'ref'] = axLetter; - - return Axes.coercePosition(annOut, gdMock, coerce, axLetter, axLetter, 0.5); - } - var visible = coerce('visible', !itemOpts.itemIsNotPlainObject); if(!visible) return annOut; @@ -136,8 +121,8 @@ function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { coerce('align'); coerce('bgcolor'); - var borderColor = coerce('bordercolor'), - borderOpacity = Color.opacity(borderColor); + var borderColor = coerce('bordercolor'); + var borderOpacity = Color.opacity(borderColor); coerce('borderpad'); @@ -154,13 +139,21 @@ function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { var h = coerce('height'); if(h) coerce('valign'); - coercePosition('x'); - coercePosition('y'); - coercePosition('z'); + // Do not use Axes.coercePosition here + // as ax._categories aren't filled in at this stage, + // Axes.coercePosition is called during scene.setConvert instead + 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'); diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index 63c206821f5..9a87d8c1e24 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -26,6 +26,7 @@ var project = require('./project'); var createAxesOptions = require('./layout/convert'); var createSpikeOptions = require('./layout/spikes'); var computeTickMarks = require('./layout/tick_marks'); +var layoutAttributes = require('./layout/layout_attributes'); var STATIC_CANVAS, STATIC_CONTEXT; @@ -396,6 +397,9 @@ proto.plot = function(sceneData, fullLayout, layout) { // Save scale this.dataScale = dataScale; + // after computeTraceBounds where ax._categories are filled in + this.updateAnnotations(); + // Update traces for(i = 0; i < sceneData.length; ++i) { data = sceneData[i]; @@ -719,16 +723,17 @@ proto.toImage = function(format) { }; proto.setConvert = function() { - var i; - - for(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; } +}; +proto.updateAnnotations = function() { var anns = this.fullSceneLayout.annotations; - for(i = 0; i < anns.length; i++) { + + for(var i = 0; i < anns.length; i++) { mockAnnAxes(anns[i], this); } @@ -750,7 +755,9 @@ proto.handleAnnotations = function() { for(var j = 0; j < 3; j++) { var axLetter = axLetters[j]; - var posFraction = fullSceneLayout[axLetter + 'axis'].r2fraction(ann[axLetter]); + var pos = ann[axLetter]; + var ax = fullSceneLayout[axLetter + 'axis']; + var posFraction = ax.r2fraction(pos); if(posFraction < 0 || posFraction > 1) { annotationIsOffscreen = true; @@ -764,9 +771,9 @@ proto.handleAnnotations = function() { .remove(); } else { ann.pdata = project(this.glplot.cameraParams, [ - fullSceneLayout.xaxis.d2l(ann.x) * dataScale[0], - fullSceneLayout.yaxis.d2l(ann.y) * dataScale[1], - fullSceneLayout.zaxis.d2l(ann.z) * dataScale[2] + fullSceneLayout.xaxis.r2l(ann.x) * dataScale[0], + fullSceneLayout.yaxis.r2l(ann.y) * dataScale[1], + fullSceneLayout.zaxis.r2l(ann.z) * dataScale[2] ]); drawAnnotation(this.graphDiv, ann, i, ann._xa, ann._ya); @@ -807,6 +814,27 @@ function mockAnnAxes(ann, scene) { 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, layoutAttributes.annotations, 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'); } module.exports = Scene; From 8f9e3703dcb17301c3bf6843420064e8235d9398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 24 May 2017 19:15:24 -0400 Subject: [PATCH 12/25] make sure to clear 3d annotations when parent scene gets removed --- src/plots/gl3d/index.js | 6 ++++++ 1 file changed, 6 insertions(+) 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(); + } } } }; From 1bbabad04c1ebe3d3896247ce03c0d116cddd4a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 24 May 2017 19:16:22 -0400 Subject: [PATCH 13/25] implement autorange logic for annotations - N.B. does not take into consideration the arrowhead (debatable) --- src/components/annotations/draw.js | 1 - src/plots/gl3d/scene.js | 30 ++++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index aac5a099a4e..bbf58caa6c2 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -684,7 +684,6 @@ function drawRaw(gd, options, index, xa, ya) { var update = {}; update[annbase + '.text'] = options.text; - // TODO if(xa && xa.autorange) { update[xa._name + '.autorange'] = true; } diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index 9a87d8c1e24..a68c9a1432c 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -459,13 +459,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); @@ -793,6 +808,9 @@ function mockAnnAxes(ann, scene) { // 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 From 952094317a6544d4ccf62b3c6a230ce6f9af71d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 24 May 2017 19:16:39 -0400 Subject: [PATCH 14/25] first pass 3d annotations jasmine tests --- test/jasmine/tests/gl_plot_interact_test.js | 334 +++++++++++++++++++- 1 file changed, 333 insertions(+), 1 deletion(-) diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js index a20a1dd0fb9..38996def6c5 100644 --- a/test/jasmine/tests/gl_plot_interact_test.js +++ b/test/jasmine/tests/gl_plot_interact_test.js @@ -1284,7 +1284,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 +1328,336 @@ 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) { + return new Promise(function(resolve) { + var scene = gd._fullLayout.scene._scene; + var camera = scene.getCamera(); + + camera.eye = {x: x, y: y, z: z}; + scene.setCamera(camera); + + setTimeout(resolve, 100); + }); + } + + 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(function() { + assertAnnotationsXY([[340, 187], [341, 142], [325, 221]], 'after camera update'); + + return updateCamera(2.1, 0.1, 0.9); + }) + .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); + }); +}); From f61cd4dc99b91247e409bcad64cf79558f265a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 24 May 2017 19:18:13 -0400 Subject: [PATCH 15/25] first pass 3d annotation image test support --- test/image/baselines/gl3d_annotations.png | Bin 0 -> 33441 bytes test/image/baselines/gl3d_triangle.png | Bin 21294 -> 22885 bytes test/image/mocks/gl3d_annotations.json | 78 ++++++++++++++++++++++ test/image/mocks/gl3d_triangle.json | 14 +++- 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 test/image/baselines/gl3d_annotations.png create mode 100644 test/image/mocks/gl3d_annotations.json diff --git a/test/image/baselines/gl3d_annotations.png b/test/image/baselines/gl3d_annotations.png new file mode 100644 index 0000000000000000000000000000000000000000..d59ee820da2192bafb3e207efb9e831ff5210326 GIT binary patch literal 33441 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 literal 21294 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 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 } } From 8cb10c16c308fed5c3c2b4141c791ccc2b7358e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 25 May 2017 10:38:31 -0400 Subject: [PATCH 16/25] try to make test that update the scene camera more robust --- test/jasmine/tests/gl_plot_interact_test.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js index 38996def6c5..cd4d0302756 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); @@ -1373,15 +1374,11 @@ describe('Test gl3d annotations', function() { // more robust (especially on CI) than update camera via mouse events function updateCamera(x, y, z) { - return new Promise(function(resolve) { - var scene = gd._fullLayout.scene._scene; - var camera = scene.getCamera(); - - camera.eye = {x: x, y: y, z: z}; - scene.setCamera(camera); + var scene = gd._fullLayout.scene._scene; + var camera = scene.getCamera(); - setTimeout(resolve, 100); - }); + camera.eye = {x: x, y: y, z: z}; + scene.setCamera(camera); } it('should move with camera', function(done) { @@ -1410,11 +1407,13 @@ describe('Test gl3d annotations', function() { 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'); }) From a31549d3b7532c48200289607ab435e4e80d891f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 25 May 2017 11:24:07 -0400 Subject: [PATCH 17/25] replace 'annotation' class for 'annotation-text' for annation text nodes - so that the `selectAll('.annotation')` call Annotation.draw doesn't clear 3d annotation text nodes. --- src/components/annotations/draw.js | 4 ++-- test/jasmine/tests/drawing_test.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index bbf58caa6c2..36fd234f1ca 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -189,7 +189,7 @@ function drawRaw(gd, options, index, xa, ya) { var font = options.font; var annText = annTextGroupInner.append('text') - .classed('annotation', true) + .classed('annotation-text', true) .attr('data-unformatted', options.text) .text(options.text); @@ -524,7 +524,7 @@ function drawRaw(gd, options, index, xa, ya) { arrowDragHeadY += options.standoff * (tailY - headY) / arrowLength; } var arrowDrag = arrowGroup.append('path') - .classed('annotation', true) + .classed('annotation-arrow', true) .classed('anndrag', true) .attr({ d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY), 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, From bd9c867cd6b4cd985dcdd8f3ab3e4ea02376f325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 25 May 2017 14:20:50 -0400 Subject: [PATCH 18/25] mv 3d annotations code out of plots/gl3d into component/annotations3d - N.B. leave (small) autorange block in scene.js --- src/components/annotations3d/attributes.js | 89 +++++++++++++++ src/components/annotations3d/convert.js | 85 +++++++++++++++ src/components/annotations3d/defaults.js | 96 +++++++++++++++++ src/components/annotations3d/draw.js | 50 +++++++++ src/components/annotations3d/index.js | 26 +++++ src/core.js | 1 + src/plots/gl3d/layout/defaults.js | 84 +-------------- src/plots/gl3d/layout/layout_attributes.js | 78 -------------- src/plots/gl3d/scene.js | 119 +-------------------- 9 files changed, 356 insertions(+), 272 deletions(-) create mode 100644 src/components/annotations3d/attributes.js create mode 100644 src/components/annotations3d/convert.js create mode 100644 src/components/annotations3d/defaults.js create mode 100644 src/components/annotations3d/draw.js create mode 100644 src/components/annotations3d/index.js diff --git a/src/components/annotations3d/attributes.js b/src/components/annotations3d/attributes.js new file mode 100644 index 00000000000..2c2e86e76bb --- /dev/null +++ b/src/components/annotations3d/attributes.js @@ -0,0 +1,89 @@ +/** +* 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: 'any', + role: 'info', + description: [ + 'Sets the x component of the arrow tail about the arrow head.' + ].join(' ') + }, + ay: { + valType: 'any', + role: 'info', + description: [ + 'Sets the y component of the arrow tail about the arrow head.' + ].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, + + // 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..ff2a1d67584 --- /dev/null +++ b/src/components/annotations3d/defaults.js @@ -0,0 +1,96 @@ +/** +* 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'); +var handleArrayContainerDefaults = require('../../plots/array_container_defaults'); +var attributes = require('./attributes'); + +module.exports = function(sceneLayoutIn, sceneLayoutOut, opts) { + handleArrayContainerDefaults(sceneLayoutIn, sceneLayoutOut, { + name: 'annotations', + handleItemDefaults: handleAnnotationDefaults, + font: opts.font, + scene: opts.id + }); +}; + +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; + + coerce('opacity'); + coerce('align'); + 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', opts.font); + + coerce('width'); + coerce('align'); + + var h = coerce('height'); + if(h) coerce('valign'); + + // Do not use Axes.coercePosition here + // as ax._categories aren't filled in at this stage, + // Axes.coercePosition is called during scene.setConvert instead + 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(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']); + + coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); + coerce('arrowhead'); + coerce('arrowsize'); + coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); + coerce('standoff'); + } + + annOut._scene = opts.scene; + + return annOut; +} diff --git a/src/components/annotations3d/draw.js b/src/components/annotations3d/draw.js new file mode 100644 index 00000000000..8a62b6f08a9 --- /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, 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/gl3d/layout/defaults.js b/src/plots/gl3d/layout/defaults.js index 27ec95c1266..956151a1d7b 100644 --- a/src/plots/gl3d/layout/defaults.js +++ b/src/plots/gl3d/layout/defaults.js @@ -11,9 +11,9 @@ var Lib = require('../../../lib'); var Color = require('../../../components/color'); +var Registry = require('../../../registry'); var handleSubplotDefaults = require('../../subplot_defaults'); -var handleArrayContainerDefaults = require('../../array_container_defaults'); var supplyGl3dAxisLayoutDefaults = require('./axis_defaults'); var layoutAttributes = require('./layout_attributes'); @@ -98,86 +98,10 @@ function handleGl3dDefaults(sceneLayoutIn, sceneLayoutOut, coerce, opts) { calendar: opts.calendar }); - handleArrayContainerDefaults(sceneLayoutIn, sceneLayoutOut, { - name: 'annotations', - handleItemDefaults: handleAnnotationDefaults, - font: opts.font, - scene: opts.id - }); + Registry.getComponentMethod('annotations3d', 'handleDefaults')( + sceneLayoutIn, sceneLayoutOut, opts + ); coerce('dragmode', opts.getDfltFromLayout('dragmode')); coerce('hovermode', opts.getDfltFromLayout('hovermode')); } - -function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { - function coerce(attr, dflt) { - return Lib.coerce(annIn, annOut, layoutAttributes.annotations, attr, dflt); - } - - var visible = coerce('visible', !itemOpts.itemIsNotPlainObject); - if(!visible) return annOut; - - coerce('opacity'); - coerce('align'); - 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', opts.font); - - coerce('width'); - coerce('align'); - - var h = coerce('height'); - if(h) coerce('valign'); - - // Do not use Axes.coercePosition here - // as ax._categories aren't filled in at this stage, - // Axes.coercePosition is called during scene.setConvert instead - 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(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']); - - coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); - coerce('arrowhead'); - coerce('arrowsize'); - coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); - coerce('standoff'); - } - - annOut._scene = opts.scene; - - return annOut; -} diff --git a/src/plots/gl3d/layout/layout_attributes.js b/src/plots/gl3d/layout/layout_attributes.js index bbb44d3895a..92e9d8c1ab0 100644 --- a/src/plots/gl3d/layout/layout_attributes.js +++ b/src/plots/gl3d/layout/layout_attributes.js @@ -10,7 +10,6 @@ 'use strict'; var gl3dAxisAttrs = require('./axis_attributes'); -var annAtts = require('../../../components/annotations/attributes'); var extendFlat = require('../../../lib/extend').extendFlat; function makeVector(x, y, z) { @@ -142,83 +141,6 @@ module.exports = { yaxis: gl3dAxisAttrs, zaxis: gl3dAxisAttrs, - annotations: { - _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: 'any', - role: 'info', - description: [ - 'Sets the x component of the arrow tail about the arrow head.' - ].join(' ') - }, - ay: { - valType: 'any', - role: 'info', - description: [ - 'Sets the y component of the arrow tail about the arrow head.' - ].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, - - // maybes later - // clicktoshow: annAtts.clicktoshow, - // xclick: annAtts.xclick, - // yclick: annAtts.yclick, - - // not needed! - // axref: 'pixel' - // ayref: 'pixel' - // xref: 'x' - // yref: 'y - // zref: 'z' - }, - dragmode: { valType: 'enumerated', role: 'info', diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index a68c9a1432c..4d1c12192c0 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -26,12 +26,10 @@ var project = require('./project'); var createAxesOptions = require('./layout/convert'); var createSpikeOptions = require('./layout/spikes'); var computeTickMarks = require('./layout/tick_marks'); -var layoutAttributes = require('./layout/layout_attributes'); var STATIC_CANVAS, STATIC_CONTEXT; function render(scene) { - var trace; // update size of svg container @@ -130,7 +128,7 @@ function render(scene) { scene.graphDiv.emit('plotly_unhover', oldEventData); } - scene.handleAnnotations(); + scene.drawAnnotations(scene); } function initializeGLPlot(scene, fullLayout, canvas, gl) { @@ -273,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 } @@ -398,7 +399,7 @@ proto.plot = function(sceneData, fullLayout, layout) { this.dataScale = dataScale; // after computeTraceBounds where ax._categories are filled in - this.updateAnnotations(); + this.convertAnnotations(this); // Update traces for(i = 0; i < sceneData.length; ++i) { @@ -745,114 +746,4 @@ proto.setConvert = function() { } }; -proto.updateAnnotations = function() { - var anns = this.fullSceneLayout.annotations; - - for(var i = 0; i < anns.length; i++) { - mockAnnAxes(anns[i], this); - } - - this.fullLayout._infolayer - .selectAll('.annotation-' + this.id) - .remove(); -}; - -proto.handleAnnotations = function() { - var drawAnnotation = Registry.getComponentMethod('annotations', 'drawRaw'); - var fullSceneLayout = this.fullSceneLayout; - var dataScale = this.dataScale; - var anns = fullSceneLayout.annotations; - var axLetters = ['x', 'y', 'z']; - - 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) { - this.fullLayout._infolayer - .select('.annotation-' + this.id + '[data-index="' + i + '"]') - .remove(); - } else { - ann.pdata = project(this.glplot.cameraParams, [ - fullSceneLayout.xaxis.r2l(ann.x) * dataScale[0], - fullSceneLayout.yaxis.r2l(ann.y) * dataScale[1], - fullSceneLayout.zaxis.r2l(ann.z) * dataScale[2] - ]); - - drawAnnotation(this.graphDiv, ann, i, ann._xa, ann._ya); - } - } -}; - -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, layoutAttributes.annotations, 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'); -} - module.exports = Scene; From 21c8cc6041a7e30368bda531446c2a49f88a916c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 25 May 2017 15:06:50 -0400 Subject: [PATCH 19/25] add common annotations defaults modules - which coerces all non-position attributes for annotations and annotations3d - automatically adds supports for 'hovertext' and 'hoverlabel' for annotation3d :tada: --- .../annotations/annotation_defaults.js | 52 ++------------- src/components/annotations/common_defaults.js | 64 +++++++++++++++++++ src/components/annotations3d/attributes.js | 5 +- src/components/annotations3d/defaults.js | 36 ++--------- src/plots/gl3d/layout/defaults.js | 1 + 5 files changed, 79 insertions(+), 79 deletions(-) create mode 100644 src/components/annotations/common_defaults.js diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index 1f008942258..f0b149b99ed 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,7 @@ 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); + coerce('captureevents', !!annOut.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..112e204bf04 --- /dev/null +++ b/src/components/annotations/common_defaults.js @@ -0,0 +1,64 @@ +/** +* 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 + }); + } +}; diff --git a/src/components/annotations3d/attributes.js b/src/components/annotations3d/attributes.js index 2c2e86e76bb..f96b4169d2b 100644 --- a/src/components/annotations3d/attributes.js +++ b/src/components/annotations3d/attributes.js @@ -74,11 +74,14 @@ module.exports = { arrowsize: annAtts.arrowsize, arrowwidth: annAtts.arrowwidth, standoff: annAtts.standoff, + hovertext: annAtts.hovertext, + hoverlabel: annAtts.hoverlabel - // maybes later + // maybes later? // clicktoshow: annAtts.clicktoshow, // xclick: annAtts.xclick, // yclick: annAtts.yclick, + // captureevent: annAtts.captureevent // not needed! // axref: 'pixel' diff --git a/src/components/annotations3d/defaults.js b/src/components/annotations3d/defaults.js index ff2a1d67584..eed9d171d86 100644 --- a/src/components/annotations3d/defaults.js +++ b/src/components/annotations3d/defaults.js @@ -9,15 +9,15 @@ 'use strict'; var Lib = require('../../lib'); -var Color = require('../color'); var handleArrayContainerDefaults = require('../../plots/array_container_defaults'); +var handleAnnotationCommonDefaults = require('../annotations/common_defaults'); var attributes = require('./attributes'); -module.exports = function(sceneLayoutIn, sceneLayoutOut, opts) { +module.exports = function handleDefaults(sceneLayoutIn, sceneLayoutOut, opts) { handleArrayContainerDefaults(sceneLayoutIn, sceneLayoutOut, { name: 'annotations', handleItemDefaults: handleAnnotationDefaults, - font: opts.font, + fullLayout: opts.fullLayout, scene: opts.id }); }; @@ -30,27 +30,7 @@ function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { var visible = coerce('visible', !itemOpts.itemIsNotPlainObject); if(!visible) return annOut; - coerce('opacity'); - coerce('align'); - 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', opts.font); - - coerce('width'); - coerce('align'); - - var h = coerce('height'); - if(h) coerce('valign'); + handleAnnotationCommonDefaults(annIn, annOut, opts.fullLayout, coerce); // Do not use Axes.coercePosition here // as ax._categories aren't filled in at this stage, @@ -72,7 +52,7 @@ function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { coerce('xshift'); coerce('yshift'); - if(showArrow) { + if(annOut.showarrow) { annOut.axref = 'pixel'; annOut.ayref = 'pixel'; @@ -82,12 +62,6 @@ function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { // if you have one part of arrow length you should have both Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); - - coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); - coerce('arrowhead'); - coerce('arrowsize'); - coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); - coerce('standoff'); } annOut._scene = opts.scene; diff --git a/src/plots/gl3d/layout/defaults.js b/src/plots/gl3d/layout/defaults.js index 956151a1d7b..e0fdb42397e 100644 --- a/src/plots/gl3d/layout/defaults.js +++ b/src/plots/gl3d/layout/defaults.js @@ -34,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, From adfc305a0918c4afcb8b17b737e281517ec09613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 25 May 2017 16:03:32 -0400 Subject: [PATCH 20/25] declare `ax` and `ay` as `valType: number` for annotation3d - as they can only be set in pixels. --- src/components/annotations3d/attributes.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/annotations3d/attributes.js b/src/components/annotations3d/attributes.js index f96b4169d2b..3a04dbd5391 100644 --- a/src/components/annotations3d/attributes.js +++ b/src/components/annotations3d/attributes.js @@ -37,17 +37,17 @@ module.exports = { ].join(' ') }, ax: { - valType: 'any', + valType: 'number', role: 'info', description: [ - 'Sets the x component of the arrow tail about the arrow head.' + 'Sets the x component of the arrow tail about the arrow head (in pixels).' ].join(' ') }, ay: { - valType: 'any', + valType: 'number', role: 'info', description: [ - 'Sets the y component of the arrow tail about the arrow head.' + 'Sets the y component of the arrow tail about the arrow head (in pixels).' ].join(' ') }, From c084da0e2282c6ea2e680e4e01dfcbbe910a949f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 25 May 2017 16:07:45 -0400 Subject: [PATCH 21/25] rename _scene ref to scene id in annotation container _sceneId --- src/components/annotations/draw.js | 12 ++++++------ src/components/annotations3d/defaults.js | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 36fd234f1ca..93d753a590b 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -83,12 +83,12 @@ function drawRaw(gd, options, index, xa, ya) { var fullLayout = gd._fullLayout; var gs = gd._fullLayout._size; - var className = options._scene ? - 'annotation-' + options._scene : + var className = options._sceneId ? + 'annotation-' + options._sceneId : 'annotation'; - var annbase = options._scene ? - options._scene + '.annotations[' + index + ']' : + var annbase = options._sceneId ? + options._sceneId + '.annotations[' + index + ']' : 'annotations[' + index + ']'; // remove the existing annotation if there is one @@ -515,7 +515,7 @@ function drawRaw(gd, options, index, xa, ya) { // 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 && !options._scene) { + if(gd._context.editable && arrow.node().parentNode && !options._sceneId) { var arrowDragHeadX = headX; var arrowDragHeadY = headY; if(options.standoff) { @@ -625,7 +625,7 @@ function drawRaw(gd, options, index, xa, ya) { drawArrow(dx, dy); } - else if(!options._scene) { + else if(!options._sceneId) { if(xa) update[annbase + '.x'] = options.x + dx / xa._m; else { var widthFraction = options._xsize / gs.w, diff --git a/src/components/annotations3d/defaults.js b/src/components/annotations3d/defaults.js index eed9d171d86..8bc20d7052f 100644 --- a/src/components/annotations3d/defaults.js +++ b/src/components/annotations3d/defaults.js @@ -18,7 +18,7 @@ module.exports = function handleDefaults(sceneLayoutIn, sceneLayoutOut, opts) { name: 'annotations', handleItemDefaults: handleAnnotationDefaults, fullLayout: opts.fullLayout, - scene: opts.id + sceneId: opts.id }); }; @@ -64,7 +64,7 @@ function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); } - annOut._scene = opts.scene; + annOut._sceneId = opts.sceneId; return annOut; } From ef985cfd5f94181a0defd5c91978b618d73a5a76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 25 May 2017 16:30:08 -0400 Subject: [PATCH 22/25] put 'captureevents' under common annotation defaults --- src/components/annotations/annotation_defaults.js | 2 -- src/components/annotations/common_defaults.js | 2 ++ src/components/annotations3d/attributes.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index f0b149b99ed..0b659244ade 100644 --- a/src/components/annotations/annotation_defaults.js +++ b/src/components/annotations/annotation_defaults.js @@ -87,7 +87,5 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op annOut._yclick = (yClick === undefined) ? annOut.y : yClick; } - coerce('captureevents', !!annOut.hovertext); - return annOut; }; diff --git a/src/components/annotations/common_defaults.js b/src/components/annotations/common_defaults.js index 112e204bf04..ece51afec5f 100644 --- a/src/components/annotations/common_defaults.js +++ b/src/components/annotations/common_defaults.js @@ -61,4 +61,6 @@ module.exports = function handleAnnotationCommonDefaults(annIn, annOut, fullLayo color: globalHoverLabel.font.color || hoverBorder }); } + + coerce('captureevents', !!hoverText); }; diff --git a/src/components/annotations3d/attributes.js b/src/components/annotations3d/attributes.js index 3a04dbd5391..ac19539d761 100644 --- a/src/components/annotations3d/attributes.js +++ b/src/components/annotations3d/attributes.js @@ -75,13 +75,13 @@ module.exports = { arrowwidth: annAtts.arrowwidth, standoff: annAtts.standoff, hovertext: annAtts.hovertext, - hoverlabel: annAtts.hoverlabel + hoverlabel: annAtts.hoverlabel, + captureevents: annAtts.captureevents // maybes later? // clicktoshow: annAtts.clicktoshow, // xclick: annAtts.xclick, // yclick: annAtts.yclick, - // captureevent: annAtts.captureevent // not needed! // axref: 'pixel' From 8b8a088b8e0e9e8f48b5d9c036398c8e1ba7976a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 26 May 2017 10:49:03 -0400 Subject: [PATCH 23/25] pass subplotId as arg to Annotations.drawRaw - instead of expecting it on options._subplotId --- src/components/annotations/draw.js | 41 +++++++++++++----------- src/components/annotations3d/defaults.js | 10 ++---- src/components/annotations3d/draw.js | 2 +- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 93d753a590b..6f962e1d7a8 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -58,7 +58,7 @@ 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 */ @@ -68,28 +68,33 @@ function drawOne(gd, index) { var xa = Axes.getFromId(gd, options.xref); var ya = Axes.getFromId(gd, options.yref); - drawRaw(gd, options, index, xa, ya); + drawRaw(gd, options, index, false, xa, ya); } -/* +/** * drawRaw: draw a single annotation, potentially with modifications * - * options (object): this annotation's options - * index (int): the annotation to draw - * xa (object | undefined): full x-axis object to compute subplot pos-to-px - * ya (object | undefined): ... y-axis + * @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, xa, ya) { +function drawRaw(gd, options, index, subplotId, xa, ya) { var fullLayout = gd._fullLayout; var gs = gd._fullLayout._size; - - var className = options._sceneId ? - 'annotation-' + options._sceneId : - 'annotation'; - - var annbase = options._sceneId ? - options._sceneId + '.annotations[' + index + ']' : - 'annotations[' + index + ']'; + var className; + var annbase; + + if(subplotId) { + className = 'annotation-' + subplotId; + annbase = subplotId + '.annotations[' + index + ']'; + } else { + className = 'annotation'; + annbase = 'annotations[' + index + ']'; + } // remove the existing annotation if there is one fullLayout._infolayer @@ -515,7 +520,7 @@ function drawRaw(gd, options, index, xa, ya) { // 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 && !options._sceneId) { + if(gd._context.editable && arrow.node().parentNode && !subplotId) { var arrowDragHeadX = headX; var arrowDragHeadY = headY; if(options.standoff) { @@ -625,7 +630,7 @@ function drawRaw(gd, options, index, xa, ya) { drawArrow(dx, dy); } - else if(!options._sceneId) { + else if(!subplotId) { if(xa) update[annbase + '.x'] = options.x + dx / xa._m; else { var widthFraction = options._xsize / gs.w, diff --git a/src/components/annotations3d/defaults.js b/src/components/annotations3d/defaults.js index 8bc20d7052f..a0353d24dc6 100644 --- a/src/components/annotations3d/defaults.js +++ b/src/components/annotations3d/defaults.js @@ -17,8 +17,7 @@ module.exports = function handleDefaults(sceneLayoutIn, sceneLayoutOut, opts) { handleArrayContainerDefaults(sceneLayoutIn, sceneLayoutOut, { name: 'annotations', handleItemDefaults: handleAnnotationDefaults, - fullLayout: opts.fullLayout, - sceneId: opts.id + fullLayout: opts.fullLayout }); }; @@ -32,9 +31,8 @@ function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { handleAnnotationCommonDefaults(annIn, annOut, opts.fullLayout, coerce); - // Do not use Axes.coercePosition here - // as ax._categories aren't filled in at this stage, - // Axes.coercePosition is called during scene.setConvert instead + // do not use Axes.coercePosition here + // as ax._categories aren't filled in at this stage coerce('x'); coerce('y'); coerce('z'); @@ -64,7 +62,5 @@ function handleAnnotationDefaults(annIn, annOut, sceneLayout, opts, itemOpts) { Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); } - annOut._sceneId = opts.sceneId; - return annOut; } diff --git a/src/components/annotations3d/draw.js b/src/components/annotations3d/draw.js index 8a62b6f08a9..e916973cad1 100644 --- a/src/components/annotations3d/draw.js +++ b/src/components/annotations3d/draw.js @@ -44,7 +44,7 @@ module.exports = function draw(scene) { fullSceneLayout.zaxis.r2l(ann.z) * dataScale[2] ]); - drawRaw(scene.graphDiv, ann, i, ann._xa, ann._ya); + drawRaw(scene.graphDiv, ann, i, scene.id, ann._xa, ann._ya); } } }; From 1d5606a8ff4bed24b9ba5e43de162f62d026b2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 26 May 2017 10:49:51 -0400 Subject: [PATCH 24/25] include subplotId in clickannotation event data - add hover/click tests for 3d annotations --- src/components/annotations/draw.js | 1 + test/jasmine/tests/gl_plot_interact_test.js | 50 +++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 6f962e1d7a8..9434a336711 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -135,6 +135,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { gd._dragging = false; gd.emit('plotly_clickannotation', { index: index, + subplotId: subplotId, annotation: options._input, fullAnnotation: options, event: d3.event diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js index cd4d0302756..4372ccf6392 100644 --- a/test/jasmine/tests/gl_plot_interact_test.js +++ b/test/jasmine/tests/gl_plot_interact_test.js @@ -1659,4 +1659,54 @@ describe('Test gl3d annotations', function() { .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); + }); }); From 46ab63dcf3fa3128957d60e0b592a316150e1823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 26 May 2017 11:30:22 -0400 Subject: [PATCH 25/25] rm subplotId key from clickannotations event data when falsy --- src/components/annotations/draw.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 9434a336711..5496471ea7d 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -133,13 +133,19 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { .call(setCursor, 'default') .on('click', function() { gd._dragging = false; - gd.emit('plotly_clickannotation', { + + var eventData = { index: index, - subplotId: subplotId, annotation: options._input, fullAnnotation: options, event: d3.event - }); + }; + + if(subplotId) { + eventData.subplotId = subplotId; + } + + gd.emit('plotly_clickannotation', eventData); }); if(options.hovertext) {