fix(pyplot): stop minorticks_on() pinning automatic major ticks

MO
4d ago
+48-1

Closes #493.

The problem

minorticks_on() freezes the major tick positions. Asking for minor ticks turns an automatic axis into a fixed one, and because the renderer filters authored positions to the visible window, the tick labels disappear once you zoom past them. Without minorticks_on() the labels rescale normally.

Visible in the compiled payload, no browser needed:

import xy.pyplot as plt

def x_axis(minor):
    fig, ax = plt.subplots(figsize=(6, 4))
    ax.plot([0, 10], [0, 10])
    if minor:
        ax.minorticks_on()
    spec, _blob = ax._build_chart(600, 400).figure().build_payload()
    return spec["x_axis"]

for key in ("tick_values", "tick_count"):
    print(key, x_axis(False).get(key), "->", x_axis(True).get(key))

Before this change:

tick_values None -> [0.0, 2.0, 4.0, 6.0, 8.0, 10.0]
tick_count  9    -> 6

The cause

_apply_tickers returns early when nothing is registered:

if locator is None and formatter is None and minor_locator is None and not is_log:
    return

minorticks_on() registers a minor_locator, so that guard stops short-circuiting and execution reaches props["tick_values"] = ..., which publishes the majors as authored positions.

That write is right when the caller sets a major locator or formatter, since those define where the majors go. It is wrong when the only thing registered is a minor locator: the majors were still chosen by AutoLocator, and pinning them is not correct.

The change

Compute ticks as before. It is still needed to subdivide between the majors, but only publish tick_values and tick_count when the majors were actually determined by the caller:

majors_are_auto = (
    locator is None
    and formatter is None
    and authored_labels is None
    and not is_log
    and "tick_values" not in props
)

After:

tick_values None -> None
tick_count  9    -> 9

with minor_tick_values still populated.

Testing

  • New regression test asserting minorticks_on() leaves tick_values unset while minor values are still emitted. It fails on main with assert [0.0, 2.0, 4.0, 6.0, 8.0, 10.0] is None.
  • Confirmed by hand in a notebook: with minor ticks on, the tick labels now survive zooming instead of disappearing past roughly 2x.

Caveat: this does not make minor ticks regenerate

The majors are automatic again, but the minors are not. minor_tick_values is the only minor field on the wire, and the client filters that fixed list to the view rather than generating anything:

const minorTicks = (axis, axisId) => {
  if (!Array.isArray(axis.minor_tick_values)) return [];
  const [lo, hi] = this._axisRange(axisId);
  return axis.minor_tick_values.map(Number).filter((v) => v >= a && v <= b);
};

So the minor positions computed for the initial domain are all that ever exist. Zoom in far enough and none of them fall inside the view, leaving regenerating majors with no minors between them.

Fixing that properly means shipping the subdivision rule rather than the positions, something like minor_subdivisions, and having the client subdivide between the majors it generates. That is a wire field plus client work, which would be a fair bit more work, and I'm not confident enough around the codebase to tackle it.

Summary by CodeRabbit

  • Bug Fixes

    • Automatic major ticks on linear axes continue to update dynamically instead of being fixed to stored positions.
    • Explicit, formatted, logarithmic, and user-authored tick configurations are preserved.
    • Tick labels remain paired with their positions on shared symlog, asinh, and logit axes.
    • Enabling minor ticks does not interrupt automatic major tick generation.
  • Tests

    • Added regression coverage for automatic major tick generation and tick behavior across nonlinear axes.