Source code for ergodic_insurance.visualization.style_manager

"""Style management for consistent visualization across all reports.

This module provides centralized style configuration for all visualizations,
including color palettes, fonts, figure sizes, and DPI settings.
"""

__all__ = [
    "StyleManager",
    "Theme",
    "ColorPalette",
    "FontConfig",
    "FigureConfig",
    "GridConfig",
]

from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union

import matplotlib.pyplot as plt
import yaml


[docs] class Theme(Enum): """Available visualization themes.""" DEFAULT = "default" COLORBLIND = "colorblind" PRESENTATION = "presentation" MINIMAL = "minimal" PRINT = "print"
[docs] @dataclass class ColorPalette: """Color palette configuration for a theme. Attributes: primary: Main color for primary elements secondary: Secondary color for supporting elements accent: Accent color for highlights warning: Color for warnings or negative values success: Color for positive values or success states neutral: Neutral gray tones background: Background color text: Text color grid: Grid line color series: List of colors for multiple data series """ primary: str = "#0080C7" # Corporate blue secondary: str = "#003F5C" # Dark blue accent: str = "#FF9800" # Orange warning: str = "#D32F2F" # Red success: str = "#4CAF50" # Green neutral: str = "#666666" # Gray background: str = "#FFFFFF" # White text: str = "#000000" # Black grid: str = "#E0E0E0" # Light gray series: List[str] = field( default_factory=lambda: [ "#0080C7", "#D32F2F", "#4CAF50", "#FF9800", "#7B1FA2", "#00796B", "#003F5C", "#FFD700", ] )
[docs] @dataclass class FontConfig: """Font configuration for a theme. Attributes: family: Font family name size_base: Base font size size_title: Title font size size_label: Label font size size_tick: Tick label font size size_legend: Legend font size weight_normal: Normal font weight weight_bold: Bold font weight """ family: str = "Arial" size_base: int = 11 size_title: int = 14 size_label: int = 12 size_tick: int = 10 size_legend: int = 10 weight_normal: str = "normal" weight_bold: str = "bold"
[docs] @dataclass class FigureConfig: """Figure size and DPI configuration. Attributes: size_small: Small figure size (width, height) in inches size_medium: Medium figure size size_large: Large figure size size_blog: Blog-optimized size (8x6) size_technical: Technical appendix size (10x8) size_presentation: Presentation slide size dpi_screen: DPI for screen display dpi_web: DPI for web publishing (150) dpi_print: DPI for print quality (300) """ size_small: Tuple[float, float] = (6, 4) size_medium: Tuple[float, float] = (8, 6) size_large: Tuple[float, float] = (12, 8) size_blog: Tuple[float, float] = (8, 6) size_technical: Tuple[float, float] = (10, 8) size_presentation: Tuple[float, float] = (10, 7.5) dpi_screen: int = 100 dpi_web: int = 150 dpi_print: int = 300
[docs] @dataclass class GridConfig: """Grid and axis configuration. Attributes: show_grid: Whether to show grid lines grid_alpha: Grid transparency grid_linewidth: Grid line width spine_top: Show top spine spine_right: Show right spine spine_bottom: Show bottom spine spine_left: Show left spine spine_linewidth: Spine line width tick_major_width: Major tick width tick_minor_width: Minor tick width """ show_grid: bool = True grid_alpha: float = 0.3 grid_linewidth: float = 0.5 spine_top: bool = False spine_right: bool = False spine_bottom: bool = True spine_left: bool = True spine_linewidth: float = 0.8 tick_major_width: float = 0.8 tick_minor_width: float = 0.4
[docs] class StyleManager: """Manages visualization styles and themes. This class provides centralized style management for all visualizations, supporting multiple themes, custom configurations, and style inheritance. Example: >>> style_mgr = StyleManager() >>> style_mgr.set_theme(Theme.PRESENTATION) >>> style_mgr.apply_style() >>> # Create plots with consistent styling >>> # Or with custom configuration >>> style_mgr = StyleManager(config_path="custom_style.yaml") >>> style_mgr.apply_style() """ def __init__( self, theme: Theme = Theme.DEFAULT, config_path: Optional[Union[str, Path]] = None, custom_colors: Optional[Dict[str, str]] = None, custom_fonts: Optional[Dict[str, Any]] = None, ): """Initialize style manager. Args: theme: Initial theme to use config_path: Path to YAML configuration file custom_colors: Custom color overrides custom_fonts: Custom font overrides """ self.current_theme = theme self.themes: Dict[Theme, Dict[str, Any]] = self._initialize_themes() # Load custom configuration if provided if config_path: self.load_config(config_path) # Apply custom overrides if custom_colors: self.update_colors(custom_colors) if custom_fonts: self.update_fonts(custom_fonts) def _initialize_themes(self) -> Dict[Theme, Dict[str, Any]]: """Initialize built-in themes. Returns: Dictionary mapping themes to their configurations """ themes = {} # Default corporate theme themes[Theme.DEFAULT] = { "colors": ColorPalette(), "fonts": FontConfig(), "figure": FigureConfig(), "grid": GridConfig(), } # Colorblind-friendly theme themes[Theme.COLORBLIND] = { "colors": ColorPalette( primary="#0173B2", # Blue secondary="#DE8F05", # Orange accent="#029E73", # Green warning="#CC78BC", # Light purple success="#56B4E9", # Sky blue series=[ "#0173B2", "#DE8F05", "#029E73", "#CC78BC", "#ECE133", "#56B4E9", "#949494", "#FBE5D6", ], ), "fonts": FontConfig(), "figure": FigureConfig(), "grid": GridConfig(), } # Presentation theme (larger fonts, bolder colors) themes[Theme.PRESENTATION] = { "colors": ColorPalette( primary="#003F7F", # Darker blue warning="#FF0000", # Bright red success="#00AA00", # Bright green ), "fonts": FontConfig( size_base=14, size_title=18, size_label=16, size_tick=12, size_legend=12, ), "figure": FigureConfig( size_medium=(10, 7.5), size_large=(14, 10), ), "grid": GridConfig(grid_alpha=0.2), } # Minimal theme themes[Theme.MINIMAL] = { "colors": ColorPalette( primary="#333333", secondary="#666666", accent="#999999", warning="#CC0000", success="#006600", series=["#333333", "#666666", "#999999", "#CCCCCC"], ), "fonts": FontConfig(family="Helvetica"), "figure": FigureConfig(), "grid": GridConfig( show_grid=False, spine_linewidth=0.5, ), } # Print theme (high contrast, thicker lines) themes[Theme.PRINT] = { "colors": ColorPalette( background="#FFFFFF", text="#000000", grid="#CCCCCC", ), "fonts": FontConfig( size_base=10, size_title=12, size_label=11, ), "figure": FigureConfig( dpi_screen=300, dpi_web=300, dpi_print=600, ), "grid": GridConfig( grid_linewidth=0.3, spine_linewidth=1.0, tick_major_width=1.0, ), } return themes
[docs] def set_theme(self, theme: Theme) -> None: """Set the current theme. Args: theme: Theme to activate """ if theme not in self.themes: raise ValueError(f"Unknown theme: {theme}") self.current_theme = theme
[docs] def get_theme_config(self, theme: Optional[Theme] = None) -> Dict[str, Any]: """Get configuration for a theme. Args: theme: Theme to get config for (defaults to current) Returns: Theme configuration dictionary """ theme = theme or self.current_theme return self.themes[theme].copy()
[docs] def get_colors(self) -> ColorPalette: """Get current color palette. Returns: Current theme's color palette """ colors = self.themes[self.current_theme]["colors"] assert isinstance(colors, ColorPalette) return colors
[docs] def get_fonts(self) -> FontConfig: """Get current font configuration. Returns: Current theme's font configuration """ fonts = self.themes[self.current_theme]["fonts"] assert isinstance(fonts, FontConfig) return fonts
[docs] def get_figure_config(self) -> FigureConfig: """Get current figure configuration. Returns: Current theme's figure configuration """ figure = self.themes[self.current_theme]["figure"] assert isinstance(figure, FigureConfig) return figure
[docs] def get_grid_config(self) -> GridConfig: """Get current grid configuration. Returns: Current theme's grid configuration """ grid = self.themes[self.current_theme]["grid"] assert isinstance(grid, GridConfig) return grid
[docs] def update_colors(self, updates: Dict[str, str]) -> None: """Update colors in current theme. Args: updates: Dictionary of color updates """ colors = self.get_colors() for key, value in updates.items(): if hasattr(colors, key): setattr(colors, key, value)
[docs] def update_fonts(self, updates: Dict[str, Any]) -> None: """Update fonts in current theme. Args: updates: Dictionary of font updates """ fonts = self.get_fonts() for key, value in updates.items(): if hasattr(fonts, key): setattr(fonts, key, value)
[docs] def apply_style(self) -> None: """Apply current theme to matplotlib. This updates matplotlib's rcParams to match the current theme settings. """ colors = self.get_colors() fonts = self.get_fonts() grid = self.get_grid_config() # Set matplotlib style plt.style.use("seaborn-v0_8-whitegrid") # Update rcParams plt.rcParams.update( { # Font settings "font.family": "sans-serif", "font.sans-serif": [fonts.family, "Arial", "Helvetica", "DejaVu Sans"], "font.size": fonts.size_base, "axes.titlesize": fonts.size_title, "axes.labelsize": fonts.size_label, "xtick.labelsize": fonts.size_tick, "ytick.labelsize": fonts.size_tick, "legend.fontsize": fonts.size_legend, "figure.titlesize": fonts.size_title + 2, # Spine settings "axes.spines.top": grid.spine_top, "axes.spines.right": grid.spine_right, "axes.spines.left": grid.spine_left, "axes.spines.bottom": grid.spine_bottom, "axes.edgecolor": colors.neutral, "axes.linewidth": grid.spine_linewidth, # Grid settings "axes.grid": grid.show_grid, "grid.color": colors.grid, "grid.linewidth": grid.grid_linewidth, "grid.alpha": grid.grid_alpha, # Line settings "lines.linewidth": 2, "patch.linewidth": 0.5, # Tick settings "xtick.major.width": grid.tick_major_width, "ytick.major.width": grid.tick_major_width, "xtick.minor.width": grid.tick_minor_width, "ytick.minor.width": grid.tick_minor_width, # Figure settings "figure.facecolor": colors.background, "axes.facecolor": colors.background, # Color cycle "axes.prop_cycle": plt.cycler("color", colors.series), } )
[docs] def get_figure_size( self, size_type: str = "medium", orientation: str = "landscape" ) -> Tuple[float, float]: """Get figure size for a given type. Args: size_type: Size type (small, medium, large, blog, technical, presentation) orientation: Figure orientation (landscape or portrait) Returns: Tuple of (width, height) in inches """ fig_config = self.get_figure_config() size_map = { "small": fig_config.size_small, "medium": fig_config.size_medium, "large": fig_config.size_large, "blog": fig_config.size_blog, "technical": fig_config.size_technical, "presentation": fig_config.size_presentation, } size = size_map.get(size_type, fig_config.size_medium) if orientation == "portrait": return (size[1], size[0]) return size
[docs] def get_dpi(self, output_type: str = "screen") -> int: """Get DPI for output type. Args: output_type: Output type (screen, web, print) Returns: DPI value """ fig_config = self.get_figure_config() dpi_map = { "screen": fig_config.dpi_screen, "web": fig_config.dpi_web, "print": fig_config.dpi_print, } return dpi_map.get(output_type, fig_config.dpi_screen)
[docs] def load_config(self, config_path: Union[str, Path]) -> None: """Load configuration from YAML file. Args: config_path: Path to YAML configuration file """ config_path = Path(config_path) if not config_path.exists(): raise FileNotFoundError(f"Config file not found: {config_path}") with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) # Update theme configurations if "themes" not in config: return for theme_name, theme_config in config["themes"].items(): self._update_theme_from_config(theme_name, theme_config)
def _update_theme_from_config(self, theme_name: str, theme_config: Dict[str, Any]) -> None: """Update a single theme from configuration. Args: theme_name: Name of the theme theme_config: Configuration for the theme """ theme = Theme[theme_name.upper()] if theme not in self.themes: self.themes[theme] = { "colors": ColorPalette(), "fonts": FontConfig(), "figure": FigureConfig(), "grid": GridConfig(), } # Update each component self._update_component(self.themes[theme]["colors"], theme_config.get("colors", {})) self._update_component(self.themes[theme]["fonts"], theme_config.get("fonts", {})) self._update_figure_component(self.themes[theme]["figure"], theme_config.get("figure", {})) self._update_component(self.themes[theme]["grid"], theme_config.get("grid", {})) def _update_component(self, component: Any, config_dict: Dict[str, Any]) -> None: """Update a component with configuration values. Args: component: Component to update config_dict: Configuration dictionary """ for key, value in config_dict.items(): if hasattr(component, key): setattr(component, key, value) def _update_figure_component(self, component: Any, config_dict: Dict[str, Any]) -> None: """Update figure component with special handling for sizes. Args: component: Figure component to update config_dict: Configuration dictionary """ for key, value in config_dict.items(): if hasattr(component, key): # Handle tuple conversions for sizes if "size" in key and isinstance(value, list): value = tuple(value) setattr(component, key, value)
[docs] def save_config(self, config_path: Union[str, Path]) -> None: """Save current configuration to YAML file. Args: config_path: Path to save YAML configuration """ config_path = Path(config_path) # Build configuration dictionary config: Dict[str, Any] = {"themes": {}} for theme, theme_config in self.themes.items(): config["themes"][theme.value] = { "colors": { key: ( list(val) if isinstance(val := getattr(theme_config["colors"], key), tuple) else val ) for key in theme_config["colors"].__dataclass_fields__ }, "fonts": { key: ( list(val) if isinstance(val := getattr(theme_config["fonts"], key), tuple) else val ) for key in theme_config["fonts"].__dataclass_fields__ }, "figure": { key: ( list(val) if isinstance(val := getattr(theme_config["figure"], key), tuple) else val ) for key in theme_config["figure"].__dataclass_fields__ }, "grid": { key: ( list(val) if isinstance(val := getattr(theme_config["grid"], key), tuple) else val ) for key in theme_config["grid"].__dataclass_fields__ }, } # Save to file config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, "w", encoding="utf-8") as f: yaml.dump(config, f, default_flow_style=False)
[docs] def create_style_sheet(self) -> Dict[str, Any]: """Create matplotlib style sheet dictionary. Returns: Style sheet dictionary compatible with matplotlib """ colors = self.get_colors() fonts = self.get_fonts() grid = self.get_grid_config() return { # Font "font.family": "sans-serif", "font.sans-serif": [fonts.family], "font.size": fonts.size_base, # Axes "axes.titlesize": fonts.size_title, "axes.labelsize": fonts.size_label, "axes.edgecolor": colors.neutral, "axes.linewidth": grid.spine_linewidth, "axes.grid": grid.show_grid, "axes.spines.top": grid.spine_top, "axes.spines.right": grid.spine_right, "axes.spines.bottom": grid.spine_bottom, "axes.spines.left": grid.spine_left, "axes.facecolor": colors.background, "axes.prop_cycle": f"cycler('color', {colors.series})", # Grid "grid.color": colors.grid, "grid.linewidth": grid.grid_linewidth, "grid.alpha": grid.grid_alpha, # Ticks "xtick.labelsize": fonts.size_tick, "ytick.labelsize": fonts.size_tick, "xtick.major.width": grid.tick_major_width, "ytick.major.width": grid.tick_major_width, # Legend "legend.fontsize": fonts.size_legend, # Figure "figure.facecolor": colors.background, "figure.titlesize": fonts.size_title + 2, }
[docs] def inherit_from(self, parent_theme: Theme, modifications: Dict[str, Any]) -> Theme: """Create a new theme inheriting from a parent with modifications. Args: parent_theme: Theme to inherit from modifications: Dictionary of modifications Returns: New theme enum value """ # This would typically create a new custom theme # For now, we'll just modify the current theme import copy new_config = copy.deepcopy(self.themes[parent_theme]) # Apply modifications for key, value in modifications.items(): if key == "colors" and isinstance(value, dict): for color_key, color_value in value.items(): if hasattr(new_config["colors"], color_key): setattr(new_config["colors"], color_key, color_value) elif key == "fonts" and isinstance(value, dict): for font_key, font_value in value.items(): if hasattr(new_config["fonts"], font_key): setattr(new_config["fonts"], font_key, font_value) # Store as a custom theme (would need enum extension in production) self.themes[Theme.DEFAULT] = new_config return Theme.DEFAULT