figure-style
Correctness and legibility checklist for publication figures, plus a matplotlib sidecar. Load before plotting anything and call `apply_figure_style()` (role-mapped font ladder, outward ticks, frameless legends, 300-dpi saves, CJK-safe fonts). Covers data fidelity, label budgets,
Install
npx skills add https://github.com/xuzhougeng/wisp-science/tree/main/skills/figure-style
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install xuzhougeng-wisp-science@llmmart
git clone https://github.com/xuzhougeng/wisp-science.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole xuzhougeng/wisp-science collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Figure correctness checklist
This skill makes one plot trustworthy and readable. It deliberately has no
house aesthetic — frame, font family, and sizes are all parameters of
apply_figure_style(), which must run before the first plotting call.
Multi-panel assembly is figure-composer's job; deciding what each figure in
a paper should argue is paper-narrative's.
Loading the Python helpers only defines them; it does not select a matplotlib
backend or apply a style. Call apply_figure_style(...) explicitly for plotting.
The optional figure_style_self_check() applies the defaults and checks font
wiring when called; it is not a load-time check.
Two tiers of rule live below. Hard rules — everything under Tell the truth, Never do, and Prove the render, plus any rule stating a perceptual or factual invariant (semantic-zero centring, colour-vision safety, leader-line anchoring) — apply to every plot with no override. Everything else is a default: deviate when you have a deliberate reason, not by accident.
Tell the truth about the data
- Excluded means excluded. A row the source data marks excluded either disappears from the plot or appears as a clearly distinct open/hatched marker named in the key — and it never contaminates a summary statistic drawn next to included rows.
- Peers must be comparable. Arms measured under different N, budget, initialisation, or protocol don't sit side by side as if equivalent. Facet them apart or mark the label, and state the difference once in the caption.
- The figure can't contradict itself. Before saving, trace every categorical label, threshold, and title back to the rule that defines it and check each plotted row satisfies it. A row that contradicts its label means the figure is wrong.
- A sentence-title is a claim — test it. Check the claim against every category on the axis. One counterexample means qualifying ("on 3 of 4 pairs") or demoting the title to a description.
- State n and what's held fixed. Any summary mark comes with
nand the unit of replication; any small-multiple that fixes a variable names the fixed value — in-panel, or in the caption when the label budget is tight. - Context structure comes from references. A tree, ordering, or topology drawn as background (scale bar, category strip) uses an established reference. Infer it from the plotted data only when the structure is itself the finding.
- One claim, one number. Each quantitative claim (accuracy, runtime, count) has a single canonical value reused identically in every panel, caption, and the abstract — with a definition of what it measures.
Say less, and say it in the right place
The panel shows the pattern; the caption carries the context. Design for a general scientific reader, not for yourself.
- Floor. Every visually distinct mark must be identifiable from the figure alone. Deleting a label may only ever leave the reader asking "why is that there?" — never "what is that?". Comparators are named for what they are ("no joint training", "prior method"), not a role word ("baseline"). Gloss any term a general scientist can't parse.
- Ceiling. Per panel: title, axis labels, ticks, series identity (labelled once per row of small multiples), and at most 2–3 narrative annotations. More than ~6 strings beyond axes/ticks means over budget. Identity labels are floor, not budget.
- Caption material: n=, held-fixed values, abbreviation expansions, exclusion rationale, non-comparability footnotes, methods caveats.
- Titles state takeaways. "Robust to gene dropout" works; "Fewer genes" doesn't — read it aloud and if a listener would ask "fewer genes what?", rewrite. A row of small multiples varying one thing gets one row header, not per-panel titles.
- Numbers on marks: headline only. Print the one value a reader would quote; the axis serves the rest.
- Tie-break: delete and re-read. If the message survives without the label, the label stays gone.
Axes and scales
- Limits clear the data by at least a marker radius on every side —
ax.margins(0.04)— and no mark or text touches a spine. - Data using under 40% of an axis calls for a break or a data-floor start with an explicit non-zero tick. Nothing may be drawn inside a break gap: the gap has no coordinates.
- Log ticks read as
10²/1k/10k/100k, never raw exponents. Filled bars on a log value axis are banned outright — bar length would encode the ratio to an arbitrary floor. Points with a median tick replace them. - In a row/column of small multiples, tick labels appear once (leftmost or
bottommost); interior panels keep tick marks only. Panels sharing y and
differing only in x abut (
wspace≤0.06) under one row header. - A panel's data envelope fills ≥75% of its rectangle; dead bands mean reshaping the grid, not padding the panel.
- When better-is-up/down isn't obvious from the axis label, put an upright
"higher = better" cue in the margin — once per row, never per panel, never
caption-only, and never rotated with rotated text (
goodness_arrow). - The full-width figure must fit the venue's double-column width at 300 dpi, and adding a schematic or label never squeezes the data panels narrower.
Colour
- A colour is a binding. Once an entity gets a colour, every mark for that entity — line, fill, marker, text, heatmap row — reuses it exactly. Colour is the cross-reference; nobody should read a legend twice.
- Few hues, one dominant. Use the minimum hue count. A focal series is
saturated and heavy; comparators desaturate and thin (
focal_palette). The focal hue may not collide with any categorical palette in the same figure, and the focal series must stay identifiable even at zero width or full overlap — outline, marker, or tinted band. - Nested categories: outer level chooses the hue family, inner level samples within it.
- Continuous data: perceptually uniform sequential map; single-hue ramp for rank/size; diverging map for signed values, centred on the semantic zero (0, 1.0, median) — never the data midpoint.
- Colour-vision safety. No red/green binary. Every binary pair survives a deuteranopia simulation. One alarm hue is reserved for error/anomaly/perturbation and never doubles as a series colour.
- Two palettes ⇒ two legends, each adjacent to the first panel using its palette.
Type
- Panel titles are plain-language sentences, regular weight, left-aligned; metric names live on the axis.
- Three sizes, mapped to roles: base for titles/axis labels/series
identity, one step down for legends/annotations, one more for ticks
(
apply_figure_style(sizes=(8,7,6))). Panel letters alone break the rule (bold, larger). A label that doesn't fit gets a layout fix or a shorter string, never a fourth size. - Species, genes, and variables that convention italicises are italicised; abbreviations inherit the style and expand once on first use.
- Large numbers wear magnitude suffixes —
4.2B,120 kb— not comma grouping. - On-mark values: ≤2 significant figures, unless rounding would collapse two distinct rows, in which case show the separating digit. Text on a fill needs 4.5:1 contrast or it moves outside the mark.
- No codebase identifiers as labels: readable name first, code in parentheses or the caption.
- Panel letters: bold, top-left, outside the axes box; case per venue
(
panel_letter(ax, 'a', case=...)).
Match the chart to the data
- Category × number: show the distribution. Small n → jittered strip
with median tick (
strip_with_median); large n → box/violin; mean-as- message → bar with raw points or interval (bar_with_points), not both.errorbar='ci95'is the t-interval, valid at small n. A missing category is markedn.d./—/hatched ghost — an empty slot reads as zero — and a true zero gets a visible stub. - One observation per category: lollipop (dot plus thin stem to the semantic zero), value beside the dot.
- Series over a continuum: mean line with markers, raw runs as thin
translucent traces behind it, series named by text at the line's right end
(
end_of_line_labels) rather than a legend box. Per-bin summary glyphs are unmistakable-for-raw, identical across series, and drawn under the raw points. - Overlapping distributions: stacked panels with shared x, or a ridgeline; overlay only when separation is obvious.
- Matrices: under ~200 cells, print every value; state the threshold in the colourbar label.
- Embedding scatters (UMAP/t-SNE/PCA): no ticks or tick labels, a corner arrow pair for axes, clusters labelled by thin leaders into whitespace.
- Prediction vs. observation: adjacent tracks, identical x and colours, alignment carries the comparison; target regions as translucent spans in the legend.
- Insets connect visibly to their source region: box plus connectors, or a wedge.
- Named-point scatters direct-label at least max, min, and every flagged point via thin leaders — and after rendering, confirm each leader ends within a marker radius of its row.
Composition
- Show what is being measured before the result — plain title, labelled schematic, or panel order — and any schematic reuses the exact words and glyphs of the data panels.
- A multi-panel figure exists to make one sentence true. Panels that neither state, support, nor bound that sentence move to the supplement.
- Legends are frameless, sit in natural whitespace or become direct labels, read swatch-first left-aligned, and resolve every distinct glyph.
- Grouped small multiples take one spanning header per group, not repeated titles.
- Across a paper, Figure 1 renders the pitch as data (scope, not
architecture); later figures carry mechanism, evidence, robustness,
application. Panels are judged against the paper's pitch and move between
figures when the story requires (
paper-narrativeruns that review). - Between revision rounds, a passing panel is left alone — decorating a clean panel is a regression.
Never do
Each of these is a correctness failure:
- red vs. green as an opposing pair;
- filled bars on a log value axis;
- a diverging map centred on the data midpoint, or a colourbar whose ticks skip the semantic centre;
- an axis title that repeats the tick labels;
- direction-of-goodness explained only in the caption;
- a "reference" line at a value that is one of the plotted points;
- an excluded row inside a plotted summary;
- a leader line whose nearest mark is not its target.
Prove the render
Run both checks after fig.savefig(...) and before presenting the file.
1. Collision scan. Assert no visible text box overlaps another or a
spine (a tick label touching its own spine doesn't count), and every text
box sits inside fig.bbox:
rend = fig.canvas.get_renderer()
labels = [(t, t.get_window_extent(rend)) for t in fig.findobj(mpl.text.Text)
if t.get_text().strip() and t.get_visible()]
frames = [(s, s.get_window_extent(rend)) for ax in fig.axes
for s in ax.spines.values() if s.get_visible()]
own_ticks = {ax: set(ax.get_xticklabels(which='both') + ax.get_yticklabels(which='both'))
for ax in fig.axes}
hits = [(a, b) for i, (a, ba) in enumerate(labels)
for b, bb in labels[i+1:] if ba.overlaps(bb)]
hits += [(t, s) for t, bt in labels for s, bs in frames
if bt.overlaps(bs) and t not in own_ticks[s.axes]]
assert not hits
Move, shorten, or stagger until the scan is clean, re-saving each time.
2. Visual pass. Geometry can't see a low-contrast label, crossing
leaders, or two confusable series colours. Crop each panel to its own file
and inspect every crop with Wisp's view_image tool:
fig.savefig("figure.png")
save_panel_crops("figure.png", panel_crops(fig)) # → .cache/figure-style/
Leave Python, then view_image each returned path asking: every glyph
legible against its background? smallest element still has a stroke or stub?
leaders uncrossed? any two series colours confusable? legend beside what it
keys? A visual defect that passed the collision scan is still a defect.
Crops are not products. They are throwaway inspection files and never
go in the figures/output directory, not even in a subfolder of it — only
the figure itself is delivered. save_panel_crops keeps them in
.cache/figure-style/ and wipes that directory on every call; if you crop
by hand, write to the same place. Delete it once the figure passes
(shutil.rmtree(".cache/figure-style", ignore_errors=True)), and never
report a crop as an output.
3. R output. Prefer explicit ggsave(filename, plot = p, dpi = 300, bg = "white", ...) over the active device; for base graphics open
png(..., bg = "white", res = 300), draw, and always dev.off(). Then
assert the file exists and is non-empty and inspect it — a "successful" R
call with a missing, zero-byte, or blank file is a failed render.
Defaults when unsure: fewer hues, direct labels over legends, raw data over summaries, and name the measurement before showing its result.
Files (wisp-science)
-
runtime.py 19.1 KB
"""Sidecar helpers for the figure-style skill. Definition-only module: importing it must not touch the network, filesystem (beyond font probing at call time), or draw anything. Heavy imports live inside function bodies so the kernel loads instantly. Public API (names are stable — other skills reference them): apply_figure_style, set_frame, panel_letter, focal_palette, bar_with_points, strip_with_median, goodness_arrow, two_tier_label, end_of_line_labels, panel_crops, save_panel_crops """ META_GREY = "#888888" # CJK-capable fonts each OS ships with, probed by file path first (so the # exact file gets registered with matplotlib) and by family name second. # Without this, Chinese/Japanese/Korean labels fall back to DejaVu Sans and # render as tofu boxes (□□□). _CJK_CANDIDATES = { "Windows": [ ("Microsoft YaHei", r"C:\Windows\Fonts\msyh.ttc"), ("Microsoft YaHei", r"C:\Windows\Fonts\msyh.ttf"), ("SimHei", r"C:\Windows\Fonts\simhei.ttf"), ("SimSun", r"C:\Windows\Fonts\simsun.ttc"), ], "Darwin": [ ("PingFang SC", "/System/Library/Fonts/PingFang.ttc"), ("Hiragino Sans GB", "/System/Library/Fonts/Hiragino Sans GB.ttc"), ("STHeiti", "/System/Library/Fonts/STHeiti Medium.ttc"), ], "Linux": [ ("Noto Sans CJK SC", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), ("Noto Sans CJK SC", "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf"), ("WenQuanYi Zen Hei", "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"), ("Source Han Sans SC", "/usr/share/fonts/opentype/source-han-sans/SourceHanSansSC-Regular.otf"), ], } def _find_cjk_font(): """Return a CJK-capable family name registered with matplotlib, or None. Probes the current OS's known font files first (registering the file so the family becomes usable), then any CJK family the font manager already knows. Uses whatever the machine has — nothing is bundled. """ import os import platform import matplotlib.font_manager as fm for family, path in _CJK_CANDIDATES.get(platform.system(), []): if os.path.isfile(path): try: fm.fontManager.addfont(path) return family except Exception: continue known = {f.name for f in fm.fontManager.ttflist} for family in ("Microsoft YaHei", "SimHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Zen Hei", "Arial Unicode MS"): if family in known: return family return None def _register_conda_fonts(): """Fonts installed via conda (e.g. mscorefonts) land in $CONDA_PREFIX/fonts, which matplotlib never scans — register them so `font=` requests resolve.""" import glob import os import sys import matplotlib.font_manager as fm fdir = os.path.join(os.environ.get("CONDA_PREFIX") or sys.prefix, "fonts") if not os.path.isdir(fdir): return known = {f.fname for f in fm.fontManager.ttflist} for f in glob.glob(os.path.join(fdir, "*.ttf")): if f not in known: fm.fontManager.addfont(f) # ---------------------------------------------------------------- style setup def apply_figure_style(*, frame="open", font=None, sizes=(8, 7, 6), grid=False): """Install publication-output rcParams. Call once, before any plotting. What this sets is mechanics, not a house look: a three-step font-size ladder mapped to text roles, outward ticks, frameless legends, left-flush regular-weight titles, 300-dpi tight saves, and Type-42 (editable) fonts in vector output. Frame shape, family, and the ladder are parameters. frame : 'open' → bottom+left spines only (default); 'boxed' → all four; 'none' → no spines, no tick marks font : preferred sans-serif family; None keeps the platform default sizes : (base, secondary, tick) point sizes — base covers titles, axis labels and series identity; secondary covers legends/annotations; tick covers tick labels grid : draw axes.grid when True """ import matplotlib as mpl if frame not in ("open", "boxed", "none"): raise ValueError(f"frame must be 'open'|'boxed'|'none', got {frame!r}") try: _register_conda_fonts() except Exception: pass base, secondary, tick = sizes boxed = (frame == "boxed") spined = (frame != "none") rc = { "font.family": "sans-serif", "font.size": base, "axes.titlesize": base, "axes.labelsize": base, "legend.fontsize": secondary, "xtick.labelsize": tick, "ytick.labelsize": tick, "axes.titleweight": "normal", "axes.titlelocation": "left", "axes.labelweight": "normal", "axes.linewidth": 0.6, "axes.spines.top": boxed, "axes.spines.right": boxed, "axes.spines.bottom": spined, "axes.spines.left": spined, "axes.grid": bool(grid), "xtick.direction": "out", "ytick.direction": "out", "xtick.major.size": 3, "ytick.major.size": 3, "xtick.major.width": 0.6, "ytick.major.width": 0.6, "legend.frameon": False, "lines.linewidth": 1.2, "patch.linewidth": 0.6, "figure.dpi": 200, "savefig.dpi": 300, "savefig.bbox": "tight", "pdf.fonttype": 42, "ps.fonttype": 42, } # Sans-serif fallback chain: explicit request first, then a CJK family # (when the OS has one) so non-Latin labels never render as boxes, then # the usual Latin families. Putting CJK ahead of DejaVu matters on older # matplotlib versions that lack per-glyph fallback. chain = [font] if font else [] cjk = _find_cjk_font() if cjk and cjk not in chain: chain.append(cjk) chain += ["DejaVu Sans", "Liberation Sans", "Arial"] rc["font.sans-serif"] = chain rc["axes.unicode_minus"] = False # a boxed minus sign is still tofu mpl.rcParams.update(rc) def set_frame(ax, style="open"): """Re-apply a frame shape to one existing axes. style ∈ {'open','boxed','none'}.""" visible = { "open": {"bottom": True, "left": True, "top": False, "right": False}, "boxed": dict.fromkeys(("top", "right", "bottom", "left"), True), "none": dict.fromkeys(("top", "right", "bottom", "left"), False), }[style] for side, vis in visible.items(): ax.spines[side].set_visible(vis) if vis: ax.spines[side].set_linewidth(0.6) ax.tick_params(direction="out", length=0 if style == "none" else 3, width=0.6) # ------------------------------------------------------------------ palettes def _desaturate(color, keep=0.3): """Pull a colour toward its own grey value, keeping `keep` of the hue.""" import matplotlib.colors as mcolors r, g, b = mcolors.to_rgb(color) grey = (r + g + b) / 3 return mcolors.to_hex(tuple(keep * c + (1 - keep) * grey for c in (r, g, b))) def focal_palette(labels, focal, focal_color, other="muted", base_colors=None): """Colour list where the focal series dominates and the rest recede. labels : ordered category labels focal : one label or an iterable of labels to emphasise focal_color : the colour the focal series gets other : how non-focal entries are drawn — 'muted' desaturated versions of base_colors (default) 'grey' one uniform light grey 'ordinal' a light→dark grey ramp in input order base_colors : cycle to mute for 'muted'; defaults to the active prop cycle """ import matplotlib.colors as mcolors import matplotlib.pyplot as plt focal_set = {focal} if isinstance(focal, str) else set(focal) if not focal_set & set(labels): raise ValueError(f"focal {focal!r} not found in labels") n = len(labels) if base_colors is None: base_colors = plt.rcParams["axes.prop_cycle"].by_key().get("color", ["#444444"]) base_colors = [base_colors[i % len(base_colors)] for i in range(n)] if other == "grey": rest = ["#BCBCBC"] * n elif other == "ordinal": n_rest = max(1, n - len(focal_set)) levels = ([0.55] if n_rest == 1 else [0.80 - 0.35 * i / (n_rest - 1) for i in range(n_rest)]) ramp = [mcolors.to_hex((v, v, v)) for v in levels] rest, k = [], 0 for lab in labels: rest.append(ramp[min(k, n_rest - 1)]) k += lab not in focal_set else: # 'muted' rest = [_desaturate(c) for c in base_colors] return [focal_color if lab in focal_set else rest[i] for i, lab in enumerate(labels)] # ------------------------------------------------------------- chart builders def _ci95_halfwidth(values): """t-based 95% CI half-width of the mean — valid at small n, where the z shortcut 1.96·s/√n is noticeably too narrow.""" import numpy as np from scipy.stats import t values = np.asarray(values) n = values.size if n < 2: return 0.0 return t.ppf(0.975, n - 1) * np.std(values, ddof=1) / np.sqrt(n) def bar_with_points(ax, x, ymat, labels, colors, jitter=0.08, show_points=True, errorbar=None, point_alpha=0.5, point_size=8): """Mean bars with either raw-point overlay or an error interval (not both). x : bar positions ymat : per-category arrays of raw observations labels : tick labels, aligned with x colors : per-category colours (e.g. from focal_palette) errorbar : None | 'sd' | 'ci95', drawn only when show_points is False; 'ci95' uses the t-distribution interval (see _ci95_halfwidth) """ import numpy as np means = np.array([np.mean(y) for y in ymat], float) err = None if errorbar and not show_points: err = np.array([ (np.std(y, ddof=1) if np.asarray(y).size > 1 else 0.0) if errorbar == "sd" else _ci95_halfwidth(y) for y in ymat ]) ax.bar(x, means, color=colors, width=0.7, edgecolor="none", yerr=err, error_kw={"elinewidth": 0.8, "capsize": 0}) if show_points: for xi, ys in zip(x, ymat): ys = np.asarray(ys) if ys.ndim and ys.size > 1: jit = (np.random.rand(ys.size) - 0.5) * 2 * jitter ax.scatter(np.full(ys.size, xi) + jit, ys, s=point_size, color="black", alpha=point_alpha, zorder=3, linewidths=0) ax.set_xticks(x) ax.set_xticklabels(labels) return ax def strip_with_median(ax, groups, values, colors=None, jitter=0.12): """Jittered raw observations per group, each with a bold median tick.""" import numpy as np labs = list(groups) colors = colors or ["#444444"] * len(labs) for i, (ys, c) in enumerate(zip(values, colors)): ys = np.asarray(ys) jit = (np.random.rand(ys.size) - 0.5) * 2 * jitter ax.scatter(np.full(ys.size, i) + jit, ys, s=10, color=c, alpha=0.6, linewidths=0, zorder=2) med = np.median(ys) ax.plot([i - 0.22, i + 0.22], [med, med], color="black", lw=1.6, zorder=3) ax.set_xticks(range(len(labs))) ax.set_xticklabels(labs) return ax # ------------------------------------------------------- annotation helpers def panel_letter(ax, letter, dx=-0.18, dy=1.02, case="lower", fontsize=None): """Bold panel letter outside the axes' top-left corner. case follows the target venue ('lower' or 'upper'). Size defaults to one step above the base of the font ladder — the single sanctioned exception to the three-size rule. """ import matplotlib.pyplot as plt if fontsize is None: fontsize = plt.rcParams.get("font.size", 8) + 1 s = letter.lower() if case == "lower" else letter.upper() ax.text(dx, dy, s, transform=ax.transAxes, fontweight="bold", fontsize=fontsize, va="bottom", ha="left") def goodness_arrow(ax, text="higher = better", loc="upper left", axis="y", fontsize=None): """Small upright direction-of-goodness cue placed in the axes margin.""" import matplotlib.pyplot as plt if fontsize is None: fontsize = plt.rcParams["legend.fontsize"] # annotation role pos = {"upper left": (0.02, 0.98), "upper right": (0.98, 0.98), "lower left": (0.02, 0.02), "lower right": (0.98, 0.02)}[loc] ax.text(*pos, ("↑ " if axis == "y" else "→ ") + text, transform=ax.transAxes, fontsize=fontsize, color=META_GREY, ha="left" if "left" in loc else "right", va="top" if "upper" in loc else "bottom") def two_tier_label(name, meta): """Two-line label (name over metadata); the caller styles the meta line.""" return f"{name}\n{meta}" def end_of_line_labels(ax, xs, ys, labels, colors=None, dx=0.01, fontsize=None): """Direct-label each line series just past its right endpoint (in place of a legend box).""" import matplotlib.pyplot as plt if fontsize is None: fontsize = plt.rcParams["font.size"] # series-identity role colors = colors or [None] * len(labels) span = ax.get_xlim()[1] - ax.get_xlim()[0] for x, y, lab, c in zip(xs, ys, labels, colors): ax.text(x[-1] + dx * span, y[-1], lab, color=c, va="center", ha="left", fontsize=fontsize) # ---------------------------------------------------------------- QA helpers def _saved_frame(fig, renderer, bbox_inches, pad_inches): """Origin and size, in inches, of the frame savefig will actually write.""" import matplotlib as mpl if bbox_inches == "tight": if pad_inches is None: pad_inches = mpl.rcParams.get("savefig.pad_inches", 0.1) tb = fig.get_tightbbox(renderer).padded(pad_inches) return tb.x0, tb.y0, tb.width, tb.height if isinstance(bbox_inches, mpl.transforms.BboxBase): return bbox_inches.x0, bbox_inches.y0, bbox_inches.width, bbox_inches.height w, h = fig.get_size_inches() return 0.0, 0.0, w, h def _lettered_axes(fig): """Map axes → panel letter, detected as the bold single-character Text that panel_letter() places. Falls back to index keys when nothing is lettered (standalone plots, or composer sub-agents told not to letter), so the QA crop loop always has something to iterate.""" import matplotlib.text found = {} for ax in fig.axes: for t in ax.findobj(matplotlib.text.Text): s = (t.get_text() or "").strip() if len(s) == 1 and s.isalpha() and t.get_fontweight() in ("bold", 700): found[ax] = s break return found or {ax: str(i) for i, ax in enumerate(fig.axes)} def panel_crops(fig, dpi=None, pad_px=6, bbox_inches=None, pad_inches=None): """Per-panel pixel crop boxes for the figure as saved to PNG. Returns ``{letter: (x0, y0, x1, y1)}`` with a top-left pixel origin, i.e. directly usable as ``PIL.Image.crop(box)``. Each panel is its axes' tight bbox mapped into the saved file's pixel grid and padded by ``pad_px``. A composite panel — abutting subplots that share an axis with the letter drawn only on the leftmost — is unioned with its letterless ``sharex``/``sharey`` siblings on the same grid row or column (and only those: ``subplots(sharey=True)`` joins the whole grid transitively, which must not merge distinct panels). ``bbox_inches`` mirrors ``Figure.savefig``: ``None`` consults rcParams (under :func:`apply_figure_style` that resolves to ``'tight'``); pass an explicit ``Bbox`` only if you saved with one. Boxes are clamped to the saved image regardless. >>> fig.savefig("fig.png") >>> from PIL import Image >>> for letter, box in panel_crops(fig).items(): ... Image.open("fig.png").crop(box).save(f"fig-{letter}.png") """ import matplotlib as mpl if dpi is None: dpi = mpl.rcParams.get("savefig.dpi", fig.dpi) if dpi == "figure": dpi = fig.dpi dpi = float(dpi) if bbox_inches is None: bbox_inches = mpl.rcParams.get("savefig.bbox") fig.canvas.draw() r = fig.canvas.get_renderer() ox_in, oy_in, w_in, h_in = _saved_frame(fig, r, bbox_inches, pad_inches) w_px, h_px = int(round(w_in * dpi)), int(round(h_in * dpi)) lettered = _lettered_axes(fig) out = {} for ax, letter in lettered.items(): boxes = [ax.get_tightbbox(r)] # display px at fig.dpi ss = ax.get_subplotspec() for sib in fig.axes: if sib is ax or sib in lettered: continue ssib = sib.get_subplotspec() same_row = ss is None or ssib is None or ss.rowspan == ssib.rowspan same_col = ss is None or ssib is None or ss.colspan == ssib.colspan if ((ax.get_shared_y_axes().joined(ax, sib) and same_row) or (ax.get_shared_x_axes().joined(ax, sib) and same_col)): boxes.append(sib.get_tightbbox(r)) bb = mpl.transforms.Bbox.union(boxes) # display px → inches → saved-frame inches → saved px, y flipped to # image convention x0 = (bb.x0 / fig.dpi - ox_in) * dpi x1 = (bb.x1 / fig.dpi - ox_in) * dpi y0 = h_px - (bb.y1 / fig.dpi - oy_in) * dpi y1 = h_px - (bb.y0 / fig.dpi - oy_in) * dpi out[letter] = ( max(int(x0) - pad_px, 0), max(int(y0) - pad_px, 0), min(int(x1) + pad_px, w_px), min(int(y1) + pad_px, h_px), ) return out def save_panel_crops(png_path, boxes, out_dir=".cache/figure-style"): """Write the QA crops to scratch and return ``{letter: path}``. A crop exists to be looked at once with ``view_image``; it is never a deliverable, so it must not land beside the figure — crops written into the figures directory leave nobody able to tell products from inspection debris. They go under ``.cache/`` instead (pdf-explore's convention for page renders), and the directory is emptied on every call so one QA pass never piles onto the last. >>> fig.savefig("figure.png") >>> save_panel_crops("figure.png", panel_crops(fig)) {'a': '.cache/figure-style/a.png', 'b': '.cache/figure-style/b.png'} """ import os import shutil from PIL import Image shutil.rmtree(out_dir, ignore_errors=True) os.makedirs(out_dir, exist_ok=True) paths = {} # Context-managed: on Windows an open handle on the PNG blocks re-saving # the figure after a fix. with Image.open(png_path) as image: for letter, box in boxes.items(): path = os.path.join(out_dir, f"{letter}.png") image.crop(box).save(path) paths[letter] = path return paths def figure_style_self_check(): """Explicitly apply defaults and check font wiring; never run on loading. Wisp executes sidecars in the REPL's __main__ namespace, so a conventional __main__ guard would run during helper loading as well as direct execution. """ import matplotlib apply_figure_style() sans = matplotlib.rcParams["font.sans-serif"] assert sans, "font.sans-serif must not be empty" assert "DejaVu Sans" in sans, "DejaVu Sans should remain a Latin fallback" assert matplotlib.rcParams["axes.unicode_minus"] is False return {"font.sans-serif": list(sans), "axes.unicode_minus": False} -
SKILL.md 13.8 KB
--- name: figure-style description: "Correctness and legibility checklist for publication figures, plus a matplotlib sidecar. Load before plotting anything and call `apply_figure_style()` (role-mapped font ladder, outward ticks, frameless legends, 300-dpi saves, CJK-safe fonts). Covers data fidelity, label budgets, axis/colour/type rules, chart choice by data shape, composition, and a mandatory render-then-inspect QA pass (bbox collisions + per-panel visual crops). Helpers: focal_palette, bar_with_points, strip_with_median, end_of_line_labels, panel_letter, set_frame, panel_crops, save_panel_crops (QA crops go to .cache/, never into the output figures directory). Multi-panel assembly lives in figure-composer; whole-paper figure ordering in paper-narrative." license: Apache-2.0 --- # Figure correctness checklist This skill makes one plot trustworthy and readable. It deliberately has no house aesthetic — frame, font family, and sizes are all parameters of `apply_figure_style()`, which must run before the first plotting call. Multi-panel assembly is `figure-composer`'s job; deciding what each figure in a paper should argue is `paper-narrative`'s. Loading the Python helpers only defines them; it does not select a matplotlib backend or apply a style. Call `apply_figure_style(...)` explicitly for plotting. The optional `figure_style_self_check()` applies the defaults and checks font wiring when called; it is not a load-time check. Two tiers of rule live below. **Hard rules** — everything under *Tell the truth*, *Never do*, and *Prove the render*, plus any rule stating a perceptual or factual invariant (semantic-zero centring, colour-vision safety, leader-line anchoring) — apply to every plot with no override. Everything else is a default: deviate when you have a deliberate reason, not by accident. ## Tell the truth about the data - **Excluded means excluded.** A row the source data marks excluded either disappears from the plot or appears as a clearly distinct open/hatched marker named in the key — and it never contaminates a summary statistic drawn next to included rows. - **Peers must be comparable.** Arms measured under different N, budget, initialisation, or protocol don't sit side by side as if equivalent. Facet them apart or mark the label, and state the difference once in the caption. - **The figure can't contradict itself.** Before saving, trace every categorical label, threshold, and title back to the rule that defines it and check each plotted row satisfies it. A row that contradicts its label means the figure is wrong. - **A sentence-title is a claim — test it.** Check the claim against every category on the axis. One counterexample means qualifying ("on 3 of 4 pairs") or demoting the title to a description. - **State n and what's held fixed.** Any summary mark comes with `n` and the unit of replication; any small-multiple that fixes a variable names the fixed value — in-panel, or in the caption when the label budget is tight. - **Context structure comes from references.** A tree, ordering, or topology drawn as background (scale bar, category strip) uses an established reference. Infer it from the plotted data only when the structure is itself the finding. - **One claim, one number.** Each quantitative claim (accuracy, runtime, count) has a single canonical value reused identically in every panel, caption, and the abstract — with a definition of what it measures. ## Say less, and say it in the right place The panel shows the pattern; the caption carries the context. Design for a general scientific reader, not for yourself. - **Floor.** Every visually distinct mark must be identifiable from the figure alone. Deleting a label may only ever leave the reader asking "why is that there?" — never "what is that?". Comparators are named for what they are ("no joint training", "prior method"), not a role word ("baseline"). Gloss any term a general scientist can't parse. - **Ceiling.** Per panel: title, axis labels, ticks, series identity (labelled once per row of small multiples), and at most 2–3 narrative annotations. More than ~6 strings beyond axes/ticks means over budget. Identity labels are floor, not budget. - **Caption material:** n=, held-fixed values, abbreviation expansions, exclusion rationale, non-comparability footnotes, methods caveats. - **Titles state takeaways.** "Robust to gene dropout" works; "Fewer genes" doesn't — read it aloud and if a listener would ask "fewer genes *what*?", rewrite. A row of small multiples varying one thing gets one row header, not per-panel titles. - **Numbers on marks: headline only.** Print the one value a reader would quote; the axis serves the rest. - **Tie-break: delete and re-read.** If the message survives without the label, the label stays gone. ## Axes and scales - Limits clear the data by at least a marker radius on every side — `ax.margins(0.04)` — and no mark or text touches a spine. - Data using under 40% of an axis calls for a break or a data-floor start with an explicit non-zero tick. Nothing may be drawn inside a break gap: the gap has no coordinates. - Log ticks read as `10²`/`1k/10k/100k`, never raw exponents. Filled bars on a log value axis are banned outright — bar length would encode the ratio to an arbitrary floor. Points with a median tick replace them. - In a row/column of small multiples, tick *labels* appear once (leftmost or bottommost); interior panels keep tick marks only. Panels sharing y and differing only in x abut (`wspace≤0.06`) under one row header. - A panel's data envelope fills ≥75% of its rectangle; dead bands mean reshaping the grid, not padding the panel. - When better-is-up/down isn't obvious from the axis label, put an upright "higher = better" cue in the margin — once per row, never per panel, never caption-only, and never rotated with rotated text (`goodness_arrow`). - The full-width figure must fit the venue's double-column width at 300 dpi, and adding a schematic or label never squeezes the data panels narrower. ## Colour - **A colour is a binding.** Once an entity gets a colour, every mark for that entity — line, fill, marker, text, heatmap row — reuses it exactly. Colour is the cross-reference; nobody should read a legend twice. - **Few hues, one dominant.** Use the minimum hue count. A focal series is saturated and heavy; comparators desaturate and thin (`focal_palette`). The focal hue may not collide with any categorical palette in the same figure, and the focal series must stay identifiable even at zero width or full overlap — outline, marker, or tinted band. - **Nested categories:** outer level chooses the hue family, inner level samples within it. - **Continuous data:** perceptually uniform sequential map; single-hue ramp for rank/size; diverging map for signed values, centred on the *semantic* zero (0, 1.0, median) — never the data midpoint. - **Colour-vision safety.** No red/green binary. Every binary pair survives a deuteranopia simulation. One alarm hue is reserved for error/anomaly/perturbation and never doubles as a series colour. - **Two palettes ⇒ two legends,** each adjacent to the first panel using its palette. ## Type - Panel titles are plain-language sentences, regular weight, left-aligned; metric names live on the axis. - **Three sizes, mapped to roles**: base for titles/axis labels/series identity, one step down for legends/annotations, one more for ticks (`apply_figure_style(sizes=(8,7,6))`). Panel letters alone break the rule (bold, larger). A label that doesn't fit gets a layout fix or a shorter string, never a fourth size. - Species, genes, and variables that convention italicises are italicised; abbreviations inherit the style and expand once on first use. - Large numbers wear magnitude suffixes — `4.2B`, `120 kb` — not comma grouping. - On-mark values: ≤2 significant figures, unless rounding would collapse two distinct rows, in which case show the separating digit. Text on a fill needs 4.5:1 contrast or it moves outside the mark. - No codebase identifiers as labels: readable name first, code in parentheses or the caption. - Panel letters: bold, top-left, outside the axes box; case per venue (`panel_letter(ax, 'a', case=...)`). ## Match the chart to the data - **Category × number:** show the distribution. Small n → jittered strip with median tick (`strip_with_median`); large n → box/violin; mean-as- message → bar with raw points *or* interval (`bar_with_points`), not both. `errorbar='ci95'` is the t-interval, valid at small n. A missing category is marked `n.d.`/`—`/hatched ghost — an empty slot reads as zero — and a true zero gets a visible stub. - **One observation per category:** lollipop (dot plus thin stem to the semantic zero), value beside the dot. - **Series over a continuum:** mean line with markers, raw runs as thin translucent traces behind it, series named by text at the line's right end (`end_of_line_labels`) rather than a legend box. Per-bin summary glyphs are unmistakable-for-raw, identical across series, and drawn under the raw points. - **Overlapping distributions:** stacked panels with shared x, or a ridgeline; overlay only when separation is obvious. - **Matrices:** under ~200 cells, print every value; state the threshold in the colourbar label. - **Embedding scatters** (UMAP/t-SNE/PCA): no ticks or tick labels, a corner arrow pair for axes, clusters labelled by thin leaders into whitespace. - **Prediction vs. observation:** adjacent tracks, identical x and colours, alignment carries the comparison; target regions as translucent spans in the legend. - **Insets** connect visibly to their source region: box plus connectors, or a wedge. - **Named-point scatters** direct-label at least max, min, and every flagged point via thin leaders — and after rendering, confirm each leader ends within a marker radius of its row. ## Composition - Show what is being measured before the result — plain title, labelled schematic, or panel order — and any schematic reuses the exact words and glyphs of the data panels. - A multi-panel figure exists to make one sentence true. Panels that neither state, support, nor bound that sentence move to the supplement. - Legends are frameless, sit in natural whitespace or become direct labels, read swatch-first left-aligned, and resolve every distinct glyph. - Grouped small multiples take one spanning header per group, not repeated titles. - Across a paper, Figure 1 renders the pitch as data (scope, not architecture); later figures carry mechanism, evidence, robustness, application. Panels are judged against the paper's pitch and move between figures when the story requires (`paper-narrative` runs that review). - Between revision rounds, a passing panel is left alone — decorating a clean panel is a regression. ## Never do Each of these is a correctness failure: - red vs. green as an opposing pair; - filled bars on a log value axis; - a diverging map centred on the data midpoint, or a colourbar whose ticks skip the semantic centre; - an axis title that repeats the tick labels; - direction-of-goodness explained only in the caption; - a "reference" line at a value that is one of the plotted points; - an excluded row inside a plotted summary; - a leader line whose nearest mark is not its target. ## Prove the render Run both checks after `fig.savefig(...)` and before presenting the file. **1. Collision scan.** Assert no visible text box overlaps another or a spine (a tick label touching its own spine doesn't count), and every text box sits inside `fig.bbox`: ```python rend = fig.canvas.get_renderer() labels = [(t, t.get_window_extent(rend)) for t in fig.findobj(mpl.text.Text) if t.get_text().strip() and t.get_visible()] frames = [(s, s.get_window_extent(rend)) for ax in fig.axes for s in ax.spines.values() if s.get_visible()] own_ticks = {ax: set(ax.get_xticklabels(which='both') + ax.get_yticklabels(which='both')) for ax in fig.axes} hits = [(a, b) for i, (a, ba) in enumerate(labels) for b, bb in labels[i+1:] if ba.overlaps(bb)] hits += [(t, s) for t, bt in labels for s, bs in frames if bt.overlaps(bs) and t not in own_ticks[s.axes]] assert not hits ``` Move, shorten, or stagger until the scan is clean, re-saving each time. **2. Visual pass.** Geometry can't see a low-contrast label, crossing leaders, or two confusable series colours. Crop each panel to its own file and inspect every crop with Wisp's `view_image` tool: ```python fig.savefig("figure.png") save_panel_crops("figure.png", panel_crops(fig)) # → .cache/figure-style/ ``` Leave Python, then `view_image` each returned path asking: every glyph legible against its background? smallest element still has a stroke or stub? leaders uncrossed? any two series colours confusable? legend beside what it keys? A visual defect that passed the collision scan is still a defect. **Crops are not products.** They are throwaway inspection files and never go in the figures/output directory, not even in a subfolder of it — only the figure itself is delivered. `save_panel_crops` keeps them in `.cache/figure-style/` and wipes that directory on every call; if you crop by hand, write to the same place. Delete it once the figure passes (`shutil.rmtree(".cache/figure-style", ignore_errors=True)`), and never report a crop as an output. **3. R output.** Prefer explicit `ggsave(filename, plot = p, dpi = 300, bg = "white", ...)` over the active device; for base graphics open `png(..., bg = "white", res = 300)`, draw, and always `dev.off()`. Then assert the file exists and is non-empty and inspect it — a "successful" R call with a missing, zero-byte, or blank file is a failed render. --- *Defaults when unsure: fewer hues, direct labels over legends, raw data over summaries, and name the measurement before showing its result.*
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.