Source code for episia.viz.themes.registry

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