chwellofficial/nt360Slides
0
1from __future__ import annotations2 3import math4import random5from dataclasses import dataclass6from typing import Dict, Optional7 8from models.theme_data import GeneratedColorPalette9 10IS_DARK_BELOW = 0.6511BACKGROUND_RETRIES = 20012TEXT_RETRIES = 20013 14LIGHTNESS_VALUES: Dict[str, float] = {15 "50": 0.97,16 "100": 0.93,17 "200": 0.86,18 "300": 0.78,19 "400": 0.70,20 "500": 0.62,21 "600": 0.54,22 "700": 0.46,23 "800": 0.38,24 "900": 0.30,25}26 27 28@dataclass(frozen=True)29class Oklch:30 l: float # noqa: E74131 c: float32 h: float33 34 35def _clamp(value: float, min_value: float = 0.0, max_value: float = 1.0) -> float:36 return max(min_value, min(max_value, value))37 38 39def _get_random_value(min_value: float, max_value: float) -> float:40 return min_value + random.random() * (max_value - min_value)41 42 43def _get_random_value_at_min_max_distance(44 base_value: float,45 min_value: float,46 max_value: float,47 min_distance: Optional[float] = None,48 max_distance: Optional[float] = None,49) -> float:50 normalized_min_distance = max(0.0, min_distance or 0.0)51 normalized_max_distance = max_distance if max_distance is not None else math.inf52 min_dist = min(normalized_min_distance, normalized_max_distance)53 max_dist = max(normalized_min_distance, normalized_max_distance)54 55 lower_start = max(min_value, base_value - max_dist)56 lower_end = min(max_value, base_value - min_dist)57 upper_start = max(min_value, base_value + min_dist)58 upper_end = min(max_value, base_value + max_dist)59 60 lower_size = max(0.0, lower_end - lower_start)61 upper_size = max(0.0, upper_end - upper_start)62 total_size = lower_size + upper_size63 64 if total_size <= 0:65 return _get_random_value(min_value, max_value)66 67 picker = random.random() * total_size68 if picker < lower_size:69 return _get_random_value(lower_start, lower_end)70 71 return _get_random_value(upper_start, upper_end)72 73 74def _srgb_to_linear(channel: float) -> float:75 if channel <= 0.04045:76 return channel / 12.9277 return ((channel + 0.055) / 1.055) ** 2.478 79 80def _linear_to_srgb(channel: float) -> float:81 if channel <= 0.0031308:82 return 12.92 * channel83 return 1.055 * (channel ** (1 / 2.4)) - 0.05584 85 86def _oklch_to_srgb(color: Oklch) -> tuple[float, float, float]:87 hue_rad = math.radians(color.h % 360)88 a = color.c * math.cos(hue_rad)89 b = color.c * math.sin(hue_rad)90 91 l_ = (color.l + 0.3963377774 * a + 0.2158037573 * b) ** 392 m_ = (color.l - 0.1055613458 * a - 0.0638541728 * b) ** 393 s_ = (color.l - 0.0894841775 * a - 1.2914855480 * b) ** 394 95 r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_96 g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_97 b = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_98 99 return (100 _clamp(_linear_to_srgb(r)),101 _clamp(_linear_to_srgb(g)),102 _clamp(_linear_to_srgb(b)),103 )104 105 106def _srgb_to_oklch(r: float, g: float, b: float) -> Oklch:107 r_lin = _srgb_to_linear(r)108 g_lin = _srgb_to_linear(g)109 b_lin = _srgb_to_linear(b)110 111 l_ = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin112 m_ = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin113 s_ = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin114 115 l_cbrt = math.copysign(abs(l_) ** (1 / 3), l_)116 m_cbrt = math.copysign(abs(m_) ** (1 / 3), m_)117 s_cbrt = math.copysign(abs(s_) ** (1 / 3), s_)118 119 lightness = 0.2104542553 * l_cbrt + 0.7936177850 * m_cbrt - 0.0040720468 * s_cbrt120 a = 1.9779984951 * l_cbrt - 2.4285922050 * m_cbrt + 0.4505937099 * s_cbrt121 b = 0.0259040371 * l_cbrt + 0.7827717662 * m_cbrt - 0.8086757660 * s_cbrt122 123 chroma = math.hypot(a, b)124 hue = math.degrees(math.atan2(b, a)) % 360125 126 return Oklch(l=lightness, c=chroma, h=hue)127 128 129def _hex_to_oklch(hex_value: str) -> Oklch:130 hex_value = hex_value.strip().lstrip("#")131 if len(hex_value) != 6:132 raise ValueError(f"Invalid hex color: {hex_value!r}")133 r = int(hex_value[0:2], 16) / 255.0134 g = int(hex_value[2:4], 16) / 255.0135 b = int(hex_value[4:6], 16) / 255.0136 return _srgb_to_oklch(r, g, b)137 138 139def _format_hex(color: Oklch) -> str:140 r, g, b = _oklch_to_srgb(color)141 return "#{:02x}{:02x}{:02x}".format(142 int(round(r * 255)),143 int(round(g * 255)),144 int(round(b * 255)),145 )146 147 148def _relative_luminance(color: Oklch) -> float:149 r, g, b = _oklch_to_srgb(color)150 r_lin = _srgb_to_linear(r)151 g_lin = _srgb_to_linear(g)152 b_lin = _srgb_to_linear(b)153 return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin154 155 156def _wcag_contrast(a: Oklch, b: Oklch) -> float:157 l1 = _relative_luminance(a)158 l2 = _relative_luminance(b)159 lighter = max(l1, l2)160 darker = min(l1, l2)161 return (lighter + 0.05) / (darker + 0.05)162 163 164def _get_color_for_all_lightness_values(base_color: Oklch) -> Dict[str, str]:165 colors: Dict[str, str] = {}166 for name, value in LIGHTNESS_VALUES.items():167 color = Oklch(l=value, c=base_color.c, h=base_color.h)168 colors[name] = _format_hex(color)169 return colors170 171 172def _generate_primary_color() -> Oklch:173 lightness = _get_random_value(0.0, 1.0)174 chroma = _get_random_value(0.0, 0.4)175 hue = _get_random_value(0.0, 360.0)176 return Oklch(l=lightness, c=chroma, h=hue)177 178 179def _generate_background_color(base_color: Oklch) -> Oklch:180 for _ in range(BACKGROUND_RETRIES):181 lightness = _get_random_value(0.0, 1.0)182 chroma = _get_random_value(0.0, 0.4)183 hue = _get_random_value(0.0, 360.0)184 color = Oklch(l=lightness, c=chroma, h=hue)185 if _wcag_contrast(color, base_color) >= 6:186 return color187 188 if base_color.l < IS_DARK_BELOW:189 return Oklch(l=1.0, c=0.0, h=0.0)190 return Oklch(l=0.0, c=0.0, h=0.0)191 192 193def _generate_accent_color(base_color: Oklch, n: int) -> Oklch:194 lightness = _get_random_value_at_min_max_distance(base_color.l, 0.0, 1.0, 0.0, 0.1)195 chroma = _get_random_value_at_min_max_distance(base_color.c, 0.0, 0.4, 0.0, 0.4)196 hue = _get_random_value_at_min_max_distance(197 base_color.h if base_color.h is not None else 0.0,198 0.0,199 360.0,200 n * 90.0,201 (n + 1) * 90.0,202 )203 return Oklch(l=lightness, c=chroma, h=hue)204 205 206def _generate_text_color(base_color: Oklch, text_type: str) -> Oklch:207 is_base_dark = base_color.l < IS_DARK_BELOW208 209 for _ in range(TEXT_RETRIES):210 if text_type == "text_1":211 lightness = (212 _get_random_value(0.8, 1.0)213 if is_base_dark214 else _get_random_value(0.0, 0.2)215 )216 chroma = _get_random_value(0.0, 0.02)217 elif text_type == "text_2":218 lightness = (219 _get_random_value(0.8, 1.0)220 if is_base_dark221 else _get_random_value(0.0, 0.2)222 )223 chroma = _get_random_value(0.0, 0.04)224 else:225 raise ValueError(f"Invalid text type: {text_type}")226 227 hue = _get_random_value(0.0, 360.0)228 color = Oklch(l=lightness, c=chroma, h=hue)229 230 min_contrast = 6.0231 max_contrast = None232 contrast = _wcag_contrast(color, base_color)233 234 if contrast >= min_contrast and (235 max_contrast is None or contrast <= max_contrast236 ):237 return color238 239 if base_color.l < IS_DARK_BELOW:240 return Oklch(l=1.0 if text_type == "text_1" else 0.9, c=0.0, h=0.0)241 return Oklch(l=0.0 if text_type == "text_1" else 0.1, c=0.0, h=0.0)242 243 244def get_lightness_key_at_distance(245 value: float,246 min_distance: Optional[int] = None,247 max_distance: Optional[int] = None,248 prefer_dark: Optional[bool] = None,249) -> str:250 items = sorted(LIGHTNESS_VALUES.items(), key=lambda item: item[1])251 252 nearest_index = 0253 nearest_distance = abs(items[0][1] - value)254 for index, (_, lightness) in enumerate(items[1:], start=1):255 distance = abs(lightness - value)256 if distance < nearest_distance or (257 distance == nearest_distance and lightness < items[nearest_index][1]258 ):259 nearest_index = index260 nearest_distance = distance261 262 normalized_min = max(0, min_distance or 0)263 normalized_max = max_distance if max_distance is not None else normalized_min264 if normalized_max < normalized_min:265 normalized_min, normalized_max = normalized_max, normalized_min266 267 candidate_indices = []268 for distance in range(normalized_min, normalized_max + 1):269 lower_index = nearest_index - distance270 upper_index = nearest_index + distance271 if 0 <= lower_index < len(items):272 candidate_indices.append(lower_index)273 if upper_index != lower_index and 0 <= upper_index < len(items):274 candidate_indices.append(upper_index)275 276 if not candidate_indices:277 return items[nearest_index][0]278 279 if prefer_dark is True:280 darker_candidates = [idx for idx in candidate_indices if idx <= nearest_index]281 if darker_candidates:282 return items[min(darker_candidates)][0]283 return items[min(candidate_indices)][0]284 if prefer_dark is False:285 lighter_candidates = [idx for idx in candidate_indices if idx >= nearest_index]286 if lighter_candidates:287 return items[max(lighter_candidates)][0]288 return items[max(candidate_indices)][0]289 290 def distance_to_value(idx: int) -> float:291 return abs(items[idx][1] - value)292 293 closest_index = min(candidate_indices, key=lambda idx: (distance_to_value(idx), idx))294 return items[closest_index][0]295 296 297def generate_color_palette(298 provided_primary: Optional[str] = None,299 provided_background: Optional[str] = None,300 provided_accent_1: Optional[str] = None,301 provided_accent_2: Optional[str] = None,302 provided_text_1: Optional[str] = None,303 provided_text_2: Optional[str] = None,304) -> GeneratedColorPalette:305 primary = (306 _hex_to_oklch(provided_primary) if provided_primary else _generate_primary_color()307 )308 background = (309 _hex_to_oklch(provided_background)310 if provided_background311 else _generate_background_color(primary)312 )313 accent_1 = (314 _hex_to_oklch(provided_accent_1)315 if provided_accent_1316 else _generate_accent_color(primary, 1)317 )318 accent_2 = (319 _hex_to_oklch(provided_accent_2)320 if provided_accent_2321 else _generate_accent_color(primary, 2)322 )323 text_1 = (324 _hex_to_oklch(provided_text_1)325 if provided_text_1326 else _generate_text_color(background, "text_1")327 )328 text_2 = (329 _hex_to_oklch(provided_text_2)330 if provided_text_2331 else _generate_text_color(primary, "text_2")332 )333 334 primary_variations = _get_color_for_all_lightness_values(primary)335 background_variations = _get_color_for_all_lightness_values(background)336 accent_1_variations = _get_color_for_all_lightness_values(accent_1)337 accent_2_variations = _get_color_for_all_lightness_values(accent_2)338 339 return GeneratedColorPalette(340 primary=_format_hex(primary),341 background=_format_hex(background),342 accent_1=_format_hex(accent_1),343 accent_2=_format_hex(accent_2),344 text_1=_format_hex(text_1),345 text_2=_format_hex(text_2),346 primary_variations=primary_variations,347 background_variations=background_variations,348 accent_1_variations=accent_1_variations,349 accent_2_variations=accent_2_variations,350 primary_lightness=primary.l,351 background_lightness=background.l,352 accent_1_lightness=accent_1.l,353 accent_2_lightness=accent_2.l,354 text_1_lightness=text_1.l,355 text_2_lightness=text_2.l,356 )357 358 