"""House style for the figures: Matplotlib settings, a palette, and a save helper.
The settings are deliberately small and explicit, so a figure looks the same whoever builds
it. :func:`house_style` returns a context manager that applies them around figure
construction without leaking into the global state, and :func:`save_figure` writes one file
per format alongside a JSON provenance sidecar.
"""
from __future__ import annotations
import json
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
import matplotlib as mpl
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from matplotlib.ticker import AutoMinorLocator
from figures import __version__
# Colourblind-safe qualitative palette (Wong, Nature Methods 2011), used in order for the
# criterion lines.
PALETTE: tuple[str, ...] = (
"#0072B2",
"#E69F00",
"#009E73",
"#D55E00",
"#CC79A7",
"#56B4E9",
"#F0E442",
"#000000",
)
# Colour of the reference lines that mark the chosen number of classes.
REFERENCE_COLOUR = "#444444"
# Matplotlib pgf-backend settings for the ``pgf`` export format. The figure text is left to the
# document's own LaTeX run (``rcfonts`` off), so an included ``.pgf`` matches the surrounding
# prose; the caller passes the variant whose preamble mirrors the target document. The default is
# serif (Latin Modern); PGF_RC_SANS mirrors reports/brief/main.tex (Helvetica via ``helvet``).
_PGF_RC: dict[str, object] = {
"pgf.texsystem": "pdflatex",
"pgf.rcfonts": False,
"font.family": "serif",
"pgf.preamble": "\n".join((r"\usepackage[T1]{fontenc}", r"\usepackage{lmodern}")),
}
PGF_RC_SANS: dict[str, object] = {
"pgf.texsystem": "pdflatex",
"pgf.rcfonts": False,
"font.family": "sans-serif",
"pgf.preamble": "\n".join(
(
r"\usepackage[T1]{fontenc}",
r"\usepackage{helvet}",
r"\usepackage[helvet]{sfmath}",
r"\renewcommand{\familydefault}{\sfdefault}",
)
),
}
# Short display labels for the seven enrichment categories, so axis ticks stay legible. Keyed
# by the names the analysis writes (American spelling and slashes preserved to match the data).
CATEGORY_LABELS: dict[str, str] = {
"anxiety/mood": "anxiety/mood",
"attention": "attention",
"disruptive behavior": "disruptive",
"self-injury": "self-injury",
"social/communication": "social/comm",
"restricted/repetitive": "restricted/rep",
"developmental": "developmental",
}
[docs]
def panel_title(ax: Axes, letter: str, text: str) -> None:
"""Set a left-aligned panel title prefixed with a bold letter label.
The letter is rendered in bold and the scientific title follows in the regular weight, so
each panel reads as, for example, "A Information criteria", left-aligned over the axes.
Parameters
----------
ax : matplotlib.axes.Axes
The axis to title.
letter : str
The panel letter, shown in bold (for example ``"A"``).
text : str
The scientific title that follows the letter.
"""
ax.set_title(rf"$\mathbf{{{letter}}}$ {text}", loc="left")
# LaTeX font sizes in the 11pt collaboration brief: ``\small`` is 10pt and ``\footnotesize`` is
# 9pt. The brief inputs these figures as pgf set in the document's own fonts, so the axis and tick
# labels are sized in points to read at the same sizes as the surrounding prose.
_BRIEF_LABEL_PT = 10.0
_BRIEF_TICK_PT = 9.0
[docs]
def brief_axis_style(ax: Axes, *, minor_x: bool = True) -> None:
r"""Match a brief figure's axis fonts to the document and add major and minor gridlines.
The axis labels are sized to the brief's ``\small`` (10pt) and the tick labels to
``\footnotesize`` (9pt), so an included ``.pgf`` reads at the same sizes as the surrounding
LaTeX. Major and minor gridlines are drawn (the minor lines fainter). Minor ticks and their
gridlines are added on the y-axis always and on the x-axis only when it carries a continuous
scale.
Parameters
----------
ax : matplotlib.axes.Axes
The axis to restyle.
minor_x : bool, optional
Whether to add minor ticks and gridlines on the x-axis, and to resize its tick labels.
Set false for a categorical x-axis (bar groups), whose labels are kept at their own size.
"""
ax.xaxis.label.set_fontsize(_BRIEF_LABEL_PT)
ax.yaxis.label.set_fontsize(_BRIEF_LABEL_PT)
ax.tick_params(axis="y", which="major", labelsize=_BRIEF_TICK_PT)
ax.yaxis.set_minor_locator(AutoMinorLocator())
if minor_x:
ax.tick_params(axis="x", which="major", labelsize=_BRIEF_TICK_PT)
ax.xaxis.set_minor_locator(AutoMinorLocator())
ax.grid(which="major", alpha=0.3, linewidth=0.5)
ax.grid(which="minor", alpha=0.15, linewidth=0.4)
_RC_PARAMS: dict[str, object] = {
"figure.dpi": 120,
"font.size": 9,
"axes.titlesize": 9,
"axes.labelsize": 9,
"axes.spines.top": False,
"axes.spines.right": False,
"axes.grid": True,
"grid.alpha": 0.3,
"grid.linewidth": 0.5,
"legend.frameon": False,
"legend.fontsize": 7,
"lines.linewidth": 1.5,
"lines.markersize": 4,
"xtick.labelsize": 8,
"ytick.labelsize": 8,
}
[docs]
@contextmanager
def house_style() -> Iterator[None]:
"""Apply the package Matplotlib settings for the duration of the context.
Yields
------
None
Control returns to the caller with the package ``rcParams`` in effect; the previous
settings are restored on exit, so the styling does not leak into other figures.
"""
with mpl.rc_context(_RC_PARAMS):
yield