levineuwirth.org/tools/viz_theme.py

115 lines
3.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
viz_theme.py — Shared matplotlib setup for levineuwirth.org figures.
Usage in a figure script:
import sys
sys.path.insert(0, 'tools') # relative to project root (where cabal runs)
from viz_theme import apply_monochrome, save_svg
apply_monochrome()
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [4, 5, 6])
ax.set_xlabel("x")
ax.set_ylabel("y")
save_svg(fig) # writes SVG to stdout; Viz.hs captures it
Design constraints
------------------
- Use pure black (#000000) for all drawn elements (lines, markers, text,
spines, ticks). Filters.Viz.processColors replaces these with
`currentColor` so the SVG adapts to light/dark mode via CSS.
- Use transparent backgrounds (figure and axes). The page background
shows through, so the figure integrates cleanly in both modes.
- For greyscale fills (bars, areas), use values in the range #333#ccc.
These do NOT get replaced by processColors, so choose mid-greys that
remain legible in both light (#faf8f4) and dark (#121212) contexts.
- For multi-series charts, distinguish series by linestyle (solid, dashed,
dotted, dash-dot) rather than colour.
Font note: matplotlib's SVG output uses the font names configured here, but
those fonts are not available in the browser SVG renderer — the browser falls
back to its default serif. Do not rely on font metrics for sizing.
"""
import sys
import io
import matplotlib as mpl
import matplotlib.pyplot as plt
# Greyscale linestyle cycle for multi-series charts.
# Each entry: (color, linestyle) — all black, distinguished by dash pattern.
LINESTYLE_CYCLE = [
{'color': '#000000', 'linestyle': 'solid'},
{'color': '#000000', 'linestyle': 'dashed'},
{'color': '#000000', 'linestyle': 'dotted'},
{'color': '#000000', 'linestyle': (0, (5, 2, 1, 2))}, # dash-dot
{'color': '#555555', 'linestyle': 'solid'},
{'color': '#555555', 'linestyle': 'dashed'},
]
def apply_monochrome():
"""Configure matplotlib for monochrome, transparent, dark-mode-safe output.
Call this before creating any figures. All element colours are set to
pure black (#000000) so Filters.Viz.processColors can replace them with
CSS currentColor. Backgrounds are transparent.
"""
mpl.rcParams.update({
# Transparent backgrounds — CSS page background shows through.
'figure.facecolor': 'none',
'axes.facecolor': 'none',
'savefig.facecolor': 'none',
'savefig.edgecolor': 'none',
# All text and structural elements: pure black → currentColor.
'text.color': 'black',
'axes.labelcolor': 'black',
'axes.edgecolor': 'black',
'xtick.color': 'black',
'ytick.color': 'black',
# Grid: mid-grey, stays legible in both modes (not replaced).
'axes.grid': False,
'grid.color': '#cccccc',
'grid.linewidth': 0.6,
# Lines and patches: black → currentColor.
'lines.color': 'black',
'patch.edgecolor': 'black',
# Legend: no box frame; background transparent.
'legend.frameon': False,
'legend.facecolor': 'none',
'legend.edgecolor': 'none',
# Use linestyle cycle instead of colour cycle for series distinction.
'axes.prop_cycle': mpl.cycler(
color=[c['color'] for c in LINESTYLE_CYCLE],
linestyle=[c['linestyle'] for c in LINESTYLE_CYCLE],
),
})
def save_svg(fig, tight=True):
"""Write *fig* as SVG to stdout and close it.
Hakyll's Viz filter captures stdout and inlines the SVG.
Parameters
----------
fig : matplotlib.figure.Figure
tight : bool
If True (default), call fig.tight_layout() before saving.
"""
if tight:
fig.tight_layout()
buf = io.StringIO()
fig.savefig(buf, format='svg', bbox_inches='tight', transparent=True)
plt.close(fig)
sys.stdout.write(buf.getvalue())