From dc05127b8e6f922da3949918618fa055cf0da625 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:47:57 -0400 Subject: [PATCH 1/3] add check before searching all points for drawing spikelines (performance fix) --- src/components/fx/hover.js | 199 ++++++++++++++++++++----------------- 1 file changed, 107 insertions(+), 92 deletions(-) diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 92c7dd8b784..1926cdfa651 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -262,6 +262,16 @@ exports.loneHover = function loneHover(hoverItems, opts) { return multiHover ? hoverLabel : hoverLabel.node(); }; +// Helper function for use with spikeline logic +// Returns true if either axis has spikeline settings which allow a spikeline to be drawn +// to a point that is not currently hovered over; returns false otherwise. +function canSpikeToNonHoveredPoint(xa, ya) { + return Boolean( + (xa && xa.showspikes && xa.spikesnap !== 'hovered data') || + (ya && ya.showspikes && ya.spikesnap !== 'hovered data') + ); +} + // The actual implementation is here: function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { if (!subplot) subplot = 'xy'; @@ -658,53 +668,58 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { distance = hoverData[0].distance; } - // Now if there is range to look in, find the points to draw the spikelines - // Do it only if there is no hoverData - if (hasCartesian && spikedistance !== 0) { - if (hoverData.length === 0) { - pointData.distance = spikedistance; - pointData.index = false; - var closestPoints = trace._module.hoverPoints(pointData, xval, yval, 'closest', { - hoverLayer: fullLayout._hoverlayer + // If there is no hoverData, and if the axis spikeline settings permit us to draw + // a spikeline to the nearest non-hovered point, search for points to spike to. + // This search is _expensive_ (especially when spikedistance is -1 which requires + // a search over all points), so we should only do it when absolutely required + if (hasCartesian + && hoverData.length === 0 + && spikedistance !== 0 + && canSpikeToNonHoveredPoint(pointData.xa, pointData.ya) + ) { + // Find the points to draw the spikelines + pointData.distance = spikedistance; + pointData.index = false; + var closestPoints = trace._module.hoverPoints(pointData, xval, yval, 'closest', { + hoverLayer: fullLayout._hoverlayer + }); + if (closestPoints) { + closestPoints = closestPoints.filter(function (point) { + // some hover points, like scatter fills, do not allow spikes, + // so will generate a hover point but without a valid spikeDistance + return point.spikeDistance <= spikedistance; }); - if (closestPoints) { - closestPoints = closestPoints.filter(function (point) { - // some hover points, like scatter fills, do not allow spikes, - // so will generate a hover point but without a valid spikeDistance - return point.spikeDistance <= spikedistance; - }); - } - if (closestPoints && closestPoints.length) { - var tmpPoint; - var closestVPoints = closestPoints.filter(function (point) { - return point.xa.showspikes && point.xa.spikesnap !== 'hovered data'; - }); - if (closestVPoints.length) { - var closestVPt = closestVPoints[0]; - if (isNumeric(closestVPt.x0) && isNumeric(closestVPt.y0)) { - tmpPoint = fillSpikePoint(closestVPt); - if ( - !spikePoints.vLinePoint || - spikePoints.vLinePoint.spikeDistance > tmpPoint.spikeDistance - ) { - spikePoints.vLinePoint = tmpPoint; - } + } + if (closestPoints && closestPoints.length) { + var tmpPoint; + var closestVPoints = closestPoints.filter(function (point) { + return point.xa.showspikes && point.xa.spikesnap !== 'hovered data'; + }); + if (closestVPoints.length) { + var closestVPt = closestVPoints[0]; + if (isNumeric(closestVPt.x0) && isNumeric(closestVPt.y0)) { + tmpPoint = fillSpikePoint(closestVPt); + if ( + !spikePoints.vLinePoint || + spikePoints.vLinePoint.spikeDistance > tmpPoint.spikeDistance + ) { + spikePoints.vLinePoint = tmpPoint; } } + } - var closestHPoints = closestPoints.filter(function (point) { - return point.ya.showspikes && point.ya.spikesnap !== 'hovered data'; - }); - if (closestHPoints.length) { - var closestHPt = closestHPoints[0]; - if (isNumeric(closestHPt.x0) && isNumeric(closestHPt.y0)) { - tmpPoint = fillSpikePoint(closestHPt); - if ( - !spikePoints.hLinePoint || - spikePoints.hLinePoint.spikeDistance > tmpPoint.spikeDistance - ) { - spikePoints.hLinePoint = tmpPoint; - } + var closestHPoints = closestPoints.filter(function (point) { + return point.ya.showspikes && point.ya.spikesnap !== 'hovered data'; + }); + if (closestHPoints.length) { + var closestHPt = closestHPoints[0]; + if (isNumeric(closestHPt.x0) && isNumeric(closestHPt.y0)) { + tmpPoint = fillSpikePoint(closestHPt); + if ( + !spikePoints.hLinePoint || + spikePoints.hLinePoint.spikeDistance > tmpPoint.spikeDistance + ) { + spikePoints.hLinePoint = tmpPoint; } } } @@ -1156,26 +1171,26 @@ function createHoverText(hoverData, opts) { lpath.attr( 'd', 'M' + - (lx - tooltipMidX) + - ',0' + - 'L' + - (lx - tooltipMidX + HOVERARROWSIZE) + - ',' + - topsign + - HOVERARROWSIZE + - 'H' + - halfWidth + - 'v' + - topsign + - (HOVERTEXTPAD * 2 + tbb.height) + - 'H' + - -halfWidth + - 'V' + - topsign + - HOVERARROWSIZE + - 'H' + - (lx - tooltipMidX - HOVERARROWSIZE) + - 'Z' + (lx - tooltipMidX) + + ',0' + + 'L' + + (lx - tooltipMidX + HOVERARROWSIZE) + + ',' + + topsign + + HOVERARROWSIZE + + 'H' + + halfWidth + + 'v' + + topsign + + (HOVERTEXTPAD * 2 + tbb.height) + + 'H' + + -halfWidth + + 'V' + + topsign + + HOVERARROWSIZE + + 'H' + + (lx - tooltipMidX - HOVERARROWSIZE) + + 'Z' ); lx = tooltipMidX; @@ -1212,24 +1227,24 @@ function createHoverText(hoverData, opts) { lpath.attr( 'd', 'M0,0' + - 'L' + - leftsign + - HOVERARROWSIZE + - ',' + - HOVERARROWSIZE + - 'V' + - (HOVERTEXTPAD + tbb.height / 2) + - 'h' + - leftsign + - (HOVERTEXTPAD * 2 + tbb.width) + - 'V-' + - (HOVERTEXTPAD + tbb.height / 2) + - 'H' + - leftsign + - HOVERARROWSIZE + - 'V-' + - HOVERARROWSIZE + - 'Z' + 'L' + + leftsign + + HOVERARROWSIZE + + ',' + + HOVERARROWSIZE + + 'V' + + (HOVERTEXTPAD + tbb.height / 2) + + 'h' + + leftsign + + (HOVERTEXTPAD * 2 + tbb.width) + + 'V-' + + (HOVERTEXTPAD + tbb.height / 2) + + 'H' + + leftsign + + HOVERARROWSIZE + + 'V-' + + HOVERARROWSIZE + + 'Z' ); commonLabelRect.minY = ly - (HOVERTEXTPAD + tbb.height / 2); @@ -1312,12 +1327,12 @@ function createHoverText(hoverData, opts) { var mainText = !unifiedhovertitleText ? t0 : Lib.hovertemplateString({ - data: - hovermode === 'x unified' ? [{ xa: item0.xa, x: item0.xVal }] : [{ ya: item0.ya, y: item0.yVal }], - fallback: item0.trace.hovertemplatefallback, - locale: fullLayout._d3locale, - template: unifiedhovertitleText - }); + data: + hovermode === 'x unified' ? [{ xa: item0.xa, x: item0.xVal }] : [{ ya: item0.ya, y: item0.yVal }], + fallback: item0.trace.hovertemplatefallback, + locale: fullLayout._d3locale, + template: unifiedhovertitleText + }); var mockLayoutIn = { showlegend: true, @@ -2182,12 +2197,12 @@ function cleanPoint(d, hovermode) { var getVal = Array.isArray(index) ? function (calcKey, traceKey) { - var v = Lib.castOption(cd0, index, calcKey); - return pass(v) ? v : Lib.extractOption({}, trace, '', traceKey); - } + var v = Lib.castOption(cd0, index, calcKey); + return pass(v) ? v : Lib.extractOption({}, trace, '', traceKey); + } : function (calcKey, traceKey) { - return Lib.extractOption(cd, trace, calcKey, traceKey); - }; + return Lib.extractOption(cd, trace, calcKey, traceKey); + }; function fill(key, calcKey, traceKey) { var val = getVal(calcKey, traceKey); From 4d086acd472fa399e5e6fef1509c397b36656521 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:48:11 -0400 Subject: [PATCH 2/3] add tests --- test/jasmine/tests/hover_spikeline_test.js | 848 +++++++++++---------- 1 file changed, 463 insertions(+), 385 deletions(-) diff --git a/test/jasmine/tests/hover_spikeline_test.js b/test/jasmine/tests/hover_spikeline_test.js index 311c0e7b503..8aa46461c32 100644 --- a/test/jasmine/tests/hover_spikeline_test.js +++ b/test/jasmine/tests/hover_spikeline_test.js @@ -9,12 +9,12 @@ var Lib = require('../../../src/lib'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); -describe('spikeline hover', function() { +describe('spikeline hover', function () { 'use strict'; var gd; - beforeEach(function() { + beforeEach(function () { gd = createGraphDiv(); }); @@ -37,7 +37,7 @@ describe('spikeline hover', function() { } function _hover(evt, subplot) { - if(!subplot) subplot = 'xy'; + if (!subplot) subplot = 'xy'; Fx.hover(gd, evt, subplot); Lib.clearThrottle(); } @@ -58,106 +58,106 @@ describe('spikeline hover', function() { expect(lines.size()).toBe(lineExpect.length * 2, '# of line nodes'); expect(circles.size()).toBe(circleExpect.length, '# of circle nodes'); - lines.each(function(_, i) { + lines.each(function (_, i) { var sel = d3Select(this); - ['x1', 'y1', 'x2', 'y2'].forEach(function(d, j) { + ['x1', 'y1', 'x2', 'y2'].forEach(function (d, j) { expect(sel.attr(d)) // we always have 2 lines with identical coords .toBeWithin(lineExpect[Math.floor(i / 2)][j], TOL, 'line ' + i + ' attr ' + d); }); }); - circles.each(function(_, i) { + circles.each(function (_, i) { var sel = d3Select(this); - ['cx', 'cy'].forEach(function(d, j) { + ['cx', 'cy'].forEach(function (d, j) { expect(sel.attr(d)) .toBeWithin(circleExpect[i][j], TOL, 'circle ' + i + ' attr ' + d); }); }); } - it('draws lines and markers on enabled axes in the closest hovermode', function(done) { + it('draws lines and markers on enabled axes in the closest hovermode', function (done) { var _mock = makeMock('toaxis', 'closest'); - Plotly.newPlot(gd, _mock).then(function() { - _hover({xval: 2, yval: 3}); + Plotly.newPlot(gd, _mock).then(function () { + _hover({ xval: 2, yval: 3 }); _assert( [[557, 401, 557, 250], [80, 250, 557, 250]], [[83, 250]] ); - _hover({xval: 30, yval: 40}, 'x2y2'); + _hover({ xval: 30, yval: 40 }, 'x2y2'); _assert( [[820, 220, 820, 167]], [] ); }) - .then(done, done.fail); + .then(done, done.fail); }); - it('works the same for scattergl', function(done) { + it('works the same for scattergl', function (done) { var _mock = makeMock('toaxis', 'closest'); _mock.data[0].type = 'scattergl'; _mock.data[1].type = 'scattergl'; - Plotly.newPlot(gd, _mock).then(function() { - _hover({xval: 2, yval: 3}); + Plotly.newPlot(gd, _mock).then(function () { + _hover({ xval: 2, yval: 3 }); _assert( [[557, 401, 557, 250], [80, 250, 557, 250]], [[83, 250]] ); - _hover({xval: 30, yval: 40}, 'x2y2'); + _hover({ xval: 30, yval: 40 }, 'x2y2'); _assert( [[820, 220, 820, 167]], [] ); }) - .then(done, done.fail); + .then(done, done.fail); }); - it('draws lines and markers on enabled axes w/o tick labels', function(done) { + it('draws lines and markers on enabled axes w/o tick labels', function (done) { var _mock = makeMock('toaxis', 'closest'); _mock.layout.xaxis.showticklabels = false; _mock.layout.yaxis.showticklabels = false; - Plotly.newPlot(gd, _mock).then(function() { - _hover({xval: 2, yval: 3}); + Plotly.newPlot(gd, _mock).then(function () { + _hover({ xval: 2, yval: 3 }); _assert( [[557, 401, 557, 250], [80, 250, 557, 250]], [[83, 250]] ); - _hover({xval: 30, yval: 40}, 'x2y2'); + _hover({ xval: 30, yval: 40 }, 'x2y2'); _assert( [[820, 220, 820, 167]], [] ); }) - .then(done, done.fail); + .then(done, done.fail); }); - it('draws lines and markers on enabled axes in the x hovermode', function(done) { + it('draws lines and markers on enabled axes in the x hovermode', function (done) { var _mock = makeMock('across', 'x'); - Plotly.newPlot(gd, _mock).then(function() { - _hover({xval: 2, yval: 3}); + Plotly.newPlot(gd, _mock).then(function () { + _hover({ xval: 2, yval: 3 }); _assert( [[557, 100, 557, 401], [80, 250, 1036, 250]], [[83, 250]] ); - _hover({xval: 30, yval: 40}, 'x2y2'); + _hover({ xval: 30, yval: 40 }, 'x2y2'); _assert( [[820, 116, 820, 220]], [] ); }) - .then(done, done.fail); + .then(done, done.fail); }); - it('draws lines up to x-axis position', function(done) { + it('draws lines up to x-axis position', function (done) { Plotly.newPlot(gd, [ { y: [1, 2, 1] }, { y: [2, 1, 2], yaxis: 'y2' } @@ -169,21 +169,21 @@ describe('spikeline hover', function() { width: 400, height: 400 }) - .then(function() { - _hover({xval: 1, yval: 2}); - // from "y" of x-axis up to "y" of pt - _assert([[189, 210.5, 189, 109.25]], []); - }) - .then(function() { return Plotly.relayout(gd, 'xaxis.spikemode', 'across'); }) - .then(function() { - _hover({xval: 1, yval: 2}); - // from "y" of xy subplot top, down to "y" xy2 subplot bottom - _assert([[189, 100, 189, 320]], []); - }) - .then(done, done.fail); + .then(function () { + _hover({ xval: 1, yval: 2 }); + // from "y" of x-axis up to "y" of pt + _assert([[189, 210.5, 189, 109.25]], []); + }) + .then(function () { return Plotly.relayout(gd, 'xaxis.spikemode', 'across'); }) + .then(function () { + _hover({ xval: 1, yval: 2 }); + // from "y" of xy subplot top, down to "y" xy2 subplot bottom + _assert([[189, 100, 189, 320]], []); + }) + .then(done, done.fail); }); - it('draws lines up to y-axis position - anchor free case', function(done) { + it('draws lines up to y-axis position - anchor free case', function (done) { Plotly.newPlot(gd, [ { y: [1, 2, 1] }, { y: [2, 1, 2], xaxis: 'x2' } @@ -197,21 +197,21 @@ describe('spikeline hover', function() { height: 400, showlegend: false }) - .then(function() { - _hover({xval: 0, yval: 2}, 'x2y'); - // from "y" of pt, down to "y" of x2 axis - _assert([[95.75, 100, 95.75, 320]], []); - }) - .then(function() { return Plotly.relayout(gd, 'xaxis2.position', 0.6); }) - .then(function() { - _hover({xval: 0, yval: 2}, 'x2y'); - // from "y" of pt, down to "y" of x axis (which is further down) - _assert([[95.75, 100, 95.75, 210]], []); - }) - .then(done, done.fail); + .then(function () { + _hover({ xval: 0, yval: 2 }, 'x2y'); + // from "y" of pt, down to "y" of x2 axis + _assert([[95.75, 100, 95.75, 320]], []); + }) + .then(function () { return Plotly.relayout(gd, 'xaxis2.position', 0.6); }) + .then(function () { + _hover({ xval: 0, yval: 2 }, 'x2y'); + // from "y" of pt, down to "y" of x axis (which is further down) + _assert([[95.75, 100, 95.75, 210]], []); + }) + .then(done, done.fail); }); - it('draws lines up to y-axis position', function(done) { + it('draws lines up to y-axis position', function (done) { Plotly.newPlot(gd, [ { y: [1, 2, 1] }, { y: [2, 1, 2], xaxis: 'x2' } @@ -225,21 +225,21 @@ describe('spikeline hover', function() { height: 400, showlegend: false }) - .then(function() { - _hover({xval: 1, yval: 2}); - // from "x" of y-axis to "x" of pt - _assert([[199.5, 114.75, 260, 114.75]], []); - }) - .then(function() { return Plotly.relayout(gd, 'yaxis.spikemode', 'across'); }) - .then(function() { - _hover({xval: 1, yval: 2}); - // from "x" at xy2 subplot left, to "x" at xy subplot right - _assert([[80, 114.75, 320, 114.75]], []); - }) - .then(done, done.fail); + .then(function () { + _hover({ xval: 1, yval: 2 }); + // from "x" of y-axis to "x" of pt + _assert([[199.5, 114.75, 260, 114.75]], []); + }) + .then(function () { return Plotly.relayout(gd, 'yaxis.spikemode', 'across'); }) + .then(function () { + _hover({ xval: 1, yval: 2 }); + // from "x" at xy2 subplot left, to "x" at xy subplot right + _assert([[80, 114.75, 320, 114.75]], []); + }) + .then(done, done.fail); }); - it('draws lines up to y-axis position - anchor free case', function(done) { + it('draws lines up to y-axis position - anchor free case', function (done) { Plotly.newPlot(gd, [ { y: [1, 2, 1] }, { y: [2, 1, 2], yaxis: 'y2' } @@ -253,21 +253,21 @@ describe('spikeline hover', function() { height: 400, showlegend: false }) - .then(function() { - _hover({xval: 0, yval: 2}, 'xy2'); - // from "x" of y2 axis to "x" of pt - _assert([[80, 114.75, 320, 114.75]], []); - }) - .then(function() { return Plotly.relayout(gd, 'yaxis2.position', 0.6); }) - .then(function() { - _hover({xval: 0, yval: 2}, 'xy2'); - // from "x" of y axis (which is further left) to "x" of pt - _assert([[200, 114.75, 320, 114.75]], []); - }) - .then(done, done.fail); + .then(function () { + _hover({ xval: 0, yval: 2 }, 'xy2'); + // from "x" of y2 axis to "x" of pt + _assert([[80, 114.75, 320, 114.75]], []); + }) + .then(function () { return Plotly.relayout(gd, 'yaxis2.position', 0.6); }) + .then(function () { + _hover({ xval: 0, yval: 2 }, 'xy2'); + // from "x" of y axis (which is further left) to "x" of pt + _assert([[200, 114.75, 320, 114.75]], []); + }) + .then(done, done.fail); }); - it('draws lines and markers on enabled axes in the spikesnap "cursor" mode', function(done) { + it('draws lines and markers on enabled axes in the spikesnap "cursor" mode', function (done) { var _mock = makeMock('toaxis', 'x'); _mock.layout.xaxis.spikesnap = 'cursor'; @@ -275,59 +275,59 @@ describe('spikeline hover', function() { _mock.layout.xaxis2.spikesnap = 'cursor'; Plotly.newPlot(gd, _mock) - .then(function() { - _setSpikedistance(200); - }) - .then(function() { - _hover({xpx: 120, ypx: 180}); - _assert( - [[200, 401, 200, 280], [80, 280, 200, 280]], - [[83, 280]] - ); - - _hover({xpx: 31, ypx: 41}, 'x2y2'); - _assert( - [[682, 220, 682, 156]], - [] - ); - }) - .then(done, done.fail); + .then(function () { + _setSpikedistance(200); + }) + .then(function () { + _hover({ xpx: 120, ypx: 180 }); + _assert( + [[200, 401, 200, 280], [80, 280, 200, 280]], + [[83, 280]] + ); + + _hover({ xpx: 31, ypx: 41 }, 'x2y2'); + _assert( + [[682, 220, 682, 156]], + [] + ); + }) + .then(done, done.fail); }); - it('does not show spikes if no points are hovered in the spikesnap "hovered data" mode', function(done) { + it('does not show spikes if no points are hovered in the spikesnap "hovered data" mode', function (done) { var _mock = makeMock('toaxis', 'x'); Plotly.newPlot(gd, _mock) - .then(function() { - _hover({xval: 1.5}); - _assert( - [[558, 401, 558, 251], [80, 251, 558, 251]], [[83, 251]] - ); - return Plotly.relayout(gd, 'xaxis.spikesnap', 'hovered data'); - }) - .then(function() { - _hover({xval: 1.5}); - _assert([[80, 251, 558, 251]], [[83, 251]]); - - return Plotly.relayout(gd, 'yaxis.spikesnap', 'hovered data'); - }) - .then(function() { - _hover({xval: 1.5}); - _assert([], []); - }) - .then(done, done.fail); + .then(function () { + _hover({ xval: 1.5 }); + _assert( + [[558, 401, 558, 251], [80, 251, 558, 251]], [[83, 251]] + ); + return Plotly.relayout(gd, 'xaxis.spikesnap', 'hovered data'); + }) + .then(function () { + _hover({ xval: 1.5 }); + _assert([[80, 251, 558, 251]], [[83, 251]]); + + return Plotly.relayout(gd, 'yaxis.spikesnap', 'hovered data'); + }) + .then(function () { + _hover({ xval: 1.5 }); + _assert([], []); + }) + .then(done, done.fail); }); - it('doesn\'t switch between toaxis and across spikemodes on switching the hovermodes', function(done) { + it('doesn\'t switch between toaxis and across spikemodes on switching the hovermodes', function (done) { var _mock = makeMock('toaxis', 'closest'); - Plotly.newPlot(gd, _mock).then(function() { - _hover({xval: 2, yval: 3}); + Plotly.newPlot(gd, _mock).then(function () { + _hover({ xval: 2, yval: 3 }); _assert( [[557, 401, 557, 250], [80, 250, 557, 250]], [[83, 250]] ); - _hover({xval: 30, yval: 40}, 'x2y2'); + _hover({ xval: 30, yval: 40 }, 'x2y2'); _assert( [[820, 220, 820, 167]], [] @@ -335,34 +335,34 @@ describe('spikeline hover', function() { _setHovermode('x'); }) - .then(function() { - _hover({xval: 2, yval: 3}); - _assert( - [[557, 401, 557, 250], [80, 250, 557, 250]], - [[83, 250]] - ); - - _hover({xval: 30, yval: 40}, 'x2y2'); - _assert( - [[820, 220, 820, 167]], - [] - ); - }) - .then(done, done.fail); + .then(function () { + _hover({ xval: 2, yval: 3 }); + _assert( + [[557, 401, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + + _hover({ xval: 30, yval: 40 }, 'x2y2'); + _assert( + [[820, 220, 820, 167]], + [] + ); + }) + .then(done, done.fail); }); - it('increase the range of search for points to draw the spikelines on spikedistance change', function(done) { + it('increase the range of search for points to draw the spikelines on spikedistance change', function (done) { var _mock = makeMock('toaxis', 'closest'); _mock.layout.spikedistance = 20; - Plotly.newPlot(gd, _mock).then(function() { - _hover({xval: 1.6, yval: 2.6}); + Plotly.newPlot(gd, _mock).then(function () { + _hover({ xval: 1.6, yval: 2.6 }); _assert( [], [] ); - _hover({xval: 26, yval: 36}, 'x2y2'); + _hover({ xval: 26, yval: 36 }, 'x2y2'); _assert( [], [] @@ -370,58 +370,58 @@ describe('spikeline hover', function() { _setSpikedistance(200); }) - .then(function() { - _hover({xval: 1.6, yval: 2.6}); - _assert( - [[557, 401, 557, 250], [80, 250, 557, 250]], - [[83, 250]] - ); - - _hover({xval: 26, yval: 36}, 'x2y2'); - _assert( - [[820, 220, 820, 167]], - [] - ); - }) - .then(done, done.fail); + .then(function () { + _hover({ xval: 1.6, yval: 2.6 }); + _assert( + [[557, 401, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + + _hover({ xval: 26, yval: 36 }, 'x2y2'); + _assert( + [[820, 220, 820, 167]], + [] + ); + }) + .then(done, done.fail); }); it('correctly responds to setting the spikedistance to -1 by increasing ' + - 'the range of search for points to draw the spikelines to Infinity', function(done) { - var _mock = makeMock('toaxis', 'closest'); - - Plotly.newPlot(gd, _mock).then(function() { - _hover({xval: 1.6, yval: 2.6}); - _assert( - [[557, 401, 557, 250], [80, 250, 557, 250]], - [[83, 250]] - ); - - _hover({xval: 26, yval: 36}, 'x2y2'); - _assert( - [[820, 220, 820, 167]], - [] - ); - - _setSpikedistance(20); - }) - .then(function() { - _hover({xval: 1.6, yval: 2.6}); - _assert( - [], - [] - ); - - _hover({xval: 26, yval: 36}, 'x2y2'); - _assert( - [], - [] - ); - }) - .then(done, done.fail); - }); + 'the range of search for points to draw the spikelines to Infinity', function (done) { + var _mock = makeMock('toaxis', 'closest'); + + Plotly.newPlot(gd, _mock).then(function () { + _hover({ xval: 1.6, yval: 2.6 }); + _assert( + [[557, 401, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + + _hover({ xval: 26, yval: 36 }, 'x2y2'); + _assert( + [[820, 220, 820, 167]], + [] + ); + + _setSpikedistance(20); + }) + .then(function () { + _hover({ xval: 1.6, yval: 2.6 }); + _assert( + [], + [] + ); + + _hover({ xval: 26, yval: 36 }, 'x2y2'); + _assert( + [], + [] + ); + }) + .then(done, done.fail); + }); - it('correctly select the closest bar even when setting spikedistance to -1 (case of x hovermode)', function(done) { + it('correctly select the closest bar even when setting spikedistance to -1 (case of x hovermode)', function (done) { var mock = require('../../image/mocks/bar_stack-with-gaps'); var mockCopy = Lib.extendDeep({}, mock); mockCopy.layout.xaxis.showspikes = true; @@ -430,21 +430,21 @@ describe('spikeline hover', function() { mockCopy.layout.hovermode = 'x'; Plotly.newPlot(gd, mockCopy) - .then(function() { - _hover({xpx: 600, ypx: 400}); - var lines = d3SelectAll('line.spikeline'); - expect(lines.size()).toBe(4); - expect(lines[0][1].getAttribute('stroke')).toBe('#2ca02c'); - - _hover({xpx: 600, ypx: 100}); - lines = d3SelectAll('line.spikeline'); - expect(lines.size()).toBe(4); - expect(lines[0][1].getAttribute('stroke')).toBe('#2ca02c'); - }) - .then(done, done.fail); + .then(function () { + _hover({ xpx: 600, ypx: 400 }); + var lines = d3SelectAll('line.spikeline'); + expect(lines.size()).toBe(4); + expect(lines[0][1].getAttribute('stroke')).toBe('#2ca02c'); + + _hover({ xpx: 600, ypx: 100 }); + lines = d3SelectAll('line.spikeline'); + expect(lines.size()).toBe(4); + expect(lines[0][1].getAttribute('stroke')).toBe('#2ca02c'); + }) + .then(done, done.fail); }); - it('correctly select the closest bar even when setting spikedistance to -1 (case of closest hovermode)', function(done) { + it('correctly select the closest bar even when setting spikedistance to -1 (case of closest hovermode)', function (done) { var mock = require('../../image/mocks/bar_stack-with-gaps'); var mockCopy = Lib.extendDeep({}, mock); mockCopy.layout.xaxis.showspikes = true; @@ -455,21 +455,21 @@ describe('spikeline hover', function() { mockCopy.layout.hovermode = 'closest'; Plotly.newPlot(gd, mockCopy) - .then(function() { - _hover({xpx: 600, ypx: 400}); - var lines = d3SelectAll('line.spikeline'); - expect(lines.size()).toBe(4); - expect(lines[0][1].getAttribute('stroke')).toBe('#1f77b4'); - - _hover({xpx: 600, ypx: 100}); - lines = d3SelectAll('line.spikeline'); - expect(lines.size()).toBe(4); - expect(lines[0][1].getAttribute('stroke')).toBe('#2ca02c'); - }) - .then(done, done.fail); + .then(function () { + _hover({ xpx: 600, ypx: 400 }); + var lines = d3SelectAll('line.spikeline'); + expect(lines.size()).toBe(4); + expect(lines[0][1].getAttribute('stroke')).toBe('#1f77b4'); + + _hover({ xpx: 600, ypx: 100 }); + lines = d3SelectAll('line.spikeline'); + expect(lines.size()).toBe(4); + expect(lines[0][1].getAttribute('stroke')).toBe('#2ca02c'); + }) + .then(done, done.fail); }); - it('could select the closest scatter point inside bar', function(done) { + it('could select the closest scatter point inside bar', function (done) { Plotly.newPlot(gd, { data: [{ type: 'scatter', @@ -517,93 +517,93 @@ describe('spikeline hover', function() { } } }) - .then(function() { - var lines; - - _hover({xpx: 200, ypx: 200}); - lines = d3SelectAll('line.spikeline'); - expect(lines.size()).toBe(4); - expect(lines[0][1].getAttribute('stroke')).toBe('green'); - - _hover({xpx: 200, ypx: 350}); - lines = d3SelectAll('line.spikeline'); - expect(lines.size()).toBe(4); - expect(lines[0][1].getAttribute('stroke')).toBe('green'); - - _hover({xpx: 300, ypx: 350}); - lines = d3SelectAll('line.spikeline'); - expect(lines.size()).toBe(4); - expect(lines[0][1].getAttribute('stroke')).toBe('blue'); - }) - .then(done, done.fail); + .then(function () { + var lines; + + _hover({ xpx: 200, ypx: 200 }); + lines = d3SelectAll('line.spikeline'); + expect(lines.size()).toBe(4); + expect(lines[0][1].getAttribute('stroke')).toBe('green'); + + _hover({ xpx: 200, ypx: 350 }); + lines = d3SelectAll('line.spikeline'); + expect(lines.size()).toBe(4); + expect(lines[0][1].getAttribute('stroke')).toBe('green'); + + _hover({ xpx: 300, ypx: 350 }); + lines = d3SelectAll('line.spikeline'); + expect(lines.size()).toBe(4); + expect(lines[0][1].getAttribute('stroke')).toBe('blue'); + }) + .then(done, done.fail); }); it('correctly responds to setting the spikedistance to 0 by disabling ' + - 'the search for points to draw the spikelines', function(done) { - var _mock = makeMock('toaxis', 'closest'); - - Plotly.newPlot(gd, _mock).then(function() { - _hover({xval: 2, yval: 3}); - _assert( - [[557, 401, 557, 250], [80, 250, 557, 250]], - [[83, 250]] - ); - - _hover({xval: 30, yval: 40}, 'x2y2'); - _assert( - [[820, 220, 820, 167]], - [] - ); - - _setSpikedistance(0); - }) - .then(function() { - _hover({xval: 2, yval: 3}); - _assert( - [], - [] - ); - - _hover({xval: 30, yval: 40}, 'x2y2'); - _assert( - [], - [] - ); - }) - .then(done, done.fail); - }); + 'the search for points to draw the spikelines', function (done) { + var _mock = makeMock('toaxis', 'closest'); + + Plotly.newPlot(gd, _mock).then(function () { + _hover({ xval: 2, yval: 3 }); + _assert( + [[557, 401, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + + _hover({ xval: 30, yval: 40 }, 'x2y2'); + _assert( + [[820, 220, 820, 167]], + [] + ); + + _setSpikedistance(0); + }) + .then(function () { + _hover({ xval: 2, yval: 3 }); + _assert( + [], + [] + ); + + _hover({ xval: 30, yval: 40 }, 'x2y2'); + _assert( + [], + [] + ); + }) + .then(done, done.fail); + }); function spikeLayout() { return { - width: 600, height: 600, margin: {l: 100, r: 100, t: 100, b: 100}, + width: 600, height: 600, margin: { l: 100, r: 100, t: 100, b: 100 }, showlegend: false, spikedistance: 20, - xaxis: {range: [-0.5, 1.5], showspikes: true, spikemode: 'toaxis+marker'}, - yaxis: {range: [-1, 3], showspikes: true, spikemode: 'toaxis+marker'}, + xaxis: { range: [-0.5, 1.5], showspikes: true, spikemode: 'toaxis+marker' }, + yaxis: { range: [-1, 3], showspikes: true, spikemode: 'toaxis+marker' }, hovermode: 'x', boxmode: 'group', barmode: 'group', violinmode: 'group' }; } - it('positions spikes at the data value on grouped bars', function(done) { + it('positions spikes at the data value on grouped bars', function (done) { function _assertBarSpikes() { // regardless of hovermode, you must be actually over the bar to see its spikes - _hover({xval: -0.2, yval: 0.8}); + _hover({ xval: -0.2, yval: 0.8 }); _assert( [[200, 500, 200, 300], [100, 300, 200, 300]], [[200, 500], [100, 300]] ); - _hover({xval: -0.2, yval: 1.2}); + _hover({ xval: -0.2, yval: 1.2 }); _assert([], []); - _hover({xval: 0.2, yval: 1.8}); + _hover({ xval: 0.2, yval: 1.8 }); _assert( [[200, 500, 200, 200], [100, 200, 200, 200]], [[200, 500], [100, 200]] ); - _hover({xval: 0.2, yval: 2.2}); + _hover({ xval: 0.2, yval: 2.2 }); _assert([], []); } @@ -612,150 +612,150 @@ describe('spikeline hover', function() { }, { type: 'bar', y: [2, 1] }], spikeLayout()) - .then(_assertBarSpikes) - .then(function() { _setHovermode('closest'); }) - .then(_assertBarSpikes) - .then(done, done.fail); + .then(_assertBarSpikes) + .then(function () { _setHovermode('closest'); }) + .then(_assertBarSpikes) + .then(done, done.fail); }); - it('positions spikes at the data value on grouped boxes', function(done) { + it('positions spikes at the data value on grouped boxes', function (done) { Plotly.newPlot(gd, [{ type: 'box', x: [0, 0, 0, 0, 1, 1, 1, 1], y: [0, 0, 1, 1, 0, 0, 1, 1], boxpoints: 'all' }, { type: 'box', x: [0, 0, 0, 0, 1, 1, 1, 1], y: [2, 2, 1, 1, 2, 2, 1, 1] }], spikeLayout()) - .then(function() { - // over the box: median line @ (0, 0.5) - _hover({xval: -0.1, yval: 0.1}); - _assert( - [[200, 500, 200, 350], [100, 350, 200, 350]], - [[200, 500], [100, 350]] - ); - - // point hover @ (0, 0) - _hover({xval: -0.4, yval: 0.1}); - _assert( - [[200, 500, 200, 400], [100, 400, 200, 400]], - [[200, 500], [100, 400]] - ); - }) - .then(done, done.fail); + .then(function () { + // over the box: median line @ (0, 0.5) + _hover({ xval: -0.1, yval: 0.1 }); + _assert( + [[200, 500, 200, 350], [100, 350, 200, 350]], + [[200, 500], [100, 350]] + ); + + // point hover @ (0, 0) + _hover({ xval: -0.4, yval: 0.1 }); + _assert( + [[200, 500, 200, 400], [100, 400, 200, 400]], + [[200, 500], [100, 400]] + ); + }) + .then(done, done.fail); }); - it('positions spikes correctly on grouped violins', function(done) { + it('positions spikes correctly on grouped violins', function (done) { Plotly.newPlot(gd, [{ type: 'violin', x: [0, 0, 0, 0, 1, 1, 1, 1], y: [0, 0, 1, 1, 0, 0, 1, 1], side: 'positive', points: 'all' }, { type: 'violin', x: [0, 0, 0, 0, 1, 1, 1, 1], y: [2, 2, 1, 1, 2, 2, 1, 1], side: 'positive' }], spikeLayout()) - .then(function() { - // over the violin: KDE @ (0, 0.2) - _hover({xval: -0.15, yval: 0.2}); - _assert( - [[200, 500, 200, 380], [100, 380, 200, 380]], - [[200, 500], [100, 380]] - ); - - // off the violin, not quite at the points - _hover({xval: -0.2, yval: 0.2}); - _assert([], []); - - // over a point - _hover({xval: -0.4, yval: 0.2}); - _assert( - [[200, 500, 200, 400], [100, 400, 200, 400]], - [[200, 500], [100, 400]] - ); - }) - .then(done, done.fail); + .then(function () { + // over the violin: KDE @ (0, 0.2) + _hover({ xval: -0.15, yval: 0.2 }); + _assert( + [[200, 500, 200, 380], [100, 380, 200, 380]], + [[200, 500], [100, 380]] + ); + + // off the violin, not quite at the points + _hover({ xval: -0.2, yval: 0.2 }); + _assert([], []); + + // over a point + _hover({ xval: -0.4, yval: 0.2 }); + _assert( + [[200, 500, 200, 400], [100, 400, 200, 400]], + [[200, 500], [100, 400]] + ); + }) + .then(done, done.fail); }); - it('positions spikes correctly on heatmaps', function(done) { + it('positions spikes correctly on heatmaps', function (done) { Plotly.newPlot(gd, [{ type: 'heatmap', x: [0, 1], y: [0, 2], z: [[1, 2], [3, 4]] }], spikeLayout()) - .then(function() { - // heatmap bricks go past the x/y bounds - _hover({xval: -0.1, yval: 0.2}); - _assert( - [[200, 500, 200, 400], [100, 400, 200, 400]], - [[200, 500], [100, 400]] - ); - }) - .then(done, done.fail); + .then(function () { + // heatmap bricks go past the x/y bounds + _hover({ xval: -0.1, yval: 0.2 }); + _assert( + [[200, 500, 200, 400], [100, 400, 200, 400]], + [[200, 500], [100, 400]] + ); + }) + .then(done, done.fail); }); - it('positions spikes correctly on contour maps', function(done) { + it('positions spikes correctly on contour maps', function (done) { Plotly.newPlot(gd, [{ type: 'contour', x: [0, 1], y: [0, 2], z: [[1, 2], [3, 4]] }], spikeLayout()) - .then(function() { - // contour doesn't draw past the x/y bounds - _hover({xval: -0.1, yval: 0.2}); - _assert([], []); - - _hover({xval: 0.1, yval: 0.2}); - _assert( - [[200, 500, 200, 400], [100, 400, 200, 400]], - [[200, 500], [100, 400]] - ); - }) - .then(done, done.fail); + .then(function () { + // contour doesn't draw past the x/y bounds + _hover({ xval: -0.1, yval: 0.2 }); + _assert([], []); + + _hover({ xval: 0.1, yval: 0.2 }); + _assert( + [[200, 500, 200, 400], [100, 400, 200, 400]], + [[200, 500], [100, 400]] + ); + }) + .then(done, done.fail); }); - it('does not show spikes on scatter fills', function(done) { + it('does not show spikes on scatter fills', function (done) { Plotly.newPlot(gd, [{ x: [0, 0, 1, 1, 0], y: [0, 2, 2, 0, 0], fill: 'toself' - }], Lib.extendFlat({}, spikeLayout(), {hovermode: 'closest'})) - .then(function() { - // center of the fill: no spikes - _hover({xval: 0.5, yval: 1}); - _assert([], []); - - // sanity check: points still generate spikes - _hover({xval: 0, yval: 0}); - _assert( - [[200, 500, 200, 400], [100, 400, 200, 400]], - [[200, 500], [100, 400]] - ); - }) - .then(done, done.fail); + }], Lib.extendFlat({}, spikeLayout(), { hovermode: 'closest' })) + .then(function () { + // center of the fill: no spikes + _hover({ xval: 0.5, yval: 1 }); + _assert([], []); + + // sanity check: points still generate spikes + _hover({ xval: 0, yval: 0 }); + _assert( + [[200, 500, 200, 400], [100, 400, 200, 400]], + [[200, 500], [100, 400]] + ); + }) + .then(done, done.fail); }); - it('correctly draws lines up to the winning point', function(done) { + it('correctly draws lines up to the winning point', function (done) { Plotly.newPlot(gd, [ - {type: 'bar', y: [5, 7, 9, 6, 4, 3]}, - {y: [5, 7, 9, 6, 4, 3], marker: {color: 'green'}}, - {y: [5, 7, 9, 6, 4, 3], marker: {color: 'red'}} + { type: 'bar', y: [5, 7, 9, 6, 4, 3] }, + { y: [5, 7, 9, 6, 4, 3], marker: { color: 'green' } }, + { y: [5, 7, 9, 6, 4, 3], marker: { color: 'red' } } ], { hovermode: 'x', - xaxis: {showspikes: true}, - yaxis: {showspikes: true}, + xaxis: { showspikes: true }, + yaxis: { showspikes: true }, spikedistance: -1, width: 400, height: 400, showlegend: false }) - .then(function() { - _hover({xpx: 150, ypx: 250}); + .then(function () { + _hover({ xpx: 150, ypx: 250 }); - var lines = d3SelectAll('line.spikeline'); - expect(lines.size()).toBe(4); - expect(lines[0][1].getAttribute('stroke')).toBe('green'); - expect(lines[0][3].getAttribute('stroke')).toBe('green'); - }) - .then(done, done.fail); + var lines = d3SelectAll('line.spikeline'); + expect(lines.size()).toBe(4); + expect(lines[0][1].getAttribute('stroke')).toBe('green'); + expect(lines[0][3].getAttribute('stroke')).toBe('green'); + }) + .then(done, done.fail); }); - describe('works across all cartesian traces', function() { + describe('works across all cartesian traces', function () { var schema = Plotly.PlotSchema.get(); var traces = Object.keys(schema.traces); var tracesSchema = []; var i, j, k; - for(i = 0; i < traces.length; i++) { + for (i = 0; i < traces.length; i++) { tracesSchema.push(schema.traces[traces[i]]); } - var excludedTraces = [ 'image' ]; - var cartesianTraces = tracesSchema.filter(function(t) { + var excludedTraces = ['image']; + var cartesianTraces = tracesSchema.filter(function (t) { return t.categories.length && t.categories.indexOf('cartesian') !== -1 && t.categories.indexOf('noHover') === -1 && @@ -768,23 +768,23 @@ describe('spikeline hover', function() { var data = input[axName === 'yaxis' ? 0 : 1]; var measure = []; - for(j = 0; j < data.length; j++) { + for (j = 0; j < data.length; j++) { measure.push('absolute'); } var z = Lib.init2dArray(cat.length, data.length); - for(j = 0; j < z.length; j++) { - for(k = 0; k < z[j].length; k++) { + for (j = 0; j < z.length; j++) { + for (k = 0; k < z[j].length; k++) { z[j][k] = 0; } } - if(axName === 'xaxis') { - for(j = 0; j < b.length; j++) { + if (axName === 'xaxis') { + for (j = 0; j < b.length; j++) { z[0][j] = b[j]; } } - if(axName === 'yaxis') { - for(j = 0; j < b.length; j++) { + if (axName === 'yaxis') { + for (j = 0; j < b.length; j++) { z[j][0] = b[j]; } } @@ -826,8 +826,8 @@ describe('spikeline hover', function() { }); } - cartesianTraces.forEach(function(trace) { - it('correctly responds to setting the spikedistance to -1 for ' + trace.type, function(done) { + cartesianTraces.forEach(function (trace) { + it('correctly responds to setting the spikedistance to -1 for ' + trace.type, function (done) { var type = trace.type; var x = [4, 5, 6]; var data = [7, 2, 3]; @@ -836,17 +836,17 @@ describe('spikeline hover', function() { data: [makeData(type, 'xaxis', x, data)], layout: { spikedistance: -1, - xaxis: {showspikes: true, spikesnap: 'data'}, - yaxis: {showspikes: true, spikesnap: 'data'}, - zaxis: {showspikes: true, spikesnap: 'data'}, - title: {text: trace.type}, + xaxis: { showspikes: true, spikesnap: 'data' }, + yaxis: { showspikes: true, spikesnap: 'data' }, + zaxis: { showspikes: true, spikesnap: 'data' }, + title: { text: trace.type }, width: 400, height: 400 } }; Plotly.newPlot(gd, mock) - .then(function() { - _hover({xpx: 200, ypx: 100}); + .then(function () { + _hover({ xpx: 200, ypx: 100 }); var lines = d3SelectAll('line.spikeline'); expect(lines.size()).toBe(4); @@ -856,3 +856,81 @@ describe('spikeline hover', function() { }); }); }); + +describe('spikeline search', function () { + 'use strict'; + + // The search for a point to spike to is expensive when spikedistance is -1, + // because every point in the graph must be checked (with the current implementation). + // These tests check that the search is only done when necessary. + // We check the number of calls to hoverPoints to determine whether the search was done, + // because the actual search is done inside hoverPoints. + // One call to hoverPoints is expected no matter what; an additional call should be made + // only if an axis is set to show spikes and snap to data or cursor. + // Note: If the implementation changes in the future to avoid calling hoverPoints, + // these tests will need to be updated accordingly. + + var gd; + + beforeEach(function () { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function countHoverPointsCalls(layout) { + return Plotly.newPlot(gd, [{ + x: [1, 2, 3], + y: [1, 2, 3], + mode: 'markers' + }], Lib.extendFlat({ width: 400, height: 400 }, layout)) + .then(function () { + var spy = spyOn(gd.calcdata[0][0].trace._module, 'hoverPoints').and.callThrough(); + + Lib.clearThrottle(); + // hover on empty space, so there is no hoverData + Fx.hover(gd, { xpx: 40, ypx: 40 }, 'xy'); + + return spy.calls.count(); + }); + } + + it('does not look for spike points when no axis shows spikes', function (done) { + countHoverPointsCalls({}) + .then(function (count) { + expect(count).toBe(1); + }) + .then(done, done.fail); + }); + + it('does not look for spike points when spikesnap is "hovered data"', function (done) { + countHoverPointsCalls({ + xaxis: { showspikes: true, spikesnap: 'hovered data' }, + yaxis: { showspikes: true, spikesnap: 'hovered data' } + }) + .then(function (count) { + expect(count).toBe(1); + }) + .then(done, done.fail); + }); + + it('looks for spike points when an axis snaps spikes to data', function (done) { + countHoverPointsCalls({ + xaxis: { showspikes: true, spikesnap: 'data' } + }) + .then(function (count) { + expect(count).toBe(2); + }) + .then(done, done.fail); + }); + + it('looks for spike points when an axis snaps spikes to the cursor', function (done) { + countHoverPointsCalls({ + yaxis: { showspikes: true, spikesnap: 'cursor' } + }) + .then(function (count) { + expect(count).toBe(2); + }) + .then(done, done.fail); + }); +}); From 6285b441f77a59195487bd0d2b781575398e6fbf Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:07:02 -0400 Subject: [PATCH 3/3] add draftlog --- draftlogs/8052_fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/8052_fix.md diff --git a/draftlogs/8052_fix.md b/draftlogs/8052_fix.md new file mode 100644 index 00000000000..613f2ac1493 --- /dev/null +++ b/draftlogs/8052_fix.md @@ -0,0 +1 @@ +- Fix issue causing `scattergl` plot to freeze up when hovering over points [[#8052](https://github.com/plotly/plotly.js/pull/8052)]