Skip to content

Reset forced minimum tick spacing on every calc pass - #7950

Open
Jaybhade wants to merge 2 commits into
plotly:masterfrom
Jaybhade:fix-stale-forced-min-tick-spacing
Open

Reset forced minimum tick spacing on every calc pass#7950
Jaybhade wants to merge 2 commits into
plotly:masterfrom
Jaybhade:fix-stale-forced-min-tick-spacing

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 9, 2026

Copy link
Copy Markdown

The bug

Plotly.react can draw an axis with different ticks than Plotly.newPlot of the exact same figure. Same for restyle, addTraces and friends — anything that updates a graph div in place.

var boxFig = {
    data: [{type: 'box', x: [1, 1, 2, 2, 3, 3], y: [1, 2, 3, 4, 5, 6]}],
    layout: {width: 700, height: 400}
};
var scatterFig = {data: [{y: [1, 2, 3]}], layout: {width: 700, height: 400}};

// x ticks: 1, 2, 3
Plotly.newPlot(gd, boxFig);

// x ticks: 0.5, 1, 1.5, 2, 2.5, 3, 3.5
Plotly.newPlot(gd, scatterFig).then(function() { return Plotly.react(gd, boxFig); });

Box, violin, candlestick and ohlc traces ask the position axis for a minimum tick spacing, so that each box gets a tick of its own instead of ticks at meaningless in-between positions. In the second case that forcing is silently lost.

It is most obvious on a date axis. Five daily candles at width: 1000, reached by reacting from a line chart over the same dates:

newPlot:  Jan 1 | Jan 2 | Jan 3 | Jan 4 | Jan 5
react:    12:00 Dec 31, 2023 | 00:00 Jan 1, 2024 | 12:00 | 00:00 Jan 2, 2024 | 12:00 | ...

Every second tick falls in the gap between two candles, and the axis now starts on the day before the data.

Cause

Axes.minDtick keeps its state in ax._minDtick / ax._forceTick0 and distinguishes three cases: undefined (nothing forced yet — adopt this trace's spacing), a positive number (a forcing is in effect), and 0 (forcing cancelled, e.g. by non-grouped bars or by scatter/heatmap, and sticky so a later trace can't reinstate it).

The reset back to undefined between passes was at the bottom of setConvert, but it has never had any effect on a real axis: setConvert runs while supplyDefaults builds the new _fullLayout, where those keys don't exist yet, and relinkPrivateKeys then copies the old values back onto it. So whichever figure the graph div held first decides the forcing forever after. Plotly.newPlot isn't affected because it starts from an empty _fullLayout.

doCalcdata already handles the identical relink staleness for shared color axes ("clear relinked cmin/cmax values in shared axes to start aggregation from scratch"). This moves the reset into ax.clearCalc() — the axis' own per-calc-pass reset, which doCalcdata runs for every axis, and always before any crossTraceCalc.

One existing expectation changed

plot_api_test.js"updates box position and axis type when it falls back to name" asserted that a single box restyled to x0: 12.3 ends up with ticks ['12', '12.5']. That was the stale value. Plotly.newPlot of that same figure produces a single tick at 12.3, on master as well as here, so the change makes restyle agree with newPlot:

master this PR
newPlot box x0: 12.3 12.3 12.3
restyle to x0: 12.3 12, 12.5 12.3
react to x0: 12.3 12, 12.5 12.3

Testing

  • New regression test in axes_test.js"should not carry over the forced minimum tick spacing of the previous figure". It fails on master (Expected 0 to be 1) and passes here.
  • npm run test-jasmine -- --nowatch: 7000 specs, and the failure set is identical to master's on this machine (a batch of font-metric, WebGL, drag/touch and @flaky tests fail either way; the two extra failures in my run pass when their suites are run alone).
  • npm run lint and npm run test-syntax are clean.
  • I don't have the docker setup for test-image / test-export. Those baselines shouldn't be able to move: they're all single newPlot calls, which never have a previous _fullLayout to relink from.

setConvert cleared ax._minDtick / ax._forceTick0 so each calc pass would
start over, but the cleanup never had any effect: setConvert runs while
supplyDefaults builds the new _fullLayout, where the keys do not exist
yet, and relinkPrivateKeys then copies the old values back onto it.

Axes.minDtick treats 0 as "forcing cancelled", so the 0 written by
whichever figure was drawn first survived every later update and vetoed
the forcing for every figure after it. Reacting from a scatter to a box
plot lost the one-tick-per-box spacing, while newPlot of the same figure
kept it.

Move the reset into ax.clearCalc, the axis' own per-calc-pass reset,
which doCalcdata runs for every axis before any cross-trace calc.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants