/** * ========================================================================== * HCL COLOR CONVERTER * ========================================================================== * Accurate color space conversions between RGB, LAB, and HCL * Based on CIE LAB color space for perceptual uniformity * ========================================================================== */ import { RGB, LAB, HCL } from '../models/hcl.models'; export class HCLConverter { // ========================================================================== // RGB ↔ HCL CONVERSIONS // ========================================================================== /** * Convert RGB to HCL color space * @param r Red component (0-255) * @param g Green component (0-255) * @param b Blue component (0-255) * @returns HCL color */ static rgbToHcl(r: number, g: number, b: number): HCL { const lab = this.rgbToLab(r, g, b); return this.labToHcl(lab.l, lab.a, lab.b); } /** * Convert HCL to RGB color space * @param h Hue (0-360) * @param c Chroma (0-100+) * @param l Lightness (0-100) * @returns RGB color */ static hclToRgb(h: number, c: number, l: number): RGB { const lab = this.hclToLab(h, c, l); return this.labToRgb(lab.l, lab.a, lab.b); } // ========================================================================== // HEX CONVERSIONS // ========================================================================== /** * Convert hex string to HCL * @param hex Hex color string (#RRGGBB or #RGB) * @returns HCL color */ static hexToHcl(hex: string): HCL { const rgb = this.hexToRgb(hex); return this.rgbToHcl(rgb.r, rgb.g, rgb.b); } /** * Convert HCL to hex string * @param h Hue (0-360) * @param c Chroma (0-100+) * @param l Lightness (0-100) * @returns Hex color string */ static hclToHex(h: number, c: number, l: number): string { const rgb = this.hclToRgb(h, c, l); return this.rgbToHex(rgb.r, rgb.g, rgb.b); } // ========================================================================== // RGB ↔ LAB CONVERSIONS (via XYZ) // ========================================================================== /** * Convert RGB to LAB color space * @param r Red component (0-255) * @param g Green component (0-255) * @param b Blue component (0-255) * @returns LAB color */ static rgbToLab(r: number, g: number, b: number): LAB { // Convert RGB to XYZ first let rNorm = r / 255; let gNorm = g / 255; let bNorm = b / 255; // Apply gamma correction (sRGB) rNorm = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92; gNorm = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92; bNorm = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92; // Convert to XYZ using sRGB matrix let x = rNorm * 0.4124564 + gNorm * 0.3575761 + bNorm * 0.1804375; let y = rNorm * 0.2126729 + gNorm * 0.7151522 + bNorm * 0.0721750; let z = rNorm * 0.0193339 + gNorm * 0.1191920 + bNorm * 0.9503041; // Normalize by D65 illuminant x = x / 0.95047; y = y / 1.00000; z = z / 1.08883; // Convert XYZ to LAB const fx = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x + 16/116); const fy = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y + 16/116); const fz = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z + 16/116); const l = (116 * fy) - 16; const a = 500 * (fx - fy); const bValue = 200 * (fy - fz); return { l: Math.max(0, Math.min(100, l)), a, b: bValue }; } /** * Convert LAB to RGB color space * @param l Lightness (0-100) * @param a Green-Red axis * @param b Blue-Yellow axis * @returns RGB color */ static labToRgb(l: number, a: number, b: number): RGB { // Convert LAB to XYZ const fy = (l + 16) / 116; const fx = a / 500 + fy; const fz = fy - b / 200; const x = (fx > 0.206897 ? Math.pow(fx, 3) : (fx - 16/116) / 7.787) * 0.95047; const y = (fy > 0.206897 ? Math.pow(fy, 3) : (fy - 16/116) / 7.787) * 1.00000; const z = (fz > 0.206897 ? Math.pow(fz, 3) : (fz - 16/116) / 7.787) * 1.08883; // Convert XYZ to RGB let r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314; let g = x * -0.9692660 + y * 1.8760108 + z * 0.0415560; let b2 = x * 0.0556434 + y * -0.2040259 + z * 1.0572252; // Apply inverse gamma correction r = r > 0.0031308 ? 1.055 * Math.pow(r, 1/2.4) - 0.055 : 12.92 * r; g = g > 0.0031308 ? 1.055 * Math.pow(g, 1/2.4) - 0.055 : 12.92 * g; b2 = b2 > 0.0031308 ? 1.055 * Math.pow(b2, 1/2.4) - 0.055 : 12.92 * b2; // Clamp to valid RGB range const rFinal = Math.max(0, Math.min(255, Math.round(r * 255))); const gFinal = Math.max(0, Math.min(255, Math.round(g * 255))); const bFinal = Math.max(0, Math.min(255, Math.round(b2 * 255))); return { r: rFinal, g: gFinal, b: bFinal }; } // ========================================================================== // LAB ↔ HCL CONVERSIONS // ========================================================================== /** * Convert LAB to HCL (cylindrical representation) * @param l Lightness (0-100) * @param a Green-Red axis * @param b Blue-Yellow axis * @returns HCL color */ static labToHcl(l: number, a: number, b: number): HCL { const c = Math.sqrt(a * a + b * b); let h = Math.atan2(b, a) * (180 / Math.PI); // Normalize hue to 0-360 if (h < 0) h += 360; return { h: isNaN(h) ? 0 : h, c: isNaN(c) ? 0 : c, l: Math.max(0, Math.min(100, l)) }; } /** * Convert HCL to LAB color space * @param h Hue (0-360) * @param c Chroma (0-100+) * @param l Lightness (0-100) * @returns LAB color */ static hclToLab(h: number, c: number, l: number): LAB { const hRad = (h * Math.PI) / 180; const a = c * Math.cos(hRad); const b = c * Math.sin(hRad); return { l, a, b }; } // ========================================================================== // HEX UTILITY METHODS // ========================================================================== /** * Convert hex string to RGB * @param hex Hex color string * @returns RGB color */ static hexToRgb(hex: string): RGB { // Remove # if present hex = hex.replace('#', ''); // Handle 3-digit hex if (hex.length === 3) { hex = hex.split('').map(h => h + h).join(''); } if (hex.length !== 6) { throw new Error(`Invalid hex color: ${hex}`); } const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); return { r, g, b }; } /** * Convert RGB to hex string * @param r Red component (0-255) * @param g Green component (0-255) * @param b Blue component (0-255) * @returns Hex color string */ static rgbToHex(r: number, g: number, b: number): string { const toHex = (n: number) => { const hex = Math.max(0, Math.min(255, Math.round(n))).toString(16); return hex.length === 1 ? '0' + hex : hex; }; return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } // ========================================================================== // COLOR MANIPULATION METHODS // ========================================================================== /** * Adjust lightness of a color * @param hex Original color * @param amount Lightness adjustment (-100 to 100) * @returns Adjusted color */ static adjustLightness(hex: string, amount: number): string { const hcl = this.hexToHcl(hex); const newL = Math.max(0, Math.min(100, hcl.l + amount)); return this.hclToHex(hcl.h, hcl.c, newL); } /** * Adjust chroma (saturation) of a color * @param hex Original color * @param amount Chroma adjustment (-100 to 100) * @returns Adjusted color */ static adjustChroma(hex: string, amount: number): string { const hcl = this.hexToHcl(hex); const newC = Math.max(0, hcl.c + amount); return this.hclToHex(hcl.h, newC, hcl.l); } /** * Adjust hue of a color * @param hex Original color * @param amount Hue adjustment in degrees (-360 to 360) * @returns Adjusted color */ static adjustHue(hex: string, amount: number): string { const hcl = this.hexToHcl(hex); let newH = hcl.h + amount; // Normalize hue to 0-360 while (newH < 0) newH += 360; while (newH >= 360) newH -= 360; return this.hclToHex(newH, hcl.c, hcl.l); } // ========================================================================== // VALIDATION METHODS // ========================================================================== /** * Validate if a string is a valid hex color * @param hex Color string to validate * @returns True if valid hex color */ static isValidHex(hex: string): boolean { try { const normalizedHex = hex.replace('#', ''); return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(normalizedHex); } catch { return false; } } /** * Calculate relative luminance of a color (for contrast calculation) * @param hex Hex color string * @returns Relative luminance (0-1) */ static getRelativeLuminance(hex: string): number { const rgb = this.hexToRgb(hex); const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(c => { c = c / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** * Calculate contrast ratio between two colors * @param color1 First color * @param color2 Second color * @returns Contrast ratio (1-21) */ static getContrastRatio(color1: string, color2: string): number { const lum1 = this.getRelativeLuminance(color1); const lum2 = this.getRelativeLuminance(color2); const lighter = Math.max(lum1, lum2); const darker = Math.min(lum1, lum2); return (lighter + 0.05) / (darker + 0.05); } }