add generate palette function in experimental

This commit is contained in:
Lendemor 2024-08-24 12:54:14 +02:00
parent 13a6d538a9
commit d885cf6a66
3 changed files with 4402 additions and 0 deletions

View File

@ -0,0 +1,199 @@
"""A module for generating Bezier easing functions."""
# These values are established by empiricism with tests (tradeoff: performance VS precision)
NEWTON_ITERATIONS = 4
NEWTON_MIN_SLOPE = 0.001
SUBDIVISION_PRECISION = 0.0000001
SUBDIVISION_MAX_ITERATIONS = 10
kSplineTableSize = 11
kSampleStepSize = 1.0 / (kSplineTableSize - 1.0)
def A(aA1, aA2):
"""Calculate A.
Args:
aA1: The first value.
aA2: The second value.
Returns:
The calculated value.
"""
return 1.0 - 3.0 * aA2 + 3.0 * aA1
def B(aA1, aA2):
"""Calculate B.
Args:
aA1: The first value.
aA2: The second value.
Returns:
The calculated value.
"""
return 3.0 * aA2 - 6.0 * aA1
def C(aA1):
"""Calculate C.
Args:
aA1: The first value.
Returns:
The calculated value.
"""
return 3.0 * aA1
def calcBezier(aT, aA1, aA2):
"""Calculate Bezier.
Args:
aT: The time.
aA1: The first value.
aA2: The second value.
Returns:
x(t) given t, x1, and x2, or y(t) given t, y1, and y2.
"""
return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT
def getSlope(aT, aA1, aA2):
"""Calculate slope.
Args:
aT: The time.
aA1: The first value.
aA2: The second value.
Returns:
dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.
"""
return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1)
def binarySubdivide(aX, aA, aB, mX1, mX2):
"""Perform a binary subdivide.
Args:
aX: The x value.
aA: The a value.
aB: The b value.
mX1: The x1 value.
mX2: The x2 value.
Returns:
The t value.
"""
current_x = aA
current_t = 0
i = 0
while True:
i += 1
if i >= SUBDIVISION_MAX_ITERATIONS:
break
current_t = aA + (aB - aA) / 2.0
current_x = calcBezier(current_t, mX1, mX2) - aX
if current_x > 0.0:
aB = current_t
else:
aA = current_t
if abs(current_x) <= SUBDIVISION_PRECISION:
break
return current_t
def newtonRaphsonIterate(aX, aGuessT, mX1, mX2):
"""Perform a Newton-Raphson iteration.
Args:
aX: The x value.
aGuessT: The guess value.
mX1: The x1 value.
mX2: The x2 value.
Returns:
The t value.
"""
for _ in range(NEWTON_ITERATIONS):
current_slope = getSlope(aGuessT, mX1, mX2)
if current_slope == 0.0:
return aGuessT
current_x = calcBezier(aGuessT, mX1, mX2) - aX
aGuessT -= current_x / current_slope
return aGuessT
def LinearEasing(x):
"""Linear easing function.
Args:
x: The x value.
Returns:
The x value.
"""
return x
def bezier(mX1, mY1, mX2, mY2):
"""Generate a Bezier easing function.
Args:
mX1: The x1 value.
mY1: The y1 value.
mX2: The x2 value.
mY2: The y2 value.
Raises:
ValueError: If the x values are not in the [0, 1] range.
Returns:
The Bezier easing function.
"""
if not (0 <= mX1 <= 1 and 0 <= mX2 <= 1):
raise ValueError("bezier x values must be in [0, 1] range")
if mX1 == mY1 and mX2 == mY2:
return LinearEasing
# Precompute samples table
sampleValues = [
calcBezier(i * kSampleStepSize, mX1, mX2) for i in range(kSplineTableSize)
]
def getTForX(aX):
intervalStart = 0.0
currentSample = 1
lastSample = kSplineTableSize - 1
while currentSample != lastSample and sampleValues[currentSample] <= aX:
intervalStart += kSampleStepSize
currentSample += 1
currentSample -= 1
# Interpolate to provide an initial guess for t
dist = (aX - sampleValues[currentSample]) / (
sampleValues[currentSample + 1] - sampleValues[currentSample]
)
guessForT = intervalStart + dist * kSampleStepSize
initialSlope = getSlope(guessForT, mX1, mX2)
if initialSlope >= NEWTON_MIN_SLOPE:
return newtonRaphsonIterate(aX, guessForT, mX1, mX2)
elif initialSlope == 0.0:
return guessForT
else:
return binarySubdivide(
aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2
)
def BezierEasing(x):
if x == 0 or x == 1:
return x
return calcBezier(getTForX(x), mY1, mY2)
return BezierEasing

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,600 @@
"""A module for generating Radix colors.
Converted from https://github.com/radix-ui/website/blob/main/components/generateRadixColors.tsx
"""
import math
from coloraide import Color
from .bezier import bezier
from .default_colors import DEFAULT_DARK_COLORS, DEFAULT_LIGHT_COLORS
ArrayOf12 = list[float]
gray_scale_names = ["gray", "mauve", "slate", "sage", "olive", "sand"]
scale_names = gray_scale_names + [
"tomato",
"red",
"ruby",
"crimson",
"pink",
"plum",
"purple",
"violet",
"iris",
"indigo",
"blue",
"cyan",
"teal",
"jade",
"green",
"grass",
"brown",
"orange",
"sky",
"mint",
"lime",
"yellow",
"amber",
]
def get_base_colors(appearance: str):
"""Get the base colors from the default dicts.
Args:
appearance: The appearance of the colors.
Returns:
dict: The base colors.
"""
raw_colors = DEFAULT_LIGHT_COLORS if appearance == "light" else DEFAULT_DARK_COLORS
colors = {}
for color_name, color_scale in raw_colors.items():
f_scale = []
for scale in color_scale:
scale["coords"] = [
float("nan") if x is None else x for x in scale["coords"]
]
f_scale.append(Color(scale))
colors[color_name] = f_scale
return colors
light_colors = get_base_colors("light")
dark_colors = get_base_colors("dark")
light_gray_colors = {name: light_colors[name] for name in gray_scale_names}
dark_gray_colors = {name: dark_colors[name] for name in gray_scale_names}
def generate_radix_colors(
appearance: str, accent: str, gray: str, background: str
) -> dict:
"""Generate Radix colors.
Args:
appearance: The appearance of the colors.
accent: The accent color.
gray: The gray color.
background: The background color.
Returns:
dict: The generated colors.
"""
all_scales = light_colors if appearance == "light" else dark_colors
gray_scales = light_gray_colors if appearance == "light" else dark_gray_colors
background_color = Color(background).convert("oklch")
gray_base_color = Color(gray).convert("oklch")
gray_scale_colors = get_scale_from_color(
gray_base_color, gray_scales, background_color
)
accent_base_color = Color(accent).convert("oklch")
accent_scale_colors = get_scale_from_color(
accent_base_color, all_scales, background_color
)
background_hex = background_color.convert("srgb").to_string(hex=True)
accent_base_hex = accent_base_color.convert("srgb").to_string(hex=True)
if accent_base_hex == "#000000" or accent_base_hex == "#ffffff":
accent_scale_colors = [color.clone() for color in gray_scale_colors]
accent9_color, accent_contrast_color = get_step9_colors(
accent_scale_colors, accent_base_color
)
accent_scale_colors[8] = accent9_color
accent_scale_colors[9] = get_button_hover_color(
accent9_color, [accent_scale_colors]
)
# Limit saturation of the text colors
accent_scale_colors[10] = accent_scale_colors[10].set(
"oklch.c",
min(
max(
accent_scale_colors[8].get("oklch.c"),
accent_scale_colors[7].get("oklch.c"),
),
accent_scale_colors[10].get("oklch.c"),
),
)
accent_scale_colors[11] = accent_scale_colors[11].set(
"oklch.c",
min(
max(
accent_scale_colors[8].get("oklch.c"),
accent_scale_colors[7].get("oklch.c"),
),
accent_scale_colors[11].get("oklch.c"),
),
)
accent_scale_hex = [
color.convert("srgb").to_string(hex=True) for color in accent_scale_colors
]
accent_scale_wide_gamut = [to_oklch_string(color) for color in accent_scale_colors]
accent_scale_alpha_hex = [
get_alpha_color_srgb(color, background_hex) for color in accent_scale_hex
]
accent_scale_alpha_wide_gamut_string = [
get_alpha_color_p3(color, background_hex) for color in accent_scale_hex
]
accent_contrast_color_hex = accent_contrast_color.convert("srgb").to_string(
hex=True
)
gray_scale_hex = [
color.convert("srgb").to_string(hex=True) for color in gray_scale_colors
]
gray_scale_wide_gamut = [to_oklch_string(color) for color in gray_scale_colors]
gray_scale_alpha_hex = [
get_alpha_color_srgb(color, background_hex) for color in gray_scale_hex
]
gray_scale_alpha_wide_gamut_string = [
get_alpha_color_p3(color, background_hex) for color in gray_scale_hex
]
accent_surface_hex = (
get_alpha_color_srgb(accent_scale_hex[1], background_hex, 0.8)
if appearance == "light"
else get_alpha_color_srgb(accent_scale_hex[1], background_hex, 0.5)
)
accent_surface_wide_gamut_string = (
get_alpha_color_p3(accent_scale_wide_gamut[1], background_hex, 0.8)
if appearance == "light"
else get_alpha_color_p3(accent_scale_wide_gamut[1], background_hex, 0.5)
)
return {
"accentScale": accent_scale_hex,
"accentScaleAlpha": accent_scale_alpha_hex,
"accentScaleWideGamut": accent_scale_wide_gamut,
"accentScaleAlphaWideGamut": accent_scale_alpha_wide_gamut_string,
"accentContrast": accent_contrast_color_hex,
"grayScale": gray_scale_hex,
"grayScaleAlpha": gray_scale_alpha_hex,
"grayScaleWideGamut": gray_scale_wide_gamut,
"grayScaleAlphaWideGamut": gray_scale_alpha_wide_gamut_string,
"graySurface": "#ffffffcc" if appearance == "light" else "rgba(0, 0, 0, 0.05)",
"graySurfaceWideGamut": "color(display-p3 1 1 1 / 80%)"
if appearance == "light"
else "color(display-p3 0 0 0 / 5%)",
"accentSurface": accent_surface_hex,
"accentSurfaceWideGamut": accent_surface_wide_gamut_string,
"background": background_hex,
}
def get_step9_colors(
scale: list[Color], accent_base_color: Color
) -> tuple[Color, Color]:
"""Get the step 9 colors.
Args:
scale: The scale of colors.
accent_base_color: The accent base color.
Returns:
The step 9 colors.
"""
reference_background_color = scale[0]
distance = accent_base_color.delta_e(reference_background_color) * 100
if distance < 25:
return scale[8], get_text_color(scale[8])
return accent_base_color, get_text_color(accent_base_color)
def get_button_hover_color(source: Color, scales: list[list[Color]]) -> Color:
"""Get the button hover color.
Args:
source: The source color.
scales: The scales of colors.
Returns:
The button hover color.
"""
L, C, H = source["lightness"], source["chroma"], source["hue"]
new_L = L - 0.03 / (L + 0.1) if L > 0.4 else L + 0.03 / (L + 0.1)
new_C = C * 0.93 if L > 0.4 and not math.isnan(H) else C
button_hover_color = Color("oklch", [new_L, new_C, H])
closest_color = button_hover_color
min_distance = float("inf")
for scale in scales:
for color in scale:
distance = button_hover_color.delta_e(color)
if distance < min_distance:
min_distance = distance
closest_color = color
button_hover_color["chroma"] = closest_color["chroma"]
button_hover_color["hue"] = closest_color["hue"]
return button_hover_color
def get_text_color(background: Color) -> Color:
"""Get the text color.
Args:
background: The background color.
Returns:
The text color.
"""
white = Color("oklch", [1, 0, 0])
if abs(white.contrast(background)) < 40:
_, C, H = background["lightness"], background["chroma"], background["hue"]
return Color("oklch", [0.25, max(0.08 * C, 0.04), H])
return white
def get_alpha_color(
target_rgb: list[float],
background_rgb: list[float],
rgb_precision: int,
alpha_precision: int,
target_alpha: float | None = None,
) -> tuple[float, float, float, float]:
"""Get the alpha color.
Args:
target_rgb: The target RGB.
background_rgb: The background RGB.
rgb_precision: The RGB precision.
alpha_precision: The alpha precision.
target_alpha: The target alpha.
Raises:
ValueError: If the color is undefined.
Returns:
The alpha color.
"""
tr, tg, tb = [round(c * rgb_precision) for c in target_rgb]
br, bg, bb = [round(c * rgb_precision) for c in background_rgb]
if any(c is None for c in [tr, tg, tb, br, bg, bb]):
raise ValueError("Color is undefined")
desired_rgb = 0
if tr > br or tg > bg or tb > bb:
desired_rgb = rgb_precision
alpha_r = (tr - br) / (desired_rgb - br)
alpha_g = (tg - bg) / (desired_rgb - bg)
alpha_b = (tb - bb) / (desired_rgb - bb)
is_pure_gray = all(alpha == alpha_r for alpha in [alpha_r, alpha_g, alpha_b])
if not target_alpha and is_pure_gray:
v = desired_rgb / rgb_precision
return v, v, v, alpha_r
def clamp_rgb(n):
return 0 if n is None else min(rgb_precision, max(0, n))
def clamp_a(n):
return 0 if n is None else min(alpha_precision, max(0, n))
max_alpha = (
target_alpha if target_alpha is not None else max(alpha_r, alpha_g, alpha_b)
)
A = clamp_a(math.ceil(max_alpha * alpha_precision)) / alpha_precision
R = clamp_rgb(((br * (1 - A) - tr) / A) * -1)
G = clamp_rgb(((bg * (1 - A) - tg) / A) * -1)
B = clamp_rgb(((bb * (1 - A) - tb) / A) * -1)
R, G, B = map(math.ceil, [R, G, B])
blended_r = blend_alpha(R, A, br)
blended_g = blend_alpha(G, A, bg)
blended_b = blend_alpha(B, A, bb)
if desired_rgb == 0:
if tr <= br and tr != blended_r:
R += 1 if tr > blended_r else -1
if tg <= bg and tg != blended_g:
G += 1 if tg > blended_g else -1
if tb <= bb and tb != blended_b:
B += 1 if tb > blended_b else -1
if desired_rgb == rgb_precision:
if tr >= br and tr != blended_r:
R += 1 if tr > blended_r else -1
if tg >= bg and tg != blended_g:
G += 1 if tg > blended_g else -1
if tb >= bb and tb != blended_b:
B += 1 if tb > blended_b else -1
R /= rgb_precision
G /= rgb_precision
B /= rgb_precision
return R, G, B, A
def blend_alpha(foreground, alpha, background, _round=True) -> float:
"""Blend the alpha.
Args:
foreground: The foreground.
alpha: The alpha.
background: The background.
_round: Whether to round the result.
Returns:
The blended alpha.
"""
if _round:
return round(background * (1 - alpha)) + round(foreground * alpha)
return background * (1 - alpha) + foreground * alpha
def get_alpha_color_srgb(
target_color: str, background_color: str, target_alpha: float | None = None
) -> str:
"""Get the alpha color in srgb.
Args:
target_color: The target color.
background_color: The background color.
target_alpha: The target alpha.
Returns:
The alpha color.
"""
r, g, b, a = get_alpha_color(
Color(target_color).convert("srgb").coords(),
Color(background_color).convert("srgb").coords(),
255,
255,
target_alpha,
)
return Color("srgb", [r, g, b], a).to_string(format="hex")
def get_alpha_color_p3(
target_color: str, background_color: str, target_alpha: float | None = None
) -> str:
"""Get the alpha color in display-p3.
Args:
target_color: The target color.
background_color: The background color.
target_alpha: The target alpha.
Returns:
The alpha color.
"""
r, g, b, a = get_alpha_color(
Color(target_color).convert("display-p3").coords(),
Color(background_color).convert("display-p3").coords(),
255,
1000,
target_alpha,
)
return Color("display-p3", [r, g, b], a).to_string(precision=4)
def format_hex(s: str) -> str:
"""Format shortform hex to longform.
Args:
s: The hex color.
Returns:
The formatted hex color.
"""
if not s.startswith("#"):
return s
if len(s) == 4:
return f"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}"
if len(s) == 5:
return f"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}{s[4]}{s[4]}"
return s
dark_mode_easing = [1, 0, 1, 0]
light_mode_easing = [0, 2, 0, 2]
def to_oklch_string(color: Color) -> str:
"""Convert a color to an oklch string for CSS.
Args:
color: The color to convert.
Returns:
The oklch string.
"""
L = round(color["lightness"] * 100, 1)
return f"oklch({L}% {color['chroma']:.4f} {color['hue']:.4f})"
def get_scale_from_color(
source: Color, scales: dict[str, list[Color]], background_color: Color
) -> list[Color]:
"""Get a scale from a color.
Args:
source: The source color.
scales: The scales of colors.
background_color: The background color.
Returns:
The generated scale.
"""
all_colors = []
for name, scale in scales.items():
for color in scale:
distance = source.delta_e(color)
all_colors.append({"scale": name, "distance": distance, "color": color})
all_colors.sort(key=lambda x: x["distance"])
# Remove non-unique scales
closest_colors = []
seen_scales = set()
for color in all_colors:
if color["scale"] not in seen_scales:
closest_colors.append(color)
seen_scales.add(color["scale"])
# Handle gray scales
gray_scale_names = ["gray", "mauve", "slate", "sage", "olive", "sand"]
all_are_grays = all(color["scale"] in gray_scale_names for color in closest_colors)
if not all_are_grays and closest_colors[0]["scale"] in gray_scale_names:
while closest_colors[1]["scale"] in gray_scale_names:
del closest_colors[1]
color_a = closest_colors[0]
color_b = closest_colors[1]
# Calculate triangle sides
a = color_b["distance"]
b = color_a["distance"]
c = color_a["color"].delta_e(color_b["color"])
# Calculate angles
cos_a = (b**2 + c**2 - a**2) / (2 * b * c)
rad_a = math.acos(cos_a)
sin_a = math.sin(rad_a)
cos_b = (a**2 + c**2 - b**2) / (2 * a * c)
rad_b = math.acos(cos_b)
sin_b = math.sin(rad_b)
# Calculate tangents
tan_c1 = cos_a / sin_a
tan_c2 = cos_b / sin_b
# Calculate ratio
ratio = max(0, tan_c1 / tan_c2) * 0.5
# Mix scales
scale_a = scales[color_a["scale"]]
scale_b = scales[color_b["scale"]]
scale = [
Color.mix(scale_a[i], scale_b[i], ratio).convert("oklch") for i in range(12)
]
# Find base color
base_color = min(scale, key=lambda color: source.delta_e(color))
# Adjust chroma ratio
ratio_c = source.get("oklch.c") / base_color.get("oklch.c")
# Modify hue and chroma of the scale
for color in scale:
color = color.set(
"oklch.c", min(source.get("oklch.c") * 1.5, color.get("oklch.c") * ratio_c)
)
color = color.set("oklch.h", source.get("oklch.h"))
# Handle light and dark modes
if scale[0].get("oklch.l") > 0.5: # Light mode
lightness_scale = [color.get("oklch.l") for color in scale]
background_l = max(0, min(1, background_color.get("oklch.l")))
new_lightness_scale = transpose_progression_start(
background_l, lightness_scale, light_mode_easing
)
new_lightness_scale = new_lightness_scale[1:] # Remove the added step
for i, lightness in enumerate(new_lightness_scale):
scale[i] = scale[i].set("oklch.l", lightness)
else: # Dark mode
ease = list(dark_mode_easing)
reference_background_color_l = scale[0].get("oklch.l")
background_color_l = max(0, min(1, background_color.get("oklch.l")))
ratio_l = background_color_l / reference_background_color_l
if ratio_l > 1:
max_ratio = 1.5
for i in range(len(ease)):
meta_ratio = (ratio_l - 1) * (max_ratio / (max_ratio - 1))
ease[i] = ( # type: ignore
0 if ratio_l > max_ratio else max(0, ease[i] * (1 - meta_ratio))
)
lightness_scale = [color.get("oklch.l") for color in scale]
background_l = background_color.get("oklch.l")
new_lightness_scale = transpose_progression_start(
background_l, lightness_scale, ease
)
for i, lightness in enumerate(new_lightness_scale):
scale[i] = scale[i].set("oklch.l", lightness)
return scale
def transpose_progression_start(to: float, arr: list, curve: list) -> list[float]:
"""Transpose a progression to a new start point.
Args:
to: The new start point.
arr: The progression.
curve: The bezier curve.
Returns:
The transposed progression.
"""
last_index = len(arr) - 1
diff = arr[0] - to
fn = bezier(*curve)
return [n - diff * fn(1 - i / last_index) for i, n in enumerate(arr)]
def transpose_progression_end(
to: float, arr: list[float], curve: list[float]
) -> list[float]:
"""Transpose a progression to a new end point.
Args:
to: The new end point.
arr: The progression.
curve: The bezier curve.
Returns:
The transposed progression.
"""
last_index = len(arr) - 1
diff = arr[-1] - to
fn = bezier(*curve)
return [n - diff * fn(i / last_index) for i, n in enumerate(arr)]