346 lines
9.1 KiB
Go
346 lines
9.1 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
)
|
|
|
|
// Lightness correctors for each hue segment (based on JavaScript implementation)
|
|
var correctors = []float64{0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55}
|
|
|
|
// Color represents a color with both HSL and RGB representations
|
|
type Color struct {
|
|
H, S, L float64 // HSL values: H=[0,1], S=[0,1], L=[0,1]
|
|
R, G, B uint8 // RGB values: [0,255]
|
|
A uint8 // Alpha channel: [0,255]
|
|
}
|
|
|
|
// NewColorHSL creates a new Color from HSL values
|
|
func NewColorHSL(h, s, l float64) Color {
|
|
r, g, b := HSLToRGB(h, s, l)
|
|
return Color{
|
|
H: h, S: s, L: l,
|
|
R: r, G: g, B: b,
|
|
A: 255,
|
|
}
|
|
}
|
|
|
|
// NewColorCorrectedHSL creates a new Color from HSL values with lightness correction
|
|
func NewColorCorrectedHSL(h, s, l float64) Color {
|
|
r, g, b := CorrectedHSLToRGB(h, s, l)
|
|
return Color{
|
|
H: h, S: s, L: l,
|
|
R: r, G: g, B: b,
|
|
A: 255,
|
|
}
|
|
}
|
|
|
|
// NewColorRGB creates a new Color from RGB values and calculates HSL
|
|
func NewColorRGB(r, g, b uint8) Color {
|
|
h, s, l := RGBToHSL(r, g, b)
|
|
return Color{
|
|
H: h, S: s, L: l,
|
|
R: r, G: g, B: b,
|
|
A: 255,
|
|
}
|
|
}
|
|
|
|
// NewColorRGBA creates a new Color from RGBA values and calculates HSL
|
|
func NewColorRGBA(r, g, b, a uint8) Color {
|
|
h, s, l := RGBToHSL(r, g, b)
|
|
return Color{
|
|
H: h, S: s, L: l,
|
|
R: r, G: g, B: b,
|
|
A: a,
|
|
}
|
|
}
|
|
|
|
// String returns the hex representation of the color
|
|
func (c Color) String() string {
|
|
if c.A == 255 {
|
|
return RGBToHex(c.R, c.G, c.B)
|
|
}
|
|
return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A)
|
|
}
|
|
|
|
// Equals compares two colors for equality
|
|
func (c Color) Equals(other Color) bool {
|
|
return c.R == other.R && c.G == other.G && c.B == other.B && c.A == other.A
|
|
}
|
|
|
|
// WithAlpha returns a new color with the specified alpha value
|
|
func (c Color) WithAlpha(alpha uint8) Color {
|
|
return Color{
|
|
H: c.H, S: c.S, L: c.L,
|
|
R: c.R, G: c.G, B: c.B,
|
|
A: alpha,
|
|
}
|
|
}
|
|
|
|
// IsGrayscale returns true if the color is grayscale (saturation near zero)
|
|
func (c Color) IsGrayscale() bool {
|
|
return c.S < 0.01 // Small tolerance for floating point comparison
|
|
}
|
|
|
|
// Darken returns a new color with reduced lightness
|
|
func (c Color) Darken(amount float64) Color {
|
|
newL := clamp(c.L-amount, 0, 1)
|
|
return NewColorCorrectedHSL(c.H, c.S, newL)
|
|
}
|
|
|
|
// Lighten returns a new color with increased lightness
|
|
func (c Color) Lighten(amount float64) Color {
|
|
newL := clamp(c.L+amount, 0, 1)
|
|
return NewColorCorrectedHSL(c.H, c.S, newL)
|
|
}
|
|
|
|
// RGBToHSL converts RGB values to HSL
|
|
// Returns H=[0,1], S=[0,1], L=[0,1]
|
|
func RGBToHSL(r, g, b uint8) (h, s, l float64) {
|
|
rf := float64(r) / 255.0
|
|
gf := float64(g) / 255.0
|
|
bf := float64(b) / 255.0
|
|
|
|
max := math.Max(rf, math.Max(gf, bf))
|
|
min := math.Min(rf, math.Min(gf, bf))
|
|
|
|
// Calculate lightness
|
|
l = (max + min) / 2.0
|
|
|
|
if max == min {
|
|
// Achromatic (gray)
|
|
h, s = 0, 0
|
|
} else {
|
|
delta := max - min
|
|
|
|
// Calculate saturation
|
|
if l > 0.5 {
|
|
s = delta / (2.0 - max - min)
|
|
} else {
|
|
s = delta / (max + min)
|
|
}
|
|
|
|
// Calculate hue
|
|
switch max {
|
|
case rf:
|
|
h = (gf-bf)/delta + (func() float64 {
|
|
if gf < bf {
|
|
return 6
|
|
}
|
|
return 0
|
|
})()
|
|
case gf:
|
|
h = (bf-rf)/delta + 2
|
|
case bf:
|
|
h = (rf-gf)/delta + 4
|
|
}
|
|
h /= 6.0
|
|
}
|
|
|
|
return h, s, l
|
|
}
|
|
|
|
// HSLToRGB converts HSL color values to RGB.
|
|
// h: hue in range [0, 1]
|
|
// s: saturation in range [0, 1]
|
|
// l: lightness in range [0, 1]
|
|
// Returns RGB values in range [0, 255]
|
|
func HSLToRGB(h, s, l float64) (r, g, b uint8) {
|
|
// Clamp input values to valid ranges
|
|
h = math.Mod(h, 1.0)
|
|
if h < 0 {
|
|
h += 1.0
|
|
}
|
|
s = clamp(s, 0, 1)
|
|
l = clamp(l, 0, 1)
|
|
|
|
// Handle grayscale case (saturation = 0)
|
|
if s == 0 {
|
|
// All RGB components are equal for grayscale
|
|
gray := uint8(clamp(l*255, 0, 255))
|
|
return gray, gray, gray
|
|
}
|
|
|
|
// Calculate intermediate values for HSL to RGB conversion
|
|
var m2 float64
|
|
if l <= 0.5 {
|
|
m2 = l * (s + 1)
|
|
} else {
|
|
m2 = l + s - l*s
|
|
}
|
|
m1 := l*2 - m2
|
|
|
|
// Convert each RGB component
|
|
r = uint8(clamp(hueToRGB(m1, m2, h*6+2)*255, 0, 255))
|
|
g = uint8(clamp(hueToRGB(m1, m2, h*6)*255, 0, 255))
|
|
b = uint8(clamp(hueToRGB(m1, m2, h*6-2)*255, 0, 255))
|
|
|
|
return r, g, b
|
|
}
|
|
|
|
// CorrectedHSLToRGB converts HSL to RGB with lightness correction for better visual perception.
|
|
// This function adjusts the lightness based on the hue to compensate for the human eye's
|
|
// different sensitivity to different colors.
|
|
func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
|
|
// Get the corrector for the current hue
|
|
hueIndex := int((h*6 + 0.5)) % len(correctors)
|
|
corrector := correctors[hueIndex]
|
|
|
|
// Adjust lightness relative to the corrector
|
|
if l < 0.5 {
|
|
l = l * corrector * 2
|
|
} else {
|
|
l = corrector + (l-0.5)*(1-corrector)*2
|
|
}
|
|
|
|
// Clamp the corrected lightness
|
|
l = clamp(l, 0, 1)
|
|
|
|
return HSLToRGB(h, s, l)
|
|
}
|
|
|
|
// hueToRGB converts a hue value to an RGB component value
|
|
// Based on the W3C CSS3 color specification
|
|
func hueToRGB(m1, m2, h float64) float64 {
|
|
// Normalize hue to [0, 6) range
|
|
if h < 0 {
|
|
h += 6
|
|
} else if h > 6 {
|
|
h -= 6
|
|
}
|
|
|
|
// Calculate RGB component based on hue position
|
|
if h < 1 {
|
|
return m1 + (m2-m1)*h
|
|
} else if h < 3 {
|
|
return m2
|
|
} else if h < 4 {
|
|
return m1 + (m2-m1)*(4-h)
|
|
} else {
|
|
return m1
|
|
}
|
|
}
|
|
|
|
// clamp constrains a value to the specified range [min, max]
|
|
func clamp(value, min, max float64) float64 {
|
|
if value < min {
|
|
return min
|
|
}
|
|
if value > max {
|
|
return max
|
|
}
|
|
return value
|
|
}
|
|
|
|
// RGBToHex converts RGB values to a hexadecimal color string
|
|
func RGBToHex(r, g, b uint8) string {
|
|
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
|
}
|
|
|
|
// ParseHexColor parses a hexadecimal color string and returns RGB values
|
|
// Supports formats: #RGB, #RRGGBB, #RRGGBBAA
|
|
// Returns error if the format is invalid
|
|
func ParseHexColor(color string) (r, g, b, a uint8, err error) {
|
|
if len(color) == 0 || color[0] != '#' {
|
|
return 0, 0, 0, 255, fmt.Errorf("invalid color format: %s", color)
|
|
}
|
|
|
|
hex := color[1:] // Remove '#' prefix
|
|
a = 255 // Default alpha
|
|
|
|
// Helper to parse a component and chain errors
|
|
parse := func(target *uint8, hexStr string) {
|
|
if err != nil {
|
|
return // Don't parse if a previous component failed
|
|
}
|
|
*target, err = hexToByte(hexStr)
|
|
}
|
|
|
|
switch len(hex) {
|
|
case 3: // #RGB
|
|
parse(&r, hex[0:1]+hex[0:1])
|
|
parse(&g, hex[1:2]+hex[1:2])
|
|
parse(&b, hex[2:3]+hex[2:3])
|
|
case 6: // #RRGGBB
|
|
parse(&r, hex[0:2])
|
|
parse(&g, hex[2:4])
|
|
parse(&b, hex[4:6])
|
|
case 8: // #RRGGBBAA
|
|
parse(&r, hex[0:2])
|
|
parse(&g, hex[2:4])
|
|
parse(&b, hex[4:6])
|
|
parse(&a, hex[6:8])
|
|
default:
|
|
return 0, 0, 0, 255, fmt.Errorf("invalid hex color length: %s", color)
|
|
}
|
|
|
|
if err != nil {
|
|
// Return zero values for color components on error, but keep default alpha
|
|
return 0, 0, 0, 255, fmt.Errorf("failed to parse color '%s': %w", color, err)
|
|
}
|
|
|
|
return r, g, b, a, nil
|
|
}
|
|
|
|
// hexToByte converts a 2-character hex string to a byte value
|
|
func hexToByte(hex string) (uint8, error) {
|
|
if len(hex) != 2 {
|
|
return 0, fmt.Errorf("invalid hex string length: expected 2 characters, got %d", len(hex))
|
|
}
|
|
|
|
n, err := strconv.ParseUint(hex, 16, 8)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid hex value '%s': %w", hex, err)
|
|
}
|
|
return uint8(n), nil
|
|
}
|
|
|
|
// GenerateColor creates a color with the specified hue and configuration-based saturation and lightness
|
|
func GenerateColor(hue float64, config ColorConfig, lightnessValue float64) Color {
|
|
// Restrict hue according to configuration
|
|
restrictedHue := config.RestrictHue(hue)
|
|
|
|
// Get lightness from configuration range
|
|
lightness := config.ColorLightness.GetLightness(lightnessValue)
|
|
|
|
// Use corrected HSL to RGB conversion
|
|
return NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, lightness)
|
|
}
|
|
|
|
// GenerateGrayscale creates a grayscale color with configuration-based saturation and lightness
|
|
func GenerateGrayscale(config ColorConfig, lightnessValue float64) Color {
|
|
// For grayscale, hue doesn't matter, but we'll use 0
|
|
hue := 0.0
|
|
|
|
// Get lightness from grayscale configuration range
|
|
lightness := config.GrayscaleLightness.GetLightness(lightnessValue)
|
|
|
|
// Use grayscale saturation (typically 0)
|
|
return NewColorCorrectedHSL(hue, config.GrayscaleSaturation, lightness)
|
|
}
|
|
|
|
// GenerateColorTheme generates a set of color candidates based on the JavaScript colorTheme function
|
|
// This matches the JavaScript implementation that creates 5 colors:
|
|
// 0: Dark gray, 1: Mid color, 2: Light gray, 3: Light color, 4: Dark color
|
|
func GenerateColorTheme(hue float64, config ColorConfig) []Color {
|
|
// Restrict hue according to configuration
|
|
restrictedHue := config.RestrictHue(hue)
|
|
|
|
return []Color{
|
|
// Dark gray (grayscale with lightness 0)
|
|
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(0)),
|
|
|
|
// Mid color (normal color with lightness 0.5)
|
|
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0.5)),
|
|
|
|
// Light gray (grayscale with lightness 1)
|
|
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(1)),
|
|
|
|
// Light color (normal color with lightness 1)
|
|
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(1)),
|
|
|
|
// Dark color (normal color with lightness 0)
|
|
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0)),
|
|
}
|
|
} |