"""
viz/themes/registry.py - Theme management for Episia visualizations.
Manages both Matplotlib .mplstyle themes and Plotly layout templates.
Provides a unified set_theme() / get_theme() API used by both backends.
Available themes
----------------
scientific — publication-ready, clean, high-contrast (default)
minimal — ultra-clean, no grid, maximum whitespace
dark — dark background for dashboards and presentations
colorblind — Wong (2011) accessible palette + distinct line styles
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
import matplotlib as _mpl
import matplotlib.style as _mpl_style
# Theme registry
_THEME_DIR = os.path.join(os.path.dirname(__file__))
# All built-in theme names
AVAILABLE_THEMES: List[str] = ["scientific", "minimal", "dark", "colorblind"]
# Currently active theme (module-level state)
_active_theme: str = "scientific"
# Plotly colour palettes per theme (mirrors plotly_plotter._PALETTES)
_PLOTLY_PALETTES: Dict[str, List[str]] = {
"scientific": ["#1f77b4", "#d62728", "#2ca02c", "#ff7f0e", "#9467bd",
"#8c564b", "#e377c2", "#7f7f7f"],
"minimal": ["#333333", "#888888", "#bbbbbb", "#555555", "#aaaaaa"],
"dark": ["#64b5f6", "#ef5350", "#66bb6a", "#ffa726", "#ab47bc",
"#26c6da", "#d4e157", "#ff7043"],
"colorblind": ["#0072B2", "#E69F00", "#56B4E9", "#009E73", "#F0E442",
"#D55E00", "#CC79A7", "#999999"],
}
# Plotly paper/plot background per theme
_PLOTLY_BG: Dict[str, Dict[str, str]] = {
"scientific": {"paper": "#ffffff", "plot": "#ffffff", "font": "#222222"},
"minimal": {"paper": "#ffffff", "plot": "#ffffff", "font": "#333333"},
"dark": {"paper": "#1e1e2e", "plot": "#1e1e2e", "font": "#eeeeee"},
"colorblind": {"paper": "#ffffff", "plot": "#ffffff", "font": "#222222"},
}
# Matplotlib built-in fallbacks when .mplstyle file is empty / missing
_MPL_FALLBACKS: Dict[str, str] = {
"scientific": "seaborn-v0_8-paper",
"minimal": "seaborn-v0_8-white",
"dark": "dark_background",
"colorblind": "seaborn-v0_8-colorblind",
}
# Public API
[docs]
def set_theme(theme: str) -> None:
"""
Set the active Episia theme globally.
Applies the Matplotlib style if available silently skips if
matplotlib.style is unavailable in the current environment.
Args:
theme: One of 'scientific', 'minimal', 'dark', 'colorblind'.
Raises:
ValueError: Unknown theme name.
"""
global _active_theme
_validate(theme)
_active_theme = theme
try:
_apply_mpl(theme)
except Exception:
pass # Matplotlib theming is best-effort — never crash set_theme()
[docs]
def get_theme() -> str:
"""Return the currently active theme name."""
return _active_theme
[docs]
def get_available_themes() -> List[str]:
"""Return list of all available theme names."""
return AVAILABLE_THEMES.copy()
[docs]
def get_palette(theme: Optional[str] = None) -> List[str]:
"""
Return the colour palette for a given theme (or the active theme).
Args:
theme: Theme name. Defaults to active theme.
Returns:
List of hex colour strings.
"""
t = theme or _active_theme
_validate(t)
return _PLOTLY_PALETTES[t].copy()
[docs]
def get_plotly_layout(theme: Optional[str] = None) -> Dict[str, Any]:
"""
Return a base Plotly layout dict for a given theme.
Intended for use inside plotly_plotter provides consistent
background colours and font colours per theme.
Args:
theme: Theme name. Defaults to active theme.
Returns:
Dict suitable for go.Figure(layout=...) or fig.update_layout().
"""
t = theme or _active_theme
_validate(t)
bg = _PLOTLY_BG[t]
return {
"paper_bgcolor": bg["paper"],
"plot_bgcolor": bg["plot"],
"font": {"color": bg["font"]},
"colorway": _PLOTLY_PALETTES[t],
}
[docs]
def apply_mpl_theme(theme: Optional[str] = None) -> None:
"""
Apply Matplotlib style for the given (or active) theme.
Called automatically by MatplotlibPlotter before each plot.
Can be called manually to affect any subsequent plt calls.
Args:
theme: Theme name. Defaults to active theme.
"""
t = theme or _active_theme
_validate(t)
_apply_mpl(t)
[docs]
def register_theme(
name: str,
palette: List[str],
mplstyle_path: Optional[str] = None,
bg_paper: str = "#ffffff",
bg_plot: str = "#ffffff",
font_color: str = "#222222",
) -> None:
"""
Register a custom theme at runtime.
Args:
name: Theme identifier (must be unique).
palette: List of hex colour strings (min 4).
mplstyle_path: Path to a .mplstyle file (optional).
bg_paper: Plotly paper background colour.
bg_plot: Plotly plot background colour.
font_color: Default font colour for Plotly.
Raises:
ValueError: palette has fewer than 4 colours.
Example::
register_theme(
"xcept",
palette=["#0d6efd", "#dc3545", "#198754", "#ffc107"],
bg_paper="#f8f9fa",
)
set_theme("xcept")
"""
if len(palette) < 4:
raise ValueError("palette must have at least 4 colours.")
AVAILABLE_THEMES.append(name)
_PLOTLY_PALETTES[name] = palette
_PLOTLY_BG[name] = {
"paper": bg_paper, "plot": bg_plot, "font": font_color,
}
if mplstyle_path:
_MPL_FALLBACKS[name] = mplstyle_path # treated as a path by _apply_mpl
else:
_MPL_FALLBACKS[name] = "default"
# Internal helpers
def _validate(theme: str) -> None:
if theme not in AVAILABLE_THEMES:
raise ValueError(
f"Unknown theme '{theme}'. "
f"Available: {AVAILABLE_THEMES}"
)
def _apply_mpl(theme: str) -> None:
"""
Apply Matplotlib style .mplstyle file first, then built-in fallbacks.
Completely silent on failure a missing style never crashes the user.
"""
# Ensure matplotlib.style is importable (lazy import guard)
try:
import matplotlib.style as _style
except ImportError:
return
# 1. Try the bundled .mplstyle file
style_path = os.path.join(_THEME_DIR, f"{theme}.mplstyle")
if os.path.isfile(style_path) and os.path.getsize(style_path) > 0:
try:
_style.use(style_path)
return
except Exception:
pass
# 2. Try the named fallback
fallback = _MPL_FALLBACKS.get(theme, "default")
if os.path.isfile(str(fallback)):
try:
_style.use(fallback)
return
except Exception:
pass
# 3. Try the fallback name directly
try:
_style.use(fallback)
return
except Exception:
pass
# 4. Try "default" as last resort
try:
_style.use("default")
except Exception:
pass # give up silently matplotlib will use its own defaults