from __future__ import annotations
import re
from copy import copy
from collections.abc import Sequence
from dataclasses import dataclass
from functools import partial
from typing import Any, Callable, Tuple, Optional, ClassVar

import numpy as np
import matplotlib as mpl
from matplotlib.ticker import (
    Locator,
    Formatter,
    AutoLocator,
    AutoMinorLocator,
    FixedLocator,
    LinearLocator,
    LogLocator,
    SymmetricalLogLocator,
    MaxNLocator,
    MultipleLocator,
    EngFormatter,
    FuncFormatter,
    LogFormatterSciNotation,
    ScalarFormatter,
    StrMethodFormatter,
)
from matplotlib.dates import (
    AutoDateLocator,
    AutoDateFormatter,
    ConciseDateFormatter,
)
from matplotlib.axis import Axis
from matplotlib.scale import ScaleBase
from pandas import Series

from seaborn._core.rules import categorical_order
from seaborn._core.typing import Default, default

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from seaborn._core.plot import Plot
    from seaborn._core.properties import Property
    from numpy.typing import ArrayLike, NDArray

    TransFuncs = Tuple[
        Callable[[ArrayLike], ArrayLike], Callable[[ArrayLike], ArrayLike]
    ]

    # TODO Reverting typing to Any as it was proving too complicated to
    # work out the right way to communicate the types to mypy. Revisit!
    Pipeline = Sequence[Optional[Callable[[Any], Any]]]


class Scale:
    """Base class for objects that map data values to visual properties."""

    values: tuple | str | list | dict | None

    _priority: ClassVar[int]
    _pipeline: Pipeline
    _matplotlib_scale: ScaleBase
    _spacer: staticmethod
    _legend: tuple[list[Any], list[str]] | None

    def __post_init__(self):

        self._tick_params = None
        self._label_params = None
        self._legend = None

    def tick(self):
        raise NotImplementedError()

    def label(self):
        raise NotImplementedError()

    def _get_locators(self):
        raise NotImplementedError()

    def _get_formatter(self, locator: Locator | None = None):
        raise NotImplementedError()

    def _get_scale(self, name: str, forward: Callable, inverse: Callable):

        major_locator, minor_locator = self._get_locators(**self._tick_params)
        major_formatter = self._get_formatter(major_locator, **self._label_params)

        class InternalScale(mpl.scale.FuncScale):
            def set_default_locators_and_formatters(self, axis):
                axis.set_major_locator(major_locator)
                if minor_locator is not None:
                    axis.set_minor_locator(minor_locator)
                axis.set_major_formatter(major_formatter)

        return InternalScale(name, (forward, inverse))

    def _spacing(self, x: Series) -> float:
        space = self._spacer(x)
        if np.isnan(space):
            # This happens when there is no variance in the orient coordinate data
            # Not exactly clear what the right default is, but 1 seems reasonable?
            return 1
        return space

    def _setup(
        self, data: Series, prop: Property, axis: Axis | None = None,
    ) -> Scale:
        raise NotImplementedError()

    def _finalize(self, p: Plot, axis: Axis) -> None:
        """Perform scale-specific axis tweaks after adding artists."""
        pass

    def __call__(self, data: Series) -> ArrayLike:

        trans_data: Series | NDArray | list

        # TODO sometimes we need to handle scalars (e.g. for Line)
        # but what is the best way to do that?
        scalar_data = np.isscalar(data)
        if scalar_data:
            trans_data = np.array([data])
        else:
            trans_data = data

        for func in self._pipeline:
            if func is not None:
                trans_data = func(trans_data)

        if scalar_data:
            return trans_data[0]
        else:
            return trans_data

    @staticmethod
    def _identity():

        class Identity(Scale):
            _pipeline = []
            _spacer = None
            _legend = None
            _matplotlib_scale = None

        return Identity()


@dataclass
class Boolean(Scale):
    """
    A scale with a discrete domain of True and False values.

    The behavior is similar to the :class:`Nominal` scale, but property
    mappings and legends will use a [True, False] ordering rather than
    a sort using numeric rules. Coordinate variables accomplish this by
    inverting axis limits so as to maintain underlying numeric positioning.
    Input data are cast to boolean values, respecting missing data.

    """
    values: tuple | list | dict | None = None

    _priority: ClassVar[int] = 3

    def _setup(
        self, data: Series, prop: Property, axis: Axis | None = None,
    ) -> Scale:

        new = copy(self)
        if new._tick_params is None:
            new = new.tick()
        if new._label_params is None:
            new = new.label()

        def na_safe_cast(x):
            # TODO this doesn't actually need to be a closure
            if np.isscalar(x):
                return float(bool(x))
            else:
                if hasattr(x, "notna"):
                    # Handle pd.NA; np<>pd interop with NA is tricky
                    use = x.notna().to_numpy()
                else:
                    use = np.isfinite(x)
                out = np.full(len(x), np.nan, dtype=float)
                out[use] = x[use].astype(bool).astype(float)
                return out

        new._pipeline = [na_safe_cast, prop.get_mapping(new, data)]
        new._spacer = _default_spacer
        if prop.legend:
            new._legend = [True, False], ["True", "False"]

        forward, inverse = _make_identity_transforms()
        mpl_scale = new._get_scale(str(data.name), forward, inverse)

        axis = PseudoAxis(mpl_scale) if axis is None else axis
        mpl_scale.set_default_locators_and_formatters(axis)
        new._matplotlib_scale = mpl_scale

        return new

    def _finalize(self, p: Plot, axis: Axis) -> None:

        # We want values to appear in a True, False order but also want
        # True/False to be drawn at 1/0 positions respectively to avoid nasty
        # surprises if additional artists are added through the matplotlib API.
        # We accomplish this using axis inversion akin to what we do in Nominal.

        ax = axis.axes
        name = axis.axis_name
        axis.grid(False, which="both")
        if name not in p._limits:
            nticks = len(axis.get_major_ticks())
            lo, hi = -.5, nticks - .5
            if name == "x":
                lo, hi = hi, lo
            set_lim = getattr(ax, f"set_{name}lim")
            set_lim(lo, hi, auto=None)

    def tick(self, locator: Locator | None = None):
        new = copy(self)
        new._tick_params = {"locator": locator}
        return new

    def label(self, formatter: Formatter | None = None):
        new = copy(self)
        new._label_params = {"formatter": formatter}
        return new

    def _get_locators(self, locator):
        if locator is not None:
            return locator
        return FixedLocator([0, 1]), None

    def _get_formatter(self, locator, formatter):
        if formatter is not None:
            return formatter
        return FuncFormatter(lambda x, _: str(bool(x)))


@dataclass
class Nominal(Scale):
    """
    A categorical scale without relative importance / magnitude.
    """
    # Categorical (convert to strings), un-sortable

    values: tuple | str | list | dict | None = None
    order: list | None = None

    _priority: ClassVar[int] = 4

    def _setup(
        self, data: Series, prop: Property, axis: Axis | None = None,
    ) -> Scale:

        new = copy(self)
        if new._tick_params is None:
            new = new.tick()
        if new._label_params is None:
            new = new.label()

        # TODO flexibility over format() which isn't great for numbers / dates
        stringify = np.vectorize(format, otypes=["object"])

        units_seed = categorical_order(data, new.order)

        # TODO move to Nominal._get_scale?
        # TODO this needs some more complicated rethinking about how to pass
        # a unit dictionary down to these methods, along with how much we want
        # to invest in their API. What is it useful for tick() to do here?
        # (Ordinal may be different if we draw that contrast).
        # Any customization we do to allow, e.g., label wrapping will probably
        # require defining our own Formatter subclass.
        # We could also potentially implement auto-wrapping in an Axis subclass
        # (see Axis.draw ... it already is computing the bboxes).
        # major_locator, minor_locator = new._get_locators(**new._tick_params)
        # major_formatter = new._get_formatter(major_locator, **new._label_params)

        class CatScale(mpl.scale.LinearScale):
            def set_default_locators_and_formatters(self, axis):
                ...
                # axis.set_major_locator(major_locator)
                # if minor_locator is not None:
                #     axis.set_minor_locator(minor_locator)
                # axis.set_major_formatter(major_formatter)

        mpl_scale = CatScale(data.name)
        if axis is None:
            axis = PseudoAxis(mpl_scale)

            # TODO Currently just used in non-Coordinate contexts, but should
            # we use this to (A) set the padding we want for categorial plots
            # and (B) allow the values parameter for a Coordinate to set xlim/ylim
            axis.set_view_interval(0, len(units_seed) - 1)

        new._matplotlib_scale = mpl_scale

        # TODO array cast necessary to handle float/int mixture, which we need
        # to solve in a more systematic way probably
        # (i.e. if we have [1, 2.5], do we want [1.0, 2.5]? Unclear)
        axis.update_units(stringify(np.array(units_seed)))

        # TODO define this more centrally
        def convert_units(x):
            # TODO only do this with explicit order?
            # (But also category dtype?)
            # TODO isin fails when units_seed mixes numbers and strings (numpy error?)
            # but np.isin also does not seem any faster? (Maybe not broadcasting in C)
            # keep = x.isin(units_seed)
            keep = np.array([x_ in units_seed for x_ in x], bool)
            out = np.full(len(x), np.nan)
            out[keep] = axis.convert_units(stringify(x[keep]))
            return out

        new._pipeline = [convert_units, prop.get_mapping(new, data)]
        new._spacer = _default_spacer

        if prop.legend:
            new._legend = units_seed, list(stringify(units_seed))

        return new

    def _finalize(self, p: Plot, axis: Axis) -> None:

        ax = axis.axes
        name = axis.axis_name
        axis.grid(False, which="both")
        if name not in p._limits:
            nticks = len(axis.get_major_ticks())
            lo, hi = -.5, nticks - .5
            if name == "y":
                lo, hi = hi, lo
            set_lim = getattr(ax, f"set_{name}lim")
            set_lim(lo, hi, auto=None)

    def tick(self, locator: Locator | None = None) -> Nominal:
        """
        Configure the selection of ticks for the scale's axis or legend.

        .. note::
            This API is under construction and will be enhanced over time.
            At the moment, it is probably not very useful.

        Parameters
        ----------
        locator : :class:`matplotlib.ticker.Locator` subclass
            Pre-configured matplotlib locator; other parameters will not be used.

        Returns
        -------
        Copy of self with new tick configuration.

        """
        new = copy(self)
        new._tick_params = {"locator": locator}
        return new

    def label(self, formatter: Formatter | None = None) -> Nominal:
        """
        Configure the selection of labels for the scale's axis or legend.

        .. note::
            This API is under construction and will be enhanced over time.
            At the moment, it is probably not very useful.

        Parameters
        ----------
        formatter : :class:`matplotlib.ticker.Formatter` subclass
            Pre-configured matplotlib formatter; other parameters will not be used.

        Returns
        -------
        scale
            Copy of self with new tick configuration.

        """
        new = copy(self)
        new._label_params = {"formatter": formatter}
        return new

    def _get_locators(self, locator):

        if locator is not None:
            return locator, None

        locator = mpl.category.StrCategoryLocator({})

        return locator, None

    def _get_formatter(self, locator, formatter):

        if formatter is not None:
            return formatter

        formatter = mpl.category.StrCategoryFormatter({})

        return formatter


@dataclass
class Ordinal(Scale):
    # Categorical (convert to strings), sortable, can skip ticklabels
    ...


@dataclass
class Discrete(Scale):
    # Numeric, integral, can skip ticks/ticklabels
    ...


@dataclass
class ContinuousBase(Scale):

    values: tuple | str | None = None
    norm: tuple | None = None

    def _setup(
        self, data: Series, prop: Property, axis: Axis | None = None,
    ) -> Scale:

        new = copy(self)
        if new._tick_params is None:
            new = new.tick()
        if new._label_params is None:
            new = new.label()

        forward, inverse = new._get_transform()

        mpl_scale = new._get_scale(str(data.name), forward, inverse)

        if axis is None:
            axis = PseudoAxis(mpl_scale)
            axis.update_units(data)

        mpl_scale.set_default_locators_and_formatters(axis)
        new._matplotlib_scale = mpl_scale

        normalize: Optional[Callable[[ArrayLike], ArrayLike]]
        if prop.normed:
            if new.norm is None:
                vmin, vmax = data.min(), data.max()
            else:
                vmin, vmax = new.norm
            vmin, vmax = map(float, axis.convert_units((vmin, vmax)))
            a = forward(vmin)
            b = forward(vmax) - forward(vmin)

            def normalize(x):
                return (x - a) / b

        else:
            normalize = vmin = vmax = None

        new._pipeline = [
            axis.convert_units,
            forward,
            normalize,
            prop.get_mapping(new, data)
        ]

        def spacer(x):
            x = x.dropna().unique()
            if len(x) < 2:
                return np.nan
            return np.min(np.diff(np.sort(x)))
        new._spacer = spacer

        # TODO How to allow disabling of legend for all uses of property?
        # Could add a Scale parameter, or perhaps Scale.suppress()?
        # Are there other useful parameters that would be in Scale.legend()
        # besides allowing Scale.legend(False)?
        if prop.legend:
            axis.set_view_interval(vmin, vmax)
            locs = axis.major.locator()
            locs = locs[(vmin <= locs) & (locs <= vmax)]
            # Avoid having an offset / scientific notation in a legend
            # as we don't represent that anywhere so it ends up incorrect.
            # This could become an option (e.g. Continuous.label(offset=True))
            # in which case we would need to figure out how to show it.
            if hasattr(axis.major.formatter, "set_useOffset"):
                axis.major.formatter.set_useOffset(False)
            if hasattr(axis.major.formatter, "set_scientific"):
                axis.major.formatter.set_scientific(False)
            labels = axis.major.formatter.format_ticks(locs)
            new._legend = list(locs), list(labels)

        return new

    def _get_transform(self):

        arg = self.trans

        def get_param(method, default):
            if arg == method:
                return default
            return float(arg[len(method):])

        if arg is None:
            return _make_identity_transforms()
        elif isinstance(arg, tuple):
            return arg
        elif isinstance(arg, str):
            if arg == "ln":
                return _make_log_transforms()
            elif arg == "logit":
                base = get_param("logit", 10)
                return _make_logit_transforms(base)
            elif arg.startswith("log"):
                base = get_param("log", 10)
                return _make_log_transforms(base)
            elif arg.startswith("symlog"):
                c = get_param("symlog", 1)
                return _make_symlog_transforms(c)
            elif arg.startswith("pow"):
                exp = get_param("pow", 2)
                return _make_power_transforms(exp)
            elif arg == "sqrt":
                return _make_sqrt_transforms()
            else:
                raise ValueError(f"Unknown value provided for trans: {arg!r}")


@dataclass
class Continuous(ContinuousBase):
    """
    A numeric scale supporting norms and functional transforms.
    """
    values: tuple | str | None = None
    trans: str | TransFuncs | None = None

    # TODO Add this to deal with outliers?
    # outside: Literal["keep", "drop", "clip"] = "keep"

    _priority: ClassVar[int] = 1

    def tick(
        self,
        locator: Locator | None = None, *,
        at: Sequence[float] | None = None,
        upto: int | None = None,
        count: int | None = None,
        every: float | None = None,
        between: tuple[float, float] | None = None,
        minor: int | None = None,
    ) -> Continuous:
        """
        Configure the selection of ticks for the scale's axis or legend.

        Parameters
        ----------
        locator : :class:`matplotlib.ticker.Locator` subclass
            Pre-configured matplotlib locator; other parameters will not be used.
        at : sequence of floats
            Place ticks at these specific locations (in data units).
        upto : int
            Choose "nice" locations for ticks, but do not exceed this number.
        count : int
            Choose exactly this number of ticks, bounded by `between` or axis limits.
        every : float
            Choose locations at this interval of separation (in data units).
        between : pair of floats
            Bound upper / lower ticks when using `every` or `count`.
        minor : int
            Number of unlabeled ticks to draw between labeled "major" ticks.

        Returns
        -------
        scale
            Copy of self with new tick configuration.

        """
        # Input checks
        if locator is not None and not isinstance(locator, Locator):
            raise TypeError(
                f"Tick locator must be an instance of {Locator!r}, "
                f"not {type(locator)!r}."
            )
        log_base, symlog_thresh = self._parse_for_log_params(self.trans)
        if log_base or symlog_thresh:
            if count is not None and between is None:
                raise RuntimeError("`count` requires `between` with log transform.")
            if every is not None:
                raise RuntimeError("`every` not supported with log transform.")

        new = copy(self)
        new._tick_params = {
            "locator": locator,
            "at": at,
            "upto": upto,
            "count": count,
            "every": every,
            "between": between,
            "minor": minor,
        }
        return new

    def label(
        self,
        formatter: Formatter | None = None, *,
        like: str | Callable | None = None,
        base: int | None | Default = default,
        unit: str | None = None,
    ) -> Continuous:
        """
        Configure the appearance of tick labels for the scale's axis or legend.

        Parameters
        ----------
        formatter : :class:`matplotlib.ticker.Formatter` subclass
            Pre-configured formatter to use; other parameters will be ignored.
        like : str or callable
            Either a format pattern (e.g., `".2f"`), a format string with fields named
            `x` and/or `pos` (e.g., `"${x:.2f}"`), or a callable with a signature like
            `f(x: float, pos: int) -> str`. In the latter variants, `x` is passed as the
            tick value and `pos` is passed as the tick index.
        base : number
            Use log formatter (with scientific notation) having this value as the base.
            Set to `None` to override the default formatter with a log transform.
        unit : str or (str, str) tuple
            Use  SI prefixes with these units (e.g., with `unit="g"`, a tick value
            of 5000 will appear as `5 kg`). When a tuple, the first element gives the
            separator between the number and unit.

        Returns
        -------
        scale
            Copy of self with new label configuration.

        """
        # Input checks
        if formatter is not None and not isinstance(formatter, Formatter):
            raise TypeError(
                f"Label formatter must be an instance of {Formatter!r}, "
                f"not {type(formatter)!r}"
            )
        if like is not None and not (isinstance(like, str) or callable(like)):
            msg = f"`like` must be a string or callable, not {type(like).__name__}."
            raise TypeError(msg)

        new = copy(self)
        new._label_params = {
            "formatter": formatter,
            "like": like,
            "base": base,
            "unit": unit,
        }
        return new

    def _parse_for_log_params(
        self, trans: str | TransFuncs | None
    ) -> tuple[float | None, float | None]:

        log_base = symlog_thresh = None
        if isinstance(trans, str):
            m = re.match(r"^log(\d*)", trans)
            if m is not None:
                log_base = float(m[1] or 10)
            m = re.match(r"symlog(\d*)", trans)
            if m is not None:
                symlog_thresh = float(m[1] or 1)
        return log_base, symlog_thresh

    def _get_locators(self, locator, at, upto, count, every, between, minor):

        log_base, symlog_thresh = self._parse_for_log_params(self.trans)

        if locator is not None:
            major_locator = locator

        elif upto is not None:
            if log_base:
                major_locator = LogLocator(base=log_base, numticks=upto)
            else:
                major_locator = MaxNLocator(upto, steps=[1, 1.5, 2, 2.5, 3, 5, 10])

        elif count is not None:
            if between is None:
                # This is rarely useful (unless you are setting limits)
                major_locator = LinearLocator(count)
            else:
                if log_base or symlog_thresh:
                    forward, inverse = self._get_transform()
                    lo, hi = forward(between)
                    ticks = inverse(np.linspace(lo, hi, num=count))
                else:
                    ticks = np.linspace(*between, num=count)
                major_locator = FixedLocator(ticks)

        elif every is not None:
            if between is None:
                major_locator = MultipleLocator(every)
            else:
                lo, hi = between
                ticks = np.arange(lo, hi + every, every)
                major_locator = FixedLocator(ticks)

        elif at is not None:
            major_locator = FixedLocator(at)

        else:
            if log_base:
                major_locator = LogLocator(log_base)
            elif symlog_thresh:
                major_locator = SymmetricalLogLocator(linthresh=symlog_thresh, base=10)
            else:
                major_locator = AutoLocator()

        if minor is None:
            minor_locator = LogLocator(log_base, subs=None) if log_base else None
        else:
            if log_base:
                subs = np.linspace(0, log_base, minor + 2)[1:-1]
                minor_locator = LogLocator(log_base, subs=subs)
            else:
                minor_locator = AutoMinorLocator(minor + 1)

        return major_locator, minor_locator

    def _get_formatter(self, locator, formatter, like, base, unit):

        log_base, symlog_thresh = self._parse_for_log_params(self.trans)
        if base is default:
            if symlog_thresh:
                log_base = 10
            base = log_base

        if formatter is not None:
            return formatter

        if like is not None:
            if isinstance(like, str):
                if "{x" in like or "{pos" in like:
                    fmt = like
                else:
                    fmt = f"{{x:{like}}}"
                formatter = StrMethodFormatter(fmt)
            else:
                formatter = FuncFormatter(like)

        elif base is not None:
            # We could add other log options if necessary
            formatter = LogFormatterSciNotation(base)

        elif unit is not None:
            if isinstance(unit, tuple):
                sep, unit = unit
            elif not unit:
                sep = ""
            else:
                sep = " "
            formatter = EngFormatter(unit, sep=sep)

        else:
            formatter = ScalarFormatter()

        return formatter


@dataclass
class Temporal(ContinuousBase):
    """
    A scale for date/time data.
    """
    # TODO date: bool?
    # For when we only care about the time component, would affect
    # default formatter and norm conversion. Should also happen in
    # Property.default_scale. The alternative was having distinct
    # Calendric / Temporal scales, but that feels a bit fussy, and it
    # would get in the way of using first-letter shorthands because
    # Calendric and Continuous would collide. Still, we haven't implemented
    # those yet, and having a clear distinction betewen date(time) / time
    # may be more useful.

    trans = None

    _priority: ClassVar[int] = 2

    def tick(
        self, locator: Locator | None = None, *,
        upto: int | None = None,
    ) -> Temporal:
        """
        Configure the selection of ticks for the scale's axis or legend.

        .. note::
            This API is under construction and will be enhanced over time.

        Parameters
        ----------
        locator : :class:`matplotlib.ticker.Locator` subclass
            Pre-configured matplotlib locator; other parameters will not be used.
        upto : int
            Choose "nice" locations for ticks, but do not exceed this number.

        Returns
        -------
        scale
            Copy of self with new tick configuration.

        """
        if locator is not None and not isinstance(locator, Locator):
            err = (
                f"Tick locator must be an instance of {Locator!r}, "
                f"not {type(locator)!r}."
            )
            raise TypeError(err)

        new = copy(self)
        new._tick_params = {"locator": locator, "upto": upto}
        return new

    def label(
        self,
        formatter: Formatter | None = None, *,
        concise: bool = False,
    ) -> Temporal:
        """
        Configure the appearance of tick labels for the scale's axis or legend.

        .. note::
            This API is under construction and will be enhanced over time.

        Parameters
        ----------
        formatter : :class:`matplotlib.ticker.Formatter` subclass
            Pre-configured formatter to use; other parameters will be ignored.
        concise : bool
            If True, use :class:`matplotlib.dates.ConciseDateFormatter` to make
            the tick labels as compact as possible.

        Returns
        -------
        scale
            Copy of self with new label configuration.

        """
        new = copy(self)
        new._label_params = {"formatter": formatter, "concise": concise}
        return new

    def _get_locators(self, locator, upto):

        if locator is not None:
            major_locator = locator
        elif upto is not None:
            major_locator = AutoDateLocator(minticks=2, maxticks=upto)

        else:
            major_locator = AutoDateLocator(minticks=2, maxticks=6)
        minor_locator = None

        return major_locator, minor_locator

    def _get_formatter(self, locator, formatter, concise):

        if formatter is not None:
            return formatter

        if concise:
            # TODO ideally we would have concise coordinate ticks,
            # but full semantic ticks. Is that possible?
            formatter = ConciseDateFormatter(locator)
        else:
            formatter = AutoDateFormatter(locator)

        return formatter


# ----------------------------------------------------------------------------------- #


# TODO Have this separate from Temporal or have Temporal(date=True) or similar?
# class Calendric(Scale):

# TODO Needed? Or handle this at layer (in stat or as param, eg binning=)
# class Binned(Scale):

# TODO any need for color-specific scales?
# class Sequential(Continuous):
# class Diverging(Continuous):
# class Qualitative(Nominal):


# ----------------------------------------------------------------------------------- #


class PseudoAxis:
    """
    Internal class implementing minimal interface equivalent to matplotlib Axis.

    Coordinate variables are typically scaled by attaching the Axis object from
    the figure where the plot will end up. Matplotlib has no similar concept of
    and axis for the other mappable variables (color, etc.), but to simplify the
    code, this object acts like an Axis and can be used to scale other variables.

    """
    axis_name = ""  # Matplotlib requirement but not actually used

    def __init__(self, scale):

        self.converter = None
        self.units = None
        self.scale = scale
        self.major = mpl.axis.Ticker()
        self.minor = mpl.axis.Ticker()

        # It appears that this needs to be initialized this way on matplotlib 3.1,
        # but not later versions. It is unclear whether there are any issues with it.
        self._data_interval = None, None

        scale.set_default_locators_and_formatters(self)
        # self.set_default_intervals()  Is this ever needed?

    def set_view_interval(self, vmin, vmax):
        self._view_interval = vmin, vmax

    def get_view_interval(self):
        return self._view_interval

    # TODO do we want to distinguish view/data intervals? e.g. for a legend
    # we probably want to represent the full range of the data values, but
    # still norm the colormap. If so, we'll need to track data range separately
    # from the norm, which we currently don't do.

    def set_data_interval(self, vmin, vmax):
        self._data_interval = vmin, vmax

    def get_data_interval(self):
        return self._data_interval

    def get_tick_space(self):
        # TODO how to do this in a configurable / auto way?
        # Would be cool to have legend density adapt to figure size, etc.
        return 5

    def set_major_locator(self, locator):
        self.major.locator = locator
        locator.set_axis(self)

    def set_major_formatter(self, formatter):
        self.major.formatter = formatter
        formatter.set_axis(self)

    def set_minor_locator(self, locator):
        self.minor.locator = locator
        locator.set_axis(self)

    def set_minor_formatter(self, formatter):
        self.minor.formatter = formatter
        formatter.set_axis(self)

    def set_units(self, units):
        self.units = units

    def update_units(self, x):
        """Pass units to the internal converter, potentially updating its mapping."""
        self.converter = mpl.units.registry.get_converter(x)
        if self.converter is not None:
            self.converter.default_units(x, self)

            info = self.converter.axisinfo(self.units, self)

            if info is None:
                return
            if info.majloc is not None:
                self.set_major_locator(info.majloc)
            if info.majfmt is not None:
                self.set_major_formatter(info.majfmt)

            # This is in matplotlib method; do we need this?
            # self.set_default_intervals()

    def convert_units(self, x):
        """Return a numeric representation of the input data."""
        if np.issubdtype(np.asarray(x).dtype, np.number):
            return x
        elif self.converter is None:
            return x
        return self.converter.convert(x, self.units, self)

    def get_scale(self):
        # Note that matplotlib actually returns a string here!
        # (e.g., with a log scale, axis.get_scale() returns "log")
        # Currently we just hit it with minor ticks where it checks for
        # scale == "log". I'm not sure how you'd actually use log-scale
        # minor "ticks" in a legend context, so this is fine....
        return self.scale

    def get_majorticklocs(self):
        return self.major.locator()


# ------------------------------------------------------------------------------------ #
# Transform function creation


def _make_identity_transforms() -> TransFuncs:

    def identity(x):
        return x

    return identity, identity


def _make_logit_transforms(base: float | None = None) -> TransFuncs:

    log, exp = _make_log_transforms(base)

    def logit(x):
        with np.errstate(invalid="ignore", divide="ignore"):
            return log(x) - log(1 - x)

    def expit(x):
        with np.errstate(invalid="ignore", divide="ignore"):
            return exp(x) / (1 + exp(x))

    return logit, expit


def _make_log_transforms(base: float | None = None) -> TransFuncs:

    fs: TransFuncs
    if base is None:
        fs = np.log, np.exp
    elif base == 2:
        fs = np.log2, partial(np.power, 2)
    elif base == 10:
        fs = np.log10, partial(np.power, 10)
    else:
        def forward(x):
            return np.log(x) / np.log(base)
        fs = forward, partial(np.power, base)

    def log(x: ArrayLike) -> ArrayLike:
        with np.errstate(invalid="ignore", divide="ignore"):
            return fs[0](x)

    def exp(x: ArrayLike) -> ArrayLike:
        with np.errstate(invalid="ignore", divide="ignore"):
            return fs[1](x)

    return log, exp


def _make_symlog_transforms(c: float = 1, base: float = 10) -> TransFuncs:

    # From https://iopscience.iop.org/article/10.1088/0957-0233/24/2/027001

    # Note: currently not using base because we only get
    # one parameter from the string, and are using c (this is consistent with d3)

    log, exp = _make_log_transforms(base)

    def symlog(x):
        with np.errstate(invalid="ignore", divide="ignore"):
            return np.sign(x) * log(1 + np.abs(np.divide(x, c)))

    def symexp(x):
        with np.errstate(invalid="ignore", divide="ignore"):
            return np.sign(x) * c * (exp(np.abs(x)) - 1)

    return symlog, symexp


def _make_sqrt_transforms() -> TransFuncs:

    def sqrt(x):
        return np.sign(x) * np.sqrt(np.abs(x))

    def square(x):
        return np.sign(x) * np.square(x)

    return sqrt, square


def _make_power_transforms(exp: float) -> TransFuncs:

    def forward(x):
        return np.sign(x) * np.power(np.abs(x), exp)

    def inverse(x):
        return np.sign(x) * np.power(np.abs(x), 1 / exp)

    return forward, inverse


def _default_spacer(x: Series) -> float:
    return 1
