diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index 32fd5b0f0a0..7df91978c59 100644 --- a/src/components/annotations/annotation_defaults.js +++ b/src/components/annotations/annotation_defaults.js @@ -25,8 +25,9 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op } var visible = coerce('visible', !itemOpts.itemIsNotPlainObject); + var clickToShow = coerce('clicktoshow'); - if(!visible) return annOut; + if(!(visible || clickToShow)) return annOut; coerce('opacity'); coerce('align'); @@ -75,7 +76,7 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op } // xanchor, yanchor - else coerce(axLetter + 'anchor'); + coerce(axLetter + 'anchor'); } // if you have one coordinate you should have both @@ -86,10 +87,21 @@ module.exports = function handleAnnotationDefaults(annIn, annOut, fullLayout, op 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']); } + if(clickToShow) { + var xClick = coerce('xclick'); + var yClick = coerce('yclick'); + + // put the actual click data to bind to into private attributes + // so we don't have to do this little bit of logic on every hover event + annOut._xclick = (xClick === undefined) ? annOut.x : xClick; + annOut._yclick = (yClick === undefined) ? annOut.y : yClick; + } + return annOut; }; diff --git a/src/components/annotations/arrow_paths.js b/src/components/annotations/arrow_paths.js index 59e09ad7580..3f27bbaf83a 100644 --- a/src/components/annotations/arrow_paths.js +++ b/src/components/annotations/arrow_paths.js @@ -21,7 +21,10 @@ module.exports = [ // no arrow - '', + { + path: '', + backoff: 0 + }, // wide with flat back { path: 'M-2.4,-3V3L0.6,0Z', diff --git a/src/components/annotations/attributes.js b/src/components/annotations/attributes.js index 143a5a41534..cdaccd8bc73 100644 --- a/src/components/annotations/attributes.js +++ b/src/components/annotations/attributes.js @@ -140,6 +140,17 @@ module.exports = { role: 'style', description: 'Sets the width (in px) of annotation arrow.' }, + standoff: { + valType: 'number', + min: 0, + dflt: 0, + role: 'style', + description: [ + 'Sets a distance, in pixels, to move the arrowhead away from the', + 'position it is pointing at, for example to point at the edge of', + 'a marker independent of zoom.' + ].join(' ') + }, ax: { valType: 'any', role: 'info', @@ -236,7 +247,7 @@ module.exports = { dflt: 'auto', role: 'info', description: [ - 'Sets the annotation\'s horizontal position anchor', + 'Sets the text box\'s horizontal position anchor', 'This anchor binds the `x` position to the *left*, *center*', 'or *right* of the annotation.', 'For example, if `x` is set to 1, `xref` to *paper* and', @@ -244,9 +255,9 @@ module.exports = { 'annotation lines up with the right-most edge of the', 'plotting area.', 'If *auto*, the anchor is equivalent to *center* for', - 'data-referenced annotations', - 'whereas for paper-referenced, the anchor picked corresponds', - 'to the closest side.' + 'data-referenced annotations or if there is an arrow,', + 'whereas for paper-referenced with no arrow, the anchor picked', + 'corresponds to the closest side.' ].join(' ') }, yref: { @@ -286,7 +297,7 @@ module.exports = { dflt: 'auto', role: 'info', description: [ - 'Sets the annotation\'s vertical position anchor', + 'Sets the text box\'s vertical position anchor', 'This anchor binds the `y` position to the *top*, *middle*', 'or *bottom* of the annotation.', 'For example, if `y` is set to 1, `yref` to *paper* and', @@ -294,9 +305,45 @@ module.exports = { 'annotation lines up with the top-most edge of the', 'plotting area.', 'If *auto*, the anchor is equivalent to *middle* for', - 'data-referenced annotations', - 'whereas for paper-referenced, the anchor picked corresponds', - 'to the closest side.' + 'data-referenced annotations or if there is an arrow,', + 'whereas for paper-referenced with no arrow, the anchor picked', + 'corresponds to the closest side.' + ].join(' ') + }, + clicktoshow: { + valType: 'enumerated', + values: [false, 'onoff', 'onout'], + dflt: false, + role: 'style', + description: [ + 'Makes this annotation respond to clicks on the plot.', + 'If you click a data point that exactly matches the `x` and `y`', + 'values of this annotation, and it is hidden (visible: false),', + 'it will appear. In *onoff* mode, you must click the same point', + 'again to make it disappear, so if you click multiple points,', + 'you can show multiple annotations. In *onout* mode, a click', + 'anywhere else in the plot (on another data point or not) will', + 'hide this annotation.', + 'If you need to show/hide this annotation in response to different', + '`x` or `y` values, you can set `xclick` and/or `yclick`. This is', + 'useful for example to label the side of a bar. To label markers', + 'though, `standoff` is preferred over `xclick` and `yclick`.' + ].join(' ') + }, + xclick: { + valType: 'any', + role: 'info', + description: [ + 'Toggle this annotation when clicking a data point whose `x` value', + 'is `xclick` rather than the annotation\'s `x` value.' + ].join(' ') + }, + yclick: { + valType: 'any', + role: 'info', + description: [ + 'Toggle this annotation when clicking a data point whose `y` value', + 'is `yclick` rather than the annotation\'s `y` value.' ].join(' ') }, diff --git a/src/components/annotations/calc_autorange.js b/src/components/annotations/calc_autorange.js index 011f5f83760..f68ea537c63 100644 --- a/src/components/annotations/calc_autorange.js +++ b/src/components/annotations/calc_autorange.js @@ -45,41 +45,49 @@ function annAutorange(gd) { // relative to their anchor points // use the arrow and the text bg rectangle, // as the whole anno may include hidden text in its bbox - fullLayout.annotations.forEach(function(ann) { + Lib.filterVisible(fullLayout.annotations).forEach(function(ann) { var xa = Axes.getFromId(gd, ann.xref), - ya = Axes.getFromId(gd, ann.yref); - - if(!(xa || ya)) return; - - var halfWidth = (ann._xsize || 0) / 2, - xShift = ann._xshift || 0, - halfHeight = (ann._ysize || 0) / 2, - yShift = ann._yshift || 0, - leftSize = halfWidth - xShift, - rightSize = halfWidth + xShift, - topSize = halfHeight - yShift, - bottomSize = halfHeight + yShift; - - if(ann.showarrow) { - var headSize = 3 * ann.arrowsize * ann.arrowwidth; - leftSize = Math.max(leftSize, headSize); - rightSize = Math.max(rightSize, headSize); - topSize = Math.max(topSize, headSize); - bottomSize = Math.max(bottomSize, headSize); - } + ya = Axes.getFromId(gd, ann.yref), + headSize = 3 * ann.arrowsize * ann.arrowwidth || 0; if(xa && xa.autorange) { - Axes.expand(xa, [xa.r2c(ann.x)], { - ppadplus: rightSize, - ppadminus: leftSize - }); + if(ann.axref === ann.xref) { + // expand for the arrowhead (padded by arrowhead) + Axes.expand(xa, [xa.r2c(ann.x)], { + ppadplus: headSize, + ppadminus: headSize + }); + // again for the textbox (padded by textbox) + Axes.expand(xa, [xa.r2c(ann.ax)], { + ppadplus: ann._xpadplus, + ppadminus: ann._xpadminus + }); + } + else { + Axes.expand(xa, [xa.r2c(ann.x)], { + ppadplus: Math.max(ann._xpadplus, headSize), + ppadminus: Math.max(ann._xpadminus, headSize) + }); + } } if(ya && ya.autorange) { - Axes.expand(ya, [ya.r2c(ann.y)], { - ppadplus: bottomSize, - ppadminus: topSize - }); + if(ann.ayref === ann.yref) { + Axes.expand(ya, [ya.r2c(ann.y)], { + ppadplus: headSize, + ppadminus: headSize + }); + Axes.expand(ya, [ya.r2c(ann.ay)], { + ppadplus: ann._ypadplus, + ppadminus: ann._ypadminus + }); + } + else { + Axes.expand(ya, [ya.r2c(ann.y)], { + ppadplus: Math.max(ann._ypadplus, headSize), + ppadminus: Math.max(ann._ypadminus, headSize) + }); + } } }); } diff --git a/src/components/annotations/click.js b/src/components/annotations/click.js new file mode 100644 index 00000000000..8fe77ce8286 --- /dev/null +++ b/src/components/annotations/click.js @@ -0,0 +1,121 @@ +/** +* 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 Plotly = require('../../plotly'); + + +module.exports = { + hasClickToShow: hasClickToShow, + onClick: onClick +}; + +/* + * hasClickToShow: does the given hoverData have ANY annotations which will + * turn ON if we click here? (used by hover events to set cursor) + * + * gd: graphDiv + * hoverData: a hoverData array, as included with the *plotly_hover* or + * *plotly_click* events in the `points` attribute + * + * returns: boolean + */ +function hasClickToShow(gd, hoverData) { + var sets = getToggleSets(gd, hoverData); + return sets.on.length > 0 || sets.explicitOff.length > 0; +} + +/* + * onClick: perform the toggling (via Plotly.update) implied by clicking + * at this hoverData + * + * gd: graphDiv + * hoverData: a hoverData array, as included with the *plotly_hover* or + * *plotly_click* events in the `points` attribute + * + * returns: Promise that the update is complete + */ +function onClick(gd, hoverData) { + var toggleSets = getToggleSets(gd, hoverData), + onSet = toggleSets.on, + offSet = toggleSets.off.concat(toggleSets.explicitOff), + update = {}, + i; + + if(!(onSet.length || offSet.length)) return; + + for(i = 0; i < onSet.length; i++) { + update['annotations[' + onSet[i] + '].visible'] = true; + } + + for(i = 0; i < offSet.length; i++) { + update['annotations[' + offSet[i] + '].visible'] = false; + } + + return Plotly.update(gd, {}, update); +} + +/* + * getToggleSets: find the annotations which will turn on or off at this + * hoverData + * + * gd: graphDiv + * hoverData: a hoverData array, as included with the *plotly_hover* or + * *plotly_click* events in the `points` attribute + * + * returns: { + * on: Array (indices of annotations to turn on), + * off: Array (indices to turn off because you're not hovering on them), + * explicitOff: Array (indices to turn off because you *are* hovering on them) + * } + */ +function getToggleSets(gd, hoverData) { + var annotations = gd._fullLayout.annotations, + onSet = [], + offSet = [], + explicitOffSet = [], + hoverLen = (hoverData || []).length; + + var i, j, anni, showMode, pointj, toggleType; + + for(i = 0; i < annotations.length; i++) { + anni = annotations[i]; + showMode = anni.clicktoshow; + if(showMode) { + for(j = 0; j < hoverLen; j++) { + pointj = hoverData[j]; + if(pointj.x === anni._xclick && pointj.y === anni._yclick && + pointj.xaxis._id === anni.xref && + pointj.yaxis._id === anni.yref) { + // match! toggle this annotation + // regardless of its clicktoshow mode + // but if it's onout mode, off is implicit + if(anni.visible) { + if(showMode === 'onout') toggleType = offSet; + else toggleType = explicitOffSet; + } + else { + toggleType = onSet; + } + toggleType.push(i); + break; + } + } + + if(j === hoverLen) { + // no match - only turn this annotation OFF, and only if + // showmode is 'onout' + if(anni.visible && showMode === 'onout') offSet.push(i); + } + } + } + + return {on: onSet, off: offSet, explicitOff: explicitOffSet}; +} diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index a6421f366a2..815701a4ddf 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -24,7 +24,7 @@ var dragElement = require('../dragelement'); var handleAnnotationDefaults = require('./annotation_defaults'); var supplyLayoutDefaults = require('./defaults'); -var arrowhead = require('./draw_arrow_head'); +var drawArrowHead = require('./draw_arrow_head'); // Annotations are stored in gd.layout.annotations, an array of objects @@ -248,14 +248,17 @@ function drawOne(gd, index, opt, value) { var xa = Axes.getFromId(gd, options.xref), ya = Axes.getFromId(gd, options.yref), - annPosPx = {x: 0, y: 0}, + + // calculated pixel positions + // x & y each will get text, head, and tail as appropriate + annPosPx = {x: {}, y: {}}, textangle = +options.textangle || 0; // create the components // made a single group to contain all, so opacity can work right // 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') + var annGroup = fullLayout._infolayer.append('g') .classed('annotation', true) .attr('data-index', String(index)) .style('opacity', options.opacity) @@ -269,17 +272,17 @@ function drawOne(gd, index, opt, value) { }); // another group for text+background so that they can rotate together - var anng = anngroup.append('g') + var annTextGroup = annGroup.append('g') .classed('annotation-text-g', true) .attr('data-index', String(index)); - var ann = anng.append('g'); + var annTextGroupInner = annTextGroup.append('g'); var borderwidth = options.borderwidth, borderpad = options.borderpad, borderfull = borderwidth + borderpad; - var annbg = ann.append('rect') + var annTextBG = annTextGroupInner.append('rect') .attr('class', 'bg') .style('stroke-width', borderwidth + 'px') .call(Color.stroke, options.bordercolor) @@ -287,7 +290,7 @@ function drawOne(gd, index, opt, value) { var font = options.font; - var anntext = ann.append('text') + var annText = annTextGroupInner.append('text') .classed('annotation', true) .attr('data-unformatted', options.text) .text(options.text); @@ -309,12 +312,12 @@ function drawOne(gd, index, opt, value) { // make sure lines are aligned the way they will be // at the end, even if their position changes - anntext.selectAll('tspan.line').attr({y: 0, x: 0}); + annText.selectAll('tspan.line').attr({y: 0, x: 0}); - var mathjaxGroup = ann.select('.annotation-math-group'), + var mathjaxGroup = annTextGroupInner.select('.annotation-math-group'), hasMathjax = !mathjaxGroup.empty(), anntextBB = Drawing.bBox( - (hasMathjax ? mathjaxGroup : anntext).node()), + (hasMathjax ? mathjaxGroup : annText).node()), annwidth = anntextBB.width, annheight = anntextBB.height, outerwidth = Math.round(annwidth + 2 * borderfull), @@ -344,22 +347,37 @@ function drawOne(gd, index, opt, value) { var annotationIsOffscreen = false; ['x', 'y'].forEach(function(axLetter) { var axRef = options[axLetter + 'ref'] || axLetter, + tailRef = options['a' + axLetter + 'ref'], 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)), + 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), + // but this one is the positive total size + annSize = Math.abs(annSizeFromWidth) + Math.abs(annSizeFromHeight), anchor = options[axLetter + 'anchor'], - alignPosition; - - // calculate pixel position + posPx = annPosPx[axLetter], + basePx, + textPadShift, + alignPosition, + autoAlignFraction, + textShift; + + /* + * calculate the *primary* pixel position + * which is the arrowhead if there is one, + * otherwise the text anchor point + */ if(ax) { - // hide the annotation if it's pointing - // outside the visible plot (as long as the axis - // isn't autoranged - then we need to draw it - // anyway to get its bounding box) + /* + * hide the annotation if it's pointing outside the visible plot + * as long as the axis isn't autoranged - then we need to draw it + * anyway to get its bounding box. When we're dragging, an axis can + * still look autoranged even though it won't be when the drag finishes. + */ var posFraction = ax.r2fraction(options[axLetter]); - if(!ax.autorange && (posFraction < 0 || posFraction > 1)) { - if(options['a' + axLetter + 'ref'] === axRef) { + if((gd._dragging || !ax.autorange) && (posFraction < 0 || posFraction > 1)) { + if(tailRef === axRef) { posFraction = ax.r2fraction(options['a' + axLetter]); if(posFraction < 0 || posFraction > 1) { annotationIsOffscreen = true; @@ -371,139 +389,148 @@ function drawOne(gd, index, opt, value) { if(annotationIsOffscreen) return; } - annPosPx[axLetter] = ax._offset + ax.r2p(options[axLetter]); - alignPosition = 0.5; + basePx = ax._offset + ax.r2p(options[axLetter]); + autoAlignFraction = 0.5; } else { - alignPosition = options[axLetter]; - if(axLetter === 'y') alignPosition = 1 - alignPosition; - annPosPx[axLetter] = (axLetter === 'x') ? - (gs.l + gs.w * alignPosition) : - (gs.t + gs.h * alignPosition); + if(axLetter === 'x') { + alignPosition = options[axLetter]; + basePx = gs.l + gs.w * alignPosition; + } + else { + alignPosition = 1 - options[axLetter]; + basePx = gs.t + gs.h * alignPosition; + } + autoAlignFraction = options.showarrow ? 0.5 : alignPosition; } - var alignShift = 0; - if(options['a' + axLetter + 'ref'] === axRef) { - annPosPx['aa' + axLetter] = ax._offset + ax.r2p(options['a' + axLetter]); - } else { - if(options.showarrow) { - alignShift = options['a' + axLetter]; + // now translate this into pixel positions of head, tail, and text + // as well as paddings for autorange + if(options.showarrow) { + posPx.head = basePx; + + var arrowLength = options['a' + axLetter]; + + // with an arrow, the text rotates around the anchor point + textShift = annSizeFromWidth * shiftFraction(0.5, options.xanchor) - + annSizeFromHeight * shiftFraction(0.5, options.yanchor); + + if(tailRef === axRef) { + posPx.tail = ax._offset + ax.r2p(arrowLength); + // tail is data-referenced: autorange pads the text in px from the tail + textPadShift = textShift; } else { - alignShift = annSize * shiftFraction(alignPosition, anchor); + posPx.tail = basePx + arrowLength; + // tail is specified in px from head, so autorange also pads vs head + textPadShift = textShift + arrowLength; + } + + posPx.text = posPx.tail + textShift; + + // constrain pixel/paper referenced so the draggers are at least + // partially visible + var maxPx = fullLayout[(axLetter === 'x') ? 'width' : 'height']; + if(axRef === 'paper') { + posPx.head = Lib.constrain(posPx.head, 1, maxPx - 1); + } + if(tailRef === 'pixel') { + var shiftPlus = -Math.max(posPx.tail - 3, posPx.text), + shiftMinus = Math.min(posPx.tail + 3, posPx.text) - maxPx; + if(shiftPlus > 0) { + posPx.tail += shiftPlus; + posPx.text += shiftPlus; + } + else if(shiftMinus > 0) { + posPx.tail -= shiftMinus; + posPx.text -= shiftMinus; + } } - annPosPx[axLetter] += alignShift; + } + else { + // with no arrow, the text rotates and *then* we put the anchor + // relative to the new bounding box + textShift = annSize * shiftFraction(autoAlignFraction, anchor); + textPadShift = textShift; + posPx.text = basePx + textShift; } + options['_' + axLetter + 'padplus'] = (annSize / 2) + textPadShift; + options['_' + axLetter + 'padminus'] = (annSize / 2) - textPadShift; + // save the current axis type for later log/linear changes options['_' + axLetter + 'type'] = ax && ax.type; - - // save the size and shift in this dim for autorange - options['_' + axLetter + 'size'] = annSize; - options['_' + axLetter + 'shift'] = alignShift; }); if(annotationIsOffscreen) { - ann.remove(); + annTextGroupInner.remove(); return; } - var arrowX, arrowY; - - // make sure the arrowhead (if there is one) - // and the annotation center are visible - if(options.showarrow) { - 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); - - var texty = borderfull - anntextBB.top, - textx = borderfull - anntextBB.left; - if(hasMathjax) { mathjaxGroup.select('svg').attr({x: borderfull - 1, y: borderfull}); } else { - anntext.attr({x: textx, y: texty}); - anntext.selectAll('tspan.line').attr({y: texty, x: textx}); + var texty = borderfull - anntextBB.top, + textx = borderfull - anntextBB.left; + annText.attr({x: textx, y: texty}); + annText.selectAll('tspan.line').attr({y: texty, x: textx}); } - annbg.call(Drawing.setRect, borderwidth / 2, borderwidth / 2, + annTextBG.call(Drawing.setRect, borderwidth / 2, borderwidth / 2, outerwidth - borderwidth, outerheight - borderwidth); - 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); - } + annTextGroupInner.call(Lib.setTranslate, + Math.round(annPosPx.x.text - outerwidth / 2), + Math.round(annPosPx.y.text - outerheight / 2)); - ann.call(Lib.setTranslate, annX, annY); + /* + * rotate text and background + * we already calculated the text center position *as rotated* + * because we needed that for autoranging anyway, so now whether + * we have an arrow or not, we rotate about the text center. + */ + 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 + /* + * add the arrow + * uses options[arrowwidth,arrowcolor,arrowhead] for styling + * dx and dy are normally zero, but when you are dragging the textbox + * 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 + '"]') .remove(); - // find where to start the arrow: - // at the border of the textbox, if that border is visible, - // or at the edge of the lines of text, if the border is hidden - // TODO: tspan bounding box fails in chrome - // 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, 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; - } + var headX = annPosPx.x.head, + headY = annPosPx.y.head, + tailX = annPosPx.x.tail + dx, + tailY = annPosPx.y.tail + dy, + textX = annPosPx.x.text + dx, + textY = annPosPx.y.text + dy, - // create transform matrix and related functions - var transform = - Lib.rotationXYMatrix(textangle, arrowX0, arrowY0), + // find the edge of the text box, where we'll start the arrow: + // create transform matrix to rotate the text box corners + transform = Lib.rotationXYMatrix(textangle, textX, textY), applyTransform = Lib.apply2DTransform(transform), applyTransform2 = Lib.apply2DTransform2(transform), // calculate and transform bounding box - xHalf = annbg.attr('width') / 2, - yHalf = annbg.attr('height') / 2, + width = +annTextBG.attr('width'), + height = +annTextBG.attr('height'), + xLeft = textX - 0.5 * width, + xRight = xLeft + width, + yTop = textY - 0.5 * height, + yBottom = yTop + height, edges = [ - [arrowX0 - xHalf, arrowY0 - yHalf, arrowX0 - xHalf, arrowY0 + yHalf], - [arrowX0 - xHalf, arrowY0 + yHalf, arrowX0 + xHalf, arrowY0 + yHalf], - [arrowX0 + xHalf, arrowY0 + yHalf, arrowX0 + xHalf, arrowY0 - yHalf], - [arrowX0 + xHalf, arrowY0 - yHalf, arrowX0 - xHalf, arrowY0 - yHalf] + [xLeft, yTop, xLeft, yBottom], + [xLeft, yBottom, xRight, yBottom], + [xRight, yBottom, xRight, yTop], + [xRight, yTop, xLeft, yTop] ].map(applyTransform2); // Remove the line if it ends inside the box. Use ray @@ -512,7 +539,7 @@ function drawOne(gd, index, opt, value) { // to get the parity of the number of intersections. if(edges.reduce(function(a, x) { return a ^ - !!lineIntersect(arrowX, arrowY, arrowX + 1e6, arrowY + 1e6, + !!lineIntersect(headX, headY, headX + 1e6, headY + 1e6, x[0], x[1], x[2], x[3]); }, false)) { // no line or arrow - so quit drawArrow now @@ -520,50 +547,61 @@ function drawOne(gd, index, opt, value) { } edges.forEach(function(x) { - var p = lineIntersect(arrowX0, arrowY0, arrowX, arrowY, + var p = lineIntersect(tailX, tailY, headX, headY, x[0], x[1], x[2], x[3]); if(p) { - arrowX0 = p.x; - arrowY0 = p.y; + tailX = p.x; + tailY = p.y; } }); var strokewidth = options.arrowwidth, arrowColor = options.arrowcolor; - var arrowgroup = anngroup.append('g') + var arrowGroup = annGroup.append('g') .style({opacity: Color.opacity(arrowColor)}) .classed('annotation-arrow-g', true) .attr('data-index', String(index)); - var arrow = arrowgroup.append('path') - .attr('d', 'M' + arrowX0 + ',' + arrowY0 + 'L' + arrowX + ',' + arrowY) + var arrow = arrowGroup.append('path') + .attr('d', 'M' + tailX + ',' + tailY + 'L' + headX + ',' + headY) .style('stroke-width', strokewidth + 'px') .call(Color.stroke, Color.rgb(arrowColor)); - arrowhead(arrow, options.arrowhead, 'end', options.arrowsize); - - var arrowdrag = arrowgroup.append('path') - .classed('annotation', true) - .classed('anndrag', true) - .attr({ - 'data-index': String(index), - d: 'M3,3H-3V-3H3ZM0,0L' + (arrowX0 - arrowX) + ',' + (arrowY0 - arrowY), - transform: 'translate(' + arrowX + ',' + arrowY + ')' - }) - .style('stroke-width', (strokewidth + 6) + 'px') - .call(Color.stroke, 'rgba(0,0,0,0)') - .call(Color.fill, 'rgba(0,0,0,0)'); - - if(gd._context.editable) { + drawArrowHead(arrow, options.arrowhead, 'end', options.arrowsize, options.standoff); + + // 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) { + var arrowDragHeadX = headX; + var arrowDragHeadY = headY; + if(options.standoff) { + var arrowLength = Math.sqrt(Math.pow(headX - tailX, 2) + Math.pow(headY - tailY, 2)); + arrowDragHeadX += options.standoff * (tailX - headX) / arrowLength; + arrowDragHeadY += options.standoff * (tailY - headY) / arrowLength; + } + var arrowDrag = arrowGroup.append('path') + .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 + ')' + }) + .style('stroke-width', (strokewidth + 6) + 'px') + .call(Color.stroke, 'rgba(0,0,0,0)') + .call(Color.fill, 'rgba(0,0,0,0)'); + var update, annx0, anny0; + // dragger for the arrow & head: translates the whole thing + // (head/tail/text) all together dragElement.init({ - element: arrowdrag.node(), + element: arrowDrag.node(), prepFn: function() { - var pos = Lib.getTranslate(ann); + var pos = Lib.getTranslate(annTextGroupInner); annx0 = pos.x; anny0 = pos.y; @@ -576,33 +614,32 @@ function drawOne(gd, index, opt, value) { } }, moveFn: function(dx, dy) { - arrowgroup.attr('transform', 'translate(' + dx + ',' + dy + ')'); - var annxy0 = applyTransform(annx0, anny0), xcenter = annxy0[0] + dx, ycenter = annxy0[1] + dy; - ann.call(Lib.setTranslate, xcenter, ycenter); + annTextGroupInner.call(Lib.setTranslate, xcenter, ycenter); update[annbase + '.x'] = xa ? xa.p2r(xa.r2p(options.x) + dx) : - ((arrowX + dx - gs.l) / gs.w); + ((headX + dx - gs.l) / gs.w); update[annbase + '.y'] = ya ? ya.p2r(ya.r2p(options.y) + dy) : - (1 - ((arrowY + dy - gs.t) / gs.h)); + (1 - ((headY + dy - gs.t) / gs.h)); if(options.axref === options.xref) { update[annbase + '.ax'] = xa ? xa.p2r(xa.r2p(options.ax) + dx) : - ((arrowX + dx - gs.l) / gs.w); + ((headX + dx - gs.l) / gs.w); } if(options.ayref === options.yref) { update[annbase + '.ay'] = ya ? ya.p2r(ya.r2p(options.ay) + dy) : - (1 - ((arrowY + dy - gs.t) / gs.h)); + (1 - ((headY + dy - gs.t) / gs.h)); } - anng.attr({ + arrowGroup.attr('transform', 'translate(' + dx + ',' + dy + ')'); + annTextGroup.attr({ transform: 'rotate(' + textangle + ',' + xcenter + ',' + ycenter + ')' }); @@ -620,28 +657,20 @@ function drawOne(gd, index, opt, value) { if(options.showarrow) drawArrow(0, 0); - // create transform matrix and related functions - var transform = Lib.rotationXYMatrix(textangle, - annPosPx.x, annPosPx.y), - applyTransform = Lib.apply2DTransform(transform); - // user dragging the annotation (text, not arrow) if(gd._context.editable) { - var x0, - y0, - update; + var update, + baseTextTransform; + // dragger for the textbox: if there's an arrow, just drag the + // textbox and tail, leave the head untouched dragElement.init({ - element: ann.node(), + element: annTextGroupInner.node(), prepFn: function() { - var pos = Lib.getTranslate(ann); - - x0 = pos.x; - y0 = pos.y; + baseTextTransform = annTextGroup.attr('transform'); update = {}; }, moveFn: function(dx, dy) { - ann.call(Lib.setTranslate, x0 + dx, y0 + dy); var csr = 'pointer'; if(options.showarrow) { if(options.axref === options.xref) { @@ -685,21 +714,14 @@ function drawOne(gd, index, opt, value) { } } - var xy1 = applyTransform(x0, y0), - x1 = xy1[0] + dx, - y1 = xy1[1] + dy; - - ann.call(Lib.setTranslate, x0 + dx, y0 + dy); - - anng.attr({ - transform: 'rotate(' + textangle + ',' + - x1 + ',' + y1 + ')' + annTextGroup.attr({ + transform: 'translate(' + dx + ',' + dy + ')' + baseTextTransform }); - setCursor(ann, csr); + setCursor(annTextGroupInner, csr); }, doneFn: function(dragged) { - setCursor(ann); + setCursor(annTextGroupInner); if(dragged) { Plotly.relayout(gd, update); var notesBox = document.querySelector('.js-notes-box-panel'); @@ -711,7 +733,7 @@ function drawOne(gd, index, opt, value) { } if(gd._context.editable) { - anntext.call(svgTextUtils.makeEditable, ann) + annText.call(svgTextUtils.makeEditable, annTextGroupInner) .call(textLayout) .on('edit', function(_text) { options.text = _text; @@ -728,12 +750,7 @@ function drawOne(gd, index, opt, value) { Plotly.relayout(gd, update); }); } - else anntext.call(textLayout); - - // rotate and position text and background - anng.attr({transform: 'rotate(' + textangle + ',' + - annPosPx.x + ',' + annPosPx.y + ')'}) - .call(Drawing.setPosition, annPosPx.x, annPosPx.y); + else annText.call(textLayout); } // look for intersection of two line segments diff --git a/src/components/annotations/draw_arrow_head.js b/src/components/annotations/draw_arrow_head.js index c959c9394df..69e5181914c 100644 --- a/src/components/annotations/draw_arrow_head.js +++ b/src/components/annotations/draw_arrow_head.js @@ -22,11 +22,10 @@ var ARROWPATHS = require('./arrow_paths'); // ends is 'start', 'end' (default), 'start+end' // mag is magnification vs. default (default 1) -module.exports = function drawArrowHead(el3, style, ends, mag) { +module.exports = function drawArrowHead(el3, style, ends, mag, standoff) { if(!isNumeric(mag)) mag = 1; var el = el3.node(), headStyle = ARROWPATHS[style||0]; - if(!headStyle) return; if(typeof ends !== 'string' || !ends) ends = 'end'; @@ -35,7 +34,7 @@ module.exports = function drawArrowHead(el3, style, ends, mag) { opacity = el3.style('stroke-opacity') || 1, doStart = ends.indexOf('start') >= 0, doEnd = ends.indexOf('end') >= 0, - backOff = headStyle.backoff * scale, + backOff = headStyle.backoff * scale + standoff, start, end, startRot, @@ -44,9 +43,17 @@ module.exports = function drawArrowHead(el3, style, ends, mag) { if(el.nodeName === 'line') { start = {x: +el3.attr('x1'), y: +el3.attr('y1')}; end = {x: +el3.attr('x2'), y: +el3.attr('y2')}; - startRot = Math.atan2(start.y - end.y, start.x - end.x); + + var dx = start.x - end.x, + dy = start.y - end.y; + + startRot = Math.atan2(dy, dx); endRot = startRot + Math.PI; if(backOff) { + if(backOff * backOff > dx * dx + dy * dy) { + hideLine(); + return; + } var backOffX = backOff * Math.cos(startRot), backOffY = backOff * Math.sin(startRot); @@ -70,6 +77,11 @@ module.exports = function drawArrowHead(el3, style, ends, mag) { // combine the two dashArray = ''; + if(pathlen < backOff) { + hideLine(); + return; + } + if(doStart) { var start0 = el.getPointAtLength(0), dstart = el.getPointAtLength(0.1); @@ -94,7 +106,10 @@ module.exports = function drawArrowHead(el3, style, ends, mag) { if(dashArray) el3.style('stroke-dasharray', dashArray); } - var drawhead = function(p, rot) { + function hideLine() { el3.style('stroke-dasharray', '0px,100px'); } + + function drawhead(p, rot) { + if(!headStyle.path) return; if(style > 5) rot = 0; // don't rotate square or circle d3.select(el.parentElement).append('path') .attr({ @@ -110,7 +125,7 @@ module.exports = function drawArrowHead(el3, style, ends, mag) { opacity: opacity, 'stroke-width': 0 }); - }; + } if(doStart) drawhead(start, startRot); if(doEnd) drawhead(end, endRot); diff --git a/src/components/annotations/index.js b/src/components/annotations/index.js index 18db2d770f4..bb32b6b69df 100644 --- a/src/components/annotations/index.js +++ b/src/components/annotations/index.js @@ -10,6 +10,7 @@ 'use strict'; var drawModule = require('./draw'); +var clickModule = require('./click'); module.exports = { moduleType: 'component', @@ -20,5 +21,8 @@ module.exports = { calcAutorange: require('./calc_autorange'), draw: drawModule.draw, - drawOne: drawModule.drawOne + drawOne: drawModule.drawOne, + + hasClickToShow: clickModule.hasClickToShow, + onClick: clickModule.onClick }; diff --git a/src/lib/override_cursor.js b/src/lib/override_cursor.js new file mode 100644 index 00000000000..ebbd290951e --- /dev/null +++ b/src/lib/override_cursor.js @@ -0,0 +1,47 @@ +/** +* 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 setCursor = require('./setcursor'); + +var STASHATTR = 'data-savedcursor'; +var NO_CURSOR = '!!'; + +/* + * works with our CSS cursor classes (see css/_cursor.scss) + * to override a previous cursor set on d3 single-element selections, + * by moving the name of the original cursor to the data-savedcursor attr. + * omit cursor to revert to the previously set value. + */ +module.exports = function overrideCursor(el3, csr) { + var savedCursor = el3.attr(STASHATTR); + if(csr) { + if(!savedCursor) { + var classes = (el3.attr('class') || '').split(' '); + for(var i = 0; i < classes.length; i++) { + var cls = classes[i]; + if(cls.indexOf('cursor-') === 0) { + el3.attr(STASHATTR, cls.substr(7)) + .classed(cls, false); + } + } + if(!el3.attr(STASHATTR)) { + el3.attr(STASHATTR, NO_CURSOR); + } + } + setCursor(el3, csr); + } + else if(savedCursor) { + el3.attr(STASHATTR, null); + + if(savedCursor === NO_CURSOR) setCursor(el3); + else setCursor(el3, savedCursor); + } +}; diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js index 5c361729a28..ea2f8c908ec 100644 --- a/src/plots/cartesian/graph_interact.js +++ b/src/plots/cartesian/graph_interact.js @@ -19,6 +19,8 @@ var svgTextUtils = require('../../lib/svg_text_utils'); var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); var dragElement = require('../../components/dragelement'); +var overrideCursor = require('../../lib/override_cursor'); +var Registry = require('../../registry'); var Axes = require('./axes'); var constants = require('./constants'); @@ -596,6 +598,13 @@ function hover(gd, evt, subplot) { gd._hoverdata = newhoverdata; + // TODO: tagName hack is needed to appease geo.js's hack of using evt.target=true + // we should improve the "fx" API so other plots can use it without these hack. + if(evt.target && evt.target.tagName) { + var hasClickToShow = Registry.getComponentMethod('annotations', 'hasClickToShow')(gd, newhoverdata); + overrideCursor(d3.select(evt.target), hasClickToShow ? 'pointer' : ''); + } + if(!hoverChanged(gd, evt, oldhoverdata)) return; if(oldhoverdata) { @@ -1319,8 +1328,16 @@ function hoverChanged(gd, evt, oldhoverdata) { // on click fx.click = function(gd, evt) { + var annotationsDone = Registry.getComponentMethod('annotations', 'onClick')(gd, gd._hoverdata); + + function emitClick() { gd.emit('plotly_click', {points: gd._hoverdata}); } + if(gd._hoverdata && evt && evt.target) { - gd.emit('plotly_click', {points: gd._hoverdata}); + if(annotationsDone && annotationsDone.then) { + annotationsDone.then(emitClick); + } + else emitClick(); + // why do we get a double event without this??? if(evt.stopImmediatePropagation) evt.stopImmediatePropagation(); } diff --git a/test/image/baselines/annotations-autorange.png b/test/image/baselines/annotations-autorange.png index 07df4be6243..cab99da4b8d 100644 Binary files a/test/image/baselines/annotations-autorange.png and b/test/image/baselines/annotations-autorange.png differ diff --git a/test/image/baselines/annotations.png b/test/image/baselines/annotations.png index 41ffc4f779f..f4e3dad3d4e 100644 Binary files a/test/image/baselines/annotations.png and b/test/image/baselines/annotations.png differ diff --git a/test/image/mocks/annotations-autorange.json b/test/image/mocks/annotations-autorange.json index 6ec2838daad..433b86aa376 100644 --- a/test/image/mocks/annotations-autorange.json +++ b/test/image/mocks/annotations-autorange.json @@ -56,9 +56,15 @@ {"ay":0,"ax":-50,"x":2,"y":1.5,"text":"Right"}, {"x":1.5,"y":2,"text":"Top","ay":50,"ax":0}, {"x":1.5,"y":1,"text":"Bottom","ay":-50,"ax":0}, - {"xref":"x2","yref":"y2","text":"From left","y":2,"ax":-50,"ay":0,"x":"2001-01-01"}, + { + "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 top","y":3,"ax":0,"ay":-50,"x":"2001-02-01"}, + { + "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":"x3","yref":"y3","text":"Right
no
arrow","y":1.5,"x":2,"showarrow":false}, diff --git a/test/image/mocks/annotations.json b/test/image/mocks/annotations.json index 06cbdd5b48f..ed767593165 100644 --- a/test/image/mocks/annotations.json +++ b/test/image/mocks/annotations.json @@ -45,7 +45,17 @@ "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} + {"text":"","showarrow":true,"borderwidth":1.2,"arrowhead":2,"axref":"x","ayref":"y","x":6,"y":2,"ax":3,"ay":3}, + { + "text": "arrow ML
+standoff", "x": 2, "y": 2, "ax": 20, "ay": 20, + "xanchor": "left", "yanchor": "middle", "standoff": 5 + }, + { + "text": "angle, arrow TR
box+standoff", "x": 2, "y": 2, "ax": -20, "ay": -20, + "xanchor": "right", "yanchor": "top", "standoff": 5, "textangle": 45, "bordercolor": "#444" + }, + {"text": "off-plot, do not show!", "x": 5.1, "y": 3.5, "showarrow": false}, + {"text": "off-plot 2", "x": 3, "y": 5.1, "showarrow": true, "ax": 0, "ay": 100} ] } } diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index fd99e6c6881..880f273ed70 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -47,7 +47,8 @@ describe('Test annotations', function() { expect(item).toEqual({ visible: false, _input: {}, - _index: i + _index: i, + clicktoshow: false }); }); }); @@ -296,3 +297,131 @@ describe('annotations autosize', function() { .then(done); }); }); + +describe('annotation clicktoshow', function() { + var gd; + + afterEach(destroyGraphDiv); + + function layout() { + return { + xaxis: {domain: [0, 0.5]}, + xaxis2: {domain: [0.5, 1], anchor: 'y2'}, + yaxis2: {anchor: 'x2'}, + annotations: [ + {x: 1, y: 2, xref: 'x', yref: 'y', text: 'index0'}, // (1,2) selects + {x: 1, y: 3, xref: 'x', yref: 'y', text: 'index1'}, + {x: 2, y: 3, xref: 'x', yref: 'y', text: 'index2'}, // ** (2,3) selects + {x: 4, y: 2, xref: 'x', yref: 'y', text: 'index3'}, + {x: 1, y: 2, xref: 'x2', yref: 'y', text: 'index4'}, + {x: 1, y: 2, xref: 'x', yref: 'y2', text: 'index5'}, + {x: 1, xclick: 5, y: 2, xref: 'x', yref: 'y', text: 'index6'}, + {x: 1, y: 2, yclick: 6, xref: 'x', yref: 'y', text: 'index7'}, + {x: 1, y: 2.0000001, xref: 'x', yref: 'y', text: 'index8'}, + {x: 1, y: 2, xref: 'x', yref: 'y', text: 'index9'}, // (1,2) selects + {x: 7, xclick: 1, y: 2, xref: 'x', yref: 'y', text: 'index10'}, // (1,2) selects + {x: 1, y: 8, yclick: 2, xref: 'x', yref: 'y', text: 'index11'}, // (1,2) selects + {x: 1, y: 2, xref: 'paper', yref: 'y', text: 'index12'}, + {x: 1, y: 2, xref: 'x', yref: 'paper', text: 'index13'}, + {x: 1, y: 2, xref: 'paper', yref: 'paper', text: 'index14'} + ] + }; + } + + var data = [ + {x: [0, 1, 2], y: [1, 2, 3]}, + {x: [0, 1, 2], y: [1, 2, 3], xaxis: 'x2', yaxis: 'y2'} + ]; + + function hoverData(xyPairs) { + // hovering on nothing can have undefined hover data - must be supported + if(!xyPairs.length) return; + + return xyPairs.map(function(xy) { + return { + x: xy[0], + y: xy[1], + xaxis: gd._fullLayout.xaxis, + yaxis: gd._fullLayout.yaxis + }; + }); + } + + function checkVisible(opts) { + gd._fullLayout.annotations.forEach(function(ann, i) { + expect(ann.visible).toBe(opts.on.indexOf(i) !== -1, 'i: ' + i + ', step: ' + opts.step); + }); + } + + function allAnnotations(attr, value) { + var update = {}; + for(var i = 0; i < gd.layout.annotations.length; i++) { + update['annotations[' + i + '].' + attr] = value; + } + return update; + } + + function clickAndCheck(opts) { + return function() { + expect(Annotations.hasClickToShow(gd, hoverData(opts.newPts))) + .toBe(opts.newCTS, 'step: ' + opts.step); + + var clickResult = Annotations.onClick(gd, hoverData(opts.newPts)); + if(clickResult && clickResult.then) { + return clickResult.then(function() { checkVisible(opts); }); + } + else { + checkVisible(opts); + } + }; + } + + function updateAndCheck(opts) { + return function() { + return Plotly.update(gd, {}, opts.update).then(function() { + checkVisible(opts); + }); + }; + } + + var allIndices = layout().annotations.map(function(v, i) { return i; }); + + it('should select only clicktoshow annotations matching x, y, and axes of any point', function(done) { + gd = createGraphDiv(); + + // first try to select without adding clicktoshow, both visible and invisible + Plotly.plot(gd, data, layout()) + // clicktoshow is off initially, so it doesn't *expect* clicking will + // do anything, and it doesn't *actually* do anything. + .then(clickAndCheck({newPts: [[1, 2]], newCTS: false, on: allIndices, step: 1})) + .then(updateAndCheck({update: allAnnotations('visible', false), on: [], step: 2})) + // still nothing happens with hidden annotations + .then(clickAndCheck({newPts: [[1, 2]], newCTS: false, on: [], step: 3})) + + // turn on clicktoshow (onout mode) and we see some action! + .then(updateAndCheck({update: allAnnotations('clicktoshow', 'onout'), on: [], step: 4})) + .then(clickAndCheck({newPts: [[1, 2]], newCTS: true, on: [0, 9, 10, 11], step: 5})) + .then(clickAndCheck({newPts: [[2, 3]], newCTS: true, on: [2], step: 6})) + // clicking the same point again will close all, but in onout mode hasClickToShow + // is false because closing notes is kind of passive + .then(clickAndCheck({newPts: [[2, 3]], newCTS: false, on: [], step: 7})) + // now click two points (as if in compare hovermode) + .then(clickAndCheck({newPts: [[1, 2], [2, 3]], newCTS: true, on: [0, 2, 9, 10, 11], step: 8})) + // close all by clicking somewhere else + .then(clickAndCheck({newPts: [[0, 1]], newCTS: false, on: [], step: 9})) + + // now switch to onoff mode + .then(updateAndCheck({update: allAnnotations('clicktoshow', 'onoff'), on: [], step: 10})) + // again, clicking a point turns those annotations on + .then(clickAndCheck({newPts: [[1, 2]], newCTS: true, on: [0, 9, 10, 11], step: 11})) + // clicking a different point (or no point at all) leaves open annotations the same + .then(clickAndCheck({newPts: [[0, 1]], newCTS: false, on: [0, 9, 10, 11], step: 12})) + .then(clickAndCheck({newPts: [], newCTS: false, on: [0, 9, 10, 11], step: 13})) + // clicking another point turns it on too, without turning off the original + .then(clickAndCheck({newPts: [[0, 1], [2, 3]], newCTS: true, on: [0, 2, 9, 10, 11], step: 14})) + // finally click each one off + .then(clickAndCheck({newPts: [[1, 2]], newCTS: true, on: [2], step: 15})) + .then(clickAndCheck({newPts: [[2, 3]], newCTS: true, on: [], step: 16})) + .then(done); + }); +}); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 4991a6e5ab5..3ef05bfd90a 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -1,5 +1,6 @@ var Lib = require('@src/lib'); var setCursor = require('@src/lib/setcursor'); +var overrideCursor = require('@src/lib/override_cursor'); var d3 = require('d3'); var Plotly = require('@lib'); @@ -1191,6 +1192,56 @@ describe('Test lib.js:', function() { }); }); + describe('overrideCursor', function() { + + beforeEach(function() { + this.el3 = d3.select(createGraphDiv()); + }); + + afterEach(destroyGraphDiv); + + it('should apply the new cursor(s) and revert to the original when removed', function() { + this.el3 + .classed('cursor-before', true) + .classed('not-a-cursor', true) + .classed('another', true); + + overrideCursor(this.el3, 'after'); + expect(this.el3.attr('class')).toBe('not-a-cursor another cursor-after'); + + overrideCursor(this.el3, 'later'); + expect(this.el3.attr('class')).toBe('not-a-cursor another cursor-later'); + + overrideCursor(this.el3); + expect(this.el3.attr('class')).toBe('not-a-cursor another cursor-before'); + }); + + it('should apply the new cursor(s) and revert to the none when removed', function() { + this.el3 + .classed('not-a-cursor', true) + .classed('another', true); + + overrideCursor(this.el3, 'after'); + expect(this.el3.attr('class')).toBe('not-a-cursor another cursor-after'); + + overrideCursor(this.el3, 'later'); + expect(this.el3.attr('class')).toBe('not-a-cursor another cursor-later'); + + overrideCursor(this.el3); + expect(this.el3.attr('class')).toBe('not-a-cursor another'); + }); + + it('should do nothing if no existing or new override is present', function() { + this.el3 + .classed('cursor-before', true) + .classed('not-a-cursor', true); + + overrideCursor(this.el3); + + expect(this.el3.attr('class')).toBe('cursor-before not-a-cursor'); + }); + }); + describe('getTranslate', function() { it('should work with regular DOM elements', function() { diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 36c7ec0f045..949aaaa14c3 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -68,7 +68,8 @@ function runTests(transitionDuration) { it('transitions an annotation', function(done) { function annotationPosition() { var g = gd._fullLayout._infolayer.select('.annotation').select('.annotation-text-g'); - return [parseInt(g.attr('x')), parseInt(g.attr('y'))]; + var bBox = g.node().getBoundingClientRect(); + return [bBox.left, bBox.top]; } var p1, p2;