From f3a3d9c8f7125158b5d7664e3b87c06352046060 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 15 Jan 2026 17:07:22 +1000 Subject: [PATCH] Introduce internal dataclass to restructure the craziest loop that exists --- ultraplot/axes/cartesian.py | 417 ++++++++++++++++++------------------ 1 file changed, 210 insertions(+), 207 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 7cb6636af..844c89bee 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -4,6 +4,8 @@ """ import copy import inspect +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Tuple, Union import matplotlib.axis as maxis import matplotlib.dates as mdates @@ -319,6 +321,72 @@ docstring._snippet_manager["axes.dualy"] = _dual_docstring.format(**_shared_y_keys) +@dataclass +class _AxisFormatConfig: + """A dataclass to hold formatting options for a single axis.""" + + # Limits and scale + min_: Optional[float] = None + max_: Optional[float] = None + lim: Optional[Tuple[Optional[float], Optional[float]]] = None + reverse: Optional[bool] = None + margin: Optional[float] = None + bounds: Optional[Tuple[float, float]] = None + tickrange: Optional[Tuple[float, float]] = None + wraprange: Optional[Tuple[float, float]] = None + scale: Any = None # scale-spec, e.g., 'log' or ('cutoff', 100, 2) + scale_kw: Dict[str, Any] = field(default_factory=dict) + + # Spines and locations + spineloc: Any = None # e.g., 'bottom', 'zero', 'center' + tickloc: Any = None + ticklabelloc: Any = None + labelloc: Any = None + offsetloc: Any = None + + # Grid + grid: Optional[bool] = None + gridminor: Optional[bool] = None + gridcolor: Any = None # color-spec + + # Locators and Formatters + locator: Any = None # locator-spec + locator_kw: Dict[str, Any] = field(default_factory=dict) + minorlocator: Any = None # locator-spec + minorlocator_kw: Dict[str, Any] = field(default_factory=dict) + formatter: Any = None # formatter-spec + formatter_kw: Dict[str, Any] = field(default_factory=dict) + + # Label properties + label: Optional[str] = None + label_kw: Dict[str, Any] = field(default_factory=dict) + labelpad: Any = None # unit-spec + labelcolor: Any = None # color-spec + labelsize: Any = None # unit-spec or str + labelweight: Optional[str] = None + + # General appearance + color: Any = None # color-spec + linewidth: Any = None # unit-spec + rotation: Optional[Union[float, str]] = None + + # Tick properties + tickminor: Optional[bool] = None + tickdir: Optional[str] = None + tickcolor: Any = None # color-spec + ticklen: Any = None # unit-spec + ticklenratio: Optional[float] = None + tickwidth: Any = None # unit-spec + tickwidthratio: Optional[float] = None + + # Tick label properties + ticklabeldir: Optional[str] = None + ticklabelpad: Any = None # unit-spec + ticklabelcolor: Any = None # color-spec + ticklabelsize: Any = None # unit-spec or str + ticklabelweight: Optional[str] = None + + class CartesianAxes(shared._SharedAxes, plot.PlotAxes): """ Axes subclass for plotting in ordinary Cartesian coordinates. Adds the @@ -1084,6 +1152,127 @@ def _validate_loc(loc, opts, descrip): axis._tick_position = offsetloc axis.offsetText.set_verticalalignment(OPPOSITE_SIDE[offsetloc]) + def _format_axis(self, s: str, config: _AxisFormatConfig, fixticks: bool): + """Helper for `format` that applies settings to a single axis.""" + # Axis scale + # WARNING: This relies on monkey patch of mscale.scale_factory + # that allows it to accept a custom scale class! + # WARNING: Changing axis scale also changes default locators + # and formatters, and restricts possible range of axis limits, + # so critical to do it first. + scale_requested = config.scale is not None + if config.scale is not None: + scale = constructor.Scale(config.scale, **config.scale_kw) + getattr(self, f"set_{s}scale")(scale) + + # Explicitly sanitize unit-accepting arguments for this axis + ticklen = units(config.ticklen) + ticklabelpad = units(config.ticklabelpad) + labelpad = units(config.labelpad) + tickwidth = units(config.tickwidth) + labelsize = units(config.labelsize) + ticklabelsize = units(config.ticklabelsize) + + # Axis limits + self._update_limits( + s, + min_=config.min_, + max_=config.max_, + lim=config.lim, + reverse=config.reverse, + ) + if config.margin is not None: + self.margins(**{s: config.margin}) + + # Axis spine settings + # NOTE: This sets spine-specific color and linewidth settings. For + # non-specific settings _update_background is called in Axes.format() + self._update_spines(s, loc=config.spineloc, bounds=config.bounds) + self._update_background( + s, + edgecolor=config.color, + linewidth=config.linewidth, + tickwidth=tickwidth, + tickwidthratio=config.tickwidthratio, + ) + + # Axis tick settings + self._update_locs( + s, + tickloc=config.tickloc, + ticklabelloc=config.ticklabelloc, + labelloc=config.labelloc, + offsetloc=config.offsetloc, + ) + self._update_rotation(s, rotation=config.rotation) + self._update_ticks( + s, + grid=config.grid, + gridminor=config.gridminor, + ticklen=ticklen, + ticklenratio=config.ticklenratio, + tickdir=config.tickdir, + labeldir=config.ticklabeldir, + labelpad=ticklabelpad, + tickcolor=config.tickcolor, + gridcolor=config.gridcolor, + labelcolor=config.ticklabelcolor, + labelsize=ticklabelsize, + labelweight=config.ticklabelweight, + ) + + # Axis label settings + # NOTE: This must come after set_label_position, or any ha and va + # overrides in label_kw are overwritten. + kw = dict( + labelpad=labelpad, + color=config.labelcolor, + size=labelsize, + weight=config.labelweight, + **config.label_kw, + ) + self._update_labels(s, config.label, **kw) + + # Axis locator + minorlocator = config.minorlocator + if minorlocator is True or minorlocator is False: # must test identity + warnings._warn_ultraplot( + f"You passed {s}minorticks={minorlocator}, but this argument " + "is used to specify the tick locations. If you just want to " + f"toggle minor ticks, please use {s}tickminor={minorlocator}." + ) + minorlocator = None + self._update_locators( + s, + config.locator, + minorlocator, + tickminor=config.tickminor, + locator_kw=config.locator_kw, + minorlocator_kw=config.minorlocator_kw, + ) + + # Axis formatter + self._update_formatter( + s, + config.formatter, + formatter_kw=config.formatter_kw, + tickrange=config.tickrange, + wraprange=config.wraprange, + ) + if ( + scale_requested + and config.formatter is None + and not config.formatter_kw + and config.tickrange is None + and config.wraprange is None + and rc.find("formatter.log", context=True) + and getattr(self, f"get_{s}scale")() == "log" + ): + self._update_formatter(s, "log") + + # Ensure ticks are within axis bounds + self._fix_ticks(s, fixticks=fixticks) + @docstring._snippet_manager def format( self, @@ -1317,213 +1506,27 @@ def format( xspineloc = _not_none(xspineloc, rc._get_loc_string("x", "axes.spines")) yspineloc = _not_none(yspineloc, rc._get_loc_string("y", "axes.spines")) - # Loop over axes - for ( - s, - min_, - max_, - lim, - reverse, - margin, - bounds, - tickrange, - wraprange, - scale, - scale_kw, - spineloc, - tickloc, - ticklabelloc, - labelloc, - offsetloc, - grid, - gridminor, - locator, - locator_kw, - minorlocator, - minorlocator_kw, - formatter, - formatter_kw, - label, - label_kw, - color, - gridcolor, - linewidth, - rotation, - tickminor, - tickdir, - tickcolor, - ticklen, - ticklenratio, - tickwidth, - tickwidthratio, - ticklabeldir, - ticklabelpad, - ticklabelcolor, - ticklabelsize, - ticklabelweight, - labelpad, - labelcolor, - labelsize, - labelweight, - ) in zip( - ("x", "y"), - (xmin, ymin), - (xmax, ymax), - (xlim, ylim), - (xreverse, yreverse), - (xmargin, ymargin), - (xbounds, ybounds), - (xtickrange, ytickrange), - (xwraprange, ywraprange), - (xscale, yscale), - (xscale_kw, yscale_kw), - (xspineloc, yspineloc), - (xtickloc, ytickloc), - (xticklabelloc, yticklabelloc), - (xlabelloc, ylabelloc), - (xoffsetloc, yoffsetloc), - (xgrid, ygrid), - (xgridminor, ygridminor), - (xlocator, ylocator), - (xlocator_kw, ylocator_kw), - (xminorlocator, yminorlocator), - (xminorlocator_kw, yminorlocator_kw), - (xformatter, yformatter), - (xformatter_kw, yformatter_kw), - (xlabel, ylabel), - (xlabel_kw, ylabel_kw), - (xcolor, ycolor), - (xgridcolor, ygridcolor), - (xlinewidth, ylinewidth), - (xrotation, yrotation), - (xtickminor, ytickminor), - (xtickdir, ytickdir), - (xtickcolor, ytickcolor), - (xticklen, yticklen), - (xticklenratio, yticklenratio), - (xtickwidth, ytickwidth), - (xtickwidthratio, ytickwidthratio), - (xticklabeldir, yticklabeldir), - (xticklabelpad, yticklabelpad), - (xticklabelcolor, yticklabelcolor), - (xticklabelsize, yticklabelsize), - (xticklabelweight, yticklabelweight), - (xlabelpad, ylabelpad), - (xlabelcolor, ylabelcolor), - (xlabelsize, ylabelsize), - (xlabelweight, ylabelweight), - ): - # Axis scale - # WARNING: This relies on monkey patch of mscale.scale_factory - # that allows it to accept a custom scale class! - # WARNING: Changing axis scale also changes default locators - # and formatters, and restricts possible range of axis limits, - # so critical to do it first. - scale_requested = scale is not None - if scale is not None: - scale = constructor.Scale(scale, **scale_kw) - getattr(self, f"set_{s}scale")(scale) - - # Explicitly sanitize unit-accepting arguments for this axis - ticklen = units(ticklen) - ticklabelpad = units(ticklabelpad) - labelpad = units(labelpad) - tickwidth = units(tickwidth) - labelsize = units(labelsize) - ticklabelsize = units(ticklabelsize) - - # Axis limits - self._update_limits(s, min_=min_, max_=max_, lim=lim, reverse=reverse) - if margin is not None: - self.margins(**{s: margin}) - - # Axis spine settings - # NOTE: This sets spine-specific color and linewidth settings. For - # non-specific settings _update_background is called in Axes.format() - self._update_spines(s, loc=spineloc, bounds=bounds) - self._update_background( - s, - edgecolor=color, - linewidth=linewidth, - tickwidth=tickwidth, - tickwidthratio=tickwidthratio, - ) - - # Axis tick settings - self._update_locs( - s, - tickloc=tickloc, - ticklabelloc=ticklabelloc, - labelloc=labelloc, - offsetloc=offsetloc, - ) - self._update_rotation(s, rotation=rotation) - self._update_ticks( - s, - grid=grid, - gridminor=gridminor, - ticklen=ticklen, - ticklenratio=ticklenratio, - tickdir=tickdir, - labeldir=ticklabeldir, - labelpad=ticklabelpad, - tickcolor=tickcolor, - gridcolor=gridcolor, - labelcolor=ticklabelcolor, - labelsize=ticklabelsize, - labelweight=ticklabelweight, - ) - - # Axis label settings - # NOTE: This must come after set_label_position, or any ha and va - # overrides in label_kw are overwritten. - kw = dict( - labelpad=labelpad, - color=labelcolor, - size=labelsize, - weight=labelweight, - **label_kw, - ) - self._update_labels(s, label, **kw) - - # Axis locator - if minorlocator is True or minorlocator is False: # must test identity - warnings._warn_ultraplot( - f"You passed {s}minorticks={minorlocator}, but this argument " - "is used to specify the tick locations. If you just want to " - f"toggle minor ticks, please use {s}tickminor={minorlocator}." - ) - minorlocator = None - self._update_locators( - s, - locator, - minorlocator, - tickminor=tickminor, - locator_kw=locator_kw, - minorlocator_kw=minorlocator_kw, - ) - - # Axis formatter - self._update_formatter( - s, - formatter, - formatter_kw=formatter_kw, - tickrange=tickrange, - wraprange=wraprange, - ) - if ( - scale_requested - and formatter is None - and not formatter_kw - and tickrange is None - and wraprange is None - and rc.find("formatter.log", context=True) - and getattr(self, f"get_{s}scale")() == "log" - ): - self._update_formatter(s, "log") - - # Ensure ticks are within axis bounds - self._fix_ticks(s, fixticks=fixticks) + # Create config objects dynamically by introspecting the dataclass fields + x_kwargs, y_kwargs = {}, {} + l_vars = locals() + for name in _AxisFormatConfig.__dataclass_fields__: + # Handle exceptions to the "x" + name pattern for local variables + if name == "min_": + x_var, y_var = "xmin", "ymin" + elif name == "max_": + x_var, y_var = "xmax", "ymax" + else: + x_var = "x" + name + y_var = "y" + name + x_kwargs[name] = l_vars.get(x_var, None) + y_kwargs[name] = l_vars.get(y_var, None) + + x_config = _AxisFormatConfig(**x_kwargs) + y_config = _AxisFormatConfig(**y_kwargs) + + # Format axes + self._format_axis("x", x_config, fixticks=fixticks) + self._format_axis("y", y_config, fixticks=fixticks) if rc.find("formatter.log", context=True): if (