From 0153c9ebe0048b2336d5a58bd3a429ae51542d8a Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Sun, 12 Jul 2026 16:37:05 -0700 Subject: [PATCH 1/4] Snap near-zero tick values to tick0 to fix float artefacts in labels When ticks are built by repeated increments, a tick that should sit exactly on tick0 can come out as a tiny nonzero value like -8.9e-17. The default numeric format rounds that away, but d3-format specs such as `~r` render it literally, so a tick at zero showed as -0.0000000000000000888178. When a computed tick value is far closer to tick0 than one dtick, snap it to exactly tick0. Fixes #7765 --- src/plots/cartesian/axes.js | 17 ++++++++++++++++- test/jasmine/tests/axes_test.js | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 08bfda6f367..204a96affde 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -90,6 +90,20 @@ function expandRange(range) { ]; } +// Correct a floating-point roundoff artefact. After repeated increments a tick +// that should land exactly on tick0 can come out as e.g. -8.9e-17. The default +// numeric tickformat rounds that away, but d3-format specs like `~r` render the +// tiny value literally, so a tick at zero shows up as -0.0000000000000000888. +// See https://github.com/plotly/plotly.js/issues/7765 +// When a value is far closer to tick0 than one dtick, snap it to exactly tick0. +function snapToTick0(l, tick0l, dtick) { + if(isNumeric(dtick) && isNumeric(tick0l) && isNumeric(l) && + l !== tick0l && Math.abs(l - tick0l) < Math.abs(dtick) * 1e-6) { + return tick0l; + } + return l; +} + /* * find the list of possible axes to reference with an xref or yref attribute * and coerce it to that list @@ -1067,6 +1081,7 @@ axes.calcTicks = function calcTicks(ax, opts) { } var dtick = mockAx.dtick; + var tick0l = (type === 'linear') ? mockAx.r2l(mockAx.tick0) : undefined; if(mockAx.rangebreaks && mockAx._tick0Init !== mockAx.tick0) { // adjust tick0 @@ -1110,7 +1125,7 @@ axes.calcTicks = function calcTicks(ax, opts) { if(tickVals.length > maxTicks || x === prevX) break; prevX = x; - var obj = { value: x }; + var obj = { value: snapToTick0(x, tick0l, dtick) }; if(major) { if(isDLog && (x !== (x | 0))) { diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 3d1e962312f..031fc5cb49c 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -3502,6 +3502,29 @@ describe('Test axes', function() { ]); }); + it('snaps a near-zero tick to exactly tick0 so d3-format specs do not show float artefacts (issue 7765)', function() { + // With this range the increments accumulate a roundoff error and the + // tick that should be exactly 0 comes out as ~-8.9e-17. The default + // format rounds that away, but `~r` would render it literally as + // -0.0000000000000000888178 without the snap-to-tick0 correction. + var spec = { + type: 'linear', + tickmode: 'linear', + tick0: 0, + dtick: 0.2, + range: [-0.65, 0.65] + }; + + expect(mockCalc(Lib.extendDeep({}, spec, {tickformat: '~r'}))).toEqual([ + '−0.6', '−0.4', '−0.2', '0', '0.2', '0.4', '0.6' + ]); + + // the default numeric format was already fine; make sure it stays fine + expect(mockCalc(Lib.extendDeep({}, spec))).toEqual([ + '−0.6', '−0.4', '−0.2', '0', '0.2', '0.4', '0.6' + ]); + }); + it('reverts to "power" for SI/B exponentformat beyond the prefix range (log case)', function() { var textOut = mockCalc({ type: 'log', From 7d556e2572ed3f250b85bf9516566d8791299b0b Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 13 Jul 2026 14:35:20 -0600 Subject: [PATCH 2/4] Refactor helper function to generalize snapping --- src/plots/cartesian/axes.js | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 204a96affde..1929750d69e 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -90,18 +90,26 @@ function expandRange(range) { ]; } -// Correct a floating-point roundoff artefact. After repeated increments a tick -// that should land exactly on tick0 can come out as e.g. -8.9e-17. The default -// numeric tickformat rounds that away, but d3-format specs like `~r` render the -// tiny value literally, so a tick at zero shows up as -0.0000000000000000888. -// See https://github.com/plotly/plotly.js/issues/7765 -// When a value is far closer to tick0 than one dtick, snap it to exactly tick0. -function snapToTick0(l, tick0l, dtick) { - if(isNumeric(dtick) && isNumeric(tick0l) && isNumeric(l) && - l !== tick0l && Math.abs(l - tick0l) < Math.abs(dtick) * 1e-6) { - return tick0l; - } - return l; +/** + * Correct a floating-point roundoff artifact in a linearized tick value. + * When `l` is much closer to its nearest ideal grid position than one `dtick` + * (within `dtick * snapThreshold`), snap it to that ideal. Otherwise return + * `l` unchanged. See https://github.com/plotly/plotly.js/issues/7765. + * + * @param l - linearized tick value from the accumulator in calcTicks + * @param tick0l - the axis' `tick0` in linearized form + * @param dtick - the axis' tick step; must be numeric and nonzero to snap + * @returns the snapped value, or `l` unchanged if no snap applies + */ +function snapToGrid(l, tick0l, dtick) { + if (![dtick, l, tick0l].every(isNumeric) || dtick === 0) return l; + + const nTicks = Math.round((l - tick0l) / dtick); + const idealTick = tick0l + nTicks * dtick; + const snapThreshold = 1e-6; + const shouldSnap = l !== idealTick && Math.abs(l - idealTick) < Math.abs(dtick) * snapThreshold; + + return shouldSnap ? idealTick : l; } /* @@ -1125,7 +1133,7 @@ axes.calcTicks = function calcTicks(ax, opts) { if(tickVals.length > maxTicks || x === prevX) break; prevX = x; - var obj = { value: snapToTick0(x, tick0l, dtick) }; + var obj = { value: snapToGrid(x, tick0l, dtick) }; if(major) { if(isDLog && (x !== (x | 0))) { From 0595efda00150f70501a41f36b9724c1e5b2f35f Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 13 Jul 2026 14:42:56 -0600 Subject: [PATCH 3/4] Add additional test and refactor a bit --- test/jasmine/tests/axes_test.js | 46 +++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 031fc5cb49c..f1dac23af92 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -14,10 +14,7 @@ var Cartesian = require('../../../src/plots/cartesian'); var Axes = require('../../../src/plots/cartesian/axes'); var Fx = require('../../../src/components/fx'); var supplyLayoutDefaults = require('../../../src/plots/cartesian/layout_defaults'); -var numerical = require('../../../src/constants/numerical'); -var BADNUM = numerical.BADNUM; -var ONEDAY = numerical.ONEDAY; -var ONEWEEK = numerical.ONEWEEK; +const {BADNUM, MINUS_SIGN, ONEDAY, ONEWEEK} = require('../../../src/constants/numerical'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -3502,12 +3499,8 @@ describe('Test axes', function() { ]); }); - it('snaps a near-zero tick to exactly tick0 so d3-format specs do not show float artefacts (issue 7765)', function() { - // With this range the increments accumulate a roundoff error and the - // tick that should be exactly 0 comes out as ~-8.9e-17. The default - // format rounds that away, but `~r` would render it literally as - // -0.0000000000000000888178 without the snap-to-tick0 correction. - var spec = { + it('snaps a near-zero tick to exactly tick0', () => { + const spec = { type: 'linear', tickmode: 'linear', tick0: 0, @@ -3515,16 +3508,41 @@ describe('Test axes', function() { range: [-0.65, 0.65] }; - expect(mockCalc(Lib.extendDeep({}, spec, {tickformat: '~r'}))).toEqual([ - '−0.6', '−0.4', '−0.2', '0', '0.2', '0.4', '0.6' + expect(mockCalc({ ...spec, tickformat: '~r' })).toEqual([ + MINUS_SIGN + '0.6', + MINUS_SIGN + '0.4', + MINUS_SIGN + '0.2', + '0', + '0.2', + '0.4', + '0.6' ]); // the default numeric format was already fine; make sure it stays fine - expect(mockCalc(Lib.extendDeep({}, spec))).toEqual([ - '−0.6', '−0.4', '−0.2', '0', '0.2', '0.4', '0.6' + expect(mockCalc(spec)).toEqual([ + MINUS_SIGN + '0.6', + MINUS_SIGN + '0.4', + MINUS_SIGN + '0.2', + '0', + '0.2', + '0.4', + '0.6' ]); }); + it('snaps ticks to non-tick0 grid positions', () => { + const textOut = mockCalc({ + type: 'linear', + tickmode: 'linear', + tick0: 1, + dtick: 0.1, + range: [-0.05, 0.05], + tickformat: '~r' + }); + + expect(textOut).toEqual(['0']); + }); + it('reverts to "power" for SI/B exponentformat beyond the prefix range (log case)', function() { var textOut = mockCalc({ type: 'log', From f846c5dabc52a4858220a32dda02e0fa23b123cd Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 13 Jul 2026 14:51:23 -0600 Subject: [PATCH 4/4] Add draftlog --- draftlogs/7901_fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7901_fix.md diff --git a/draftlogs/7901_fix.md b/draftlogs/7901_fix.md new file mode 100644 index 00000000000..bf8a1a098bd --- /dev/null +++ b/draftlogs/7901_fix.md @@ -0,0 +1 @@ +- Snap cartesian axis tick values to multiple of delta so custom `tickformat` (e.g. `"~r"`) no longer shows floating-point artifacts [[#7901](https://github.com/plotly/plotly.js/pull/7901)], with thanks to @arieleli01212 and @kirthi-b for the contribution!