UNPKG

@fimbul-works/vec-color

Version:

A comprehensive, type-safe color manipulation library for TypeScript that provides a wide range of color space conversions, blending operations, and accessibility utilities.

306 lines (305 loc) 11.8 kB
import { Vec3 } from "@fimbul-works/vec"; import { rgbToHSL } from "./hsl"; import { rgbToLAB } from "./lab"; import { rgbToXYZ } from "./xyz"; /** * Calculates an approximate perceptual color difference using a weighted RGB comparison * This is a fast approximation that weighs green more heavily than red or blue to match human perception * Trade speed for accuracy: use this when performance is critical and approximate results are acceptable * Based on the redmean color difference algorithm * @param colorA - First color as Vec3 RGB (each channel from 0 to 1) * @param colorB - Second color as Vec3 RGB (each channel from 0 to 1) * @returns A value between 0 and 1, where 1 means identical colors and 0 means maximum perceptual difference */ export function calculateColorSimilarityFast(A, B) { const ar = Math.round(A.x * 255); const ag = Math.round(A.y * 255); const ab = Math.round(A.z * 255); const br = Math.round(B.x * 255); const bg = Math.round(B.y * 255); const bb = Math.round(B.z * 255); const rmean = (ar + br) / 2; const r = ar - br; const g = ag - bg; const b = ab - bb; return (1.0 - Math.sqrt((Math.floor((512 + rmean) * r * r) >> 8) + 4 * g * g + (Math.floor((767 - rmean) * b * b) >> 8)) / 765.0); } /** * Calculates precise perceptual color difference using CIE Delta E 2000 * This is the most accurate color difference algorithm, accounting for human perception * characteristics in different color ranges. Uses LAB color space for calculations. * Trade accuracy for speed: use this when precision is more important than performance * @param colorA - First color as Vec3 RGB (each channel from 0 to 1) * @param colorB - Second color as Vec3 RGB (each channel from 0 to 1) * @returns A value between 0 and 1, where 1 means identical colors and 0 means maximum perceptual difference * Internally uses CIEDE2000 algorithm which has a non-linear relationship to human perception */ export function calculateColorSimilarityLab(rgb1, rgb2) { const lab1 = rgbToLAB(rgbToXYZ(rgb1)); const lab2 = rgbToLAB(rgbToXYZ(rgb2)); const difference = deltaE2000(lab1, lab2); const similarity = Math.exp(-difference / 10); return similarity; } /** * Calculates the relative luminance of a color according to WCAG 2.0 specifications * This is used in determining contrast ratios for accessibility compliance * @param color - Input color as Vec3 RGB (each channel from 0 to 1) * @returns A value between 0 and 1, where 0 is darkest and 1 is brightest * @see https://www.w3.org/WAI/GL/wiki/Relative_luminance */ export function relativeLuminance(color) { const rsRGB = color.x <= 0.03928 ? color.x / 12.92 : ((color.x + 0.055) / 1.055) ** 2.4; const gsRGB = color.y <= 0.03928 ? color.y / 12.92 : ((color.y + 0.055) / 1.055) ** 2.4; const bsRGB = color.z <= 0.03928 ? color.z / 12.92 : ((color.z + 0.055) / 1.055) ** 2.4; return 0.2126 * rsRGB + 0.7152 * gsRGB + 0.0722 * bsRGB; } /** * Calculates the contrast ratio between two colors according to WCAG 2.0 specifications * @param color1 - First color as Vec3 RGB (each channel from 0 to 1) * @param color2 - Second color as Vec3 RGB (each channel from 0 to 1) * @returns A value between 1 and 21, where 1 means no contrast and 21 means maximum contrast * @see https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html */ export function contrastRatio(color1, color2) { const l1 = relativeLuminance(color1); const l2 = relativeLuminance(color2); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } /** * Calculates perceived brightness based on human perception * Different from luminance as it accounts for human color sensitivity * Uses perceived brightness formula (ITU-R BT.709) * @param color Input color as Vec3 RGB * @returns Perceived brightness value (0 to 1) */ export function perceivedBrightness(color) { return Math.sqrt(0.299 * color.x * color.x + 0.587 * color.y * color.y + 0.114 * color.z * color.z); } /** * Checks if a color combination meets WCAG contrast requirements for accessibility * @param foreground - Foreground color as Vec3 RGB (each channel from 0 to 1) * @param background - Background color as Vec3 RGB (each channel from 0 to 1) * @param level - WCAG compliance level to check ("AA" or "AAA") * @param isLargeText - Whether the text is considered "large" by WCAG standards * @returns boolean indicating whether the combination meets WCAG requirements */ export function meetsWCAGRequirements(foreground, background, level = "AA", isLargeText = false) { const ratio = contrastRatio(foreground, background); if (level === "AAA") { return isLargeText ? ratio >= 4.5 : ratio >= 7; } return isLargeText ? ratio >= 3 : ratio >= 4.5; } /** * Calculate CIEDE2000 color difference * @param lab1 - First color as Vec3 LAB (each channel from 0 to 1) * @param lab2 - Second color as Vec3 LAB (each channel from 0 to 1) * @returns */ export function deltaE2000(lab1, lab2) { const [l1, a1, b1] = lab1; const [l2, a2, b2] = lab2; const kL = 1; const kC = 1; const kH = 1; const C1 = Math.sqrt(a1 * a1 + b1 * b1); const C2 = Math.sqrt(a2 * a2 + b2 * b2); const Cb = (C1 + C2) / 2; const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7))); const a1p = (1 + G) * a1; const a2p = (1 + G) * a2; const C1p = Math.sqrt(a1p * a1p + b1 * b1); const C2p = Math.sqrt(a2p * a2p + b2 * b2); const h1p = (Math.atan2(b1, a1p) * 180) / Math.PI; const h2p = (Math.atan2(b2, a2p) * 180) / Math.PI; const dLp = l2 - l1; const dCp = C2p - C1p; let dhp; if (C1p * C2p === 0) { dhp = 0; } else { if (Math.abs(h2p - h1p) <= 180) { dhp = h2p - h1p; } else if (h2p - h1p > 180) { dhp = h2p - h1p - 360; } else { dhp = h2p - h1p + 360; } } const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * Math.PI) / 360); const Lbp = (l1 + l2) / 2; const Cbp = (C1p + C2p) / 2; let hbp; if (C1p * C2p === 0) { hbp = h1p + h2p; } else { if (Math.abs(h1p - h2p) <= 180) { hbp = (h1p + h2p) / 2; } else if (h1p + h2p < 360) { hbp = (h1p + h2p + 360) / 2; } else { hbp = (h1p + h2p - 360) / 2; } } const T = 1 - 0.17 * Math.cos(((hbp - 30) * Math.PI) / 180) + 0.24 * Math.cos((2 * hbp * Math.PI) / 180) + 0.32 * Math.cos(((3 * hbp + 6) * Math.PI) / 180) - 0.2 * Math.cos(((4 * hbp - 63) * Math.PI) / 180); const sL = 1 + (0.015 * (Lbp - 50) ** 2) / Math.sqrt(20 + (Lbp - 50) ** 2); const sC = 1 + 0.045 * Cbp; const sH = 1 + 0.015 * Cbp * T; const RT = -2 * Math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25 ** 7)) * Math.sin((60 * Math.exp(-(((hbp - 275) / 25) ** 2)) * Math.PI) / 180); const dE = Math.sqrt((dLp / (kL * sL)) ** 2 + (dCp / (kC * sC)) ** 2 + (dHp / (kH * sH)) ** 2 + RT * (dCp / (kC * sC)) * (dHp / (kH * sH))); return dE; } /** * Finds dominant colors in a set of colors using k-means clustering * @param colors Array of colors as Vec3 RGB * @param count Number of dominant colors to find (default: 5) * @returns Array of dominant colors as Vec3 RGB */ export function findDominantColors(colors, count = 5) { if (colors.length <= count) return colors; let centroids = colors .slice() .sort(() => Math.random() - 0.5) .slice(0, count); const maxIterations = 50; let iterations = 0; let previousCentroids = []; while (iterations < maxIterations) { const clusters = Array.from({ length: count }, () => []); for (const color of colors) { let minDistance = Number.POSITIVE_INFINITY; let closestIndex = 0; centroids.forEach((centroid, index) => { const distance = color.subtract(centroid).magnitude; if (distance < minDistance) { minDistance = distance; closestIndex = index; } }); clusters[closestIndex].push(color); } // Update centroids previousCentroids = centroids; centroids = clusters.map((cluster) => { if (cluster.length === 0) return previousCentroids[0]; const sum = cluster.reduce((acc, color) => acc.add(color), new Vec3(0, 0, 0)); return sum.scale(1 / cluster.length); }); // Check for convergence const hasConverged = centroids.every((centroid, i) => centroid.subtract(previousCentroids[i]).magnitude < 0.001); if (hasConverged) break; iterations++; } return centroids; } /** * Classifies a color into basic categories * @param color Color as Vec3 RGB * @returns Object containing color classifications */ export function classifyColor(color) { const hsl = rgbToHSL(color); const lab = rgbToLAB(color); // Determine temperature const hue = hsl.x * 360; const temperature = (hue >= 30 && hue <= 110) || (hue >= 270 && hue <= 290) ? "cool" : hue > 110 && hue < 270 ? "neutral" : "warm"; // Determine intensity let intensity; if (hsl.z < 0.2) intensity = "dark"; else if (hsl.z > 0.8) intensity = "light"; else if (hsl.y > 0.8) intensity = "vivid"; else if (hsl.y < 0.3 && hsl.z > 0.7) intensity = "pastel"; else intensity = "medium"; // Determine basic color category const hueCategories = [ { name: "red", start: 345, end: 15 }, { name: "orange", start: 15, end: 45 }, { name: "yellow", start: 45, end: 75 }, { name: "green", start: 75, end: 165 }, { name: "cyan", start: 165, end: 195 }, { name: "blue", start: 195, end: 255 }, { name: "purple", start: 255, end: 315 }, { name: "pink", start: 315, end: 345 }, ]; let category = "grayscale"; if (hsl.y > 0.15) { category = hueCategories.find((cat) => { if (cat.start > cat.end) { return hue >= cat.start || hue <= cat.end; } return hue >= cat.start && hue < cat.end; })?.name || "grayscale"; } return { temperature, intensity, category }; } /** * Sorts colors by various attributes * @param colors Array of colors to sort * @param by Attribute to sort by ('hue', 'saturation', 'lightness', 'temperature') * @returns Sorted array of colors */ export function sortColors(colors, by = "hue") { return [...colors].sort((a, b) => { const hslA = rgbToHSL(a); const hslB = rgbToHSL(b); switch (by) { case "hue": return hslA.x - hslB.x; case "saturation": return hslB.y - hslA.y; case "lightness": return hslB.z - hslA.z; case "temperature": { // Calculate temperature based on red/blue ratio const tempA = a.x / Math.max(0.1, a.z); const tempB = b.x / Math.max(0.1, b.z); return tempB - tempA; } } }); } /** * Creates a color distance matrix for a set of colors * @param colors Array of colors to analyze * @returns 2D array of perceptual color differences */ export function colorDistanceMatrix(colors) { return colors.map((colorA) => colors.map((colorB) => calculateColorSimilarityLab(colorA, colorB))); }