diff --git a/draftlogs/7964_change.md b/draftlogs/7964_change.md new file mode 100644 index 00000000000..ebf0ea4f3fc --- /dev/null +++ b/draftlogs/7964_change.md @@ -0,0 +1 @@ +- Return actual data values (rather than calcdata values) for `xvals` / `yvals` in `hoveranywhere` and `clickanywhere` events [[#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..9303b772b61 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, layoutExtras = {}, traceExtras = {}, 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,20 @@ 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, layoutExtras) { + return makePlot(gd, Lib.extendFlat({ xaxis: { type: 'date', range: [dayStart, dayEnd] } }, layoutExtras), { + x: [dayStart, dayNoon], + y: [1, 3] + }); +} + describe('hoveranywhere', () => { 'use strict'; @@ -205,6 +222,7 @@ describe('hoveranywhere', () => { } ] }, + {}, { edits: { shapePosition: true } } ) .then(() => { @@ -231,6 +249,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, + { xaxis: { type: 'category' }, yaxis: { type: 'log', range: [1, 3] }, hoveranywhere: true }, + { x: ['a', 'b', 'c'], y: [10, 20, 30] } + ) + .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', () => { @@ -342,6 +409,7 @@ describe('clickanywhere', () => { } ] }, + {}, { edits: { shapePosition: true } } ) .then(() => { @@ -367,4 +435,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); + }); });