Add plotstyle: reusable KIT-branded matplotlib styling toolkit
Introduces a new plotstyle package providing consistent, presentation/thesis-ready matplotlib figures: a KIT corporate-design color palette (categorical, sequential, diverging, status), a theme applied via use() (KIT-black bottom spine only, left/bottom ticks, horizontal gridlines, left-aligned titles, LaTeX text in Latin Modern Sans), new_figure() with size presets and figure-level title/params subtitles, a same-size colorbar() helper, style_legend() and panel_label() building blocks, and savefig(). Ships as an optional "plotting" extra so core gallery installs stay lightweight, with a full test suite and a runnable example notebook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""Reusable matplotlib styling and building blocks for consistent, modern
|
||||
scientific plots across presentations and thesis figures.
|
||||
|
||||
import plotstyle as ps
|
||||
|
||||
ps.use()
|
||||
fig, ax = ps.new_figure("thesis-single")
|
||||
ax.plot(x, y, label="A")
|
||||
ps.style_legend(ax)
|
||||
ps.savefig(fig, "plots/my_plot", formats=("pdf", "png"))
|
||||
"""
|
||||
|
||||
from . import colors
|
||||
from .annotations import panel_label, style_legend
|
||||
from .colors import get_color
|
||||
from .figures import FIGSIZES, colorbar, new_figure, savefig
|
||||
from .style import reset, use
|
||||
|
||||
__all__ = [
|
||||
"colors",
|
||||
"get_color",
|
||||
"use",
|
||||
"reset",
|
||||
"new_figure",
|
||||
"colorbar",
|
||||
"savefig",
|
||||
"FIGSIZES",
|
||||
"style_legend",
|
||||
"panel_label",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Legend and panel-label helpers for consistent multi-panel figures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
from matplotlib.axes import Axes
|
||||
from matplotlib.colors import to_rgba
|
||||
|
||||
from .colors import INK
|
||||
|
||||
_PANEL_LOCS = {
|
||||
"upper left": (0.02, 0.98, "left", "top"),
|
||||
"upper right": (0.98, 0.98, "right", "top"),
|
||||
"lower left": (0.02, 0.08, "left", "bottom"),
|
||||
"lower right": (0.98, 0.08, "right", "bottom"),
|
||||
}
|
||||
|
||||
|
||||
def style_legend(
|
||||
ax: Axes,
|
||||
loc: str = "outside right upper",
|
||||
frameon: bool = False,
|
||||
title: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Add a figure-level legend, placed outside the axes by default.
|
||||
|
||||
Builds on `ax`'s handles/labels (or explicit `handles=`/`labels=` kwargs)
|
||||
but attaches the legend to `ax`'s figure via `fig.legend(...)`, so it sits
|
||||
outside the plot area rather than overlapping the data.
|
||||
|
||||
A `title` is strongly encouraged: an untitled legend floating outside the
|
||||
axes loses its visual link to what it's describing, e.g.
|
||||
`style_legend(ax, title="Series")`. Without one, this emits a warning
|
||||
rather than failing — the legend still renders.
|
||||
"""
|
||||
if title is None:
|
||||
warnings.warn(
|
||||
"style_legend() called without a title — an outside legend reads better with one, "
|
||||
"e.g. style_legend(ax, title='Series').",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
fig = ax.get_figure()
|
||||
handles = kwargs.pop("handles", None)
|
||||
labels = kwargs.pop("labels", None)
|
||||
if handles is None or labels is None:
|
||||
handles, labels = ax.get_legend_handles_labels()
|
||||
|
||||
legend = fig.legend(handles, labels, loc=loc, frameon=frameon, title=title, **kwargs)
|
||||
if legend.get_title() is not None:
|
||||
legend.get_title().set_fontweight("bold")
|
||||
return legend
|
||||
|
||||
|
||||
def panel_label(
|
||||
ax: Axes,
|
||||
label: str,
|
||||
loc: str = "lower right",
|
||||
fontweight: str = "bold",
|
||||
box: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""Add a panel label like "(a)" for multi-panel thesis/paper figures.
|
||||
|
||||
Defaults to the bottom-right corner, colored to match the axes' xlabel,
|
||||
on a light-grey semi-transparent rounded box with a slim solid border.
|
||||
Pass `box=False` for bare text with no box.
|
||||
"""
|
||||
try:
|
||||
x, y, ha, va = _PANEL_LOCS[loc]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown panel_label loc {loc!r}. Choose from {sorted(_PANEL_LOCS)}.") from exc
|
||||
|
||||
kwargs.setdefault("color", ax.xaxis.label.get_color())
|
||||
if box:
|
||||
kwargs.setdefault(
|
||||
"bbox",
|
||||
dict(
|
||||
boxstyle="round,pad=0.3",
|
||||
facecolor=to_rgba(INK["gridline"], alpha=0.8),
|
||||
edgecolor=INK["baseline"],
|
||||
linewidth=0.8,
|
||||
),
|
||||
)
|
||||
|
||||
return ax.text(
|
||||
x,
|
||||
y,
|
||||
f"({label})",
|
||||
transform=ax.transAxes,
|
||||
ha=ha,
|
||||
va=va,
|
||||
fontweight=fontweight,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
# Base rcParams for plotstyle. Loaded via plt.style.use() from style.use().
|
||||
# Colors here mirror plotstyle.colors.INK (KIT black-70% gray family on white) — kept in sync by hand.
|
||||
|
||||
figure.facecolor: ffffff
|
||||
figure.edgecolor: ffffff
|
||||
figure.constrained_layout.use: True
|
||||
|
||||
axes.facecolor: ffffff
|
||||
# axes.edgecolor/linewidth style the one visible spine (bottom — the rest are
|
||||
# off below), so this is really "the bottom spine is black and heavier", not
|
||||
# a general axes outline color.
|
||||
axes.edgecolor: 000000
|
||||
axes.linewidth: 1.25
|
||||
axes.labelcolor: 6a6a6a
|
||||
axes.titlecolor: 404040
|
||||
axes.titleweight: bold
|
||||
axes.titlelocation: left
|
||||
axes.grid: True
|
||||
axes.grid.axis: y
|
||||
axes.axisbelow: True
|
||||
axes.spines.top: False
|
||||
axes.spines.right: False
|
||||
axes.spines.left: False
|
||||
axes.spines.bottom: True
|
||||
|
||||
grid.color: ececec
|
||||
grid.linewidth: 0.8
|
||||
grid.alpha: 1.0
|
||||
|
||||
xtick.color: 969696
|
||||
ytick.color: 969696
|
||||
# Major tick labels read as the primary ("black") ink; minor tick labels are
|
||||
# muted grey. rcParams only expose one labelcolor per axis (no major/minor
|
||||
# split) — the per-major/minor distinction is applied in code by
|
||||
# plotstyle.figures.new_figure() via ax.tick_params(which=...). These values
|
||||
# are just the fallback/default for axes that bypass new_figure().
|
||||
xtick.labelcolor: 404040
|
||||
ytick.labelcolor: 404040
|
||||
xtick.direction: out
|
||||
ytick.direction: out
|
||||
xtick.bottom: True
|
||||
xtick.top: False
|
||||
ytick.left: True
|
||||
ytick.right: False
|
||||
# Minor ticks: x-axis only. The y-axis already has horizontal gridlines at
|
||||
# major ticks, so y minor ticks would just add unlabeled clutter.
|
||||
xtick.minor.visible: True
|
||||
ytick.minor.visible: False
|
||||
xtick.major.size: 6.0
|
||||
xtick.minor.size: 3.0
|
||||
xtick.major.width: 0.8
|
||||
xtick.minor.width: 0.6
|
||||
ytick.major.size: 6.0
|
||||
ytick.minor.size: 3.0
|
||||
ytick.major.width: 0.8
|
||||
ytick.minor.width: 0.6
|
||||
|
||||
lines.linewidth: 2.0
|
||||
lines.markersize: 6.0
|
||||
lines.solid_capstyle: round
|
||||
|
||||
font.family: sans-serif
|
||||
font.sans-serif: DejaVu Sans, Arial, Helvetica, sans-serif
|
||||
font.size: 11
|
||||
axes.titlesize: 13
|
||||
axes.labelsize: 11
|
||||
xtick.labelsize: 10
|
||||
ytick.labelsize: 10
|
||||
legend.fontsize: 10
|
||||
|
||||
legend.frameon: False
|
||||
legend.handlelength: 1.6
|
||||
legend.labelspacing: 0.4
|
||||
legend.title_fontsize: 10
|
||||
|
||||
savefig.facecolor: ffffff
|
||||
savefig.edgecolor: ffffff
|
||||
savefig.dpi: 300
|
||||
savefig.bbox: tight
|
||||
savefig.pad_inches: 0.05
|
||||
@@ -0,0 +1,102 @@
|
||||
"""KIT (Karlsruhe Institute of Technology) corporate design color palette.
|
||||
|
||||
Hex values for KIT green, KIT blue, black 70%, and the corporate accent
|
||||
colors are taken verbatim from the KIT corporate design guide
|
||||
(https://kit-cd.km.kit.edu/english/341.php) — do not hand-edit them without
|
||||
checking that page.
|
||||
|
||||
The categorical *order* below is not arbitrary: it was chosen by running
|
||||
every hue through the CVD-safety/contrast checks described in the `dataviz`
|
||||
skill (fixed hue order, OKLab CVD separation under simulated color-vision
|
||||
deficiency, a normal-vision separation floor, contrast vs. a white surface)
|
||||
and keeping the ordering that clears the adjacent-pair checks. KIT yellow
|
||||
(#FCE500) is the one hue that cannot pass on its own (too light on a white
|
||||
surface, ~1.3:1 contrast) — that is a property of the hex value itself, not
|
||||
the ordering, so it is placed last and should always be paired with a
|
||||
visible direct label rather than relied on as a fill alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from matplotlib.colors import LinearSegmentedColormap
|
||||
|
||||
# Fixed-order categorical hues (KIT primary + accent colors). Order is the
|
||||
# CVD-safety mechanism: never reorder, and never cycle past the last slot
|
||||
# (fold extra series into "Other").
|
||||
CATEGORICAL = [
|
||||
"#009682", # 0 KIT green (primary)
|
||||
"#DF9B1B", # 1 orange
|
||||
"#4664AA", # 2 KIT blue (primary)
|
||||
"#A78230", # 3 brown
|
||||
"#23A1E0", # 4 cyan
|
||||
"#A3107C", # 5 purple
|
||||
"#8CB63C", # 6 pea green
|
||||
"#A22223", # 7 red
|
||||
"#FCE500", # 8 yellow — low contrast on white; always pair with a direct label
|
||||
]
|
||||
|
||||
# Single-hue (KIT blue) sequential ramp, steps 100..700. Step 700 is the exact
|
||||
# brand hex (the high/saturated end); lighter steps are tints blended toward
|
||||
# white in sRGB — there is no darker-than-brand shade.
|
||||
SEQUENTIAL_STEPS = [
|
||||
"#e3e8f2", # 100
|
||||
"#c9d2e6", # 200
|
||||
"#afbcda", # 300
|
||||
"#95a6ce", # 400
|
||||
"#7a90c2", # 500
|
||||
"#607ab6", # 600
|
||||
"#4664AA", # 700 (KIT blue)
|
||||
]
|
||||
|
||||
# Diverging KIT blue <-> KIT red, neutral gray midpoint.
|
||||
DIVERGING = {
|
||||
"low": "#4664AA",
|
||||
"mid": "#f7f7f7",
|
||||
"high": "#A22223",
|
||||
}
|
||||
|
||||
# Fixed, reserved status scale — deliberately NOT re-themed to KIT colors:
|
||||
# status is a small fixed scale with reserved meaning that must stay visually
|
||||
# distinct from the categorical slots so it never impersonates a series.
|
||||
# Never put these in the categorical cycle; always pair with an icon/label.
|
||||
STATUS = {
|
||||
"good": "#0ca30c",
|
||||
"warning": "#fab219",
|
||||
"serious": "#ec835a",
|
||||
"critical": "#d03b3b",
|
||||
}
|
||||
|
||||
# Chrome / ink roles, derived from KIT's specified "black 70%" (#404040,
|
||||
# used by KIT for headings and continuous text) on a white surface.
|
||||
INK = {
|
||||
"surface": "#ffffff",
|
||||
"primary": "#404040", # KIT black 70% — headings, titles, continuous text
|
||||
"secondary": "#6a6a6a",
|
||||
"muted": "#969696",
|
||||
"gridline": "#ececec",
|
||||
"baseline": "#c2c2c2",
|
||||
}
|
||||
|
||||
|
||||
def get_color(index: int) -> str:
|
||||
"""Return the categorical color for series `index` (0-based).
|
||||
|
||||
Raises ValueError past the validated slots instead of silently wrapping
|
||||
back to slot 0, which would collide two series on the same hue.
|
||||
"""
|
||||
if not 0 <= index < len(CATEGORICAL):
|
||||
raise ValueError(
|
||||
f"get_color({index}) out of range: only {len(CATEGORICAL)} validated categorical "
|
||||
"colors exist. Fold extra series into an 'Other' bucket or facet instead of cycling."
|
||||
)
|
||||
return CATEGORICAL[index]
|
||||
|
||||
|
||||
def sequential_cmap(name: str = "ps_sequential") -> LinearSegmentedColormap:
|
||||
"""Continuous KIT-blue sequential colormap for magnitude encoding."""
|
||||
return LinearSegmentedColormap.from_list(name, SEQUENTIAL_STEPS)
|
||||
|
||||
|
||||
def diverging_cmap(name: str = "ps_diverging") -> LinearSegmentedColormap:
|
||||
"""Continuous KIT blue-gray-red diverging colormap for polarity encoding."""
|
||||
return LinearSegmentedColormap.from_list(name, [DIVERGING["low"], DIVERGING["mid"], DIVERGING["high"]])
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Figure creation, colorbar, and saving helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Sequence, Union
|
||||
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.axes import Axes
|
||||
from matplotlib.colorbar import Colorbar
|
||||
from matplotlib.figure import Figure
|
||||
from mpl_toolkits.axes_grid1 import make_axes_locatable
|
||||
|
||||
from .colors import INK
|
||||
|
||||
FIGSIZES = {
|
||||
"thesis-single": (6.0, 4.0),
|
||||
"thesis-wide": (8.0, 4.5),
|
||||
"slide-16x9": (10.0, 5.625),
|
||||
"square": (5.0, 5.0),
|
||||
}
|
||||
|
||||
|
||||
def _style_ticks(ax: Axes) -> None:
|
||||
"""Major tick labels read as primary ("black") ink; minor as muted grey.
|
||||
|
||||
rcParams only expose a single labelcolor per axis (no major/minor split),
|
||||
so this distinction has to be applied per-Axes in code.
|
||||
"""
|
||||
ax.tick_params(axis="both", which="major", labelcolor=INK["primary"])
|
||||
ax.tick_params(axis="both", which="minor", labelcolor=INK["muted"])
|
||||
|
||||
|
||||
def _format_params(params: Mapping) -> str:
|
||||
return " | ".join(f"{key}: {value}" for key, value in params.items())
|
||||
|
||||
|
||||
def _set_figure_title(fig: Figure, title: Union[str, None], params: Union[Mapping, None]) -> None:
|
||||
# A single Text artist gets one color under matplotlib's usetex rendering
|
||||
# (dvipng rasterizes it as one greyscale mask, tinted uniformly — any
|
||||
# in-source \color/\textcolor is ignored), so the subtitle is set apart
|
||||
# from the title by size (\small) only, not color.
|
||||
lines = []
|
||||
if title is not None:
|
||||
lines.append(r"\textbf{" + title + "}")
|
||||
if params:
|
||||
lines.append(r"{\small " + _format_params(params) + "}")
|
||||
if lines:
|
||||
fig.suptitle("\n".join(lines), x=0.0, ha="left")
|
||||
|
||||
|
||||
def new_figure(
|
||||
preset: Union[str, tuple] = "thesis-single",
|
||||
*,
|
||||
title: str = None,
|
||||
params: Mapping = None,
|
||||
**subplots_kwargs,
|
||||
):
|
||||
"""Create a figure/axes pair sized for a named preset or an explicit (w, h) tuple.
|
||||
|
||||
Presets (inches): thesis-single, thesis-wide, slide-16x9, square.
|
||||
|
||||
`title` sets a left-aligned, bold figure-level title (`fig.suptitle`) —
|
||||
this is preferred over an axes title even when there's a single axes, so
|
||||
it stays consistent for single- and multi-panel figures alike. `params`
|
||||
is an optional dict rendered as a smaller, muted subtitle line below the
|
||||
title, formatted as "key1: value1 | key2: value2 | ...".
|
||||
"""
|
||||
if isinstance(preset, str):
|
||||
try:
|
||||
figsize = FIGSIZES[preset]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unknown figure preset {preset!r}. Choose from {sorted(FIGSIZES)} or pass an (w, h) tuple."
|
||||
) from exc
|
||||
else:
|
||||
figsize = preset
|
||||
|
||||
subplots_kwargs.setdefault("figsize", figsize)
|
||||
fig, axes = plt.subplots(**subplots_kwargs)
|
||||
|
||||
for ax in [axes] if isinstance(axes, Axes) else np.ravel(axes):
|
||||
_style_ticks(ax)
|
||||
|
||||
_set_figure_title(fig, title, params)
|
||||
|
||||
return fig, axes
|
||||
|
||||
|
||||
def colorbar(mappable, ax: Axes, size: str = "5%", pad: float = 0.05, **kwargs) -> Colorbar:
|
||||
"""Add a colorbar matched to `ax`'s actual on-screen size.
|
||||
|
||||
`fig.colorbar(mappable, ax=ax)` sizes the colorbar to the axes' nominal
|
||||
bounding box, which is taller than the axes once things like
|
||||
`ax.set_aspect("equal")` have visually shrunk it (e.g. a non-square
|
||||
`imshow`). This appends a same-size axes via `make_axes_locatable`
|
||||
instead, so the colorbar always matches what's actually drawn.
|
||||
"""
|
||||
divider = make_axes_locatable(ax)
|
||||
cax = divider.append_axes("right", size=size, pad=pad)
|
||||
cb = ax.get_figure().colorbar(mappable, cax=cax, **kwargs)
|
||||
# Colorbar draws its own border (a dedicated "outline" spine) that isn't
|
||||
# covered by axes.spines.{left,right,top} — without this it'd pick up
|
||||
# the bold black bottom-spine color/width from the main theme as a box
|
||||
# around the whole colorbar, which reads as a stray, heavier-than-intended
|
||||
# edge rather than the plain baseline it's styled to be elsewhere.
|
||||
cb.outline.set_visible(False)
|
||||
return cb
|
||||
|
||||
|
||||
def savefig(fig: Figure, path: Union[str, Path], formats: Sequence[str] = ("pdf",), dpi: int = 300) -> list[Path]:
|
||||
"""Save `fig` to `path` once per format, creating parent directories as needed.
|
||||
|
||||
`path` should have no extension — it's appended per format, e.g.
|
||||
savefig(fig, "plots/my_plot", formats=("pdf", "png")) writes
|
||||
plots/my_plot.pdf and plots/my_plot.png.
|
||||
"""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
written = []
|
||||
for fmt in formats:
|
||||
out_path = path.with_suffix(f".{fmt}")
|
||||
fig.savefig(out_path, format=fmt, dpi=dpi)
|
||||
written.append(out_path)
|
||||
return written
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Apply the plotstyle rcParams theme.
|
||||
|
||||
Text (titles, axis labels, legends, tick labels, annotations — the entirety
|
||||
of every string) is always rendered through a real LaTeX toolchain, using
|
||||
Latin Modern Sans as a modern sans-serif LaTeX font (including math mode, via
|
||||
`sfmath` — otherwise tick numbers fall back to a serif math font even with
|
||||
`\\familydefault` set to sans). `fontenc`'s T1 encoding is loaded too — without
|
||||
it, some plain ASCII characters (e.g. "|") render as the wrong glyph under
|
||||
OT1, LaTeX's default. This requires a working local `latex`/`dvipng` install;
|
||||
there is no mathtext fallback.
|
||||
|
||||
Note: matplotlib's usetex rendering rasterizes each Text artist as a single
|
||||
greyscale glyph mask via dvipng and then tints the *whole* thing with that
|
||||
artist's one `color` — any in-source `\\color`/`\\textcolor` command is
|
||||
ignored. So two differently-colored spans (e.g. a black title next to a grey
|
||||
subtitle) can't live in one Text object; `figures.new_figure()`'s title/params
|
||||
handling only varies font size (`\\small`) between lines, not color, for
|
||||
exactly this reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cycler import cycler
|
||||
from importlib import resources
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from .colors import CATEGORICAL
|
||||
|
||||
_LINESTYLES = ["-", "--", "-.", ":"]
|
||||
|
||||
_LATEX_PREAMBLE = (
|
||||
r"\usepackage[T1]{fontenc}\usepackage{amsmath}\usepackage{lmodern}\usepackage{sfmath}"
|
||||
r"\renewcommand{\familydefault}{\sfdefault}"
|
||||
)
|
||||
|
||||
|
||||
def use(cycle_linestyles: bool = False) -> None:
|
||||
"""Apply the plotstyle theme to matplotlib's global rcParams.
|
||||
|
||||
Call once at the top of a plotting script, before creating any figures.
|
||||
|
||||
By default `axes.prop_cycle` only cycles color (all lines solid) — pass
|
||||
`cycle_linestyles=True` to also cycle through a repeating linestyle
|
||||
sequence, so series stay distinguishable even if color is lost
|
||||
(grayscale printing, projector glare, color-vision deficiency).
|
||||
"""
|
||||
style_path = resources.files("plotstyle").joinpath("assets", "plotstyle.mplstyle")
|
||||
plt.style.use(str(style_path))
|
||||
|
||||
if cycle_linestyles:
|
||||
n = len(CATEGORICAL)
|
||||
linestyles = (_LINESTYLES * (n // len(_LINESTYLES) + 1))[:n]
|
||||
plt.rcParams["axes.prop_cycle"] = cycler(color=CATEGORICAL) + cycler(linestyle=linestyles)
|
||||
else:
|
||||
plt.rcParams["axes.prop_cycle"] = cycler(color=CATEGORICAL)
|
||||
|
||||
plt.rcParams["text.usetex"] = True
|
||||
plt.rcParams["text.latex.preamble"] = _LATEX_PREAMBLE
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Restore matplotlib defaults (useful between tests/notebook cells)."""
|
||||
plt.rcdefaults()
|
||||
Reference in New Issue
Block a user