"""Simple & semi-automatic color-system for data visualization purposes."""
import re
from colorsys import hls_to_rgb, rgb_to_hls
from contextvars import ContextVar
from dataclasses import dataclass, field
from os import environ
from pathlib import Path
from typing import Any
from webcolors import IntegerRGB, hex_to_rgb, name_to_rgb, rgb_to_hex
from yaml import CLoader, load
[docs]
def default_highlights():
"""Return default highlight colors."""
return {
0: "#663399",
1: "#1E90FF",
2: "#3CB371",
}
[docs]
def default_scales():
"""Return default color scale."""
return {
0: [
(0, "#ebf6fa"),
(0.01, "#d6edf5"),
(0.03, "#99d3e6"),
(0.10, "#5cb8d6"),
(0.30, "#14d5cc"),
(1, "#35d27e"),
],
1: [
(0, "#005b7f"),
(0.15, "#39a9cd"),
(0.30, "#14d5cc"),
(1, "#35d27e"),
],
}
[docs]
@dataclass
class ColorTheme:
"""Define a color theme. Can be used as context manager to activate the theme."""
highlights: dict[Any, str] = field(default_factory=default_highlights)
"""Highlight colors for different, known categories."""
scales: dict[Any, list[tuple[float, str]]] = field(default_factory=default_scales)
"""Named or numbered color sclales."""
def __post_init__(self): # noqa: D105
self.__token = None
[docs]
def activate(self):
"""Set this as the current theme."""
self.__token = active_theme.set(self)
def __enter__(self): # noqa: D105
self.activate()
return self
def __exit__(self, *_): # noqa: D105
if self.__token is not None:
active_theme.reset(self.__token)
active_theme: ContextVar[ColorTheme] = ContextVar(
"active_color_theme", default=ColorTheme()
)
def _parse_css_color(color: str) -> IntegerRGB:
if (rgb_match := re.match(r"^rgb\(([0-9]+,[0-9]+,[0-9]+)\)$", color)) is not None:
return IntegerRGB(*(int(val) for val in rgb_match.groups()[0].split(",")))
elif re.match(r"^#[0-9abcdefABCDEF]{3,6}$", color) is not None:
return hex_to_rgb(color)
else:
try:
return name_to_rgb(color)
except ValueError as exc:
raise ValueError(f"CSS color definition not recognized: {color}") from exc
def _integer_rgb_to_float(color: IntegerRGB) -> tuple[float, float, float]:
return (color.red / 255, color.green / 255, color.blue / 255)
def _float_to_integer_rgb(color: tuple[float, float, float]) -> IntegerRGB:
return IntegerRGB(*(int(c * 255) for c in color))
def _adjust_lightness(color: IntegerRGB, lightness: float) -> IntegerRGB:
hls = rgb_to_hls(*_integer_rgb_to_float(color))
changed = (hls[0], lightness, hls[2])
return _float_to_integer_rgb(hls_to_rgb(*changed))
[docs]
def get_theme() -> ColorTheme:
"""Return active color theme (may be derived from current execution context)."""
return active_theme.get()
[docs]
def to_bg_color(color: str, lightness: float = 0.8) -> str:
"""Turn a highlight color into a background color."""
return rgb_to_hex(_adjust_lightness(_parse_css_color(color), lightness))
default_file_path = Path(environ.get("COLOR_FILE_PATH") or (Path.cwd() / "colors.yaml"))
"""Default path to color theme file."""
[docs]
def load_from_file(path: Path | str | None = None) -> Path | None:
"""Load color theme from file.
Args:
path: Path to file. If None, the default file path is used.
Returns:
Path to the loaded file, if not given as argument.
"""
res_path = path or default_file_path
with open(default_file_path, encoding="utf-8") as f:
active_theme.set(ColorTheme(**load(f, Loader=CLoader)))
if path is None:
return Path(res_path)