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! diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 08bfda6f367..1929750d69e 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -90,6 +90,28 @@ function expandRange(range) { ]; } +/** + * 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; +} + /* * find the list of possible axes to reference with an xref or yref attribute * and coerce it to that list @@ -1067,6 +1089,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 +1133,7 @@ axes.calcTicks = function calcTicks(ax, opts) { if(tickVals.length > maxTicks || x === prevX) break; prevX = x; - var obj = { value: x }; + var obj = { value: snapToGrid(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..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,6 +3499,50 @@ describe('Test axes', function() { ]); }); + it('snaps a near-zero tick to exactly tick0', () => { + const spec = { + type: 'linear', + tickmode: 'linear', + tick0: 0, + dtick: 0.2, + range: [-0.65, 0.65] + }; + + 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(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',