From 3f70312513ec9eedad8ea382f23e77218d3e4406 Mon Sep 17 00:00:00 2001 From: Ariel Eli Date: Wed, 27 May 2026 12:45:06 +0300 Subject: [PATCH] fix: snap tick values to grid before applying custom tickformat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When floating-point arithmetic produces a tick value that is slightly off its true position (e.g. -8.88e-16 instead of 0), the default formatter is immune because it adds an internal rounding epsilon before rendering. Custom d3 formats specified via tickformat (such as '~r') don't go through that path and expose the raw number, which can produce labels like '−0.0000000000000000888178' for a tick that should read '0'. Fix by snapping v to the nearest ideal tick position (tick0 + n*dtick) before passing it to the user's formatter, using a relative threshold of 1e-9 of dtick so only genuine floating-point noise is removed. Fixes #7765 --- src/plots/cartesian/axes.js | 13 ++++++++++++- test/jasmine/tests/axes_test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 08bfda6f367..4f9f8c9675b 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -2189,7 +2189,18 @@ function numFormat(v, ax, fmtoverride, hover) { if(ax.hoverformat) tickformat = ax.hoverformat; } - if(tickformat) return ax._numFormat(tickformat)(v).replace(/-/g, MINUS_SIGN); + if(tickformat) { + // Snap v to the nearest ideal tick position before applying a custom tickformat. + // Floating-point arithmetic can leave tick values slightly off their true position + // (e.g. -8.88e-16 instead of 0). The default formatter is immune because it adds + // a rounding epsilon, but custom d3 formats like '~r' expose the raw number. + // Only snap for linear-style numeric ticks (dtick and tick0 are both numbers). + if(!hover && isNumeric(ax.dtick) && isNumeric(ax.tick0) && ax.dtick) { + var ideal = ax.tick0 + Math.round((v - ax.tick0) / ax.dtick) * ax.dtick; + if(Math.abs(v - ideal) < Math.abs(ax.dtick) * 1e-9) v = ideal; + } + return ax._numFormat(tickformat)(v).replace(/-/g, MINUS_SIGN); + } // 'epsilon' - rounding increment var e = Math.pow(10, -tickRound) / 2; diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 3d1e962312f..8b76c82fdb1 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -18,6 +18,7 @@ var numerical = require('../../../src/constants/numerical'); var BADNUM = numerical.BADNUM; var ONEDAY = numerical.ONEDAY; var ONEWEEK = numerical.ONEWEEK; +var MINUS_SIGN = numerical.MINUS_SIGN; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -3888,6 +3889,31 @@ describe('Test axes', function() { }); }); + it('should not show floating-point artefacts with custom tickformat', function() { + // Floating-point arithmetic can leave tick values slightly off their true + // position (e.g. -8.88e-16 instead of 0). The default formatter is immune + // because it adds an internal rounding epsilon, but custom d3 formats like + // '~r' expose the raw number. Ticks should be snapped to their ideal + // position (tick0 + n*dtick) before formatting. See gh#7765. + var ax = { + type: 'linear', + tickmode: 'linear', + tick0: 0, + dtick: 0.5, + tickformat: '~r', + range: [-0.75, 0.75] + }; + var textOut = mockCalc(ax); + // Without the fix the middle tick renders as '−0.0000000000000000888178' + expect(textOut).toEqual([MINUS_SIGN + '0.5', '0', '0.5']); + + // Also check with a dtick that itself introduces floating-point error. + ax.dtick = 0.1; + ax.range = [-0.25, 0.25]; + textOut = mockCalc(ax); + expect(textOut).toEqual([MINUS_SIGN + '0.2', MINUS_SIGN + '0.1', '0', '0.1', '0.2']); + }); + it('should always start at year for date axis hover', function() { var ax = { type: 'date',