Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/7966_add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add top-level `xPixel` and `yPixel` keys to hover and click event data, corresponding to the pixel position of the cursor relative to the top-left corner of the graph div. Also, when `hoveranywhere` is enabled, emit a `plotly_unhover` event when the cursor leaves the plot area. [[#7966](https://github.com/plotly/plotly.js/pull/7966)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Add top-level `xPixel` and `yPixel` keys to hover and click event data, corresponding to the pixel position of the cursor relative to the top-left corner of the graph div. Also, when `hoveranywhere` is enabled, emit a `plotly_unhover` event when the cursor leaves the plot area. [[#7966](https://github.com/plotly/plotly.js/pull/7966)]
- Add top-level `xPixel` and `yPixel` keys to hover and click event data, corresponding to the pixel position of the cursor relative to the top-left corner of the graph div [[#7966](https://github.com/plotly/plotly.js/pull/7966)]
- When `hoveranywhere` is enabled, a `plotly_unhover` event will be emitted when the cursor leaves the plot area

16 changes: 16 additions & 0 deletions src/components/dragelement/unhover.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,23 @@ unhover.wrapped = function(gd, evt, subplot) {
throttle.clear(gd._fullLayout._uid + hoverConstants.HOVERID);
}

var oldhoverdata = gd._hoverdata;

unhover.raw(gd, evt, subplot);

// Special handling for `hoveranywhere`, to ensure we emit exactly one unhover event
// when the cursor leaves the plot area.
// gd._hoverAnywhereActive is set in fx/hover.js when we emit an empty-space hover event.
if(gd._hoverAnywhereActive) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we update the guard to also check if hoveranywhere is active? Someone trying to break things could turn off hoveranywhere with relayout and then _hoverAnywhereActive would still be true.

Suggested change
if(gd._hoverAnywhereActive) {
if (gd._fullLayout?.hoveranywhere && gd._hoverAnywhereActive) {

gd._hoverAnywhereActive = false;

if(evt && evt.target && !oldhoverdata) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if(evt && evt.target && !oldhoverdata) {
if(evt?.target && !oldhoverdata) {

gd.emit('plotly_unhover', {
event: evt,
points: []
});
Comment on lines +30 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This event doesn't check to see if plotly_beforehover is false, but it probably should to preserve that behavior. The current check happens inside raw. You could save that result on gd (or somewhere else) and use it in the conditional check.

}
}
};


Expand Down
2 changes: 2 additions & 0 deletions src/components/fx/click.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ module.exports = function click(gd, evt, subplot) {
clickData.yaxes ??= gd._hoverYAxes;
clickData.xvals ??= gd._hoverXVals;
clickData.yvals ??= gd._hoverYVals;
clickData.xPixel ??= gd._hoverPointerX;
clickData.yPixel ??= gd._hoverPointerY;

gd.emit('plotly_click', clickData);
}
Expand Down
13 changes: 12 additions & 1 deletion src/components/fx/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
gd._hoverYVals = yvalArray;
gd._hoverXAxes = xaArray;
gd._hoverYAxes = yaArray;
gd._hoverPointerX = evt.pointerX;
gd._hoverPointerY = evt.pointerY;
Comment on lines +486 to +487

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these need to be moved out of this block to ensure that they're available without clickanywhere?

}

// the pixel distance to beat as a matching point
Expand Down Expand Up @@ -818,6 +820,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
gd._hoverdata = [];
}
emitHover([]);

// Set a flag to note that an empty-space hover event is being emitted,
// so that we know to emit an unhover event when the mouse leaves the plot area.
// See dragelement/unhover.js.
gd._hoverAnywhereActive = true;
}
return result;
}
Expand Down Expand Up @@ -977,7 +984,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
xaxes: xaArray,
yaxes: yaArray,
xvals: xvalArray,
yvals: yvalArray
yvals: yvalArray,
// Note: top-level xPixel/yPixel correspond to the pixel position of the cursor.
// Inside `points` array, points[i].xPixel/yPixel correspond to the pixel position of the point itself.
xPixel: evt.pointerX,
yPixel: evt.pointerY
});
}
}
Expand Down
9 changes: 7 additions & 2 deletions src/components/fx/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ module.exports = {
'If true, `plotly_hover` events will fire for any cursor position',
'within the plot area, not just over traces.',
'When the cursor is not over a trace, the event will have an empty `points` array',
'but will include `xvals` and `yvals` with cursor coordinates in data space.'
'but will include `xvals` and `yvals` with cursor coordinates in data space,',
'and `xPixel` and `yPixel` with cursor coordinates in pixels,',
'relative to the top-left corner of the graph div.',
'A `plotly_unhover` event fires when the cursor leaves the plot area.'
].join(' ')
},
clickanywhere: {
Expand All @@ -123,7 +126,9 @@ module.exports = {
'If true, `plotly_click` events will fire for any click position',
'within the plot area, not just over traces.',
'When clicking where there is no trace data, the event will have an empty `points` array',
'but will include `xvals` and `yvals` with click coordinates in data space.'
'but will include `xvals` and `yvals` with click coordinates in data space,',
'and `xPixel` and `yPixel` with click coordinates in pixels,',
'relative to the top-left corner of the graph div.'
].join(' ')
},
hoverdistance: {
Expand Down
152 changes: 147 additions & 5 deletions test/jasmine/tests/hover_click_anywhere_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ var Lib = require('../../../src/lib');
var createGraphDiv = require('../assets/create_graph_div');
var destroyGraphDiv = require('../assets/destroy_graph_div');
var click = require('../assets/click');
var mouseEvent = require('../assets/mouse_event');

function makePlot(gd, layoutExtras = {}, configExtras) {
return Plotly.newPlot(
Expand Down Expand Up @@ -55,6 +56,15 @@ describe('hoveranywhere', () => {
Lib.clearThrottle();
}

// leave the plot area, as the maindrag sees it
function _leavePlotArea() {
var bb = gd.getBoundingClientRect();
mouseEvent('mouseout', bb.left - 50, bb.top - 50, {
element: gd.querySelector('.nsewdrag')
});
Lib.clearThrottle();
}

it('emits plotly_hover with coordinate data on empty space', (done) => {
var hoverData;

Expand All @@ -75,6 +85,8 @@ describe('hoveranywhere', () => {
expect(hoverData.yvals.length).toBe(1);
expect(hoverData.xvals[0]).toBeCloseTo(250 / 30, 2);
expect(hoverData.yvals[0]).toBeCloseTo(10 - 50 / 30, 2);
expect(hoverData.xPixel).toBeCloseTo(300, 1); // hover x-position (250) + left margin (50)
expect(hoverData.yPixel).toBeCloseTo(100, 1); // hover y-position (50) + top margin (50)
})
.then(done, done.fail);
});
Expand Down Expand Up @@ -129,6 +141,27 @@ describe('hoveranywhere', () => {
.then(done, done.fail);
});

it('reports cursor position in top-level xPixel/yPixel, and point position in point-level xPixel/yPixel', (done) => {
var hoverData;

makePlot(gd, { hoveranywhere: true })
.then(() => {
gd.on('plotly_hover', (d) => (hoverData = d));

// hover near, but not exactly on, the point (2, 3), which is at px (60, 210)
_hover(65, 205);

expect(hoverData.points.length).toBe(1);
// top-level: cursor position
expect(hoverData.xPixel).toBeCloseTo(115, 1); // hover x-position (65) + left margin (50)
expect(hoverData.yPixel).toBeCloseTo(255, 1); // hover y-position (205) + top margin (50)
// point-level: position of the point itself
expect(hoverData.points[0].xPixel).toBeCloseTo(110, 1); // point x-position in plot area (60) + left margin (50)
expect(hoverData.points[0].yPixel).toBeCloseTo(260, 1); // point y-position in plot area (210) + top margin (50)
})
.then(done, done.fail);
});

it('respects hovermode:false', (done) => {
var hoverData;

Expand All @@ -141,6 +174,102 @@ describe('hoveranywhere', () => {
.then(done, done.fail);
});

it('emits plotly_unhover when the cursor leaves the plot area after hovering empty space', (done) => {
var events = [];
var unhoverData;

makePlot(gd, { hoveranywhere: true })
.then(() => {
gd.on('plotly_hover', () => events.push('hover'));
gd.on('plotly_unhover', (d) => {
events.push('unhover');
unhoverData = d;
});

_hover(250, 50);
expect(events).toEqual(['hover']);

_leavePlotArea();

expect(events).toEqual(['hover', 'unhover']);
expect(unhoverData.points).toEqual([]);
})
.then(done, done.fail);
});

it('emits only one unhover per departure from the plot area', (done) => {
var events = [];

makePlot(gd, { hoveranywhere: true })
.then(() => {
gd.on('plotly_unhover', () => events.push('unhover'));

_hover(250, 50);
_leavePlotArea();
_leavePlotArea();

expect(events).toEqual(['unhover']);
})
.then(done, done.fail);
});

it('does not emit unhover while moving within empty space', (done) => {
var events = [];

makePlot(gd, { hoveranywhere: true })
.then(() => {
gd.on('plotly_hover', () => events.push('hover'));
gd.on('plotly_unhover', () => events.push('unhover'));

_hover(250, 50);
_hover(255, 55);
_hover(260, 60);

expect(events).toEqual(['hover', 'hover', 'hover']);
})
.then(done, done.fail);
});

it('emits unhover with point data, not empty points, when leaving from a point', (done) => {
var events = [];
var unhoverData;

makePlot(gd, { hoveranywhere: true })
.then(() => {
gd.on('plotly_unhover', (d) => {
events.push('unhover');
unhoverData = d;
});

// hover empty space, then the point (2, 3), then leave
_hover(250, 50);
_hover(60, 210);
_leavePlotArea();

expect(events).toEqual(['unhover']);
expect(unhoverData.points.length).toBe(1);
expect(unhoverData.points[0].x).toBe(2);
expect(unhoverData.points[0].y).toBe(3);
})
.then(done, done.fail);
});

it('does not emit unhover on leaving empty space when hoveranywhere is false', (done) => {
var events = [];

makePlot(gd)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be good to make this explicit:

Suggested change
makePlot(gd)
makePlot(gd, { hoveranywhere: false })

.then(() => {
gd.on('plotly_hover', () => events.push('hover'));
gd.on('plotly_unhover', () => events.push('unhover'));

_hover(250, 50);
_leavePlotArea();

expect(events).toEqual([]);
})
.then(done, done.fail);
});

it('emits plotly_hover over an editable shape', (done) => {
let hoverData;

Expand Down Expand Up @@ -170,11 +299,13 @@ describe('hoveranywhere', () => {
const bb = gd.getBoundingClientRect();
const s = gd._fullLayout._size;
// center of shape at data (7.5, 7.5) = plot-area px (225, 75)
const mouseX = bb.left + s.l + 225;
const mouseY = bb.top + s.t + 75;
shapePath.dispatchEvent(
new MouseEvent('mousemove', {
bubbles: true,
clientX: bb.left + s.l + 225,
clientY: bb.top + s.t + 75
clientX: mouseX,
clientY: mouseY
})
);
Lib.clearThrottle();
Expand All @@ -183,6 +314,10 @@ describe('hoveranywhere', () => {
expect(hoverData.points).toEqual([]);
expect(hoverData.xvals[0]).toBeCloseTo(7.5, 1);
expect(hoverData.yvals[0]).toBeCloseTo(7.5, 1);
// mouseX and mouseY are relative to the full page, so subtract the bounding box
// to get pixel coordinates relative to the graph div, which should match hoverData.xPixel/yPixel
expect(hoverData.xPixel).toBeCloseTo(mouseX - bb.left, 1);
expect(hoverData.yPixel).toBeCloseTo(mouseY - bb.top, 1);
})
.then(done, done.fail);
});
Expand Down Expand Up @@ -248,9 +383,12 @@ describe('clickanywhere', () => {
.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);
const bb = gd.getBoundingClientRect();
const s = gd._fullLayout._size;
const clickX = bb.left + s.l + 250;
const clickY = bb.top + s.t + 50;

click(clickX, clickY);

expect(clickData).toBeDefined();
expect(clickData.points).toEqual([]);
Expand All @@ -262,6 +400,10 @@ describe('clickanywhere', () => {
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);
// click pixels: clickX and clickY are relative to full page, so subtract the graph div bounding box
// to get pixel coordinates relative to the graph div, which should match clickData.xPixel/yPixel
expect(clickData.xPixel).toBeCloseTo(clickX - bb.left, 1);
expect(clickData.yPixel).toBeCloseTo(clickY - bb.top, 1);
})
.then(done, done.fail);
});
Expand Down
4 changes: 2 additions & 2 deletions test/plot-schema.json

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you run npm run schema to regenerate the TS types with these new descriptions? You might also need to update the hand written types.

Original file line number Diff line number Diff line change
Expand Up @@ -1140,7 +1140,7 @@
]
},
"clickanywhere": {
"description": "If true, `plotly_click` events will fire for any click position within the plot area, not just over traces. When clicking where there is no trace data, the event will have an empty `points` array but will include `xvals` and `yvals` with click coordinates in data space.",
"description": "If true, `plotly_click` events will fire for any click position within the plot area, not just over traces. When clicking where there is no trace data, the event will have an empty `points` array but will include `xvals` and `yvals` with click coordinates in data space, and `xPixel` and `yPixel` with click coordinates in pixels, relative to the top-left corner of the graph div.",
"dflt": false,
"editType": "none",
"valType": "boolean"
Expand Down Expand Up @@ -2785,7 +2785,7 @@
"valType": "number"
},
"hoveranywhere": {
"description": "If true, `plotly_hover` events will fire for any cursor position within the plot area, not just over traces. When the cursor is not over a trace, the event will have an empty `points` array but will include `xvals` and `yvals` with cursor coordinates in data space.",
"description": "If true, `plotly_hover` events will fire for any cursor position within the plot area, not just over traces. When the cursor is not over a trace, the event will have an empty `points` array but will include `xvals` and `yvals` with cursor coordinates in data space, and `xPixel` and `yPixel` with cursor coordinates in pixels, relative to the top-left corner of the graph div. A `plotly_unhover` event fires when the cursor leaves the plot area.",
"dflt": false,
"editType": "none",
"valType": "boolean"
Expand Down