diff --git a/src/components/shapes/attributes.js b/src/components/shapes/attributes.js index 622cf147015..7fce0cce265 100644 --- a/src/components/shapes/attributes.js +++ b/src/components/shapes/attributes.js @@ -35,16 +35,20 @@ module.exports = { 'Specifies the shape type to be drawn.', 'If *line*, a line is drawn from (`x0`,`y0`) to (`x1`,`y1`)', + 'with respect to the axes\' sizing mode.', 'If *circle*, a circle is drawn from', '((`x0`+`x1`)/2, (`y0`+`y1`)/2))', 'with radius', '(|(`x0`+`x1`)/2 - `x0`|, |(`y0`+`y1`)/2 -`y0`)|)', + 'with respect to the axes\' sizing mode.', 'If *rect*, a rectangle is drawn linking', '(`x0`,`y0`), (`x1`,`y0`), (`x1`,`y1`), (`x0`,`y1`), (`x0`,`y0`)', + 'with respect to the axes\' sizing mode.', - 'If *path*, draw a custom SVG path using `path`.' + 'If *path*, draw a custom SVG path using `path`.', + 'with respect to the axes\' sizing mode.' ].join(' ') }, @@ -61,7 +65,7 @@ module.exports = { description: [ 'Sets the shape\'s x coordinate axis.', 'If set to an x axis id (e.g. *x* or *x2*), the `x` position', - 'refers to an x coordinate', + 'refers to an x coordinate.', 'If set to *paper*, the `x` position refers to the distance from', 'the left side of the plotting area in normalized coordinates', 'where *0* (*1*) corresponds to the left (right) side.', @@ -71,13 +75,43 @@ module.exports = { 'the date to unix time in milliseconds.' ].join(' ') }), + xsizemode: { + valType: 'enumerated', + values: ['scaled', 'pixel'], + dflt: 'scaled', + role: 'info', + editType: 'calcIfAutorange+arraydraw', + description: [ + 'Sets the shapes\'s sizing mode along the x axis.', + 'If set to *scaled*, `x0`, `x1` and x coordinates within `path` refer to', + 'data values on the x axis or a fraction of the plot area\'s width', + '(`xref` set to *paper*).', + 'If set to *pixel*, `xanchor` specifies the x position in terms', + 'of data or plot fraction but `x0`, `x1` and x coordinates within `path`', + 'are pixels relative to `xanchor`. This way, the shape can have', + 'a fixed width while maintaining a position relative to data or', + 'plot fraction.' + ].join(' ') + }, + xanchor: { + valType: 'any', + role: 'info', + editType: 'calcIfAutorange+arraydraw', + description: [ + 'Only relevant in conjunction with `xsizemode` set to *pixel*.', + 'Specifies the anchor point on the x axis to which `x0`, `x1`', + 'and x coordinates within `path` are relative to.', + 'E.g. useful to attach a pixel sized shape to a certain data value.', + 'No effect when `xsizemode` not set to *pixel*.' + ].join(' ') + }, x0: { valType: 'any', role: 'info', editType: 'calcIfAutorange+arraydraw', description: [ 'Sets the shape\'s starting x position.', - 'See `type` for more info.' + 'See `type` and `xsizemode` for more info.' ].join(' ') }, x1: { @@ -86,7 +120,7 @@ module.exports = { editType: 'calcIfAutorange+arraydraw', description: [ 'Sets the shape\'s end x position.', - 'See `type` for more info.' + 'See `type` and `xsizemode` for more info.' ].join(' ') }, @@ -100,13 +134,43 @@ module.exports = { 'where *0* (*1*) corresponds to the bottom (top).' ].join(' ') }), + ysizemode: { + valType: 'enumerated', + values: ['scaled', 'pixel'], + dflt: 'scaled', + role: 'info', + editType: 'calcIfAutorange+arraydraw', + description: [ + 'Sets the shapes\'s sizing mode along the y axis.', + 'If set to *scaled*, `y0`, `y1` and y coordinates within `path` refer to', + 'data values on the y axis or a fraction of the plot area\'s height', + '(`yref` set to *paper*).', + 'If set to *pixel*, `yanchor` specifies the y position in terms', + 'of data or plot fraction but `y0`, `y1` and y coordinates within `path`', + 'are pixels relative to `yanchor`. This way, the shape can have', + 'a fixed height while maintaining a position relative to data or', + 'plot fraction.' + ].join(' ') + }, + yanchor: { + valType: 'any', + role: 'info', + editType: 'calcIfAutorange+arraydraw', + description: [ + 'Only relevant in conjunction with `ysizemode` set to *pixel*.', + 'Specifies the anchor point on the y axis to which `y0`, `y1`', + 'and y coordinates within `path` are relative to.', + 'E.g. useful to attach a pixel sized shape to a certain data value.', + 'No effect when `ysizemode` not set to *pixel*.' + ].join(' ') + }, y0: { valType: 'any', role: 'info', editType: 'calcIfAutorange+arraydraw', description: [ 'Sets the shape\'s starting y position.', - 'See `type` for more info.' + 'See `type` and `ysizemode` for more info.' ].join(' ') }, y1: { @@ -115,7 +179,7 @@ module.exports = { editType: 'calcIfAutorange+arraydraw', description: [ 'Sets the shape\'s end y position.', - 'See `type` for more info.' + 'See `type` and `ysizemode` for more info.' ].join(' ') }, @@ -124,8 +188,11 @@ module.exports = { role: 'info', editType: 'calcIfAutorange+arraydraw', description: [ - 'For `type` *path* - a valid SVG path but with the pixel values', - 'replaced by data values. There are a few restrictions / quirks', + 'For `type` *path* - a valid SVG path with the pixel values', + 'replaced by data values in `xsizemode`/`ysizemode` being *scaled*', + 'and taken unmodified as pixels relative to `xanchor` and `yanchor`', + 'in case of *pixel* size mode.', + 'There are a few restrictions / quirks', 'only absolute instructions, not relative. So the allowed segments', 'are: M, L, H, V, Q, C, T, S, and Z', 'arcs (A) are not allowed because radius rx and ry are relative.', diff --git a/src/components/shapes/calc_autorange.js b/src/components/shapes/calc_autorange.js index 474cdef3613..06c599255eb 100644 --- a/src/components/shapes/calc_autorange.js +++ b/src/components/shapes/calc_autorange.js @@ -23,25 +23,79 @@ module.exports = function calcAutorange(gd) { if(!shapeList.length || !gd._fullData.length) return; for(var i = 0; i < shapeList.length; i++) { - var shape = shapeList[i], - ppad = shape.line.width / 2; + var shape = shapeList[i]; var ax, bounds; if(shape.xref !== 'paper') { + var vx0 = shape.xsizemode === 'pixel' ? shape.xanchor : shape.x0, + vx1 = shape.xsizemode === 'pixel' ? shape.xanchor : shape.x1; ax = Axes.getFromId(gd, shape.xref); - bounds = shapeBounds(ax, shape.x0, shape.x1, shape.path, constants.paramIsX); - if(bounds) Axes.expand(ax, bounds, {ppad: ppad}); + + bounds = shapeBounds(ax, vx0, vx1, shape.path, constants.paramIsX); + + if(bounds) Axes.expand(ax, bounds, calcXPaddingOptions(shape)); } if(shape.yref !== 'paper') { + var vy0 = shape.ysizemode === 'pixel' ? shape.yanchor : shape.y0, + vy1 = shape.ysizemode === 'pixel' ? shape.yanchor : shape.y1; ax = Axes.getFromId(gd, shape.yref); - bounds = shapeBounds(ax, shape.y0, shape.y1, shape.path, constants.paramIsY); - if(bounds) Axes.expand(ax, bounds, {ppad: ppad}); + + bounds = shapeBounds(ax, vy0, vy1, shape.path, constants.paramIsY); + if(bounds) Axes.expand(ax, bounds, calcYPaddingOptions(shape)); } } }; +function calcXPaddingOptions(shape) { + return calcPaddingOptions(shape.line.width, shape.xsizemode, shape.x0, shape.x1, shape.path, false); +} + +function calcYPaddingOptions(shape) { + return calcPaddingOptions(shape.line.width, shape.ysizemode, shape.y0, shape.y1, shape.path, true); +} + +function calcPaddingOptions(lineWidth, sizeMode, v0, v1, path, isYAxis) { + var ppad = lineWidth / 2, + axisDirectionReverted = isYAxis; + + if(sizeMode === 'pixel') { + var coords = path ? + extractPathCoords(path, isYAxis ? constants.paramIsY : constants.paramIsX) : + [v0, v1]; + var maxValue = Lib.aggNums(Math.max, null, coords), + minValue = Lib.aggNums(Math.min, null, coords), + beforePad = minValue < 0 ? Math.abs(minValue) + ppad : ppad, + afterPad = maxValue > 0 ? maxValue + ppad : ppad; + + return { + ppad: ppad, + ppadplus: axisDirectionReverted ? beforePad : afterPad, + ppadminus: axisDirectionReverted ? afterPad : beforePad + }; + } else { + return {ppad: ppad}; + } +} + +function extractPathCoords(path, paramsToUse) { + var extractedCoordinates = []; + + var segments = path.match(constants.segmentRE); + segments.forEach(function(segment) { + var relevantParamIdx = paramsToUse[segment.charAt(0)].drawn; + if(relevantParamIdx === undefined) return; + + var params = segment.substr(1).match(constants.paramRE); + if(!params || params.length < relevantParamIdx) return; + + extractedCoordinates.push(params[relevantParamIdx]); + }); + + return extractedCoordinates; +} + function shapeBounds(ax, v0, v1, path, paramsToUse) { var convertVal = (ax.type === 'category') ? ax.r2c : ax.d2c; diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 271e37c4861..53673e7c173 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -122,8 +122,11 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { var MINWIDTH = 10, MINHEIGHT = 10; + var xPixelSized = shapeOptions.xsizemode === 'pixel', + yPixelSized = shapeOptions.ysizemode === 'pixel'; + var update; - var x0, y0, x1, y1, astrX0, astrY0, astrX1, astrY1; + var x0, y0, x1, y1, xAnchor, yAnchor, astrX0, astrY0, astrX1, astrY1, astrXAnchor, astrYAnchor; var n0, s0, w0, e0, astrN, astrS, astrW, astrE, optN, optS, optW, optE; var pathIn, astrPath; @@ -135,7 +138,6 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { prepFn: startDrag, doneFn: endDrag }, - dragBBox = dragOptions.element.getBoundingClientRect(), dragMode; dragElement.init(dragOptions); @@ -143,6 +145,10 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { shapePath.node().onmousemove = updateDragMode; function updateDragMode(evt) { + // element might not be on screen at time of setup, + // so obtain bounding box here + var dragBBox = dragOptions.element.getBoundingClientRect(); + // choose 'move' or 'resize' // based on initial position of cursor within the drag element var w = dragBBox.right - dragBBox.left, @@ -171,15 +177,25 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { // setup update strings and initial values var astr = 'shapes[' + index + ']'; + + if(xPixelSized) { + xAnchor = x2p(shapeOptions.xanchor); + astrXAnchor = astr + '.xanchor'; + } + if(yPixelSized) { + yAnchor = y2p(shapeOptions.yanchor); + astrYAnchor = astr + '.yanchor'; + } + if(shapeOptions.type === 'path') { pathIn = shapeOptions.path; astrPath = astr + '.path'; } else { - x0 = x2p(shapeOptions.x0); - y0 = y2p(shapeOptions.y0); - x1 = x2p(shapeOptions.x1); - y1 = y2p(shapeOptions.y1); + x0 = xPixelSized ? shapeOptions.x0 : x2p(shapeOptions.x0); + y0 = yPixelSized ? shapeOptions.y0 : y2p(shapeOptions.y0); + x1 = xPixelSized ? shapeOptions.x1 : x2p(shapeOptions.x1); + y1 = yPixelSized ? shapeOptions.y1 : y2p(shapeOptions.y1); astrX0 = astr + '.x0'; astrY0 = astr + '.y0'; @@ -195,7 +211,10 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { w0 = x1; astrW = astr + '.x1'; optW = 'x1'; e0 = x0; astrE = astr + '.x0'; optE = 'x0'; } - if(y0 < y1) { + + // For fixed size shapes take opposing direction of y-axis into account. + // Hint: For data sized shapes this is done by the y2p function. + if((!yPixelSized && y0 < y1) || (yPixelSized && y0 > y1)) { n0 = y0; astrN = astr + '.y0'; optN = 'y0'; s0 = y1; astrS = astr + '.y1'; optS = 'y1'; } @@ -218,20 +237,41 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { function moveShape(dx, dy) { if(shapeOptions.type === 'path') { - var moveX = function moveX(x) { return p2x(x2p(x) + dx); }; - if(xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); + var noOp = function(coord) { return coord; }, + moveX = noOp, + moveY = noOp; + + if(xPixelSized) { + update[astrXAnchor] = shapeOptions.xanchor = p2x(xAnchor + dx); + } else { + moveX = function moveX(x) { return p2x(x2p(x) + dx); }; + if(xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); + } - var moveY = function moveY(y) { return p2y(y2p(y) + dy); }; - if(ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); + if(yPixelSized) { + update[astrYAnchor] = shapeOptions.yanchor = p2y(yAnchor + dy); + } else { + moveY = function moveY(y) { return p2y(y2p(y) + dy); }; + if(ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); + } shapeOptions.path = movePath(pathIn, moveX, moveY); update[astrPath] = shapeOptions.path; } else { - update[astrX0] = shapeOptions.x0 = p2x(x0 + dx); - update[astrY0] = shapeOptions.y0 = p2y(y0 + dy); - update[astrX1] = shapeOptions.x1 = p2x(x1 + dx); - update[astrY1] = shapeOptions.y1 = p2y(y1 + dy); + if(xPixelSized) { + update[astrXAnchor] = shapeOptions.xanchor = p2x(xAnchor + dx); + } else { + update[astrX0] = shapeOptions.x0 = p2x(x0 + dx); + update[astrX1] = shapeOptions.x1 = p2x(x1 + dx); + } + + if(yPixelSized) { + update[astrYAnchor] = shapeOptions.yanchor = p2y(yAnchor + dy); + } else { + update[astrY0] = shapeOptions.y0 = p2y(y0 + dy); + update[astrY1] = shapeOptions.y1 = p2y(y1 + dy); + } } shapePath.attr('d', getPathString(gd, shapeOptions)); @@ -240,11 +280,23 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { function resizeShape(dx, dy) { if(shapeOptions.type === 'path') { // TODO: implement path resize - var moveX = function moveX(x) { return p2x(x2p(x) + dx); }; - if(xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); + var noOp = function(coord) { return coord; }, + moveX = noOp, + moveY = noOp; + + if(xPixelSized) { + update[astrXAnchor] = shapeOptions.xanchor = p2x(xAnchor + dx); + } else { + moveX = function moveX(x) { return p2x(x2p(x) + dx); }; + if(xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); + } - var moveY = function moveY(y) { return p2y(y2p(y) + dy); }; - if(ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); + if(yPixelSized) { + update[astrYAnchor] = shapeOptions.yanchor = p2y(yAnchor + dy); + } else { + moveY = function moveY(y) { return p2y(y2p(y) + dy); }; + if(ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); + } shapeOptions.path = movePath(pathIn, moveX, moveY); update[astrPath] = shapeOptions.path; @@ -255,14 +307,21 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { newW = (~dragMode.indexOf('w')) ? w0 + dx : w0, newE = (~dragMode.indexOf('e')) ? e0 + dx : e0; - if(newS - newN > MINHEIGHT) { - update[astrN] = shapeOptions[optN] = p2y(newN); - update[astrS] = shapeOptions[optS] = p2y(newS); + // Do things in opposing direction for y-axis. + // Hint: for data-sized shapes the reversal of axis direction is done in p2y. + if(~dragMode.indexOf('n') && yPixelSized) newN = n0 - dy; + if(~dragMode.indexOf('s') && yPixelSized) newS = s0 - dy; + + // Update shape eventually. Again, be aware of the + // opposing direction of the y-axis of fixed size shapes. + if((!yPixelSized && newS - newN > MINHEIGHT) || + (yPixelSized && newN - newS > MINHEIGHT)) { + update[astrN] = shapeOptions[optN] = yPixelSized ? newN : p2y(newN); + update[astrS] = shapeOptions[optS] = yPixelSized ? newS : p2y(newS); } - if(newE - newW > MINWIDTH) { - update[astrW] = shapeOptions[optW] = p2x(newW); - update[astrE] = shapeOptions[optE] = p2x(newE); + update[astrW] = shapeOptions[optW] = xPixelSized ? newW : p2x(newW); + update[astrE] = shapeOptions[optE] = xPixelSized ? newE : p2x(newE); } } @@ -275,10 +334,8 @@ function getPathString(gd, options) { xa = Axes.getFromId(gd, options.xref), ya = Axes.getFromId(gd, options.yref), gs = gd._fullLayout._size, - x2r, - x2p, - y2r, - y2p; + x2r, x2p, y2r, y2p, + x0, x1, y0, y1; if(xa) { x2r = helpers.shapePositionToRange(xa); @@ -299,13 +356,28 @@ function getPathString(gd, options) { if(type === 'path') { if(xa && xa.type === 'date') x2p = helpers.decodeDate(x2p); if(ya && ya.type === 'date') y2p = helpers.decodeDate(y2p); - return convertPath(options.path, x2p, y2p); + return convertPath(options, x2p, y2p); } - var x0 = x2p(options.x0), - x1 = x2p(options.x1), - y0 = y2p(options.y0), + if(options.xsizemode === 'pixel') { + var xAnchorPos = x2p(options.xanchor); + x0 = xAnchorPos + options.x0; + x1 = xAnchorPos + options.x1; + } + else { + x0 = x2p(options.x0); + x1 = x2p(options.x1); + } + + if(options.ysizemode === 'pixel') { + var yAnchorPos = y2p(options.yanchor); + y0 = yAnchorPos - options.y0; + y1 = yAnchorPos - options.y1; + } + else { + y0 = y2p(options.y0); y1 = y2p(options.y1); + } if(type === 'line') return 'M' + x0 + ',' + y0 + 'L' + x1 + ',' + y1; if(type === 'rect') return 'M' + x0 + ',' + y0 + 'H' + x1 + 'V' + y1 + 'H' + x0 + 'Z'; @@ -322,8 +394,14 @@ function getPathString(gd, options) { } -function convertPath(pathIn, x2p, y2p) { - // convert an SVG path string from data units to pixels +function convertPath(options, x2p, y2p) { + var pathIn = options.path, + xSizemode = options.xsizemode, + ySizemode = options.ysizemode, + xAnchor = options.xanchor, + yAnchor = options.yanchor; + + return pathIn.replace(constants.segmentRE, function(segment) { var paramNumber = 0, segmentType = segment.charAt(0), @@ -332,8 +410,14 @@ function convertPath(pathIn, x2p, y2p) { nParams = constants.numParams[segmentType]; var paramString = segment.substr(1).replace(constants.paramRE, function(param) { - if(xParams[paramNumber]) param = x2p(param); - else if(yParams[paramNumber]) param = y2p(param); + if(xParams[paramNumber]) { + if(xSizemode === 'pixel') param = x2p(xAnchor) + Number(param); + else param = x2p(param); + } + else if(yParams[paramNumber]) { + if(ySizemode === 'pixel') param = y2p(yAnchor) - Number(param); + else param = y2p(param); + } paramNumber++; if(paramNumber > nParams) param = 'X'; diff --git a/src/components/shapes/shape_defaults.js b/src/components/shapes/shape_defaults.js index dc80021f9c8..66259ac63d7 100644 --- a/src/components/shapes/shape_defaults.js +++ b/src/components/shapes/shape_defaults.js @@ -36,32 +36,37 @@ module.exports = function handleShapeDefaults(shapeIn, shapeOut, fullLayout, opt coerce('line.dash'); var dfltType = shapeIn.path ? 'path' : 'rect', - shapeType = coerce('type', dfltType); + shapeType = coerce('type', dfltType), + xSizeMode = coerce('xsizemode'), + ySizeMode = coerce('ysizemode'); // positioning var axLetters = ['x', 'y']; for(var i = 0; i < 2; i++) { var axLetter = axLetters[i], - gdMock = {_fullLayout: fullLayout}; + attrAnchor = axLetter + 'anchor', + sizeMode = axLetter === 'x' ? xSizeMode : ySizeMode, + gdMock = {_fullLayout: fullLayout}, + ax, + pos2r, + r2pos; // xref, yref var axRef = Axes.coerceRef(shapeIn, shapeOut, gdMock, axLetter, '', 'paper'); + if(axRef !== 'paper') { + ax = Axes.getFromId(gdMock, axRef); + r2pos = helpers.rangeToShapePosition(ax); + pos2r = helpers.shapePositionToRange(ax); + } + else { + pos2r = r2pos = Lib.identity; + } + + // Coerce x0, x1, y0, y1 if(shapeType !== 'path') { var dflt0 = 0.25, - dflt1 = 0.75, - ax, - pos2r, - r2pos; - - if(axRef !== 'paper') { - ax = Axes.getFromId(gdMock, axRef); - r2pos = helpers.rangeToShapePosition(ax); - pos2r = helpers.shapePositionToRange(ax); - } - else { - pos2r = r2pos = Lib.identity; - } + dflt1 = 0.75; // hack until V2.0 when log has regular range behavior - make it look like other // ranges to send to coerce, then put it back after @@ -74,9 +79,13 @@ module.exports = function handleShapeDefaults(shapeIn, shapeOut, fullLayout, opt shapeIn[attr0] = pos2r(shapeIn[attr0], true); shapeIn[attr1] = pos2r(shapeIn[attr1], true); - // x0, x1 (and y0, y1) - Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr0, dflt0); - Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr1, dflt1); + if(sizeMode === 'pixel') { + coerce(attr0, 0); + coerce(attr1, 10); + } else { + Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr0, dflt0); + Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr1, dflt1); + } // hack part 2 shapeOut[attr0] = r2pos(shapeOut[attr0]); @@ -84,6 +93,19 @@ module.exports = function handleShapeDefaults(shapeIn, shapeOut, fullLayout, opt shapeIn[attr0] = in0; shapeIn[attr1] = in1; } + + // Coerce xanchor and yanchor + if(sizeMode === 'pixel') { + // Hack for log axis described above + var inAnchor = shapeIn[attrAnchor]; + shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true); + + Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attrAnchor, 0.25); + + // Hack part 2 + shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]); + shapeIn[attrAnchor] = inAnchor; + } } if(shapeType === 'path') { diff --git a/src/plots/cartesian/autorange.js b/src/plots/cartesian/autorange.js index 797add39ac6..117abcde849 100644 --- a/src/plots/cartesian/autorange.js +++ b/src/plots/cartesian/autorange.js @@ -219,6 +219,8 @@ function needsAutorange(ax) { * Note that `expand` is called during `calc`, when we don't yet know the axis * length; all the inputs should be based solely on the trace data, nothing * about the axis layout. + * Note that `ppad` and `vpad` as well as their asymmetric variants refer to + * the before and after padding of the passed `data` array, not to the whole axis. * * @param {object} ax: the axis being expanded. The result will be more entries * in ax._min and ax._max if necessary to include the new data diff --git a/test/image/baselines/fixed_size_shapes.png b/test/image/baselines/fixed_size_shapes.png new file mode 100644 index 00000000000..306caf72ed0 Binary files /dev/null and b/test/image/baselines/fixed_size_shapes.png differ diff --git a/test/image/mocks/fixed_size_shapes.json b/test/image/mocks/fixed_size_shapes.json new file mode 100644 index 00000000000..8cce393f526 --- /dev/null +++ b/test/image/mocks/fixed_size_shapes.json @@ -0,0 +1,259 @@ +{ + "data": [ + { + "x": [1,4], + "y": [1,4] + }, { + "x": [1,3,4], + "y": ["Giraffes","Apes","Zebras"], + "xaxis": "x2", + "yaxis": "y2" + }, { + "x": [ + "2018-01-01 00:00:00", + "2018-02-01 00:00:00", + "2018-03-01 00:00:00" + ], + "y": [1,5,7], + "xaxis": "x3", + "yaxis": "y3" + }, { + "x": [1,4], + "y": [1,5], + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "title": "Fixed size shapes", + "autosize": false, + "width": 1000, + "height": 800, + "xaxis": { + "domain": [0,0.30], + "anchor": "y", + "title": "rect, circle, line" + }, + "yaxis": { + "domain": [0.55,1], + "type": "log", + "anchor": "x" + }, + "xaxis2": { + "domain":[0.33,0.66], + "anchor": "y2", + "title": "triangle as path" + }, + "yaxis2": { + "domain": [0.55,1], + "type": "categorical", + "anchor": "x2" + }, + "xaxis3": { + "domain":[0.70,1], + "anchor": "y3", + "title": "drag / resize for auto-range" + }, + "yaxis3": { + "domain": [0.55,1], + "type": "linear", + "anchor": "x3" + }, + "xaxis4": { + "domain": [0,0.30], + "anchor": "y4", + "title": "xy pos & x size paper, y size pixel" + }, + "yaxis4": { + "domain": [0,0.45], + "type": "linear", + "anchor": "x4" + }, + "xaxis5": { + "domain":[0.33,0.66], + "anchor": "y5", + "title": "rects w/ mixed size modes" + }, + "yaxis5": { + "domain": [0,0.45], + "type": "linear", + "anchor": "x5" + }, + "xaxis6": { + "domain":[0.70,1], + "anchor": "y6", + "title": "paths w/ mixed size modes" + }, + "yaxis6": { + "domain": [0,0.45], + "type": "linear", + "anchor": "x6" + }, + "shapes": [ + { + "type": "circle", + "xref": "x", + "xsizemode": "pixel", + "yref": "y", + "ysizemode": "pixel", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": 8, + "yanchor": 8, + "x0": -20, + "x1": 20, + "y0": 20, + "y1": -20, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "rect", + "xref": "x", + "xsizemode": "pixel", + "yref": "y", + "ysizemode": "pixel", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": 7, + "yanchor": 8, + "x0": -20, + "x1": 20, + "y0": 20, + "y1": -20, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "line", + "xref": "x", + "xsizemode": "pixel", + "yref": "y", + "ysizemode": "pixel", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": 6, + "yanchor": 2, + "x0": -20, + "x1": 60, + "y0": 0, + "y1": 40, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "path", + "xref": "x2", + "xsizemode": "pixel", + "yref": "y2", + "ysizemode": "pixel", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": -1, + "yanchor": 1, + "path": "M0,0 L30,0 L15,15 Z", + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "rect", + "xref": "x3", + "xsizemode": "pixel", + "yref": "y3", + "ysizemode": "pixel", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": "2018-02-15 00:00:00", + "yanchor": 2, + "x0": 3, + "x1": 53, + "y0": 20, + "y1": -20, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "rect", + "xref": "paper", + "xsizemode": "scaled", + "yref": "paper", + "ysizemode": "pixel", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": 0, + "yanchor": 0, + "x0": 0, + "x1": 0.3, + "y0": 10, + "y1": -10, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "rect", + "xref": "x5", + "xsizemode": "scaled", + "yref": "y5", + "ysizemode": "pixel", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": 0, + "yanchor": 10, + "x0": 0, + "x1": 10, + "y0": 0, + "y1": -30, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "rect", + "xref": "x5", + "xsizemode": "pixel", + "yref": "y5", + "ysizemode": "scaled", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": 3, + "yanchor": 3, + "x0": 0, + "x1": 30, + "y0": 0, + "y1": 10, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "path", + "xref": "x6", + "xsizemode": "pixel", + "yref": "y6", + "ysizemode": "scaled", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": 10, + "yanchor": 10, + "path": "M0,0 L30,0 L15,4 Z", + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "path", + "xref": "x6", + "xsizemode": "scaled", + "yref": "y6", + "ysizemode": "pixel", + "fillcolor": "rgba(96, 171, 50,0.7)", + "xanchor": 10, + "yanchor": 10, + "path": "M0,0 L10,0 L5,25 Z", + "line": { + "color": "rgba(96, 171, 50, 1)" + } + } + ] + }, + "config": { + "editable": "true" + } +} diff --git a/test/jasmine/assets/custom_assertions.js b/test/jasmine/assets/custom_assertions.js index aa594999e82..ba791a38250 100644 --- a/test/jasmine/assets/custom_assertions.js +++ b/test/jasmine/assets/custom_assertions.js @@ -190,3 +190,29 @@ exports.checkTicks = function(axLetter, vals, msg) { expect(d3.select(this).text()).toBe(vals[i], msg + ': ' + i); }); }; + +exports.assertElemRightTo = function(elem, refElem, msg) { + var elemBB = elem.getBoundingClientRect(); + var refElemBB = refElem.getBoundingClientRect(); + expect(elemBB.left >= refElemBB.right).toBe(true, msg); +}; + + +exports.assertElemTopsAligned = function(elem1, elem2, msg) { + var elem1BB = elem1.getBoundingClientRect(); + var elem2BB = elem2.getBoundingClientRect(); + + // Hint: toBeWithin tolerance is exclusive, hence a + // diff of exactly 1 would fail the test + var tolerance = 1.1; + expect(elem1BB.top - elem2BB.top).toBeWithin(0, tolerance, msg); +}; + +exports.assertElemInside = function(elem, container, msg) { + var elemBB = elem.getBoundingClientRect(); + var contBB = container.getBoundingClientRect(); + expect(contBB.left < elemBB.left && + contBB.right > elemBB.right && + contBB.top < elemBB.top && + contBB.bottom > elemBB.bottom).toBe(true, msg); +}; diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 847c7b96cc9..1ad54ea3b6c 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -17,6 +17,9 @@ var fail = require('../assets/fail_test'); var customAssertions = require('../assets/custom_assertions'); var assertHoverLabelStyle = customAssertions.assertHoverLabelStyle; var assertHoverLabelContent = customAssertions.assertHoverLabelContent; +var assertElemRightTo = customAssertions.assertElemRightTo; +var assertElemTopsAligned = customAssertions.assertElemTopsAligned; +var assertElemInside = customAssertions.assertElemInside; describe('hover info', function() { 'use strict'; @@ -1136,15 +1139,6 @@ describe('hover info', function() { msgPrefixFmt + 'Primary text inside box'); assertElemInside(nodes.secondaryText, nodes.secondaryBox, msgPrefixFmt + 'Secondary text inside box'); - - function assertElemInside(elem, container, msg) { - var elemBB = elem.getBoundingClientRect(); - var contBB = container.getBoundingClientRect(); - expect(contBB.left < elemBB.left && - contBB.right > elemBB.right && - contBB.top < elemBB.top && - contBB.bottom > elemBB.bottom).toBe(true, msg); - } } function assertSecondaryRightToPrimaryBox(nodes, msgPrefix) { @@ -1154,22 +1148,6 @@ describe('hover info', function() { msgPrefixFmt + 'Secondary box right to primary box'); assertElemTopsAligned(nodes.secondaryBox, nodes.primaryBox, msgPrefixFmt + 'Top edges of primary and secondary boxes aligned'); - - function assertElemRightTo(elem, refElem, msg) { - var elemBB = elem.getBoundingClientRect(); - var refElemBB = refElem.getBoundingClientRect(); - expect(elemBB.left >= refElemBB.right).toBe(true, msg); - } - - function assertElemTopsAligned(elem1, elem2, msg) { - var elem1BB = elem1.getBoundingClientRect(); - var elem2BB = elem2.getBoundingClientRect(); - - // Hint: toBeWithin tolerance is exclusive, hence a - // diff of exactly 1 would fail the test - var tolerance = 1.1; - expect(elem1BB.top - elem2BB.top).toBeWithin(0, tolerance, msg); - } } describe('centered', function() { diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index c80361c0081..c5c9b5e77b6 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -13,6 +13,23 @@ var destroyGraphDiv = require('../assets/destroy_graph_div'); var failTest = require('../assets/fail_test'); var drag = require('../assets/drag'); +var customAssertions = require('../assets/custom_assertions'); +var assertElemRightTo = customAssertions.assertElemRightTo; +var assertElemTopsAligned = customAssertions.assertElemTopsAligned; +var assertElemInside = customAssertions.assertElemInside; + +// Reusable vars +var shapeTypes = [{type: 'rect'}, {type: 'circle'}, {type: 'line'}]; +var resizeDirections = ['n', 's', 'w', 'e', 'nw', 'se', 'ne', 'sw']; +var resizeTypes = [ + {resizeType: 'shrink', resizeDisplayName: 'shrunken'}, + {resizeType: 'enlarge', resizeDisplayName: 'enlarged'} +]; +var dxToShrinkWidth = { n: 0, s: 0, w: 10, e: -10, nw: 10, se: -10, ne: -10, sw: 10 }; +var dyToShrinkHeight = { n: 10, s: -10, w: 0, e: 0, nw: 10, se: -10, ne: 10, sw: -10 }; +var dxToEnlargeWidth = { n: 0, s: 0, w: -10, e: 10, nw: -10, se: 10, ne: 10, sw: -10 }; +var dyToEnlargeHeight = { n: -10, s: 10, w: 0, e: 0, nw: -10, se: 10, ne: -10, sw: 10 }; + describe('Test shapes defaults:', function() { 'use strict'; @@ -626,6 +643,508 @@ describe('Test shapes: a plot with shapes and an overlaid axis', function() { }); }); +function getFirstShapeNode() { + return d3.selectAll('.shapelayer path').node(); +} + +function assertShapeSize(shapeNode, w, h) { + var bBox = shapeNode.getBoundingClientRect(); + expect(bBox.width).toBe(w); + expect(bBox.height).toBe(h); +} + +function assertShapeFullyVisible(shapeElem) { + var gridLayer = d3.selectAll('.gridlayer').node(); + assertElemInside(shapeElem, gridLayer, 'shape element fully visible'); +} + +describe('A path shape sized relative to data', function() { + 'use strict'; + + var gd, data, layout; + + beforeEach(function() { + gd = createGraphDiv(); + data = [{ + x: [1, 5], + y: [1, 5], + type: 'scatter' + }]; + layout = { + title: 'Path shape sized relative to data', + width: 400, + height: 400, + shapes: [{ + type: 'path', + xref: 'x', + yref: 'y', + xsizemode: 'data', + ysizemode: 'data', + path: 'M10,0 L2,10 L1,0 Z', + + // Hint: set those too intentionally + xanchor: '3', + yanchor: '0', + x0: 1, + x1: 3, + y0: 1, + y1: 3 + }] + }; + }); + + afterEach(destroyGraphDiv); + + it('is expanding an auto-ranging axes', function() { + Plotly.plot(gd, data, layout); + + assertShapeFullyVisible(getFirstShapeNode()); + }); +}); + +describe('A fixed size path shape', function() { + 'use strict'; + + var gd, data, layout; + + beforeEach(function() { + gd = createGraphDiv(); + data = [{ + x: [1, 5], + y: [1, 5], + type: 'scatter' + }]; + layout = { + title: 'Fixed size path shape', + width: 400, + height: 400, + shapes: [{ + type: 'path', + xref: 'x', + yref: 'y', + xsizemode: 'pixel', + ysizemode: 'pixel', + path: 'M0,0 L30,0 L15,20 Z', + xanchor: '3', + yanchor: '0', + + // Hint: set those too intentionally + x0: 1, + x1: 3, + y0: 1, + y1: 3 + }] + }; + }); + + afterEach(destroyGraphDiv); + + it('is defined in pixel', function() { + Plotly.plot(gd, data, layout); + + assertShapeSize(getFirstShapeNode(), 30, 20); + }); + + it('is expanding auto-ranging axes', function() { + layout.shapes[0].xanchor = 10; + layout.shapes[0].yanchor = 10; + + Plotly.plot(gd, data, layout); + + assertShapeFullyVisible(getFirstShapeNode()); + }); + + it('is being rendered correctly when linked to a date axis', function() { + data = [{ + x: ['2018-01-01 00:00:00', + '2018-02-01 00:00:00', + '2018-03-01 00:00:00', + '2018-04-01 00:00:00'], + y: [3, 4, 2, 5], + type: 'scatter' + }]; + layout.shapes[0].xanchor = '2018-07-01 00:00:00'; + layout.shapes[0].yanchor = 10; + + Plotly.plot(gd, data, layout); + + var shapeNode = getFirstShapeNode(); + assertShapeFullyVisible(shapeNode); + assertShapeSize(shapeNode, 30, 20); + }); + + it('keeps its dimensions when plot is being resized', function(done) { + Plotly.plot(gd, data, layout); + + assertShapeSize(getFirstShapeNode(), 30, 20); + + Plotly.relayout(gd, {height: 200, width: 600}).then(function() { + assertShapeSize(getFirstShapeNode(), 30, 20); + }) + .catch(failTest) + .then(done); + }); + + it('is draggable', function(done) { + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + drag(getFirstShapeNode(), 50, 50).then(function() { + assertShapeSize(getFirstShapeNode(), 30, 20); + done(); + }); + }); + }); + + it('being sized relative to data horizontally is getting narrower ' + + 'when being dragged to expand the x-axis', + function(done) { + layout.shapes[0].xsizemode = 'data'; + layout.shapes[0].path = 'M0,0 L2,0 L1,20 Z'; + + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + var shapeNodeBeforeDrag = getFirstShapeNode(); + var widthBeforeDrag = shapeNodeBeforeDrag.getBoundingClientRect().width; + + drag(shapeNodeBeforeDrag, 300, 50).then(function() { + var shapeNodeAfterDrag = getFirstShapeNode(); + var bbox = shapeNodeAfterDrag.getBoundingClientRect(); + expect(bbox.height).toBe(20); + expect(bbox.width).toBeLessThan(widthBeforeDrag); + assertShapeFullyVisible(shapeNodeAfterDrag); + done(); + }); + }); + }); + + it('being sized relative to data vertically is getting lower ' + + 'when being dragged to expand the y-axis', + function(done) { + layout.shapes[0].ysizemode = 'data'; + layout.shapes[0].path = 'M0,0 L30,0 L15,2 Z'; + + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + var shapeNodeBeforeDrag = getFirstShapeNode(); + var heightBeforeDrag = shapeNodeBeforeDrag.getBoundingClientRect().height; + + drag(shapeNodeBeforeDrag, 50, 300).then(function() { + var shapeNodeAfterDrag = getFirstShapeNode(); + var bbox = shapeNodeAfterDrag.getBoundingClientRect(); + expect(bbox.width).toBe(30); + expect(bbox.height).toBeLessThan(heightBeforeDrag); + assertShapeFullyVisible(shapeNodeAfterDrag); + done(); + }); + }); + }); +}); + +describe('A fixed size shape', function() { + 'use strict'; + + var gd, data, layout; + + beforeEach(function() { + gd = createGraphDiv(); + data = [{ + x: [1, 5], + y: [1, 5], + type: 'scatter' + }]; + layout = { + title: 'Fixed size shape', + width: 400, + height: 400, + shapes: [{ + type: 'rect', + xref: 'x', + yref: 'y', + xsizemode: 'pixel', + ysizemode: 'pixel', + xanchor: '3', + yanchor: '0', + x0: 3, + x1: 28, + y0: 0, + y1: -25 + }] + }; + }); + + afterEach(destroyGraphDiv); + + it('can be positioned relative to data', function() { + Plotly.plot(gd, data, layout); + + var shapeNode = getFirstShapeNode(); + assertShapeSize(shapeNode, 25, 25); + + // Check position relative to data with zero line and grid line as a reference + var xAxisLine = d3.selectAll('.zerolinelayer .yzl').node(); + assertElemTopsAligned(shapeNode, xAxisLine, 'Top edges of shape and x-axis zero line aligned'); + var gridLine = d3.selectAll('.gridlayer .xgrid:nth-child(3)').node(); + assertElemRightTo(shapeNode, gridLine, 'Shape right to third grid line'); + }); + + it('can be positioned relative to the plotting area', function() { + layout.shapes[0].xref = 'paper'; + layout.shapes[0].yref = 'paper'; + layout.shapes[0].xanchor = '1'; + layout.shapes[0].yanchor = '1'; + Plotly.plot(gd, data, layout); + + var shapeNode = getFirstShapeNode(); + assertShapeSize(shapeNode, 25, 25); + assertElemRightTo(shapeNode, d3.selectAll('.cartesianlayer').node(), 'Shape right to plotting area'); + }); + + it('can be sized by pixel horizontally and relative to data vertically', function() { + layout.shapes[0].ysizemode = 'data'; + layout.shapes[0].y0 = 1; + layout.shapes[0].y1 = 5; + Plotly.plot(gd, data, layout); + + var shapeNode = getFirstShapeNode(); + var bBox = shapeNode.getBoundingClientRect(); + expect(bBox.width).toBeLessThan(bBox.height); + expect(bBox.width).toBe(25); + }); + + it('can be sized relative to data vertically and by pixel horizontally', function() { + layout.shapes[0].xsizemode = 'data'; + layout.shapes[0].x0 = 1; + layout.shapes[0].x1 = 5; + Plotly.plot(gd, data, layout); + + var shapeNode = getFirstShapeNode(); + var bBox = shapeNode.getBoundingClientRect(); + expect(bBox.height).toBeLessThan(bBox.width); + expect(bBox.height).toBe(25); + }); + + it('is being rendered correctly when linked to a date axis', function() { + data = [{ + x: ['2018-01-01 00:00:00', + '2018-02-01 00:00:00', + '2018-03-01 00:00:00', + '2018-04-01 00:00:00'], + y: [3, 4, 2, 5], + type: 'scatter' + }]; + layout.shapes[0].xanchor = '2018-07-01 00:00:00'; + layout.shapes[0].yanchor = 10; + + Plotly.plot(gd, data, layout); + + var shapeNode = getFirstShapeNode(); + assertShapeFullyVisible(shapeNode); + assertShapeSize(shapeNode, 25, 25); + }); + + it('keeps its dimensions when plot is being resized', function(done) { + layout.shapes[0].yanchor = 3; // Ensure visible for debugging + Plotly.plot(gd, data, layout); + + var shapeNode = getFirstShapeNode(); + assertShapeSize(shapeNode, 25, 25); + + Plotly.relayout(gd, {height: 200, width: 600}).then(function() { + var reRenderedShapeNode = getFirstShapeNode(); + assertShapeSize(reRenderedShapeNode, 25, 25); + }) + .catch(failTest) + .then(done); + }); + + it('is draggable', function(done) { + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + drag(getFirstShapeNode(), 50, 50).then(function() { + assertShapeSize(getFirstShapeNode(), 25, 25); + done(); + }); + }); + }); + + it('being sized relative to data horizontally is getting narrower ' + + 'when being dragged to expand the x-axis', + function(done) { + layout.shapes[0].xsizemode = 'data'; + layout.shapes[0].x0 = 1; + layout.shapes[0].x1 = 2; + + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + var shapeNodeBeforeDrag = getFirstShapeNode(); + var widthBeforeDrag = shapeNodeBeforeDrag.getBoundingClientRect().width; + + drag(shapeNodeBeforeDrag, 300, 50).then(function() { + var shapeNodeAfterDrag = getFirstShapeNode(); + var bbox = shapeNodeAfterDrag.getBoundingClientRect(); + expect(bbox.height).toBe(25); + expect(bbox.width).toBeLessThan(widthBeforeDrag); + assertShapeFullyVisible(shapeNodeAfterDrag); + done(); + }); + }); + }); + + it('being sized relative to data vertically is getting lower ' + + 'when being dragged to expand the y-axis', + function(done) { + layout.shapes[0].ysizemode = 'data'; + layout.shapes[0].y0 = 1; + layout.shapes[0].y1 = 2; + + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + var shapeNodeBeforeDrag = getFirstShapeNode(); + var heightBeforeDrag = shapeNodeBeforeDrag.getBoundingClientRect().height; + + drag(shapeNodeBeforeDrag, 50, 300).then(function() { + var shapeNodeAfterDrag = getFirstShapeNode(); + var bbox = shapeNodeAfterDrag.getBoundingClientRect(); + expect(bbox.width).toBe(25); + expect(bbox.height).toBeLessThan(heightBeforeDrag); + assertShapeFullyVisible(shapeNodeAfterDrag); + done(); + }); + }); + }); + + // Helper to combine two arrays of objects + function combinations(arr1, arr2) { + var combinations = []; + arr1.forEach(function(elemArr1) { + arr2.forEach(function(elemArr2) { + combinations.push(Lib.extendFlat({}, elemArr1, elemArr2)); + }); + }); + return combinations; + } + + var shapeAndResizeTypes = combinations(shapeTypes, resizeTypes); + shapeAndResizeTypes.forEach(function(testCase) { + describe('of type ' + testCase.type + ' can be ' + testCase.resizeDisplayName, function() { + resizeDirections.forEach(function(direction) { + it('over direction ' + direction, function(done) { + layout.shapes[0].type = testCase.type; + + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + var shapeNodeBeforeDrag = getFirstShapeNode(); + var bBoxBeforeDrag = shapeNodeBeforeDrag.getBoundingClientRect(); + + var shallShrink = testCase.resizeType === 'shrink'; + var dx = shallShrink ? dxToShrinkWidth[direction] : dxToEnlargeWidth[direction]; + var dy = shallShrink ? dyToShrinkHeight[direction] : dyToEnlargeHeight[direction]; + + drag(shapeNodeBeforeDrag, dx, dy, direction) + .then(function() { + var shapeNodeAfterDrag = getFirstShapeNode(); + var bBoxAfterDrag = shapeNodeAfterDrag.getBoundingClientRect(); + var resizeFactor = shallShrink ? -1 : 1; + expect(bBoxAfterDrag.height).toBe(bBoxBeforeDrag.height + resizeFactor * Math.abs(dy)); + expect(bBoxAfterDrag.width).toBe(bBoxBeforeDrag.width + resizeFactor * Math.abs(dx)); + assertShapeFullyVisible(shapeNodeAfterDrag); + done(); + }); + }); + }); + }); + }); + }); + + describe('is expanding an auto-ranging x-axis', function() { + var sizeVariants = [ + {x0: 5, x1: 25}, + {x0: 5, x1: -25}, + {x0: -5, x1: 25}, + {x0: -5, x1: -25} + ]; + var shapeVariants = combinations(shapeTypes, sizeVariants); + + describe('to the left', function() { + shapeVariants.forEach(function(testCase) { + it('and is fully visible when being a ' + testCase.type + + ' with x0,x1=[' + testCase.x0 + ',' + testCase.x1 + ']', + function() { + layout.shapes[0].type = testCase.type; + layout.shapes[0].xanchor = -1; + layout.shapes[0].x0 = testCase.x0; + layout.shapes[0].x1 = testCase.x1; + Plotly.plot(gd, data, layout); + + expect(gd.layout.xaxis.range[0]).toBeLessThanOrEqual(-1); + assertShapeFullyVisible(getFirstShapeNode()); + }); + }); + }); + + describe('to the right', function() { + shapeVariants.forEach(function(testCase) { + it('and is fully visible when being a ' + testCase.type + + ' with x0,x1=[' + testCase.x0 + ',' + testCase.x1 + ']', + function() { + layout.shapes[0].type = testCase.type; + layout.shapes[0].xanchor = 10; + layout.shapes[0].x0 = testCase.x0; + layout.shapes[0].x1 = testCase.x1; + Plotly.plot(gd, data, layout); + + expect(gd.layout.xaxis.range[1]).toBeGreaterThanOrEqual(10); + assertShapeFullyVisible(getFirstShapeNode()); + }); + }); + }); + }); + + describe('is expanding an auto-ranging y-axis', function() { + var sizeVariants = [ + {y0: 5, y1: 25}, + {y0: 5, y1: -25}, + {y0: -5, y1: 25}, + {y0: -5, y1: -25} + ]; + var shapeVariants = combinations(shapeTypes, sizeVariants); + + describe('to the bottom', function() { + shapeVariants.forEach(function(testCase) { + it('and is fully visible when being a ' + testCase.type + + ' with y0,y1=[' + testCase.y0 + ',' + testCase.y1 + ']', + function() { + layout.shapes[0].type = testCase.type; + layout.shapes[0].yanchor = -1; + layout.shapes[0].y0 = testCase.y0; + layout.shapes[0].y1 = testCase.y1; + Plotly.plot(gd, data, layout); + + expect(gd.layout.yaxis.range[0]).toBeLessThanOrEqual(-1); + assertShapeFullyVisible(getFirstShapeNode()); + }); + }); + }); + + describe('to the top', function() { + shapeVariants.forEach(function(testCase) { + it('and is fully visible when being a ' + testCase.type + + ' with y0,y1=[' + testCase.y0 + ',' + testCase.y1 + ']', + function() { + layout.shapes[0].type = testCase.type; + layout.shapes[0].yanchor = 10; + layout.shapes[0].y0 = testCase.y0; + layout.shapes[0].y1 = testCase.y1; + Plotly.plot(gd, data, layout); + + expect(gd.layout.yaxis.range[1]).toBeGreaterThanOrEqual(10); + assertShapeFullyVisible(getFirstShapeNode()); + }); + }); + }); + }); +}); + describe('@flaky Test shapes', function() { 'use strict'; @@ -675,7 +1194,7 @@ describe('@flaky Test shapes', function() { }); testCases.forEach(function(testCase) { - ['n', 's', 'w', 'e', 'nw', 'se', 'ne', 'sw'].forEach(function(direction) { + resizeDirections.forEach(function(direction) { var testTitle = testCase.title + 'should be resizeable over direction ' + direction; @@ -777,12 +1296,6 @@ describe('@flaky Test shapes', function() { expect(layoutShapes.length).toBe(4); // line, rect, circle and path - var dxToShrinkWidth = { - n: 0, s: 0, w: 10, e: -10, nw: 10, se: -10, ne: -10, sw: 10 - }, - dyToShrinkHeight = { - n: 10, s: -10, w: 0, e: 0, nw: 10, se: -10, ne: 10, sw: -10 - }; layoutShapes.forEach(function(layoutShape, index) { if(layoutShape.path) return;