///|
/// A premultiplied-independent RGBA color used by pattern generators.
/// Components are stored in the closed interval [0, 1].
pub(all) struct Color {
r : Double
g : Double
b : Double
a : Double
} derive(Eq, Debug, ToJson)
///|
fn clamp01(value : Double) -> Double {
if value < 0.0 {
0.0
} else if value > 1.0 {
1.0
} else {
value
}
}
///|
/// Creates a color from normalized channels.
pub fn Color::rgba(
r : Double,
g : Double,
b : Double,
a? : Double = 1.0,
) -> Color {
{ r: clamp01(r), g: clamp01(g), b: clamp01(b), a: clamp01(a) }
}
///|
/// Creates an opaque color from 8-bit RGB channels.
pub fn Color::rgb8(r : Int, g : Int, b : Int) -> Color {
Color::rgba(
r.to_double() / 255.0,
g.to_double() / 255.0,
b.to_double() / 255.0,
)
}
///|
pub fn Color::with_alpha(self : Color, alpha : Double) -> Color {
Color::rgba(self.r, self.g, self.b, a=alpha)
}
///|
pub fn Color::mix(self : Color, other : Color, amount : Double) -> Color {
let t = clamp01(amount)
Color::rgba(
self.r + (other.r - self.r) * t,
self.g + (other.g - self.g) * t,
self.b + (other.b - self.b) * t,
a=self.a + (other.a - self.a) * t,
)
}
///|
/// Alpha-composites `top` over this color.
pub fn Color::over(self : Color, top : Color) -> Color {
let out_a = top.a + self.a * (1.0 - top.a)
if out_a == 0.0 {
Color::rgba(0.0, 0.0, 0.0, a=0.0)
} else {
Color::rgba(
(top.r * top.a + self.r * self.a * (1.0 - top.a)) / out_a,
(top.g * top.a + self.g * self.a * (1.0 - top.a)) / out_a,
(top.b * top.a + self.b * self.a * (1.0 - top.a)) / out_a,
a=out_a,
)
}
}
///|
/// Relative luminance according to the sRGB transfer curve.
pub fn Color::luminance(self : Color) -> Double {
fn linear(channel : Double) -> Double {
if channel <= 0.04045 {
channel / 12.92
} else {
@math.pow((channel + 0.055) / 1.055, 2.4)
}
}
0.2126 * linear(self.r) + 0.7152 * linear(self.g) + 0.0722 * linear(self.b)
}
///|
pub fn Color::contrast_ratio(self : Color, other : Color) -> Double {
let a = self.luminance()
let b = other.luminance()
let hi = if a > b { a } else { b }
let lo = if a > b { b } else { a }
(hi + 0.05) / (lo + 0.05)
}
///|
/// Returns a CSS-compatible hexadecimal color. Alpha is included when needed.
pub fn Color::to_hex(self : Color) -> String {
fn byte(value : Double) -> String {
let n = (clamp01(value) * 255.0 + 0.5).to_int()
let high = n / 16
let low = n % 16
fn digit(value : Int) -> String {
match value {
0 => "0"
1 => "1"
2 => "2"
3 => "3"
4 => "4"
5 => "5"
6 => "6"
7 => "7"
8 => "8"
9 => "9"
10 => "a"
11 => "b"
12 => "c"
13 => "d"
14 => "e"
_ => "f"
}
}
digit(high) + digit(low)
}
let base = "#\{byte(self.r)}\{byte(self.g)}\{byte(self.b)}"
if self.a >= 0.999 {
base
} else {
base + byte(self.a)
}
}
///|
/// Converts HSL values (hue in radians, saturation and lightness in [0, 1]).
pub fn Color::hsl(
hue : Double,
saturation : Double,
lightness : Double,
alpha? : Double = 1.0,
) -> Color {
let s = clamp01(saturation)
let l = clamp01(lightness)
if s == 0.0 {
Color::rgba(l, l, l, a=alpha)
} else {
let h = hue / (2.0 * @math.PI) -
(hue / (2.0 * @math.PI)).to_int().to_double()
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }
let p = 2.0 * l - q
fn hue_to_rgb(p : Double, q : Double, t0 : Double) -> Double {
let t = if t0 < 0.0 { t0 + 1.0 } else if t0 > 1.0 { t0 - 1.0 } else { t0 }
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 1.0 / 2.0 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
}
Color::rgba(
hue_to_rgb(p, q, h + 1.0 / 3.0),
hue_to_rgb(p, q, h),
hue_to_rgb(p, q, h - 1.0 / 3.0),
a=alpha,
)
}
}
///|
pub fn palette_complementary(base : Color) -> Array[Color] {
[base, Color::rgba(1.0 - base.r, 1.0 - base.g, 1.0 - base.b)]
}
///|
pub fn palette_analogous(
base : Color,
count : Int,
spread? : Double = @math.PI / 6.0,
) -> Array[Color] {
let colors = []
if count <= 0 {
colors
} else {
let center = (count - 1).to_double() / 2.0
for i in 0.. Array[Color] {
let result = []
if samples <= 0 || stops.length() == 0 {
result
} else if stops.length() == 1 {
for _ in 0..= stops.length() { left } else { left + 1 }
let amount = scaled - left.to_double()
result.push(stops[left].mix(stops[right], amount))
}
result
}
}