Figure style guide¶
A portable method for figures that look authored: diagrams for slides, papers, and reports.
This page is the reasoning behind SKILL.md. It gives the measurements, and the
failure each rule was written to prevent. Read it when you want to deviate from
a constraint, when a validator fails in a way you want to argue with, or when
you are porting the checks off matplotlib.
Everything here is downstream of the form being right in the first place. That question is in choosing a form, which is the perceptual and statistical argument for what to draw, before any question of how it looks.
What ships¶
| File | In the skill | Needs | What it is |
|---|---|---|---|
figure.mplstyle |
assets/ |
matplotlib | visual defaults as a style sheet |
check_palette.py |
scripts/ |
Python 3.8+, stdlib only | color gates, any toolchain |
check_figure.py |
scripts/ |
Python 3.11+, matplotlib 3.8+ | composition + type-size gates |
Copy all three into your project, conventionally into diagrams/, where they
sit beside each other as this guide assumes.
Only check_figure.py is coupled to matplotlib. The composition rules it
enforces are library-agnostic, and each one reads geometry that any mature
plotting library reports.
Everything matplotlib ships is used as shipped: viridis, RdBu, style sheets,
constrained_layout. What remains is the part matplotlib has no opinion about:
which palette, whether the figure is composed, and whether the type clears the
page.
Fast path¶
plt.style.use("figure.mplstyle")
STYLE: journal figure, no shadows/cards/chips
dashing = unobserved/projected/threshold only
layout = constrained_layout
COLOR: categorical Okabe-Ito, first N in order
sequential cmap="viridis" (window t∈[0.05,0.70] for discrete tiers)
diverging cmap="RdBu" (RdBu_r when low=blue)
status #009E73 / #E69F00 / #D55E00, always with icon
SIZE: author at placed width → scale = 1.0, points are points
otherwise check_figure.py derives scale from CONTENT_WIDTH_PT
type floor 7.5pt on page; cut words before shrinking
VALIDATE: check_palette.py "<hexes>" && check_figure.py && open PNG
Procedure¶
- Ground the content. Use the document's exact nouns. Compute real numbers. Check every label against the drawn data, not against the idea it names.
- Choose the form. See choosing a form. Nothing downstream fixes a wrong one.
- Build on
figure.mplstyle. - Compose, run
check_figure.py, render a PNG, and look at it. - Validate the palette with
check_palette.py. Do not eyeball color. - Render the finished document and look at that.
Composition rules the checker cannot decide for you¶
- Keep one thing opaque, and use at most three alpha levels. Context gets the alpha; data does not.
- Keep marks within about 4x the area of each other. Emphasis is shape or a label, not 5x the area.
- Give context surfaces structure, such as bands or isolines, rather than a plain neutral fill.
- Give panels on a shared scale shared axis furniture.
- Encode only what exists. A path through unordered data draws a sequence that is not there.
- Make a path with real order read as directed. Downsample or smooth it, add a single arrowhead pointing forward, or model it so it stays visually contained. A noisy SGD walk near a minimum is an Ornstein-Uhlenbeck process, whose stationary distribution is the Gaussian being drawn. A self-intersecting scribble drowns out every other mark.
- Put a 1-3px white stroke behind marks that cross a textured or variable-value
context surface, using
pe.withStroke(foreground="white")in matplotlib. That knockout halo guarantees separation regardless of the local background value. A line that vanishes where it crosses a contour is not a line, and a marker that disappears into a filled band is not a marker. - Make ordinal emphasis a lightness ramp, not a transparency stack.
Panel occupancy¶
check_ink measures the fraction of each axes rectangle whose pixels differ
from the page colour, and warns outside INK_MIN, INK_MAX = 0.02, 0.55.
The row is advisory, and it stays advisory because the right density is a
property of the form. An imshow heatmap measures 1.00 and would warn on every
figure that has one. Colorbars are exempt, being a solid ramp by construction
and nobody's design decision.
This is not the data-ink ratio. Tufte's quantity is the share of ink that carries data. This one counts every non-background pixel in the rectangle, including frame, ticks, gridlines, and labels. The two move opposite ways under one edit: deleting gridlines raises the data-ink ratio and lowers this number. A high reading means saturation rather than decoration, so stripping furniture is not the fix. This guide states no data-ink rule of its own, partly because the empirical case for minimalism is weaker than its standing suggests (Bateman et al., in the references).
The two ends fail differently.
At the floor, suspect the scale before the data. Marks occupying one corner of a panel usually mean limits set by an outlier or left at the default, and the fix is the limits rather than more ink.
A panel with nothing drawn in it warns whether or not the range would have
caught it, because the gate asks whether anything was drawn at all. So a blank
cell in a grid is a WARN and not a pass. That question does real work, because
empty furniture is not reliably under the floor: frame, ticks, and gridlines are
a perimeter while the panel is an area, so their share falls as the panel grows.
Measured at MEASURE_DPI, a blank half of a 3x1.5in pair reads 0.03, inside the
range, and a blank half of a 6x3in pair reads 0.01, under it. Only the first is
caught by the range.
At the ceiling, the panel is reporting density rather than observations. That is the overplotting case in choosing a form: transparency within the three-alpha-level budget, a hexbin, or a 2-D density estimate.
When the fill is context, say so. A contourf landscape under a few marks
reads as saturated, because the surface is what fills the rectangle. Pass those
axes as check_ink(fig, context_axes=[ax]) or audit(fig, context_axes=[ax]),
and the gate separates surface from marks and measures only what sits on top.
Then still look at it¶
The checker sees geometry, not meaning. It cannot tell you that an arrow points at the wrong thing, or that the reading order runs backwards.
Color¶
| Encoding | What |
|---|---|
| Categorical | Okabe-Ito |
| Sequential / ordinal | cmap="viridis" |
| Diverging | cmap="RdBu" or RdBu_r |
| Cyclic | cmap="twilight" |
| Context backdrop | achromatic ramp, below |
Categorical: Okabe-Ito¶
Okabe-Ito is an established colorblind-safe categorical set with a published order. Adopting it as-is beats inventing a private set: it carries a decade of use, and readers who know it recognise it.
| Slot | Hue | Hex | Contrast #ffffff |
Role |
|---|---|---|---|---|
| 1 | black | #000000 |
21.00 | ink token by convention |
| 2 | orange | #E69F00 |
2.25 † | series |
| 3 | sky blue | #56B4E9 |
2.31 † | series |
| 4 | bluish green | #009E73 |
3.42 | series |
| 5 | yellow | #F0E442 |
1.32 ‡ | fills, not hairlines |
| 6 | blue | #0072B2 |
5.19 | series |
| 7 | vermillion | #D55E00 |
3.87 | series |
| 8 | reddish purple | #CC79A7 |
3.06 | series |
† below 3:1, so it must carry a visible direct label. ‡ below the lightness band.
The hue is the one cell here a reader cannot recompute. "Vermillion" is the Hue
column attempting it in words, and the swatch is the same claim in color. The
swatch renders on the site only: in the file it is an attribute list on the code
span, restating the hex that span already holds. A second copy of a color is how
the ink table below came to credit the sheet with a token the sheet had stopped
shipping, so both ends are held. tests/test_docs_match_code.py reads the
source and asserts the pair agrees, and tests/test_docs_render.py renders the
page and asserts the browser paints it.
A legend entry is not a direct label. The rule was ambiguous on this, and
examples/demo.py read it the other way for a while. It is settled the strict
way, because the obligation follows from the measurement. A sub-3:1 mark is
faint against the page, and a legend leaves the reader matching a small faint
swatch to a small faint curve, which is the exact step a direct label exists to
remove. Put text at the mark, in ink, with the mark beside it carrying the
identity.
Ratios are measured against white, because that is what figure.mplstyle
renders and what the page under it is. They were previously quoted against
#fcfcfb, a surface no figure in this project ever had. The numbers were all
slightly wrong, and reddish purple was listed as needing a direct label when on
white it clears 3:1 at 3.06. tests/test_docs_match_code.py now reads this
table and checks every number against contrast(), so it cannot drift again. On
a genuinely tinted page, pass --surface.
Two slots are held out. Yellow at 1.32:1 genuinely vanishes as a hairline, so it is for fills only. Black is a preference rather than a measurement, because it keeps "ink" unambiguous; take it as a series color if you want.
The remaining six, in canonical order: #E69F00 #56B4E9 #009E73 #0072B2 #D55E00 #CC79A7
Adjacent CVD ΔE 32.0, adjacent normal-vision ΔE 31.5, all-pairs ΔE 12.8/21.6.
Take slots in order. One series takes orange. Three take orange, sky blue, and bluish green. Never cherry-pick by meaning. There is one limit rather than two: the first six slots clear all-pairs, and there is no seventh hue.
The all-pairs limit has been wrong twice, in both directions. It was written as
four for a long time and nobody had run it. Measured, it became five, because
six slots failed at ΔE 7.9 (#009E73 against #CC79A7) with a target of 8.
That measurement was made in OKLab, whose distances have no calibrated
threshold. In CAM02-UCS the same worst pair is #0072B2 against #CC79A7 at
12.8, against a floor of 10.5. Two spaces disagreeing about which pair is
worst is the clearest illustration in this guide of why 0.8.0 changed metric.
The restriction to five was an artefact and it is gone.
tests/test_docs_match_code.py derives the number by asking
check_palette.check(..., all_pairs=True) for the largest count that passes, so
the constant below and this sentence cannot drift from the validator again.
Grayscale is not solved by Okabe-Ito. Orange and sky blue, the canonical
first two, separate by relative luminance 0.011 (#E69F00 vs #56B4E9) and are
invisible once desaturated. Take orange and blue instead, at relative luminance
0.264 (#E69F00 vs #0072B2), and add a second channel when it must survive a
photocopier.
The unit here is WCAG relative luminance, not the CAM02-UCS ΔE the separation gates use and not the OKLab lightness quoted below, because luminance is the channel a desaturation keeps. In OKLab lightness the same two pairs are 0.018 and 0.221: the ordering is the same and the numbers are not, and a reader who recomputes one convention against the other concludes the guide has drifted.
Sequential: viridis, as shipped¶
viridis is perceptually uniform, monotone in lightness, colorblind-safe, and in matplotlib already.
Window it only for discrete tiers drawn as standalone lines or marks.
Full-range viridis ends at #fde725, 1.26:1, which
is invisible as a hairline. Sample t ∈ [0.05, 0.70] through ordinal() from
the appendix.
Two things to get right when you window:
- Sample evenly, keeping the ΔL ratio under 2x.
- Narrow to
t ∈ [0.00, 0.38]when the figure also carries status green, because viridis passes through green neart=0.7, ΔE 10.7 from#009E73.
On marks, hand the ramp to a colormap rather than a list of colors. An
ordinal ramp is built to violate the categorical separation floor: its steps are
meant to be close, and there can be many of them. check_figure.py reads a
scatter drawn as scatter(c=[rgba, rgba, …]) as that many independent
categorical identities and fails it against the ΔE floor, because a
pre-evaluated RGBA list carries no signal that the steps are ordered.
Draw it as scatter(c=values, cmap=ListedColormap(ramp)), or with a
Normalize, instead. The gate reads that as the value encoding it is and exempts
it, and the intent now lives in the code rather than being lost in a flat bag of
hues.
A ramp drawn as standalone lines has no colormap to hand it to. Keep those
tiers few and evenly stepped, and validate with --ordinal, since the
composition gate measures them against the categorical floor.
Diverging: RdBu, as shipped¶
RdBu is ColorBrewer, colorblind-safe, and in matplotlib. Its midpoint is
#f6f7f7. Never put a hue at the midpoint, and never
use two cool poles.
Take discrete levels from inside the ends, the way viridis is windowed. Its
ends as shipped are #67001f and
#053061. Handed to check_palette.py as swatches,
both fall outside the lightness band, and the dark blue falls under the chroma
floor as well. t ∈ [0.1, 0.9] gives #b1182b and
#2065ab, which clear every gate.
A continuous fill is unaffected. The colormap gate reads the kind, and only runs the swatch gates on a map discrete enough to count as qualitative.
Cyclic: twilight, as shipped¶
An angle has no ends. For phase, heading, direction, or time of day, the last value is adjacent to the first, and a ramp that starts dark and ends light draws a seam straight across the figure at the one place the data is continuous.
twilight and twilight_shifted close the loop and are monotone in lightness
over each half, so a reader can still order two values within a half turn.
Tick the bar at both ends and the centre. The two ends are the same colour because they are the same angle, and a bar that does not show that reads as a scale someone got wrong.
Which kind, and the key it takes¶
There are four kinds and four questions. The kind is a property of the data rather than a preference, and picking the wrong one is not a matter of taste: it claims an ordering the values do not have, or hides one they do.
| Kind | The question it answers | matplotlib | Key |
|---|---|---|---|
| Sequential | can a reader put two values in order? | viridis |
colorbar |
| Diverging | is there a middle the values are signed around? | RdBu |
colorbar, centred on that middle |
| Cyclic | does the last value touch the first? | twilight |
colorbar, ticked at both ends and the centre |
| Qualitative | are the levels identities, with no order to read? | okabe_ito |
legend |
A colorbar is a ruler, so it needs something to be a ruler along. The three continuous kinds have one. Categories do not, and a bar drawn beside them is a scale along nothing, so name them in a legend instead.
The same rule decides where a value that falls outside the measured range goes.
"Did not converge", "no data", and "censored" are separate classes rather than
small values, so draw them in an explicit neutral and key them off the bar,
never as cmap(0). The bar is the range that was measured, and putting a
non-value at the bottom of it claims a quantity nobody measured.
The kind is measured rather than trusted to a name, because matplotlib groups
its colormaps by kind in prose documentation only: there is no API and no
metadata on a Colormap object.
The gate is in check_figure.py, which samples CMAP_SAMPLES = 256 levels off
the colormap and hands them to cmap_kind() in check_palette.py, where every
constant below is defined and where the OKLab conversion and the lightness
reading happen. The split is the porting story: the kind measure travels with
the stdlib-only module.
The measure works in three steps:
- Anything under
CMAP_QUALITATIVE_N = 40levels is qualitative, and goes to the categorical gates instead. - A map whose lightness span is under
CMAP_SPAN_MIN = 0.02is isoluminant and carries no order at all. - Otherwise the measure is back-travel, the fraction of a segment's
lightness span spent moving against its own direction. Under
CMAP_BACKTRAVEL_MAX = 0.02over the whole map is sequential. Under it over both halves is diverging, or cyclic when the two ends are withinCMAP_WRAP_DE_MAX = 1.0of each other in CAM02-UCS ΔE, one just-noticeable difference, which is what "the ends are the same colour" means once the metric has a unit.
The 18 maps in the registry that reach that last branch wrap at 0.000 and 0.376, the two twilights, or at 27.963 and above, everything diverging. The threshold has two orders of magnitude of clearance either side.
Everything else is misc, and misc is the only outcome that fails. jet,
rainbow, hsv, and gist_ncar land there. A reader cannot order two values in
any of them, and ordering is what a colormap is for.
The thresholds are empirical, measured across matplotlib's registry against
cmasher.get_cmap_type() as an oracle. The margins, the three adjudicated
disagreements, and the one known false positive are recorded in the design note
that ships with the repository, at
specs/2026-07-28-colormap-kind-gate-design.md.
The theory is Kovesi; see the references at the end.
Passing the kind gate is not a quality verdict. turbo passes as diverging
because its lightness profile genuinely is diverging-shaped. Its problem is hue
banding, which a lightness-only measure cannot see. The row is named "Colormap
kind" for that reason.
Ink, status, backdrop¶
| Token | Hex | Defined in |
|---|---|---|
| Ink primary / secondary / muted | #000000 / #52514e / #777570 |
figure.mplstyle |
| Grid / axis / surface | #e1e0d9 / #c3c2b7 / #ffffff |
figure.mplstyle |
Muted ink was #898781 until check_text_readability was written and failed
the sheet's own tick labels on every figure in the repository. It measures
3.59:1 on white, under the 4.5:1 a glyph stem needs. #777570 is 4.6:1 and
still sits below the 7.94:1 axis label, so the hierarchy survives the
correction. The old spelling stays in check_figure.py's INK_TOKENS so that
figures built on the old sheet are still read as furniture; it is not a token to
reach for. tests/test_docs_match_code.py now resolves every hex in this table
against the sheet, so the table cannot quote a token the sheet does not ship.
Status colors are drawn from the palette, and always carry an icon or label:
Status and series are mutually exclusive roles for a hue within one figure.
Neutral backdrop: #ffffff →
#e6e5de → #c3c2b7 →
#95938b.
Categorical or ordinal?¶
- Categorical means independent identities, such as different models or different groups.
- Ordinal means ordered steps. A numbered cycle is ordinal, so it takes viridis rather than four hues.
Validator¶
python check_palette.py "#E69F00,#56B4E9" # lines, bars
python check_palette.py "#E69F00,#56B4E9,#009E73" --pairs all # scatter
python check_palette.py "#471365,#2c718e,#44bf70" --ordinal # ramps
The gates are lightness band, chroma floor, CVD separation ≥ 10.5,
normal-vision ≥ 21, and contrast ≥ 3:1. Separations are CAM02-UCS ΔE. CVD gating
covers protanopia and deuteranopia, together about 8% of males; tritan is
reported but not gated, at about 0.01%. Surface defaults to white, and
--surface sets a tinted page.
Dichromacy is not the worst case, so the separation row is swept over severity. Most colour vision deficiency is anomalous trichromacy, meaning a cone whose peak sensitivity is shifted rather than one that is missing. Simulating only the endpoint would be sound if the endpoint were the hardest view.
Measured over 244 650 pairs of hues this validator would accept as series slots,
1.27% clear ΔE 10.5 under dichromacy and miss it at some lower severity, and
dichromacy overstates separation by as much as 12.7 ΔE. #8e4dc7 and #1402ef
are one such pair, at 19.2 under dichromacy and 8.3 at severity 0.9.
So the row reports the worst view found and names the severity it was found at,
using the Machado, Oliveira and Fernandes (2009) matrices for
ANOMALOUS_SEVERITIES and the Viénot matrices at dichromacy. The bundled
cycle's worst adjacent pair reads 31.7, which is what says the stricter reading
is not a floor nobody can satisfy.
Where the two floors come from. A hue pair that reads as two on a filled band ought to read as one on a hairline, and that intuition is right: a small target does need more colour difference than a large one.
Stone, Szafir and Setlur (2014) measured how much. The noticeable difference for half of observers rises as C + K/s, with s the target's visual angle in degrees, fitted from 0.333° to 6°. That comes to 6.1 CIELAB ΔE at a two-degree patch and 10.4 at a third of a degree.
Before 0.8.0 that model could only sit beside the floors and be compared against them, because the floors were OKLab distances and OKLab has no calibrated metric. Its authors fitted it for hue uniformity and never against discrimination data, so a distance of 8 in it was a number with no referent. Since 0.8.0 the floors are CAM02-UCS, which Luo, Cui and Li (2006) fitted so that Euclidean distance predicts perceived difference, and the model is what they are derived from:
One CAM02-UCS unit is a median 1.99 CIELAB ΔE*ab over the gamut this validator accepts as series slots, so Stone et al.'s 10.4 is 5.23 CAM02-UCS.
CVD_TARGETis 2× that andNORMAL_FLOORis 4×.
The bridge is population-dependent, and the population is load-bearing. Over the
whole sRGB cube the same ratio reads 1.85, which would put CVD_TARGET at 11.3
instead of 10.5. The slot gamut is the right one, because the lightness band and
the chroma floor reject everything else before the separation rows are reached,
and the cube contains pairs this gate cannot be handed.
test_the_bridge_between_the_two_colour_spaces_is_population_dependent measures
both so the choice cannot quietly revert.
The multiples are the one judgement in the derivation. 10.4 CIELAB is where half of observers notice a difference. A figure asks for more, because the reader is not detecting that two marks differ side by side; they are identifying which series a mark belongs to, across a page, from memory of a legend. 2× under simulation and 4× in full colour is where this project puts that margin. The guide used to describe the OKLab floors as landing at the same multiples. That is true, and it is a coincidence: it computed them through a whole-cube ratio against a metric that had no calibration.
What is evidence for them. The floors were derived without reference to any
palette. Run against the published Okabe-Ito eight-colour set, CVD_TARGET
clears all 28 pairs and NORMAL_FLOOR misses exactly one: orange #E69F00
against yellow #F0E442, at 20.75. Yellow is one of the two colours the shipped
cycle already drops. Arriving at that independently is the strongest argument
these numbers have, and it is why the result is pinned in
test_okabe_ito_yellow_beside_its_orange_misses_the_normal_vision_floor rather
than sanded off with a friendlier fixture.
Why there is still no size-weighted gate. A gate multiplying the floors by Stone et al.'s size ratio was built and thrown away. Publication line widths are far below the range the model was fitted over: a 1pt stroke is 0.05° at reading distance, two decades outside the data, and extrapolating the fit there produces a number nothing supports.
So the rule is a rule and not a gate. A hairline is where a palette gets
tested, so give thin strokes the widest-separated slots, and do not lean on a
pair that only just clears a floor.
test_the_separation_floors_reproduce_the_size_model_they_derive_from pins the
arithmetic, so the next person to propose the gate can re-derive this in one
run.
Text on fills¶
Hold text to 4.5:1, or 3:1 at 14pt bold and above.
check_text_readability enforces this against the backdrop each string
actually got, measured off the rendered pixels. That is the only way to know,
because the backdrop is whatever happened to be drawn under the label, and no
artist knows that about itself.
Direct labels: the alignment is the decision¶
A label is a box, not a point. On a curve with any slope, ha="center" is the
one alignment that puts both ends of the box back down on the line. It clears
the curve at the anchor and nowhere else, because across the label's own width
the curve has moved further than the offset holding the text up.
This shipped in examples/demo.py for months. Every check passed, all three
labels sat on their own curves, and their casing punched visible white gaps
through the data.
- Align toward the side the curve is leaving. On a descending curve, above
it, clear ground runs right, so use
ha="left"; below it, clear ground runs left, so useha="right". - Anchor on the extreme of the data across the label's own span, not on its value at one point. Sampled noise routinely spikes further than any sane offset.
- Use casing (
pe.withStroke) to rescue a 0.7pt gridline behind a label. It does not rescue a 1.6pt curve, where it only hides the collision by deleting the data underneath. That is why the gate measures the backdrop rather than the finished render. - When a panel has no clear ground anywhere, take the labels off the field
entirely. A filled field crossed by isolines is the usual case.
examples/gallery.pyhas the one figure in this repository that uses a legend, and that is the reason.
Standing rules¶
- Text wears ink, never the series color.
- Status colors are status only.
- Color follows the entity, not its rank.
- Sequential is monotone in lightness, evenly stepped. Never jet.
- Dashing means unobserved, projected, or threshold, and nothing else. Pass
linestyles="solid"tocontouron signed data, because matplotlib dashes negative levels by default, and that reads here as a hedge the contour is not making.
Legibility budget¶
Decide on-page placement before authoring. "8-inch figure" means nothing until you know what width the document gives it: a full page, a text block, or two columns. Author it at that width, and the scale is 1.0, the type gate is exact, and there is nothing to compute.
A figure placed below roughly 35% of the content width, which is two-column
width for a landscape figure, puts every label at or below 6pt on the page
regardless of what the script says. check_figure.py warns when placed_frac <
0.35, but the right move is to change the placement or the figure, not to
shrink the type further.
Set CONTENT_WIDTH_PT once in check_figure.py, or pass venue= for one of
the twelve the table already knows (python check_figure.py --venues). The
script derives the scale per figure and fails any string under 7.5pt on the
page.
The scale is per figure because the shrink differs. A 14in figure on a 750pt slide shrinks to 0.74x, an 8.6in one is 1.21x, and the same 10pt label is fine in one and illegible in the other.
7.5pt is stricter than any journal that publishes a number. Nature sets a 5pt minimum and a 7pt maximum for text in figures. PNAS requires numbers, letters and symbols no smaller than 6pt after reduction, which is the same requirement its guidelines also state as 2mm. Science publishes no text floor at all: it asks for a 6pt minimum on symbols, 0.5pt on line widths, and 10pt bold part labels. Those are the sizes at which a string is still possible to read. 7.5 is where it is comfortable, and it costs nothing to hold, because the fix is nearly always cutting words.
If text does not fit, cut it. "Acquisition function ranks the candidate molecules" becomes "Rank every candidate."
Text off the canvas has two fixes that are not fixes. The Clipping gate fails when a string's bounding box crosses the canvas edge, and the cause is nearly always a figure authored at a size its labels do not fit in. Shrinking type until it fits pays for canvas space out of the legibility budget, and a string driven under 7.5pt on the page has bought a Clipping pass with a Type size failure.
bbox_inches="tight" is the one to actually watch, because it fails nothing. It
trims the canvas to the drawn content, so the saved file is no longer the width
you authored
(matplotlib#11681).
Every number in this section is derived from that width: the page scale, the
type floor, the stroke arithmetic. Trim the canvas and all of them are computed
against a size the file does not have. Nothing reports it either, because
check_figure.py reads the figure in memory and never sees the savefig call.
figure.mplstyle says this at the top, and it is repeated here because this is
where the gate that catches the symptom sends a reader.
The fix is constrained_layout=True at figure creation, or a wider figsize
followed by re-deciding the placement, since the placed fraction just changed.
Strokes have the same problem and the same arithmetic. SIAM's instructions
for authors call for lines one point or thicker, because thinner lines break up
or disappear in print. A 0.8pt stroke in a 9in figure placed at 5.5in prints at
0.49pt, so check_line_weight measures on the page through the same
page_scale. Gridlines are held to a lower floor than data deliberately: a
gridline that drops out costs a reference, and a curve that drops out costs the
finding.
Embed fonts as Type 42. matplotlib defaults to Type 3. IEEE PDF eXpress
takes embedded Type 1 or TrueType and does not accept Type 3, so the upload is
refused before a reviewer sees it. ACM and Elsevier check embedding in
production instead, which is the same problem surfacing after acceptance rather
than a milder one. The figure renders identically either way, so nothing tells
you until the latest and most expensive possible moment. figure.mplstyle sets
pdf.fonttype: 42 and ps.fonttype: 42.
Describe the figure. Across 100,000 public notebooks, 99.81% of programmatically generated images shipped with no alt text (Potluri et al., in the references), and matplotlib was the charting library behind more of them than anything else.
Call describe(fig, ...), then
savefig(path, metadata=alt_metadata(fig, path)), passing the path a second
time so the call can pick the key that format takes. Say what the reader would
have taken from looking, meaning the numbers and the direction, rather than what
the figure is made of. "A line chart with three lines" describes the file.
Prose¶
Match the surrounding voice. State the mechanism once, in the caption. Check every spatial claim against the render. Invent no precision. Use no em-dashes in rendered strings.
Discipline¶
- Declare the read before you draw. One sentence: what, for whom, and the job it does.
- Prefer checks over adjectives. A check you can run beats an adjective you have to feel.
- Expect every gate to have a blind spot. A figure that passes is not good; it is not-bad in exactly the ways enumerated. When it still looks wrong, write the gate.
Review checklist, for the things no script can check¶
- [ ] Uses the surrounding text's exact vocabulary
- [ ] Every label checked against the drawn data, not the idea
- [ ] Nothing drawn that the data does not support
- [ ] Ordinal against categorical decided correctly
- [ ] Placement decided before authoring; figure authored at its placed width
- [ ] Named algorithm shown with its defining objects (paper or repo), not remembered
- [ ] Document compiled; caption states mechanism once; no em-dashes
Ship¶
Build, run the tests, and compile the document. Smoke-test each figure, so a broken one fails the build instead of shipping blank.
Appendix: palette constants¶
from matplotlib import colormaps
from matplotlib.colors import to_hex
_OKABE = colormaps["okabe_ito"]
# 0 black 1 #E69F00 2 #56B4E9 3 #009E73 4 #F0E442 5 #0072B2 6 #D55E00 7 #CC79A7
SERIES = [_OKABE(i) for i in (1, 2, 3, 5, 6, 7)]
MAX_SERIES, MAX_SERIES_ALL_PAIRS = 6, 6
STATUS = {"good": _OKABE(3), "warning": _OKABE(1), "critical": _OKABE(6)}
NEUTRAL_BACKDROP = ["#ffffff", "#e6e5de", "#c3c2b7", "#95938b"]
VIRIDIS_WINDOW = (0.05, 0.70)
VIRIDIS_WINDOW_COOL = (0.00, 0.38)
def series(n):
if n > MAX_SERIES:
raise ValueError(f"{n} series requested but theme has {MAX_SERIES} slots")
return [to_hex(c) for c in SERIES[:n]]
def ordinal(n, window=VIRIDIS_WINDOW):
lo, hi = window
vir = colormaps["viridis"]
if n < 2: # one step has no gap to space; n=1 divided by zero
return [to_hex(vir(lo))] * n
return [to_hex(vir(lo + (hi - lo) * i / (n - 1))) for i in range(n)]
Ink tokens live in figure.mplstyle, not here.
References¶
The colour rules above are not house preference. Each of the four colormap
kinds, and the reason misc fails, comes from this literature.
- Kovesi, P. (2015). Good Colour Maps: How to Design Them. arXiv:1509.03700. Uniform incremental change in perceptual lightness as the governing requirement, with separate stated criteria for linear, diverging, rainbow and cyclic maps. The back-travel measure is this criterion made mechanical.
- Crameri, F., Shephard, G. E. & Heron, P. J. (2020). The misuse of colour in science communication. Nature Communications 11, 5444. doi:10.1038/s41467-020-19160-7. Why rainbow-like and red-green maps are still prevalent, and what they cost a reader.
- Nuñez, J. R., Anderton, C. R. & Renslow, R. S. (2018). Optimizing colormaps with consideration for color vision deficiency to enable accurate interpretation of scientific data. PLoS ONE 13(7), e0199239. The CVD-safe side of the same question, and the argument the Okabe-Ito section above rests on.
- Moreland, K. (2009). Diverging Color Maps for Scientific Visualization. In Advances in Visual Computing (ISVC 2009), 92-103. doi:10.1007/978-3-642-10520-3_9. The midpoint rule: never a hue at the centre, and why a diverging map needs a meaningful zero to diverge around.
- Stone, M., Szafir, D. A. & Setlur, V. (2014). An Engineering Model for Color Difference as a Function of Size. In Color and Imaging Conference 2014(1), 253-258. The size model. They fit the noticeable difference for 50% of observers as a linear function of inverse size over 11 target sizes from 0.333° to 6°, and report it as about 6 CIELAB ΔE at two degrees rising to about 11 at a third of a degree. The section above is why the numbers here already clear that and there is no gate.
- Machado, G. M., Oliveira, M. M. & Fernandes, L. A. F. (2009). A
physiologically-based model for simulation of color vision deficiency. IEEE
Transactions on Visualization and Computer Graphics 15(6), 1291-1298.
doi:10.1109/TVCG.2009.113. The severity model. Their Table 1 publishes a
simulation matrix at each severity from 0.0 to 1.0 in tenths, and severity 1.0
is calibrated against the same dichromacy model
simulateuses, which is what lets the two be read side by side.
Two more, for claims the sections above make outside colour.
- Potluri, V., Singanamalla, S., Tieanklin, N. & Mankoff, J. (2023). Notably Inaccessible: Data Driven Understanding of Data Science Notebook (In)Accessibility. ASSETS '23. doi:10.1145/3597638.3608417, arXiv:2308.03241. The 99.81% is their measured figure: of the programmatically generated images across 100,000 notebooks, N=342102 carry no alternative text. They also identify matplotlib as the most-imported charting library in the corpus, seaborn second and built on it.
- Bateman, S., Mandryk, R. L., Gutwin, C., Genest, A., McDine, D. & Brooks, C. (2010). Useful junk? The effects of visual embellishment on comprehension and memorability of charts. CHI '10, 2573-2582. doi:10.1145/1753326.1753716. Accuracy in describing embellished charts was no worse than for plain ones, and recall after a two-to-three-week gap was significantly better. Cited to mark that "less ink is better" is not settled, not to license decoration.