eeb483a4fa
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>
318 lines
15 KiB
Markdown
318 lines
15 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
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
|
||
|
||
```python
|
||
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 — pass `cycle_linestyles=True` to 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 = True` with Latin Modern Sans (`lmodern` + `sfmath` so
|
||
*math-mode* text is sans too, not just body text; `fontenc` T1 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 of `ps.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** via
|
||
`fig.suptitle`. **Prefer this over `ax.set_title()` for a single-axes
|
||
figure** — it's the recommended, consistent way to title a plot in this
|
||
codebase. Reserve `ax.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)`:
|
||
|
||
```python
|
||
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 symmetric `vmin`/`vmax` around the data's true
|
||
zero when using it.
|
||
- `STATUS` — fixed `good`/`warning`/`serious`/`critical` colors. **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)
|
||
|
||
1. **Call `ps.use()` once, before any figure is created.** Don't call it
|
||
again mid-script unless deliberately toggling `cycle_linestyles` back and
|
||
forth (rare — only useful when a notebook wants to show both modes).
|
||
2. **Prefer `new_figure(title=..., params=...)` over `ax.set_title()`** for
|
||
any single-axes figure. Use `ax.set_title()` only per-panel in multi-axes
|
||
figures.
|
||
3. **Never use literal `#` or `%` in any text passed to matplotlib** (titles,
|
||
labels, legend entries, annotations) while `plotstyle` is active — usetex
|
||
is always on, and those are LaTeX special characters that will break
|
||
rendering with a `RuntimeError` from `latex`. 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).
|
||
4. **Don't hand-style spines/ticks/grid/legend/panel-labels manually** —
|
||
that's what `use()`, `style_legend()`, and `panel_label()` are for. If an
|
||
agent finds itself writing `ax.spines[...].set_visible(...)` or similar
|
||
in a script that already calls `ps.use()`, that's very likely fighting
|
||
the theme rather than working with it — stop and reconsider. The one
|
||
sanctioned exception is `ps.no_spines(ax)` on `imshow`/`pcolormesh`/2D
|
||
histogram Axes, where the themed bottom spine implies a baseline that
|
||
doesn't exist for pixel/bin data.
|
||
5. **Default `savefig(..., formats=("pdf",))`.** Only add `"png"`/`"svg"` if
|
||
there's a concrete reason (e.g. a quick raster preview outside the
|
||
gallery pipeline) — seeing `formats=("pdf", "png")` in a new script is a
|
||
signal to ask why, since the gallery already produces its own PNG
|
||
thumbnails from the PDF.
|
||
6. **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.
|
||
7. **Known limitation, don't try to route around it:** a figure title and
|
||
its `params` subtitle can't have different colors (matplotlib's usetex
|
||
rendering tints an entire Text artist with one color; any in-source
|
||
`\color`/`\textcolor` is 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
|
||
`\textcolor` again.
|
||
|
||
## 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
|
||
|
||
1. **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.
|
||
|
||
2. **Write the plotting script using `plotstyle`**, saving into that
|
||
directory:
|
||
|
||
```python
|
||
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",))
|
||
```
|
||
|
||
3. **Add a `metadata.yaml`** in 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".
|
||
|
||
```yaml
|
||
# 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`/`.json` instead of `.yaml`.
|
||
- **Per-plot override**: create `<plotname>.yaml` next 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$$"` or
|
||
`luminosity: "35.9 fb^{-1}"`.
|
||
|
||
4. **Register the source** in `gallery`'s `config.yaml` if it isn't already
|
||
there:
|
||
|
||
```yaml
|
||
sources:
|
||
- name: "run42"
|
||
path: "/home/user/experiments/run42/plots"
|
||
```
|
||
|
||
5. **Generate (or update) the gallery**:
|
||
|
||
```bash
|
||
gallery generate --source /home/user/experiments/run42/plots --verbose
|
||
```
|
||
|
||
Incremental: `gallery` only reconverts a PDF to PNG if the PDF is newer
|
||
than the cached PNG (with a 30s buffer — see repo-root `CLAUDE.md`), so
|
||
re-running a `plotstyle` script that overwrites the same PDF path is
|
||
exactly the intended update flow.
|