Skip to content

Commit a677f28

Browse files
authored
Merge pull request #7901 from kirthi-b/fix/tick-label-float-artefacts
Fix floating-point artefacts in tick labels (snap near-zero ticks to tick0)
2 parents 55dfea3 + f846c5d commit a677f28

3 files changed

Lines changed: 70 additions & 5 deletions

File tree

draftlogs/7901_fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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!

src/plots/cartesian/axes.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,28 @@ function expandRange(range) {
9090
];
9191
}
9292

93+
/**
94+
* Correct a floating-point roundoff artifact in a linearized tick value.
95+
* When `l` is much closer to its nearest ideal grid position than one `dtick`
96+
* (within `dtick * snapThreshold`), snap it to that ideal. Otherwise return
97+
* `l` unchanged. See https://github.com/plotly/plotly.js/issues/7765.
98+
*
99+
* @param l - linearized tick value from the accumulator in calcTicks
100+
* @param tick0l - the axis' `tick0` in linearized form
101+
* @param dtick - the axis' tick step; must be numeric and nonzero to snap
102+
* @returns the snapped value, or `l` unchanged if no snap applies
103+
*/
104+
function snapToGrid(l, tick0l, dtick) {
105+
if (![dtick, l, tick0l].every(isNumeric) || dtick === 0) return l;
106+
107+
const nTicks = Math.round((l - tick0l) / dtick);
108+
const idealTick = tick0l + nTicks * dtick;
109+
const snapThreshold = 1e-6;
110+
const shouldSnap = l !== idealTick && Math.abs(l - idealTick) < Math.abs(dtick) * snapThreshold;
111+
112+
return shouldSnap ? idealTick : l;
113+
}
114+
93115
/*
94116
* find the list of possible axes to reference with an xref or yref attribute
95117
* and coerce it to that list
@@ -1067,6 +1089,7 @@ axes.calcTicks = function calcTicks(ax, opts) {
10671089
}
10681090

10691091
var dtick = mockAx.dtick;
1092+
var tick0l = (type === 'linear') ? mockAx.r2l(mockAx.tick0) : undefined;
10701093

10711094
if(mockAx.rangebreaks && mockAx._tick0Init !== mockAx.tick0) {
10721095
// adjust tick0
@@ -1110,7 +1133,7 @@ axes.calcTicks = function calcTicks(ax, opts) {
11101133
if(tickVals.length > maxTicks || x === prevX) break;
11111134
prevX = x;
11121135

1113-
var obj = { value: x };
1136+
var obj = { value: snapToGrid(x, tick0l, dtick) };
11141137

11151138
if(major) {
11161139
if(isDLog && (x !== (x | 0))) {

test/jasmine/tests/axes_test.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ var Cartesian = require('../../../src/plots/cartesian');
1414
var Axes = require('../../../src/plots/cartesian/axes');
1515
var Fx = require('../../../src/components/fx');
1616
var supplyLayoutDefaults = require('../../../src/plots/cartesian/layout_defaults');
17-
var numerical = require('../../../src/constants/numerical');
18-
var BADNUM = numerical.BADNUM;
19-
var ONEDAY = numerical.ONEDAY;
20-
var ONEWEEK = numerical.ONEWEEK;
17+
const {BADNUM, MINUS_SIGN, ONEDAY, ONEWEEK} = require('../../../src/constants/numerical');
2118

2219
var createGraphDiv = require('../assets/create_graph_div');
2320
var destroyGraphDiv = require('../assets/destroy_graph_div');
@@ -3502,6 +3499,50 @@ describe('Test axes', function() {
35023499
]);
35033500
});
35043501

3502+
it('snaps a near-zero tick to exactly tick0', () => {
3503+
const spec = {
3504+
type: 'linear',
3505+
tickmode: 'linear',
3506+
tick0: 0,
3507+
dtick: 0.2,
3508+
range: [-0.65, 0.65]
3509+
};
3510+
3511+
expect(mockCalc({ ...spec, tickformat: '~r' })).toEqual([
3512+
MINUS_SIGN + '0.6',
3513+
MINUS_SIGN + '0.4',
3514+
MINUS_SIGN + '0.2',
3515+
'0',
3516+
'0.2',
3517+
'0.4',
3518+
'0.6'
3519+
]);
3520+
3521+
// the default numeric format was already fine; make sure it stays fine
3522+
expect(mockCalc(spec)).toEqual([
3523+
MINUS_SIGN + '0.6',
3524+
MINUS_SIGN + '0.4',
3525+
MINUS_SIGN + '0.2',
3526+
'0',
3527+
'0.2',
3528+
'0.4',
3529+
'0.6'
3530+
]);
3531+
});
3532+
3533+
it('snaps ticks to non-tick0 grid positions', () => {
3534+
const textOut = mockCalc({
3535+
type: 'linear',
3536+
tickmode: 'linear',
3537+
tick0: 1,
3538+
dtick: 0.1,
3539+
range: [-0.05, 0.05],
3540+
tickformat: '~r'
3541+
});
3542+
3543+
expect(textOut).toEqual(['0']);
3544+
});
3545+
35053546
it('reverts to "power" for SI/B exponentformat beyond the prefix range (log case)', function() {
35063547
var textOut = mockCalc({
35073548
type: 'log',

0 commit comments

Comments
 (0)