diff --git a/draftlogs/7964_change.md b/draftlogs/7964_change.md new file mode 100644 index 00000000000..3f3bd43c453 --- /dev/null +++ b/draftlogs/7964_change.md @@ -0,0 +1 @@ +- **Breaking**: Return actual data values (rather than calcdata values) for `xvals` / `yvals` in `hoveranywhere` and `clickanywhere` events. Date and category axes will now return strings rather than numeric values. Linear and log axis values remain unchanged. [[#7964](https://github.com/plotly/plotly.js/pull/7964)] diff --git a/src/components/fx/click.js b/src/components/fx/click.js index 268dd6ae05d..bb51bc23028 100644 --- a/src/components/fx/click.js +++ b/src/components/fx/click.js @@ -1,6 +1,7 @@ 'use strict'; var Registry = require('../../registry'); +var helpers = require('./helpers'); var hover = require('./hover').hover; module.exports = function click(gd, evt, subplot) { @@ -21,8 +22,8 @@ module.exports = function click(gd, evt, subplot) { // get coordinate values from latest hover call, if available clickData.xaxes ??= gd._hoverXAxes; clickData.yaxes ??= gd._hoverYAxes; - clickData.xvals ??= gd._hoverXVals; - clickData.yvals ??= gd._hoverYVals; + clickData.xvals ??= gd._hoverXVals && helpers.c2dApply(gd._hoverXAxes, gd._hoverXVals); + clickData.yvals ??= gd._hoverYVals && helpers.c2dApply(gd._hoverYAxes, gd._hoverYVals); gd.emit('plotly_click', clickData); } diff --git a/src/components/fx/helpers.js b/src/components/fx/helpers.js index d3d2530bccb..43750ee1281 100644 --- a/src/components/fx/helpers.js +++ b/src/components/fx/helpers.js @@ -44,6 +44,39 @@ exports.p2c = function (axArray, v) { return out; }; +/* + * Given an array of calcdata values and an array of axes corresponding to each value, + * convert the calcdata values to data values by calling the `c2d` method of each axis. + * + * This function is intended to be used in constructing hover and click events, + * for converting x/y values from calc space to data space. axArray and valArray are arrays + * rather than single values because in the case of stacked subplots, there may be multiple axes + * (and therefore multiple data values) corresponding to a single hover or click event. + * + * For linear and log axes, this conversion has no effect beyond validating the inputs. + * However, for some axes types, the converted values may be of a different type than the + * inputs: + * - For category axes, `c2d` converts calcdata values (numeric) into category labels (strings) + * - For date axes, `c2d` converts calcdata values (numeric values in ms) into date strings + * + * For axes which don't define `c2d` (e.g. geo, map), the inputs are passed through untouched. + * + * @param {Array} axArray : axes corresponding to each value in valArray + * @param {Array} valArray : calcdata values + * @return {Array} : data values, computed by calling `ax.c2d` (if defined) on each input value + */ +exports.c2dApply = function (axArray, valArray) { + if(axArray.length !== valArray.length) { + Lib.warn('c2dApply: axArray and valArray must be the same length'); + } + var out = new Array(valArray.length); + for (var i = 0; i < valArray.length; i++) { + var ax = axArray && axArray[i]; + out[i] = ax && ax.c2d ? ax.c2d(valArray[i]) : valArray[i]; + } + return out; +}; + exports.getDistanceFunction = function (mode, dx, dy, dxy) { if (mode === 'closest') return dxy || exports.quadrature(dx, dy); return mode.charAt(0) === 'x' ? dx : dy; diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 05c03ebdfb1..2a6e9660697 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -976,8 +976,8 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { points: points, xaxes: xaArray, yaxes: yaArray, - xvals: xvalArray, - yvals: yvalArray + xvals: helpers.c2dApply(xaArray, xvalArray), + yvals: helpers.c2dApply(yaArray, yvalArray) }); } } diff --git a/test/jasmine/tests/hover_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js index 6a940078be2..a9bfd391402 100644 --- a/test/jasmine/tests/hover_click_anywhere_test.js +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -6,16 +6,19 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var click = require('../assets/click'); -function makePlot(gd, layoutExtras = {}, configExtras) { +function makePlot(gd, traceExtras = {}, layoutExtras = {}, configExtras) { return Plotly.newPlot( gd, [ - { - x: [1, 2, 3], - y: [1, 3, 2], - type: 'scatter', - mode: 'markers' - } + Lib.extendFlat( + { + x: [1, 2, 3], + y: [1, 3, 2], + type: 'scatter', + mode: 'markers' + }, + traceExtras + ) ], Lib.extendFlat( { @@ -32,6 +35,21 @@ function makePlot(gd, layoutExtras = {}, configExtras) { ); } +// local midnight, as in https://github.com/plotly/plotly.js/issues/7816 +var dayStart = new Date(2026, 4, 31); +var dayNoon = new Date(2026, 4, 31, 12); +var dayEnd = new Date(2026, 5, 1); + +// the 300px-wide plot area spans exactly one day, so 0px is local midnight +// and 150px is local noon, in any timezone +function makeDatePlot(gd, traceExtras, layoutExtras) { + return makePlot( + gd, + Lib.extendFlat({ x: [dayStart, dayNoon], y: [1, 3] }, traceExtras), + Lib.extendFlat({ xaxis: { type: 'date', range: [dayStart, dayEnd] } }, layoutExtras) + ); +} + describe('hoveranywhere', () => { 'use strict'; @@ -58,7 +76,7 @@ describe('hoveranywhere', () => { it('emits plotly_hover with coordinate data on empty space', (done) => { var hoverData; - makePlot(gd, { hoveranywhere: true }) + makePlot(gd, {}, { hoveranywhere: true }) .then(() => { gd.on('plotly_hover', (d) => (hoverData = d)); @@ -94,7 +112,7 @@ describe('hoveranywhere', () => { it('still returns normal point data on traces', (done) => { var hoverData; - makePlot(gd, { hoveranywhere: true }) + makePlot(gd, {}, { hoveranywhere: true }) .then(() => { gd.on('plotly_hover', (d) => (hoverData = d)); @@ -132,7 +150,7 @@ describe('hoveranywhere', () => { it('respects hovermode:false', (done) => { var hoverData; - makePlot(gd, { hoveranywhere: true, hovermode: false }) + makePlot(gd, {}, { hoveranywhere: true, hovermode: false }) .then(() => { gd.on('plotly_hover', (d) => (hoverData = d)); _hover(250, 50); @@ -144,7 +162,7 @@ describe('hoveranywhere', () => { it('emits plotly_hover over an editable shape', (done) => { let hoverData; - makePlot(gd, { + makePlot(gd, {}, { hoveranywhere: true, shapes: [ { @@ -192,6 +210,7 @@ describe('hoveranywhere', () => { makePlot( gd, + {}, { hoveranywhere: true, shapes: [ @@ -231,6 +250,55 @@ describe('hoveranywhere', () => { }) .then(done, done.fail); }); + + it('reports date axis positions as date strings', (done) => { + var hoverData; + + makeDatePlot(gd, {}, { hoveranywhere: true }) + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + + _hover(0, 60); + expect(hoverData.points).toEqual([]); + expect(hoverData.xvals[0]).toBe('2026-05-31'); + expect(hoverData.yvals[0]).toBeCloseTo(10 - 60 / 30, 2); + + _hover(150, 60); + expect(hoverData.xvals[0]).toBe('2026-05-31 12:00'); + + // the point at (dayStart, 1) reports that same value + _hover(0, gd._fullLayout.yaxis.c2p(1)); + expect(hoverData.points[0].x).toBe('2026-05-31'); + expect(hoverData.xvals[0]).toBe('2026-05-31'); + }) + .then(done, done.fail); + }); + + it('reports category names and log axis data values', (done) => { + var hoverData; + + makePlot( + gd, + { x: ['a', 'b', 'c'], y: [10, 20, 30] }, + { xaxis: { type: 'category' }, yaxis: { type: 'log', range: [1, 3] }, hoveranywhere: true } + ) + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + + var xa = gd._fullLayout.xaxis; + + // empty space above the middle category, halfway up 10 -> 1000 + _hover(xa.c2p(1), 150); + expect(hoverData.points).toEqual([]); + expect(hoverData.xvals[0]).toBe('b'); + expect(hoverData.yvals[0]).toBeCloseTo(100, 6); + + _hover(xa.c2p(1), gd._fullLayout.yaxis.c2p(20)); + expect(hoverData.points[0].x).toBe('b'); + expect(hoverData.xvals[0]).toBe('b'); + }) + .then(done, done.fail); + }); }); describe('clickanywhere', () => { @@ -244,7 +312,7 @@ describe('clickanywhere', () => { it('emits plotly_click with empty points on empty space', (done) => { var clickData; - makePlot(gd, { clickanywhere: true }) + makePlot(gd, {}, { clickanywhere: true }) .then(() => { gd.on('plotly_click', (d) => (clickData = d)); @@ -285,7 +353,7 @@ describe('clickanywhere', () => { it('emits plotly_click over an editable shape', (done) => { let clickData; - makePlot(gd, { + makePlot(gd, {}, { clickanywhere: true, shapes: [ { @@ -329,6 +397,7 @@ describe('clickanywhere', () => { makePlot( gd, + {}, { clickanywhere: true, shapes: [ @@ -367,4 +436,21 @@ describe('clickanywhere', () => { }) .then(done, done.fail); }); + it('reports date axis positions as date strings', (done) => { + var clickData; + + makeDatePlot(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, bb.top + s.t + 60); + + expect(clickData.points).toEqual([]); + expect(clickData.xvals[0]).toBe('2026-05-31'); + expect(clickData.yvals[0]).toBeCloseTo(10 - 60 / 30, 2); + }) + .then(done, done.fail); + }); });