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
Expected behavior
The values passed to
ticks.callbackshould be free of floating point drift: the tick that sits on 50 should be exactly50, not50.00000000000001.generateTicksalready 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/maxbounds, the generated ticks carry floating point drift, and the rounding safeguard above silently does nothing.With
min: 49.894andmax: 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.NumberFormatrounds50.00000000000001down to "50.0". But any customticks.callbackthat 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
generateTicksinsrc/scales/scale.linearbase.js:The precision used to clean the generated ticks is computed from
niceMin:but
niceMinwas itself produced by a floating point operation at L62:which returns 49.800000000000004 rather than 49.8 in the sample above.
_decimalPlacescounts how many decimal digits are needed to write a value back exactly, so on that value it reports 16 instead of 1, andfactorbecomes 1e16.The rounding at L126 is then asked to round to the 16th decimal, so the drift passes through. Had
niceMinbeen exactly 49.8,_decimalPlaceswould 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
niceMinandniceMaxright after the alignment, since in thebounds === 'ticks'branch they are multiples ofspacingby construction: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