"""Matplotlib styling for ``andrey.viz`` -- the package palette applied to charts.
matplotlib is an optional dependency (the ``viz`` extra: ``pip install 'andrey-core[viz]'``). The
color data (:func:`cycle`) and the :func:`rc_params` dict need no matplotlib import; :func:`use`,
:func:`context`, and :func:`cmap` do, and raise a clear error if it is missing. Every color comes
from the same :mod:`andrey.viz.theme` tokens as the graph renderer, so charts and structure diagrams
speak one visual language.
Examples
--------
>>> from andrey.viz import mpl
>>> len(mpl.cycle()) == 8 and mpl.cycle()[0] == "#3b5bdb"
True
>>> mpl.rc_params(dark=True)["figure.facecolor"]
'#0b1220'
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from .theme import DARK, LIGHT, Palette
if TYPE_CHECKING:
from matplotlib.colors import LinearSegmentedColormap
__all__ = ["cycle", "rc_params", "use", "context", "cmap"]
_FONT = ["Inter", "DejaVu Sans", "system-ui", "sans-serif"]
def _palette(dark: bool) -> Palette:
return DARK if dark else LIGHT
def _require_matplotlib() -> Any:
try:
import matplotlib
except ModuleNotFoundError as exc: # pragma: no cover - trivial import guard
raise ModuleNotFoundError(
"andrey.viz.mpl needs matplotlib; install the viz extra: pip install 'andrey-core[viz]'"
) from exc
return matplotlib
[docs]
def cycle(dark: bool = False) -> list[str]:
"""Return the categorical color cycle for line / bar / scatter series.
Parameters
----------
dark : bool, default=False
Select the dark palette instead of the light one.
Returns
-------
list of str
The colorblind-safe categorical eight, as hex strings, in the order series should consume
them. Needs no matplotlib import, so it also serves manual coloring and non-matplotlib
plotters.
"""
return list(_palette(dark).categorical)
[docs]
def rc_params(dark: bool = False) -> dict[str, Any]:
"""Return the ``andrey.viz`` chart style as a dict of matplotlib rcParams.
Building the dict needs no matplotlib. Apply it with ``matplotlib.rcParams.update`` or
``matplotlib.rc_context``; :func:`use` and :func:`context` do this and also set the color cycle.
Parameters
----------
dark : bool, default=False
Select the dark palette instead of the light one.
Returns
-------
dict of str to object
Colors from the palette for the figure, axes, grid, text, and ticks, plus font, line, and
legend settings. It holds no ``axes.prop_cycle``; :func:`cycle` gives the series colors.
"""
p = _palette(dark)
return {
"figure.facecolor": p.paper,
"figure.edgecolor": p.paper,
"savefig.facecolor": p.paper,
"savefig.edgecolor": p.paper,
"axes.facecolor": p.paper,
"axes.edgecolor": p.line,
"axes.linewidth": 1.0,
"axes.labelcolor": p.ink,
"axes.titlecolor": p.ink,
"axes.titleweight": "semibold",
"axes.titlesize": 12.5,
"axes.labelsize": 10.5,
"axes.grid": True,
"axes.grid.axis": "y",
"axes.axisbelow": True,
"axes.spines.top": False,
"axes.spines.right": False,
"grid.color": p.line,
"grid.linewidth": 0.7,
"grid.alpha": 0.9,
"text.color": p.ink,
"xtick.color": p.muted,
"ytick.color": p.muted,
"xtick.labelsize": 9.5,
"ytick.labelsize": 9.5,
"font.family": "sans-serif",
"font.sans-serif": list(_FONT), # copy: rcParams may mutate the value in place
"font.size": 10.5,
"lines.linewidth": 2.0,
"lines.solid_capstyle": "round",
"lines.markersize": 5.5,
"patch.edgecolor": p.paper,
"patch.linewidth": 0.6,
"legend.frameon": False,
"legend.fontsize": 9.5,
"figure.dpi": 120,
}
[docs]
def use(dark: bool = False) -> None:
"""Apply the style to the global matplotlib rcParams, for the rest of the process.
Parameters
----------
dark : bool, default=False
Select the dark palette instead of the light one.
Raises
------
ModuleNotFoundError
If matplotlib is not installed. Install the extra: ``pip install 'andrey-core[viz]'``.
See Also
--------
context : Scope the same style to a ``with`` block instead of the whole process.
"""
mpl = _require_matplotlib()
from cycler import cycler
mpl.rcParams.update(rc_params(dark))
mpl.rcParams["axes.prop_cycle"] = cycler(color=cycle(dark))
[docs]
def context(dark: bool = False) -> Any:
"""Return a context manager that applies the style within a ``with`` block.
Parameters
----------
dark : bool, default=False
Select the dark palette instead of the light one.
Returns
-------
contextlib.AbstractContextManager
The result of ``matplotlib.rc_context``: on exit it restores the previous rcParams, so
styling one figure leaves the rest of the process untouched.
Raises
------
ModuleNotFoundError
If matplotlib is not installed. Install the extra: ``pip install 'andrey-core[viz]'``.
"""
matplotlib = _require_matplotlib()
from cycler import cycler
rc = dict(rc_params(dark))
rc["axes.prop_cycle"] = cycler(color=cycle(dark))
return matplotlib.rc_context(rc) # top-level: avoids importing pyplot / selecting a backend
[docs]
def cmap(dark: bool = False, name: str | None = None) -> LinearSegmentedColormap:
"""Return the sequential cobalt colormap for heatmaps, weights, and densities.
This is the confidence ramp: reach for it whenever a value is ordered (edge stability, an
adjacency weight, a density), and for :func:`cycle` when it is categorical.
Parameters
----------
dark : bool, default=False
Select the dark palette instead of the light one.
name : str or None, default=None
The name carried on the returned colormap object. ``None`` uses ``"andrey"``
(``"andrey-dark"`` when ``dark``). Nothing is registered globally, so pass the object
itself as ``cmap=``, not the name.
Returns
-------
matplotlib.colors.LinearSegmentedColormap
The ramp, interpolated from the palette's five cobalt stops.
Raises
------
ModuleNotFoundError
If matplotlib is not installed. Install the extra: ``pip install 'andrey-core[viz]'``.
"""
_require_matplotlib()
from matplotlib.colors import LinearSegmentedColormap
resolved = name if name is not None else ("andrey-dark" if dark else "andrey")
return LinearSegmentedColormap.from_list(resolved, list(_palette(dark).ramp))