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