diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 1a9307cfb67..37947c093df 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -17,13 +17,13 @@ var Drawing = require('../drawing'); var arrayEditor = require('../../plot_api/plot_template').arrayEditor; var dragElement = require('../dragelement'); +var Fx = require('../fx'); var setCursor = require('../../lib/setcursor'); var constants = require('./constants'); var helpers = require('./helpers'); var getPathString = helpers.getPathString; - // Shapes are stored in gd.layout.shapes, an array of objects // index can point to one item in this array, // or non-numeric to simply add a new one @@ -37,7 +37,7 @@ module.exports = { draw: draw, drawOne: drawOne, eraseActiveShape: eraseActiveShape, - drawLabel: drawLabel, + drawLabel: drawLabel }; function draw(gd) { @@ -49,16 +49,16 @@ function draw(gd) { fullLayout._shapeUpperLayer.selectAll('text').remove(); fullLayout._shapeLowerLayer.selectAll('text').remove(); - for(var k in fullLayout._plots) { + for (var k in fullLayout._plots) { var shapelayer = fullLayout._plots[k].shapelayer; - if(shapelayer) { + if (shapelayer) { shapelayer.selectAll('path').remove(); shapelayer.selectAll('text').remove(); } } - for(var i = 0; i < fullLayout.shapes.length; i++) { - if(fullLayout.shapes[i].visible === true) { + for (var i = 0; i < fullLayout.shapes.length; i++) { + if (fullLayout.shapes[i].visible === true) { drawOne(gd, i); } } @@ -79,9 +79,7 @@ function couldHaveActiveShape(gd) { function drawOne(gd, index) { // remove the existing shape if there is one. // because indices can change, we need to look in all shape layers - gd._fullLayout._paperdiv - .selectAll('.shapelayer [data-index="' + index + '"]') - .remove(); + gd._fullLayout._paperdiv.selectAll('.shapelayer [data-index="' + index + '"]').remove(); var o = helpers.makeShapesOptionsAndPlotinfo(gd, index); var options = o.options; @@ -89,18 +87,18 @@ function drawOne(gd, index) { // this shape is gone - quit now after deleting it // TODO: use d3 idioms instead of deleting and redrawing every time - if(!options._input || options.visible !== true) return; + if (!options._input || options.visible !== true) return; const isMultiAxisShape = Array.isArray(options.xref) || Array.isArray(options.yref); - if(options.layer === 'above') { + if (options.layer === 'above') { drawShape(gd._fullLayout._shapeUpperLayer); - } else if(options.xref.includes('paper') || options.yref.includes('paper')) { + } else if (options.xref.includes('paper') || options.yref.includes('paper')) { drawShape(gd._fullLayout._shapeLowerLayer); - } else if(options.layer === 'between' && !isMultiAxisShape) { + } else if (options.layer === 'between' && !isMultiAxisShape) { drawShape(plotinfo.shapelayerBetween); } else { - if(plotinfo._hadPlotinfo) { + if (plotinfo._hadPlotinfo) { var mainPlot = plotinfo.mainplotinfo || plotinfo; drawShape(mainPlot.shapelayer); } else { @@ -124,7 +122,7 @@ function drawOne(gd, index) { var lineColor = options.line.width ? options.line.color : 'rgba(0,0,0,0)'; var lineWidth = options.line.width; var lineDash = options.line.dash; - if(!lineWidth && options.editable === true) { + if (!lineWidth && options.editable === true) { // ensure invisible border to activate the shape lineWidth = 5; lineDash = 'solid'; @@ -132,21 +130,18 @@ function drawOne(gd, index) { var isOpen = d[d.length - 1] !== 'Z'; - var isActiveShape = couldHaveActiveShape(gd) && - options.editable && gd._fullLayout._activeShapeIndex === index; + var isActiveShape = couldHaveActiveShape(gd) && options.editable && gd._fullLayout._activeShapeIndex === index; - if(isActiveShape) { - fillColor = isOpen ? 'rgba(0,0,0,0)' : - gd._fullLayout.activeshape.fillcolor; + if (isActiveShape) { + fillColor = isOpen ? 'rgba(0,0,0,0)' : gd._fullLayout.activeshape.fillcolor; opacity = gd._fullLayout.activeshape.opacity; } - var shapeGroup = shapeLayer.append('g') - .classed('shape-group', true) - .attr({ 'data-index': index }); + var shapeGroup = shapeLayer.append('g').classed('shape-group', true).attr({ 'data-index': index }); - var path = shapeGroup.append('path') + var path = shapeGroup + .append('path') .attr(attrs) .style('opacity', opacity) .call(Color.stroke, lineColor) @@ -159,11 +154,11 @@ function drawOne(gd, index) { drawLabel(gd, index, options, shapeGroup); var editHelpers; - if(isActiveShape || gd._context.edits.shapePosition) editHelpers = arrayEditor(gd.layout, 'shapes', options); + if (isActiveShape || gd._context.edits.shapePosition) editHelpers = arrayEditor(gd.layout, 'shapes', options); - if(isActiveShape) { + if (isActiveShape) { path.style({ - cursor: 'move', + cursor: 'move' }); var dragOptions = { @@ -179,18 +174,58 @@ function drawOne(gd, index) { // display polygons on the screen displayOutlines(polygons, path, dragOptions); } else { - if(gd._context.edits.shapePosition) { + if (gd._context.edits.shapePosition) { setupDragElement(gd, path, options, index, shapeLayer, editHelpers); - } else if(options.editable === true) { - path.style('pointer-events', - (isOpen || Color.opacity(fillColor) * opacity <= 0.5) ? 'stroke' : 'all' - ); + } else if (options.editable === true) { + path.style('pointer-events', isOpen || Color.opacity(fillColor) * opacity <= 0.5 ? 'stroke' : 'all'); } } - path.node().addEventListener('click', function() { return activateShape(gd, path); }); + path.node().addEventListener('click', function () { + return activateShape(gd, path); + }); + + // Editable / shape-position-edit shapes get inline pointer-events + // that prevent mouse events from reaching the maindrag, where + // hoveranywhere / clickanywhere are wired up. Forward those events + // from the shape so the layout-level events still fire. + forwardHoverClickAnywhere(gd, path, plotinfo); } } +function forwardHoverClickAnywhere(gd, path, plotinfo) { + if (!plotinfo?.id) return; + + const node = path.node(); + + // Fx.hover/Fx.click compute plot-area pixel coordinates from + // evt.clientX/Y relative to evt.target's bounding box. + // The shape path's bbox differs from the plot area, so we + // re-target events to the subplot's nsewdrag element. + function patchedEvt(evt) { + const mainPlot = plotinfo.mainplotinfo || plotinfo; + const nsew = mainPlot?.draglayer?.select('.nsewdrag').node(); + if (!nsew) return null; + + return { clientX: evt.clientX, clientY: evt.clientY, target: nsew }; + } + + node.addEventListener('mousemove', (evt) => { + if (gd._dragging) return; + if (gd._fullLayout.hoveranywhere) { + const e = patchedEvt(evt); + if (e) Fx.hover(gd, e, plotinfo.id); + } + }); + + node.addEventListener('click', (evt) => { + if (gd._dragged) return; + if (gd._fullLayout.clickanywhere) { + const e = patchedEvt(evt); + if (e) Fx.click(gd, e, plotinfo.id); + } + }); +} + function setClipPath(shapePath, gd, shapeOptions) { // note that for layer="below" the clipAxes can be different from the // subplot we're drawing this in. This could cause problems if the shape @@ -203,13 +238,15 @@ function setClipPath(shapePath, gd, shapeOptions) { const yref = shapeOptions.yref; // For multi-axis shapes, create a custom clip path from axis bounds - if(Array.isArray(xref) || Array.isArray(yref)) { + if (Array.isArray(xref) || Array.isArray(yref)) { const clipId = 'clip' + gd._fullLayout._uid + 'shape' + shapeOptions._index; const rect = getMultiAxisClipRect(gd, xref, yref); - Lib.ensureSingleById(gd._fullLayout._clips, 'clipPath', clipId, function(s) { + Lib.ensureSingleById(gd._fullLayout._clips, 'clipPath', clipId, function (s) { s.append('rect'); - }).select('rect').attr(rect); + }) + .select('rect') + .attr(rect); Drawing.setClipUrl(shapePath, clipId, gd); } else { @@ -223,25 +260,27 @@ function getMultiAxisClipRect(gd, xref, yref) { function getBounds(refs, isVertical) { // Retrieve all existing axes from the references - const axes = (Array.isArray(refs) ? refs : [refs]) - .map(r => Axes.getFromId(gd, r)) - .filter(Boolean); + const axes = (Array.isArray(refs) ? refs : [refs]).map((r) => Axes.getFromId(gd, r)).filter(Boolean); // If no valid axes, return the bounds of the larger plot area - if(!axes.length) { + if (!axes.length) { return isVertical ? [gs.t, gs.t + gs.h] : [gs.l, gs.l + gs.w]; } // Otherwise, we find all find and return the smallest start point // and largest end point to be used as the clip bounds - const startBounds = axes.map(function(ax) { return ax._offset; }); - const endBounds = axes.map(function(ax) { return ax._offset + ax._length; }); + const startBounds = axes.map(function (ax) { + return ax._offset; + }); + const endBounds = axes.map(function (ax) { + return ax._offset + ax._length; + }); return [Math.min(...startBounds), Math.max(...endBounds)]; } const xb = getBounds(xref, false); const yb = getBounds(yref, true); - return {x: xb[0], y: yb[0], width: xb[1] - xb[0], height: yb[1] - yb[0]}; + return { x: xb[0], y: yb[0], width: xb[1] - xb[0], height: yb[1] - yb[0] }; } function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHelpers) { @@ -270,11 +309,11 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe var shiftXEnd = shapeOptions.x1shift; var shiftYStart = shapeOptions.y0shift; var shiftYEnd = shapeOptions.y1shift; - var x2p = function(v, shift) { + var x2p = function (v, shift) { var dataToPixel = helpers.getDataToPixel(gd, xa, shift, false, xRefType); return dataToPixel(v); }; - var y2p = function(v, shift) { + var y2p = function (v, shift) { var dataToPixel = helpers.getDataToPixel(gd, ya, shift, true, yRefType); return dataToPixel(v); }; @@ -306,18 +345,14 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe // Helper shapes group // Note that by setting the `data-index` attr, it is ensured that // the helper group is purged in this modules `draw` function - var g = shapeLayer.append('g') - .attr('data-index', index) - .attr('drag-helper', true); + var g = shapeLayer.append('g').attr('data-index', index).attr('drag-helper', true); // Helper path for moving - g.append('path') - .attr('d', shapePath.attr('d')) - .style({ - cursor: 'move', - 'stroke-width': sensoryWidth, - 'stroke-opacity': '0' // ensure not visible - }); + g.append('path').attr('d', shapePath.attr('d')).style({ + cursor: 'move', + 'stroke-width': sensoryWidth, + 'stroke-opacity': '0' // ensure not visible + }); // Helper circles for resizing var circleStyle = { @@ -326,40 +361,42 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe var circleRadius = Math.max(sensoryWidth / 2, minSensoryWidth); g.append('circle') - .attr({ - 'data-line-point': 'start-point', - cx: xPixelSized ? x2p(shapeOptions.xanchor) + shapeOptions.x0 : x2p(shapeOptions.x0, shiftXStart), - cy: yPixelSized ? y2p(shapeOptions.yanchor) - shapeOptions.y0 : y2p(shapeOptions.y0, shiftYStart), - r: circleRadius - }) - .style(circleStyle) - .classed('cursor-grab', true); + .attr({ + 'data-line-point': 'start-point', + cx: xPixelSized ? x2p(shapeOptions.xanchor) + shapeOptions.x0 : x2p(shapeOptions.x0, shiftXStart), + cy: yPixelSized ? y2p(shapeOptions.yanchor) - shapeOptions.y0 : y2p(shapeOptions.y0, shiftYStart), + r: circleRadius + }) + .style(circleStyle) + .classed('cursor-grab', true); g.append('circle') - .attr({ - 'data-line-point': 'end-point', - cx: xPixelSized ? x2p(shapeOptions.xanchor) + shapeOptions.x1 : x2p(shapeOptions.x1, shiftXEnd), - cy: yPixelSized ? y2p(shapeOptions.yanchor) - shapeOptions.y1 : y2p(shapeOptions.y1, shiftYEnd), - r: circleRadius - }) - .style(circleStyle) - .classed('cursor-grab', true); + .attr({ + 'data-line-point': 'end-point', + cx: xPixelSized ? x2p(shapeOptions.xanchor) + shapeOptions.x1 : x2p(shapeOptions.x1, shiftXEnd), + cy: yPixelSized ? y2p(shapeOptions.yanchor) - shapeOptions.y1 : y2p(shapeOptions.y1, shiftYEnd), + r: circleRadius + }) + .style(circleStyle) + .classed('cursor-grab', true); return g; } function updateDragMode(evt) { - if(shouldSkipEdits(gd)) { + if (shouldSkipEdits(gd)) { dragMode = null; return; } - if(isLine) { - if(evt.target.tagName === 'path') { + if (isLine) { + if (evt.target.tagName === 'path') { dragMode = 'move'; } else { - dragMode = evt.target.attributes['data-line-point'].value === 'start-point' ? - 'resize-over-start-point' : 'resize-over-end-point'; + dragMode = + evt.target.attributes['data-line-point'].value === 'start-point' + ? 'resize-over-start-point' + : 'resize-over-end-point'; } } else { // element might not be on screen at time of setup, @@ -372,9 +409,10 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe var h = dragBBox.bottom - dragBBox.top; var x = evt.clientX - dragBBox.left; var y = evt.clientY - dragBBox.top; - var cursor = (!isPath && w > MINWIDTH && h > MINHEIGHT && !evt.shiftKey) ? - dragElement.getCursor(x / w, 1 - y / h) : - 'move'; + var cursor = + !isPath && w > MINWIDTH && h > MINHEIGHT && !evt.shiftKey + ? dragElement.getCursor(x / w, 1 - y / h) + : 'move'; setCursor(shapePath, cursor); @@ -384,17 +422,17 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe } function startDrag(evt) { - if(shouldSkipEdits(gd)) return; + if (shouldSkipEdits(gd)) return; // setup update strings and initial values - if(xPixelSized) { + if (xPixelSized) { xAnchor = x2p(shapeOptions.xanchor); } - if(yPixelSized) { + if (yPixelSized) { yAnchor = y2p(shapeOptions.yanchor); } - if(shapeOptions.type === 'path') { + if (shapeOptions.type === 'path') { pathIn = shapeOptions.path; } else { x0 = xPixelSized ? shapeOptions.x0 : x2p(shapeOptions.x0); @@ -403,7 +441,7 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe y1 = yPixelSized ? shapeOptions.y1 : y2p(shapeOptions.y1); } - if(x0 < x1) { + if (x0 < x1) { w0 = x0; optW = 'x0'; e0 = x1; @@ -417,7 +455,7 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe // For fixed size shapes take opposing direction of y-axis into account. // Hint: For data sized shapes this is done by the y2p function. - if((!yPixelSized && y0 < y1) || (yPixelSized && y0 > y1)) { + if ((!yPixelSized && y0 < y1) || (yPixelSized && y0 > y1)) { n0 = y0; optN = 'y0'; s0 = y1; @@ -433,12 +471,12 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe updateDragMode(evt); renderVisualCues(shapeLayer, shapeOptions); deactivateClipPathTemporarily(shapePath, shapeOptions, gd); - dragOptions.moveFn = (dragMode === 'move') ? moveShape : resizeShape; + dragOptions.moveFn = dragMode === 'move' ? moveShape : resizeShape; dragOptions.altKey = evt.altKey; } function endDrag() { - if(shouldSkipEdits(gd)) return; + if (shouldSkipEdits(gd)) return; setCursor(shapePath); removeVisualCues(shapeLayer); @@ -449,45 +487,51 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe } function abortDrag() { - if(shouldSkipEdits(gd)) return; + if (shouldSkipEdits(gd)) return; removeVisualCues(shapeLayer); } function moveShape(dx, dy) { - if(shapeOptions.type === 'path') { - var noOp = function(coord) { return coord; }; + if (shapeOptions.type === 'path') { + var noOp = function (coord) { + return coord; + }; var moveX = noOp; var moveY = noOp; - if(xPixelSized) { - modifyItem('xanchor', shapeOptions.xanchor = p2x(xAnchor + dx)); + if (xPixelSized) { + modifyItem('xanchor', (shapeOptions.xanchor = p2x(xAnchor + dx))); } else { - moveX = function moveX(x) { return p2x(x2p(x) + dx); }; - if(xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); + moveX = function moveX(x) { + return p2x(x2p(x) + dx); + }; + if (xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); } - if(yPixelSized) { - modifyItem('yanchor', shapeOptions.yanchor = p2y(yAnchor + dy)); + if (yPixelSized) { + modifyItem('yanchor', (shapeOptions.yanchor = p2y(yAnchor + dy))); } else { - moveY = function moveY(y) { return p2y(y2p(y) + dy); }; - if(ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); + moveY = function moveY(y) { + return p2y(y2p(y) + dy); + }; + if (ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); } - modifyItem('path', shapeOptions.path = movePath(pathIn, moveX, moveY)); + modifyItem('path', (shapeOptions.path = movePath(pathIn, moveX, moveY))); } else { - if(xPixelSized) { - modifyItem('xanchor', shapeOptions.xanchor = p2x(xAnchor + dx)); + if (xPixelSized) { + modifyItem('xanchor', (shapeOptions.xanchor = p2x(xAnchor + dx))); } else { - modifyItem('x0', shapeOptions.x0 = p2x(x0 + dx)); - modifyItem('x1', shapeOptions.x1 = p2x(x1 + dx)); + modifyItem('x0', (shapeOptions.x0 = p2x(x0 + dx))); + modifyItem('x1', (shapeOptions.x1 = p2x(x1 + dx))); } - if(yPixelSized) { - modifyItem('yanchor', shapeOptions.yanchor = p2y(yAnchor + dy)); + if (yPixelSized) { + modifyItem('yanchor', (shapeOptions.yanchor = p2y(yAnchor + dy))); } else { - modifyItem('y0', shapeOptions.y0 = p2y(y0 + dy)); - modifyItem('y1', shapeOptions.y1 = p2y(y1 + dy)); + modifyItem('y0', (shapeOptions.y0 = p2y(y0 + dy))); + modifyItem('y1', (shapeOptions.y1 = p2y(y1 + dy))); } } @@ -497,41 +541,49 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe } function resizeShape(dx, dy) { - if(isPath) { + if (isPath) { // TODO: implement path resize, don't forget to update dragMode code - var noOp = function(coord) { return coord; }; + var noOp = function (coord) { + return coord; + }; var moveX = noOp; var moveY = noOp; - if(xPixelSized) { - modifyItem('xanchor', shapeOptions.xanchor = p2x(xAnchor + dx)); + if (xPixelSized) { + modifyItem('xanchor', (shapeOptions.xanchor = p2x(xAnchor + dx))); } else { - moveX = function moveX(x) { return p2x(x2p(x) + dx); }; - if(xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); + moveX = function moveX(x) { + return p2x(x2p(x) + dx); + }; + if (xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); } - if(yPixelSized) { - modifyItem('yanchor', shapeOptions.yanchor = p2y(yAnchor + dy)); + if (yPixelSized) { + modifyItem('yanchor', (shapeOptions.yanchor = p2y(yAnchor + dy))); } else { - moveY = function moveY(y) { return p2y(y2p(y) + dy); }; - if(ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); + moveY = function moveY(y) { + return p2y(y2p(y) + dy); + }; + if (ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); } - modifyItem('path', shapeOptions.path = movePath(pathIn, moveX, moveY)); - } else if(isLine) { - if(dragMode === 'resize-over-start-point') { + modifyItem('path', (shapeOptions.path = movePath(pathIn, moveX, moveY))); + } else if (isLine) { + if (dragMode === 'resize-over-start-point') { var newX0 = x0 + dx; var newY0 = yPixelSized ? y0 - dy : y0 + dy; - modifyItem('x0', shapeOptions.x0 = xPixelSized ? newX0 : p2x(newX0)); - modifyItem('y0', shapeOptions.y0 = yPixelSized ? newY0 : p2y(newY0)); - } else if(dragMode === 'resize-over-end-point') { + modifyItem('x0', (shapeOptions.x0 = xPixelSized ? newX0 : p2x(newX0))); + modifyItem('y0', (shapeOptions.y0 = yPixelSized ? newY0 : p2y(newY0))); + } else if (dragMode === 'resize-over-end-point') { var newX1 = x1 + dx; var newY1 = yPixelSized ? y1 - dy : y1 + dy; - modifyItem('x1', shapeOptions.x1 = xPixelSized ? newX1 : p2x(newX1)); - modifyItem('y1', shapeOptions.y1 = yPixelSized ? newY1 : p2y(newY1)); + modifyItem('x1', (shapeOptions.x1 = xPixelSized ? newX1 : p2x(newX1))); + modifyItem('y1', (shapeOptions.y1 = yPixelSized ? newY1 : p2y(newY1))); } } else { - var has = function(str) { return dragMode.indexOf(str) !== -1; }; + var has = function (str) { + return dragMode.indexOf(str) !== -1; + }; var hasN = has('n'); var hasS = has('s'); var hasW = has('w'); @@ -542,25 +594,22 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe var newW = hasW ? w0 + dx : w0; var newE = hasE ? e0 + dx : e0; - if(yPixelSized) { + 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; + 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) - ) { - modifyItem(optN, shapeOptions[optN] = yPixelSized ? newN : p2y(newN)); - modifyItem(optS, shapeOptions[optS] = yPixelSized ? newS : p2y(newS)); + 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))); } - if(newE - newW > MINWIDTH) { - modifyItem(optW, shapeOptions[optW] = xPixelSized ? newW : p2x(newW)); - modifyItem(optE, shapeOptions[optE] = xPixelSized ? newE : p2x(newE)); + if (newE - newW > MINWIDTH) { + modifyItem(optW, (shapeOptions[optW] = xPixelSized ? newW : p2x(newW))); + modifyItem(optE, (shapeOptions[optE] = xPixelSized ? newE : p2x(newE))); } } @@ -570,7 +619,7 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe } function renderVisualCues(shapeLayer, shapeOptions) { - if(xPixelSized || yPixelSized) { + if (xPixelSized || yPixelSized) { renderAnchor(); } @@ -582,48 +631,53 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe // Enter var strokeWidth = 1; - visualCues.enter() - .append('path') - .attr({ - fill: '#fff', - 'fill-rule': 'evenodd', - stroke: '#000', - 'stroke-width': strokeWidth - }) - .classed('visual-cue', true); + visualCues + .enter() + .append('path') + .attr({ + fill: '#fff', + 'fill-rule': 'evenodd', + stroke: '#000', + 'stroke-width': strokeWidth + }) + .classed('visual-cue', true); // Update var posX = x2p( - xPixelSized ? - shapeOptions.xanchor : - Lib.midRange( - isNotPath ? - [shapeOptions.x0, shapeOptions.x1] : - helpers.extractPathCoords(shapeOptions.path, constants.paramIsX)) + xPixelSized + ? shapeOptions.xanchor + : Lib.midRange( + isNotPath + ? [shapeOptions.x0, shapeOptions.x1] + : helpers.extractPathCoords(shapeOptions.path, constants.paramIsX) + ) ); var posY = y2p( - yPixelSized ? - shapeOptions.yanchor : - Lib.midRange( - isNotPath ? - [shapeOptions.y0, shapeOptions.y1] : - helpers.extractPathCoords(shapeOptions.path, constants.paramIsY)) + yPixelSized + ? shapeOptions.yanchor + : Lib.midRange( + isNotPath + ? [shapeOptions.y0, shapeOptions.y1] + : helpers.extractPathCoords(shapeOptions.path, constants.paramIsY) + ) ); posX = helpers.roundPositionForSharpStrokeRendering(posX, strokeWidth); posY = helpers.roundPositionForSharpStrokeRendering(posY, strokeWidth); - if(xPixelSized && yPixelSized) { - var crossPath = 'M' + (posX - 1 - strokeWidth) + ',' + (posY - 1 - strokeWidth) + - 'h-8v2h8 v8h2v-8 h8v-2h-8 v-8h-2 Z'; + if (xPixelSized && yPixelSized) { + var crossPath = + 'M' + + (posX - 1 - strokeWidth) + + ',' + + (posY - 1 - strokeWidth) + + 'h-8v2h8 v8h2v-8 h8v-2h-8 v-8h-2 Z'; visualCues.attr('d', crossPath); - } else if(xPixelSized) { - var vBarPath = 'M' + (posX - 1 - strokeWidth) + ',' + (posY - 9 - strokeWidth) + - 'v18 h2 v-18 Z'; + } else if (xPixelSized) { + var vBarPath = 'M' + (posX - 1 - strokeWidth) + ',' + (posY - 9 - strokeWidth) + 'v18 h2 v-18 Z'; visualCues.attr('d', vBarPath); } else { - var hBarPath = 'M' + (posX - 9 - strokeWidth) + ',' + (posY - 1 - strokeWidth) + - 'h18 v2 h-18 Z'; + var hBarPath = 'M' + (posX - 9 - strokeWidth) + ',' + (posY - 1 - strokeWidth) + 'h18 v2 h-18 Z'; visualCues.attr('d', hBarPath); } } @@ -640,30 +694,26 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe var ya = Axes.getFromId(gd, yref); var clipAxes = ''; - if(xref !== 'paper' && !xa.autorange) clipAxes += xref; - if(yref !== 'paper' && !ya.autorange) clipAxes += yref; - - Drawing.setClipUrl( - shapePath, - clipAxes ? 'clip' + gd._fullLayout._uid + clipAxes : null, - gd - ); + if (xref !== 'paper' && !xa.autorange) clipAxes += xref; + if (yref !== 'paper' && !ya.autorange) clipAxes += yref; + + Drawing.setClipUrl(shapePath, clipAxes ? 'clip' + gd._fullLayout._uid + clipAxes : null, gd); } } function movePath(pathIn, moveX, moveY) { - return pathIn.replace(constants.segmentRE, function(segment) { + return pathIn.replace(constants.segmentRE, function (segment) { var paramNumber = 0; var segmentType = segment.charAt(0); var xParams = constants.paramIsX[segmentType]; var yParams = constants.paramIsY[segmentType]; var nParams = constants.numParams[segmentType]; - var paramString = segment.slice(1).replace(constants.paramRE, function(param) { - if(paramNumber >= nParams) return param; + var paramString = segment.slice(1).replace(constants.paramRE, function (param) { + if (paramNumber >= nParams) return param; - if(xParams[paramNumber]) param = moveX(param); - else if(yParams[paramNumber]) param = moveY(param); + if (xParams[paramNumber]) param = moveX(param); + else if (yParams[paramNumber]) param = moveY(param); paramNumber++; @@ -675,13 +725,13 @@ function movePath(pathIn, moveX, moveY) { } function activateShape(gd, path) { - if(!couldHaveActiveShape(gd)) return; + if (!couldHaveActiveShape(gd)) return; var element = path.node(); var id = +element.getAttribute('data-index'); - if(id >= 0) { + if (id >= 0) { // deactivate if already active - if(id === gd._fullLayout._activeShapeIndex) { + if (id === gd._fullLayout._activeShapeIndex) { deactivateShape(gd); return; } @@ -693,10 +743,10 @@ function activateShape(gd, path) { } function deactivateShape(gd) { - if(!couldHaveActiveShape(gd)) return; + if (!couldHaveActiveShape(gd)) return; var id = gd._fullLayout._activeShapeIndex; - if(id >= 0) { + if (id >= 0) { clearOutlineControllers(gd); delete gd._fullLayout._activeShapeIndex; draw(gd); @@ -704,16 +754,16 @@ function deactivateShape(gd) { } function eraseActiveShape(gd) { - if(!couldHaveActiveShape(gd)) return; + if (!couldHaveActiveShape(gd)) return; clearOutlineControllers(gd); var id = gd._fullLayout._activeShapeIndex; var shapes = (gd.layout || {}).shapes || []; - if(id < shapes.length) { + if (id < shapes.length) { var list = []; - for(var q = 0; q < shapes.length; q++) { - if(q !== id) { + for (var q = 0; q < shapes.length; q++) { + if (q !== id) { list.push(shapes[q]); } } diff --git a/test/jasmine/tests/hover_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js index e19d2ca234e..6a940078be2 100644 --- a/test/jasmine/tests/hover_click_anywhere_test.js +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -6,168 +6,365 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var click = require('../assets/click'); - -function makePlot(gd, layoutExtras) { - return Plotly.newPlot(gd, [{ - x: [1, 2, 3], - y: [1, 3, 2], - type: 'scatter', - mode: 'markers' - }], Lib.extendFlat({ - width: 400, height: 400, - margin: {l: 50, t: 50, r: 50, b: 50}, - xaxis: {range: [0, 10]}, - yaxis: {range: [0, 10]}, - hovermode: 'closest' - }, layoutExtras || {})); +function makePlot(gd, layoutExtras = {}, configExtras) { + return Plotly.newPlot( + gd, + [ + { + x: [1, 2, 3], + y: [1, 3, 2], + type: 'scatter', + mode: 'markers' + } + ], + Lib.extendFlat( + { + width: 400, + height: 400, + margin: { l: 50, t: 50, r: 50, b: 50 }, + xaxis: { range: [0, 10] }, + yaxis: { range: [0, 10] }, + hovermode: 'closest' + }, + layoutExtras + ), + configExtras + ); } -describe('hoveranywhere', function() { +describe('hoveranywhere', () => { 'use strict'; var gd; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(() => (gd = createGraphDiv())); afterEach(destroyGraphDiv); function _hover(xPixel, yPixel) { var bb = gd.getBoundingClientRect(); var s = gd._fullLayout._size; - Fx.hover(gd, { - clientX: xPixel + bb.left + s.l, - clientY: yPixel + bb.top + s.t, - target: gd.querySelector('.nsewdrag') - }, 'xy'); + Fx.hover( + gd, + { + clientX: xPixel + bb.left + s.l, + clientY: yPixel + bb.top + s.t, + target: gd.querySelector('.nsewdrag') + }, + 'xy' + ); Lib.clearThrottle(); } - it('emits plotly_hover with coordinate data on empty space', function(done) { + it('emits plotly_hover with coordinate data on empty space', (done) => { var hoverData; - makePlot(gd, {hoveranywhere: true}).then(function() { - gd.on('plotly_hover', function(d) { hoverData = d; }); - - // hover over empty area (no data points nearby) - _hover(250, 50); - - expect(hoverData).toBeDefined(); - expect(hoverData.points).toEqual([]); - expect(hoverData.xaxes.length).toBe(1); - expect(hoverData.yaxes.length).toBe(1); - expect(hoverData.xaxes[0]._id).toBe('x'); - expect(hoverData.yaxes[0]._id).toBe('y'); - expect(hoverData.xvals.length).toBe(1); - expect(hoverData.yvals.length).toBe(1); - expect(hoverData.xvals[0]).toBeCloseTo(250 / 30, 2); - expect(hoverData.yvals[0]).toBeCloseTo(10 - 50 / 30, 2); - }) - .then(done, done.fail); + makePlot(gd, { hoveranywhere: true }) + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + + // hover over empty area (no data points nearby) + _hover(250, 50); + + expect(hoverData).toBeDefined(); + expect(hoverData.points).toEqual([]); + expect(hoverData.xaxes.length).toBe(1); + expect(hoverData.yaxes.length).toBe(1); + expect(hoverData.xaxes[0]._id).toBe('x'); + expect(hoverData.yaxes[0]._id).toBe('y'); + expect(hoverData.xvals.length).toBe(1); + expect(hoverData.yvals.length).toBe(1); + expect(hoverData.xvals[0]).toBeCloseTo(250 / 30, 2); + expect(hoverData.yvals[0]).toBeCloseTo(10 - 50 / 30, 2); + }) + .then(done, done.fail); }); - it('does not emit plotly_hover event on empty space when hoveranywhere is false', function(done) { + it('does not emit plotly_hover event on empty space when hoveranywhere is false', (done) => { var hoverData; - makePlot(gd).then(function() { - gd.on('plotly_hover', function(d) { hoverData = d; }); - _hover(250, 50); - expect(hoverData).toBeUndefined(); - }) - .then(done, done.fail); + makePlot(gd) + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + _hover(250, 50); + expect(hoverData).toBeUndefined(); + }) + .then(done, done.fail); }); - it('still returns normal point data on traces', function(done) { + it('still returns normal point data on traces', (done) => { var hoverData; - makePlot(gd, {hoveranywhere: true}).then(function() { - gd.on('plotly_hover', function(d) { hoverData = d; }); - - // hover near (2, 3) - _hover(60, 210); - - expect(hoverData.points.length).toBe(1); - var pt = hoverData.points[0]; - expect(pt.x).toBe(2); - expect(pt.y).toBe(3); - expect(pt.curveNumber).toBe(0); - expect(pt.pointNumber).toBe(1); - // xPixel/yPixel: plot-area px + margin (60+50=110, 210+50=260) - expect(pt.xPixel).toBeCloseTo(110, 1); - expect(pt.yPixel).toBeCloseTo(260, 1); - // bbox is page-relative (xPixel/yPixel + graph div page offset); - // center of bbox should equal xPixel/yPixel + page offset - var gLeft = gd.offsetLeft + gd.clientLeft; - var gTop = gd.offsetTop + gd.clientTop; - expect(pt.bbox).toBeDefined(); - expect((pt.bbox.x0 + pt.bbox.x1) / 2).toBeCloseTo(110 + gLeft, 1); - expect((pt.bbox.y0 + pt.bbox.y1) / 2).toBeCloseTo(260 + gTop, 1); - expect(pt.bbox.x0).toBeLessThan(pt.bbox.x1); - expect(pt.bbox.y0).toBeLessThan(pt.bbox.y1); - expect(hoverData.xaxes.length).toBe(1); - expect(hoverData.yaxes.length).toBe(1); - expect(hoverData.xvals.length).toBe(1); - expect(hoverData.yvals.length).toBe(1); - expect(hoverData.xvals[0]).toBeCloseTo(2, 2); - expect(hoverData.yvals[0]).toBeCloseTo(3, 2); - }) - .then(done, done.fail); + makePlot(gd, { hoveranywhere: true }) + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + + // hover near (2, 3) + _hover(60, 210); + + expect(hoverData.points.length).toBe(1); + var pt = hoverData.points[0]; + expect(pt.x).toBe(2); + expect(pt.y).toBe(3); + expect(pt.curveNumber).toBe(0); + expect(pt.pointNumber).toBe(1); + // xPixel/yPixel: plot-area px + margin (60+50=110, 210+50=260) + expect(pt.xPixel).toBeCloseTo(110, 1); + expect(pt.yPixel).toBeCloseTo(260, 1); + // bbox is page-relative (xPixel/yPixel + graph div page offset); + // center of bbox should equal xPixel/yPixel + page offset + var gLeft = gd.offsetLeft + gd.clientLeft; + var gTop = gd.offsetTop + gd.clientTop; + expect(pt.bbox).toBeDefined(); + expect((pt.bbox.x0 + pt.bbox.x1) / 2).toBeCloseTo(110 + gLeft, 1); + expect((pt.bbox.y0 + pt.bbox.y1) / 2).toBeCloseTo(260 + gTop, 1); + expect(pt.bbox.x0).toBeLessThan(pt.bbox.x1); + expect(pt.bbox.y0).toBeLessThan(pt.bbox.y1); + expect(hoverData.xaxes.length).toBe(1); + expect(hoverData.yaxes.length).toBe(1); + expect(hoverData.xvals.length).toBe(1); + expect(hoverData.yvals.length).toBe(1); + expect(hoverData.xvals[0]).toBeCloseTo(2, 2); + expect(hoverData.yvals[0]).toBeCloseTo(3, 2); + }) + .then(done, done.fail); }); - it('respects hovermode:false', function(done) { + it('respects hovermode:false', (done) => { var hoverData; - makePlot(gd, {hoveranywhere: true, hovermode: false}).then(function() { - gd.on('plotly_hover', function(d) { hoverData = d; }); - _hover(250, 50); - expect(hoverData).toBeUndefined(); + makePlot(gd, { hoveranywhere: true, hovermode: false }) + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + _hover(250, 50); + expect(hoverData).toBeUndefined(); + }) + .then(done, done.fail); + }); + + it('emits plotly_hover over an editable shape', (done) => { + let hoverData; + + makePlot(gd, { + hoveranywhere: true, + shapes: [ + { + type: 'rect', + x0: 6, + x1: 9, + y0: 6, + y1: 9, + fillcolor: 'rgba(0, 128, 255, 0.8)', + editable: true + } + ] }) - .then(done, done.fail); + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + + // Dispatch mousemove directly on the shape path element, + // which has pointer-events that intercept events from the + // underlying maindrag. + const shapePath = gd.querySelector('.shape-group path'); + expect(shapePath).toBeDefined(); + + const bb = gd.getBoundingClientRect(); + const s = gd._fullLayout._size; + // center of shape at data (7.5, 7.5) = plot-area px (225, 75) + shapePath.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + clientX: bb.left + s.l + 225, + clientY: bb.top + s.t + 75 + }) + ); + Lib.clearThrottle(); + + expect(hoverData).toBeDefined(); + expect(hoverData.points).toEqual([]); + expect(hoverData.xvals[0]).toBeCloseTo(7.5, 1); + expect(hoverData.yvals[0]).toBeCloseTo(7.5, 1); + }) + .then(done, done.fail); + }); + + it('emits plotly_hover over a shape with edits.shapePosition', (done) => { + let hoverData; + + makePlot( + gd, + { + hoveranywhere: true, + shapes: [ + { + type: 'rect', + x0: 6, + x1: 9, + y0: 6, + y1: 9, + fillcolor: 'rgba(0, 128, 255, 0.8)' + } + ] + }, + { edits: { shapePosition: true } } + ) + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + + const shapePath = gd.querySelector('.shape-group path'); + expect(shapePath).toBeDefined(); + + const bb = gd.getBoundingClientRect(); + const s = gd._fullLayout._size; + shapePath.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + clientX: bb.left + s.l + 225, + clientY: bb.top + s.t + 75 + }) + ); + Lib.clearThrottle(); + + expect(hoverData).toBeDefined(); + expect(hoverData.points).toEqual([]); + expect(hoverData.xvals[0]).toBeCloseTo(7.5, 1); + expect(hoverData.yvals[0]).toBeCloseTo(7.5, 1); + }) + .then(done, done.fail); }); }); -describe('clickanywhere', function() { +describe('clickanywhere', () => { 'use strict'; var gd; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(() => (gd = createGraphDiv())); afterEach(destroyGraphDiv); - it('emits plotly_click with empty points on empty space', function(done) { + it('emits plotly_click with empty points on empty space', (done) => { var clickData; - makePlot(gd, {clickanywhere: true}).then(function() { - gd.on('plotly_click', function(d) { clickData = d; }); - - var bb = gd.getBoundingClientRect(); - var s = gd._fullLayout._size; - click(bb.left + s.l + 250, bb.top + s.t + 50); - - expect(clickData).toBeDefined(); - expect(clickData.points).toEqual([]); - expect(clickData.xaxes.length).toBe(1); - expect(clickData.yaxes.length).toBe(1); - expect(clickData.xvals.length).toBe(1); - expect(clickData.yvals.length).toBe(1); - // click at 250px into 300px plot area, xrange [0,10]: 250/300*10 = 8.33 - expect(clickData.xvals[0]).toBeCloseTo(250 / 30, 2); - // click at 50px into 300px plot area, yrange [0,10]: 10 - 50/300*10 = 8.33 - expect(clickData.yvals[0]).toBeCloseTo(10 - 50 / 30, 2); - }) - .then(done, done.fail); + makePlot(gd, { clickanywhere: true }) + .then(() => { + gd.on('plotly_click', (d) => (clickData = d)); + + var bb = gd.getBoundingClientRect(); + var s = gd._fullLayout._size; + click(bb.left + s.l + 250, bb.top + s.t + 50); + + expect(clickData).toBeDefined(); + expect(clickData.points).toEqual([]); + expect(clickData.xaxes.length).toBe(1); + expect(clickData.yaxes.length).toBe(1); + expect(clickData.xvals.length).toBe(1); + expect(clickData.yvals.length).toBe(1); + // click at 250px into 300px plot area, xrange [0,10]: 250/300*10 = 8.33 + expect(clickData.xvals[0]).toBeCloseTo(250 / 30, 2); + // click at 50px into 300px plot area, yrange [0,10]: 10 - 50/300*10 = 8.33 + expect(clickData.yvals[0]).toBeCloseTo(10 - 50 / 30, 2); + }) + .then(done, done.fail); }); - it('does not emit plotly_click event on empty space when clickanywhere is false', function(done) { + it('does not emit plotly_click event on empty space when clickanywhere is false', (done) => { var clickData; - makePlot(gd).then(function() { - gd.on('plotly_click', function(d) { clickData = d; }); + makePlot(gd) + .then(() => { + gd.on('plotly_click', (d) => (clickData = d)); - var bb = gd.getBoundingClientRect(); - var s = gd._fullLayout._size; - click(bb.left + s.l + 250, bb.top + s.t + 50); + var bb = gd.getBoundingClientRect(); + var s = gd._fullLayout._size; + click(bb.left + s.l + 250, bb.top + s.t + 50); - expect(clickData).toBeUndefined(); + expect(clickData).toBeUndefined(); + }) + .then(done, done.fail); + }); + + it('emits plotly_click over an editable shape', (done) => { + let clickData; + + makePlot(gd, { + clickanywhere: true, + shapes: [ + { + type: 'rect', + x0: 6, + x1: 9, + y0: 6, + y1: 9, + fillcolor: 'rgba(0, 128, 255, 0.8)', + editable: true + } + ] }) - .then(done, done.fail); + .then(() => { + gd.on('plotly_click', (d) => (clickData = d)); + + const shapePath = gd.querySelector('.shape-group path'); + expect(shapePath).toBeDefined(); + + const bb = gd.getBoundingClientRect(); + const s = gd._fullLayout._size; + // center of shape at data (7.5, 7.5) = plot-area px (225, 75) + shapePath.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + clientX: bb.left + s.l + 225, + clientY: bb.top + s.t + 75 + }) + ); + + expect(clickData).toBeDefined(); + expect(clickData.points).toEqual([]); + expect(clickData.xvals[0]).toBeCloseTo(7.5, 1); + expect(clickData.yvals[0]).toBeCloseTo(7.5, 1); + }) + .then(done, done.fail); + }); + + it('emits plotly_click over a shape with edits.shapePosition', (done) => { + let clickData; + + makePlot( + gd, + { + clickanywhere: true, + shapes: [ + { + type: 'rect', + x0: 6, + x1: 9, + y0: 6, + y1: 9, + fillcolor: 'rgba(0, 128, 255, 0.8)' + } + ] + }, + { edits: { shapePosition: true } } + ) + .then(() => { + gd.on('plotly_click', (d) => (clickData = d)); + + const shapePath = gd.querySelector('.shape-group path'); + expect(shapePath).toBeDefined(); + + const bb = gd.getBoundingClientRect(); + const s = gd._fullLayout._size; + shapePath.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + clientX: bb.left + s.l + 225, + clientY: bb.top + s.t + 75 + }) + ); + + expect(clickData).toBeDefined(); + expect(clickData.points).toEqual([]); + expect(clickData.xvals[0]).toBeCloseTo(7.5, 1); + expect(clickData.yvals[0]).toBeCloseTo(7.5, 1); + }) + .then(done, done.fail); }); });