123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092 |
- 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):
- name = None # To work around mpl<3.4 compat issues
- 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
|