diff --git a/src/components/annotations/attributes.js b/src/components/annotations/attributes.js index a2eaf49d45d..cea6afb9a4a 100644 --- a/src/components/annotations/attributes.js +++ b/src/components/annotations/attributes.js @@ -137,8 +137,10 @@ module.exports = { role: 'info', description: [ 'Sets the x component of the arrow tail about the arrow head.', - 'A positive (negative) component corresponds to an arrow pointing', - 'from right to left (left to right)' + 'If `axref` is `pixel`, a positive (negative) ', + 'component corresponds to an arrow pointing', + 'from right to left (left to right).', + 'If `axref` is an axis, this is a value on that axis.' ].join(' ') }, ay: { @@ -147,8 +149,44 @@ module.exports = { role: 'info', description: [ 'Sets the y component of the arrow tail about the arrow head.', - 'A positive (negative) component corresponds to an arrow pointing', - 'from bottom to top (top to bottom)' + 'If `ayref` is `pixel`, a positive (negative) ', + 'component corresponds to an arrow pointing', + 'from bottom to top (top to bottom).', + 'If `ayref` is an axis, this is a value on that axis.' + ].join(' ') + }, + axref: { + valType: 'enumerated', + dflt: 'pixel', + values: [ + 'pixel', + cartesianConstants.idRegex.x.toString() + ], + role: 'info', + description: [ + 'Indicates in what terms the tail of the annotation (ax,ay) ', + 'is specified. If `pixel`, `ax` is a relative offset in pixels ', + 'from `x`. If set to an x axis id (e.g. *x* or *x2*), `ax` is ', + 'specified in the same terms as that axis. This is useful ', + 'for trendline annotations which should continue to indicate ', + 'the correct trend when zoomed.' + ].join(' ') + }, + ayref: { + valType: 'enumerated', + dflt: 'pixel', + values: [ + 'pixel', + cartesianConstants.idRegex.y.toString() + ], + role: 'info', + description: [ + 'Indicates in what terms the tail of the annotation (ax,ay) ', + 'is specified. If `pixel`, `ay` is a relative offset in pixels ', + 'from `y`. If set to a y axis id (e.g. *y* or *y2*), `ay` is ', + 'specified in the same terms as that axis. This is useful ', + 'for trendline annotations which should continue to indicate ', + 'the correct trend when zoomed.' ].join(' ') }, // positioning diff --git a/src/components/annotations/index.js b/src/components/annotations/index.js index 60385078844..3fc49527908 100644 --- a/src/components/annotations/index.js +++ b/src/components/annotations/index.js @@ -59,6 +59,8 @@ function handleAnnotationDefaults(annIn, fullLayout) { coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); coerce('ax'); coerce('ay'); + coerce('axref'); + coerce('ayref'); // if you have one part of arrow length you should have both Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); @@ -76,6 +78,10 @@ function handleAnnotationDefaults(annIn, fullLayout) { // xref, yref var axRef = Axes.coerceRef(annIn, annOut, tdMock, axLetter); + //todo: should be refactored in conjunction with Axes + // axref, ayref + var aaxRef = Axes.coerceARef(annIn, annOut, tdMock, axLetter); + // x, y var defaultPosition = 0.5; if(axRef !== 'paper') { @@ -89,6 +95,11 @@ function handleAnnotationDefaults(annIn, fullLayout) { if(ax.type === 'date') { newval = Lib.dateTime2ms(annIn[axLetter]); if(newval !== false) annIn[axLetter] = newval; + + if(aaxRef === axRef) { + var newvalB = Lib.dateTime2ms(annIn['a' + axLetter]); + if(newvalB !== false) annIn['a' + axLetter] = newvalB; + } } else if((ax._categories || []).length) { newval = ax._categories.indexOf(annIn[axLetter]); @@ -419,8 +430,8 @@ annotations.draw = function(gd, index, opt, value) { var annotationIsOffscreen = false; ['x', 'y'].forEach(function(axLetter) { - var ax = Axes.getFromId(gd, - options[axLetter + 'ref'] || axLetter), + var axRef = options[axLetter + 'ref'] || axLetter, + ax = Axes.getFromId(gd, axRef), dimAngle = (textangle + (axLetter === 'x' ? 0 : 90)) * Math.PI / 180, annSize = outerwidth * Math.abs(Math.cos(dimAngle)) + outerheight * Math.abs(Math.sin(dimAngle)), @@ -435,8 +446,16 @@ annotations.draw = function(gd, index, opt, value) { // anyway to get its bounding box) if(!ax.autorange && ((options[axLetter] - ax.range[0]) * (options[axLetter] - ax.range[1]) > 0)) { - annotationIsOffscreen = true; - return; + if(options['a' + axLetter + 'ref'] === axRef) { + if((options['a' + axLetter] - ax.range[0]) * + (options['a' + axLetter] - ax.range[1]) > 0) { + annotationIsOffscreen = true; + } + } else { + annotationIsOffscreen = true; + } + + if(annotationIsOffscreen) return; } annPosPx[axLetter] = ax._offset + ax.l2p(options[axLetter]); alignPosition = 0.5; @@ -450,13 +469,17 @@ annotations.draw = function(gd, index, opt, value) { } var alignShift = 0; - if(options.showarrow) { - alignShift = options['a' + axLetter]; - } - else { - alignShift = annSize * shiftFraction(alignPosition, anchor); + if(options['a' + axLetter + 'ref'] === axRef) { + annPosPx['aa' + axLetter] = ax._offset + ax.l2p(options['a' + axLetter]); + } else { + if(options.showarrow) { + alignShift = options['a' + axLetter]; + } + else { + alignShift = annSize * shiftFraction(alignPosition, anchor); + } + annPosPx[axLetter] += alignShift; } - annPosPx[axLetter] += alignShift; // save the current axis type for later log/linear changes options['_' + axLetter + 'type'] = ax && ax.type; @@ -476,8 +499,21 @@ annotations.draw = function(gd, index, opt, value) { // make sure the arrowhead (if there is one) // and the annotation center are visible if(options.showarrow) { - arrowX = Lib.constrain(annPosPx.x - options.ax, 1, fullLayout.width - 1); - arrowY = Lib.constrain(annPosPx.y - options.ay, 1, fullLayout.height - 1); + if(options.axref === options.xref) { + //we don't want to constrain if the tail is absolute + //or the slope (which is meaningful) will change. + arrowX = annPosPx.x; + } else { + arrowX = Lib.constrain(annPosPx.x - options.ax, 1, fullLayout.width - 1); + } + + if(options.ayref === options.yref) { + //we don't want to constrain if the tail is absolute + //or the slope (which is meaningful) will change. + arrowY = annPosPx.y; + } else { + arrowY = Lib.constrain(annPosPx.y - options.ay, 1, fullLayout.height - 1); + } } annPosPx.x = Lib.constrain(annPosPx.x, 1, fullLayout.width - 1); annPosPx.y = Lib.constrain(annPosPx.y, 1, fullLayout.height - 1); @@ -496,8 +532,19 @@ annotations.draw = function(gd, index, opt, value) { annbg.call(Drawing.setRect, borderwidth / 2, borderwidth / 2, outerwidth - borderwidth, outerheight - borderwidth); - var annX = Math.round(annPosPx.x - outerwidth / 2), + var annX = 0, annY = 0; + if(options.axref === options.xref) { + annX = Math.round(annPosPx.aax - outerwidth / 2); + } else { + annX = Math.round(annPosPx.x - outerwidth / 2); + } + + if(options.ayref === options.yref) { + annY = Math.round(annPosPx.aay - outerheight / 2); + } else { annY = Math.round(annPosPx.y - outerheight / 2); + } + ann.call(Lib.setTranslate, annX, annY); var annbase = 'annotations[' + index + ']'; @@ -515,11 +562,22 @@ annotations.draw = function(gd, index, opt, value) { // looks like there may be a cross-browser solution, see // http://stackoverflow.com/questions/5364980/ // how-to-get-the-width-of-an-svg-tspan-element - var arrowX0 = annPosPx.x + dx, - arrowY0 = annPosPx.y + dy, + var arrowX0, arrowY0; + + if(options.axref === options.xref) { + arrowX0 = annPosPx.aax + dx; + } else { + arrowX0 = annPosPx.x + dx; + } + + if(options.ayref === options.yref) { + arrowY0 = annPosPx.aay + dy; + } else { + arrowY0 = annPosPx.y + dy; + } // create transform matrix and related functions - transform = + var transform = Lib.rotationXYMatrix(textangle, arrowX0, arrowY0), applyTransform = Lib.apply2DTransform(transform), applyTransform2 = Lib.apply2DTransform2(transform), @@ -618,6 +676,18 @@ annotations.draw = function(gd, index, opt, value) { (options.y + dy / ya._m) : (1 - ((arrowY + dy - gs.t) / gs.h)); + if(options.axref === options.xref) { + update[annbase + '.ax'] = xa ? + (options.ax + dx / xa._m) : + ((arrowX + dx - gs.l) / gs.w); + } + + if(options.ayref === options.yref) { + update[annbase + '.ay'] = ya ? + (options.ay + dy / ya._m) : + (1 - ((arrowY + dy - gs.t) / gs.h)); + } + anng.attr({ transform: 'rotate(' + textangle + ',' + xcenter + ',' + ycenter + ')' @@ -660,8 +730,18 @@ annotations.draw = function(gd, index, opt, value) { ann.call(Lib.setTranslate, x0 + dx, y0 + dy); var csr = 'pointer'; if(options.showarrow) { - update[annbase + '.ax'] = options.ax + dx; - update[annbase + '.ay'] = options.ay + dy; + if(options.axref === options.xref) { + update[annbase + '.ax'] = xa.p2l(xa.l2p(options.ax) + dx); + } else { + update[annbase + '.ax'] = options.ax + dx; + } + + if(options.ayref === options.yref) { + update[annbase + '.ay'] = ya.p2l(ya.l2p(options.ay) + dy); + } else { + update[annbase + '.ay'] = options.ay + dy; + } + drawArrow(dx, dy); } else { diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index fc0c4c2ea4a..4db4ca2c642 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -55,6 +55,26 @@ axes.coerceRef = function(containerIn, containerOut, gd, axLetter, dflt) { return Lib.coerce(containerIn, containerOut, attrDef, refAttr); }; +//todo: duplicated per github PR 610. Should be consolidated with axes.coerceRef. +// find the list of possible axes to reference with an axref or ayref attribute +// and coerce it to that list +axes.coerceARef = function(containerIn, containerOut, gd, axLetter, dflt) { + var axlist = gd._fullLayout._has('gl2d') ? [] : axes.listIds(gd, axLetter), + refAttr = 'a' + axLetter + 'ref', + attrDef = {}; + + // data-ref annotations are not supported in gl2d yet + + attrDef[refAttr] = { + valType: 'enumerated', + values: axlist.concat(['pixel']), + dflt: dflt || 'pixel' || axlist[0] + }; + + // axref, ayref + return Lib.coerce(containerIn, containerOut, attrDef, refAttr); +}; + // empty out types for all axes containing these traces // so we auto-set them again axes.clearTypes = function(gd, traces) { diff --git a/test/image/baselines/annotations.png b/test/image/baselines/annotations.png index 5edbbe7d1c5..056e5c86268 100644 Binary files a/test/image/baselines/annotations.png and b/test/image/baselines/annotations.png differ diff --git a/test/image/mocks/annotations.json b/test/image/mocks/annotations.json index 9eea27ff63e..9f88a7a5179 100644 --- a/test/image/mocks/annotations.json +++ b/test/image/mocks/annotations.json @@ -41,7 +41,9 @@ "bordercolor":"rgb(255, 0, 0)","borderwidth":4,"bgcolor":"rgba(255,255,0,0.5)", "font":{"color":"rgb(0, 0, 255)","size":20}, "arrowcolor":"rgb(166, 28, 0)","borderpad":3,"textangle":50,"x":5,"y":1 - } + }, + {"text":"","showarrow":true,"borderwidth":1.2,"arrowhead":2,"axref":"x","ayref":"y","x":5,"y":3,"ax":4,"ay":5}, + {"text":"","showarrow":true,"borderwidth":1.2,"arrowhead":2,"axref":"x","ayref":"y","x":6,"y":2,"ax":3,"ay":3} ] } } diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js new file mode 100644 index 00000000000..3b6d02a75e6 --- /dev/null +++ b/test/jasmine/tests/annotations_test.js @@ -0,0 +1,33 @@ +require('@src/plotly'); +var Plots = require('@src/plots/plots'); +var Annotations = require('@src/components/annotations'); +var Dates = require('@src/lib/dates'); + +describe('Test annotations', function() { + 'use strict'; + + describe('supplyLayoutDefaults', function() { + it('should default to pixel for axref/ayref', function() { + var annotationDefaults = {}; + annotationDefaults._has = Plots._hasPlotType.bind(annotationDefaults); + + Annotations.supplyLayoutDefaults({ annotations: [{ showarrow: true, arrowhead: 2}] }, annotationDefaults); + + expect(annotationDefaults.annotations[0].axref).toEqual('pixel'); + expect(annotationDefaults.annotations[0].ayref).toEqual('pixel'); + }); + + it('should convert ax/ay date coordinates to milliseconds if tail is in axis terms and axis is a date', function() { + var annotationOut = { xaxis: { type: 'date', range: ['2000-01-01', '2016-01-01'] }}; + annotationOut._has = Plots._hasPlotType.bind(annotationOut); + + var annotationIn = { + annotations: [{ showarrow: true, axref: 'x', ayref: 'y', x: '2008-07-01', ax: '2004-07-01', y: 0, ay: 50}] + }; + + Annotations.supplyLayoutDefaults(annotationIn, annotationOut); + + expect(annotationIn.annotations[0].ax).toEqual(Dates.dateTime2ms('2004-07-01')); + }); + }); +});