diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index 7df91978c59..c11a0822549 100644 --- a/src/components/annotations/annotation_defaults.js +++ b/src/components/annotations/annotation_defaults.js @@ -30,7 +30,6 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op if(!(visible || clickToShow)) return annOut; coerce('opacity'); - coerce('align'); coerce('bgcolor'); var borderColor = coerce('bordercolor'), @@ -45,6 +44,12 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op coerce('textangle'); Lib.coerceFont(coerce, 'font', fullLayout.font); + coerce('width'); + coerce('align'); + + var h = coerce('height'); + if(h) coerce('valign'); + // positioning var axLetters = ['x', 'y'], arrowPosDflt = [-10, -30], diff --git a/src/components/annotations/attributes.js b/src/components/annotations/attributes.js index cdaccd8bc73..22567530137 100644 --- a/src/components/annotations/attributes.js +++ b/src/components/annotations/attributes.js @@ -49,6 +49,27 @@ module.exports = { font: extendFlat({}, fontAttrs, { description: 'Sets the annotation text font.' }), + width: { + valType: 'number', + min: 1, + dflt: null, + role: 'style', + description: [ + 'Sets an explicit width for the text box. null (default) lets the', + 'text set the box width. Wider text will be clipped.', + 'There is no automatic wrapping; use
to start a new line.' + ].join(' ') + }, + height: { + valType: 'number', + min: 1, + dflt: null, + role: 'style', + description: [ + 'Sets an explicit height for the text box. null (default) lets the', + 'text set the box height. Taller text will be clipped.' + ].join(' ') + }, opacity: { valType: 'number', min: 0, @@ -63,10 +84,21 @@ module.exports = { dflt: 'center', role: 'style', description: [ - 'Sets the vertical alignment of the `text` with', - 'respect to the set `x` and `y` position.', - 'Has only an effect if `text` spans more two or more lines', - '(i.e. `text` contains one or more
HTML tags).' + 'Sets the horizontal alignment of the `text` within the box.', + 'Has an effect only if `text` spans more two or more lines', + '(i.e. `text` contains one or more
HTML tags) or if an', + 'explicit width is set to override the text width.' + ].join(' ') + }, + valign: { + valType: 'enumerated', + values: ['top', 'middle', 'bottom'], + dflt: 'middle', + role: 'style', + description: [ + 'Sets the vertical alignment of the `text` within the box.', + 'Has an effect only if an explicit height is set to override', + 'the text height.' ].join(' ') }, bgcolor: { diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 491dfc1eba8..2ed9e9429f2 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -72,9 +72,14 @@ function drawOne(gd, index) { var optionsIn = (layout.annotations || [])[index], options = fullLayout.annotations[index]; + 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) return; + if(!optionsIn || options.visible === false) { + d3.selectAll('#' + annClipID).remove(); + return; + } var xa = Axes.getFromId(gd, options.xref), ya = Axes.getFromId(gd, options.yref), @@ -118,6 +123,18 @@ function drawOne(gd, index) { .call(Color.stroke, options.bordercolor) .call(Color.fill, options.bgcolor); + var isSizeConstrained = options.width || options.height; + + var annTextClip = fullLayout._defs.select('.clips') + .selectAll('#' + annClipID) + .data(isSizeConstrained ? [0] : []); + + annTextClip.enter().append('clipPath') + .classed('annclip', true) + .attr('id', annClipID) + .append('rect'); + annTextClip.exit().remove(); + var font = options.font; var annText = annTextGroupInner.append('text') @@ -144,19 +161,21 @@ function drawOne(gd, index) { // at the end, even if their position changes annText.selectAll('tspan.line').attr({y: 0, x: 0}); - var mathjaxGroup = annTextGroupInner.select('.annotation-math-group'), - hasMathjax = !mathjaxGroup.empty(), - anntextBB = Drawing.bBox( - (hasMathjax ? mathjaxGroup : annText).node()), - annwidth = anntextBB.width, - annheight = anntextBB.height, - outerwidth = Math.round(annwidth + 2 * borderfull), - outerheight = Math.round(annheight + 2 * borderfull); + var mathjaxGroup = annTextGroupInner.select('.annotation-math-group'); + var hasMathjax = !mathjaxGroup.empty(); + var anntextBB = Drawing.bBox( + (hasMathjax ? mathjaxGroup : annText).node()); + var textWidth = anntextBB.width; + var textHeight = anntextBB.height; + var annWidth = options.width || textWidth; + var annHeight = options.height || textHeight; + var outerWidth = Math.round(annWidth + 2 * borderfull); + var outerHeight = Math.round(annHeight + 2 * borderfull); // save size in the annotation object for use by autoscale - options._w = annwidth; - options._h = annheight; + options._w = annWidth; + options._h = annHeight; function shiftFraction(v, anchor) { if(anchor === 'auto') { @@ -181,8 +200,8 @@ function drawOne(gd, index) { ax = Axes.getFromId(gd, axRef), dimAngle = (textangle + (axLetter === 'x' ? 0 : -90)) * Math.PI / 180, // note that these two can be either positive or negative - annSizeFromWidth = outerwidth * Math.cos(dimAngle), - annSizeFromHeight = outerheight * Math.sin(dimAngle), + annSizeFromWidth = outerWidth * Math.cos(dimAngle), + annSizeFromHeight = outerHeight * Math.sin(dimAngle), // but this one is the positive total size annSize = Math.abs(annSizeFromWidth) + Math.abs(annSizeFromHeight), anchor = options[axLetter + 'anchor'], @@ -299,22 +318,43 @@ function drawOne(gd, index) { return; } + var xShift = 0; + var yShift = 0; + + if(options.align !== 'left') { + xShift = (annWidth - textWidth) * (options.align === 'center' ? 0.5 : 1); + } + if(options.valign !== 'top') { + yShift = (annHeight - textHeight) * (options.valign === 'middle' ? 0.5 : 1); + } + if(hasMathjax) { - mathjaxGroup.select('svg').attr({x: borderfull - 1, y: borderfull}); + mathjaxGroup.select('svg').attr({ + x: borderfull + xShift - 1, + y: borderfull + yShift + }) + .call(Drawing.setClipUrl, isSizeConstrained ? annClipID : null); } else { - var texty = borderfull - anntextBB.top, - textx = borderfull - anntextBB.left; - annText.attr({x: textx, y: texty}); + var texty = borderfull + yShift - anntextBB.top, + textx = borderfull + xShift - anntextBB.left; + annText.attr({ + x: textx, + y: texty + }) + .call(Drawing.setClipUrl, isSizeConstrained ? annClipID : null); annText.selectAll('tspan.line').attr({y: texty, x: textx}); } + annTextClip.select('rect').call(Drawing.setRect, borderfull, borderfull, + annWidth, annHeight); + annTextBG.call(Drawing.setRect, borderwidth / 2, borderwidth / 2, - outerwidth - borderwidth, outerheight - borderwidth); + outerWidth - borderwidth, outerHeight - borderwidth); annTextGroupInner.call(Drawing.setTranslate, - Math.round(annPosPx.x.text - outerwidth / 2), - Math.round(annPosPx.y.text - outerheight / 2)); + Math.round(annPosPx.x.text - outerWidth / 2), + Math.round(annPosPx.y.text - outerHeight / 2)); /* * rotate text and background diff --git a/test/image/baselines/annotations-autorange.png b/test/image/baselines/annotations-autorange.png index cab99da4b8d..fae1b98ca22 100644 Binary files a/test/image/baselines/annotations-autorange.png and b/test/image/baselines/annotations-autorange.png differ diff --git a/test/image/mocks/annotations-autorange.json b/test/image/mocks/annotations-autorange.json index 433b86aa376..3fa5453db48 100644 --- a/test/image/mocks/annotations-autorange.json +++ b/test/image/mocks/annotations-autorange.json @@ -48,9 +48,9 @@ "zeroline":false, "showline":true }, - "height":300, + "height":360, "width":800, - "margin":{"r":40,"b":40,"l":40,"t":40}, + "margin":{"r":40,"b":100,"l":40,"t":40}, "annotations":[ {"ay":0,"ax":50,"x":1,"y":1.5,"text":"Left"}, {"ay":0,"ax":-50,"x":2,"y":1.5,"text":"Right"}, @@ -60,16 +60,42 @@ "xref":"x2","yref":"y2","text":"From left","y":2,"ax":-17,"ay":0,"x":"2001-01-01", "xanchor": "right", "yanchor": "top", "textangle": 35, "bordercolor": "#444" }, - {"xref":"x2","yref":"y2","text":"From right","y":2,"x":"2001-03-01","ay":0,"ax":50}, + { + "xref":"x2","yref":"y2","text":"From
right","y":2,"x":"2001-03-01","ay":0,"ax":50, + "bgcolor": "#eee", "width": 50, "height": 40, "textangle": 70 + }, { "xref":"x2","yref":"y2","text":"From top","y":3,"ax":0,"ay":-27,"x":"2001-02-01", "xanchor": "left", "yanchor": "bottom", "textangle": -15, "bordercolor": "#444" }, - {"xref":"x2","yref":"y2","text":"From Bottom","y":1,"ax":0,"ay":50,"x":"2001-02-01"}, - {"xref":"x3","yref":"y3","text":"Left
no
arrow","y":1.5,"x":1,"showarrow":false}, + { + "xref":"x2","yref":"y2","text":"From Bottom","y":1,"ax":0,"ay":50,"x":"2001-02-01", + "bordercolor": "#444", "borderwidth": 3, "height": 30 + }, + { + "xref":"x3","yref":"y3","text":"Left
no
arrow","y":1.5,"x":1,"showarrow":false, + "bordercolor": "#444", "bgcolor": "#eee", "width": 50, "height": 60, "textangle": 10, + "align": "right", "valign": "bottom" + }, {"xref":"x3","yref":"y3","text":"Right
no
arrow","y":1.5,"x":2,"showarrow":false}, - {"xref":"x3","yref":"y3","text":"Bottom
no
arrow","y":1,"x":1.5,"showarrow":false}, - {"xref":"x3","yref":"y3","text":"Top
no
arrow","y":2,"x":1.5,"showarrow":false} + { + "xref":"x3","yref":"y3","text":"Bottom
no
arrow","y":1,"x":1.5,"showarrow":false, + "bgcolor": "#eee", "width": 30, "height": 40, "textangle":-10, + "align": "left", "valign": "top" + }, + {"xref":"x3","yref":"y3","text":"Top
no
arrow","y":2,"x":1.5,"showarrow":false}, + { + "xref": "paper", "yref": "paper", "text": "On the
bottom of the plot", + "x": 0.3, "y": -0.1, "showarrow": false, + "xanchor": "right", "yanchor": "top", "width": 200, "height": 60, + "bgcolor": "#eee", "bordercolor": "#444" + }, + { + "xref": "paper", "yref": "paper", "text": "blah blah blah blah
blah
blah
blah
blah
blah", + "x": 0.3, "y": -0.25, "ax": 100, "ay": 0, "textangle": 40, "borderpad": 4, + "xanchor": "left", "yanchor": "bottom", "align": "left", "valign": "top", + "width": 60, "height": 40, "bgcolor": "#eee", "bordercolor": "#444" + } ] } } diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index ce0cc760c10..ee86b379b34 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -1041,3 +1041,51 @@ describe('annotation dragging', function() { .then(done); }); }); + +describe('annotation clip paths', function() { + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + // we've already tested autorange with relayout, so fix the geometry + // completely so we know exactly what we're dealing with + // plot area is 300x300, and covers data range 100x100 + Plotly.plot(gd, [{x: [0, 100], y: [0, 100]}], { + annotations: [ + {x: 50, y: 50, text: 'hi', width: 50}, + {x: 20, y: 20, text: 'bye', height: 40}, + {x: 80, y: 80, text: 'why?'} + ] + }) + .then(done); + }); + + afterEach(destroyGraphDiv); + + it('should only make the clippaths it needs and delete others', function(done) { + expect(d3.select(gd).selectAll('.annclip').size()).toBe(2); + + Plotly.relayout(gd, {'annotations[0].visible': false}) + .then(function() { + expect(d3.select(gd).selectAll('.annclip').size()).toBe(1); + + return Plotly.relayout(gd, {'annotations[2].width': 20}); + }) + .then(function() { + expect(d3.select(gd).selectAll('.annclip').size()).toBe(2); + + return Plotly.relayout(gd, {'annotations[1].height': null}); + }) + .then(function() { + expect(d3.select(gd).selectAll('.annclip').size()).toBe(1); + + return Plotly.relayout(gd, {'annotations[2]': null}); + }) + .then(function() { + expect(d3.select(gd).selectAll('.annclip').size()).toBe(0); + }) + .catch(failTest) + .then(done); + }); +});