- Add new libraries: ui-accessibility, ui-animations, ui-backgrounds, ui-code-display, ui-data-utils, ui-font-manager, hcl-studio - Add extensive layout components: gallery-grid, infinite-scroll-container, kanban-board, masonry, split-view, sticky-layout - Add comprehensive demo components for all new features - Update project configuration and dependencies - Expand component exports and routing structure - Add UI landing pages planning document 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
324 lines
9.9 KiB
TypeScript
324 lines
9.9 KiB
TypeScript
/**
|
|
* ==========================================================================
|
|
* 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);
|
|
}
|
|
} |