From 464db502612e9954df1775b24b712066afad9109 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Fri, 1 May 2026 08:28:15 -0400 Subject: [PATCH 1/7] Fix hoveranywhere/clickanywhere events over editable shapes --- src/components/shapes/draw.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 1a9307cfb67..cf9f95d871d 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -17,6 +17,7 @@ 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'); @@ -188,9 +189,35 @@ function drawOne(gd, index) { } } 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 || !plotinfo.id) return; + + var node = path.node(); + + node.addEventListener('mousemove', function(evt) { + if(gd._dragging) return; + if(gd._fullLayout.hoveranywhere) { + Fx.hover(gd, evt, plotinfo.id); + } + }); + + node.addEventListener('click', function(evt) { + if(gd._dragged) return; + if(gd._fullLayout.clickanywhere) { + Fx.click(gd, evt, 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 From 965930b43fd642ed01c940efe061fec2f86ea078 Mon Sep 17 00:00:00 2001 From: Alex Hsu <42301846+alexshoe@users.noreply.github.com> Date: Mon, 4 May 2026 14:53:43 -0400 Subject: [PATCH 2/7] Update src/components/shapes/draw.js Co-authored-by: Cameron DeCoster --- src/components/shapes/draw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index cf9f95d871d..826f2239600 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -199,7 +199,7 @@ function drawOne(gd, index) { } function forwardHoverClickAnywhere(gd, path, plotinfo) { - if(!plotinfo || !plotinfo.id) return; + if(!plotinfo?.id) return; var node = path.node(); From 62b0f2b624bb862130c063dab5905663f71f39dc Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 18 May 2026 16:57:20 -0600 Subject: [PATCH 3/7] Linting/formatting --- src/components/shapes/draw.js | 403 +++++++++++++++++----------------- 1 file changed, 206 insertions(+), 197 deletions(-) diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 826f2239600..f9cf3cb8ca9 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -24,7 +24,6 @@ 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 @@ -38,7 +37,7 @@ module.exports = { draw: draw, drawOne: drawOne, eraseActiveShape: eraseActiveShape, - drawLabel: drawLabel, + drawLabel: drawLabel }; function draw(gd) { @@ -50,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); } } @@ -80,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; @@ -90,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 { @@ -125,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'; @@ -133,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) @@ -160,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 = { @@ -180,15 +174,15 @@ 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 @@ -199,20 +193,20 @@ function drawOne(gd, index) { } function forwardHoverClickAnywhere(gd, path, plotinfo) { - if(!plotinfo?.id) return; + if (!plotinfo?.id) return; var node = path.node(); - node.addEventListener('mousemove', function(evt) { - if(gd._dragging) return; - if(gd._fullLayout.hoveranywhere) { + node.addEventListener('mousemove', function (evt) { + if (gd._dragging) return; + if (gd._fullLayout.hoveranywhere) { Fx.hover(gd, evt, plotinfo.id); } }); - node.addEventListener('click', function(evt) { - if(gd._dragged) return; - if(gd._fullLayout.clickanywhere) { + node.addEventListener('click', function (evt) { + if (gd._dragged) return; + if (gd._fullLayout.clickanywhere) { Fx.click(gd, evt, plotinfo.id); } }); @@ -230,13 +224,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 { @@ -250,25 +246,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) { @@ -297,11 +295,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); }; @@ -333,18 +331,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 = { @@ -353,40 +347,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, @@ -399,9 +395,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); @@ -411,17 +408,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); @@ -430,7 +427,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; @@ -444,7 +441,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; @@ -460,12 +457,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); @@ -476,45 +473,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))); } } @@ -524,41 +527,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'); @@ -569,25 +580,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))); } } @@ -597,7 +605,7 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHe } function renderVisualCues(shapeLayer, shapeOptions) { - if(xPixelSized || yPixelSized) { + if (xPixelSized || yPixelSized) { renderAnchor(); } @@ -609,48 +617,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); } } @@ -667,30 +680,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++; @@ -702,13 +711,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; } @@ -720,10 +729,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); @@ -731,16 +740,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]); } } From 2ab7e84d0d1a40ae33a31a2e984b8345c5d24906 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 18 May 2026 17:10:56 -0600 Subject: [PATCH 4/7] Use nsewdrag element for proper location coord calc --- src/components/shapes/draw.js | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index f9cf3cb8ca9..37947c093df 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -195,19 +195,33 @@ function drawOne(gd, index) { function forwardHoverClickAnywhere(gd, path, plotinfo) { if (!plotinfo?.id) return; - var node = path.node(); + 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', function (evt) { + node.addEventListener('mousemove', (evt) => { if (gd._dragging) return; if (gd._fullLayout.hoveranywhere) { - Fx.hover(gd, evt, plotinfo.id); + const e = patchedEvt(evt); + if (e) Fx.hover(gd, e, plotinfo.id); } }); - node.addEventListener('click', function (evt) { + node.addEventListener('click', (evt) => { if (gd._dragged) return; if (gd._fullLayout.clickanywhere) { - Fx.click(gd, evt, plotinfo.id); + const e = patchedEvt(evt); + if (e) Fx.click(gd, e, plotinfo.id); } }); } From e481f14dba6a6a9b95ac6ab1b6c32bde0fbc8365 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 18 May 2026 17:11:49 -0600 Subject: [PATCH 5/7] Linting/formatting --- .../tests/hover_click_anywhere_test.js | 269 ++++++++++-------- 1 file changed, 152 insertions(+), 117 deletions(-) diff --git a/test/jasmine/tests/hover_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js index e19d2ca234e..c9260ac7aa5 100644 --- a/test/jasmine/tests/hover_click_anywhere_test.js +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -6,168 +6,203 @@ 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 || {})); + 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 || {} + ) + ); } -describe('hoveranywhere', function() { +describe('hoveranywhere', function () { 'use strict'; var gd; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(function () { + 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', function (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(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); }); - 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', function (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(function () { + gd.on('plotly_hover', function (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', function (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(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); }); - it('respects hovermode:false', function(done) { + it('respects hovermode:false', function (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(); - }) - .then(done, done.fail); + makePlot(gd, { hoveranywhere: true, hovermode: false }) + .then(function () { + gd.on('plotly_hover', function (d) { + hoverData = d; + }); + _hover(250, 50); + expect(hoverData).toBeUndefined(); + }) + .then(done, done.fail); }); }); -describe('clickanywhere', function() { +describe('clickanywhere', function () { 'use strict'; var gd; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(function () { + 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', function (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(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); }); - 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', function (done) { var clickData; - makePlot(gd).then(function() { - gd.on('plotly_click', function(d) { clickData = d; }); + makePlot(gd) + .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); + var bb = gd.getBoundingClientRect(); + var s = gd._fullLayout._size; + click(bb.left + s.l + 250, bb.top + s.t + 50); - expect(clickData).toBeUndefined(); - }) - .then(done, done.fail); + expect(clickData).toBeUndefined(); + }) + .then(done, done.fail); }); }); From 8c1f569fa1a134af88fa97f9de5967acb3517c84 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 18 May 2026 17:17:12 -0600 Subject: [PATCH 6/7] Add tests for "anywhere" events over editable shapes --- .../tests/hover_click_anywhere_test.js | 192 +++++++++++++++++- 1 file changed, 189 insertions(+), 3 deletions(-) diff --git a/test/jasmine/tests/hover_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js index c9260ac7aa5..6cc56b78fa0 100644 --- a/test/jasmine/tests/hover_click_anywhere_test.js +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -6,7 +6,7 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var click = require('../assets/click'); -function makePlot(gd, layoutExtras) { +function makePlot(gd, layoutExtras = {}, configExtras) { return Plotly.newPlot( gd, [ @@ -26,8 +26,9 @@ function makePlot(gd, layoutExtras) { yaxis: { range: [0, 10] }, hovermode: 'closest' }, - layoutExtras || {} - ) + layoutExtras + ), + configExtras ); } @@ -149,6 +150,101 @@ describe('hoveranywhere', function () { }) .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(() => { + 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 () { @@ -205,4 +301,94 @@ describe('clickanywhere', function () { }) .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(() => { + 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); + }); }); From 171f3327cf914ecf9508803bc3d71684e020bc08 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Tue, 19 May 2026 07:04:21 -0600 Subject: [PATCH 7/7] Refactor test file --- .../tests/hover_click_anywhere_test.js | 76 +++++++------------ 1 file changed, 26 insertions(+), 50 deletions(-) diff --git a/test/jasmine/tests/hover_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js index 6cc56b78fa0..6a940078be2 100644 --- a/test/jasmine/tests/hover_click_anywhere_test.js +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -32,14 +32,12 @@ function makePlot(gd, layoutExtras = {}, configExtras) { ); } -describe('hoveranywhere', function () { +describe('hoveranywhere', () => { 'use strict'; var gd; - beforeEach(function () { - gd = createGraphDiv(); - }); + beforeEach(() => (gd = createGraphDiv())); afterEach(destroyGraphDiv); function _hover(xPixel, yPixel) { @@ -57,14 +55,12 @@ describe('hoveranywhere', function () { 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; - }); + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); // hover over empty area (no data points nearby) _hover(250, 50); @@ -83,28 +79,24 @@ describe('hoveranywhere', function () { .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; - }); + .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; - }); + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); // hover near (2, 3) _hover(60, 210); @@ -137,14 +129,12 @@ describe('hoveranywhere', function () { .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; - }); + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); _hover(250, 50); expect(hoverData).toBeUndefined(); }) @@ -169,9 +159,7 @@ describe('hoveranywhere', function () { ] }) .then(() => { - gd.on('plotly_hover', (d) => { - hoverData = d; - }); + gd.on('plotly_hover', (d) => (hoverData = d)); // Dispatch mousemove directly on the shape path element, // which has pointer-events that intercept events from the @@ -220,9 +208,7 @@ describe('hoveranywhere', function () { { edits: { shapePosition: true } } ) .then(() => { - gd.on('plotly_hover', (d) => { - hoverData = d; - }); + gd.on('plotly_hover', (d) => (hoverData = d)); const shapePath = gd.querySelector('.shape-group path'); expect(shapePath).toBeDefined(); @@ -247,24 +233,20 @@ describe('hoveranywhere', function () { }); }); -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; - }); + .then(() => { + gd.on('plotly_click', (d) => (clickData = d)); var bb = gd.getBoundingClientRect(); var s = gd._fullLayout._size; @@ -284,14 +266,12 @@ describe('clickanywhere', function () { .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; - }); + .then(() => { + gd.on('plotly_click', (d) => (clickData = d)); var bb = gd.getBoundingClientRect(); var s = gd._fullLayout._size; @@ -320,9 +300,7 @@ describe('clickanywhere', function () { ] }) .then(() => { - gd.on('plotly_click', (d) => { - clickData = d; - }); + gd.on('plotly_click', (d) => (clickData = d)); const shapePath = gd.querySelector('.shape-group path'); expect(shapePath).toBeDefined(); @@ -367,9 +345,7 @@ describe('clickanywhere', function () { { edits: { shapePosition: true } } ) .then(() => { - gd.on('plotly_click', (d) => { - clickData = d; - }); + gd.on('plotly_click', (d) => (clickData = d)); const shapePath = gd.querySelector('.shape-group path'); expect(shapePath).toBeDefined();