Source code for numeraire_graphics.theme

"""Presentation helpers: a house theme, a colourblind colour scale, and a paper-exact save.

Kept separate from the plot builders because they are composed onto *any* returned grammar
object (``plot + theme_numeraire()``), and because saving is a deliberate, explicit act — the
plot functions never write files.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

from plotnine import (
    element_blank,
    element_line,
    element_rect,
    element_text,
    scale_color_manual,
    scale_fill_gradient2,
    scale_fill_manual,
    theme,
    theme_matplotlib,
)

from numeraire_graphics._common import OKABE_ITO

# Colourblind-safe diverging endpoints (Okabe-Ito blue / near-white / vermillion), for a signed
# fill centred at zero: blue = negative (short), vermillion = positive (long).
_DIVERGING_LOW = "#0072B2"
_DIVERGING_MID = "#F7F7F7"
_DIVERGING_HIGH = "#D55E00"

# cm-per-inch, for the paper-exact save path.
_CM_PER_INCH = 2.54

# Ordered greys for a monochrome print (shared by the colour and fill greyscale scales).
_GREYS = ("#000000", "#555555", "#888888", "#AAAAAA", "#333333", "#777777", "#999999")

# How many times to cycle a discrete palette so a comparison with more than eight methods still
# renders every group (colours repeat past eight — the plot builders warn when that happens).
_PALETTE_CYCLES = 3


def _cycled(values: tuple[str, ...]) -> list[str]:
    """Repeat a discrete palette so an oversized comparison never runs out of fill/colour values.

    The first ``len(values)`` entries are unchanged (so the leading draws stay the exact Okabe-Ito
    order); only beyond that do colours repeat. The builders emit a warning when this cycling bites.
    """
    return list(values) * _PALETTE_CYCLES


# rcParam profiles for print. ``latex`` selects Computer-Modern-like serif math without requiring
# a TeX installation (a true PGF/usetex export is available to callers who set it themselves and
# have LaTeX installed); ``sans`` is a neutral screen profile; ``none`` leaves rcParams untouched.
_FONT_PROFILES: dict[str, dict[str, Any]] = {
    "latex": {
        "font.family": "serif",
        "mathtext.fontset": "cm",
        "axes.unicode_minus": False,
    },
    "sans": {
        "font.family": "sans-serif",
        "mathtext.fontset": "dejavusans",
    },
    "none": {},
}


[docs] def theme_numeraire( base_size: float = 8.0, base_family: str = "serif", x_axis_rotation: float = 0.0, grid: str = "none", ) -> theme: """The house theme: a clean, publication-oriented look on a matplotlib base. ``base_size`` (points) and ``base_family`` set the typographic defaults; 8pt serif suits a single-column journal figure. ``x_axis_rotation`` (degrees) is the rotation hook for the x-axis tick labels — 0 by default (the smart date axis keeps them short), raise it (e.g. 30–45) as a fallback when a categorical or dense axis still crowds. Returns a plotnine ``theme`` to add onto any plot. ``grid`` selects which panel gridlines are drawn: ``"none"`` (default — a clean, grid-free panel, which also keeps the weight heatmap's tiled surface free of any row/column rules), ``"y"`` (horizontal value guides for a line/bar reader), ``"x"``, or ``"both"``. The vertical (x) grid is never drawn over a date/tile axis unless you explicitly ask for it. Beyond the typography this pins the publication defaults plotnine otherwise leaves to its matplotlib base: a flat light facet strip (no heavy grey block), a bottom legend with no key background, sized axis text, breathing room between facet panels, and a small plot margin — so a figure looks intentional straight out of the builder without hand-tuning. """ if grid not in ("none", "y", "x", "both"): raise ValueError(f"grid must be one of 'none'/'y'/'x'/'both'; got {grid!r}") show_y, show_x = grid in ("y", "both"), grid in ("x", "both") y_major = element_line(color="#E6E6E6", size=0.3) if show_y else element_blank() y_minor = element_line(color="#F2F2F2", size=0.2) if show_y else element_blank() x_major = element_line(color="#E6E6E6", size=0.3) if show_x else element_blank() x_text_ha = "right" if x_axis_rotation else "center" return theme_matplotlib() + theme( text=element_text(family=base_family, size=base_size), axis_title=element_text(size=base_size), axis_text=element_text(size=base_size - 1), axis_text_x=element_text(size=base_size - 1, ha=x_text_ha, rotation=x_axis_rotation), legend_title=element_text(size=base_size), legend_text=element_text(size=base_size - 1), plot_title=element_text(size=base_size + 1), panel_grid_major_y=y_major, panel_grid_minor_y=y_minor, panel_grid_major_x=x_major, panel_grid_minor_x=element_blank(), panel_background=element_rect(fill="white"), panel_spacing=0.03, # A flat, light facet strip instead of plotnine's heavy grey block. strip_background=element_rect(fill="#F0F0F0", color="none"), strip_text=element_text(size=base_size, color="#1A1A1A"), legend_position="bottom", legend_key=element_rect(fill="white", color="none"), legend_background=element_rect(fill="white", color="none"), plot_margin=0.02, figure_size=(8.0 / _CM_PER_INCH, 6.0 / _CM_PER_INCH), # ~8x6 cm; overridden on save_paper )
[docs] def scale_color_numeraire( palette: str = "okabe_ito", greyscale: bool = False ) -> scale_color_manual: """A colourblind-safe discrete colour scale (Okabe-Ito by default). ``greyscale=True`` collapses to an ordered set of greys for a monochrome print (pair it with varied linetypes for separability). ``palette`` currently accepts only ``"okabe_ito"``. """ if palette != "okabe_ito": raise ValueError(f"unknown palette {palette!r}; only 'okabe_ito' is available") if greyscale: return scale_color_manual(values=_cycled(_GREYS), name="") return scale_color_manual(values=_cycled(OKABE_ITO), name="")
[docs] def scale_fill_numeraire( palette: str = "okabe_ito", greyscale: bool = False, diverging: bool = False ) -> Any: """The ``fill`` counterpart of :func:`scale_color_numeraire`, discrete or diverging. With ``diverging=False`` (the default) this is the discrete Okabe-Ito fill scale for a categorical ``fill`` aesthetic — the bars of :func:`~numeraire_graphics.plot_metric_by` and any other grouped fill. ``greyscale=True`` collapses it to ordered greys for a monochrome print, matching :func:`scale_color_numeraire`. With ``diverging=True`` it is a *continuous* two-sided fill centred at zero — the scale the weight heatmap wants, so a long (positive) and a short (negative) weight read as opposite hues with an unsaturated midpoint at zero. The endpoints are colourblind-safe (blue for negative, vermillion for positive); ``greyscale=True`` gives a light-to-dark grey ramp through white. ``palette`` currently accepts only ``"okabe_ito"``. """ if palette != "okabe_ito": raise ValueError(f"unknown palette {palette!r}; only 'okabe_ito' is available") if diverging: if greyscale: return scale_fill_gradient2( low="#333333", mid="#FFFFFF", high="#000000", midpoint=0.0, name="" ) return scale_fill_gradient2( low=_DIVERGING_LOW, mid=_DIVERGING_MID, high=_DIVERGING_HIGH, midpoint=0.0, name="" ) if greyscale: return scale_fill_manual(values=_cycled(_GREYS), name="") return scale_fill_manual(values=_cycled(OKABE_ITO), name="")
[docs] def save_paper( plot: Any, path: str | Path, *, width_cm: float = 8.4, height_cm: float = 6.0, font_profile: str = "latex", dpi: int = 300, format: str | None = None, ) -> Path: """Save ``plot`` at an exact centimetre size for a paper, under a print font profile. ``width_cm`` / ``height_cm`` size the figure exactly (journals specify column widths in cm); the defaults (8.4 x 6 cm) are a single journal column, so a bare ``save_paper(plot, path)`` already yields a sensibly-proportioned figure. ``font_profile`` (``"latex"`` | ``"sans"`` | ``"none"``) sets matplotlib rcParams for the duration of the save only, then restores them. ``format`` forces the output format (``"pdf"``, ``"png"``, ``"svg"``, …) regardless of the path extension; left ``None`` the extension decides. Returns the written path. This is the *only* save surface — the plot builders never write. """ import matplotlib as mpl if font_profile not in _FONT_PROFILES: raise ValueError( f"unknown font_profile {font_profile!r}; choose from {sorted(_FONT_PROFILES)}" ) out = Path(path) # matplotlib types rcParams keys as a closed Literal; we pass a dynamic set of profile keys # deliberately, so view it as a plain mapping for this scoped save/restore. rc: Any = mpl.rcParams profile = _FONT_PROFILES[font_profile] saved = {k: rc[k] for k in profile} try: rc.update(profile) plot.save( filename=str(out), format=format, width=width_cm, height=height_cm, units="cm", dpi=dpi, verbose=False, ) finally: rc.update(saved) return out