diff --git a/package.json b/package.json index 5e51a3b4e9f..7fde6cd1898 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "ndarray": "^1.0.18", "ndarray-fill": "^1.0.2", "ndarray-homography": "^1.0.0", + "parse-svg-path": "^0.1.2", "point-cluster": "^3.1.8", "polybooljs": "^1.2.0", "regl": "^1.3.11", diff --git a/src/components/dragelement/helpers.js b/src/components/dragelement/helpers.js new file mode 100644 index 00000000000..ce10ba5b9f2 --- /dev/null +++ b/src/components/dragelement/helpers.js @@ -0,0 +1,57 @@ +/** +* Copyright 2012-2020, 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'; + +exports.selectMode = function(dragmode) { + return ( + dragmode === 'lasso' || + dragmode === 'select' + ); +}; + +exports.drawMode = function(dragmode) { + return ( + dragmode === 'drawclosedpath' || + dragmode === 'drawopenpath' || + dragmode === 'drawline' || + dragmode === 'drawrect' || + dragmode === 'drawcircle' + ); +}; + +exports.openMode = function(dragmode) { + return ( + dragmode === 'drawline' || + dragmode === 'drawopenpath' + ); +}; + +exports.rectMode = function(dragmode) { + return ( + dragmode === 'select' || + dragmode === 'drawline' || + dragmode === 'drawrect' || + dragmode === 'drawcircle' + ); +}; + +exports.freeMode = function(dragmode) { + return ( + dragmode === 'lasso' || + dragmode === 'drawclosedpath' || + dragmode === 'drawopenpath' + ); +}; + +exports.selectingOrDrawing = function(dragmode) { + return ( + exports.freeMode(dragmode) || + exports.rectMode(dragmode) + ); +}; diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index 64935ed59fb..8e6735f092f 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -44,7 +44,20 @@ module.exports = { dragmode: { valType: 'enumerated', role: 'info', - values: ['zoom', 'pan', 'select', 'lasso', 'orbit', 'turntable', false], + values: [ + 'zoom', + 'pan', + 'select', + 'lasso', + 'drawclosedpath', + 'drawopenpath', + 'drawline', + 'drawrect', + 'drawcircle', + 'orbit', + 'turntable', + false + ], dflt: 'zoom', editType: 'modebar', description: [ @@ -161,9 +174,9 @@ module.exports = { values: ['h', 'v', 'd', 'any'], dflt: 'any', description: [ - 'When "dragmode" is set to "select", this limits the selection of the drag to', - 'horizontal, vertical or diagonal. "h" only allows horizontal selection,', - '"v" only vertical, "d" only diagonal and "any" sets no limit.' + 'When `dragmode` is set to *select*, this limits the selection of the drag to', + 'horizontal, vertical or diagonal. *h* only allows horizontal selection,', + '*v* only vertical, *d* only diagonal and *any* sets no limit.' ].join(' '), editType: 'none' } diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 0e21d292802..069e8d0958d 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -11,9 +11,9 @@ var Registry = require('../../registry'); var Plots = require('../../plots/plots'); var axisIds = require('../../plots/cartesian/axis_ids'); -var Lib = require('../../lib'); var Icons = require('../../fonts/ploticon'); - +var eraseActiveShape = require('../shapes/draw').eraseActiveShape; +var Lib = require('../../lib'); var _ = Lib._; var modeBarButtons = module.exports = {}; @@ -134,6 +134,58 @@ modeBarButtons.lasso2d = { click: handleCartesian }; +modeBarButtons.drawclosedpath = { + name: 'drawclosedpath', + title: function(gd) { return _(gd, 'Draw closed freeform'); }, + attr: 'dragmode', + val: 'drawclosedpath', + icon: Icons.drawclosedpath, + click: handleCartesian +}; + +modeBarButtons.drawopenpath = { + name: 'drawopenpath', + title: function(gd) { return _(gd, 'Draw open freeform'); }, + attr: 'dragmode', + val: 'drawopenpath', + icon: Icons.drawopenpath, + click: handleCartesian +}; + +modeBarButtons.drawline = { + name: 'drawline', + title: function(gd) { return _(gd, 'Draw line'); }, + attr: 'dragmode', + val: 'drawline', + icon: Icons.drawline, + click: handleCartesian +}; + +modeBarButtons.drawrect = { + name: 'drawrect', + title: function(gd) { return _(gd, 'Draw rectangle'); }, + attr: 'dragmode', + val: 'drawrect', + icon: Icons.drawrect, + click: handleCartesian +}; + +modeBarButtons.drawcircle = { + name: 'drawcircle', + title: function(gd) { return _(gd, 'Draw circle'); }, + attr: 'dragmode', + val: 'drawcircle', + icon: Icons.drawcircle, + click: handleCartesian +}; + +modeBarButtons.eraseshape = { + name: 'eraseshape', + title: function(gd) { return _(gd, 'Erase active shape'); }, + icon: Icons.eraseshape, + click: eraseActiveShape +}; + modeBarButtons.zoomIn2d = { name: 'zoomIn2d', title: function(gd) { return _(gd, 'Zoom in'); }, diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js index 267f2117392..3198f0b66a2 100644 --- a/src/components/modebar/manage.js +++ b/src/components/modebar/manage.js @@ -67,6 +67,15 @@ module.exports = function manageModeBar(gd) { else fullLayout._modeBar = createModeBar(gd, buttonGroups); }; +var DRAW_MODES = [ + 'drawline', + 'drawopenpath', + 'drawclosedpath', + 'drawcircle', + 'drawrect', + 'eraseshape' +]; + // logic behind which buttons are displayed by default function getButtonGroups(gd) { var fullLayout = gd._fullLayout; @@ -170,6 +179,25 @@ function getButtonGroups(gd) { dragModeGroup.push('select2d', 'lasso2d'); } + // accept pre-defined buttons as string + if(Array.isArray(buttonsToAdd)) { + var newList = []; + for(var i = 0; i < buttonsToAdd.length; i++) { + var b = buttonsToAdd[i]; + if(typeof b === 'string') { + if(DRAW_MODES.indexOf(b) !== -1) { + if( + fullLayout._has('mapbox') || // draw shapes in paper coordinate (could be improved in future to support data coordinate, when there is no pitch) + fullLayout._has('cartesian') // draw shapes in data coordinate + ) { + dragModeGroup.push(b); + } + } + } else newList.push(b); + } + buttonsToAdd = newList; + } + addGroup(dragModeGroup); addGroup(zoomGroup.concat(resetGroup)); addGroup(hoverGroup); diff --git a/src/components/shapes/attributes.js b/src/components/shapes/attributes.js index 013cc804fbe..1b8b0ff2665 100644 --- a/src/components/shapes/attributes.js +++ b/src/components/shapes/attributes.js @@ -235,8 +235,31 @@ module.exports = templatedArray('shape', { role: 'info', editType: 'arraydraw', description: [ - 'Sets the color filling the shape\'s interior.' + 'Sets the color filling the shape\'s interior. Only applies to closed shapes.' ].join(' ') }, + fillrule: { + valType: 'enumerated', + values: ['evenodd', 'nonzero'], + dflt: 'evenodd', + role: 'info', + editType: 'arraydraw', + description: [ + 'Determines which regions of complex paths constitute the interior.', + 'For more info please visit https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill-rule' + ].join(' ') + }, + editable: { + valType: 'boolean', + role: 'info', + dflt: false, + editType: 'calc+arraydraw', + description: [ + 'Determines whether the shape could be activated for edit or not.', + 'Has no effect when the older editable shapes mode is enabled via', + '`config.editable` or `config.edits.shapePosition`.' + ].join(' ') + }, + editType: 'arraydraw' }); diff --git a/src/components/shapes/defaults.js b/src/components/shapes/defaults.js index d11ee8eb604..85b18ad3463 100644 --- a/src/components/shapes/defaults.js +++ b/src/components/shapes/defaults.js @@ -30,18 +30,24 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) { } var visible = coerce('visible'); - if(!visible) return; + var path = coerce('path'); + var dfltType = path ? 'path' : 'rect'; + var shapeType = coerce('type', dfltType); + if(shapeOut.type !== 'path') delete shapeOut.path; + + coerce('editable'); coerce('layer'); coerce('opacity'); coerce('fillcolor'); - coerce('line.color'); - coerce('line.width'); - coerce('line.dash'); + coerce('fillrule'); + var lineWidth = coerce('line.width'); + if(lineWidth) { + coerce('line.color'); + coerce('line.dash'); + } - var dfltType = shapeIn.path ? 'path' : 'rect'; - var shapeType = coerce('type', dfltType); var xSizeMode = coerce('xsizemode'); var ySizeMode = coerce('ysizemode'); diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index c1541be4966..28e7f849f74 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -12,6 +12,12 @@ var Registry = require('../../registry'); var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); + +var readPaths = require('./draw_newshape/helpers').readPaths; +var displayOutlines = require('./draw_newshape/display_outlines'); + +var clearOutlineControllers = require('../../plots/cartesian/handle_outline').clearOutlineControllers; + var Color = require('../color'); var Drawing = require('../drawing'); var arrayEditor = require('../../plot_api/plot_template').arrayEditor; @@ -34,7 +40,8 @@ var helpers = require('./helpers'); module.exports = { draw: draw, - drawOne: drawOne + drawOne: drawOne, + eraseActiveShape: eraseActiveShape }; function draw(gd) { @@ -59,6 +66,15 @@ function draw(gd) { // return Plots.previousPromises(gd); } +function shouldSkipEdits(gd) { + return !!gd._fullLayout._drawing; +} + +function couldHaveActiveShape(gd) { + // for now keep config.editable: true as it was before shape-drawing PR + return !gd._context.edits.shapePosition; +} + function drawOne(gd, index) { // remove the existing shape if there is one. // because indices can change, we need to look in all shape layers @@ -66,7 +82,9 @@ function drawOne(gd, index) { .selectAll('.shapelayer [data-index="' + index + '"]') .remove(); - var options = gd._fullLayout.shapes[index] || {}; + var o = helpers.makeOptionsAndPlotinfo(gd, index); + var options = o.options; + var plotinfo = o.plotinfo; // this shape is gone - quit now after deleting it // TODO: use d3 idioms instead of deleting and redrawing every time @@ -77,8 +95,7 @@ function drawOne(gd, index) { } else if(options.xref === 'paper' || options.yref === 'paper') { drawShape(gd._fullLayout._shapeLowerLayer); } else { - var plotinfo = gd._fullLayout._plots[options.xref + options.yref]; - if(plotinfo) { + if(plotinfo._hadPlotinfo) { var mainPlot = plotinfo.mainplotinfo || plotinfo; drawShape(mainPlot.shapelayer); } else { @@ -90,23 +107,74 @@ function drawOne(gd, index) { } function drawShape(shapeLayer) { + var d = getPathString(gd, options); var attrs = { 'data-index': index, - 'fill-rule': 'evenodd', - d: getPathString(gd, options) + 'fill-rule': options.fillrule, + d: d }; + + var opacity = options.opacity; + var fillColor = options.fillcolor; var lineColor = options.line.width ? options.line.color : 'rgba(0,0,0,0)'; + var lineWidth = options.line.width; + var lineDash = options.line.dash; + + var isOpen = d[d.length - 1] !== 'Z'; + + var isActiveShape = couldHaveActiveShape(gd) && + options.editable && gd._fullLayout._activeShapeIndex === index; + + if(isActiveShape) { + fillColor = isOpen ? 'rgba(0,0,0,0)' : + gd._fullLayout.activeshape.fillcolor; + + opacity = gd._fullLayout.activeshape.opacity; + } var path = shapeLayer.append('path') .attr(attrs) - .style('opacity', options.opacity) + .style('opacity', opacity) .call(Color.stroke, lineColor) - .call(Color.fill, options.fillcolor) - .call(Drawing.dashLine, options.line.dash, options.line.width); + .call(Color.fill, fillColor) + .call(Drawing.dashLine, lineDash, lineWidth); setClipPath(path, gd, options); - if(gd._context.edits.shapePosition) setupDragElement(gd, path, options, index, shapeLayer); + var editHelpers; + if(isActiveShape || gd._context.edits.shapePosition) editHelpers = arrayEditor(gd.layout, 'shapes', options); + + if(isActiveShape) { + path.style({ + 'cursor': 'move', + }); + + var dragOptions = { + element: path.node(), + plotinfo: plotinfo, + gd: gd, + editHelpers: editHelpers, + isActiveShape: true // i.e. to enable controllers + }; + + var polygons = readPaths(d, gd); + // display polygons on the screen + displayOutlines(polygons, path, dragOptions); + } else { + if(gd._context.edits.shapePosition) { + setupDragElement(gd, path, options, index, shapeLayer, editHelpers); + } + + path.style('pointer-events', + !couldHaveActiveShape(gd) || ( + lineWidth < 2 || ( // not has a remarkable border + !isOpen && Color.opacity(fillColor) * opacity > 0.5 // not too transparent + ) + ) ? 'all' : 'stroke' + ); + } + + path.node().addEventListener('click', function() { return activateShape(gd, path); }); } } @@ -123,7 +191,7 @@ function setClipPath(shapePath, gd, shapeOptions) { ); } -function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { +function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHelpers) { var MINWIDTH = 10; var MINHEIGHT = 10; @@ -132,7 +200,6 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { var isLine = shapeOptions.type === 'line'; var isPath = shapeOptions.type === 'path'; - var editHelpers = arrayEditor(gd.layout, 'shapes', shapeOptions); var modifyItem = editHelpers.modifyItem; var x0, y0, x1, y1, xAnchor, yAnchor; @@ -188,7 +255,7 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { var circleStyle = { 'fill-opacity': '0' // ensure not visible }; - var circleRadius = sensoryWidth / 2 > minSensoryWidth ? sensoryWidth / 2 : minSensoryWidth; + var circleRadius = Math.max(sensoryWidth / 2, minSensoryWidth); g.append('circle') .attr({ @@ -214,6 +281,11 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { } function updateDragMode(evt) { + if(shouldSkipEdits(gd)) { + dragMode = null; + return; + } + if(isLine) { if(evt.target.tagName === 'path') { dragMode = 'move'; @@ -244,6 +316,8 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { } function startDrag(evt) { + if(shouldSkipEdits(gd)) return; + // setup update strings and initial values if(xPixelSized) { xAnchor = x2p(shapeOptions.xanchor); @@ -292,9 +366,12 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { renderVisualCues(shapeLayer, shapeOptions); deactivateClipPathTemporarily(shapePath, shapeOptions, gd); dragOptions.moveFn = (dragMode === 'move') ? moveShape : resizeShape; + dragOptions.altKey = evt.altKey; } function endDrag() { + if(shouldSkipEdits(gd)) return; + setCursor(shapePath); removeVisualCues(shapeLayer); @@ -304,6 +381,8 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { } function abortDrag() { + if(shouldSkipEdits(gd)) return; + removeVisualCues(shapeLayer); } @@ -383,20 +462,30 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { modifyItem('y1', shapeOptions.y1 = yPixelSized ? newY1 : p2y(newY1)); } } else { - var newN = (~dragMode.indexOf('n')) ? n0 + dy : n0; - var newS = (~dragMode.indexOf('s')) ? s0 + dy : s0; - var newW = (~dragMode.indexOf('w')) ? w0 + dx : w0; - var newE = (~dragMode.indexOf('e')) ? e0 + dx : e0; + var has = function(str) { return dragMode.indexOf(str) !== -1; }; + var hasN = has('n'); + var hasS = has('s'); + var hasW = has('w'); + var hasE = has('e'); - // 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; + var newN = hasN ? n0 + dy : n0; + var newS = hasS ? s0 + dy : s0; + var newW = hasW ? w0 + dx : w0; + var newE = hasE ? e0 + dx : e0; + + if(yPixelSized) { + // Do things in opposing direction for y-axis. + // Hint: for data-sized shapes the reversal of axis direction is done in p2y. + if(hasN) newN = n0 - dy; + if(hasS) 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)) { + if( + (!yPixelSized && newS - newN > MINHEIGHT) || + (yPixelSized && newN - newS > MINHEIGHT) + ) { modifyItem(optN, shapeOptions[optN] = yPixelSized ? newN : p2y(newN)); modifyItem(optS, shapeOptions[optS] = yPixelSized ? newS : p2y(newS)); } @@ -613,3 +702,55 @@ function movePath(pathIn, moveX, moveY) { return segmentType + paramString; }); } + +function activateShape(gd, path) { + if(!couldHaveActiveShape(gd)) return; + + var element = path.node(); + var id = +element.getAttribute('data-index'); + if(id >= 0) { + // deactivate if already active + if(id === gd._fullLayout._activeShapeIndex) { + deactivateShape(gd); + return; + } + + gd._fullLayout._activeShapeIndex = id; + gd._fullLayout._deactivateShape = deactivateShape; + draw(gd); + } +} + +function deactivateShape(gd) { + if(!couldHaveActiveShape(gd)) return; + + var id = gd._fullLayout._activeShapeIndex; + if(id >= 0) { + clearOutlineControllers(gd); + delete gd._fullLayout._activeShapeIndex; + draw(gd); + } +} + +function eraseActiveShape(gd) { + if(!couldHaveActiveShape(gd)) return; + + clearOutlineControllers(gd); + + var id = gd._fullLayout._activeShapeIndex; + var shapes = (gd.layout || {}).shapes || []; + if(id < shapes.length) { + var newShapes = []; + for(var q = 0; q < shapes.length; q++) { + if(q !== id) { + newShapes.push(shapes[q]); + } + } + + delete gd._fullLayout._activeShapeIndex; + + Registry.call('_guiRelayout', gd, { + shapes: newShapes + }); + } +} diff --git a/src/components/shapes/draw_newshape/attributes.js b/src/components/shapes/draw_newshape/attributes.js new file mode 100644 index 00000000000..90d6cb810e5 --- /dev/null +++ b/src/components/shapes/draw_newshape/attributes.js @@ -0,0 +1,120 @@ +/** +* Copyright 2012-2020, 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 dash = require('../../drawing/attributes').dash; +var extendFlat = require('../../../lib/extend').extendFlat; + +module.exports = { + newshape: { + line: { + color: { + valType: 'color', + editType: 'none', + role: 'info', + description: [ + 'Sets the line color.', + 'By default uses either dark grey or white', + 'to increase contrast with background color.' + ].join(' ') + }, + width: { + valType: 'number', + min: 0, + dflt: 4, + role: 'info', + editType: 'none', + description: 'Sets the line width (in px).' + }, + dash: extendFlat({}, dash, { + dflt: 'solid', + editType: 'none' + }), + role: 'info', + editType: 'none' + }, + fillcolor: { + valType: 'color', + dflt: 'rgba(0,0,0,0)', + role: 'info', + editType: 'none', + description: [ + 'Sets the color filling new shapes\' interior.', + 'Please note that if using a fillcolor with alpha greater than half,', + 'drag inside the active shape starts moving the shape underneath,', + 'otherwise a new shape could be started over.' + ].join(' ') + }, + fillrule: { + valType: 'enumerated', + values: ['evenodd', 'nonzero'], + dflt: 'evenodd', + role: 'info', + editType: 'none', + description: [ + 'Determines the path\'s interior.', + 'For more info please visit https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill-rule' + ].join(' ') + }, + opacity: { + valType: 'number', + min: 0, + max: 1, + dflt: 1, + role: 'info', + editType: 'none', + description: 'Sets the opacity of new shapes.' + }, + layer: { + valType: 'enumerated', + values: ['below', 'above'], + dflt: 'above', + role: 'info', + editType: 'none', + description: 'Specifies whether new shapes are drawn below or above traces.' + }, + drawdirection: { + valType: 'enumerated', + role: 'info', + values: ['ortho', 'horizontal', 'vertical', 'diagonal'], + dflt: 'diagonal', + editType: 'none', + description: [ + 'When `dragmode` is set to *drawrect*, *drawline* or *drawcircle*', + 'this limits the drag to be horizontal, vertical or diagonal.', + 'Using *diagonal* there is no limit e.g. in drawing lines in any direction.', + '*ortho* limits the draw to be either horizontal or vertical.', + '*horizontal* allows horizontal extend.', + '*vertical* allows vertical extend.' + ].join(' ') + }, + + editType: 'none' + }, + + activeshape: { + fillcolor: { + valType: 'color', + dflt: 'rgb(255,0,255)', + role: 'style', + editType: 'none', + description: 'Sets the color filling the active shape\' interior.' + }, + opacity: { + valType: 'number', + min: 0, + max: 1, + dflt: 0.5, + role: 'info', + editType: 'none', + description: 'Sets the opacity of the active shape.' + }, + editType: 'none' + } +}; diff --git a/src/components/shapes/draw_newshape/constants.js b/src/components/shapes/draw_newshape/constants.js new file mode 100644 index 00000000000..49b311ca675 --- /dev/null +++ b/src/components/shapes/draw_newshape/constants.js @@ -0,0 +1,22 @@ +/** +* Copyright 2012-2020, 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 CIRCLE_SIDES = 32; // should be divisible by 4 + +module.exports = { + CIRCLE_SIDES: CIRCLE_SIDES, + i000: 0, + i090: CIRCLE_SIDES / 4, + i180: CIRCLE_SIDES / 2, + i270: CIRCLE_SIDES / 4 * 3, + cos45: Math.cos(Math.PI / 4), + sin45: Math.sin(Math.PI / 4), + SQRT2: Math.sqrt(2) +}; diff --git a/src/components/shapes/draw_newshape/defaults.js b/src/components/shapes/draw_newshape/defaults.js new file mode 100644 index 00000000000..06b37bd1345 --- /dev/null +++ b/src/components/shapes/draw_newshape/defaults.js @@ -0,0 +1,30 @@ +/** +* Copyright 2012-2020, 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 Color = require('../../color'); + + +module.exports = function supplyDrawNewShapeDefaults(layoutIn, layoutOut, coerce) { + coerce('newshape.drawdirection'); + coerce('newshape.layer'); + coerce('newshape.fillcolor'); + coerce('newshape.fillrule'); + coerce('newshape.opacity'); + var newshapeLineWidth = coerce('newshape.line.width'); + if(newshapeLineWidth) { + var bgcolor = (layoutIn || {}).plot_bgcolor || '#FFF'; + coerce('newshape.line.color', Color.contrast(bgcolor)); + coerce('newshape.line.dash'); + } + + coerce('activeshape.fillcolor'); + coerce('activeshape.opacity'); +}; diff --git a/src/components/shapes/draw_newshape/display_outlines.js b/src/components/shapes/draw_newshape/display_outlines.js new file mode 100644 index 00000000000..7f5704438d0 --- /dev/null +++ b/src/components/shapes/draw_newshape/display_outlines.js @@ -0,0 +1,293 @@ +/** +* Copyright 2012-2020, 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 dragElement = require('../../dragelement'); +var dragHelpers = require('../../dragelement/helpers'); +var drawMode = dragHelpers.drawMode; + +var Registry = require('../../../registry'); + +var constants = require('./constants'); +var i000 = constants.i000; +var i090 = constants.i090; +var i180 = constants.i180; +var i270 = constants.i270; + +var handleOutline = require('../../../plots/cartesian/handle_outline'); +var clearOutlineControllers = handleOutline.clearOutlineControllers; + +var helpers = require('./helpers'); +var pointsShapeRectangle = helpers.pointsShapeRectangle; +var pointsShapeEllipse = helpers.pointsShapeEllipse; +var writePaths = helpers.writePaths; +var newShapes = require('./newshapes'); + +module.exports = function displayOutlines(polygons, outlines, dragOptions, nCalls) { + if(!nCalls) nCalls = 0; + + var gd = dragOptions.gd; + + function redraw() { + // recursive call + displayOutlines(polygons, outlines, dragOptions, nCalls++); + + if(pointsShapeEllipse(polygons[0])) { + update({redrawing: true}); + } + } + + function update(opts) { + dragOptions.isActiveShape = false; // i.e. to disable controllers + + var updateObject = newShapes(outlines, dragOptions); + if(Object.keys(updateObject).length) { + Registry.call((opts || {}).redrawing ? 'relayout' : '_guiRelayout', gd, updateObject); + } + } + + + var isActiveShape = dragOptions.isActiveShape; + var fullLayout = gd._fullLayout; + var zoomLayer = fullLayout._zoomlayer; + + var dragmode = dragOptions.dragmode; + var isDrawMode = drawMode(dragmode); + + if(isDrawMode) gd._fullLayout._drawing = true; + else if(gd._fullLayout._activeShapeIndex >= 0) clearOutlineControllers(gd); + + // make outline + outlines.attr('d', writePaths(polygons)); + + // add controllers + var vertexDragOptions; + var shapeDragOptions; + var indexI; // cell index + var indexJ; // vertex or cell-controller index + var copyPolygons; + + if(isActiveShape && !nCalls) { + copyPolygons = recordPositions([], polygons); + + var g = zoomLayer.append('g').attr('class', 'outline-controllers'); + addVertexControllers(g); + addShapeControllers(); + } + + function startDragVertex(evt) { + indexI = +evt.srcElement.getAttribute('data-i'); + indexJ = +evt.srcElement.getAttribute('data-j'); + + vertexDragOptions[indexI][indexJ].moveFn = moveVertexController; + } + + function moveVertexController(dx, dy) { + if(!polygons.length) return; + + var x0 = copyPolygons[indexI][indexJ][1]; + var y0 = copyPolygons[indexI][indexJ][2]; + + var cell = polygons[indexI]; + var len = cell.length; + if(pointsShapeRectangle(cell)) { + for(var q = 0; q < len; q++) { + if(q === indexJ) continue; + + // move other corners of rectangle + var pos = cell[q]; + + if(pos[1] === cell[indexJ][1]) { + pos[1] = x0 + dx; + } + + if(pos[2] === cell[indexJ][2]) { + pos[2] = y0 + dy; + } + } + // move the corner + cell[indexJ][1] = x0 + dx; + cell[indexJ][2] = y0 + dy; + + if(!pointsShapeRectangle(cell)) { + // reject result to rectangles with ensure areas + for(var j = 0; j < len; j++) { + for(var k = 0; k < cell[j].length; k++) { + cell[j][k] = copyPolygons[indexI][j][k]; + } + } + } + } else { // other polylines + cell[indexJ][1] = x0 + dx; + cell[indexJ][2] = y0 + dy; + } + + redraw(); + } + + function endDragVertexController() { + update(); + } + + function removeVertex() { + if(!polygons.length) return; + if(!polygons[indexI]) return; + if(!polygons[indexI].length) return; + + var newPolygon = []; + for(var j = 0; j < polygons[indexI].length; j++) { + if(j !== indexJ) { + newPolygon.push( + polygons[indexI][j] + ); + } + } + + if(newPolygon.length > 1 && !( + newPolygon.length === 2 && newPolygon[1][0] === 'Z') + ) { + if(indexJ === 0) { + newPolygon[0][0] = 'M'; + } + + polygons[indexI] = newPolygon; + + redraw(); + update(); + } + } + + function clickVertexController(numClicks, evt) { + if(numClicks === 2) { + indexI = +evt.srcElement.getAttribute('data-i'); + indexJ = +evt.srcElement.getAttribute('data-j'); + + var cell = polygons[indexI]; + if( + !pointsShapeRectangle(cell) && + !pointsShapeEllipse(cell) + ) { + removeVertex(); + } + } + } + + function addVertexControllers(g) { + vertexDragOptions = []; + + for(var i = 0; i < polygons.length; i++) { + var cell = polygons[i]; + + var onRect = pointsShapeRectangle(cell); + var onEllipse = !onRect && pointsShapeEllipse(cell); + + vertexDragOptions[i] = []; + for(var j = 0; j < cell.length; j++) { + if(cell[j][0] === 'Z') continue; + + if(onEllipse && + j !== i000 && + j !== i090 && + j !== i180 && + j !== i270 + ) { + continue; + } + + var x = cell[j][1]; + var y = cell[j][2]; + + var vertex = g.append('circle') + .classed('cursor-grab', true) + .attr('data-i', i) + .attr('data-j', j) + .attr('cx', x) + .attr('cy', y) + .attr('r', 4) + .style({ + 'mix-blend-mode': 'luminosity', + fill: 'black', + stroke: 'white', + 'stroke-width': 1 + }); + + vertexDragOptions[i][j] = { + element: vertex.node(), + gd: gd, + prepFn: startDragVertex, + doneFn: endDragVertexController, + clickFn: clickVertexController + }; + + dragElement.init(vertexDragOptions[i][j]); + } + } + } + + function moveShape(dx, dy) { + if(!polygons.length) return; + + for(var i = 0; i < polygons.length; i++) { + for(var j = 0; j < polygons[i].length; j++) { + for(var k = 0; k + 2 < polygons[i][j].length; k += 2) { + polygons[i][j][k + 1] = copyPolygons[i][j][k + 1] + dx; + polygons[i][j][k + 2] = copyPolygons[i][j][k + 2] + dy; + } + } + } + } + + function moveShapeController(dx, dy) { + moveShape(dx, dy); + + redraw(); + } + + function startDragShapeController(evt) { + indexI = +evt.srcElement.getAttribute('data-i'); + if(!indexI) indexI = 0; // ensure non-existing move button get zero index + + shapeDragOptions[indexI].moveFn = moveShapeController; + } + + function endDragShapeController() { + update(); + } + + function addShapeControllers() { + shapeDragOptions = []; + + if(!polygons.length) return; + + var i = 0; + shapeDragOptions[i] = { + element: outlines[0][0], + gd: gd, + prepFn: startDragShapeController, + doneFn: endDragShapeController + }; + + dragElement.init(shapeDragOptions[i]); + } +}; + +function recordPositions(polygonsOut, polygonsIn) { + for(var i = 0; i < polygonsIn.length; i++) { + var cell = polygonsIn[i]; + polygonsOut[i] = []; + for(var j = 0; j < cell.length; j++) { + polygonsOut[i][j] = []; + for(var k = 0; k < cell[j].length; k++) { + polygonsOut[i][j][k] = cell[j][k]; + } + } + } + return polygonsOut; +} diff --git a/src/components/shapes/draw_newshape/helpers.js b/src/components/shapes/draw_newshape/helpers.js new file mode 100644 index 00000000000..2d74f34f834 --- /dev/null +++ b/src/components/shapes/draw_newshape/helpers.js @@ -0,0 +1,336 @@ +/** +* Copyright 2012-2020, 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 parseSvgPath = require('parse-svg-path'); + +var constants = require('./constants'); +var CIRCLE_SIDES = constants.CIRCLE_SIDES; +var SQRT2 = constants.SQRT2; + +var cartesianHelpers = require('../../../plots/cartesian/helpers'); +var p2r = cartesianHelpers.p2r; +var r2p = cartesianHelpers.r2p; + +var iC = [0, 3, 4, 5, 6, 1, 2]; +var iQS = [0, 3, 4, 1, 2]; + +exports.writePaths = function(polygons) { + var nI = polygons.length; + if(!nI) return 'M0,0Z'; + + var str = ''; + for(var i = 0; i < nI; i++) { + var nJ = polygons[i].length; + for(var j = 0; j < nJ; j++) { + var w = polygons[i][j][0]; + if(w === 'Z') { + str += 'Z'; + } else { + var nK = polygons[i][j].length; + for(var k = 0; k < nK; k++) { + var realK = k; + if(w === 'Q' || w === 'S') { + realK = iQS[k]; + } else if(w === 'C') { + realK = iC[k]; + } + + str += polygons[i][j][realK]; + if(k > 0 && k < nK - 1) { + str += ','; + } + } + } + } + } + + return str; +}; + +exports.readPaths = function(str, gd, plotinfo, isActiveShape) { + var cmd = parseSvgPath(str); + + var polys = []; + var n = -1; + var newPoly = function() { + n++; + polys[n] = []; + }; + + var k; + var x = 0; + var y = 0; + var initX; + var initY; + var recStart = function() { + initX = x; + initY = y; + }; + + recStart(); + for(var i = 0; i < cmd.length; i++) { + var newPos = []; + + var x1, x2, y1, y2; // i.e. extra params for curves + + var c = cmd[i][0]; + var w = c; + switch(c) { + case 'M': + newPoly(); + x = +cmd[i][1]; + y = +cmd[i][2]; + newPos.push([w, x, y]); + + recStart(); + break; + + case 'Q': + case 'S': + x1 = +cmd[i][1]; + y1 = +cmd[i][2]; + x = +cmd[i][3]; + y = +cmd[i][4]; + newPos.push([w, x, y, x1, y1]); // -> iQS order + break; + + case 'C': + x1 = +cmd[i][1]; + y1 = +cmd[i][2]; + x2 = +cmd[i][3]; + y2 = +cmd[i][4]; + x = +cmd[i][5]; + y = +cmd[i][6]; + newPos.push([w, x, y, x1, y1, x2, y2]); // -> iC order + break; + + case 'T': + case 'L': + x = +cmd[i][1]; + y = +cmd[i][2]; + newPos.push([w, x, y]); + break; + + case 'H': + w = 'L'; // convert to line (for now) + x = +cmd[i][1]; + newPos.push([w, x, y]); + break; + + case 'V': + w = 'L'; // convert to line (for now) + y = +cmd[i][1]; + newPos.push([w, x, y]); + break; + + case 'A': + w = 'L'; // convert to line to handle circle + var rx = +cmd[i][1]; + var ry = +cmd[i][2]; + if(!+cmd[i][4]) { + rx = -rx; + ry = -ry; + } + + var cenX = x - rx; + var cenY = y; + for(k = 1; k <= CIRCLE_SIDES / 2; k++) { + var t = 2 * Math.PI * k / CIRCLE_SIDES; + newPos.push([ + w, + cenX + rx * Math.cos(t), + cenY + ry * Math.sin(t) + ]); + } + break; + + case 'Z': + if(x !== initX || y !== initY) { + x = initX; + y = initY; + newPos.push([w, x, y]); + } + break; + } + + var domain = (plotinfo || {}).domain; + var size = gd._fullLayout._size; + var xPixelSized = plotinfo && plotinfo.xsizemode === 'pixel'; + var yPixelSized = plotinfo && plotinfo.ysizemode === 'pixel'; + var noOffset = isActiveShape === false; + + for(var j = 0; j < newPos.length; j++) { + for(k = 0; k + 2 < 7; k += 2) { + var _x = newPos[j][k + 1]; + var _y = newPos[j][k + 2]; + + if(_x === undefined || _y === undefined) continue; + // keep track of end point for Z + x = _x; + y = _y; + + if(plotinfo) { + if(plotinfo.xaxis && plotinfo.xaxis.p2r) { + if(noOffset) _x -= plotinfo.xaxis._offset; + if(xPixelSized) { + _x = r2p(plotinfo.xaxis, plotinfo.xanchor) + _x; + } else { + _x = p2r(plotinfo.xaxis, _x); + } + } else { + if(noOffset) _x -= size.l; + if(domain) _x = domain.x[0] + _x / size.w; + else _x = _x / size.w; + } + + if(plotinfo.yaxis && plotinfo.yaxis.p2r) { + if(noOffset) _y -= plotinfo.yaxis._offset; + if(yPixelSized) { + _y = r2p(plotinfo.yaxis, plotinfo.yanchor) - _y; + } else { + _y = p2r(plotinfo.yaxis, _y); + } + } else { + if(noOffset) _y -= size.t; + if(domain) _y = domain.y[1] - _y / size.h; + else _y = 1 - _y / size.h; + } + } + + newPos[j][k + 1] = _x; + newPos[j][k + 2] = _y; + } + polys[n].push( + newPos[j].slice() + ); + } + } + + return polys; +}; + +function almostEq(a, b) { + return Math.abs(a - b) <= 1e-6; +} + +function dist(a, b) { + var dx = b[1] - a[1]; + var dy = b[2] - a[2]; + return Math.sqrt( + dx * dx + + dy * dy + ); +} + +exports.pointsShapeRectangle = function(cell) { + var len = cell.length; + if(len !== 5) return false; + + for(var j = 1; j < 3; j++) { + var e01 = cell[0][j] - cell[1][j]; + var e32 = cell[3][j] - cell[2][j]; + + if(!almostEq(e01, e32)) return false; + + var e03 = cell[0][j] - cell[3][j]; + var e12 = cell[1][j] - cell[2][j]; + if(!almostEq(e03, e12)) return false; + } + + // N.B. rotated rectangles are not valid rects since rotation is not supported in shapes for now. + if( + !almostEq(cell[0][1], cell[1][1]) && + !almostEq(cell[0][1], cell[3][1]) + ) return false; + + // reject cases with zero area + return !!( + dist(cell[0], cell[1]) * + dist(cell[0], cell[3]) + ); +}; + +exports.pointsShapeEllipse = function(cell) { + var len = cell.length; + if(len !== CIRCLE_SIDES + 1) return false; + + // opposite diagonals should be the same + len = CIRCLE_SIDES; + for(var i = 0; i < len; i++) { + var k = (len * 2 - i) % len; + + var k2 = (len / 2 + k) % len; + var i2 = (len / 2 + i) % len; + + if(!almostEq( + dist(cell[i], cell[i2]), + dist(cell[k], cell[k2]) + )) return false; + } + return true; +}; + +exports.handleEllipse = function(isEllipse, start, end) { + if(!isEllipse) return [start, end]; // i.e. case of line + + var pos = exports.ellipseOver({ + x0: start[0], + y0: start[1], + x1: end[0], + y1: end[1] + }); + + var cx = (pos.x1 + pos.x0) / 2; + var cy = (pos.y1 + pos.y0) / 2; + var rx = (pos.x1 - pos.x0) / 2; + var ry = (pos.y1 - pos.y0) / 2; + + // make a circle when one dimension is zero + if(!rx) rx = ry = ry / SQRT2; + if(!ry) ry = rx = rx / SQRT2; + + var cell = []; + for(var i = 0; i < CIRCLE_SIDES; i++) { + var t = i * 2 * Math.PI / CIRCLE_SIDES; + cell.push([ + cx + rx * Math.cos(t), + cy + ry * Math.sin(t), + ]); + } + return cell; +}; + +exports.ellipseOver = function(pos) { + var x0 = pos.x0; + var y0 = pos.y0; + var x1 = pos.x1; + var y1 = pos.y1; + + var dx = x1 - x0; + var dy = y1 - y0; + + x0 -= dx; + y0 -= dy; + + var cx = (x0 + x1) / 2; + var cy = (y0 + y1) / 2; + + var scale = SQRT2; + dx *= scale; + dy *= scale; + + return { + x0: cx - dx, + y0: cy - dy, + x1: cx + dx, + y1: cy + dy + }; +}; diff --git a/src/components/shapes/draw_newshape/newshapes.js b/src/components/shapes/draw_newshape/newshapes.js new file mode 100644 index 00000000000..5138ecc410a --- /dev/null +++ b/src/components/shapes/draw_newshape/newshapes.js @@ -0,0 +1,257 @@ +/** +* Copyright 2012-2020, 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 dragHelpers = require('../../dragelement/helpers'); +var drawMode = dragHelpers.drawMode; +var openMode = dragHelpers.openMode; + +var constants = require('./constants'); +var i000 = constants.i000; +var i090 = constants.i090; +var i180 = constants.i180; +var i270 = constants.i270; +var cos45 = constants.cos45; +var sin45 = constants.sin45; + +var cartesianHelpers = require('../../../plots/cartesian/helpers'); +var p2r = cartesianHelpers.p2r; +var r2p = cartesianHelpers.r2p; + +var handleOutline = require('../../../plots/cartesian/handle_outline'); +var clearSelect = handleOutline.clearSelect; + +var helpers = require('./helpers'); +var readPaths = helpers.readPaths; +var writePaths = helpers.writePaths; +var ellipseOver = helpers.ellipseOver; + + +module.exports = function newShapes(outlines, dragOptions) { + if(!outlines.length) return; + var e = outlines[0][0]; // pick first + if(!e) return; + var d = e.getAttribute('d'); + + var gd = dragOptions.gd; + var drwStyle = gd._fullLayout.newshape; + + var plotinfo = dragOptions.plotinfo; + var xaxis = plotinfo.xaxis; + var yaxis = plotinfo.yaxis; + var xPaper = !!plotinfo.domain || !plotinfo.xaxis; + var yPaper = !!plotinfo.domain || !plotinfo.yaxis; + + var isActiveShape = dragOptions.isActiveShape; + var dragmode = dragOptions.dragmode; + + var shapes = (gd.layout || {}).shapes || []; + + if(!drawMode(dragmode) && isActiveShape !== undefined) { + var id = gd._fullLayout._activeShapeIndex; + if(id < shapes.length) { + switch(gd._fullLayout.shapes[id].type) { + case 'rect': + dragmode = 'drawrect'; + break; + case 'circle': + dragmode = 'drawcircle'; + break; + case 'line': + dragmode = 'drawline'; + break; + case 'path': + var path = shapes[id].path || ''; + if(path[path.length - 1] === 'Z') { + dragmode = 'drawclosedpath'; + } else { + dragmode = 'drawopenpath'; + } + break; + } + } + } + + var isOpenMode = openMode(dragmode); + + var polygons = readPaths(d, gd, plotinfo, isActiveShape); + + var newShape = { + editable: true, + + xref: xPaper ? 'paper' : xaxis._id, + yref: yPaper ? 'paper' : yaxis._id, + + layer: drwStyle.layer, + opacity: drwStyle.opacity, + line: { + color: drwStyle.line.color, + width: drwStyle.line.width, + dash: drwStyle.line.dash + } + }; + + if(!isOpenMode) { + newShape.fillcolor = drwStyle.fillcolor; + newShape.fillrule = drwStyle.fillrule; + } + + var cell; + // line, rect and circle can be in one cell + // only define cell if there is single cell + if(polygons.length === 1) cell = polygons[0]; + + if( + cell && + dragmode === 'drawrect' + ) { + newShape.type = 'rect'; + newShape.x0 = cell[0][1]; + newShape.y0 = cell[0][2]; + newShape.x1 = cell[2][1]; + newShape.y1 = cell[2][2]; + } else if( + cell && + dragmode === 'drawline' + ) { + newShape.type = 'line'; + newShape.x0 = cell[0][1]; + newShape.y0 = cell[0][2]; + newShape.x1 = cell[1][1]; + newShape.y1 = cell[1][2]; + } else if( + cell && + dragmode === 'drawcircle' + ) { + newShape.type = 'circle'; // an ellipse! + + var xA = cell[i000][1]; + var xB = cell[i090][1]; + var xC = cell[i180][1]; + var xD = cell[i270][1]; + + var yA = cell[i000][2]; + var yB = cell[i090][2]; + var yC = cell[i180][2]; + var yD = cell[i270][2]; + + var xDateOrLog = plotinfo.xaxis && ( + plotinfo.xaxis.type === 'date' || + plotinfo.xaxis.type === 'log' + ); + + var yDateOrLog = plotinfo.yaxis && ( + plotinfo.yaxis.type === 'date' || + plotinfo.yaxis.type === 'log' + ); + + if(xDateOrLog) { + xA = r2p(plotinfo.xaxis, xA); + xB = r2p(plotinfo.xaxis, xB); + xC = r2p(plotinfo.xaxis, xC); + xD = r2p(plotinfo.xaxis, xD); + } + + if(yDateOrLog) { + yA = r2p(plotinfo.yaxis, yA); + yB = r2p(plotinfo.yaxis, yB); + yC = r2p(plotinfo.yaxis, yC); + yD = r2p(plotinfo.yaxis, yD); + } + + var x0 = (xB + xD) / 2; + var y0 = (yA + yC) / 2; + var rx = (xD - xB + xC - xA) / 2; + var ry = (yD - yB + yC - yA) / 2; + var pos = ellipseOver({ + x0: x0, + y0: y0, + x1: x0 + rx * cos45, + y1: y0 + ry * sin45 + }); + + if(xDateOrLog) { + pos.x0 = p2r(plotinfo.xaxis, pos.x0); + pos.x1 = p2r(plotinfo.xaxis, pos.x1); + } + + if(yDateOrLog) { + pos.y0 = p2r(plotinfo.yaxis, pos.y0); + pos.y1 = p2r(plotinfo.yaxis, pos.y1); + } + + newShape.x0 = pos.x0; + newShape.y0 = pos.y0; + newShape.x1 = pos.x1; + newShape.y1 = pos.y1; + } else { + newShape.type = 'path'; + if(xaxis && yaxis) fixDatesForPaths(polygons, xaxis, yaxis); + newShape.path = writePaths(polygons); + cell = null; + } + + clearSelect(gd); + + var editHelpers = dragOptions.editHelpers; + var modifyItem = (editHelpers || {}).modifyItem; + + var allShapes = []; + for(var q = 0; q < shapes.length; q++) { + var beforeEdit = gd._fullLayout.shapes[q]; + allShapes[q] = beforeEdit._input; + + if( + isActiveShape !== undefined && + q === gd._fullLayout._activeShapeIndex + ) { + var afterEdit = newShape; + + switch(beforeEdit.type) { + case 'line': + case 'rect': + case 'circle': + modifyItem('x0', afterEdit.x0); + modifyItem('x1', afterEdit.x1); + modifyItem('y0', afterEdit.y0); + modifyItem('y1', afterEdit.y1); + break; + + case 'path': + modifyItem('path', afterEdit.path); + break; + } + } + } + + if(isActiveShape === undefined) { + allShapes.push(newShape); // add new shape + return allShapes; + } + + return editHelpers ? editHelpers.getUpdateObj() : {}; +}; + +function fixDatesForPaths(polygons, xaxis, yaxis) { + var xIsDate = xaxis.type === 'date'; + var yIsDate = yaxis.type === 'date'; + if(!xIsDate && !yIsDate) return polygons; + + for(var i = 0; i < polygons.length; i++) { + for(var j = 0; j < polygons[i].length; j++) { + for(var k = 0; k + 2 < polygons[i][j].length; k += 2) { + if(xIsDate) polygons[i][j][k + 1] = polygons[i][j][k + 1].replace(' ', '_'); + if(yIsDate) polygons[i][j][k + 2] = polygons[i][j][k + 2].replace(' ', '_'); + } + } + } + + return polygons; +} diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js index ae95bddef02..fb5416a70ef 100644 --- a/src/components/shapes/helpers.js +++ b/src/components/shapes/helpers.js @@ -117,3 +117,27 @@ exports.roundPositionForSharpStrokeRendering = function(pos, strokeWidth) { return strokeWidthIsOdd ? posValAsInt + 0.5 : posValAsInt; }; + +exports.makeOptionsAndPlotinfo = function(gd, index) { + var options = gd._fullLayout.shapes[index] || {}; + + var plotinfo = gd._fullLayout._plots[options.xref + options.yref]; + var hasPlotinfo = !!plotinfo; + if(hasPlotinfo) { + plotinfo._hadPlotinfo = true; + } else { + plotinfo = {}; + if(options.xref && options.xref !== 'paper') plotinfo.xaxis = gd._fullLayout[options.xref + 'axis']; + if(options.yref && options.yref !== 'paper') plotinfo.yaxis = gd._fullLayout[options.yref + 'axis']; + } + + plotinfo.xsizemode = options.xsizemode; + plotinfo.ysizemode = options.ysizemode; + plotinfo.xanchor = options.xanchor; + plotinfo.yanchor = options.yanchor; + + return { + options: options, + plotinfo: plotinfo + }; +}; diff --git a/src/components/shapes/index.js b/src/components/shapes/index.js index 9cbdefd62f8..db2160e4cc5 100644 --- a/src/components/shapes/index.js +++ b/src/components/shapes/index.js @@ -17,6 +17,7 @@ module.exports = { layoutAttributes: require('./attributes'), supplyLayoutDefaults: require('./defaults'), + supplyDrawNewShapeDefaults: require('./draw_newshape/defaults'), includeBasePlot: require('../../plots/cartesian/include_components')('shapes'), calcAutorange: require('./calc_autorange'), diff --git a/src/fonts/ploticon.js b/src/fonts/ploticon.js index c7a33742cde..814cb16364d 100644 --- a/src/fonts/ploticon.js +++ b/src/fonts/ploticon.js @@ -111,6 +111,18 @@ module.exports = { 'path': 'm214-7h429v214h-429v-214z m500 0h72v500q0 8-6 21t-11 20l-157 156q-5 6-19 12t-22 5v-232q0-22-15-38t-38-16h-322q-22 0-37 16t-16 38v232h-72v-714h72v232q0 22 16 38t37 16h465q22 0 38-16t15-38v-232z m-214 518v178q0 8-5 13t-13 5h-107q-7 0-13-5t-5-13v-178q0-8 5-13t13-5h107q7 0 13 5t5 13z m357-18v-518q0-22-15-38t-38-16h-750q-23 0-38 16t-16 38v750q0 22 16 38t38 16h517q23 0 50-12t42-26l156-157q16-15 27-42t11-49z', 'transform': 'matrix(1 0 0 -1 0 850)' }, + 'drawopenpath': { + 'width': 70, + 'height': 70, + 'path': 'M33.21,85.65a7.31,7.31,0,0,1-2.59-.48c-8.16-3.11-9.27-19.8-9.88-41.3-.1-3.58-.19-6.68-.35-9-.15-2.1-.67-3.48-1.43-3.79-2.13-.88-7.91,2.32-12,5.86L3,32.38c1.87-1.64,11.55-9.66,18.27-6.9,2.13.87,4.75,3.14,5.17,9,.17,2.43.26,5.59.36,9.25a224.17,224.17,0,0,0,1.5,23.4c1.54,10.76,4,12.22,4.48,12.4.84.32,2.79-.46,5.76-3.59L43,80.07C41.53,81.57,37.68,85.64,33.21,85.65ZM74.81,69a11.34,11.34,0,0,0,6.09-6.72L87.26,44.5,74.72,32,56.9,38.35c-2.37.86-5.57,3.42-6.61,6L38.65,72.14l8.42,8.43ZM55,46.27a7.91,7.91,0,0,1,3.64-3.17l14.8-5.3,8,8L76.11,60.6l-.06.19a6.37,6.37,0,0,1-3,3.43L48.25,74.59,44.62,71Zm16.57,7.82A6.9,6.9,0,1,0,64.64,61,6.91,6.91,0,0,0,71.54,54.09Zm-4.05,0a2.85,2.85,0,1,1-2.85-2.85A2.86,2.86,0,0,1,67.49,54.09Zm-4.13,5.22L60.5,56.45,44.26,72.7l2.86,2.86ZM97.83,35.67,84.14,22l-8.57,8.57L89.26,44.24Zm-13.69-8,8,8-2.85,2.85-8-8Z', + 'transform': 'matrix(1 0 0 1 -15 -15)' + }, + 'drawclosedpath': { + 'width': 90, + 'height': 90, + 'path': 'M88.41,21.12a26.56,26.56,0,0,0-36.18,0l-2.07,2-2.07-2a26.57,26.57,0,0,0-36.18,0,23.74,23.74,0,0,0,0,34.8L48,90.12a3.22,3.22,0,0,0,4.42,0l36-34.21a23.73,23.73,0,0,0,0-34.79ZM84,51.24,50.16,83.35,16.35,51.25a17.28,17.28,0,0,1,0-25.47,20,20,0,0,1,27.3,0l4.29,4.07a3.23,3.23,0,0,0,4.44,0l4.29-4.07a20,20,0,0,1,27.3,0,17.27,17.27,0,0,1,0,25.46ZM66.76,47.68h-33v6.91h33ZM53.35,35H46.44V68h6.91Z', + 'transform': 'matrix(1 0 0 1 -5 -5)' + }, 'lasso': { 'width': 1031, 'height': 1000, @@ -123,6 +135,30 @@ module.exports = { 'path': 'm0 850l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m285 0l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m-857-286l0-143 143 0 0 143-143 0z m857 0l0-143 143 0 0 143-143 0z m-857-285l0-143 143 0 0 143-143 0z m857 0l0-143 143 0 0 143-143 0z m-857-286l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m285 0l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z', 'transform': 'matrix(1 0 0 -1 0 850)' }, + 'drawline': { + 'width': 70, + 'height': 70, + 'path': 'M60.64,62.3a11.29,11.29,0,0,0,6.09-6.72l6.35-17.72L60.54,25.31l-17.82,6.4c-2.36.86-5.57,3.41-6.6,6L24.48,65.5l8.42,8.42ZM40.79,39.63a7.89,7.89,0,0,1,3.65-3.17l14.79-5.31,8,8L61.94,54l-.06.19a6.44,6.44,0,0,1-3,3.43L34.07,68l-3.62-3.63Zm16.57,7.81a6.9,6.9,0,1,0-6.89,6.9A6.9,6.9,0,0,0,57.36,47.44Zm-4,0a2.86,2.86,0,1,1-2.85-2.85A2.86,2.86,0,0,1,53.32,47.44Zm-4.13,5.22L46.33,49.8,30.08,66.05l2.86,2.86ZM83.65,29,70,15.34,61.4,23.9,75.09,37.59ZM70,21.06l8,8-2.84,2.85-8-8ZM87,80.49H10.67V87H87Z', + 'transform': 'matrix(1 0 0 1 -15 -15)' + }, + 'drawrect': { + 'width': 80, + 'height': 80, + 'path': 'M78,22V79H21V22H78m9-9H12V88H87V13ZM68,46.22H31V54H68ZM53,32H45.22V69H53Z', + 'transform': 'matrix(1 0 0 1 -10 -10)' + }, + 'drawcircle': { + 'width': 80, + 'height': 80, + 'path': 'M50,84.72C26.84,84.72,8,69.28,8,50.3S26.84,15.87,50,15.87,92,31.31,92,50.3,73.16,84.72,50,84.72Zm0-60.59c-18.6,0-33.74,11.74-33.74,26.17S31.4,76.46,50,76.46,83.74,64.72,83.74,50.3,68.6,24.13,50,24.13Zm17.15,22h-34v7.11h34Zm-13.8-13H46.24v34h7.11Z', + 'transform': 'matrix(1 0 0 1 -10 -10)' + }, + 'eraseshape': { + 'width': 80, + 'height': 80, + 'path': 'M82.77,78H31.85L6,49.57,31.85,21.14H82.77a8.72,8.72,0,0,1,8.65,8.77V69.24A8.72,8.72,0,0,1,82.77,78ZM35.46,69.84H82.77a.57.57,0,0,0,.49-.6V29.91a.57.57,0,0,0-.49-.61H35.46L17,49.57Zm32.68-34.7-24,24,5,5,24-24Zm-19,.53-5,5,24,24,5-5Z', + 'transform': 'matrix(1 0 0 1 -10 -10)' + }, 'spikeline': { 'width': 1000, 'height': 1000, diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index be7bfd12dce..5b7130785f9 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -21,6 +21,10 @@ var Fx = require('../../components/fx'); var Axes = require('./axes'); var setCursor = require('../../lib/setcursor'); var dragElement = require('../../components/dragelement'); +var helpers = require('../../components/dragelement/helpers'); +var selectingOrDrawing = helpers.selectingOrDrawing; +var freeMode = helpers.freeMode; + var FROM_TL = require('../../constants/alignment').FROM_TL; var clearGlCanvases = require('../../lib/clear_gl_canvases'); var redrawReglTraces = require('../../plot_api/subroutines').redrawReglTraces; @@ -163,7 +167,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { // to pan (or to zoom if it already is pan) on shift if(e.shiftKey) { if(dragModeNow === 'pan') dragModeNow = 'zoom'; - else if(!isSelectOrLasso(dragModeNow)) dragModeNow = 'pan'; + else if(!selectingOrDrawing(dragModeNow)) dragModeNow = 'pan'; } else if(e.ctrlKey) { dragModeNow = 'pan'; } @@ -173,17 +177,17 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { } } - if(dragModeNow === 'lasso') dragOptions.minDrag = 1; + if(freeMode(dragModeNow)) dragOptions.minDrag = 1; else dragOptions.minDrag = undefined; - if(isSelectOrLasso(dragModeNow)) { + if(selectingOrDrawing(dragModeNow)) { dragOptions.xaxes = xaxes; dragOptions.yaxes = yaxes; // this attaches moveFn, clickFn, doneFn on dragOptions prepSelect(e, startX, startY, dragOptions, dragModeNow); } else { dragOptions.clickFn = clickFn; - if(isSelectOrLasso(dragModePrev)) { + if(selectingOrDrawing(dragModePrev)) { // TODO Fix potential bug // Note: clearing / resetting selection state only happens, when user // triggers at least one interaction in pan/zoom mode. Otherwise, the @@ -221,7 +225,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(dragDataNow && dragDataNow.element === dragger) { var dragModeNow = gd._fullLayout.dragmode; - if(!isSelectOrLasso(dragModeNow)) { + if(!selectingOrDrawing(dragModeNow)) { recomputeAxisLists(); updateSubplots([0, 0, pw, ph]); dragOptions.moveFn(dragDataNow.dx, dragDataNow.dy); @@ -241,6 +245,12 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { } function clickFn(numClicks, evt) { + var gd = dragOptions.gd; + if(gd._fullLayout._activeShapeIndex >= 0) { + gd._fullLayout._deactivateShape(gd); + return; + } + var clickmode = gd._fullLayout.clickmode; removeZoombox(gd); @@ -1111,10 +1121,6 @@ function showDoubleClickNotifier(gd) { } } -function isSelectOrLasso(dragmode) { - return dragmode === 'lasso' || dragmode === 'select'; -} - function xCorners(box, y0) { return 'M' + (box.l - 0.5) + ',' + (y0 - MINZOOM - 0.5) + diff --git a/src/plots/cartesian/handle_outline.js b/src/plots/cartesian/handle_outline.js new file mode 100644 index 00000000000..0f10d1438d9 --- /dev/null +++ b/src/plots/cartesian/handle_outline.js @@ -0,0 +1,34 @@ +/** +* Copyright 2012-2020, 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'; + +function clearOutlineControllers(gd) { + var zoomLayer = gd._fullLayout._zoomlayer; + if(zoomLayer) { + zoomLayer.selectAll('.outline-controllers').remove(); + } +} + +function clearSelect(gd) { + var zoomLayer = gd._fullLayout._zoomlayer; + if(zoomLayer) { + // until we get around to persistent selections, remove the outline + // here. The selection itself will be removed when the plot redraws + // at the end. + zoomLayer.selectAll('.select-outline').remove(); + } + + gd._fullLayout._drawing = false; +} + +module.exports = { + clearOutlineControllers: clearOutlineControllers, + clearSelect: clearSelect +}; diff --git a/src/plots/cartesian/helpers.js b/src/plots/cartesian/helpers.js new file mode 100644 index 00000000000..b07c36f0703 --- /dev/null +++ b/src/plots/cartesian/helpers.js @@ -0,0 +1,52 @@ +/** +* Copyright 2012-2020, 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'; + +// in v2 (once log ranges are fixed), +// we'll be able to p2r here for all axis types +function p2r(ax, v) { + switch(ax.type) { + case 'log': + return ax.p2d(v); + case 'date': + return ax.p2r(v, 0, ax.calendar); + default: + return ax.p2r(v); + } +} + +function r2p(ax, v) { + switch(ax.type) { + case 'log': + return ax.d2p(v); + case 'date': + return ax.r2p(v, 0, ax.calendar); + default: + return ax.r2p(v); + } +} + +function axValue(ax) { + var index = (ax._id.charAt(0) === 'y') ? 1 : 0; + return function(v) { return p2r(ax, v[index]); }; +} + +function getTransform(plotinfo) { + return 'translate(' + + plotinfo.xaxis._offset + ',' + + plotinfo.yaxis._offset + ')'; +} + +module.exports = { + p2r: p2r, + r2p: r2p, + axValue: axValue, + getTransform: getTransform +}; diff --git a/src/plots/cartesian/select.js b/src/plots/cartesian/select.js index 78d00c2e94c..a61bbbcb23c 100644 --- a/src/plots/cartesian/select.js +++ b/src/plots/cartesian/select.js @@ -12,13 +12,24 @@ var polybool = require('polybooljs'); var Registry = require('../../registry'); +var dashStyle = require('../../components/drawing').dashStyle; var Color = require('../../components/color'); var Fx = require('../../components/fx'); +var makeEventData = require('../../components/fx/helpers').makeEventData; +var dragHelpers = require('../../components/dragelement/helpers'); +var freeMode = dragHelpers.freeMode; +var rectMode = dragHelpers.rectMode; +var drawMode = dragHelpers.drawMode; +var openMode = dragHelpers.openMode; +var selectMode = dragHelpers.selectMode; + +var displayOutlines = require('../../components/shapes/draw_newshape/display_outlines'); +var handleEllipse = require('../../components/shapes/draw_newshape/helpers').handleEllipse; +var newShapes = require('../../components/shapes/draw_newshape/newshapes'); var Lib = require('../../lib'); var polygon = require('../../lib/polygon'); var throttle = require('../../lib/throttle'); -var makeEventData = require('../../components/fx/helpers').makeEventData; var getFromId = require('./axis_ids').getFromId; var clearGlCanvases = require('../../lib/clear_gl_canvases'); @@ -30,16 +41,30 @@ var MINSELECT = constants.MINSELECT; var filteredPolygon = polygon.filter; var polygonTester = polygon.tester; -function getAxId(ax) { return ax._id; } +var clearSelect = require('./handle_outline').clearSelect; + +var helpers = require('./helpers'); +var p2r = helpers.p2r; +var axValue = helpers.axValue; +var getTransform = helpers.getTransform; function prepSelect(e, startX, startY, dragOptions, mode) { + var isFreeMode = freeMode(mode); + var isRectMode = rectMode(mode); + var isOpenMode = openMode(mode); + var isDrawMode = drawMode(mode); + var isSelectMode = selectMode(mode); + + var isLine = mode === 'drawline'; + var isEllipse = mode === 'drawcircle'; + var isLineOrEllipse = isLine || isEllipse; // cases with two start & end positions + var gd = dragOptions.gd; var fullLayout = gd._fullLayout; var zoomLayer = fullLayout._zoomlayer; var dragBBox = dragOptions.element.getBoundingClientRect(); var plotinfo = dragOptions.plotinfo; - var xs = plotinfo.xaxis._offset; - var ys = plotinfo.yaxis._offset; + var transform = getTransform(plotinfo); var x0 = startX - dragBBox.left; var y0 = startY - dragBBox.top; var x1 = x0; @@ -48,23 +73,34 @@ function prepSelect(e, startX, startY, dragOptions, mode) { var pw = dragOptions.xaxes[0]._length; var ph = dragOptions.yaxes[0]._length; var allAxes = dragOptions.xaxes.concat(dragOptions.yaxes); - var subtract = e.altKey; + var subtract = e.altKey && + !(drawMode(mode) && isOpenMode); var filterPoly, selectionTester, mergedPolygons, currentPolygon; var i, searchInfo, eventData; coerceSelectionsCache(e, gd, dragOptions); - if(mode === 'lasso') { + if(isFreeMode) { filterPoly = filteredPolygon([[x0, y0]], constants.BENDPX); } - var outlines = zoomLayer.selectAll('path.select-outline-' + plotinfo.id).data([1, 2]); + var outlines = zoomLayer.selectAll('path.select-outline-' + plotinfo.id).data(isDrawMode ? [0] : [1, 2]); + var drwStyle = fullLayout.newshape; outlines.enter() .append('path') .attr('class', function(d) { return 'select-outline select-outline-' + d + ' select-outline-' + plotinfo.id; }) - .attr('transform', 'translate(' + xs + ', ' + ys + ')') + .style(isDrawMode ? { + opacity: drwStyle.opacity / 2, + fill: isOpenMode ? undefined : drwStyle.fillcolor, + stroke: drwStyle.line.color, + 'stroke-dasharray': dashStyle(drwStyle.line.dash, drwStyle.line.width), + 'stroke-width': drwStyle.line.width + 'px' + } : {}) + .attr('fill-rule', drwStyle.fillrule) + .classed('cursor-move', isDrawMode ? true : false) + .attr('transform', transform) .attr('d', path0 + 'Z'); var corners = zoomLayer.append('path') @@ -74,7 +110,7 @@ function prepSelect(e, startX, startY, dragOptions, mode) { stroke: Color.defaultLine, 'stroke-width': 1 }) - .attr('transform', 'translate(' + xs + ', ' + ys + ')') + .attr('transform', transform) .attr('d', 'M0,0Z'); @@ -85,17 +121,6 @@ function prepSelect(e, startX, startY, dragOptions, mode) { var searchTraces = determineSearchTraces(gd, dragOptions.xaxes, dragOptions.yaxes, dragOptions.subplot); - // in v2 (once log ranges are fixed), - // we'll be able to p2r here for all axis types - function p2r(ax, v) { - return ax.type === 'log' ? ax.p2d(v) : ax.p2r(v); - } - - function axValue(ax) { - var index = (ax._id.charAt(0) === 'y') ? 1 : 0; - return function(v) { return p2r(ax, v[index]); }; - } - function ascending(a, b) { return a - b; } // allow subplots to override fillRangeItems routine @@ -104,7 +129,7 @@ function prepSelect(e, startX, startY, dragOptions, mode) { if(plotinfo.fillRangeItems) { fillRangeItems = plotinfo.fillRangeItems; } else { - if(mode === 'select') { + if(isRectMode) { fillRangeItems = function(eventData, poly) { var ranges = eventData.range = {}; @@ -118,7 +143,7 @@ function prepSelect(e, startX, startY, dragOptions, mode) { ].sort(ascending); } }; - } else { + } else { // case of isFreeMode fillRangeItems = function(eventData, poly, filterPoly) { var dataPts = eventData.lassoPoints = {}; @@ -137,50 +162,107 @@ function prepSelect(e, startX, startY, dragOptions, mode) { var dx = Math.abs(x1 - x0); var dy = Math.abs(y1 - y0); - if(mode === 'select') { - var direction = fullLayout.selectdirection; + if(isRectMode) { + var direction; + var start, end; - if(fullLayout.selectdirection === 'any') { - if(dy < Math.min(dx * 0.6, MINSELECT)) direction = 'h'; - else if(dx < Math.min(dy * 0.6, MINSELECT)) direction = 'v'; - else direction = 'd'; - } else { - direction = fullLayout.selectdirection; + if(isSelectMode) { + var q = fullLayout.selectdirection; + + if(q === 'any') { + if(dy < Math.min(dx * 0.6, MINSELECT)) { + direction = 'h'; + } else if(dx < Math.min(dy * 0.6, MINSELECT)) { + direction = 'v'; + } else { + direction = 'd'; + } + } else { + direction = q; + } + + switch(direction) { + case 'h': + start = isEllipse ? ph / 2 : 0; + end = ph; + break; + case 'v': + start = isEllipse ? pw / 2 : 0; + end = pw; + break; + } + } + + if(isDrawMode) { + switch(fullLayout.newshape.drawdirection) { + case 'vertical': + direction = 'h'; + start = isEllipse ? ph / 2 : 0; + end = ph; + break; + case 'horizontal': + direction = 'v'; + start = isEllipse ? pw / 2 : 0; + end = pw; + break; + case 'ortho': + if(dx < dy) { + direction = 'h'; + start = y0; + end = y1; + } else { + direction = 'v'; + start = x0; + end = x1; + } + break; + default: // i.e. case of 'diagonal' + direction = 'd'; + } } if(direction === 'h') { - // horizontal motion: make a vertical box - currentPolygon = [[x0, 0], [x0, ph], [x1, ph], [x1, 0]]; - currentPolygon.xmin = Math.min(x0, x1); - currentPolygon.xmax = Math.max(x0, x1); - currentPolygon.ymin = Math.min(0, ph); - currentPolygon.ymax = Math.max(0, ph); + // horizontal motion + currentPolygon = isLineOrEllipse ? + handleEllipse(isEllipse, [x1, start], [x1, end]) : // using x1 instead of x0 allows adjusting the line while drawing + [[x0, start], [x0, end], [x1, end], [x1, start]]; // make a vertical box + + currentPolygon.xmin = isLineOrEllipse ? x1 : Math.min(x0, x1); + currentPolygon.xmax = isLineOrEllipse ? x1 : Math.max(x0, x1); + currentPolygon.ymin = Math.min(start, end); + currentPolygon.ymax = Math.max(start, end); // extras to guide users in keeping a straight selection corners.attr('d', 'M' + currentPolygon.xmin + ',' + (y0 - MINSELECT) + 'h-4v' + (2 * MINSELECT) + 'h4Z' + 'M' + (currentPolygon.xmax - 1) + ',' + (y0 - MINSELECT) + 'h4v' + (2 * MINSELECT) + 'h-4Z'); } else if(direction === 'v') { - // vertical motion: make a horizontal box - currentPolygon = [[0, y0], [0, y1], [pw, y1], [pw, y0]]; - currentPolygon.xmin = Math.min(0, pw); - currentPolygon.xmax = Math.max(0, pw); - currentPolygon.ymin = Math.min(y0, y1); - currentPolygon.ymax = Math.max(y0, y1); + // vertical motion + currentPolygon = isLineOrEllipse ? + handleEllipse(isEllipse, [start, y1], [end, y1]) : // using y1 instead of y0 allows adjusting the line while drawing + [[start, y0], [start, y1], [end, y1], [end, y0]]; // make a horizontal box + + currentPolygon.xmin = Math.min(start, end); + currentPolygon.xmax = Math.max(start, end); + currentPolygon.ymin = isLineOrEllipse ? y1 : Math.min(y0, y1); + currentPolygon.ymax = isLineOrEllipse ? y1 : Math.max(y0, y1); corners.attr('d', 'M' + (x0 - MINSELECT) + ',' + currentPolygon.ymin + 'v-4h' + (2 * MINSELECT) + 'v4Z' + 'M' + (x0 - MINSELECT) + ',' + (currentPolygon.ymax - 1) + 'v4h' + (2 * MINSELECT) + 'v-4Z'); } else if(direction === 'd') { // diagonal motion - currentPolygon = [[x0, y0], [x0, y1], [x1, y1], [x1, y0]]; + currentPolygon = isLineOrEllipse ? + handleEllipse(isEllipse, [x0, y0], [x1, y1]) : + [[x0, y0], [x0, y1], [x1, y1], [x1, y0]]; + currentPolygon.xmin = Math.min(x0, x1); currentPolygon.xmax = Math.max(x0, x1); currentPolygon.ymin = Math.min(y0, y1); currentPolygon.ymax = Math.max(y0, y1); corners.attr('d', 'M0,0Z'); } - } else if(mode === 'lasso') { + } else if(isFreeMode) { filterPoly.addPt([x1, y1]); currentPolygon = filterPoly.filtered; } @@ -195,47 +277,54 @@ function prepSelect(e, startX, startY, dragOptions, mode) { selectionTester = polygonTester(currentPolygon); } - // draw selection - drawSelection(mergedPolygons, outlines); + // display polygons on the screen + displayOutlines(convertPoly(mergedPolygons, isOpenMode), outlines, dragOptions); + if(isSelectMode) { + throttle.throttle( + throttleID, + constants.SELECTDELAY, + function() { + selection = []; - throttle.throttle( - throttleID, - constants.SELECTDELAY, - function() { - selection = []; + var thisSelection; + var traceSelections = []; + var traceSelection; + for(i = 0; i < searchTraces.length; i++) { + searchInfo = searchTraces[i]; - var thisSelection; - var traceSelections = []; - var traceSelection; - for(i = 0; i < searchTraces.length; i++) { - searchInfo = searchTraces[i]; + traceSelection = searchInfo._module.selectPoints(searchInfo, selectionTester); + traceSelections.push(traceSelection); - traceSelection = searchInfo._module.selectPoints(searchInfo, selectionTester); - traceSelections.push(traceSelection); + thisSelection = fillSelectionItem(traceSelection, searchInfo); - thisSelection = fillSelectionItem(traceSelection, searchInfo); + if(selection.length) { + for(var j = 0; j < thisSelection.length; j++) { + selection.push(thisSelection[j]); + } + } else selection = thisSelection; + } - if(selection.length) { - for(var j = 0; j < thisSelection.length; j++) { - selection.push(thisSelection[j]); - } - } else selection = thisSelection; + eventData = {points: selection}; + updateSelectedState(gd, searchTraces, eventData); + fillRangeItems(eventData, currentPolygon, filterPoly); + dragOptions.gd.emit('plotly_selecting', eventData); } - - eventData = {points: selection}; - updateSelectedState(gd, searchTraces, eventData); - fillRangeItems(eventData, currentPolygon, filterPoly); - dragOptions.gd.emit('plotly_selecting', eventData); - } - ); + ); + } }; dragOptions.clickFn = function(numClicks, evt) { - var clickmode = fullLayout.clickmode; - corners.remove(); + if(gd._fullLayout._activeShapeIndex >= 0) { + gd._fullLayout._deactivateShape(gd); + return; + } + if(isDrawMode) return; + + var clickmode = fullLayout.clickmode; + throttle.done(throttleID).then(function() { throttle.clear(throttleID); if(numClicks === 2) { @@ -291,12 +380,17 @@ function prepSelect(e, startX, startY, dragOptions, mode) { dragOptions.doneFnCompleted(selection); } }).catch(Lib.error); + + if(isDrawMode) { + clearSelectionsCache(dragOptions); + } }; } function selectOnClick(evt, gd, xAxes, yAxes, subplot, dragOptions, polygonOutlines) { var hoverData = gd._hoverdata; - var clickmode = gd._fullLayout.clickmode; + var fullLayout = gd._fullLayout; + var clickmode = fullLayout.clickmode; var sendEvents = clickmode.indexOf('event') > -1; var selection = []; var searchTraces, searchInfo, currentSelectionDef, selectionTester, traceSelection; @@ -357,7 +451,13 @@ function selectOnClick(evt, gd, xAxes, yAxes, subplot, dragOptions, polygonOutli dragOptions.selectionDefs.push(currentSelectionDef); } - if(polygonOutlines) drawSelection(dragOptions.mergedPolygons, polygonOutlines); + if(polygonOutlines) { + var polygons = dragOptions.mergedPolygons; + var isOpenMode = openMode(dragOptions.dragmode); + + // display polygons on the screen + displayOutlines(convertPoly(polygons, isOpenMode), polygonOutlines, dragOptions); + } if(sendEvents) { gd.emit('plotly_selected', eventData); @@ -468,14 +568,19 @@ function multiTester(list) { } function coerceSelectionsCache(evt, gd, dragOptions) { + gd._fullLayout._drawing = false; + var fullLayout = gd._fullLayout; var plotinfo = dragOptions.plotinfo; + var dragmode = dragOptions.dragmode; var selectingOnSameSubplot = ( fullLayout._lastSelectedSubplot && fullLayout._lastSelectedSubplot === plotinfo.id ); - var hasModifierKey = evt.shiftKey || evt.altKey; + + var hasModifierKey = (evt.shiftKey || evt.altKey) && + !(drawMode(dragmode) && openMode(dragmode)); if(selectingOnSameSubplot && hasModifierKey && (plotinfo.selection && plotinfo.selection.selectionDefs) && !dragOptions.selectionDefs) { @@ -494,8 +599,32 @@ function coerceSelectionsCache(evt, gd, dragOptions) { } function clearSelectionsCache(dragOptions) { + var dragmode = dragOptions.dragmode; var plotinfo = dragOptions.plotinfo; + var gd = dragOptions.gd; + if(gd._fullLayout._activeShapeIndex >= 0) { + gd._fullLayout._deactivateShape(gd); + } + + if(drawMode(dragmode)) { + var fullLayout = gd._fullLayout; + var zoomLayer = fullLayout._zoomlayer; + + var outlines = zoomLayer.selectAll('.select-outline-' + plotinfo.id); + if(outlines && gd._fullLayout._drawing) { + // add shape + var shapes = newShapes(outlines, dragOptions); + if(shapes) { + Registry.call('_guiRelayout', gd, { + shapes: shapes + }); + } + + gd._fullLayout._drawing = false; + } + } + plotinfo.selection = {}; plotinfo.selection.selectionDefs = dragOptions.selectionDefs = []; plotinfo.selection.mergedPolygons = dragOptions.mergedPolygons = []; @@ -503,8 +632,8 @@ function clearSelectionsCache(dragOptions) { function determineSearchTraces(gd, xAxes, yAxes, subplot) { var searchTraces = []; - var xAxisIds = xAxes.map(getAxId); - var yAxisIds = yAxes.map(getAxId); + var xAxisIds = xAxes.map(function(ax) { return ax._id; }); + var yAxisIds = yAxes.map(function(ax) { return ax._id; }); var cd, trace, i; for(i = 0; i < gd.calcdata.length; i++) { @@ -549,21 +678,6 @@ function determineSearchTraces(gd, xAxes, yAxes, subplot) { } } -function drawSelection(polygons, outlines) { - var paths = []; - var i, d; - - for(i = 0; i < polygons.length; i++) { - var ppts = polygons[i]; - paths.push(ppts.join('L') + 'L' + ppts[0]); - } - - d = polygons.length > 0 ? - 'M' + paths.join('M') + 'Z' : - 'M0,0Z'; - outlines.attr('d', d); -} - function isHoverDataSet(hoverData) { return hoverData && Array.isArray(hoverData) && @@ -785,19 +899,35 @@ function fillSelectionItem(selection, searchInfo) { return selection; } -// until we get around to persistent selections, remove the outline -// here. The selection itself will be removed when the plot redraws -// at the end. -function clearSelect(gd) { - var fullLayout = gd._fullLayout || {}; - var zoomlayer = fullLayout._zoomlayer; - if(zoomlayer) { - zoomlayer.selectAll('.select-outline').remove(); +function convertPoly(polygonsIn, isOpenMode) { // add M and L command to draft positions + var polygonsOut = []; + for(var i = 0; i < polygonsIn.length; i++) { + polygonsOut[i] = []; + for(var j = 0; j < polygonsIn[i].length; j++) { + polygonsOut[i][j] = []; + polygonsOut[i][j][0] = j ? 'L' : 'M'; + for(var k = 0; k < polygonsIn[i][j].length; k++) { + polygonsOut[i][j].push( + polygonsIn[i][j][k] + ); + } + } + + if(!isOpenMode) { + polygonsOut[i].push([ + 'Z', + polygonsOut[i][0][1], // initial x + polygonsOut[i][0][2] // initial y + ]); + } } + + return polygonsOut; } module.exports = { prepSelect: prepSelect, clearSelect: clearSelect, + clearSelectionsCache: clearSelectionsCache, selectOnClick: selectOnClick }; diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 3c74c1647d0..00088cf9b03 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -22,6 +22,7 @@ var Axes = require('../cartesian/axes'); var getAutoRange = require('../cartesian/autorange').getAutoRange; var dragElement = require('../../components/dragelement'); var prepSelect = require('../cartesian/select').prepSelect; +var clearSelect = require('../cartesian/select').clearSelect; var selectOnClick = require('../cartesian/select').selectOnClick; var createGeoZoom = require('./zoom'); @@ -489,7 +490,7 @@ proto.updateFx = function(fullLayout, geoLayout) { subplot: _this.id, clickFn: function(numClicks) { if(numClicks === 2) { - fullLayout._zoomlayer.selectAll('.select-outline').remove(); + clearSelect(gd); } } }; diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index bbfbe9cebfa..79d6bc48de0 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -26,6 +26,10 @@ var enforceAxisConstraints = axisConstraints.enforce; var cleanAxisConstraints = axisConstraints.clean; var doAutoRange = require('../cartesian/autorange').doAutoRange; +var dragHelpers = require('../../components/dragelement/helpers'); +var drawMode = dragHelpers.drawMode; +var selectMode = dragHelpers.selectMode; + var AXES = ['xaxis', 'yaxis']; var STATIC_CANVAS, STATIC_CONTEXT; @@ -524,8 +528,8 @@ proto.updateTraces = function(fullData, calcData) { }; proto.updateFx = function(dragmode) { - // switch to svg interactions in lasso/select mode - if(dragmode === 'lasso' || dragmode === 'select') { + // switch to svg interactions in lasso/select mode & shape drawing + if(selectMode(dragmode) || drawMode(dragmode)) { this.pickCanvas.style['pointer-events'] = 'none'; this.mouseContainer.style['pointer-events'] = 'none'; } else { diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index c90509d8bf0..2a2a2f3a473 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -11,6 +11,7 @@ var fontAttrs = require('./font_attributes'); var animationAttrs = require('./animation_attributes'); var colorAttrs = require('../components/color/attributes'); +var drawNewShapeAttrs = require('../components/shapes/draw_newshape/attributes'); var padAttrs = require('./pad_attributes'); var extendFlat = require('../lib/extend').extendFlat; @@ -444,6 +445,9 @@ module.exports = { editType: 'modebar' }, + newshape: drawNewShapeAttrs.newshape, + activeshape: drawNewShapeAttrs.activeshape, + meta: { valType: 'any', arrayOk: true, diff --git a/src/plots/mapbox/mapbox.js b/src/plots/mapbox/mapbox.js index 4986c647a7f..b3268b9a5cb 100644 --- a/src/plots/mapbox/mapbox.js +++ b/src/plots/mapbox/mapbox.js @@ -10,13 +10,21 @@ var mapboxgl = require('mapbox-gl'); -var Fx = require('../../components/fx'); var Lib = require('../../lib'); var geoUtils = require('../../lib/geo_location_utils'); var Registry = require('../../registry'); var Axes = require('../cartesian/axes'); var dragElement = require('../../components/dragelement'); + +var Fx = require('../../components/fx'); +var dragHelpers = require('../../components/dragelement/helpers'); +var rectMode = dragHelpers.rectMode; +var drawMode = dragHelpers.drawMode; +var selectMode = dragHelpers.selectMode; + var prepSelect = require('../cartesian/select').prepSelect; +var clearSelect = require('../cartesian/select').clearSelect; +var clearSelectionsCache = require('../cartesian/select').clearSelectionsCache; var selectOnClick = require('../cartesian/select').selectOnClick; var constants = require('./constants'); @@ -507,7 +515,8 @@ proto.initFx = function(calcData, fullLayout) { // define event handlers on map creation, to keep one ref per map, // so that map.on / map.off in updateFx works as expected self.clearSelect = function() { - gd._fullLayout._zoomlayer.selectAll('.select-outline').remove(); + clearSelectionsCache(self.dragOptions); + clearSelect(self.dragOptions.gd); }; /** @@ -549,7 +558,7 @@ proto.updateFx = function(fullLayout) { var dragMode = fullLayout.dragmode; var fillRangeItems; - if(dragMode === 'select') { + if(rectMode(dragMode)) { fillRangeItems = function(eventData, poly) { var ranges = eventData.range = {}; ranges[self.id] = [ @@ -570,10 +579,12 @@ proto.updateFx = function(fullLayout) { // persistent selection state. var oldDragOptions = self.dragOptions; self.dragOptions = Lib.extendDeep(oldDragOptions || {}, { + dragmode: fullLayout.dragmode, element: self.div, gd: gd, plotinfo: { id: self.id, + domain: fullLayout[self.id].domain, xaxis: self.xaxis, yaxis: self.yaxis, fillRangeItems: fillRangeItems @@ -587,7 +598,7 @@ proto.updateFx = function(fullLayout) { // a new one. Otherwise multiple click handlers might // be registered resulting in unwanted behavior. map.off('click', self.onClickInPanHandler); - if(dragMode === 'select' || dragMode === 'lasso') { + if(selectMode(dragMode) || drawMode(dragMode)) { map.dragPan.disable(); map.on('zoomstart', self.clearSelect); diff --git a/src/plots/plots.js b/src/plots/plots.js index d91b4f89b88..309e1edbe43 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -19,6 +19,7 @@ var Color = require('../components/color'); var BADNUM = require('../constants/numerical').BADNUM; var axisIDs = require('./cartesian/axis_ids'); +var clearSelect = require('./cartesian/handle_outline').clearSelect; var animationAttrs = require('./animation_attributes'); var frameAttrs = require('./frame_attributes'); @@ -487,7 +488,9 @@ plots.supplyDefaults = function(gd, opts) { // we should try to come up with a better solution when implementing // https://github.com/plotly/plotly.js/issues/1851 if(oldFullLayout._zoomlayer && !gd._dragging) { - oldFullLayout._zoomlayer.selectAll('.select-outline').remove(); + clearSelect({ // mock old gd + _fullLayout: oldFullLayout + }); } @@ -1524,6 +1527,11 @@ plots.supplyLayoutGlobalDefaults = function(layoutIn, layoutOut, formatObj) { coerce('modebar.activecolor', Color.addOpacity(modebarDefaultColor, 0.7)); coerce('modebar.uirevision', uirevision); + Registry.getComponentMethod( + 'shapes', + 'supplyDrawNewShapeDefaults' + )(layoutIn, layoutOut, coerce); + coerce('meta'); // do not include defaults in fullLayout when users do not set transition diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index 400530c2d69..ccf109c26b2 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -23,10 +23,14 @@ var Plots = require('../plots'); var Axes = require('../cartesian/axes'); var dragElement = require('../../components/dragelement'); var Fx = require('../../components/fx'); +var dragHelpers = require('../../components/dragelement/helpers'); +var freeMode = dragHelpers.freeMode; +var rectMode = dragHelpers.rectMode; var Titles = require('../../components/titles'); var prepSelect = require('../cartesian/select').prepSelect; var selectOnClick = require('../cartesian/select').selectOnClick; var clearSelect = require('../cartesian/select').clearSelect; +var clearSelectionsCache = require('../cartesian/select').clearSelectionsCache; var constants = require('../cartesian/constants'); function Ternary(options, fullLayout) { @@ -488,6 +492,11 @@ var STARTMARKER = 'm0.5,0.5h5v-2h-5v-5h-2v5h-5v2h5v5h2Z'; // I guess this could be shared with cartesian... but for now it's separate. var SHOWZOOMOUTTIP = true; +proto.clearSelect = function() { + clearSelectionsCache(this.dragOptions); + clearSelect(this.dragOptions.gd); +}; + proto.initInteractions = function() { var _this = this; var dragger = _this.layers.plotbg.select('path').node(); @@ -495,11 +504,12 @@ proto.initInteractions = function() { var zoomLayer = gd._fullLayout._zoomlayer; // use plotbg for the main interactions - var dragOptions = { + this.dragOptions = { element: dragger, gd: gd, plotinfo: { id: _this.id, + domain: gd._fullLayout[_this.id].domain, xaxis: _this.xaxis, yaxis: _this.yaxis }, @@ -507,26 +517,27 @@ proto.initInteractions = function() { prepFn: function(e, startX, startY) { // these aren't available yet when initInteractions // is called - dragOptions.xaxes = [_this.xaxis]; - dragOptions.yaxes = [_this.yaxis]; - var dragModeNow = gd._fullLayout.dragmode; + _this.dragOptions.xaxes = [_this.xaxis]; + _this.dragOptions.yaxes = [_this.yaxis]; + + var dragModeNow = _this.dragOptions.dragmode = gd._fullLayout.dragmode; - if(dragModeNow === 'lasso') dragOptions.minDrag = 1; - else dragOptions.minDrag = undefined; + if(freeMode(dragModeNow)) _this.dragOptions.minDrag = 1; + else _this.dragOptions.minDrag = undefined; if(dragModeNow === 'zoom') { - dragOptions.moveFn = zoomMove; - dragOptions.clickFn = clickZoomPan; - dragOptions.doneFn = zoomDone; + _this.dragOptions.moveFn = zoomMove; + _this.dragOptions.clickFn = clickZoomPan; + _this.dragOptions.doneFn = zoomDone; zoomPrep(e, startX, startY); } else if(dragModeNow === 'pan') { - dragOptions.moveFn = plotDrag; - dragOptions.clickFn = clickZoomPan; - dragOptions.doneFn = dragDone; + _this.dragOptions.moveFn = plotDrag; + _this.dragOptions.clickFn = clickZoomPan; + _this.dragOptions.doneFn = dragDone; panPrep(); - clearSelect(gd); - } else if(dragModeNow === 'select' || dragModeNow === 'lasso') { - prepSelect(e, startX, startY, dragOptions, dragModeNow); + _this.clearSelect(gd); + } else if(rectMode(dragModeNow) || freeMode(dragModeNow)) { + prepSelect(e, startX, startY, _this.dragOptions, dragModeNow); } } }; @@ -552,7 +563,7 @@ proto.initInteractions = function() { } if(clickMode.indexOf('select') > -1 && numClicks === 1) { - selectOnClick(evt, gd, [_this.xaxis], [_this.yaxis], _this.id, dragOptions); + selectOnClick(evt, gd, [_this.xaxis], [_this.yaxis], _this.id, _this.dragOptions); } if(clickMode.indexOf('event') > -1) { @@ -595,7 +606,7 @@ proto.initInteractions = function() { }) .attr('d', 'M0,0Z'); - clearSelect(gd); + _this.clearSelect(gd); } function getAFrac(x, y) { return 1 - (y / _this.h); } @@ -745,7 +756,7 @@ proto.initInteractions = function() { dragElement.unhover(gd, evt); }; - dragElement.init(dragOptions); + dragElement.init(this.dragOptions); }; function removeZoombox(gd) { diff --git a/src/traces/scattergl/plot.js b/src/traces/scattergl/plot.js index 2b0aec67917..a7d3c074f2e 100644 --- a/src/traces/scattergl/plot.js +++ b/src/traces/scattergl/plot.js @@ -14,6 +14,7 @@ var createError = require('regl-error2d'); var Text = require('gl-text'); var Lib = require('../../lib'); +var selectMode = require('../../components/dragelement/helpers').selectMode; var prepareRegl = require('../../lib/prepare_regl'); var subTypes = require('../scatter/subtypes'); @@ -246,7 +247,7 @@ module.exports = function plot(gd, subplot, cdata) { // form batch arrays, and check for selected points var dragmode = fullLayout.dragmode; - var selectMode = dragmode === 'lasso' || dragmode === 'select'; + var isSelectMode = selectMode(dragmode); var clickSelectEnabled = fullLayout.clickmode.indexOf('select') > -1; for(i = 0; i < count; i++) { @@ -258,8 +259,8 @@ module.exports = function plot(gd, subplot, cdata) { var x = stash.x; var y = stash.y; - if(trace.selectedpoints || selectMode || clickSelectEnabled) { - if(!selectMode) selectMode = true; + if(trace.selectedpoints || isSelectMode || clickSelectEnabled) { + if(!isSelectMode) isSelectMode = true; // regenerate scene batch, if traces number changed during selection if(trace.selectedpoints) { @@ -292,7 +293,7 @@ module.exports = function plot(gd, subplot, cdata) { } } - if(selectMode) { + if(isSelectMode) { // create scatter instance by cloning scatter2d if(!scene.select2d) { scene.select2d = createScatter(fullLayout._glcanvas.data()[1].regl); diff --git a/src/traces/splom/plot.js b/src/traces/splom/plot.js index ed46850c5d3..94a6bb12de7 100644 --- a/src/traces/splom/plot.js +++ b/src/traces/splom/plot.js @@ -12,6 +12,7 @@ var createMatrix = require('regl-splom'); var Lib = require('../../lib'); var AxisIDs = require('../../plots/cartesian/axis_ids'); +var selectMode = require('../../components/dragelement/helpers').selectMode; module.exports = function plot(gd, _, splomCalcData) { if(!splomCalcData.length) return; @@ -78,11 +79,11 @@ function plotOne(gd, cd0) { } var clickSelectEnabled = fullLayout.clickmode.indexOf('select') > -1; - var selectMode = dragmode === 'lasso' || dragmode === 'select' || + var isSelectMode = selectMode(dragmode) || !!trace.selectedpoints || clickSelectEnabled; var needsBaseUpdate = true; - if(selectMode) { + if(isSelectMode) { var commonLength = trace._length; // regenerate scene batch, if traces number changed during selection diff --git a/test/jasmine/tests/cartesian_interact_test.js b/test/jasmine/tests/cartesian_interact_test.js index e3f2ea51cb6..2fdd891f83b 100644 --- a/test/jasmine/tests/cartesian_interact_test.js +++ b/test/jasmine/tests/cartesian_interact_test.js @@ -1946,7 +1946,7 @@ describe('axis zoom/pan and main plot zoom', function() { hasDragData: true, selectingCnt: 1, selectedCnt: 0, - selectOutline: 'M20,20L20,220L220,220L220,20L20,20Z' + selectOutline: 'M20,20L20,220L220,220L220,20Z' })) .then(delay(100)) .then(_assert('while holding on mouse', { @@ -1954,7 +1954,7 @@ describe('axis zoom/pan and main plot zoom', function() { hasDragData: true, selectingCnt: 1, selectedCnt: 0, - selectOutline: 'M20,20L20,220L220,220L220,20L20,20Z' + selectOutline: 'M20,20L20,220L220,220L220,20Z' })) .then(drag.end); }) diff --git a/test/jasmine/tests/draw_newshape_test.js b/test/jasmine/tests/draw_newshape_test.js new file mode 100644 index 00000000000..83c10f8b505 --- /dev/null +++ b/test/jasmine/tests/draw_newshape_test.js @@ -0,0 +1,1428 @@ +var parseSvgPath = require('parse-svg-path'); + +var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var failTest = require('../assets/fail_test'); +var selectButton = require('../assets/modebar_button'); +var mouseEvent = require('../assets/mouse_event'); +var touchEvent = require('../assets/touch_event'); +var click = require('../assets/click'); + +function drag(path, options) { + var len = path.length; + + if(!options) options = { type: 'mouse' }; + + if(options.type === 'touch') { + touchEvent('touchstart', path[0][0], path[0][1], options); + + path.slice(1, len).forEach(function(pt) { + touchEvent('touchmove', pt[0], pt[1], options); + }); + + touchEvent('touchend', path[len - 1][0], path[len - 1][1], options); + return; + } + + mouseEvent('mousemove', path[0][0], path[0][1], options); + mouseEvent('mousedown', path[0][0], path[0][1], options); + + path.slice(1, len).forEach(function(pt) { + mouseEvent('mousemove', pt[0], pt[1], options); + }); + + mouseEvent('mouseup', path[len - 1][0], path[len - 1][1], options); +} + +function print(obj) { + // console.log(JSON.stringify(obj, null, 4).replace(/"/g, '\'')); + return obj; +} + +function assertPos(actual, expected, tolerance) { + if(tolerance === undefined) tolerance = 2; + + expect(typeof actual).toEqual(typeof expected); + + if(typeof actual === 'string') { + if(expected.indexOf('_') !== -1) { + actual = fixDates(actual); + expected = fixDates(expected); + } + + var cmd1 = parseSvgPath(actual); + var cmd2 = parseSvgPath(expected); + + expect(cmd1.length).toEqual(cmd2.length); + for(var i = 0; i < cmd1.length; i++) { + var A = cmd1[i]; + var B = cmd2[i]; + expect(A.length).toEqual(B.length); // svg letters should be identical + expect(A[0]).toEqual(B[0]); + for(var k = 1; k < A.length; k++) { + expect(A[k]).toBeCloseTo(B[k], tolerance); + } + } + } else { + var o1 = Object.keys(actual); + var o2 = Object.keys(expected); + expect(o1.length === o2.length); + for(var j = 0; j < o1.length; j++) { + var key = o1[j]; + + var posA = actual[key]; + var posB = expected[key]; + + if(typeof posA === 'string') { + posA = fixDates(posA); + posB = fixDates(posB); + } + + expect(posA).toBeCloseTo(posB, tolerance); + } + } +} + +function fixDates(str) { + // hack to conver date axes to some numbers to parse with parse-svg-path + return str.replace(/[ _\-:]/g, ''); +} + +describe('Draw new shapes to layout', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + var allMocks = [ + { + name: 'heatmap', + json: require('@mocks/13'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M3.603343465045593,16.95098039215686L5.123100303951368,18.91176470588235L6.034954407294833,18.91176470588235L3.603343465045593,16.95098039215686' + ); + }, + function(pos) { + return assertPos(pos, + 'M1.3237082066869301,17.931372549019606L4.363221884498481,17.931372549019606L4.363221884498481,14.009803921568627L1.3237082066869301,14.009803921568627Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': 3.603343465045593, + 'y0': 14.990196078431373, + 'x1': 6.642857142857143, + 'y1': 11.068627450980392 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 2.0835866261398177, + 'y0': 16.95098039215686, + 'x1': 0.5638297872340426, + 'y1': 18.91176470588235 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -0.06567410694999332, + 'y0': 12.21722830907236, + 'x1': 4.232847359229629, + 'y1': 17.763163847790384 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 3.6033434650455933, + 'y0': 13.029411764705882, + 'x1': 0.5638297872340421, + 'y1': 16.950980392156865 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.5638297872340419, + 'y0': 16.950980392156865, + 'x1': 3.6033434650455938, + 'y1': 13.029411764705882 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 3.1582169926847232, + 'y0': 5.284808885674838, + 'x1': 1.0089562595949124, + 'y1': 24.695583271187907 + }); + } + ] + }, + { + name: 'log axis', + json: require('@mocks/12'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M7315.010246711367,81.03588258089053L11872.300299303395,86.01381805862732L14606.674330858614,86.01381805862732L7315.010246711367,81.03588258089053' + ); + }, + function(pos) { + return assertPos(pos, + 'M479.0751678233218,83.52485031975893L9593.655273007382,83.52485031975893L9593.655273007382,73.56897936428534L479.0751678233218,73.56897936428534Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': 7315.010246711367, + 'y0': 76.05794710315374, + 'x1': 16429.590351895426, + 'y1': 66.10207614768017 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 2757.7201941193366, + 'y0': 81.03588258089053, + 'x1': -1799.5698584726929, + 'y1': 86.01381805862732 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -3687.2612059243093, + 'y0': 69.01808323792017, + 'x1': 9202.701594162983, + 'y1': 83.09781096838731 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 7315.0102467113675, + 'y0': 71.08001162541694, + 'x1': -1799.5698584726952, + 'y1': 81.03588258089053 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -1799.5698584726942, + 'y0': 81.03588258089053, + 'x1': 7315.010246711368, + 'y1': 71.08001162541694 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 5980.210894141159, + 'y0': 51.41842357483628, + 'x1': -464.77050590248655, + 'y1': 100.69747063147119 + }); + } + ] + }, + { + name: 'date axis', + json: require('@mocks/29'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M2014-04-13_03:31:06.4962,105.53339869281045L2014-04-13_08:01:18.9023,111.99745098039214L2014-04-13_10:43:26.3459,111.99745098039214L2014-04-13_03:31:06.4962,105.53339869281045' + ); + }, + function(pos) { + return assertPos(pos, + 'M2014-04-12_20:45:47.8872,108.7654248366013L2014-04-13_05:46:12.6992,108.7654248366013L2014-04-13_05:46:12.6992,95.8373202614379L2014-04-12_20:45:47.8872,95.8373202614379Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2014-04-13 03:31:06.4962', + 'y0': 99.06934640522876, + 'x1': '2014-04-13 12:31:31.3083', + 'y1': 86.14124183006535 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2014-04-12 23:00:54.0902', + 'y0': 105.53339869281045, + 'x1': '2014-04-12 18:30:41.6842', + 'y1': 111.99745098039214 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2014-04-12 16:38:46.5056', + 'y0': 89.9277959922419, + 'x1': '2014-04-13 05:23:01.6748', + 'y1': 108.21089681821562 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2014-04-13 03:31:06.4962', + 'y0': 92.60529411764705, + 'x1': '2014-04-12 18:30:41.6842', + 'y1': 105.53339869281047 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2014-04-12 18:30:41.6842', + 'y0': 105.53339869281047, + 'x1': '2014-04-13 03:31:06.4962', + 'y1': 92.60529411764705 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2014-04-13 02:11:59.5038', + 'y0': 67.07391995977471, + 'x1': '2014-04-12 19:49:48.6767', + 'y1': 131.0647728506828 + }); + } + ] + }, + { + name: 'date and log axes together', + json: require('@mocks/cliponaxis_false-dates-log'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M5290.268558951965,2017-11-19_16:54:39.7153L5608.318049490538,2017-11-20_09:59:34.3772L5799.147743813683,2017-11-20_09:59:34.3772L5290.268558951965,2017-11-19_16:54:39.7153' + ); + }, + function(pos) { + return assertPos(pos, + 'M4813.194323144105,2017-11-20_01:27:07.0463L5449.293304221252,2017-11-20_01:27:07.0463L5449.293304221252,2017-11-18_15:17:17.7224L4813.194323144105,2017-11-18_15:17:17.7224Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': 5290.268558951965, + 'y0': '2017-11-18 23:49:45.0534', + 'x1': 5926.367540029112, + 'y1': '2017-11-17 13:39:55.7295' + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4972.219068413392, + 'y0': '2017-11-19 16:54:39.7153', + 'x1': 4654.169577874818, + 'y1': '2017-11-20 09:59:34.3772' + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4522.429165387887, + 'y0': '2017-11-17 23:40:19.3025', + 'x1': 5422.008971438897, + 'y1': '2017-11-19 23:59:10.8043' + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 5290.268558951966, + 'y0': '2017-11-18 06:44:50.3915', + 'x1': 4654.169577874818, + 'y1': '2017-11-19 16:54:39.7153' + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4654.169577874818, + 'y0': '2017-11-19 16:54:39.7153', + 'x1': 5290.268558951966, + 'y1': '2017-11-18 06:44:50.3915' + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 5197.114019926144, + 'y0': '2017-11-15 11:16:38.7758', + 'x1': 4747.324116900641, + 'y1': '2017-11-22 12:22:51.331' + }); + } + ] + }, + { + name: 'axes with rangebreaks', + json: require('@mocks/axes_breaks-gridlines'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M2015-06-01_11:36:29.5216,132.73855280509412L2015-07-26_21:54:15.5809,137.67764538538205L2015-08-29_04:04:55.2164,137.67764538538205L2015-06-01_11:36:29.5216,132.73855280509412' + ); + }, + function(pos) { + return assertPos(pos, + 'M2015-03-10_08:09:50.4328,135.20809909523808L2015-06-29_04:45:22.5512,135.20809909523808L2015-06-29_04:45:22.5512,125.32991393466223L2015-03-10_08:09:50.4328,125.32991393466223Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2015-06-01 11:36:29.5216', + 'y0': 127.7994602248062, + 'x1': '2015-09-20 08:12:01.6401', + 'y1': 117.92127506423034 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2015-04-07 01:18:43.4624', + 'y0': 132.73855280509412, + 'x1': '2015-02-10 15:00:57.4032', + 'y1': 137.67764538538205 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2015-01-18 16:00:26.2414', + 'y0': 120.81452851194669, + 'x1': '2015-06-24 10:37:00.6834', + 'y1': 134.7843919376657 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2015-06-01 11:36:29.5216', + 'y0': 122.86036764451828, + 'x1': '2015-02-10 15:00:57.4032', + 'y1': 132.73855280509412 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2015-02-10 15:00:57.4032', + 'y0': 132.73855280509412, + 'x1': '2015-06-01 11:36:29.5216', + 'y1': 122.86036764451825 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': '2015-05-16 06:05:50.9795', + 'y0': 103.35219922979789, + 'x1': '2015-02-26 20:31:35.9453', + 'y1': 152.2467212198145 + }); + } + ] + }, + { + name: 'subplot', + json: require('@mocks/18'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M4.933775889537972,7.614950166112958L5.2524163568773234,8.013621262458473L5.443600637280936,8.013621262458473L4.933775889537972,7.614950166112958' + ); + }, + function(pos) { + return assertPos(pos, + 'M4.455815188528943,7.814285714285716L5.093096123207648,7.814285714285716L5.093096123207648,7.016943521594685L4.455815188528943,7.016943521594685Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4.933775889537972, + 'y0': 7.216279069767443, + 'x1': 5.571056824216676, + 'y1': 6.418936877076413 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4.61513542219862, + 'y0': 7.614950166112958, + 'x1': 4.296494954859267, + 'y1': 8.013621262458473 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4.164509751766406, + 'y0': 6.652472998389465, + 'x1': 5.065761092630833, + 'y1': 7.78008514114542 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4.933775889537972, + 'y0': 6.817607973421927, + 'x1': 4.296494954859267, + 'y1': 7.614950166112958 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4.296494954859267, + 'y0': 7.614950166112958, + 'x1': 4.933775889537972, + 'y1': 6.817607973421927 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 4.840448257414727, + 'y0': 5.24295781994452, + 'x1': 4.389822586982512, + 'y1': 9.189600319590365 + }); + } + ] + }, + { + name: 'scattergl', + json: require('@mocks/gl2d_scatter2d-multiple-colors'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M-678.5714285714287,875.5760368663595L-500.00000000000006,1105.9907834101382L-392.8571428571429,1105.9907834101382L-678.5714285714287,875.5760368663595' + ); + }, + function(pos) { + return assertPos(pos, + 'M-946.4285714285716,990.7834101382489L-589.2857142857143,990.7834101382489L-589.2857142857143,529.9539170506913L-946.4285714285716,529.9539170506913Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': -678.5714285714287, + 'y0': 645.1612903225806, + 'x1': -321.42857142857144, + 'y1': 184.33179723502303 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -857.1428571428572, + 'y0': 875.5760368663595, + 'x1': -1035.7142857142858, + 'y1': 1105.9907834101382 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -1109.68099328091, + 'y0': 319.3056307896095, + 'x1': -604.6047210048046, + 'y1': 971.0169498555517 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -678.5714285714286, + 'y0': 414.74654377880177, + 'x1': -1035.7142857142858, + 'y1': 875.5760368663595 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -1035.7142857142858, + 'y0': 875.5760368663595, + 'x1': -678.5714285714286, + 'y1': 414.74654377880177 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -730.8737890738308, + 'y0': -495.3335180428189, + 'x1': -983.4119252118836, + 'y1': 1785.65609868798 + }); + } + ] + }, + { + name: 'cheater', + json: require('@mocks/cheater'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M0.08104371867979952,10.021132897603486L0.19336443753240018,10.84248366013072L0.26075686884396054,10.84248366013072L0.08104371867979952,10.021132897603486' + ); + }, + function(pos) { + return assertPos(pos, + 'M-0.08743735959910146,10.431808278867104L0.13720407810609983,10.431808278867104L0.13720407810609983,8.789106753812636L-0.08743735959910146,8.789106753812636Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.08104371867979952, + 'y0': 9.199782135076253, + 'x1': 0.3056851563850008, + 'y1': 7.557080610021787 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -0.03127700017280113, + 'y0': 10.021132897603486, + 'x1': -0.14359771902540178, + 'y1': 10.84248366013072 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -0.19012248410964433, + 'y0': 8.038216747244757, + 'x1': 0.12756848376404212, + 'y1': 10.36134752290775 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.08104371867979954, + 'y0': 8.378431372549018, + 'x1': -0.14359771902540183, + 'y1': 10.021132897603488 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': -0.1435977190254018, + 'y0': 10.021132897603488, + 'x1': 0.08104371867979956, + 'y1': 8.378431372549018 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.0481457417956205, + 'y0': 5.134303277666014, + 'x1': -0.11069974214122275, + 'y1': 13.265260992486493 + }); + } + ] + }, + { + name: 'box plot', + json: require('@mocks/1'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M492.4445277361319,7.3824607089211325L509.46101949025484,8.652380340970662L519.6709145427286,8.652380340970662L492.4445277361319,7.3824607089211325' + ); + }, + function(pos) { + return assertPos(pos, + 'M466.9197901049475,8.017420524945898L500.95277361319336,8.017420524945898L500.95277361319336,5.477581260846837L466.9197901049475,5.477581260846837Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': 492.4445277361319, + 'y0': 6.112541076871603, + 'x1': 526.4775112443778, + 'y1': 3.572701812772542 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 475.42803598200896, + 'y0': 7.3824607089211325, + 'x1': 458.411544227886, + 'y1': 8.652380340970662 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 451.3630825593184, + 'y0': 4.316603510103307, + 'x1': 499.49298940469953, + 'y1': 7.908478643639898 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 492.4445277361319, + 'y0': 4.842621444822074, + 'x1': 458.41154422788605, + 'y1': 7.382460708921132 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 458.41154422788605, + 'y0': 7.382460708921133, + 'x1': 492.4445277361319, + 'y1': 4.842621444822072 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 487.4605126933543, + 'y0': -0.1732404068174329, + 'x1': 463.3955592706636, + 'y1': 12.398322560560638 + }); + } + ] + }, + { + name: 'mapbox', + json: require('@mocks/mapbox_angles'), + testPos: [ + function(pos) { + return assertPos(pos, + 'M0.2076923076923077,0.8725490196078431L0.2846153846153846,0.9705882352941176L0.33076923076923076,0.9705882352941176L0.2076923076923077,0.8725490196078431' + ); + }, + function(pos) { + return assertPos(pos, + 'M0.09230769230769231,0.9215686274509804L0.24615384615384617,0.9215686274509804L0.24615384615384617,0.7254901960784313L0.09230769230769231,0.7254901960784313Z' + ); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.2076923076923077, + 'y0': 0.7745098039215687, + 'x1': 0.36153846153846153, + 'y1': 0.5784313725490196 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.13076923076923078, + 'y0': 0.8725490196078431, + 'x1': 0.05384615384615385, + 'y1': 0.9705882352941176 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.021983572125146553, + 'y0': 0.6358614154536182, + 'x1': 0.23955488941331504, + 'y1': 0.9131581923895189 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.2076923076923077, + 'y0': 0.6764705882352943, + 'x1': 0.053846153846153794, + 'y1': 0.872549019607843 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.053846153846153835, + 'y0': 0.872549019607843, + 'x1': 0.2076923076923078, + 'y1': 0.6764705882352943 + }); + }, + function(pos) { + return assertPos(pos, { + 'x0': 0.1851620600912729, + 'y0': 0.3862943162113073, + 'x1': 0.07637640144718866, + 'y1': 1.1627252916318298 + }); + } + ] + } + ]; + + allMocks.forEach(function(mockItem) { + ['mouse', 'touch'].forEach(function(device) { + var _drag = function(path) { + return drag(path, {type: device}); + }; + + it('@flaky draw various shape types over mock ' + mockItem.name + ' using ' + device, function(done) { + var fig = Lib.extendDeep({}, mockItem.json); + fig.layout = { + width: 800, + height: 600, + margin: { + t: 60, + l: 40, + r: 20, + b: 30 + } + }; + + var n; + Plotly.newPlot(gd, { + data: fig.data, + layout: fig.layout, + config: { + mapboxAccessToken: require('@build/credentials.json').MAPBOX_ACCESS_TOKEN + } + }) + .then(function() { + n = gd._fullLayout.shapes.length; // initial number of shapes on _fullLayout + }) + + .then(function() { + var newFig = Lib.extendFlat({}, fig); + + newFig.layout.dragmode = 'drawopenpath'; + + return Plotly.react(gd, newFig); + }) + .then(function() { + return _drag([[175, 125], [225, 75], [255, 75], [175, 125]]); + }) + .then(function() { + var shapes = gd._fullLayout.shapes; + expect(shapes.length).toEqual(++n); + var obj = shapes[n - 1]._input; + expect(obj.type).toEqual('path'); + print(obj); + mockItem.testPos[n - 1](obj.path); + }) + + .then(function() { + var newFig = Lib.extendFlat({}, fig); + + newFig.layout.dragmode = 'drawclosedpath'; + + return Plotly.react(gd, newFig); + }) + .then(function() { + return _drag([[100, 100], [200, 100], [200, 200], [100, 200]]); + }) + .then(function() { + var shapes = gd._fullLayout.shapes; + expect(shapes.length).toEqual(++n); + var obj = shapes[n - 1]._input; + expect(obj.type).toEqual('path'); + print(obj); + mockItem.testPos[n - 1](obj.path); + }) + + .then(function() { + var newFig = Lib.extendFlat({}, fig); + + newFig.layout.dragmode = 'drawrect'; + + return Plotly.react(gd, newFig); + }) + .then(function() { + return _drag([[175, 175], [275, 275]]); + }) + .then(function() { + var shapes = gd._fullLayout.shapes; + expect(shapes.length).toEqual(++n); + var obj = shapes[n - 1]._input; + print(obj); + mockItem.testPos[n - 1]({ + x0: obj.x0, + y0: obj.y0, + x1: obj.x1, + y1: obj.y1 + }); + }) + + .then(function() { + var newFig = Lib.extendFlat({}, fig); + + newFig.layout.dragmode = 'drawline'; + + return Plotly.react(gd, newFig); + }) + .then(function() { + return _drag([[125, 125], [75, 75]]); + }) + .then(function() { + var shapes = gd._fullLayout.shapes; + expect(shapes.length).toEqual(++n); + var obj = shapes[n - 1]._input; + expect(obj.type).toEqual('line'); + print(obj); + mockItem.testPos[n - 1]({ + x0: obj.x0, + y0: obj.y0, + x1: obj.x1, + y1: obj.y1 + }); + }) + + .then(function() { + var newFig = Lib.extendFlat({}, fig); + + newFig.layout.dragmode = 'drawcircle'; + + return Plotly.react(gd, newFig); + }) + .then(function() { + return _drag([[125, 175], [75, 225]]); + }) + .then(function() { + var shapes = gd._fullLayout.shapes; + expect(shapes.length).toEqual(++n); + var obj = shapes[n - 1]._input; + expect(obj.type).toEqual('circle'); + print(obj); + mockItem.testPos[n - 1]({ + x0: obj.x0, + y0: obj.y0, + x1: obj.x1, + y1: obj.y1 + }); + }) + + .then(function() { + var newFig = Lib.extendFlat({}, fig); + + newFig.layout.dragmode = 'drawcircle'; + + return Plotly.react(gd, newFig); + }) + .then(function() { + return _drag([[125, 175], [126, 225]]); // dx close to 0 should draw a circle not an ellipse + }) + .then(function() { + var shapes = gd._fullLayout.shapes; + expect(shapes.length).toEqual(++n); + var obj = shapes[n - 1]._input; + expect(obj.type).toEqual('circle'); + print(obj); + mockItem.testPos[n - 1]({ + x0: obj.x0, + y0: obj.y0, + x1: obj.x1, + y1: obj.y1 + }); + }) + .then(function() { + return _drag([[125, 175], [75, 176]]); // dy close to 0 should draw a circle not an ellipse + }) + .then(function() { + var shapes = gd._fullLayout.shapes; + expect(shapes.length).toEqual(++n); + var obj = shapes[n - 1]._input; + expect(obj.type).toEqual('circle'); + print(obj); + mockItem.testPos[n - 1]({ + x0: obj.x0, + y0: obj.y0, + x1: obj.x1, + y1: obj.y1 + }); + }) + .then(function() { + return _drag([[125, 175], [150, 350]]); // ellipse + }) + .then(function() { + var shapes = gd._fullLayout.shapes; + expect(shapes.length).toEqual(++n); + var obj = shapes[n - 1]._input; + expect(obj.type).toEqual('circle'); + print(obj); + mockItem.testPos[n - 1]({ + x0: obj.x0, + y0: obj.y0, + x1: obj.x1, + y1: obj.y1 + }); + }) + + .catch(failTest) + .then(done); + }); + }); + }); +}); + +describe('Activate and edit editable shapes', function() { + var fig = { + 'data': [ + { + 'x': [ + 0, + 50 + ], + 'y': [ + 0, + 50 + ] + } + ], + 'layout': { + 'width': 800, + 'height': 600, + 'margin': { + 't': 100, + 'b': 50, + 'l': 100, + 'r': 50 + }, + 'yaxis': { + 'autorange': 'reversed' + }, + 'template': { + 'layout': { + 'shapes': [ + { + 'name': 'myPath', + 'editable': true, + 'layer': 'below', + 'line': { + 'width': 0 + }, + 'fillcolor': 'gray', + 'opacity': 0.5, + 'xref': 'paper', + 'yref': 'paper', + 'path': 'M0.5,0.3C0.5,0.9 0.9,0.9 0.9,0.3C0.9,0.1 0.5,0.1 0.5,0.3ZM0.6,0.4C0.6,0.5 0.66,0.5 0.66,0.4ZM0.74,0.4C0.74,0.5 0.8,0.5 0.8,0.4ZM0.6,0.3C0.63,0.2 0.77,0.2 0.8,0.3Z' + } + ] + } + }, + 'shapes': [ + { + 'editable': true, + 'layer': 'below', + 'type': 'rect', + 'line': { + 'width': 5 + }, + 'fillcolor': 'red', + 'opacity': 0.5, + 'xref': 'xaxis', + 'yref': 'yaxis', + 'x0': 25, + 'y0': 25, + 'x1': 75, + 'y1': 75 + }, + { + 'editable': true, + 'layer': 'top', + 'type': 'circle', + 'line': { + 'width': 5 + }, + 'fillcolor': 'green', + 'opacity': 0.5, + 'xref': 'xaxis', + 'yref': 'yaxis', + 'x0': 125, + 'y0': 25, + 'x1': 175, + 'y1': 75 + }, + { + 'editable': true, + 'line': { + 'width': 5 + }, + 'fillcolor': 'blue', + 'path': 'M250,25L225,75L275,75Z' + }, + { + 'editable': true, + 'line': { + 'width': 15 + }, + 'path': 'M250,225L225,275L275,275' + }, + { + 'editable': true, + 'layer': 'below', + 'path': 'M320,100C390,180 290,180 360,100Z', + 'fillcolor': 'rgba(0,127,127,0.5)', + 'line': { + 'width': 5 + } + }, + { + 'editable': true, + 'line': { + 'width': 5, + 'color': 'orange' + }, + 'fillcolor': 'rgba(127,255,127,0.5)', + 'path': 'M0,100V200H50L0,300Q100,300 100,200T150,200C100,300 200,300 200,200S150,200 150,100Z' + }, + { + 'editable': true, + 'line': { + 'width': 2 + }, + 'fillcolor': 'yellow', + 'path': 'M300,70C300,10 380,10 380,70C380,90 300,90 300,70ZM320,60C320,50 332,50 332,60ZM348,60C348,50 360,50 360,60ZM320,70C326,80 354,80 360,70Z' + } + ] + }, + 'config': { + 'editable': false, + 'modeBarButtonsToAdd': [ + 'drawline', + 'drawopenpath', + 'drawclosedpath', + 'drawcircle', + 'drawrect', + 'eraseshape' + ] + } + }; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + ['mouse'].forEach(function(device) { + it('@flaky reactangle using' + device, function(done) { + var i = 0; // shape index + + Plotly.newPlot(gd, { + data: fig.data, + layout: fig.layout, + config: fig.config + }) + + // shape between 175, 160 and 255, 230 + .then(function() { click(200, 160); }) // activate shape + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'activate shape by clicking border'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + expect(obj.type).toEqual('rect'); + print(obj); + assertPos({ + 'x0': obj.x0, + 'y0': obj.y0, + 'x1': obj.x1, + 'y1': obj.y1 + }, { + 'x0': 25, + 'y0': 25, + 'x1': 75, + 'y1': 75 + }); + }) + .then(function() { drag([[255, 230], [300, 200]]); }) // move vertex + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + expect(obj.type).toEqual('rect'); + print(obj); + assertPos({ + 'x0': obj.x0, + 'y0': obj.y0, + 'x1': obj.x1, + 'y1': obj.y1 + }, { + 'x0': 24.998449612403103, + 'y0': 24.997032640949552, + 'x1': 102.90852713178295, + 'y1': 53.63323442136499 + }); + }) + .then(function() { drag([[300, 200], [255, 230]]); }) // move vertex back + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + expect(obj.type).toEqual('rect'); + print(obj); + assertPos({ + 'x0': obj.x0, + 'y0': obj.y0, + 'x1': obj.x1, + 'y1': obj.y1 + }, { + 'x0': 25, + 'y0': 25, + 'x1': 75, + 'y1': 75 + }); + }) + .then(function() { drag([[215, 195], [300, 200]]); }) // move shape + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + expect(obj.type).toEqual('rect'); + print(obj); + assertPos({ + 'x0': obj.x0, + 'y0': obj.y0, + 'x1': obj.x1, + 'y1': obj.y1 + }, { + 'x0': 77.71162790697674, + 'y0': 24.997032640949552, + 'x1': 127.71472868217053, + 'y1': 74.99821958456974 + }); + }) + .then(function() { drag([[300, 200], [215, 195]]); }) // move shape back + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + expect(obj.type).toEqual('rect'); + print(obj); + assertPos({ + 'x0': obj.x0, + 'y0': obj.y0, + 'x1': obj.x1, + 'y1': obj.y1 + }, { + 'x0': 25, + 'y0': 25, + 'x1': 75, + 'y1': 75 + }); + }) + .then(function() { click(100, 100); }) + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(undefined, 'deactivate shape by clicking outside'); + }) + .then(function() { click(255, 230); }) + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'activate shape by clicking on corner'); + }) + .then(function() { click(215, 195); }) + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(undefined, 'deactivate shape by clicking inside'); + }) + + .catch(failTest) + .then(done); + }); + + it('@flaky circle using' + device, function(done) { + var i = 1; // shape index + + Plotly.newPlot(gd, { + data: fig.data, + layout: fig.layout, + config: fig.config + }) + + // next shape + .then(function() { click(355, 225); }) // activate shape + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'activate shape by clicking border'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + expect(obj.type).toEqual('circle'); + print(obj); + assertPos({ + 'x0': obj.x0, + 'y0': obj.y0, + 'x1': obj.x1, + 'y1': obj.y1 + }, { + 'x0': 125, + 'x1': 175, + 'y0': 25, + 'y1': 75 + }); + }) + .then(function() { drag([[338, 196], [300, 175]]); }) // move vertex + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + expect(obj.type).toEqual('circle'); + print(obj); + assertPos({ + 'x0': obj.x0, + 'y0': obj.y0, + 'x1': obj.x1, + 'y1': obj.y1 + }, { + 'x0': 186.78449612403102, + 'y0': 74.99821958456971, + 'x1': 113.21550387596898, + 'y1': 10.04154302670623 + }); + }) + + .catch(failTest) + .then(done); + }); + + it('@flaky closed-path using' + device, function(done) { + var i = 2; // shape index + + Plotly.newPlot(gd, { + data: fig.data, + layout: fig.layout, + config: fig.config + }) + + // next shape + .then(function() { click(500, 225); }) // activate shape + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'activate shape by clicking border'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M250,25L225,75L275,75Z'); + }) + .then(function() { drag([[540, 160], [500, 120]]); }) // move vertex + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M225.1968992248062,-3.4896142433234463L225,75L275,75Z'); + }) + .then(function() { drag([[500, 120], [540, 160]]); }) // move vertex back + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M250,25L225,75L275,75Z'); + }) + + .catch(failTest) + .then(done); + }); + + it('@flaky bezier curves using' + device, function(done) { + var i = 5; // shape index + + Plotly.newPlot(gd, { + data: fig.data, + layout: fig.layout, + config: fig.config + }) + + // next shape + .then(function() { click(300, 266); }) // activate shape + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'activate shape by clicking border'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M0,100V200H50L0,300Q100,300 100,200T150,200C100,300 200,300 200,200S150,200 150,100Z'); + }) + .then(function() { drag([[297, 407], [200, 300]]); }) // move vertex + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M0,100.00237388724034L0,199.99762611275966L50.00310077519379,199.99762611275966L0,300Q100,300,39.84496124031008,123.79584569732937T150.0031007751938,199.99762611275966C100,300,200,300,200,199.99762611275966S150.0031007751938,199.99762611275966,150.0031007751938,100.00237388724034Z'); + }) + .then(function() { drag([[200, 300], [297, 407]]); }) // move vertex back + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M0,100.00237388724034L0,199.99762611275966L50.00310077519379,199.99762611275966L0,300Q100,300,100,199.9976261127597T150.0031007751938,199.99762611275966C100,300,200,300,200,199.99762611275966S150.0031007751938,199.99762611275966,150.0031007751938,100.00237388724034Z'); + }) + + .catch(failTest) + .then(done); + }); + + it('@flaky multi-cell path using' + device, function(done) { + var i = 6; // shape index + + Plotly.newPlot(gd, { + data: fig.data, + layout: fig.layout, + config: fig.config + }) + + .then(function() { click(627, 193); }) // activate shape + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'activate shape by clicking border'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M300,70C300,10 380,10 380,70C380,90 300,90 300,70ZM320,60C320,50 332,50 332,60ZM348,60C348,50 360,50 360,60ZM320,70C326,80 354,80 360,70Z'); + }) + .then(function() { drag([[717, 225], [700, 250]]); }) // move vertex + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M300,69.99881305637984C300,9.998813056379817,380,9.998813056379817,380,69.99881305637984C380,90.00356083086054,300,90.00356083086054,300,69.99881305637984ZM320,60.00000000000001C320,50.00118694362017,332,50.00118694362017,332,60.00000000000001ZM348,60.00000000000001C348,50.00118694362017,360,50.00118694362017,360,60.00000000000001ZM320,69.99881305637984C326.0031007751938,79.99762611275966,354.0031007751938,79.99762611275966,349.4573643410853,87.80296735905047Z'); + }) + .then(function() { drag([[700, 250], [717, 225]]); }) // move vertex back + .then(function() { + var id = gd._fullLayout._activeShapeIndex; + expect(id).toEqual(i, 'keep shape active after drag corner'); + + var shapes = gd._fullLayout.shapes; + var obj = shapes[id]._input; + print(obj); + assertPos(obj.path, 'M300,70C300,10 380,10 380,70C380,90 300,90 300,70ZM320,60C320,50 332,50 332,60ZM348,60C348,50 360,50 360,60ZM320,70C326,80 354,80 360,70Z'); + }) + + // erase shape + .then(function() { + expect(gd._fullLayout.shapes.length).toEqual(8); + selectButton(gd._fullLayout._modeBar, 'eraseshape').click(); + }) + .then(function() { + expect(gd._fullLayout.shapes.length).toEqual(7); + expect(gd._fullLayout._activeShapeIndex).toEqual(undefined, 'clear active shape index'); + }) + + .catch(failTest) + .then(done); + }); + }); +}); diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js index c450a044201..66c7b7b9a9a 100644 --- a/test/jasmine/tests/modebar_test.js +++ b/test/jasmine/tests/modebar_test.js @@ -981,6 +981,29 @@ describe('ModeBar', function() { expect(function() { manageModeBar(gd); }).toThrowError(); }); + + it('add pre-defined buttons as strings for drawing shapes on cartesian subplot', function() { + var gd = setupGraphInfo(); + manageModeBar(gd); + + var initialGroupCount = countGroups(gd._fullLayout._modeBar); + var initialButtonCount = countButtons(gd._fullLayout._modeBar); + + gd._context.modeBarButtonsToAdd = [ + 'drawline', + 'drawopenpath', + 'drawclosedpath', + 'drawcircle', + 'drawrect', + 'eraseshape' + ]; + manageModeBar(gd); + + expect(countGroups(gd._fullLayout._modeBar)) + .toEqual(initialGroupCount + 0); // no new group - added inside the dragMode group + expect(countButtons(gd._fullLayout._modeBar)) + .toEqual(initialButtonCount + 6); + }); }); describe('modebar on clicks', function() { diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index ecf4f0689e3..fbcc5261fa3 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -1646,13 +1646,13 @@ describe('Test select box and lasso in general:', function() { .then(_drag(path1)) .then(function() { _assert('select path1', { - outline: [[150, 150], [150, 170], [170, 170], [170, 150], [150, 150]] + outline: [[150, 150], [150, 170], [170, 170], [170, 150]] }); }) .then(_drag(path2)) .then(function() { _assert('select path2', { - outline: [[193, 0], [193, 500], [213, 500], [213, 0], [193, 0]] + outline: [[193, 0], [193, 500], [213, 500], [213, 0]] }); }) .then(_drag(path1)) @@ -1660,8 +1660,8 @@ describe('Test select box and lasso in general:', function() { .then(function() { _assert('select path1+path2', { outline: [ - [170, 170], [170, 150], [150, 150], [150, 170], [170, 170], - [213, 500], [213, 0], [193, 0], [193, 500], [213, 500] + [170, 170], [170, 150], [150, 150], [150, 170], + [213, 500], [213, 0], [193, 0], [193, 500] ] }); }) @@ -1678,16 +1678,16 @@ describe('Test select box and lasso in general:', function() { // merged with previous 'select' polygon _assert('after shift lasso', { outline: [ - [170, 170], [170, 150], [150, 150], [150, 170], [170, 170], - [213, 500], [213, 0], [193, 0], [193, 500], [213, 500], - [335, 243], [328, 169], [316, 171], [318, 239], [335, 243] + [170, 170], [170, 150], [150, 150], [150, 170], + [213, 500], [213, 0], [193, 0], [193, 500], + [335, 243], [328, 169], [316, 171], [318, 239] ] }); }) .then(_drag(lassoPath)) .then(function() { _assert('after lasso (no-shift)', { - outline: [[316, 171], [318, 239], [335, 243], [328, 169], [316, 171]] + outline: [[316, 171], [318, 239], [335, 243], [328, 169]] }); }) .then(function() { @@ -1706,15 +1706,15 @@ describe('Test select box and lasso in general:', function() { .then(function() { // this used to merged 'lasso' polygons before (see #2669) _assert('shift select path1 after pan', { - outline: [[150, 150], [150, 170], [170, 170], [170, 150], [150, 150]] + outline: [[150, 150], [150, 170], [170, 170], [170, 150]] }); }) .then(_drag(path2, {shiftKey: true})) .then(function() { _assert('shift select path1+path2 after pan', { outline: [ - [170, 170], [170, 150], [150, 150], [150, 170], [170, 170], - [213, 500], [213, 0], [193, 0], [193, 500], [213, 500] + [170, 170], [170, 150], [150, 150], [150, 170], + [213, 500], [213, 0], [193, 0], [193, 500] ] }); }) @@ -1725,7 +1725,7 @@ describe('Test select box and lasso in general:', function() { .then(_drag(path1, {shiftKey: true})) .then(function() { _assert('shift select path1 after scroll', { - outline: [[150, 150], [150, 170], [170, 170], [170, 150], [150, 150]] + outline: [[150, 150], [150, 170], [170, 170], [170, 150]] }); }) .catch(failTest) diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index b6390dba30c..1a0950088c7 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -126,6 +126,37 @@ describe('Test shapes defaults:', function() { expect(shape2Out.y0).toBeWithin(1.5, 0.001); expect(shape2Out.y1).toBeWithin(5.5, 0.001); }); + + it('should not coerce line.color and line.dash when line.width is zero', function() { + var fullLayout = { + xaxis: {type: 'linear', range: [0, 1], _shapeIndices: []}, + yaxis: {type: 'log', range: [0, 1], _shapeIndices: []}, + _subplots: {xaxis: ['x'], yaxis: ['y']} + }; + + Axes.setConvert(fullLayout.xaxis); + Axes.setConvert(fullLayout.yaxis); + + var layoutIn = { + shapes: [{ + type: 'line', + xref: 'xaxis', + yref: 'yaxis', + x0: 0, + x1: 1, + y0: 1, + y1: 10, + line: { + width: 0 + } + }] + }; + + var shapes = _supply(layoutIn, fullLayout); + + expect(shapes[0].line.color).toEqual(undefined); + expect(shapes[0].line.dash).toEqual(undefined); + }); }); function countShapesInLowerLayer(gd) {