Skip to content

Commit ba74af7

Browse files
authored
Merge pull request #7966 from plotly/add-xpixel-ypixel-unhover
Improvements to `hoveranywhere` / `clickanywhere` feature
2 parents 602daa8 + f1ec8fe commit ba74af7

12 files changed

Lines changed: 284 additions & 27 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,33 +91,33 @@ jobs:
9191
- uses: ./.github/actions/setup-workspace
9292
- uses: ./.github/actions/setup-chrome
9393

94-
- name: Run hover_label test in UTC timezone
94+
- name: Run hover test in UTC timezone
9595
uses: ./.github/actions/run-xvfb
9696
env:
9797
TZ: 'UTC'
9898
with:
99-
run: npm run test-jasmine hover_label
99+
run: npm run test-jasmine hover
100100

101-
- name: Run hover_label test in Europe/Berlin timezone
101+
- name: Run hover test in Europe/Berlin timezone
102102
uses: ./.github/actions/run-xvfb
103103
env:
104104
TZ: 'Europe/Berlin'
105105
with:
106-
run: npm run test-jasmine hover_label
106+
run: npm run test-jasmine hover
107107

108-
- name: Run hover_label test in Asia/Tokyo timezone
108+
- name: Run hover test in Asia/Tokyo timezone
109109
uses: ./.github/actions/run-xvfb
110110
env:
111111
TZ: 'Asia/Tokyo'
112112
with:
113-
run: npm run test-jasmine hover_label
113+
run: npm run test-jasmine hover
114114

115-
- name: Run hover_label test in America/Toronto timezone
115+
- name: Run hover test in America/Toronto timezone
116116
uses: ./.github/actions/run-xvfb
117117
env:
118118
TZ: 'America/Toronto'
119119
with:
120-
run: npm run test-jasmine hover_label
120+
run: npm run test-jasmine hover
121121

122122
no-gl-jasmine:
123123
needs: install-and-cibuild

draftlogs/7966_add.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
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 [[#7966](https://github.com/plotly/plotly.js/pull/7966)]
2+
- 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)]

src/components/dragelement/unhover.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,38 @@ unhover.wrapped = function(gd, evt, subplot) {
1616
throttle.clear(gd._fullLayout._uid + hoverConstants.HOVERID);
1717
}
1818

19-
unhover.raw(gd, evt, subplot);
19+
const oldhoverdata = gd._hoverdata;
20+
21+
const shouldEmitUnhover = unhover.raw(gd, evt, subplot);
22+
23+
// Special handling for `hoveranywhere`, to ensure we emit exactly one unhover event
24+
// when the cursor leaves the plot area.
25+
// gd._hoverAnywhereActive is set in fx/hover.js when we emit an empty-space hover event.
26+
if(shouldEmitUnhover && gd._hoverAnywhereActive) {
27+
gd._hoverAnywhereActive = false;
28+
29+
// Make sure hoveranywhere is still enabled
30+
if(gd._fullLayout?.hoveranywhere && evt?.target && !oldhoverdata) {
31+
gd.emit('plotly_unhover', {
32+
event: evt,
33+
points: []
34+
});
35+
}
36+
}
2037
};
2138

2239

2340
// remove hover effects on mouse out, and emit unhover event
41+
// returns false if unhover was skipped due to the plotly_beforehover handler returning false;
42+
// returns true otherwise
2443
unhover.raw = function raw(gd, evt) {
2544
var fullLayout = gd._fullLayout;
2645
var oldhoverdata = gd._hoverdata;
2746

2847
if(!evt) evt = {};
2948
if(evt.target && !gd._dragged &&
3049
Events.triggerHandler(gd, 'plotly_beforehover', evt) === false) {
31-
return;
50+
return false;
3251
}
3352

3453
fullLayout._hoverlayer.selectAll('g').remove();
@@ -42,4 +61,5 @@ unhover.raw = function raw(gd, evt) {
4261
points: oldhoverdata
4362
});
4463
}
64+
return true;
4565
};

src/components/fx/click.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ module.exports = function click(gd, evt, subplot) {
2424
clickData.yaxes ??= gd._hoverYAxes;
2525
clickData.xvals ??= gd._hoverXVals && helpers.c2dApply(gd._hoverXAxes, gd._hoverXVals);
2626
clickData.yvals ??= gd._hoverYVals && helpers.c2dApply(gd._hoverYAxes, gd._hoverYVals);
27+
clickData.xPixel ??= gd._hoverPointerX;
28+
clickData.yPixel ??= gd._hoverPointerY;
2729

2830
gd.emit('plotly_click', clickData);
2931
}

src/components/fx/hover.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
471471
if ('yval' in evt) yvalArray = helpers.flat(subplots, evt.yval);
472472
else yvalArray = helpers.p2c(yaArray, ypx);
473473

474+
// Save pointer position to gd so that it can be included in all hover/click event data,
475+
// even when hoveranywhere and clickanywhere are not enabled
476+
gd._hoverPointerX = evt.pointerX;
477+
gd._hoverPointerY = evt.pointerY;
478+
474479
if (!isNumeric(xvalArray[0]) || !isNumeric(yvalArray[0])) {
475480
Lib.warn('Fx.hover failed', evt, gd);
476481
return dragElement.unhoverRaw(gd, evt);
@@ -818,6 +823,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
818823
gd._hoverdata = [];
819824
}
820825
emitHover([]);
826+
827+
// Set a flag to note that an empty-space hover event is being emitted,
828+
// so that we know to emit an unhover event when the mouse leaves the plot area.
829+
// See dragelement/unhover.js.
830+
gd._hoverAnywhereActive = true;
821831
}
822832
return result;
823833
}
@@ -977,7 +987,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
977987
xaxes: xaArray,
978988
yaxes: yaArray,
979989
xvals: helpers.c2dApply(xaArray, xvalArray),
980-
yvals: helpers.c2dApply(yaArray, yvalArray)
990+
yvals: helpers.c2dApply(yaArray, yvalArray),
991+
// Note: top-level xPixel/yPixel correspond to the pixel position of the cursor.
992+
// Inside `points` array, points[i].xPixel/yPixel correspond to the pixel position of the point itself.
993+
xPixel: evt.pointerX,
994+
yPixel: evt.pointerY
981995
});
982996
}
983997
}

src/components/fx/layout_attributes.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ module.exports = {
112112
'If true, `plotly_hover` events will fire for any cursor position',
113113
'within the plot area, not just over traces.',
114114
'When the cursor is not over a trace, the event will have an empty `points` array',
115-
'but will include `xvals` and `yvals` with cursor coordinates in data space.'
115+
'but will include `xvals` and `yvals` with cursor coordinates in data space,',
116+
'and `xPixel` and `yPixel` with cursor coordinates in pixels,',
117+
'relative to the top-left corner of the graph div.',
118+
'A `plotly_unhover` event fires when the cursor leaves the plot area.'
116119
].join(' ')
117120
},
118121
clickanywhere: {
@@ -123,7 +126,9 @@ module.exports = {
123126
'If true, `plotly_click` events will fire for any click position',
124127
'within the plot area, not just over traces.',
125128
'When clicking where there is no trace data, the event will have an empty `points` array',
126-
'but will include `xvals` and `yvals` with click coordinates in data space.'
129+
'but will include `xvals` and `yvals` with click coordinates in data space,',
130+
'and `xPixel` and `yPixel` with click coordinates in pixels,',
131+
'relative to the top-left corner of the graph div.'
127132
].join(' ')
128133
},
129134
hoverdistance: {

src/types/core/events.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,14 @@ export interface PlotDatum {
6565
x: Datum;
6666
/** Axis the point's x is plotted against. */
6767
xaxis: LayoutAxis;
68+
/** Point center x position in pixels from the graph div's top-left corner */
69+
xPixel?: number;
6870
/** y coordinate of the point. */
6971
y: Datum;
7072
/** Axis the point's y is plotted against. */
7173
yaxis: LayoutAxis;
74+
/** Point center y position in pixels from the graph div's top-left corner */
75+
yPixel?: number;
7276
/** Resolved hover/display text for the point. */
7377
text: string;
7478
}
@@ -104,6 +108,10 @@ export interface PlotMouseEvent {
104108
points: PlotDatum[];
105109
/** The original DOM mouse event. */
106110
event: MouseEvent;
111+
/** Cursor x position in pixels from the graph div's top-left corner */
112+
xPixel?: number;
113+
/** Cursor y position in pixels from the graph div's top-left corner */
114+
yPixel?: number;
107115
}
108116

109117
/** Payload for `plotly_hover` — augments `PlotMouseEvent` with axis values. */

src/types/generated/schema.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15945,7 +15945,7 @@ export interface Layout {
1594515945
*/
1594615946
calendar?: Calendar;
1594715947
/**
15948-
* 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.
15948+
* 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.
1594915949
* @default false
1595015950
*/
1595115951
clickanywhere?: boolean;
@@ -16058,7 +16058,7 @@ export interface Layout {
1605816058
*/
1605916059
height?: number;
1606016060
/**
16061-
* 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.
16061+
* 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.
1606216062
* @default false
1606316063
*/
1606416064
hoveranywhere?: boolean;

test/jasmine/tests/click_test.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,23 @@ describe('Test click interactions:', function() {
110110
expect(contextPassthroughs).toBe(0);
111111
});
112112

113+
function checkData() {
114+
expect(Object.keys(futureData).sort()).toEqual([
115+
'event', 'points', 'xaxes', 'yaxes', 'xvals', 'yvals', 'xPixel', 'yPixel'
116+
].sort());
117+
118+
expect(futureData.event).not.toBe(null);
119+
checkPointData();
120+
// xvals, yvals, xaxes, and yaxes should all be undefined since clickanywhere is not enabled
121+
expect(futureData.xvals).toBe(undefined);
122+
expect(futureData.yvals).toBe(undefined);
123+
expect(futureData.xaxes).toBe(undefined);
124+
expect(futureData.yaxes).toBe(undefined);
125+
// However, xPixel and yPixel should be defined and match the click position
126+
expect(futureData.xPixel).toEqual(pointPos[0]);
127+
expect(futureData.yPixel).toEqual(pointPos[1]);
128+
}
129+
113130
function checkPointData() {
114131
expect(futureData.points.length).toEqual(1);
115132
expect(clickPassthroughs).toBe(2);
@@ -131,14 +148,14 @@ describe('Test click interactions:', function() {
131148
expect(evt.clientY).toEqual(pointPos[1]);
132149
}
133150

134-
it('should contain the correct fields', function() {
151+
it('should contain the correct fields with the correct values', function() {
135152
click(pointPos[0], pointPos[1]);
136-
checkPointData();
153+
checkData();
137154
});
138155

139156
it('should work with a sloppy click (shift < minDrag before mouseup)', function() {
140157
click(pointPos[0], pointPos[1], {slop: [4, 4]});
141-
checkPointData();
158+
checkData();
142159
});
143160

144161
it('works with fixedrange axes', function(done) {

0 commit comments

Comments
 (0)