""" 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())