Image- and bin-indexed plots have no meaningful x baseline, so the themed bottom spine kept by use() doesn't apply to them. Adds an explicit opt-in to disable all spines on such Axes, documents it in plotstyle/CLAUDE.md, and updates the showcase notebook's colormap and multi-panel examples to use it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 KiB
CLAUDE.md — plotstyle
Guidance for Claude Code (or any coding agent) writing or editing scripts that
use plotstyle. This package is a standalone matplotlib styling toolkit,
decoupled from the gallery/ package in this repo — gallery/ never imports
it. The connection between the two is a file on disk: plotstyle produces
PDF figures, and gallery (elsewhere in this repo) turns a directory of PDFs
into an HTML gallery. See the bottom of this file for that handoff.
What it is
A KIT (Karlsruhe Institute of Technology) corporate-design matplotlib theme plus a handful of building-block functions, so every figure produced for a thesis chapter or a talk slide looks consistent — validated color palette, consistent spines/ticks/grid, LaTeX text in a modern sans font, figure titles with a parameters subtitle, and legend/panel-label helpers.
Read examples/plotstyle_showcase.ipynb (repo root) for a fully rendered,
end-to-end tour — it's the fastest way to see what every function actually
produces. Everything below is the reference; the notebook is the demo.
Install
uv sync --extra plotting # this repo's uv workflow
# or
pip install -e ".[plotting]"
plotstyle is an optional extra (matplotlib>=3.7) so core gallery
installs stay lightweight — don't add matplotlib to gallery's own
unconditional dependencies to support this package.
Hard requirement: a working local LaTeX toolchain (latex + dvipng).
plotstyle.use() sets text.usetex = True unconditionally — there is no
mathtext fallback. If a script using plotstyle needs to run somewhere LaTeX
isn't installed, that's a real environment gap to flag, not something to
silently work around in plotstyle itself (that decision was made
deliberately across several iterations of this package — don't reintroduce a
fallback without being asked).
Quick start
import numpy as np
import plotstyle as ps
ps.use() # once, before creating any figure
fig, ax = ps.new_figure(
"thesis-single",
title="Measured signal",
params={"N": 512, "sigma": 1.2, "seed": 42},
)
ax.plot(np.linspace(0, 10, 200), np.sin(np.linspace(0, 10, 200)), label="signal")
ax.set_xlabel("Time (s)")
ax.set_ylabel(r"Amplitude $A(t)$")
ps.style_legend(ax, title="Series")
ps.savefig(fig, "plots/measured_signal", formats=("pdf",))
API reference
Everything is re-exported at the top level (import plotstyle as ps); the
submodule layout (style.py, colors.py, figures.py, annotations.py) is
an implementation detail, not part of the interface agents should reach into.
ps.use(cycle_linestyles: bool = False)
Applies the theme to matplotlib's global rcParams. Call this once, near
the top of the script, before creating any figure. Sets:
- Only the bottom spine visible, colored black and heavier than default
(
axes.edgecolor/axes.linewidth) — left/top/right spines off. - Ticks on left + bottom only; x-axis gets shorter minor ticks between the major ones (y-axis doesn't — its horizontal gridlines already mark position). Major tick labels read as dark ink, minor tick labels lighter grey.
- Horizontal-only, light-grey gridlines.
- Left-aligned axes titles (
axes.titlelocation: left). axes.prop_cycle= the 9 KIT categorical colors, in fixed order. Color only by default — passcycle_linestyles=Trueto also cycle linestyle (solid/dashed/dash-dot/dotted), which matters if a figure might be printed in grayscale or viewed by someone with color-vision deficiency.text.usetex = Truewith Latin Modern Sans (lmodern+sfmathso math-mode text is sans too, not just body text;fontencT1 so plain ASCII like|doesn't render as the wrong glyph under LaTeX's OT1 default).
ps.reset() restores matplotlib defaults (plt.rcdefaults()) — useful
between notebook cells or in tests, not normally needed in a script.
ps.new_figure(preset="thesis-single", *, title=None, params=None, **subplots_kwargs)
Thin wrapper over plt.subplots(). Returns (fig, ax) or (fig, axes)
exactly like plt.subplots() — **subplots_kwargs (nrows, ncols,
sharex, ...) pass straight through.
preset: one ofps.FIGSIZES—"thesis-single"(6×4"),"thesis-wide"(8×4.5"),"slide-16x9"(10×5.625"),"square"(5×5"). Or pass an explicit(w, h)tuple in inches to bypass the presets.title: sets a left-aligned, bold figure-level title viafig.suptitle. Prefer this overax.set_title()for a single-axes figure — it's the recommended, consistent way to title a plot in this codebase. Reserveax.set_title()for multi-axes figures, where each panel needs its own title and no single figure title could cover all of them (see the multi-panel example in the notebook).params: an optional dict rendered as a smaller subtitle line under the title:key1: value1 | key2: value2 | .... Good for recording the run parameters that produced a plot (params={"N": 512, "seed": 42}). Note it is not colored differently from the title (see "Known limitation" below) — only smaller.
ps.colorbar(mappable, ax, size="5%", pad=0.05, **kwargs)
Use this instead of fig.colorbar(im, ax=ax) whenever ax has
set_aspect("equal") (or anything else that visually shrinks it) — plain
fig.colorbar sizes to the axes' nominal bounding box and ends up taller
than what's actually drawn. This appends a matching-size axes via
mpl_toolkits.axes_grid1.make_axes_locatable and also turns off the
colorbar's own border (cb.outline.set_visible(False)), which otherwise
independently picks up the bold black spine styling as a stray box around
the colorbar.
ps.no_spines(ax)
Hides all four spines on ax (or every Axes in an array, e.g. from
new_figure(nrows=..., ncols=...)). use() keeps only the bottom spine
visible by default, since most plots have a meaningful x baseline — but
pixel/bin-indexed plots (imshow, pcolormesh, 2D histograms) don't have
one, so the themed bottom spine implies an axis origin that doesn't mean
anything there. Call this on the Axes for that kind of plot instead of
leaving the bottom spine on or hand-rolling
ax.spines[...].set_visible(False):
im = ax.imshow(image_data)
ps.no_spines(ax)
ps.colorbar(im, ax, label="Intensity")
ps.style_legend(ax, loc="outside right upper", frameon=False, title=None, **kwargs)
Builds a legend from ax's handles/labels but attaches it to the figure
(fig.legend(...)), so it always sits outside the axes rather than
overlapping data. Pass title= — strongly encouraged; omitting it prints a
UserWarning (the legend still renders, so this won't break a script, but
an agent generating new plots should always pass one).
ps.panel_label(ax, label, loc="lower right", fontweight="bold", box=True, **kwargs)
Adds a (a)/(b)/… label for multi-panel figures. Defaults to the
bottom-right corner (nudged up from the very edge so it clears the x-axis),
colored to match ax's xlabel, on a light-grey semi-transparent rounded box
with a slim solid border. Pass box=False for bare text. Don't hand-roll
this with ax.text(...) — use the helper so every panel label in a figure
(and across figures) looks the same.
ps.savefig(fig, path, formats=("pdf",), dpi=300)
Writes one file per format (path has no extension; each format is
appended). Default to formats=("pdf",) — see "Combining with
gallery" below for why PDF is what you almost always want here. Creates
parent directories automatically.
ps.get_color(i) / ps.colors
ps.get_color(i) indexes the 9-color categorical palette (0-based) and
raises ValueError past the last slot — never wrap/cycle back to 0
yourself past index 8; fold extra series into an "Other" bucket or facet
instead. Prefer relying on the default prop_cycle (i.e. just call
ax.plot(...) repeatedly without specifying color=) over calling
get_color() explicitly, unless you need a specific slot out of order (e.g.
matching a color used elsewhere in the same figure).
ps.colors also exposes, if you need direct access:
CATEGORICAL— the 9 hex strings, in order.sequential_cmap()— continuous KIT-blue colormap (light tint → brand blue) for magnitude/heatmap data.diverging_cmap()— KIT blue ↔ KIT red through a neutral grey midpoint, for signed data. Always pass symmetricvmin/vmaxaround the data's true zero when using it.STATUS— fixedgood/warning/serious/criticalcolors. Never put these in a categorical series cycle; only use them for actual good/bad-style status encoding, always paired with a label.INK— the grey/text roles (primary,secondary,muted,gridline,baseline,surface) the theme itself is built from.
Best practices (for agents writing or reviewing plot scripts)
- Call
ps.use()once, before any figure is created. Don't call it again mid-script unless deliberately togglingcycle_linestylesback and forth (rare — only useful when a notebook wants to show both modes). - Prefer
new_figure(title=..., params=...)overax.set_title()for any single-axes figure. Useax.set_title()only per-panel in multi-axes figures. - Never use literal
#or%in any text passed to matplotlib (titles, labels, legend entries, annotations) whileplotstyleis active — usetex is always on, and those are LaTeX special characters that will break rendering with aRuntimeErrorfromlatex. Rephrase instead of escaping where possible (e.g. a hex color used as a label should be spelled without its#; a percentage should read "42 percent" or use an escaped\%if you specifically need the glyph). - Don't hand-style spines/ticks/grid/legend/panel-labels manually —
that's what
use(),style_legend(), andpanel_label()are for. If an agent finds itself writingax.spines[...].set_visible(...)or similar in a script that already callsps.use(), that's very likely fighting the theme rather than working with it — stop and reconsider. The one sanctioned exception isps.no_spines(ax)onimshow/pcolormesh/2D histogram Axes, where the themed bottom spine implies a baseline that doesn't exist for pixel/bin data. - Default
savefig(..., formats=("pdf",)). Only add"png"/"svg"if there's a concrete reason (e.g. a quick raster preview outside the gallery pipeline) — seeingformats=("pdf", "png")in a new script is a signal to ask why, since the gallery already produces its own PNG thumbnails from the PDF. - This package has no test/CI dependency on a real LaTeX install being
absent — the test suite (
tests/test_plotstyle.py) assumes LaTeX is present (this repo's dev machine has it), and exercises real rendering rather than mocking it out. Don't add a mathtext-fallback code path to make tests pass in a hypothetical no-LaTeX CI without being asked; that would silently reintroduce the fallback behavior that was deliberately removed. - Known limitation, don't try to route around it: a figure title and
its
paramssubtitle can't have different colors (matplotlib's usetex rendering tints an entire Text artist with one color; any in-source\color/\textcoloris ignored). They're differentiated by size (\small) only. If asked to make the subtitle a different color, the real fix requires a second, independently-positioned Text artist with its own color — flag the added complexity rather than quietly reaching for\textcoloragain.
Combining with gallery: producing plots the gallery will display
plotstyle and gallery never share code or imports — the only connection
is that gallery recursively scans source directories (configured in
gallery's config.yaml, see repo-root CLAUDE.md) for PDF/HTML files plus
metadata.yaml/.yml/.json files, and turns them into a static site.
gallery does its own PDF→PNG conversion at a configured DPI — so a
plotstyle script only needs to produce the PDF; don't also generate a
PNG "for the gallery" (that's gallery's job, and a hand-made PNG would just
be redundant/inconsistent with the thumbnail gallery generates itself).
End-to-end workflow
-
Pick or create a source directory for the project's plots — this can be anywhere on disk, it does not need to live inside this repo (e.g.
~/experiments/run42/plots/). Subdirectories inside it become the gallery's folder hierarchy. -
Write the plotting script using
plotstyle, saving into that directory:import plotstyle as ps ps.use() fig, ax = ps.new_figure("thesis-single", title="Beam profile", params={"run": 42}) # ... plot ... ps.savefig(fig, "/home/user/experiments/run42/plots/beam_profile/x_projection", formats=("pdf",)) -
Add a
metadata.yamlin any folder of that source tree to annotate every plot within it (and its subfolders — metadata inherits downward, child keys override parent keys). Fields are freeform YAML — there's no fixed schema — but a few keys get special, prominent placement in the per-plot popup UI:title,description,plot_type,experiment. Everything else still displays, just under "Additional Information".# metadata.yaml title: "Run 42 — Beam Profile Measurements" description: "Transverse beam profiles at IP1, measured with the wire scanner." experiment: "Run 42" plot_type: "beam-profile" parameters: beam_energy: "6.5 TeV" bunch_intensity: "1.1e11" tags: - "beam-diagnostics" - "ip1"- Also accepts
.yml/.jsoninstead of.yaml. - Per-plot override: create
<plotname>.yamlnext to<plotname>.pdf(matching the PDF's stem) with just the keys to override for that one plot — it's merged on top of the inherited folder metadata. - Long text values (>100 chars), lists, and nested mappings all render sensibly in the UI automatically (truncated-with-"show more", tags, nested key/value blocks respectively) — no special formatting needed on the Python/YAML side.
- Metadata text values support inline LaTeX, rendered client-side via
MathJax, e.g.
formula: "$$E = mc^2$$"orluminosity: "35.9 fb^{-1}".
- Also accepts
-
Register the source in
gallery'sconfig.yamlif it isn't already there:sources: - name: "run42" path: "/home/user/experiments/run42/plots" -
Generate (or update) the gallery:
gallery generate --source /home/user/experiments/run42/plots --verboseIncremental:
galleryonly reconverts a PDF to PNG if the PDF is newer than the cached PNG (with a 30s buffer — see repo-rootCLAUDE.md), so re-running aplotstylescript that overwrites the same PDF path is exactly the intended update flow.