Ticks may carry floating point drift
- Dominant language
- JavaScript
- Stars
- 67.7k
- Forks
- 11.9k
- Avg merge
- 7h 39m
- Merged PRs (30d)
- 5
Description
### Expected behavior
The values passed to `ticks.callback` should be free of floating point drift: the tick that sits on 50 should be exactly `50`, not `50.00000000000001`.
`generateTicks` already rounds every generated tick for this purpose (`const tickValue = Math.round((niceMin + j * spacing) * factor) / factor`), so tick values reaching user code are expected to be clean.
### Current behavior
When a linear scale has explicit non round `min`/`max` bounds, the generated ticks carry floating point drift, and the rounding safeguard above silently does nothing.
With `min: 49.894` and `max: 51.5264`, the callback receives:
49.894
50.00000000000001 <-- should be 50
50.2
50.400000000000006
50.6
50.800000000000004
51.00000000000001 <-- should be 51
51.2
51.400000000000006
51.5264
The built-in formatter hides this, since `Intl.NumberFormat` rounds `50.00000000000001` down to "50.0". But any custom `ticks.callback` that tests a tick against an integer, which is our way to reduce label clutter, breaks:
`callback: value => (Number.isInteger(value) ? value : '')`
Not a single tick passes the test, so the axis ends up with no label at all.
### Reproducible sample
https://codepen.io/lamasse/pen/MYJzVpz
### Optional extra steps/info to reproduce
_No response_
### Possible solution
I am not familiar enough with the internals of the tick generation to propose a proper fix, so please take the following as a lead rather than a solution.
My understanding of what happens, from reading `generateTicks` in `src/scales/scale.linearbase.js`:
The precision used to clean the generated ticks is computed from `niceMin`:
```
const decimalPlaces = Math.max(
_decimalPlaces(spacing),
_decimalPlaces(niceMin)
);
factor = Math.pow(10, isNullOrUndef(precision) ? decimalPlaces : precision);
```
but `niceMin` was itself produced by a floating point operation at L62:
```
niceMin = Math.floor(rmin / spacing) * spacing;
```
which returns 49.800000000000004 rather than 49.8 in the sample above. `_decimalPlaces` counts how many decimal digits are needed to write a value back exactly, so on that value it reports 16 instead of 1, and `factor` becomes 1e16.
The rounding at L126 is then asked to round to the 16th decimal, so the drift passes through. Had `niceMin` been exactly 49.8, `_decimalPlaces` would have reported 1, and rounding to the first decimal would have cleaned every tick.
Assuming so, one thing that might be worth exploring is normalizing `niceMin` and `niceMax` right after the alignment, since in the `bounds === 'ticks'` branch they are multiples of `spacing` by construction:
```
if (bounds === 'ticks') {
niceMin = Math.floor(rmin / spacing) * spacing;
niceMax = Math.ceil(rmax / spacing) * spacing;
const spacingFactor = Math.pow(10, _decimalPlaces(spacing));
niceMin = Math.round(niceMin * spacingFactor) / spacingFactor;
niceMax = Math.round(niceMax * spacingFactor) / spacingFactor;
}
```
I applied that change to the 4.5.1 dist build and re-ran the sample above: the ticks come out as 49.8, 50, 50.2, 50.4, 50.6, 50.8, 51, 51.2, 51.4 and the two expected labels show up. That is the only thing I checked though. I did not run the test suite, and I have no idea whether it holds for the `step` / `count` / `bounds: 'data'` paths, for negative or very large ranges, or for the scales that build on this one, so it may well be the wrong place to act.
### Context
We display KPI charts where the y-axis is scaled to the data range, so the bounds are not round numbers. The tick callback labels integer ticks only and returns an empty string otherwise, to keep the axis readable on small widgets.
On those charts the y-axis silently lost every label. Nothing in the chart config hints at the cause, we simply noticed that the number expected to be integers weren't in the label callback. We now round the value inside our callback before testing it, but that is a workaround for something the scale is already trying to guarantee.
### chart.js version
4.5.1
### Browser name and version
Chrome latest
### Link to your project
Company-owned unfortunately
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.