How to Build a Reusable Map Style Module
Problem statement
The tenth map in a project looks different from the first. The boundary is 0.5 pt on one and 0.4 pt on another, the legend moved, the grey is #666 here and #64748b there, and one figure was drawn at 8 inches while the rest were drawn at 90 mm.
None of these are mistakes exactly. They are the accumulated cost of styling decisions living in the scripts that use them rather than in one place. The symptoms are recognisable:
- a change to the house palette means editing fourteen files
- two figures in the same report have different type sizes after placement
- nobody can say which colour ramp is "the" ramp
- the tested, accessible palette exists in a notebook somebody has closed
A style module fixes this the way any other shared code does: one definition, imported everywhere, versioned with the project.
Quick answer
One module, three things in it: tokens, rcParams and helpers.
# mapstyle.py
MM = 1 / 25.4
COLOURS = { # tested, with their measured contrast on white
"ink": "#1e293b", # 14.63:1 โ text
"indigo": "#1a3a6b", # 11.28:1 โ emphasis
"muted": "#64748b", # 4.76:1 โ context lines
"faint": "#94a3b8", # 2.56:1 โ decorative only
"rule": "#e2e8f0", # 1.23:1 โ hairlines
"missing": "#e2e8f0",
"highlight": "#ef4444",
}
RAMPS = {"sequential": "YlGnBu", "diverging": "RdBu", "qualitative": "Dark2"}
SIZES = {"title": 9, "label": 7, "legend": 6.5, "footer": 5.5}
WIDTHS = {"coastline": 0.6, "boundary": 0.35, "hairline": 0.25}
RC = {
"font.size": SIZES["label"],
"font.family": "sans-serif",
"axes.linewidth": 0.5,
"pdf.fonttype": 42,
"ps.fonttype": 42,
"svg.fonttype": "none",
"figure.dpi": 150,
"savefig.facecolor": "white",
}
import mapstyle
plt.rcParams.update(mapstyle.RC)
The comments carrying the measured contrast ratios are not decoration. They are what stops somebody reaching for #94a3b8 for text.
Step-by-step solution
1. Separate tokens from usage
A token is a named value: COLOURS["muted"], SIZES["label"]. A usage is where it is applied: the coastline is drawn in muted at WIDTHS["coastline"].
Keeping them apart means the palette can change without touching the drawing code, and the drawing code documents which token each element uses. It also makes the accessibility audit a loop over one dictionary.
2. Put the rcParams in the module and apply them explicitly
Global plt.rcParams mutation from an imported module is a side effect that surprises people. Export a dictionary and let the caller apply it โ or provide a context manager, which is better still because it is scoped:
import contextlib
import matplotlib.pyplot as plt
@contextlib.contextmanager
def house_style(**overrides):
with plt.rc_context({**RC, **overrides}):
yield
with mapstyle.house_style():
fig, ax = plt.subplots(figsize=(90 * MM, 70 * MM))
...
3. Provide the figure constructors, not just the values
The most valuable function in a style module is the one that creates a correctly sized figure, because that is the decision that goes wrong most often and costs most:
def figure(width_mm=170, aspect=0.72, **kwargs):
"""A figure at its final printed size. Place at 100%, never rescale."""
return plt.subplots(figsize=(width_mm * MM, width_mm * aspect * MM), **kwargs)
An 8-inch figure placed in a 90 mm column scales by 0.443, turning 8 pt type into 3.54 pt. A constructor that takes millimetres makes that mistake hard to make.
4. Include the tested palette and the test
The module should carry both the palette and the function that validates it, so a change to the palette can be checked in the same commit:
def audit_palette():
"""Contrast on white, and separation under simulated colour vision deficiency."""
...
That turns "is this colour accessible?" from an argument into a test run.
5. Version it, and record the version on the output
A map made last March was made with last March's style. Recording the version in the figure's metadata or footer means a difference between two figures can be explained rather than investigated:
__version__ = "1.4.0"
def provenance(fig, sources, projection):
fig.text(0.01, 0.01,
f"Source: {' ยท '.join(sources)} ยท Projection: {projection} ยท "
f"style {__version__}",
fontsize=SIZES["footer"], color=COLOURS["muted"], va="bottom")
6. Keep it small
A style module that grows into a plotting framework stops being read. Tokens, rcParams, a figure constructor, an export function, and two or three helpers is enough for almost every project.
Anything that only one map needs belongs in that map's script.
Code examples
Example 1 โ the complete module
"""mapstyle.py โ the house style for this project's maps.
Import the tokens; apply RC through the context manager; build figures with
`figure()` so every map is created at its final printed size.
"""
import contextlib
import matplotlib.pyplot as plt
__version__ = "1.4.0"
MM = 1 / 25.4
COLOURS = {
"ink": "#1e293b", "indigo": "#1a3a6b", "muted": "#64748b",
"faint": "#94a3b8", "rule": "#e2e8f0", "missing": "#e2e8f0",
"highlight": "#ef4444", "water": "#e8f4fd", "land": "#f8fafc",
}
RAMPS = {"sequential": "YlGnBu", "diverging": "RdBu", "qualitative": "Dark2"}
SIZES = {"title": 9, "label": 7, "legend": 6.5, "footer": 5.5, "inset": 6.5}
WIDTHS = {"coastline": 0.6, "boundary": 0.35, "hairline": 0.25, "highlight": 1.1}
RC = {
"font.size": SIZES["label"], "font.family": "sans-serif",
"axes.linewidth": 0.5, "axes.edgecolor": COLOURS["rule"],
"pdf.fonttype": 42, "ps.fonttype": 42, "svg.fonttype": "none",
"figure.dpi": 150, "savefig.facecolor": "white", "savefig.bbox": None,
"legend.frameon": False, "legend.fontsize": SIZES["legend"],
}
LAYER = {
"subject": dict(edgecolor="white", linewidth=WIDTHS["boundary"], zorder=5),
"context": dict(facecolor=COLOURS["land"], edgecolor=COLOURS["rule"],
linewidth=WIDTHS["hairline"], zorder=3),
"water": dict(facecolor=COLOURS["water"], edgecolor="none", zorder=2),
"coast": dict(facecolor="none", edgecolor=COLOURS["muted"],
linewidth=WIDTHS["coastline"], zorder=6),
}
@contextlib.contextmanager
def house_style(**overrides):
with plt.rc_context({**RC, **overrides}):
yield
def figure(width_mm=170, aspect=0.72, **kwargs):
fig, ax = plt.subplots(figsize=(width_mm * MM, width_mm * aspect * MM), **kwargs)
if not isinstance(ax, (list, tuple)) and hasattr(ax, "set_aspect"):
ax.set_aspect("equal")
ax.set_axis_off()
return fig, ax
def provenance(fig, sources, projection, made_on=None):
from datetime import date
fig.text(0.01, 0.008,
f"Source: {' ยท '.join(sources)} ยท Projection: {projection} ยท "
f"Made {made_on or date.today().isoformat()} ยท style {__version__}",
fontsize=SIZES["footer"], color=COLOURS["muted"],
va="bottom", ha="left")
def export(fig, stem, preset="print_pdf"):
import os
presets = {"print_pdf": dict(format="pdf", dpi=300),
"print_png": dict(format="png", dpi=300),
"web_png": dict(format="png", dpi=144),
"editable_svg": dict(format="svg")}
options = dict(presets[preset])
fmt = options.pop("format")
path = f"{stem}.{fmt}"
with house_style():
fig.savefig(path, **options)
print(f"{path} {os.path.getsize(path) / 1024:,.0f} kB")
return path
Example 2 โ the palette audit that ships with the module
def audit_palette(background="white", text_threshold=4.5, graphic_threshold=3.0):
"""Run this in CI. A palette change that fails is a failing build."""
import numpy as np
import matplotlib.colors as mc
def luminance(colour):
c = np.asarray(mc.to_rgb(colour), dtype=float)
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
return float(0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2])
def ratio(fg, bg):
a, b = luminance(fg), luminance(bg)
hi, lo = max(a, b), min(a, b)
return (hi + 0.05) / (lo + 0.05)
EXPECTED_ROLE = {
"ink": "text", "indigo": "text", "muted": "text",
"faint": "graphic", "rule": "decorative", "highlight": "graphic",
}
failures = []
print(f"{'token':11} {'colour':9} {'ratio':>6} role")
for name, colour in COLOURS.items():
r = ratio(colour, background)
role = ("text" if r >= text_threshold else
"graphic" if r >= graphic_threshold else "decorative")
expected = EXPECTED_ROLE.get(name)
flag = ""
if expected and role != expected:
flag = f" ! expected {expected}"
failures.append(name)
print(f"{name:11} {colour:9} {r:6.2f} {role}{flag}")
assert not failures, f"palette roles broken: {failures}"
return True
Example 3 โ using it
import geopandas as gpd
import mapstyle as ms
with ms.house_style():
fig, ax = ms.figure(width_mm=170, aspect=0.7)
water.plot(ax=ax, **ms.LAYER["water"])
context.plot(ax=ax, **ms.LAYER["context"])
districts.plot(ax=ax, column="rate", cmap=ms.RAMPS["sequential"],
legend=True,
missing_kwds={"color": ms.COLOURS["missing"], "label": "no data"},
**ms.LAYER["subject"])
coast.plot(ax=ax, **ms.LAYER["coast"])
ax.set_title("Unemployment rate by district, 2025",
fontsize=ms.SIZES["title"], loc="left", color=ms.COLOURS["ink"])
ms.provenance(fig, ["ONS mid-2025", "OS BoundaryLine 2026-04"],
"British National Grid (EPSG:27700)")
ms.export(fig, "outputs/unemployment_2025")
Nine lines of drawing code, no styling decisions, and the next map in the report is identical by construction.
Explanation
Why a module beats a stylesheet
Matplotlib style sheets (.mplstyle) cover rcParams, which is the easy half. They cannot carry a palette dictionary, a figure constructor that takes millimetres, layer styles, or the audit function.
Use both: the rcParams in the module can be exported as a stylesheet for interactive use, while the module carries everything a stylesheet cannot express.
Why the figure constructor matters more than the colours
Colour drift is visible and gets fixed. Size drift is invisible until the figures are placed, and then every point-based size in the figure is wrong at once โ an 8-inch figure in a 90 mm column scales by 0.443.
A constructor that takes the width in millimetres makes the correct thing the easy thing, which is the only reliable way to enforce a convention.
Why the contrast ratios belong in comments
A palette is a set of hex codes, which look interchangeable. The measured ratios โ 4.76:1 for muted, 2.56:1 for faint, 1.23:1 for rule โ are what tell the next person which ones can carry text and which are decorative.
Putting them in a comment beside each token, and asserting them in audit_palette(), means the constraint survives a redesign.
Why versioning the style is worth one line
Figures outlive the code that made them. A footer that records the style version turns "why does this figure look different?" into a diff between two versions rather than an investigation.
It costs one string in the provenance line, next to the source and the projection that should be there anyway.
Edge cases or notes
- Do not mutate
plt.rcParamsat import. Export the dictionary or provide a context manager. savefig.bbox: Noneis deliberate โ"tight"changes the figure's physical size.- Keep the module importable without side effects, so it can be tested.
- Run the palette audit in CI. A palette change that breaks a role should fail the build.
- Fonts are an environment dependency. Pin the family and check it exists, or figures differ between machines.
- A style module is not a plotting framework. When it grows a
plot_everything()function, split it. - Ship it with the project, not as a personal package that others cannot install.
- Record the version on the output, or two figures cannot be compared later.
Internal links
- How to build a print-ready map layout in Matplotlib โ the layout the module constructs
- How to make a map series with consistent symbology โ the module's main consumer
- How to export a map at print quality โ the export presets
- Accessible maps explained: contrast, text and alternatives โ the ratios in the comments
- How to choose and test a colour ramp in Python โ the tested ramp
- Visual hierarchy explained: what a map reader sees first โ the layer tiers
- Reproducible GIS workflows in Python โ versioning the outputs
- How to generate map images in batch โ many maps, one style
FAQ
Should I use a matplotlib style sheet or a module?
Both. A .mplstyle covers rcParams; a module carries the palette, layer styles, the figure constructor and the audit function that a stylesheet cannot express.
What belongs in a map style module?
Colour tokens with their measured contrast, ramp names, type sizes, line widths, rcParams, a figure constructor that takes millimetres, an export function and a provenance helper.
Why should the figure constructor take millimetres?
Because that is the unit the destination uses. Drawing at 8 inches and placing at 90 mm scales every size by 0.443, turning 8 pt type into 3.54 pt.
Should the module change plt.rcParams when imported?
No. Import-time side effects surprise people. Export a dictionary, or provide a context manager so the settings are scoped.
How do I stop somebody using an inaccessible colour for text?
Record the measured contrast ratio next to each token and assert the roles in an audit function that runs in CI.
Should I version the style module?
Yes, and record the version in the figure's footer. Figures outlive the code, and a version string turns a puzzling difference into a diff.