Skip to content

Ticks may carry floating point drift #12281

Description

@charlesmass2

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions