diff --git a/build/plotcss.js b/build/plotcss.js index c3984376e12..459d89d47bd 100644 --- a/build/plotcss.js +++ b/build/plotcss.js @@ -30,6 +30,7 @@ var rules = { "X .cursor-nw-resize": "cursor:nw-resize;", "X .cursor-n-resize": "cursor:n-resize;", "X .cursor-ne-resize": "cursor:ne-resize;", + "X .cursor-grab": "cursor:-webkit-grab;cursor:grab;", "X .modebar": "position:absolute;top:2px;right:2px;z-index:1001;background:rgba(255,255,255,0.7);", "X .modebar--hover": "opacity:0;-webkit-transition:opacity 0.3s ease 0s;-moz-transition:opacity 0.3s ease 0s;-ms-transition:opacity 0.3s ease 0s;-o-transition:opacity 0.3s ease 0s;transition:opacity 0.3s ease 0s;", "X:hover .modebar--hover": "opacity:1;", diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index d5b2ce2e287..3dd8bd166ce 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -132,7 +132,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { var annTextGroupInner = annTextGroup.append('g') .style('pointer-events', textEvents ? 'all' : null) - .call(setCursor, 'default') + .call(setCursor, 'pointer') .on('click', function() { gd._dragging = false; @@ -533,6 +533,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { var arrowDrag = arrowGroup.append('path') .classed('annotation-arrow', true) .classed('anndrag', true) + .classed('cursor-move', true) .attr({ d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY), transform: 'translate(' + arrowDragHeadX + ',' + arrowDragHeadY + ')' diff --git a/src/components/shapes/calc_autorange.js b/src/components/shapes/calc_autorange.js index 06c599255eb..967cbcc4992 100644 --- a/src/components/shapes/calc_autorange.js +++ b/src/components/shapes/calc_autorange.js @@ -62,7 +62,7 @@ function calcPaddingOptions(lineWidth, sizeMode, v0, v1, path, isYAxis) { if(sizeMode === 'pixel') { var coords = path ? - extractPathCoords(path, isYAxis ? constants.paramIsY : constants.paramIsX) : + helpers.extractPathCoords(path, isYAxis ? constants.paramIsY : constants.paramIsX) : [v0, v1]; var maxValue = Lib.aggNums(Math.max, null, coords), minValue = Lib.aggNums(Math.min, null, coords), @@ -79,23 +79,6 @@ function calcPaddingOptions(lineWidth, sizeMode, v0, v1, path, isYAxis) { } } -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 cd7bdf4acef..fef3eb31931 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -108,77 +108,143 @@ function drawOne(gd, index) { .call(Color.fill, options.fillcolor) .call(Drawing.dashLine, options.line.dash, options.line.width); - // note that for layer="below" the clipAxes can be different from the - // subplot we're drawing this in. This could cause problems if the shape - // spans two subplots. See https://github.com/plotly/plotly.js/issues/1452 - var clipAxes = (options.xref + options.yref).replace(/paper/g, ''); - - path.call(Drawing.setClipUrl, clipAxes ? - ('clip' + gd._fullLayout._uid + clipAxes) : - null - ); + setClipPath(path, gd, options); - if(gd._context.edits.shapePosition) setupDragElement(gd, path, options, index); + if(gd._context.edits.shapePosition) setupDragElement(gd, path, options, index, shapeLayer); } } -function setupDragElement(gd, shapePath, shapeOptions, index) { +function setClipPath(shapePath, gd, shapeOptions) { + // note that for layer="below" the clipAxes can be different from the + // subplot we're drawing this in. This could cause problems if the shape + // spans two subplots. See https://github.com/plotly/plotly.js/issues/1452 + var clipAxes = (shapeOptions.xref + shapeOptions.yref).replace(/paper/g, ''); + + shapePath.call(Drawing.setClipUrl, clipAxes ? + ('clip' + gd._fullLayout._uid + clipAxes) : + null + ); +} + +function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { var MINWIDTH = 10, MINHEIGHT = 10; var xPixelSized = shapeOptions.xsizemode === 'pixel', - yPixelSized = shapeOptions.ysizemode === 'pixel'; + yPixelSized = shapeOptions.ysizemode === 'pixel', + isLine = shapeOptions.type === 'line', + isPath = shapeOptions.type === 'path'; var update; 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; - var xa, ya, x2p, y2p, p2x, p2y; + // setup conversion functions + var xa = Axes.getFromId(gd, shapeOptions.xref), + ya = Axes.getFromId(gd, shapeOptions.yref), + x2p = helpers.getDataToPixel(gd, xa), + y2p = helpers.getDataToPixel(gd, ya, true), + p2x = helpers.getPixelToData(gd, xa), + p2y = helpers.getPixelToData(gd, ya, true); + var sensoryElement = obtainSensoryElement(); var dragOptions = { - element: shapePath.node(), + element: sensoryElement.node(), gd: gd, prepFn: startDrag, - doneFn: endDrag + doneFn: endDrag, + clickFn: abortDrag }, dragMode; dragElement.init(dragOptions); - shapePath.node().onmousemove = updateDragMode; + sensoryElement.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, - h = dragBBox.bottom - dragBBox.top, - x = evt.clientX - dragBBox.left, - y = evt.clientY - dragBBox.top, - cursor = (w > MINWIDTH && h > MINHEIGHT && !evt.shiftKey) ? - dragElement.getCursor(x / w, 1 - y / h) : - 'move'; - - setCursor(shapePath, cursor); - - // possible values 'move', 'sw', 'w', 'se', 'e', 'ne', 'n', 'nw' and 'w' - dragMode = cursor.split('-')[0]; + function obtainSensoryElement() { + return isLine ? createLineDragHandles() : shapePath; } - function startDrag(evt) { - // setup conversion functions - xa = Axes.getFromId(gd, shapeOptions.xref); - ya = Axes.getFromId(gd, shapeOptions.yref); + function createLineDragHandles() { + var minSensoryWidth = 10, + sensoryWidth = Math.max(shapeOptions.line.width, minSensoryWidth); + + // Helper shapes group + // Note that by setting the `data-index` attr, it is ensured that + // the helper group is purged in this modules `draw` function + var g = shapeLayer.append('g') + .attr('data-index', index); + + // Helper path for moving + g.append('path') + .attr('d', shapePath.attr('d')) + .style({ + 'cursor': 'move', + 'stroke-width': sensoryWidth, + 'stroke-opacity': '0' // ensure not visible + }); + + // Helper circles for resizing + var circleStyle = { + 'fill-opacity': '0' // ensure not visible + }; + var circleRadius = sensoryWidth / 2 > minSensoryWidth ? sensoryWidth / 2 : minSensoryWidth; + + g.append('circle') + .attr({ + 'data-line-point': 'start-point', + 'cx': xPixelSized ? x2p(shapeOptions.xanchor) + shapeOptions.x0 : x2p(shapeOptions.x0), + 'cy': yPixelSized ? y2p(shapeOptions.yanchor) - shapeOptions.y0 : y2p(shapeOptions.y0), + 'r': circleRadius + }) + .style(circleStyle) + .classed('cursor-grab', true); + + g.append('circle') + .attr({ + 'data-line-point': 'end-point', + 'cx': xPixelSized ? x2p(shapeOptions.xanchor) + shapeOptions.x1 : x2p(shapeOptions.x1), + 'cy': yPixelSized ? y2p(shapeOptions.yanchor) - shapeOptions.y1 : y2p(shapeOptions.y1), + 'r': circleRadius + }) + .style(circleStyle) + .classed('cursor-grab', true); + + return g; + } - x2p = helpers.getDataToPixel(gd, xa); - y2p = helpers.getDataToPixel(gd, ya, true); - p2x = helpers.getPixelToData(gd, xa); - p2y = helpers.getPixelToData(gd, ya, true); + function updateDragMode(evt) { + if(isLine) { + if(evt.target.tagName === 'path') { + dragMode = 'move'; + } else { + dragMode = evt.target.attributes['data-line-point'].value === 'start-point' ? + 'resize-over-start-point' : 'resize-over-end-point'; + } + } else { + // 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, + h = dragBBox.bottom - dragBBox.top, + x = evt.clientX - dragBBox.left, + y = evt.clientY - dragBBox.top, + cursor = (!isPath && w > MINWIDTH && h > MINHEIGHT && !evt.shiftKey) ? + dragElement.getCursor(x / w, 1 - y / h) : + 'move'; + + setCursor(shapePath, cursor); + + // possible values 'move', 'sw', 'w', 'se', 'e', 'ne', 'n', 'nw' and 'w' + dragMode = cursor.split('-')[0]; + } + } + function startDrag(evt) { // setup update strings and initial values var astr = 'shapes[' + index + ']'; @@ -231,14 +297,24 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { // setup dragMode and the corresponding handler updateDragMode(evt); + renderVisualCues(shapeLayer, shapeOptions); + deactivateClipPathTemporarily(shapePath, shapeOptions, gd); dragOptions.moveFn = (dragMode === 'move') ? moveShape : resizeShape; } function endDrag() { setCursor(shapePath); + removeVisualCues(shapeLayer); + + // Don't rely on clipPath being activated during re-layout + setClipPath(shapePath, gd, shapeOptions); Registry.call('relayout', gd, update); } + function abortDrag() { + removeVisualCues(shapeLayer); + } + function moveShape(dx, dy) { if(shapeOptions.type === 'path') { var noOp = function(coord) { return coord; }, @@ -279,11 +355,12 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { } shapePath.attr('d', getPathString(gd, shapeOptions)); + renderVisualCues(shapeLayer, shapeOptions); } function resizeShape(dx, dy) { - if(shapeOptions.type === 'path') { - // TODO: implement path resize + if(isPath) { + // TODO: implement path resize, don't forget to update dragMode code var noOp = function(coord) { return coord; }, moveX = noOp, moveY = noOp; @@ -305,6 +382,19 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { shapeOptions.path = movePath(pathIn, moveX, moveY); update[astrPath] = shapeOptions.path; } + else if(isLine) { + if(dragMode === 'resize-over-start-point') { + var newX0 = x0 + dx; + var newY0 = yPixelSized ? y0 - dy : y0 + dy; + update[astrX0] = shapeOptions.x0 = xPixelSized ? newX0 : p2x(newX0); + update[astrY0] = shapeOptions.y0 = yPixelSized ? newY0 : p2y(newY0); + } else if(dragMode === 'resize-over-end-point') { + var newX1 = x1 + dx; + var newY1 = yPixelSized ? y1 - dy : y1 + dy; + update[astrX1] = shapeOptions.x1 = xPixelSized ? newX1 : p2x(newX1); + update[astrY1] = shapeOptions.y1 = yPixelSized ? newY1 : p2y(newY1); + } + } else { var newN = (~dragMode.indexOf('n')) ? n0 + dy : n0, newS = (~dragMode.indexOf('s')) ? s0 + dy : s0, @@ -330,6 +420,87 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { } shapePath.attr('d', getPathString(gd, shapeOptions)); + renderVisualCues(shapeLayer, shapeOptions); + } + + function renderVisualCues(shapeLayer, shapeOptions) { + if(xPixelSized || yPixelSized) { + renderAnchor(); + } + + function renderAnchor() { + var isNotPath = shapeOptions.type !== 'path'; + + // d3 join with dummy data to satisfy d3 data-binding + var visualCues = shapeLayer.selectAll('.visual-cue').data([0]); + + // Enter + var strokeWidth = 1; + visualCues.enter() + .append('path') + .attr({ + 'fill': '#fff', + 'fill-rule': 'evenodd', + 'stroke': '#000', + 'stroke-width': strokeWidth + }) + .classed('visual-cue', true); + + // Update + var posX = x2p( + xPixelSized ? + shapeOptions.xanchor : + Lib.midRange( + isNotPath ? + [shapeOptions.x0, shapeOptions.x1] : + helpers.extractPathCoords(shapeOptions.path, constants.paramIsX)) + ); + var posY = y2p( + yPixelSized ? + shapeOptions.yanchor : + Lib.midRange( + isNotPath ? + [shapeOptions.y0, shapeOptions.y1] : + helpers.extractPathCoords(shapeOptions.path, constants.paramIsY)) + ); + + posX = helpers.roundPositionForSharpStrokeRendering(posX, strokeWidth); + posY = helpers.roundPositionForSharpStrokeRendering(posY, strokeWidth); + + if(xPixelSized && yPixelSized) { + var crossPath = 'M' + (posX - 1 - strokeWidth) + ',' + (posY - 1 - strokeWidth) + + 'h-8v2h8 v8h2v-8 h8v-2h-8 v-8h-2 Z'; + visualCues.attr('d', crossPath); + } else if(xPixelSized) { + var vBarPath = 'M' + (posX - 1 - strokeWidth) + ',' + (posY - 9 - strokeWidth) + + 'v18 h2 v-18 Z'; + visualCues.attr('d', vBarPath); + } else { + var hBarPath = 'M' + (posX - 9 - strokeWidth) + ',' + (posY - 1 - strokeWidth) + + 'h18 v2 h-18 Z'; + visualCues.attr('d', hBarPath); + } + } + } + + function removeVisualCues(shapeLayer) { + shapeLayer.selectAll('.visual-cue').remove(); + } + + function deactivateClipPathTemporarily(shapePath, shapeOptions, gd) { + var xref = shapeOptions.xref, + yref = shapeOptions.yref, + xa = Axes.getFromId(gd, xref), + ya = Axes.getFromId(gd, yref); + + var clipAxes = ''; + if(xref !== 'paper' && !xa.autorange) clipAxes += xref; + if(yref !== 'paper' && !ya.autorange) clipAxes += yref; + + shapePath.call(Drawing.setClipUrl, clipAxes ? + 'clip' + gd._fullLayout._uid + clipAxes : + null + ); } } diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js index c0b27149f11..aac463393d0 100644 --- a/src/components/shapes/helpers.js +++ b/src/components/shapes/helpers.js @@ -9,6 +9,10 @@ 'use strict'; +var constants = require('./constants'); + +var Lib = require('../../lib'); + // special position conversion functions... category axis positions can't be // specified by their data values, because they don't make a continuous mapping. // so these have to be specified in terms of the category serial numbers, @@ -37,6 +41,23 @@ exports.encodeDate = function(convertToDate) { return function(v) { return convertToDate(v).replace(' ', '_'); }; }; +exports.extractPathCoords = function(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(Lib.cleanNumber(params[relevantParamIdx])); + }); + + return extractedCoordinates; +}; + exports.getDataToPixel = function(gd, axis, isVertical) { var gs = gd._fullLayout._size, dataToPixel; @@ -77,3 +98,26 @@ exports.getPixelToData = function(gd, axis, isVertical) { return pixelToData; }; + +/** + * Based on the given stroke width, rounds the passed + * position value to represent either a full or half pixel. + * + * In case of an odd stroke width (e.g. 1), this measure ensures + * that a stroke positioned at the returned position isn't rendered + * blurry due to anti-aliasing. + * + * In case of an even stroke width (e.g. 2), this measure ensures + * that the position value is transformed to a full pixel value + * so that anti-aliasing doesn't take effect either. + * + * @param {number} pos The raw position value to be transformed + * @param {number} strokeWidth The stroke width + * @returns {number} either an integer or a .5 decimal number + */ +exports.roundPositionForSharpStrokeRendering = function(pos, strokeWidth) { + var strokeWidthIsOdd = Math.round(strokeWidth % 2) === 1; + var posValAsInt = Math.round(pos); + + return strokeWidthIsOdd ? posValAsInt + 0.5 : posValAsInt; +}; diff --git a/src/css/_cursor.scss b/src/css/_cursor.scss index 6ebe9984aa8..5b874ea0220 100644 --- a/src/css/_cursor.scss +++ b/src/css/_cursor.scss @@ -14,3 +14,7 @@ .cursor-nw-resize { cursor: nw-resize; } .cursor-n-resize { cursor: n-resize; } .cursor-ne-resize { cursor: ne-resize; } +.cursor-grab { + cursor: -webkit-grab; // For Chrome + cursor: grab; +} \ No newline at end of file diff --git a/src/lib/index.js b/src/lib/index.js index 75354fd4972..9c2b4b03cb1 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -67,6 +67,7 @@ var statsModule = require('./stats'); lib.aggNums = statsModule.aggNums; lib.len = statsModule.len; lib.mean = statsModule.mean; +lib.midRange = statsModule.midRange; lib.variance = statsModule.variance; lib.stdev = statsModule.stdev; lib.interp = statsModule.interp; diff --git a/src/lib/stats.js b/src/lib/stats.js index a717f7b49bd..71c23d70375 100644 --- a/src/lib/stats.js +++ b/src/lib/stats.js @@ -56,6 +56,11 @@ exports.mean = function(data, len) { return exports.aggNums(function(a, b) { return a + b; }, 0, data) / len; }; +exports.midRange = function(numArr) { + if(numArr === undefined || numArr.length === 0) return undefined; + return (exports.aggNums(Math.max, null, numArr) + exports.aggNums(Math.min, null, numArr)) / 2; +}; + exports.variance = function(data, len, mean) { if(!len) len = exports.len(data); if(!isNumeric(mean)) mean = exports.mean(data, len); diff --git a/test/image/baselines/fixed_size_shapes.png b/test/image/baselines/shapes_fixed_size.png similarity index 100% rename from test/image/baselines/fixed_size_shapes.png rename to test/image/baselines/shapes_fixed_size.png diff --git a/test/image/baselines/shapes_move-and-reshape-lines.png b/test/image/baselines/shapes_move-and-reshape-lines.png new file mode 100644 index 00000000000..2bb1df1c0de Binary files /dev/null and b/test/image/baselines/shapes_move-and-reshape-lines.png differ diff --git a/test/image/mocks/fixed_size_shapes.json b/test/image/mocks/shapes_fixed_size.json similarity index 100% rename from test/image/mocks/fixed_size_shapes.json rename to test/image/mocks/shapes_fixed_size.json diff --git a/test/image/mocks/shapes_move-and-reshape-lines.json b/test/image/mocks/shapes_move-and-reshape-lines.json new file mode 100644 index 00000000000..5d18a389ad7 --- /dev/null +++ b/test/image/mocks/shapes_move-and-reshape-lines.json @@ -0,0 +1,226 @@ +{ + "data": [ + { + "x": [1], + "y": [1] + }, + { + "x": [1,3,4], + "y": ["Giraffes","Apes","Zebras"], + "xaxis": "x2", + "yaxis": "y2" + } + ], + "layout": { + "title": "Editable line shapes", + "autosize": false, + "width": 1000, + "height": 800, + "xaxis": { + "domain": [0,0.45], + "anchor": "y" + }, + "yaxis": { + "domain": [0.55,0.92], + "type": "log", + "anchor": "x" + }, + "xaxis2": { + "domain":[0.50,0.95], + "anchor": "y2" + }, + "yaxis2": { + "domain": [0.55,0.92], + "type": "categorical", + "anchor": "x2" + }, + "xaxis3": { + "domain":[0,0.45], + "range": ["2018-04-01", "2018-04-30"], + "type": "date", + "anchor": "y3" + }, + "yaxis3": { + "domain": [0,0.37], + "anchor": "x3" + }, + "xaxis4": { + "domain": [0.50,0.95], + "anchor": "y4" + }, + "yaxis4": { + "domain": [0,0.37], + "type": "linear", + "anchor": "x4" + }, + "annotations": [ + { + "text": "Fixed and log scaled", "xref": "paper", "yref": "paper", "x": 0, "y": 1, + "xanchor": "left", "showarrow": false, "font": {"size": 24} + }, + { + "text": "Fixed and categorical scaled", "xref": "paper", "yref": "paper", "x": 0.5, "y": 1, + "xanchor": "left", "showarrow": false, "font": {"size": 24} + }, + { + "text": "Fixed and date scaled", "xref": "paper", "yref": "paper", "x": 0, "y": 0.42, + "xanchor": "left", "showarrow": false, "font": {"size": 24} + }, + { + "text": "Thick line shapes", "xref": "paper", "yref": "paper", "x": 0.5, "y": 0.42, + "xanchor": "left", "showarrow": false, "font": {"size": 24} + }, + { + "text": "Green is fixed size", "xref": "x4", "yref": "y4", "x": 1, "y": 2, + "showarrow": true, "font": {"size": 14, "color": "rgba(96, 171, 50, 1)"} + }, + { + "text": "Violet is data scaled", "xref": "x4", "yref": "y4", "x": 2.1, "y": 2.1, + "showarrow": true, "font": {"size": 14, "color": "rgba(96, 50, 171, 1)"}, + "ax": 40 + }, + { + "text": "Brown is paper scaled", "xref": "paper", "yref": "paper", "x": 0.9, "y": 0.9, + "showarrow": true, "font": {"size": 14, "color": "rgba(171, 96, 50, 1)"}, + "ax": 40, "ay": 50 + } + ], + "shapes": [ + { + "type": "line", + "xref": "x", + "xsizemode": "pixel", + "yref": "y", + "ysizemode": "pixel", + "xanchor": 0, + "yanchor": 2, + "x0": 0, + "x1": 60, + "y0": 0, + "y1": 40, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "line", + "xref": "x", + "yref": "y", + "x0": 0, + "x1": 4, + "y0": 1, + "y1": 10, + "line": { + "color": "rgba(96, 50, 171, 1)" + } + }, + { + "type": "line", + "xref": "x2", + "xsizemode": "pixel", + "yref": "y2", + "ysizemode": "pixel", + "xanchor": 2, + "yanchor": 2, + "x0": 0, + "x1": 60, + "y0": 0, + "y1": 40, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "line", + "xref": "x2", + "xsizemode": "scaled", + "yref": "y2", + "ysizemode": "scaled", + "x0": 0, + "x1": 5, + "y0": "Zebras", + "y1": "Apes", + "line": { + "color": "rgba(96, 50, 171, 1)" + } + }, + { + "type": "line", + "xref": "x3", + "xsizemode": "pixel", + "yref": "y3", + "ysizemode": "pixel", + "xanchor": "2018-04-15", + "yanchor": 2, + "x0": 0, + "x1": 60, + "y0": 0, + "y1": 40, + "line": { + "color": "rgba(96, 171, 50, 1)" + } + }, + { + "type": "line", + "xref": "x3", + "xsizemode": "scaled", + "yref": "y3", + "ysizemode": "scaled", + "x0": "2018-04-05", + "x1": "2018-04-20", + "y0": 0, + "y1": 40, + "line": { + "color": "rgba(96, 50, 171, 1)" + } + }, + { + "type": "line", + "xref": "x4", + "xsizemode": "pixel", + "yref": "y4", + "ysizemode": "pixel", + "xanchor": 1, + "yanchor": 2, + "x0": 0, + "x1": 120, + "y0": 0, + "y1": -40, + "line": { + "color": "rgba(96, 171, 50, 1)", + "width": 30 + } + }, + { + "type": "line", + "xref": "x4", + "xsizemode": "scaled", + "yref": "y4", + "ysizemode": "scaled", + "x0": 1, + "x1": 3, + "y0": 3, + "y1": 1, + "line": { + "color": "rgba(96, 50, 171, 1)", + "width": 30 + } + }, + { + "type": "line", + "xref": "paper", + "yref": "paper", + "x0": 0, + "y0": 0, + "x1": 1, + "y1": 1, + "line": { + "color": "rgba(171, 96, 50, 1)" + } + } + ] + }, + "config": { + "editable": "true" + } +} diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 62db6868a9f..dffebe8ae9a 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -108,12 +108,12 @@ describe('Test lib.js:', function() { }); describe('mean() should', function() { - it('toss out non-numerics (strings):', function() { + it('toss out non-numerics (strings)', function() { var input = [1, 2, 'apple', 'orange'], res = Lib.mean(input); expect(res).toEqual(1.5); }); - it('toss out non-numerics (NaN):', function() { + it('toss out non-numerics (NaN)', function() { var input = [1, 2, NaN], res = Lib.mean(input); expect(res).toEqual(1.5); @@ -125,6 +125,34 @@ describe('Test lib.js:', function() { }); }); + describe('midRange() should', function() { + it('should calculate the arithmetic mean of the maximum and minimum value of a given array', function() { + var input = [1, 5.5, 6, 15, 10, 13], + res = Lib.midRange(input); + expect(res).toEqual(8); + }); + it('toss out non-numerics (strings)', function() { + var input = [1, 2, 'apple', 'orange'], + res = Lib.midRange(input); + expect(res).toEqual(1.5); + }); + it('toss out non-numerics (NaN)', function() { + var input = [1, 2, NaN], + res = Lib.midRange(input); + expect(res).toEqual(1.5); + }); + it('should be able to deal with array of length 1', function() { + var input = [10], + res = Lib.midRange(input); + expect(res).toEqual(10); + }); + it('should return undefined for an empty array', function() { + var input = [], + res = Lib.midRange(input); + expect(res).toBeUndefined(); + }); + }); + describe('variance() should', function() { it('return 0 on input [2, 2, 2, 2, 2]:', function() { var input = [2, 2, 2, 2], diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index 7463db6f01d..66aa2c53dc6 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -30,6 +30,22 @@ var dyToShrinkHeight = { n: 10, s: -10, w: 0, e: 0, nw: 10, se: -10, ne: 10, sw: 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 }; +// Helper functions +function getMoveLineDragElement(index) { + index = index || 0; + return d3.selectAll('.shapelayer g[data-index="' + index + '"] path').node(); +} + +function getResizeLineOverStartPointElement(index) { + index = index || 0; + return d3.selectAll('.shapelayer g[data-index="' + index + '"] circle[data-line-point="start-point"]').node(); +} + +function getResizeLineOverEndPointElement(index) { + index = index || 0; + return d3.selectAll('.shapelayer g[data-index="' + index + '"] circle[data-line-point="end-point"]').node(); +} + describe('Test shapes defaults:', function() { 'use strict'; @@ -1024,7 +1040,9 @@ describe('A fixed size shape', function() { return combinations; } - var shapeAndResizeTypes = combinations(shapeTypes, resizeTypes); + // Only rect and circle because (i) path isn't yet resizable + // and (ii) line has a different resize behavior. + var shapeAndResizeTypes = combinations([{type: 'rect'}, {type: 'circle'}], resizeTypes); shapeAndResizeTypes.forEach(function(testCase) { describe('@flaky of type ' + testCase.type + ' can be ' + testCase.resizeDisplayName, function() { resizeDirections.forEach(function(direction) { @@ -1056,6 +1074,81 @@ describe('A fixed size shape', function() { }); }); + describe('@flaky of type line', function() { + beforeEach(function() { + layout.shapes[0].type = 'line'; + layout.shapes[0].yanchor = 3; + + }); + + it('can be moved by dragging the middle', function(done) { + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + var shapeNodeBeforeDrag = getFirstShapeNode(); + var bBoxBeforeDrag = shapeNodeBeforeDrag.getBoundingClientRect(); + + var dragSensitiveElement = getMoveLineDragElement(0); + drag(dragSensitiveElement, 10, -10) + .then(function() { + var shapeNodeAfterDrag = getFirstShapeNode(); + var bBoxAfterDrag = shapeNodeAfterDrag.getBoundingClientRect(); + + assertShapeSize(shapeNodeAfterDrag, 25, 25); + expect(bBoxAfterDrag.left).toBe(bBoxBeforeDrag.left + 10); + expect(bBoxAfterDrag.top).toBe(bBoxBeforeDrag.top - 10); + + done(); + }); + }); + }); + + it('can be resized by dragging the start point', function(done) { + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + var shapeNodeBeforeDrag = getFirstShapeNode(); + var bBoxBeforeDrag = shapeNodeBeforeDrag.getBoundingClientRect(); + + var dragSensitiveElement = getResizeLineOverStartPointElement(); + drag(dragSensitiveElement, 50, -10) + .then(function() { + var shapeNodeAfterDrag = getFirstShapeNode(); + var bBoxAfterDrag = shapeNodeAfterDrag.getBoundingClientRect(); + + assertShapeSize(shapeNodeAfterDrag, 25, 35); + expect(bBoxAfterDrag.top).toBe(bBoxBeforeDrag.top - 10, 'top'); + expect(bBoxAfterDrag.right).toBe(bBoxBeforeDrag.right + 25, 'right'); + expect(bBoxAfterDrag.bottom).toBe(bBoxBeforeDrag.bottom, 'bottom'); + expect(bBoxAfterDrag.left).toBe(bBoxBeforeDrag.left + 25, 'left'); + + done(); + }); + }); + }); + + it('can be resized by dragging the end point', function(done) { + Plotly.plot(gd, data, layout, {editable: true}) + .then(function() { + var shapeNodeBeforeDrag = getFirstShapeNode(); + var bBoxBeforeDrag = shapeNodeBeforeDrag.getBoundingClientRect(); + + var dragSensitiveElement = getResizeLineOverEndPointElement(); + drag(dragSensitiveElement, 50, -10) + .then(function() { + var shapeNodeAfterDrag = getFirstShapeNode(); + var bBoxAfterDrag = shapeNodeAfterDrag.getBoundingClientRect(); + + assertShapeSize(shapeNodeAfterDrag, 75, 15); + expect(bBoxAfterDrag.top).toBe(bBoxBeforeDrag.top, 'top'); + expect(bBoxAfterDrag.right).toBe(bBoxBeforeDrag.right + 50, 'right'); + expect(bBoxAfterDrag.bottom).toBe(bBoxBeforeDrag.bottom - 10, 'bottom'); + expect(bBoxAfterDrag.left).toBe(bBoxBeforeDrag.left, 'left'); + + done(); + }); + }); + }); + }); + describe('is expanding an auto-ranging x-axis', function() { var sizeVariants = [ {x0: 5, x1: 25}, @@ -1187,8 +1280,8 @@ describe('@flaky Test shapes', function() { ]; testCases.forEach(function(testCase) { - it(testCase.title + 'should be draggable', function(done) { - setupLayout(testCase); + it(testCase.title + ' should be draggable', function(done) { + setupLayout(testCase, [{type: 'line'}, {type: 'rect'}, {type: 'circle'}, {type: 'path'}]); testDragEachShape(done); }); }); @@ -1196,16 +1289,28 @@ describe('@flaky Test shapes', function() { testCases.forEach(function(testCase) { resizeDirections.forEach(function(direction) { var testTitle = testCase.title + - 'should be resizeable over direction ' + + ' should be resizeable over direction ' + direction; it(testTitle, function(done) { - setupLayout(testCase); + // Exclude line because it has a different resize behavior + setupLayout(testCase, [{type: 'rect'}, {type: 'circle'}, {type: 'path'}]); testResizeEachShape(direction, done); }); }); }); - function setupLayout(testCase) { + testCases.forEach(function(testCase) { + ['start', 'end'].forEach(function(linePoint) { + var testTitle = 'Line shape ' + testCase.title + + ' should be resizable by dragging the ' + linePoint + ' point'; + it(testTitle, function(done) { + setupLayout(testCase, [{type: 'line'}]); + testLineResize(linePoint, done); + }); + }); + }); + + function setupLayout(testCase, layoutShapes) { Lib.extendDeep(layout, testCase); var xrange = testCase.xaxis ? testCase.xaxis.range : [0.25, 0.75], @@ -1241,26 +1346,19 @@ describe('@flaky Test shapes', function() { x1y1 = x1 + ',' + y1, x1y0 = x1 + ',' + y0; - var layoutShapes = [ - { type: 'line' }, - { type: 'rect' }, - { type: 'circle' }, - {} // path - ]; - layoutShapes.forEach(function(s) { s.xref = xref; s.yref = yref; - if(s.type) { + if(s.type === 'path') { + s.path = 'M' + x0y0 + 'L' + x1y1 + 'L' + x1y0 + 'Z'; + } + else { s.x0 = x0; s.x1 = x1; s.y0 = y0; s.y1 = y1; } - else { - s.path = 'M' + x0y0 + 'L' + x1y1 + 'L' + x1y0 + 'Z'; - } }); layout.shapes = layoutShapes; @@ -1277,7 +1375,9 @@ describe('@flaky Test shapes', function() { var dx = 100, dy = 100; promise = promise.then(function() { - var node = getShapeNode(index); + var node = layoutShape.type === 'line' ? + getMoveLineDragElement(index) : + getShapeNode(index); expect(node).not.toBe(null); return (layoutShape.path) ? @@ -1294,7 +1394,9 @@ describe('@flaky Test shapes', function() { var layoutShapes = gd.layout.shapes; - expect(layoutShapes.length).toBe(4); // line, rect, circle and path + // Only rect, circle and path. + // Hint: line has different resize behavior. + expect(layoutShapes.length).toBe(3); layoutShapes.forEach(function(layoutShape, index) { if(layoutShape.path) return; @@ -1431,6 +1533,38 @@ describe('@flaky Test shapes', function() { }); } + function testLineResize(pointToMove, done) { + var promise = Plotly.plot(gd, data, layout, config); + var layoutShape = gd.layout.shapes[0]; + + var xa = Axes.getFromId(gd, layoutShape.xref), + ya = Axes.getFromId(gd, layoutShape.yref), + x2p = helpers.getDataToPixel(gd, xa), + y2p = helpers.getDataToPixel(gd, ya, true); + + + promise = promise.then(function() { + var dragHandle = pointToMove === 'start' ? + getResizeLineOverStartPointElement() : + getResizeLineOverEndPointElement(); + + var initialCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); + return drag(dragHandle, 10, 10).then(function() { + var finalCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); + + if(pointToMove === 'start') { + expect(finalCoordinates.x0 - initialCoordinates.x0).toBeCloseTo(10); + expect(finalCoordinates.y0 - initialCoordinates.y0).toBeCloseTo(10); + } else { + expect(finalCoordinates.x1 - initialCoordinates.x1).toBeCloseTo(10); + expect(finalCoordinates.y1 - initialCoordinates.y1).toBeCloseTo(10); + } + }); + }); + + return promise.then(done); + } + function getPathCoordinates(pathString, x2p, y2p) { var coordinates = [];